From fb13d93ab3a37480fdd037d8ed04b3b43252f6b9 Mon Sep 17 00:00:00 2001 From: TronoSfera <119615520+TronoSfera@users.noreply.github.com> Date: Mon, 23 Feb 2026 15:20:00 +0300 Subject: [PATCH] Second commit --- Makefile | 3 + README.md | 6 + alembic/env.py | 2 + .../0002_add_responsible_to_all_tables.py | 46 + .../0003_add_primary_topic_to_admin_users.py | 24 + .../0004_add_avatar_to_admin_users.py | 22 + .../versions/0005_add_admin_user_topics.py | 41 + .../versions/0006_add_request_read_markers.py | 34 + .../0007_add_topic_status_transitions.py | 51 + .../versions/0008_add_request_templates.py | 100 + ...009_add_sla_hours_to_status_transitions.py | 28 + alembic/versions/0010_add_notifications.py | 68 + ...0011_add_financial_fields_for_dashboard.py | 54 + app/api/admin/config.py | 9 +- app/api/admin/crud.py | 871 +++++++ app/api/admin/metrics.py | 235 +- app/api/admin/notifications.py | 120 + app/api/admin/quotes.py | 3 +- app/api/admin/requests.py | 510 +++- app/api/admin/router.py | 4 +- app/api/admin/uploads.py | 247 +- app/api/public/otp.py | 192 +- app/api/public/requests.py | 421 +++- app/api/public/uploads.py | 168 +- app/core/config.py | 3 +- app/data/__init__.py | 1 + app/data/quotes_justice_seed.py | 52 + app/models/admin_user.py | 6 +- app/models/admin_user_topic.py | 16 + app/models/common.py | 3 +- app/models/notification.py | 25 + app/models/request.py | 12 +- app/models/request_data_requirement.py | 27 + app/models/topic_data_template.py | 24 + app/models/topic_required_field.py | 22 + app/models/topic_status_transition.py | 24 + app/schemas/admin.py | 34 +- app/schemas/public.py | 43 +- app/schemas/uploads.py | 43 + app/scripts/__init__.py | 1 + app/scripts/upsert_quotes.py | 68 + app/services/notifications.py | 436 ++++ app/services/request_read_markers.py | 31 + app/services/request_status.py | 82 + app/services/request_templates.py | 48 + app/services/s3_storage.py | 75 + app/services/sla_metrics.py | 180 ++ app/services/status_flow.py | 46 + app/services/telegram_notify.py | 51 + app/services/universal_query.py | 33 +- app/web/admin.html | 154 +- app/web/admin.jsx | 1485 ++++++++++-- app/web/landing.html | 552 ++++- app/workers/tasks/assign.py | 118 +- app/workers/tasks/security.py | 22 +- app/workers/tasks/sla.py | 60 +- app/workers/tasks/uploads.py | 55 +- context/00_system_overview.md | 29 +- context/01_public_requests_service.md | 45 +- context/02_otp_service.md | 21 +- context/03_admin_panel_service.md | 149 +- context/04_files_service.md | 29 +- context/05_sla_auto_assign_service.md | 55 +- context/08_security_model.md | 29 +- context/09_metrics_dashboard.md | 78 +- context/10_development_execution_plan.md | 64 + context/11_test_runbook.md | 63 + tests/test_admin_universal_crud.py | 1252 ++++++++++ tests/test_auto_assign.py | 356 +++ tests/test_dashboard_finance.py | 301 +++ tests/test_migrations.py | 74 +- tests/test_notifications.py | 375 +++ tests/test_public_cabinet.py | 266 +++ tests/test_public_requests.py | 171 +- tests/test_quotes_seed.py | 77 + tests/test_uploads_s3.py | 645 +++++ tests/test_worker_maintenance.py | 289 +++ tmp/admin.bundle.js | 2100 +++++++++++++++++ 78 files changed, 13202 insertions(+), 357 deletions(-) create mode 100644 alembic/versions/0002_add_responsible_to_all_tables.py create mode 100644 alembic/versions/0003_add_primary_topic_to_admin_users.py create mode 100644 alembic/versions/0004_add_avatar_to_admin_users.py create mode 100644 alembic/versions/0005_add_admin_user_topics.py create mode 100644 alembic/versions/0006_add_request_read_markers.py create mode 100644 alembic/versions/0007_add_topic_status_transitions.py create mode 100644 alembic/versions/0008_add_request_templates.py create mode 100644 alembic/versions/0009_add_sla_hours_to_status_transitions.py create mode 100644 alembic/versions/0010_add_notifications.py create mode 100644 alembic/versions/0011_add_financial_fields_for_dashboard.py create mode 100644 app/api/admin/crud.py create mode 100644 app/api/admin/notifications.py create mode 100644 app/data/__init__.py create mode 100644 app/data/quotes_justice_seed.py create mode 100644 app/models/admin_user_topic.py create mode 100644 app/models/notification.py create mode 100644 app/models/request_data_requirement.py create mode 100644 app/models/topic_data_template.py create mode 100644 app/models/topic_required_field.py create mode 100644 app/models/topic_status_transition.py create mode 100644 app/schemas/uploads.py create mode 100644 app/scripts/__init__.py create mode 100644 app/scripts/upsert_quotes.py create mode 100644 app/services/notifications.py create mode 100644 app/services/request_read_markers.py create mode 100644 app/services/request_status.py create mode 100644 app/services/request_templates.py create mode 100644 app/services/s3_storage.py create mode 100644 app/services/sla_metrics.py create mode 100644 app/services/status_flow.py create mode 100644 app/services/telegram_notify.py create mode 100644 context/10_development_execution_plan.md create mode 100644 context/11_test_runbook.md create mode 100644 tests/test_admin_universal_crud.py create mode 100644 tests/test_auto_assign.py create mode 100644 tests/test_dashboard_finance.py create mode 100644 tests/test_notifications.py create mode 100644 tests/test_public_cabinet.py create mode 100644 tests/test_quotes_seed.py create mode 100644 tests/test_uploads_s3.py create mode 100644 tests/test_worker_maintenance.py create mode 100644 tmp/admin.bundle.js diff --git a/Makefile b/Makefile index e4f125b..e497774 100644 --- a/Makefile +++ b/Makefile @@ -6,3 +6,6 @@ migrate: test: 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 diff --git a/README.md b/README.md index 1a9b1ea..dca9f24 100644 --- a/README.md +++ b/README.md @@ -15,3 +15,9 @@ Swagger: http://localhost:8002/docs ```bash 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)`. diff --git a/alembic/env.py b/alembic/env.py index 354759f..d6ea437 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -16,6 +16,8 @@ from app.models.status_history import StatusHistory from app.models.audit_log import AuditLog from app.models.otp_session import OtpSession from app.models.quote import Quote +from app.models.admin_user_topic import AdminUserTopic +from app.models.notification import Notification config = context.config fileConfig(config.config_file_name) diff --git a/alembic/versions/0002_add_responsible_to_all_tables.py b/alembic/versions/0002_add_responsible_to_all_tables.py new file mode 100644 index 0000000..1141eae --- /dev/null +++ b/alembic/versions/0002_add_responsible_to_all_tables.py @@ -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") diff --git a/alembic/versions/0003_add_primary_topic_to_admin_users.py b/alembic/versions/0003_add_primary_topic_to_admin_users.py new file mode 100644 index 0000000..f73ee47 --- /dev/null +++ b/alembic/versions/0003_add_primary_topic_to_admin_users.py @@ -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") diff --git a/alembic/versions/0004_add_avatar_to_admin_users.py b/alembic/versions/0004_add_avatar_to_admin_users.py new file mode 100644 index 0000000..b978ef9 --- /dev/null +++ b/alembic/versions/0004_add_avatar_to_admin_users.py @@ -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") diff --git a/alembic/versions/0005_add_admin_user_topics.py b/alembic/versions/0005_add_admin_user_topics.py new file mode 100644 index 0000000..718499f --- /dev/null +++ b/alembic/versions/0005_add_admin_user_topics.py @@ -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") diff --git a/alembic/versions/0006_add_request_read_markers.py b/alembic/versions/0006_add_request_read_markers.py new file mode 100644 index 0000000..897eef7 --- /dev/null +++ b/alembic/versions/0006_add_request_read_markers.py @@ -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") diff --git a/alembic/versions/0007_add_topic_status_transitions.py b/alembic/versions/0007_add_topic_status_transitions.py new file mode 100644 index 0000000..1203e35 --- /dev/null +++ b/alembic/versions/0007_add_topic_status_transitions.py @@ -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") diff --git a/alembic/versions/0008_add_request_templates.py b/alembic/versions/0008_add_request_templates.py new file mode 100644 index 0000000..d664136 --- /dev/null +++ b/alembic/versions/0008_add_request_templates.py @@ -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") diff --git a/alembic/versions/0009_add_sla_hours_to_status_transitions.py b/alembic/versions/0009_add_sla_hours_to_status_transitions.py new file mode 100644 index 0000000..bcda2ad --- /dev/null +++ b/alembic/versions/0009_add_sla_hours_to_status_transitions.py @@ -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") diff --git a/alembic/versions/0010_add_notifications.py b/alembic/versions/0010_add_notifications.py new file mode 100644 index 0000000..8c12c3d --- /dev/null +++ b/alembic/versions/0010_add_notifications.py @@ -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") diff --git a/alembic/versions/0011_add_financial_fields_for_dashboard.py b/alembic/versions/0011_add_financial_fields_for_dashboard.py new file mode 100644 index 0000000..e49b83d --- /dev/null +++ b/alembic/versions/0011_add_financial_fields_for_dashboard.py @@ -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") diff --git a/app/api/admin/config.py b/app/api/admin/config.py index ff752d1..c502a2a 100644 --- a/app/api/admin/config.py +++ b/app/api/admin/config.py @@ -50,7 +50,8 @@ def query_topics(uq: UniversalQuery, db: Session = Depends(get_db), admin=Depend @router.post("/topics", status_code=201) 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: db.add(row) db.commit() @@ -97,7 +98,8 @@ def query_statuses(uq: UniversalQuery, db: Session = Depends(get_db), admin=Depe @router.post("/statuses", status_code=201) 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: db.add(row) 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) 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: db.add(row) db.commit() diff --git a/app/api/admin/crud.py b/app/api/admin/crud.py new file mode 100644 index 0000000..8fb026a --- /dev/null +++ b/app/api/admin/crud.py @@ -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} diff --git a/app/api/admin/metrics.py b/app/api/admin/metrics.py index 600b75f..7483b97 100644 --- a/app/api/admin/metrics.py +++ b/app/api/admin/metrics.py @@ -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 sqlalchemy import func from sqlalchemy.orm import Session + from app.core.deps import require_role from app.db.session import get_db +from app.models.admin_user import AdminUser 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() +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") -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() - by_status = {status: count for status, count in by_status_rows} +def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER"))): + role = str(admin.get("role") or "").upper() + 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 { - "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, - "frt_avg_minutes": None, - "sla_overdue": 0, - "avg_time_in_status_hours": {}, + "assigned_total": assigned_total, + "active_assigned_total": active_assigned_total, + "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, } diff --git a/app/api/admin/notifications.py b/app/api/admin/notifications.py new file mode 100644 index 0000000..72d083f --- /dev/null +++ b/app/api/admin/notifications.py @@ -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)} diff --git a/app/api/admin/quotes.py b/app/api/admin/quotes.py index 95f5b6e..e012305 100644 --- a/app/api/admin/quotes.py +++ b/app/api/admin/quotes.py @@ -32,7 +32,8 @@ def query_quotes(uq: UniversalQuery, db: Session = Depends(get_db), admin=Depend @router.post("", status_code=201) 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) return {"id": str(q.id)} diff --git a/app/api/admin/requests.py b/app/api/admin/requests.py index e90fb9f..9a26925 100644 --- a/app/api/admin/requests.py +++ b/app/api/admin/requests.py @@ -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 sqlalchemy.orm import Session from sqlalchemy.exc import IntegrityError +from sqlalchemy import update + from app.db.session import get_db from app.core.deps import require_role 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.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 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") 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) @@ -25,6 +80,14 @@ def query_requests(uq: UniversalQuery, db: Session = Depends(get_db), admin=Depe "client_name": r.client_name, "client_phone": r.client_phone, "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, "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) 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()}" + responsible = str(admin.get("email") or "").strip() or "Администратор системы" row = Request( track_number=track, client_name=payload.client_name, @@ -46,7 +113,12 @@ def create_request(payload: RequestAdminCreate, db: Session = Depends(get_db), a description=payload.description, extra_fields=payload.extra_fields, 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, + responsible=responsible, ) try: db.add(row) @@ -65,11 +137,43 @@ def update_request( 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: 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) + 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: db.add(row) db.commit() @@ -82,7 +186,8 @@ def update_request( @router.delete("/{request_id}") 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: raise HTTPException(status_code=404, detail="Заявка не найдена") db.delete(row) @@ -91,9 +196,25 @@ def delete_request(request_id: str, db: Session = Depends(get_db), admin=Depends @router.get("/{request_id}") 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: 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 { "id": str(req.id), "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, "extra_fields": req.extra_fields, "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, + "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, "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)} diff --git a/app/api/admin/router.py b/app/api/admin/router.py index 5a78dcb..43edaf4 100644 --- a/app/api/admin/router.py +++ b/app/api/admin/router.py @@ -1,5 +1,5 @@ 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.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(uploads.router, prefix="/uploads", tags=["AdminFiles"]) 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"]) diff --git a/app/api/admin/uploads.py b/app/api/admin/uploads.py index 28c9b9a..9232ea5 100644 --- a/app/api/admin/uploads.py +++ b/app/api/admin/uploads.py @@ -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.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.post("/init") -def upload_init(admin=Depends(require_role("ADMIN","LAWYER"))): - return {"method": "PRESIGNED_PUT", "presigned_url": "https://s3.local/..."} -@router.post("/complete") -def upload_complete(admin=Depends(require_role("ADMIN","LAWYER"))): - return {"status": "ok"} +def _max_file_bytes() -> int: + return int(settings.MAX_FILE_MB) * 1024 * 1024 + + +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) diff --git a/app/api/public/otp.py b/app/api/public/otp.py index f51139a..21046e2 100644 --- a/app/api/public/otp.py +++ b/app/api/public/otp.py @@ -1,19 +1,66 @@ -from fastapi import APIRouter, Response -from datetime import timedelta -from app.schemas.public import OtpSend, OtpVerify +from __future__ import annotations + +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.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.post("/send") -def send_otp(payload: OtpSend): - return {"status": "sent"} +OTP_TTL_MINUTES = 10 +OTP_MAX_ATTEMPTS = 5 +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): - token = create_jwt({"sub": payload.track_number or "unknown", "purpose": payload.purpose}, - settings.PUBLIC_JWT_SECRET, timedelta(days=settings.PUBLIC_JWT_TTL_DAYS)) + +def _now_utc() -> datetime: + return datetime.now(timezone.utc) + + +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( key=settings.PUBLIC_COOKIE_NAME, value=token, @@ -22,4 +69,125 @@ def verify_otp(payload: OtpVerify, response: Response): samesite="lax", 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} diff --git a/app/api/public/requests.py b/app/api/public/requests.py index a07d5f6..32c95de 100644 --- a/app/api/public/requests.py +++ b/app/api/public/requests.py @@ -1,16 +1,421 @@ -from fastapi import APIRouter, Depends -from sqlalchemy.orm import Session +from __future__ import annotations + +from datetime import timedelta +from uuid import UUID 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.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.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() +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) -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()}" - r = Request(track_number=track, client_name=payload.client_name, client_phone=payload.client_phone, - topic_code=payload.topic_code, description=payload.description, extra_fields=payload.extra_fields) - db.add(r); db.commit(); db.refresh(r) - return PublicRequestCreated(request_id=r.id, track_number=r.track_number, otp_required=True) + row = Request( + track_number=track, + client_name=payload.client_name, + 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)} diff --git a/app/api/public/uploads.py b/app/api/public/uploads.py index 991c309..323dd6b 100644 --- a/app/api/public/uploads.py +++ b/app/api/public/uploads.py @@ -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.post("/init") -def upload_init(): - return {"method": "PRESIGNED_PUT", "presigned_url": "https://s3.local/..."} -@router.post("/complete") -def upload_complete(): - return {"status": "ok"} +def _max_file_bytes() -> int: + return int(settings.MAX_FILE_MB) * 1024 * 1024 + + +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) diff --git a/app/core/config.py b/app/core/config.py index 0e16a4e..4029e81 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -23,11 +23,12 @@ class Settings(BaseSettings): S3_REGION: str = "us-east-1" S3_USE_SSL: bool = False MAX_FILE_MB: int = 25 - MAX_CASE_MB: int = 350 + MAX_CASE_MB: int = 250 TELEGRAM_BOT_TOKEN: str = "change_me" TELEGRAM_CHAT_ID: str = "0" SMS_PROVIDER: str = "dummy" + DATA_ENCRYPTION_SECRET: str = "change_me_data_encryption" @property def cors_origins_list(self) -> List[str]: diff --git a/app/data/__init__.py b/app/data/__init__.py new file mode 100644 index 0000000..005be0b --- /dev/null +++ b/app/data/__init__.py @@ -0,0 +1 @@ +# Data package for static seed content. diff --git a/app/data/quotes_justice_seed.py b/app/data/quotes_justice_seed.py new file mode 100644 index 0000000..02beba1 --- /dev/null +++ b/app/data/quotes_justice_seed.py @@ -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": "Искусство войны"}, +] diff --git a/app/models/admin_user.py b/app/models/admin_user.py index 2bf50f1..8f6246b 100644 --- a/app/models/admin_user.py +++ b/app/models/admin_user.py @@ -1,4 +1,4 @@ -from sqlalchemy import String, Boolean +from sqlalchemy import Boolean, Numeric, String from sqlalchemy.orm import Mapped, mapped_column from app.db.session import Base 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) email: Mapped[str] = mapped_column(String(200), unique=True, 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) diff --git a/app/models/admin_user_topic.py b/app/models/admin_user_topic.py new file mode 100644 index 0000000..3edcadf --- /dev/null +++ b/app/models/admin_user_topic.py @@ -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) diff --git a/app/models/common.py b/app/models/common.py index 5eae76c..97bc601 100644 --- a/app/models/common.py +++ b/app/models/common.py @@ -2,7 +2,7 @@ import uuid from datetime import datetime, timezone from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.dialects.postgresql import UUID -from sqlalchemy import DateTime +from sqlalchemy import DateTime, String def utcnow(): return datetime.now(timezone.utc) @@ -13,3 +13,4 @@ class UUIDMixin: class TimestampMixin: created_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="Администратор системы") diff --git a/app/models/notification.py b/app/models/notification.py new file mode 100644 index 0000000..be0864e --- /dev/null +++ b/app/models/notification.py @@ -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) diff --git a/app/models/request.py b/app/models/request.py index 145f7ea..b7e322e 100644 --- a/app/models/request.py +++ b/app/models/request.py @@ -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 app.db.session import Base 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) extra_fields: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False) 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) + 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) diff --git a/app/models/request_data_requirement.py b/app/models/request_data_requirement.py new file mode 100644 index 0000000..62fb814 --- /dev/null +++ b/app/models/request_data_requirement.py @@ -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) diff --git a/app/models/topic_data_template.py b/app/models/topic_data_template.py new file mode 100644 index 0000000..e8cebf2 --- /dev/null +++ b/app/models/topic_data_template.py @@ -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) diff --git a/app/models/topic_required_field.py b/app/models/topic_required_field.py new file mode 100644 index 0000000..22ce1c2 --- /dev/null +++ b/app/models/topic_required_field.py @@ -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) diff --git a/app/models/topic_status_transition.py b/app/models/topic_status_transition.py new file mode 100644 index 0000000..7ca4488 --- /dev/null +++ b/app/models/topic_status_transition.py @@ -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) diff --git a/app/schemas/admin.py b/app/schemas/admin.py index e27e231..b18b7c5 100644 --- a/app/schemas/admin.py +++ b/app/schemas/admin.py @@ -1,5 +1,7 @@ +from datetime import datetime + from pydantic import BaseModel, Field -from typing import Optional, Any +from typing import Optional class AdminLogin(BaseModel): email: str @@ -51,6 +53,10 @@ class RequestAdminCreate(BaseModel): description: Optional[str] = None extra_fields: dict = Field(default_factory=dict) 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 @@ -63,4 +69,30 @@ class RequestAdminPatch(BaseModel): description: Optional[str] = None extra_fields: Optional[dict] = 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 + + +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 diff --git a/app/schemas/public.py b/app/schemas/public.py index 53703e1..2bd2854 100644 --- a/app/schemas/public.py +++ b/app/schemas/public.py @@ -1,5 +1,5 @@ from pydantic import BaseModel, Field -from typing import Optional, Dict, Any, List +from typing import Optional, Dict, Any, List, Literal from uuid import UUID class PublicRequestCreate(BaseModel): @@ -23,4 +23,45 @@ class OtpSend(BaseModel): class OtpVerify(BaseModel): purpose: str track_number: Optional[str] = None + client_phone: Optional[str] = None 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) diff --git a/app/schemas/uploads.py b/app/schemas/uploads.py new file mode 100644 index 0000000..af2dd32 --- /dev/null +++ b/app/schemas/uploads.py @@ -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 diff --git a/app/scripts/__init__.py b/app/scripts/__init__.py new file mode 100644 index 0000000..a00d356 --- /dev/null +++ b/app/scripts/__init__.py @@ -0,0 +1 @@ +# Utility scripts for operational tasks. diff --git a/app/scripts/upsert_quotes.py b/app/scripts/upsert_quotes.py new file mode 100644 index 0000000..2f356a6 --- /dev/null +++ b/app/scripts/upsert_quotes.py @@ -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() diff --git a/app/services/notifications.py b/app/services/notifications.py new file mode 100644 index 0000000..0d817cf --- /dev/null +++ b/app/services/notifications.py @@ -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() + ) diff --git a/app/services/request_read_markers.py b/app/services/request_read_markers.py new file mode 100644 index 0000000..c301973 --- /dev/null +++ b/app/services/request_read_markers.py @@ -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 diff --git a/app/services/request_status.py b/app/services/request_status.py new file mode 100644 index 0000000..5156578 --- /dev/null +++ b/app/services/request_status.py @@ -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, + ) diff --git a/app/services/request_templates.py b/app/services/request_templates.py new file mode 100644 index 0000000..e69d80b --- /dev/null +++ b/app/services/request_templates.py @@ -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), + ) diff --git a/app/services/s3_storage.py b/app/services/s3_storage.py new file mode 100644 index 0000000..2ec3ce9 --- /dev/null +++ b/app/services/s3_storage.py @@ -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() diff --git a/app/services/sla_metrics.py b/app/services/sla_metrics.py new file mode 100644 index 0000000..0851da3 --- /dev/null +++ b/app/services/sla_metrics.py @@ -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 diff --git a/app/services/status_flow.py b/app/services/status_flow.py new file mode 100644 index 0000000..86c7591 --- /dev/null +++ b/app/services/status_flow.py @@ -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 diff --git a/app/services/telegram_notify.py b/app/services/telegram_notify.py new file mode 100644 index 0000000..2659aeb --- /dev/null +++ b/app/services/telegram_notify.py @@ -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)} diff --git a/app/services/universal_query.py b/app/services/universal_query.py index ab26e23..423b703 100644 --- a/app/services/universal_query.py +++ b/app/services/universal_query.py @@ -1,26 +1,45 @@ +import uuid + +from fastapi import HTTPException from sqlalchemy.orm import Query from sqlalchemy import asc, desc + 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: for f in uq.filters: col = getattr(model, f.field, None) if col is None: continue + value = _coerce_filter_value(col, f.value) if f.op == "=": - q = q.filter(col == f.value) + q = q.filter(col == value) elif f.op == "!=": - q = q.filter(col != f.value) + q = q.filter(col != value) elif f.op == ">": - q = q.filter(col > f.value) + q = q.filter(col > value) elif f.op == "<": - q = q.filter(col < f.value) + q = q.filter(col < value) elif f.op == ">=": - q = q.filter(col >= f.value) + q = q.filter(col >= value) elif f.op == "<=": - q = q.filter(col <= f.value) + q = q.filter(col <= value) elif f.op == "~": - q = q.filter(col.ilike(f"%{f.value}%")) + q = q.filter(col.ilike(f"%{value}%")) for s in uq.sort: col = getattr(model, s.field, None) if col is None: diff --git a/app/web/admin.html b/app/web/admin.html index 27de879..43f7cab 100644 --- a/app/web/admin.html +++ b/app/web/admin.html @@ -95,6 +95,21 @@ 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 { margin-top: 1.2rem; border: 1px solid var(--line); @@ -309,6 +324,18 @@ 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 { width: 100%; border: 1px solid #3c4d62; @@ -380,12 +407,92 @@ 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 { display: flex; gap: 0.4rem; 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 { border: 1px solid var(--line); border-radius: 9px; @@ -472,54 +579,10 @@ .config-layout { display: grid; - grid-template-columns: 260px 1fr; + grid-template-columns: 1fr; 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 { min-width: 0; } @@ -635,7 +698,6 @@ .filters { grid-template-columns: repeat(2, minmax(0, 1fr)); } .triple { grid-template-columns: 1fr; } .config-layout { grid-template-columns: 1fr; } - .config-tree { position: static; } } @media (max-width: 920px) { diff --git a/app/web/admin.jsx b/app/web/admin.jsx index 2a8d7cf..b9b8d78 100644 --- a/app/web/admin.jsx +++ b/app/web/admin.jsx @@ -30,29 +30,76 @@ REJECTED: "Отклонена", }; + const REQUEST_UPDATE_EVENT_LABELS = { + MESSAGE: "сообщение", + ATTACHMENT: "файл", + STATUS: "статус", + }; + const TABLE_SERVER_CONFIG = { requests: { - endpoint: "/api/admin/requests/query", + table: "requests", + endpoint: "/api/admin/crud/requests/query", sort: [{ field: "created_at", dir: "desc" }], }, quotes: { - endpoint: "/api/admin/quotes/query", + table: "quotes", + endpoint: "/api/admin/crud/quotes/query", sort: [{ field: "sort_order", dir: "asc" }], }, topics: { - endpoint: "/api/admin/config/topics/query", + table: "topics", + endpoint: "/api/admin/crud/topics/query", sort: [{ field: "sort_order", dir: "asc" }], }, statuses: { - endpoint: "/api/admin/config/statuses/query", + table: "statuses", + endpoint: "/api/admin/crud/statuses/query", sort: [{ field: "sort_order", dir: "asc" }], }, formFields: { - endpoint: "/api/admin/config/form-fields/query", + table: "form_fields", + endpoint: "/api/admin/crud/form_fields/query", sort: [{ field: "sort_order", dir: "asc" }], }, + topicRequiredFields: { + table: "topic_required_fields", + endpoint: "/api/admin/crud/topic_required_fields/query", + sort: [{ field: "sort_order", dir: "asc" }], + }, + topicDataTemplates: { + table: "topic_data_templates", + endpoint: "/api/admin/crud/topic_data_templates/query", + sort: [{ field: "sort_order", dir: "asc" }], + }, + statusTransitions: { + table: "topic_status_transitions", + endpoint: "/api/admin/crud/topic_status_transitions/query", + sort: [{ field: "sort_order", dir: "asc" }], + }, + users: { + table: "admin_users", + endpoint: "/api/admin/crud/admin_users/query", + sort: [{ field: "created_at", dir: "desc" }], + }, + userTopics: { + table: "admin_user_topics", + endpoint: "/api/admin/crud/admin_user_topics/query", + sort: [{ field: "created_at", dir: "desc" }], + }, }; + const TABLE_MUTATION_CONFIG = Object.fromEntries( + Object.entries(TABLE_SERVER_CONFIG).map(([tableKey, config]) => [ + tableKey, + { + create: "/api/admin/crud/" + config.table, + update: (id) => "/api/admin/crud/" + config.table + "/" + id, + delete: (id) => "/api/admin/crud/" + config.table + "/" + id, + }, + ]) + ); + function createTableState() { return { filters: [], @@ -106,6 +153,36 @@ return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString("ru-RU"); } + function userInitials(name, email) { + const source = String(name || "").trim(); + if (source) { + const parts = source.split(/\s+/).filter(Boolean); + if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase(); + return source.slice(0, 2).toUpperCase(); + } + const mail = String(email || "").trim(); + return (mail.slice(0, 2) || "U").toUpperCase(); + } + + function avatarColor(seed) { + const palette = ["#6f8fa9", "#568f7d", "#a07a5c", "#7d6ea9", "#8f6f8f", "#7f8c5a"]; + const text = String(seed || ""); + let hash = 0; + for (let i = 0; i < text.length; i += 1) hash = (hash * 31 + text.charCodeAt(i)) >>> 0; + return palette[hash % palette.length]; + } + + function resolveAvatarSrc(avatarUrl, accessToken) { + const raw = String(avatarUrl || "").trim(); + if (!raw) return ""; + if (raw.startsWith("s3://")) { + const key = raw.slice("s3://".length); + if (!key || !accessToken) return ""; + return "/api/admin/uploads/object/" + encodeURIComponent(key) + "?token=" + encodeURIComponent(accessToken); + } + return raw; + } + function buildUniversalQuery(filters, sort, limit, offset) { return { filters: filters || [], @@ -152,12 +229,58 @@ Описание: row.description || null, "Дополнительные поля": row.extra_fields || {}, "Назначенный юрист (ID)": row.assigned_lawyer_id || null, + "Ставка (фикс.)": row.effective_rate ?? null, + "Сумма счета": row.invoice_amount ?? null, + "Оплачено": row.paid_at ? fmtDate(row.paid_at) : null, + "Оплату подтвердил (ID)": row.paid_by_admin_id || null, + "Непрочитано клиентом": boolLabel(Boolean(row.client_has_unread_updates)), + "Тип обновления для клиента": row.client_unread_event_type ? (REQUEST_UPDATE_EVENT_LABELS[row.client_unread_event_type] || row.client_unread_event_type) : null, + "Непрочитано юристом": boolLabel(Boolean(row.lawyer_has_unread_updates)), + "Тип обновления для юриста": row.lawyer_unread_event_type ? (REQUEST_UPDATE_EVENT_LABELS[row.lawyer_unread_event_type] || row.lawyer_unread_event_type) : null, "Общий размер вложений (байт)": row.total_attachments_bytes ?? 0, Создано: fmtDate(row.created_at), Обновлено: fmtDate(row.updated_at), }; } + function renderRequestUpdatesCell(row, role) { + if (role === "LAWYER") { + const has = Boolean(row.lawyer_has_unread_updates); + const eventType = String(row.lawyer_unread_event_type || "").toUpperCase(); + return has ? ( + + + {REQUEST_UPDATE_EVENT_LABELS[eventType] || "обновление"} + + ) : ( + нет + ); + } + + const clientHas = Boolean(row.client_has_unread_updates); + const clientType = String(row.client_unread_event_type || "").toUpperCase(); + const lawyerHas = Boolean(row.lawyer_has_unread_updates); + const lawyerType = String(row.lawyer_unread_event_type || "").toUpperCase(); + + if (!clientHas && !lawyerHas) return нет; + return ( + + {clientHas ? ( + + + {"Клиент: " + (REQUEST_UPDATE_EVENT_LABELS[clientType] || "обновление")} + + ) : null} + {lawyerHas ? ( + + + {"Юрист: " + (REQUEST_UPDATE_EVENT_LABELS[lawyerType] || "обновление")} + + ) : null} + + ); + } + function localizeMeta(data) { const fieldTypeMap = { string: "строка", @@ -327,6 +450,24 @@ ); } + function UserAvatar({ name, email, avatarUrl, accessToken, size = 32 }) { + const [broken, setBroken] = useState(false); + useEffect(() => setBroken(false), [avatarUrl]); + const initials = userInitials(name, email); + const bg = avatarColor(name || email || initials); + const src = resolveAvatarSrc(avatarUrl, accessToken); + const canShowImage = Boolean(src && !broken); + return ( + + {canShowImage ? ( + {name setBroken(true)} /> + ) : ( + {initials} + )} + + ); + } + function LoginScreen({ onSubmit, status }) { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); @@ -479,16 +620,16 @@ ); } - function QuoteModal({ open, editing, form, status, onClose, onChange, onSubmit }) { + function ReassignModal({ open, status, options, value, onChange, onClose, onSubmit, trackNumber }) { if (!open) return null; return ( - event.target.id === "quote-overlay" && onClose()}> -
event.stopPropagation()}> + event.target.id === "reassign-overlay" && onClose()}> +
event.stopPropagation()}>
-

{editing ? "Редактирование цитаты" : "Новая цитата"}

+

Переназначение заявки

- Создание и редактирование цитат. + {trackNumber ? "Заявка: " + trackNumber : "Выберите нового юриста"}

- - onChange("author", event.target.value)} /> -
-
- - + + + + +
+

Файлы по заявке

+
    +
    + + +
    +
    + +
    +

    История изменений

    +
      +
      +
      + +

      Если вы пришли на сайт по рекомендации, укажите имя рекомендателя при отправке заявки. @@ -747,6 +917,25 @@ const status = document.getElementById("form-status"); const quoteText = document.getElementById("quote-text"); 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() { modal.classList.add("open"); @@ -771,6 +960,136 @@ 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() { try { 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) => { event.preventDefault(); - status.className = "status"; - status.textContent = "Отправляем заявку..."; + setStatus(status, "Отправляем заявку...", null); const payload = { client_name: document.getElementById("name").value.trim(), @@ -808,6 +1318,33 @@ }; 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", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -816,17 +1353,20 @@ if (!response.ok) throw new Error("create request failed"); const data = await response.json(); - status.className = "status ok"; - status.textContent = "Заявка принята. Номер: " + data.track_number; + setStatus(status, "Заявка принята. Номер: " + data.track_number, "ok"); + cabinetTrackInput.value = data.track_number; form.reset(); setTimeout(closeModal, 1200); } catch (error) { - status.className = "status error"; - status.textContent = "Не удалось отправить заявку. Повторите попытку позже."; + setStatus(status, "Не удалось отправить заявку. Повторите попытку позже.", "error"); } }); loadQuotes(); + setCabinetEnabled(false); + clearList(cabinetMessages, "Сообщений пока нет."); + clearList(cabinetFiles, "Файлы пока не загружены."); + clearList(cabinetTimeline, "История пока пуста."); })(); diff --git a/app/workers/tasks/assign.py b/app/workers/tasks/assign.py index 8213bf7..f8ac923 100644 --- a/app/workers/tasks/assign.py +++ b/app/workers/tasks/assign.py @@ -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 -@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(): - 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() diff --git a/app/workers/tasks/security.py b/app/workers/tasks/security.py index 12a4413..229459d 100644 --- a/app/workers/tasks/security.py +++ b/app/workers/tasks/security.py @@ -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 -@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(): - 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() diff --git a/app/workers/tasks/sla.py b/app/workers/tasks/sla.py index 4424cac..aa16809 100644 --- a/app/workers/tasks/sla.py +++ b/app/workers/tasks/sla.py @@ -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 -@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(): - 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() diff --git a/app/workers/tasks/uploads.py b/app/workers/tasks/uploads.py index 4c2202e..db42b08 100644 --- a/app/workers/tasks/uploads.py +++ b/app/workers/tasks/uploads.py @@ -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 -@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(): - 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() diff --git a/context/00_system_overview.md b/context/00_system_overview.md index 9833863..b1d695a 100644 --- a/context/00_system_overview.md +++ b/context/00_system_overview.md @@ -9,11 +9,32 @@ One-page landing + public case tracking (OTP + JWT cookie) + admin panel (ADMIN/ - Backend: Python 3.12 + FastAPI - DB: PostgreSQL - Queue: Redis + Celery -- Immutable data after status change -- Full audit log for admin changes +- OTP is required for request creation and for public request access +- 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) +- Security controls for S3/PII: access audit trail, encryption, retention policy, and incident visibility ## Roles - PUBLIC (via OTP + cookie) -- LAWYER -- ADMIN \ No newline at end of file +- LAWYER (assigned + unassigned queue visibility) +- ADMIN (access to all platform data and configuration) diff --git a/context/01_public_requests_service.md b/context/01_public_requests_service.md index 081962e..e92f7de 100644 --- a/context/01_public_requests_service.md +++ b/context/01_public_requests_service.md @@ -1,18 +1,45 @@ # Public Requests Service Context ## Responsibilities -- Accept new legal case requests -- Generate track_number -- Store configurable form fields (form_fields table) -- Trigger OTP flow -- Allow client to view request (after OTP verify) +- Show landing page and accept new legal case requests +- Request base fields: full name, phone, topic, problem description +- Require OTP verification before request creation (phone confirmation) +- Generate and return unique `track_number` +- 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 -- Phone is mandatory -- Extra fields stored as JSON (validated against form_fields config) +- Phone is mandatory and must be OTP-verified for create flow +- 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 -- 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 - Rate limit by IP/phone/track_number -- No direct access without OTP verification \ No newline at end of file +- 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=` +- Public view endpoint (`GET /api/public/requests/{track_number}`) requires cookie `purpose=VIEW_REQUEST` with `sub=` + +## 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`) diff --git a/context/02_otp_service.md b/context/02_otp_service.md index df7f8f5..a08b91e 100644 --- a/context/02_otp_service.md +++ b/context/02_otp_service.md @@ -1,18 +1,27 @@ # OTP Service Context ## Purpose -Secure access for: -- Creating request -- Viewing request +Secure public access for: +- Creating request (phone confirmation is mandatory) +- Viewing request status/chat/files by `track_number` ## Flow -1. Send OTP (CREATE_REQUEST / VIEW_REQUEST) +1. Send OTP (`CREATE_REQUEST` / `VIEW_REQUEST`) 2. Store hashed code 3. Expire in 10 minutes 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=` +- Request creation endpoint requires that cookie and then switches cookie to `purpose=VIEW_REQUEST`, `sub=` +- `VIEW_REQUEST` verification issues cookie with `purpose=VIEW_REQUEST` and `sub=` ## Anti-abuse - Rate limit (Redis) - Cooldown between sends -- Lock after N failed attempts \ No newline at end of file +- Lock after N failed attempts +- Throttling by phone + track number + IP diff --git a/context/03_admin_panel_service.md b/context/03_admin_panel_service.md index b9b97c4..528a280 100644 --- a/context/03_admin_panel_service.md +++ b/context/03_admin_panel_service.md @@ -1,17 +1,150 @@ # Admin Panel Service Context ## Roles -- ADMIN: full CRUD + config + SLA + quotes -- LAWYER: work with assigned requests only +- ADMIN: full CRUD + all dictionaries + SLA + users + quotes +- LAWYER: + - see assigned requests + - see unassigned queue + - can manually claim unassigned request ("Take in work") ## Core Features -- Universal table with filters (= != > < >= <= ~) +- Universal table with filters (`= != > < >= <= ~`) - Universal record modal (meta-driven) -- Manual editing of any table -- AuditLog for any CREATE/UPDATE/DELETE +- Manual editing of available entities +- 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 flow is configured per topic +- Base model is linear, but with allowed flow variations (Jira-like) - On any status change: - - All previous messages immutable - - All previous attachments immutable - - Add status_history record \ No newline at end of file + - all previous messages immutable + - all previous attachments immutable + - 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 diff --git a/context/04_files_service.md b/context/04_files_service.md index 5ccb62b..481c3ff 100644 --- a/context/04_files_service.md +++ b/context/04_files_service.md @@ -3,10 +3,31 @@ ## Storage - Self-hosted S3 (MinIO) - Presigned PUT or multipart upload -- Store metadata in attachments table +- Store metadata in `attachments` table ## Rules - Max 25MB per file -- Max 350MB per request -- Immutable after status change -- Download via presigned GET or proxy endpoint \ No newline at end of file +- Max 250MB per request +- Attachments created in previous statuses become immutable after status change +- 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 diff --git a/context/05_sla_auto_assign_service.md b/context/05_sla_auto_assign_service.md index a9e3544..454a562 100644 --- a/context/05_sla_auto_assign_service.md +++ b/context/05_sla_auto_assign_service.md @@ -12,12 +12,59 @@ - cleanup_stale_uploads (daily) ## Auto Assign Logic -- If request unclaimed for 24h -- Match by topic -- Assign to lawyer with lowest active load +- Apply to any request that is still unassigned for 24h +- Candidate selection order: + 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 - First response time - Time in status - Overdue detection -- Telegram notification to group chat \ No newline at end of file +- 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)`. diff --git a/context/08_security_model.md b/context/08_security_model.md index 2413428..091ee0c 100644 --- a/context/08_security_model.md +++ b/context/08_security_model.md @@ -1,7 +1,7 @@ # Security Model Context ## Public -- OTP verification required +- OTP verification required for request creation and request access - JWT in httpOnly cookie (7 days) - Rate limiting - Protection from brute force @@ -12,5 +12,28 @@ - Audit log required ## Data Protection -- Immutable after status change -- All actions logged \ No newline at end of file +- Messages and attachments from previous statuses are immutable after status change +- 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) diff --git a/context/09_metrics_dashboard.md b/context/09_metrics_dashboard.md index 5dfaad5..84aec07 100644 --- a/context/09_metrics_dashboard.md +++ b/context/09_metrics_dashboard.md @@ -6,9 +6,85 @@ - SLA overdue - Avg first response time - 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 - requests - status_history - messages -- sla config \ No newline at end of file +- 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` diff --git a/context/10_development_execution_plan.md b/context/10_development_execution_plan.md new file mode 100644 index 0000000..3209575 --- /dev/null +++ b/context/10_development_execution_plan.md @@ -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`. diff --git a/context/11_test_runbook.md b/context/11_test_runbook.md new file mode 100644 index 0000000..6eb13b4 --- /dev/null +++ b/context/11_test_runbook.md @@ -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`. diff --git a/tests/test_admin_universal_crud.py b/tests/test_admin_universal_crud.py new file mode 100644 index 0000000..389f5be --- /dev/null +++ b/tests/test_admin_universal_crud.py @@ -0,0 +1,1252 @@ +import os +import re +import unittest +from datetime import datetime, timedelta, timezone +from uuid import UUID, uuid4 + +from fastapi.testclient import TestClient +from sqlalchemy import create_engine, delete +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.core.config import settings +from app.core.security import create_jwt, verify_password +from app.db.session import get_db +from app.main import app +from app.models.admin_user import AdminUser +from app.models.admin_user_topic import AdminUserTopic +from app.models.attachment import Attachment +from app.models.audit_log import AuditLog +from app.models.form_field import FormField +from app.models.message import Message +from app.models.notification import Notification +from app.models.quote import Quote +from app.models.request import Request +from app.models.status import Status +from app.models.status_history import StatusHistory +from app.models.topic_data_template import TopicDataTemplate +from app.models.topic import Topic +from app.models.topic_required_field import TopicRequiredField +from app.models.request_data_requirement import RequestDataRequirement +from app.models.topic_status_transition import TopicStatusTransition + + +class AdminUniversalCrudTests(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) + Quote.__table__.create(bind=cls.engine) + FormField.__table__.create(bind=cls.engine) + Request.__table__.create(bind=cls.engine) + Status.__table__.create(bind=cls.engine) + Message.__table__.create(bind=cls.engine) + Attachment.__table__.create(bind=cls.engine) + StatusHistory.__table__.create(bind=cls.engine) + Topic.__table__.create(bind=cls.engine) + TopicRequiredField.__table__.create(bind=cls.engine) + TopicDataTemplate.__table__.create(bind=cls.engine) + RequestDataRequirement.__table__.create(bind=cls.engine) + TopicStatusTransition.__table__.create(bind=cls.engine) + AdminUserTopic.__table__.create(bind=cls.engine) + Notification.__table__.create(bind=cls.engine) + AuditLog.__table__.create(bind=cls.engine) + + @classmethod + def tearDownClass(cls): + AuditLog.__table__.drop(bind=cls.engine) + Notification.__table__.drop(bind=cls.engine) + AdminUserTopic.__table__.drop(bind=cls.engine) + RequestDataRequirement.__table__.drop(bind=cls.engine) + TopicDataTemplate.__table__.drop(bind=cls.engine) + TopicRequiredField.__table__.drop(bind=cls.engine) + TopicStatusTransition.__table__.drop(bind=cls.engine) + Topic.__table__.drop(bind=cls.engine) + StatusHistory.__table__.drop(bind=cls.engine) + Attachment.__table__.drop(bind=cls.engine) + Message.__table__.drop(bind=cls.engine) + Status.__table__.drop(bind=cls.engine) + Request.__table__.drop(bind=cls.engine) + FormField.__table__.drop(bind=cls.engine) + Quote.__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(StatusHistory)) + db.execute(delete(Attachment)) + db.execute(delete(Message)) + db.execute(delete(Request)) + db.execute(delete(Status)) + db.execute(delete(FormField)) + db.execute(delete(Topic)) + db.execute(delete(TopicRequiredField)) + db.execute(delete(TopicDataTemplate)) + db.execute(delete(RequestDataRequirement)) + db.execute(delete(TopicStatusTransition)) + db.execute(delete(AdminUserTopic)) + db.execute(delete(Notification)) + db.execute(delete(Quote)) + 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 _auth_headers(role: str, email: str | None = None, sub: str | None = None) -> dict[str, str]: + token = create_jwt( + {"sub": str(sub or 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_can_crud_quotes_and_audit_is_written(self): + headers = self._auth_headers("ADMIN") + + created = self.client.post( + "/api/admin/crud/quotes", + headers=headers, + json={"author": "Тест", "text": "Цитата", "source": "suite", "is_active": True, "sort_order": 7}, + ) + self.assertEqual(created.status_code, 201) + created_body = created.json() + self.assertEqual(created_body["author"], "Тест") + self.assertEqual(created_body["responsible"], "admin@example.com") + quote_id = created_body["id"] + UUID(quote_id) + + updated = self.client.patch( + f"/api/admin/crud/quotes/{quote_id}", + headers=headers, + json={"text": "Цитата обновлена", "sort_order": 9}, + ) + self.assertEqual(updated.status_code, 200) + self.assertEqual(updated.json()["text"], "Цитата обновлена") + self.assertEqual(updated.json()["responsible"], "admin@example.com") + + got = self.client.get(f"/api/admin/crud/quotes/{quote_id}", headers=headers) + self.assertEqual(got.status_code, 200) + self.assertEqual(got.json()["sort_order"], 9) + + deleted = self.client.delete(f"/api/admin/crud/quotes/{quote_id}", headers=headers) + self.assertEqual(deleted.status_code, 200) + + missing = self.client.get(f"/api/admin/crud/quotes/{quote_id}", headers=headers) + self.assertEqual(missing.status_code, 404) + + with self.SessionLocal() as db: + actions = [row.action for row in db.query(AuditLog).filter(AuditLog.entity == "quotes", AuditLog.entity_id == quote_id).all()] + self.assertEqual(set(actions), {"CREATE", "UPDATE", "DELETE"}) + + def test_lawyer_permissions_and_request_crud(self): + lawyer_headers = self._auth_headers("LAWYER") + + forbidden = self.client.post( + "/api/admin/crud/quotes", + headers=lawyer_headers, + json={"author": "X", "text": "Y"}, + ) + self.assertEqual(forbidden.status_code, 403) + + request_create = self.client.post( + "/api/admin/crud/requests", + headers=lawyer_headers, + json={ + "client_name": "ООО Право", + "client_phone": "+79990000002", + "status_code": "NEW", + "description": "Тест универсального CRUD", + }, + ) + self.assertEqual(request_create.status_code, 201) + body = request_create.json() + self.assertTrue(body["track_number"].startswith("TRK-")) + self.assertEqual(body["responsible"], "lawyer@example.com") + request_id = body["id"] + UUID(request_id) + + query = self.client.post( + "/api/admin/crud/requests/query", + headers=lawyer_headers, + json={"filters": [], "sort": [{"field": "created_at", "dir": "desc"}], "page": {"limit": 50, "offset": 0}}, + ) + self.assertEqual(query.status_code, 200) + self.assertEqual(query.json()["total"], 1) + + status_forbidden = self.client.post( + "/api/admin/crud/statuses/query", + headers=lawyer_headers, + json={"filters": [], "sort": [], "page": {"limit": 50, "offset": 0}}, + ) + self.assertEqual(status_forbidden.status_code, 403) + + def test_request_read_markers_status_update_and_lawyer_open_reset(self): + with self.SessionLocal() as db: + lawyer = AdminUser( + role="LAWYER", + name="Юрист Маркер", + email="lawyer-marker@example.com", + password_hash="hash", + is_active=True, + ) + db.add(lawyer) + db.flush() + request_row = Request( + track_number="TRK-MARK-1", + client_name="Клиент Маркер", + client_phone="+79990009900", + status_code="NEW", + description="markers", + extra_fields={}, + assigned_lawyer_id=str(lawyer.id), + lawyer_has_unread_updates=True, + lawyer_unread_event_type="MESSAGE", + ) + db.add(request_row) + db.commit() + lawyer_id = str(lawyer.id) + request_id = str(request_row.id) + + lawyer_headers = self._auth_headers("LAWYER", email="lawyer-marker@example.com", sub=lawyer_id) + admin_headers = self._auth_headers("ADMIN", email="root@example.com") + + opened = self.client.get(f"/api/admin/crud/requests/{request_id}", headers=lawyer_headers) + self.assertEqual(opened.status_code, 200) + opened_body = opened.json() + self.assertFalse(opened_body["lawyer_has_unread_updates"]) + self.assertIsNone(opened_body["lawyer_unread_event_type"]) + + with self.SessionLocal() as db: + opened_db = db.get(Request, UUID(request_id)) + self.assertIsNotNone(opened_db) + self.assertFalse(opened_db.lawyer_has_unread_updates) + self.assertIsNone(opened_db.lawyer_unread_event_type) + + updated = self.client.patch( + f"/api/admin/crud/requests/{request_id}", + headers=admin_headers, + json={"status_code": "IN_PROGRESS"}, + ) + self.assertEqual(updated.status_code, 200) + updated_body = updated.json() + self.assertTrue(updated_body["client_has_unread_updates"]) + self.assertEqual(updated_body["client_unread_event_type"], "STATUS") + + with self.SessionLocal() as db: + refreshed = db.get(Request, UUID(request_id)) + self.assertIsNotNone(refreshed) + self.assertEqual(refreshed.status_code, "IN_PROGRESS") + self.assertTrue(refreshed.client_has_unread_updates) + self.assertEqual(refreshed.client_unread_event_type, "STATUS") + + def test_topic_status_flow_blocks_disallowed_transitions(self): + headers = self._auth_headers("ADMIN", email="root@example.com") + with self.SessionLocal() as db: + db.add(Topic(code="civil-law", name="Гражданское право", enabled=True, sort_order=1)) + db.add_all( + [ + TopicStatusTransition(topic_code="civil-law", from_status="NEW", to_status="IN_PROGRESS", enabled=True, sort_order=1), + TopicStatusTransition( + topic_code="civil-law", + from_status="IN_PROGRESS", + to_status="WAITING_CLIENT", + enabled=True, + sort_order=2, + ), + ] + ) + req = Request( + track_number="TRK-FLOW-1", + client_name="Клиент Флоу", + client_phone="+79997770011", + topic_code="civil-law", + status_code="NEW", + description="flow", + extra_fields={}, + ) + db.add(req) + db.commit() + request_id = str(req.id) + + allowed = self.client.patch( + f"/api/admin/crud/requests/{request_id}", + headers=headers, + json={"status_code": "IN_PROGRESS"}, + ) + self.assertEqual(allowed.status_code, 200) + + blocked = self.client.patch( + f"/api/admin/crud/requests/{request_id}", + headers=headers, + json={"status_code": "CLOSED"}, + ) + self.assertEqual(blocked.status_code, 400) + self.assertIn("Переход статуса не разрешен", blocked.json().get("detail", "")) + + blocked_legacy = self.client.patch( + f"/api/admin/requests/{request_id}", + headers=headers, + json={"status_code": "CLOSED"}, + ) + self.assertEqual(blocked_legacy.status_code, 400) + self.assertIn("Переход статуса не разрешен", blocked_legacy.json().get("detail", "")) + + def test_topic_without_configured_flow_keeps_backward_compatibility(self): + headers = self._auth_headers("ADMIN", email="root@example.com") + with self.SessionLocal() as db: + db.add(Topic(code="tax-law", name="Налоговое право", enabled=True, sort_order=1)) + req = Request( + track_number="TRK-FLOW-2", + client_name="Клиент Флоу 2", + client_phone="+79997770012", + topic_code="tax-law", + status_code="NEW", + description="flow fallback", + extra_fields={}, + ) + db.add(req) + db.commit() + request_id = str(req.id) + + updated = self.client.patch( + f"/api/admin/crud/requests/{request_id}", + headers=headers, + json={"status_code": "CLOSED"}, + ) + self.assertEqual(updated.status_code, 200) + + def test_admin_can_configure_sla_hours_for_status_transition(self): + headers = self._auth_headers("ADMIN", email="root@example.com") + with self.SessionLocal() as db: + db.add(Topic(code="civil-law", name="Гражданское право", enabled=True, sort_order=1)) + 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.commit() + + created = self.client.post( + "/api/admin/crud/topic_status_transitions", + headers=headers, + json={ + "topic_code": "civil-law", + "from_status": "NEW", + "to_status": "IN_PROGRESS", + "enabled": True, + "sort_order": 1, + "sla_hours": 24, + }, + ) + self.assertEqual(created.status_code, 201) + body = created.json() + self.assertEqual(body["sla_hours"], 24) + row_id = body["id"] + + updated = self.client.patch( + f"/api/admin/crud/topic_status_transitions/{row_id}", + headers=headers, + json={"sla_hours": 12}, + ) + self.assertEqual(updated.status_code, 200) + self.assertEqual(updated.json()["sla_hours"], 12) + + invalid_zero = self.client.patch( + f"/api/admin/crud/topic_status_transitions/{row_id}", + headers=headers, + json={"sla_hours": 0}, + ) + self.assertEqual(invalid_zero.status_code, 400) + + invalid_same_status = self.client.patch( + f"/api/admin/crud/topic_status_transitions/{row_id}", + headers=headers, + json={"to_status": "NEW"}, + ) + self.assertEqual(invalid_same_status.status_code, 400) + + def test_status_change_freezes_previous_messages_and_attachments_and_writes_history(self): + headers = self._auth_headers("ADMIN", email="root@example.com") + with self.SessionLocal() as db: + req = Request( + track_number="TRK-IMM-1", + client_name="Клиент Иммутабельность", + client_phone="+79998880011", + topic_code="civil-law", + status_code="NEW", + description="immutable", + extra_fields={}, + ) + db.add(req) + db.flush() + msg = Message( + request_id=req.id, + author_type="CLIENT", + author_name="Клиент", + body="Первое сообщение", + immutable=False, + ) + att = Attachment( + request_id=req.id, + file_name="old.pdf", + mime_type="application/pdf", + size_bytes=100, + s3_key="requests/old.pdf", + immutable=False, + ) + db.add_all([msg, att]) + db.commit() + request_id = str(req.id) + message_id = str(msg.id) + attachment_id = str(att.id) + + changed = self.client.patch( + f"/api/admin/crud/requests/{request_id}", + headers=headers, + json={"status_code": "IN_PROGRESS"}, + ) + self.assertEqual(changed.status_code, 200) + + with self.SessionLocal() as db: + msg = db.get(Message, UUID(message_id)) + att = db.get(Attachment, UUID(attachment_id)) + self.assertIsNotNone(msg) + self.assertIsNotNone(att) + self.assertTrue(msg.immutable) + self.assertTrue(att.immutable) + history = db.query(StatusHistory).filter(StatusHistory.request_id == UUID(request_id)).all() + self.assertEqual(len(history), 1) + self.assertEqual(history[0].from_status, "NEW") + self.assertEqual(history[0].to_status, "IN_PROGRESS") + + blocked_update = self.client.patch( + f"/api/admin/crud/messages/{message_id}", + headers=headers, + json={"body": "Попытка правки"}, + ) + self.assertEqual(blocked_update.status_code, 400) + self.assertIn("зафиксирована", blocked_update.json().get("detail", "")) + + blocked_delete = self.client.delete(f"/api/admin/crud/attachments/{attachment_id}", headers=headers) + self.assertEqual(blocked_delete.status_code, 400) + self.assertIn("зафиксирована", blocked_delete.json().get("detail", "")) + + def test_legacy_request_patch_also_writes_status_history_and_freezes(self): + headers = self._auth_headers("ADMIN", email="root@example.com") + with self.SessionLocal() as db: + req = Request( + track_number="TRK-IMM-2", + client_name="Клиент Legacy", + client_phone="+79998880012", + topic_code="civil-law", + status_code="NEW", + description="legacy immutable", + extra_fields={}, + ) + db.add(req) + db.flush() + msg = Message( + request_id=req.id, + author_type="LAWYER", + author_name="Юрист", + body="Ответ", + immutable=False, + ) + db.add(msg) + db.commit() + request_id = str(req.id) + message_id = str(msg.id) + + changed = self.client.patch( + f"/api/admin/requests/{request_id}", + headers=headers, + json={"status_code": "IN_PROGRESS"}, + ) + self.assertEqual(changed.status_code, 200) + + with self.SessionLocal() as db: + msg = db.get(Message, UUID(message_id)) + self.assertIsNotNone(msg) + self.assertTrue(msg.immutable) + history = db.query(StatusHistory).filter(StatusHistory.request_id == UUID(request_id)).all() + self.assertEqual(len(history), 1) + self.assertEqual(history[0].from_status, "NEW") + self.assertEqual(history[0].to_status, "IN_PROGRESS") + + def test_lawyer_can_claim_unassigned_request_and_takeover_is_forbidden(self): + with self.SessionLocal() as db: + lawyer1 = AdminUser( + role="LAWYER", + name="Юрист 1", + email="lawyer1@example.com", + password_hash="hash", + is_active=True, + ) + lawyer2 = AdminUser( + role="LAWYER", + name="Юрист 2", + email="lawyer2@example.com", + password_hash="hash", + is_active=True, + ) + request_row = Request( + track_number="TRK-CLAIM-1", + client_name="Клиент", + client_phone="+79991112233", + status_code="NEW", + description="claim test", + extra_fields={}, + assigned_lawyer_id=None, + ) + db.add_all([lawyer1, lawyer2, request_row]) + db.commit() + lawyer1_id = str(lawyer1.id) + lawyer2_id = str(lawyer2.id) + request_id = str(request_row.id) + + headers1 = self._auth_headers("LAWYER", email="lawyer1@example.com", sub=lawyer1_id) + headers2 = self._auth_headers("LAWYER", email="lawyer2@example.com", sub=lawyer2_id) + admin_headers = self._auth_headers("ADMIN", email="root@example.com") + + first = self.client.post(f"/api/admin/requests/{request_id}/claim", headers=headers1) + self.assertEqual(first.status_code, 200) + self.assertEqual(first.json()["assigned_lawyer_id"], lawyer1_id) + + second = self.client.post(f"/api/admin/requests/{request_id}/claim", headers=headers2) + self.assertEqual(second.status_code, 409) + + admin_forbidden = self.client.post(f"/api/admin/requests/{request_id}/claim", headers=admin_headers) + self.assertEqual(admin_forbidden.status_code, 403) + + with self.SessionLocal() as db: + row = db.get(Request, UUID(request_id)) + self.assertIsNotNone(row) + self.assertEqual(row.assigned_lawyer_id, lawyer1_id) + claim_audits = db.query(AuditLog).filter(AuditLog.entity == "requests", AuditLog.entity_id == request_id, AuditLog.action == "MANUAL_CLAIM").all() + self.assertEqual(len(claim_audits), 1) + + def test_lawyer_cannot_assign_request_via_universal_crud(self): + with self.SessionLocal() as db: + lawyer = AdminUser( + role="LAWYER", + name="Юрист", + email="lawyer-assign@example.com", + password_hash="hash", + is_active=True, + ) + request_row = Request( + track_number="TRK-CLAIM-2", + client_name="Клиент", + client_phone="+79994445566", + status_code="NEW", + description="crud assign block", + extra_fields={}, + assigned_lawyer_id=None, + ) + db.add_all([lawyer, request_row]) + db.commit() + lawyer_id = str(lawyer.id) + request_id = str(request_row.id) + + headers = self._auth_headers("LAWYER", email="lawyer-assign@example.com", sub=lawyer_id) + blocked_update = self.client.patch( + f"/api/admin/crud/requests/{request_id}", + headers=headers, + json={"assigned_lawyer_id": lawyer_id}, + ) + self.assertEqual(blocked_update.status_code, 403) + + blocked_create = self.client.post( + "/api/admin/crud/requests", + headers=headers, + json={ + "client_name": "Новый клиент", + "client_phone": "+79990001122", + "status_code": "NEW", + "description": "blocked create assign", + "assigned_lawyer_id": lawyer_id, + }, + ) + self.assertEqual(blocked_create.status_code, 403) + + blocked_update_legacy = self.client.patch( + f"/api/admin/requests/{request_id}", + headers=headers, + json={"assigned_lawyer_id": lawyer_id}, + ) + self.assertEqual(blocked_update_legacy.status_code, 403) + + blocked_create_legacy = self.client.post( + "/api/admin/requests", + headers=headers, + json={ + "client_name": "Legacy клиент", + "client_phone": "+79990001123", + "status_code": "NEW", + "description": "legacy assign block", + "assigned_lawyer_id": lawyer_id, + }, + ) + self.assertEqual(blocked_create_legacy.status_code, 403) + + def test_admin_can_reassign_assigned_request(self): + with self.SessionLocal() as db: + lawyer_from = AdminUser( + role="LAWYER", + name="Юрист Исходный", + email="lawyer-from@example.com", + password_hash="hash", + is_active=True, + ) + lawyer_to = AdminUser( + role="LAWYER", + name="Юрист Целевой", + email="lawyer-to@example.com", + password_hash="hash", + is_active=True, + ) + request_row = Request( + track_number="TRK-REASSIGN-1", + client_name="Клиент", + client_phone="+79993334455", + status_code="NEW", + description="reassign test", + extra_fields={}, + assigned_lawyer_id=None, + ) + db.add_all([lawyer_from, lawyer_to, request_row]) + db.commit() + lawyer_from_id = str(lawyer_from.id) + lawyer_to_id = str(lawyer_to.id) + request_id = str(request_row.id) + + claim_headers = self._auth_headers("LAWYER", email="lawyer-from@example.com", sub=lawyer_from_id) + claimed = self.client.post(f"/api/admin/requests/{request_id}/claim", headers=claim_headers) + self.assertEqual(claimed.status_code, 200) + + admin_headers = self._auth_headers("ADMIN", email="root@example.com") + reassigned = self.client.post( + f"/api/admin/requests/{request_id}/reassign", + headers=admin_headers, + json={"lawyer_id": lawyer_to_id}, + ) + self.assertEqual(reassigned.status_code, 200) + body = reassigned.json() + self.assertEqual(body["from_lawyer_id"], lawyer_from_id) + self.assertEqual(body["assigned_lawyer_id"], lawyer_to_id) + + with self.SessionLocal() as db: + row = db.get(Request, UUID(request_id)) + self.assertIsNotNone(row) + self.assertEqual(row.assigned_lawyer_id, lawyer_to_id) + events = db.query(AuditLog).filter(AuditLog.entity == "requests", AuditLog.entity_id == request_id).all() + actions = [event.action for event in events] + self.assertIn("MANUAL_REASSIGN", actions) + + def test_reassign_is_admin_only_and_validates_request_state(self): + with self.SessionLocal() as db: + lawyer1 = AdminUser( + role="LAWYER", + name="Юрист Один", + email="lawyer-one@example.com", + password_hash="hash", + is_active=True, + ) + lawyer2 = AdminUser( + role="LAWYER", + name="Юрист Два", + email="lawyer-two@example.com", + password_hash="hash", + is_active=True, + ) + db.add_all([lawyer1, lawyer2]) + db.flush() + lawyer1_id = str(lawyer1.id) + lawyer2_id = str(lawyer2.id) + + request_unassigned = Request( + track_number="TRK-REASSIGN-2", + client_name="Клиент", + client_phone="+79995556677", + status_code="NEW", + description="reassign invalid", + extra_fields={}, + assigned_lawyer_id=None, + ) + request_assigned = Request( + track_number="TRK-REASSIGN-3", + client_name="Клиент", + client_phone="+79995556678", + status_code="NEW", + description="reassign invalid same", + extra_fields={}, + assigned_lawyer_id=lawyer1_id, + ) + db.add_all([request_unassigned, request_assigned]) + db.commit() + unassigned_id = str(request_unassigned.id) + assigned_id = str(request_assigned.id) + + admin_headers = self._auth_headers("ADMIN", email="root@example.com") + lawyer_headers = self._auth_headers("LAWYER", email="lawyer-one@example.com", sub=lawyer1_id) + + lawyer_forbidden = self.client.post( + f"/api/admin/requests/{assigned_id}/reassign", + headers=lawyer_headers, + json={"lawyer_id": lawyer2_id}, + ) + self.assertEqual(lawyer_forbidden.status_code, 403) + + unassigned_blocked = self.client.post( + f"/api/admin/requests/{unassigned_id}/reassign", + headers=admin_headers, + json={"lawyer_id": lawyer2_id}, + ) + self.assertEqual(unassigned_blocked.status_code, 400) + + same_lawyer_blocked = self.client.post( + f"/api/admin/requests/{assigned_id}/reassign", + headers=admin_headers, + json={"lawyer_id": lawyer1_id}, + ) + self.assertEqual(same_lawyer_blocked.status_code, 400) + + def test_responsible_is_protected_from_manual_input(self): + headers = self._auth_headers("ADMIN") + response = self.client.post( + "/api/admin/crud/quotes", + headers=headers, + json={"author": "A", "text": "B", "responsible": "hacker@example.com"}, + ) + self.assertEqual(response.status_code, 400) + self.assertIn("Неизвестные поля", response.json().get("detail", "")) + + def test_topic_code_is_autogenerated_when_missing(self): + headers = self._auth_headers("ADMIN") + first = self.client.post( + "/api/admin/crud/topics", + headers=headers, + json={"name": "Семейное право"}, + ) + self.assertEqual(first.status_code, 201) + body1 = first.json() + self.assertTrue(body1.get("code")) + self.assertRegex(body1["code"], r"^[a-z0-9-]+$") + + second = self.client.post( + "/api/admin/crud/topics", + headers=headers, + json={"name": "Семейное право"}, + ) + self.assertEqual(second.status_code, 201) + body2 = second.json() + self.assertTrue(body2.get("code")) + self.assertRegex(body2["code"], r"^[a-z0-9-]+$") + self.assertNotEqual(body1["code"], body2["code"]) + + def test_admin_can_manage_users_with_password_hashing(self): + headers = self._auth_headers("ADMIN", email="root@example.com") + topic_create = self.client.post( + "/api/admin/crud/topics", + headers=headers, + json={"code": "civil-law", "name": "Гражданское право"}, + ) + self.assertEqual(topic_create.status_code, 201) + + created = self.client.post( + "/api/admin/crud/admin_users", + headers=headers, + json={ + "name": "Юрист Тестовый", + "email": "Lawyer.TEST@Example.com", + "role": "LAWYER", + "primary_topic_code": "civil-law", + "avatar_url": "https://cdn.example.com/avatars/lawyer-test.png", + "password": "StartPass-123", + "is_active": True, + }, + ) + self.assertEqual(created.status_code, 201) + body = created.json() + self.assertEqual(body["email"], "lawyer.test@example.com") + self.assertEqual(body["role"], "LAWYER") + self.assertEqual(body["avatar_url"], "https://cdn.example.com/avatars/lawyer-test.png") + self.assertEqual(body["primary_topic_code"], "civil-law") + self.assertNotIn("password_hash", body) + user_id = body["id"] + UUID(user_id) + + with self.SessionLocal() as db: + user = db.get(AdminUser, UUID(user_id)) + self.assertIsNotNone(user) + self.assertTrue(verify_password("StartPass-123", user.password_hash)) + + updated = self.client.patch( + f"/api/admin/crud/admin_users/{user_id}", + headers=headers, + json={"role": "ADMIN", "password": "UpdatedPass-999", "is_active": False, "primary_topic_code": "", "avatar_url": ""}, + ) + self.assertEqual(updated.status_code, 200) + upd_body = updated.json() + self.assertEqual(upd_body["role"], "ADMIN") + self.assertIsNone(upd_body["avatar_url"]) + self.assertIsNone(upd_body["primary_topic_code"]) + self.assertFalse(upd_body["is_active"]) + self.assertNotIn("password_hash", upd_body) + + with self.SessionLocal() as db: + user = db.get(AdminUser, UUID(user_id)) + self.assertIsNotNone(user) + self.assertTrue(verify_password("UpdatedPass-999", user.password_hash)) + self.assertFalse(verify_password("StartPass-123", user.password_hash)) + + q = self.client.post( + "/api/admin/crud/admin_users/query", + headers=headers, + json={"filters": [], "sort": [{"field": "created_at", "dir": "desc"}], "page": {"limit": 50, "offset": 0}}, + ) + self.assertEqual(q.status_code, 200) + self.assertGreaterEqual(q.json()["total"], 1) + self.assertNotIn("password_hash", q.json()["rows"][0]) + + blocked_hash_write = self.client.patch( + f"/api/admin/crud/admin_users/{user_id}", + headers=headers, + json={"password_hash": "forged"}, + ) + self.assertEqual(blocked_hash_write.status_code, 400) + + self_headers = self._auth_headers("ADMIN", email="self@example.com", sub=user_id) + self_delete = self.client.delete(f"/api/admin/crud/admin_users/{user_id}", headers=self_headers) + self.assertEqual(self_delete.status_code, 400) + + deleted = self.client.delete(f"/api/admin/crud/admin_users/{user_id}", headers=headers) + self.assertEqual(deleted.status_code, 200) + + def test_dashboard_metrics_returns_lawyer_loads(self): + headers = self._auth_headers("ADMIN", email="root@example.com") + 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), + ] + ) + lawyer_busy = AdminUser( + role="LAWYER", + name="Юрист Загруженный", + email="busy@example.com", + password_hash="hash", + avatar_url="https://cdn.example.com/a.png", + primary_topic_code="civil-law", + is_active=True, + ) + lawyer_free = AdminUser( + role="LAWYER", + name="Юрист Свободный", + email="free@example.com", + password_hash="hash", + avatar_url=None, + primary_topic_code="family-law", + is_active=True, + ) + db.add_all([lawyer_busy, lawyer_free]) + db.flush() + db.add_all( + [ + Request( + track_number="TRK-METRICS-1", + client_name="Клиент 1", + client_phone="+79990000001", + topic_code="civil-law", + status_code="NEW", + assigned_lawyer_id=str(lawyer_busy.id), + extra_fields={}, + ), + Request( + track_number="TRK-METRICS-2", + client_name="Клиент 2", + client_phone="+79990000002", + topic_code="civil-law", + status_code="CLOSED", + assigned_lawyer_id=str(lawyer_busy.id), + extra_fields={}, + ), + ] + ) + db.commit() + + response = self.client.get("/api/admin/metrics/overview", headers=headers) + self.assertEqual(response.status_code, 200) + body = response.json() + self.assertIn("lawyer_loads", body) + self.assertEqual(len(body["lawyer_loads"]), 2) + + by_email = {row["email"]: row for row in body["lawyer_loads"]} + self.assertEqual(by_email["busy@example.com"]["active_load"], 1) + self.assertEqual(by_email["busy@example.com"]["total_assigned"], 2) + self.assertEqual(by_email["busy@example.com"]["avatar_url"], "https://cdn.example.com/a.png") + self.assertEqual(by_email["free@example.com"]["active_load"], 0) + self.assertEqual(by_email["free@example.com"]["total_assigned"], 0) + + def test_dashboard_metrics_returns_dynamic_sla_and_frt(self): + headers = self._auth_headers("ADMIN", email="root@example.com") + 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=1, is_terminal=True), + ] + ) + + req = Request( + track_number="TRK-SLA-M-1", + client_name="Клиент SLA", + client_phone="+79990000003", + topic_code="civil-law", + status_code="NEW", + extra_fields={}, + created_at=now - timedelta(hours=30), + updated_at=now - timedelta(hours=30), + ) + db.add(req) + db.flush() + db.add( + Message( + request_id=req.id, + author_type="LAWYER", + author_name="Юрист", + body="Ответ", + created_at=req.created_at + timedelta(minutes=20), + updated_at=req.created_at + timedelta(minutes=20), + ) + ) + db.add( + StatusHistory( + request_id=req.id, + from_status=None, + to_status="NEW", + changed_by_admin_id=None, + created_at=now - timedelta(hours=30), + updated_at=now - timedelta(hours=30), + ) + ) + db.commit() + + response = self.client.get("/api/admin/metrics/overview", headers=headers) + self.assertEqual(response.status_code, 200) + body = response.json() + self.assertGreaterEqual(int(body.get("sla_overdue") or 0), 1) + self.assertIsNotNone(body.get("frt_avg_minutes")) + self.assertAlmostEqual(float(body["frt_avg_minutes"]), 20.0, places=1) + self.assertIn("NEW", body.get("avg_time_in_status_hours") or {}) + + def test_admin_can_manage_admin_user_topics_only_for_lawyers(self): + headers = self._auth_headers("ADMIN", email="root@example.com") + with self.SessionLocal() as db: + db.add_all( + [ + Topic(code="civil-law", name="Гражданское право", enabled=True, sort_order=1), + Topic(code="tax-law", name="Налоговое право", enabled=True, sort_order=2), + ] + ) + lawyer = AdminUser( + role="LAWYER", + name="Юрист Профильный", + email="lawyer.topics@example.com", + password_hash="hash", + is_active=True, + ) + admin = AdminUser( + role="ADMIN", + name="Администратор", + email="admin.topics@example.com", + password_hash="hash", + is_active=True, + ) + db.add_all([lawyer, admin]) + db.commit() + lawyer_id = str(lawyer.id) + admin_id = str(admin.id) + + created = self.client.post( + "/api/admin/crud/admin_user_topics", + headers=headers, + json={"admin_user_id": lawyer_id, "topic_code": "civil-law"}, + ) + self.assertEqual(created.status_code, 201) + body = created.json() + self.assertEqual(body["admin_user_id"], lawyer_id) + self.assertEqual(body["topic_code"], "civil-law") + self.assertEqual(body["responsible"], "root@example.com") + relation_id = body["id"] + UUID(relation_id) + + queried = self.client.post( + "/api/admin/crud/admin_user_topics/query", + headers=headers, + json={ + "filters": [{"field": "admin_user_id", "op": "=", "value": lawyer_id}], + "sort": [{"field": "created_at", "dir": "desc"}], + "page": {"limit": 50, "offset": 0}, + }, + ) + self.assertEqual(queried.status_code, 200) + self.assertEqual(queried.json()["total"], 1) + + updated = self.client.patch( + f"/api/admin/crud/admin_user_topics/{relation_id}", + headers=headers, + json={"topic_code": "tax-law"}, + ) + self.assertEqual(updated.status_code, 200) + self.assertEqual(updated.json()["topic_code"], "tax-law") + + forbidden_for_non_lawyer = self.client.post( + "/api/admin/crud/admin_user_topics", + headers=headers, + json={"admin_user_id": admin_id, "topic_code": "civil-law"}, + ) + self.assertEqual(forbidden_for_non_lawyer.status_code, 400) + + deleted = self.client.delete(f"/api/admin/crud/admin_user_topics/{relation_id}", headers=headers) + self.assertEqual(deleted.status_code, 200) + + def test_topic_templates_crud_and_request_required_fields_validation(self): + headers = self._auth_headers("ADMIN", email="root@example.com") + with self.SessionLocal() as db: + db.add(Topic(code="civil-law", name="Гражданское право", enabled=True, sort_order=1)) + db.add( + FormField( + key="passport_series", + label="Серия паспорта", + type="string", + required=False, + enabled=True, + sort_order=1, + ) + ) + db.commit() + + required_created = self.client.post( + "/api/admin/crud/topic_required_fields", + headers=headers, + json={ + "topic_code": "civil-law", + "field_key": "passport_series", + "required": True, + "enabled": True, + "sort_order": 10, + }, + ) + self.assertEqual(required_created.status_code, 201) + self.assertEqual(required_created.json()["responsible"], "root@example.com") + + invalid_required = self.client.post( + "/api/admin/crud/topic_required_fields", + headers=headers, + json={ + "topic_code": "civil-law", + "field_key": "missing_field", + "required": True, + "enabled": True, + "sort_order": 11, + }, + ) + self.assertEqual(invalid_required.status_code, 400) + + template_created = self.client.post( + "/api/admin/crud/topic_data_templates", + headers=headers, + json={ + "topic_code": "civil-law", + "key": "court_file", + "label": "Судебный файл", + "description": "PDF с материалами", + "required": True, + "enabled": True, + "sort_order": 1, + }, + ) + self.assertEqual(template_created.status_code, 201) + self.assertEqual(template_created.json()["topic_code"], "civil-law") + + blocked = self.client.post( + "/api/admin/crud/requests", + headers=headers, + json={ + "client_name": "ООО Проверка", + "client_phone": "+79995550001", + "topic_code": "civil-law", + "status_code": "NEW", + "description": "missing required extra field", + "extra_fields": {}, + }, + ) + self.assertEqual(blocked.status_code, 400) + self.assertIn("passport_series", blocked.json().get("detail", "")) + + created = self.client.post( + "/api/admin/crud/requests", + headers=headers, + json={ + "client_name": "ООО Проверка", + "client_phone": "+79995550001", + "topic_code": "civil-law", + "status_code": "NEW", + "description": "required extra field provided", + "extra_fields": {"passport_series": "1234"}, + }, + ) + self.assertEqual(created.status_code, 201) + request_id = created.json()["id"] + + with self.SessionLocal() as db: + row = db.get(Request, UUID(request_id)) + self.assertIsNotNone(row) + self.assertEqual(row.extra_fields, {"passport_series": "1234"}) + + def test_request_data_template_endpoints_for_assigned_lawyer(self): + headers_admin = self._auth_headers("ADMIN", email="root@example.com") + with self.SessionLocal() as db: + db.add(Topic(code="civil-law", name="Гражданское право", enabled=True, sort_order=1)) + lawyer = AdminUser( + role="LAWYER", + name="Юрист Шаблон", + email="lawyer.template@example.com", + password_hash="hash", + is_active=True, + ) + outsider = AdminUser( + role="LAWYER", + name="Юрист Чужой", + email="lawyer.outside@example.com", + password_hash="hash", + is_active=True, + ) + db.add_all([lawyer, outsider]) + db.flush() + req = Request( + track_number="TRK-TEMPLATE-1", + client_name="Клиент", + client_phone="+79997770013", + topic_code="civil-law", + status_code="IN_PROGRESS", + assigned_lawyer_id=str(lawyer.id), + description="template flow", + extra_fields={}, + ) + db.add(req) + db.flush() + db.add_all( + [ + TopicDataTemplate( + topic_code="civil-law", + key="power_of_attorney", + label="Доверенность", + description="Скан доверенности", + required=True, + enabled=True, + sort_order=1, + ), + TopicDataTemplate( + topic_code="civil-law", + key="claim_copy", + label="Копия иска", + description="Копия заявления", + required=False, + enabled=True, + sort_order=2, + ), + ] + ) + db.commit() + request_id = str(req.id) + lawyer_id = str(lawyer.id) + outsider_id = str(outsider.id) + + headers_lawyer = self._auth_headers("LAWYER", email="lawyer.template@example.com", sub=lawyer_id) + headers_outsider = self._auth_headers("LAWYER", email="lawyer.outside@example.com", sub=outsider_id) + + pre = self.client.get(f"/api/admin/requests/{request_id}/data-template", headers=headers_lawyer) + self.assertEqual(pre.status_code, 200) + self.assertEqual(len(pre.json()["topic_items"]), 2) + self.assertEqual(len(pre.json()["request_items"]), 0) + + sync = self.client.post(f"/api/admin/requests/{request_id}/data-template/sync", headers=headers_lawyer) + self.assertEqual(sync.status_code, 200) + self.assertEqual(sync.json()["created"], 2) + + sync_repeat = self.client.post(f"/api/admin/requests/{request_id}/data-template/sync", headers=headers_lawyer) + self.assertEqual(sync_repeat.status_code, 200) + self.assertEqual(sync_repeat.json()["created"], 0) + + created_custom = self.client.post( + f"/api/admin/requests/{request_id}/data-template/items", + headers=headers_lawyer, + json={ + "key": "additional_scan", + "label": "Дополнительный скан", + "description": "Любой дополнительный файл", + "required": False, + }, + ) + self.assertEqual(created_custom.status_code, 201) + custom_item_id = created_custom.json()["id"] + + updated_custom = self.client.patch( + f"/api/admin/requests/{request_id}/data-template/items/{custom_item_id}", + headers=headers_lawyer, + json={"label": "Дополнительный скан (обновлено)", "required": True}, + ) + self.assertEqual(updated_custom.status_code, 200) + self.assertEqual(updated_custom.json()["label"], "Дополнительный скан (обновлено)") + self.assertTrue(updated_custom.json()["required"]) + + outsider_forbidden = self.client.get(f"/api/admin/requests/{request_id}/data-template", headers=headers_outsider) + self.assertEqual(outsider_forbidden.status_code, 403) + + admin_access = self.client.get(f"/api/admin/requests/{request_id}/data-template", headers=headers_admin) + self.assertEqual(admin_access.status_code, 200) + self.assertEqual(len(admin_access.json()["request_items"]), 3) + + deleted_custom = self.client.delete( + f"/api/admin/requests/{request_id}/data-template/items/{custom_item_id}", + headers=headers_lawyer, + ) + self.assertEqual(deleted_custom.status_code, 200) + + with self.SessionLocal() as db: + count = db.query(RequestDataRequirement).filter(RequestDataRequirement.request_id == UUID(request_id)).count() + self.assertEqual(count, 2) diff --git a/tests/test_auto_assign.py b/tests/test_auto_assign.py new file mode 100644 index 0000000..d315f3e --- /dev/null +++ b/tests/test_auto_assign.py @@ -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") diff --git a/tests/test_dashboard_finance.py b/tests/test_dashboard_finance.py new file mode 100644 index 0000000..24d50b9 --- /dev/null +++ b/tests/test_dashboard_finance.py @@ -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) diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 5cd624f..0ffe383 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -83,6 +83,9 @@ class MigrationTests(unittest.TestCase): "topics", "statuses", "form_fields", + "topic_required_fields", + "topic_data_templates", + "request_data_requirements", "requests", "messages", "attachments", @@ -90,6 +93,9 @@ class MigrationTests(unittest.TestCase): "audit_log", "otp_sessions", "quotes", + "admin_user_topics", + "topic_status_transitions", + "notifications", "alembic_version", } tables = set(self.inspector.get_table_names()) @@ -98,4 +104,70 @@ class MigrationTests(unittest.TestCase): def test_alembic_version_is_set(self): with self.engine.connect() as conn: 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) diff --git a/tests/test_notifications.py b/tests/test_notifications.py new file mode 100644 index 0000000..0525856 --- /dev/null +++ b/tests/test_notifications.py @@ -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) diff --git a/tests/test_public_cabinet.py b/tests/test_public_cabinet.py new file mode 100644 index 0000000..cbaf13b --- /dev/null +++ b/tests/test_public_cabinet.py @@ -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) diff --git a/tests/test_public_requests.py b/tests/test_public_requests.py index d86cf5a..333c95e 100644 --- a/tests/test_public_requests.py +++ b/tests/test_public_requests.py @@ -1,5 +1,7 @@ import os import unittest +from datetime import timedelta +from unittest.mock import patch from uuid import UUID from fastapi.testclient import TestClient @@ -16,8 +18,13 @@ os.environ.setdefault("S3_SECRET_KEY", "test") os.environ.setdefault("S3_BUCKET", "test") 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.models.notification import Notification +from app.models.otp_session import OtpSession from app.models.request import Request +from app.models.topic_required_field import TopicRequiredField class PublicRequestCreateTests(unittest.TestCase): @@ -30,14 +37,23 @@ class PublicRequestCreateTests(unittest.TestCase): ) cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False) 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 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) cls.engine.dispose() def setUp(self): with self.SessionLocal() as db: + db.execute(delete(Notification)) + db.execute(delete(OtpSession)) + db.execute(delete(TopicRequiredField)) db.execute(delete(Request)) db.commit() @@ -55,7 +71,25 @@ class PublicRequestCreateTests(unittest.TestCase): self.client.close() 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 = { "client_name": "ООО Ромашка", "client_phone": "+79990000001", @@ -63,16 +97,16 @@ class PublicRequestCreateTests(unittest.TestCase): "description": "Тестируем создание заявки", "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) - self.assertEqual(response.status_code, 201) body = response.json() - self.assertTrue(body["track_number"].startswith("TRK-")) - self.assertTrue(body["otp_required"]) - self.assertIsNotNone(body["request_id"]) - + self.assertFalse(body["otp_required"]) request_id = UUID(body["request_id"]) with self.SessionLocal() as db: @@ -85,3 +119,128 @@ class PublicRequestCreateTests(unittest.TestCase): self.assertEqual(created.extra_fields, payload["extra_fields"]) self.assertEqual(created.status_code, "NEW") 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-")) diff --git a/tests/test_quotes_seed.py b/tests/test_quotes_seed.py new file mode 100644 index 0000000..15c0bab --- /dev/null +++ b/tests/test_quotes_seed.py @@ -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) diff --git a/tests/test_uploads_s3.py b/tests/test_uploads_s3.py new file mode 100644 index 0000000..4c8d03d --- /dev/null +++ b/tests/test_uploads_s3.py @@ -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) diff --git a/tests/test_worker_maintenance.py b/tests/test_worker_maintenance.py new file mode 100644 index 0000000..552fc0b --- /dev/null +++ b/tests/test_worker_maintenance.py @@ -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) diff --git a/tmp/admin.bundle.js b/tmp/admin.bundle.js new file mode 100644 index 0000000..b25ad2b --- /dev/null +++ b/tmp/admin.bundle.js @@ -0,0 +1,2100 @@ +(() => { + // app/web/admin.jsx + (function() { + const { useCallback, useEffect, useMemo, useRef, useState } = React; + const LS_TOKEN = "admin_access_token"; + const PAGE_SIZE = 50; + const DEFAULT_FORM_FIELD_TYPES = ["string", "text", "number", "boolean", "date"]; + const ALL_OPERATORS = ["=", "!=", ">", "<", ">=", "<=", "~"]; + const OPERATOR_LABELS = { + "=": "=", + "!=": "!=", + ">": ">", + "<": "<", + ">=": ">=", + "<=": "<=", + "~": "~" + }; + const ROLE_LABELS = { + ADMIN: "\u0410\u0434\u043C\u0438\u043D\u0438\u0441\u0442\u0440\u0430\u0442\u043E\u0440", + LAWYER: "\u042E\u0440\u0438\u0441\u0442" + }; + const STATUS_LABELS = { + NEW: "\u041D\u043E\u0432\u0430\u044F", + IN_PROGRESS: "\u0412 \u0440\u0430\u0431\u043E\u0442\u0435", + WAITING_CLIENT: "\u041E\u0436\u0438\u0434\u0430\u043D\u0438\u0435 \u043A\u043B\u0438\u0435\u043D\u0442\u0430", + WAITING_COURT: "\u041E\u0436\u0438\u0434\u0430\u043D\u0438\u0435 \u0441\u0443\u0434\u0430", + RESOLVED: "\u0420\u0435\u0448\u0435\u043D\u0430", + CLOSED: "\u0417\u0430\u043A\u0440\u044B\u0442\u0430", + REJECTED: "\u041E\u0442\u043A\u043B\u043E\u043D\u0435\u043D\u0430" + }; + const REQUEST_UPDATE_EVENT_LABELS = { + MESSAGE: "\u0441\u043E\u043E\u0431\u0449\u0435\u043D\u0438\u0435", + ATTACHMENT: "\u0444\u0430\u0439\u043B", + STATUS: "\u0441\u0442\u0430\u0442\u0443\u0441" + }; + const TABLE_SERVER_CONFIG = { + requests: { + table: "requests", + endpoint: "/api/admin/crud/requests/query", + sort: [{ field: "created_at", dir: "desc" }] + }, + quotes: { + table: "quotes", + endpoint: "/api/admin/crud/quotes/query", + sort: [{ field: "sort_order", dir: "asc" }] + }, + topics: { + table: "topics", + endpoint: "/api/admin/crud/topics/query", + sort: [{ field: "sort_order", dir: "asc" }] + }, + statuses: { + table: "statuses", + endpoint: "/api/admin/crud/statuses/query", + sort: [{ field: "sort_order", dir: "asc" }] + }, + formFields: { + table: "form_fields", + endpoint: "/api/admin/crud/form_fields/query", + sort: [{ field: "sort_order", dir: "asc" }] + }, + topicRequiredFields: { + table: "topic_required_fields", + endpoint: "/api/admin/crud/topic_required_fields/query", + sort: [{ field: "sort_order", dir: "asc" }] + }, + topicDataTemplates: { + table: "topic_data_templates", + endpoint: "/api/admin/crud/topic_data_templates/query", + sort: [{ field: "sort_order", dir: "asc" }] + }, + statusTransitions: { + table: "topic_status_transitions", + endpoint: "/api/admin/crud/topic_status_transitions/query", + sort: [{ field: "sort_order", dir: "asc" }] + }, + users: { + table: "admin_users", + endpoint: "/api/admin/crud/admin_users/query", + sort: [{ field: "created_at", dir: "desc" }] + }, + userTopics: { + table: "admin_user_topics", + endpoint: "/api/admin/crud/admin_user_topics/query", + sort: [{ field: "created_at", dir: "desc" }] + } + }; + const TABLE_MUTATION_CONFIG = Object.fromEntries( + Object.entries(TABLE_SERVER_CONFIG).map(([tableKey, config]) => [ + tableKey, + { + create: "/api/admin/crud/" + config.table, + update: (id) => "/api/admin/crud/" + config.table + "/" + id, + delete: (id) => "/api/admin/crud/" + config.table + "/" + id + } + ]) + ); + function createTableState() { + return { + filters: [], + sort: null, + offset: 0, + total: 0, + showAll: false, + rows: [] + }; + } + function decodeJwtPayload(token) { + try { + const payload = token.split(".")[1] || ""; + const base64 = payload.replace(/-/g, "+").replace(/_/g, "/"); + const json = decodeURIComponent( + atob(base64).split("").map((c) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2)).join("") + ); + return JSON.parse(json); + } catch (_) { + return null; + } + } + function sortByName(items) { + return [...items].sort((a, b) => String(a.name || a.code || "").localeCompare(String(b.name || b.code || ""), "ru")); + } + function roleLabel(role) { + return ROLE_LABELS[role] || role || "-"; + } + function statusLabel(code) { + return STATUS_LABELS[code] || code || "-"; + } + function boolLabel(value) { + return value ? "\u0414\u0430" : "\u041D\u0435\u0442"; + } + function boolFilterLabel(value) { + return value ? "True" : "False"; + } + function fmtDate(value) { + if (!value) return "-"; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString("ru-RU"); + } + function userInitials(name, email) { + const source = String(name || "").trim(); + if (source) { + const parts = source.split(/\s+/).filter(Boolean); + if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase(); + return source.slice(0, 2).toUpperCase(); + } + const mail = String(email || "").trim(); + return (mail.slice(0, 2) || "U").toUpperCase(); + } + function avatarColor(seed) { + const palette = ["#6f8fa9", "#568f7d", "#a07a5c", "#7d6ea9", "#8f6f8f", "#7f8c5a"]; + const text = String(seed || ""); + let hash = 0; + for (let i = 0; i < text.length; i += 1) hash = hash * 31 + text.charCodeAt(i) >>> 0; + return palette[hash % palette.length]; + } + function resolveAvatarSrc(avatarUrl, accessToken) { + const raw = String(avatarUrl || "").trim(); + if (!raw) return ""; + if (raw.startsWith("s3://")) { + const key = raw.slice("s3://".length); + if (!key || !accessToken) return ""; + return "/api/admin/uploads/object/" + encodeURIComponent(key) + "?token=" + encodeURIComponent(accessToken); + } + return raw; + } + function buildUniversalQuery(filters, sort, limit, offset) { + return { + filters: filters || [], + sort: sort || [], + page: { limit: limit ?? PAGE_SIZE, offset: offset ?? 0 } + }; + } + function canAccessSection(role, section) { + if (section === "quotes" || section === "config") return role === "ADMIN"; + return true; + } + function translateApiError(message) { + const direct = { + "Missing auth token": "\u041E\u0442\u0441\u0443\u0442\u0441\u0442\u0432\u0443\u0435\u0442 \u0442\u043E\u043A\u0435\u043D \u0430\u0432\u0442\u043E\u0440\u0438\u0437\u0430\u0446\u0438\u0438", + "Missing bearer token": "\u041E\u0442\u0441\u0443\u0442\u0441\u0442\u0432\u0443\u0435\u0442 \u0442\u043E\u043A\u0435\u043D \u0430\u0432\u0442\u043E\u0440\u0438\u0437\u0430\u0446\u0438\u0438", + "Invalid token": "\u041D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0439 \u0442\u043E\u043A\u0435\u043D", + Forbidden: "\u041D\u0435\u0434\u043E\u0441\u0442\u0430\u0442\u043E\u0447\u043D\u043E \u043F\u0440\u0430\u0432", + "Invalid credentials": "\u041D\u0435\u0432\u0435\u0440\u043D\u044B\u0439 \u043B\u043E\u0433\u0438\u043D \u0438\u043B\u0438 \u043F\u0430\u0440\u043E\u043B\u044C", + "Request not found": "\u0417\u0430\u044F\u0432\u043A\u0430 \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u0430", + "Quote not found": "\u0426\u0438\u0442\u0430\u0442\u0430 \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u0430", + not_found: "\u0417\u0430\u043F\u0438\u0441\u044C \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u0430" + }; + if (direct[message]) return direct[message]; + if (String(message).startsWith("HTTP ")) return "\u041E\u0448\u0438\u0431\u043A\u0430 \u0441\u0435\u0440\u0432\u0435\u0440\u0430 (" + message + ")"; + return message; + } + function getOperatorsForType(type) { + if (type === "number" || type === "date" || type === "datetime") return ["=", "!=", ">", "<", ">=", "<="]; + if (type === "boolean" || type === "reference" || type === "enum") return ["=", "!="]; + return [...ALL_OPERATORS]; + } + function localizeRequestDetails(row) { + return { + ID: row.id || null, + "\u041D\u043E\u043C\u0435\u0440 \u0437\u0430\u044F\u0432\u043A\u0438": row.track_number || null, + \u041A\u043B\u0438\u0435\u043D\u0442: row.client_name || null, + \u0422\u0435\u043B\u0435\u0444\u043E\u043D: row.client_phone || null, + "\u0422\u0435\u043C\u0430 (\u043A\u043E\u0434)": row.topic_code || null, + \u0421\u0442\u0430\u0442\u0443\u0441: statusLabel(row.status_code), + \u041E\u043F\u0438\u0441\u0430\u043D\u0438\u0435: row.description || null, + "\u0414\u043E\u043F\u043E\u043B\u043D\u0438\u0442\u0435\u043B\u044C\u043D\u044B\u0435 \u043F\u043E\u043B\u044F": row.extra_fields || {}, + "\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u043D\u044B\u0439 \u044E\u0440\u0438\u0441\u0442 (ID)": row.assigned_lawyer_id || null, + "\u041D\u0435\u043F\u0440\u043E\u0447\u0438\u0442\u0430\u043D\u043E \u043A\u043B\u0438\u0435\u043D\u0442\u043E\u043C": boolLabel(Boolean(row.client_has_unread_updates)), + "\u0422\u0438\u043F \u043E\u0431\u043D\u043E\u0432\u043B\u0435\u043D\u0438\u044F \u0434\u043B\u044F \u043A\u043B\u0438\u0435\u043D\u0442\u0430": row.client_unread_event_type ? REQUEST_UPDATE_EVENT_LABELS[row.client_unread_event_type] || row.client_unread_event_type : null, + "\u041D\u0435\u043F\u0440\u043E\u0447\u0438\u0442\u0430\u043D\u043E \u044E\u0440\u0438\u0441\u0442\u043E\u043C": boolLabel(Boolean(row.lawyer_has_unread_updates)), + "\u0422\u0438\u043F \u043E\u0431\u043D\u043E\u0432\u043B\u0435\u043D\u0438\u044F \u0434\u043B\u044F \u044E\u0440\u0438\u0441\u0442\u0430": row.lawyer_unread_event_type ? REQUEST_UPDATE_EVENT_LABELS[row.lawyer_unread_event_type] || row.lawyer_unread_event_type : null, + "\u041E\u0431\u0449\u0438\u0439 \u0440\u0430\u0437\u043C\u0435\u0440 \u0432\u043B\u043E\u0436\u0435\u043D\u0438\u0439 (\u0431\u0430\u0439\u0442)": row.total_attachments_bytes ?? 0, + \u0421\u043E\u0437\u0434\u0430\u043D\u043E: fmtDate(row.created_at), + \u041E\u0431\u043D\u043E\u0432\u043B\u0435\u043D\u043E: fmtDate(row.updated_at) + }; + } + function renderRequestUpdatesCell(row, role) { + if (role === "LAWYER") { + const has = Boolean(row.lawyer_has_unread_updates); + const eventType = String(row.lawyer_unread_event_type || "").toUpperCase(); + return has ? /* @__PURE__ */ React.createElement("span", { className: "request-update-chip", title: "\u0415\u0441\u0442\u044C \u043D\u0435\u043F\u0440\u043E\u0447\u0438\u0442\u0430\u043D\u043D\u043E\u0435 \u043E\u0431\u043D\u043E\u0432\u043B\u0435\u043D\u0438\u0435: " + (REQUEST_UPDATE_EVENT_LABELS[eventType] || eventType.toLowerCase()) }, /* @__PURE__ */ React.createElement("span", { className: "request-update-dot" }), REQUEST_UPDATE_EVENT_LABELS[eventType] || "\u043E\u0431\u043D\u043E\u0432\u043B\u0435\u043D\u0438\u0435") : /* @__PURE__ */ React.createElement("span", { className: "request-update-empty" }, "\u043D\u0435\u0442"); + } + const clientHas = Boolean(row.client_has_unread_updates); + const clientType = String(row.client_unread_event_type || "").toUpperCase(); + const lawyerHas = Boolean(row.lawyer_has_unread_updates); + const lawyerType = String(row.lawyer_unread_event_type || "").toUpperCase(); + if (!clientHas && !lawyerHas) return /* @__PURE__ */ React.createElement("span", { className: "request-update-empty" }, "\u043D\u0435\u0442"); + return /* @__PURE__ */ React.createElement("span", { className: "request-updates-stack" }, clientHas ? /* @__PURE__ */ React.createElement("span", { className: "request-update-chip", title: "\u041A\u043B\u0438\u0435\u043D\u0442\u0443: " + (REQUEST_UPDATE_EVENT_LABELS[clientType] || clientType.toLowerCase()) }, /* @__PURE__ */ React.createElement("span", { className: "request-update-dot" }), "\u041A\u043B\u0438\u0435\u043D\u0442: " + (REQUEST_UPDATE_EVENT_LABELS[clientType] || "\u043E\u0431\u043D\u043E\u0432\u043B\u0435\u043D\u0438\u0435")) : null, lawyerHas ? /* @__PURE__ */ React.createElement("span", { className: "request-update-chip", title: "\u042E\u0440\u0438\u0441\u0442\u0443: " + (REQUEST_UPDATE_EVENT_LABELS[lawyerType] || lawyerType.toLowerCase()) }, /* @__PURE__ */ React.createElement("span", { className: "request-update-dot" }), "\u042E\u0440\u0438\u0441\u0442: " + (REQUEST_UPDATE_EVENT_LABELS[lawyerType] || "\u043E\u0431\u043D\u043E\u0432\u043B\u0435\u043D\u0438\u0435")) : null); + } + function localizeMeta(data) { + const fieldTypeMap = { + string: "\u0441\u0442\u0440\u043E\u043A\u0430", + text: "\u0442\u0435\u043A\u0441\u0442", + boolean: "\u0431\u0443\u043B\u0435\u0432\u043E", + number: "\u0447\u0438\u0441\u043B\u043E", + date: "\u0434\u0430\u0442\u0430" + }; + return { + \u0421\u0443\u0449\u043D\u043E\u0441\u0442\u044C: data.entity, + \u041F\u043E\u043B\u044F: (data.fields || []).map((field) => ({ + "\u041A\u043E\u0434 \u043F\u043E\u043B\u044F": field.field_name, + \u041D\u0430\u0437\u0432\u0430\u043D\u0438\u0435: field.label, + \u0422\u0438\u043F: fieldTypeMap[field.type] || field.type, + \u041E\u0431\u044F\u0437\u0430\u0442\u0435\u043B\u044C\u043D\u043E\u0435: boolLabel(field.required), + "\u0422\u043E\u043B\u044C\u043A\u043E \u0447\u0442\u0435\u043D\u0438\u0435": boolLabel(field.read_only), + "\u0420\u0435\u0434\u0430\u043A\u0442\u0438\u0440\u0443\u0435\u043C\u044B\u0435 \u0440\u043E\u043B\u0438": (field.editable_roles || []).map(roleLabel) + })) + }; + } + function StatusLine({ status }) { + return /* @__PURE__ */ React.createElement("p", { className: "status" + (status?.kind ? " " + status.kind : "") }, status?.message || ""); + } + function Section({ active, children, id }) { + return /* @__PURE__ */ React.createElement("section", { className: "section" + (active ? " active" : ""), id }, children); + } + function DataTable({ headers, rows, emptyColspan, renderRow, onSort, sortClause }) { + return /* @__PURE__ */ React.createElement("div", { className: "table-wrap" }, /* @__PURE__ */ React.createElement("table", null, /* @__PURE__ */ React.createElement("thead", null, /* @__PURE__ */ React.createElement("tr", null, headers.map((header) => { + const h = typeof header === "string" ? { key: header, label: header } : header; + const sortable = Boolean(h.sortable && h.field && onSort); + const active = Boolean(sortable && sortClause && sortClause.field === h.field); + const direction = active ? sortClause.dir : ""; + return /* @__PURE__ */ React.createElement( + "th", + { + key: h.key || h.label, + className: sortable ? "sortable-th" : "", + onClick: sortable ? () => onSort(h.field) : void 0, + title: sortable ? "\u041D\u0430\u0436\u043C\u0438\u0442\u0435 \u0434\u043B\u044F \u0441\u043E\u0440\u0442\u0438\u0440\u043E\u0432\u043A\u0438" : void 0 + }, + /* @__PURE__ */ React.createElement("span", { className: sortable ? "sortable-head" : "" }, h.label, sortable ? /* @__PURE__ */ React.createElement("span", { className: "sort-indicator" + (active ? " active" : "") }, direction === "desc" ? "\u2193" : "\u2191") : null) + ); + }))), /* @__PURE__ */ React.createElement("tbody", null, rows.length ? rows.map((row, index) => renderRow(row, index)) : /* @__PURE__ */ React.createElement("tr", null, /* @__PURE__ */ React.createElement("td", { colSpan: emptyColspan }, "\u041D\u0435\u0442 \u0434\u0430\u043D\u043D\u044B\u0445"))))); + } + function TablePager({ tableState, onPrev, onNext, onLoadAll }) { + return /* @__PURE__ */ React.createElement("div", { className: "pager" }, /* @__PURE__ */ React.createElement("div", null, tableState.showAll ? "\u0412\u0441\u0435\u0433\u043E: " + tableState.total + " \u2022 \u043F\u043E\u043A\u0430\u0437\u0430\u043D\u044B \u0432\u0441\u0435 \u0437\u0430\u043F\u0438\u0441\u0438" : "\u0412\u0441\u0435\u0433\u043E: " + tableState.total + " \u2022 \u0441\u043C\u0435\u0449\u0435\u043D\u0438\u0435: " + tableState.offset), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", gap: "0.5rem" } }, /* @__PURE__ */ React.createElement( + "button", + { + className: "btn secondary", + type: "button", + onClick: onLoadAll, + disabled: tableState.total === 0 || tableState.showAll || tableState.rows.length >= tableState.total + }, + "\u0417\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044C \u0432\u0441\u0435 " + tableState.total + ), /* @__PURE__ */ React.createElement("button", { className: "btn secondary", type: "button", onClick: onPrev, disabled: tableState.showAll || tableState.offset <= 0 }, "\u041D\u0430\u0437\u0430\u0434"), /* @__PURE__ */ React.createElement( + "button", + { + className: "btn secondary", + type: "button", + onClick: onNext, + disabled: tableState.showAll || tableState.offset + PAGE_SIZE >= tableState.total + }, + "\u0412\u043F\u0435\u0440\u0435\u0434" + ))); + } + function FilterToolbar({ filters, onOpen, onRemove, onEdit, getChipLabel }) { + return /* @__PURE__ */ React.createElement("div", { className: "filter-toolbar" }, /* @__PURE__ */ React.createElement("div", { className: "filter-chips" }, filters.length ? filters.map((filter, index) => /* @__PURE__ */ React.createElement( + "div", + { + className: "filter-chip", + key: filter.field + filter.op + index, + onClick: () => onEdit(index), + role: "button", + tabIndex: 0, + onKeyDown: (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onEdit(index); + } + }, + title: "\u0420\u0435\u0434\u0430\u043A\u0442\u0438\u0440\u043E\u0432\u0430\u0442\u044C \u0444\u0438\u043B\u044C\u0442\u0440" + }, + /* @__PURE__ */ React.createElement("span", null, getChipLabel(filter)), + /* @__PURE__ */ React.createElement( + "button", + { + type: "button", + "aria-label": "\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u0444\u0438\u043B\u044C\u0442\u0440", + onClick: (event) => { + event.stopPropagation(); + onRemove(index); + } + }, + "\xD7" + ) + )) : /* @__PURE__ */ React.createElement("span", { className: "chip-placeholder" }, "\u0424\u0438\u043B\u044C\u0442\u0440\u044B \u043D\u0435 \u0437\u0430\u0434\u0430\u043D\u044B")), /* @__PURE__ */ React.createElement("div", { className: "filter-action" }, /* @__PURE__ */ React.createElement("button", { className: "btn secondary", type: "button", onClick: onOpen }, "\u0424\u0438\u043B\u044C\u0442\u0440"))); + } + function Overlay({ open, onClose, children, id }) { + return /* @__PURE__ */ React.createElement("div", { className: "overlay" + (open ? " open" : ""), id, onClick: onClose }, children); + } + function IconButton({ icon, tooltip, onClick, tone }) { + return /* @__PURE__ */ React.createElement("button", { className: "icon-btn" + (tone ? " " + tone : ""), type: "button", "data-tooltip": tooltip, onClick, "aria-label": tooltip }, icon); + } + function UserAvatar({ name, email, avatarUrl, accessToken, size = 32 }) { + const [broken, setBroken] = useState(false); + useEffect(() => setBroken(false), [avatarUrl]); + const initials = userInitials(name, email); + const bg = avatarColor(name || email || initials); + const src = resolveAvatarSrc(avatarUrl, accessToken); + const canShowImage = Boolean(src && !broken); + return /* @__PURE__ */ React.createElement("span", { className: "avatar", style: { width: size + "px", height: size + "px", backgroundColor: bg } }, canShowImage ? /* @__PURE__ */ React.createElement("img", { src, alt: name || email || "avatar", onError: () => setBroken(true) }) : /* @__PURE__ */ React.createElement("span", null, initials)); + } + function LoginScreen({ onSubmit, status }) { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const submit = (event) => { + event.preventDefault(); + onSubmit(email, password); + }; + return /* @__PURE__ */ React.createElement("div", { className: "login-screen" }, /* @__PURE__ */ React.createElement("div", { className: "login-card" }, /* @__PURE__ */ React.createElement("h2", null, "\u0412\u0445\u043E\u0434 \u0432 \u0430\u0434\u043C\u0438\u043D-\u043F\u0430\u043D\u0435\u043B\u044C"), /* @__PURE__ */ React.createElement("p", { className: "muted" }, "\u0418\u0441\u043F\u043E\u043B\u044C\u0437\u0443\u0439\u0442\u0435 \u0443\u0447\u0435\u0442\u043D\u0443\u044E \u0437\u0430\u043F\u0438\u0441\u044C \u0430\u0434\u043C\u0438\u043D\u0438\u0441\u0442\u0440\u0430\u0442\u043E\u0440\u0430 \u0438\u043B\u0438 \u044E\u0440\u0438\u0441\u0442\u0430."), /* @__PURE__ */ React.createElement("form", { className: "stack", style: { marginTop: "0.7rem" }, onSubmit: submit }, /* @__PURE__ */ React.createElement("div", { className: "field" }, /* @__PURE__ */ React.createElement("label", { htmlFor: "login-email" }, "\u042D\u043B. \u043F\u043E\u0447\u0442\u0430"), /* @__PURE__ */ React.createElement( + "input", + { + id: "login-email", + type: "email", + required: true, + placeholder: "admin@example.com", + value: email, + onChange: (event) => setEmail(event.target.value) + } + )), /* @__PURE__ */ React.createElement("div", { className: "field" }, /* @__PURE__ */ React.createElement("label", { htmlFor: "login-password" }, "\u041F\u0430\u0440\u043E\u043B\u044C"), /* @__PURE__ */ React.createElement( + "input", + { + id: "login-password", + type: "password", + required: true, + placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022", + value: password, + onChange: (event) => setPassword(event.target.value) + } + )), /* @__PURE__ */ React.createElement("button", { className: "btn", type: "submit" }, "\u0412\u043E\u0439\u0442\u0438"), /* @__PURE__ */ React.createElement(StatusLine, { status })))); + } + function FilterModal({ + open, + tableLabel, + fields, + draft, + status, + onClose, + onFieldChange, + onOpChange, + onValueChange, + onSubmit, + onClear, + getOperators, + getFieldOptions + }) { + if (!open) return null; + const selectedField = fields.find((field) => field.field === draft.field) || fields[0] || null; + const operators = getOperators(selectedField?.type || "text"); + const options = selectedField ? getFieldOptions(selectedField) : []; + return /* @__PURE__ */ React.createElement(Overlay, { open, id: "filter-overlay", onClose: (event) => event.target.id === "filter-overlay" && onClose() }, /* @__PURE__ */ React.createElement("div", { className: "modal", style: { width: "min(560px, 100%)" }, onClick: (event) => event.stopPropagation() }, /* @__PURE__ */ React.createElement("div", { className: "modal-head" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h3", null, "\u0424\u0438\u043B\u044C\u0442\u0440 \u0442\u0430\u0431\u043B\u0438\u0446\u044B"), /* @__PURE__ */ React.createElement("p", { className: "muted", style: { marginTop: "0.35rem" } }, tableLabel ? (draft.editIndex !== null ? "\u0420\u0435\u0434\u0430\u043A\u0442\u0438\u0440\u043E\u0432\u0430\u043D\u0438\u0435 \u0444\u0438\u043B\u044C\u0442\u0440\u0430 \u2022 " : "\u041D\u043E\u0432\u044B\u0439 \u0444\u0438\u043B\u044C\u0442\u0440 \u2022 ") + "\u0422\u0430\u0431\u043B\u0438\u0446\u0430: " + tableLabel : "\u0412\u044B\u0431\u0435\u0440\u0438\u0442\u0435 \u043F\u043E\u043B\u0435, \u043E\u043F\u0435\u0440\u0430\u0442\u043E\u0440 \u0438 \u0437\u043D\u0430\u0447\u0435\u043D\u0438\u0435.")), /* @__PURE__ */ React.createElement("button", { className: "close", type: "button", onClick: onClose }, "\xD7")), /* @__PURE__ */ React.createElement("form", { className: "stack", onSubmit }, /* @__PURE__ */ React.createElement("div", { className: "field" }, /* @__PURE__ */ React.createElement("label", { htmlFor: "filter-field" }, "\u041F\u043E\u043B\u0435"), /* @__PURE__ */ React.createElement("select", { id: "filter-field", value: draft.field, onChange: onFieldChange }, fields.map((field) => /* @__PURE__ */ React.createElement("option", { value: field.field, key: field.field }, field.label)))), /* @__PURE__ */ React.createElement("div", { className: "field" }, /* @__PURE__ */ React.createElement("label", { htmlFor: "filter-op" }, "\u041E\u043F\u0435\u0440\u0430\u0442\u043E\u0440"), /* @__PURE__ */ React.createElement("select", { id: "filter-op", value: draft.op, onChange: onOpChange }, operators.map((op) => /* @__PURE__ */ React.createElement("option", { value: op, key: op }, OPERATOR_LABELS[op])))), /* @__PURE__ */ React.createElement("div", { className: "field" }, /* @__PURE__ */ React.createElement("label", { htmlFor: "filter-value" }, selectedField ? "\u0417\u043D\u0430\u0447\u0435\u043D\u0438\u0435: " + selectedField.label : "\u0417\u043D\u0430\u0447\u0435\u043D\u0438\u0435"), !selectedField || selectedField.type === "text" ? /* @__PURE__ */ React.createElement("input", { id: "filter-value", type: "text", value: draft.rawValue, onChange: onValueChange, placeholder: "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0437\u043D\u0430\u0447\u0435\u043D\u0438\u0435" }) : selectedField.type === "number" ? /* @__PURE__ */ React.createElement("input", { id: "filter-value", type: "number", step: "any", value: draft.rawValue, onChange: onValueChange, placeholder: "\u0427\u0438\u0441\u043B\u043E" }) : selectedField.type === "date" ? /* @__PURE__ */ React.createElement("input", { id: "filter-value", type: "date", value: draft.rawValue, onChange: onValueChange }) : selectedField.type === "boolean" ? /* @__PURE__ */ React.createElement("select", { id: "filter-value", value: draft.rawValue, onChange: onValueChange }, /* @__PURE__ */ React.createElement("option", { value: "true" }, "True"), /* @__PURE__ */ React.createElement("option", { value: "false" }, "False")) : selectedField.type === "reference" || selectedField.type === "enum" ? /* @__PURE__ */ React.createElement("select", { id: "filter-value", value: draft.rawValue, onChange: onValueChange, disabled: !options.length }, !options.length ? /* @__PURE__ */ React.createElement("option", { value: "" }, "\u041D\u0435\u0442 \u0434\u043E\u0441\u0442\u0443\u043F\u043D\u044B\u0445 \u0437\u043D\u0430\u0447\u0435\u043D\u0438\u0439") : options.map((option) => /* @__PURE__ */ React.createElement("option", { value: String(option.value), key: String(option.value) }, option.label))) : /* @__PURE__ */ React.createElement("input", { id: "filter-value", type: "text", value: draft.rawValue, onChange: onValueChange, placeholder: "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0437\u043D\u0430\u0447\u0435\u043D\u0438\u0435" })), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", gap: "0.6rem", flexWrap: "wrap" } }, /* @__PURE__ */ React.createElement("button", { className: "btn", type: "submit" }, "\u0414\u043E\u0431\u0430\u0432\u0438\u0442\u044C/\u0421\u043E\u0445\u0440\u0430\u043D\u0438\u0442\u044C"), /* @__PURE__ */ React.createElement("button", { className: "btn secondary", type: "button", onClick: onClear }, "\u041E\u0447\u0438\u0441\u0442\u0438\u0442\u044C \u0432\u0441\u0435"), /* @__PURE__ */ React.createElement("button", { className: "btn secondary", type: "button", onClick: onClose }, "\u041E\u0442\u043C\u0435\u043D\u0430")), /* @__PURE__ */ React.createElement(StatusLine, { status })))); + } + function ReassignModal({ open, status, options, value, onChange, onClose, onSubmit, trackNumber }) { + if (!open) return null; + return /* @__PURE__ */ React.createElement(Overlay, { open, id: "reassign-overlay", onClose: (event) => event.target.id === "reassign-overlay" && onClose() }, /* @__PURE__ */ React.createElement("div", { className: "modal", style: { width: "min(520px, 100%)" }, onClick: (event) => event.stopPropagation() }, /* @__PURE__ */ React.createElement("div", { className: "modal-head" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h3", null, "\u041F\u0435\u0440\u0435\u043D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u0438\u0435 \u0437\u0430\u044F\u0432\u043A\u0438"), /* @__PURE__ */ React.createElement("p", { className: "muted", style: { marginTop: "0.35rem" } }, trackNumber ? "\u0417\u0430\u044F\u0432\u043A\u0430: " + trackNumber : "\u0412\u044B\u0431\u0435\u0440\u0438\u0442\u0435 \u043D\u043E\u0432\u043E\u0433\u043E \u044E\u0440\u0438\u0441\u0442\u0430")), /* @__PURE__ */ React.createElement("button", { className: "close", type: "button", onClick: onClose }, "\xD7")), /* @__PURE__ */ React.createElement("form", { className: "stack", onSubmit }, /* @__PURE__ */ React.createElement("div", { className: "field" }, /* @__PURE__ */ React.createElement("label", { htmlFor: "reassign-lawyer" }, "\u041D\u043E\u0432\u044B\u0439 \u044E\u0440\u0438\u0441\u0442"), /* @__PURE__ */ React.createElement("select", { id: "reassign-lawyer", value, onChange, disabled: !options.length }, !options.length ? /* @__PURE__ */ React.createElement("option", { value: "" }, "\u041D\u0435\u0442 \u0434\u043E\u0441\u0442\u0443\u043F\u043D\u044B\u0445 \u044E\u0440\u0438\u0441\u0442\u043E\u0432") : options.map((option) => /* @__PURE__ */ React.createElement("option", { value: String(option.value), key: String(option.value) }, option.label)))), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", gap: "0.6rem", flexWrap: "wrap" } }, /* @__PURE__ */ React.createElement("button", { className: "btn", type: "submit", disabled: !value }, "\u0421\u043E\u0445\u0440\u0430\u043D\u0438\u0442\u044C"), /* @__PURE__ */ React.createElement("button", { className: "btn secondary", type: "button", onClick: onClose }, "\u041E\u0442\u043C\u0435\u043D\u0430")), /* @__PURE__ */ React.createElement(StatusLine, { status })))); + } + function RequestModal({ open, jsonText, onClose }) { + if (!open) return null; + return /* @__PURE__ */ React.createElement(Overlay, { open, id: "request-overlay", onClose: (event) => event.target.id === "request-overlay" && onClose() }, /* @__PURE__ */ React.createElement("div", { className: "modal", onClick: (event) => event.stopPropagation() }, /* @__PURE__ */ React.createElement("div", { className: "modal-head" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h3", null, "\u0414\u0435\u0442\u0430\u043B\u0438 \u0437\u0430\u044F\u0432\u043A\u0438"), /* @__PURE__ */ React.createElement("p", { className: "muted", style: { marginTop: "0.35rem" } }, "\u041F\u043E\u0434\u0440\u043E\u0431\u043D\u0430\u044F \u043A\u0430\u0440\u0442\u043E\u0447\u043A\u0430 \u0437\u0430\u044F\u0432\u043A\u0438.")), /* @__PURE__ */ React.createElement("button", { className: "close", type: "button", onClick: onClose }, "\xD7")), /* @__PURE__ */ React.createElement("div", { className: "json" }, jsonText))); + } + function RecordModal({ open, title, fields, form, status, onClose, onChange, onSubmit, onUploadField }) { + if (!open) return null; + const renderField = (field) => { + const value = form[field.key] ?? ""; + const options = typeof field.options === "function" ? field.options() : []; + const id = "record-field-" + field.key; + if (field.type === "textarea" || field.type === "json") { + return /* @__PURE__ */ React.createElement( + "textarea", + { + id, + value, + onChange: (event) => onChange(field.key, event.target.value), + placeholder: field.placeholder || "", + required: Boolean(field.required) + } + ); + } + if (field.type === "boolean") { + return /* @__PURE__ */ React.createElement("select", { id, value, onChange: (event) => onChange(field.key, event.target.value) }, /* @__PURE__ */ React.createElement("option", { value: "true" }, "\u0414\u0430"), /* @__PURE__ */ React.createElement("option", { value: "false" }, "\u041D\u0435\u0442")); + } + if (field.type === "reference" || field.type === "enum") { + return /* @__PURE__ */ React.createElement("select", { id, value, onChange: (event) => onChange(field.key, event.target.value) }, field.optional ? /* @__PURE__ */ React.createElement("option", { value: "" }, "-") : null, options.map((option) => /* @__PURE__ */ React.createElement("option", { value: String(option.value), key: String(option.value) }, option.label))); + } + if (field.uploadScope) { + return /* @__PURE__ */ React.createElement("div", { className: "field-inline" }, /* @__PURE__ */ React.createElement( + "input", + { + id, + type: "text", + value, + onChange: (event) => onChange(field.key, event.target.value), + placeholder: field.placeholder || "", + required: Boolean(field.required) + } + ), /* @__PURE__ */ React.createElement("label", { className: "btn secondary btn-sm", style: { whiteSpace: "nowrap" } }, "\u0417\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044C", /* @__PURE__ */ React.createElement( + "input", + { + type: "file", + accept: field.accept || "*/*", + style: { display: "none" }, + onChange: (event) => { + const file = event.target.files && event.target.files[0]; + if (file && onUploadField) onUploadField(field, file); + event.target.value = ""; + } + } + ))); + } + return /* @__PURE__ */ React.createElement( + "input", + { + id, + type: field.type === "number" ? "number" : field.type === "password" ? "password" : "text", + step: field.type === "number" ? "any" : void 0, + value, + onChange: (event) => onChange(field.key, event.target.value), + placeholder: field.placeholder || "", + required: Boolean(field.required) + } + ); + }; + return /* @__PURE__ */ React.createElement(Overlay, { open, id: "record-overlay", onClose: (event) => event.target.id === "record-overlay" && onClose() }, /* @__PURE__ */ React.createElement("div", { className: "modal", style: { width: "min(760px, 100%)" }, onClick: (event) => event.stopPropagation() }, /* @__PURE__ */ React.createElement("div", { className: "modal-head" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h3", null, title), /* @__PURE__ */ React.createElement("p", { className: "muted", style: { marginTop: "0.35rem" } }, "\u0421\u043E\u0437\u0434\u0430\u043D\u0438\u0435 \u0438 \u0440\u0435\u0434\u0430\u043A\u0442\u0438\u0440\u043E\u0432\u0430\u043D\u0438\u0435 \u0437\u0430\u043F\u0438\u0441\u0438.")), /* @__PURE__ */ React.createElement("button", { className: "close", type: "button", onClick: onClose }, "\xD7")), /* @__PURE__ */ React.createElement("form", { className: "stack", onSubmit }, /* @__PURE__ */ React.createElement("div", { className: "filters", style: { gridTemplateColumns: "repeat(2, minmax(0,1fr))" } }, fields.map((field) => /* @__PURE__ */ React.createElement("div", { className: "field", key: field.key }, /* @__PURE__ */ React.createElement("label", { htmlFor: "record-field-" + field.key }, field.label), renderField(field)))), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", gap: "0.6rem", flexWrap: "wrap" } }, /* @__PURE__ */ React.createElement("button", { className: "btn", type: "submit" }, "\u0421\u043E\u0445\u0440\u0430\u043D\u0438\u0442\u044C"), /* @__PURE__ */ React.createElement("button", { className: "btn secondary", type: "button", onClick: onClose }, "\u041E\u0442\u043C\u0435\u043D\u0430")), /* @__PURE__ */ React.createElement(StatusLine, { status })))); + } + function App() { + const [token, setToken] = useState(""); + const [role, setRole] = useState(""); + const [email, setEmail] = useState(""); + const [activeSection, setActiveSection] = useState("dashboard"); + const [dashboardData, setDashboardData] = useState({ cards: [], byStatus: {}, lawyerLoads: [] }); + const [tables, setTables] = useState({ + requests: createTableState(), + quotes: createTableState(), + topics: createTableState(), + statuses: createTableState(), + formFields: createTableState(), + topicRequiredFields: createTableState(), + topicDataTemplates: createTableState(), + statusTransitions: createTableState(), + users: createTableState(), + userTopics: createTableState() + }); + const [dictionaries, setDictionaries] = useState({ + topics: [], + statuses: Object.entries(STATUS_LABELS).map(([code, name]) => ({ code, name })), + formFieldTypes: [...DEFAULT_FORM_FIELD_TYPES], + formFieldKeys: [], + users: [] + }); + const [statusMap, setStatusMap] = useState({}); + const [requestModal, setRequestModal] = useState({ open: false, jsonText: "" }); + const [recordModal, setRecordModal] = useState({ + open: false, + tableKey: null, + mode: "create", + rowId: null, + form: {} + }); + const [configActiveKey, setConfigActiveKey] = useState("quotes"); + const [referencesExpanded, setReferencesExpanded] = useState(true); + const [metaEntity, setMetaEntity] = useState("quotes"); + const [metaJson, setMetaJson] = useState(""); + const [filterModal, setFilterModal] = useState({ + open: false, + tableKey: null, + field: "", + op: "=", + rawValue: "", + editIndex: null + }); + const [reassignModal, setReassignModal] = useState({ + open: false, + requestId: null, + trackNumber: "", + lawyerId: "" + }); + const tablesRef = useRef(tables); + useEffect(() => { + tablesRef.current = tables; + }, [tables]); + const setStatus = useCallback((key, message, kind) => { + setStatusMap((prev) => ({ ...prev, [key]: { message: message || "", kind: kind || "" } })); + }, []); + const getStatus = useCallback((key) => statusMap[key] || { message: "", kind: "" }, [statusMap]); + const api = useCallback( + async (path, options, tokenOverride) => { + const opts = options || {}; + const authToken = tokenOverride !== void 0 ? tokenOverride : token; + const headers = { "Content-Type": "application/json", ...opts.headers || {} }; + if (opts.auth !== false) { + if (!authToken) throw new Error("\u041E\u0442\u0441\u0443\u0442\u0441\u0442\u0432\u0443\u0435\u0442 \u0442\u043E\u043A\u0435\u043D \u0430\u0432\u0442\u043E\u0440\u0438\u0437\u0430\u0446\u0438\u0438"); + headers.Authorization = "Bearer " + authToken; + } + const response = await fetch(path, { + method: opts.method || "GET", + headers, + body: opts.body ? JSON.stringify(opts.body) : void 0 + }); + const text = await response.text(); + let payload; + try { + payload = text ? JSON.parse(text) : {}; + } catch (_) { + payload = { raw: text }; + } + if (!response.ok) { + const message = payload && (payload.detail || payload.error || payload.raw) || "HTTP " + response.status; + throw new Error(translateApiError(String(message))); + } + return payload; + }, + [token] + ); + const getStatusOptions = useCallback(() => { + return (dictionaries.statuses || []).filter((item) => item && item.code).map((item) => ({ value: item.code, label: (item.name || statusLabel(item.code)) + " (" + item.code + ")" })); + }, [dictionaries.statuses]); + const getTopicOptions = useCallback(() => { + return (dictionaries.topics || []).filter((item) => item && item.code).map((item) => ({ value: item.code, label: (item.name || item.code) + " (" + item.code + ")" })); + }, [dictionaries.topics]); + const getLawyerOptions = useCallback(() => { + return (dictionaries.users || []).filter((item) => item && item.id && String(item.role || "").toUpperCase() === "LAWYER").map((item) => ({ + value: item.id, + label: (item.name || item.email || item.id) + (item.email ? " (" + item.email + ")" : "") + })); + }, [dictionaries.users]); + const getFormFieldTypeOptions = useCallback(() => { + return (dictionaries.formFieldTypes || []).filter(Boolean).map((item) => ({ value: item, label: item })); + }, [dictionaries.formFieldTypes]); + const getFormFieldKeyOptions = useCallback(() => { + return (dictionaries.formFieldKeys || []).filter((item) => item && item.key).map((item) => ({ value: item.key, label: (item.label || item.key) + " (" + item.key + ")" })); + }, [dictionaries.formFieldKeys]); + const getRoleOptions = useCallback(() => { + return Object.entries(ROLE_LABELS).map(([code, label]) => ({ value: code, label: label + " (" + code + ")" })); + }, []); + const getFilterFields = useCallback( + (tableKey) => { + if (tableKey === "requests") { + return [ + { field: "track_number", label: "\u041D\u043E\u043C\u0435\u0440 \u0437\u0430\u044F\u0432\u043A\u0438", type: "text" }, + { field: "client_name", label: "\u041A\u043B\u0438\u0435\u043D\u0442", type: "text" }, + { field: "client_phone", label: "\u0422\u0435\u043B\u0435\u0444\u043E\u043D", type: "text" }, + { field: "status_code", label: "\u0421\u0442\u0430\u0442\u0443\u0441", type: "reference", options: getStatusOptions }, + { field: "topic_code", label: "\u0422\u0435\u043C\u0430", type: "reference", options: getTopicOptions }, + { field: "created_at", label: "\u0414\u0430\u0442\u0430 \u0441\u043E\u0437\u0434\u0430\u043D\u0438\u044F", type: "date" } + ]; + } + if (tableKey === "quotes") { + return [ + { field: "author", label: "\u0410\u0432\u0442\u043E\u0440", type: "text" }, + { field: "text", label: "\u0422\u0435\u043A\u0441\u0442", type: "text" }, + { field: "source", label: "\u0418\u0441\u0442\u043E\u0447\u043D\u0438\u043A", type: "text" }, + { field: "is_active", label: "\u0410\u043A\u0442\u0438\u0432\u043D\u0430", type: "boolean" }, + { field: "sort_order", label: "\u041F\u043E\u0440\u044F\u0434\u043E\u043A", type: "number" }, + { field: "created_at", label: "\u0414\u0430\u0442\u0430 \u0441\u043E\u0437\u0434\u0430\u043D\u0438\u044F", type: "date" } + ]; + } + if (tableKey === "topics") { + return [ + { field: "code", label: "\u041A\u043E\u0434", type: "text" }, + { field: "name", label: "\u041D\u0430\u0437\u0432\u0430\u043D\u0438\u0435", type: "text" }, + { field: "enabled", label: "\u0410\u043A\u0442\u0438\u0432\u043D\u0430", type: "boolean" }, + { field: "sort_order", label: "\u041F\u043E\u0440\u044F\u0434\u043E\u043A", type: "number" } + ]; + } + if (tableKey === "statuses") { + return [ + { field: "code", label: "\u041A\u043E\u0434", type: "text" }, + { field: "name", label: "\u041D\u0430\u0437\u0432\u0430\u043D\u0438\u0435", type: "text" }, + { field: "enabled", label: "\u0410\u043A\u0442\u0438\u0432\u0435\u043D", type: "boolean" }, + { field: "sort_order", label: "\u041F\u043E\u0440\u044F\u0434\u043E\u043A", type: "number" }, + { field: "is_terminal", label: "\u0422\u0435\u0440\u043C\u0438\u043D\u0430\u043B\u044C\u043D\u044B\u0439", type: "boolean" } + ]; + } + if (tableKey === "formFields") { + return [ + { field: "key", label: "\u041A\u043B\u044E\u0447", type: "text" }, + { field: "label", label: "\u041C\u0435\u0442\u043A\u0430", type: "text" }, + { field: "type", label: "\u0422\u0438\u043F", type: "enum", options: getFormFieldTypeOptions }, + { field: "required", label: "\u041E\u0431\u044F\u0437\u0430\u0442\u0435\u043B\u044C\u043D\u043E\u0435", type: "boolean" }, + { field: "enabled", label: "\u0410\u043A\u0442\u0438\u0432\u043D\u043E", type: "boolean" }, + { field: "sort_order", label: "\u041F\u043E\u0440\u044F\u0434\u043E\u043A", type: "number" } + ]; + } + if (tableKey === "topicRequiredFields") { + return [ + { field: "topic_code", label: "\u0422\u0435\u043C\u0430", type: "reference", options: getTopicOptions }, + { field: "field_key", label: "\u041F\u043E\u043B\u0435 \u0444\u043E\u0440\u043C\u044B", type: "reference", options: getFormFieldKeyOptions }, + { field: "required", label: "\u041E\u0431\u044F\u0437\u0430\u0442\u0435\u043B\u044C\u043D\u043E\u0435", type: "boolean" }, + { field: "enabled", label: "\u0410\u043A\u0442\u0438\u0432\u043D\u043E", type: "boolean" }, + { field: "sort_order", label: "\u041F\u043E\u0440\u044F\u0434\u043E\u043A", type: "number" } + ]; + } + if (tableKey === "topicDataTemplates") { + return [ + { field: "topic_code", label: "\u0422\u0435\u043C\u0430", type: "reference", options: getTopicOptions }, + { field: "key", label: "\u041A\u043B\u044E\u0447", type: "text" }, + { field: "label", label: "\u041C\u0435\u0442\u043A\u0430", type: "text" }, + { field: "required", label: "\u041E\u0431\u044F\u0437\u0430\u0442\u0435\u043B\u044C\u043D\u043E\u0435", type: "boolean" }, + { field: "enabled", label: "\u0410\u043A\u0442\u0438\u0432\u043D\u043E", type: "boolean" }, + { field: "sort_order", label: "\u041F\u043E\u0440\u044F\u0434\u043E\u043A", type: "number" }, + { field: "created_at", label: "\u0414\u0430\u0442\u0430 \u0441\u043E\u0437\u0434\u0430\u043D\u0438\u044F", type: "date" } + ]; + } + if (tableKey === "statusTransitions") { + return [ + { field: "topic_code", label: "\u0422\u0435\u043C\u0430", type: "reference", options: getTopicOptions }, + { field: "from_status", label: "\u0418\u0437 \u0441\u0442\u0430\u0442\u0443\u0441\u0430", type: "reference", options: getStatusOptions }, + { field: "to_status", label: "\u0412 \u0441\u0442\u0430\u0442\u0443\u0441", type: "reference", options: getStatusOptions }, + { field: "enabled", label: "\u0410\u043A\u0442\u0438\u0432\u0435\u043D", type: "boolean" }, + { field: "sort_order", label: "\u041F\u043E\u0440\u044F\u0434\u043E\u043A", type: "number" } + ]; + } + if (tableKey === "users") { + return [ + { field: "name", label: "\u0418\u043C\u044F", type: "text" }, + { field: "email", label: "Email", type: "text" }, + { field: "role", label: "\u0420\u043E\u043B\u044C", type: "enum", options: getRoleOptions }, + { field: "primary_topic_code", label: "\u041F\u0440\u043E\u0444\u0438\u043B\u044C (\u0442\u0435\u043C\u0430)", type: "reference", options: getTopicOptions }, + { field: "is_active", label: "\u0410\u043A\u0442\u0438\u0432\u0435\u043D", type: "boolean" }, + { field: "responsible", label: "\u041E\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0435\u043D\u043D\u044B\u0439", type: "text" }, + { field: "created_at", label: "\u0414\u0430\u0442\u0430 \u0441\u043E\u0437\u0434\u0430\u043D\u0438\u044F", type: "date" } + ]; + } + if (tableKey === "userTopics") { + return [ + { field: "admin_user_id", label: "\u042E\u0440\u0438\u0441\u0442", type: "reference", options: getLawyerOptions }, + { field: "topic_code", label: "\u0414\u043E\u043F. \u0442\u0435\u043C\u0430", type: "reference", options: getTopicOptions }, + { field: "responsible", label: "\u041E\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0435\u043D\u043D\u044B\u0439", type: "text" }, + { field: "created_at", label: "\u0414\u0430\u0442\u0430 \u0441\u043E\u0437\u0434\u0430\u043D\u0438\u044F", type: "date" } + ]; + } + return []; + }, + [getFormFieldKeyOptions, getFormFieldTypeOptions, getLawyerOptions, getRoleOptions, getStatusOptions, getTopicOptions] + ); + const getTableLabel = useCallback((tableKey) => { + if (tableKey === "requests") return "\u0417\u0430\u044F\u0432\u043A\u0438"; + if (tableKey === "quotes") return "\u0426\u0438\u0442\u0430\u0442\u044B"; + if (tableKey === "topics") return "\u0422\u0435\u043C\u044B"; + if (tableKey === "statuses") return "\u0421\u0442\u0430\u0442\u0443\u0441\u044B"; + if (tableKey === "formFields") return "\u041F\u043E\u043B\u044F \u0444\u043E\u0440\u043C\u044B"; + if (tableKey === "topicRequiredFields") return "\u041E\u0431\u044F\u0437\u0430\u0442\u0435\u043B\u044C\u043D\u044B\u0435 \u043F\u043E\u043B\u044F \u043F\u043E \u0442\u0435\u043C\u0430\u043C"; + if (tableKey === "topicDataTemplates") return "\u0428\u0430\u0431\u043B\u043E\u043D\u044B \u0434\u043E\u0437\u0430\u043F\u0440\u043E\u0441\u0430 \u043F\u043E \u0442\u0435\u043C\u0430\u043C"; + if (tableKey === "statusTransitions") return "\u041F\u0435\u0440\u0435\u0445\u043E\u0434\u044B \u0441\u0442\u0430\u0442\u0443\u0441\u043E\u0432"; + if (tableKey === "users") return "\u041F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u0435\u043B\u0438"; + if (tableKey === "userTopics") return "\u0414\u043E\u043F\u043E\u043B\u043D\u0438\u0442\u0435\u043B\u044C\u043D\u044B\u0435 \u0442\u0435\u043C\u044B \u044E\u0440\u0438\u0441\u0442\u043E\u0432"; + return "\u0422\u0430\u0431\u043B\u0438\u0446\u0430"; + }, []); + const getRecordFields = useCallback( + (tableKey) => { + if (tableKey === "requests") { + return [ + { key: "track_number", label: "\u041D\u043E\u043C\u0435\u0440 \u0437\u0430\u044F\u0432\u043A\u0438", type: "text", optional: true, placeholder: "\u041E\u0441\u0442\u0430\u0432\u044C\u0442\u0435 \u043F\u0443\u0441\u0442\u044B\u043C \u0434\u043B\u044F \u0430\u0432\u0442\u043E\u0433\u0435\u043D\u0435\u0440\u0430\u0446\u0438\u0438" }, + { key: "client_name", label: "\u041A\u043B\u0438\u0435\u043D\u0442", type: "text", required: true }, + { key: "client_phone", label: "\u0422\u0435\u043B\u0435\u0444\u043E\u043D", type: "text", required: true }, + { key: "topic_code", label: "\u0422\u0435\u043C\u0430", type: "reference", optional: true, options: getTopicOptions }, + { key: "status_code", label: "\u0421\u0442\u0430\u0442\u0443\u0441", type: "reference", required: true, options: getStatusOptions }, + { key: "description", label: "\u041E\u043F\u0438\u0441\u0430\u043D\u0438\u0435", type: "textarea", optional: true }, + { key: "extra_fields", label: "\u0414\u043E\u043F\u043E\u043B\u043D\u0438\u0442\u0435\u043B\u044C\u043D\u044B\u0435 \u043F\u043E\u043B\u044F (JSON)", type: "json", optional: true, defaultValue: "{}" }, + { key: "assigned_lawyer_id", label: "\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u043D\u044B\u0439 \u044E\u0440\u0438\u0441\u0442 (ID)", type: "text", optional: true }, + { key: "total_attachments_bytes", label: "\u0420\u0430\u0437\u043C\u0435\u0440 \u0432\u043B\u043E\u0436\u0435\u043D\u0438\u0439 (\u0431\u0430\u0439\u0442)", type: "number", optional: true, defaultValue: "0" } + ]; + } + if (tableKey === "quotes") { + return [ + { key: "author", label: "\u0410\u0432\u0442\u043E\u0440", type: "text", required: true }, + { key: "text", label: "\u0422\u0435\u043A\u0441\u0442", type: "textarea", required: true }, + { key: "source", label: "\u0418\u0441\u0442\u043E\u0447\u043D\u0438\u043A", type: "text", optional: true }, + { key: "is_active", label: "\u0410\u043A\u0442\u0438\u0432\u043D\u0430", type: "boolean", defaultValue: "true" }, + { key: "sort_order", label: "\u041F\u043E\u0440\u044F\u0434\u043E\u043A", type: "number", defaultValue: "0" } + ]; + } + if (tableKey === "topics") { + return [ + { key: "code", label: "\u041A\u043E\u0434", type: "text", required: true, autoCreate: true }, + { key: "name", label: "\u041D\u0430\u0437\u0432\u0430\u043D\u0438\u0435", type: "text", required: true }, + { key: "enabled", label: "\u0410\u043A\u0442\u0438\u0432\u043D\u0430", type: "boolean", defaultValue: "true" }, + { key: "sort_order", label: "\u041F\u043E\u0440\u044F\u0434\u043E\u043A", type: "number", defaultValue: "0" } + ]; + } + if (tableKey === "statuses") { + return [ + { key: "code", label: "\u041A\u043E\u0434", type: "text", required: true }, + { key: "name", label: "\u041D\u0430\u0437\u0432\u0430\u043D\u0438\u0435", type: "text", required: true }, + { key: "enabled", label: "\u0410\u043A\u0442\u0438\u0432\u0435\u043D", type: "boolean", defaultValue: "true" }, + { key: "sort_order", label: "\u041F\u043E\u0440\u044F\u0434\u043E\u043A", type: "number", defaultValue: "0" }, + { key: "is_terminal", label: "\u0422\u0435\u0440\u043C\u0438\u043D\u0430\u043B\u044C\u043D\u044B\u0439", type: "boolean", defaultValue: "false" } + ]; + } + if (tableKey === "formFields") { + return [ + { key: "key", label: "\u041A\u043B\u044E\u0447", type: "text", required: true }, + { key: "label", label: "\u041C\u0435\u0442\u043A\u0430", type: "text", required: true }, + { key: "type", label: "\u0422\u0438\u043F", type: "enum", required: true, options: getFormFieldTypeOptions }, + { key: "required", label: "\u041E\u0431\u044F\u0437\u0430\u0442\u0435\u043B\u044C\u043D\u043E\u0435", type: "boolean", defaultValue: "false" }, + { key: "enabled", label: "\u0410\u043A\u0442\u0438\u0432\u043D\u043E", type: "boolean", defaultValue: "true" }, + { key: "sort_order", label: "\u041F\u043E\u0440\u044F\u0434\u043E\u043A", type: "number", defaultValue: "0" }, + { key: "options", label: "\u041E\u043F\u0446\u0438\u0438 (JSON)", type: "json", optional: true } + ]; + } + if (tableKey === "topicRequiredFields") { + return [ + { key: "topic_code", label: "\u0422\u0435\u043C\u0430", type: "reference", required: true, options: getTopicOptions }, + { key: "field_key", label: "\u041F\u043E\u043B\u0435 \u0444\u043E\u0440\u043C\u044B", type: "reference", required: true, options: getFormFieldKeyOptions }, + { key: "required", label: "\u041E\u0431\u044F\u0437\u0430\u0442\u0435\u043B\u044C\u043D\u043E\u0435", type: "boolean", defaultValue: "true" }, + { key: "enabled", label: "\u0410\u043A\u0442\u0438\u0432\u043D\u043E", type: "boolean", defaultValue: "true" }, + { key: "sort_order", label: "\u041F\u043E\u0440\u044F\u0434\u043E\u043A", type: "number", defaultValue: "0" } + ]; + } + if (tableKey === "topicDataTemplates") { + return [ + { key: "topic_code", label: "\u0422\u0435\u043C\u0430", type: "reference", required: true, options: getTopicOptions }, + { key: "key", label: "\u041A\u043B\u044E\u0447", type: "text", required: true }, + { key: "label", label: "\u041C\u0435\u0442\u043A\u0430", type: "text", required: true }, + { key: "description", label: "\u041E\u043F\u0438\u0441\u0430\u043D\u0438\u0435", type: "textarea", optional: true }, + { key: "required", label: "\u041E\u0431\u044F\u0437\u0430\u0442\u0435\u043B\u044C\u043D\u043E\u0435", type: "boolean", defaultValue: "true" }, + { key: "enabled", label: "\u0410\u043A\u0442\u0438\u0432\u043D\u043E", type: "boolean", defaultValue: "true" }, + { key: "sort_order", label: "\u041F\u043E\u0440\u044F\u0434\u043E\u043A", type: "number", defaultValue: "0" } + ]; + } + if (tableKey === "statusTransitions") { + return [ + { key: "topic_code", label: "\u0422\u0435\u043C\u0430", type: "reference", required: true, options: getTopicOptions }, + { key: "from_status", label: "\u0418\u0437 \u0441\u0442\u0430\u0442\u0443\u0441\u0430", type: "reference", required: true, options: getStatusOptions }, + { key: "to_status", label: "\u0412 \u0441\u0442\u0430\u0442\u0443\u0441", type: "reference", required: true, options: getStatusOptions }, + { key: "enabled", label: "\u0410\u043A\u0442\u0438\u0432\u0435\u043D", type: "boolean", defaultValue: "true" }, + { key: "sort_order", label: "\u041F\u043E\u0440\u044F\u0434\u043E\u043A", type: "number", defaultValue: "0" } + ]; + } + if (tableKey === "users") { + return [ + { key: "name", label: "\u0418\u043C\u044F", type: "text", required: true }, + { key: "email", label: "Email", type: "text", required: true }, + { key: "role", label: "\u0420\u043E\u043B\u044C", type: "enum", required: true, options: getRoleOptions, defaultValue: "LAWYER" }, + { + key: "avatar_url", + label: "URL \u0430\u0432\u0430\u0442\u0430\u0440\u0430", + type: "text", + optional: true, + placeholder: "https://... \u0438\u043B\u0438 s3://...", + uploadScope: "USER_AVATAR", + accept: "image/*" + }, + { key: "primary_topic_code", label: "\u041F\u0440\u043E\u0444\u0438\u043B\u044C (\u0442\u0435\u043C\u0430)", type: "reference", optional: true, options: getTopicOptions }, + { key: "is_active", label: "\u0410\u043A\u0442\u0438\u0432\u0435\u043D", type: "boolean", defaultValue: "true" }, + { key: "password", label: "\u041F\u0430\u0440\u043E\u043B\u044C", type: "password", requiredOnCreate: true, optional: true, omitIfEmpty: true, placeholder: "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043F\u0430\u0440\u043E\u043B\u044C" } + ]; + } + if (tableKey === "userTopics") { + return [ + { key: "admin_user_id", label: "\u042E\u0440\u0438\u0441\u0442", type: "reference", required: true, options: getLawyerOptions }, + { key: "topic_code", label: "\u0414\u043E\u043F\u043E\u043B\u043D\u0438\u0442\u0435\u043B\u044C\u043D\u0430\u044F \u0442\u0435\u043C\u0430", type: "reference", required: true, options: getTopicOptions } + ]; + } + return []; + }, + [getFormFieldKeyOptions, getFormFieldTypeOptions, getLawyerOptions, getRoleOptions, getStatusOptions, getTopicOptions] + ); + const getFieldDef = useCallback( + (tableKey, fieldName) => { + return getFilterFields(tableKey).find((field) => field.field === fieldName) || null; + }, + [getFilterFields] + ); + const getFieldOptions = useCallback((fieldDef) => { + if (!fieldDef) return []; + if (typeof fieldDef.options === "function") return fieldDef.options() || []; + return []; + }, []); + const getFilterValuePreview = useCallback( + (tableKey, clause) => { + const fieldDef = getFieldDef(tableKey, clause.field); + if (!fieldDef) return String(clause.value ?? ""); + if (fieldDef.type === "boolean") return boolFilterLabel(Boolean(clause.value)); + if (fieldDef.type === "reference" || fieldDef.type === "enum") { + const options = getFieldOptions(fieldDef); + const found = options.find((option) => String(option.value) === String(clause.value)); + return found ? found.label : String(clause.value ?? ""); + } + return String(clause.value ?? ""); + }, + [getFieldDef, getFieldOptions] + ); + const setTableState = useCallback((tableKey, next) => { + setTables((prev) => ({ ...prev, [tableKey]: next })); + }, []); + const loadTable = useCallback( + async (tableKey, options, tokenOverride) => { + const opts = options || {}; + const config = TABLE_SERVER_CONFIG[tableKey]; + if (!config) return false; + const current = tablesRef.current[tableKey] || createTableState(); + const next = { + ...current, + filters: Array.isArray(opts.filtersOverride) ? [...opts.filtersOverride] : [...current.filters || []], + sort: Array.isArray(opts.sortOverride) ? [...opts.sortOverride] : Array.isArray(current.sort) ? [...current.sort] : null, + rows: [...current.rows || []] + }; + if (opts.resetOffset) { + next.offset = 0; + next.showAll = false; + } + if (opts.loadAll) { + next.offset = 0; + next.showAll = true; + } + const statusKey = tableKey; + setStatus(statusKey, "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430...", ""); + try { + const activeSort = next.sort && next.sort.length ? next.sort : config.sort; + let limit = next.showAll ? Math.max(next.total || PAGE_SIZE, PAGE_SIZE) : PAGE_SIZE; + const offset = next.showAll ? 0 : next.offset; + let data = await api( + config.endpoint, + { + method: "POST", + body: buildUniversalQuery(next.filters, activeSort, limit, offset) + }, + tokenOverride + ); + next.total = Number(data.total || 0); + next.rows = data.rows || []; + if (next.showAll && next.total > next.rows.length) { + limit = next.total; + data = await api( + config.endpoint, + { + method: "POST", + body: buildUniversalQuery(next.filters, activeSort, limit, 0) + }, + tokenOverride + ); + next.total = Number(data.total || next.total); + next.rows = data.rows || []; + } + if (!next.showAll && next.total > 0 && next.offset >= next.total) { + next.offset = Math.floor((next.total - 1) / PAGE_SIZE) * PAGE_SIZE; + setTableState(tableKey, next); + return loadTable(tableKey, {}, tokenOverride); + } + setTableState(tableKey, next); + if (tableKey === "requests") { + setDictionaries((prev) => { + const map = new Map((prev.topics || []).map((topic) => [topic.code, topic])); + (next.rows || []).forEach((row) => { + if (!row.topic_code || map.has(row.topic_code)) return; + map.set(row.topic_code, { code: row.topic_code, name: row.topic_code }); + }); + return { ...prev, topics: sortByName(Array.from(map.values())) }; + }); + } + if (tableKey === "topics") { + setDictionaries((prev) => ({ + ...prev, + topics: sortByName((next.rows || []).map((row) => ({ code: row.code, name: row.name || row.code }))) + })); + } + if (tableKey === "statuses") { + setDictionaries((prev) => { + const map = new Map(Object.entries(STATUS_LABELS).map(([code, name]) => [code, { code, name }])); + (next.rows || []).forEach((row) => { + if (!row.code) return; + map.set(row.code, { code: row.code, name: row.name || statusLabel(row.code) }); + }); + return { ...prev, statuses: sortByName(Array.from(map.values())) }; + }); + } + if (tableKey === "formFields") { + setDictionaries((prev) => { + const set = new Set(DEFAULT_FORM_FIELD_TYPES); + (next.rows || []).forEach((row) => { + if (row?.type) set.add(row.type); + }); + const fieldKeys = (next.rows || []).filter((row) => row && row.key).map((row) => ({ key: row.key, label: row.label || row.key })).sort((a, b) => String(a.label || a.key).localeCompare(String(b.label || b.key), "ru")); + return { + ...prev, + formFieldTypes: Array.from(set.values()).sort((a, b) => String(a).localeCompare(String(b), "ru")), + formFieldKeys: fieldKeys + }; + }); + } + if (tableKey === "users") { + setDictionaries((prev) => { + const map = new Map((prev.users || []).map((user) => [user.id, user])); + (next.rows || []).forEach((row) => { + map.set(row.id, { + id: row.id, + name: row.name || "", + email: row.email || "", + role: row.role || "", + is_active: Boolean(row.is_active) + }); + }); + return { ...prev, users: Array.from(map.values()) }; + }); + } + setStatus(statusKey, "\u0421\u043F\u0438\u0441\u043E\u043A \u043E\u0431\u043D\u043E\u0432\u043B\u0435\u043D", "ok"); + return true; + } catch (error) { + setStatus(statusKey, "\u041E\u0448\u0438\u0431\u043A\u0430: " + error.message, "error"); + return false; + } + }, + [api, setStatus, setTableState] + ); + const loadCurrentConfigTable = useCallback( + async (resetOffset, tokenOverride, keyOverride) => { + const currentKey = keyOverride || configActiveKey; + setStatus("config", "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430...", ""); + const ok = await loadTable(currentKey, { resetOffset: Boolean(resetOffset) }, tokenOverride); + if (ok) { + setStatus("config", "\u0421\u043F\u0440\u0430\u0432\u043E\u0447\u043D\u0438\u043A \u043E\u0431\u043D\u043E\u0432\u043B\u0435\u043D", "ok"); + } else { + setStatus("config", "\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043E\u0431\u043D\u043E\u0432\u0438\u0442\u044C \u0441\u043F\u0440\u0430\u0432\u043E\u0447\u043D\u0438\u043A", "error"); + } + }, + [configActiveKey, loadTable, setStatus] + ); + const loadDashboard = useCallback( + async (tokenOverride) => { + setStatus("dashboard", "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430...", ""); + try { + const data = await api("/api/admin/metrics/overview", {}, tokenOverride); + const cards = [ + { label: "\u041D\u043E\u0432\u044B\u0435", value: data.new ?? 0 }, + { label: "\u041F\u0440\u043E\u0441\u0440\u043E\u0447\u0435\u043D\u043E SLA", value: data.sla_overdue ?? 0 }, + { label: "\u0421\u0440\u0435\u0434\u043D\u0438\u0439 FRT (\u043C\u0438\u043D)", value: data.frt_avg_minutes ?? "-" }, + { label: "\u0413\u0440\u0443\u043F\u043F \u043F\u043E \u0441\u0442\u0430\u0442\u0443\u0441\u0430\u043C", value: Object.keys(data.by_status || {}).length } + ]; + const localized = {}; + Object.entries(data.by_status || {}).forEach(([code, count]) => { + localized[statusLabel(code)] = count; + }); + setDashboardData({ cards, byStatus: localized, lawyerLoads: data.lawyer_loads || [] }); + setStatus("dashboard", "\u0414\u0430\u043D\u043D\u044B\u0435 \u043E\u0431\u043D\u043E\u0432\u043B\u0435\u043D\u044B", "ok"); + } catch (error) { + setStatus("dashboard", "\u041E\u0448\u0438\u0431\u043A\u0430: " + error.message, "error"); + } + }, + [api, setStatus] + ); + const loadMeta = useCallback( + async (tokenOverride) => { + const entity = (metaEntity || "quotes").trim() || "quotes"; + setStatus("meta", "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430...", ""); + try { + const data = await api("/api/admin/meta/" + encodeURIComponent(entity), {}, tokenOverride); + setMetaJson(JSON.stringify(localizeMeta(data), null, 2)); + setStatus("meta", "\u041C\u0435\u0442\u0430\u0434\u0430\u043D\u043D\u044B\u0435 \u043F\u043E\u043B\u0443\u0447\u0435\u043D\u044B", "ok"); + } catch (error) { + setStatus("meta", "\u041E\u0448\u0438\u0431\u043A\u0430: " + error.message, "error"); + } + }, + [api, metaEntity, setStatus] + ); + const refreshSection = useCallback( + async (section, tokenOverride) => { + if (!(tokenOverride !== void 0 ? tokenOverride : token)) return; + if (section === "dashboard") return loadDashboard(tokenOverride); + if (section === "requests") return loadTable("requests", {}, tokenOverride); + if (section === "quotes" && canAccessSection(role, "quotes")) return loadTable("quotes", {}, tokenOverride); + if (section === "config" && canAccessSection(role, "config")) return loadCurrentConfigTable(false, tokenOverride); + if (section === "meta") return loadMeta(tokenOverride); + }, + [loadCurrentConfigTable, loadDashboard, loadMeta, loadTable, role, token] + ); + const bootstrapReferenceData = useCallback( + async (tokenOverride, roleOverride) => { + setDictionaries((prev) => ({ + ...prev, + statuses: Object.entries(STATUS_LABELS).map(([code, name]) => ({ code, name })) + })); + if (roleOverride !== "ADMIN") return; + try { + const body = buildUniversalQuery([], [{ field: "sort_order", dir: "asc" }], 500, 0); + const usersBody = buildUniversalQuery([], [{ field: "created_at", dir: "desc" }], 500, 0); + const [topicsData, statusesData, fieldsData, usersData] = await Promise.all([ + api("/api/admin/crud/topics/query", { method: "POST", body }, tokenOverride), + api("/api/admin/crud/statuses/query", { method: "POST", body }, tokenOverride), + api("/api/admin/crud/form_fields/query", { method: "POST", body }, tokenOverride), + api("/api/admin/crud/admin_users/query", { method: "POST", body: usersBody }, tokenOverride) + ]); + const statusesMap = new Map(Object.entries(STATUS_LABELS).map(([code, name]) => [code, { code, name }])); + (statusesData.rows || []).forEach((row) => { + if (!row.code) return; + statusesMap.set(row.code, { code: row.code, name: row.name || statusLabel(row.code) }); + }); + const typeSet = new Set(DEFAULT_FORM_FIELD_TYPES); + (fieldsData.rows || []).forEach((row) => { + if (row?.type) typeSet.add(row.type); + }); + const fieldKeys = (fieldsData.rows || []).filter((row) => row && row.key).map((row) => ({ key: row.key, label: row.label || row.key })).sort((a, b) => String(a.label || a.key).localeCompare(String(b.label || b.key), "ru")); + setDictionaries((prev) => ({ + ...prev, + topics: sortByName((topicsData.rows || []).map((row) => ({ code: row.code, name: row.name || row.code }))), + statuses: sortByName(Array.from(statusesMap.values())), + formFieldTypes: Array.from(typeSet.values()).sort((a, b) => String(a).localeCompare(String(b), "ru")), + formFieldKeys: fieldKeys, + users: (usersData.rows || []).map((row) => ({ + id: row.id, + name: row.name || "", + email: row.email || "", + role: row.role || "", + is_active: Boolean(row.is_active) + })) + })); + } catch (_) { + } + }, + [api] + ); + const openRequestDetails = useCallback( + async (requestId) => { + setRequestModal({ open: true, jsonText: "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430..." }); + try { + const row = await api("/api/admin/crud/requests/" + requestId); + setRequestModal({ open: true, jsonText: JSON.stringify(localizeRequestDetails(row), null, 2) }); + } catch (error) { + setRequestModal({ open: true, jsonText: "\u041E\u0448\u0438\u0431\u043A\u0430: " + error.message }); + } + }, + [api] + ); + const openCreateRecordModal = useCallback( + (tableKey) => { + const fields = getRecordFields(tableKey); + const initial = {}; + fields.forEach((field) => { + if (field.defaultValue !== void 0) initial[field.key] = String(field.defaultValue); + else if (field.type === "boolean") initial[field.key] = "false"; + else if (field.type === "json") initial[field.key] = field.optional ? "" : "{}"; + else if ((field.type === "reference" || field.type === "enum") && !field.optional) { + const options = typeof field.options === "function" ? field.options() : []; + initial[field.key] = options.length ? String(options[0].value) : ""; + } else initial[field.key] = ""; + }); + if (tableKey === "requests" && !initial.status_code) initial.status_code = "NEW"; + setRecordModal({ open: true, tableKey, mode: "create", rowId: null, form: initial }); + setStatus("recordForm", "", ""); + }, + [getRecordFields, setStatus] + ); + const openEditRecordModal = useCallback( + (tableKey, row) => { + const fields = getRecordFields(tableKey); + const nextForm = {}; + fields.forEach((field) => { + const value = row[field.key]; + if (field.type === "boolean") nextForm[field.key] = value ? "true" : "false"; + else if (field.type === "json") nextForm[field.key] = value == null ? "" : JSON.stringify(value, null, 2); + else nextForm[field.key] = value == null ? "" : String(value); + }); + setRecordModal({ open: true, tableKey, mode: "edit", rowId: row.id, form: nextForm }); + setStatus("recordForm", "", ""); + }, + [getRecordFields, setStatus] + ); + const closeRecordModal = useCallback(() => { + setRecordModal({ open: false, tableKey: null, mode: "create", rowId: null, form: {} }); + setStatus("recordForm", "", ""); + }, [setStatus]); + const updateRecordField = useCallback((field, value) => { + setRecordModal((prev) => ({ ...prev, form: { ...prev.form || {}, [field]: value } })); + }, []); + const uploadRecordFieldFile = useCallback( + async (field, file) => { + if (!recordModal.tableKey || !field || !file) return; + if (field.uploadScope !== "USER_AVATAR") return; + if (recordModal.tableKey !== "users") return; + if (recordModal.mode !== "edit" || !recordModal.rowId) { + setStatus("recordForm", "\u0421\u043D\u0430\u0447\u0430\u043B\u0430 \u0441\u043E\u0445\u0440\u0430\u043D\u0438\u0442\u0435 \u043F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u0435\u043B\u044F, \u0437\u0430\u0442\u0435\u043C \u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u0435 \u0430\u0432\u0430\u0442\u0430\u0440", "error"); + return; + } + try { + setStatus("recordForm", "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430 \u0444\u0430\u0439\u043B\u0430...", ""); + const mimeType = String(file.type || "application/octet-stream"); + const initPayload = { + file_name: file.name, + mime_type: mimeType, + size_bytes: file.size, + scope: "USER_AVATAR", + user_id: recordModal.rowId + }; + const init = await api("/api/admin/uploads/init", { method: "POST", body: initPayload }); + const putResp = await fetch(init.presigned_url, { + method: "PUT", + headers: { "Content-Type": mimeType }, + body: file + }); + if (!putResp.ok) { + throw new Error("\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044C \u0444\u0430\u0439\u043B \u0432 \u0445\u0440\u0430\u043D\u0438\u043B\u0438\u0449\u0435"); + } + const done = await api("/api/admin/uploads/complete", { + method: "POST", + body: { + key: init.key, + file_name: file.name, + mime_type: mimeType, + size_bytes: file.size, + scope: "USER_AVATAR", + user_id: recordModal.rowId + } + }); + updateRecordField("avatar_url", String(done.avatar_url || "")); + setStatus("recordForm", "\u0410\u0432\u0430\u0442\u0430\u0440 \u0437\u0430\u0433\u0440\u0443\u0436\u0435\u043D", "ok"); + } catch (error) { + setStatus("recordForm", "\u041E\u0448\u0438\u0431\u043A\u0430 \u0437\u0430\u0433\u0440\u0443\u0437\u043A\u0438: " + error.message, "error"); + } + }, + [api, recordModal, setStatus, updateRecordField] + ); + const buildRecordPayload = useCallback( + (tableKey, form, mode) => { + const fields = getRecordFields(tableKey); + const payload = {}; + fields.forEach((field) => { + const raw = form[field.key]; + if (field.type === "boolean") { + payload[field.key] = raw === "true"; + return; + } + if (field.type === "number") { + if (raw === "" || raw == null) { + if (!field.optional) payload[field.key] = 0; + return; + } + const number = Number(raw); + if (Number.isNaN(number)) throw new Error('\u041D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u043E\u0435 \u0447\u0438\u0441\u043B\u043E \u0432 \u043F\u043E\u043B\u0435 "' + field.label + '"'); + payload[field.key] = number; + return; + } + if (field.type === "json") { + const text = String(raw || "").trim(); + if (!text) { + if (field.optional) payload[field.key] = null; + else payload[field.key] = {}; + return; + } + try { + payload[field.key] = JSON.parse(text); + } catch (_) { + throw new Error('\u041F\u043E\u043B\u0435 "' + field.label + '" \u0434\u043E\u043B\u0436\u043D\u043E \u0431\u044B\u0442\u044C \u0432\u0430\u043B\u0438\u0434\u043D\u044B\u043C JSON'); + } + return; + } + const value = String(raw || "").trim(); + if (!value) { + if (mode === "create" && field.autoCreate) return; + if (mode === "create" && field.requiredOnCreate) throw new Error('\u0417\u0430\u043F\u043E\u043B\u043D\u0438\u0442\u0435 \u043F\u043E\u043B\u0435 "' + field.label + '"'); + if (field.required) throw new Error('\u0417\u0430\u043F\u043E\u043B\u043D\u0438\u0442\u0435 \u043F\u043E\u043B\u0435 "' + field.label + '"'); + if (field.omitIfEmpty) return; + if (tableKey === "requests" && field.key === "track_number") return; + if (field.optional) payload[field.key] = null; + return; + } + payload[field.key] = value; + }); + if (tableKey === "requests" && !payload.extra_fields) payload.extra_fields = {}; + return payload; + }, + [getRecordFields] + ); + const submitRecordModal = useCallback( + async (event) => { + event.preventDefault(); + const tableKey = recordModal.tableKey; + if (!tableKey) return; + const endpoints = TABLE_MUTATION_CONFIG[tableKey]; + if (!endpoints) return; + try { + setStatus("recordForm", "\u0421\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0438\u0435...", ""); + const payload = buildRecordPayload(tableKey, recordModal.form || {}, recordModal.mode); + if (recordModal.mode === "edit" && recordModal.rowId) { + await api(endpoints.update(recordModal.rowId), { method: "PATCH", body: payload }); + } else { + await api(endpoints.create, { method: "POST", body: payload }); + } + setStatus("recordForm", "\u0421\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u043E", "ok"); + await loadTable(tableKey, { resetOffset: true }); + setTimeout(() => closeRecordModal(), 250); + } catch (error) { + setStatus("recordForm", "\u041E\u0448\u0438\u0431\u043A\u0430: " + error.message, "error"); + } + }, + [api, buildRecordPayload, closeRecordModal, loadTable, recordModal, setStatus] + ); + const deleteRecord = useCallback( + async (tableKey, id) => { + const endpoints = TABLE_MUTATION_CONFIG[tableKey]; + if (!endpoints) return; + if (!confirm("\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u0437\u0430\u043F\u0438\u0441\u044C?")) return; + try { + await api(endpoints.delete(id), { method: "DELETE" }); + setStatus(tableKey, "\u0417\u0430\u043F\u0438\u0441\u044C \u0443\u0434\u0430\u043B\u0435\u043D\u0430", "ok"); + await loadTable(tableKey, { resetOffset: true }); + } catch (error) { + setStatus(tableKey, "\u041E\u0448\u0438\u0431\u043A\u0430 \u0443\u0434\u0430\u043B\u0435\u043D\u0438\u044F: " + error.message, "error"); + } + }, + [api, loadTable, setStatus] + ); + const claimRequest = useCallback( + async (requestId) => { + if (!requestId) return; + try { + setStatus("requests", "\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u0438\u0435 \u0437\u0430\u044F\u0432\u043A\u0438...", ""); + await api("/api/admin/requests/" + requestId + "/claim", { method: "POST" }); + setStatus("requests", "\u0417\u0430\u044F\u0432\u043A\u0430 \u0432\u0437\u044F\u0442\u0430 \u0432 \u0440\u0430\u0431\u043E\u0442\u0443", "ok"); + await loadTable("requests", { resetOffset: true }); + } catch (error) { + setStatus("requests", "\u041E\u0448\u0438\u0431\u043A\u0430 \u043D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u0438\u044F: " + error.message, "error"); + } + }, + [api, loadTable, setStatus] + ); + const openReassignModal = useCallback( + (row) => { + const options = getLawyerOptions(); + if (!options.length) { + setStatus("reassignForm", "\u041D\u0435\u0442 \u0434\u043E\u0441\u0442\u0443\u043F\u043D\u044B\u0445 \u044E\u0440\u0438\u0441\u0442\u043E\u0432 \u0434\u043B\u044F \u043F\u0435\u0440\u0435\u043D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u0438\u044F", "error"); + return; + } + const current = String(row?.assigned_lawyer_id || ""); + const hasCurrent = options.some((option) => String(option.value) === current); + const fallback = options[0] ? String(options[0].value) : ""; + setReassignModal({ + open: true, + requestId: row?.id || null, + trackNumber: row?.track_number || "", + lawyerId: hasCurrent ? current : fallback + }); + setStatus("reassignForm", "", ""); + }, + [getLawyerOptions, setStatus] + ); + const closeReassignModal = useCallback(() => { + setReassignModal({ open: false, requestId: null, trackNumber: "", lawyerId: "" }); + setStatus("reassignForm", "", ""); + }, [setStatus]); + const updateReassignLawyer = useCallback((event) => { + setReassignModal((prev) => ({ ...prev, lawyerId: event.target.value })); + }, []); + const submitReassignModal = useCallback( + async (event) => { + event.preventDefault(); + if (!reassignModal.requestId) return; + const lawyerId = String(reassignModal.lawyerId || "").trim(); + if (!lawyerId) { + setStatus("reassignForm", "\u0412\u044B\u0431\u0435\u0440\u0438\u0442\u0435 \u044E\u0440\u0438\u0441\u0442\u0430", "error"); + return; + } + try { + setStatus("reassignForm", "\u0421\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0438\u0435...", ""); + await api("/api/admin/requests/" + reassignModal.requestId + "/reassign", { + method: "POST", + body: { lawyer_id: lawyerId } + }); + setStatus("requests", "\u0417\u0430\u044F\u0432\u043A\u0430 \u043F\u0435\u0440\u0435\u043D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u0430", "ok"); + closeReassignModal(); + await loadTable("requests", { resetOffset: true }); + } catch (error) { + setStatus("reassignForm", "\u041E\u0448\u0438\u0431\u043A\u0430: " + error.message, "error"); + } + }, + [api, closeReassignModal, loadTable, reassignModal.lawyerId, reassignModal.requestId, setStatus] + ); + const defaultFilterValue = useCallback( + (fieldDef) => { + if (!fieldDef) return ""; + if (fieldDef.type === "boolean") return "true"; + if (fieldDef.type === "reference" || fieldDef.type === "enum") { + const options = getFieldOptions(fieldDef); + return options.length ? String(options[0].value) : ""; + } + return ""; + }, + [getFieldOptions] + ); + const openFilterModal = useCallback( + (tableKey) => { + const fields = getFilterFields(tableKey); + if (!fields.length) { + setStatus("filter", "\u0414\u043B\u044F \u0442\u0430\u0431\u043B\u0438\u0446\u044B \u043D\u0435\u0442 \u0434\u043E\u0441\u0442\u0443\u043F\u043D\u044B\u0445 \u043F\u043E\u043B\u0435\u0439 \u0444\u0438\u043B\u044C\u0442\u0440\u0430\u0446\u0438\u0438", "error"); + return; + } + const firstField = fields[0]; + const firstOp = getOperatorsForType(firstField.type)[0] || "="; + setFilterModal({ + open: true, + tableKey, + field: firstField.field, + op: firstOp, + rawValue: defaultFilterValue(firstField), + editIndex: null + }); + setStatus("filter", "", ""); + }, + [defaultFilterValue, getFilterFields, setStatus] + ); + const openFilterEditModal = useCallback( + (tableKey, index) => { + const tableState = tablesRef.current[tableKey] || createTableState(); + const target = (tableState.filters || [])[index]; + if (!target) return; + const fieldDef = getFieldDef(tableKey, target.field); + if (!fieldDef) return; + const allowedOps = getOperatorsForType(fieldDef.type); + const safeOp = allowedOps.includes(target.op) ? target.op : allowedOps[0] || "="; + const rawValue = fieldDef.type === "boolean" ? target.value ? "true" : "false" : String(target.value ?? ""); + setFilterModal({ + open: true, + tableKey, + field: fieldDef.field, + op: safeOp, + rawValue, + editIndex: index + }); + setStatus("filter", "", ""); + }, + [getFieldDef, setStatus] + ); + const closeFilterModal = useCallback(() => { + setFilterModal((prev) => ({ ...prev, open: false, editIndex: null })); + setStatus("filter", "", ""); + }, [setStatus]); + const updateFilterField = useCallback( + (event) => { + const fieldName = event.target.value; + const fields = getFilterFields(filterModal.tableKey); + const fieldDef = fields.find((field) => field.field === fieldName) || null; + if (!fieldDef) return; + const defaultOp = getOperatorsForType(fieldDef.type)[0] || "="; + setFilterModal((prev) => ({ + ...prev, + field: fieldName, + op: defaultOp, + rawValue: defaultFilterValue(fieldDef) + })); + }, + [defaultFilterValue, filterModal.tableKey, getFilterFields] + ); + const updateFilterOp = useCallback((event) => { + const op = event.target.value; + setFilterModal((prev) => ({ ...prev, op })); + }, []); + const updateFilterValue = useCallback((event) => { + setFilterModal((prev) => ({ ...prev, rawValue: event.target.value })); + }, []); + const applyFilterModal = useCallback( + async (event) => { + event.preventDefault(); + if (!filterModal.tableKey) return; + const fieldDef = getFieldDef(filterModal.tableKey, filterModal.field); + if (!fieldDef) { + setStatus("filter", "\u041F\u043E\u043B\u0435 \u0444\u0438\u043B\u044C\u0442\u0440\u0430 \u043D\u0435 \u0432\u044B\u0431\u0440\u0430\u043D\u043E", "error"); + return; + } + let value; + if (fieldDef.type === "boolean") { + value = filterModal.rawValue === "true"; + } else if (fieldDef.type === "number") { + if (String(filterModal.rawValue || "").trim() === "") { + setStatus("filter", "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0447\u0438\u0441\u043B\u043E", "error"); + return; + } + value = Number(filterModal.rawValue); + if (Number.isNaN(value)) { + setStatus("filter", "\u041D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u043E\u0435 \u0447\u0438\u0441\u043B\u043E", "error"); + return; + } + } else { + value = String(filterModal.rawValue || "").trim(); + if (!value) { + setStatus("filter", "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0437\u043D\u0430\u0447\u0435\u043D\u0438\u0435 \u0444\u0438\u043B\u044C\u0442\u0440\u0430", "error"); + return; + } + } + const tableState = tablesRef.current[filterModal.tableKey] || createTableState(); + const nextFilters = [...tableState.filters || []]; + const nextClause = { field: fieldDef.field, op: filterModal.op, value }; + if (Number.isInteger(filterModal.editIndex) && filterModal.editIndex >= 0 && filterModal.editIndex < nextFilters.length) { + nextFilters[filterModal.editIndex] = nextClause; + } else { + const existingIndex = nextFilters.findIndex((item) => item.field === nextClause.field && item.op === nextClause.op); + if (existingIndex >= 0) nextFilters[existingIndex] = nextClause; + else nextFilters.push(nextClause); + } + setTableState(filterModal.tableKey, { + ...tableState, + filters: nextFilters, + offset: 0, + showAll: false + }); + closeFilterModal(); + await loadTable(filterModal.tableKey, { resetOffset: true, filtersOverride: nextFilters }); + }, + [closeFilterModal, filterModal, getFieldDef, loadTable, setStatus, setTableState] + ); + const clearFiltersFromModal = useCallback(async () => { + if (!filterModal.tableKey) return; + const tableState = tablesRef.current[filterModal.tableKey] || createTableState(); + setTableState(filterModal.tableKey, { + ...tableState, + filters: [], + offset: 0, + showAll: false + }); + closeFilterModal(); + await loadTable(filterModal.tableKey, { resetOffset: true, filtersOverride: [] }); + }, [closeFilterModal, filterModal.tableKey, loadTable, setTableState]); + const removeFilterChip = useCallback( + async (tableKey, index) => { + const tableState = tablesRef.current[tableKey] || createTableState(); + const nextFilters = [...tableState.filters || []]; + nextFilters.splice(index, 1); + setTableState(tableKey, { + ...tableState, + filters: nextFilters, + offset: 0, + showAll: false + }); + await loadTable(tableKey, { resetOffset: true, filtersOverride: nextFilters }); + }, + [loadTable, setTableState] + ); + const loadPrevPage = useCallback( + (tableKey) => { + const tableState = tablesRef.current[tableKey] || createTableState(); + const next = { ...tableState, offset: Math.max(0, tableState.offset - PAGE_SIZE), showAll: false }; + setTableState(tableKey, next); + loadTable(tableKey, {}); + }, + [loadTable, setTableState] + ); + const loadNextPage = useCallback( + (tableKey) => { + const tableState = tablesRef.current[tableKey] || createTableState(); + if (tableState.offset + PAGE_SIZE >= tableState.total) return; + const next = { ...tableState, offset: tableState.offset + PAGE_SIZE, showAll: false }; + setTableState(tableKey, next); + loadTable(tableKey, {}); + }, + [loadTable, setTableState] + ); + const loadAllRows = useCallback( + (tableKey) => { + const tableState = tablesRef.current[tableKey] || createTableState(); + if (!tableState.total) return; + const next = { ...tableState, offset: 0, showAll: true }; + setTableState(tableKey, next); + loadTable(tableKey, { loadAll: true }); + }, + [loadTable, setTableState] + ); + const toggleTableSort = useCallback( + (tableKey, field) => { + const tableState = tablesRef.current[tableKey] || createTableState(); + const currentSort = Array.isArray(tableState.sort) ? tableState.sort[0] : null; + const dir = currentSort && currentSort.field === field ? currentSort.dir === "asc" ? "desc" : "asc" : "asc"; + const sortOverride = [{ field, dir }]; + const next = { ...tableState, sort: sortOverride, offset: 0, showAll: false }; + setTableState(tableKey, next); + loadTable(tableKey, { resetOffset: true, sortOverride }); + }, + [loadTable, setTableState] + ); + const selectConfigNode = useCallback( + (tableKey) => { + setConfigActiveKey(tableKey); + setActiveSection("config"); + loadCurrentConfigTable(false, void 0, tableKey); + }, + [loadCurrentConfigTable] + ); + const refreshAll = useCallback(() => { + refreshSection(activeSection); + }, [activeSection, refreshSection]); + const activateSection = useCallback( + (section) => { + const nextSection = canAccessSection(role, section) ? section : "dashboard"; + setActiveSection(nextSection); + refreshSection(nextSection); + }, + [refreshSection, role] + ); + const logout = useCallback(() => { + localStorage.removeItem(LS_TOKEN); + setToken(""); + setRole(""); + setEmail(""); + setRecordModal({ open: false, tableKey: null, mode: "create", rowId: null, form: {} }); + setRequestModal({ open: false, jsonText: "" }); + setFilterModal({ open: false, tableKey: null, field: "", op: "=", rawValue: "", editIndex: null }); + setReassignModal({ open: false, requestId: null, trackNumber: "", lawyerId: "" }); + setDashboardData({ cards: [], byStatus: {}, lawyerLoads: [] }); + setMetaJson(""); + setConfigActiveKey("quotes"); + setReferencesExpanded(true); + setTables({ + requests: createTableState(), + quotes: createTableState(), + topics: createTableState(), + statuses: createTableState(), + formFields: createTableState(), + topicRequiredFields: createTableState(), + topicDataTemplates: createTableState(), + statusTransitions: createTableState(), + users: createTableState(), + userTopics: createTableState() + }); + setDictionaries({ + topics: [], + statuses: Object.entries(STATUS_LABELS).map(([code, name]) => ({ code, name })), + formFieldTypes: [...DEFAULT_FORM_FIELD_TYPES], + formFieldKeys: [], + users: [] + }); + setStatusMap({}); + setActiveSection("dashboard"); + }, []); + const login = useCallback( + async (emailInput, passwordInput) => { + try { + setStatus("login", "\u0412\u044B\u043F\u043E\u043B\u043D\u044F\u0435\u043C \u0432\u0445\u043E\u0434...", ""); + const data = await api( + "/api/admin/auth/login", + { + method: "POST", + auth: false, + body: { email: String(emailInput || "").trim(), password: passwordInput || "" } + }, + "" + ); + const nextToken = data.access_token; + const payload = decodeJwtPayload(nextToken || ""); + if (!payload || !payload.role || !payload.email) throw new Error("\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043F\u0440\u043E\u0447\u0438\u0442\u0430\u0442\u044C \u0434\u0430\u043D\u043D\u044B\u0435 \u0442\u043E\u043A\u0435\u043D\u0430"); + localStorage.setItem(LS_TOKEN, nextToken); + setToken(nextToken); + setRole(payload.role); + setEmail(payload.email); + await bootstrapReferenceData(nextToken, payload.role); + setActiveSection("dashboard"); + await loadDashboard(nextToken); + setStatus("login", "\u0423\u0441\u043F\u0435\u0448\u043D\u044B\u0439 \u0432\u0445\u043E\u0434", "ok"); + } catch (error) { + setStatus("login", "\u041E\u0448\u0438\u0431\u043A\u0430 \u0432\u0445\u043E\u0434\u0430: " + error.message, "error"); + } + }, + [api, bootstrapReferenceData, loadDashboard, setStatus] + ); + useEffect(() => { + const saved = localStorage.getItem(LS_TOKEN) || ""; + if (!saved) return; + const payload = decodeJwtPayload(saved); + if (!payload || !payload.role || !payload.email) { + localStorage.removeItem(LS_TOKEN); + return; + } + setToken(saved); + setRole(payload.role); + setEmail(payload.email); + }, []); + useEffect(() => { + if (!token || !role) return; + let cancelled = false; + (async () => { + await bootstrapReferenceData(token, role); + if (!cancelled) await refreshSection(activeSection, token); + })(); + return () => { + cancelled = true; + }; + }, [bootstrapReferenceData, refreshSection, role, token]); + const anyOverlayOpen = requestModal.open || recordModal.open || filterModal.open || reassignModal.open; + useEffect(() => { + document.body.classList.toggle("modal-open", anyOverlayOpen); + return () => document.body.classList.remove("modal-open"); + }, [anyOverlayOpen]); + useEffect(() => { + const onEsc = (event) => { + if (event.key !== "Escape") return; + setRequestModal((prev) => ({ ...prev, open: false })); + setRecordModal((prev) => ({ ...prev, open: false })); + setFilterModal((prev) => ({ ...prev, open: false })); + setReassignModal((prev) => ({ ...prev, open: false })); + }; + document.addEventListener("keydown", onEsc); + return () => document.removeEventListener("keydown", onEsc); + }, []); + const menuItems = useMemo(() => { + return [ + { key: "dashboard", label: "\u041E\u0431\u0437\u043E\u0440" }, + { key: "requests", label: "\u0417\u0430\u044F\u0432\u043A\u0438" }, + { key: "meta", label: "\u041C\u0435\u0442\u0430\u0434\u0430\u043D\u043D\u044B\u0435" } + ]; + }, []); + const activeFilterFields = useMemo(() => { + if (!filterModal.tableKey) return []; + return getFilterFields(filterModal.tableKey); + }, [filterModal.tableKey, getFilterFields]); + const filterTableLabel = useMemo(() => getTableLabel(filterModal.tableKey), [filterModal.tableKey, getTableLabel]); + const recordModalFields = useMemo(() => { + const all = getRecordFields(recordModal.tableKey); + if (recordModal.mode !== "create") return all; + return all.filter((field) => !field.autoCreate); + }, [getRecordFields, recordModal.mode, recordModal.tableKey]); + return /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("div", { className: "layout" }, /* @__PURE__ */ React.createElement("aside", { className: "sidebar" }, /* @__PURE__ */ React.createElement("div", { className: "logo" }, /* @__PURE__ */ React.createElement("a", { href: "/" }, "\u041F\u0440\u0430\u0432\u043E\u0432\u043E\u0439 \u0442\u0440\u0435\u043A\u0435\u0440")), /* @__PURE__ */ React.createElement("nav", { className: "menu" }, menuItems.map((item) => /* @__PURE__ */ React.createElement( + "button", + { + key: item.key, + className: activeSection === item.key ? "active" : "", + "data-section": item.key, + type: "button", + onClick: () => activateSection(item.key) + }, + item.label + )), role === "ADMIN" ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement( + "button", + { + className: activeSection === "config" ? "active" : "", + type: "button", + onClick: () => { + setReferencesExpanded((prev) => !prev); + activateSection("config"); + } + }, + "\u0421\u043F\u0440\u0430\u0432\u043E\u0447\u043D\u0438\u043A\u0438 " + (referencesExpanded ? "\u25BE" : "\u25B8") + ), referencesExpanded ? /* @__PURE__ */ React.createElement("div", { className: "menu-tree" }, /* @__PURE__ */ React.createElement( + "button", + { + type: "button", + className: activeSection === "config" && configActiveKey === "quotes" ? "active" : "", + onClick: () => selectConfigNode("quotes") + }, + "\u0426\u0438\u0442\u0430\u0442\u044B" + ), /* @__PURE__ */ React.createElement( + "button", + { + type: "button", + className: activeSection === "config" && configActiveKey === "topics" ? "active" : "", + onClick: () => selectConfigNode("topics") + }, + "\u0422\u0435\u043C\u044B" + ), /* @__PURE__ */ React.createElement( + "button", + { + type: "button", + className: activeSection === "config" && configActiveKey === "statuses" ? "active" : "", + onClick: () => selectConfigNode("statuses") + }, + "\u0421\u0442\u0430\u0442\u0443\u0441\u044B" + ), /* @__PURE__ */ React.createElement( + "button", + { + type: "button", + className: activeSection === "config" && configActiveKey === "formFields" ? "active" : "", + onClick: () => selectConfigNode("formFields") + }, + "\u041F\u043E\u043B\u044F \u0444\u043E\u0440\u043C\u044B" + ), /* @__PURE__ */ React.createElement( + "button", + { + type: "button", + className: activeSection === "config" && configActiveKey === "topicRequiredFields" ? "active" : "", + onClick: () => selectConfigNode("topicRequiredFields") + }, + "\u041E\u0431\u044F\u0437\u0430\u0442\u0435\u043B\u044C\u043D\u044B\u0435 \u043F\u043E\u043B\u044F \u0442\u0435\u043C\u044B" + ), /* @__PURE__ */ React.createElement( + "button", + { + type: "button", + className: activeSection === "config" && configActiveKey === "topicDataTemplates" ? "active" : "", + onClick: () => selectConfigNode("topicDataTemplates") + }, + "\u0428\u0430\u0431\u043B\u043E\u043D\u044B \u0434\u043E\u0437\u0430\u043F\u0440\u043E\u0441\u0430" + ), /* @__PURE__ */ React.createElement( + "button", + { + type: "button", + className: activeSection === "config" && configActiveKey === "statusTransitions" ? "active" : "", + onClick: () => selectConfigNode("statusTransitions") + }, + "\u041F\u0435\u0440\u0435\u0445\u043E\u0434\u044B \u0441\u0442\u0430\u0442\u0443\u0441\u043E\u0432" + ), /* @__PURE__ */ React.createElement( + "button", + { + type: "button", + className: activeSection === "config" && configActiveKey === "users" ? "active" : "", + onClick: () => selectConfigNode("users") + }, + "\u041F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u0435\u043B\u0438" + ), /* @__PURE__ */ React.createElement( + "button", + { + type: "button", + className: activeSection === "config" && configActiveKey === "userTopics" ? "active" : "", + onClick: () => selectConfigNode("userTopics") + }, + "\u0422\u0435\u043C\u044B \u044E\u0440\u0438\u0441\u0442\u043E\u0432" + )) : null) : null), /* @__PURE__ */ React.createElement("div", { className: "auth-box" }, token && role ? /* @__PURE__ */ React.createElement(React.Fragment, null, "\u041F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u0435\u043B\u044C: ", /* @__PURE__ */ React.createElement("b", null, email), /* @__PURE__ */ React.createElement("br", null), "\u0420\u043E\u043B\u044C: ", /* @__PURE__ */ React.createElement("b", null, roleLabel(role))) : "\u041D\u0435 \u0430\u0432\u0442\u043E\u0440\u0438\u0437\u043E\u0432\u0430\u043D"), /* @__PURE__ */ React.createElement("div", { style: { marginTop: "0.75rem", display: "flex", gap: "0.5rem", flexWrap: "wrap" } }, /* @__PURE__ */ React.createElement("button", { className: "btn secondary", type: "button", onClick: refreshAll }, "\u041E\u0431\u043D\u043E\u0432\u0438\u0442\u044C"), /* @__PURE__ */ React.createElement("button", { className: "btn danger", type: "button", onClick: logout }, "\u0412\u044B\u0439\u0442\u0438"))), /* @__PURE__ */ React.createElement("main", { className: "main" }, /* @__PURE__ */ React.createElement("div", { className: "topbar" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h1", null, "\u041F\u0430\u043D\u0435\u043B\u044C \u0430\u0434\u043C\u0438\u043D\u0438\u0441\u0442\u0440\u0430\u0442\u043E\u0440\u0430"), /* @__PURE__ */ React.createElement("p", { className: "muted" }, "UniversalQuery, RBAC \u0438 \u0430\u0443\u0434\u0438\u0442 \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0439 \u043F\u043E \u043A\u043B\u044E\u0447\u0435\u0432\u044B\u043C \u0441\u0443\u0449\u043D\u043E\u0441\u0442\u044F\u043C \u0441\u0438\u0441\u0442\u0435\u043C\u044B.")), /* @__PURE__ */ React.createElement("span", { className: "badge" }, "\u0440\u043E\u043B\u044C: ", roleLabel(role))), /* @__PURE__ */ React.createElement(Section, { active: activeSection === "dashboard", id: "section-dashboard" }, /* @__PURE__ */ React.createElement("div", { className: "section-head" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", null, "\u041E\u0431\u0437\u043E\u0440 \u043C\u0435\u0442\u0440\u0438\u043A"), /* @__PURE__ */ React.createElement("p", { className: "muted" }, "\u0421\u043E\u0441\u0442\u043E\u044F\u043D\u0438\u0435 \u0437\u0430\u044F\u0432\u043E\u043A \u0438 SLA-\u043C\u043E\u043D\u0438\u0442\u043E\u0440\u0438\u043D\u0433."))), /* @__PURE__ */ React.createElement("div", { className: "cards" }, dashboardData.cards.map((card) => /* @__PURE__ */ React.createElement("div", { className: "card", key: card.label }, /* @__PURE__ */ React.createElement("p", null, card.label), /* @__PURE__ */ React.createElement("b", null, card.value)))), /* @__PURE__ */ React.createElement("div", { className: "json" }, JSON.stringify(dashboardData.byStatus || {}, null, 2)), /* @__PURE__ */ React.createElement("div", { style: { marginTop: "0.85rem" } }, /* @__PURE__ */ React.createElement("h3", { style: { margin: "0 0 0.55rem" } }, "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430 \u044E\u0440\u0438\u0441\u0442\u043E\u0432"), /* @__PURE__ */ React.createElement( + DataTable, + { + headers: [ + { key: "name", label: "\u042E\u0440\u0438\u0441\u0442" }, + { key: "email", label: "Email" }, + { key: "primary_topic_code", label: "\u041E\u0441\u043D\u043E\u0432\u043D\u0430\u044F \u0442\u0435\u043C\u0430" }, + { key: "active_load", label: "\u0410\u043A\u0442\u0438\u0432\u043D\u044B\u0435 \u0437\u0430\u044F\u0432\u043A\u0438" }, + { key: "total_assigned", label: "\u0412\u0441\u0435\u0433\u043E \u043D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u043E" } + ], + rows: dashboardData.lawyerLoads || [], + emptyColspan: 5, + renderRow: (row) => /* @__PURE__ */ React.createElement("tr", { key: row.lawyer_id }, /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("div", { className: "user-identity" }, /* @__PURE__ */ React.createElement(UserAvatar, { name: row.name, email: row.email, avatarUrl: row.avatar_url, accessToken: token, size: 32 }), /* @__PURE__ */ React.createElement("div", { className: "user-identity-text" }, /* @__PURE__ */ React.createElement("b", null, row.name || "-")))), /* @__PURE__ */ React.createElement("td", null, row.email || "-"), /* @__PURE__ */ React.createElement("td", null, row.primary_topic_code || "-"), /* @__PURE__ */ React.createElement("td", null, String(row.active_load ?? 0)), /* @__PURE__ */ React.createElement("td", null, String(row.total_assigned ?? 0))) + } + )), /* @__PURE__ */ React.createElement(StatusLine, { status: getStatus("dashboard") })), /* @__PURE__ */ React.createElement(Section, { active: activeSection === "requests", id: "section-requests" }, /* @__PURE__ */ React.createElement("div", { className: "section-head" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", null, "\u0417\u0430\u044F\u0432\u043A\u0438"), /* @__PURE__ */ React.createElement("p", { className: "muted" }, "\u0421\u0435\u0440\u0432\u0435\u0440\u043D\u0430\u044F \u0444\u0438\u043B\u044C\u0442\u0440\u0430\u0446\u0438\u044F \u0438 \u043F\u0440\u043E\u0441\u043C\u043E\u0442\u0440 \u043A\u043B\u0438\u0435\u043D\u0442\u0441\u043A\u0438\u0445 \u0437\u0430\u044F\u0432\u043E\u043A.")), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", gap: "0.5rem" } }, /* @__PURE__ */ React.createElement("button", { className: "btn secondary", type: "button", onClick: () => loadTable("requests", { resetOffset: true }) }, "\u041E\u0431\u043D\u043E\u0432\u0438\u0442\u044C"), /* @__PURE__ */ React.createElement("button", { className: "btn", type: "button", onClick: () => openCreateRecordModal("requests") }, "\u041D\u043E\u0432\u0430\u044F \u0437\u0430\u044F\u0432\u043A\u0430"))), /* @__PURE__ */ React.createElement( + FilterToolbar, + { + filters: tables.requests.filters, + onOpen: () => openFilterModal("requests"), + onRemove: (index) => removeFilterChip("requests", index), + onEdit: (index) => openFilterEditModal("requests", index), + getChipLabel: (clause) => { + const fieldDef = getFieldDef("requests", clause.field); + return (fieldDef ? fieldDef.label : clause.field) + " " + OPERATOR_LABELS[clause.op] + " " + getFilterValuePreview("requests", clause); + } + } + ), /* @__PURE__ */ React.createElement( + DataTable, + { + headers: [ + { key: "track_number", label: "\u041D\u043E\u043C\u0435\u0440", sortable: true, field: "track_number" }, + { key: "client_name", label: "\u041A\u043B\u0438\u0435\u043D\u0442", sortable: true, field: "client_name" }, + { key: "client_phone", label: "\u0422\u0435\u043B\u0435\u0444\u043E\u043D", sortable: true, field: "client_phone" }, + { key: "status_code", label: "\u0421\u0442\u0430\u0442\u0443\u0441", sortable: true, field: "status_code" }, + { key: "topic_code", label: "\u0422\u0435\u043C\u0430", sortable: true, field: "topic_code" }, + { key: "assigned_lawyer_id", label: "\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D", sortable: true, field: "assigned_lawyer_id" }, + { key: "updates", label: "\u041E\u0431\u043D\u043E\u0432\u043B\u0435\u043D\u0438\u044F" }, + { key: "created_at", label: "\u0421\u043E\u0437\u0434\u0430\u043D\u0430", sortable: true, field: "created_at" }, + { key: "actions", label: "\u0414\u0435\u0439\u0441\u0442\u0432\u0438\u044F" } + ], + rows: tables.requests.rows, + emptyColspan: 9, + onSort: (field) => toggleTableSort("requests", field), + sortClause: tables.requests.sort && tables.requests.sort[0] || TABLE_SERVER_CONFIG.requests.sort[0], + renderRow: (row) => /* @__PURE__ */ React.createElement("tr", { key: row.id }, /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("code", null, row.track_number || "-")), /* @__PURE__ */ React.createElement("td", null, row.client_name || "-"), /* @__PURE__ */ React.createElement("td", null, row.client_phone || "-"), /* @__PURE__ */ React.createElement("td", null, statusLabel(row.status_code)), /* @__PURE__ */ React.createElement("td", null, row.topic_code || "-"), /* @__PURE__ */ React.createElement("td", null, row.assigned_lawyer_id || "-"), /* @__PURE__ */ React.createElement("td", null, renderRequestUpdatesCell(row, role)), /* @__PURE__ */ React.createElement("td", null, fmtDate(row.created_at)), /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("div", { className: "table-actions" }, role === "LAWYER" && !row.assigned_lawyer_id ? /* @__PURE__ */ React.createElement(IconButton, { icon: "\u{1F4E5}", tooltip: "\u0412\u0437\u044F\u0442\u044C \u0432 \u0440\u0430\u0431\u043E\u0442\u0443", onClick: () => claimRequest(row.id) }) : null, role === "ADMIN" && row.assigned_lawyer_id ? /* @__PURE__ */ React.createElement(IconButton, { icon: "\u21C4", tooltip: "\u041F\u0435\u0440\u0435\u043D\u0430\u0437\u043D\u0430\u0447\u0438\u0442\u044C", onClick: () => openReassignModal(row) }) : null, /* @__PURE__ */ React.createElement(IconButton, { icon: "\u{1F441}", tooltip: "\u041E\u0442\u043A\u0440\u044B\u0442\u044C \u0437\u0430\u044F\u0432\u043A\u0443", onClick: () => openRequestDetails(row.id) }), /* @__PURE__ */ React.createElement(IconButton, { icon: "\u270E", tooltip: "\u0420\u0435\u0434\u0430\u043A\u0442\u0438\u0440\u043E\u0432\u0430\u0442\u044C \u0437\u0430\u044F\u0432\u043A\u0443", onClick: () => openEditRecordModal("requests", row) }), /* @__PURE__ */ React.createElement(IconButton, { icon: "\u{1F5D1}", tooltip: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u0437\u0430\u044F\u0432\u043A\u0443", onClick: () => deleteRecord("requests", row.id), tone: "danger" })))) + } + ), /* @__PURE__ */ React.createElement( + TablePager, + { + tableState: tables.requests, + onPrev: () => loadPrevPage("requests"), + onNext: () => loadNextPage("requests"), + onLoadAll: () => loadAllRows("requests") + } + ), /* @__PURE__ */ React.createElement(StatusLine, { status: getStatus("requests") })), /* @__PURE__ */ React.createElement(Section, { active: activeSection === "quotes", id: "section-quotes" }, /* @__PURE__ */ React.createElement("div", { className: "section-head" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", null, "\u0426\u0438\u0442\u0430\u0442\u044B"), /* @__PURE__ */ React.createElement("p", { className: "muted" }, "\u0423\u043F\u0440\u0430\u0432\u043B\u0435\u043D\u0438\u0435 \u043F\u0443\u0431\u043B\u0438\u0447\u043D\u043E\u0439 \u043B\u0435\u043D\u0442\u043E\u0439 \u0446\u0438\u0442\u0430\u0442 \u0441 \u0441\u0435\u0440\u0432\u0435\u0440\u043D\u044B\u043C\u0438 \u0444\u0438\u043B\u044C\u0442\u0440\u0430\u043C\u0438.")), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", gap: "0.5rem" } }, /* @__PURE__ */ React.createElement("button", { className: "btn secondary", type: "button", onClick: () => loadTable("quotes", { resetOffset: true }) }, "\u041E\u0431\u043D\u043E\u0432\u0438\u0442\u044C"), /* @__PURE__ */ React.createElement("button", { className: "btn", type: "button", onClick: () => openCreateRecordModal("quotes") }, "\u041D\u043E\u0432\u0430\u044F \u0446\u0438\u0442\u0430\u0442\u0430"))), /* @__PURE__ */ React.createElement( + FilterToolbar, + { + filters: tables.quotes.filters, + onOpen: () => openFilterModal("quotes"), + onRemove: (index) => removeFilterChip("quotes", index), + onEdit: (index) => openFilterEditModal("quotes", index), + getChipLabel: (clause) => { + const fieldDef = getFieldDef("quotes", clause.field); + return (fieldDef ? fieldDef.label : clause.field) + " " + OPERATOR_LABELS[clause.op] + " " + getFilterValuePreview("quotes", clause); + } + } + ), /* @__PURE__ */ React.createElement( + DataTable, + { + headers: [ + { key: "author", label: "\u0410\u0432\u0442\u043E\u0440", sortable: true, field: "author" }, + { key: "text", label: "\u0422\u0435\u043A\u0441\u0442", sortable: true, field: "text" }, + { key: "source", label: "\u0418\u0441\u0442\u043E\u0447\u043D\u0438\u043A", sortable: true, field: "source" }, + { key: "is_active", label: "\u0410\u043A\u0442\u0438\u0432\u043D\u0430", sortable: true, field: "is_active" }, + { key: "sort_order", label: "\u041F\u043E\u0440\u044F\u0434\u043E\u043A", sortable: true, field: "sort_order" }, + { key: "created_at", label: "\u0421\u043E\u0437\u0434\u0430\u043D\u0430", sortable: true, field: "created_at" }, + { key: "actions", label: "\u0414\u0435\u0439\u0441\u0442\u0432\u0438\u044F" } + ], + rows: tables.quotes.rows, + emptyColspan: 7, + onSort: (field) => toggleTableSort("quotes", field), + sortClause: tables.quotes.sort && tables.quotes.sort[0] || TABLE_SERVER_CONFIG.quotes.sort[0], + renderRow: (row) => /* @__PURE__ */ React.createElement("tr", { key: row.id }, /* @__PURE__ */ React.createElement("td", null, row.author || "-"), /* @__PURE__ */ React.createElement("td", null, row.text || "-"), /* @__PURE__ */ React.createElement("td", null, row.source || "-"), /* @__PURE__ */ React.createElement("td", null, boolLabel(row.is_active)), /* @__PURE__ */ React.createElement("td", null, String(row.sort_order ?? 0)), /* @__PURE__ */ React.createElement("td", null, fmtDate(row.created_at)), /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("div", { className: "table-actions" }, /* @__PURE__ */ React.createElement(IconButton, { icon: "\u270E", tooltip: "\u0420\u0435\u0434\u0430\u043A\u0442\u0438\u0440\u043E\u0432\u0430\u0442\u044C \u0446\u0438\u0442\u0430\u0442\u0443", onClick: () => openEditRecordModal("quotes", row) }), /* @__PURE__ */ React.createElement(IconButton, { icon: "\u{1F5D1}", tooltip: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u0446\u0438\u0442\u0430\u0442\u0443", onClick: () => deleteRecord("quotes", row.id), tone: "danger" })))) + } + ), /* @__PURE__ */ React.createElement( + TablePager, + { + tableState: tables.quotes, + onPrev: () => loadPrevPage("quotes"), + onNext: () => loadNextPage("quotes"), + onLoadAll: () => loadAllRows("quotes") + } + ), /* @__PURE__ */ React.createElement(StatusLine, { status: getStatus("quotes") })), /* @__PURE__ */ React.createElement(Section, { active: activeSection === "config", id: "section-config" }, /* @__PURE__ */ React.createElement("div", { className: "section-head" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", null, "\u0421\u043F\u0440\u0430\u0432\u043E\u0447\u043D\u0438\u043A\u0438"), /* @__PURE__ */ React.createElement("p", { className: "muted" }, "\u0412\u044B\u0431\u0435\u0440\u0438\u0442\u0435 \u0441\u043F\u0440\u0430\u0432\u043E\u0447\u043D\u0438\u043A \u0432 \u0434\u0435\u0440\u0435\u0432\u0435 \u0441\u043B\u0435\u0432\u0430.")), /* @__PURE__ */ React.createElement("button", { className: "btn secondary", type: "button", onClick: () => loadCurrentConfigTable(true) }, "\u041E\u0431\u043D\u043E\u0432\u0438\u0442\u044C")), /* @__PURE__ */ React.createElement("div", { className: "config-layout" }, /* @__PURE__ */ React.createElement("div", { className: "config-panel" }, /* @__PURE__ */ React.createElement("div", { className: "block" }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center", gap: "0.5rem", marginBottom: "0.5rem" } }, /* @__PURE__ */ React.createElement("h3", { style: { margin: 0 } }, getTableLabel(configActiveKey)), /* @__PURE__ */ React.createElement("button", { className: "btn", type: "button", onClick: () => openCreateRecordModal(configActiveKey) }, "\u0414\u043E\u0431\u0430\u0432\u0438\u0442\u044C")), /* @__PURE__ */ React.createElement( + FilterToolbar, + { + filters: tables[configActiveKey].filters, + onOpen: () => openFilterModal(configActiveKey), + onRemove: (index) => removeFilterChip(configActiveKey, index), + onEdit: (index) => openFilterEditModal(configActiveKey, index), + getChipLabel: (clause) => { + const fieldDef = getFieldDef(configActiveKey, clause.field); + return (fieldDef ? fieldDef.label : clause.field) + " " + OPERATOR_LABELS[clause.op] + " " + getFilterValuePreview(configActiveKey, clause); + } + } + ), configActiveKey === "topics" ? /* @__PURE__ */ React.createElement( + DataTable, + { + headers: [ + { key: "code", label: "\u041A\u043E\u0434", sortable: true, field: "code" }, + { key: "name", label: "\u041D\u0430\u0437\u0432\u0430\u043D\u0438\u0435", sortable: true, field: "name" }, + { key: "enabled", label: "\u0410\u043A\u0442\u0438\u0432\u043D\u0430", sortable: true, field: "enabled" }, + { key: "sort_order", label: "\u041F\u043E\u0440\u044F\u0434\u043E\u043A", sortable: true, field: "sort_order" }, + { key: "actions", label: "\u0414\u0435\u0439\u0441\u0442\u0432\u0438\u044F" } + ], + rows: tables.topics.rows, + emptyColspan: 5, + onSort: (field) => toggleTableSort("topics", field), + sortClause: tables.topics.sort && tables.topics.sort[0] || TABLE_SERVER_CONFIG.topics.sort[0], + renderRow: (row) => /* @__PURE__ */ React.createElement("tr", { key: row.id }, /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("code", null, row.code || "-")), /* @__PURE__ */ React.createElement("td", null, row.name || "-"), /* @__PURE__ */ React.createElement("td", null, boolLabel(row.enabled)), /* @__PURE__ */ React.createElement("td", null, String(row.sort_order ?? 0)), /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("div", { className: "table-actions" }, /* @__PURE__ */ React.createElement(IconButton, { icon: "\u270E", tooltip: "\u0420\u0435\u0434\u0430\u043A\u0442\u0438\u0440\u043E\u0432\u0430\u0442\u044C \u0442\u0435\u043C\u0443", onClick: () => openEditRecordModal("topics", row) }), /* @__PURE__ */ React.createElement(IconButton, { icon: "\u{1F5D1}", tooltip: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u0442\u0435\u043C\u0443", onClick: () => deleteRecord("topics", row.id), tone: "danger" })))) + } + ) : null, configActiveKey === "quotes" ? /* @__PURE__ */ React.createElement( + DataTable, + { + headers: [ + { key: "author", label: "\u0410\u0432\u0442\u043E\u0440", sortable: true, field: "author" }, + { key: "text", label: "\u0422\u0435\u043A\u0441\u0442", sortable: true, field: "text" }, + { key: "source", label: "\u0418\u0441\u0442\u043E\u0447\u043D\u0438\u043A", sortable: true, field: "source" }, + { key: "is_active", label: "\u0410\u043A\u0442\u0438\u0432\u043D\u0430", sortable: true, field: "is_active" }, + { key: "sort_order", label: "\u041F\u043E\u0440\u044F\u0434\u043E\u043A", sortable: true, field: "sort_order" }, + { key: "created_at", label: "\u0421\u043E\u0437\u0434\u0430\u043D\u0430", sortable: true, field: "created_at" }, + { key: "actions", label: "\u0414\u0435\u0439\u0441\u0442\u0432\u0438\u044F" } + ], + rows: tables.quotes.rows, + emptyColspan: 7, + onSort: (field) => toggleTableSort("quotes", field), + sortClause: tables.quotes.sort && tables.quotes.sort[0] || TABLE_SERVER_CONFIG.quotes.sort[0], + renderRow: (row) => /* @__PURE__ */ React.createElement("tr", { key: row.id }, /* @__PURE__ */ React.createElement("td", null, row.author || "-"), /* @__PURE__ */ React.createElement("td", null, row.text || "-"), /* @__PURE__ */ React.createElement("td", null, row.source || "-"), /* @__PURE__ */ React.createElement("td", null, boolLabel(row.is_active)), /* @__PURE__ */ React.createElement("td", null, String(row.sort_order ?? 0)), /* @__PURE__ */ React.createElement("td", null, fmtDate(row.created_at)), /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("div", { className: "table-actions" }, /* @__PURE__ */ React.createElement(IconButton, { icon: "\u270E", tooltip: "\u0420\u0435\u0434\u0430\u043A\u0442\u0438\u0440\u043E\u0432\u0430\u0442\u044C \u0446\u0438\u0442\u0430\u0442\u0443", onClick: () => openEditRecordModal("quotes", row) }), /* @__PURE__ */ React.createElement(IconButton, { icon: "\u{1F5D1}", tooltip: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u0446\u0438\u0442\u0430\u0442\u0443", onClick: () => deleteRecord("quotes", row.id), tone: "danger" })))) + } + ) : null, configActiveKey === "statuses" ? /* @__PURE__ */ React.createElement( + DataTable, + { + headers: [ + { key: "code", label: "\u041A\u043E\u0434", sortable: true, field: "code" }, + { key: "name", label: "\u041D\u0430\u0437\u0432\u0430\u043D\u0438\u0435", sortable: true, field: "name" }, + { key: "enabled", label: "\u0410\u043A\u0442\u0438\u0432\u0435\u043D", sortable: true, field: "enabled" }, + { key: "sort_order", label: "\u041F\u043E\u0440\u044F\u0434\u043E\u043A", sortable: true, field: "sort_order" }, + { key: "is_terminal", label: "\u0422\u0435\u0440\u043C\u0438\u043D\u0430\u043B\u044C\u043D\u044B\u0439", sortable: true, field: "is_terminal" }, + { key: "actions", label: "\u0414\u0435\u0439\u0441\u0442\u0432\u0438\u044F" } + ], + rows: tables.statuses.rows, + emptyColspan: 6, + onSort: (field) => toggleTableSort("statuses", field), + sortClause: tables.statuses.sort && tables.statuses.sort[0] || TABLE_SERVER_CONFIG.statuses.sort[0], + renderRow: (row) => /* @__PURE__ */ React.createElement("tr", { key: row.id }, /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("code", null, row.code || "-")), /* @__PURE__ */ React.createElement("td", null, row.name || "-"), /* @__PURE__ */ React.createElement("td", null, boolLabel(row.enabled)), /* @__PURE__ */ React.createElement("td", null, String(row.sort_order ?? 0)), /* @__PURE__ */ React.createElement("td", null, boolLabel(row.is_terminal)), /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("div", { className: "table-actions" }, /* @__PURE__ */ React.createElement(IconButton, { icon: "\u270E", tooltip: "\u0420\u0435\u0434\u0430\u043A\u0442\u0438\u0440\u043E\u0432\u0430\u0442\u044C \u0441\u0442\u0430\u0442\u0443\u0441", onClick: () => openEditRecordModal("statuses", row) }), /* @__PURE__ */ React.createElement(IconButton, { icon: "\u{1F5D1}", tooltip: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u0441\u0442\u0430\u0442\u0443\u0441", onClick: () => deleteRecord("statuses", row.id), tone: "danger" })))) + } + ) : null, configActiveKey === "formFields" ? /* @__PURE__ */ React.createElement( + DataTable, + { + headers: [ + { key: "key", label: "\u041A\u043B\u044E\u0447", sortable: true, field: "key" }, + { key: "label", label: "\u041C\u0435\u0442\u043A\u0430", sortable: true, field: "label" }, + { key: "type", label: "\u0422\u0438\u043F", sortable: true, field: "type" }, + { key: "required", label: "\u041E\u0431\u044F\u0437\u0430\u0442\u0435\u043B\u044C\u043D\u043E\u0435", sortable: true, field: "required" }, + { key: "enabled", label: "\u0410\u043A\u0442\u0438\u0432\u043D\u043E", sortable: true, field: "enabled" }, + { key: "sort_order", label: "\u041F\u043E\u0440\u044F\u0434\u043E\u043A", sortable: true, field: "sort_order" }, + { key: "actions", label: "\u0414\u0435\u0439\u0441\u0442\u0432\u0438\u044F" } + ], + rows: tables.formFields.rows, + emptyColspan: 7, + onSort: (field) => toggleTableSort("formFields", field), + sortClause: tables.formFields.sort && tables.formFields.sort[0] || TABLE_SERVER_CONFIG.formFields.sort[0], + renderRow: (row) => /* @__PURE__ */ React.createElement("tr", { key: row.id }, /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("code", null, row.key || "-")), /* @__PURE__ */ React.createElement("td", null, row.label || "-"), /* @__PURE__ */ React.createElement("td", null, row.type || "-"), /* @__PURE__ */ React.createElement("td", null, boolLabel(row.required)), /* @__PURE__ */ React.createElement("td", null, boolLabel(row.enabled)), /* @__PURE__ */ React.createElement("td", null, String(row.sort_order ?? 0)), /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("div", { className: "table-actions" }, /* @__PURE__ */ React.createElement(IconButton, { icon: "\u270E", tooltip: "\u0420\u0435\u0434\u0430\u043A\u0442\u0438\u0440\u043E\u0432\u0430\u0442\u044C \u043F\u043E\u043B\u0435 \u0444\u043E\u0440\u043C\u044B", onClick: () => openEditRecordModal("formFields", row) }), /* @__PURE__ */ React.createElement(IconButton, { icon: "\u{1F5D1}", tooltip: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u043F\u043E\u043B\u0435 \u0444\u043E\u0440\u043C\u044B", onClick: () => deleteRecord("formFields", row.id), tone: "danger" })))) + } + ) : null, configActiveKey === "topicRequiredFields" ? /* @__PURE__ */ React.createElement( + DataTable, + { + headers: [ + { key: "topic_code", label: "\u0422\u0435\u043C\u0430", sortable: true, field: "topic_code" }, + { key: "field_key", label: "\u041F\u043E\u043B\u0435 \u0444\u043E\u0440\u043C\u044B", sortable: true, field: "field_key" }, + { key: "required", label: "\u041E\u0431\u044F\u0437\u0430\u0442\u0435\u043B\u044C\u043D\u043E\u0435", sortable: true, field: "required" }, + { key: "enabled", label: "\u0410\u043A\u0442\u0438\u0432\u043D\u043E", sortable: true, field: "enabled" }, + { key: "sort_order", label: "\u041F\u043E\u0440\u044F\u0434\u043E\u043A", sortable: true, field: "sort_order" }, + { key: "created_at", label: "\u0421\u043E\u0437\u0434\u0430\u043D\u043E", sortable: true, field: "created_at" }, + { key: "actions", label: "\u0414\u0435\u0439\u0441\u0442\u0432\u0438\u044F" } + ], + rows: tables.topicRequiredFields.rows, + emptyColspan: 7, + onSort: (field) => toggleTableSort("topicRequiredFields", field), + sortClause: tables.topicRequiredFields.sort && tables.topicRequiredFields.sort[0] || TABLE_SERVER_CONFIG.topicRequiredFields.sort[0], + renderRow: (row) => /* @__PURE__ */ React.createElement("tr", { key: row.id }, /* @__PURE__ */ React.createElement("td", null, row.topic_code || "-"), /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("code", null, row.field_key || "-")), /* @__PURE__ */ React.createElement("td", null, boolLabel(row.required)), /* @__PURE__ */ React.createElement("td", null, boolLabel(row.enabled)), /* @__PURE__ */ React.createElement("td", null, String(row.sort_order ?? 0)), /* @__PURE__ */ React.createElement("td", null, fmtDate(row.created_at)), /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("div", { className: "table-actions" }, /* @__PURE__ */ React.createElement( + IconButton, + { + icon: "\u270E", + tooltip: "\u0420\u0435\u0434\u0430\u043A\u0442\u0438\u0440\u043E\u0432\u0430\u0442\u044C \u043E\u0431\u044F\u0437\u0430\u0442\u0435\u043B\u044C\u043D\u043E\u0435 \u043F\u043E\u043B\u0435", + onClick: () => openEditRecordModal("topicRequiredFields", row) + } + ), /* @__PURE__ */ React.createElement( + IconButton, + { + icon: "\u{1F5D1}", + tooltip: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u043E\u0431\u044F\u0437\u0430\u0442\u0435\u043B\u044C\u043D\u043E\u0435 \u043F\u043E\u043B\u0435", + onClick: () => deleteRecord("topicRequiredFields", row.id), + tone: "danger" + } + )))) + } + ) : null, configActiveKey === "topicDataTemplates" ? /* @__PURE__ */ React.createElement( + DataTable, + { + headers: [ + { key: "topic_code", label: "\u0422\u0435\u043C\u0430", sortable: true, field: "topic_code" }, + { key: "key", label: "\u041A\u043B\u044E\u0447", sortable: true, field: "key" }, + { key: "label", label: "\u041C\u0435\u0442\u043A\u0430", sortable: true, field: "label" }, + { key: "description", label: "\u041E\u043F\u0438\u0441\u0430\u043D\u0438\u0435", sortable: true, field: "description" }, + { key: "required", label: "\u041E\u0431\u044F\u0437\u0430\u0442\u0435\u043B\u044C\u043D\u043E\u0435", sortable: true, field: "required" }, + { key: "enabled", label: "\u0410\u043A\u0442\u0438\u0432\u043D\u043E", sortable: true, field: "enabled" }, + { key: "sort_order", label: "\u041F\u043E\u0440\u044F\u0434\u043E\u043A", sortable: true, field: "sort_order" }, + { key: "created_at", label: "\u0421\u043E\u0437\u0434\u0430\u043D\u043E", sortable: true, field: "created_at" }, + { key: "actions", label: "\u0414\u0435\u0439\u0441\u0442\u0432\u0438\u044F" } + ], + rows: tables.topicDataTemplates.rows, + emptyColspan: 9, + onSort: (field) => toggleTableSort("topicDataTemplates", field), + sortClause: tables.topicDataTemplates.sort && tables.topicDataTemplates.sort[0] || TABLE_SERVER_CONFIG.topicDataTemplates.sort[0], + renderRow: (row) => /* @__PURE__ */ React.createElement("tr", { key: row.id }, /* @__PURE__ */ React.createElement("td", null, row.topic_code || "-"), /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("code", null, row.key || "-")), /* @__PURE__ */ React.createElement("td", null, row.label || "-"), /* @__PURE__ */ React.createElement("td", null, row.description || "-"), /* @__PURE__ */ React.createElement("td", null, boolLabel(row.required)), /* @__PURE__ */ React.createElement("td", null, boolLabel(row.enabled)), /* @__PURE__ */ React.createElement("td", null, String(row.sort_order ?? 0)), /* @__PURE__ */ React.createElement("td", null, fmtDate(row.created_at)), /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("div", { className: "table-actions" }, /* @__PURE__ */ React.createElement(IconButton, { icon: "\u270E", tooltip: "\u0420\u0435\u0434\u0430\u043A\u0442\u0438\u0440\u043E\u0432\u0430\u0442\u044C \u0448\u0430\u0431\u043B\u043E\u043D", onClick: () => openEditRecordModal("topicDataTemplates", row) }), /* @__PURE__ */ React.createElement(IconButton, { icon: "\u{1F5D1}", tooltip: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u0448\u0430\u0431\u043B\u043E\u043D", onClick: () => deleteRecord("topicDataTemplates", row.id), tone: "danger" })))) + } + ) : null, configActiveKey === "statusTransitions" ? /* @__PURE__ */ React.createElement( + DataTable, + { + headers: [ + { key: "topic_code", label: "\u0422\u0435\u043C\u0430", sortable: true, field: "topic_code" }, + { key: "from_status", label: "\u0418\u0437 \u0441\u0442\u0430\u0442\u0443\u0441\u0430", sortable: true, field: "from_status" }, + { key: "to_status", label: "\u0412 \u0441\u0442\u0430\u0442\u0443\u0441", sortable: true, field: "to_status" }, + { key: "enabled", label: "\u0410\u043A\u0442\u0438\u0432\u0435\u043D", sortable: true, field: "enabled" }, + { key: "sort_order", label: "\u041F\u043E\u0440\u044F\u0434\u043E\u043A", sortable: true, field: "sort_order" }, + { key: "actions", label: "\u0414\u0435\u0439\u0441\u0442\u0432\u0438\u044F" } + ], + rows: tables.statusTransitions.rows, + emptyColspan: 6, + onSort: (field) => toggleTableSort("statusTransitions", field), + sortClause: tables.statusTransitions.sort && tables.statusTransitions.sort[0] || TABLE_SERVER_CONFIG.statusTransitions.sort[0], + renderRow: (row) => /* @__PURE__ */ React.createElement("tr", { key: row.id }, /* @__PURE__ */ React.createElement("td", null, row.topic_code || "-"), /* @__PURE__ */ React.createElement("td", null, statusLabel(row.from_status)), /* @__PURE__ */ React.createElement("td", null, statusLabel(row.to_status)), /* @__PURE__ */ React.createElement("td", null, boolLabel(row.enabled)), /* @__PURE__ */ React.createElement("td", null, String(row.sort_order ?? 0)), /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("div", { className: "table-actions" }, /* @__PURE__ */ React.createElement( + IconButton, + { + icon: "\u270E", + tooltip: "\u0420\u0435\u0434\u0430\u043A\u0442\u0438\u0440\u043E\u0432\u0430\u0442\u044C \u043F\u0435\u0440\u0435\u0445\u043E\u0434", + onClick: () => openEditRecordModal("statusTransitions", row) + } + ), /* @__PURE__ */ React.createElement( + IconButton, + { + icon: "\u{1F5D1}", + tooltip: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u043F\u0435\u0440\u0435\u0445\u043E\u0434", + onClick: () => deleteRecord("statusTransitions", row.id), + tone: "danger" + } + )))) + } + ) : null, configActiveKey === "users" ? /* @__PURE__ */ React.createElement( + DataTable, + { + headers: [ + { key: "name", label: "\u041F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u0435\u043B\u044C", sortable: true, field: "name" }, + { key: "email", label: "Email", sortable: true, field: "email" }, + { key: "role", label: "\u0420\u043E\u043B\u044C", sortable: true, field: "role" }, + { key: "primary_topic_code", label: "\u041F\u0440\u043E\u0444\u0438\u043B\u044C (\u0442\u0435\u043C\u0430)", sortable: true, field: "primary_topic_code" }, + { key: "is_active", label: "\u0410\u043A\u0442\u0438\u0432\u0435\u043D", sortable: true, field: "is_active" }, + { key: "responsible", label: "\u041E\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0435\u043D\u043D\u044B\u0439", sortable: true, field: "responsible" }, + { key: "created_at", label: "\u0421\u043E\u0437\u0434\u0430\u043D", sortable: true, field: "created_at" }, + { key: "actions", label: "\u0414\u0435\u0439\u0441\u0442\u0432\u0438\u044F" } + ], + rows: tables.users.rows, + emptyColspan: 8, + onSort: (field) => toggleTableSort("users", field), + sortClause: tables.users.sort && tables.users.sort[0] || TABLE_SERVER_CONFIG.users.sort[0], + renderRow: (row) => /* @__PURE__ */ React.createElement("tr", { key: row.id }, /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("div", { className: "user-identity" }, /* @__PURE__ */ React.createElement(UserAvatar, { name: row.name, email: row.email, avatarUrl: row.avatar_url, accessToken: token, size: 32 }), /* @__PURE__ */ React.createElement("div", { className: "user-identity-text" }, /* @__PURE__ */ React.createElement("b", null, row.name || "-")))), /* @__PURE__ */ React.createElement("td", null, row.email || "-"), /* @__PURE__ */ React.createElement("td", null, roleLabel(row.role)), /* @__PURE__ */ React.createElement("td", null, row.primary_topic_code || "-"), /* @__PURE__ */ React.createElement("td", null, boolLabel(row.is_active)), /* @__PURE__ */ React.createElement("td", null, row.responsible || "-"), /* @__PURE__ */ React.createElement("td", null, fmtDate(row.created_at)), /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("div", { className: "table-actions" }, /* @__PURE__ */ React.createElement(IconButton, { icon: "\u270E", tooltip: "\u0420\u0435\u0434\u0430\u043A\u0442\u0438\u0440\u043E\u0432\u0430\u0442\u044C \u043F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u0435\u043B\u044F", onClick: () => openEditRecordModal("users", row) }), /* @__PURE__ */ React.createElement(IconButton, { icon: "\u{1F5D1}", tooltip: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u043F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u0435\u043B\u044F", onClick: () => deleteRecord("users", row.id), tone: "danger" })))) + } + ) : null, configActiveKey === "userTopics" ? /* @__PURE__ */ React.createElement( + DataTable, + { + headers: [ + { key: "admin_user_id", label: "\u042E\u0440\u0438\u0441\u0442", sortable: true, field: "admin_user_id" }, + { key: "topic_code", label: "\u0414\u043E\u043F. \u0442\u0435\u043C\u0430", sortable: true, field: "topic_code" }, + { key: "responsible", label: "\u041E\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0435\u043D\u043D\u044B\u0439", sortable: true, field: "responsible" }, + { key: "created_at", label: "\u0421\u043E\u0437\u0434\u0430\u043D\u043E", sortable: true, field: "created_at" }, + { key: "actions", label: "\u0414\u0435\u0439\u0441\u0442\u0432\u0438\u044F" } + ], + rows: tables.userTopics.rows, + emptyColspan: 5, + onSort: (field) => toggleTableSort("userTopics", field), + sortClause: tables.userTopics.sort && tables.userTopics.sort[0] || TABLE_SERVER_CONFIG.userTopics.sort[0], + renderRow: (row) => { + const lawyer = (dictionaries.users || []).find((item) => String(item.id) === String(row.admin_user_id)); + const lawyerLabel = lawyer ? lawyer.name || lawyer.email || row.admin_user_id : row.admin_user_id || "-"; + return /* @__PURE__ */ React.createElement("tr", { key: row.id }, /* @__PURE__ */ React.createElement("td", null, lawyerLabel), /* @__PURE__ */ React.createElement("td", null, row.topic_code || "-"), /* @__PURE__ */ React.createElement("td", null, row.responsible || "-"), /* @__PURE__ */ React.createElement("td", null, fmtDate(row.created_at)), /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("div", { className: "table-actions" }, /* @__PURE__ */ React.createElement(IconButton, { icon: "\u270E", tooltip: "\u0420\u0435\u0434\u0430\u043A\u0442\u0438\u0440\u043E\u0432\u0430\u0442\u044C \u0441\u0432\u044F\u0437\u044C", onClick: () => openEditRecordModal("userTopics", row) }), /* @__PURE__ */ React.createElement(IconButton, { icon: "\u{1F5D1}", tooltip: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u0441\u0432\u044F\u0437\u044C", onClick: () => deleteRecord("userTopics", row.id), tone: "danger" })))); + } + } + ) : null, /* @__PURE__ */ React.createElement( + TablePager, + { + tableState: tables[configActiveKey], + onPrev: () => loadPrevPage(configActiveKey), + onNext: () => loadNextPage(configActiveKey), + onLoadAll: () => loadAllRows(configActiveKey) + } + ), /* @__PURE__ */ React.createElement(StatusLine, { status: getStatus(configActiveKey) })))), /* @__PURE__ */ React.createElement(StatusLine, { status: getStatus("config") })), /* @__PURE__ */ React.createElement(Section, { active: activeSection === "meta", id: "section-meta" }, /* @__PURE__ */ React.createElement("div", { className: "section-head" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", null, "\u0421\u0445\u0435\u043C\u0430 \u043C\u0435\u0442\u0430\u0434\u0430\u043D\u043D\u044B\u0445"), /* @__PURE__ */ React.createElement("p", { className: "muted" }, "\u041F\u043E\u043B\u044F \u0441\u0443\u0449\u043D\u043E\u0441\u0442\u0435\u0439 \u0434\u043B\u044F meta-driven \u0444\u043E\u0440\u043C."))), /* @__PURE__ */ React.createElement("div", { className: "filters", style: { gridTemplateColumns: "1fr auto" } }, /* @__PURE__ */ React.createElement("div", { className: "field" }, /* @__PURE__ */ React.createElement("label", { htmlFor: "meta-entity" }, "\u0421\u0443\u0449\u043D\u043E\u0441\u0442\u044C"), /* @__PURE__ */ React.createElement( + "input", + { + id: "meta-entity", + value: metaEntity, + placeholder: "quotes", + onChange: (event) => setMetaEntity(event.target.value) + } + )), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "end" } }, /* @__PURE__ */ React.createElement("button", { className: "btn secondary", type: "button", onClick: () => loadMeta() }, "\u0417\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044C"))), /* @__PURE__ */ React.createElement("div", { className: "json" }, metaJson), /* @__PURE__ */ React.createElement(StatusLine, { status: getStatus("meta") })))), /* @__PURE__ */ React.createElement(RequestModal, { open: requestModal.open, jsonText: requestModal.jsonText, onClose: () => setRequestModal((prev) => ({ ...prev, open: false })) }), /* @__PURE__ */ React.createElement( + RecordModal, + { + open: recordModal.open, + title: (recordModal.mode === "edit" ? "\u0420\u0435\u0434\u0430\u043A\u0442\u0438\u0440\u043E\u0432\u0430\u043D\u0438\u0435 \u2022 " : "\u0421\u043E\u0437\u0434\u0430\u043D\u0438\u0435 \u2022 ") + getTableLabel(recordModal.tableKey), + fields: recordModalFields, + form: recordModal.form || {}, + status: getStatus("recordForm"), + onClose: closeRecordModal, + onChange: updateRecordField, + onUploadField: uploadRecordFieldFile, + onSubmit: submitRecordModal + } + ), /* @__PURE__ */ React.createElement( + FilterModal, + { + open: filterModal.open, + tableLabel: filterTableLabel, + fields: activeFilterFields, + draft: filterModal, + status: getStatus("filter"), + onClose: closeFilterModal, + onFieldChange: updateFilterField, + onOpChange: updateFilterOp, + onValueChange: updateFilterValue, + onSubmit: applyFilterModal, + onClear: clearFiltersFromModal, + getOperators: getOperatorsForType, + getFieldOptions + } + ), /* @__PURE__ */ React.createElement( + ReassignModal, + { + open: reassignModal.open, + status: getStatus("reassignForm"), + options: getLawyerOptions(), + value: reassignModal.lawyerId, + onChange: updateReassignLawyer, + onClose: closeReassignModal, + onSubmit: submitReassignModal, + trackNumber: reassignModal.trackNumber + } + ), !token || !role ? /* @__PURE__ */ React.createElement(LoginScreen, { onSubmit: login, status: getStatus("login") }) : null); + } + const root = ReactDOM.createRoot(document.getElementById("admin-root")); + root.render(/* @__PURE__ */ React.createElement(App, null)); + })(); +})();