mirror of
https://github.com/TronoSfera/Law.git
synced 2026-06-09 21:03:44 +03:00
Second commit
This commit is contained in:
parent
112ab43b34
commit
fb13d93ab3
78 changed files with 13202 additions and 357 deletions
3
Makefile
3
Makefile
|
|
@ -6,3 +6,6 @@ migrate:
|
||||||
|
|
||||||
test:
|
test:
|
||||||
docker compose exec backend python -m unittest discover -s tests -p "test_*.py" -v
|
docker compose exec backend python -m unittest discover -s tests -p "test_*.py" -v
|
||||||
|
|
||||||
|
seed-quotes:
|
||||||
|
docker compose exec backend python -m app.scripts.upsert_quotes
|
||||||
|
|
|
||||||
|
|
@ -15,3 +15,9 @@ Swagger: http://localhost:8002/docs
|
||||||
```bash
|
```bash
|
||||||
docker compose exec backend alembic upgrade head
|
docker compose exec backend alembic upgrade head
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Seed Quotes (Upsert)
|
||||||
|
```bash
|
||||||
|
make seed-quotes
|
||||||
|
```
|
||||||
|
Loads 50 justice-themed quotes into `quotes` with idempotent upsert by `(author, text)`.
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ from app.models.status_history import StatusHistory
|
||||||
from app.models.audit_log import AuditLog
|
from app.models.audit_log import AuditLog
|
||||||
from app.models.otp_session import OtpSession
|
from app.models.otp_session import OtpSession
|
||||||
from app.models.quote import Quote
|
from app.models.quote import Quote
|
||||||
|
from app.models.admin_user_topic import AdminUserTopic
|
||||||
|
from app.models.notification import Notification
|
||||||
|
|
||||||
config = context.config
|
config = context.config
|
||||||
fileConfig(config.config_file_name)
|
fileConfig(config.config_file_name)
|
||||||
|
|
|
||||||
46
alembic/versions/0002_add_responsible_to_all_tables.py
Normal file
46
alembic/versions/0002_add_responsible_to_all_tables.py
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
"""add responsible to all tables
|
||||||
|
|
||||||
|
Revision ID: 0002_add_responsible
|
||||||
|
Revises: 0001_init
|
||||||
|
Create Date: 2026-02-22
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0002_add_responsible"
|
||||||
|
down_revision = "0001_init"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
TABLES = [
|
||||||
|
"admin_users",
|
||||||
|
"topics",
|
||||||
|
"statuses",
|
||||||
|
"form_fields",
|
||||||
|
"requests",
|
||||||
|
"messages",
|
||||||
|
"attachments",
|
||||||
|
"status_history",
|
||||||
|
"audit_log",
|
||||||
|
"otp_sessions",
|
||||||
|
"quotes",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
for table in TABLES:
|
||||||
|
op.add_column(
|
||||||
|
table,
|
||||||
|
sa.Column(
|
||||||
|
"responsible",
|
||||||
|
sa.String(length=200),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("'Администратор системы'"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
for table in reversed(TABLES):
|
||||||
|
op.drop_column(table, "responsible")
|
||||||
24
alembic/versions/0003_add_primary_topic_to_admin_users.py
Normal file
24
alembic/versions/0003_add_primary_topic_to_admin_users.py
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
"""add primary topic profile to admin users
|
||||||
|
|
||||||
|
Revision ID: 0003_admin_user_primary_topic
|
||||||
|
Revises: 0002_add_responsible
|
||||||
|
Create Date: 2026-02-22
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0003_admin_user_primary_topic"
|
||||||
|
down_revision = "0002_add_responsible"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.add_column("admin_users", sa.Column("primary_topic_code", sa.String(length=50), nullable=True))
|
||||||
|
op.create_index("ix_admin_users_primary_topic_code", "admin_users", ["primary_topic_code"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_index("ix_admin_users_primary_topic_code", table_name="admin_users")
|
||||||
|
op.drop_column("admin_users", "primary_topic_code")
|
||||||
22
alembic/versions/0004_add_avatar_to_admin_users.py
Normal file
22
alembic/versions/0004_add_avatar_to_admin_users.py
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
"""add avatar url to admin users
|
||||||
|
|
||||||
|
Revision ID: 0004_admin_user_avatar
|
||||||
|
Revises: 0003_admin_user_primary_topic
|
||||||
|
Create Date: 2026-02-22
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0004_admin_user_avatar"
|
||||||
|
down_revision = "0003_admin_user_primary_topic"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.add_column("admin_users", sa.Column("avatar_url", sa.String(length=500), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_column("admin_users", "avatar_url")
|
||||||
41
alembic/versions/0005_add_admin_user_topics.py
Normal file
41
alembic/versions/0005_add_admin_user_topics.py
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
"""add admin user topics relation table
|
||||||
|
|
||||||
|
Revision ID: 0005_admin_user_topics
|
||||||
|
Revises: 0004_admin_user_avatar
|
||||||
|
Create Date: 2026-02-22
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision = "0005_admin_user_topics"
|
||||||
|
down_revision = "0004_admin_user_avatar"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.create_table(
|
||||||
|
"admin_user_topics",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"responsible",
|
||||||
|
sa.String(length=200),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("'Администратор системы'"),
|
||||||
|
),
|
||||||
|
sa.Column("admin_user_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("topic_code", sa.String(length=50), nullable=False),
|
||||||
|
sa.UniqueConstraint("admin_user_id", "topic_code", name="uq_admin_user_topics_user_topic"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_admin_user_topics_admin_user_id", "admin_user_topics", ["admin_user_id"])
|
||||||
|
op.create_index("ix_admin_user_topics_topic_code", "admin_user_topics", ["topic_code"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_index("ix_admin_user_topics_topic_code", table_name="admin_user_topics")
|
||||||
|
op.drop_index("ix_admin_user_topics_admin_user_id", table_name="admin_user_topics")
|
||||||
|
op.drop_table("admin_user_topics")
|
||||||
34
alembic/versions/0006_add_request_read_markers.py
Normal file
34
alembic/versions/0006_add_request_read_markers.py
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
"""add request read/unread markers
|
||||||
|
|
||||||
|
Revision ID: 0006_request_read_markers
|
||||||
|
Revises: 0005_admin_user_topics
|
||||||
|
Create Date: 2026-02-23
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0006_request_read_markers"
|
||||||
|
down_revision = "0005_admin_user_topics"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.add_column(
|
||||||
|
"requests",
|
||||||
|
sa.Column("client_has_unread_updates", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||||
|
)
|
||||||
|
op.add_column("requests", sa.Column("client_unread_event_type", sa.String(length=32), nullable=True))
|
||||||
|
op.add_column(
|
||||||
|
"requests",
|
||||||
|
sa.Column("lawyer_has_unread_updates", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||||
|
)
|
||||||
|
op.add_column("requests", sa.Column("lawyer_unread_event_type", sa.String(length=32), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_column("requests", "lawyer_unread_event_type")
|
||||||
|
op.drop_column("requests", "lawyer_has_unread_updates")
|
||||||
|
op.drop_column("requests", "client_unread_event_type")
|
||||||
|
op.drop_column("requests", "client_has_unread_updates")
|
||||||
51
alembic/versions/0007_add_topic_status_transitions.py
Normal file
51
alembic/versions/0007_add_topic_status_transitions.py
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
"""add topic status transitions
|
||||||
|
|
||||||
|
Revision ID: 0007_topic_status_transitions
|
||||||
|
Revises: 0006_request_read_markers
|
||||||
|
Create Date: 2026-02-23
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision = "0007_topic_status_transitions"
|
||||||
|
down_revision = "0006_request_read_markers"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.create_table(
|
||||||
|
"topic_status_transitions",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"responsible",
|
||||||
|
sa.String(length=200),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("'Администратор системы'"),
|
||||||
|
),
|
||||||
|
sa.Column("topic_code", sa.String(length=50), nullable=False),
|
||||||
|
sa.Column("from_status", sa.String(length=50), nullable=False),
|
||||||
|
sa.Column("to_status", sa.String(length=50), nullable=False),
|
||||||
|
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||||
|
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"topic_code",
|
||||||
|
"from_status",
|
||||||
|
"to_status",
|
||||||
|
name="uq_topic_status_transitions_topic_from_to",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index("ix_topic_status_transitions_topic_code", "topic_status_transitions", ["topic_code"])
|
||||||
|
op.create_index("ix_topic_status_transitions_from_status", "topic_status_transitions", ["from_status"])
|
||||||
|
op.create_index("ix_topic_status_transitions_to_status", "topic_status_transitions", ["to_status"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_index("ix_topic_status_transitions_to_status", table_name="topic_status_transitions")
|
||||||
|
op.drop_index("ix_topic_status_transitions_from_status", table_name="topic_status_transitions")
|
||||||
|
op.drop_index("ix_topic_status_transitions_topic_code", table_name="topic_status_transitions")
|
||||||
|
op.drop_table("topic_status_transitions")
|
||||||
100
alembic/versions/0008_add_request_templates.py
Normal file
100
alembic/versions/0008_add_request_templates.py
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
"""add topic/request template tables
|
||||||
|
|
||||||
|
Revision ID: 0008_request_templates
|
||||||
|
Revises: 0007_topic_status_transitions
|
||||||
|
Create Date: 2026-02-23
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision = "0008_request_templates"
|
||||||
|
down_revision = "0007_topic_status_transitions"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.create_table(
|
||||||
|
"topic_required_fields",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"responsible",
|
||||||
|
sa.String(length=200),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("'Администратор системы'"),
|
||||||
|
),
|
||||||
|
sa.Column("topic_code", sa.String(length=50), nullable=False),
|
||||||
|
sa.Column("field_key", sa.String(length=80), nullable=False),
|
||||||
|
sa.Column("required", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||||
|
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||||
|
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.UniqueConstraint("topic_code", "field_key", name="uq_topic_required_fields_topic_field"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_topic_required_fields_topic_code", "topic_required_fields", ["topic_code"])
|
||||||
|
op.create_index("ix_topic_required_fields_field_key", "topic_required_fields", ["field_key"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"topic_data_templates",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"responsible",
|
||||||
|
sa.String(length=200),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("'Администратор системы'"),
|
||||||
|
),
|
||||||
|
sa.Column("topic_code", sa.String(length=50), nullable=False),
|
||||||
|
sa.Column("key", sa.String(length=80), nullable=False),
|
||||||
|
sa.Column("label", sa.String(length=200), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("required", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||||
|
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||||
|
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.UniqueConstraint("topic_code", "key", name="uq_topic_data_templates_topic_key"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_topic_data_templates_topic_code", "topic_data_templates", ["topic_code"])
|
||||||
|
op.create_index("ix_topic_data_templates_key", "topic_data_templates", ["key"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"request_data_requirements",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"responsible",
|
||||||
|
sa.String(length=200),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("'Администратор системы'"),
|
||||||
|
),
|
||||||
|
sa.Column("request_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("topic_template_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||||
|
sa.Column("key", sa.String(length=80), nullable=False),
|
||||||
|
sa.Column("label", sa.String(length=200), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("required", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||||
|
sa.Column("created_by_admin_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||||
|
sa.UniqueConstraint("request_id", "key", name="uq_request_data_requirements_request_key"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_request_data_requirements_request_id", "request_data_requirements", ["request_id"])
|
||||||
|
op.create_index("ix_request_data_requirements_topic_template_id", "request_data_requirements", ["topic_template_id"])
|
||||||
|
op.create_index("ix_request_data_requirements_key", "request_data_requirements", ["key"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_index("ix_request_data_requirements_key", table_name="request_data_requirements")
|
||||||
|
op.drop_index("ix_request_data_requirements_topic_template_id", table_name="request_data_requirements")
|
||||||
|
op.drop_index("ix_request_data_requirements_request_id", table_name="request_data_requirements")
|
||||||
|
op.drop_table("request_data_requirements")
|
||||||
|
|
||||||
|
op.drop_index("ix_topic_data_templates_key", table_name="topic_data_templates")
|
||||||
|
op.drop_index("ix_topic_data_templates_topic_code", table_name="topic_data_templates")
|
||||||
|
op.drop_table("topic_data_templates")
|
||||||
|
|
||||||
|
op.drop_index("ix_topic_required_fields_field_key", table_name="topic_required_fields")
|
||||||
|
op.drop_index("ix_topic_required_fields_topic_code", table_name="topic_required_fields")
|
||||||
|
op.drop_table("topic_required_fields")
|
||||||
28
alembic/versions/0009_add_sla_hours_to_status_transitions.py
Normal file
28
alembic/versions/0009_add_sla_hours_to_status_transitions.py
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
"""add sla_hours to topic status transitions
|
||||||
|
|
||||||
|
Revision ID: 0009_sla_transition_config
|
||||||
|
Revises: 0008_request_templates
|
||||||
|
Create Date: 2026-02-23
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0009_sla_transition_config"
|
||||||
|
down_revision = "0008_request_templates"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.add_column("topic_status_transitions", sa.Column("sla_hours", sa.Integer(), nullable=True))
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_topic_status_transitions_sla_hours_positive",
|
||||||
|
"topic_status_transitions",
|
||||||
|
"sla_hours IS NULL OR sla_hours > 0",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_constraint("ck_topic_status_transitions_sla_hours_positive", "topic_status_transitions", type_="check")
|
||||||
|
op.drop_column("topic_status_transitions", "sla_hours")
|
||||||
68
alembic/versions/0010_add_notifications.py
Normal file
68
alembic/versions/0010_add_notifications.py
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
"""add notifications table
|
||||||
|
|
||||||
|
Revision ID: 0010_notifications
|
||||||
|
Revises: 0009_sla_transition_config
|
||||||
|
Create Date: 2026-02-23
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision = "0010_notifications"
|
||||||
|
down_revision = "0009_sla_transition_config"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.create_table(
|
||||||
|
"notifications",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("responsible", sa.String(length=200), nullable=False, server_default="Администратор системы"),
|
||||||
|
sa.Column("request_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||||
|
sa.Column("recipient_type", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("recipient_admin_user_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||||
|
sa.Column("recipient_track_number", sa.String(length=40), nullable=True),
|
||||||
|
sa.Column("event_type", sa.String(length=50), nullable=False),
|
||||||
|
sa.Column("title", sa.String(length=200), nullable=False),
|
||||||
|
sa.Column("body", sa.Text(), nullable=True),
|
||||||
|
sa.Column("payload", sa.JSON(), nullable=False, server_default=sa.text("'{}'::json")),
|
||||||
|
sa.Column("is_read", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||||
|
sa.Column("read_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("dedupe_key", sa.String(length=255), nullable=True, unique=True),
|
||||||
|
)
|
||||||
|
op.create_index("ix_notifications_request_id", "notifications", ["request_id"])
|
||||||
|
op.create_index("ix_notifications_recipient_type", "notifications", ["recipient_type"])
|
||||||
|
op.create_index("ix_notifications_recipient_admin_user_id", "notifications", ["recipient_admin_user_id"])
|
||||||
|
op.create_index("ix_notifications_recipient_track_number", "notifications", ["recipient_track_number"])
|
||||||
|
op.create_index("ix_notifications_event_type", "notifications", ["event_type"])
|
||||||
|
op.create_index("ix_notifications_is_read", "notifications", ["is_read"])
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_notifications_recipient_type",
|
||||||
|
"notifications",
|
||||||
|
"recipient_type IN ('CLIENT','ADMIN_USER')",
|
||||||
|
)
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_notifications_recipient_binding",
|
||||||
|
"notifications",
|
||||||
|
"("
|
||||||
|
"(recipient_type = 'CLIENT' AND recipient_track_number IS NOT NULL AND recipient_admin_user_id IS NULL)"
|
||||||
|
" OR "
|
||||||
|
"(recipient_type = 'ADMIN_USER' AND recipient_admin_user_id IS NOT NULL AND recipient_track_number IS NULL)"
|
||||||
|
")",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_constraint("ck_notifications_recipient_binding", "notifications", type_="check")
|
||||||
|
op.drop_constraint("ck_notifications_recipient_type", "notifications", type_="check")
|
||||||
|
op.drop_index("ix_notifications_is_read", table_name="notifications")
|
||||||
|
op.drop_index("ix_notifications_event_type", table_name="notifications")
|
||||||
|
op.drop_index("ix_notifications_recipient_track_number", table_name="notifications")
|
||||||
|
op.drop_index("ix_notifications_recipient_admin_user_id", table_name="notifications")
|
||||||
|
op.drop_index("ix_notifications_recipient_type", table_name="notifications")
|
||||||
|
op.drop_index("ix_notifications_request_id", table_name="notifications")
|
||||||
|
op.drop_table("notifications")
|
||||||
54
alembic/versions/0011_add_financial_fields_for_dashboard.py
Normal file
54
alembic/versions/0011_add_financial_fields_for_dashboard.py
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
"""add financial fields for dashboard metrics
|
||||||
|
|
||||||
|
Revision ID: 0011_dashboard_financial_fields
|
||||||
|
Revises: 0010_notifications
|
||||||
|
Create Date: 2026-02-23
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0011_dashboard_financial_fields"
|
||||||
|
down_revision = "0010_notifications"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.add_column("admin_users", sa.Column("default_rate", sa.Numeric(12, 2), nullable=True))
|
||||||
|
op.add_column("admin_users", sa.Column("salary_percent", sa.Numeric(5, 2), nullable=True))
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_admin_users_salary_percent_range",
|
||||||
|
"admin_users",
|
||||||
|
"salary_percent IS NULL OR (salary_percent >= 0 AND salary_percent <= 100)",
|
||||||
|
)
|
||||||
|
|
||||||
|
op.add_column("requests", sa.Column("effective_rate", sa.Numeric(12, 2), nullable=True))
|
||||||
|
op.add_column("requests", sa.Column("invoice_amount", sa.Numeric(14, 2), nullable=True))
|
||||||
|
op.add_column("requests", sa.Column("paid_at", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
op.add_column("requests", sa.Column("paid_by_admin_id", sa.String(length=64), nullable=True))
|
||||||
|
op.create_index("ix_requests_paid_at", "requests", ["paid_at"])
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_requests_invoice_amount_non_negative",
|
||||||
|
"requests",
|
||||||
|
"invoice_amount IS NULL OR invoice_amount >= 0",
|
||||||
|
)
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_requests_effective_rate_non_negative",
|
||||||
|
"requests",
|
||||||
|
"effective_rate IS NULL OR effective_rate >= 0",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_constraint("ck_requests_effective_rate_non_negative", "requests", type_="check")
|
||||||
|
op.drop_constraint("ck_requests_invoice_amount_non_negative", "requests", type_="check")
|
||||||
|
op.drop_index("ix_requests_paid_at", table_name="requests")
|
||||||
|
op.drop_column("requests", "paid_by_admin_id")
|
||||||
|
op.drop_column("requests", "paid_at")
|
||||||
|
op.drop_column("requests", "invoice_amount")
|
||||||
|
op.drop_column("requests", "effective_rate")
|
||||||
|
|
||||||
|
op.drop_constraint("ck_admin_users_salary_percent_range", "admin_users", type_="check")
|
||||||
|
op.drop_column("admin_users", "salary_percent")
|
||||||
|
op.drop_column("admin_users", "default_rate")
|
||||||
|
|
@ -50,7 +50,8 @@ def query_topics(uq: UniversalQuery, db: Session = Depends(get_db), admin=Depend
|
||||||
|
|
||||||
@router.post("/topics", status_code=201)
|
@router.post("/topics", status_code=201)
|
||||||
def create_topic(payload: TopicUpsert, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN"))):
|
def create_topic(payload: TopicUpsert, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN"))):
|
||||||
row = Topic(**payload.model_dump())
|
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
|
||||||
|
row = Topic(**payload.model_dump(), responsible=responsible)
|
||||||
try:
|
try:
|
||||||
db.add(row)
|
db.add(row)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
@ -97,7 +98,8 @@ def query_statuses(uq: UniversalQuery, db: Session = Depends(get_db), admin=Depe
|
||||||
|
|
||||||
@router.post("/statuses", status_code=201)
|
@router.post("/statuses", status_code=201)
|
||||||
def create_status(payload: StatusUpsert, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN"))):
|
def create_status(payload: StatusUpsert, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN"))):
|
||||||
row = Status(**payload.model_dump())
|
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
|
||||||
|
row = Status(**payload.model_dump(), responsible=responsible)
|
||||||
try:
|
try:
|
||||||
db.add(row)
|
db.add(row)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
@ -144,7 +146,8 @@ def query_form_fields(uq: UniversalQuery, db: Session = Depends(get_db), admin=D
|
||||||
|
|
||||||
@router.post("/form-fields", status_code=201)
|
@router.post("/form-fields", status_code=201)
|
||||||
def create_form_field(payload: FormFieldUpsert, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN"))):
|
def create_form_field(payload: FormFieldUpsert, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN"))):
|
||||||
row = FormField(**payload.model_dump())
|
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
|
||||||
|
row = FormField(**payload.model_dump(), responsible=responsible)
|
||||||
try:
|
try:
|
||||||
db.add(row)
|
db.add(row)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
|
||||||
871
app/api/admin/crud.py
Normal file
871
app/api/admin/crud.py
Normal file
|
|
@ -0,0 +1,871 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import pkgutil
|
||||||
|
import uuid
|
||||||
|
from datetime import date, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from functools import lru_cache
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.inspection import inspect as sa_inspect
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
import app.models as models_pkg
|
||||||
|
from app.core.deps import get_current_admin
|
||||||
|
from app.core.security import hash_password
|
||||||
|
from app.db.session import Base, get_db
|
||||||
|
from app.models.admin_user import AdminUser
|
||||||
|
from app.models.audit_log import AuditLog
|
||||||
|
from app.models.form_field import FormField
|
||||||
|
from app.models.request_data_requirement import RequestDataRequirement
|
||||||
|
from app.models.attachment import Attachment
|
||||||
|
from app.models.message import Message
|
||||||
|
from app.models.request import Request
|
||||||
|
from app.models.status import Status
|
||||||
|
from app.models.topic_data_template import TopicDataTemplate
|
||||||
|
from app.models.topic_required_field import TopicRequiredField
|
||||||
|
from app.models.topic import Topic
|
||||||
|
from app.schemas.universal import UniversalQuery
|
||||||
|
from app.services.notifications import (
|
||||||
|
EVENT_ATTACHMENT as NOTIFICATION_EVENT_ATTACHMENT,
|
||||||
|
EVENT_MESSAGE as NOTIFICATION_EVENT_MESSAGE,
|
||||||
|
EVENT_STATUS as NOTIFICATION_EVENT_STATUS,
|
||||||
|
mark_admin_notifications_read,
|
||||||
|
notify_request_event,
|
||||||
|
)
|
||||||
|
from app.services.request_read_markers import (
|
||||||
|
EVENT_ATTACHMENT,
|
||||||
|
EVENT_MESSAGE,
|
||||||
|
EVENT_STATUS,
|
||||||
|
clear_unread_for_lawyer,
|
||||||
|
mark_unread_for_client,
|
||||||
|
mark_unread_for_lawyer,
|
||||||
|
)
|
||||||
|
from app.services.request_status import apply_status_change_effects
|
||||||
|
from app.services.status_flow import transition_allowed_for_topic
|
||||||
|
from app.services.request_templates import validate_required_topic_fields_or_400
|
||||||
|
from app.services.universal_query import apply_universal_query
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
CRUD_ACTIONS = {"query", "read", "create", "update", "delete"}
|
||||||
|
SYSTEM_FIELDS = {
|
||||||
|
"id",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"responsible",
|
||||||
|
"client_has_unread_updates",
|
||||||
|
"client_unread_event_type",
|
||||||
|
"lawyer_has_unread_updates",
|
||||||
|
"lawyer_unread_event_type",
|
||||||
|
}
|
||||||
|
ALLOWED_ADMIN_ROLES = {"ADMIN", "LAWYER"}
|
||||||
|
|
||||||
|
# Per-table RBAC: table -> role -> actions.
|
||||||
|
# If a table is missing here, fallback rules are used.
|
||||||
|
TABLE_ROLE_ACTIONS: dict[str, dict[str, set[str]]] = {
|
||||||
|
"requests": {
|
||||||
|
"ADMIN": set(CRUD_ACTIONS),
|
||||||
|
"LAWYER": set(CRUD_ACTIONS),
|
||||||
|
},
|
||||||
|
"quotes": {"ADMIN": set(CRUD_ACTIONS)},
|
||||||
|
"topics": {"ADMIN": set(CRUD_ACTIONS)},
|
||||||
|
"statuses": {"ADMIN": set(CRUD_ACTIONS)},
|
||||||
|
"form_fields": {"ADMIN": set(CRUD_ACTIONS)},
|
||||||
|
"audit_log": {"ADMIN": {"query", "read"}},
|
||||||
|
"otp_sessions": {"ADMIN": {"query", "read"}},
|
||||||
|
"admin_users": {"ADMIN": set(CRUD_ACTIONS)},
|
||||||
|
"admin_user_topics": {"ADMIN": set(CRUD_ACTIONS)},
|
||||||
|
"topic_status_transitions": {"ADMIN": set(CRUD_ACTIONS)},
|
||||||
|
"topic_required_fields": {"ADMIN": set(CRUD_ACTIONS)},
|
||||||
|
"topic_data_templates": {"ADMIN": set(CRUD_ACTIONS)},
|
||||||
|
"request_data_requirements": {"ADMIN": set(CRUD_ACTIONS)},
|
||||||
|
"notifications": {"ADMIN": {"query", "read", "update"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_ROLE_ACTIONS: dict[str, set[str]] = {
|
||||||
|
"ADMIN": set(CRUD_ACTIONS),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_table_name(table_name: str) -> str:
|
||||||
|
raw = (table_name or "").strip().replace("-", "_")
|
||||||
|
if not raw:
|
||||||
|
return ""
|
||||||
|
chars: list[str] = []
|
||||||
|
for index, ch in enumerate(raw):
|
||||||
|
if ch.isupper() and index > 0 and raw[index - 1].isalnum() and raw[index - 1] != "_":
|
||||||
|
chars.append("_")
|
||||||
|
chars.append(ch.lower())
|
||||||
|
return "".join(chars)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def _table_model_map() -> dict[str, type]:
|
||||||
|
for module in pkgutil.iter_modules(models_pkg.__path__):
|
||||||
|
if module.name.startswith("_"):
|
||||||
|
continue
|
||||||
|
importlib.import_module(f"{models_pkg.__name__}.{module.name}")
|
||||||
|
return {
|
||||||
|
mapper.class_.__tablename__: mapper.class_
|
||||||
|
for mapper in Base.registry.mappers
|
||||||
|
if getattr(mapper.class_, "__tablename__", None)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_table_model(table_name: str) -> tuple[str, type]:
|
||||||
|
normalized = _normalize_table_name(table_name)
|
||||||
|
model = _table_model_map().get(normalized)
|
||||||
|
if model is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Таблица не найдена")
|
||||||
|
return normalized, model
|
||||||
|
|
||||||
|
|
||||||
|
def _allowed_actions(role: str, table_name: str) -> set[str]:
|
||||||
|
per_table = TABLE_ROLE_ACTIONS.get(table_name)
|
||||||
|
if per_table is not None:
|
||||||
|
return set(per_table.get(role, set()))
|
||||||
|
return set(DEFAULT_ROLE_ACTIONS.get(role, set()))
|
||||||
|
|
||||||
|
|
||||||
|
def _require_table_action(admin: dict, table_name: str, action: str) -> None:
|
||||||
|
role = str(admin.get("role") or "").upper()
|
||||||
|
allowed = _allowed_actions(role, table_name)
|
||||||
|
if action not in allowed:
|
||||||
|
raise HTTPException(status_code=403, detail="Недостаточно прав")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_lawyer(admin: dict) -> bool:
|
||||||
|
return str(admin.get("role") or "").upper() == "LAWYER"
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_value(value: Any) -> Any:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {key: _serialize_value(val) for key, val in value.items()}
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [_serialize_value(item) for item in value]
|
||||||
|
if isinstance(value, tuple):
|
||||||
|
return [_serialize_value(item) for item in value]
|
||||||
|
if isinstance(value, (datetime, date)):
|
||||||
|
return value.isoformat()
|
||||||
|
if isinstance(value, uuid.UUID):
|
||||||
|
return str(value)
|
||||||
|
if isinstance(value, Decimal):
|
||||||
|
return float(value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_dict(row: Any) -> dict[str, Any]:
|
||||||
|
mapper = sa_inspect(type(row))
|
||||||
|
return {column.key: _serialize_value(getattr(row, column.key)) for column in mapper.columns}
|
||||||
|
|
||||||
|
|
||||||
|
def _columns_map(model: type) -> dict[str, Any]:
|
||||||
|
mapper = sa_inspect(model)
|
||||||
|
return {column.key: column for column in mapper.columns}
|
||||||
|
|
||||||
|
|
||||||
|
def _hidden_response_fields(table_name: str) -> set[str]:
|
||||||
|
if table_name == "admin_users":
|
||||||
|
return {"password_hash"}
|
||||||
|
return set()
|
||||||
|
|
||||||
|
|
||||||
|
def _protected_input_fields(table_name: str) -> set[str]:
|
||||||
|
if table_name == "admin_users":
|
||||||
|
return {"password_hash"}
|
||||||
|
return set()
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_payload(
|
||||||
|
model: type,
|
||||||
|
table_name: str,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
*,
|
||||||
|
is_update: bool,
|
||||||
|
allow_protected_fields: set[str] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise HTTPException(status_code=400, detail="Тело запроса должно быть JSON-объектом")
|
||||||
|
|
||||||
|
columns = _columns_map(model)
|
||||||
|
allowed_hidden = set(allow_protected_fields or set())
|
||||||
|
mutable_columns = {
|
||||||
|
name
|
||||||
|
for name in columns.keys()
|
||||||
|
if name not in SYSTEM_FIELDS and (name not in _protected_input_fields(table_name) or name in allowed_hidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
unknown_fields = sorted(set(payload.keys()) - mutable_columns)
|
||||||
|
if unknown_fields:
|
||||||
|
raise HTTPException(status_code=400, detail="Неизвестные поля: " + ", ".join(unknown_fields))
|
||||||
|
|
||||||
|
cleaned: dict[str, Any] = {}
|
||||||
|
for key, value in payload.items():
|
||||||
|
column = columns[key]
|
||||||
|
if value is None and not column.nullable:
|
||||||
|
raise HTTPException(status_code=400, detail=f'Поле "{key}" не может быть null')
|
||||||
|
cleaned[key] = value
|
||||||
|
|
||||||
|
if is_update:
|
||||||
|
if not cleaned:
|
||||||
|
raise HTTPException(status_code=400, detail="Нет полей для обновления")
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
required_missing: list[str] = []
|
||||||
|
for name, column in columns.items():
|
||||||
|
if name in SYSTEM_FIELDS:
|
||||||
|
continue
|
||||||
|
if column.nullable:
|
||||||
|
continue
|
||||||
|
if column.default is not None or column.server_default is not None:
|
||||||
|
continue
|
||||||
|
if name not in cleaned:
|
||||||
|
required_missing.append(name)
|
||||||
|
if required_missing:
|
||||||
|
raise HTTPException(status_code=400, detail="Отсутствуют обязательные поля: " + ", ".join(sorted(required_missing)))
|
||||||
|
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
def _pk_value(model: type, row_id: str) -> Any:
|
||||||
|
pk = sa_inspect(model).primary_key
|
||||||
|
if len(pk) != 1:
|
||||||
|
raise HTTPException(status_code=400, detail="Поддерживаются только таблицы с одним первичным ключом")
|
||||||
|
pk_column = pk[0]
|
||||||
|
try:
|
||||||
|
python_type = pk_column.type.python_type
|
||||||
|
except Exception:
|
||||||
|
python_type = str
|
||||||
|
if python_type is uuid.UUID:
|
||||||
|
try:
|
||||||
|
return uuid.UUID(str(row_id))
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Некорректный идентификатор")
|
||||||
|
return row_id
|
||||||
|
|
||||||
|
|
||||||
|
def _load_row_or_404(db: Session, model: type, row_id: str):
|
||||||
|
entity = db.get(model, _pk_value(model, row_id))
|
||||||
|
if entity is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Запись не найдена")
|
||||||
|
return entity
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_create_payload(table_name: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
data = dict(payload)
|
||||||
|
if table_name == "requests":
|
||||||
|
track_number = str(data.get("track_number") or "").strip()
|
||||||
|
data["track_number"] = track_number or f"TRK-{uuid.uuid4().hex[:10].upper()}"
|
||||||
|
if data.get("extra_fields") is None:
|
||||||
|
data["extra_fields"] = {}
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_optional_string(value: Any) -> str | None:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_admin_user_fields_for_create(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
data = dict(payload)
|
||||||
|
if "password_hash" in data:
|
||||||
|
raise HTTPException(status_code=400, detail='Поле "password_hash" недоступно для записи')
|
||||||
|
raw_password = str(data.pop("password", "")).strip()
|
||||||
|
if not raw_password:
|
||||||
|
raise HTTPException(status_code=400, detail="Пароль обязателен")
|
||||||
|
role = str(data.get("role") or "").strip().upper()
|
||||||
|
if role not in ALLOWED_ADMIN_ROLES:
|
||||||
|
raise HTTPException(status_code=400, detail="Некорректная роль")
|
||||||
|
email = str(data.get("email") or "").strip().lower()
|
||||||
|
if not email:
|
||||||
|
raise HTTPException(status_code=400, detail="Email обязателен")
|
||||||
|
data["email"] = email
|
||||||
|
data["role"] = role
|
||||||
|
data["avatar_url"] = _normalize_optional_string(data.get("avatar_url"))
|
||||||
|
data["primary_topic_code"] = _normalize_optional_string(data.get("primary_topic_code"))
|
||||||
|
data["password_hash"] = hash_password(raw_password)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_admin_user_fields_for_update(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
data = dict(payload)
|
||||||
|
if "password_hash" in data:
|
||||||
|
raise HTTPException(status_code=400, detail='Поле "password_hash" недоступно для записи')
|
||||||
|
if "password" in data:
|
||||||
|
raw_password = str(data.pop("password") or "").strip()
|
||||||
|
if not raw_password:
|
||||||
|
raise HTTPException(status_code=400, detail="Пароль не может быть пустым")
|
||||||
|
data["password_hash"] = hash_password(raw_password)
|
||||||
|
if "role" in data:
|
||||||
|
role = str(data.get("role") or "").strip().upper()
|
||||||
|
if role not in ALLOWED_ADMIN_ROLES:
|
||||||
|
raise HTTPException(status_code=400, detail="Некорректная роль")
|
||||||
|
data["role"] = role
|
||||||
|
if "email" in data:
|
||||||
|
email = str(data.get("email") or "").strip().lower()
|
||||||
|
if not email:
|
||||||
|
raise HTTPException(status_code=400, detail="Email не может быть пустым")
|
||||||
|
data["email"] = email
|
||||||
|
if "avatar_url" in data:
|
||||||
|
data["avatar_url"] = _normalize_optional_string(data.get("avatar_url"))
|
||||||
|
if "primary_topic_code" in data:
|
||||||
|
data["primary_topic_code"] = _normalize_optional_string(data.get("primary_topic_code"))
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_uuid_or_400(value: Any, field_name: str) -> uuid.UUID:
|
||||||
|
try:
|
||||||
|
return uuid.UUID(str(value))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise HTTPException(status_code=400, detail=f'Поле "{field_name}" должно быть UUID')
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_admin_user_topics_fields(db: Session, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
data = dict(payload)
|
||||||
|
if "admin_user_id" in data:
|
||||||
|
user_id = _parse_uuid_or_400(data.get("admin_user_id"), "admin_user_id")
|
||||||
|
user = db.get(AdminUser, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=400, detail="Пользователь не найден")
|
||||||
|
if str(user.role or "").upper() != "LAWYER":
|
||||||
|
raise HTTPException(status_code=400, detail="Дополнительные темы доступны только для юриста")
|
||||||
|
data["admin_user_id"] = user_id
|
||||||
|
if "topic_code" in data:
|
||||||
|
topic_code = str(data.get("topic_code") or "").strip()
|
||||||
|
if not topic_code:
|
||||||
|
raise HTTPException(status_code=400, detail='Поле "topic_code" не может быть пустым')
|
||||||
|
topic_exists = db.query(Topic.id).filter(Topic.code == topic_code).first()
|
||||||
|
if topic_exists is None:
|
||||||
|
raise HTTPException(status_code=400, detail="Тема не найдена")
|
||||||
|
data["topic_code"] = topic_code
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_topic_exists_or_400(db: Session, topic_code: str) -> None:
|
||||||
|
exists = db.query(Topic.id).filter(Topic.code == topic_code).first()
|
||||||
|
if exists is None:
|
||||||
|
raise HTTPException(status_code=400, detail="Тема не найдена")
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_form_field_exists_or_400(db: Session, field_key: str) -> None:
|
||||||
|
exists = db.query(FormField.id).filter(FormField.key == field_key).first()
|
||||||
|
if exists is None:
|
||||||
|
raise HTTPException(status_code=400, detail="Поле формы не найдено")
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_status_exists_or_400(db: Session, status_code: str) -> None:
|
||||||
|
exists = db.query(Status.id).filter(Status.code == status_code).first()
|
||||||
|
if exists is None:
|
||||||
|
raise HTTPException(status_code=400, detail="Статус не найден")
|
||||||
|
|
||||||
|
|
||||||
|
def _as_positive_int_or_400(value: Any, field_name: str) -> int:
|
||||||
|
try:
|
||||||
|
number = int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise HTTPException(status_code=400, detail=f'Поле "{field_name}" должно быть целым числом')
|
||||||
|
if number <= 0:
|
||||||
|
raise HTTPException(status_code=400, detail=f'Поле "{field_name}" должно быть больше 0')
|
||||||
|
return number
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_topic_required_fields_fields(db: Session, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
data = dict(payload)
|
||||||
|
if "topic_code" in data:
|
||||||
|
topic_code = str(data.get("topic_code") or "").strip()
|
||||||
|
if not topic_code:
|
||||||
|
raise HTTPException(status_code=400, detail='Поле "topic_code" не может быть пустым')
|
||||||
|
_ensure_topic_exists_or_400(db, topic_code)
|
||||||
|
data["topic_code"] = topic_code
|
||||||
|
if "field_key" in data:
|
||||||
|
field_key = str(data.get("field_key") or "").strip()
|
||||||
|
if not field_key:
|
||||||
|
raise HTTPException(status_code=400, detail='Поле "field_key" не может быть пустым')
|
||||||
|
_ensure_form_field_exists_or_400(db, field_key)
|
||||||
|
data["field_key"] = field_key
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_topic_data_templates_fields(db: Session, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
data = dict(payload)
|
||||||
|
if "topic_code" in data:
|
||||||
|
topic_code = str(data.get("topic_code") or "").strip()
|
||||||
|
if not topic_code:
|
||||||
|
raise HTTPException(status_code=400, detail='Поле "topic_code" не может быть пустым')
|
||||||
|
_ensure_topic_exists_or_400(db, topic_code)
|
||||||
|
data["topic_code"] = topic_code
|
||||||
|
if "key" in data:
|
||||||
|
key = str(data.get("key") or "").strip()
|
||||||
|
if not key:
|
||||||
|
raise HTTPException(status_code=400, detail='Поле "key" не может быть пустым')
|
||||||
|
data["key"] = key
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_request_data_requirements_fields(db: Session, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
data = dict(payload)
|
||||||
|
if "request_id" in data:
|
||||||
|
request_id = _parse_uuid_or_400(data.get("request_id"), "request_id")
|
||||||
|
request = db.get(Request, request_id)
|
||||||
|
if request is None:
|
||||||
|
raise HTTPException(status_code=400, detail="Заявка не найдена")
|
||||||
|
data["request_id"] = request_id
|
||||||
|
if "topic_template_id" in data and data.get("topic_template_id") is not None:
|
||||||
|
template_id = _parse_uuid_or_400(data.get("topic_template_id"), "topic_template_id")
|
||||||
|
template = db.get(TopicDataTemplate, template_id)
|
||||||
|
if template is None:
|
||||||
|
raise HTTPException(status_code=400, detail="Шаблон темы не найден")
|
||||||
|
data["topic_template_id"] = template_id
|
||||||
|
if "key" in data:
|
||||||
|
key = str(data.get("key") or "").strip()
|
||||||
|
if not key:
|
||||||
|
raise HTTPException(status_code=400, detail='Поле "key" не может быть пустым')
|
||||||
|
data["key"] = key
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_topic_status_transitions_fields(db: Session, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
data = dict(payload)
|
||||||
|
topic_code = None
|
||||||
|
from_status = None
|
||||||
|
to_status = None
|
||||||
|
|
||||||
|
if "topic_code" in data:
|
||||||
|
topic_code = str(data.get("topic_code") or "").strip()
|
||||||
|
if not topic_code:
|
||||||
|
raise HTTPException(status_code=400, detail='Поле "topic_code" не может быть пустым')
|
||||||
|
_ensure_topic_exists_or_400(db, topic_code)
|
||||||
|
data["topic_code"] = topic_code
|
||||||
|
if "from_status" in data:
|
||||||
|
from_status = str(data.get("from_status") or "").strip()
|
||||||
|
if not from_status:
|
||||||
|
raise HTTPException(status_code=400, detail='Поле "from_status" не может быть пустым')
|
||||||
|
_ensure_status_exists_or_400(db, from_status)
|
||||||
|
data["from_status"] = from_status
|
||||||
|
if "to_status" in data:
|
||||||
|
to_status = str(data.get("to_status") or "").strip()
|
||||||
|
if not to_status:
|
||||||
|
raise HTTPException(status_code=400, detail='Поле "to_status" не может быть пустым')
|
||||||
|
_ensure_status_exists_or_400(db, to_status)
|
||||||
|
data["to_status"] = to_status
|
||||||
|
|
||||||
|
if from_status and to_status and from_status == to_status:
|
||||||
|
raise HTTPException(status_code=400, detail='Поля "from_status" и "to_status" не должны совпадать')
|
||||||
|
|
||||||
|
if "sla_hours" in data:
|
||||||
|
raw = data.get("sla_hours")
|
||||||
|
if raw is None or str(raw).strip() == "":
|
||||||
|
data["sla_hours"] = None
|
||||||
|
else:
|
||||||
|
data["sla_hours"] = _as_positive_int_or_400(raw, "sla_hours")
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
_RU_TO_LATIN = {
|
||||||
|
"а": "a",
|
||||||
|
"б": "b",
|
||||||
|
"в": "v",
|
||||||
|
"г": "g",
|
||||||
|
"д": "d",
|
||||||
|
"е": "e",
|
||||||
|
"ё": "e",
|
||||||
|
"ж": "zh",
|
||||||
|
"з": "z",
|
||||||
|
"и": "i",
|
||||||
|
"й": "y",
|
||||||
|
"к": "k",
|
||||||
|
"л": "l",
|
||||||
|
"м": "m",
|
||||||
|
"н": "n",
|
||||||
|
"о": "o",
|
||||||
|
"п": "p",
|
||||||
|
"р": "r",
|
||||||
|
"с": "s",
|
||||||
|
"т": "t",
|
||||||
|
"у": "u",
|
||||||
|
"ф": "f",
|
||||||
|
"х": "h",
|
||||||
|
"ц": "ts",
|
||||||
|
"ч": "ch",
|
||||||
|
"ш": "sh",
|
||||||
|
"щ": "sch",
|
||||||
|
"ъ": "",
|
||||||
|
"ы": "y",
|
||||||
|
"ь": "",
|
||||||
|
"э": "e",
|
||||||
|
"ю": "yu",
|
||||||
|
"я": "ya",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _slugify(value: str, fallback: str) -> str:
|
||||||
|
raw = str(value or "").strip().lower()
|
||||||
|
if not raw:
|
||||||
|
return fallback
|
||||||
|
latin = "".join(_RU_TO_LATIN.get(ch, ch) for ch in raw)
|
||||||
|
out: list[str] = []
|
||||||
|
prev_dash = False
|
||||||
|
for ch in latin:
|
||||||
|
if ("a" <= ch <= "z") or ("0" <= ch <= "9"):
|
||||||
|
out.append(ch)
|
||||||
|
prev_dash = False
|
||||||
|
continue
|
||||||
|
if not prev_dash:
|
||||||
|
out.append("-")
|
||||||
|
prev_dash = True
|
||||||
|
slug = "".join(out).strip("-")
|
||||||
|
return slug or fallback
|
||||||
|
|
||||||
|
|
||||||
|
def _make_unique_value(db: Session, model: type, field_name: str, base_value: str) -> str:
|
||||||
|
columns = _columns_map(model)
|
||||||
|
column = columns[field_name]
|
||||||
|
max_len = getattr(column.type, "length", None)
|
||||||
|
base = base_value.strip("-") or field_name
|
||||||
|
if max_len:
|
||||||
|
base = base[:max_len]
|
||||||
|
|
||||||
|
field = getattr(model, field_name)
|
||||||
|
if not db.query(model).filter(field == base).first():
|
||||||
|
return base
|
||||||
|
|
||||||
|
idx = 2
|
||||||
|
while True:
|
||||||
|
suffix = f"-{idx}"
|
||||||
|
candidate = base
|
||||||
|
if max_len and len(candidate) + len(suffix) > max_len:
|
||||||
|
candidate = candidate[: max_len - len(suffix)]
|
||||||
|
candidate = (candidate + suffix).strip("-")
|
||||||
|
if not db.query(model).filter(field == candidate).first():
|
||||||
|
return candidate
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_auto_fields_for_create(db: Session, model: type, table_name: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
data = dict(payload)
|
||||||
|
if table_name == "topics" and not str(data.get("code") or "").strip():
|
||||||
|
base = _slugify(str(data.get("name") or ""), "topic")
|
||||||
|
data["code"] = _make_unique_value(db, model, "code", base)
|
||||||
|
if table_name == "statuses" and not str(data.get("code") or "").strip():
|
||||||
|
base = _slugify(str(data.get("name") or ""), "status")
|
||||||
|
data["code"] = _make_unique_value(db, model, "code", base)
|
||||||
|
if table_name == "form_fields" and not str(data.get("key") or "").strip():
|
||||||
|
base = _slugify(str(data.get("label") or ""), "field")
|
||||||
|
data["key"] = _make_unique_value(db, model, "key", base)
|
||||||
|
if table_name == "admin_users":
|
||||||
|
data = _apply_admin_user_fields_for_create(data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_responsible(admin: dict | None) -> str:
|
||||||
|
if not admin:
|
||||||
|
return "Администратор системы"
|
||||||
|
email = str(admin.get("email") or "").strip()
|
||||||
|
return email or "Администратор системы"
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_hidden_fields(table_name: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
hidden = _hidden_response_fields(table_name)
|
||||||
|
if not hidden:
|
||||||
|
return payload
|
||||||
|
return {k: v for k, v in payload.items() if k not in hidden}
|
||||||
|
|
||||||
|
|
||||||
|
def _actor_uuid(admin: dict) -> uuid.UUID | None:
|
||||||
|
sub = admin.get("sub")
|
||||||
|
if not sub:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return uuid.UUID(str(sub))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _append_audit(db: Session, admin: dict, table_name: str, entity_id: str, action: str, diff: dict[str, Any]) -> None:
|
||||||
|
db.add(
|
||||||
|
AuditLog(
|
||||||
|
actor_admin_id=_actor_uuid(admin),
|
||||||
|
entity=table_name,
|
||||||
|
entity_id=str(entity_id),
|
||||||
|
action=action,
|
||||||
|
diff=diff,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _integrity_error(detail: str = "Нарушение ограничений данных") -> HTTPException:
|
||||||
|
return HTTPException(status_code=400, detail=detail)
|
||||||
|
|
||||||
|
|
||||||
|
def _actor_role(admin: dict) -> str:
|
||||||
|
role = str(admin.get("role") or "").strip().upper()
|
||||||
|
return role or "ADMIN"
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_create_side_effects(db: Session, *, table_name: str, row: Any, admin: dict) -> None:
|
||||||
|
if table_name == "messages" and isinstance(row, Message):
|
||||||
|
req = db.get(Request, row.request_id)
|
||||||
|
if req is None:
|
||||||
|
return
|
||||||
|
author_type = str(row.author_type or "").strip().upper()
|
||||||
|
if author_type == "CLIENT":
|
||||||
|
mark_unread_for_lawyer(req, EVENT_MESSAGE)
|
||||||
|
responsible = "Клиент"
|
||||||
|
actor_role = "CLIENT"
|
||||||
|
actor_admin_user_id = None
|
||||||
|
else:
|
||||||
|
mark_unread_for_client(req, EVENT_MESSAGE)
|
||||||
|
responsible = _resolve_responsible(admin)
|
||||||
|
actor_role = _actor_role(admin)
|
||||||
|
actor_admin_user_id = admin.get("sub")
|
||||||
|
req.responsible = responsible
|
||||||
|
db.add(req)
|
||||||
|
notify_request_event(
|
||||||
|
db,
|
||||||
|
request=req,
|
||||||
|
event_type=NOTIFICATION_EVENT_MESSAGE,
|
||||||
|
actor_role=actor_role,
|
||||||
|
actor_admin_user_id=actor_admin_user_id,
|
||||||
|
body=str(row.body or "").strip() or None,
|
||||||
|
responsible=responsible,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if table_name == "attachments" and isinstance(row, Attachment):
|
||||||
|
req = db.get(Request, row.request_id)
|
||||||
|
if req is None:
|
||||||
|
return
|
||||||
|
mark_unread_for_client(req, EVENT_ATTACHMENT)
|
||||||
|
responsible = _resolve_responsible(admin)
|
||||||
|
req.responsible = responsible
|
||||||
|
db.add(req)
|
||||||
|
notify_request_event(
|
||||||
|
db,
|
||||||
|
request=req,
|
||||||
|
event_type=NOTIFICATION_EVENT_ATTACHMENT,
|
||||||
|
actor_role=_actor_role(admin),
|
||||||
|
actor_admin_user_id=admin.get("sub"),
|
||||||
|
body=f"Файл: {row.file_name}",
|
||||||
|
responsible=responsible,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{table_name}/query")
|
||||||
|
def query_table(
|
||||||
|
table_name: str,
|
||||||
|
uq: UniversalQuery,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
admin: dict = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
normalized, model = _resolve_table_model(table_name)
|
||||||
|
_require_table_action(admin, normalized, "query")
|
||||||
|
query = apply_universal_query(db.query(model), model, uq)
|
||||||
|
total = query.count()
|
||||||
|
rows = query.offset(uq.page.offset).limit(uq.page.limit).all()
|
||||||
|
return {"rows": [_strip_hidden_fields(normalized, _row_to_dict(row)) for row in rows], "total": total}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{table_name}/{row_id}")
|
||||||
|
def get_row(
|
||||||
|
table_name: str,
|
||||||
|
row_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
admin: dict = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
normalized, model = _resolve_table_model(table_name)
|
||||||
|
_require_table_action(admin, normalized, "read")
|
||||||
|
row = _load_row_or_404(db, model, row_id)
|
||||||
|
if normalized == "requests":
|
||||||
|
req = row if isinstance(row, Request) else None
|
||||||
|
if req is not None:
|
||||||
|
changed = False
|
||||||
|
if _is_lawyer(admin) and clear_unread_for_lawyer(req):
|
||||||
|
changed = True
|
||||||
|
db.add(req)
|
||||||
|
read_count = mark_admin_notifications_read(
|
||||||
|
db,
|
||||||
|
admin_user_id=admin.get("sub"),
|
||||||
|
request_id=req.id,
|
||||||
|
responsible=_resolve_responsible(admin),
|
||||||
|
)
|
||||||
|
if read_count:
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
db.commit()
|
||||||
|
db.refresh(req)
|
||||||
|
row = req
|
||||||
|
return _strip_hidden_fields(normalized, _row_to_dict(row))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{table_name}", status_code=201)
|
||||||
|
def create_row(
|
||||||
|
table_name: str,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
admin: dict = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
normalized, model = _resolve_table_model(table_name)
|
||||||
|
_require_table_action(admin, normalized, "create")
|
||||||
|
if normalized == "requests" and _is_lawyer(admin):
|
||||||
|
assigned_lawyer_id = payload.get("assigned_lawyer_id") if isinstance(payload, dict) else None
|
||||||
|
if str(assigned_lawyer_id or "").strip():
|
||||||
|
raise HTTPException(status_code=403, detail='Юрист не может назначать заявку при создании')
|
||||||
|
|
||||||
|
prepared = _prepare_create_payload(normalized, payload)
|
||||||
|
if normalized == "requests":
|
||||||
|
validate_required_topic_fields_or_400(db, prepared.get("topic_code"), prepared.get("extra_fields"))
|
||||||
|
prepared = _apply_auto_fields_for_create(db, model, normalized, prepared)
|
||||||
|
clean_payload = _sanitize_payload(
|
||||||
|
model,
|
||||||
|
normalized,
|
||||||
|
prepared,
|
||||||
|
is_update=False,
|
||||||
|
allow_protected_fields={"password_hash"} if normalized == "admin_users" else None,
|
||||||
|
)
|
||||||
|
if normalized == "admin_user_topics":
|
||||||
|
clean_payload = _apply_admin_user_topics_fields(db, clean_payload)
|
||||||
|
if normalized == "topic_required_fields":
|
||||||
|
clean_payload = _apply_topic_required_fields_fields(db, clean_payload)
|
||||||
|
if normalized == "topic_data_templates":
|
||||||
|
clean_payload = _apply_topic_data_templates_fields(db, clean_payload)
|
||||||
|
if normalized == "request_data_requirements":
|
||||||
|
clean_payload = _apply_request_data_requirements_fields(db, clean_payload)
|
||||||
|
if normalized == "topic_status_transitions":
|
||||||
|
clean_payload = _apply_topic_status_transitions_fields(db, clean_payload)
|
||||||
|
if "responsible" in _columns_map(model):
|
||||||
|
clean_payload["responsible"] = _resolve_responsible(admin)
|
||||||
|
row = model(**clean_payload)
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.add(row)
|
||||||
|
db.flush()
|
||||||
|
_apply_create_side_effects(db, table_name=normalized, row=row, admin=admin)
|
||||||
|
snapshot = _row_to_dict(row)
|
||||||
|
_append_audit(db, admin, normalized, str(snapshot.get("id") or ""), "CREATE", {"after": snapshot})
|
||||||
|
db.commit()
|
||||||
|
db.refresh(row)
|
||||||
|
except IntegrityError:
|
||||||
|
db.rollback()
|
||||||
|
raise _integrity_error()
|
||||||
|
|
||||||
|
return _strip_hidden_fields(normalized, _row_to_dict(row))
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{table_name}/{row_id}")
|
||||||
|
def update_row(
|
||||||
|
table_name: str,
|
||||||
|
row_id: str,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
admin: dict = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
normalized, model = _resolve_table_model(table_name)
|
||||||
|
_require_table_action(admin, normalized, "update")
|
||||||
|
if normalized == "requests" and _is_lawyer(admin) and isinstance(payload, dict) and "assigned_lawyer_id" in payload:
|
||||||
|
raise HTTPException(status_code=403, detail='Назначение доступно только через действие "Взять в работу"')
|
||||||
|
row = _load_row_or_404(db, model, row_id)
|
||||||
|
if normalized in {"messages", "attachments"} and bool(getattr(row, "immutable", False)):
|
||||||
|
raise HTTPException(status_code=400, detail="Запись зафиксирована и недоступна для редактирования")
|
||||||
|
prepared = dict(payload)
|
||||||
|
if normalized == "admin_users":
|
||||||
|
prepared = _apply_admin_user_fields_for_update(prepared)
|
||||||
|
clean_payload = _sanitize_payload(
|
||||||
|
model,
|
||||||
|
normalized,
|
||||||
|
prepared,
|
||||||
|
is_update=True,
|
||||||
|
allow_protected_fields={"password_hash"} if normalized == "admin_users" else None,
|
||||||
|
)
|
||||||
|
if normalized == "admin_user_topics":
|
||||||
|
clean_payload = _apply_admin_user_topics_fields(db, clean_payload)
|
||||||
|
if normalized == "topic_required_fields":
|
||||||
|
clean_payload = _apply_topic_required_fields_fields(db, clean_payload)
|
||||||
|
if normalized == "topic_data_templates":
|
||||||
|
clean_payload = _apply_topic_data_templates_fields(db, clean_payload)
|
||||||
|
if normalized == "request_data_requirements":
|
||||||
|
clean_payload = _apply_request_data_requirements_fields(db, clean_payload)
|
||||||
|
if normalized == "topic_status_transitions":
|
||||||
|
clean_payload = _apply_topic_status_transitions_fields(db, clean_payload)
|
||||||
|
before = _row_to_dict(row)
|
||||||
|
if normalized == "topic_status_transitions":
|
||||||
|
next_from = str(clean_payload.get("from_status", before.get("from_status") or "")).strip()
|
||||||
|
next_to = str(clean_payload.get("to_status", before.get("to_status") or "")).strip()
|
||||||
|
if next_from and next_to and next_from == next_to:
|
||||||
|
raise HTTPException(status_code=400, detail='Поля "from_status" и "to_status" не должны совпадать')
|
||||||
|
if normalized == "requests" and "status_code" in clean_payload:
|
||||||
|
before_status = str(before.get("status_code") or "")
|
||||||
|
after_status = str(clean_payload.get("status_code") or "")
|
||||||
|
topic_code = str(before.get("topic_code") or "").strip() or None
|
||||||
|
if not transition_allowed_for_topic(db, topic_code, before_status, after_status):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Переход статуса не разрешен для выбранной темы",
|
||||||
|
)
|
||||||
|
if before_status != after_status and isinstance(row, Request):
|
||||||
|
mark_unread_for_client(row, EVENT_STATUS)
|
||||||
|
apply_status_change_effects(
|
||||||
|
db,
|
||||||
|
row,
|
||||||
|
from_status=before_status,
|
||||||
|
to_status=after_status,
|
||||||
|
admin=admin,
|
||||||
|
responsible=_resolve_responsible(admin),
|
||||||
|
)
|
||||||
|
notify_request_event(
|
||||||
|
db,
|
||||||
|
request=row,
|
||||||
|
event_type=NOTIFICATION_EVENT_STATUS,
|
||||||
|
actor_role=_actor_role(admin),
|
||||||
|
actor_admin_user_id=admin.get("sub"),
|
||||||
|
body=f"{before_status} -> {after_status}",
|
||||||
|
responsible=_resolve_responsible(admin),
|
||||||
|
)
|
||||||
|
for key, value in clean_payload.items():
|
||||||
|
setattr(row, key, value)
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.add(row)
|
||||||
|
db.flush()
|
||||||
|
after = _row_to_dict(row)
|
||||||
|
_append_audit(db, admin, normalized, str(after.get("id") or row_id), "UPDATE", {"before": before, "after": after})
|
||||||
|
db.commit()
|
||||||
|
db.refresh(row)
|
||||||
|
except IntegrityError:
|
||||||
|
db.rollback()
|
||||||
|
raise _integrity_error()
|
||||||
|
|
||||||
|
return _strip_hidden_fields(normalized, _row_to_dict(row))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{table_name}/{row_id}")
|
||||||
|
def delete_row(
|
||||||
|
table_name: str,
|
||||||
|
row_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
admin: dict = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
normalized, model = _resolve_table_model(table_name)
|
||||||
|
_require_table_action(admin, normalized, "delete")
|
||||||
|
if normalized == "admin_users" and str(admin.get("sub") or "") == str(row_id):
|
||||||
|
raise HTTPException(status_code=400, detail="Нельзя удалить собственную учетную запись")
|
||||||
|
row = _load_row_or_404(db, model, row_id)
|
||||||
|
if normalized in {"messages", "attachments"} and bool(getattr(row, "immutable", False)):
|
||||||
|
raise HTTPException(status_code=400, detail="Запись зафиксирована и недоступна для удаления")
|
||||||
|
|
||||||
|
before = _row_to_dict(row)
|
||||||
|
entity_id = str(before.get("id") or row_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.delete(row)
|
||||||
|
_append_audit(db, admin, normalized, entity_id, "DELETE", {"before": before})
|
||||||
|
db.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
db.rollback()
|
||||||
|
raise _integrity_error("Невозможно удалить запись из-за ограничений связанных данных")
|
||||||
|
|
||||||
|
return {"status": "удалено", "id": entity_id}
|
||||||
|
|
@ -1,20 +1,241 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from decimal import Decimal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.deps import require_role
|
from app.core.deps import require_role
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
|
from app.models.admin_user import AdminUser
|
||||||
from app.models.request import Request
|
from app.models.request import Request
|
||||||
|
from app.models.status import Status
|
||||||
|
from app.models.status_history import StatusHistory
|
||||||
|
from app.services.sla_metrics import compute_sla_snapshot
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
DEFAULT_TERMINAL_STATUS_CODES = {"RESOLVED", "CLOSED", "REJECTED"}
|
||||||
|
PAID_STATUS_CODES = {"PAID", "ОПЛАЧЕНО"}
|
||||||
|
|
||||||
|
|
||||||
|
def _terminal_status_codes(db: Session) -> set[str]:
|
||||||
|
rows = db.query(Status.code).filter(Status.is_terminal.is_(True)).all()
|
||||||
|
codes = {str(code).strip() for (code,) in rows if code}
|
||||||
|
return codes or set(DEFAULT_TERMINAL_STATUS_CODES)
|
||||||
|
|
||||||
|
|
||||||
|
def _paid_status_codes() -> set[str]:
|
||||||
|
return set(PAID_STATUS_CODES)
|
||||||
|
|
||||||
|
|
||||||
|
def _month_bounds(now_utc: datetime) -> tuple[datetime, datetime]:
|
||||||
|
start = datetime(now_utc.year, now_utc.month, 1, tzinfo=timezone.utc)
|
||||||
|
if now_utc.month == 12:
|
||||||
|
end = datetime(now_utc.year + 1, 1, 1, tzinfo=timezone.utc)
|
||||||
|
else:
|
||||||
|
end = datetime(now_utc.year, now_utc.month + 1, 1, tzinfo=timezone.utc)
|
||||||
|
return start, end
|
||||||
|
|
||||||
|
|
||||||
|
def _to_float(value) -> float:
|
||||||
|
if value is None:
|
||||||
|
return 0.0
|
||||||
|
if isinstance(value, Decimal):
|
||||||
|
return float(value)
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _uuid_or_none(value: str | None) -> UUID | None:
|
||||||
|
try:
|
||||||
|
return UUID(str(value or ""))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/overview")
|
@router.get("/overview")
|
||||||
def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN","LAWYER"))):
|
def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER"))):
|
||||||
by_status_rows = db.query(Request.status_code, func.count(Request.id)).group_by(Request.status_code).all()
|
role = str(admin.get("role") or "").upper()
|
||||||
by_status = {status: count for status, count in by_status_rows}
|
actor_id = str(admin.get("sub") or "").strip()
|
||||||
|
actor_uuid = _uuid_or_none(actor_id)
|
||||||
|
|
||||||
|
terminal_codes = _terminal_status_codes(db)
|
||||||
|
paid_codes = _paid_status_codes()
|
||||||
|
now_utc = datetime.now(timezone.utc)
|
||||||
|
month_start, next_month_start = _month_bounds(now_utc)
|
||||||
|
|
||||||
|
unread_for_clients = (
|
||||||
|
db.query(func.count(Request.id))
|
||||||
|
.filter(Request.client_has_unread_updates.is_(True))
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
unread_for_lawyers = (
|
||||||
|
db.query(func.count(Request.id))
|
||||||
|
.filter(Request.lawyer_has_unread_updates.is_(True))
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
active_load_rows = (
|
||||||
|
db.query(Request.assigned_lawyer_id, func.count(Request.id))
|
||||||
|
.filter(Request.assigned_lawyer_id.is_not(None))
|
||||||
|
.filter(Request.status_code.notin_(terminal_codes))
|
||||||
|
.group_by(Request.assigned_lawyer_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
total_load_rows = (
|
||||||
|
db.query(Request.assigned_lawyer_id, func.count(Request.id))
|
||||||
|
.filter(Request.assigned_lawyer_id.is_not(None))
|
||||||
|
.group_by(Request.assigned_lawyer_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
active_amount_rows = (
|
||||||
|
db.query(Request.assigned_lawyer_id, func.coalesce(func.sum(func.coalesce(Request.invoice_amount, 0)), 0))
|
||||||
|
.filter(Request.assigned_lawyer_id.is_not(None))
|
||||||
|
.filter(Request.status_code.notin_(terminal_codes))
|
||||||
|
.group_by(Request.assigned_lawyer_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
paid_rows = (
|
||||||
|
db.query(
|
||||||
|
Request.assigned_lawyer_id,
|
||||||
|
func.count(StatusHistory.id),
|
||||||
|
func.coalesce(func.sum(func.coalesce(Request.invoice_amount, 0)), 0),
|
||||||
|
)
|
||||||
|
.join(StatusHistory, StatusHistory.request_id == Request.id)
|
||||||
|
.filter(Request.assigned_lawyer_id.is_not(None))
|
||||||
|
.filter(StatusHistory.created_at >= month_start, StatusHistory.created_at < next_month_start)
|
||||||
|
.filter(func.upper(StatusHistory.to_status).in_(paid_codes))
|
||||||
|
.group_by(Request.assigned_lawyer_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
active_load_map = {str(lawyer_id): int(count) for lawyer_id, count in active_load_rows if lawyer_id}
|
||||||
|
total_load_map = {str(lawyer_id): int(count) for lawyer_id, count in total_load_rows if lawyer_id}
|
||||||
|
active_amount_map = {str(lawyer_id): _to_float(amount) for lawyer_id, amount in active_amount_rows if lawyer_id}
|
||||||
|
paid_events_map = {str(lawyer_id): int(events) for lawyer_id, events, _ in paid_rows if lawyer_id}
|
||||||
|
monthly_gross_map = {str(lawyer_id): _to_float(gross) for lawyer_id, _, gross in paid_rows if lawyer_id}
|
||||||
|
|
||||||
|
lawyers = (
|
||||||
|
db.query(AdminUser)
|
||||||
|
.filter(AdminUser.role == "LAWYER", AdminUser.is_active.is_(True))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
lawyer_loads = []
|
||||||
|
for lawyer in lawyers:
|
||||||
|
lawyer_id = str(lawyer.id)
|
||||||
|
salary_percent = _to_float(lawyer.salary_percent)
|
||||||
|
monthly_paid_gross = monthly_gross_map.get(lawyer_id, 0.0)
|
||||||
|
monthly_salary = monthly_paid_gross * salary_percent / 100.0
|
||||||
|
lawyer_loads.append(
|
||||||
|
{
|
||||||
|
"lawyer_id": lawyer_id,
|
||||||
|
"name": lawyer.name,
|
||||||
|
"email": lawyer.email,
|
||||||
|
"avatar_url": lawyer.avatar_url,
|
||||||
|
"primary_topic_code": lawyer.primary_topic_code,
|
||||||
|
"default_rate": _to_float(lawyer.default_rate),
|
||||||
|
"salary_percent": salary_percent,
|
||||||
|
"active_load": active_load_map.get(lawyer_id, 0),
|
||||||
|
"total_assigned": total_load_map.get(lawyer_id, 0),
|
||||||
|
"active_amount": round(active_amount_map.get(lawyer_id, 0.0), 2),
|
||||||
|
"monthly_paid_events": paid_events_map.get(lawyer_id, 0),
|
||||||
|
"monthly_paid_gross": round(monthly_paid_gross, 2),
|
||||||
|
"monthly_salary": round(monthly_salary, 2),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
lawyer_loads.sort(key=lambda row: (-row["active_load"], row["name"] or "", row["email"] or ""))
|
||||||
|
|
||||||
|
if role == "LAWYER" and actor_uuid is not None:
|
||||||
|
scoped_by_status_rows = (
|
||||||
|
db.query(Request.status_code, func.count(Request.id))
|
||||||
|
.filter(Request.assigned_lawyer_id == str(actor_uuid))
|
||||||
|
.group_by(Request.status_code)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
by_status = {status: int(count) for status, count in scoped_by_status_rows}
|
||||||
|
assigned_total = int(sum(by_status.values()))
|
||||||
|
active_assigned_total = int(
|
||||||
|
db.query(func.count(Request.id))
|
||||||
|
.filter(Request.assigned_lawyer_id == str(actor_uuid))
|
||||||
|
.filter(Request.status_code.notin_(terminal_codes))
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
unassigned_total = int(db.query(func.count(Request.id)).filter(Request.assigned_lawyer_id.is_(None)).scalar() or 0)
|
||||||
|
my_unread_updates = int(
|
||||||
|
db.query(func.count(Request.id))
|
||||||
|
.filter(
|
||||||
|
Request.assigned_lawyer_id == str(actor_uuid),
|
||||||
|
Request.lawyer_has_unread_updates.is_(True),
|
||||||
|
)
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
my_unread_by_event_rows = (
|
||||||
|
db.query(Request.lawyer_unread_event_type, func.count(Request.id))
|
||||||
|
.filter(
|
||||||
|
Request.assigned_lawyer_id == str(actor_uuid),
|
||||||
|
Request.lawyer_has_unread_updates.is_(True),
|
||||||
|
Request.lawyer_unread_event_type.is_not(None),
|
||||||
|
)
|
||||||
|
.group_by(Request.lawyer_unread_event_type)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
my_unread_by_event = {str(event_type): int(count) for event_type, count in my_unread_by_event_rows if event_type}
|
||||||
|
scoped_lawyer_loads = [row for row in lawyer_loads if str(row["lawyer_id"]) == str(actor_uuid)]
|
||||||
|
elif role == "LAWYER":
|
||||||
|
by_status = {}
|
||||||
|
assigned_total = 0
|
||||||
|
active_assigned_total = 0
|
||||||
|
unassigned_total = int(db.query(func.count(Request.id)).filter(Request.assigned_lawyer_id.is_(None)).scalar() or 0)
|
||||||
|
my_unread_updates = 0
|
||||||
|
my_unread_by_event = {}
|
||||||
|
scoped_lawyer_loads = []
|
||||||
|
else:
|
||||||
|
scoped_by_status_rows = db.query(Request.status_code, func.count(Request.id)).group_by(Request.status_code).all()
|
||||||
|
by_status = {status: int(count) for status, count in scoped_by_status_rows}
|
||||||
|
assigned_total = int(
|
||||||
|
db.query(func.count(Request.id))
|
||||||
|
.filter(Request.assigned_lawyer_id.is_not(None))
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
active_assigned_total = int(
|
||||||
|
db.query(func.count(Request.id))
|
||||||
|
.filter(Request.assigned_lawyer_id.is_not(None))
|
||||||
|
.filter(Request.status_code.notin_(terminal_codes))
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
unassigned_total = int(db.query(func.count(Request.id)).filter(Request.assigned_lawyer_id.is_(None)).scalar() or 0)
|
||||||
|
my_unread_updates = 0
|
||||||
|
my_unread_by_event = {}
|
||||||
|
scoped_lawyer_loads = lawyer_loads
|
||||||
|
|
||||||
|
sla_snapshot = compute_sla_snapshot(db)
|
||||||
return {
|
return {
|
||||||
"new": by_status.get("NEW", 0),
|
"scope": role if role in {"ADMIN", "LAWYER"} else "ADMIN",
|
||||||
|
"new": int(by_status.get("NEW", 0)),
|
||||||
"by_status": by_status,
|
"by_status": by_status,
|
||||||
"frt_avg_minutes": None,
|
"assigned_total": assigned_total,
|
||||||
"sla_overdue": 0,
|
"active_assigned_total": active_assigned_total,
|
||||||
"avg_time_in_status_hours": {},
|
"unassigned_total": unassigned_total,
|
||||||
|
"my_unread_updates": my_unread_updates,
|
||||||
|
"my_unread_by_event": my_unread_by_event,
|
||||||
|
"frt_avg_minutes": sla_snapshot.get("frt_avg_minutes"),
|
||||||
|
"sla_overdue": sla_snapshot.get("overdue_total", 0),
|
||||||
|
"overdue_by_status": sla_snapshot.get("overdue_by_status", {}),
|
||||||
|
"overdue_by_transition": sla_snapshot.get("overdue_by_transition", {}),
|
||||||
|
"avg_time_in_status_hours": sla_snapshot.get("avg_time_in_status_hours", {}),
|
||||||
|
"unread_for_clients": int(unread_for_clients),
|
||||||
|
"unread_for_lawyers": int(unread_for_lawyers),
|
||||||
|
"lawyer_loads": scoped_lawyer_loads,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
120
app/api/admin/notifications.py
Normal file
120
app/api/admin/notifications.py
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.deps import require_role
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.schemas.admin import NotificationsReadAll
|
||||||
|
from app.services.notifications import (
|
||||||
|
get_admin_notification,
|
||||||
|
list_admin_notifications,
|
||||||
|
mark_admin_notifications_read,
|
||||||
|
serialize_notification,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _actor_uuid_or_401(admin: dict) -> uuid.UUID:
|
||||||
|
try:
|
||||||
|
return uuid.UUID(str(admin.get("sub") or ""))
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=401, detail="Некорректный токен")
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_uuid_or_400(raw: str | None, field_name: str) -> uuid.UUID | None:
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
value = str(raw).strip()
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return uuid.UUID(value)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail=f'Некорректный "{field_name}"')
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def list_notifications(
|
||||||
|
unread_only: bool = Query(default=False),
|
||||||
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
request_id: str | None = Query(default=None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
|
||||||
|
):
|
||||||
|
actor_id = _actor_uuid_or_401(admin)
|
||||||
|
request_uuid = _optional_uuid_or_400(request_id, "request_id")
|
||||||
|
rows, total = list_admin_notifications(
|
||||||
|
db,
|
||||||
|
admin_user_id=actor_id,
|
||||||
|
unread_only=unread_only,
|
||||||
|
request_id=request_uuid,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
_, unread_total = list_admin_notifications(
|
||||||
|
db,
|
||||||
|
admin_user_id=actor_id,
|
||||||
|
unread_only=True,
|
||||||
|
request_id=request_uuid,
|
||||||
|
limit=1,
|
||||||
|
offset=0,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"rows": [serialize_notification(row) for row in rows],
|
||||||
|
"total": total,
|
||||||
|
"unread_total": int(unread_total),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{notification_id}/read")
|
||||||
|
def read_single_notification(
|
||||||
|
notification_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
|
||||||
|
):
|
||||||
|
actor_id = _actor_uuid_or_401(admin)
|
||||||
|
try:
|
||||||
|
notification_uuid = uuid.UUID(str(notification_id))
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Некорректный notification_id")
|
||||||
|
|
||||||
|
row = get_admin_notification(db, admin_user_id=actor_id, notification_id=notification_uuid)
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Уведомление не найдено")
|
||||||
|
|
||||||
|
changed = mark_admin_notifications_read(
|
||||||
|
db,
|
||||||
|
admin_user_id=actor_id,
|
||||||
|
notification_id=notification_uuid,
|
||||||
|
responsible=str(admin.get("email") or "").strip() or "Администратор системы",
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
refreshed = get_admin_notification(db, admin_user_id=actor_id, notification_id=notification_uuid)
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"changed": int(changed),
|
||||||
|
"notification": serialize_notification(refreshed) if refreshed else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/read-all")
|
||||||
|
def read_all_notifications(
|
||||||
|
payload: NotificationsReadAll,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
|
||||||
|
):
|
||||||
|
actor_id = _actor_uuid_or_401(admin)
|
||||||
|
request_uuid = _optional_uuid_or_400(payload.request_id, "request_id")
|
||||||
|
changed = mark_admin_notifications_read(
|
||||||
|
db,
|
||||||
|
admin_user_id=actor_id,
|
||||||
|
request_id=request_uuid,
|
||||||
|
responsible=str(admin.get("email") or "").strip() or "Администратор системы",
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return {"status": "ok", "changed": int(changed)}
|
||||||
|
|
@ -32,7 +32,8 @@ def query_quotes(uq: UniversalQuery, db: Session = Depends(get_db), admin=Depend
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
def create_quote(payload: QuoteUpsert, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN"))):
|
def create_quote(payload: QuoteUpsert, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN"))):
|
||||||
q = Quote(**payload.model_dump())
|
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
|
||||||
|
q = Quote(**payload.model_dump(), responsible=responsible)
|
||||||
db.add(q); db.commit(); db.refresh(q)
|
db.add(q); db.commit(); db.refresh(q)
|
||||||
return {"id": str(q.id)}
|
return {"id": str(q.id)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,71 @@
|
||||||
from uuid import uuid4
|
from datetime import datetime, timezone
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy import update
|
||||||
|
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.core.deps import require_role
|
from app.core.deps import require_role
|
||||||
from app.schemas.universal import UniversalQuery
|
from app.schemas.universal import UniversalQuery
|
||||||
from app.schemas.admin import RequestAdminCreate, RequestAdminPatch
|
from app.schemas.admin import (
|
||||||
|
RequestAdminCreate,
|
||||||
|
RequestAdminPatch,
|
||||||
|
RequestDataRequirementCreate,
|
||||||
|
RequestDataRequirementPatch,
|
||||||
|
RequestReassign,
|
||||||
|
)
|
||||||
|
from app.models.admin_user import AdminUser
|
||||||
|
from app.models.audit_log import AuditLog
|
||||||
|
from app.models.request_data_requirement import RequestDataRequirement
|
||||||
from app.models.request import Request
|
from app.models.request import Request
|
||||||
|
from app.models.topic_data_template import TopicDataTemplate
|
||||||
|
from app.services.notifications import (
|
||||||
|
EVENT_STATUS as NOTIFICATION_EVENT_STATUS,
|
||||||
|
mark_admin_notifications_read,
|
||||||
|
notify_request_event,
|
||||||
|
)
|
||||||
|
from app.services.request_read_markers import EVENT_STATUS, clear_unread_for_lawyer, mark_unread_for_client
|
||||||
|
from app.services.request_status import actor_admin_uuid, apply_status_change_effects
|
||||||
|
from app.services.status_flow import transition_allowed_for_topic
|
||||||
|
from app.services.request_templates import validate_required_topic_fields_or_400
|
||||||
from app.services.universal_query import apply_universal_query
|
from app.services.universal_query import apply_universal_query
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _request_uuid_or_400(request_id: str) -> UUID:
|
||||||
|
try:
|
||||||
|
return UUID(str(request_id))
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Некорректный идентификатор заявки")
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_lawyer_can_manage_request_or_403(admin: dict, req: Request) -> None:
|
||||||
|
role = str(admin.get("role") or "").upper()
|
||||||
|
if role != "LAWYER":
|
||||||
|
return
|
||||||
|
actor = str(admin.get("sub") or "").strip()
|
||||||
|
assigned = str(req.assigned_lawyer_id or "").strip()
|
||||||
|
if not actor or not assigned or actor != assigned:
|
||||||
|
raise HTTPException(status_code=403, detail="Юрист может работать только со своими назначенными заявками")
|
||||||
|
|
||||||
|
|
||||||
|
def _request_data_requirement_row(row: RequestDataRequirement) -> dict:
|
||||||
|
return {
|
||||||
|
"id": str(row.id),
|
||||||
|
"request_id": str(row.request_id),
|
||||||
|
"topic_template_id": str(row.topic_template_id) if row.topic_template_id else None,
|
||||||
|
"key": row.key,
|
||||||
|
"label": row.label,
|
||||||
|
"description": row.description,
|
||||||
|
"required": bool(row.required),
|
||||||
|
"created_by_admin_id": str(row.created_by_admin_id) if row.created_by_admin_id else None,
|
||||||
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||||
|
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
@router.post("/query")
|
@router.post("/query")
|
||||||
def query_requests(uq: UniversalQuery, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN","LAWYER"))):
|
def query_requests(uq: UniversalQuery, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN","LAWYER"))):
|
||||||
q = apply_universal_query(db.query(Request), Request, uq)
|
q = apply_universal_query(db.query(Request), Request, uq)
|
||||||
|
|
@ -25,6 +80,14 @@ def query_requests(uq: UniversalQuery, db: Session = Depends(get_db), admin=Depe
|
||||||
"client_name": r.client_name,
|
"client_name": r.client_name,
|
||||||
"client_phone": r.client_phone,
|
"client_phone": r.client_phone,
|
||||||
"topic_code": r.topic_code,
|
"topic_code": r.topic_code,
|
||||||
|
"effective_rate": float(r.effective_rate) if r.effective_rate is not None else None,
|
||||||
|
"invoice_amount": float(r.invoice_amount) if r.invoice_amount is not None else None,
|
||||||
|
"paid_at": r.paid_at.isoformat() if r.paid_at else None,
|
||||||
|
"paid_by_admin_id": r.paid_by_admin_id,
|
||||||
|
"client_has_unread_updates": r.client_has_unread_updates,
|
||||||
|
"client_unread_event_type": r.client_unread_event_type,
|
||||||
|
"lawyer_has_unread_updates": r.lawyer_has_unread_updates,
|
||||||
|
"lawyer_unread_event_type": r.lawyer_unread_event_type,
|
||||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||||
"updated_at": r.updated_at.isoformat() if r.updated_at else None,
|
"updated_at": r.updated_at.isoformat() if r.updated_at else None,
|
||||||
}
|
}
|
||||||
|
|
@ -36,7 +99,11 @@ def query_requests(uq: UniversalQuery, db: Session = Depends(get_db), admin=Depe
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
def create_request(payload: RequestAdminCreate, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER"))):
|
def create_request(payload: RequestAdminCreate, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER"))):
|
||||||
|
if str(admin.get("role") or "").upper() == "LAWYER" and str(payload.assigned_lawyer_id or "").strip():
|
||||||
|
raise HTTPException(status_code=403, detail="Юрист не может назначать заявку при создании")
|
||||||
|
validate_required_topic_fields_or_400(db, payload.topic_code, payload.extra_fields)
|
||||||
track = payload.track_number or f"TRK-{uuid4().hex[:10].upper()}"
|
track = payload.track_number or f"TRK-{uuid4().hex[:10].upper()}"
|
||||||
|
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
|
||||||
row = Request(
|
row = Request(
|
||||||
track_number=track,
|
track_number=track,
|
||||||
client_name=payload.client_name,
|
client_name=payload.client_name,
|
||||||
|
|
@ -46,7 +113,12 @@ def create_request(payload: RequestAdminCreate, db: Session = Depends(get_db), a
|
||||||
description=payload.description,
|
description=payload.description,
|
||||||
extra_fields=payload.extra_fields,
|
extra_fields=payload.extra_fields,
|
||||||
assigned_lawyer_id=payload.assigned_lawyer_id,
|
assigned_lawyer_id=payload.assigned_lawyer_id,
|
||||||
|
effective_rate=payload.effective_rate,
|
||||||
|
invoice_amount=payload.invoice_amount,
|
||||||
|
paid_at=payload.paid_at,
|
||||||
|
paid_by_admin_id=payload.paid_by_admin_id,
|
||||||
total_attachments_bytes=payload.total_attachments_bytes,
|
total_attachments_bytes=payload.total_attachments_bytes,
|
||||||
|
responsible=responsible,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
db.add(row)
|
db.add(row)
|
||||||
|
|
@ -65,11 +137,43 @@ def update_request(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
admin=Depends(require_role("ADMIN", "LAWYER")),
|
admin=Depends(require_role("ADMIN", "LAWYER")),
|
||||||
):
|
):
|
||||||
row = db.query(Request).filter(Request.id == request_id).first()
|
request_uuid = _request_uuid_or_400(request_id)
|
||||||
|
row = db.get(Request, request_uuid)
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||||
for key, value in payload.model_dump(exclude_unset=True).items():
|
changes = payload.model_dump(exclude_unset=True)
|
||||||
|
if str(admin.get("role") or "").upper() == "LAWYER" and "assigned_lawyer_id" in changes:
|
||||||
|
raise HTTPException(status_code=403, detail='Назначение доступно только через действие "Взять в работу"')
|
||||||
|
old_status = str(row.status_code or "")
|
||||||
|
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
|
||||||
|
for key, value in changes.items():
|
||||||
setattr(row, key, value)
|
setattr(row, key, value)
|
||||||
|
if "status_code" in changes and str(changes.get("status_code") or "") != old_status:
|
||||||
|
if not transition_allowed_for_topic(
|
||||||
|
db,
|
||||||
|
str(row.topic_code or "").strip() or None,
|
||||||
|
old_status,
|
||||||
|
str(changes.get("status_code") or ""),
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=400, detail="Переход статуса не разрешен для выбранной темы")
|
||||||
|
mark_unread_for_client(row, EVENT_STATUS)
|
||||||
|
apply_status_change_effects(
|
||||||
|
db,
|
||||||
|
row,
|
||||||
|
from_status=old_status,
|
||||||
|
to_status=str(changes.get("status_code") or ""),
|
||||||
|
admin=admin,
|
||||||
|
responsible=responsible,
|
||||||
|
)
|
||||||
|
notify_request_event(
|
||||||
|
db,
|
||||||
|
request=row,
|
||||||
|
event_type=NOTIFICATION_EVENT_STATUS,
|
||||||
|
actor_role=str(admin.get("role") or "").upper() or "ADMIN",
|
||||||
|
actor_admin_user_id=admin.get("sub"),
|
||||||
|
body=f"{old_status} -> {str(changes.get('status_code') or '').strip()}",
|
||||||
|
responsible=responsible,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
db.add(row)
|
db.add(row)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
@ -82,7 +186,8 @@ def update_request(
|
||||||
|
|
||||||
@router.delete("/{request_id}")
|
@router.delete("/{request_id}")
|
||||||
def delete_request(request_id: str, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER"))):
|
def delete_request(request_id: str, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER"))):
|
||||||
row = db.query(Request).filter(Request.id == request_id).first()
|
request_uuid = _request_uuid_or_400(request_id)
|
||||||
|
row = db.get(Request, request_uuid)
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||||
db.delete(row)
|
db.delete(row)
|
||||||
|
|
@ -91,9 +196,25 @@ def delete_request(request_id: str, db: Session = Depends(get_db), admin=Depends
|
||||||
|
|
||||||
@router.get("/{request_id}")
|
@router.get("/{request_id}")
|
||||||
def get_request(request_id: str, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN","LAWYER"))):
|
def get_request(request_id: str, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN","LAWYER"))):
|
||||||
req = db.query(Request).filter(Request.id == request_id).first()
|
request_uuid = _request_uuid_or_400(request_id)
|
||||||
|
req = db.get(Request, request_uuid)
|
||||||
if not req:
|
if not req:
|
||||||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||||
|
changed = False
|
||||||
|
if str(admin.get("role") or "").upper() == "LAWYER" and clear_unread_for_lawyer(req):
|
||||||
|
changed = True
|
||||||
|
db.add(req)
|
||||||
|
read_count = mark_admin_notifications_read(
|
||||||
|
db,
|
||||||
|
admin_user_id=admin.get("sub"),
|
||||||
|
request_id=req.id,
|
||||||
|
responsible=str(admin.get("email") or "").strip() or "Администратор системы",
|
||||||
|
)
|
||||||
|
if read_count:
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
db.commit()
|
||||||
|
db.refresh(req)
|
||||||
return {
|
return {
|
||||||
"id": str(req.id),
|
"id": str(req.id),
|
||||||
"track_number": req.track_number,
|
"track_number": req.track_number,
|
||||||
|
|
@ -104,7 +225,384 @@ def get_request(request_id: str, db: Session = Depends(get_db), admin=Depends(re
|
||||||
"description": req.description,
|
"description": req.description,
|
||||||
"extra_fields": req.extra_fields,
|
"extra_fields": req.extra_fields,
|
||||||
"assigned_lawyer_id": req.assigned_lawyer_id,
|
"assigned_lawyer_id": req.assigned_lawyer_id,
|
||||||
|
"effective_rate": float(req.effective_rate) if req.effective_rate is not None else None,
|
||||||
|
"invoice_amount": float(req.invoice_amount) if req.invoice_amount is not None else None,
|
||||||
|
"paid_at": req.paid_at.isoformat() if req.paid_at else None,
|
||||||
|
"paid_by_admin_id": req.paid_by_admin_id,
|
||||||
"total_attachments_bytes": req.total_attachments_bytes,
|
"total_attachments_bytes": req.total_attachments_bytes,
|
||||||
|
"client_has_unread_updates": req.client_has_unread_updates,
|
||||||
|
"client_unread_event_type": req.client_unread_event_type,
|
||||||
|
"lawyer_has_unread_updates": req.lawyer_has_unread_updates,
|
||||||
|
"lawyer_unread_event_type": req.lawyer_unread_event_type,
|
||||||
"created_at": req.created_at.isoformat() if req.created_at else None,
|
"created_at": req.created_at.isoformat() if req.created_at else None,
|
||||||
"updated_at": req.updated_at.isoformat() if req.updated_at else None,
|
"updated_at": req.updated_at.isoformat() if req.updated_at else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{request_id}/claim")
|
||||||
|
def claim_request(request_id: str, db: Session = Depends(get_db), admin=Depends(require_role("LAWYER"))):
|
||||||
|
request_uuid = _request_uuid_or_400(request_id)
|
||||||
|
|
||||||
|
lawyer_sub = str(admin.get("sub") or "").strip()
|
||||||
|
if not lawyer_sub:
|
||||||
|
raise HTTPException(status_code=401, detail="Некорректный токен")
|
||||||
|
try:
|
||||||
|
lawyer_uuid = UUID(lawyer_sub)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=401, detail="Некорректный токен")
|
||||||
|
|
||||||
|
lawyer = db.get(AdminUser, lawyer_uuid)
|
||||||
|
if not lawyer or str(lawyer.role or "").upper() != "LAWYER" or not bool(lawyer.is_active):
|
||||||
|
raise HTTPException(status_code=403, detail="Доступно только активному юристу")
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
update(Request)
|
||||||
|
.where(Request.id == request_uuid, Request.assigned_lawyer_id.is_(None))
|
||||||
|
.values(
|
||||||
|
assigned_lawyer_id=str(lawyer_uuid),
|
||||||
|
updated_at=now,
|
||||||
|
responsible=responsible,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
updated_rows = db.execute(stmt).rowcount or 0
|
||||||
|
if updated_rows == 0:
|
||||||
|
existing = db.get(Request, request_uuid)
|
||||||
|
if existing is None:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=409, detail="Заявка уже назначена")
|
||||||
|
|
||||||
|
db.add(
|
||||||
|
AuditLog(
|
||||||
|
actor_admin_id=lawyer_uuid,
|
||||||
|
entity="requests",
|
||||||
|
entity_id=str(request_uuid),
|
||||||
|
action="MANUAL_CLAIM",
|
||||||
|
diff={"assigned_lawyer_id": str(lawyer_uuid)},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
row = db.get(Request, request_uuid)
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "claimed",
|
||||||
|
"id": str(row.id),
|
||||||
|
"track_number": row.track_number,
|
||||||
|
"assigned_lawyer_id": row.assigned_lawyer_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{request_id}/reassign")
|
||||||
|
def reassign_request(
|
||||||
|
request_id: str,
|
||||||
|
payload: RequestReassign,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
admin=Depends(require_role("ADMIN")),
|
||||||
|
):
|
||||||
|
request_uuid = _request_uuid_or_400(request_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
lawyer_uuid = UUID(str(payload.lawyer_id))
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Некорректный идентификатор юриста")
|
||||||
|
|
||||||
|
target_lawyer = db.get(AdminUser, lawyer_uuid)
|
||||||
|
if not target_lawyer or str(target_lawyer.role or "").upper() != "LAWYER" or not bool(target_lawyer.is_active):
|
||||||
|
raise HTTPException(status_code=400, detail="Можно переназначить только на активного юриста")
|
||||||
|
|
||||||
|
req = db.get(Request, request_uuid)
|
||||||
|
if req is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||||
|
if req.assigned_lawyer_id is None:
|
||||||
|
raise HTTPException(status_code=400, detail="Заявка не назначена")
|
||||||
|
if str(req.assigned_lawyer_id) == str(lawyer_uuid):
|
||||||
|
raise HTTPException(status_code=400, detail="Заявка уже назначена на выбранного юриста")
|
||||||
|
|
||||||
|
old_assigned = str(req.assigned_lawyer_id)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
|
||||||
|
admin_actor_id = None
|
||||||
|
try:
|
||||||
|
admin_actor_id = UUID(str(admin.get("sub") or ""))
|
||||||
|
except ValueError:
|
||||||
|
admin_actor_id = None
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
update(Request)
|
||||||
|
.where(Request.id == request_uuid, Request.assigned_lawyer_id == old_assigned)
|
||||||
|
.values(
|
||||||
|
assigned_lawyer_id=str(lawyer_uuid),
|
||||||
|
updated_at=now,
|
||||||
|
responsible=responsible,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
updated_rows = db.execute(stmt).rowcount or 0
|
||||||
|
if updated_rows == 0:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=409, detail="Заявка уже была переназначена")
|
||||||
|
|
||||||
|
db.add(
|
||||||
|
AuditLog(
|
||||||
|
actor_admin_id=admin_actor_id,
|
||||||
|
entity="requests",
|
||||||
|
entity_id=str(request_uuid),
|
||||||
|
action="MANUAL_REASSIGN",
|
||||||
|
diff={"from_lawyer_id": old_assigned, "to_lawyer_id": str(lawyer_uuid)},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
row = db.get(Request, request_uuid)
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "reassigned",
|
||||||
|
"id": str(row.id),
|
||||||
|
"track_number": row.track_number,
|
||||||
|
"from_lawyer_id": old_assigned,
|
||||||
|
"assigned_lawyer_id": row.assigned_lawyer_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{request_id}/data-template")
|
||||||
|
def get_request_data_template(
|
||||||
|
request_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
admin=Depends(require_role("ADMIN", "LAWYER")),
|
||||||
|
):
|
||||||
|
request_uuid = _request_uuid_or_400(request_id)
|
||||||
|
req = db.get(Request, request_uuid)
|
||||||
|
if req is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||||
|
_ensure_lawyer_can_manage_request_or_403(admin, req)
|
||||||
|
|
||||||
|
topic_items = (
|
||||||
|
db.query(TopicDataTemplate)
|
||||||
|
.filter(
|
||||||
|
TopicDataTemplate.topic_code == str(req.topic_code or ""),
|
||||||
|
TopicDataTemplate.enabled.is_(True),
|
||||||
|
)
|
||||||
|
.order_by(TopicDataTemplate.sort_order.asc(), TopicDataTemplate.key.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
request_items = (
|
||||||
|
db.query(RequestDataRequirement)
|
||||||
|
.filter(RequestDataRequirement.request_id == req.id)
|
||||||
|
.order_by(RequestDataRequirement.created_at.asc(), RequestDataRequirement.key.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"request_id": str(req.id),
|
||||||
|
"topic_code": req.topic_code,
|
||||||
|
"topic_items": [
|
||||||
|
{
|
||||||
|
"id": str(row.id),
|
||||||
|
"key": row.key,
|
||||||
|
"label": row.label,
|
||||||
|
"description": row.description,
|
||||||
|
"required": bool(row.required),
|
||||||
|
"sort_order": row.sort_order,
|
||||||
|
}
|
||||||
|
for row in topic_items
|
||||||
|
],
|
||||||
|
"request_items": [_request_data_requirement_row(row) for row in request_items],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{request_id}/data-template/sync")
|
||||||
|
def sync_request_data_template_from_topic(
|
||||||
|
request_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
admin=Depends(require_role("ADMIN", "LAWYER")),
|
||||||
|
):
|
||||||
|
request_uuid = _request_uuid_or_400(request_id)
|
||||||
|
req = db.get(Request, request_uuid)
|
||||||
|
if req is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||||
|
_ensure_lawyer_can_manage_request_or_403(admin, req)
|
||||||
|
topic_code = str(req.topic_code or "").strip()
|
||||||
|
if not topic_code:
|
||||||
|
return {"status": "ok", "created": 0, "request_id": str(req.id)}
|
||||||
|
|
||||||
|
topic_items = (
|
||||||
|
db.query(TopicDataTemplate)
|
||||||
|
.filter(
|
||||||
|
TopicDataTemplate.topic_code == topic_code,
|
||||||
|
TopicDataTemplate.enabled.is_(True),
|
||||||
|
)
|
||||||
|
.order_by(TopicDataTemplate.sort_order.asc(), TopicDataTemplate.key.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
existing_keys = {
|
||||||
|
str(key).strip()
|
||||||
|
for (key,) in db.query(RequestDataRequirement.key).filter(RequestDataRequirement.request_id == req.id).all()
|
||||||
|
if key
|
||||||
|
}
|
||||||
|
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
|
||||||
|
actor_id = actor_admin_uuid(admin)
|
||||||
|
|
||||||
|
created = 0
|
||||||
|
for template in topic_items:
|
||||||
|
key = str(template.key or "").strip()
|
||||||
|
if not key or key in existing_keys:
|
||||||
|
continue
|
||||||
|
db.add(
|
||||||
|
RequestDataRequirement(
|
||||||
|
request_id=req.id,
|
||||||
|
topic_template_id=template.id,
|
||||||
|
key=key,
|
||||||
|
label=template.label,
|
||||||
|
description=template.description,
|
||||||
|
required=bool(template.required),
|
||||||
|
created_by_admin_id=actor_id,
|
||||||
|
responsible=responsible,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
existing_keys.add(key)
|
||||||
|
created += 1
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"status": "ok", "created": created, "request_id": str(req.id)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{request_id}/data-template/items", status_code=201)
|
||||||
|
def create_request_data_requirement(
|
||||||
|
request_id: str,
|
||||||
|
payload: RequestDataRequirementCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
admin=Depends(require_role("ADMIN", "LAWYER")),
|
||||||
|
):
|
||||||
|
request_uuid = _request_uuid_or_400(request_id)
|
||||||
|
req = db.get(Request, request_uuid)
|
||||||
|
if req is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||||
|
_ensure_lawyer_can_manage_request_or_403(admin, req)
|
||||||
|
|
||||||
|
key = str(payload.key or "").strip()
|
||||||
|
label = str(payload.label or "").strip()
|
||||||
|
if not key:
|
||||||
|
raise HTTPException(status_code=400, detail='Поле "key" обязательно')
|
||||||
|
if not label:
|
||||||
|
raise HTTPException(status_code=400, detail='Поле "label" обязательно')
|
||||||
|
|
||||||
|
exists = (
|
||||||
|
db.query(RequestDataRequirement.id)
|
||||||
|
.filter(RequestDataRequirement.request_id == req.id, RequestDataRequirement.key == key)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if exists is not None:
|
||||||
|
raise HTTPException(status_code=400, detail="Элемент с таким key уже существует в шаблоне заявки")
|
||||||
|
|
||||||
|
row = RequestDataRequirement(
|
||||||
|
request_id=req.id,
|
||||||
|
topic_template_id=None,
|
||||||
|
key=key,
|
||||||
|
label=label,
|
||||||
|
description=payload.description,
|
||||||
|
required=bool(payload.required),
|
||||||
|
created_by_admin_id=actor_admin_uuid(admin),
|
||||||
|
responsible=str(admin.get("email") or "").strip() or "Администратор системы",
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(row)
|
||||||
|
return _request_data_requirement_row(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{request_id}/data-template/items/{item_id}")
|
||||||
|
def update_request_data_requirement(
|
||||||
|
request_id: str,
|
||||||
|
item_id: str,
|
||||||
|
payload: RequestDataRequirementPatch,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
admin=Depends(require_role("ADMIN", "LAWYER")),
|
||||||
|
):
|
||||||
|
request_uuid = _request_uuid_or_400(request_id)
|
||||||
|
req = db.get(Request, request_uuid)
|
||||||
|
if req is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||||
|
_ensure_lawyer_can_manage_request_or_403(admin, req)
|
||||||
|
|
||||||
|
item_uuid = _request_uuid_or_400(item_id)
|
||||||
|
row = db.get(RequestDataRequirement, item_uuid)
|
||||||
|
if row is None or row.request_id != req.id:
|
||||||
|
raise HTTPException(status_code=404, detail="Элемент шаблона заявки не найден")
|
||||||
|
|
||||||
|
changes = payload.model_dump(exclude_unset=True)
|
||||||
|
if not changes:
|
||||||
|
raise HTTPException(status_code=400, detail="Нет полей для обновления")
|
||||||
|
if "key" in changes:
|
||||||
|
key = str(changes.get("key") or "").strip()
|
||||||
|
if not key:
|
||||||
|
raise HTTPException(status_code=400, detail='Поле "key" не может быть пустым')
|
||||||
|
duplicate = (
|
||||||
|
db.query(RequestDataRequirement.id)
|
||||||
|
.filter(
|
||||||
|
RequestDataRequirement.request_id == req.id,
|
||||||
|
RequestDataRequirement.key == key,
|
||||||
|
RequestDataRequirement.id != row.id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if duplicate is not None:
|
||||||
|
raise HTTPException(status_code=400, detail="Элемент с таким key уже существует в шаблоне заявки")
|
||||||
|
row.key = key
|
||||||
|
if "label" in changes:
|
||||||
|
label = str(changes.get("label") or "").strip()
|
||||||
|
if not label:
|
||||||
|
raise HTTPException(status_code=400, detail='Поле "label" не может быть пустым')
|
||||||
|
row.label = label
|
||||||
|
if "description" in changes:
|
||||||
|
row.description = changes.get("description")
|
||||||
|
if "required" in changes:
|
||||||
|
row.required = bool(changes.get("required"))
|
||||||
|
row.responsible = str(admin.get("email") or "").strip() or "Администратор системы"
|
||||||
|
|
||||||
|
db.add(row)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(row)
|
||||||
|
return _request_data_requirement_row(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{request_id}/data-template/items/{item_id}")
|
||||||
|
def delete_request_data_requirement(
|
||||||
|
request_id: str,
|
||||||
|
item_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
admin=Depends(require_role("ADMIN", "LAWYER")),
|
||||||
|
):
|
||||||
|
request_uuid = _request_uuid_or_400(request_id)
|
||||||
|
req = db.get(Request, request_uuid)
|
||||||
|
if req is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||||
|
_ensure_lawyer_can_manage_request_or_403(admin, req)
|
||||||
|
|
||||||
|
item_uuid = _request_uuid_or_400(item_id)
|
||||||
|
row = db.get(RequestDataRequirement, item_uuid)
|
||||||
|
if row is None or row.request_id != req.id:
|
||||||
|
raise HTTPException(status_code=404, detail="Элемент шаблона заявки не найден")
|
||||||
|
db.delete(row)
|
||||||
|
db.commit()
|
||||||
|
return {"status": "удалено", "id": str(row.id)}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from app.api.admin import auth, requests, quotes, meta, config, uploads, metrics
|
from app.api.admin import auth, requests, quotes, meta, config, uploads, metrics, crud, notifications
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
router.include_router(auth.router, prefix="/auth", tags=["AdminAuth"])
|
router.include_router(auth.router, prefix="/auth", tags=["AdminAuth"])
|
||||||
|
|
@ -9,3 +9,5 @@ router.include_router(meta.router, prefix="/meta", tags=["AdminMeta"])
|
||||||
router.include_router(config.router, prefix="/config", tags=["AdminConfig"])
|
router.include_router(config.router, prefix="/config", tags=["AdminConfig"])
|
||||||
router.include_router(uploads.router, prefix="/uploads", tags=["AdminFiles"])
|
router.include_router(uploads.router, prefix="/uploads", tags=["AdminFiles"])
|
||||||
router.include_router(metrics.router, prefix="/metrics", tags=["AdminMetrics"])
|
router.include_router(metrics.router, prefix="/metrics", tags=["AdminMetrics"])
|
||||||
|
router.include_router(notifications.router, prefix="/notifications", tags=["AdminNotifications"])
|
||||||
|
router.include_router(crud.router, prefix="/crud", tags=["AdminCrud"])
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,245 @@
|
||||||
from fastapi import APIRouter, Depends
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Tuple
|
||||||
|
|
||||||
|
from botocore.exceptions import ClientError
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
from app.core.deps import require_role
|
from app.core.deps import require_role
|
||||||
|
from app.core.security import decode_jwt
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models.admin_user import AdminUser
|
||||||
|
from app.models.attachment import Attachment
|
||||||
|
from app.models.message import Message
|
||||||
|
from app.models.request import Request
|
||||||
|
from app.schemas.uploads import UploadCompletePayload, UploadCompleteResponse, UploadInitPayload, UploadInitResponse, UploadScope
|
||||||
|
from app.services.notifications import EVENT_ATTACHMENT as NOTIFICATION_EVENT_ATTACHMENT, notify_request_event
|
||||||
|
from app.services.request_read_markers import EVENT_ATTACHMENT, mark_unread_for_client
|
||||||
|
from app.services.s3_storage import build_object_key, get_s3_storage
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@router.post("/init")
|
|
||||||
def upload_init(admin=Depends(require_role("ADMIN","LAWYER"))):
|
|
||||||
return {"method": "PRESIGNED_PUT", "presigned_url": "https://s3.local/..."}
|
|
||||||
|
|
||||||
@router.post("/complete")
|
def _max_file_bytes() -> int:
|
||||||
def upload_complete(admin=Depends(require_role("ADMIN","LAWYER"))):
|
return int(settings.MAX_FILE_MB) * 1024 * 1024
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
|
def _max_case_bytes() -> int:
|
||||||
|
return int(settings.MAX_CASE_MB) * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_size_or_400(size_bytes: int) -> None:
|
||||||
|
if int(size_bytes or 0) <= 0:
|
||||||
|
raise HTTPException(status_code=400, detail="Некорректный размер файла")
|
||||||
|
if int(size_bytes) > _max_file_bytes():
|
||||||
|
raise HTTPException(status_code=400, detail=f"Превышен лимит файла ({settings.MAX_FILE_MB} МБ)")
|
||||||
|
|
||||||
|
|
||||||
|
def _uuid_or_400(raw: str | None, field_name: str) -> uuid.UUID:
|
||||||
|
if not raw:
|
||||||
|
raise HTTPException(status_code=400, detail=f'Поле "{field_name}" обязательно')
|
||||||
|
try:
|
||||||
|
return uuid.UUID(str(raw))
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail=f'Некорректный "{field_name}"')
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_case_capacity_or_400(request: Request, add_bytes: int) -> None:
|
||||||
|
current = int(request.total_attachments_bytes or 0)
|
||||||
|
if current + int(add_bytes) > _max_case_bytes():
|
||||||
|
raise HTTPException(status_code=400, detail=f"Превышен лимит вложений заявки ({settings.MAX_CASE_MB} МБ)")
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_object_key_prefix_or_400(key: str, prefix: str) -> None:
|
||||||
|
if not str(key or "").startswith(prefix):
|
||||||
|
raise HTTPException(status_code=400, detail="Некорректный ключ объекта для выбранной сущности")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_scoped_object_key(key: str) -> Tuple[str, str]:
|
||||||
|
raw = str(key or "").strip()
|
||||||
|
if not raw or "/" not in raw:
|
||||||
|
return "", ""
|
||||||
|
first = raw.split("/", 1)[0].strip().lower()
|
||||||
|
parts = raw.split("/")
|
||||||
|
if len(parts) < 3:
|
||||||
|
return first, ""
|
||||||
|
return first, parts[1].strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _uuid_or_none(raw: str) -> uuid.UUID | None:
|
||||||
|
try:
|
||||||
|
return uuid.UUID(str(raw))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/init", response_model=UploadInitResponse)
|
||||||
|
def upload_init(
|
||||||
|
payload: UploadInitPayload,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
|
||||||
|
):
|
||||||
|
_validate_size_or_400(payload.size_bytes)
|
||||||
|
storage = get_s3_storage()
|
||||||
|
role = str(admin.get("role") or "")
|
||||||
|
actor_id = str(admin.get("sub") or "")
|
||||||
|
|
||||||
|
if payload.scope == UploadScope.REQUEST_ATTACHMENT:
|
||||||
|
request_uuid = _uuid_or_400(payload.request_id, "request_id")
|
||||||
|
request = db.get(Request, request_uuid)
|
||||||
|
if request is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||||
|
_ensure_case_capacity_or_400(request, payload.size_bytes)
|
||||||
|
key = build_object_key(f"requests/{request.id}", payload.file_name)
|
||||||
|
return UploadInitResponse(key=key, presigned_url=storage.create_presigned_put_url(key, payload.mime_type))
|
||||||
|
|
||||||
|
if payload.scope == UploadScope.USER_AVATAR:
|
||||||
|
target_user_id = str(payload.user_id or actor_id)
|
||||||
|
target_uuid = _uuid_or_400(target_user_id, "user_id")
|
||||||
|
if role != "ADMIN" and str(target_uuid) != actor_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Недостаточно прав для загрузки аватара")
|
||||||
|
user = db.get(AdminUser, target_uuid)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||||||
|
key = build_object_key(f"avatars/{user.id}", payload.file_name)
|
||||||
|
return UploadInitResponse(key=key, presigned_url=storage.create_presigned_put_url(key, payload.mime_type))
|
||||||
|
|
||||||
|
raise HTTPException(status_code=400, detail="Неподдерживаемый scope")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/complete", response_model=UploadCompleteResponse)
|
||||||
|
def upload_complete(
|
||||||
|
payload: UploadCompletePayload,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
|
||||||
|
):
|
||||||
|
_validate_size_or_400(payload.size_bytes)
|
||||||
|
storage = get_s3_storage()
|
||||||
|
role = str(admin.get("role") or "")
|
||||||
|
actor_id = str(admin.get("sub") or "")
|
||||||
|
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
|
||||||
|
try:
|
||||||
|
head = storage.head_object(payload.key)
|
||||||
|
except ClientError:
|
||||||
|
raise HTTPException(status_code=400, detail="Файл не найден в хранилище")
|
||||||
|
|
||||||
|
actual_size = int(head.get("ContentLength") or payload.size_bytes)
|
||||||
|
if actual_size <= 0:
|
||||||
|
raise HTTPException(status_code=400, detail="Некорректный размер файла")
|
||||||
|
if actual_size > _max_file_bytes():
|
||||||
|
raise HTTPException(status_code=400, detail=f"Превышен лимит файла ({settings.MAX_FILE_MB} МБ)")
|
||||||
|
|
||||||
|
if payload.scope == UploadScope.REQUEST_ATTACHMENT:
|
||||||
|
request_uuid = _uuid_or_400(payload.request_id, "request_id")
|
||||||
|
request = db.get(Request, request_uuid)
|
||||||
|
if request is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||||
|
_ensure_object_key_prefix_or_400(payload.key, f"requests/{request.id}/")
|
||||||
|
_ensure_case_capacity_or_400(request, actual_size)
|
||||||
|
|
||||||
|
message_uuid = None
|
||||||
|
if payload.message_id:
|
||||||
|
message_uuid = _uuid_or_400(payload.message_id, "message_id")
|
||||||
|
message = db.get(Message, message_uuid)
|
||||||
|
if message is None or message.request_id != request.id:
|
||||||
|
raise HTTPException(status_code=400, detail="Сообщение не найдено для указанной заявки")
|
||||||
|
if bool(message.immutable):
|
||||||
|
raise HTTPException(status_code=400, detail="Нельзя прикрепить файл к зафиксированному сообщению")
|
||||||
|
|
||||||
|
row = Attachment(
|
||||||
|
request_id=request.id,
|
||||||
|
message_id=message_uuid,
|
||||||
|
file_name=payload.file_name,
|
||||||
|
mime_type=payload.mime_type,
|
||||||
|
size_bytes=actual_size,
|
||||||
|
s3_key=payload.key,
|
||||||
|
responsible=responsible,
|
||||||
|
)
|
||||||
|
mark_unread_for_client(request, EVENT_ATTACHMENT)
|
||||||
|
notify_request_event(
|
||||||
|
db,
|
||||||
|
request=request,
|
||||||
|
event_type=NOTIFICATION_EVENT_ATTACHMENT,
|
||||||
|
actor_role=str(admin.get("role") or "").upper() or "ADMIN",
|
||||||
|
actor_admin_user_id=admin.get("sub"),
|
||||||
|
body=f'Файл: {payload.file_name}',
|
||||||
|
responsible=responsible,
|
||||||
|
)
|
||||||
|
request.total_attachments_bytes = int(request.total_attachments_bytes or 0) + actual_size
|
||||||
|
request.responsible = responsible
|
||||||
|
db.add(row)
|
||||||
|
db.add(request)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(row)
|
||||||
|
return UploadCompleteResponse(status="ok", attachment_id=str(row.id))
|
||||||
|
|
||||||
|
if payload.scope == UploadScope.USER_AVATAR:
|
||||||
|
target_user_id = str(payload.user_id or actor_id)
|
||||||
|
target_uuid = _uuid_or_400(target_user_id, "user_id")
|
||||||
|
if role != "ADMIN" and str(target_uuid) != actor_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Недостаточно прав для загрузки аватара")
|
||||||
|
user = db.get(AdminUser, target_uuid)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||||||
|
_ensure_object_key_prefix_or_400(payload.key, f"avatars/{user.id}/")
|
||||||
|
user.avatar_url = f"s3://{payload.key}"
|
||||||
|
user.responsible = responsible
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
return UploadCompleteResponse(status="ok", avatar_url=user.avatar_url)
|
||||||
|
|
||||||
|
raise HTTPException(status_code=400, detail="Неподдерживаемый scope")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/object/{object_key:path}")
|
||||||
|
def get_object_proxy(object_key: str, token: str = Query(...), db: Session = Depends(get_db)):
|
||||||
|
try:
|
||||||
|
claims = decode_jwt(token, settings.ADMIN_JWT_SECRET)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=401, detail="Некорректный токен")
|
||||||
|
role = str(claims.get("role") or "").upper()
|
||||||
|
if role not in {"ADMIN", "LAWYER"}:
|
||||||
|
raise HTTPException(status_code=403, detail="Недостаточно прав")
|
||||||
|
|
||||||
|
key = str(object_key or "").strip()
|
||||||
|
if not key:
|
||||||
|
raise HTTPException(status_code=400, detail="Некорректный ключ объекта")
|
||||||
|
|
||||||
|
scope, scoped_id_raw = _parse_scoped_object_key(key)
|
||||||
|
if role == "LAWYER":
|
||||||
|
actor_id = _uuid_or_none(claims.get("sub"))
|
||||||
|
if actor_id is None:
|
||||||
|
raise HTTPException(status_code=401, detail="Некорректный токен")
|
||||||
|
scoped_uuid = _uuid_or_none(scoped_id_raw)
|
||||||
|
if scope == "avatars":
|
||||||
|
if scoped_uuid is None or scoped_uuid != actor_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Недостаточно прав")
|
||||||
|
elif scope == "requests":
|
||||||
|
if scoped_uuid is None:
|
||||||
|
raise HTTPException(status_code=403, detail="Недостаточно прав")
|
||||||
|
# LAWYER can download files from own or unassigned requests only.
|
||||||
|
request = db.get(Request, scoped_uuid)
|
||||||
|
if request is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||||
|
assigned = str(request.assigned_lawyer_id or "").strip()
|
||||||
|
if assigned and assigned != str(actor_id):
|
||||||
|
raise HTTPException(status_code=403, detail="Недостаточно прав")
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=403, detail="Недостаточно прав")
|
||||||
|
|
||||||
|
try:
|
||||||
|
obj = get_s3_storage().get_object(key)
|
||||||
|
except ClientError:
|
||||||
|
raise HTTPException(status_code=404, detail="Файл не найден")
|
||||||
|
|
||||||
|
body = obj["Body"]
|
||||||
|
content_length = obj.get("ContentLength")
|
||||||
|
media_type = obj.get("ContentType") or "application/octet-stream"
|
||||||
|
headers = {}
|
||||||
|
if content_length is not None:
|
||||||
|
headers["Content-Length"] = str(content_length)
|
||||||
|
return StreamingResponse(body.iter_chunks(chunk_size=64 * 1024), media_type=media_type, headers=headers)
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,66 @@
|
||||||
from fastapi import APIRouter, Response
|
from __future__ import annotations
|
||||||
from datetime import timedelta
|
|
||||||
from app.schemas.public import OtpSend, OtpVerify
|
import secrets
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.security import create_jwt
|
from app.core.security import create_jwt, hash_password, verify_password
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models.otp_session import OtpSession
|
||||||
|
from app.models.request import Request
|
||||||
|
from app.schemas.public import OtpSend, OtpVerify
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@router.post("/send")
|
OTP_TTL_MINUTES = 10
|
||||||
def send_otp(payload: OtpSend):
|
OTP_MAX_ATTEMPTS = 5
|
||||||
return {"status": "sent"}
|
OTP_CREATE_PURPOSE = "CREATE_REQUEST"
|
||||||
|
OTP_VIEW_PURPOSE = "VIEW_REQUEST"
|
||||||
|
ALLOWED_PURPOSES = {OTP_CREATE_PURPOSE, OTP_VIEW_PURPOSE}
|
||||||
|
|
||||||
@router.post("/verify")
|
|
||||||
def verify_otp(payload: OtpVerify, response: Response):
|
def _now_utc() -> datetime:
|
||||||
token = create_jwt({"sub": payload.track_number or "unknown", "purpose": payload.purpose},
|
return datetime.now(timezone.utc)
|
||||||
settings.PUBLIC_JWT_SECRET, timedelta(days=settings.PUBLIC_JWT_TTL_DAYS))
|
|
||||||
|
|
||||||
|
def _as_utc(dt: datetime | None) -> datetime:
|
||||||
|
if dt is None:
|
||||||
|
return _now_utc()
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
return dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_purpose(raw: str | None) -> str:
|
||||||
|
return str(raw or "").strip().upper()
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_phone(raw: str | None) -> str:
|
||||||
|
phone = str(raw or "").strip()
|
||||||
|
if not phone:
|
||||||
|
return ""
|
||||||
|
allowed = {"+", "(", ")", "-", " "}
|
||||||
|
digits = [ch for ch in phone if ch.isdigit() or ch in allowed]
|
||||||
|
return "".join(digits).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_track(raw: str | None) -> str:
|
||||||
|
return str(raw or "").strip().upper()
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_code() -> str:
|
||||||
|
return f"{secrets.randbelow(1_000_000):06d}"
|
||||||
|
|
||||||
|
|
||||||
|
def _set_public_cookie(response: Response, *, subject: str, purpose: str) -> None:
|
||||||
|
token = create_jwt(
|
||||||
|
{"sub": subject, "purpose": purpose},
|
||||||
|
settings.PUBLIC_JWT_SECRET,
|
||||||
|
timedelta(days=settings.PUBLIC_JWT_TTL_DAYS),
|
||||||
|
)
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
key=settings.PUBLIC_COOKIE_NAME,
|
key=settings.PUBLIC_COOKIE_NAME,
|
||||||
value=token,
|
value=token,
|
||||||
|
|
@ -22,4 +69,125 @@ def verify_otp(payload: OtpVerify, response: Response):
|
||||||
samesite="lax",
|
samesite="lax",
|
||||||
max_age=settings.PUBLIC_JWT_TTL_DAYS * 24 * 3600,
|
max_age=settings.PUBLIC_JWT_TTL_DAYS * 24 * 3600,
|
||||||
)
|
)
|
||||||
return {"status": "verified"}
|
|
||||||
|
|
||||||
|
def _mock_sms_send(phone: str, code: str, purpose: str, track_number: str | None = None) -> dict:
|
||||||
|
# Dev-only behavior: emit OTP in console instead of sending real SMS.
|
||||||
|
print(f"[OTP MOCK] purpose={purpose} phone={phone} track={track_number or '-'} code={code}")
|
||||||
|
return {
|
||||||
|
"provider": "mock_sms",
|
||||||
|
"status": "accepted",
|
||||||
|
"message": "SMS provider response mocked",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/send")
|
||||||
|
def send_otp(payload: OtpSend, db: Session = Depends(get_db)):
|
||||||
|
purpose = _normalize_purpose(payload.purpose)
|
||||||
|
if purpose not in ALLOWED_PURPOSES:
|
||||||
|
raise HTTPException(status_code=400, detail="Некорректная цель OTP")
|
||||||
|
|
||||||
|
track_number: str | None = None
|
||||||
|
phone = ""
|
||||||
|
if purpose == OTP_CREATE_PURPOSE:
|
||||||
|
phone = _normalize_phone(payload.client_phone)
|
||||||
|
if not phone:
|
||||||
|
raise HTTPException(status_code=400, detail='Поле "client_phone" обязательно для CREATE_REQUEST')
|
||||||
|
else:
|
||||||
|
track_number = _normalize_track(payload.track_number)
|
||||||
|
if not track_number:
|
||||||
|
raise HTTPException(status_code=400, detail='Поле "track_number" обязательно для VIEW_REQUEST')
|
||||||
|
request = db.query(Request).filter(Request.track_number == track_number).first()
|
||||||
|
if request is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||||
|
phone = _normalize_phone(request.client_phone)
|
||||||
|
if not phone:
|
||||||
|
raise HTTPException(status_code=400, detail="У заявки отсутствует номер телефона")
|
||||||
|
|
||||||
|
code = _generate_code()
|
||||||
|
now = _now_utc()
|
||||||
|
expires_at = now + timedelta(minutes=OTP_TTL_MINUTES)
|
||||||
|
|
||||||
|
existing_query = db.query(OtpSession).filter(
|
||||||
|
OtpSession.purpose == purpose,
|
||||||
|
OtpSession.phone == phone,
|
||||||
|
OtpSession.track_number == track_number,
|
||||||
|
)
|
||||||
|
existing_query.delete(synchronize_session=False)
|
||||||
|
|
||||||
|
row = OtpSession(
|
||||||
|
purpose=purpose,
|
||||||
|
track_number=track_number,
|
||||||
|
phone=phone,
|
||||||
|
code_hash=hash_password(code),
|
||||||
|
attempts=0,
|
||||||
|
expires_at=expires_at,
|
||||||
|
responsible="Система OTP",
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(row)
|
||||||
|
|
||||||
|
sms_response = _mock_sms_send(phone, code, purpose, track_number)
|
||||||
|
return {
|
||||||
|
"status": "sent",
|
||||||
|
"purpose": purpose,
|
||||||
|
"track_number": track_number,
|
||||||
|
"ttl_seconds": OTP_TTL_MINUTES * 60,
|
||||||
|
"sms_response": sms_response,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/verify")
|
||||||
|
def verify_otp(payload: OtpVerify, response: Response, db: Session = Depends(get_db)):
|
||||||
|
purpose = _normalize_purpose(payload.purpose)
|
||||||
|
if purpose not in ALLOWED_PURPOSES:
|
||||||
|
raise HTTPException(status_code=400, detail="Некорректная цель OTP")
|
||||||
|
|
||||||
|
track_number: str | None = None
|
||||||
|
phone: str | None = None
|
||||||
|
if purpose == OTP_CREATE_PURPOSE:
|
||||||
|
phone = _normalize_phone(payload.client_phone)
|
||||||
|
if not phone:
|
||||||
|
raise HTTPException(status_code=400, detail='Поле "client_phone" обязательно для CREATE_REQUEST')
|
||||||
|
else:
|
||||||
|
track_number = _normalize_track(payload.track_number)
|
||||||
|
if not track_number:
|
||||||
|
raise HTTPException(status_code=400, detail='Поле "track_number" обязательно для VIEW_REQUEST')
|
||||||
|
|
||||||
|
query = db.query(OtpSession).filter(
|
||||||
|
OtpSession.purpose == purpose,
|
||||||
|
OtpSession.track_number == track_number,
|
||||||
|
)
|
||||||
|
if phone is not None:
|
||||||
|
query = query.filter(OtpSession.phone == phone)
|
||||||
|
|
||||||
|
row = query.order_by(OtpSession.created_at.desc()).first()
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=400, detail="OTP не найден или истек")
|
||||||
|
|
||||||
|
now = _now_utc()
|
||||||
|
if _as_utc(row.expires_at) <= now:
|
||||||
|
db.delete(row)
|
||||||
|
db.commit()
|
||||||
|
raise HTTPException(status_code=400, detail="OTP не найден или истек")
|
||||||
|
|
||||||
|
if int(row.attempts or 0) >= OTP_MAX_ATTEMPTS:
|
||||||
|
raise HTTPException(status_code=429, detail="Превышено количество попыток")
|
||||||
|
|
||||||
|
code = str(payload.code or "").strip()
|
||||||
|
if not code or not verify_password(code, row.code_hash):
|
||||||
|
row.attempts = int(row.attempts or 0) + 1
|
||||||
|
db.add(row)
|
||||||
|
db.commit()
|
||||||
|
raise HTTPException(status_code=400, detail="Неверный OTP-код")
|
||||||
|
|
||||||
|
subject = row.phone if purpose == OTP_CREATE_PURPOSE else str(row.track_number or "")
|
||||||
|
if not subject:
|
||||||
|
raise HTTPException(status_code=400, detail="Некорректная OTP-сессия")
|
||||||
|
|
||||||
|
_set_public_cookie(response, subject=subject, purpose=purpose)
|
||||||
|
|
||||||
|
db.delete(row)
|
||||||
|
db.commit()
|
||||||
|
return {"status": "verified", "purpose": purpose}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,421 @@
|
||||||
from fastapi import APIRouter, Depends
|
from __future__ import annotations
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
from datetime import timedelta
|
||||||
|
from uuid import UUID
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.deps import get_public_session
|
||||||
|
from app.core.security import create_jwt
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.schemas.public import PublicRequestCreate, PublicRequestCreated
|
from app.models.attachment import Attachment
|
||||||
|
from app.models.message import Message
|
||||||
from app.models.request import Request
|
from app.models.request import Request
|
||||||
|
from app.models.status_history import StatusHistory
|
||||||
|
from app.services.notifications import (
|
||||||
|
EVENT_MESSAGE as NOTIFICATION_EVENT_MESSAGE,
|
||||||
|
get_client_notification,
|
||||||
|
list_client_notifications,
|
||||||
|
mark_client_notifications_read,
|
||||||
|
notify_request_event,
|
||||||
|
serialize_notification,
|
||||||
|
)
|
||||||
|
from app.services.request_read_markers import EVENT_MESSAGE, clear_unread_for_client, mark_unread_for_lawyer
|
||||||
|
from app.services.request_templates import validate_required_topic_fields_or_400
|
||||||
|
from app.schemas.public import (
|
||||||
|
PublicAttachmentRead,
|
||||||
|
PublicMessageCreate,
|
||||||
|
PublicMessageRead,
|
||||||
|
PublicRequestCreate,
|
||||||
|
PublicRequestCreated,
|
||||||
|
PublicStatusHistoryRead,
|
||||||
|
PublicTimelineEvent,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
OTP_CREATE_PURPOSE = "CREATE_REQUEST"
|
||||||
|
OTP_VIEW_PURPOSE = "VIEW_REQUEST"
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_phone(raw: str | None) -> str:
|
||||||
|
return str(raw or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_track(raw: str | None) -> str:
|
||||||
|
return str(raw or "").strip().upper()
|
||||||
|
|
||||||
|
|
||||||
|
def _set_view_cookie(response: Response, track_number: str) -> None:
|
||||||
|
token = create_jwt(
|
||||||
|
{"sub": track_number, "purpose": OTP_VIEW_PURPOSE},
|
||||||
|
settings.PUBLIC_JWT_SECRET,
|
||||||
|
timedelta(days=settings.PUBLIC_JWT_TTL_DAYS),
|
||||||
|
)
|
||||||
|
response.set_cookie(
|
||||||
|
key=settings.PUBLIC_COOKIE_NAME,
|
||||||
|
value=token,
|
||||||
|
httponly=True,
|
||||||
|
secure=False,
|
||||||
|
samesite="lax",
|
||||||
|
max_age=settings.PUBLIC_JWT_TTL_DAYS * 24 * 3600,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_create_session_or_403(session: dict, client_phone: str) -> None:
|
||||||
|
purpose = str(session.get("purpose") or "").strip().upper()
|
||||||
|
sub = _normalize_phone(session.get("sub"))
|
||||||
|
if purpose != OTP_CREATE_PURPOSE or not sub or sub != _normalize_phone(client_phone):
|
||||||
|
raise HTTPException(status_code=403, detail="Требуется подтверждение телефона через OTP")
|
||||||
|
|
||||||
|
|
||||||
|
def _require_view_session_for_track_or_403(session: dict, track_number: str) -> None:
|
||||||
|
purpose = str(session.get("purpose") or "").strip().upper()
|
||||||
|
sub = _normalize_track(session.get("sub"))
|
||||||
|
if purpose != OTP_VIEW_PURPOSE or not sub or sub != _normalize_track(track_number):
|
||||||
|
raise HTTPException(status_code=403, detail="Нет доступа к заявке")
|
||||||
|
|
||||||
|
|
||||||
|
def _request_for_track_or_404(db: Session, session: dict, track_number: str) -> Request:
|
||||||
|
normalized_track = _normalize_track(track_number)
|
||||||
|
_require_view_session_for_track_or_403(session, normalized_track)
|
||||||
|
req = db.query(Request).filter(Request.track_number == normalized_track).first()
|
||||||
|
if req is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||||
|
return req
|
||||||
|
|
||||||
|
|
||||||
|
def _to_iso(value) -> str | None:
|
||||||
|
return value.isoformat() if value is not None else None
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=PublicRequestCreated, status_code=201)
|
@router.post("", response_model=PublicRequestCreated, status_code=201)
|
||||||
def create_request(payload: PublicRequestCreate, db: Session = Depends(get_db)):
|
def create_request(
|
||||||
|
payload: PublicRequestCreate,
|
||||||
|
response: Response,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
session: dict = Depends(get_public_session),
|
||||||
|
):
|
||||||
|
_require_create_session_or_403(session, payload.client_phone)
|
||||||
|
validate_required_topic_fields_or_400(db, payload.topic_code, payload.extra_fields)
|
||||||
|
|
||||||
track = f"TRK-{uuid4().hex[:10].upper()}"
|
track = f"TRK-{uuid4().hex[:10].upper()}"
|
||||||
r = Request(track_number=track, client_name=payload.client_name, client_phone=payload.client_phone,
|
row = Request(
|
||||||
topic_code=payload.topic_code, description=payload.description, extra_fields=payload.extra_fields)
|
track_number=track,
|
||||||
db.add(r); db.commit(); db.refresh(r)
|
client_name=payload.client_name,
|
||||||
return PublicRequestCreated(request_id=r.id, track_number=r.track_number, otp_required=True)
|
client_phone=payload.client_phone,
|
||||||
|
topic_code=payload.topic_code,
|
||||||
|
description=payload.description,
|
||||||
|
extra_fields=payload.extra_fields,
|
||||||
|
responsible="Клиент",
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(row)
|
||||||
|
|
||||||
|
_set_view_cookie(response, track)
|
||||||
|
return PublicRequestCreated(request_id=row.id, track_number=row.track_number, otp_required=False)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{track_number}")
|
||||||
|
def get_request_by_track(
|
||||||
|
track_number: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
session: dict = Depends(get_public_session),
|
||||||
|
):
|
||||||
|
req = _request_for_track_or_404(db, session, track_number)
|
||||||
|
markers_cleared = clear_unread_for_client(req)
|
||||||
|
notifications_cleared = mark_client_notifications_read(
|
||||||
|
db,
|
||||||
|
track_number=req.track_number,
|
||||||
|
request_id=req.id,
|
||||||
|
responsible="Клиент",
|
||||||
|
)
|
||||||
|
if markers_cleared or notifications_cleared:
|
||||||
|
db.add(req)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(req)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(req.id),
|
||||||
|
"track_number": req.track_number,
|
||||||
|
"client_name": req.client_name,
|
||||||
|
"client_phone": req.client_phone,
|
||||||
|
"topic_code": req.topic_code,
|
||||||
|
"status_code": req.status_code,
|
||||||
|
"description": req.description,
|
||||||
|
"extra_fields": req.extra_fields,
|
||||||
|
"assigned_lawyer_id": req.assigned_lawyer_id,
|
||||||
|
"client_has_unread_updates": req.client_has_unread_updates,
|
||||||
|
"client_unread_event_type": req.client_unread_event_type,
|
||||||
|
"lawyer_has_unread_updates": req.lawyer_has_unread_updates,
|
||||||
|
"lawyer_unread_event_type": req.lawyer_unread_event_type,
|
||||||
|
"created_at": _to_iso(req.created_at),
|
||||||
|
"updated_at": _to_iso(req.updated_at),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{track_number}/messages", response_model=list[PublicMessageRead])
|
||||||
|
def list_messages_by_track(
|
||||||
|
track_number: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
session: dict = Depends(get_public_session),
|
||||||
|
):
|
||||||
|
req = _request_for_track_or_404(db, session, track_number)
|
||||||
|
rows = (
|
||||||
|
db.query(Message)
|
||||||
|
.filter(Message.request_id == req.id)
|
||||||
|
.order_by(Message.created_at.asc(), Message.id.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
PublicMessageRead(
|
||||||
|
id=row.id,
|
||||||
|
request_id=row.request_id,
|
||||||
|
author_type=row.author_type,
|
||||||
|
author_name=row.author_name,
|
||||||
|
body=row.body,
|
||||||
|
created_at=_to_iso(row.created_at),
|
||||||
|
updated_at=_to_iso(row.updated_at),
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{track_number}/messages", response_model=PublicMessageRead, status_code=201)
|
||||||
|
def create_message_by_track(
|
||||||
|
track_number: str,
|
||||||
|
payload: PublicMessageCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
session: dict = Depends(get_public_session),
|
||||||
|
):
|
||||||
|
req = _request_for_track_or_404(db, session, track_number)
|
||||||
|
body = str(payload.body or "").strip()
|
||||||
|
if not body:
|
||||||
|
raise HTTPException(status_code=400, detail='Поле "body" обязательно')
|
||||||
|
|
||||||
|
row = Message(
|
||||||
|
request_id=req.id,
|
||||||
|
author_type="CLIENT",
|
||||||
|
author_name=req.client_name,
|
||||||
|
body=body,
|
||||||
|
responsible="Клиент",
|
||||||
|
)
|
||||||
|
mark_unread_for_lawyer(req, EVENT_MESSAGE)
|
||||||
|
req.responsible = "Клиент"
|
||||||
|
notify_request_event(
|
||||||
|
db,
|
||||||
|
request=req,
|
||||||
|
event_type=NOTIFICATION_EVENT_MESSAGE,
|
||||||
|
actor_role="CLIENT",
|
||||||
|
body=body,
|
||||||
|
responsible="Клиент",
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
db.add(req)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(row)
|
||||||
|
|
||||||
|
return PublicMessageRead(
|
||||||
|
id=row.id,
|
||||||
|
request_id=row.request_id,
|
||||||
|
author_type=row.author_type,
|
||||||
|
author_name=row.author_name,
|
||||||
|
body=row.body,
|
||||||
|
created_at=_to_iso(row.created_at),
|
||||||
|
updated_at=_to_iso(row.updated_at),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{track_number}/attachments", response_model=list[PublicAttachmentRead])
|
||||||
|
def list_attachments_by_track(
|
||||||
|
track_number: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
session: dict = Depends(get_public_session),
|
||||||
|
):
|
||||||
|
req = _request_for_track_or_404(db, session, track_number)
|
||||||
|
rows = (
|
||||||
|
db.query(Attachment)
|
||||||
|
.filter(Attachment.request_id == req.id)
|
||||||
|
.order_by(Attachment.created_at.desc(), Attachment.id.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
PublicAttachmentRead(
|
||||||
|
id=row.id,
|
||||||
|
request_id=row.request_id,
|
||||||
|
message_id=row.message_id,
|
||||||
|
file_name=row.file_name,
|
||||||
|
mime_type=row.mime_type,
|
||||||
|
size_bytes=row.size_bytes,
|
||||||
|
created_at=_to_iso(row.created_at),
|
||||||
|
download_url=f"/api/public/uploads/object/{row.id}",
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{track_number}/history", response_model=list[PublicStatusHistoryRead])
|
||||||
|
def list_status_history_by_track(
|
||||||
|
track_number: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
session: dict = Depends(get_public_session),
|
||||||
|
):
|
||||||
|
req = _request_for_track_or_404(db, session, track_number)
|
||||||
|
rows = (
|
||||||
|
db.query(StatusHistory)
|
||||||
|
.filter(StatusHistory.request_id == req.id)
|
||||||
|
.order_by(StatusHistory.created_at.asc(), StatusHistory.id.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
PublicStatusHistoryRead(
|
||||||
|
id=row.id,
|
||||||
|
request_id=row.request_id,
|
||||||
|
from_status=row.from_status,
|
||||||
|
to_status=row.to_status,
|
||||||
|
comment=row.comment,
|
||||||
|
created_at=_to_iso(row.created_at),
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{track_number}/timeline", response_model=list[PublicTimelineEvent])
|
||||||
|
def list_timeline_by_track(
|
||||||
|
track_number: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
session: dict = Depends(get_public_session),
|
||||||
|
):
|
||||||
|
req = _request_for_track_or_404(db, session, track_number)
|
||||||
|
messages = db.query(Message).filter(Message.request_id == req.id).all()
|
||||||
|
attachments = db.query(Attachment).filter(Attachment.request_id == req.id).all()
|
||||||
|
statuses = db.query(StatusHistory).filter(StatusHistory.request_id == req.id).all()
|
||||||
|
|
||||||
|
events: list[PublicTimelineEvent] = []
|
||||||
|
for row in statuses:
|
||||||
|
events.append(
|
||||||
|
PublicTimelineEvent(
|
||||||
|
type="status_change",
|
||||||
|
created_at=_to_iso(row.created_at),
|
||||||
|
payload={
|
||||||
|
"id": str(row.id),
|
||||||
|
"from_status": row.from_status,
|
||||||
|
"to_status": row.to_status,
|
||||||
|
"comment": row.comment,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for row in messages:
|
||||||
|
events.append(
|
||||||
|
PublicTimelineEvent(
|
||||||
|
type="message",
|
||||||
|
created_at=_to_iso(row.created_at),
|
||||||
|
payload={
|
||||||
|
"id": str(row.id),
|
||||||
|
"author_type": row.author_type,
|
||||||
|
"author_name": row.author_name,
|
||||||
|
"body": row.body,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for row in attachments:
|
||||||
|
events.append(
|
||||||
|
PublicTimelineEvent(
|
||||||
|
type="attachment",
|
||||||
|
created_at=_to_iso(row.created_at),
|
||||||
|
payload={
|
||||||
|
"id": str(row.id),
|
||||||
|
"file_name": row.file_name,
|
||||||
|
"mime_type": row.mime_type,
|
||||||
|
"size_bytes": row.size_bytes,
|
||||||
|
"download_url": f"/api/public/uploads/object/{row.id}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _sort_key(event: PublicTimelineEvent):
|
||||||
|
return event.created_at or ""
|
||||||
|
|
||||||
|
events.sort(key=_sort_key)
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{track_number}/notifications")
|
||||||
|
def list_notifications_by_track(
|
||||||
|
track_number: str,
|
||||||
|
unread_only: bool = False,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
session: dict = Depends(get_public_session),
|
||||||
|
):
|
||||||
|
req = _request_for_track_or_404(db, session, track_number)
|
||||||
|
rows, total = list_client_notifications(
|
||||||
|
db,
|
||||||
|
track_number=req.track_number,
|
||||||
|
unread_only=bool(unread_only),
|
||||||
|
request_id=req.id,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
_, unread_total = list_client_notifications(
|
||||||
|
db,
|
||||||
|
track_number=req.track_number,
|
||||||
|
unread_only=True,
|
||||||
|
request_id=req.id,
|
||||||
|
limit=1,
|
||||||
|
offset=0,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"rows": [serialize_notification(row) for row in rows],
|
||||||
|
"total": int(total),
|
||||||
|
"unread_total": int(unread_total),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{track_number}/notifications/{notification_id}/read")
|
||||||
|
def read_notification_by_track(
|
||||||
|
track_number: str,
|
||||||
|
notification_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
session: dict = Depends(get_public_session),
|
||||||
|
):
|
||||||
|
req = _request_for_track_or_404(db, session, track_number)
|
||||||
|
try:
|
||||||
|
notification_uuid = UUID(str(notification_id))
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Некорректный notification_id")
|
||||||
|
row = get_client_notification(db, track_number=req.track_number, notification_id=notification_uuid)
|
||||||
|
if row is None or str(row.request_id) != str(req.id):
|
||||||
|
raise HTTPException(status_code=404, detail="Уведомление не найдено")
|
||||||
|
changed = mark_client_notifications_read(
|
||||||
|
db,
|
||||||
|
track_number=req.track_number,
|
||||||
|
request_id=req.id,
|
||||||
|
notification_id=notification_uuid,
|
||||||
|
responsible="Клиент",
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
refreshed = get_client_notification(db, track_number=req.track_number, notification_id=notification_uuid)
|
||||||
|
return {"status": "ok", "changed": int(changed), "notification": serialize_notification(refreshed) if refreshed else None}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{track_number}/notifications/read-all")
|
||||||
|
def read_all_notifications_by_track(
|
||||||
|
track_number: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
session: dict = Depends(get_public_session),
|
||||||
|
):
|
||||||
|
req = _request_for_track_or_404(db, session, track_number)
|
||||||
|
changed = mark_client_notifications_read(
|
||||||
|
db,
|
||||||
|
track_number=req.track_number,
|
||||||
|
request_id=req.id,
|
||||||
|
responsible="Клиент",
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return {"status": "ok", "changed": int(changed)}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,164 @@
|
||||||
from fastapi import APIRouter
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from botocore.exceptions import ClientError
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.deps import get_public_session
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models.attachment import Attachment
|
||||||
|
from app.models.request import Request
|
||||||
|
from app.schemas.uploads import UploadCompletePayload, UploadCompleteResponse, UploadInitPayload, UploadInitResponse, UploadScope
|
||||||
|
from app.services.notifications import EVENT_ATTACHMENT as NOTIFICATION_EVENT_ATTACHMENT, notify_request_event
|
||||||
|
from app.services.request_read_markers import EVENT_ATTACHMENT, mark_unread_for_lawyer
|
||||||
|
from app.services.s3_storage import build_object_key, get_s3_storage
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@router.post("/init")
|
|
||||||
def upload_init():
|
|
||||||
return {"method": "PRESIGNED_PUT", "presigned_url": "https://s3.local/..."}
|
|
||||||
|
|
||||||
@router.post("/complete")
|
def _max_file_bytes() -> int:
|
||||||
def upload_complete():
|
return int(settings.MAX_FILE_MB) * 1024 * 1024
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
|
def _max_case_bytes() -> int:
|
||||||
|
return int(settings.MAX_CASE_MB) * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def _uuid_or_400(raw: str | None, field_name: str) -> uuid.UUID:
|
||||||
|
if not raw:
|
||||||
|
raise HTTPException(status_code=400, detail=f'Поле "{field_name}" обязательно')
|
||||||
|
try:
|
||||||
|
return uuid.UUID(str(raw))
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail=f'Некорректный "{field_name}"')
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_object_key_prefix_or_400(key: str, prefix: str) -> None:
|
||||||
|
if not str(key or "").startswith(prefix):
|
||||||
|
raise HTTPException(status_code=400, detail="Некорректный ключ объекта для выбранной заявки")
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_public_request_access_or_403(request: Request, session: dict) -> None:
|
||||||
|
purpose = str(session.get("purpose") or "").strip().upper()
|
||||||
|
if purpose != "VIEW_REQUEST":
|
||||||
|
raise HTTPException(status_code=403, detail="Нет доступа к заявке")
|
||||||
|
track_from_session = str(session.get("sub") or "").strip()
|
||||||
|
if not track_from_session or track_from_session != str(request.track_number):
|
||||||
|
raise HTTPException(status_code=403, detail="Нет доступа к заявке")
|
||||||
|
|
||||||
|
|
||||||
|
def _load_attachment_with_access_or_4xx(attachment_id: str, db: Session, session: dict) -> Attachment:
|
||||||
|
attachment_uuid = _uuid_or_400(attachment_id, "attachment_id")
|
||||||
|
attachment = db.get(Attachment, attachment_uuid)
|
||||||
|
if attachment is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Файл не найден")
|
||||||
|
request = db.get(Request, attachment.request_id)
|
||||||
|
if request is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||||
|
_ensure_public_request_access_or_403(request, session)
|
||||||
|
return attachment
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/init", response_model=UploadInitResponse)
|
||||||
|
def upload_init(payload: UploadInitPayload, db: Session = Depends(get_db), session: dict = Depends(get_public_session)):
|
||||||
|
if payload.scope != UploadScope.REQUEST_ATTACHMENT:
|
||||||
|
raise HTTPException(status_code=400, detail="Публичная загрузка поддерживает только REQUEST_ATTACHMENT")
|
||||||
|
if int(payload.size_bytes or 0) <= 0:
|
||||||
|
raise HTTPException(status_code=400, detail="Некорректный размер файла")
|
||||||
|
if int(payload.size_bytes) > _max_file_bytes():
|
||||||
|
raise HTTPException(status_code=400, detail=f"Превышен лимит файла ({settings.MAX_FILE_MB} МБ)")
|
||||||
|
|
||||||
|
request_uuid = _uuid_or_400(payload.request_id, "request_id")
|
||||||
|
request = db.get(Request, request_uuid)
|
||||||
|
if request is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||||
|
_ensure_public_request_access_or_403(request, session)
|
||||||
|
|
||||||
|
current = int(request.total_attachments_bytes or 0)
|
||||||
|
if current + int(payload.size_bytes) > _max_case_bytes():
|
||||||
|
raise HTTPException(status_code=400, detail=f"Превышен лимит вложений заявки ({settings.MAX_CASE_MB} МБ)")
|
||||||
|
|
||||||
|
key = build_object_key(f"requests/{request.id}", payload.file_name)
|
||||||
|
presigned_url = get_s3_storage().create_presigned_put_url(key, payload.mime_type)
|
||||||
|
return UploadInitResponse(key=key, presigned_url=presigned_url)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/complete", response_model=UploadCompleteResponse)
|
||||||
|
def upload_complete(payload: UploadCompletePayload, db: Session = Depends(get_db), session: dict = Depends(get_public_session)):
|
||||||
|
if payload.scope != UploadScope.REQUEST_ATTACHMENT:
|
||||||
|
raise HTTPException(status_code=400, detail="Публичная загрузка поддерживает только REQUEST_ATTACHMENT")
|
||||||
|
request_uuid = _uuid_or_400(payload.request_id, "request_id")
|
||||||
|
request = db.get(Request, request_uuid)
|
||||||
|
if request is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||||
|
_ensure_public_request_access_or_403(request, session)
|
||||||
|
_ensure_object_key_prefix_or_400(payload.key, f"requests/{request.id}/")
|
||||||
|
|
||||||
|
storage = get_s3_storage()
|
||||||
|
try:
|
||||||
|
head = storage.head_object(payload.key)
|
||||||
|
except ClientError:
|
||||||
|
raise HTTPException(status_code=400, detail="Файл не найден в хранилище")
|
||||||
|
|
||||||
|
actual_size = int(head.get("ContentLength") or payload.size_bytes or 0)
|
||||||
|
if actual_size <= 0:
|
||||||
|
raise HTTPException(status_code=400, detail="Некорректный размер файла")
|
||||||
|
if actual_size > _max_file_bytes():
|
||||||
|
raise HTTPException(status_code=400, detail=f"Превышен лимит файла ({settings.MAX_FILE_MB} МБ)")
|
||||||
|
if int(request.total_attachments_bytes or 0) + actual_size > _max_case_bytes():
|
||||||
|
raise HTTPException(status_code=400, detail=f"Превышен лимит вложений заявки ({settings.MAX_CASE_MB} МБ)")
|
||||||
|
|
||||||
|
row = Attachment(
|
||||||
|
request_id=request.id,
|
||||||
|
message_id=None,
|
||||||
|
file_name=payload.file_name,
|
||||||
|
mime_type=payload.mime_type,
|
||||||
|
size_bytes=actual_size,
|
||||||
|
s3_key=payload.key,
|
||||||
|
responsible="Клиент",
|
||||||
|
)
|
||||||
|
mark_unread_for_lawyer(request, EVENT_ATTACHMENT)
|
||||||
|
notify_request_event(
|
||||||
|
db,
|
||||||
|
request=request,
|
||||||
|
event_type=NOTIFICATION_EVENT_ATTACHMENT,
|
||||||
|
actor_role="CLIENT",
|
||||||
|
body=f'Файл: {payload.file_name}',
|
||||||
|
responsible="Клиент",
|
||||||
|
)
|
||||||
|
request.total_attachments_bytes = int(request.total_attachments_bytes or 0) + actual_size
|
||||||
|
request.responsible = "Клиент"
|
||||||
|
db.add(row)
|
||||||
|
db.add(request)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(row)
|
||||||
|
return UploadCompleteResponse(status="ok", attachment_id=str(row.id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/object/{attachment_id}")
|
||||||
|
def get_public_attachment_object(
|
||||||
|
attachment_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
session: dict = Depends(get_public_session),
|
||||||
|
):
|
||||||
|
attachment = _load_attachment_with_access_or_4xx(attachment_id, db, session)
|
||||||
|
try:
|
||||||
|
obj = get_s3_storage().get_object(attachment.s3_key)
|
||||||
|
except ClientError:
|
||||||
|
raise HTTPException(status_code=404, detail="Файл не найден в хранилище")
|
||||||
|
|
||||||
|
body = obj["Body"]
|
||||||
|
content_length = obj.get("ContentLength")
|
||||||
|
media_type = obj.get("ContentType") or attachment.mime_type or "application/octet-stream"
|
||||||
|
encoded_name = quote(str(attachment.file_name or "file"), safe="")
|
||||||
|
headers = {
|
||||||
|
"Content-Disposition": f"inline; filename*=UTF-8''{encoded_name}",
|
||||||
|
}
|
||||||
|
if content_length is not None:
|
||||||
|
headers["Content-Length"] = str(content_length)
|
||||||
|
return StreamingResponse(body.iter_chunks(chunk_size=64 * 1024), media_type=media_type, headers=headers)
|
||||||
|
|
|
||||||
|
|
@ -23,11 +23,12 @@ class Settings(BaseSettings):
|
||||||
S3_REGION: str = "us-east-1"
|
S3_REGION: str = "us-east-1"
|
||||||
S3_USE_SSL: bool = False
|
S3_USE_SSL: bool = False
|
||||||
MAX_FILE_MB: int = 25
|
MAX_FILE_MB: int = 25
|
||||||
MAX_CASE_MB: int = 350
|
MAX_CASE_MB: int = 250
|
||||||
|
|
||||||
TELEGRAM_BOT_TOKEN: str = "change_me"
|
TELEGRAM_BOT_TOKEN: str = "change_me"
|
||||||
TELEGRAM_CHAT_ID: str = "0"
|
TELEGRAM_CHAT_ID: str = "0"
|
||||||
SMS_PROVIDER: str = "dummy"
|
SMS_PROVIDER: str = "dummy"
|
||||||
|
DATA_ENCRYPTION_SECRET: str = "change_me_data_encryption"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cors_origins_list(self) -> List[str]:
|
def cors_origins_list(self) -> List[str]:
|
||||||
|
|
|
||||||
1
app/data/__init__.py
Normal file
1
app/data/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
# Data package for static seed content.
|
||||||
52
app/data/quotes_justice_seed.py
Normal file
52
app/data/quotes_justice_seed.py
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
JUSTICE_QUOTES = [
|
||||||
|
{"author": "Платон", "text": "Справедливость состоит в том, чтобы каждый занимался своим делом.", "source": "Государство"},
|
||||||
|
{"author": "Аристотель", "text": "Справедливость есть совершенная добродетель в отношении к другому.", "source": "Никомахова этика"},
|
||||||
|
{"author": "Сократ", "text": "Лучше терпеть несправедливость, чем самому ее совершать.", "source": "Горгий"},
|
||||||
|
{"author": "Цицерон", "text": "Мы рабы законов, чтобы быть свободными.", "source": "О законах"},
|
||||||
|
{"author": "Ульпиан", "text": "Правосудие есть постоянная и неизменная воля воздавать каждому свое.", "source": "Дигесты Юстиниана"},
|
||||||
|
{"author": "Аврелий Августин", "text": "Без справедливости государство - лишь большая шайка разбойников.", "source": "О граде Божьем"},
|
||||||
|
{"author": "Фома Аквинский", "text": "Несправедливый закон не есть закон.", "source": "Сумма теологии"},
|
||||||
|
{"author": "Джон Локк", "text": "Там, где нет закона, нет и свободы.", "source": "Два трактата о правлении"},
|
||||||
|
{"author": "Шарль де Монтескье", "text": "Нет большей тирании, чем та, которая совершается под сенью закона и именем правосудия.", "source": "О духе законов"},
|
||||||
|
{"author": "Жан-Жак Руссо", "text": "Между сильным и слабым свобода угнетает, а закон освобождает.", "source": "Об общественном договоре"},
|
||||||
|
{"author": "Чезаре Беккариа", "text": "Лучше предупреждать преступления, чем наказывать их.", "source": "О преступлениях и наказаниях"},
|
||||||
|
{"author": "Вольтер", "text": "Лучше оправдать виновного, чем осудить невиновного.", "source": "Афоризм"},
|
||||||
|
{"author": "Иммануил Кант", "text": "Право - это совокупность условий, при которых свобода каждого совместима со свободой всех.", "source": "Метафизика нравов"},
|
||||||
|
{"author": "Георг Гегель", "text": "Наказание есть право преступника.", "source": "Философия права"},
|
||||||
|
{"author": "Джереми Бентам", "text": "Цель наказания - не месть, а предупреждение зла.", "source": "Введение в основания нравственности и законодательства"},
|
||||||
|
{"author": "Уильям Блэкстоун", "text": "Лучше, чтобы десять виновных избежали наказания, чем один невиновный пострадал.", "source": "Комментарии к законам Англии"},
|
||||||
|
{"author": "Эдмунд Берк", "text": "Плохие законы - худший вид тирании.", "source": "Речь в парламенте"},
|
||||||
|
{"author": "Александр Гамильтон", "text": "Первая обязанность общества - справедливость.", "source": "Федералист"},
|
||||||
|
{"author": "Джеймс Мэдисон", "text": "Справедливость - цель правительства.", "source": "Федералист No. 51"},
|
||||||
|
{"author": "Томас Джефферсон", "text": "Равная и точная справедливость ко всем людям - первый принцип доброго правления.", "source": "Первая инаугурационная речь"},
|
||||||
|
{"author": "Авраам Линкольн", "text": "С твердостью в правом деле постараемся довести до конца дело справедливого мира.", "source": "Вторая инаугурационная речь"},
|
||||||
|
{"author": "Теодор Паркер", "text": "Дуга нравственной вселенной длинна, но она склоняется к справедливости.", "source": "Проповедь 1853 года"},
|
||||||
|
{"author": "Мартин Лютер Кинг-младший", "text": "Несправедливость где бы то ни было - угроза справедливости повсюду.", "source": "Письмо из бирмингемской тюрьмы"},
|
||||||
|
{"author": "Нельсон Мандела", "text": "Лишать людей их прав - значит бросать вызов их человечности.", "source": "Речь о правах человека"},
|
||||||
|
{"author": "Махатма Ганди", "text": "Закон любви выше закона насилия.", "source": "Публичные выступления"},
|
||||||
|
{"author": "Франклин Д. Рузвельт", "text": "Истинная личная свобода не может существовать без экономической безопасности и независимости.", "source": "Послание о четырех свободах"},
|
||||||
|
{"author": "Уинстон Черчилль", "text": "Сила нации измеряется не только армией, но и доверием к ее правосудию.", "source": "Парламентская речь"},
|
||||||
|
{"author": "Шарль де Голль", "text": "Власть должна служить праву, иначе она превращается в произвол.", "source": "Политические мемуары"},
|
||||||
|
{"author": "Мустафа Кемаль Ататюрк", "text": "Справедливость - фундамент государства.", "source": "Речь в Великом национальном собрании"},
|
||||||
|
{"author": "Ли Куан Ю", "text": "Где закон применяется одинаково, там появляется доверие к государству.", "source": "Интервью о государственном управлении"},
|
||||||
|
{"author": "Анатоль Франс", "text": "Закон в своем величественном равенстве запрещает и богатым, и бедным спать под мостами.", "source": "Красная лилия"},
|
||||||
|
{"author": "Федор Достоевский", "text": "Степень цивилизации общества определяется тем, как оно относится к заключенным.", "source": "Записки из Мертвого дома"},
|
||||||
|
{"author": "Анатолий Кони", "text": "Суд должен быть не только справедливым, но и убедительно справедливым.", "source": "Судебные речи"},
|
||||||
|
{"author": "Василий Ключевский", "text": "Государство крепко не строгостью законов, а их уважением.", "source": "Лекции по русской истории"},
|
||||||
|
{"author": "Тацит", "text": "Чем больше в государстве испорченности, тем больше законов.", "source": "Анналы"},
|
||||||
|
{"author": "Фукидид", "text": "Право имеет силу там, где силы сторон равны.", "source": "История Пелопоннесской войны"},
|
||||||
|
{"author": "Геродот", "text": "Обычай и закон для каждого народа - царь.", "source": "История"},
|
||||||
|
{"author": "Солон", "text": "Закон подобен паутине: слабого он удержит, а сильный прорвет.", "source": "Античная традиция"},
|
||||||
|
{"author": "Перикл", "text": "В нашей жизни мы свободны, но в общественных делах подчиняемся законам.", "source": "Надгробная речь"},
|
||||||
|
{"author": "Гераклит", "text": "Народу следует сражаться за закон, как за городскую стену.", "source": "Фрагменты"},
|
||||||
|
{"author": "Гуго Гроций", "text": "Даже война должна иметь свои законы.", "source": "О праве войны и мира"},
|
||||||
|
{"author": "Томас Мор", "text": "Там, где собственность - привилегия немногих, правосудие редко бывает равным.", "source": "Утопия"},
|
||||||
|
{"author": "Роберт Х. Джексон", "text": "Нюрнбергский процесс показал: даже государственная власть отвечает перед законом.", "source": "Речь на Нюрнбергском процессе"},
|
||||||
|
{"author": "Луи Брандейс", "text": "В демократическом обществе лучший защитник свободы - открытое правосудие.", "source": "Судебные мнения"},
|
||||||
|
{"author": "Оливер Уэнделл Холмс-младший", "text": "Право живет не логикой, а опытом.", "source": "Общее право"},
|
||||||
|
{"author": "Бенджамин Кардозо", "text": "Правосудие - это не механика формул, а разумное применение принципов.", "source": "Природа судебного процесса"},
|
||||||
|
{"author": "Ханна Арендт", "text": "Там, где исчезает ответственность, исчезает и справедливость.", "source": "Истоки тоталитаризма"},
|
||||||
|
{"author": "Альбер Камю", "text": "Справедливость без свободы становится новой формой насилия.", "source": "Бунтующий человек"},
|
||||||
|
{"author": "Конфуций", "text": "Править - значит исправлять: кто правит справедливо, тому следуют без приказа.", "source": "Лунь юй"},
|
||||||
|
{"author": "Сунь-цзы", "text": "Когда наказания справедливы и неизбежны, в войске устанавливается порядок.", "source": "Искусство войны"},
|
||||||
|
]
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
from sqlalchemy import String, Boolean
|
from sqlalchemy import Boolean, Numeric, String
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
from app.db.session import Base
|
from app.db.session import Base
|
||||||
from app.models.common import UUIDMixin, TimestampMixin
|
from app.models.common import UUIDMixin, TimestampMixin
|
||||||
|
|
@ -9,4 +9,8 @@ class AdminUser(Base, UUIDMixin, TimestampMixin):
|
||||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
email: Mapped[str] = mapped_column(String(200), unique=True, nullable=False)
|
email: Mapped[str] = mapped_column(String(200), unique=True, nullable=False)
|
||||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
avatar_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
primary_topic_code: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True)
|
||||||
|
default_rate: Mapped[float | None] = mapped_column(Numeric(12, 2), nullable=True)
|
||||||
|
salary_percent: Mapped[float | None] = mapped_column(Numeric(5, 2), nullable=True)
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
|
|
||||||
16
app/models/admin_user_topic.py
Normal file
16
app/models/admin_user_topic.py
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import String, UniqueConstraint
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.session import Base
|
||||||
|
from app.models.common import UUIDMixin, TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUserTopic(Base, UUIDMixin, TimestampMixin):
|
||||||
|
__tablename__ = "admin_user_topics"
|
||||||
|
__table_args__ = (UniqueConstraint("admin_user_id", "topic_code", name="uq_admin_user_topics_user_topic"),)
|
||||||
|
|
||||||
|
admin_user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False, index=True)
|
||||||
|
topic_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||||
|
|
@ -2,7 +2,7 @@ import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
from sqlalchemy import DateTime
|
from sqlalchemy import DateTime, String
|
||||||
|
|
||||||
def utcnow():
|
def utcnow():
|
||||||
return datetime.now(timezone.utc)
|
return datetime.now(timezone.utc)
|
||||||
|
|
@ -13,3 +13,4 @@ class UUIDMixin:
|
||||||
class TimestampMixin:
|
class TimestampMixin:
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||||
|
responsible: Mapped[str] = mapped_column(String(200), nullable=False, default="Администратор системы")
|
||||||
|
|
|
||||||
25
app/models/notification.py
Normal file
25
app/models/notification.py
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, JSON, String, Text
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.session import Base
|
||||||
|
from app.models.common import TimestampMixin, UUIDMixin
|
||||||
|
|
||||||
|
|
||||||
|
class Notification(Base, UUIDMixin, TimestampMixin):
|
||||||
|
__tablename__ = "notifications"
|
||||||
|
|
||||||
|
request_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), index=True, nullable=True)
|
||||||
|
recipient_type: Mapped[str] = mapped_column(String(20), index=True, nullable=False) # CLIENT|ADMIN_USER
|
||||||
|
recipient_admin_user_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), index=True, nullable=True)
|
||||||
|
recipient_track_number: Mapped[str | None] = mapped_column(String(40), index=True, nullable=True)
|
||||||
|
event_type: Mapped[str] = mapped_column(String(50), index=True, nullable=False)
|
||||||
|
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
|
body: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
payload: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
|
is_read: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True)
|
||||||
|
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
dedupe_key: Mapped[str | None] = mapped_column(String(255), unique=True, nullable=True)
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
from sqlalchemy import String, Integer, Text, JSON
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, Integer, JSON, Numeric, String, Text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
from app.db.session import Base
|
from app.db.session import Base
|
||||||
from app.models.common import UUIDMixin, TimestampMixin
|
from app.models.common import UUIDMixin, TimestampMixin
|
||||||
|
|
@ -13,4 +15,12 @@ class Request(Base, UUIDMixin, TimestampMixin):
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
extra_fields: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
|
extra_fields: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
assigned_lawyer_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
assigned_lawyer_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
effective_rate: Mapped[float | None] = mapped_column(Numeric(12, 2), nullable=True)
|
||||||
|
invoice_amount: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True)
|
||||||
|
paid_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
paid_by_admin_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
total_attachments_bytes: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
total_attachments_bytes: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
client_has_unread_updates: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
client_unread_event_type: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||||
|
lawyer_has_unread_updates: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
lawyer_unread_event_type: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||||
|
|
|
||||||
27
app/models/request_data_requirement.py
Normal file
27
app/models/request_data_requirement.py
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import String, Boolean, Text, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|
||||||
|
from app.db.session import Base
|
||||||
|
from app.models.common import UUIDMixin, TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
|
class RequestDataRequirement(Base, UUIDMixin, TimestampMixin):
|
||||||
|
__tablename__ = "request_data_requirements"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"request_id",
|
||||||
|
"key",
|
||||||
|
name="uq_request_data_requirements_request_key",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
request_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False, index=True)
|
||||||
|
topic_template_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True, index=True)
|
||||||
|
key: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
|
||||||
|
label: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
required: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
created_by_admin_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
|
||||||
24
app/models/topic_data_template.py
Normal file
24
app/models/topic_data_template.py
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
from sqlalchemy import String, Integer, Boolean, Text, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.session import Base
|
||||||
|
from app.models.common import UUIDMixin, TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
|
class TopicDataTemplate(Base, UUIDMixin, TimestampMixin):
|
||||||
|
__tablename__ = "topic_data_templates"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"topic_code",
|
||||||
|
"key",
|
||||||
|
name="uq_topic_data_templates_topic_key",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
topic_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||||
|
key: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
|
||||||
|
label: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
required: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
22
app/models/topic_required_field.py
Normal file
22
app/models/topic_required_field.py
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
from sqlalchemy import String, Integer, Boolean, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.session import Base
|
||||||
|
from app.models.common import UUIDMixin, TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
|
class TopicRequiredField(Base, UUIDMixin, TimestampMixin):
|
||||||
|
__tablename__ = "topic_required_fields"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"topic_code",
|
||||||
|
"field_key",
|
||||||
|
name="uq_topic_required_fields_topic_field",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
topic_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||||
|
field_key: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
|
||||||
|
required: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
24
app/models/topic_status_transition.py
Normal file
24
app/models/topic_status_transition.py
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
from sqlalchemy import String, Integer, Boolean, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.session import Base
|
||||||
|
from app.models.common import UUIDMixin, TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
|
class TopicStatusTransition(Base, UUIDMixin, TimestampMixin):
|
||||||
|
__tablename__ = "topic_status_transitions"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"topic_code",
|
||||||
|
"from_status",
|
||||||
|
"to_status",
|
||||||
|
name="uq_topic_status_transitions_topic_from_to",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
topic_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||||
|
from_status: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||||
|
to_status: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
sla_hours: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from typing import Optional, Any
|
from typing import Optional
|
||||||
|
|
||||||
class AdminLogin(BaseModel):
|
class AdminLogin(BaseModel):
|
||||||
email: str
|
email: str
|
||||||
|
|
@ -51,6 +53,10 @@ class RequestAdminCreate(BaseModel):
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
extra_fields: dict = Field(default_factory=dict)
|
extra_fields: dict = Field(default_factory=dict)
|
||||||
assigned_lawyer_id: Optional[str] = None
|
assigned_lawyer_id: Optional[str] = None
|
||||||
|
effective_rate: Optional[float] = None
|
||||||
|
invoice_amount: Optional[float] = None
|
||||||
|
paid_at: Optional[datetime] = None
|
||||||
|
paid_by_admin_id: Optional[str] = None
|
||||||
total_attachments_bytes: int = 0
|
total_attachments_bytes: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -63,4 +69,30 @@ class RequestAdminPatch(BaseModel):
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
extra_fields: Optional[dict] = None
|
extra_fields: Optional[dict] = None
|
||||||
assigned_lawyer_id: Optional[str] = None
|
assigned_lawyer_id: Optional[str] = None
|
||||||
|
effective_rate: Optional[float] = None
|
||||||
|
invoice_amount: Optional[float] = None
|
||||||
|
paid_at: Optional[datetime] = None
|
||||||
|
paid_by_admin_id: Optional[str] = None
|
||||||
total_attachments_bytes: Optional[int] = None
|
total_attachments_bytes: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class RequestReassign(BaseModel):
|
||||||
|
lawyer_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class RequestDataRequirementCreate(BaseModel):
|
||||||
|
key: str
|
||||||
|
label: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
required: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class RequestDataRequirementPatch(BaseModel):
|
||||||
|
key: Optional[str] = None
|
||||||
|
label: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
required: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationsReadAll(BaseModel):
|
||||||
|
request_id: Optional[str] = None
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from typing import Optional, Dict, Any, List
|
from typing import Optional, Dict, Any, List, Literal
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
class PublicRequestCreate(BaseModel):
|
class PublicRequestCreate(BaseModel):
|
||||||
|
|
@ -23,4 +23,45 @@ class OtpSend(BaseModel):
|
||||||
class OtpVerify(BaseModel):
|
class OtpVerify(BaseModel):
|
||||||
purpose: str
|
purpose: str
|
||||||
track_number: Optional[str] = None
|
track_number: Optional[str] = None
|
||||||
|
client_phone: Optional[str] = None
|
||||||
code: str
|
code: str
|
||||||
|
|
||||||
|
|
||||||
|
class PublicMessageCreate(BaseModel):
|
||||||
|
body: str
|
||||||
|
|
||||||
|
|
||||||
|
class PublicMessageRead(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
request_id: UUID
|
||||||
|
author_type: str
|
||||||
|
author_name: Optional[str] = None
|
||||||
|
body: Optional[str] = None
|
||||||
|
created_at: Optional[str] = None
|
||||||
|
updated_at: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PublicAttachmentRead(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
request_id: UUID
|
||||||
|
message_id: Optional[UUID] = None
|
||||||
|
file_name: str
|
||||||
|
mime_type: str
|
||||||
|
size_bytes: int
|
||||||
|
created_at: Optional[str] = None
|
||||||
|
download_url: str
|
||||||
|
|
||||||
|
|
||||||
|
class PublicStatusHistoryRead(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
request_id: UUID
|
||||||
|
from_status: Optional[str] = None
|
||||||
|
to_status: str
|
||||||
|
comment: Optional[str] = None
|
||||||
|
created_at: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PublicTimelineEvent(BaseModel):
|
||||||
|
type: Literal["status_change", "message", "attachment"]
|
||||||
|
created_at: Optional[str] = None
|
||||||
|
payload: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
|
||||||
43
app/schemas/uploads.py
Normal file
43
app/schemas/uploads.py
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class UploadScope(str, Enum):
|
||||||
|
REQUEST_ATTACHMENT = "REQUEST_ATTACHMENT"
|
||||||
|
USER_AVATAR = "USER_AVATAR"
|
||||||
|
|
||||||
|
|
||||||
|
class UploadInitPayload(BaseModel):
|
||||||
|
file_name: str
|
||||||
|
mime_type: str
|
||||||
|
size_bytes: int
|
||||||
|
scope: UploadScope
|
||||||
|
request_id: Optional[str] = None
|
||||||
|
user_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class UploadInitResponse(BaseModel):
|
||||||
|
method: str = "PRESIGNED_PUT"
|
||||||
|
key: str
|
||||||
|
presigned_url: str
|
||||||
|
|
||||||
|
|
||||||
|
class UploadCompletePayload(BaseModel):
|
||||||
|
key: str
|
||||||
|
file_name: str
|
||||||
|
mime_type: str
|
||||||
|
size_bytes: int
|
||||||
|
scope: UploadScope
|
||||||
|
request_id: Optional[str] = None
|
||||||
|
message_id: Optional[str] = None
|
||||||
|
user_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class UploadCompleteResponse(BaseModel):
|
||||||
|
status: str = "ok"
|
||||||
|
attachment_id: Optional[str] = None
|
||||||
|
avatar_url: Optional[str] = None
|
||||||
1
app/scripts/__init__.py
Normal file
1
app/scripts/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
# Utility scripts for operational tasks.
|
||||||
68
app/scripts/upsert_quotes.py
Normal file
68
app/scripts/upsert_quotes.py
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.data.quotes_justice_seed import JUSTICE_QUOTES
|
||||||
|
from app.db.session import SessionLocal
|
||||||
|
from app.models.quote import Quote
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_quotes(db: Session, quotes: list[dict]) -> tuple[int, int]:
|
||||||
|
created = 0
|
||||||
|
updated = 0
|
||||||
|
|
||||||
|
for index, item in enumerate(quotes, start=1):
|
||||||
|
author = str(item["author"]).strip()
|
||||||
|
text = str(item["text"]).strip()
|
||||||
|
source = str(item.get("source") or "").strip() or None
|
||||||
|
sort_order = int(item.get("sort_order") or index)
|
||||||
|
is_active = bool(item.get("is_active", True))
|
||||||
|
|
||||||
|
row = db.query(Quote).filter(Quote.author == author, Quote.text == text).first()
|
||||||
|
if row is None:
|
||||||
|
db.add(
|
||||||
|
Quote(
|
||||||
|
author=author,
|
||||||
|
text=text,
|
||||||
|
source=source,
|
||||||
|
sort_order=sort_order,
|
||||||
|
is_active=is_active,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
created += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
changed = False
|
||||||
|
if row.source != source:
|
||||||
|
row.source = source
|
||||||
|
changed = True
|
||||||
|
if row.sort_order != sort_order:
|
||||||
|
row.sort_order = sort_order
|
||||||
|
changed = True
|
||||||
|
if row.is_active != is_active:
|
||||||
|
row.is_active = is_active
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if changed:
|
||||||
|
row.updated_at = datetime.now(timezone.utc)
|
||||||
|
db.add(row)
|
||||||
|
updated += 1
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return created, updated
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
created, updated = upsert_quotes(db, JUSTICE_QUOTES)
|
||||||
|
total = db.query(Quote).count()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
print(f"quotes upsert done: created={created}, updated={updated}, total={total}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
436
app/services/notifications.py
Normal file
436
app/services/notifications.py
Normal file
|
|
@ -0,0 +1,436 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import and_
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.admin_user import AdminUser
|
||||||
|
from app.models.notification import Notification
|
||||||
|
from app.models.request import Request
|
||||||
|
from app.services.telegram_notify import send_telegram_message
|
||||||
|
|
||||||
|
RECIPIENT_CLIENT = "CLIENT"
|
||||||
|
RECIPIENT_ADMIN_USER = "ADMIN_USER"
|
||||||
|
|
||||||
|
EVENT_MESSAGE = "MESSAGE"
|
||||||
|
EVENT_ATTACHMENT = "ATTACHMENT"
|
||||||
|
EVENT_STATUS = "STATUS"
|
||||||
|
EVENT_SLA_OVERDUE = "SLA_OVERDUE"
|
||||||
|
|
||||||
|
_EVENT_LABELS = {
|
||||||
|
EVENT_MESSAGE: "Новое сообщение",
|
||||||
|
EVENT_ATTACHMENT: "Новый файл",
|
||||||
|
EVENT_STATUS: "Изменен статус",
|
||||||
|
EVENT_SLA_OVERDUE: "SLA просрочен",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _as_utc_now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _as_uuid_or_none(value: Any) -> uuid.UUID | None:
|
||||||
|
try:
|
||||||
|
return uuid.UUID(str(value))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_track(value: Any) -> str | None:
|
||||||
|
track = str(value or "").strip().upper()
|
||||||
|
return track or None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized_event(event_type: str) -> str:
|
||||||
|
return str(event_type or "").strip().upper()
|
||||||
|
|
||||||
|
|
||||||
|
def _title_for_event(event_type: str, request: Request) -> str:
|
||||||
|
prefix = _EVENT_LABELS.get(event_type, "Обновление")
|
||||||
|
track = str(request.track_number or "").strip() or str(request.id)
|
||||||
|
return f"{prefix} по заявке {track}"
|
||||||
|
|
||||||
|
|
||||||
|
def _telegram_text_for_event(event_type: str, request: Request, body: str | None = None) -> str:
|
||||||
|
label = _EVENT_LABELS.get(event_type, "Обновление")
|
||||||
|
track = str(request.track_number or "").strip() or str(request.id)
|
||||||
|
topic = str(request.topic_code or "").strip() or "-"
|
||||||
|
status = str(request.status_code or "").strip() or "-"
|
||||||
|
tail = f"\n{body.strip()}" if str(body or "").strip() else ""
|
||||||
|
return f"#{track}\n{label}\nТема: {topic}\nСтатус: {status}{tail}"
|
||||||
|
|
||||||
|
|
||||||
|
def _active_admin_ids(db: Session, *, exclude_admin_user_id: uuid.UUID | None = None) -> list[uuid.UUID]:
|
||||||
|
try:
|
||||||
|
rows = (
|
||||||
|
db.query(AdminUser.id)
|
||||||
|
.filter(
|
||||||
|
AdminUser.role == "ADMIN",
|
||||||
|
AdminUser.is_active.is_(True),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
except SQLAlchemyError:
|
||||||
|
# Some isolated tests bootstrap only a subset of tables.
|
||||||
|
return []
|
||||||
|
out: list[uuid.UUID] = []
|
||||||
|
for (admin_id,) in rows:
|
||||||
|
if not admin_id:
|
||||||
|
continue
|
||||||
|
if exclude_admin_user_id is not None and admin_id == exclude_admin_user_id:
|
||||||
|
continue
|
||||||
|
out.append(admin_id)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _create_notification(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
request: Request,
|
||||||
|
recipient_type: str,
|
||||||
|
recipient_admin_user_id: uuid.UUID | None = None,
|
||||||
|
recipient_track_number: str | None = None,
|
||||||
|
event_type: str,
|
||||||
|
title: str,
|
||||||
|
body: str | None = None,
|
||||||
|
payload: dict[str, Any] | None = None,
|
||||||
|
responsible: str = "Система уведомлений",
|
||||||
|
dedupe_key: str | None = None,
|
||||||
|
) -> Notification | None:
|
||||||
|
recipient_kind = str(recipient_type or "").strip().upper()
|
||||||
|
if recipient_kind not in {RECIPIENT_CLIENT, RECIPIENT_ADMIN_USER}:
|
||||||
|
return None
|
||||||
|
if recipient_kind == RECIPIENT_CLIENT and not _normalize_track(recipient_track_number):
|
||||||
|
return None
|
||||||
|
if recipient_kind == RECIPIENT_ADMIN_USER and recipient_admin_user_id is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
normalized_dedupe = str(dedupe_key or "").strip() or None
|
||||||
|
if normalized_dedupe:
|
||||||
|
exists = db.query(Notification.id).filter(Notification.dedupe_key == normalized_dedupe).first()
|
||||||
|
if exists is not None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
row = Notification(
|
||||||
|
request_id=request.id,
|
||||||
|
recipient_type=recipient_kind,
|
||||||
|
recipient_admin_user_id=recipient_admin_user_id if recipient_kind == RECIPIENT_ADMIN_USER else None,
|
||||||
|
recipient_track_number=_normalize_track(recipient_track_number) if recipient_kind == RECIPIENT_CLIENT else None,
|
||||||
|
event_type=_normalized_event(event_type),
|
||||||
|
title=str(title or "").strip() or _title_for_event(event_type, request),
|
||||||
|
body=str(body or "").strip() or None,
|
||||||
|
payload=dict(payload or {}),
|
||||||
|
is_read=False,
|
||||||
|
read_at=None,
|
||||||
|
responsible=str(responsible or "").strip() or "Система уведомлений",
|
||||||
|
dedupe_key=normalized_dedupe,
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def notify_request_event(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
request: Request,
|
||||||
|
event_type: str,
|
||||||
|
actor_role: str,
|
||||||
|
actor_admin_user_id: str | uuid.UUID | None = None,
|
||||||
|
body: str | None = None,
|
||||||
|
responsible: str = "Система уведомлений",
|
||||||
|
send_telegram: bool = True,
|
||||||
|
dedupe_prefix: str | None = None,
|
||||||
|
) -> dict[str, int]:
|
||||||
|
event = _normalized_event(event_type)
|
||||||
|
actor = str(actor_role or "").strip().upper() or "SYSTEM"
|
||||||
|
actor_uuid = _as_uuid_or_none(actor_admin_user_id)
|
||||||
|
title = _title_for_event(event, request)
|
||||||
|
payload = {
|
||||||
|
"request_id": str(request.id),
|
||||||
|
"track_number": request.track_number,
|
||||||
|
"topic_code": request.topic_code,
|
||||||
|
"status_code": request.status_code,
|
||||||
|
"event_type": event,
|
||||||
|
"actor_role": actor,
|
||||||
|
}
|
||||||
|
|
||||||
|
internal_created = 0
|
||||||
|
telegram_sent = 0
|
||||||
|
|
||||||
|
def _dedupe_key_for(recipient_marker: str) -> str | None:
|
||||||
|
prefix = str(dedupe_prefix or "").strip()
|
||||||
|
if not prefix:
|
||||||
|
return None
|
||||||
|
return f"{prefix}:{recipient_marker}"
|
||||||
|
|
||||||
|
def _notify_client() -> None:
|
||||||
|
nonlocal internal_created
|
||||||
|
track = _normalize_track(request.track_number)
|
||||||
|
if not track:
|
||||||
|
return
|
||||||
|
dedupe_key = _dedupe_key_for(f"client:{track}")
|
||||||
|
row = _create_notification(
|
||||||
|
db,
|
||||||
|
request=request,
|
||||||
|
recipient_type=RECIPIENT_CLIENT,
|
||||||
|
recipient_track_number=track,
|
||||||
|
event_type=event,
|
||||||
|
title=title,
|
||||||
|
body=body,
|
||||||
|
payload=payload,
|
||||||
|
responsible=responsible,
|
||||||
|
dedupe_key=dedupe_key,
|
||||||
|
)
|
||||||
|
if row is not None:
|
||||||
|
internal_created += 1
|
||||||
|
|
||||||
|
def _notify_lawyer_if_any() -> None:
|
||||||
|
nonlocal internal_created
|
||||||
|
lawyer_uuid = _as_uuid_or_none(request.assigned_lawyer_id)
|
||||||
|
if lawyer_uuid is None:
|
||||||
|
return
|
||||||
|
if actor_uuid is not None and lawyer_uuid == actor_uuid:
|
||||||
|
return
|
||||||
|
dedupe_key = _dedupe_key_for(f"lawyer:{lawyer_uuid}")
|
||||||
|
row = _create_notification(
|
||||||
|
db,
|
||||||
|
request=request,
|
||||||
|
recipient_type=RECIPIENT_ADMIN_USER,
|
||||||
|
recipient_admin_user_id=lawyer_uuid,
|
||||||
|
event_type=event,
|
||||||
|
title=title,
|
||||||
|
body=body,
|
||||||
|
payload=payload,
|
||||||
|
responsible=responsible,
|
||||||
|
dedupe_key=dedupe_key,
|
||||||
|
)
|
||||||
|
if row is not None:
|
||||||
|
internal_created += 1
|
||||||
|
|
||||||
|
def _notify_admins() -> None:
|
||||||
|
nonlocal internal_created
|
||||||
|
admin_ids = _active_admin_ids(db, exclude_admin_user_id=actor_uuid)
|
||||||
|
for admin_id in admin_ids:
|
||||||
|
dedupe_key = _dedupe_key_for(f"admin:{admin_id}")
|
||||||
|
row = _create_notification(
|
||||||
|
db,
|
||||||
|
request=request,
|
||||||
|
recipient_type=RECIPIENT_ADMIN_USER,
|
||||||
|
recipient_admin_user_id=admin_id,
|
||||||
|
event_type=event,
|
||||||
|
title=title,
|
||||||
|
body=body,
|
||||||
|
payload=payload,
|
||||||
|
responsible=responsible,
|
||||||
|
dedupe_key=dedupe_key,
|
||||||
|
)
|
||||||
|
if row is not None:
|
||||||
|
internal_created += 1
|
||||||
|
|
||||||
|
if event in {EVENT_MESSAGE, EVENT_ATTACHMENT}:
|
||||||
|
if actor == "CLIENT":
|
||||||
|
_notify_lawyer_if_any()
|
||||||
|
_notify_admins()
|
||||||
|
else:
|
||||||
|
_notify_client()
|
||||||
|
elif event == EVENT_STATUS:
|
||||||
|
_notify_client()
|
||||||
|
if actor == "ADMIN":
|
||||||
|
_notify_lawyer_if_any()
|
||||||
|
elif event == EVENT_SLA_OVERDUE:
|
||||||
|
_notify_lawyer_if_any()
|
||||||
|
_notify_admins()
|
||||||
|
else:
|
||||||
|
_notify_client()
|
||||||
|
_notify_lawyer_if_any()
|
||||||
|
|
||||||
|
if send_telegram and internal_created > 0:
|
||||||
|
result = send_telegram_message(_telegram_text_for_event(event, request, body))
|
||||||
|
if bool(result.get("sent")):
|
||||||
|
telegram_sent += 1
|
||||||
|
|
||||||
|
return {"internal_created": int(internal_created), "telegram_sent": int(telegram_sent)}
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_notification(row: Notification) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": str(row.id),
|
||||||
|
"request_id": str(row.request_id) if row.request_id else None,
|
||||||
|
"recipient_type": row.recipient_type,
|
||||||
|
"recipient_admin_user_id": str(row.recipient_admin_user_id) if row.recipient_admin_user_id else None,
|
||||||
|
"recipient_track_number": row.recipient_track_number,
|
||||||
|
"event_type": row.event_type,
|
||||||
|
"title": row.title,
|
||||||
|
"body": row.body,
|
||||||
|
"payload": row.payload or {},
|
||||||
|
"is_read": bool(row.is_read),
|
||||||
|
"read_at": row.read_at.isoformat() if row.read_at else None,
|
||||||
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||||
|
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def mark_admin_notifications_read(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
admin_user_id: str | uuid.UUID,
|
||||||
|
request_id: uuid.UUID | None = None,
|
||||||
|
notification_id: uuid.UUID | None = None,
|
||||||
|
responsible: str = "Система уведомлений",
|
||||||
|
) -> int:
|
||||||
|
admin_uuid = _as_uuid_or_none(admin_user_id)
|
||||||
|
if admin_uuid is None:
|
||||||
|
return 0
|
||||||
|
query = db.query(Notification).filter(
|
||||||
|
Notification.recipient_type == RECIPIENT_ADMIN_USER,
|
||||||
|
Notification.recipient_admin_user_id == admin_uuid,
|
||||||
|
Notification.is_read.is_(False),
|
||||||
|
)
|
||||||
|
if request_id is not None:
|
||||||
|
query = query.filter(Notification.request_id == request_id)
|
||||||
|
if notification_id is not None:
|
||||||
|
query = query.filter(Notification.id == notification_id)
|
||||||
|
rows = query.all()
|
||||||
|
now = _as_utc_now()
|
||||||
|
for row in rows:
|
||||||
|
row.is_read = True
|
||||||
|
row.read_at = now
|
||||||
|
row.responsible = responsible
|
||||||
|
db.add(row)
|
||||||
|
return len(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def mark_client_notifications_read(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
track_number: str,
|
||||||
|
request_id: uuid.UUID | None = None,
|
||||||
|
notification_id: uuid.UUID | None = None,
|
||||||
|
responsible: str = "Клиент",
|
||||||
|
) -> int:
|
||||||
|
track = _normalize_track(track_number)
|
||||||
|
if not track:
|
||||||
|
return 0
|
||||||
|
query = db.query(Notification).filter(
|
||||||
|
Notification.recipient_type == RECIPIENT_CLIENT,
|
||||||
|
Notification.recipient_track_number == track,
|
||||||
|
Notification.is_read.is_(False),
|
||||||
|
)
|
||||||
|
if request_id is not None:
|
||||||
|
query = query.filter(Notification.request_id == request_id)
|
||||||
|
if notification_id is not None:
|
||||||
|
query = query.filter(Notification.id == notification_id)
|
||||||
|
rows = query.all()
|
||||||
|
now = _as_utc_now()
|
||||||
|
for row in rows:
|
||||||
|
row.is_read = True
|
||||||
|
row.read_at = now
|
||||||
|
row.responsible = responsible
|
||||||
|
db.add(row)
|
||||||
|
return len(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def list_admin_notifications(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
admin_user_id: str | uuid.UUID,
|
||||||
|
unread_only: bool = False,
|
||||||
|
request_id: uuid.UUID | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> tuple[list[Notification], int]:
|
||||||
|
admin_uuid = _as_uuid_or_none(admin_user_id)
|
||||||
|
if admin_uuid is None:
|
||||||
|
return [], 0
|
||||||
|
query = db.query(Notification).filter(
|
||||||
|
Notification.recipient_type == RECIPIENT_ADMIN_USER,
|
||||||
|
Notification.recipient_admin_user_id == admin_uuid,
|
||||||
|
)
|
||||||
|
if unread_only:
|
||||||
|
query = query.filter(Notification.is_read.is_(False))
|
||||||
|
if request_id is not None:
|
||||||
|
query = query.filter(Notification.request_id == request_id)
|
||||||
|
total = query.count()
|
||||||
|
rows = (
|
||||||
|
query.order_by(Notification.created_at.desc(), Notification.id.desc())
|
||||||
|
.offset(int(max(offset, 0)))
|
||||||
|
.limit(int(min(max(limit, 1), 200)))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return rows, int(total)
|
||||||
|
|
||||||
|
|
||||||
|
def list_client_notifications(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
track_number: str,
|
||||||
|
unread_only: bool = False,
|
||||||
|
request_id: uuid.UUID | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> tuple[list[Notification], int]:
|
||||||
|
track = _normalize_track(track_number)
|
||||||
|
if not track:
|
||||||
|
return [], 0
|
||||||
|
query = db.query(Notification).filter(
|
||||||
|
Notification.recipient_type == RECIPIENT_CLIENT,
|
||||||
|
Notification.recipient_track_number == track,
|
||||||
|
)
|
||||||
|
if unread_only:
|
||||||
|
query = query.filter(Notification.is_read.is_(False))
|
||||||
|
if request_id is not None:
|
||||||
|
query = query.filter(Notification.request_id == request_id)
|
||||||
|
total = query.count()
|
||||||
|
rows = (
|
||||||
|
query.order_by(Notification.created_at.desc(), Notification.id.desc())
|
||||||
|
.offset(int(max(offset, 0)))
|
||||||
|
.limit(int(min(max(limit, 1), 200)))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return rows, int(total)
|
||||||
|
|
||||||
|
|
||||||
|
def get_admin_notification(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
admin_user_id: str | uuid.UUID,
|
||||||
|
notification_id: uuid.UUID,
|
||||||
|
) -> Notification | None:
|
||||||
|
admin_uuid = _as_uuid_or_none(admin_user_id)
|
||||||
|
if admin_uuid is None:
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
db.query(Notification)
|
||||||
|
.filter(
|
||||||
|
Notification.id == notification_id,
|
||||||
|
Notification.recipient_type == RECIPIENT_ADMIN_USER,
|
||||||
|
Notification.recipient_admin_user_id == admin_uuid,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_client_notification(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
track_number: str,
|
||||||
|
notification_id: uuid.UUID,
|
||||||
|
) -> Notification | None:
|
||||||
|
track = _normalize_track(track_number)
|
||||||
|
if not track:
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
db.query(Notification)
|
||||||
|
.filter(
|
||||||
|
and_(
|
||||||
|
Notification.id == notification_id,
|
||||||
|
Notification.recipient_type == RECIPIENT_CLIENT,
|
||||||
|
Notification.recipient_track_number == track,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
31
app/services/request_read_markers.py
Normal file
31
app/services/request_read_markers.py
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.models.request import Request
|
||||||
|
|
||||||
|
EVENT_MESSAGE = "MESSAGE"
|
||||||
|
EVENT_ATTACHMENT = "ATTACHMENT"
|
||||||
|
EVENT_STATUS = "STATUS"
|
||||||
|
|
||||||
|
|
||||||
|
def mark_unread_for_client(request: Request, event_type: str) -> None:
|
||||||
|
request.client_has_unread_updates = True
|
||||||
|
request.client_unread_event_type = str(event_type or "").strip().upper() or None
|
||||||
|
|
||||||
|
|
||||||
|
def mark_unread_for_lawyer(request: Request, event_type: str) -> None:
|
||||||
|
request.lawyer_has_unread_updates = True
|
||||||
|
request.lawyer_unread_event_type = str(event_type or "").strip().upper() or None
|
||||||
|
|
||||||
|
|
||||||
|
def clear_unread_for_client(request: Request) -> bool:
|
||||||
|
changed = bool(request.client_has_unread_updates or request.client_unread_event_type)
|
||||||
|
request.client_has_unread_updates = False
|
||||||
|
request.client_unread_event_type = None
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def clear_unread_for_lawyer(request: Request) -> bool:
|
||||||
|
changed = bool(request.lawyer_has_unread_updates or request.lawyer_unread_event_type)
|
||||||
|
request.lawyer_has_unread_updates = False
|
||||||
|
request.lawyer_unread_event_type = None
|
||||||
|
return changed
|
||||||
82
app/services/request_status.py
Normal file
82
app/services/request_status.py
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.attachment import Attachment
|
||||||
|
from app.models.message import Message
|
||||||
|
from app.models.request import Request
|
||||||
|
from app.models.status_history import StatusHistory
|
||||||
|
|
||||||
|
|
||||||
|
def actor_admin_uuid(admin: dict[str, Any] | None) -> uuid.UUID | None:
|
||||||
|
if not admin:
|
||||||
|
return None
|
||||||
|
raw = str(admin.get("sub") or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return uuid.UUID(raw)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def freeze_request_messages_and_attachments(db: Session, request_id: uuid.UUID) -> None:
|
||||||
|
db.query(Message).filter(Message.request_id == request_id, Message.immutable.is_(False)).update(
|
||||||
|
{"immutable": True},
|
||||||
|
synchronize_session=False,
|
||||||
|
)
|
||||||
|
db.query(Attachment).filter(Attachment.request_id == request_id, Attachment.immutable.is_(False)).update(
|
||||||
|
{"immutable": True},
|
||||||
|
synchronize_session=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def register_status_history(
|
||||||
|
db: Session,
|
||||||
|
request: Request,
|
||||||
|
from_status: str,
|
||||||
|
to_status: str,
|
||||||
|
*,
|
||||||
|
admin: dict[str, Any] | None = None,
|
||||||
|
comment: str | None = None,
|
||||||
|
responsible: str = "Администратор системы",
|
||||||
|
) -> None:
|
||||||
|
db.add(
|
||||||
|
StatusHistory(
|
||||||
|
request_id=request.id,
|
||||||
|
from_status=str(from_status or "").strip() or None,
|
||||||
|
to_status=str(to_status or "").strip(),
|
||||||
|
changed_by_admin_id=actor_admin_uuid(admin),
|
||||||
|
comment=comment,
|
||||||
|
responsible=responsible,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_status_change_effects(
|
||||||
|
db: Session,
|
||||||
|
request: Request,
|
||||||
|
*,
|
||||||
|
from_status: str,
|
||||||
|
to_status: str,
|
||||||
|
admin: dict[str, Any] | None = None,
|
||||||
|
comment: str | None = None,
|
||||||
|
responsible: str = "Администратор системы",
|
||||||
|
) -> None:
|
||||||
|
old_code = str(from_status or "").strip()
|
||||||
|
new_code = str(to_status or "").strip()
|
||||||
|
if not new_code or old_code == new_code:
|
||||||
|
return
|
||||||
|
freeze_request_messages_and_attachments(db, request.id)
|
||||||
|
register_status_history(
|
||||||
|
db,
|
||||||
|
request,
|
||||||
|
old_code,
|
||||||
|
new_code,
|
||||||
|
admin=admin,
|
||||||
|
comment=comment,
|
||||||
|
responsible=responsible,
|
||||||
|
)
|
||||||
48
app/services/request_templates.py
Normal file
48
app/services/request_templates.py
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.topic_required_field import TopicRequiredField
|
||||||
|
|
||||||
|
|
||||||
|
def _is_missing_value(value) -> bool:
|
||||||
|
if value is None:
|
||||||
|
return True
|
||||||
|
if isinstance(value, str):
|
||||||
|
return not value.strip()
|
||||||
|
if isinstance(value, (list, tuple, dict, set)):
|
||||||
|
return len(value) == 0
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def validate_required_topic_fields_or_400(
|
||||||
|
db: Session,
|
||||||
|
topic_code: str | None,
|
||||||
|
extra_fields: dict | None,
|
||||||
|
) -> None:
|
||||||
|
topic = str(topic_code or "").strip()
|
||||||
|
if not topic:
|
||||||
|
return
|
||||||
|
|
||||||
|
required_rows = (
|
||||||
|
db.query(TopicRequiredField.field_key)
|
||||||
|
.filter(
|
||||||
|
TopicRequiredField.topic_code == topic,
|
||||||
|
TopicRequiredField.enabled.is_(True),
|
||||||
|
TopicRequiredField.required.is_(True),
|
||||||
|
)
|
||||||
|
.order_by(TopicRequiredField.sort_order.asc(), TopicRequiredField.field_key.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
required_keys = [str(field_key).strip() for (field_key,) in required_rows if field_key]
|
||||||
|
if not required_keys:
|
||||||
|
return
|
||||||
|
|
||||||
|
payload = extra_fields if isinstance(extra_fields, dict) else {}
|
||||||
|
missing = [key for key in required_keys if _is_missing_value(payload.get(key))]
|
||||||
|
if missing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Для выбранной темы обязательны поля: " + ", ".join(missing),
|
||||||
|
)
|
||||||
75
app/services/s3_storage.py
Normal file
75
app/services/s3_storage.py
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from functools import lru_cache
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
from botocore.exceptions import ClientError
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_file_name(file_name: str) -> str:
|
||||||
|
raw = str(file_name or "").strip() or "file.bin"
|
||||||
|
return re.sub(r"[^A-Za-z0-9._-]+", "_", raw)
|
||||||
|
|
||||||
|
|
||||||
|
def build_object_key(prefix: str, file_name: str) -> str:
|
||||||
|
safe_name = _safe_file_name(file_name)
|
||||||
|
return f"{prefix.strip('/')}/{uuid.uuid4().hex}-{safe_name}"
|
||||||
|
|
||||||
|
|
||||||
|
class S3Storage:
|
||||||
|
def __init__(self):
|
||||||
|
self.bucket = settings.S3_BUCKET
|
||||||
|
self.client = boto3.client(
|
||||||
|
"s3",
|
||||||
|
endpoint_url=settings.S3_ENDPOINT,
|
||||||
|
aws_access_key_id=settings.S3_ACCESS_KEY,
|
||||||
|
aws_secret_access_key=settings.S3_SECRET_KEY,
|
||||||
|
region_name=settings.S3_REGION,
|
||||||
|
use_ssl=settings.S3_USE_SSL,
|
||||||
|
)
|
||||||
|
self._bucket_checked = False
|
||||||
|
|
||||||
|
def ensure_bucket(self) -> None:
|
||||||
|
if self._bucket_checked:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.client.head_bucket(Bucket=self.bucket)
|
||||||
|
except ClientError as exc:
|
||||||
|
code = str(exc.response.get("Error", {}).get("Code", ""))
|
||||||
|
if code not in {"404", "NoSuchBucket", "NotFound"}:
|
||||||
|
raise
|
||||||
|
kwargs: dict = {"Bucket": self.bucket}
|
||||||
|
if settings.S3_REGION and settings.S3_REGION != "us-east-1":
|
||||||
|
kwargs["CreateBucketConfiguration"] = {"LocationConstraint": settings.S3_REGION}
|
||||||
|
self.client.create_bucket(**kwargs)
|
||||||
|
self._bucket_checked = True
|
||||||
|
|
||||||
|
def create_presigned_put_url(self, key: str, mime_type: str, expires_sec: int = 900) -> str:
|
||||||
|
self.ensure_bucket()
|
||||||
|
return self.client.generate_presigned_url(
|
||||||
|
"put_object",
|
||||||
|
Params={"Bucket": self.bucket, "Key": key, "ContentType": mime_type},
|
||||||
|
ExpiresIn=expires_sec,
|
||||||
|
HttpMethod="PUT",
|
||||||
|
)
|
||||||
|
|
||||||
|
def head_object(self, key: str) -> dict:
|
||||||
|
self.ensure_bucket()
|
||||||
|
return self.client.head_object(Bucket=self.bucket, Key=key)
|
||||||
|
|
||||||
|
def get_object(self, key: str) -> dict:
|
||||||
|
self.ensure_bucket()
|
||||||
|
return self.client.get_object(Bucket=self.bucket, Key=key)
|
||||||
|
|
||||||
|
def get_avatar_proxy_path(self, key: str, token: str) -> str:
|
||||||
|
return "/api/admin/uploads/object/" + quote(key, safe="") + "?token=" + quote(token, safe="")
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def get_s3_storage() -> S3Storage:
|
||||||
|
return S3Storage()
|
||||||
180
app/services/sla_metrics.py
Normal file
180
app/services/sla_metrics.py
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.message import Message
|
||||||
|
from app.models.request import Request
|
||||||
|
from app.models.status import Status
|
||||||
|
from app.models.status_history import StatusHistory
|
||||||
|
from app.models.topic_status_transition import TopicStatusTransition
|
||||||
|
|
||||||
|
DEFAULT_TERMINAL_STATUS_CODES = {"RESOLVED", "CLOSED", "REJECTED"}
|
||||||
|
DEFAULT_SLA_HOURS_BY_STATUS = {
|
||||||
|
"NEW": 24,
|
||||||
|
"IN_PROGRESS": 72,
|
||||||
|
"WAITING_CLIENT": 168,
|
||||||
|
"WAITING_COURT": 336,
|
||||||
|
}
|
||||||
|
DEFAULT_SLA_HOURS = 72
|
||||||
|
|
||||||
|
|
||||||
|
def _terminal_status_codes(db: Session) -> set[str]:
|
||||||
|
rows = db.query(Status.code).filter(Status.is_terminal.is_(True)).all()
|
||||||
|
codes = {str(code).strip() for (code,) in rows if code}
|
||||||
|
return codes or set(DEFAULT_TERMINAL_STATUS_CODES)
|
||||||
|
|
||||||
|
|
||||||
|
def _as_utc(value: datetime | None, fallback: datetime) -> datetime:
|
||||||
|
if value is None:
|
||||||
|
return fallback
|
||||||
|
if value.tzinfo is None:
|
||||||
|
return value.replace(tzinfo=timezone.utc)
|
||||||
|
return value.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_topic_sla_maps(db: Session) -> tuple[dict[tuple[str, str], int], dict[tuple[str, str, str], int]]:
|
||||||
|
rows = (
|
||||||
|
db.query(
|
||||||
|
TopicStatusTransition.topic_code,
|
||||||
|
TopicStatusTransition.from_status,
|
||||||
|
TopicStatusTransition.to_status,
|
||||||
|
TopicStatusTransition.sla_hours,
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
TopicStatusTransition.enabled.is_(True),
|
||||||
|
TopicStatusTransition.sla_hours.is_not(None),
|
||||||
|
TopicStatusTransition.sla_hours > 0,
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
outgoing_sla: dict[tuple[str, str], int] = {}
|
||||||
|
exact_sla: dict[tuple[str, str, str], int] = {}
|
||||||
|
for topic_code, from_status, to_status, sla_hours in rows:
|
||||||
|
topic = str(topic_code or "").strip()
|
||||||
|
from_code = str(from_status or "").strip()
|
||||||
|
to_code = str(to_status or "").strip()
|
||||||
|
if not topic or not from_code or not to_code:
|
||||||
|
continue
|
||||||
|
sla = int(sla_hours or 0)
|
||||||
|
if sla <= 0:
|
||||||
|
continue
|
||||||
|
exact_sla[(topic, from_code, to_code)] = sla
|
||||||
|
key = (topic, from_code)
|
||||||
|
if key not in outgoing_sla or sla < outgoing_sla[key]:
|
||||||
|
outgoing_sla[key] = sla
|
||||||
|
return outgoing_sla, exact_sla
|
||||||
|
|
||||||
|
|
||||||
|
def _current_status_started_at(req: Request, request_rows: list[StatusHistory], now_utc: datetime) -> datetime:
|
||||||
|
current_status = str(req.status_code or "").strip()
|
||||||
|
if current_status and request_rows:
|
||||||
|
for row in reversed(request_rows):
|
||||||
|
if str(row.to_status or "").strip() == current_status:
|
||||||
|
return _as_utc(row.created_at, now_utc)
|
||||||
|
return _as_utc(req.updated_at or req.created_at, now_utc)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_sla_snapshot(
|
||||||
|
db: Session,
|
||||||
|
now: datetime | None = None,
|
||||||
|
*,
|
||||||
|
include_overdue_requests: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
now_utc = _as_utc(now, datetime.now(timezone.utc))
|
||||||
|
terminal_codes = _terminal_status_codes(db)
|
||||||
|
active_requests = db.query(Request).filter(Request.status_code.notin_(terminal_codes)).all()
|
||||||
|
|
||||||
|
status_rows = db.query(StatusHistory).order_by(StatusHistory.request_id.asc(), StatusHistory.created_at.asc()).all()
|
||||||
|
rows_by_request: dict[str, list[StatusHistory]] = defaultdict(list)
|
||||||
|
for row in status_rows:
|
||||||
|
rows_by_request[str(row.request_id)].append(row)
|
||||||
|
|
||||||
|
outgoing_sla_map, _ = _load_topic_sla_maps(db)
|
||||||
|
|
||||||
|
overdue_by_status: dict[str, int] = defaultdict(int)
|
||||||
|
overdue_by_transition: dict[str, int] = defaultdict(int)
|
||||||
|
overdue_requests: list[dict[str, Any]] = []
|
||||||
|
for req in active_requests:
|
||||||
|
status_code = str(req.status_code or "").strip() or "UNKNOWN"
|
||||||
|
topic_code = str(req.topic_code or "").strip()
|
||||||
|
threshold_hours = outgoing_sla_map.get(
|
||||||
|
(topic_code, status_code),
|
||||||
|
int(DEFAULT_SLA_HOURS_BY_STATUS.get(status_code, DEFAULT_SLA_HOURS)),
|
||||||
|
)
|
||||||
|
status_started_at = _current_status_started_at(req, rows_by_request.get(str(req.id), []), now_utc)
|
||||||
|
hours_in_status = (now_utc - status_started_at).total_seconds() / 3600.0
|
||||||
|
if hours_in_status > threshold_hours:
|
||||||
|
overdue_by_status[status_code] += 1
|
||||||
|
transition_key = f"{topic_code or '*'}:{status_code}->*"
|
||||||
|
overdue_by_transition[transition_key] += 1
|
||||||
|
if include_overdue_requests:
|
||||||
|
overdue_requests.append(
|
||||||
|
{
|
||||||
|
"request_id": str(req.id),
|
||||||
|
"track_number": req.track_number,
|
||||||
|
"topic_code": req.topic_code,
|
||||||
|
"status_code": req.status_code,
|
||||||
|
"assigned_lawyer_id": req.assigned_lawyer_id,
|
||||||
|
"hours_in_status": round(hours_in_status, 2),
|
||||||
|
"threshold_hours": int(threshold_hours),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
first_response_rows = (
|
||||||
|
db.query(Message.request_id, Message.created_at)
|
||||||
|
.filter(Message.author_type == "LAWYER")
|
||||||
|
.order_by(Message.request_id.asc(), Message.created_at.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
first_response_map = {}
|
||||||
|
for request_id, created_at in first_response_rows:
|
||||||
|
key = str(request_id)
|
||||||
|
if key not in first_response_map and created_at is not None:
|
||||||
|
first_response_map[key] = created_at
|
||||||
|
|
||||||
|
frt_minutes: list[float] = []
|
||||||
|
for req in active_requests:
|
||||||
|
first_response_at = first_response_map.get(str(req.id))
|
||||||
|
if not first_response_at or not req.created_at:
|
||||||
|
continue
|
||||||
|
first_dt = _as_utc(first_response_at, now_utc)
|
||||||
|
req_created = _as_utc(req.created_at, now_utc)
|
||||||
|
delta_min = (first_dt - req_created).total_seconds() / 60.0
|
||||||
|
if delta_min >= 0:
|
||||||
|
frt_minutes.append(delta_min)
|
||||||
|
|
||||||
|
durations_by_status: dict[str, list[float]] = defaultdict(list)
|
||||||
|
for req in active_requests:
|
||||||
|
request_rows = rows_by_request.get(str(req.id), [])
|
||||||
|
if not request_rows:
|
||||||
|
started_at = _as_utc(req.created_at, now_utc)
|
||||||
|
status_code = str(req.status_code or "").strip() or "UNKNOWN"
|
||||||
|
durations_by_status[status_code].append(max((now_utc - started_at).total_seconds() / 3600.0, 0.0))
|
||||||
|
continue
|
||||||
|
|
||||||
|
for idx, row in enumerate(request_rows):
|
||||||
|
start = _as_utc(row.created_at, now_utc)
|
||||||
|
end_raw = request_rows[idx + 1].created_at if idx + 1 < len(request_rows) else now_utc
|
||||||
|
end = _as_utc(end_raw, now_utc)
|
||||||
|
status_code = str(row.to_status or "").strip() or "UNKNOWN"
|
||||||
|
duration_hours = max((end - start).total_seconds() / 3600.0, 0.0)
|
||||||
|
durations_by_status[status_code].append(duration_hours)
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"checked_active_requests": int(len(active_requests)),
|
||||||
|
"overdue_total": int(sum(overdue_by_status.values())),
|
||||||
|
"overdue_by_status": dict(overdue_by_status),
|
||||||
|
"overdue_by_transition": dict(overdue_by_transition),
|
||||||
|
"frt_avg_minutes": round(sum(frt_minutes) / len(frt_minutes), 2) if frt_minutes else None,
|
||||||
|
"avg_time_in_status_hours": {
|
||||||
|
code: round(sum(values) / len(values), 2) for code, values in durations_by_status.items() if values
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if include_overdue_requests:
|
||||||
|
result["overdue_requests"] = overdue_requests
|
||||||
|
return result
|
||||||
46
app/services/status_flow.py
Normal file
46
app/services/status_flow.py
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.topic_status_transition import TopicStatusTransition
|
||||||
|
|
||||||
|
|
||||||
|
def transition_allowed_for_topic(
|
||||||
|
db: Session,
|
||||||
|
topic_code: str | None,
|
||||||
|
from_status: str,
|
||||||
|
to_status: str,
|
||||||
|
) -> bool:
|
||||||
|
from_code = str(from_status or "").strip()
|
||||||
|
to_code = str(to_status or "").strip()
|
||||||
|
if not from_code or not to_code:
|
||||||
|
return False
|
||||||
|
if from_code == to_code:
|
||||||
|
return True
|
||||||
|
|
||||||
|
topic = str(topic_code or "").strip()
|
||||||
|
if not topic:
|
||||||
|
return True
|
||||||
|
|
||||||
|
has_any_rules = (
|
||||||
|
db.query(TopicStatusTransition.id)
|
||||||
|
.filter(
|
||||||
|
TopicStatusTransition.topic_code == topic,
|
||||||
|
TopicStatusTransition.enabled.is_(True),
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if has_any_rules is None:
|
||||||
|
return True
|
||||||
|
|
||||||
|
matched = (
|
||||||
|
db.query(TopicStatusTransition.id)
|
||||||
|
.filter(
|
||||||
|
TopicStatusTransition.topic_code == topic,
|
||||||
|
TopicStatusTransition.from_status == from_code,
|
||||||
|
TopicStatusTransition.to_status == to_code,
|
||||||
|
TopicStatusTransition.enabled.is_(True),
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
return matched is not None
|
||||||
51
app/services/telegram_notify.py
Normal file
51
app/services/telegram_notify.py
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def _telegram_enabled() -> bool:
|
||||||
|
token = str(settings.TELEGRAM_BOT_TOKEN or "").strip()
|
||||||
|
chat_id = str(settings.TELEGRAM_CHAT_ID or "").strip()
|
||||||
|
if not token or token == "change_me":
|
||||||
|
return False
|
||||||
|
if not chat_id or chat_id == "0":
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def send_telegram_message(text: str) -> dict[str, Any]:
|
||||||
|
payload_text = str(text or "").strip()
|
||||||
|
if not payload_text:
|
||||||
|
return {"ok": False, "sent": False, "reason": "empty_text"}
|
||||||
|
|
||||||
|
if not _telegram_enabled():
|
||||||
|
# Dev-safe fallback: show delivery payload in logs.
|
||||||
|
print(f"[TELEGRAM MOCK] {payload_text}")
|
||||||
|
return {"ok": True, "sent": False, "mocked": True}
|
||||||
|
|
||||||
|
token = str(settings.TELEGRAM_BOT_TOKEN).strip()
|
||||||
|
chat_id = str(settings.TELEGRAM_CHAT_ID).strip()
|
||||||
|
url = f"https://api.telegram.org/bot{token}/sendMessage"
|
||||||
|
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=5.0) as client:
|
||||||
|
response = client.post(
|
||||||
|
url,
|
||||||
|
json={
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"text": payload_text,
|
||||||
|
"disable_web_page_preview": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
data = response.json() if response.content else {}
|
||||||
|
if response.status_code >= 400 or not bool(data.get("ok")):
|
||||||
|
print(f"[TELEGRAM ERROR] status={response.status_code} body={data}")
|
||||||
|
return {"ok": False, "sent": False, "status_code": response.status_code, "response": data}
|
||||||
|
return {"ok": True, "sent": True}
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[TELEGRAM ERROR] {exc}")
|
||||||
|
return {"ok": False, "sent": False, "error": str(exc)}
|
||||||
|
|
@ -1,26 +1,45 @@
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
from sqlalchemy.orm import Query
|
from sqlalchemy.orm import Query
|
||||||
from sqlalchemy import asc, desc
|
from sqlalchemy import asc, desc
|
||||||
|
|
||||||
from app.schemas.universal import UniversalQuery
|
from app.schemas.universal import UniversalQuery
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_filter_value(column, value):
|
||||||
|
try:
|
||||||
|
python_type = column.property.columns[0].type.python_type
|
||||||
|
except Exception:
|
||||||
|
return value
|
||||||
|
if python_type is uuid.UUID and isinstance(value, str):
|
||||||
|
try:
|
||||||
|
return uuid.UUID(value)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail=f'Некорректный UUID в фильтре поля "{column.key}"')
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
def apply_universal_query(q: Query, model, uq: UniversalQuery) -> Query:
|
def apply_universal_query(q: Query, model, uq: UniversalQuery) -> Query:
|
||||||
for f in uq.filters:
|
for f in uq.filters:
|
||||||
col = getattr(model, f.field, None)
|
col = getattr(model, f.field, None)
|
||||||
if col is None:
|
if col is None:
|
||||||
continue
|
continue
|
||||||
|
value = _coerce_filter_value(col, f.value)
|
||||||
if f.op == "=":
|
if f.op == "=":
|
||||||
q = q.filter(col == f.value)
|
q = q.filter(col == value)
|
||||||
elif f.op == "!=":
|
elif f.op == "!=":
|
||||||
q = q.filter(col != f.value)
|
q = q.filter(col != value)
|
||||||
elif f.op == ">":
|
elif f.op == ">":
|
||||||
q = q.filter(col > f.value)
|
q = q.filter(col > value)
|
||||||
elif f.op == "<":
|
elif f.op == "<":
|
||||||
q = q.filter(col < f.value)
|
q = q.filter(col < value)
|
||||||
elif f.op == ">=":
|
elif f.op == ">=":
|
||||||
q = q.filter(col >= f.value)
|
q = q.filter(col >= value)
|
||||||
elif f.op == "<=":
|
elif f.op == "<=":
|
||||||
q = q.filter(col <= f.value)
|
q = q.filter(col <= value)
|
||||||
elif f.op == "~":
|
elif f.op == "~":
|
||||||
q = q.filter(col.ilike(f"%{f.value}%"))
|
q = q.filter(col.ilike(f"%{value}%"))
|
||||||
for s in uq.sort:
|
for s in uq.sort:
|
||||||
col = getattr(model, s.field, None)
|
col = getattr(model, s.field, None)
|
||||||
if col is None:
|
if col is None:
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,21 @@
|
||||||
color: #fde5c2;
|
color: #fde5c2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.menu-tree {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding-left: 0.6rem;
|
||||||
|
border-left: 1px dashed rgba(212, 168, 106, 0.3);
|
||||||
|
margin: 0.2rem 0 0.1rem 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-tree button {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.52rem 0.62rem;
|
||||||
|
color: #c8d8ea;
|
||||||
|
}
|
||||||
|
|
||||||
.auth-box {
|
.auth-box {
|
||||||
margin-top: 1.2rem;
|
margin-top: 1.2rem;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
|
|
@ -309,6 +324,18 @@
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.field-inline {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 0.5rem 0.68rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
input, textarea, select {
|
input, textarea, select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 1px solid #3c4d62;
|
border: 1px solid #3c4d62;
|
||||||
|
|
@ -380,12 +407,92 @@
|
||||||
color: #f7dfb8;
|
color: #f7dfb8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #f5f8ff;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.24);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-identity {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.52rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-identity-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-identity-text b {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #eaf2fd;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 230px;
|
||||||
|
}
|
||||||
|
|
||||||
.table-actions {
|
.table-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.request-updates-stack {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.24rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-update-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.32rem;
|
||||||
|
border: 1px solid rgba(95, 182, 145, 0.34);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(77, 190, 147, 0.14);
|
||||||
|
color: #bef5df;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
padding: 0.18rem 0.45rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-update-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #3ed692;
|
||||||
|
box-shadow: 0 0 0 3px rgba(62, 214, 146, 0.18);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-update-empty {
|
||||||
|
color: #8ea1b8;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
.icon-btn {
|
.icon-btn {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
|
|
@ -472,54 +579,10 @@
|
||||||
|
|
||||||
.config-layout {
|
.config-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 260px 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 0.85rem;
|
gap: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-tree {
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 12px;
|
|
||||||
background: rgba(255, 255, 255, 0.02);
|
|
||||||
padding: 0.75rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.45rem;
|
|
||||||
align-self: start;
|
|
||||||
position: sticky;
|
|
||||||
top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-title {
|
|
||||||
margin: 0 0 0.2rem;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
color: #8da2bc;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-node {
|
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: rgba(255, 255, 255, 0.02);
|
|
||||||
color: #d7e4f5;
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: left;
|
|
||||||
padding: 0.52rem 0.6rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-node:hover {
|
|
||||||
border-color: var(--line);
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-node.active {
|
|
||||||
border-color: rgba(212, 168, 106, 0.5);
|
|
||||||
background: var(--brand-soft);
|
|
||||||
color: #f7dfb8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.config-panel {
|
.config-panel {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
@ -635,7 +698,6 @@
|
||||||
.filters { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.filters { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
.triple { grid-template-columns: 1fr; }
|
.triple { grid-template-columns: 1fr; }
|
||||||
.config-layout { grid-template-columns: 1fr; }
|
.config-layout { grid-template-columns: 1fr; }
|
||||||
.config-tree { position: static; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 920px) {
|
@media (max-width: 920px) {
|
||||||
|
|
|
||||||
1485
app/web/admin.jsx
1485
app/web/admin.jsx
File diff suppressed because it is too large
Load diff
|
|
@ -519,6 +519,102 @@
|
||||||
.status.ok { color: var(--ok); }
|
.status.ok { color: var(--ok); }
|
||||||
.status.error { color: var(--danger); }
|
.status.error { color: var(--danger); }
|
||||||
|
|
||||||
|
.cabinet-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cabinet-card {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: linear-gradient(160deg, rgba(23, 32, 42, 0.9), rgba(17, 24, 33, 0.95));
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cabinet-card h3 {
|
||||||
|
margin: 0 0 0.65rem;
|
||||||
|
font-size: 1.03rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cabinet-meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-row {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.58rem 0.65rem;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-row small {
|
||||||
|
display: block;
|
||||||
|
color: #9fb0c6;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-row b {
|
||||||
|
display: block;
|
||||||
|
color: #eaf2ff;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
max-height: 280px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-item {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.58rem 0.65rem;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-item p {
|
||||||
|
margin: 0.24rem 0 0;
|
||||||
|
color: #d8e3f3;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-item time {
|
||||||
|
color: #9eb1ca;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-form {
|
||||||
|
margin-top: 0.7rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-form textarea {
|
||||||
|
min-height: 84px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-row {
|
||||||
|
margin-top: 0.7rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes rise {
|
@keyframes rise {
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
|
@ -552,6 +648,14 @@
|
||||||
.form {
|
.form {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cabinet-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cabinet-meta {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
@ -563,6 +667,7 @@
|
||||||
<a href="#practices">Компетенции</a>
|
<a href="#practices">Компетенции</a>
|
||||||
<a href="#approach">Подход</a>
|
<a href="#approach">Подход</a>
|
||||||
<a href="#expert">Эксперт</a>
|
<a href="#expert">Эксперт</a>
|
||||||
|
<a href="#cabinet">Кабинет клиента</a>
|
||||||
<a href="/admin" class="btn btn-ghost">Админ-панель</a>
|
<a href="/admin" class="btn btn-ghost">Админ-панель</a>
|
||||||
<button class="btn btn-ghost" type="button" data-open-modal>Оставить заявку</button>
|
<button class="btn btn-ghost" type="button" data-open-modal>Оставить заявку</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
@ -691,6 +796,71 @@
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section id="cabinet">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<h2>Кабинет клиента</h2>
|
||||||
|
<p class="subtitle">Введите номер заявки, подтвердите доступ по OTP и отслеживайте статус, переписку и файлы в одном окне.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cabinet-layout">
|
||||||
|
<article class="cabinet-card">
|
||||||
|
<h3>Доступ по номеру заявки</h3>
|
||||||
|
<div class="field">
|
||||||
|
<label for="cabinet-track">Номер заявки</label>
|
||||||
|
<input id="cabinet-track" type="text" placeholder="TRK-XXXXXXXXXX">
|
||||||
|
</div>
|
||||||
|
<div class="form-foot">
|
||||||
|
<button class="btn btn-primary" id="cabinet-open" type="button">Открыть кабинет</button>
|
||||||
|
<p class="status" id="cabinet-status"></p>
|
||||||
|
</div>
|
||||||
|
<div id="cabinet-summary" hidden>
|
||||||
|
<div class="cabinet-meta">
|
||||||
|
<div class="meta-row">
|
||||||
|
<small>Статус</small>
|
||||||
|
<b id="cabinet-request-status">-</b>
|
||||||
|
</div>
|
||||||
|
<div class="meta-row">
|
||||||
|
<small>Тема</small>
|
||||||
|
<b id="cabinet-request-topic">-</b>
|
||||||
|
</div>
|
||||||
|
<div class="meta-row">
|
||||||
|
<small>Создана</small>
|
||||||
|
<b id="cabinet-request-created">-</b>
|
||||||
|
</div>
|
||||||
|
<div class="meta-row">
|
||||||
|
<small>Обновлена</small>
|
||||||
|
<b id="cabinet-request-updated">-</b>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="cabinet-card">
|
||||||
|
<h3>Чат с юристом</h3>
|
||||||
|
<ul class="simple-list" id="cabinet-messages"></ul>
|
||||||
|
<form class="chat-form" id="cabinet-chat-form">
|
||||||
|
<textarea id="cabinet-chat-body" placeholder="Введите сообщение" disabled></textarea>
|
||||||
|
<button class="btn btn-ghost" type="submit" id="cabinet-chat-send" disabled>Отправить сообщение</button>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="cabinet-card">
|
||||||
|
<h3>Файлы по заявке</h3>
|
||||||
|
<ul class="simple-list" id="cabinet-files"></ul>
|
||||||
|
<div class="file-row">
|
||||||
|
<input id="cabinet-file-input" type="file" disabled>
|
||||||
|
<button class="btn btn-ghost" id="cabinet-file-upload" type="button" disabled>Загрузить файл</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="cabinet-card">
|
||||||
|
<h3>История изменений</h3>
|
||||||
|
<ul class="simple-list" id="cabinet-timeline"></ul>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="cta-band">
|
<section class="cta-band">
|
||||||
<p>
|
<p>
|
||||||
Если вы пришли на сайт по рекомендации, укажите имя рекомендателя при отправке заявки.
|
Если вы пришли на сайт по рекомендации, укажите имя рекомендателя при отправке заявки.
|
||||||
|
|
@ -747,6 +917,25 @@
|
||||||
const status = document.getElementById("form-status");
|
const status = document.getElementById("form-status");
|
||||||
const quoteText = document.getElementById("quote-text");
|
const quoteText = document.getElementById("quote-text");
|
||||||
const quoteMeta = document.getElementById("quote-meta");
|
const quoteMeta = document.getElementById("quote-meta");
|
||||||
|
const cabinetTrackInput = document.getElementById("cabinet-track");
|
||||||
|
const cabinetOpenButton = document.getElementById("cabinet-open");
|
||||||
|
const cabinetStatus = document.getElementById("cabinet-status");
|
||||||
|
const cabinetSummary = document.getElementById("cabinet-summary");
|
||||||
|
const cabinetRequestStatus = document.getElementById("cabinet-request-status");
|
||||||
|
const cabinetRequestTopic = document.getElementById("cabinet-request-topic");
|
||||||
|
const cabinetRequestCreated = document.getElementById("cabinet-request-created");
|
||||||
|
const cabinetRequestUpdated = document.getElementById("cabinet-request-updated");
|
||||||
|
const cabinetMessages = document.getElementById("cabinet-messages");
|
||||||
|
const cabinetFiles = document.getElementById("cabinet-files");
|
||||||
|
const cabinetTimeline = document.getElementById("cabinet-timeline");
|
||||||
|
const cabinetChatForm = document.getElementById("cabinet-chat-form");
|
||||||
|
const cabinetChatBody = document.getElementById("cabinet-chat-body");
|
||||||
|
const cabinetChatSend = document.getElementById("cabinet-chat-send");
|
||||||
|
const cabinetFileInput = document.getElementById("cabinet-file-input");
|
||||||
|
const cabinetFileUpload = document.getElementById("cabinet-file-upload");
|
||||||
|
|
||||||
|
let activeTrack = "";
|
||||||
|
let activeRequestId = "";
|
||||||
|
|
||||||
function openModal() {
|
function openModal() {
|
||||||
modal.classList.add("open");
|
modal.classList.add("open");
|
||||||
|
|
@ -771,6 +960,136 @@
|
||||||
if (event.key === "Escape" && modal.classList.contains("open")) closeModal();
|
if (event.key === "Escape" && modal.classList.contains("open")) closeModal();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function formatDate(value) {
|
||||||
|
if (!value) return "-";
|
||||||
|
try {
|
||||||
|
const dt = new Date(value);
|
||||||
|
if (Number.isNaN(dt.getTime())) return value;
|
||||||
|
return dt.toLocaleString("ru-RU");
|
||||||
|
} catch (_) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(el, message, kind) {
|
||||||
|
el.className = "status";
|
||||||
|
if (kind === "ok") el.classList.add("ok");
|
||||||
|
if (kind === "error") el.classList.add("error");
|
||||||
|
el.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseJsonSafe(response) {
|
||||||
|
try {
|
||||||
|
return await response.json();
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiErrorDetail(data, fallbackMessage) {
|
||||||
|
if (data && typeof data.detail === "string" && data.detail.trim()) return data.detail;
|
||||||
|
return fallbackMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCabinetEnabled(enabled) {
|
||||||
|
cabinetChatBody.disabled = !enabled;
|
||||||
|
cabinetChatSend.disabled = !enabled;
|
||||||
|
cabinetFileInput.disabled = !enabled;
|
||||||
|
cabinetFileUpload.disabled = !enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearList(node, emptyMessage) {
|
||||||
|
node.innerHTML = "";
|
||||||
|
const li = document.createElement("li");
|
||||||
|
li.className = "simple-item";
|
||||||
|
const p = document.createElement("p");
|
||||||
|
p.textContent = emptyMessage;
|
||||||
|
li.appendChild(p);
|
||||||
|
node.appendChild(li);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMessages(items) {
|
||||||
|
cabinetMessages.innerHTML = "";
|
||||||
|
if (!Array.isArray(items) || items.length === 0) {
|
||||||
|
clearList(cabinetMessages, "Сообщений пока нет.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
items.forEach((item) => {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
li.className = "simple-item";
|
||||||
|
|
||||||
|
const time = document.createElement("time");
|
||||||
|
time.textContent = formatDate(item.created_at);
|
||||||
|
li.appendChild(time);
|
||||||
|
|
||||||
|
const p = document.createElement("p");
|
||||||
|
const author = item.author_name || item.author_type || "Участник";
|
||||||
|
p.textContent = author + ": " + (item.body || "");
|
||||||
|
li.appendChild(p);
|
||||||
|
cabinetMessages.appendChild(li);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFiles(items) {
|
||||||
|
cabinetFiles.innerHTML = "";
|
||||||
|
if (!Array.isArray(items) || items.length === 0) {
|
||||||
|
clearList(cabinetFiles, "Файлы пока не загружены.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
items.forEach((item) => {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
li.className = "simple-item";
|
||||||
|
|
||||||
|
const time = document.createElement("time");
|
||||||
|
time.textContent = formatDate(item.created_at);
|
||||||
|
li.appendChild(time);
|
||||||
|
|
||||||
|
const p = document.createElement("p");
|
||||||
|
const sizeKb = Math.max(1, Math.round(Number(item.size_bytes || 0) / 1024));
|
||||||
|
p.textContent = item.file_name + " (" + sizeKb + " КБ)";
|
||||||
|
li.appendChild(p);
|
||||||
|
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = item.download_url;
|
||||||
|
link.textContent = "Открыть / скачать";
|
||||||
|
link.target = "_blank";
|
||||||
|
link.rel = "noopener noreferrer";
|
||||||
|
link.style.color = "#f6d7a8";
|
||||||
|
li.appendChild(link);
|
||||||
|
cabinetFiles.appendChild(li);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTimeline(items) {
|
||||||
|
cabinetTimeline.innerHTML = "";
|
||||||
|
if (!Array.isArray(items) || items.length === 0) {
|
||||||
|
clearList(cabinetTimeline, "История пока пуста.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
items.forEach((item) => {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
li.className = "simple-item";
|
||||||
|
|
||||||
|
const time = document.createElement("time");
|
||||||
|
time.textContent = formatDate(item.created_at);
|
||||||
|
li.appendChild(time);
|
||||||
|
|
||||||
|
const p = document.createElement("p");
|
||||||
|
if (item.type === "status_change") {
|
||||||
|
p.textContent = "Статус: " + (item.payload?.from_status || "NEW") + " -> " + (item.payload?.to_status || "-");
|
||||||
|
} else if (item.type === "message") {
|
||||||
|
const author = item.payload?.author_name || item.payload?.author_type || "Участник";
|
||||||
|
p.textContent = "Сообщение от " + author + ": " + (item.payload?.body || "");
|
||||||
|
} else if (item.type === "attachment") {
|
||||||
|
p.textContent = "Файл: " + (item.payload?.file_name || "вложение");
|
||||||
|
} else {
|
||||||
|
p.textContent = "Событие";
|
||||||
|
}
|
||||||
|
li.appendChild(p);
|
||||||
|
cabinetTimeline.appendChild(li);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function loadQuotes() {
|
async function loadQuotes() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/public/quotes?limit=8&order=random");
|
const response = await fetch("/api/public/quotes?limit=8&order=random");
|
||||||
|
|
@ -792,10 +1111,201 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchRequestByTrack(trackNumber) {
|
||||||
|
const response = await fetch("/api/public/requests/" + encodeURIComponent(trackNumber));
|
||||||
|
const data = await parseJsonSafe(response);
|
||||||
|
return { response, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureViewAccess(trackNumber) {
|
||||||
|
let { response, data } = await fetchRequestByTrack(trackNumber);
|
||||||
|
if (response.ok) return data;
|
||||||
|
|
||||||
|
if (response.status !== 401 && response.status !== 403) {
|
||||||
|
throw new Error(apiErrorDetail(data, "Не удалось открыть заявку"));
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(cabinetStatus, "Отправляем OTP-код...", null);
|
||||||
|
const sendResponse = await fetch("/api/public/otp/send", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
purpose: "VIEW_REQUEST",
|
||||||
|
track_number: trackNumber
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const sendData = await parseJsonSafe(sendResponse);
|
||||||
|
if (!sendResponse.ok) {
|
||||||
|
throw new Error(apiErrorDetail(sendData, "Не удалось отправить OTP"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = window.prompt("Введите OTP-код из SMS (в dev-режиме смотрите backend console):");
|
||||||
|
if (!code) {
|
||||||
|
throw new Error("Код OTP не введен");
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(cabinetStatus, "Проверяем OTP...", null);
|
||||||
|
const verifyResponse = await fetch("/api/public/otp/verify", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
purpose: "VIEW_REQUEST",
|
||||||
|
track_number: trackNumber,
|
||||||
|
code: String(code).trim()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const verifyData = await parseJsonSafe(verifyResponse);
|
||||||
|
if (!verifyResponse.ok) {
|
||||||
|
throw new Error(apiErrorDetail(verifyData, "OTP не подтвержден"));
|
||||||
|
}
|
||||||
|
|
||||||
|
({ response, data } = await fetchRequestByTrack(trackNumber));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(apiErrorDetail(data, "Нет доступа к заявке"));
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshCabinetData() {
|
||||||
|
if (!activeTrack) return;
|
||||||
|
|
||||||
|
const [messagesRes, filesRes, timelineRes] = await Promise.all([
|
||||||
|
fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/messages"),
|
||||||
|
fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/attachments"),
|
||||||
|
fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/timeline")
|
||||||
|
]);
|
||||||
|
|
||||||
|
const messagesData = await parseJsonSafe(messagesRes);
|
||||||
|
const filesData = await parseJsonSafe(filesRes);
|
||||||
|
const timelineData = await parseJsonSafe(timelineRes);
|
||||||
|
|
||||||
|
if (!messagesRes.ok) throw new Error(apiErrorDetail(messagesData, "Не удалось загрузить сообщения"));
|
||||||
|
if (!filesRes.ok) throw new Error(apiErrorDetail(filesData, "Не удалось загрузить файлы"));
|
||||||
|
if (!timelineRes.ok) throw new Error(apiErrorDetail(timelineData, "Не удалось загрузить историю"));
|
||||||
|
|
||||||
|
renderMessages(messagesData);
|
||||||
|
renderFiles(filesData);
|
||||||
|
renderTimeline(timelineData);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openCabinetByTrack() {
|
||||||
|
const trackNumber = String(cabinetTrackInput.value || "").trim().toUpperCase();
|
||||||
|
if (!trackNumber) {
|
||||||
|
setStatus(cabinetStatus, "Введите номер заявки.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setStatus(cabinetStatus, "Открываем кабинет...", null);
|
||||||
|
const requestData = await ensureViewAccess(trackNumber);
|
||||||
|
activeTrack = trackNumber;
|
||||||
|
activeRequestId = requestData.id;
|
||||||
|
|
||||||
|
cabinetRequestStatus.textContent = requestData.status_code || "-";
|
||||||
|
cabinetRequestTopic.textContent = requestData.topic_code || "Не указана";
|
||||||
|
cabinetRequestCreated.textContent = formatDate(requestData.created_at);
|
||||||
|
cabinetRequestUpdated.textContent = formatDate(requestData.updated_at);
|
||||||
|
cabinetSummary.hidden = false;
|
||||||
|
setCabinetEnabled(true);
|
||||||
|
|
||||||
|
await refreshCabinetData();
|
||||||
|
setStatus(cabinetStatus, "Кабинет открыт: " + trackNumber, "ok");
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(cabinetStatus, error?.message || "Не удалось открыть кабинет", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cabinetOpenButton.addEventListener("click", () => {
|
||||||
|
openCabinetByTrack();
|
||||||
|
});
|
||||||
|
|
||||||
|
cabinetChatForm.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!activeTrack) {
|
||||||
|
setStatus(cabinetStatus, "Сначала откройте кабинет по номеру заявки.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = String(cabinetChatBody.value || "").trim();
|
||||||
|
if (!body) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setStatus(cabinetStatus, "Отправляем сообщение...", null);
|
||||||
|
const response = await fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/messages", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ body })
|
||||||
|
});
|
||||||
|
const data = await parseJsonSafe(response);
|
||||||
|
if (!response.ok) throw new Error(apiErrorDetail(data, "Не удалось отправить сообщение"));
|
||||||
|
cabinetChatBody.value = "";
|
||||||
|
await refreshCabinetData();
|
||||||
|
setStatus(cabinetStatus, "Сообщение отправлено.", "ok");
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(cabinetStatus, error?.message || "Ошибка отправки сообщения", "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
cabinetFileUpload.addEventListener("click", async () => {
|
||||||
|
if (!activeTrack || !activeRequestId) {
|
||||||
|
setStatus(cabinetStatus, "Сначала откройте кабинет по номеру заявки.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const file = cabinetFileInput.files && cabinetFileInput.files[0];
|
||||||
|
if (!file) {
|
||||||
|
setStatus(cabinetStatus, "Выберите файл для загрузки.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setStatus(cabinetStatus, "Подготавливаем загрузку файла...", null);
|
||||||
|
const initResponse = await fetch("/api/public/uploads/init", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
file_name: file.name,
|
||||||
|
mime_type: file.type || "application/octet-stream",
|
||||||
|
size_bytes: file.size,
|
||||||
|
scope: "REQUEST_ATTACHMENT",
|
||||||
|
request_id: activeRequestId
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const initData = await parseJsonSafe(initResponse);
|
||||||
|
if (!initResponse.ok) throw new Error(apiErrorDetail(initData, "Не удалось начать загрузку"));
|
||||||
|
|
||||||
|
const putResponse = await fetch(initData.presigned_url, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": file.type || "application/octet-stream" },
|
||||||
|
body: file
|
||||||
|
});
|
||||||
|
if (!putResponse.ok) throw new Error("Ошибка передачи файла в хранилище");
|
||||||
|
|
||||||
|
const completeResponse = await fetch("/api/public/uploads/complete", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
key: initData.key,
|
||||||
|
file_name: file.name,
|
||||||
|
mime_type: file.type || "application/octet-stream",
|
||||||
|
size_bytes: file.size,
|
||||||
|
scope: "REQUEST_ATTACHMENT",
|
||||||
|
request_id: activeRequestId
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const completeData = await parseJsonSafe(completeResponse);
|
||||||
|
if (!completeResponse.ok) throw new Error(apiErrorDetail(completeData, "Не удалось завершить загрузку"));
|
||||||
|
|
||||||
|
cabinetFileInput.value = "";
|
||||||
|
await refreshCabinetData();
|
||||||
|
setStatus(cabinetStatus, "Файл загружен.", "ok");
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(cabinetStatus, error?.message || "Ошибка загрузки файла", "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
form.addEventListener("submit", async (event) => {
|
form.addEventListener("submit", async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
status.className = "status";
|
setStatus(status, "Отправляем заявку...", null);
|
||||||
status.textContent = "Отправляем заявку...";
|
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
client_name: document.getElementById("name").value.trim(),
|
client_name: document.getElementById("name").value.trim(),
|
||||||
|
|
@ -808,6 +1318,33 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
setStatus(status, "Отправляем OTP-код...", null);
|
||||||
|
const otpSend = await fetch("/api/public/otp/send", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
purpose: "CREATE_REQUEST",
|
||||||
|
client_phone: payload.client_phone
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!otpSend.ok) throw new Error("otp send failed");
|
||||||
|
|
||||||
|
const code = window.prompt("Введите OTP-код из SMS (в dev-режиме смотрите backend console):");
|
||||||
|
if (!code) throw new Error("otp code required");
|
||||||
|
|
||||||
|
setStatus(status, "Проверяем OTP...", null);
|
||||||
|
const otpVerify = await fetch("/api/public/otp/verify", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
purpose: "CREATE_REQUEST",
|
||||||
|
client_phone: payload.client_phone,
|
||||||
|
code: String(code).trim()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!otpVerify.ok) throw new Error("otp verify failed");
|
||||||
|
|
||||||
|
setStatus(status, "Создаем заявку...", null);
|
||||||
const response = await fetch("/api/public/requests", {
|
const response = await fetch("/api/public/requests", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
|
@ -816,17 +1353,20 @@
|
||||||
|
|
||||||
if (!response.ok) throw new Error("create request failed");
|
if (!response.ok) throw new Error("create request failed");
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
status.className = "status ok";
|
setStatus(status, "Заявка принята. Номер: " + data.track_number, "ok");
|
||||||
status.textContent = "Заявка принята. Номер: " + data.track_number;
|
cabinetTrackInput.value = data.track_number;
|
||||||
form.reset();
|
form.reset();
|
||||||
setTimeout(closeModal, 1200);
|
setTimeout(closeModal, 1200);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
status.className = "status error";
|
setStatus(status, "Не удалось отправить заявку. Повторите попытку позже.", "error");
|
||||||
status.textContent = "Не удалось отправить заявку. Повторите попытку позже.";
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
loadQuotes();
|
loadQuotes();
|
||||||
|
setCabinetEnabled(false);
|
||||||
|
clearList(cabinetMessages, "Сообщений пока нет.");
|
||||||
|
clearList(cabinetFiles, "Файлы пока не загружены.");
|
||||||
|
clearList(cabinetTimeline, "История пока пуста.");
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,119 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
|
from app.db.session import SessionLocal
|
||||||
|
from app.models.admin_user import AdminUser
|
||||||
|
from app.models.admin_user_topic import AdminUserTopic
|
||||||
|
from app.models.audit_log import AuditLog
|
||||||
|
from app.models.request import Request
|
||||||
|
from app.models.status import Status
|
||||||
from app.workers.celery_app import celery_app
|
from app.workers.celery_app import celery_app
|
||||||
|
|
||||||
@celery_app.task(name='app.workers.tasks.assign.auto_assign_unclaimed')
|
DEFAULT_TERMINAL_STATUS_CODES = {"RESOLVED", "CLOSED", "REJECTED"}
|
||||||
|
|
||||||
|
|
||||||
|
def _terminal_status_codes(db) -> set[str]:
|
||||||
|
rows = db.query(Status.code).filter(Status.is_terminal.is_(True)).all()
|
||||||
|
codes = {str(code).strip() for (code,) in rows if code}
|
||||||
|
return codes or set(DEFAULT_TERMINAL_STATUS_CODES)
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(name="app.workers.tasks.assign.auto_assign_unclaimed")
|
||||||
def auto_assign_unclaimed():
|
def auto_assign_unclaimed():
|
||||||
return 'ok'
|
now = datetime.now(timezone.utc)
|
||||||
|
cutoff = now - timedelta(hours=24)
|
||||||
|
checked = 0
|
||||||
|
assigned = 0
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
terminal_codes = _terminal_status_codes(db)
|
||||||
|
active_load_rows = (
|
||||||
|
db.query(Request.assigned_lawyer_id, func.count(Request.id))
|
||||||
|
.filter(Request.assigned_lawyer_id.is_not(None))
|
||||||
|
.filter(Request.status_code.notin_(terminal_codes))
|
||||||
|
.group_by(Request.assigned_lawyer_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
lawyer_load: dict[str, int] = {str(lawyer_id): int(count) for lawyer_id, count in active_load_rows if lawyer_id}
|
||||||
|
|
||||||
|
active_lawyers = (
|
||||||
|
db.query(AdminUser.id, AdminUser.primary_topic_code)
|
||||||
|
.filter(AdminUser.role == "LAWYER", AdminUser.is_active.is_(True))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
active_lawyer_ids = {str(lawyer_id) for lawyer_id, _ in active_lawyers if lawyer_id}
|
||||||
|
|
||||||
|
primary_by_topic: dict[str, list[str]] = {}
|
||||||
|
for lawyer_id, primary_topic_code in active_lawyers:
|
||||||
|
topic_code = str(primary_topic_code or "").strip()
|
||||||
|
if not topic_code:
|
||||||
|
continue
|
||||||
|
primary_by_topic.setdefault(topic_code, []).append(str(lawyer_id))
|
||||||
|
|
||||||
|
additional_by_topic: dict[str, set[str]] = {}
|
||||||
|
additional_rows = (
|
||||||
|
db.query(AdminUserTopic.topic_code, AdminUserTopic.admin_user_id)
|
||||||
|
.join(AdminUser, AdminUser.id == AdminUserTopic.admin_user_id)
|
||||||
|
.filter(AdminUser.role == "LAWYER", AdminUser.is_active.is_(True))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for topic_code_raw, lawyer_id in additional_rows:
|
||||||
|
topic_code = str(topic_code_raw or "").strip()
|
||||||
|
lawyer_key = str(lawyer_id or "").strip()
|
||||||
|
if not topic_code or not lawyer_key or lawyer_key not in active_lawyer_ids:
|
||||||
|
continue
|
||||||
|
additional_by_topic.setdefault(topic_code, set()).add(lawyer_key)
|
||||||
|
|
||||||
|
queue = (
|
||||||
|
db.query(Request)
|
||||||
|
.filter(
|
||||||
|
Request.assigned_lawyer_id.is_(None),
|
||||||
|
Request.created_at <= cutoff,
|
||||||
|
Request.topic_code.is_not(None),
|
||||||
|
)
|
||||||
|
.order_by(Request.created_at.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
checked = len(queue)
|
||||||
|
|
||||||
|
for req in queue:
|
||||||
|
topic_code = str(req.topic_code or "").strip()
|
||||||
|
if not topic_code:
|
||||||
|
continue
|
||||||
|
primary_candidates = primary_by_topic.get(topic_code) or []
|
||||||
|
if primary_candidates:
|
||||||
|
candidates = primary_candidates
|
||||||
|
assignment_basis = "primary_topic"
|
||||||
|
else:
|
||||||
|
candidates = sorted(additional_by_topic.get(topic_code) or [])
|
||||||
|
assignment_basis = "additional_topic"
|
||||||
|
if not candidates:
|
||||||
|
continue
|
||||||
|
selected = min(candidates, key=lambda lawyer_id: (lawyer_load.get(lawyer_id, 0), lawyer_id))
|
||||||
|
req.assigned_lawyer_id = selected
|
||||||
|
req.updated_at = now
|
||||||
|
req.responsible = "Администратор системы"
|
||||||
|
lawyer_load[selected] = lawyer_load.get(selected, 0) + 1
|
||||||
|
assigned += 1
|
||||||
|
db.add(
|
||||||
|
AuditLog(
|
||||||
|
actor_admin_id=None,
|
||||||
|
entity="requests",
|
||||||
|
entity_id=str(req.id),
|
||||||
|
action="AUTO_ASSIGN",
|
||||||
|
diff={"topic_code": topic_code, "assigned_lawyer_id": selected, "basis": assignment_basis},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"checked": checked, "assigned": assigned}
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,23 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from app.db.session import SessionLocal
|
||||||
|
from app.models.otp_session import OtpSession
|
||||||
from app.workers.celery_app import celery_app
|
from app.workers.celery_app import celery_app
|
||||||
|
|
||||||
@celery_app.task(name='app.workers.tasks.security.cleanup_expired_otps')
|
|
||||||
|
@celery_app.task(name="app.workers.tasks.security.cleanup_expired_otps")
|
||||||
def cleanup_expired_otps():
|
def cleanup_expired_otps():
|
||||||
return 'ok'
|
now = datetime.now(timezone.utc)
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
total = db.query(OtpSession).count()
|
||||||
|
deleted = db.query(OtpSession).filter(OtpSession.expires_at <= now).delete(synchronize_session=False)
|
||||||
|
db.commit()
|
||||||
|
return {"checked": int(total), "deleted": int(deleted)}
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,61 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from app.db.session import SessionLocal
|
||||||
|
from app.models.request import Request
|
||||||
|
from app.services.notifications import EVENT_SLA_OVERDUE, notify_request_event
|
||||||
|
from app.services.sla_metrics import compute_sla_snapshot
|
||||||
from app.workers.celery_app import celery_app
|
from app.workers.celery_app import celery_app
|
||||||
|
|
||||||
@celery_app.task(name='app.workers.tasks.sla.sla_check')
|
|
||||||
|
def _emit_sla_overdue_notifications(db, overdue_rows: list[dict]) -> dict[str, int]:
|
||||||
|
internal_created = 0
|
||||||
|
telegram_sent = 0
|
||||||
|
for item in overdue_rows:
|
||||||
|
request_id_raw = str(item.get("request_id") or "").strip()
|
||||||
|
if not request_id_raw:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
request_uuid = UUID(request_id_raw)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
req = db.get(Request, request_uuid)
|
||||||
|
if req is None:
|
||||||
|
continue
|
||||||
|
threshold = item.get("threshold_hours")
|
||||||
|
spent = item.get("hours_in_status")
|
||||||
|
body = f"Просрочка SLA: {spent}ч > {threshold}ч"
|
||||||
|
dedupe_prefix = f"sla:{req.id}:{req.status_code}"
|
||||||
|
result = notify_request_event(
|
||||||
|
db,
|
||||||
|
request=req,
|
||||||
|
event_type=EVENT_SLA_OVERDUE,
|
||||||
|
actor_role="SYSTEM",
|
||||||
|
body=body,
|
||||||
|
responsible="SLA сервис",
|
||||||
|
dedupe_prefix=dedupe_prefix,
|
||||||
|
)
|
||||||
|
internal_created += int(result.get("internal_created", 0))
|
||||||
|
telegram_sent += int(result.get("telegram_sent", 0))
|
||||||
|
return {"internal_created": int(internal_created), "telegram_sent": int(telegram_sent)}
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(name="app.workers.tasks.sla.sla_check")
|
||||||
def sla_check():
|
def sla_check():
|
||||||
return 'ok'
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
snapshot = compute_sla_snapshot(db, include_overdue_requests=True)
|
||||||
|
overdue_rows = list(snapshot.get("overdue_requests") or [])
|
||||||
|
notify_result = _emit_sla_overdue_notifications(db, overdue_rows)
|
||||||
|
if notify_result["internal_created"] > 0:
|
||||||
|
db.commit()
|
||||||
|
snapshot.pop("overdue_requests", None)
|
||||||
|
snapshot["notifications_created"] = int(notify_result["internal_created"])
|
||||||
|
snapshot["telegram_sent"] = int(notify_result["telegram_sent"])
|
||||||
|
return snapshot
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,56 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
|
from app.db.session import SessionLocal
|
||||||
|
from app.models.attachment import Attachment
|
||||||
|
from app.models.request import Request
|
||||||
from app.workers.celery_app import celery_app
|
from app.workers.celery_app import celery_app
|
||||||
|
|
||||||
@celery_app.task(name='app.workers.tasks.uploads.cleanup_stale_uploads')
|
|
||||||
|
@celery_app.task(name="app.workers.tasks.uploads.cleanup_stale_uploads")
|
||||||
def cleanup_stale_uploads():
|
def cleanup_stale_uploads():
|
||||||
return 'ok'
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
requests = db.query(Request).all()
|
||||||
|
existing_request_ids = {str(req.id) for req in requests}
|
||||||
|
|
||||||
|
deleted_orphan = 0
|
||||||
|
deleted_invalid = 0
|
||||||
|
attachment_rows = db.query(Attachment.id, Attachment.request_id, Attachment.size_bytes, Attachment.s3_key).all()
|
||||||
|
for att_id, request_id, size_bytes, s3_key in attachment_rows:
|
||||||
|
request_id_str = str(request_id)
|
||||||
|
if request_id_str not in existing_request_ids:
|
||||||
|
db.query(Attachment).filter(Attachment.id == att_id).delete(synchronize_session=False)
|
||||||
|
deleted_orphan += 1
|
||||||
|
continue
|
||||||
|
if int(size_bytes or 0) <= 0 or not str(s3_key or "").strip():
|
||||||
|
db.query(Attachment).filter(Attachment.id == att_id).delete(synchronize_session=False)
|
||||||
|
deleted_invalid += 1
|
||||||
|
|
||||||
|
if deleted_orphan or deleted_invalid:
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
totals_rows = db.query(Attachment.request_id, func.coalesce(func.sum(Attachment.size_bytes), 0)).group_by(Attachment.request_id).all()
|
||||||
|
totals_map = {str(request_id): int(total or 0) for request_id, total in totals_rows}
|
||||||
|
|
||||||
|
fixed_requests = 0
|
||||||
|
for req in requests:
|
||||||
|
request_total = totals_map.get(str(req.id), 0)
|
||||||
|
if int(req.total_attachments_bytes or 0) != request_total:
|
||||||
|
req.total_attachments_bytes = request_total
|
||||||
|
req.responsible = "Администратор системы"
|
||||||
|
db.add(req)
|
||||||
|
fixed_requests += 1
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {
|
||||||
|
"deleted_orphan_attachments": int(deleted_orphan),
|
||||||
|
"deleted_invalid_attachments": int(deleted_invalid),
|
||||||
|
"fixed_requests": int(fixed_requests),
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,32 @@ One-page landing + public case tracking (OTP + JWT cookie) + admin panel (ADMIN/
|
||||||
- Backend: Python 3.12 + FastAPI
|
- Backend: Python 3.12 + FastAPI
|
||||||
- DB: PostgreSQL
|
- DB: PostgreSQL
|
||||||
- Queue: Redis + Celery
|
- Queue: Redis + Celery
|
||||||
- Immutable data after status change
|
- OTP is required for request creation and for public request access
|
||||||
- Full audit log for admin changes
|
- Public JWT cookie is stored for 7 days on one device to reduce repeated OTP sends
|
||||||
|
- Request assignment: manual lawyer claim or automatic assignment after 24h if still unassigned
|
||||||
|
- Automatic assignment priority: primary lawyer topic, then additional topics, then lowest active load
|
||||||
|
- Additional lawyer topics are stored in a separate link table (many-to-many), not in `admin_users` array/json
|
||||||
|
- Active load means requests in non-terminal statuses (`is_terminal = false`)
|
||||||
|
- Topic-specific status flow + SLA per status transition
|
||||||
|
- Topic template split: required create fields (`topic_required_fields`) + per-topic request template (`topic_data_templates`) + per-request expansion (`request_data_requirements`)
|
||||||
|
- Topic-specific status flow rules are stored in `topic_status_transitions` and validated server-side on status update
|
||||||
|
- Each lawyer has default rate; each request stores fixed effective rate (can be overridden by ADMIN)
|
||||||
|
- Request fixed rate is immutable for billing history and does not follow future lawyer rate edits
|
||||||
|
- Lawyer rates are internal data and must not be exposed in public client API/UI
|
||||||
|
- Each lawyer has salary percent used for payroll calculation
|
||||||
|
- Payment fact is recorded on ADMIN status change to "Оплачено"; this timestamp is used in monthly gross/payroll
|
||||||
|
- A request can have multiple invoice-payment cycles and multiple payment events
|
||||||
|
- Status flow supports billing step type ("выставление счета") with invoice generation from template and delivery to client
|
||||||
|
- On status change, previous messages and attachments become immutable
|
||||||
|
- Manual claim is allowed only for unassigned requests; no lawyer-to-lawyer takeover
|
||||||
|
- Reassignment of already assigned request is allowed for ADMIN only
|
||||||
|
- Read state is tracked per request (not per message/file); opening request marks updates as seen
|
||||||
|
- UI shows one-time green dot indicators for changed entities (messages/files/status) until request is opened
|
||||||
|
- Full audit log for admin actions
|
||||||
- UniversalTable + UniversalRecordModal (meta-driven admin UI)
|
- UniversalTable + UniversalRecordModal (meta-driven admin UI)
|
||||||
|
- Security controls for S3/PII: access audit trail, encryption, retention policy, and incident visibility
|
||||||
|
|
||||||
## Roles
|
## Roles
|
||||||
- PUBLIC (via OTP + cookie)
|
- PUBLIC (via OTP + cookie)
|
||||||
- LAWYER
|
- LAWYER (assigned + unassigned queue visibility)
|
||||||
- ADMIN
|
- ADMIN (access to all platform data and configuration)
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,45 @@
|
||||||
# Public Requests Service Context
|
# Public Requests Service Context
|
||||||
|
|
||||||
## Responsibilities
|
## Responsibilities
|
||||||
- Accept new legal case requests
|
- Show landing page and accept new legal case requests
|
||||||
- Generate track_number
|
- Request base fields: full name, phone, topic, problem description
|
||||||
- Store configurable form fields (form_fields table)
|
- Require OTP verification before request creation (phone confirmation)
|
||||||
- Trigger OTP flow
|
- Generate and return unique `track_number`
|
||||||
- Allow client to view request (after OTP verify)
|
- Allow client to reopen request by `track_number` and continue communication with lawyer
|
||||||
|
- Allow client to see status/history, upload requested files, and read/write chat messages
|
||||||
|
- Track unread updates at request level for both client and lawyer side
|
||||||
|
- Store extra fields as JSON from admin-configured topic form template
|
||||||
|
|
||||||
## Key Rules
|
## Key Rules
|
||||||
- Phone is mandatory
|
- Phone is mandatory and must be OTP-verified for create flow
|
||||||
- Extra fields stored as JSON (validated against form_fields config)
|
- Base request fields are mandatory (`client_name`, `client_phone`, `topic_code`, `description`)
|
||||||
|
- Topic-specific required fields are configured by ADMIN in `topic_required_fields`
|
||||||
- File size limit: 25MB per file
|
- File size limit: 25MB per file
|
||||||
- Case size limit: 350MB total
|
- Case size limit: 250MB total
|
||||||
|
- Public file interaction for now: download/open (no inline preview requirement)
|
||||||
|
- New message/file/status update sets request-level "has updates" marker for target side
|
||||||
|
- Opening request resets marker and counts as acknowledgment
|
||||||
|
- Internal lawyer rates are hidden from public API/UI
|
||||||
|
- Client can receive generated invoice documents when request enters billing status
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
- Rate limit by IP/phone/track_number
|
- Rate limit by IP/phone/track_number
|
||||||
- No direct access without OTP verification
|
- No direct access without OTP verification
|
||||||
|
- After successful OTP verification, device keeps JWT cookie for 7 days
|
||||||
|
- Public create endpoint requires cookie `purpose=CREATE_REQUEST` with `sub=<phone>`
|
||||||
|
- Public view endpoint (`GET /api/public/requests/{track_number}`) requires cookie `purpose=VIEW_REQUEST` with `sub=<track_number>`
|
||||||
|
|
||||||
|
## Implemented Public Cabinet Endpoints (`P12`)
|
||||||
|
- `GET /api/public/requests/{track_number}`: карточка заявки
|
||||||
|
- `GET /api/public/requests/{track_number}/messages`: чат заявки
|
||||||
|
- `POST /api/public/requests/{track_number}/messages`: сообщение клиента
|
||||||
|
- `GET /api/public/requests/{track_number}/attachments`: список файлов заявки
|
||||||
|
- `GET /api/public/requests/{track_number}/history`: история смены статусов
|
||||||
|
- `GET /api/public/requests/{track_number}/timeline`: объединенная лента событий (статусы/сообщения/файлы)
|
||||||
|
- `GET /api/public/uploads/object/{attachment_id}`: открыть/скачать файл с проверкой доступа по `track_number`
|
||||||
|
|
||||||
|
## UI
|
||||||
|
- На лендинге добавлен блок «Кабинет клиента»:
|
||||||
|
- вход по `track_number` с OTP (`VIEW_REQUEST`) при отсутствии валидной 7-дневной cookie
|
||||||
|
- отображение статуса заявки, чата, файлов и таймлайна
|
||||||
|
- отправка сообщения клиентом и загрузка файла через public upload flow (`init` -> `PUT` -> `complete`)
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,27 @@
|
||||||
# OTP Service Context
|
# OTP Service Context
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
Secure access for:
|
Secure public access for:
|
||||||
- Creating request
|
- Creating request (phone confirmation is mandatory)
|
||||||
- Viewing request
|
- Viewing request status/chat/files by `track_number`
|
||||||
|
|
||||||
## Flow
|
## Flow
|
||||||
1. Send OTP (CREATE_REQUEST / VIEW_REQUEST)
|
1. Send OTP (`CREATE_REQUEST` / `VIEW_REQUEST`)
|
||||||
2. Store hashed code
|
2. Store hashed code
|
||||||
3. Expire in 10 minutes
|
3. Expire in 10 minutes
|
||||||
4. Max attempts limit
|
4. Max attempts limit
|
||||||
5. On verify -> issue public JWT cookie (7 days)
|
5. On verify -> issue public JWT cookie (7 days, same device)
|
||||||
|
6. If valid JWT exists on device, do not resend OTP until cookie expiration
|
||||||
|
|
||||||
|
## Current Dev Mode
|
||||||
|
- OTP code is printed to backend console log (`[OTP MOCK] ... code=XXXXXX`)
|
||||||
|
- SMS provider call is mocked (`sms_response.provider = mock_sms`)
|
||||||
|
- `CREATE_REQUEST` verification issues cookie with `purpose=CREATE_REQUEST` and `sub=<phone>`
|
||||||
|
- Request creation endpoint requires that cookie and then switches cookie to `purpose=VIEW_REQUEST`, `sub=<track_number>`
|
||||||
|
- `VIEW_REQUEST` verification issues cookie with `purpose=VIEW_REQUEST` and `sub=<track_number>`
|
||||||
|
|
||||||
## Anti-abuse
|
## Anti-abuse
|
||||||
- Rate limit (Redis)
|
- Rate limit (Redis)
|
||||||
- Cooldown between sends
|
- Cooldown between sends
|
||||||
- Lock after N failed attempts
|
- Lock after N failed attempts
|
||||||
|
- Throttling by phone + track number + IP
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,150 @@
|
||||||
# Admin Panel Service Context
|
# Admin Panel Service Context
|
||||||
|
|
||||||
## Roles
|
## Roles
|
||||||
- ADMIN: full CRUD + config + SLA + quotes
|
- ADMIN: full CRUD + all dictionaries + SLA + users + quotes
|
||||||
- LAWYER: work with assigned requests only
|
- LAWYER:
|
||||||
|
- see assigned requests
|
||||||
|
- see unassigned queue
|
||||||
|
- can manually claim unassigned request ("Take in work")
|
||||||
|
|
||||||
## Core Features
|
## Core Features
|
||||||
- Universal table with filters (= != > < >= <= ~)
|
- Universal table with filters (`= != > < >= <= ~`)
|
||||||
- Universal record modal (meta-driven)
|
- Universal record modal (meta-driven)
|
||||||
- Manual editing of any table
|
- Manual editing of available entities
|
||||||
- AuditLog for any CREATE/UPDATE/DELETE
|
- AuditLog for any CREATE/UPDATE/DELETE and system assignment events
|
||||||
|
- Atomic claim action to prevent race conditions between lawyers
|
||||||
|
- User profile avatar (`avatar_url`) with fallback initials in UI
|
||||||
|
- Lawyer profile includes default lawyer rate (editable by ADMIN)
|
||||||
|
- Lawyer profile includes salary percent (editable by ADMIN)
|
||||||
|
|
||||||
|
## Assignment Logic
|
||||||
|
- Request can be claimed manually by lawyer
|
||||||
|
- Manual claim is allowed only when `assigned_lawyer_id` is null
|
||||||
|
- Lawyer takeover is forbidden
|
||||||
|
- Manual reassignment of assigned request is ADMIN-only action
|
||||||
|
- If not claimed within 24h and still unassigned, auto-assign is applied
|
||||||
|
- Lawyer profile includes:
|
||||||
|
- one primary topic
|
||||||
|
- additional topics
|
||||||
|
- Additional topics are stored via separate link table (`admin_user_topics`)
|
||||||
|
- Assignment priority: primary topic matches first, then additional topics, then lowest active load
|
||||||
|
- Active load = count of assigned requests with non-terminal status (`is_terminal=false`)
|
||||||
|
|
||||||
## Status Logic
|
## Status Logic
|
||||||
|
- Status flow is configured per topic
|
||||||
|
- Base model is linear, but with allowed flow variations (Jira-like)
|
||||||
- On any status change:
|
- On any status change:
|
||||||
- All previous messages immutable
|
- all previous messages immutable
|
||||||
- All previous attachments immutable
|
- all previous attachments immutable
|
||||||
- Add status_history record
|
- add `status_history` record
|
||||||
|
|
||||||
|
### Implemented Flow Configuration (`P14`)
|
||||||
|
- New dictionary table: `topic_status_transitions`
|
||||||
|
- Transition rule fields:
|
||||||
|
- `topic_code`
|
||||||
|
- `from_status`
|
||||||
|
- `to_status`
|
||||||
|
- `sla_hours` (SLA для перехода, в часах)
|
||||||
|
- `enabled`
|
||||||
|
- `sort_order`
|
||||||
|
- ADMIN manages flow rules in "Справочники -> Переходы статусов"
|
||||||
|
- Server-side validation:
|
||||||
|
- if topic has configured enabled rules, transition is allowed only when rule exists
|
||||||
|
- if topic has no rules yet, backward-compatible free transition is kept
|
||||||
|
|
||||||
|
### Planned Billing Status Extension
|
||||||
|
- Add status type: `INVOICE` / "Выставление счета"
|
||||||
|
- For this status type:
|
||||||
|
- invoice is generated from admin-managed template
|
||||||
|
- invoice is attached/sent to client through platform notification channel
|
||||||
|
- billing status can be included in topic-specific flow as regular transition node
|
||||||
|
|
||||||
|
### Implemented SLA Transition Config (`P18`)
|
||||||
|
- SLA configuration is stored in `topic_status_transitions.sla_hours`
|
||||||
|
- `sla_hours` is optional but if set must be integer > 0
|
||||||
|
- CRUD validation prevents:
|
||||||
|
- unknown `topic_code` / status codes
|
||||||
|
- `from_status == to_status`
|
||||||
|
- non-positive `sla_hours`
|
||||||
|
- Admin UI for "Переходы статусов" includes `SLA (часы)` in table, filters, and edit/create modal
|
||||||
|
|
||||||
|
### Implemented Status Immutability (`P15`)
|
||||||
|
- On request status change:
|
||||||
|
- all existing `messages` for request are marked `immutable=true`
|
||||||
|
- all existing `attachments` for request are marked `immutable=true`
|
||||||
|
- new row is written into `status_history` (`from_status`, `to_status`, `changed_by_admin_id`)
|
||||||
|
- Immutable records protection:
|
||||||
|
- `PATCH /api/admin/crud/messages/{id}` and `DELETE /api/admin/crud/messages/{id}` are blocked for immutable rows
|
||||||
|
- `PATCH /api/admin/crud/attachments/{id}` and `DELETE /api/admin/crud/attachments/{id}` are blocked for immutable rows
|
||||||
|
- upload complete rejects binding file to immutable message (`message_id`)
|
||||||
|
|
||||||
|
## Templates
|
||||||
|
- ADMIN configures required client fields for request creation (by topic)
|
||||||
|
- For in-progress work, lawyer can use topic template for requested docs/data
|
||||||
|
- Lawyer can extend that template for a specific request only
|
||||||
|
- No template versioning requirement
|
||||||
|
|
||||||
|
### Implemented Template Split (`P16`)
|
||||||
|
- New dictionaries:
|
||||||
|
- `topic_required_fields`: required request creation fields per topic (`topic_code`, `field_key`, `required`, `enabled`, `sort_order`)
|
||||||
|
- `topic_data_templates`: topic-level data/doc request template (`topic_code`, `key`, `label`, `description`, `required`, `enabled`, `sort_order`)
|
||||||
|
- Request-level table:
|
||||||
|
- `request_data_requirements`: per-request expanded template items, including lawyer-added custom items
|
||||||
|
- Validation:
|
||||||
|
- create request (`/api/public/requests`, `/api/admin/requests`, `/api/admin/crud/requests`) validates `extra_fields` against active required keys from `topic_required_fields`
|
||||||
|
- Request template API:
|
||||||
|
- `GET /api/admin/requests/{request_id}/data-template`
|
||||||
|
- `POST /api/admin/requests/{request_id}/data-template/sync`
|
||||||
|
- `POST /api/admin/requests/{request_id}/data-template/items`
|
||||||
|
- `PATCH /api/admin/requests/{request_id}/data-template/items/{item_id}`
|
||||||
|
- `DELETE /api/admin/requests/{request_id}/data-template/items/{item_id}`
|
||||||
|
- RBAC:
|
||||||
|
- ADMIN has full access
|
||||||
|
- LAWYER can work with template items only for assigned request
|
||||||
|
|
||||||
|
## Rates & Billing Rules (planned)
|
||||||
|
- ADMIN sets default lawyer rate in user profile
|
||||||
|
- ADMIN sets lawyer salary percent in user profile
|
||||||
|
- ADMIN can override rate for a specific request
|
||||||
|
- Effective request rate is stored in request and frozen for financial traceability
|
||||||
|
- Request rate is not returned in public client endpoints and not shown in public UI
|
||||||
|
- Effective request amount is stored in request and frozen for financial traceability
|
||||||
|
- Fact of payment is recorded when ADMIN changes request status to "Оплачено" (business paid event)
|
||||||
|
- Payment event stores who changed status and when (for salary/month reports)
|
||||||
|
- A request may contain more than one payment event (multiple invoice-payment cycles)
|
||||||
|
|
||||||
|
### Implemented Baseline For Dashboard (`P21`)
|
||||||
|
- Financial profile fields are persisted:
|
||||||
|
- `admin_users.default_rate`
|
||||||
|
- `admin_users.salary_percent`
|
||||||
|
- Request financial fields are persisted:
|
||||||
|
- `requests.effective_rate`
|
||||||
|
- `requests.invoice_amount`
|
||||||
|
- `requests.paid_at`
|
||||||
|
- `requests.paid_by_admin_id`
|
||||||
|
- Admin UI record forms/tables include these fields.
|
||||||
|
- Public API still does not expose internal financial fields.
|
||||||
|
|
||||||
|
## Read / Unread UX
|
||||||
|
- Unread state is tracked per request for both LAWYER and PUBLIC user
|
||||||
|
- New message/file/status change marks request as updated
|
||||||
|
- Opening request marks updates as read
|
||||||
|
- UI can show one-time green indicator for what changed (message/file/status)
|
||||||
|
|
||||||
|
## Implemented Marker Model (`P13`)
|
||||||
|
- `requests.client_has_unread_updates` / `requests.client_unread_event_type`
|
||||||
|
- `requests.lawyer_has_unread_updates` / `requests.lawyer_unread_event_type`
|
||||||
|
- Event types: `MESSAGE`, `ATTACHMENT`, `STATUS`
|
||||||
|
- LAWYER opening request (`GET /api/admin/crud/requests/{id}` or `GET /api/admin/requests/{id}`) clears lawyer marker
|
||||||
|
- Client opening request (`GET /api/public/requests/{track_number}`) clears client marker
|
||||||
|
|
||||||
|
## Admin Dashboard Financial Metrics (planned)
|
||||||
|
- For each lawyer show:
|
||||||
|
- active requests count (current load)
|
||||||
|
- sum of active requests amounts (if amount exists)
|
||||||
|
- monthly gross of paid requests
|
||||||
|
- monthly gross of paid events
|
||||||
|
- monthly salary amount
|
||||||
|
- Salary calculation base:
|
||||||
|
- paid event = ADMIN changes request status to "Оплачено"
|
||||||
|
- salary = paid request amount * lawyer salary percent
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,31 @@
|
||||||
## Storage
|
## Storage
|
||||||
- Self-hosted S3 (MinIO)
|
- Self-hosted S3 (MinIO)
|
||||||
- Presigned PUT or multipart upload
|
- Presigned PUT or multipart upload
|
||||||
- Store metadata in attachments table
|
- Store metadata in `attachments` table
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
- Max 25MB per file
|
- Max 25MB per file
|
||||||
- Max 350MB per request
|
- Max 250MB per request
|
||||||
- Immutable after status change
|
- Attachments created in previous statuses become immutable after status change
|
||||||
- Download via presigned GET or proxy endpoint
|
- Current UX target: download/open file (no mandatory inline preview yet)
|
||||||
|
- Download via presigned GET or proxy endpoint
|
||||||
|
|
||||||
|
## Implemented Enforcement (`P17`)
|
||||||
|
- Server-side limit checks in both public/admin upload flows:
|
||||||
|
- `init`: checks requested size and current request total
|
||||||
|
- `complete`: re-checks actual object size from S3 `head_object` and request total
|
||||||
|
- Object key scope validation:
|
||||||
|
- public attachment upload accepts only keys under `requests/{request_id}/...`
|
||||||
|
- admin request attachment upload accepts only keys under `requests/{request_id}/...`
|
||||||
|
- admin avatar upload accepts only keys under `avatars/{user_id}/...`
|
||||||
|
- Download access guard (`/api/admin/uploads/object/{key}`):
|
||||||
|
- `ADMIN`: full access
|
||||||
|
- `LAWYER`: only own avatar and files from own/unassigned requests
|
||||||
|
|
||||||
|
## Planned Security Audit (`P27`)
|
||||||
|
- Security event log for every file operation:
|
||||||
|
- upload init/complete
|
||||||
|
- download/open
|
||||||
|
- denied access attempts
|
||||||
|
- Logging fields: actor, role, IP/device, object key, request_id, outcome, timestamp
|
||||||
|
- Add periodic integrity/security checks for object metadata and access anomalies
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,59 @@
|
||||||
- cleanup_stale_uploads (daily)
|
- cleanup_stale_uploads (daily)
|
||||||
|
|
||||||
## Auto Assign Logic
|
## Auto Assign Logic
|
||||||
- If request unclaimed for 24h
|
- Apply to any request that is still unassigned for 24h
|
||||||
- Match by topic
|
- Candidate selection order:
|
||||||
- Assign to lawyer with lowest active load
|
1. lawyers with matching primary topic
|
||||||
|
2. lawyers with matching additional topics
|
||||||
|
3. among candidates -> lowest active load
|
||||||
|
- Additional topics source: link table `admin_user_topics`
|
||||||
|
- Active load definition: assigned requests in non-terminal statuses (`is_terminal=false`)
|
||||||
|
- Manual lawyer claim has priority if request already claimed before scheduler run
|
||||||
|
- Auto-assign never overrides already assigned request
|
||||||
|
|
||||||
## SLA Metrics
|
## SLA Metrics
|
||||||
- First response time
|
- First response time
|
||||||
- Time in status
|
- Time in status
|
||||||
- Overdue detection
|
- Overdue detection
|
||||||
- Telegram notification to group chat
|
- SLA is configured per topic and per status transition
|
||||||
|
- Telegram notification to group chat (if connected)
|
||||||
|
- In-site notifications for new updates/events
|
||||||
|
|
||||||
|
## Implemented SLA Config (`P18`)
|
||||||
|
- SLA config storage: `topic_status_transitions.sla_hours`
|
||||||
|
- Config is editable by ADMIN via universal CRUD / admin panel dictionary "Переходы статусов"
|
||||||
|
- Validation rules:
|
||||||
|
- `topic_code`, `from_status`, `to_status` must reference existing dictionaries
|
||||||
|
- `from_status` and `to_status` must differ
|
||||||
|
- `sla_hours` if provided must be integer > 0
|
||||||
|
- Applying transition-level SLA thresholds in `sla_check` remains in `P19`
|
||||||
|
- SLA should also apply to billing transitions (including "выставление счета" step) once billing status type is enabled
|
||||||
|
|
||||||
|
## Implemented SLA Check (`P19`)
|
||||||
|
- `sla_check` uses transition SLA config for active requests:
|
||||||
|
- source: `topic_status_transitions.sla_hours`
|
||||||
|
- matching: by `topic_code` + current `from_status` (outgoing transitions)
|
||||||
|
- when several outgoing transitions exist, minimal configured `sla_hours` is used as active threshold
|
||||||
|
- fallback: status defaults (`NEW`, `IN_PROGRESS`, etc.) when transition SLA is absent
|
||||||
|
- outputs:
|
||||||
|
- `overdue_total`
|
||||||
|
- `overdue_by_status`
|
||||||
|
- `overdue_by_transition` (format: `topic:status->*`)
|
||||||
|
- `frt_avg_minutes`
|
||||||
|
- `avg_time_in_status_hours`
|
||||||
|
|
||||||
|
## Implemented Notifications (`P20`)
|
||||||
|
- Internal notifications table: `notifications`
|
||||||
|
- Notification recipients:
|
||||||
|
- `CLIENT` by `track_number`
|
||||||
|
- `ADMIN_USER` by `admin_user_id` (admin/lawyer)
|
||||||
|
- Event sources integrated:
|
||||||
|
- public/client message (`MESSAGE`)
|
||||||
|
- public/client file upload (`ATTACHMENT`)
|
||||||
|
- admin/lawyer file upload (`ATTACHMENT`)
|
||||||
|
- admin/lawyer status change (`STATUS`)
|
||||||
|
- SLA overdue worker event (`SLA_OVERDUE`)
|
||||||
|
- Telegram delivery:
|
||||||
|
- if bot/chat configured -> send via Telegram Bot API
|
||||||
|
- if not configured -> safe mock output in console (`[TELEGRAM MOCK]`)
|
||||||
|
- SLA-overdue notifications are deduplicated per `(request, status, recipient)`.
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Security Model Context
|
# Security Model Context
|
||||||
|
|
||||||
## Public
|
## Public
|
||||||
- OTP verification required
|
- OTP verification required for request creation and request access
|
||||||
- JWT in httpOnly cookie (7 days)
|
- JWT in httpOnly cookie (7 days)
|
||||||
- Rate limiting
|
- Rate limiting
|
||||||
- Protection from brute force
|
- Protection from brute force
|
||||||
|
|
@ -12,5 +12,28 @@
|
||||||
- Audit log required
|
- Audit log required
|
||||||
|
|
||||||
## Data Protection
|
## Data Protection
|
||||||
- Immutable after status change
|
- Messages and attachments from previous statuses are immutable after status change
|
||||||
- All actions logged
|
- All actions logged
|
||||||
|
|
||||||
|
## S3 & Personal Data (planned hardening)
|
||||||
|
- Files in S3 are treated as personal data (PII/ПДн)
|
||||||
|
- Security baseline for implementation:
|
||||||
|
- Access model:
|
||||||
|
- strict RBAC/least-privilege for object read/write
|
||||||
|
- scoped object keys and server-side authorization checks on every download
|
||||||
|
- no direct anonymous public bucket/object access
|
||||||
|
- Cryptography:
|
||||||
|
- encryption in transit (TLS) for all client<->API and API<->S3 paths
|
||||||
|
- encryption at rest for object storage and backups
|
||||||
|
- key rotation policy and secret management (no static secrets in code)
|
||||||
|
- Audit & accountability:
|
||||||
|
- immutable security audit trail for file operations (who, when, what object, action, result)
|
||||||
|
- alerting on suspicious access patterns (mass download, repeated denied attempts)
|
||||||
|
- periodic access review reports
|
||||||
|
- Data lifecycle:
|
||||||
|
- retention rules by data category/status
|
||||||
|
- controlled deletion and archival procedures
|
||||||
|
- backup restore testing and disaster recovery runbook
|
||||||
|
- Compliance posture:
|
||||||
|
- map controls to РФ requirements for personal data protection and internal cyber policies
|
||||||
|
- formalize security checklist for release gates (threat review + access review + logging verification)
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,85 @@
|
||||||
- SLA overdue
|
- SLA overdue
|
||||||
- Avg first response time
|
- Avg first response time
|
||||||
- Avg time in status
|
- Avg time in status
|
||||||
|
- SLA by topic + status transition
|
||||||
|
- Overdue by transition (`topic:status->*`)
|
||||||
|
- Per-lawyer workload (active and total assigned requests)
|
||||||
|
- Per-lawyer financial block:
|
||||||
|
- active requests amount (sum of fixed request amounts for active requests)
|
||||||
|
- monthly paid gross (sum of paid requests in current month)
|
||||||
|
- monthly salary (sum of paid request amount * lawyer percent)
|
||||||
|
|
||||||
|
## Lawyer Dashboard
|
||||||
|
- Assigned requests
|
||||||
|
- Unassigned requests queue
|
||||||
|
- Active requests by statuses
|
||||||
|
- New/unseen messages
|
||||||
|
- New/unseen files
|
||||||
|
- Unseen status changes
|
||||||
|
- Unseen state is request-level (single marker per request)
|
||||||
|
- Opening request resets unseen marker
|
||||||
|
- One-time green dot can be shown for changed entity type (message/file/status)
|
||||||
|
|
||||||
## Data Sources
|
## Data Sources
|
||||||
- requests
|
- requests
|
||||||
- status_history
|
- status_history
|
||||||
- messages
|
- messages
|
||||||
- sla config
|
- attachments
|
||||||
|
- sla config (`topic_status_transitions`, field `sla_hours`)
|
||||||
|
- notification/read markers
|
||||||
|
- lawyer financial profile (`default_rate`, `salary_percent`)
|
||||||
|
- fixed financial fields in request (`effective_rate`, `invoice_amount`, `paid_at`, `paid_by_admin_id`)
|
||||||
|
|
||||||
|
## Payment Event Rule
|
||||||
|
- Fact of payment is recognized only on ADMIN status change to "Оплачено"
|
||||||
|
- This event timestamp is used as payment date for monthly gross/salary
|
||||||
|
- A request may produce multiple paid events; monthly aggregates sum all paid events in month
|
||||||
|
- Salary formula: `invoice_amount * salary_percent`
|
||||||
|
|
||||||
|
## Implemented Marker Fields (`P13`)
|
||||||
|
- request-level booleans: `client_has_unread_updates`, `lawyer_has_unread_updates`
|
||||||
|
- request-level event types: `client_unread_event_type`, `lawyer_unread_event_type`
|
||||||
|
- `overview` now exposes aggregate counters:
|
||||||
|
- `unread_for_clients`
|
||||||
|
- `unread_for_lawyers`
|
||||||
|
|
||||||
|
## Implemented SLA Snapshot (`P19`)
|
||||||
|
- `overview` includes:
|
||||||
|
- `sla_overdue` (total)
|
||||||
|
- `overdue_by_status`
|
||||||
|
- `overdue_by_transition`
|
||||||
|
- `frt_avg_minutes`
|
||||||
|
- `avg_time_in_status_hours`
|
||||||
|
|
||||||
|
## Implemented Notifications (`P20`)
|
||||||
|
- Notification storage: table `notifications`
|
||||||
|
- Channels:
|
||||||
|
- in-site notifications for `CLIENT` and `ADMIN_USER`
|
||||||
|
- Telegram (if bot configured; otherwise mock logging)
|
||||||
|
- Events:
|
||||||
|
- `MESSAGE`, `ATTACHMENT`, `STATUS`, `SLA_OVERDUE`
|
||||||
|
- Read behavior:
|
||||||
|
- opening request marks related notifications as read for viewer side
|
||||||
|
|
||||||
|
## Implemented Dashboard Role Split (`P21`)
|
||||||
|
- `/api/admin/metrics/overview` is role-aware:
|
||||||
|
- `ADMIN` sees global metrics and full per-lawyer block
|
||||||
|
- `LAWYER` sees scoped metrics for own assigned requests + unassigned queue
|
||||||
|
- Added counters:
|
||||||
|
- `assigned_total`
|
||||||
|
- `active_assigned_total`
|
||||||
|
- `unassigned_total`
|
||||||
|
- `my_unread_updates`
|
||||||
|
- `my_unread_by_event`
|
||||||
|
- Per-lawyer financial metrics:
|
||||||
|
- `active_amount` (sum `requests.invoice_amount` for active assigned requests)
|
||||||
|
- `monthly_paid_events` (count of `status_history.to_status == PAID/ОПЛАЧЕНО` in current month)
|
||||||
|
- `monthly_paid_gross` (sum invoice amount per paid event in current month)
|
||||||
|
- `monthly_salary` (`monthly_paid_gross * salary_percent / 100`)
|
||||||
|
- Financial source fields added:
|
||||||
|
- `admin_users.default_rate`
|
||||||
|
- `admin_users.salary_percent`
|
||||||
|
- `requests.effective_rate`
|
||||||
|
- `requests.invoice_amount`
|
||||||
|
- `requests.paid_at`
|
||||||
|
- `requests.paid_by_admin_id`
|
||||||
|
|
|
||||||
64
context/10_development_execution_plan.md
Normal file
64
context/10_development_execution_plan.md
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
# План Разработки (Execution Plan)
|
||||||
|
|
||||||
|
## Назначение
|
||||||
|
Этот файл фиксирует последовательность работ до завершения проекта.
|
||||||
|
Используется ИИ-агентом как рабочий чеклист реализации.
|
||||||
|
|
||||||
|
## Правило статусов
|
||||||
|
- `сделано` — реализовано в коде, покрыто тестами или подтверждено рабочим сценарием.
|
||||||
|
- `к разработке` — еще не реализовано полностью или реализовано как заглушка.
|
||||||
|
|
||||||
|
## Порядок выполнения
|
||||||
|
Работы выполняются строго сверху вниз по `ID`.
|
||||||
|
Переход к следующему пункту возможен только после:
|
||||||
|
1. Обновления кода.
|
||||||
|
2. Обновления/добавления тестов.
|
||||||
|
3. Проверки `unittest` и миграций.
|
||||||
|
4. Обновления контекста (`context/*.md`) при изменении требований.
|
||||||
|
|
||||||
|
## Дорожная карта
|
||||||
|
| ID | Статус | Блок | Задача для ИИ-агента | Критерий готовности |
|
||||||
|
|---|---|---|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---|
|
||||||
|
| P01 | сделано | База проекта | Поднять базовую архитектуру FastAPI + PostgreSQL + Redis + Celery + админку и лендинг | API запускается, миграции применяются, базовые страницы доступны |
|
||||||
|
| P02 | сделано | Модели и миграции | Создать основные таблицы (`requests`, `topics`, `statuses`, `form_fields`, `messages`, `attachments`, `status_history`, `audit_log`, `admin_users`, `otp_sessions`, `quotes`) + системные поля | Миграции `0001-0003` проходят, тесты миграций зеленые |
|
||||||
|
| P03 | сделано | Универсальный CRUD | Реализовать универсальный CRUD + RBAC по таблицам + аудит изменений | CRUD работает для справочников и пользователей, аудит пишется |
|
||||||
|
| P04 | сделано | Пользователи и роли | CRUD пользователей, хеширование паролей, роль `ADMIN/LAWYER`, профильная тема (`primary_topic_code`) | Тесты на CRUD пользователей и пароли проходят |
|
||||||
|
| P05 | сделано | Базовый auto-assign | Реализовать автоназначение через 24 часа по профильной теме и активной нагрузке | `auto_assign_unclaimed` назначает корректно, тесты проходят |
|
||||||
|
| P06 | сделано | Базовая админ-панель | Перевод `admin.html` -> `admin.jsx`, универсальные таблицы/модалки/фильтры | Работа с сущностями через единый UI доступна |
|
||||||
|
| P07 | сделано | Темы юриста (расширение) | Добавить связующую таблицу `admin_user_topics` для дополнительных тем юриста; обновить CRUD/UI/фильтры | У юриста: 1 основная + N дополнительных тем; выбор тем в UI и в API |
|
||||||
|
| P08 | сделано | Назначение заявок (ручное) | Добавить endpoint «Взять в работу» для юриста с атомарной блокировкой (без гонок), только если заявка не назначена | Два юриста не могут одновременно взять одну заявку; takeover запрещен |
|
||||||
|
| P09 | сделано | Переназначение | Добавить ADMIN-only ручное переназначение уже назначенной заявки | Юрист не может перехватывать назначенные заявки, админ может переназначить |
|
||||||
|
| P10 | сделано | Алгоритм auto-assign v2 | Доработать автоназначение: приоритет `primary_topic` -> `admin_user_topics` -> минимальная активная нагрузка (`is_terminal=false`) | Воркеры и тесты покрывают новый приоритет и edge-cases |
|
||||||
|
| P11 | сделано | OTP create/view | Внедрить полноценный OTP-поток: OTP обязателен для создания заявки и просмотра по `track_number`, cookie JWT 7 дней | Без OTP нельзя создать/смотреть заявку; повторный OTP не нужен при валидной cookie |
|
||||||
|
| P12 | сделано | Публичный кабинет клиента | Реализовать публичные endpoints и UI: просмотр статуса, чат, файлы, история изменений по `track_number` | Клиент может вести диалог и видеть прогресс заявки в одном контуре |
|
||||||
|
| P13 | сделано | Read/unread маркеры | Добавить request-level маркеры «есть обновления» для клиента/юриста; открытие заявки сбрасывает маркер; одноразовая зеленая точка типа события | В списке заявок корректно отображаются непрочитанные обновления |
|
||||||
|
| P14 | сделано | Статусные флоу по темам | Добавить настройку линейных флоу с допустимыми вариациями переходов (Jira-like), валидировать переходы | Нельзя выполнить переход вне разрешенной цепочки |
|
||||||
|
| P15 | сделано | Иммутабельность по статусам | На смене статуса «замораживать» сообщения и вложения предыдущих статусов + писать `status_history` | Попытка изменения старых сообщений/файлов отклоняется API |
|
||||||
|
| P16 | сделано | Шаблоны данных | Разделить шаблоны: (1) обязательные поля создания по теме; (2) шаблон дозапроса документов в работе, расширяемый юристом только для текущей заявки | Админ настраивает базу, юрист расширяет только в рамках конкретной заявки |
|
||||||
|
| P17 | сделано | Файловый контур | Довести загрузку/скачивание файлов: лимиты 25MB/файл и 250MB/заявка, хранение метаданных, контроль суммарного объема | Лимиты enforced сервером, загрузки и скачивание стабильны |
|
||||||
|
| P18 | сделано | SLA-конфиг | Настройка SLA на каждый переход статуса для каждой темы | SLA-конфиг хранится в БД, валидируется в админке |
|
||||||
|
| P19 | сделано | SLA-check и overdue | Реализовать `sla_check`: контроль просрочек по переходам, расчет FRT/времени в статусе | Метрики и флаги просрочек обновляются по расписанию |
|
||||||
|
| P20 | сделано | Уведомления | Уведомления в Telegram (если подключен) + внутренние уведомления сайта по изменениям | При событиях (сообщения/файлы/статусы/SLA) уведомления доставляются |
|
||||||
|
| P21 | сделано | Dashboard LAWYER/ADMIN | Расширить дашборды: назначенные/неназначенные, активные по статусам, непрочитанные, SLA, по каждому юристу: активная загрузка, сумма активных заявок, вал оплаченных за месяц, зарплата за месяц | Дашборды соответствуют ролям и данным из БД |
|
||||||
|
| P22 | к разработке | Тестирование E2E | Покрыть ключевые бизнес-сценарии: OTP, claim, auto-assign v2, чат, файлы, SLA, уведомления, read markers | Набор автотестов фиксирует регрессии критичных сценариев |
|
||||||
|
| P23 | к разработке | Hardening/release | Полировка безопасности, логирования, лимитов, отказоустойчивости, документации API/UI и runbook | Проект готов к стабилизации и приемке |
|
||||||
|
| P24 | к разработке | Mobile UX | Мобильная адаптация лендинга и клиентских форм (заявка, OTP, кабинет клиента: чат, файлы, история) | UI корректно работает на 320-768px, элементы доступны и читаемы без горизонтального скролла |
|
||||||
|
| P25 | к разработке | Тарифы юристов | Добавить ставку и процент юриста (по умолчанию в профиле), а также фиксируемые в заявке поля ставки/суммы (override админом) | Финансовые поля заявки фиксируются и не зависят от последующих правок профиля; клиенту не показываются |
|
||||||
|
| P26 | к разработке | Биллинг-статус | Добавить тип статуса «выставление счета»: генерация счета из шаблона, отправка клиенту и фиксация события оплаты по смене статуса администратором на `Оплачено` | Для темы можно включить billing-этап, счет формируется и доставляется; факт оплаты фиксируется по событиям `Оплачено` (возможны множественные события в одной заявке) |
|
||||||
|
| P27 | к разработке | Security Audit | Внедрить аудит безопасности и защиту ПДн для S3/файлов по требованиям РФ и кибербезопасности | Реализован журнал доступа, шифрование, RBAC/least-privilege, политика хранения и контроль инцидентов |
|
||||||
|
|
||||||
|
## Критический маршрут (обязательный порядок)
|
||||||
|
1. `P07 -> P08 -> P09 -> P10` (полный контур назначения).
|
||||||
|
2. `P11 -> P12 -> P13` (публичный клиентский контур).
|
||||||
|
3. `P14 -> P15 -> P16` (процесс работы по заявке).
|
||||||
|
4. `P17 -> P18 -> P25 -> P26 -> P19 -> P20 -> P21` (файлы, SLA, тарифы/биллинг, аналитика).
|
||||||
|
5. `P22 -> P23 -> P24 -> P27` (стабилизация, mobile UX, security-аудит).
|
||||||
|
|
||||||
|
## Правила выполнения для ИИ-агента
|
||||||
|
1. Не менять бизнес-правила без обновления `context/*.md`.
|
||||||
|
2. Любую новую таблицу добавлять только через миграции + тест миграций.
|
||||||
|
3. На каждый новый endpoint добавлять позитивный и негативный автотест.
|
||||||
|
4. Для RBAC: сначала ограничить доступ, затем открывать минимально необходимое.
|
||||||
|
5. Для операций назначения использовать транзакционную защиту от гонок.
|
||||||
|
6. Для статусов и SLA использовать только серверную валидацию (не доверять фронту).
|
||||||
|
7. Перед переводом пункта в `сделано` выполнять проверки из `context/11_test_runbook.md`.
|
||||||
63
context/11_test_runbook.md
Normal file
63
context/11_test_runbook.md
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
# Runbook Проверок (Тесты и Валидация по Плану)
|
||||||
|
|
||||||
|
## Назначение
|
||||||
|
Этот файл фиксирует, где находятся проверки для каждого пункта `P01-P23` и как их запускать.
|
||||||
|
Использовать перед переводом пункта в статус `сделано`.
|
||||||
|
|
||||||
|
## Базовые команды
|
||||||
|
1. Применить миграции:
|
||||||
|
```bash
|
||||||
|
docker compose exec -T backend alembic upgrade head
|
||||||
|
```
|
||||||
|
2. Полный прогон автотестов:
|
||||||
|
```bash
|
||||||
|
docker compose exec -T backend python -m unittest discover -s tests -p 'test_*.py' -v
|
||||||
|
```
|
||||||
|
3. Быстрая проверка импорта/синтаксиса Python:
|
||||||
|
```bash
|
||||||
|
docker compose exec -T backend python -m compileall app tests alembic
|
||||||
|
```
|
||||||
|
4. Проверка сборки `admin.jsx` через Docker Compose (на образе `frontend`):
|
||||||
|
```bash
|
||||||
|
docker compose build frontend
|
||||||
|
docker compose run --rm --no-deps --entrypoint sh frontend -lc "apk add --no-cache nodejs npm >/dev/null && npx --yes esbuild /usr/share/nginx/html/admin.jsx --loader:.jsx=jsx --bundle --outfile=/tmp/admin.bundle.js"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Матрица проверок по задачам
|
||||||
|
| ID | Что проверяем | Где тесты | Как запускать |
|
||||||
|
|---|---|---|---|
|
||||||
|
| P01 | Базовый запуск сервисов и API | smoke + общие тесты | `docker compose up -d`; затем базовые команды 1-3 |
|
||||||
|
| P02 | Таблицы и миграции | `tests/test_migrations.py` | `docker compose exec -T backend python -m unittest tests.test_migrations -v` |
|
||||||
|
| P03 | Universal CRUD + RBAC + audit | `tests/test_admin_universal_crud.py` | `docker compose exec -T backend python -m unittest tests.test_admin_universal_crud.AdminUniversalCrudTests -v` |
|
||||||
|
| P04 | Пользователи, роли, пароли | `tests/test_admin_universal_crud.py` (тесты про `admin_users`) | команда как для `P03` |
|
||||||
|
| P05 | Базовый auto-assign | `tests/test_auto_assign.py` | `docker compose exec -T backend python -m unittest tests.test_auto_assign -v` |
|
||||||
|
| P06 | Админка `admin.jsx` + базовый UI контур | сборка `admin.jsx` + CRUD/API тесты | базовая команда 4 + тесты `P03` |
|
||||||
|
| P07 | Доп. темы юристов (`admin_user_topics`) | `tests/test_admin_universal_crud.py` | команда как для `P03` |
|
||||||
|
| P08 | Ручной claim (без гонок) | `tests/test_admin_universal_crud.py` (claim-тесты) | команда как для `P03` |
|
||||||
|
| P09 | ADMIN-only переназначение | `tests/test_admin_universal_crud.py` (reassign-тесты) | команда как для `P03` |
|
||||||
|
| P10 | Auto-assign v2 приоритетов | `tests/test_auto_assign.py` | команда как для `P05` |
|
||||||
|
| P11 | OTP create/view + 7-day cookie | `tests/test_public_requests.py` | `docker compose exec -T backend python -m unittest tests.test_public_requests -v` |
|
||||||
|
| P12 | Публичный кабинет (статус/чат/файлы/таймлайн) | `tests/test_public_cabinet.py` | `docker compose exec -T backend python -m unittest tests.test_public_cabinet -v` |
|
||||||
|
| P13 | Read/unread маркеры | `tests/test_public_requests.py`, `tests/test_admin_universal_crud.py`, `tests/test_uploads_s3.py` | запустить 3 набора: `test_public_requests`, `test_admin_universal_crud`, `test_uploads_s3` |
|
||||||
|
| P14 | Валидация флоу статусов по темам | `tests/test_admin_universal_crud.py` (status-flow тесты) | команда как для `P03` |
|
||||||
|
| P15 | Иммутабельность сообщений/файлов на смене статуса | `tests/test_admin_universal_crud.py`, `tests/test_uploads_s3.py` | `test_admin_universal_crud` + `test_uploads_s3` |
|
||||||
|
| P16 | Шаблоны данных (required + request template) | `tests/test_public_requests.py`, `tests/test_admin_universal_crud.py`, `tests/test_migrations.py` | запустить 3 набора + миграции |
|
||||||
|
| P17 | Файловый контур и лимиты | `tests/test_uploads_s3.py`, `tests/test_worker_maintenance.py` | `docker compose exec -T backend python -m unittest tests.test_uploads_s3 tests.test_worker_maintenance -v` |
|
||||||
|
| P18 | SLA-конфиг | `tests/test_admin_universal_crud.py`, `tests/test_migrations.py` | `alembic upgrade head`; затем `python -m unittest tests.test_admin_universal_crud tests.test_migrations -v` |
|
||||||
|
| P19 | SLA overdue/FRT расчеты | `tests/test_worker_maintenance.py`, `tests/test_admin_universal_crud.py` (metrics) | `docker compose exec -T backend python -m unittest tests.test_worker_maintenance tests.test_admin_universal_crud -v`; проверить `overdue_by_transition` |
|
||||||
|
| P20 | Уведомления | `tests/test_notifications.py`, а также регрессии `tests/test_public_cabinet.py`, `tests/test_uploads_s3.py`, `tests/test_worker_maintenance.py` | `docker compose exec -T backend python -m unittest tests.test_notifications tests.test_public_cabinet tests.test_uploads_s3 tests.test_worker_maintenance -v`; затем полный прогон |
|
||||||
|
| P21 | Dashboard ADMIN/LAWYER | `tests/test_admin_universal_crud.py` (metrics/dashboard) + `tests/test_dashboard_finance.py` | `docker compose exec -T backend python -m unittest tests.test_dashboard_finance tests.test_admin_universal_crud -v`; проверить role-scope и метрики юристов: загрузка, сумма активных, вал за месяц, зарплата за месяц |
|
||||||
|
| P22 | E2E критические сценарии | набор `tests/test_*.py` + новые E2E-тесты | базовые команды 1-3 + полный прогон |
|
||||||
|
| P23 | Hardening/release | весь regression + compile + миграции + UI build | базовые команды 1-4 |
|
||||||
|
| P24 | Мобильная адаптация лендинга/клиентских форм | `app/web/landing.html` + ручная проверка в mobile viewport | собрать `admin.jsx` при затрагивании админки + открыть `landing.html` в 320px/375px/768px, проверить формы/чат/файлы без горизонтального скролла |
|
||||||
|
| P25 | Ставки юриста и ставка заявки | новые тесты `tests/test_rates.py` + интеграционные в `tests/test_admin_universal_crud.py` | прогон `test_rates` + `test_admin_universal_crud`; проверка что public API не отдает поля ставок/процентов |
|
||||||
|
| P26 | Billing-статус и шаблон счета | новые тесты `tests/test_billing_flow.py` + e2e статусных переходов | прогон `test_billing_flow` + `test_admin_universal_crud`; валидация генерации счета и фиксации оплаты при ADMIN->\"Оплачено\" (в т.ч. множественные оплаты в одной заявке) |
|
||||||
|
| P27 | Security audit S3/ПДн | новые тесты `tests/test_security_audit.py` + `tests/test_uploads_s3.py` | прогон `test_security_audit` + `test_uploads_s3`; проверка логирования и ограничений доступа |
|
||||||
|
|
||||||
|
## Минимальный чеклист закрытия пункта
|
||||||
|
1. Выполнить миграции (если были изменения схемы).
|
||||||
|
2. Выполнить целевые тесты пункта по матрице выше.
|
||||||
|
3. Выполнить полный прогон `unittest discover`.
|
||||||
|
4. Выполнить `compileall`.
|
||||||
|
5. Для изменений `admin.jsx` выполнить сборку `admin.jsx` через Docker Compose.
|
||||||
|
6. После успешной проверки обновить статус пункта в `context/10_development_execution_plan.md`.
|
||||||
1252
tests/test_admin_universal_crud.py
Normal file
1252
tests/test_admin_universal_crud.py
Normal file
File diff suppressed because it is too large
Load diff
356
tests/test_auto_assign.py
Normal file
356
tests/test_auto_assign.py
Normal file
|
|
@ -0,0 +1,356 @@
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine, delete
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
|
||||||
|
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0")
|
||||||
|
os.environ.setdefault("S3_ENDPOINT", "http://localhost:9000")
|
||||||
|
os.environ.setdefault("S3_ACCESS_KEY", "test")
|
||||||
|
os.environ.setdefault("S3_SECRET_KEY", "test")
|
||||||
|
os.environ.setdefault("S3_BUCKET", "test")
|
||||||
|
|
||||||
|
from app.models.admin_user import AdminUser
|
||||||
|
from app.models.admin_user_topic import AdminUserTopic
|
||||||
|
from app.models.audit_log import AuditLog
|
||||||
|
from app.models.request import Request
|
||||||
|
from app.models.status import Status
|
||||||
|
from app.workers.tasks import assign as assign_task
|
||||||
|
|
||||||
|
|
||||||
|
class AutoAssignTaskTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.engine = create_engine(
|
||||||
|
"sqlite+pysqlite:///:memory:",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=StaticPool,
|
||||||
|
)
|
||||||
|
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
|
||||||
|
AdminUser.__table__.create(bind=cls.engine)
|
||||||
|
AdminUserTopic.__table__.create(bind=cls.engine)
|
||||||
|
Status.__table__.create(bind=cls.engine)
|
||||||
|
Request.__table__.create(bind=cls.engine)
|
||||||
|
AuditLog.__table__.create(bind=cls.engine)
|
||||||
|
|
||||||
|
cls._old_session_local = assign_task.SessionLocal
|
||||||
|
assign_task.SessionLocal = cls.SessionLocal
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
assign_task.SessionLocal = cls._old_session_local
|
||||||
|
AuditLog.__table__.drop(bind=cls.engine)
|
||||||
|
Request.__table__.drop(bind=cls.engine)
|
||||||
|
Status.__table__.drop(bind=cls.engine)
|
||||||
|
AdminUserTopic.__table__.drop(bind=cls.engine)
|
||||||
|
AdminUser.__table__.drop(bind=cls.engine)
|
||||||
|
cls.engine.dispose()
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
db.execute(delete(AuditLog))
|
||||||
|
db.execute(delete(Request))
|
||||||
|
db.execute(delete(Status))
|
||||||
|
db.execute(delete(AdminUserTopic))
|
||||||
|
db.execute(delete(AdminUser))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
def _create_lawyer(self, db, *, name, email, topic_code=None, is_active=True):
|
||||||
|
lawyer = AdminUser(
|
||||||
|
role="LAWYER",
|
||||||
|
name=name,
|
||||||
|
email=email,
|
||||||
|
password_hash="hash",
|
||||||
|
is_active=is_active,
|
||||||
|
primary_topic_code=topic_code,
|
||||||
|
)
|
||||||
|
db.add(lawyer)
|
||||||
|
db.flush()
|
||||||
|
return lawyer
|
||||||
|
|
||||||
|
def _link_additional_topic(self, db, *, lawyer_id, topic_code):
|
||||||
|
row = AdminUserTopic(admin_user_id=lawyer_id, topic_code=topic_code)
|
||||||
|
db.add(row)
|
||||||
|
db.flush()
|
||||||
|
return row
|
||||||
|
|
||||||
|
def _create_request(
|
||||||
|
self,
|
||||||
|
db,
|
||||||
|
*,
|
||||||
|
track_number,
|
||||||
|
topic_code,
|
||||||
|
created_at,
|
||||||
|
status_code="NEW",
|
||||||
|
assigned_lawyer_id=None,
|
||||||
|
):
|
||||||
|
req = Request(
|
||||||
|
track_number=track_number,
|
||||||
|
client_name="Тестовый клиент",
|
||||||
|
client_phone="+79990000000",
|
||||||
|
topic_code=topic_code,
|
||||||
|
status_code=status_code,
|
||||||
|
description="Описание",
|
||||||
|
extra_fields={},
|
||||||
|
assigned_lawyer_id=assigned_lawyer_id,
|
||||||
|
total_attachments_bytes=0,
|
||||||
|
created_at=created_at,
|
||||||
|
updated_at=created_at,
|
||||||
|
)
|
||||||
|
db.add(req)
|
||||||
|
db.flush()
|
||||||
|
return req
|
||||||
|
|
||||||
|
def test_auto_assign_matches_topic_and_uses_lowest_active_load(self):
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
Status(code="NEW", name="Новая", enabled=True, sort_order=0, is_terminal=False),
|
||||||
|
Status(code="IN_PROGRESS", name="В работе", enabled=True, sort_order=1, is_terminal=False),
|
||||||
|
Status(code="CLOSED", name="Закрыта", enabled=True, sort_order=99, is_terminal=True),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
lawyer_low = self._create_lawyer(db, name="Юрист 1", email="law1@example.com", topic_code="family")
|
||||||
|
lawyer_high = self._create_lawyer(db, name="Юрист 2", email="law2@example.com", topic_code="family")
|
||||||
|
self._create_lawyer(db, name="Юрист 3", email="law3@example.com", topic_code="tax")
|
||||||
|
|
||||||
|
self._create_request(
|
||||||
|
db,
|
||||||
|
track_number="TRK-HIGH-1",
|
||||||
|
topic_code="family",
|
||||||
|
created_at=now - timedelta(hours=30),
|
||||||
|
status_code="NEW",
|
||||||
|
assigned_lawyer_id=str(lawyer_high.id),
|
||||||
|
)
|
||||||
|
self._create_request(
|
||||||
|
db,
|
||||||
|
track_number="TRK-HIGH-2",
|
||||||
|
topic_code="family",
|
||||||
|
created_at=now - timedelta(hours=29),
|
||||||
|
status_code="IN_PROGRESS",
|
||||||
|
assigned_lawyer_id=str(lawyer_high.id),
|
||||||
|
)
|
||||||
|
self._create_request(
|
||||||
|
db,
|
||||||
|
track_number="TRK-LOW-CLOSED",
|
||||||
|
topic_code="family",
|
||||||
|
created_at=now - timedelta(hours=28),
|
||||||
|
status_code="CLOSED",
|
||||||
|
assigned_lawyer_id=str(lawyer_low.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
target = self._create_request(
|
||||||
|
db,
|
||||||
|
track_number="TRK-TARGET",
|
||||||
|
topic_code="family",
|
||||||
|
created_at=now - timedelta(hours=25),
|
||||||
|
status_code="NEW",
|
||||||
|
assigned_lawyer_id=None,
|
||||||
|
)
|
||||||
|
fresh = self._create_request(
|
||||||
|
db,
|
||||||
|
track_number="TRK-FRESH",
|
||||||
|
topic_code="family",
|
||||||
|
created_at=now - timedelta(hours=3),
|
||||||
|
status_code="NEW",
|
||||||
|
assigned_lawyer_id=None,
|
||||||
|
)
|
||||||
|
unknown_topic = self._create_request(
|
||||||
|
db,
|
||||||
|
track_number="TRK-UNKNOWN",
|
||||||
|
topic_code="banking",
|
||||||
|
created_at=now - timedelta(hours=25),
|
||||||
|
status_code="NEW",
|
||||||
|
assigned_lawyer_id=None,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
target_id = str(target.id)
|
||||||
|
target_expected_lawyer_id = str(lawyer_low.id)
|
||||||
|
fresh_id = str(fresh.id)
|
||||||
|
unknown_topic_id = str(unknown_topic.id)
|
||||||
|
|
||||||
|
result = assign_task.auto_assign_unclaimed()
|
||||||
|
self.assertEqual(result["checked"], 2)
|
||||||
|
self.assertEqual(result["assigned"], 1)
|
||||||
|
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
assigned_target = db.get(Request, UUID(target_id))
|
||||||
|
self.assertIsNotNone(assigned_target)
|
||||||
|
self.assertEqual(assigned_target.assigned_lawyer_id, target_expected_lawyer_id)
|
||||||
|
|
||||||
|
still_fresh = db.get(Request, UUID(fresh_id))
|
||||||
|
self.assertIsNotNone(still_fresh)
|
||||||
|
self.assertIsNone(still_fresh.assigned_lawyer_id)
|
||||||
|
|
||||||
|
still_unknown = db.get(Request, UUID(unknown_topic_id))
|
||||||
|
self.assertIsNotNone(still_unknown)
|
||||||
|
self.assertIsNone(still_unknown.assigned_lawyer_id)
|
||||||
|
|
||||||
|
audit_rows = (
|
||||||
|
db.query(AuditLog)
|
||||||
|
.filter(AuditLog.entity == "requests", AuditLog.entity_id == target_id, AuditLog.action == "AUTO_ASSIGN")
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
self.assertEqual(len(audit_rows), 1)
|
||||||
|
|
||||||
|
def test_auto_assign_uses_default_terminal_statuses_when_dictionary_is_empty(self):
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
lawyer_low = self._create_lawyer(db, name="Юрист A", email="la@example.com", topic_code="civil")
|
||||||
|
lawyer_high = self._create_lawyer(db, name="Юрист B", email="lb@example.com", topic_code="civil")
|
||||||
|
|
||||||
|
self._create_request(
|
||||||
|
db,
|
||||||
|
track_number="TRK-CLOSED",
|
||||||
|
topic_code="civil",
|
||||||
|
created_at=now - timedelta(hours=40),
|
||||||
|
status_code="CLOSED",
|
||||||
|
assigned_lawyer_id=str(lawyer_low.id),
|
||||||
|
)
|
||||||
|
self._create_request(
|
||||||
|
db,
|
||||||
|
track_number="TRK-ACTIVE",
|
||||||
|
topic_code="civil",
|
||||||
|
created_at=now - timedelta(hours=40),
|
||||||
|
status_code="NEW",
|
||||||
|
assigned_lawyer_id=str(lawyer_high.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
target = self._create_request(
|
||||||
|
db,
|
||||||
|
track_number="TRK-TARGET2",
|
||||||
|
topic_code="civil",
|
||||||
|
created_at=now - timedelta(hours=26),
|
||||||
|
status_code="NEW",
|
||||||
|
assigned_lawyer_id=None,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
target_id = str(target.id)
|
||||||
|
expected_lawyer_id = str(lawyer_low.id)
|
||||||
|
|
||||||
|
result = assign_task.auto_assign_unclaimed()
|
||||||
|
self.assertEqual(result["assigned"], 1)
|
||||||
|
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
assigned_target = db.get(Request, UUID(target_id))
|
||||||
|
self.assertIsNotNone(assigned_target)
|
||||||
|
self.assertEqual(assigned_target.assigned_lawyer_id, expected_lawyer_id)
|
||||||
|
|
||||||
|
def test_auto_assign_prefers_primary_topic_over_additional_topic(self):
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
Status(code="NEW", name="Новая", enabled=True, sort_order=0, is_terminal=False),
|
||||||
|
Status(code="CLOSED", name="Закрыта", enabled=True, sort_order=99, is_terminal=True),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
lawyer_primary = self._create_lawyer(db, name="Primary", email="primary@example.com", topic_code="family")
|
||||||
|
lawyer_additional = self._create_lawyer(db, name="Additional", email="additional@example.com", topic_code="tax")
|
||||||
|
self._link_additional_topic(db, lawyer_id=lawyer_additional.id, topic_code="family")
|
||||||
|
|
||||||
|
self._create_request(
|
||||||
|
db,
|
||||||
|
track_number="TRK-PRI-LOAD-1",
|
||||||
|
topic_code="family",
|
||||||
|
created_at=now - timedelta(hours=40),
|
||||||
|
status_code="NEW",
|
||||||
|
assigned_lawyer_id=str(lawyer_primary.id),
|
||||||
|
)
|
||||||
|
self._create_request(
|
||||||
|
db,
|
||||||
|
track_number="TRK-PRI-LOAD-2",
|
||||||
|
topic_code="family",
|
||||||
|
created_at=now - timedelta(hours=39),
|
||||||
|
status_code="NEW",
|
||||||
|
assigned_lawyer_id=str(lawyer_primary.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
target = self._create_request(
|
||||||
|
db,
|
||||||
|
track_number="TRK-PRI-TARGET",
|
||||||
|
topic_code="family",
|
||||||
|
created_at=now - timedelta(hours=30),
|
||||||
|
status_code="NEW",
|
||||||
|
assigned_lawyer_id=None,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
target_id = str(target.id)
|
||||||
|
expected_lawyer_id = str(lawyer_primary.id)
|
||||||
|
|
||||||
|
result = assign_task.auto_assign_unclaimed()
|
||||||
|
self.assertEqual(result["checked"], 1)
|
||||||
|
self.assertEqual(result["assigned"], 1)
|
||||||
|
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
assigned_target = db.get(Request, UUID(target_id))
|
||||||
|
self.assertIsNotNone(assigned_target)
|
||||||
|
self.assertEqual(assigned_target.assigned_lawyer_id, expected_lawyer_id)
|
||||||
|
audit = (
|
||||||
|
db.query(AuditLog)
|
||||||
|
.filter(AuditLog.entity == "requests", AuditLog.entity_id == target_id, AuditLog.action == "AUTO_ASSIGN")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(audit)
|
||||||
|
self.assertEqual((audit.diff or {}).get("basis"), "primary_topic")
|
||||||
|
|
||||||
|
def test_auto_assign_falls_back_to_additional_topics_and_uses_lowest_load(self):
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
Status(code="NEW", name="Новая", enabled=True, sort_order=0, is_terminal=False),
|
||||||
|
Status(code="CLOSED", name="Закрыта", enabled=True, sort_order=99, is_terminal=True),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
lawyer_busy = self._create_lawyer(db, name="Busy", email="busy@example.com", topic_code="tax")
|
||||||
|
lawyer_free = self._create_lawyer(db, name="Free", email="free@example.com", topic_code="corporate")
|
||||||
|
lawyer_inactive = self._create_lawyer(db, name="Inactive", email="inactive@example.com", topic_code=None, is_active=False)
|
||||||
|
|
||||||
|
self._link_additional_topic(db, lawyer_id=lawyer_busy.id, topic_code="family")
|
||||||
|
self._link_additional_topic(db, lawyer_id=lawyer_free.id, topic_code="family")
|
||||||
|
self._link_additional_topic(db, lawyer_id=lawyer_inactive.id, topic_code="family")
|
||||||
|
|
||||||
|
self._create_request(
|
||||||
|
db,
|
||||||
|
track_number="TRK-BUSY-1",
|
||||||
|
topic_code="tax",
|
||||||
|
created_at=now - timedelta(hours=40),
|
||||||
|
status_code="NEW",
|
||||||
|
assigned_lawyer_id=str(lawyer_busy.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
target = self._create_request(
|
||||||
|
db,
|
||||||
|
track_number="TRK-ADD-TARGET",
|
||||||
|
topic_code="family",
|
||||||
|
created_at=now - timedelta(hours=30),
|
||||||
|
status_code="NEW",
|
||||||
|
assigned_lawyer_id=None,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
target_id = str(target.id)
|
||||||
|
expected_lawyer_id = str(lawyer_free.id)
|
||||||
|
|
||||||
|
result = assign_task.auto_assign_unclaimed()
|
||||||
|
self.assertEqual(result["checked"], 1)
|
||||||
|
self.assertEqual(result["assigned"], 1)
|
||||||
|
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
assigned_target = db.get(Request, UUID(target_id))
|
||||||
|
self.assertIsNotNone(assigned_target)
|
||||||
|
self.assertEqual(assigned_target.assigned_lawyer_id, expected_lawyer_id)
|
||||||
|
audit = (
|
||||||
|
db.query(AuditLog)
|
||||||
|
.filter(AuditLog.entity == "requests", AuditLog.entity_id == target_id, AuditLog.action == "AUTO_ASSIGN")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(audit)
|
||||||
|
self.assertEqual((audit.diff or {}).get("basis"), "additional_topic")
|
||||||
301
tests/test_dashboard_finance.py
Normal file
301
tests/test_dashboard_finance.py
Normal file
|
|
@ -0,0 +1,301 @@
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import create_engine, delete
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
|
||||||
|
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0")
|
||||||
|
os.environ.setdefault("S3_ENDPOINT", "http://localhost:9000")
|
||||||
|
os.environ.setdefault("S3_ACCESS_KEY", "test")
|
||||||
|
os.environ.setdefault("S3_SECRET_KEY", "test")
|
||||||
|
os.environ.setdefault("S3_BUCKET", "test")
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.security import create_jwt
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.main import app
|
||||||
|
from app.models.admin_user import AdminUser
|
||||||
|
from app.models.message import Message
|
||||||
|
from app.models.request import Request
|
||||||
|
from app.models.status import Status
|
||||||
|
from app.models.status_history import StatusHistory
|
||||||
|
from app.models.topic_status_transition import TopicStatusTransition
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardFinanceTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.engine = create_engine(
|
||||||
|
"sqlite+pysqlite:///:memory:",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=StaticPool,
|
||||||
|
)
|
||||||
|
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
|
||||||
|
AdminUser.__table__.create(bind=cls.engine)
|
||||||
|
Request.__table__.create(bind=cls.engine)
|
||||||
|
Status.__table__.create(bind=cls.engine)
|
||||||
|
Message.__table__.create(bind=cls.engine)
|
||||||
|
StatusHistory.__table__.create(bind=cls.engine)
|
||||||
|
TopicStatusTransition.__table__.create(bind=cls.engine)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
StatusHistory.__table__.drop(bind=cls.engine)
|
||||||
|
TopicStatusTransition.__table__.drop(bind=cls.engine)
|
||||||
|
Message.__table__.drop(bind=cls.engine)
|
||||||
|
Status.__table__.drop(bind=cls.engine)
|
||||||
|
Request.__table__.drop(bind=cls.engine)
|
||||||
|
AdminUser.__table__.drop(bind=cls.engine)
|
||||||
|
cls.engine.dispose()
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
db.execute(delete(StatusHistory))
|
||||||
|
db.execute(delete(TopicStatusTransition))
|
||||||
|
db.execute(delete(Message))
|
||||||
|
db.execute(delete(Request))
|
||||||
|
db.execute(delete(Status))
|
||||||
|
db.execute(delete(AdminUser))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
def override_get_db():
|
||||||
|
db = self.SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = override_get_db
|
||||||
|
self.client = TestClient(app)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.client.close()
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _headers(role: str, sub: str | None = None, email: str | None = None) -> dict[str, str]:
|
||||||
|
token = create_jwt(
|
||||||
|
{"sub": sub or str(uuid4()), "email": email or f"{role.lower()}@example.com", "role": role},
|
||||||
|
settings.ADMIN_JWT_SECRET,
|
||||||
|
timedelta(minutes=30),
|
||||||
|
)
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
def test_admin_dashboard_contains_lawyer_financial_metrics(self):
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
current_month_event = now - timedelta(days=2)
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
Status(code="NEW", name="Новая", enabled=True, sort_order=0, is_terminal=False),
|
||||||
|
Status(code="CLOSED", name="Закрыта", enabled=True, sort_order=1, is_terminal=True),
|
||||||
|
Status(code="PAID", name="Оплачено", enabled=True, sort_order=2, is_terminal=False),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
lawyer_a = AdminUser(
|
||||||
|
role="LAWYER",
|
||||||
|
name="Юрист A",
|
||||||
|
email="lawyer.a@example.com",
|
||||||
|
password_hash="hash",
|
||||||
|
salary_percent=30,
|
||||||
|
default_rate=5000,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
lawyer_b = AdminUser(
|
||||||
|
role="LAWYER",
|
||||||
|
name="Юрист B",
|
||||||
|
email="lawyer.b@example.com",
|
||||||
|
password_hash="hash",
|
||||||
|
salary_percent=10,
|
||||||
|
default_rate=3000,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add_all([lawyer_a, lawyer_b])
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
req_a_active = Request(
|
||||||
|
track_number="TRK-FIN-A1",
|
||||||
|
client_name="Клиент A1",
|
||||||
|
client_phone="+79990000010",
|
||||||
|
topic_code="civil",
|
||||||
|
status_code="NEW",
|
||||||
|
assigned_lawyer_id=str(lawyer_a.id),
|
||||||
|
invoice_amount=1000,
|
||||||
|
extra_fields={},
|
||||||
|
)
|
||||||
|
req_a_closed = Request(
|
||||||
|
track_number="TRK-FIN-A2",
|
||||||
|
client_name="Клиент A2",
|
||||||
|
client_phone="+79990000011",
|
||||||
|
topic_code="civil",
|
||||||
|
status_code="CLOSED",
|
||||||
|
assigned_lawyer_id=str(lawyer_a.id),
|
||||||
|
invoice_amount=500,
|
||||||
|
extra_fields={},
|
||||||
|
)
|
||||||
|
req_b_active = Request(
|
||||||
|
track_number="TRK-FIN-B1",
|
||||||
|
client_name="Клиент B1",
|
||||||
|
client_phone="+79990000012",
|
||||||
|
topic_code="civil",
|
||||||
|
status_code="NEW",
|
||||||
|
assigned_lawyer_id=str(lawyer_b.id),
|
||||||
|
invoice_amount=2000,
|
||||||
|
extra_fields={},
|
||||||
|
)
|
||||||
|
db.add_all([req_a_active, req_a_closed, req_b_active])
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
StatusHistory(
|
||||||
|
request_id=req_a_active.id,
|
||||||
|
from_status="INVOICE",
|
||||||
|
to_status="PAID",
|
||||||
|
changed_by_admin_id=None,
|
||||||
|
created_at=current_month_event,
|
||||||
|
updated_at=current_month_event,
|
||||||
|
),
|
||||||
|
StatusHistory(
|
||||||
|
request_id=req_a_active.id,
|
||||||
|
from_status="INVOICE",
|
||||||
|
to_status="PAID",
|
||||||
|
changed_by_admin_id=None,
|
||||||
|
created_at=current_month_event + timedelta(hours=1),
|
||||||
|
updated_at=current_month_event + timedelta(hours=1),
|
||||||
|
),
|
||||||
|
StatusHistory(
|
||||||
|
request_id=req_a_closed.id,
|
||||||
|
from_status="INVOICE",
|
||||||
|
to_status="PAID",
|
||||||
|
changed_by_admin_id=None,
|
||||||
|
created_at=current_month_event + timedelta(hours=2),
|
||||||
|
updated_at=current_month_event + timedelta(hours=2),
|
||||||
|
),
|
||||||
|
StatusHistory(
|
||||||
|
request_id=req_b_active.id,
|
||||||
|
from_status="INVOICE",
|
||||||
|
to_status="PAID",
|
||||||
|
changed_by_admin_id=None,
|
||||||
|
created_at=now - timedelta(days=40),
|
||||||
|
updated_at=now - timedelta(days=40),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
response = self.client.get("/api/admin/metrics/overview", headers=self._headers("ADMIN"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
body = response.json()
|
||||||
|
self.assertEqual(body.get("scope"), "ADMIN")
|
||||||
|
self.assertIn("lawyer_loads", body)
|
||||||
|
|
||||||
|
by_email = {row["email"]: row for row in body["lawyer_loads"]}
|
||||||
|
self.assertEqual(by_email["lawyer.a@example.com"]["active_load"], 1)
|
||||||
|
self.assertEqual(by_email["lawyer.a@example.com"]["total_assigned"], 2)
|
||||||
|
self.assertAlmostEqual(float(by_email["lawyer.a@example.com"]["active_amount"]), 1000.0, places=2)
|
||||||
|
self.assertEqual(by_email["lawyer.a@example.com"]["monthly_paid_events"], 3)
|
||||||
|
self.assertAlmostEqual(float(by_email["lawyer.a@example.com"]["monthly_paid_gross"]), 2500.0, places=2)
|
||||||
|
self.assertAlmostEqual(float(by_email["lawyer.a@example.com"]["monthly_salary"]), 750.0, places=2)
|
||||||
|
|
||||||
|
self.assertEqual(by_email["lawyer.b@example.com"]["active_load"], 1)
|
||||||
|
self.assertAlmostEqual(float(by_email["lawyer.b@example.com"]["active_amount"]), 2000.0, places=2)
|
||||||
|
self.assertEqual(by_email["lawyer.b@example.com"]["monthly_paid_events"], 0)
|
||||||
|
self.assertAlmostEqual(float(by_email["lawyer.b@example.com"]["monthly_paid_gross"]), 0.0, places=2)
|
||||||
|
self.assertAlmostEqual(float(by_email["lawyer.b@example.com"]["monthly_salary"]), 0.0, places=2)
|
||||||
|
|
||||||
|
def test_lawyer_dashboard_is_scoped_to_current_lawyer(self):
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
Status(code="NEW", name="Новая", enabled=True, sort_order=0, is_terminal=False),
|
||||||
|
Status(code="CLOSED", name="Закрыта", enabled=True, sort_order=1, is_terminal=True),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
lawyer_a = AdminUser(
|
||||||
|
role="LAWYER",
|
||||||
|
name="Юрист A",
|
||||||
|
email="lawyer.scope.a@example.com",
|
||||||
|
password_hash="hash",
|
||||||
|
salary_percent=20,
|
||||||
|
default_rate=4000,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
lawyer_b = AdminUser(
|
||||||
|
role="LAWYER",
|
||||||
|
name="Юрист B",
|
||||||
|
email="lawyer.scope.b@example.com",
|
||||||
|
password_hash="hash",
|
||||||
|
salary_percent=15,
|
||||||
|
default_rate=3500,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add_all([lawyer_a, lawyer_b])
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
Request(
|
||||||
|
track_number="TRK-SCOPE-A1",
|
||||||
|
client_name="Клиент A1",
|
||||||
|
client_phone="+79990001001",
|
||||||
|
topic_code="civil",
|
||||||
|
status_code="NEW",
|
||||||
|
assigned_lawyer_id=str(lawyer_a.id),
|
||||||
|
lawyer_has_unread_updates=True,
|
||||||
|
lawyer_unread_event_type="MESSAGE",
|
||||||
|
extra_fields={},
|
||||||
|
),
|
||||||
|
Request(
|
||||||
|
track_number="TRK-SCOPE-A2",
|
||||||
|
client_name="Клиент A2",
|
||||||
|
client_phone="+79990001002",
|
||||||
|
topic_code="civil",
|
||||||
|
status_code="CLOSED",
|
||||||
|
assigned_lawyer_id=str(lawyer_a.id),
|
||||||
|
extra_fields={},
|
||||||
|
),
|
||||||
|
Request(
|
||||||
|
track_number="TRK-SCOPE-B1",
|
||||||
|
client_name="Клиент B1",
|
||||||
|
client_phone="+79990001003",
|
||||||
|
topic_code="civil",
|
||||||
|
status_code="NEW",
|
||||||
|
assigned_lawyer_id=str(lawyer_b.id),
|
||||||
|
extra_fields={},
|
||||||
|
),
|
||||||
|
Request(
|
||||||
|
track_number="TRK-SCOPE-U1",
|
||||||
|
client_name="Клиент U1",
|
||||||
|
client_phone="+79990001004",
|
||||||
|
topic_code="civil",
|
||||||
|
status_code="NEW",
|
||||||
|
assigned_lawyer_id=None,
|
||||||
|
extra_fields={},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
lawyer_a_id = str(lawyer_a.id)
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
"/api/admin/metrics/overview",
|
||||||
|
headers=self._headers("LAWYER", sub=lawyer_a_id, email="lawyer.scope.a@example.com"),
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
body = response.json()
|
||||||
|
self.assertEqual(body.get("scope"), "LAWYER")
|
||||||
|
self.assertEqual(int(body.get("assigned_total") or 0), 2)
|
||||||
|
self.assertEqual(int(body.get("active_assigned_total") or 0), 1)
|
||||||
|
self.assertEqual(int(body.get("unassigned_total") or 0), 1)
|
||||||
|
self.assertEqual(int(body.get("my_unread_updates") or 0), 1)
|
||||||
|
self.assertEqual(int((body.get("my_unread_by_event") or {}).get("MESSAGE") or 0), 1)
|
||||||
|
self.assertEqual(int((body.get("by_status") or {}).get("NEW") or 0), 1)
|
||||||
|
self.assertEqual(int((body.get("by_status") or {}).get("CLOSED") or 0), 1)
|
||||||
|
self.assertEqual(len(body.get("lawyer_loads") or []), 1)
|
||||||
|
self.assertEqual((body.get("lawyer_loads") or [])[0].get("lawyer_id"), lawyer_a_id)
|
||||||
|
|
@ -83,6 +83,9 @@ class MigrationTests(unittest.TestCase):
|
||||||
"topics",
|
"topics",
|
||||||
"statuses",
|
"statuses",
|
||||||
"form_fields",
|
"form_fields",
|
||||||
|
"topic_required_fields",
|
||||||
|
"topic_data_templates",
|
||||||
|
"request_data_requirements",
|
||||||
"requests",
|
"requests",
|
||||||
"messages",
|
"messages",
|
||||||
"attachments",
|
"attachments",
|
||||||
|
|
@ -90,6 +93,9 @@ class MigrationTests(unittest.TestCase):
|
||||||
"audit_log",
|
"audit_log",
|
||||||
"otp_sessions",
|
"otp_sessions",
|
||||||
"quotes",
|
"quotes",
|
||||||
|
"admin_user_topics",
|
||||||
|
"topic_status_transitions",
|
||||||
|
"notifications",
|
||||||
"alembic_version",
|
"alembic_version",
|
||||||
}
|
}
|
||||||
tables = set(self.inspector.get_table_names())
|
tables = set(self.inspector.get_table_names())
|
||||||
|
|
@ -98,4 +104,70 @@ class MigrationTests(unittest.TestCase):
|
||||||
def test_alembic_version_is_set(self):
|
def test_alembic_version_is_set(self):
|
||||||
with self.engine.connect() as conn:
|
with self.engine.connect() as conn:
|
||||||
version = conn.execute(text("SELECT version_num FROM alembic_version")).scalar_one()
|
version = conn.execute(text("SELECT version_num FROM alembic_version")).scalar_one()
|
||||||
self.assertEqual(version, "0001_init")
|
self.assertEqual(version, "0011_dashboard_financial_fields")
|
||||||
|
|
||||||
|
def test_responsible_column_exists_in_all_domain_tables(self):
|
||||||
|
tables = {
|
||||||
|
"admin_users",
|
||||||
|
"topics",
|
||||||
|
"statuses",
|
||||||
|
"form_fields",
|
||||||
|
"topic_required_fields",
|
||||||
|
"topic_data_templates",
|
||||||
|
"request_data_requirements",
|
||||||
|
"requests",
|
||||||
|
"messages",
|
||||||
|
"attachments",
|
||||||
|
"status_history",
|
||||||
|
"audit_log",
|
||||||
|
"otp_sessions",
|
||||||
|
"quotes",
|
||||||
|
"admin_user_topics",
|
||||||
|
"topic_status_transitions",
|
||||||
|
"notifications",
|
||||||
|
}
|
||||||
|
for table in tables:
|
||||||
|
columns = {column["name"] for column in self.inspector.get_columns(table)}
|
||||||
|
self.assertIn("id", columns)
|
||||||
|
self.assertIn("created_at", columns)
|
||||||
|
self.assertIn("responsible", columns)
|
||||||
|
|
||||||
|
def test_admin_users_contains_primary_topic_profile_column(self):
|
||||||
|
columns = {column["name"] for column in self.inspector.get_columns("admin_users")}
|
||||||
|
self.assertIn("primary_topic_code", columns)
|
||||||
|
|
||||||
|
def test_admin_users_contains_avatar_column(self):
|
||||||
|
columns = {column["name"] for column in self.inspector.get_columns("admin_users")}
|
||||||
|
self.assertIn("avatar_url", columns)
|
||||||
|
|
||||||
|
def test_requests_contains_read_marker_columns(self):
|
||||||
|
columns = {column["name"] for column in self.inspector.get_columns("requests")}
|
||||||
|
self.assertIn("client_has_unread_updates", columns)
|
||||||
|
self.assertIn("client_unread_event_type", columns)
|
||||||
|
self.assertIn("lawyer_has_unread_updates", columns)
|
||||||
|
self.assertIn("lawyer_unread_event_type", columns)
|
||||||
|
|
||||||
|
def test_status_transitions_contains_sla_hours_column(self):
|
||||||
|
columns = {column["name"] for column in self.inspector.get_columns("topic_status_transitions")}
|
||||||
|
self.assertIn("sla_hours", columns)
|
||||||
|
|
||||||
|
def test_notifications_has_recipient_and_read_columns(self):
|
||||||
|
columns = {column["name"] for column in self.inspector.get_columns("notifications")}
|
||||||
|
self.assertIn("recipient_type", columns)
|
||||||
|
self.assertIn("recipient_admin_user_id", columns)
|
||||||
|
self.assertIn("recipient_track_number", columns)
|
||||||
|
self.assertIn("event_type", columns)
|
||||||
|
self.assertIn("is_read", columns)
|
||||||
|
self.assertIn("read_at", columns)
|
||||||
|
|
||||||
|
def test_admin_users_contains_rate_columns(self):
|
||||||
|
columns = {column["name"] for column in self.inspector.get_columns("admin_users")}
|
||||||
|
self.assertIn("default_rate", columns)
|
||||||
|
self.assertIn("salary_percent", columns)
|
||||||
|
|
||||||
|
def test_requests_contains_financial_columns(self):
|
||||||
|
columns = {column["name"] for column in self.inspector.get_columns("requests")}
|
||||||
|
self.assertIn("effective_rate", columns)
|
||||||
|
self.assertIn("invoice_amount", columns)
|
||||||
|
self.assertIn("paid_at", columns)
|
||||||
|
self.assertIn("paid_by_admin_id", columns)
|
||||||
|
|
|
||||||
375
tests/test_notifications.py
Normal file
375
tests/test_notifications.py
Normal file
|
|
@ -0,0 +1,375 @@
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from uuid import UUID
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from botocore.exceptions import ClientError
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import create_engine, delete
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
|
||||||
|
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0")
|
||||||
|
os.environ.setdefault("S3_ENDPOINT", "http://localhost:9000")
|
||||||
|
os.environ.setdefault("S3_ACCESS_KEY", "test")
|
||||||
|
os.environ.setdefault("S3_SECRET_KEY", "test")
|
||||||
|
os.environ.setdefault("S3_BUCKET", "test")
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.security import create_jwt
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.main import app
|
||||||
|
from app.models.admin_user import AdminUser
|
||||||
|
from app.models.attachment import Attachment
|
||||||
|
from app.models.message import Message
|
||||||
|
from app.models.notification import Notification
|
||||||
|
from app.models.request import Request
|
||||||
|
from app.models.status import Status
|
||||||
|
from app.models.status_history import StatusHistory
|
||||||
|
from app.models.topic_status_transition import TopicStatusTransition
|
||||||
|
from app.workers.tasks import sla as sla_task
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeS3Storage:
|
||||||
|
def __init__(self):
|
||||||
|
self.objects = {}
|
||||||
|
|
||||||
|
def create_presigned_put_url(self, key: str, mime_type: str, expires_sec: int = 900) -> str:
|
||||||
|
return f"https://s3.local/{key}?expires={expires_sec}"
|
||||||
|
|
||||||
|
def head_object(self, key: str) -> dict:
|
||||||
|
obj = self.objects.get(key)
|
||||||
|
if obj is None:
|
||||||
|
raise ClientError({"Error": {"Code": "404", "Message": "Not Found"}}, "HeadObject")
|
||||||
|
return {"ContentLength": obj["size"], "ContentType": obj["mime"]}
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationFlowTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.engine = create_engine(
|
||||||
|
"sqlite+pysqlite:///:memory:",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=StaticPool,
|
||||||
|
)
|
||||||
|
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
|
||||||
|
AdminUser.__table__.create(bind=cls.engine)
|
||||||
|
Request.__table__.create(bind=cls.engine)
|
||||||
|
Message.__table__.create(bind=cls.engine)
|
||||||
|
Attachment.__table__.create(bind=cls.engine)
|
||||||
|
StatusHistory.__table__.create(bind=cls.engine)
|
||||||
|
TopicStatusTransition.__table__.create(bind=cls.engine)
|
||||||
|
Notification.__table__.create(bind=cls.engine)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
Notification.__table__.drop(bind=cls.engine)
|
||||||
|
TopicStatusTransition.__table__.drop(bind=cls.engine)
|
||||||
|
StatusHistory.__table__.drop(bind=cls.engine)
|
||||||
|
Attachment.__table__.drop(bind=cls.engine)
|
||||||
|
Message.__table__.drop(bind=cls.engine)
|
||||||
|
Request.__table__.drop(bind=cls.engine)
|
||||||
|
AdminUser.__table__.drop(bind=cls.engine)
|
||||||
|
cls.engine.dispose()
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
db.execute(delete(Notification))
|
||||||
|
db.execute(delete(StatusHistory))
|
||||||
|
db.execute(delete(TopicStatusTransition))
|
||||||
|
db.execute(delete(Attachment))
|
||||||
|
db.execute(delete(Message))
|
||||||
|
db.execute(delete(Request))
|
||||||
|
db.execute(delete(AdminUser))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
def override_get_db():
|
||||||
|
db = self.SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = override_get_db
|
||||||
|
self.client = TestClient(app)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.client.close()
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _admin_headers(sub: str, role: str, email: str) -> dict[str, str]:
|
||||||
|
token = create_jwt(
|
||||||
|
{"sub": sub, "email": email, "role": role},
|
||||||
|
settings.ADMIN_JWT_SECRET,
|
||||||
|
timedelta(minutes=30),
|
||||||
|
)
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _public_cookies(track_number: str) -> dict[str, str]:
|
||||||
|
token = create_jwt({"sub": track_number, "purpose": "VIEW_REQUEST"}, settings.PUBLIC_JWT_SECRET, timedelta(days=1))
|
||||||
|
return {settings.PUBLIC_COOKIE_NAME: token}
|
||||||
|
|
||||||
|
def test_public_message_creates_internal_notification_for_lawyer(self):
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
lawyer = AdminUser(
|
||||||
|
role="LAWYER",
|
||||||
|
name="Юрист",
|
||||||
|
email="lawyer@example.com",
|
||||||
|
password_hash="hash",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(lawyer)
|
||||||
|
db.flush()
|
||||||
|
req = Request(
|
||||||
|
track_number="TRK-NOTIF-MSG",
|
||||||
|
client_name="Клиент",
|
||||||
|
client_phone="+79990000001",
|
||||||
|
topic_code="civil",
|
||||||
|
status_code="NEW",
|
||||||
|
description="notification",
|
||||||
|
extra_fields={},
|
||||||
|
assigned_lawyer_id=str(lawyer.id),
|
||||||
|
)
|
||||||
|
db.add(req)
|
||||||
|
db.commit()
|
||||||
|
lawyer_id = str(lawyer.id)
|
||||||
|
|
||||||
|
created = self.client.post(
|
||||||
|
"/api/public/requests/TRK-NOTIF-MSG/messages",
|
||||||
|
cookies=self._public_cookies("TRK-NOTIF-MSG"),
|
||||||
|
json={"body": "Есть новое сообщение"},
|
||||||
|
)
|
||||||
|
self.assertEqual(created.status_code, 201)
|
||||||
|
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
rows = db.query(Notification).all()
|
||||||
|
self.assertEqual(len(rows), 1)
|
||||||
|
self.assertEqual(rows[0].event_type, "MESSAGE")
|
||||||
|
self.assertEqual(str(rows[0].recipient_admin_user_id), lawyer_id)
|
||||||
|
self.assertFalse(rows[0].is_read)
|
||||||
|
notif_id = str(rows[0].id)
|
||||||
|
|
||||||
|
headers = self._admin_headers(lawyer_id, "LAWYER", "lawyer@example.com")
|
||||||
|
listed = self.client.get("/api/admin/notifications", headers=headers)
|
||||||
|
self.assertEqual(listed.status_code, 200)
|
||||||
|
self.assertEqual(listed.json()["total"], 1)
|
||||||
|
self.assertEqual(listed.json()["unread_total"], 1)
|
||||||
|
|
||||||
|
marked = self.client.post(f"/api/admin/notifications/{notif_id}/read", headers=headers)
|
||||||
|
self.assertEqual(marked.status_code, 200)
|
||||||
|
self.assertEqual(marked.json()["changed"], 1)
|
||||||
|
|
||||||
|
unread = self.client.get("/api/admin/notifications?unread_only=true", headers=headers)
|
||||||
|
self.assertEqual(unread.status_code, 200)
|
||||||
|
self.assertEqual(unread.json()["total"], 0)
|
||||||
|
|
||||||
|
def test_admin_status_change_creates_client_notification_and_open_marks_read(self):
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
req = Request(
|
||||||
|
track_number="TRK-NOTIF-STATUS",
|
||||||
|
client_name="Клиент",
|
||||||
|
client_phone="+79990000002",
|
||||||
|
topic_code="civil",
|
||||||
|
status_code="NEW",
|
||||||
|
description="notification status",
|
||||||
|
extra_fields={},
|
||||||
|
)
|
||||||
|
db.add(req)
|
||||||
|
db.commit()
|
||||||
|
request_id = str(req.id)
|
||||||
|
|
||||||
|
headers = self._admin_headers(sub=str(UUID(int=1)), role="ADMIN", email="admin@example.com")
|
||||||
|
updated = self.client.patch(
|
||||||
|
f"/api/admin/requests/{request_id}",
|
||||||
|
headers=headers,
|
||||||
|
json={"status_code": "IN_PROGRESS"},
|
||||||
|
)
|
||||||
|
self.assertEqual(updated.status_code, 200)
|
||||||
|
|
||||||
|
listed = self.client.get(
|
||||||
|
"/api/public/requests/TRK-NOTIF-STATUS/notifications",
|
||||||
|
cookies=self._public_cookies("TRK-NOTIF-STATUS"),
|
||||||
|
)
|
||||||
|
self.assertEqual(listed.status_code, 200)
|
||||||
|
self.assertEqual(listed.json()["total"], 1)
|
||||||
|
self.assertEqual(listed.json()["rows"][0]["event_type"], "STATUS")
|
||||||
|
self.assertEqual(listed.json()["unread_total"], 1)
|
||||||
|
|
||||||
|
opened = self.client.get(
|
||||||
|
"/api/public/requests/TRK-NOTIF-STATUS",
|
||||||
|
cookies=self._public_cookies("TRK-NOTIF-STATUS"),
|
||||||
|
)
|
||||||
|
self.assertEqual(opened.status_code, 200)
|
||||||
|
|
||||||
|
unread = self.client.get(
|
||||||
|
"/api/public/requests/TRK-NOTIF-STATUS/notifications?unread_only=true",
|
||||||
|
cookies=self._public_cookies("TRK-NOTIF-STATUS"),
|
||||||
|
)
|
||||||
|
self.assertEqual(unread.status_code, 200)
|
||||||
|
self.assertEqual(unread.json()["total"], 0)
|
||||||
|
|
||||||
|
def test_public_attachment_creates_lawyer_notification(self):
|
||||||
|
fake_s3 = _FakeS3Storage()
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
lawyer = AdminUser(
|
||||||
|
role="LAWYER",
|
||||||
|
name="Юрист",
|
||||||
|
email="lawyer-file@example.com",
|
||||||
|
password_hash="hash",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(lawyer)
|
||||||
|
db.flush()
|
||||||
|
req = Request(
|
||||||
|
track_number="TRK-NOTIF-FILE",
|
||||||
|
client_name="Клиент",
|
||||||
|
client_phone="+79990000003",
|
||||||
|
topic_code="civil",
|
||||||
|
status_code="NEW",
|
||||||
|
description="notification file",
|
||||||
|
extra_fields={},
|
||||||
|
assigned_lawyer_id=str(lawyer.id),
|
||||||
|
)
|
||||||
|
db.add(req)
|
||||||
|
db.commit()
|
||||||
|
request_id = str(req.id)
|
||||||
|
lawyer_id = str(lawyer.id)
|
||||||
|
|
||||||
|
with patch("app.api.public.uploads.get_s3_storage", return_value=fake_s3):
|
||||||
|
init_resp = self.client.post(
|
||||||
|
"/api/public/uploads/init",
|
||||||
|
cookies=self._public_cookies("TRK-NOTIF-FILE"),
|
||||||
|
json={
|
||||||
|
"file_name": "doc.pdf",
|
||||||
|
"mime_type": "application/pdf",
|
||||||
|
"size_bytes": 1024,
|
||||||
|
"scope": "REQUEST_ATTACHMENT",
|
||||||
|
"request_id": request_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(init_resp.status_code, 200)
|
||||||
|
key = init_resp.json()["key"]
|
||||||
|
fake_s3.objects[key] = {"size": 1024, "mime": "application/pdf"}
|
||||||
|
|
||||||
|
complete = self.client.post(
|
||||||
|
"/api/public/uploads/complete",
|
||||||
|
cookies=self._public_cookies("TRK-NOTIF-FILE"),
|
||||||
|
json={
|
||||||
|
"key": key,
|
||||||
|
"file_name": "doc.pdf",
|
||||||
|
"mime_type": "application/pdf",
|
||||||
|
"size_bytes": 1024,
|
||||||
|
"scope": "REQUEST_ATTACHMENT",
|
||||||
|
"request_id": request_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(complete.status_code, 200)
|
||||||
|
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
rows = db.query(Notification).filter(Notification.event_type == "ATTACHMENT").all()
|
||||||
|
self.assertEqual(len(rows), 1)
|
||||||
|
self.assertEqual(str(rows[0].recipient_admin_user_id), lawyer_id)
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationSlaTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.engine = create_engine(
|
||||||
|
"sqlite+pysqlite:///:memory:",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=StaticPool,
|
||||||
|
)
|
||||||
|
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
|
||||||
|
AdminUser.__table__.create(bind=cls.engine)
|
||||||
|
Request.__table__.create(bind=cls.engine)
|
||||||
|
Status.__table__.create(bind=cls.engine)
|
||||||
|
Message.__table__.create(bind=cls.engine)
|
||||||
|
TopicStatusTransition.__table__.create(bind=cls.engine)
|
||||||
|
StatusHistory.__table__.create(bind=cls.engine)
|
||||||
|
Notification.__table__.create(bind=cls.engine)
|
||||||
|
|
||||||
|
cls._old_sla_session_local = sla_task.SessionLocal
|
||||||
|
sla_task.SessionLocal = cls.SessionLocal
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
sla_task.SessionLocal = cls._old_sla_session_local
|
||||||
|
Notification.__table__.drop(bind=cls.engine)
|
||||||
|
StatusHistory.__table__.drop(bind=cls.engine)
|
||||||
|
TopicStatusTransition.__table__.drop(bind=cls.engine)
|
||||||
|
Message.__table__.drop(bind=cls.engine)
|
||||||
|
Status.__table__.drop(bind=cls.engine)
|
||||||
|
Request.__table__.drop(bind=cls.engine)
|
||||||
|
AdminUser.__table__.drop(bind=cls.engine)
|
||||||
|
cls.engine.dispose()
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
db.execute(delete(Notification))
|
||||||
|
db.execute(delete(StatusHistory))
|
||||||
|
db.execute(delete(TopicStatusTransition))
|
||||||
|
db.execute(delete(Message))
|
||||||
|
db.execute(delete(Status))
|
||||||
|
db.execute(delete(Request))
|
||||||
|
db.execute(delete(AdminUser))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
def test_sla_overdue_notifications_are_deduplicated(self):
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
admin = AdminUser(
|
||||||
|
role="ADMIN",
|
||||||
|
name="Админ",
|
||||||
|
email="root@example.com",
|
||||||
|
password_hash="hash",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
lawyer = AdminUser(
|
||||||
|
role="LAWYER",
|
||||||
|
name="Юрист",
|
||||||
|
email="lawyer-sla@example.com",
|
||||||
|
password_hash="hash",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add_all([admin, lawyer])
|
||||||
|
db.flush()
|
||||||
|
db.add(Status(code="NEW", name="Новая", enabled=True, sort_order=0, is_terminal=False))
|
||||||
|
db.add(Status(code="IN_PROGRESS", name="В работе", enabled=True, sort_order=1, is_terminal=False))
|
||||||
|
db.add(
|
||||||
|
TopicStatusTransition(
|
||||||
|
topic_code="civil",
|
||||||
|
from_status="NEW",
|
||||||
|
to_status="IN_PROGRESS",
|
||||||
|
enabled=True,
|
||||||
|
sla_hours=1,
|
||||||
|
sort_order=1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
req = Request(
|
||||||
|
track_number="TRK-NOTIF-SLA",
|
||||||
|
client_name="Клиент",
|
||||||
|
client_phone="+79990000009",
|
||||||
|
topic_code="civil",
|
||||||
|
status_code="NEW",
|
||||||
|
description="sla",
|
||||||
|
extra_fields={},
|
||||||
|
assigned_lawyer_id=str(lawyer.id),
|
||||||
|
created_at=now - timedelta(hours=2),
|
||||||
|
updated_at=now - timedelta(hours=2),
|
||||||
|
)
|
||||||
|
db.add(req)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
first = sla_task.sla_check()
|
||||||
|
second = sla_task.sla_check()
|
||||||
|
|
||||||
|
self.assertGreaterEqual(first.get("notifications_created", 0), 2)
|
||||||
|
self.assertEqual(second.get("notifications_created", 0), 0)
|
||||||
|
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
rows = db.query(Notification).filter(Notification.event_type == "SLA_OVERDUE").all()
|
||||||
|
self.assertGreaterEqual(len(rows), 2)
|
||||||
266
tests/test_public_cabinet.py
Normal file
266
tests/test_public_cabinet.py
Normal file
|
|
@ -0,0 +1,266 @@
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from datetime import timedelta
|
||||||
|
from uuid import UUID
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from botocore.exceptions import ClientError
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import create_engine, delete
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
|
||||||
|
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0")
|
||||||
|
os.environ.setdefault("S3_ENDPOINT", "http://localhost:9000")
|
||||||
|
os.environ.setdefault("S3_ACCESS_KEY", "test")
|
||||||
|
os.environ.setdefault("S3_SECRET_KEY", "test")
|
||||||
|
os.environ.setdefault("S3_BUCKET", "test")
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.security import create_jwt
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.main import app
|
||||||
|
from app.models.attachment import Attachment
|
||||||
|
from app.models.message import Message
|
||||||
|
from app.models.notification import Notification
|
||||||
|
from app.models.request import Request
|
||||||
|
from app.models.status_history import StatusHistory
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeBody:
|
||||||
|
def __init__(self, payload: bytes):
|
||||||
|
self.payload = payload
|
||||||
|
|
||||||
|
def iter_chunks(self, chunk_size=65536):
|
||||||
|
for i in range(0, len(self.payload), chunk_size):
|
||||||
|
yield self.payload[i : i + chunk_size]
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeS3Storage:
|
||||||
|
def __init__(self):
|
||||||
|
self.objects = {}
|
||||||
|
|
||||||
|
def get_object(self, key: str) -> dict:
|
||||||
|
row = self.objects.get(key)
|
||||||
|
if row is None:
|
||||||
|
raise ClientError({"Error": {"Code": "404", "Message": "Not Found"}}, "GetObject")
|
||||||
|
return {"Body": _FakeBody(row["content"]), "ContentType": row["mime"], "ContentLength": row["size"]}
|
||||||
|
|
||||||
|
|
||||||
|
class PublicCabinetTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.engine = create_engine(
|
||||||
|
"sqlite+pysqlite:///:memory:",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=StaticPool,
|
||||||
|
)
|
||||||
|
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
|
||||||
|
Request.__table__.create(bind=cls.engine)
|
||||||
|
Notification.__table__.create(bind=cls.engine)
|
||||||
|
Message.__table__.create(bind=cls.engine)
|
||||||
|
Attachment.__table__.create(bind=cls.engine)
|
||||||
|
StatusHistory.__table__.create(bind=cls.engine)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
StatusHistory.__table__.drop(bind=cls.engine)
|
||||||
|
Attachment.__table__.drop(bind=cls.engine)
|
||||||
|
Message.__table__.drop(bind=cls.engine)
|
||||||
|
Notification.__table__.drop(bind=cls.engine)
|
||||||
|
Request.__table__.drop(bind=cls.engine)
|
||||||
|
cls.engine.dispose()
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
db.execute(delete(Notification))
|
||||||
|
db.execute(delete(StatusHistory))
|
||||||
|
db.execute(delete(Attachment))
|
||||||
|
db.execute(delete(Message))
|
||||||
|
db.execute(delete(Request))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
def override_get_db():
|
||||||
|
db = self.SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = override_get_db
|
||||||
|
self.client = TestClient(app)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.client.close()
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _public_cookies(track_number: str) -> dict[str, str]:
|
||||||
|
token = create_jwt({"sub": track_number, "purpose": "VIEW_REQUEST"}, settings.PUBLIC_JWT_SECRET, timedelta(days=1))
|
||||||
|
return {settings.PUBLIC_COOKIE_NAME: token}
|
||||||
|
|
||||||
|
def test_cabinet_lists_messages_attachments_history_and_timeline(self):
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
req = Request(
|
||||||
|
track_number="TRK-CAB-001",
|
||||||
|
client_name="Тест Клиент",
|
||||||
|
client_phone="+79991110000",
|
||||||
|
topic_code="consulting",
|
||||||
|
status_code="IN_PROGRESS",
|
||||||
|
description="Проверка кабинета",
|
||||||
|
extra_fields={},
|
||||||
|
)
|
||||||
|
db.add(req)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(req)
|
||||||
|
|
||||||
|
db.add(
|
||||||
|
Message(
|
||||||
|
request_id=req.id,
|
||||||
|
author_type="LAWYER",
|
||||||
|
author_name="Юрист",
|
||||||
|
body="Принял в работу.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.add(
|
||||||
|
Attachment(
|
||||||
|
request_id=req.id,
|
||||||
|
file_name="doc.pdf",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
size_bytes=1234,
|
||||||
|
s3_key="requests/key/doc.pdf",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.add(
|
||||||
|
StatusHistory(
|
||||||
|
request_id=req.id,
|
||||||
|
from_status="NEW",
|
||||||
|
to_status="IN_PROGRESS",
|
||||||
|
comment="Юрист взял заявку",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
cookies = self._public_cookies("TRK-CAB-001")
|
||||||
|
messages = self.client.get("/api/public/requests/TRK-CAB-001/messages", cookies=cookies)
|
||||||
|
self.assertEqual(messages.status_code, 200)
|
||||||
|
self.assertEqual(len(messages.json()), 1)
|
||||||
|
self.assertEqual(messages.json()[0]["author_type"], "LAWYER")
|
||||||
|
|
||||||
|
attachments = self.client.get("/api/public/requests/TRK-CAB-001/attachments", cookies=cookies)
|
||||||
|
self.assertEqual(attachments.status_code, 200)
|
||||||
|
self.assertEqual(len(attachments.json()), 1)
|
||||||
|
self.assertIn("/api/public/uploads/object/", attachments.json()[0]["download_url"])
|
||||||
|
|
||||||
|
history = self.client.get("/api/public/requests/TRK-CAB-001/history", cookies=cookies)
|
||||||
|
self.assertEqual(history.status_code, 200)
|
||||||
|
self.assertEqual(len(history.json()), 1)
|
||||||
|
self.assertEqual(history.json()[0]["to_status"], "IN_PROGRESS")
|
||||||
|
|
||||||
|
timeline = self.client.get("/api/public/requests/TRK-CAB-001/timeline", cookies=cookies)
|
||||||
|
self.assertEqual(timeline.status_code, 200)
|
||||||
|
events = timeline.json()
|
||||||
|
self.assertEqual(len(events), 3)
|
||||||
|
self.assertEqual({event["type"] for event in events}, {"status_change", "message", "attachment"})
|
||||||
|
|
||||||
|
def test_client_can_create_message_in_public_cabinet(self):
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
req = Request(
|
||||||
|
track_number="TRK-CAB-MSG",
|
||||||
|
client_name="Клиент Сообщение",
|
||||||
|
client_phone="+79992220000",
|
||||||
|
topic_code="consulting",
|
||||||
|
status_code="NEW",
|
||||||
|
description="Проверка отправки",
|
||||||
|
extra_fields={},
|
||||||
|
)
|
||||||
|
db.add(req)
|
||||||
|
db.commit()
|
||||||
|
request_id = req.id
|
||||||
|
|
||||||
|
cookies = self._public_cookies("TRK-CAB-MSG")
|
||||||
|
created = self.client.post(
|
||||||
|
"/api/public/requests/TRK-CAB-MSG/messages",
|
||||||
|
cookies=cookies,
|
||||||
|
json={"body": "Добрый день, есть вопрос по документам."},
|
||||||
|
)
|
||||||
|
self.assertEqual(created.status_code, 201)
|
||||||
|
message_id = UUID(created.json()["id"])
|
||||||
|
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
row = db.get(Message, message_id)
|
||||||
|
self.assertIsNotNone(row)
|
||||||
|
self.assertEqual(row.request_id, request_id)
|
||||||
|
self.assertEqual(row.author_type, "CLIENT")
|
||||||
|
self.assertEqual(row.body, "Добрый день, есть вопрос по документам.")
|
||||||
|
req = db.get(Request, request_id)
|
||||||
|
self.assertIsNotNone(req)
|
||||||
|
self.assertEqual(req.responsible, "Клиент")
|
||||||
|
self.assertTrue(req.lawyer_has_unread_updates)
|
||||||
|
self.assertEqual(req.lawyer_unread_event_type, "MESSAGE")
|
||||||
|
|
||||||
|
def test_public_cabinet_respects_track_access(self):
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
req = Request(
|
||||||
|
track_number="TRK-REAL",
|
||||||
|
client_name="Клиент Ограничение",
|
||||||
|
client_phone="+79993330000",
|
||||||
|
topic_code="consulting",
|
||||||
|
status_code="NEW",
|
||||||
|
description="Проверка доступа",
|
||||||
|
extra_fields={},
|
||||||
|
)
|
||||||
|
db.add(req)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
cookies = self._public_cookies("TRK-OTHER")
|
||||||
|
denied = self.client.get("/api/public/requests/TRK-REAL/messages", cookies=cookies)
|
||||||
|
self.assertEqual(denied.status_code, 403)
|
||||||
|
|
||||||
|
def test_public_attachment_download_requires_access(self):
|
||||||
|
fake_s3 = _FakeS3Storage()
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
req = Request(
|
||||||
|
track_number="TRK-FILE-1",
|
||||||
|
client_name="Клиент Файл",
|
||||||
|
client_phone="+79994440000",
|
||||||
|
topic_code="consulting",
|
||||||
|
status_code="NEW",
|
||||||
|
description="Файл",
|
||||||
|
extra_fields={},
|
||||||
|
)
|
||||||
|
db.add(req)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(req)
|
||||||
|
|
||||||
|
att = Attachment(
|
||||||
|
request_id=req.id,
|
||||||
|
file_name="act.pdf",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
size_bytes=4,
|
||||||
|
s3_key="requests/a/act.pdf",
|
||||||
|
)
|
||||||
|
db.add(att)
|
||||||
|
db.commit()
|
||||||
|
attachment_id = str(att.id)
|
||||||
|
|
||||||
|
fake_s3.objects["requests/a/act.pdf"] = {
|
||||||
|
"content": b"test",
|
||||||
|
"mime": "application/pdf",
|
||||||
|
"size": 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch("app.api.public.uploads.get_s3_storage", return_value=fake_s3):
|
||||||
|
allowed = self.client.get(
|
||||||
|
f"/api/public/uploads/object/{attachment_id}",
|
||||||
|
cookies=self._public_cookies("TRK-FILE-1"),
|
||||||
|
)
|
||||||
|
self.assertEqual(allowed.status_code, 200)
|
||||||
|
self.assertEqual(allowed.content, b"test")
|
||||||
|
|
||||||
|
denied = self.client.get(
|
||||||
|
f"/api/public/uploads/object/{attachment_id}",
|
||||||
|
cookies=self._public_cookies("TRK-OTHER"),
|
||||||
|
)
|
||||||
|
self.assertEqual(denied.status_code, 403)
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import os
|
import os
|
||||||
import unittest
|
import unittest
|
||||||
|
from datetime import timedelta
|
||||||
|
from unittest.mock import patch
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
@ -16,8 +18,13 @@ os.environ.setdefault("S3_SECRET_KEY", "test")
|
||||||
os.environ.setdefault("S3_BUCKET", "test")
|
os.environ.setdefault("S3_BUCKET", "test")
|
||||||
|
|
||||||
from app.main import app
|
from app.main import app
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.security import create_jwt
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
|
from app.models.notification import Notification
|
||||||
|
from app.models.otp_session import OtpSession
|
||||||
from app.models.request import Request
|
from app.models.request import Request
|
||||||
|
from app.models.topic_required_field import TopicRequiredField
|
||||||
|
|
||||||
|
|
||||||
class PublicRequestCreateTests(unittest.TestCase):
|
class PublicRequestCreateTests(unittest.TestCase):
|
||||||
|
|
@ -30,14 +37,23 @@ class PublicRequestCreateTests(unittest.TestCase):
|
||||||
)
|
)
|
||||||
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
|
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
|
||||||
Request.__table__.create(bind=cls.engine)
|
Request.__table__.create(bind=cls.engine)
|
||||||
|
Notification.__table__.create(bind=cls.engine)
|
||||||
|
OtpSession.__table__.create(bind=cls.engine)
|
||||||
|
TopicRequiredField.__table__.create(bind=cls.engine)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def tearDownClass(cls):
|
def tearDownClass(cls):
|
||||||
|
Notification.__table__.drop(bind=cls.engine)
|
||||||
|
OtpSession.__table__.drop(bind=cls.engine)
|
||||||
|
TopicRequiredField.__table__.drop(bind=cls.engine)
|
||||||
Request.__table__.drop(bind=cls.engine)
|
Request.__table__.drop(bind=cls.engine)
|
||||||
cls.engine.dispose()
|
cls.engine.dispose()
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
with self.SessionLocal() as db:
|
with self.SessionLocal() as db:
|
||||||
|
db.execute(delete(Notification))
|
||||||
|
db.execute(delete(OtpSession))
|
||||||
|
db.execute(delete(TopicRequiredField))
|
||||||
db.execute(delete(Request))
|
db.execute(delete(Request))
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
@ -55,7 +71,25 @@ class PublicRequestCreateTests(unittest.TestCase):
|
||||||
self.client.close()
|
self.client.close()
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
def test_create_request_persists_in_database(self):
|
def _send_and_verify_create_otp(self, phone: str) -> None:
|
||||||
|
with patch("app.api.public.otp._generate_code", return_value="123456"):
|
||||||
|
sent = self.client.post(
|
||||||
|
"/api/public/otp/send",
|
||||||
|
json={"purpose": "CREATE_REQUEST", "client_phone": phone},
|
||||||
|
)
|
||||||
|
self.assertEqual(sent.status_code, 200)
|
||||||
|
body = sent.json()
|
||||||
|
self.assertEqual(body["status"], "sent")
|
||||||
|
self.assertEqual(body["sms_response"]["provider"], "mock_sms")
|
||||||
|
|
||||||
|
verified = self.client.post(
|
||||||
|
"/api/public/otp/verify",
|
||||||
|
json={"purpose": "CREATE_REQUEST", "client_phone": phone, "code": "123456"},
|
||||||
|
)
|
||||||
|
self.assertEqual(verified.status_code, 200)
|
||||||
|
self.assertEqual(verified.json()["status"], "verified")
|
||||||
|
|
||||||
|
def test_create_request_requires_verified_otp_cookie(self):
|
||||||
payload = {
|
payload = {
|
||||||
"client_name": "ООО Ромашка",
|
"client_name": "ООО Ромашка",
|
||||||
"client_phone": "+79990000001",
|
"client_phone": "+79990000001",
|
||||||
|
|
@ -63,16 +97,16 @@ class PublicRequestCreateTests(unittest.TestCase):
|
||||||
"description": "Тестируем создание заявки",
|
"description": "Тестируем создание заявки",
|
||||||
"extra_fields": {"referral_name": "Партнер"},
|
"extra_fields": {"referral_name": "Партнер"},
|
||||||
}
|
}
|
||||||
|
response = self.client.post("/api/public/requests", json=payload)
|
||||||
|
self.assertEqual(response.status_code, 401)
|
||||||
|
|
||||||
|
self._send_and_verify_create_otp(payload["client_phone"])
|
||||||
|
|
||||||
response = self.client.post("/api/public/requests", json=payload)
|
response = self.client.post("/api/public/requests", json=payload)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 201)
|
self.assertEqual(response.status_code, 201)
|
||||||
body = response.json()
|
body = response.json()
|
||||||
|
|
||||||
self.assertTrue(body["track_number"].startswith("TRK-"))
|
self.assertTrue(body["track_number"].startswith("TRK-"))
|
||||||
self.assertTrue(body["otp_required"])
|
self.assertFalse(body["otp_required"])
|
||||||
self.assertIsNotNone(body["request_id"])
|
|
||||||
|
|
||||||
request_id = UUID(body["request_id"])
|
request_id = UUID(body["request_id"])
|
||||||
|
|
||||||
with self.SessionLocal() as db:
|
with self.SessionLocal() as db:
|
||||||
|
|
@ -85,3 +119,128 @@ class PublicRequestCreateTests(unittest.TestCase):
|
||||||
self.assertEqual(created.extra_fields, payload["extra_fields"])
|
self.assertEqual(created.extra_fields, payload["extra_fields"])
|
||||||
self.assertEqual(created.status_code, "NEW")
|
self.assertEqual(created.status_code, "NEW")
|
||||||
self.assertEqual(created.track_number, body["track_number"])
|
self.assertEqual(created.track_number, body["track_number"])
|
||||||
|
self.assertEqual(created.responsible, "Клиент")
|
||||||
|
|
||||||
|
# After creation, cookie is switched to VIEW_REQUEST for this track.
|
||||||
|
read = self.client.get(f"/api/public/requests/{body['track_number']}")
|
||||||
|
self.assertEqual(read.status_code, 200)
|
||||||
|
self.assertEqual(read.json()["track_number"], body["track_number"])
|
||||||
|
|
||||||
|
def test_view_request_requires_view_otp_and_uses_track_cookie(self):
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
row = Request(
|
||||||
|
track_number="TRK-VIEW-OTP",
|
||||||
|
client_name="Клиент",
|
||||||
|
client_phone="+79991112233",
|
||||||
|
topic_code="consulting",
|
||||||
|
status_code="NEW",
|
||||||
|
description="Проверка просмотра",
|
||||||
|
extra_fields={},
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
no_session = self.client.get("/api/public/requests/TRK-VIEW-OTP")
|
||||||
|
self.assertEqual(no_session.status_code, 401)
|
||||||
|
|
||||||
|
with patch("app.api.public.otp._generate_code", return_value="654321"):
|
||||||
|
sent = self.client.post(
|
||||||
|
"/api/public/otp/send",
|
||||||
|
json={"purpose": "VIEW_REQUEST", "track_number": "TRK-VIEW-OTP"},
|
||||||
|
)
|
||||||
|
self.assertEqual(sent.status_code, 200)
|
||||||
|
self.assertEqual(sent.json()["status"], "sent")
|
||||||
|
|
||||||
|
wrong_code = self.client.post(
|
||||||
|
"/api/public/otp/verify",
|
||||||
|
json={"purpose": "VIEW_REQUEST", "track_number": "TRK-VIEW-OTP", "code": "000000"},
|
||||||
|
)
|
||||||
|
self.assertEqual(wrong_code.status_code, 400)
|
||||||
|
|
||||||
|
verified = self.client.post(
|
||||||
|
"/api/public/otp/verify",
|
||||||
|
json={"purpose": "VIEW_REQUEST", "track_number": "TRK-VIEW-OTP", "code": "654321"},
|
||||||
|
)
|
||||||
|
self.assertEqual(verified.status_code, 200)
|
||||||
|
|
||||||
|
ok = self.client.get("/api/public/requests/TRK-VIEW-OTP")
|
||||||
|
self.assertEqual(ok.status_code, 200)
|
||||||
|
self.assertEqual(ok.json()["track_number"], "TRK-VIEW-OTP")
|
||||||
|
|
||||||
|
denied_other_track = self.client.get("/api/public/requests/TRK-OTHER")
|
||||||
|
self.assertEqual(denied_other_track.status_code, 403)
|
||||||
|
|
||||||
|
def test_open_request_marks_client_updates_as_read(self):
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
row = Request(
|
||||||
|
track_number="TRK-READ-1",
|
||||||
|
client_name="Клиент",
|
||||||
|
client_phone="+79995550011",
|
||||||
|
topic_code="consulting",
|
||||||
|
status_code="IN_PROGRESS",
|
||||||
|
description="Проверка чтения",
|
||||||
|
extra_fields={},
|
||||||
|
client_has_unread_updates=True,
|
||||||
|
client_unread_event_type="STATUS",
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
db.commit()
|
||||||
|
request_id = row.id
|
||||||
|
|
||||||
|
public_token = create_jwt({"sub": "TRK-READ-1", "purpose": "VIEW_REQUEST"}, settings.PUBLIC_JWT_SECRET, timedelta(days=1))
|
||||||
|
cookies = {settings.PUBLIC_COOKIE_NAME: public_token}
|
||||||
|
|
||||||
|
opened = self.client.get("/api/public/requests/TRK-READ-1", cookies=cookies)
|
||||||
|
self.assertEqual(opened.status_code, 200)
|
||||||
|
body = opened.json()
|
||||||
|
self.assertFalse(body["client_has_unread_updates"])
|
||||||
|
self.assertIsNone(body["client_unread_event_type"])
|
||||||
|
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
refreshed = db.get(Request, request_id)
|
||||||
|
self.assertIsNotNone(refreshed)
|
||||||
|
self.assertFalse(refreshed.client_has_unread_updates)
|
||||||
|
self.assertIsNone(refreshed.client_unread_event_type)
|
||||||
|
|
||||||
|
def test_create_request_checks_required_topic_fields(self):
|
||||||
|
phone = "+79990000005"
|
||||||
|
self._send_and_verify_create_otp(phone)
|
||||||
|
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
db.add(
|
||||||
|
TopicRequiredField(
|
||||||
|
topic_code="consulting",
|
||||||
|
field_key="passport_series",
|
||||||
|
required=True,
|
||||||
|
enabled=True,
|
||||||
|
sort_order=1,
|
||||||
|
responsible="root@example.com",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
missing = self.client.post(
|
||||||
|
"/api/public/requests",
|
||||||
|
json={
|
||||||
|
"client_name": "ООО Поле",
|
||||||
|
"client_phone": phone,
|
||||||
|
"topic_code": "consulting",
|
||||||
|
"description": "Проверка обязательного поля",
|
||||||
|
"extra_fields": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(missing.status_code, 400)
|
||||||
|
self.assertIn("passport_series", missing.json().get("detail", ""))
|
||||||
|
|
||||||
|
created = self.client.post(
|
||||||
|
"/api/public/requests",
|
||||||
|
json={
|
||||||
|
"client_name": "ООО Поле",
|
||||||
|
"client_phone": phone,
|
||||||
|
"topic_code": "consulting",
|
||||||
|
"description": "Проверка обязательного поля",
|
||||||
|
"extra_fields": {"passport_series": "1234"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(created.status_code, 201)
|
||||||
|
self.assertTrue(created.json()["track_number"].startswith("TRK-"))
|
||||||
|
|
|
||||||
77
tests/test_quotes_seed.py
Normal file
77
tests/test_quotes_seed.py
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
# Ensure settings can be initialized in test environments
|
||||||
|
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
|
||||||
|
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0")
|
||||||
|
os.environ.setdefault("S3_ENDPOINT", "http://localhost:9000")
|
||||||
|
os.environ.setdefault("S3_ACCESS_KEY", "test")
|
||||||
|
os.environ.setdefault("S3_SECRET_KEY", "test")
|
||||||
|
os.environ.setdefault("S3_BUCKET", "test")
|
||||||
|
|
||||||
|
from app.data.quotes_justice_seed import JUSTICE_QUOTES
|
||||||
|
from app.models.quote import Quote
|
||||||
|
from app.scripts.upsert_quotes import upsert_quotes
|
||||||
|
|
||||||
|
|
||||||
|
class QuotesSeedTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.engine = create_engine(
|
||||||
|
"sqlite+pysqlite:///:memory:",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=StaticPool,
|
||||||
|
)
|
||||||
|
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
|
||||||
|
Quote.__table__.create(bind=cls.engine)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
Quote.__table__.drop(bind=cls.engine)
|
||||||
|
cls.engine.dispose()
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.db = self.SessionLocal()
|
||||||
|
self.db.query(Quote).delete()
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.db.close()
|
||||||
|
|
||||||
|
def test_upsert_creates_all_50_quotes_and_is_idempotent(self):
|
||||||
|
created, updated = upsert_quotes(self.db, JUSTICE_QUOTES)
|
||||||
|
self.assertEqual(created, 50)
|
||||||
|
self.assertEqual(updated, 0)
|
||||||
|
self.assertEqual(self.db.query(Quote).count(), 50)
|
||||||
|
|
||||||
|
created2, updated2 = upsert_quotes(self.db, JUSTICE_QUOTES)
|
||||||
|
self.assertEqual(created2, 0)
|
||||||
|
self.assertEqual(updated2, 0)
|
||||||
|
self.assertEqual(self.db.query(Quote).count(), 50)
|
||||||
|
|
||||||
|
def test_upsert_updates_existing_quote(self):
|
||||||
|
base = JUSTICE_QUOTES[0]
|
||||||
|
self.db.add(
|
||||||
|
Quote(
|
||||||
|
author=base["author"],
|
||||||
|
text=base["text"],
|
||||||
|
source="wrong",
|
||||||
|
is_active=False,
|
||||||
|
sort_order=999,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
created, updated = upsert_quotes(self.db, [base])
|
||||||
|
self.assertEqual(created, 0)
|
||||||
|
self.assertEqual(updated, 1)
|
||||||
|
|
||||||
|
row = self.db.query(Quote).filter(Quote.author == base["author"], Quote.text == base["text"]).first()
|
||||||
|
self.assertIsNotNone(row)
|
||||||
|
self.assertEqual(row.source, base.get("source"))
|
||||||
|
self.assertTrue(row.is_active)
|
||||||
|
self.assertEqual(row.sort_order, 1)
|
||||||
645
tests/test_uploads_s3.py
Normal file
645
tests/test_uploads_s3.py
Normal file
|
|
@ -0,0 +1,645 @@
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from datetime import timedelta
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from botocore.exceptions import ClientError
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import create_engine, delete
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
|
||||||
|
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0")
|
||||||
|
os.environ.setdefault("S3_ENDPOINT", "http://localhost:9000")
|
||||||
|
os.environ.setdefault("S3_ACCESS_KEY", "test")
|
||||||
|
os.environ.setdefault("S3_SECRET_KEY", "test")
|
||||||
|
os.environ.setdefault("S3_BUCKET", "test")
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.security import create_jwt
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.main import app
|
||||||
|
from app.models.admin_user import AdminUser
|
||||||
|
from app.models.attachment import Attachment
|
||||||
|
from app.models.message import Message
|
||||||
|
from app.models.notification import Notification
|
||||||
|
from app.models.request import Request
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeBody:
|
||||||
|
def __init__(self, payload: bytes):
|
||||||
|
self.payload = payload
|
||||||
|
|
||||||
|
def iter_chunks(self, chunk_size=65536):
|
||||||
|
for i in range(0, len(self.payload), chunk_size):
|
||||||
|
yield self.payload[i : i + chunk_size]
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeS3Storage:
|
||||||
|
def __init__(self):
|
||||||
|
self.objects = {}
|
||||||
|
|
||||||
|
def create_presigned_put_url(self, key: str, mime_type: str, expires_sec: int = 900) -> str:
|
||||||
|
return f"https://s3.local/{key}?expires={expires_sec}"
|
||||||
|
|
||||||
|
def head_object(self, key: str) -> dict:
|
||||||
|
obj = self.objects.get(key)
|
||||||
|
if obj is None:
|
||||||
|
raise ClientError({"Error": {"Code": "404", "Message": "Not Found"}}, "HeadObject")
|
||||||
|
return {"ContentLength": obj["size"], "ContentType": obj["mime"]}
|
||||||
|
|
||||||
|
def get_object(self, key: str) -> dict:
|
||||||
|
obj = self.objects.get(key)
|
||||||
|
if obj is None:
|
||||||
|
raise ClientError({"Error": {"Code": "404", "Message": "Not Found"}}, "GetObject")
|
||||||
|
return {"Body": _FakeBody(obj["content"]), "ContentType": obj["mime"], "ContentLength": obj["size"]}
|
||||||
|
|
||||||
|
|
||||||
|
class UploadsS3Tests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.engine = create_engine(
|
||||||
|
"sqlite+pysqlite:///:memory:",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=StaticPool,
|
||||||
|
)
|
||||||
|
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
|
||||||
|
AdminUser.__table__.create(bind=cls.engine)
|
||||||
|
Request.__table__.create(bind=cls.engine)
|
||||||
|
Notification.__table__.create(bind=cls.engine)
|
||||||
|
Message.__table__.create(bind=cls.engine)
|
||||||
|
Attachment.__table__.create(bind=cls.engine)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
Attachment.__table__.drop(bind=cls.engine)
|
||||||
|
Message.__table__.drop(bind=cls.engine)
|
||||||
|
Notification.__table__.drop(bind=cls.engine)
|
||||||
|
Request.__table__.drop(bind=cls.engine)
|
||||||
|
AdminUser.__table__.drop(bind=cls.engine)
|
||||||
|
cls.engine.dispose()
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
db.execute(delete(Notification))
|
||||||
|
db.execute(delete(Attachment))
|
||||||
|
db.execute(delete(Message))
|
||||||
|
db.execute(delete(Request))
|
||||||
|
db.execute(delete(AdminUser))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
def override_get_db():
|
||||||
|
db = self.SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = override_get_db
|
||||||
|
self.client = TestClient(app)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.client.close()
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _admin_headers(sub: str, role: str = "ADMIN", email: str = "admin@example.com") -> dict[str, str]:
|
||||||
|
token = create_jwt(
|
||||||
|
{"sub": sub, "email": email, "role": role},
|
||||||
|
settings.ADMIN_JWT_SECRET,
|
||||||
|
timedelta(minutes=30),
|
||||||
|
)
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
def test_admin_avatar_upload_flow_updates_user_avatar_key(self):
|
||||||
|
fake_s3 = _FakeS3Storage()
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
user = AdminUser(
|
||||||
|
role="LAWYER",
|
||||||
|
name="Юрист Аватар",
|
||||||
|
email="avatar@example.com",
|
||||||
|
password_hash="hash",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
user_id = str(user.id)
|
||||||
|
|
||||||
|
headers = self._admin_headers(sub=user_id, role="LAWYER", email="avatar@example.com")
|
||||||
|
with patch("app.api.admin.uploads.get_s3_storage", return_value=fake_s3):
|
||||||
|
init_resp = self.client.post(
|
||||||
|
"/api/admin/uploads/init",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"file_name": "photo.png",
|
||||||
|
"mime_type": "image/png",
|
||||||
|
"size_bytes": 2048,
|
||||||
|
"scope": "USER_AVATAR",
|
||||||
|
"user_id": user_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(init_resp.status_code, 200)
|
||||||
|
key = init_resp.json()["key"]
|
||||||
|
self.assertTrue(key.startswith("avatars/"))
|
||||||
|
|
||||||
|
fake_s3.objects[key] = {"size": 2048, "mime": "image/png", "content": b"x" * 2048}
|
||||||
|
done_resp = self.client.post(
|
||||||
|
"/api/admin/uploads/complete",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"key": key,
|
||||||
|
"file_name": "photo.png",
|
||||||
|
"mime_type": "image/png",
|
||||||
|
"size_bytes": 2048,
|
||||||
|
"scope": "USER_AVATAR",
|
||||||
|
"user_id": user_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(done_resp.status_code, 200)
|
||||||
|
self.assertEqual(done_resp.json()["avatar_url"], f"s3://{key}")
|
||||||
|
|
||||||
|
token = headers["Authorization"].replace("Bearer ", "")
|
||||||
|
view_resp = self.client.get(f"/api/admin/uploads/object/{key}?token={token}")
|
||||||
|
self.assertEqual(view_resp.status_code, 200)
|
||||||
|
self.assertEqual(view_resp.content, b"x" * 2048)
|
||||||
|
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
refreshed = db.get(AdminUser, UUID(user_id))
|
||||||
|
self.assertIsNotNone(refreshed)
|
||||||
|
self.assertEqual(refreshed.avatar_url, f"s3://{key}")
|
||||||
|
|
||||||
|
def test_public_request_attachment_upload_flow_creates_attachment(self):
|
||||||
|
fake_s3 = _FakeS3Storage()
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
req = Request(
|
||||||
|
track_number="TRK-PUB-UPLOAD",
|
||||||
|
client_name="Клиент",
|
||||||
|
client_phone="+79991112233",
|
||||||
|
topic_code="civil-law",
|
||||||
|
status_code="NEW",
|
||||||
|
extra_fields={},
|
||||||
|
total_attachments_bytes=0,
|
||||||
|
)
|
||||||
|
db.add(req)
|
||||||
|
db.commit()
|
||||||
|
request_id = str(req.id)
|
||||||
|
track = req.track_number
|
||||||
|
|
||||||
|
public_token = create_jwt({"sub": track, "purpose": "VIEW_REQUEST"}, settings.PUBLIC_JWT_SECRET, timedelta(days=1))
|
||||||
|
cookies = {settings.PUBLIC_COOKIE_NAME: public_token}
|
||||||
|
|
||||||
|
with patch("app.api.public.uploads.get_s3_storage", return_value=fake_s3):
|
||||||
|
init_resp = self.client.post(
|
||||||
|
"/api/public/uploads/init",
|
||||||
|
cookies=cookies,
|
||||||
|
json={
|
||||||
|
"file_name": "contract.pdf",
|
||||||
|
"mime_type": "application/pdf",
|
||||||
|
"size_bytes": 4096,
|
||||||
|
"scope": "REQUEST_ATTACHMENT",
|
||||||
|
"request_id": request_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(init_resp.status_code, 200)
|
||||||
|
key = init_resp.json()["key"]
|
||||||
|
self.assertTrue(key.startswith("requests/"))
|
||||||
|
|
||||||
|
fake_s3.objects[key] = {"size": 4096, "mime": "application/pdf", "content": b"p" * 4096}
|
||||||
|
done_resp = self.client.post(
|
||||||
|
"/api/public/uploads/complete",
|
||||||
|
cookies=cookies,
|
||||||
|
json={
|
||||||
|
"key": key,
|
||||||
|
"file_name": "contract.pdf",
|
||||||
|
"mime_type": "application/pdf",
|
||||||
|
"size_bytes": 4096,
|
||||||
|
"scope": "REQUEST_ATTACHMENT",
|
||||||
|
"request_id": request_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(done_resp.status_code, 200)
|
||||||
|
self.assertTrue(done_resp.json().get("attachment_id"))
|
||||||
|
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
req = db.get(Request, UUID(request_id))
|
||||||
|
self.assertIsNotNone(req)
|
||||||
|
self.assertEqual(req.total_attachments_bytes, 4096)
|
||||||
|
self.assertTrue(req.lawyer_has_unread_updates)
|
||||||
|
self.assertEqual(req.lawyer_unread_event_type, "ATTACHMENT")
|
||||||
|
rows = db.query(Attachment).filter(Attachment.request_id == UUID(request_id)).all()
|
||||||
|
self.assertEqual(len(rows), 1)
|
||||||
|
self.assertEqual(rows[0].s3_key, key)
|
||||||
|
|
||||||
|
def test_admin_request_attachment_upload_sets_client_unread_marker(self):
|
||||||
|
fake_s3 = _FakeS3Storage()
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
lawyer = AdminUser(
|
||||||
|
role="LAWYER",
|
||||||
|
name="Юрист Загрузка",
|
||||||
|
email="lawyer-upload@example.com",
|
||||||
|
password_hash="hash",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(lawyer)
|
||||||
|
db.flush()
|
||||||
|
req = Request(
|
||||||
|
track_number="TRK-ADM-UPLOAD",
|
||||||
|
client_name="Клиент",
|
||||||
|
client_phone="+79995554433",
|
||||||
|
topic_code="civil-law",
|
||||||
|
status_code="IN_PROGRESS",
|
||||||
|
extra_fields={},
|
||||||
|
assigned_lawyer_id=str(lawyer.id),
|
||||||
|
total_attachments_bytes=0,
|
||||||
|
)
|
||||||
|
db.add(req)
|
||||||
|
db.commit()
|
||||||
|
request_id = str(req.id)
|
||||||
|
lawyer_id = str(lawyer.id)
|
||||||
|
|
||||||
|
headers = self._admin_headers(sub=lawyer_id, role="LAWYER", email="lawyer-upload@example.com")
|
||||||
|
with patch("app.api.admin.uploads.get_s3_storage", return_value=fake_s3):
|
||||||
|
init_resp = self.client.post(
|
||||||
|
"/api/admin/uploads/init",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"file_name": "evidence.pdf",
|
||||||
|
"mime_type": "application/pdf",
|
||||||
|
"size_bytes": 2048,
|
||||||
|
"scope": "REQUEST_ATTACHMENT",
|
||||||
|
"request_id": request_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(init_resp.status_code, 200)
|
||||||
|
key = init_resp.json()["key"]
|
||||||
|
fake_s3.objects[key] = {"size": 2048, "mime": "application/pdf", "content": b"x" * 2048}
|
||||||
|
|
||||||
|
done_resp = self.client.post(
|
||||||
|
"/api/admin/uploads/complete",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"key": key,
|
||||||
|
"file_name": "evidence.pdf",
|
||||||
|
"mime_type": "application/pdf",
|
||||||
|
"size_bytes": 2048,
|
||||||
|
"scope": "REQUEST_ATTACHMENT",
|
||||||
|
"request_id": request_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(done_resp.status_code, 200)
|
||||||
|
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
req = db.get(Request, UUID(request_id))
|
||||||
|
self.assertIsNotNone(req)
|
||||||
|
self.assertEqual(req.total_attachments_bytes, 2048)
|
||||||
|
self.assertTrue(req.client_has_unread_updates)
|
||||||
|
self.assertEqual(req.client_unread_event_type, "ATTACHMENT")
|
||||||
|
|
||||||
|
def test_admin_upload_rejects_attachment_for_immutable_message(self):
|
||||||
|
fake_s3 = _FakeS3Storage()
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
lawyer = AdminUser(
|
||||||
|
role="LAWYER",
|
||||||
|
name="Юрист Иммутабельный",
|
||||||
|
email="lawyer-immutable@example.com",
|
||||||
|
password_hash="hash",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(lawyer)
|
||||||
|
db.flush()
|
||||||
|
req = Request(
|
||||||
|
track_number="TRK-ADM-IMM-MSG",
|
||||||
|
client_name="Клиент",
|
||||||
|
client_phone="+79995554434",
|
||||||
|
topic_code="civil-law",
|
||||||
|
status_code="IN_PROGRESS",
|
||||||
|
extra_fields={},
|
||||||
|
assigned_lawyer_id=str(lawyer.id),
|
||||||
|
total_attachments_bytes=0,
|
||||||
|
)
|
||||||
|
db.add(req)
|
||||||
|
db.flush()
|
||||||
|
msg = Message(
|
||||||
|
request_id=req.id,
|
||||||
|
author_type="CLIENT",
|
||||||
|
author_name="Клиент",
|
||||||
|
body="Старое сообщение",
|
||||||
|
immutable=True,
|
||||||
|
)
|
||||||
|
db.add(msg)
|
||||||
|
db.commit()
|
||||||
|
request_id = str(req.id)
|
||||||
|
message_id = str(msg.id)
|
||||||
|
lawyer_id = str(lawyer.id)
|
||||||
|
|
||||||
|
headers = self._admin_headers(sub=lawyer_id, role="LAWYER", email="lawyer-immutable@example.com")
|
||||||
|
with patch("app.api.admin.uploads.get_s3_storage", return_value=fake_s3):
|
||||||
|
init_resp = self.client.post(
|
||||||
|
"/api/admin/uploads/init",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"file_name": "appendix.pdf",
|
||||||
|
"mime_type": "application/pdf",
|
||||||
|
"size_bytes": 1024,
|
||||||
|
"scope": "REQUEST_ATTACHMENT",
|
||||||
|
"request_id": request_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(init_resp.status_code, 200)
|
||||||
|
key = init_resp.json()["key"]
|
||||||
|
fake_s3.objects[key] = {"size": 1024, "mime": "application/pdf", "content": b"x" * 1024}
|
||||||
|
|
||||||
|
blocked = self.client.post(
|
||||||
|
"/api/admin/uploads/complete",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"key": key,
|
||||||
|
"file_name": "appendix.pdf",
|
||||||
|
"mime_type": "application/pdf",
|
||||||
|
"size_bytes": 1024,
|
||||||
|
"scope": "REQUEST_ATTACHMENT",
|
||||||
|
"request_id": request_id,
|
||||||
|
"message_id": message_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(blocked.status_code, 400)
|
||||||
|
self.assertIn("зафиксированному", blocked.json().get("detail", ""))
|
||||||
|
|
||||||
|
def test_public_upload_rejects_file_over_limit_on_init(self):
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
req = Request(
|
||||||
|
track_number="TRK-PUB-LIMIT-FILE",
|
||||||
|
client_name="Клиент",
|
||||||
|
client_phone="+79990001111",
|
||||||
|
topic_code="civil-law",
|
||||||
|
status_code="NEW",
|
||||||
|
extra_fields={},
|
||||||
|
total_attachments_bytes=0,
|
||||||
|
)
|
||||||
|
db.add(req)
|
||||||
|
db.commit()
|
||||||
|
request_id = str(req.id)
|
||||||
|
track = req.track_number
|
||||||
|
|
||||||
|
public_token = create_jwt({"sub": track, "purpose": "VIEW_REQUEST"}, settings.PUBLIC_JWT_SECRET, timedelta(days=1))
|
||||||
|
cookies = {settings.PUBLIC_COOKIE_NAME: public_token}
|
||||||
|
with patch("app.api.public.uploads.settings.MAX_FILE_MB", 1):
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/public/uploads/init",
|
||||||
|
cookies=cookies,
|
||||||
|
json={
|
||||||
|
"file_name": "big.mp4",
|
||||||
|
"mime_type": "video/mp4",
|
||||||
|
"size_bytes": 2 * 1024 * 1024,
|
||||||
|
"scope": "REQUEST_ATTACHMENT",
|
||||||
|
"request_id": request_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
self.assertIn("лимит файла", response.json().get("detail", ""))
|
||||||
|
|
||||||
|
def test_public_upload_rejects_case_limit_on_complete(self):
|
||||||
|
fake_s3 = _FakeS3Storage()
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
req = Request(
|
||||||
|
track_number="TRK-PUB-LIMIT-CASE",
|
||||||
|
client_name="Клиент",
|
||||||
|
client_phone="+79990002222",
|
||||||
|
topic_code="civil-law",
|
||||||
|
status_code="NEW",
|
||||||
|
extra_fields={},
|
||||||
|
total_attachments_bytes=(1024 * 1024) - 512,
|
||||||
|
)
|
||||||
|
db.add(req)
|
||||||
|
db.commit()
|
||||||
|
request_id = str(req.id)
|
||||||
|
track = req.track_number
|
||||||
|
|
||||||
|
public_token = create_jwt({"sub": track, "purpose": "VIEW_REQUEST"}, settings.PUBLIC_JWT_SECRET, timedelta(days=1))
|
||||||
|
cookies = {settings.PUBLIC_COOKIE_NAME: public_token}
|
||||||
|
with (
|
||||||
|
patch("app.api.public.uploads.get_s3_storage", return_value=fake_s3),
|
||||||
|
patch("app.api.public.uploads.settings.MAX_CASE_MB", 1),
|
||||||
|
patch("app.api.public.uploads.settings.MAX_FILE_MB", 5),
|
||||||
|
):
|
||||||
|
init_resp = self.client.post(
|
||||||
|
"/api/public/uploads/init",
|
||||||
|
cookies=cookies,
|
||||||
|
json={
|
||||||
|
"file_name": "edge.pdf",
|
||||||
|
"mime_type": "application/pdf",
|
||||||
|
"size_bytes": 256,
|
||||||
|
"scope": "REQUEST_ATTACHMENT",
|
||||||
|
"request_id": request_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(init_resp.status_code, 200)
|
||||||
|
key = init_resp.json()["key"]
|
||||||
|
fake_s3.objects[key] = {"size": 1024, "mime": "application/pdf", "content": b"x" * 1024}
|
||||||
|
|
||||||
|
done_resp = self.client.post(
|
||||||
|
"/api/public/uploads/complete",
|
||||||
|
cookies=cookies,
|
||||||
|
json={
|
||||||
|
"key": key,
|
||||||
|
"file_name": "edge.pdf",
|
||||||
|
"mime_type": "application/pdf",
|
||||||
|
"size_bytes": 256,
|
||||||
|
"scope": "REQUEST_ATTACHMENT",
|
||||||
|
"request_id": request_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(done_resp.status_code, 400)
|
||||||
|
self.assertIn("лимит вложений заявки", done_resp.json().get("detail", ""))
|
||||||
|
|
||||||
|
def test_public_upload_rejects_foreign_object_key(self):
|
||||||
|
fake_s3 = _FakeS3Storage()
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
req = Request(
|
||||||
|
track_number="TRK-PUB-KEY",
|
||||||
|
client_name="Клиент",
|
||||||
|
client_phone="+79990003333",
|
||||||
|
topic_code="civil-law",
|
||||||
|
status_code="NEW",
|
||||||
|
extra_fields={},
|
||||||
|
total_attachments_bytes=0,
|
||||||
|
)
|
||||||
|
db.add(req)
|
||||||
|
db.commit()
|
||||||
|
request_id = str(req.id)
|
||||||
|
track = req.track_number
|
||||||
|
|
||||||
|
public_token = create_jwt({"sub": track, "purpose": "VIEW_REQUEST"}, settings.PUBLIC_JWT_SECRET, timedelta(days=1))
|
||||||
|
cookies = {settings.PUBLIC_COOKIE_NAME: public_token}
|
||||||
|
foreign_key = f"requests/{uuid4()}/foreign.pdf"
|
||||||
|
fake_s3.objects[foreign_key] = {"size": 1024, "mime": "application/pdf", "content": b"x" * 1024}
|
||||||
|
|
||||||
|
with patch("app.api.public.uploads.get_s3_storage", return_value=fake_s3):
|
||||||
|
done_resp = self.client.post(
|
||||||
|
"/api/public/uploads/complete",
|
||||||
|
cookies=cookies,
|
||||||
|
json={
|
||||||
|
"key": foreign_key,
|
||||||
|
"file_name": "foreign.pdf",
|
||||||
|
"mime_type": "application/pdf",
|
||||||
|
"size_bytes": 1024,
|
||||||
|
"scope": "REQUEST_ATTACHMENT",
|
||||||
|
"request_id": request_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(done_resp.status_code, 400)
|
||||||
|
self.assertIn("Некорректный ключ объекта", done_resp.json().get("detail", ""))
|
||||||
|
|
||||||
|
def test_admin_upload_rejects_file_over_limit_on_complete(self):
|
||||||
|
fake_s3 = _FakeS3Storage()
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
admin = AdminUser(
|
||||||
|
role="ADMIN",
|
||||||
|
name="Админ Ограничений",
|
||||||
|
email="admin-limits@example.com",
|
||||||
|
password_hash="hash",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
req = Request(
|
||||||
|
track_number="TRK-ADM-LIMIT-FILE",
|
||||||
|
client_name="Клиент",
|
||||||
|
client_phone="+79990004444",
|
||||||
|
topic_code="civil-law",
|
||||||
|
status_code="NEW",
|
||||||
|
extra_fields={},
|
||||||
|
total_attachments_bytes=0,
|
||||||
|
)
|
||||||
|
db.add_all([admin, req])
|
||||||
|
db.commit()
|
||||||
|
admin_id = str(admin.id)
|
||||||
|
request_id = str(req.id)
|
||||||
|
|
||||||
|
headers = self._admin_headers(sub=admin_id, role="ADMIN", email="admin-limits@example.com")
|
||||||
|
with (
|
||||||
|
patch("app.api.admin.uploads.get_s3_storage", return_value=fake_s3),
|
||||||
|
patch("app.api.admin.uploads.settings.MAX_FILE_MB", 1),
|
||||||
|
):
|
||||||
|
init_resp = self.client.post(
|
||||||
|
"/api/admin/uploads/init",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"file_name": "proof.mp4",
|
||||||
|
"mime_type": "video/mp4",
|
||||||
|
"size_bytes": 1024,
|
||||||
|
"scope": "REQUEST_ATTACHMENT",
|
||||||
|
"request_id": request_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(init_resp.status_code, 200)
|
||||||
|
key = init_resp.json()["key"]
|
||||||
|
fake_s3.objects[key] = {"size": 2 * 1024 * 1024, "mime": "video/mp4", "content": b"x" * 1024}
|
||||||
|
|
||||||
|
done_resp = self.client.post(
|
||||||
|
"/api/admin/uploads/complete",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"key": key,
|
||||||
|
"file_name": "proof.mp4",
|
||||||
|
"mime_type": "video/mp4",
|
||||||
|
"size_bytes": 1024,
|
||||||
|
"scope": "REQUEST_ATTACHMENT",
|
||||||
|
"request_id": request_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(done_resp.status_code, 400)
|
||||||
|
self.assertIn("лимит файла", done_resp.json().get("detail", ""))
|
||||||
|
|
||||||
|
def test_admin_upload_rejects_foreign_object_key(self):
|
||||||
|
fake_s3 = _FakeS3Storage()
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
admin = AdminUser(
|
||||||
|
role="ADMIN",
|
||||||
|
name="Админ Ключей",
|
||||||
|
email="admin-keys@example.com",
|
||||||
|
password_hash="hash",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
req = Request(
|
||||||
|
track_number="TRK-ADM-KEY",
|
||||||
|
client_name="Клиент",
|
||||||
|
client_phone="+79990005555",
|
||||||
|
topic_code="civil-law",
|
||||||
|
status_code="NEW",
|
||||||
|
extra_fields={},
|
||||||
|
total_attachments_bytes=0,
|
||||||
|
)
|
||||||
|
db.add_all([admin, req])
|
||||||
|
db.commit()
|
||||||
|
admin_id = str(admin.id)
|
||||||
|
request_id = str(req.id)
|
||||||
|
|
||||||
|
headers = self._admin_headers(sub=admin_id, role="ADMIN", email="admin-keys@example.com")
|
||||||
|
foreign_key = f"requests/{uuid4()}/another.pdf"
|
||||||
|
fake_s3.objects[foreign_key] = {"size": 2048, "mime": "application/pdf", "content": b"x" * 2048}
|
||||||
|
|
||||||
|
with patch("app.api.admin.uploads.get_s3_storage", return_value=fake_s3):
|
||||||
|
done_resp = self.client.post(
|
||||||
|
"/api/admin/uploads/complete",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"key": foreign_key,
|
||||||
|
"file_name": "another.pdf",
|
||||||
|
"mime_type": "application/pdf",
|
||||||
|
"size_bytes": 2048,
|
||||||
|
"scope": "REQUEST_ATTACHMENT",
|
||||||
|
"request_id": request_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(done_resp.status_code, 400)
|
||||||
|
self.assertIn("Некорректный ключ объекта", done_resp.json().get("detail", ""))
|
||||||
|
|
||||||
|
def test_admin_object_proxy_blocks_lawyer_for_foreign_assigned_request(self):
|
||||||
|
fake_s3 = _FakeS3Storage()
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
lawyer_a = AdminUser(
|
||||||
|
role="LAWYER",
|
||||||
|
name="Юрист А",
|
||||||
|
email="lawyer-a@example.com",
|
||||||
|
password_hash="hash",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
lawyer_b = AdminUser(
|
||||||
|
role="LAWYER",
|
||||||
|
name="Юрист Б",
|
||||||
|
email="lawyer-b@example.com",
|
||||||
|
password_hash="hash",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add_all([lawyer_a, lawyer_b])
|
||||||
|
db.flush()
|
||||||
|
req = Request(
|
||||||
|
track_number="TRK-ADM-PROXY-LOCK",
|
||||||
|
client_name="Клиент",
|
||||||
|
client_phone="+79990006666",
|
||||||
|
topic_code="civil-law",
|
||||||
|
status_code="IN_PROGRESS",
|
||||||
|
assigned_lawyer_id=str(lawyer_b.id),
|
||||||
|
extra_fields={},
|
||||||
|
total_attachments_bytes=0,
|
||||||
|
)
|
||||||
|
db.add(req)
|
||||||
|
db.flush()
|
||||||
|
key = f"requests/{req.id}/proof.pdf"
|
||||||
|
att = Attachment(
|
||||||
|
request_id=req.id,
|
||||||
|
file_name="proof.pdf",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
size_bytes=1024,
|
||||||
|
s3_key=key,
|
||||||
|
)
|
||||||
|
db.add(att)
|
||||||
|
db.commit()
|
||||||
|
lawyer_a_id = str(lawyer_a.id)
|
||||||
|
|
||||||
|
token = self._admin_headers(sub=lawyer_a_id, role="LAWYER", email="lawyer-a@example.com")["Authorization"].replace("Bearer ", "")
|
||||||
|
fake_s3.objects[key] = {"size": 1024, "mime": "application/pdf", "content": b"x" * 1024}
|
||||||
|
with patch("app.api.admin.uploads.get_s3_storage", return_value=fake_s3):
|
||||||
|
response = self.client.get(f"/api/admin/uploads/object/{key}?token={token}")
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
289
tests/test_worker_maintenance.py
Normal file
289
tests/test_worker_maintenance.py
Normal file
|
|
@ -0,0 +1,289 @@
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine, delete
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
|
||||||
|
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0")
|
||||||
|
os.environ.setdefault("S3_ENDPOINT", "http://localhost:9000")
|
||||||
|
os.environ.setdefault("S3_ACCESS_KEY", "test")
|
||||||
|
os.environ.setdefault("S3_SECRET_KEY", "test")
|
||||||
|
os.environ.setdefault("S3_BUCKET", "test")
|
||||||
|
|
||||||
|
from app.models.attachment import Attachment
|
||||||
|
from app.models.message import Message
|
||||||
|
from app.models.notification import Notification
|
||||||
|
from app.models.otp_session import OtpSession
|
||||||
|
from app.models.request import Request
|
||||||
|
from app.models.status import Status
|
||||||
|
from app.models.status_history import StatusHistory
|
||||||
|
from app.models.topic_status_transition import TopicStatusTransition
|
||||||
|
from app.workers.tasks import security as security_task
|
||||||
|
from app.workers.tasks import sla as sla_task
|
||||||
|
from app.workers.tasks import uploads as uploads_task
|
||||||
|
|
||||||
|
|
||||||
|
class WorkerMaintenanceTaskTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.engine = create_engine(
|
||||||
|
"sqlite+pysqlite:///:memory:",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=StaticPool,
|
||||||
|
)
|
||||||
|
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
|
||||||
|
OtpSession.__table__.create(bind=cls.engine)
|
||||||
|
Request.__table__.create(bind=cls.engine)
|
||||||
|
Attachment.__table__.create(bind=cls.engine)
|
||||||
|
Status.__table__.create(bind=cls.engine)
|
||||||
|
Message.__table__.create(bind=cls.engine)
|
||||||
|
StatusHistory.__table__.create(bind=cls.engine)
|
||||||
|
TopicStatusTransition.__table__.create(bind=cls.engine)
|
||||||
|
Notification.__table__.create(bind=cls.engine)
|
||||||
|
|
||||||
|
cls._old_security_session_local = security_task.SessionLocal
|
||||||
|
cls._old_uploads_session_local = uploads_task.SessionLocal
|
||||||
|
cls._old_sla_session_local = sla_task.SessionLocal
|
||||||
|
security_task.SessionLocal = cls.SessionLocal
|
||||||
|
uploads_task.SessionLocal = cls.SessionLocal
|
||||||
|
sla_task.SessionLocal = cls.SessionLocal
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
security_task.SessionLocal = cls._old_security_session_local
|
||||||
|
uploads_task.SessionLocal = cls._old_uploads_session_local
|
||||||
|
sla_task.SessionLocal = cls._old_sla_session_local
|
||||||
|
StatusHistory.__table__.drop(bind=cls.engine)
|
||||||
|
Notification.__table__.drop(bind=cls.engine)
|
||||||
|
TopicStatusTransition.__table__.drop(bind=cls.engine)
|
||||||
|
Message.__table__.drop(bind=cls.engine)
|
||||||
|
Status.__table__.drop(bind=cls.engine)
|
||||||
|
Attachment.__table__.drop(bind=cls.engine)
|
||||||
|
Request.__table__.drop(bind=cls.engine)
|
||||||
|
OtpSession.__table__.drop(bind=cls.engine)
|
||||||
|
cls.engine.dispose()
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
db.execute(delete(StatusHistory))
|
||||||
|
db.execute(delete(Message))
|
||||||
|
db.execute(delete(Status))
|
||||||
|
db.execute(delete(TopicStatusTransition))
|
||||||
|
db.execute(delete(Notification))
|
||||||
|
db.execute(delete(Attachment))
|
||||||
|
db.execute(delete(Request))
|
||||||
|
db.execute(delete(OtpSession))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
def test_cleanup_expired_otps_deletes_only_expired_rows(self):
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
OtpSession(
|
||||||
|
purpose="VIEW_REQUEST",
|
||||||
|
track_number="TRK-EXP",
|
||||||
|
phone="+79990000001",
|
||||||
|
code_hash="hash-exp",
|
||||||
|
attempts=0,
|
||||||
|
expires_at=now - timedelta(minutes=1),
|
||||||
|
),
|
||||||
|
OtpSession(
|
||||||
|
purpose="VIEW_REQUEST",
|
||||||
|
track_number="TRK-ACT",
|
||||||
|
phone="+79990000002",
|
||||||
|
code_hash="hash-act",
|
||||||
|
attempts=0,
|
||||||
|
expires_at=now + timedelta(minutes=10),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
result = security_task.cleanup_expired_otps()
|
||||||
|
self.assertEqual(result["checked"], 2)
|
||||||
|
self.assertEqual(result["deleted"], 1)
|
||||||
|
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
remaining = db.query(OtpSession).all()
|
||||||
|
self.assertEqual(len(remaining), 1)
|
||||||
|
self.assertEqual(remaining[0].track_number, "TRK-ACT")
|
||||||
|
|
||||||
|
def test_cleanup_stale_uploads_removes_invalid_and_fixes_totals(self):
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
req1 = Request(
|
||||||
|
track_number="TRK-UP-1",
|
||||||
|
client_name="Клиент 1",
|
||||||
|
client_phone="+79990001001",
|
||||||
|
topic_code="civil",
|
||||||
|
status_code="NEW",
|
||||||
|
extra_fields={},
|
||||||
|
total_attachments_bytes=999,
|
||||||
|
)
|
||||||
|
req2 = Request(
|
||||||
|
track_number="TRK-UP-2",
|
||||||
|
client_name="Клиент 2",
|
||||||
|
client_phone="+79990001002",
|
||||||
|
topic_code="civil",
|
||||||
|
status_code="NEW",
|
||||||
|
extra_fields={},
|
||||||
|
total_attachments_bytes=0,
|
||||||
|
)
|
||||||
|
db.add_all([req1, req2])
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
Attachment(request_id=req1.id, message_id=None, file_name="a.pdf", mime_type="application/pdf", size_bytes=100, s3_key="k1"),
|
||||||
|
Attachment(request_id=req1.id, message_id=None, file_name="b.pdf", mime_type="application/pdf", size_bytes=200, s3_key="k2"),
|
||||||
|
Attachment(request_id=req1.id, message_id=None, file_name="bad-size.pdf", mime_type="application/pdf", size_bytes=0, s3_key="k3"),
|
||||||
|
Attachment(request_id=req1.id, message_id=None, file_name="bad-key.pdf", mime_type="application/pdf", size_bytes=20, s3_key=""),
|
||||||
|
Attachment(request_id=uuid4(), message_id=None, file_name="orphan.pdf", mime_type="application/pdf", size_bytes=50, s3_key="orphan"),
|
||||||
|
Attachment(request_id=req2.id, message_id=None, file_name="c.pdf", mime_type="application/pdf", size_bytes=70, s3_key="k4"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
req1_id = req1.id
|
||||||
|
req2_id = req2.id
|
||||||
|
|
||||||
|
result = uploads_task.cleanup_stale_uploads()
|
||||||
|
self.assertEqual(result["deleted_orphan_attachments"], 1)
|
||||||
|
self.assertEqual(result["deleted_invalid_attachments"], 2)
|
||||||
|
self.assertEqual(result["fixed_requests"], 2)
|
||||||
|
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
req1 = db.get(Request, req1_id)
|
||||||
|
req2 = db.get(Request, req2_id)
|
||||||
|
self.assertIsNotNone(req1)
|
||||||
|
self.assertIsNotNone(req2)
|
||||||
|
self.assertEqual(req1.total_attachments_bytes, 300)
|
||||||
|
self.assertEqual(req2.total_attachments_bytes, 70)
|
||||||
|
all_attachments = db.query(Attachment).all()
|
||||||
|
self.assertEqual(len(all_attachments), 3)
|
||||||
|
|
||||||
|
def test_sla_check_computes_overdue_and_frt(self):
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
Status(code="NEW", name="Новая", enabled=True, sort_order=0, is_terminal=False),
|
||||||
|
Status(code="IN_PROGRESS", name="В работе", enabled=True, sort_order=1, is_terminal=False),
|
||||||
|
Status(code="CLOSED", name="Закрыта", enabled=True, sort_order=2, is_terminal=True),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
req_overdue = Request(
|
||||||
|
track_number="TRK-SLA-1",
|
||||||
|
client_name="Клиент SLA 1",
|
||||||
|
client_phone="+79990002001",
|
||||||
|
topic_code="civil",
|
||||||
|
status_code="NEW",
|
||||||
|
extra_fields={},
|
||||||
|
created_at=now - timedelta(hours=30),
|
||||||
|
updated_at=now - timedelta(hours=30),
|
||||||
|
)
|
||||||
|
req_ok = Request(
|
||||||
|
track_number="TRK-SLA-2",
|
||||||
|
client_name="Клиент SLA 2",
|
||||||
|
client_phone="+79990002002",
|
||||||
|
topic_code="civil",
|
||||||
|
status_code="NEW",
|
||||||
|
extra_fields={},
|
||||||
|
created_at=now - timedelta(hours=2),
|
||||||
|
updated_at=now - timedelta(hours=2),
|
||||||
|
)
|
||||||
|
req_closed = Request(
|
||||||
|
track_number="TRK-SLA-3",
|
||||||
|
client_name="Клиент SLA 3",
|
||||||
|
client_phone="+79990002003",
|
||||||
|
topic_code="civil",
|
||||||
|
status_code="CLOSED",
|
||||||
|
extra_fields={},
|
||||||
|
created_at=now - timedelta(hours=50),
|
||||||
|
updated_at=now - timedelta(hours=50),
|
||||||
|
)
|
||||||
|
db.add_all([req_overdue, req_ok, req_closed])
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
db.add(
|
||||||
|
Message(
|
||||||
|
request_id=req_overdue.id,
|
||||||
|
author_type="LAWYER",
|
||||||
|
author_name="Юрист",
|
||||||
|
body="Первый ответ",
|
||||||
|
created_at=req_overdue.created_at + timedelta(minutes=30),
|
||||||
|
updated_at=req_overdue.created_at + timedelta(minutes=30),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
StatusHistory(
|
||||||
|
request_id=req_overdue.id,
|
||||||
|
from_status=None,
|
||||||
|
to_status="NEW",
|
||||||
|
changed_by_admin_id=None,
|
||||||
|
created_at=now - timedelta(hours=30),
|
||||||
|
updated_at=now - timedelta(hours=30),
|
||||||
|
),
|
||||||
|
StatusHistory(
|
||||||
|
request_id=req_overdue.id,
|
||||||
|
from_status="NEW",
|
||||||
|
to_status="IN_PROGRESS",
|
||||||
|
changed_by_admin_id=None,
|
||||||
|
created_at=now - timedelta(hours=10),
|
||||||
|
updated_at=now - timedelta(hours=10),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
result = sla_task.sla_check()
|
||||||
|
self.assertEqual(result["checked_active_requests"], 2)
|
||||||
|
self.assertGreaterEqual(result["overdue_total"], 1)
|
||||||
|
self.assertGreaterEqual(result["overdue_by_status"].get("NEW", 0), 1)
|
||||||
|
self.assertIsNotNone(result["frt_avg_minutes"])
|
||||||
|
self.assertAlmostEqual(result["frt_avg_minutes"], 30.0, places=1)
|
||||||
|
self.assertIn("NEW", result["avg_time_in_status_hours"])
|
||||||
|
|
||||||
|
def test_sla_check_uses_topic_transition_sla_for_active_status(self):
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
Status(code="NEW", name="Новая", enabled=True, sort_order=0, is_terminal=False),
|
||||||
|
Status(code="IN_PROGRESS", name="В работе", enabled=True, sort_order=1, is_terminal=False),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db.add(
|
||||||
|
TopicStatusTransition(
|
||||||
|
topic_code="civil",
|
||||||
|
from_status="NEW",
|
||||||
|
to_status="IN_PROGRESS",
|
||||||
|
enabled=True,
|
||||||
|
sla_hours=1,
|
||||||
|
sort_order=1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
req = Request(
|
||||||
|
track_number="TRK-SLA-T-1",
|
||||||
|
client_name="Клиент SLA T",
|
||||||
|
client_phone="+79990002101",
|
||||||
|
topic_code="civil",
|
||||||
|
status_code="NEW",
|
||||||
|
extra_fields={},
|
||||||
|
created_at=now - timedelta(hours=2),
|
||||||
|
updated_at=now - timedelta(hours=2),
|
||||||
|
)
|
||||||
|
db.add(req)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
result = sla_task.sla_check()
|
||||||
|
self.assertEqual(result["checked_active_requests"], 1)
|
||||||
|
self.assertEqual(result["overdue_total"], 1)
|
||||||
|
self.assertGreaterEqual(result["overdue_by_status"].get("NEW", 0), 1)
|
||||||
|
self.assertGreaterEqual(result["overdue_by_transition"].get("civil:NEW->*", 0), 1)
|
||||||
2100
tmp/admin.bundle.js
Normal file
2100
tmp/admin.bundle.js
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue