From ff169cb42dc7ec8cad0d6daba01849507d4ca4b6 Mon Sep 17 00:00:00 2001 From: TronoSfera <119615520+TronoSfera@users.noreply.github.com> Date: Fri, 27 Feb 2026 18:46:07 +0300 Subject: [PATCH] Task P054-P057 --- README.md | 20 + alembic/env.py | 1 + .../0025_add_request_service_requests.py | 78 + ...026_request_service_requests_string_ids.py | 57 + app/api/admin/chat.py | 14 +- app/api/admin/crud.py | 1844 +------------ app/api/admin/crud_modules/__init__.py | 3 + app/api/admin/crud_modules/access.py | 164 ++ app/api/admin/crud_modules/audit.py | 55 + app/api/admin/crud_modules/meta.py | 532 ++++ app/api/admin/crud_modules/payloads.py | 625 +++++ app/api/admin/crud_modules/router.py | 99 + app/api/admin/crud_modules/service.py | 552 ++++ app/api/admin/metrics.py | 26 +- app/api/admin/requests.py | 1598 +----------- app/api/admin/requests_modules/__init__.py | 3 + app/api/admin/requests_modules/common.py | 29 + .../admin/requests_modules/data_templates.py | 233 ++ app/api/admin/requests_modules/kanban.py | 519 ++++ app/api/admin/requests_modules/permissions.py | 117 + app/api/admin/requests_modules/router.py | 195 ++ app/api/admin/requests_modules/service.py | 471 ++++ .../requests_modules/service_requests.py | 186 ++ app/api/admin/requests_modules/status_flow.py | 431 +++ app/api/admin/router.py | 3 +- app/api/admin/system.py | 14 + app/api/public/otp.py | 17 +- app/api/public/requests.py | 95 + app/core/config.py | 3 + app/models/request_service_request.py | 27 + app/schemas/admin.py | 4 + app/schemas/public.py | 18 + app/services/sms_service.py | 188 ++ app/services/test_data_cleanup.py | 10 +- app/web/admin.jsx | 175 ++ .../admin/features/config/ConfigSection.jsx | 40 +- .../features/requests/RequestsSection.jsx | 31 +- .../ServiceRequestsSection.jsx | 138 + app/web/admin/hooks/useTablesState.js | 1 + app/web/admin/shared/constants.js | 19 + app/web/admin/shared/utils.js | 13 +- app/web/client.css | 28 + app/web/client.html | 31 + app/web/client.js | 144 +- celerybeat-schedule | Bin 16384 -> 16384 bytes context/10_development_execution_plan.md | 18 +- context/11_test_runbook.md | 83 +- e2e/tests/admin_role_flow.spec.js | 2 +- e2e/tests/admin_status_designer_flow.spec.js | 6 +- e2e/tests/helpers.js | 18 +- e2e/tests/kanban_role_flow.spec.js | 4 +- e2e/tests/lawyer_role_flow.spec.js | 2 +- e2e/tests/request_data_file_flow.spec.js | 2 +- e2e/tests/service_requests_flow.spec.js | 57 + requirements.txt | 1 + tests/admin/__init__.py | 1 + tests/admin/base.py | 146 ++ tests/admin/test_assignment_users.py | 412 +++ tests/admin/test_crud_meta.py | 192 ++ tests/admin/test_lawyer_chat.py | 419 +++ tests/admin/test_metrics_templates.py | 461 ++++ tests/admin/test_service_requests.py | 286 ++ tests/admin/test_status_flow_kanban.py | 762 ++++++ tests/test_admin_universal_crud.py | 2313 +---------------- tests/test_dashboard_finance.py | 4 + tests/test_migrations.py | 17 +- tests/test_public_cabinet.py | 4 + tests/test_public_requests.py | 93 +- tests/test_sms_provider_health.py | 115 + 69 files changed, 8435 insertions(+), 5834 deletions(-) create mode 100644 alembic/versions/0025_add_request_service_requests.py create mode 100644 alembic/versions/0026_request_service_requests_string_ids.py create mode 100644 app/api/admin/crud_modules/__init__.py create mode 100644 app/api/admin/crud_modules/access.py create mode 100644 app/api/admin/crud_modules/audit.py create mode 100644 app/api/admin/crud_modules/meta.py create mode 100644 app/api/admin/crud_modules/payloads.py create mode 100644 app/api/admin/crud_modules/router.py create mode 100644 app/api/admin/crud_modules/service.py create mode 100644 app/api/admin/requests_modules/__init__.py create mode 100644 app/api/admin/requests_modules/common.py create mode 100644 app/api/admin/requests_modules/data_templates.py create mode 100644 app/api/admin/requests_modules/kanban.py create mode 100644 app/api/admin/requests_modules/permissions.py create mode 100644 app/api/admin/requests_modules/router.py create mode 100644 app/api/admin/requests_modules/service.py create mode 100644 app/api/admin/requests_modules/service_requests.py create mode 100644 app/api/admin/requests_modules/status_flow.py create mode 100644 app/api/admin/system.py create mode 100644 app/models/request_service_request.py create mode 100644 app/services/sms_service.py create mode 100644 app/web/admin/features/service-requests/ServiceRequestsSection.jsx create mode 100644 e2e/tests/service_requests_flow.spec.js create mode 100644 tests/admin/__init__.py create mode 100644 tests/admin/base.py create mode 100644 tests/admin/test_assignment_users.py create mode 100644 tests/admin/test_crud_meta.py create mode 100644 tests/admin/test_lawyer_chat.py create mode 100644 tests/admin/test_metrics_templates.py create mode 100644 tests/admin/test_service_requests.py create mode 100644 tests/admin/test_status_flow_kanban.py create mode 100644 tests/test_sms_provider_health.py diff --git a/README.md b/README.md index dca9f24..3f95c46 100644 --- a/README.md +++ b/README.md @@ -21,3 +21,23 @@ docker compose exec backend alembic upgrade head make seed-quotes ``` Loads 50 justice-themed quotes into `quotes` with idempotent upsert by `(author, text)`. + +## OTP SMS provider (SMS Aero) +OTP sending is implemented through a dedicated SMS service layer (`app/services/sms_service.py`). + +Configure provider in `.env`: +```bash +SMS_PROVIDER=smsaero +SMSAERO_EMAIL=your_email@example.com +SMSAERO_API_KEY=your_api_key +OTP_SMS_TEMPLATE=Your verification code: {code} +``` + +For local/dev mock mode: +```bash +SMS_PROVIDER=dummy +``` +In this mode OTP code is printed to backend logs. + +Admin health-check endpoint (no SMS send): +`GET /api/admin/system/sms-provider-health` diff --git a/alembic/env.py b/alembic/env.py index d15bac0..1bbb571 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -20,6 +20,7 @@ from app.models.admin_user_topic import AdminUserTopic from app.models.notification import Notification from app.models.invoice import Invoice from app.models.security_audit_log import SecurityAuditLog +from app.models.request_service_request import RequestServiceRequest config = context.config fileConfig(config.config_file_name) diff --git a/alembic/versions/0025_add_request_service_requests.py b/alembic/versions/0025_add_request_service_requests.py new file mode 100644 index 0000000..db95de7 --- /dev/null +++ b/alembic/versions/0025_add_request_service_requests.py @@ -0,0 +1,78 @@ +"""add request service requests table + +Revision ID: 0025_service_requests +Revises: 0024_featured_staff_carousel +Create Date: 2026-02-27 14:45:00.000000 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision = "0025_service_requests" +down_revision = "0024_featured_staff_carousel" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "request_service_requests", + sa.Column("request_id", sa.String(length=60), nullable=False), + sa.Column("client_id", sa.String(length=60), nullable=True), + sa.Column("assigned_lawyer_id", sa.String(length=60), nullable=True), + sa.Column("resolved_by_admin_id", sa.String(length=60), nullable=True), + sa.Column("type", sa.String(length=40), nullable=False), + sa.Column("status", sa.String(length=30), nullable=False, server_default="NEW"), + sa.Column("body", sa.Text(), nullable=False), + sa.Column("created_by_client", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.Column("admin_unread", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.Column("lawyer_unread", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.Column("admin_read_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("lawyer_read_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("responsible", sa.String(length=200), nullable=False, server_default="Администратор системы"), + ) + op.create_index(op.f("ix_request_service_requests_request_id"), "request_service_requests", ["request_id"], unique=False) + op.create_index(op.f("ix_request_service_requests_client_id"), "request_service_requests", ["client_id"], unique=False) + op.create_index( + op.f("ix_request_service_requests_assigned_lawyer_id"), + "request_service_requests", + ["assigned_lawyer_id"], + unique=False, + ) + op.create_index( + op.f("ix_request_service_requests_resolved_by_admin_id"), + "request_service_requests", + ["resolved_by_admin_id"], + unique=False, + ) + op.create_index(op.f("ix_request_service_requests_type"), "request_service_requests", ["type"], unique=False) + op.create_index(op.f("ix_request_service_requests_status"), "request_service_requests", ["status"], unique=False) + op.create_index(op.f("ix_request_service_requests_admin_unread"), "request_service_requests", ["admin_unread"], unique=False) + op.create_index(op.f("ix_request_service_requests_lawyer_unread"), "request_service_requests", ["lawyer_unread"], unique=False) + + op.alter_column("request_service_requests", "status", server_default=None) + op.alter_column("request_service_requests", "created_by_client", server_default=None) + op.alter_column("request_service_requests", "admin_unread", server_default=None) + op.alter_column("request_service_requests", "lawyer_unread", server_default=None) + op.alter_column("request_service_requests", "responsible", server_default=None) + + +def downgrade() -> None: + op.drop_index(op.f("ix_request_service_requests_lawyer_unread"), table_name="request_service_requests") + op.drop_index(op.f("ix_request_service_requests_admin_unread"), table_name="request_service_requests") + op.drop_index(op.f("ix_request_service_requests_status"), table_name="request_service_requests") + op.drop_index(op.f("ix_request_service_requests_type"), table_name="request_service_requests") + op.drop_index(op.f("ix_request_service_requests_resolved_by_admin_id"), table_name="request_service_requests") + op.drop_index(op.f("ix_request_service_requests_assigned_lawyer_id"), table_name="request_service_requests") + op.drop_index(op.f("ix_request_service_requests_client_id"), table_name="request_service_requests") + op.drop_index(op.f("ix_request_service_requests_request_id"), table_name="request_service_requests") + op.drop_table("request_service_requests") diff --git a/alembic/versions/0026_request_service_requests_string_ids.py b/alembic/versions/0026_request_service_requests_string_ids.py new file mode 100644 index 0000000..feabc49 --- /dev/null +++ b/alembic/versions/0026_request_service_requests_string_ids.py @@ -0,0 +1,57 @@ +"""normalize request_service_requests link column types to varchar + +Revision ID: 0026_srv_req_str_ids +Revises: 0025_service_requests +Create Date: 2026-02-27 15:40:00.000000 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + + +# revision identifiers, used by Alembic. +revision = "0026_srv_req_str_ids" +down_revision = "0025_service_requests" +branch_labels = None +depends_on = None + + +def _postgres_alter_to_varchar(column_name: str) -> None: + op.execute( + sa.text( + f""" + ALTER TABLE request_service_requests + ALTER COLUMN {column_name} TYPE VARCHAR(60) + USING {column_name}::text + """ + ) + ) + + +def _postgres_alter_to_uuid(column_name: str) -> None: + op.execute( + sa.text( + f""" + ALTER TABLE request_service_requests + ALTER COLUMN {column_name} TYPE UUID + USING NULLIF({column_name}, '')::uuid + """ + ) + ) + + +def upgrade() -> None: + bind = op.get_bind() + if bind.dialect.name == "postgresql": + for name in ("request_id", "client_id", "assigned_lawyer_id", "resolved_by_admin_id"): + _postgres_alter_to_varchar(name) + + +def downgrade() -> None: + bind = op.get_bind() + if bind.dialect.name == "postgresql": + for name in ("request_id", "client_id", "assigned_lawyer_id", "resolved_by_admin_id"): + _postgres_alter_to_uuid(name) + diff --git a/app/api/admin/chat.py b/app/api/admin/chat.py index d0124ee..4ca1820 100644 --- a/app/api/admin/chat.py +++ b/app/api/admin/chat.py @@ -183,7 +183,7 @@ def _serialize_data_request_items(db: Session, rows: list[RequestDataRequirement def list_request_messages( request_id: str, db: Session = Depends(get_db), - admin: dict = Depends(require_role("ADMIN", "LAWYER")), + admin: dict = Depends(require_role("ADMIN", "LAWYER", "CURATOR")), ): req = _request_for_id_or_404(db, request_id) _ensure_lawyer_can_view_request_or_403(admin, req) @@ -196,7 +196,7 @@ def create_request_message( request_id: str, payload: dict, db: Session = Depends(get_db), - admin: dict = Depends(require_role("ADMIN", "LAWYER")), + admin: dict = Depends(require_role("ADMIN", "LAWYER", "CURATOR")), ): req = _request_for_id_or_404(db, request_id) _ensure_lawyer_can_manage_request_or_403(admin, req) @@ -229,7 +229,7 @@ def list_data_request_templates( request_id: str, document: str | None = None, db: Session = Depends(get_db), - admin: dict = Depends(require_role("ADMIN", "LAWYER")), + admin: dict = Depends(require_role("ADMIN", "LAWYER", "CURATOR")), ): req = _request_for_id_or_404(db, request_id) _ensure_lawyer_can_manage_request_or_403(admin, req) @@ -273,7 +273,7 @@ def get_data_request_batch( request_id: str, message_id: str, db: Session = Depends(get_db), - admin: dict = Depends(require_role("ADMIN", "LAWYER")), + admin: dict = Depends(require_role("ADMIN", "LAWYER", "CURATOR")), ): req = _request_for_id_or_404(db, request_id) _ensure_lawyer_can_view_request_or_403(admin, req) @@ -306,7 +306,7 @@ def get_data_request_template( request_id: str, template_id: str, db: Session = Depends(get_db), - admin: dict = Depends(require_role("ADMIN", "LAWYER")), + admin: dict = Depends(require_role("ADMIN", "LAWYER", "CURATOR")), ): req = _request_for_id_or_404(db, request_id) _ensure_lawyer_can_manage_request_or_403(admin, req) @@ -333,7 +333,7 @@ def save_data_request_template( request_id: str, payload: dict, db: Session = Depends(get_db), - admin: dict = Depends(require_role("ADMIN", "LAWYER")), + admin: dict = Depends(require_role("ADMIN", "LAWYER", "CURATOR")), ): req = _request_for_id_or_404(db, request_id) _ensure_lawyer_can_manage_request_or_403(admin, req) @@ -497,7 +497,7 @@ def upsert_data_request_batch( request_id: str, payload: dict, db: Session = Depends(get_db), - admin: dict = Depends(require_role("ADMIN", "LAWYER")), + admin: dict = Depends(require_role("ADMIN", "LAWYER", "CURATOR")), ): req = _request_for_id_or_404(db, request_id) _ensure_lawyer_can_manage_request_or_403(admin, req) diff --git a/app/api/admin/crud.py b/app/api/admin/crud.py index 47cc80d..7944742 100644 --- a/app/api/admin/crud.py +++ b/app/api/admin/crud.py @@ -1,1842 +1,8 @@ -from __future__ import annotations +"""Backward-compatible entrypoint for Admin CRUD router. -import importlib -import json -import pkgutil -import uuid -from datetime import date, datetime, timedelta, timezone -from decimal import Decimal -from functools import lru_cache -from typing import Any +Implementation moved to app.api.admin.crud_modules. +""" -from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel -from sqlalchemy import or_ -from sqlalchemy.exc import IntegrityError -from sqlalchemy.inspection import inspect as sa_inspect -from sqlalchemy.orm import Session -from sqlalchemy.sql.sqltypes import Boolean, Date, DateTime, Float, Integer, JSON, Numeric +from app.api.admin.crud_modules.router import router -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.client import Client -from app.models.table_availability import TableAvailability -from app.models.request_data_requirement import RequestDataRequirement -from app.models.request_data_template import RequestDataTemplate -from app.models.request_data_template_item import RequestDataTemplateItem -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.status_group import StatusGroup -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.status_transition_requirements import validate_transition_requirements_or_400 -from app.services.billing_flow import apply_billing_transition_effects, normalize_status_kind_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", -} -REQUEST_FINANCIAL_FIELDS = {"effective_rate", "invoice_amount", "paid_at", "paid_by_admin_id"} -REQUEST_CALCULATED_FIELDS = {"invoice_amount", "paid_at", "paid_by_admin_id", "total_attachments_bytes"} -INVOICE_CALCULATED_FIELDS = {"issued_by_admin_user_id", "issued_by_role", "issued_at", "paid_at"} -ALLOWED_ADMIN_ROLES = {"ADMIN", "LAWYER"} -ALLOWED_REQUEST_DATA_VALUE_TYPES = {"string", "text", "date", "number", "file"} - -# 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), - }, - "messages": { - "ADMIN": set(CRUD_ACTIONS), - "LAWYER": {"query", "read", "create"}, - }, - "attachments": { - "ADMIN": set(CRUD_ACTIONS), - "LAWYER": {"query", "read"}, - }, - "quotes": {"ADMIN": set(CRUD_ACTIONS)}, - "topics": {"ADMIN": set(CRUD_ACTIONS)}, - "statuses": {"ADMIN": set(CRUD_ACTIONS)}, - "status_groups": {"ADMIN": set(CRUD_ACTIONS)}, - "form_fields": {"ADMIN": set(CRUD_ACTIONS)}, - "clients": {"ADMIN": set(CRUD_ACTIONS)}, - "table_availability": {"ADMIN": set(CRUD_ACTIONS)}, - "audit_log": {"ADMIN": {"query", "read"}}, - "security_audit_log": {"ADMIN": {"query", "read"}}, - "otp_sessions": {"ADMIN": {"query", "read"}}, - "admin_users": {"ADMIN": set(CRUD_ACTIONS)}, - "admin_user_topics": {"ADMIN": set(CRUD_ACTIONS)}, - "landing_featured_staff": {"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_templates": {"ADMIN": set(CRUD_ACTIONS)}, - "request_data_template_items": {"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 _lawyer_actor_id_or_401(admin: dict) -> str: - actor_id = str(admin.get("sub") or "").strip() - if not actor_id: - raise HTTPException(status_code=401, detail="Некорректный токен") - return actor_id - - -def _ensure_lawyer_can_view_request_or_403(admin: dict, req: Request) -> None: - if not _is_lawyer(admin): - return - actor_id = _lawyer_actor_id_or_401(admin) - assigned = str(req.assigned_lawyer_id or "").strip() - if assigned and assigned != actor_id: - raise HTTPException(status_code=403, detail="Юрист может видеть только свои и неназначенные заявки") - - -def _ensure_lawyer_can_manage_request_or_403(admin: dict, req: Request) -> None: - if not _is_lawyer(admin): - return - actor_id = _lawyer_actor_id_or_401(admin) - assigned = str(req.assigned_lawyer_id or "").strip() - if not assigned or assigned != actor_id: - raise HTTPException(status_code=403, detail="Юрист может работать только со своими назначенными заявками") - - -def _request_for_related_row_or_404(db: Session, row: Any) -> Request: - request_id = getattr(row, "request_id", None) - if request_id is None: - raise HTTPException(status_code=400, detail="Связанная заявка не найдена") - req = db.get(Request, request_id) - if req is None: - raise HTTPException(status_code=404, detail="Заявка не найдена") - return req - - -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 _column_kind(column: Any) -> str: - col_type = column.type - if isinstance(col_type, Boolean): - return "boolean" - if isinstance(col_type, (Integer, Numeric, Float)): - return "number" - if isinstance(col_type, DateTime): - return "datetime" - if isinstance(col_type, Date): - return "date" - if isinstance(col_type, JSON): - return "json" - try: - python_type = col_type.python_type - except Exception: - python_type = None - if python_type is uuid.UUID: - return "uuid" - return "text" - - -def _table_label(table_name: str) -> str: - normalized = _normalize_table_name(table_name) - if not normalized: - return "Таблица" - - explicit_labels = { - "requests": "Заявки", - "invoices": "Счета", - "quotes": "Цитаты", - "topics": "Темы", - "statuses": "Статусы", - "status_groups": "Группы статусов", - "form_fields": "Поля формы", - "clients": "Клиенты", - "table_availability": "Доступность таблиц", - "topic_required_fields": "Обязательные поля темы", - "topic_data_templates": "Дополнительные данные", - "request_data_templates": "Шаблоны доп. данных", - "request_data_template_items": "Набор данных шаблона", - "topic_status_transitions": "Переходы статусов темы", - "admin_users": "Пользователи", - "admin_user_topics": "Дополнительные темы юристов", - "landing_featured_staff": "Карусель сотрудников лендинга", - "attachments": "Вложения", - "messages": "Сообщения", - "audit_log": "Журнал аудита", - "security_audit_log": "Журнал безопасности файлов", - "status_history": "История статусов", - "request_data_requirements": "Требования данных заявки", - "otp_sessions": "OTP-сессии", - "notifications": "Уведомления", - } - if normalized in explicit_labels: - return explicit_labels[normalized] - - return _humanize_identifier_ru(normalized) - - -def _humanize_identifier_ru(identifier: str) -> str: - normalized = _normalize_table_name(identifier) - if not normalized: - return "Таблица" - - token_labels = { - "request": "заявка", - "requests": "заявки", - "invoice": "счет", - "invoices": "счета", - "topic": "тема", - "topics": "темы", - "status": "статус", - "statuses": "статусы", - "transition": "переход", - "transitions": "переходы", - "required": "обязательные", - "form": "формы", - "field": "поле", - "fields": "поля", - "template": "шаблон", - "templates": "шаблоны", - "data": "данных", - "requirement": "требование", - "requirements": "требования", - "admin": "админ", - "user": "пользователь", - "users": "пользователи", - "quote": "цитата", - "quotes": "цитаты", - "message": "сообщение", - "messages": "сообщения", - "attachment": "вложение", - "attachments": "вложения", - "notification": "уведомление", - "notifications": "уведомления", - "audit": "аудита", - "security": "безопасности", - "log": "журнал", - "history": "история", - "otp": "OTP", - "session": "сессия", - "sessions": "сессии", - "id": "ID", - } - words = [token_labels.get(token, token) for token in normalized.split("_") if token] - if not words: - return "Таблица" - phrase = " ".join(words).strip() - return phrase[:1].upper() + phrase[1:] if phrase else "Таблица" - - -def _column_label(table_name: str, column_name: str) -> str: - normalized_table = _normalize_table_name(table_name) - normalized_column = _normalize_table_name(column_name) - if not normalized_column: - return "Поле" - - table_overrides = { - ("invoices", "request_id"): "ID заявки", - ("invoices", "issued_by_admin_user_id"): "ID сотрудника", - ("request_data_requirements", "request_id"): "ID заявки", - } - if (normalized_table, normalized_column) in table_overrides: - return table_overrides[(normalized_table, normalized_column)] - - explicit = { - "id": "ID", - "code": "Код", - "key": "Ключ", - "name": "Название", - "label": "Метка", - "caption": "Подпись", - "value_type": "Тип значения", - "document_name": "Документ", - "request_data_template_id": "Шаблон", - "request_data_template_item_id": "Элемент шаблона", - "text": "Текст", - "description": "Описание", - "request_message_id": "ID сообщения запроса", - "field_type": "Тип поля", - "value_text": "Данные", - "author": "Автор", - "source": "Источник", - "email": "Email", - "role": "Роль", - "kind": "Тип", - "status_group_id": "Группа", - "status": "Статус", - "status_code": "Статус", - "topic_code": "Тема", - "from_status": "Из статуса", - "to_status": "В статус", - "track_number": "Номер заявки", - "invoice_number": "Номер счета", - "invoice_template": "Шаблон счета", - "amount": "Сумма", - "currency": "Валюта", - "client_name": "Клиент", - "client_id": "Клиент (ID)", - "client_phone": "Телефон", - "payer_display_name": "Плательщик", - "payer_details_encrypted": "Реквизиты (шифр.)", - "issued_at": "Дата формирования", - "paid_at": "Дата оплаты", - "created_at": "Дата создания", - "updated_at": "Дата обновления", - "responsible": "Ответственный", - "sort_order": "Порядок", - "pinned": "Закреплен", - "is_active": "Активен", - "enabled": "Активен", - "required": "Обязательное", - "nullable": "Может быть пустым", - "is_terminal": "Терминальный", - "request_id": "ID заявки", - "admin_user_id": "ID пользователя", - "assigned_lawyer_id": "Назначенный юрист", - "issued_by_admin_user_id": "ID сотрудника", - "primary_topic_code": "Профильная тема", - "default_rate": "Ставка по умолчанию", - "effective_rate": "Ставка (фикс.)", - "request_cost": "Стоимость заявки", - "salary_percent": "Процент зарплаты", - "invoice_amount": "Сумма счета", - "paid_by_admin_id": "Оплату подтвердил", - "extra_fields": "Доп. поля", - "total_attachments_bytes": "Размер вложений (байт)", - "type": "Тип", - "options": "Опции", - "field_key": "Поле формы", - "sla_hours": "SLA (часы)", - "required_data_keys": "Обязательные данные шага", - "required_mime_types": "Обязательные файлы шага", - "avatar_url": "Аватар", - "file_name": "Имя файла", - "mime_type": "MIME-тип", - "size_bytes": "Размер (байт)", - "s3_key": "Ключ S3", - "author_type": "Автор", - "is_fulfilled": "Выполнено", - "requested_by_admin_user_id": "Запросил сотрудник", - "fulfilled_at": "Дата выполнения", - "title": "Заголовок", - "body": "Текст", - "event_type": "Тип события", - "is_read": "Прочитано", - "read_at": "Дата прочтения", - "notified_at": "Дата уведомления", - "otp_code": "OTP-код", - "phone": "Телефон", - "verified_at": "Подтверждено", - "expires_at": "Истекает", - "action": "Действие", - "entity": "Сущность", - "entity_id": "ID сущности", - "actor_admin_id": "ID автора", - "actor_role": "Роль субъекта", - "actor_subject": "Субъект", - "actor_ip": "IP адрес", - "allowed": "Разрешено", - "reason": "Причина", - "diff": "Изменения", - "details": "Детали", - "table_name": "Таблица", - } - if normalized_column in explicit: - return explicit[normalized_column] - - return _humanize_identifier_ru(normalized_column) - - -def _pluralize_identifier(base: str) -> list[str]: - token = _normalize_table_name(base) - if not token: - return [] - candidates = [token] - if token.endswith("y"): - candidates.append(token[:-1] + "ies") - candidates.append(token + "s") - return list(dict.fromkeys(candidates)) - - -def _reference_override(table_name: str, column_name: str) -> tuple[str, str] | None: - normalized_table = _normalize_table_name(table_name) - normalized_column = _normalize_table_name(column_name) - explicit: dict[tuple[str, str], tuple[str, str]] = { - ("requests", "assigned_lawyer_id"): ("admin_users", "id"), - ("requests", "paid_by_admin_id"): ("admin_users", "id"), - ("requests", "topic_code"): ("topics", "code"), - ("requests", "status_code"): ("statuses", "code"), - ("statuses", "status_group_id"): ("status_groups", "id"), - ("topic_required_fields", "topic_code"): ("topics", "code"), - ("topic_required_fields", "field_key"): ("form_fields", "key"), - ("topic_data_templates", "topic_code"): ("topics", "code"), - ("request_data_templates", "topic_code"): ("topics", "code"), - ("request_data_templates", "created_by_admin_id"): ("admin_users", "id"), - ("request_data_template_items", "request_data_template_id"): ("request_data_templates", "id"), - ("request_data_template_items", "topic_data_template_id"): ("topic_data_templates", "id"), - ("topic_status_transitions", "topic_code"): ("topics", "code"), - ("topic_status_transitions", "from_status"): ("statuses", "code"), - ("topic_status_transitions", "to_status"): ("statuses", "code"), - ("admin_users", "primary_topic_code"): ("topics", "code"), - ("admin_user_topics", "admin_user_id"): ("admin_users", "id"), - ("admin_user_topics", "topic_code"): ("topics", "code"), - ("landing_featured_staff", "admin_user_id"): ("admin_users", "id"), - ("request_data_requirements", "request_id"): ("requests", "id"), - ("request_data_requirements", "topic_template_id"): ("topic_data_templates", "id"), - ("request_data_requirements", "created_by_admin_id"): ("admin_users", "id"), - ("messages", "request_id"): ("requests", "id"), - ("attachments", "request_id"): ("requests", "id"), - ("attachments", "message_id"): ("messages", "id"), - ("invoices", "request_id"): ("requests", "id"), - ("invoices", "client_id"): ("clients", "id"), - ("invoices", "issued_by_admin_user_id"): ("admin_users", "id"), - ("notifications", "recipient_admin_user_id"): ("admin_users", "id"), - ("status_history", "request_id"): ("requests", "id"), - ("status_history", "changed_by_admin_id"): ("admin_users", "id"), - ("audit_log", "actor_admin_id"): ("admin_users", "id"), - } - if (normalized_table, normalized_column) in explicit: - return explicit[(normalized_table, normalized_column)] - return None - - -def _detect_reference_for_column(table_name: str, column_name: str) -> tuple[str, str] | None: - override = _reference_override(table_name, column_name) - if override is not None: - return override - - normalized = _normalize_table_name(column_name) - table_models = _table_model_map() - - if normalized.endswith("_id") and normalized not in {"id"}: - base = normalized[:-3] - for candidate in _pluralize_identifier(base): - if candidate in table_models: - return candidate, "id" - if base.endswith("_admin_user"): - return "admin_users", "id" - if base.endswith("_lawyer"): - return "admin_users", "id" - - if normalized.endswith("_code"): - base = normalized[:-5] - for candidate in _pluralize_identifier(base): - if candidate in table_models: - return candidate, "code" - - return None - - -def _reference_label_field(table_name: str, value_field: str) -> str: - explicit = { - "admin_users": "name", - "clients": "full_name", - "requests": "track_number", - "topics": "name", - "statuses": "name", - "status_groups": "name", - "form_fields": "label", - "topic_data_templates": "label", - "request_data_templates": "name", - "request_data_template_items": "label", - "invoices": "invoice_number", - "messages": "body", - "attachments": "file_name", - } - if table_name in explicit: - return explicit[table_name] - - _, model = _resolve_table_model(table_name) - mapper = sa_inspect(model) - hidden = _hidden_response_fields(table_name) - blocked = {"id", value_field, "created_at", "updated_at", "responsible"} - for column in mapper.columns: - name = str(column.key) - if name in hidden or name in blocked: - continue - return name - return value_field - - -def _reference_meta_for_column(table_name: str, column_name: str) -> dict[str, str] | None: - detected = _detect_reference_for_column(table_name, column_name) - if detected is None: - return None - ref_table, value_field = detected - try: - label_field = _reference_label_field(ref_table, value_field) - except HTTPException: - return None - return { - "table": ref_table, - "value_field": value_field, - "label_field": label_field, - } - - -def _default_sort_for_table(model: type) -> list[dict[str, str]]: - columns = _columns_map(model) - if "sort_order" in columns: - return [{"field": "sort_order", "dir": "asc"}] - if "created_at" in columns: - return [{"field": "created_at", "dir": "desc"}] - pk = sa_inspect(model).primary_key - if pk: - return [{"field": pk[0].key, "dir": "asc"}] - return [] - - -def _table_columns_meta(table_name: str, model: type) -> list[dict[str, Any]]: - mapper = sa_inspect(model) - hidden = _hidden_response_fields(table_name) - protected = _protected_input_fields(table_name) - primary_keys = {column.key for column in mapper.primary_key} - out: list[dict[str, Any]] = [] - for column in mapper.columns: - name = column.key - if name in hidden: - continue - kind = _column_kind(column) - has_default = column.default is not None or column.server_default is not None or name in primary_keys - editable = name not in SYSTEM_FIELDS and name not in protected and name not in primary_keys - item = { - "name": name, - "label": _column_label(table_name, name), - "kind": kind, - "nullable": bool(column.nullable), - "editable": bool(editable), - "sortable": True, - "filterable": kind != "json", - "required_on_create": not bool(column.nullable) and not bool(has_default) and bool(editable), - "has_default": bool(has_default), - "is_primary_key": name in primary_keys, - } - reference = _reference_meta_for_column(table_name, name) - if reference is not None: - item["reference"] = reference - out.append(item) - return out - - -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"} - if table_name == "requests": - return {"client_id", *REQUEST_CALCULATED_FIELDS} - if table_name == "invoices": - return {"client_id", *INVOICE_CALCULATED_FIELDS} - 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 _normalize_client_phone(value: Any) -> str: - text = str(value or "").strip() - if not text: - return "" - allowed = {"+", "(", ")", "-", " "} - return "".join(ch for ch in text if ch.isdigit() or ch in allowed).strip() - - -def _upsert_client_or_400(db: Session, *, full_name: Any, phone: Any, responsible: str) -> Client: - normalized_phone = _normalize_client_phone(phone) - if not normalized_phone: - raise HTTPException(status_code=400, detail='Поле "client_phone" обязательно') - normalized_name = str(full_name or "").strip() or "Клиент" - - row = db.query(Client).filter(Client.phone == normalized_phone).first() - if row is None: - row = Client( - full_name=normalized_name, - phone=normalized_phone, - responsible=responsible or "Администратор системы", - ) - db.add(row) - db.flush() - return row - - changed = False - if normalized_name and row.full_name != normalized_name: - row.full_name = normalized_name - changed = True - if responsible and row.responsible != responsible: - row.responsible = responsible - changed = True - if changed: - db.add(row) - db.flush() - return row - - -def _request_for_uuid_or_400(db: Session, raw_request_id: Any) -> Request: - request_uuid = _parse_uuid_or_400(raw_request_id, "request_id") - req = db.get(Request, request_uuid) - if req is None: - raise HTTPException(status_code=400, detail="Заявка не найдена") - return req - - -def _active_lawyer_or_400(db: Session, lawyer_id: Any) -> AdminUser: - lawyer_uuid = _parse_uuid_or_400(lawyer_id, "assigned_lawyer_id") - lawyer = db.get(AdminUser, lawyer_uuid) - if lawyer is None or str(lawyer.role or "").upper() != "LAWYER" or not bool(lawyer.is_active): - raise HTTPException(status_code=400, detail="Можно назначить только активного юриста") - return lawyer - - -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 - if "phone" in data: - data["phone"] = _normalize_optional_string(_normalize_client_phone(data.get("phone"))) - 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 "phone" in data: - data["phone"] = _normalize_optional_string(_normalize_client_phone(data.get("phone"))) - 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 _normalize_string_list_or_400(value: Any, field_name: str) -> list[str] | None: - if value is None: - return None - - source = value - if isinstance(source, str): - text = source.strip() - if not text: - return None - if text.startswith("["): - try: - source = json.loads(text) - except json.JSONDecodeError: - raise HTTPException(status_code=400, detail=f'Поле "{field_name}" должно быть JSON-массивом строк') - else: - source = [chunk.strip() for chunk in text.replace("\n", ",").split(",")] - - if not isinstance(source, (list, tuple, set)): - raise HTTPException(status_code=400, detail=f'Поле "{field_name}" должно быть массивом строк') - - out: list[str] = [] - seen: set[str] = set() - for item in source: - text = str(item or "").strip() - if not text: - continue - lowered = text.lower() - if lowered in seen: - continue - seen.add(lowered) - out.append(text) - return out - - -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 - if "value_type" in data: - value_type = str(data.get("value_type") or "").strip().lower() - if value_type not in ALLOWED_REQUEST_DATA_VALUE_TYPES: - raise HTTPException(status_code=400, detail='Поле "value_type" должно быть одним из: string, text, date, number, file') - data["value_type"] = value_type - if "document_name" in data: - data["document_name"] = _normalize_optional_string(data.get("document_name")) - return data - - -def _apply_request_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 "name" in data: - name = str(data.get("name") or "").strip() - if not name: - raise HTTPException(status_code=400, detail='Поле "name" не может быть пустым') - data["name"] = name - if "description" in data: - data["description"] = _normalize_optional_string(data.get("description")) - if "created_by_admin_id" in data and data.get("created_by_admin_id") is not None: - admin_id = _parse_uuid_or_400(data.get("created_by_admin_id"), "created_by_admin_id") - admin_user = db.get(AdminUser, admin_id) - if admin_user is None: - raise HTTPException(status_code=400, detail="Пользователь не найден") - data["created_by_admin_id"] = admin_id - return data - - -def _apply_request_data_template_items_fields(db: Session, payload: dict[str, Any]) -> dict[str, Any]: - data = dict(payload) - template = None - if "request_data_template_id" in data: - template_id = _parse_uuid_or_400(data.get("request_data_template_id"), "request_data_template_id") - template = db.get(RequestDataTemplate, template_id) - if template is None: - raise HTTPException(status_code=400, detail="Шаблон не найден") - data["request_data_template_id"] = template_id - if "topic_data_template_id" in data and data.get("topic_data_template_id") is not None: - catalog_id = _parse_uuid_or_400(data.get("topic_data_template_id"), "topic_data_template_id") - catalog = db.get(TopicDataTemplate, catalog_id) - if catalog is None: - raise HTTPException(status_code=400, detail="Поле доп. данных не найдено") - data["topic_data_template_id"] = catalog_id - if "key" not in data or not str(data.get("key") or "").strip(): - data["key"] = str(catalog.key or "").strip() - if "label" not in data or not str(data.get("label") or "").strip(): - data["label"] = str(catalog.label or catalog.key or "").strip() - if "value_type" not in data or not str(data.get("value_type") or "").strip(): - data["value_type"] = str(catalog.value_type or "string") - if template is not None and str(template.topic_code or "").strip() and str(catalog.topic_code or "").strip(): - if str(template.topic_code) != str(catalog.topic_code): - raise HTTPException(status_code=400, detail="Поле не соответствует теме шаблона") - if "key" in data: - key = str(data.get("key") or "").strip() - if not key: - raise HTTPException(status_code=400, detail='Поле "key" не может быть пустым') - data["key"] = key[:80] - if "label" in data: - label = str(data.get("label") or "").strip() - if not label: - raise HTTPException(status_code=400, detail='Поле "label" не может быть пустым') - data["label"] = label - if "value_type" in data: - value_type = str(data.get("value_type") or "").strip().lower() - if value_type not in ALLOWED_REQUEST_DATA_VALUE_TYPES: - raise HTTPException(status_code=400, detail='Поле "value_type" должно быть одним из: string, text, date, number, file') - data["value_type"] = value_type - if "sort_order" in data: - raw = data.get("sort_order") - if raw is None or str(raw).strip() == "": - data["sort_order"] = 0 - else: - try: - data["sort_order"] = int(raw) - except (TypeError, ValueError): - raise HTTPException(status_code=400, detail='Поле "sort_order" должно быть целым числом') - 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 "request_message_id" in data and data.get("request_message_id") is not None: - data["request_message_id"] = _parse_uuid_or_400(data.get("request_message_id"), "request_message_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 - if "field_type" in data: - field_type = str(data.get("field_type") or "").strip().lower() - if field_type not in ALLOWED_REQUEST_DATA_VALUE_TYPES: - raise HTTPException(status_code=400, detail='Поле "field_type" должно быть одним из: string, text, date, number, file') - data["field_type"] = field_type - if "document_name" in data: - data["document_name"] = _normalize_optional_string(data.get("document_name")) - if "value_text" in data: - data["value_text"] = _normalize_optional_string(data.get("value_text")) - if "sort_order" in data: - raw_sort = data.get("sort_order") - if raw_sort is None or str(raw_sort).strip() == "": - data["sort_order"] = 0 - else: - try: - data["sort_order"] = int(raw_sort) - except (TypeError, ValueError): - raise HTTPException(status_code=400, detail='Поле "sort_order" должно быть целым числом') - 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") - if "required_data_keys" in data: - data["required_data_keys"] = _normalize_string_list_or_400(data.get("required_data_keys"), "required_data_keys") - if "required_mime_types" in data: - data["required_mime_types"] = _normalize_string_list_or_400(data.get("required_mime_types"), "required_mime_types") - - return data - - -def _apply_status_fields(db: Session, payload: dict[str, Any]) -> dict[str, Any]: - data = dict(payload) - if "kind" in data: - data["kind"] = normalize_status_kind_or_400(data.get("kind")) - if "status_group_id" in data: - raw_group = data.get("status_group_id") - if raw_group is None or str(raw_group).strip() == "": - data["status_group_id"] = None - else: - group_id = _parse_uuid_or_400(raw_group, "status_group_id") - group = db.get(StatusGroup, group_id) - if group is None: - raise HTTPException(status_code=400, detail="Группа статусов не найдена") - data["status_group_id"] = group_id - if "invoice_template" in data: - text = str(data.get("invoice_template") or "").strip() - data["invoice_template"] = text or None - 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, - ) - - -def _table_section(table_name: str) -> str: - if table_name in {"requests", "invoices"}: - return "main" - if table_name == "table_availability": - return "system" - return "dictionary" - - -def _table_availability_map(db: Session) -> dict[str, TableAvailability]: - rows = db.query(TableAvailability).all() - return {str(row.table_name): row for row in rows if row and row.table_name} - - -def _table_is_active(table_name: str, availability: dict[str, TableAvailability]) -> bool: - row = availability.get(table_name) - if row is None: - return True - return bool(row.is_active) - - -def _meta_tables_payload( - db: Session, - *, - role: str, - include_inactive_dictionaries: bool, -) -> list[dict[str, Any]]: - table_models = _table_model_map() - availability = _table_availability_map(db) - rows: list[dict[str, Any]] = [] - for table_name in sorted(table_models.keys()): - model = table_models[table_name] - section = _table_section(table_name) - is_active = _table_is_active(table_name, availability) - if section == "dictionary" and not include_inactive_dictionaries and not is_active: - continue - actions = sorted(_allowed_actions(role, table_name)) - rows.append( - { - "key": table_name, - "table": table_name, - "label": _table_label(table_name), - "section": section, - "is_active": is_active, - "actions": actions, - "query_endpoint": f"/api/admin/crud/{table_name}/query", - "create_endpoint": f"/api/admin/crud/{table_name}", - "update_endpoint_template": f"/api/admin/crud/{table_name}" + "/{id}", - "delete_endpoint_template": f"/api/admin/crud/{table_name}" + "/{id}", - "default_sort": _default_sort_for_table(model), - "columns": _table_columns_meta(table_name, model), - } - ) - return rows - - -class TableAvailabilityUpdatePayload(BaseModel): - is_active: bool - - -@router.get("/meta/tables") -def list_tables_meta(db: Session = Depends(get_db), admin: dict = Depends(get_current_admin)): - role = str(admin.get("role") or "").upper() - if role != "ADMIN": - raise HTTPException(status_code=403, detail="Недостаточно прав") - return {"tables": _meta_tables_payload(db, role=role, include_inactive_dictionaries=False)} - - -@router.get("/meta/available-tables") -def list_available_tables(db: Session = Depends(get_db), admin: dict = Depends(get_current_admin)): - role = str(admin.get("role") or "").upper() - if role != "ADMIN": - raise HTTPException(status_code=403, detail="Недостаточно прав") - - availability = _table_availability_map(db) - rows = [] - for item in _meta_tables_payload(db, role=role, include_inactive_dictionaries=True): - table_name = str(item.get("table") or "") - state = availability.get(table_name) - rows.append( - { - "table": table_name, - "label": item.get("label"), - "section": item.get("section"), - "is_active": bool(item.get("is_active")), - "responsible": state.responsible if state is not None else None, - "updated_at": _serialize_value(state.updated_at) if state is not None else None, - } - ) - return {"rows": rows, "total": len(rows)} - - -@router.patch("/meta/available-tables/{table_name}") -def update_available_table( - table_name: str, - payload: TableAvailabilityUpdatePayload, - db: Session = Depends(get_db), - admin: dict = Depends(get_current_admin), -): - role = str(admin.get("role") or "").upper() - if role != "ADMIN": - raise HTTPException(status_code=403, detail="Недостаточно прав") - - normalized, _ = _resolve_table_model(table_name) - row = db.query(TableAvailability).filter(TableAvailability.table_name == normalized).first() - responsible = _resolve_responsible(admin) - is_active = bool(payload.is_active) - if row is None: - row = TableAvailability( - table_name=normalized, - is_active=is_active, - responsible=responsible, - ) - db.add(row) - else: - row.is_active = is_active - row.updated_at = datetime.now(timezone.utc) - row.responsible = responsible - db.add(row) - db.commit() - db.refresh(row) - return { - "table": normalized, - "is_active": bool(row.is_active), - "responsible": row.responsible, - "updated_at": _serialize_value(row.updated_at), - } - - -@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") - base_query = db.query(model) - if normalized == "requests" and _is_lawyer(admin): - actor_id = _lawyer_actor_id_or_401(admin) - base_query = base_query.filter( - or_( - Request.assigned_lawyer_id == actor_id, - Request.assigned_lawyer_id.is_(None), - ) - ) - if normalized == "messages" and _is_lawyer(admin): - actor_id = _lawyer_actor_id_or_401(admin) - base_query = base_query.join(Request, Request.id == Message.request_id).filter( - or_( - Request.assigned_lawyer_id == actor_id, - Request.assigned_lawyer_id.is_(None), - ) - ) - if normalized == "attachments" and _is_lawyer(admin): - actor_id = _lawyer_actor_id_or_401(admin) - base_query = base_query.join(Request, Request.id == Attachment.request_id).filter( - or_( - Request.assigned_lawyer_id == actor_id, - Request.assigned_lawyer_id.is_(None), - ) - ) - query = apply_universal_query(base_query, 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: - _ensure_lawyer_can_view_request_or_403(admin, req) - 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 - if normalized == "messages" and isinstance(row, Message): - req = _request_for_related_row_or_404(db, row) - _ensure_lawyer_can_view_request_or_403(admin, req) - if normalized == "attachments" and isinstance(row, Attachment): - req = _request_for_related_row_or_404(db, row) - _ensure_lawyer_can_view_request_or_403(admin, req) - payload = _strip_hidden_fields(normalized, _row_to_dict(row)) - if normalized == "requests" and isinstance(row, Request): - assigned_lawyer_id = str(row.assigned_lawyer_id or "").strip() - if assigned_lawyer_id: - try: - lawyer_uuid = uuid.UUID(assigned_lawyer_id) - except ValueError: - lawyer_uuid = None - if lawyer_uuid is not None: - lawyer = db.get(AdminUser, lawyer_uuid) - if lawyer is not None: - payload["assigned_lawyer_name"] = lawyer.name or lawyer.email or assigned_lawyer_id - payload["assigned_lawyer_phone"] = _serialize_value(getattr(lawyer, "phone", None)) - return payload - - -@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") - responsible = _resolve_responsible(admin) - resolved_request_client_id: uuid.UUID | None = None - resolved_invoice_client_id: uuid.UUID | None = None - if normalized == "requests" and _is_lawyer(admin) and isinstance(payload, dict): - assigned_lawyer_id = payload.get("assigned_lawyer_id") - if str(assigned_lawyer_id or "").strip(): - raise HTTPException(status_code=403, detail='Юрист не может назначать заявку при создании') - forbidden_fields = sorted(REQUEST_FINANCIAL_FIELDS.intersection(set(payload.keys()))) - if forbidden_fields: - raise HTTPException(status_code=403, detail="Юрист не может изменять финансовые поля заявки") - - prepared = _prepare_create_payload(normalized, payload) - if normalized == "messages": - request_uuid = _parse_uuid_or_400(prepared.get("request_id"), "request_id") - req = db.get(Request, request_uuid) - if req is None: - raise HTTPException(status_code=404, detail="Заявка не найдена") - if _is_lawyer(admin): - _ensure_lawyer_can_manage_request_or_403(admin, req) - prepared["author_type"] = "LAWYER" - prepared["author_name"] = str(admin.get("email") or "").strip() or "Юрист" - prepared["immutable"] = False - prepared["request_id"] = request_uuid - if normalized == "requests": - validate_required_topic_fields_or_400(db, prepared.get("topic_code"), prepared.get("extra_fields")) - client = _upsert_client_or_400( - db, - full_name=prepared.get("client_name"), - phone=prepared.get("client_phone"), - responsible=responsible, - ) - resolved_request_client_id = client.id - prepared["client_name"] = client.full_name - prepared["client_phone"] = client.phone - if not _is_lawyer(admin): - assigned_raw = prepared.get("assigned_lawyer_id") - if assigned_raw is None or not str(assigned_raw).strip(): - if "assigned_lawyer_id" in prepared: - prepared["assigned_lawyer_id"] = None - else: - assigned_lawyer = _active_lawyer_or_400(db, assigned_raw) - prepared["assigned_lawyer_id"] = str(assigned_lawyer.id) - if prepared.get("effective_rate") is None: - prepared["effective_rate"] = assigned_lawyer.default_rate - if normalized == "invoices": - req = _request_for_uuid_or_400(db, prepared.get("request_id")) - prepared["request_id"] = req.id - resolved_invoice_client_id = req.client_id - 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_templates": - clean_payload = _apply_request_data_templates_fields(db, clean_payload) - if normalized == "request_data_template_items": - clean_payload = _apply_request_data_template_items_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 normalized == "statuses": - clean_payload = _apply_status_fields(db, clean_payload) - if normalized == "requests": - clean_payload["client_id"] = resolved_request_client_id - if normalized == "invoices": - clean_payload["client_id"] = resolved_invoice_client_id - if "responsible" in _columns_map(model): - clean_payload["responsible"] = responsible - 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") - responsible = _resolve_responsible(admin) - if normalized == "requests" and _is_lawyer(admin) and isinstance(payload, dict): - if "assigned_lawyer_id" in payload: - raise HTTPException(status_code=403, detail='Назначение доступно только через действие "Взять в работу"') - forbidden_fields = sorted(REQUEST_FINANCIAL_FIELDS.intersection(set(payload.keys()))) - if forbidden_fields: - raise HTTPException(status_code=403, detail="Юрист не может изменять финансовые поля заявки") - row = _load_row_or_404(db, model, row_id) - if normalized == "requests" and isinstance(row, Request): - _ensure_lawyer_can_manage_request_or_403(admin, row) - 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_templates": - clean_payload = _apply_request_data_templates_fields(db, clean_payload) - if normalized == "request_data_template_items": - clean_payload = _apply_request_data_template_items_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 normalized == "statuses": - clean_payload = _apply_status_fields(db, clean_payload) - if normalized == "requests" and isinstance(row, Request): - if {"client_name", "client_phone"}.intersection(set(clean_payload.keys())) or row.client_id is None: - client = _upsert_client_or_400( - db, - full_name=clean_payload.get("client_name", row.client_name), - phone=clean_payload.get("client_phone", row.client_phone), - responsible=responsible, - ) - clean_payload["client_id"] = client.id - clean_payload["client_name"] = client.full_name - clean_payload["client_phone"] = client.phone - if normalized == "invoices": - if "request_id" in clean_payload: - req = _request_for_uuid_or_400(db, clean_payload.get("request_id")) - clean_payload["request_id"] = req.id - clean_payload["client_id"] = req.client_id - elif getattr(row, "client_id", None) is None: - req = db.get(Request, getattr(row, "request_id", None)) - if req is not None: - clean_payload["client_id"] = req.client_id - if normalized == "requests" and not _is_lawyer(admin) and "assigned_lawyer_id" in clean_payload: - assigned_raw = clean_payload.get("assigned_lawyer_id") - if assigned_raw is None or not str(assigned_raw).strip(): - clean_payload["assigned_lawyer_id"] = None - else: - assigned_lawyer = _active_lawyer_or_400(db, assigned_raw) - clean_payload["assigned_lawyer_id"] = str(assigned_lawyer.id) - if isinstance(row, Request) and row.effective_rate is None and "effective_rate" not in clean_payload: - clean_payload["effective_rate"] = assigned_lawyer.default_rate - if "responsible" in _columns_map(model): - clean_payload["responsible"] = responsible - 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 "") - if before_status != after_status and isinstance(row, Request): - if "important_date_at" not in clean_payload or clean_payload.get("important_date_at") is None: - clean_payload["important_date_at"] = datetime.now(timezone.utc) + timedelta(days=3) - billing_note = apply_billing_transition_effects( - db, - req=row, - from_status=before_status, - to_status=after_status, - admin=admin, - responsible=responsible, - ) - mark_unread_for_client(row, EVENT_STATUS) - apply_status_change_effects( - db, - row, - from_status=before_status, - to_status=after_status, - admin=admin, - important_date_at=clean_payload.get("important_date_at"), - responsible=responsible, - ) - 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}" - + ( - f"\nВажная дата: {clean_payload.get('important_date_at').isoformat()}" - if isinstance(clean_payload.get("important_date_at"), datetime) - else "" - ) - + (f"\n{billing_note}" if billing_note else "") - ), - responsible=responsible, - ) - 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 == "requests" and isinstance(row, Request): - _ensure_lawyer_can_manage_request_or_403(admin, row) - 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} +__all__ = ["router"] diff --git a/app/api/admin/crud_modules/__init__.py b/app/api/admin/crud_modules/__init__.py new file mode 100644 index 0000000..5bc0c2e --- /dev/null +++ b/app/api/admin/crud_modules/__init__.py @@ -0,0 +1,3 @@ +from .router import router + +__all__ = ["router"] diff --git a/app/api/admin/crud_modules/access.py b/app/api/admin/crud_modules/access.py new file mode 100644 index 0000000..0971a28 --- /dev/null +++ b/app/api/admin/crud_modules/access.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +import importlib +import pkgutil +from functools import lru_cache +from typing import Any + +from fastapi import HTTPException +from sqlalchemy.orm import Session + +import app.models as models_pkg +from app.db.session import Base +from app.models.request import Request + +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", +} +REQUEST_FINANCIAL_FIELDS = {"effective_rate", "invoice_amount", "paid_at", "paid_by_admin_id"} +REQUEST_CALCULATED_FIELDS = {"invoice_amount", "paid_at", "paid_by_admin_id", "total_attachments_bytes"} +INVOICE_CALCULATED_FIELDS = {"issued_by_admin_user_id", "issued_by_role", "issued_at", "paid_at"} +ALLOWED_ADMIN_ROLES = {"ADMIN", "LAWYER", "CURATOR"} +ALLOWED_REQUEST_DATA_VALUE_TYPES = {"string", "text", "date", "number", "file"} + +# 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), + "CURATOR": {"query", "read"}, + }, + "messages": { + "ADMIN": set(CRUD_ACTIONS), + "LAWYER": {"query", "read", "create"}, + }, + "attachments": { + "ADMIN": set(CRUD_ACTIONS), + "LAWYER": {"query", "read"}, + }, + "quotes": {"ADMIN": set(CRUD_ACTIONS)}, + "topics": {"ADMIN": set(CRUD_ACTIONS)}, + "statuses": {"ADMIN": set(CRUD_ACTIONS)}, + "status_groups": {"ADMIN": set(CRUD_ACTIONS)}, + "form_fields": {"ADMIN": set(CRUD_ACTIONS)}, + "clients": {"ADMIN": set(CRUD_ACTIONS)}, + "table_availability": {"ADMIN": set(CRUD_ACTIONS)}, + "audit_log": {"ADMIN": {"query", "read"}}, + "security_audit_log": {"ADMIN": {"query", "read"}}, + "otp_sessions": {"ADMIN": {"query", "read"}}, + "admin_users": {"ADMIN": set(CRUD_ACTIONS)}, + "admin_user_topics": {"ADMIN": set(CRUD_ACTIONS)}, + "landing_featured_staff": {"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_templates": {"ADMIN": set(CRUD_ACTIONS)}, + "request_data_template_items": {"ADMIN": set(CRUD_ACTIONS)}, + "request_data_requirements": {"ADMIN": set(CRUD_ACTIONS)}, + "request_service_requests": { + "ADMIN": set(CRUD_ACTIONS), + "LAWYER": {"query", "read"}, + "CURATOR": {"query", "read", "update"}, + }, + "notifications": {"ADMIN": {"query", "read", "update"}}, +} + +DEFAULT_ROLE_ACTIONS: dict[str, set[str]] = { + "ADMIN": set(CRUD_ACTIONS), + "CURATOR": {"query", "read"}, +} + + +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 _lawyer_actor_id_or_401(admin: dict) -> str: + actor_id = str(admin.get("sub") or "").strip() + if not actor_id: + raise HTTPException(status_code=401, detail="Некорректный токен") + return actor_id + + +def _ensure_lawyer_can_view_request_or_403(admin: dict, req: Request) -> None: + if not _is_lawyer(admin): + return + actor_id = _lawyer_actor_id_or_401(admin) + assigned = str(req.assigned_lawyer_id or "").strip() + if assigned and assigned != actor_id: + raise HTTPException(status_code=403, detail="Юрист может видеть только свои и неназначенные заявки") + + +def _ensure_lawyer_can_manage_request_or_403(admin: dict, req: Request) -> None: + if not _is_lawyer(admin): + return + actor_id = _lawyer_actor_id_or_401(admin) + assigned = str(req.assigned_lawyer_id or "").strip() + if not assigned or assigned != actor_id: + raise HTTPException(status_code=403, detail="Юрист может работать только со своими назначенными заявками") + + +def _request_for_related_row_or_404(db: Session, row: Any) -> Request: + request_id = getattr(row, "request_id", None) + if request_id is None: + raise HTTPException(status_code=400, detail="Связанная заявка не найдена") + req = db.get(Request, request_id) + if req is None: + raise HTTPException(status_code=404, detail="Заявка не найдена") + return req diff --git a/app/api/admin/crud_modules/audit.py b/app/api/admin/crud_modules/audit.py new file mode 100644 index 0000000..6d30588 --- /dev/null +++ b/app/api/admin/crud_modules/audit.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import uuid +from typing import Any + +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from app.models.audit_log import AuditLog + +from .meta import _hidden_response_fields + +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" diff --git a/app/api/admin/crud_modules/meta.py b/app/api/admin/crud_modules/meta.py new file mode 100644 index 0000000..fbb4f19 --- /dev/null +++ b/app/api/admin/crud_modules/meta.py @@ -0,0 +1,532 @@ +from __future__ import annotations + +import uuid +from datetime import date, datetime +from decimal import Decimal +from typing import Any + +from fastapi import HTTPException +from sqlalchemy.inspection import inspect as sa_inspect +from sqlalchemy.orm import Session +from sqlalchemy.sql.sqltypes import Boolean, Date, DateTime, Float, Integer, JSON, Numeric + +from app.models.table_availability import TableAvailability + +from .access import ( + REQUEST_CALCULATED_FIELDS, + INVOICE_CALCULATED_FIELDS, + SYSTEM_FIELDS, + _allowed_actions, + _normalize_table_name, + _resolve_table_model, + _table_model_map, +) + +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 _column_kind(column: Any) -> str: + col_type = column.type + if isinstance(col_type, Boolean): + return "boolean" + if isinstance(col_type, (Integer, Numeric, Float)): + return "number" + if isinstance(col_type, DateTime): + return "datetime" + if isinstance(col_type, Date): + return "date" + if isinstance(col_type, JSON): + return "json" + try: + python_type = col_type.python_type + except Exception: + python_type = None + if python_type is uuid.UUID: + return "uuid" + return "text" + + +def _table_label(table_name: str) -> str: + normalized = _normalize_table_name(table_name) + if not normalized: + return "Таблица" + + explicit_labels = { + "requests": "Заявки", + "invoices": "Счета", + "quotes": "Цитаты", + "topics": "Темы", + "statuses": "Статусы", + "status_groups": "Группы статусов", + "form_fields": "Поля формы", + "clients": "Клиенты", + "table_availability": "Доступность таблиц", + "topic_required_fields": "Обязательные поля темы", + "topic_data_templates": "Дополнительные данные", + "request_data_templates": "Шаблоны доп. данных", + "request_data_template_items": "Набор данных шаблона", + "topic_status_transitions": "Переходы статусов темы", + "admin_users": "Пользователи", + "admin_user_topics": "Дополнительные темы юристов", + "landing_featured_staff": "Карусель сотрудников лендинга", + "attachments": "Вложения", + "messages": "Сообщения", + "audit_log": "Журнал аудита", + "security_audit_log": "Журнал безопасности файлов", + "status_history": "История статусов", + "request_data_requirements": "Требования данных заявки", + "request_service_requests": "Запросы", + "otp_sessions": "OTP-сессии", + "notifications": "Уведомления", + } + if normalized in explicit_labels: + return explicit_labels[normalized] + + return _humanize_identifier_ru(normalized) + + +def _humanize_identifier_ru(identifier: str) -> str: + normalized = _normalize_table_name(identifier) + if not normalized: + return "Таблица" + + token_labels = { + "request": "заявка", + "requests": "заявки", + "invoice": "счет", + "invoices": "счета", + "topic": "тема", + "topics": "темы", + "status": "статус", + "statuses": "статусы", + "transition": "переход", + "transitions": "переходы", + "required": "обязательные", + "form": "формы", + "field": "поле", + "fields": "поля", + "template": "шаблон", + "templates": "шаблоны", + "data": "данных", + "requirement": "требование", + "requirements": "требования", + "admin": "админ", + "user": "пользователь", + "users": "пользователи", + "quote": "цитата", + "quotes": "цитаты", + "message": "сообщение", + "messages": "сообщения", + "attachment": "вложение", + "attachments": "вложения", + "notification": "уведомление", + "notifications": "уведомления", + "audit": "аудита", + "security": "безопасности", + "log": "журнал", + "history": "история", + "otp": "OTP", + "session": "сессия", + "sessions": "сессии", + "id": "ID", + } + words = [token_labels.get(token, token) for token in normalized.split("_") if token] + if not words: + return "Таблица" + phrase = " ".join(words).strip() + return phrase[:1].upper() + phrase[1:] if phrase else "Таблица" + + +def _column_label(table_name: str, column_name: str) -> str: + normalized_table = _normalize_table_name(table_name) + normalized_column = _normalize_table_name(column_name) + if not normalized_column: + return "Поле" + + table_overrides = { + ("invoices", "request_id"): "ID заявки", + ("invoices", "issued_by_admin_user_id"): "ID сотрудника", + ("request_data_requirements", "request_id"): "ID заявки", + } + if (normalized_table, normalized_column) in table_overrides: + return table_overrides[(normalized_table, normalized_column)] + + explicit = { + "id": "ID", + "code": "Код", + "key": "Ключ", + "name": "Название", + "label": "Метка", + "caption": "Подпись", + "value_type": "Тип значения", + "document_name": "Документ", + "request_data_template_id": "Шаблон", + "request_data_template_item_id": "Элемент шаблона", + "text": "Текст", + "description": "Описание", + "request_message_id": "ID сообщения запроса", + "created_by_client": "Создан клиентом", + "admin_unread": "Не прочитано администратором", + "lawyer_unread": "Не прочитано юристом", + "admin_read_at": "Прочитано администратором", + "lawyer_read_at": "Прочитано юристом", + "resolved_at": "Дата обработки", + "field_type": "Тип поля", + "value_text": "Данные", + "author": "Автор", + "source": "Источник", + "email": "Email", + "role": "Роль", + "kind": "Тип", + "status_group_id": "Группа", + "status": "Статус", + "status_code": "Статус", + "topic_code": "Тема", + "from_status": "Из статуса", + "to_status": "В статус", + "track_number": "Номер заявки", + "invoice_number": "Номер счета", + "invoice_template": "Шаблон счета", + "amount": "Сумма", + "currency": "Валюта", + "client_name": "Клиент", + "client_id": "Клиент (ID)", + "client_phone": "Телефон", + "payer_display_name": "Плательщик", + "payer_details_encrypted": "Реквизиты (шифр.)", + "issued_at": "Дата формирования", + "paid_at": "Дата оплаты", + "created_at": "Дата создания", + "updated_at": "Дата обновления", + "responsible": "Ответственный", + "sort_order": "Порядок", + "pinned": "Закреплен", + "is_active": "Активен", + "enabled": "Активен", + "required": "Обязательное", + "nullable": "Может быть пустым", + "is_terminal": "Терминальный", + "request_id": "ID заявки", + "admin_user_id": "ID пользователя", + "assigned_lawyer_id": "Назначенный юрист", + "issued_by_admin_user_id": "ID сотрудника", + "primary_topic_code": "Профильная тема", + "default_rate": "Ставка по умолчанию", + "effective_rate": "Ставка (фикс.)", + "request_cost": "Стоимость заявки", + "salary_percent": "Процент зарплаты", + "invoice_amount": "Сумма счета", + "paid_by_admin_id": "Оплату подтвердил", + "resolved_by_admin_id": "Обработал", + "extra_fields": "Доп. поля", + "total_attachments_bytes": "Размер вложений (байт)", + "type": "Тип", + "options": "Опции", + "field_key": "Поле формы", + "sla_hours": "SLA (часы)", + "required_data_keys": "Обязательные данные шага", + "required_mime_types": "Обязательные файлы шага", + "avatar_url": "Аватар", + "file_name": "Имя файла", + "mime_type": "MIME-тип", + "size_bytes": "Размер (байт)", + "s3_key": "Ключ S3", + "author_type": "Автор", + "is_fulfilled": "Выполнено", + "requested_by_admin_user_id": "Запросил сотрудник", + "fulfilled_at": "Дата выполнения", + "title": "Заголовок", + "body": "Текст", + "event_type": "Тип события", + "is_read": "Прочитано", + "read_at": "Дата прочтения", + "notified_at": "Дата уведомления", + "otp_code": "OTP-код", + "phone": "Телефон", + "verified_at": "Подтверждено", + "expires_at": "Истекает", + "action": "Действие", + "entity": "Сущность", + "entity_id": "ID сущности", + "actor_admin_id": "ID автора", + "actor_role": "Роль субъекта", + "actor_subject": "Субъект", + "actor_ip": "IP адрес", + "allowed": "Разрешено", + "reason": "Причина", + "diff": "Изменения", + "details": "Детали", + "table_name": "Таблица", + } + if normalized_column in explicit: + return explicit[normalized_column] + + return _humanize_identifier_ru(normalized_column) + + +def _pluralize_identifier(base: str) -> list[str]: + token = _normalize_table_name(base) + if not token: + return [] + candidates = [token] + if token.endswith("y"): + candidates.append(token[:-1] + "ies") + candidates.append(token + "s") + return list(dict.fromkeys(candidates)) + + +def _reference_override(table_name: str, column_name: str) -> tuple[str, str] | None: + normalized_table = _normalize_table_name(table_name) + normalized_column = _normalize_table_name(column_name) + explicit: dict[tuple[str, str], tuple[str, str]] = { + ("requests", "assigned_lawyer_id"): ("admin_users", "id"), + ("requests", "paid_by_admin_id"): ("admin_users", "id"), + ("requests", "topic_code"): ("topics", "code"), + ("requests", "status_code"): ("statuses", "code"), + ("statuses", "status_group_id"): ("status_groups", "id"), + ("topic_required_fields", "topic_code"): ("topics", "code"), + ("topic_required_fields", "field_key"): ("form_fields", "key"), + ("topic_data_templates", "topic_code"): ("topics", "code"), + ("request_data_templates", "topic_code"): ("topics", "code"), + ("request_data_templates", "created_by_admin_id"): ("admin_users", "id"), + ("request_data_template_items", "request_data_template_id"): ("request_data_templates", "id"), + ("request_data_template_items", "topic_data_template_id"): ("topic_data_templates", "id"), + ("topic_status_transitions", "topic_code"): ("topics", "code"), + ("topic_status_transitions", "from_status"): ("statuses", "code"), + ("topic_status_transitions", "to_status"): ("statuses", "code"), + ("admin_users", "primary_topic_code"): ("topics", "code"), + ("admin_user_topics", "admin_user_id"): ("admin_users", "id"), + ("admin_user_topics", "topic_code"): ("topics", "code"), + ("landing_featured_staff", "admin_user_id"): ("admin_users", "id"), + ("request_data_requirements", "request_id"): ("requests", "id"), + ("request_data_requirements", "topic_template_id"): ("topic_data_templates", "id"), + ("request_data_requirements", "created_by_admin_id"): ("admin_users", "id"), + ("request_service_requests", "request_id"): ("requests", "id"), + ("request_service_requests", "client_id"): ("clients", "id"), + ("request_service_requests", "assigned_lawyer_id"): ("admin_users", "id"), + ("request_service_requests", "resolved_by_admin_id"): ("admin_users", "id"), + ("messages", "request_id"): ("requests", "id"), + ("attachments", "request_id"): ("requests", "id"), + ("attachments", "message_id"): ("messages", "id"), + ("invoices", "request_id"): ("requests", "id"), + ("invoices", "client_id"): ("clients", "id"), + ("invoices", "issued_by_admin_user_id"): ("admin_users", "id"), + ("notifications", "recipient_admin_user_id"): ("admin_users", "id"), + ("status_history", "request_id"): ("requests", "id"), + ("status_history", "changed_by_admin_id"): ("admin_users", "id"), + ("audit_log", "actor_admin_id"): ("admin_users", "id"), + } + if (normalized_table, normalized_column) in explicit: + return explicit[(normalized_table, normalized_column)] + return None + + +def _detect_reference_for_column(table_name: str, column_name: str) -> tuple[str, str] | None: + override = _reference_override(table_name, column_name) + if override is not None: + return override + + normalized = _normalize_table_name(column_name) + table_models = _table_model_map() + + if normalized.endswith("_id") and normalized not in {"id"}: + base = normalized[:-3] + for candidate in _pluralize_identifier(base): + if candidate in table_models: + return candidate, "id" + if base.endswith("_admin_user"): + return "admin_users", "id" + if base.endswith("_lawyer"): + return "admin_users", "id" + + if normalized.endswith("_code"): + base = normalized[:-5] + for candidate in _pluralize_identifier(base): + if candidate in table_models: + return candidate, "code" + + return None + + +def _reference_label_field(table_name: str, value_field: str) -> str: + explicit = { + "admin_users": "name", + "clients": "full_name", + "requests": "track_number", + "topics": "name", + "statuses": "name", + "status_groups": "name", + "form_fields": "label", + "topic_data_templates": "label", + "request_data_templates": "name", + "request_data_template_items": "label", + "invoices": "invoice_number", + "messages": "body", + "attachments": "file_name", + } + if table_name in explicit: + return explicit[table_name] + + _, model = _resolve_table_model(table_name) + mapper = sa_inspect(model) + hidden = _hidden_response_fields(table_name) + blocked = {"id", value_field, "created_at", "updated_at", "responsible"} + for column in mapper.columns: + name = str(column.key) + if name in hidden or name in blocked: + continue + return name + return value_field + + +def _reference_meta_for_column(table_name: str, column_name: str) -> dict[str, str] | None: + detected = _detect_reference_for_column(table_name, column_name) + if detected is None: + return None + ref_table, value_field = detected + try: + label_field = _reference_label_field(ref_table, value_field) + except HTTPException: + return None + return { + "table": ref_table, + "value_field": value_field, + "label_field": label_field, + } + + +def _default_sort_for_table(model: type) -> list[dict[str, str]]: + columns = _columns_map(model) + if "sort_order" in columns: + return [{"field": "sort_order", "dir": "asc"}] + if "created_at" in columns: + return [{"field": "created_at", "dir": "desc"}] + pk = sa_inspect(model).primary_key + if pk: + return [{"field": pk[0].key, "dir": "asc"}] + return [] + + +def _table_columns_meta(table_name: str, model: type) -> list[dict[str, Any]]: + mapper = sa_inspect(model) + hidden = _hidden_response_fields(table_name) + protected = _protected_input_fields(table_name) + primary_keys = {column.key for column in mapper.primary_key} + out: list[dict[str, Any]] = [] + for column in mapper.columns: + name = column.key + if name in hidden: + continue + kind = _column_kind(column) + has_default = column.default is not None or column.server_default is not None or name in primary_keys + editable = name not in SYSTEM_FIELDS and name not in protected and name not in primary_keys + item = { + "name": name, + "label": _column_label(table_name, name), + "kind": kind, + "nullable": bool(column.nullable), + "editable": bool(editable), + "sortable": True, + "filterable": kind != "json", + "required_on_create": not bool(column.nullable) and not bool(has_default) and bool(editable), + "has_default": bool(has_default), + "is_primary_key": name in primary_keys, + } + reference = _reference_meta_for_column(table_name, name) + if reference is not None: + item["reference"] = reference + out.append(item) + return out + + +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"} + if table_name == "requests": + return {"client_id", *REQUEST_CALCULATED_FIELDS} + if table_name == "invoices": + return {"client_id", *INVOICE_CALCULATED_FIELDS} + return set() + +def _table_section(table_name: str) -> str: + if table_name in {"requests", "invoices", "request_service_requests"}: + return "main" + if table_name == "table_availability": + return "system" + return "dictionary" + + +def _table_availability_map(db: Session) -> dict[str, TableAvailability]: + rows = db.query(TableAvailability).all() + return {str(row.table_name): row for row in rows if row and row.table_name} + + +def _table_is_active(table_name: str, availability: dict[str, TableAvailability]) -> bool: + row = availability.get(table_name) + if row is None: + return True + return bool(row.is_active) + + +def _meta_tables_payload( + db: Session, + *, + role: str, + include_inactive_dictionaries: bool, +) -> list[dict[str, Any]]: + table_models = _table_model_map() + availability = _table_availability_map(db) + rows: list[dict[str, Any]] = [] + for table_name in sorted(table_models.keys()): + model = table_models[table_name] + section = _table_section(table_name) + is_active = _table_is_active(table_name, availability) + if section == "dictionary" and not include_inactive_dictionaries and not is_active: + continue + actions = sorted(_allowed_actions(role, table_name)) + rows.append( + { + "key": table_name, + "table": table_name, + "label": _table_label(table_name), + "section": section, + "is_active": is_active, + "actions": actions, + "query_endpoint": f"/api/admin/crud/{table_name}/query", + "create_endpoint": f"/api/admin/crud/{table_name}", + "update_endpoint_template": f"/api/admin/crud/{table_name}" + "/{id}", + "delete_endpoint_template": f"/api/admin/crud/{table_name}" + "/{id}", + "default_sort": _default_sort_for_table(model), + "columns": _table_columns_meta(table_name, model), + } + ) + return rows diff --git a/app/api/admin/crud_modules/payloads.py b/app/api/admin/crud_modules/payloads.py new file mode 100644 index 0000000..8f19827 --- /dev/null +++ b/app/api/admin/crud_modules/payloads.py @@ -0,0 +1,625 @@ +from __future__ import annotations + +import json +import uuid +from typing import Any + +from fastapi import HTTPException +from sqlalchemy.inspection import inspect as sa_inspect +from sqlalchemy.orm import Session + +from app.core.security import hash_password +from app.models.admin_user import AdminUser +from app.models.client import Client +from app.models.form_field import FormField +from app.models.request import Request +from app.models.request_data_requirement import RequestDataRequirement +from app.models.request_data_template import RequestDataTemplate +from app.models.request_data_template_item import RequestDataTemplateItem +from app.models.status import Status +from app.models.status_group import StatusGroup +from app.models.topic import Topic +from app.models.topic_data_template import TopicDataTemplate +from app.services.billing_flow import normalize_status_kind_or_400 + +from .access import ALLOWED_ADMIN_ROLES, ALLOWED_REQUEST_DATA_VALUE_TYPES +from .meta import SYSTEM_FIELDS, _columns_map, _protected_input_fields + +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 _normalize_client_phone(value: Any) -> str: + text = str(value or "").strip() + if not text: + return "" + allowed = {"+", "(", ")", "-", " "} + return "".join(ch for ch in text if ch.isdigit() or ch in allowed).strip() + + +def _upsert_client_or_400(db: Session, *, full_name: Any, phone: Any, responsible: str) -> Client: + normalized_phone = _normalize_client_phone(phone) + if not normalized_phone: + raise HTTPException(status_code=400, detail='Поле "client_phone" обязательно') + normalized_name = str(full_name or "").strip() or "Клиент" + + row = db.query(Client).filter(Client.phone == normalized_phone).first() + if row is None: + row = Client( + full_name=normalized_name, + phone=normalized_phone, + responsible=responsible or "Администратор системы", + ) + db.add(row) + db.flush() + return row + + changed = False + if normalized_name and row.full_name != normalized_name: + row.full_name = normalized_name + changed = True + if responsible and row.responsible != responsible: + row.responsible = responsible + changed = True + if changed: + db.add(row) + db.flush() + return row + + +def _request_for_uuid_or_400(db: Session, raw_request_id: Any) -> Request: + request_uuid = _parse_uuid_or_400(raw_request_id, "request_id") + req = db.get(Request, request_uuid) + if req is None: + raise HTTPException(status_code=400, detail="Заявка не найдена") + return req + + +def _active_lawyer_or_400(db: Session, lawyer_id: Any) -> AdminUser: + lawyer_uuid = _parse_uuid_or_400(lawyer_id, "assigned_lawyer_id") + lawyer = db.get(AdminUser, lawyer_uuid) + if lawyer is None or str(lawyer.role or "").upper() != "LAWYER" or not bool(lawyer.is_active): + raise HTTPException(status_code=400, detail="Можно назначить только активного юриста") + return lawyer + + +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 + if "phone" in data: + data["phone"] = _normalize_optional_string(_normalize_client_phone(data.get("phone"))) + 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 "phone" in data: + data["phone"] = _normalize_optional_string(_normalize_client_phone(data.get("phone"))) + 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 _normalize_string_list_or_400(value: Any, field_name: str) -> list[str] | None: + if value is None: + return None + + source = value + if isinstance(source, str): + text = source.strip() + if not text: + return None + if text.startswith("["): + try: + source = json.loads(text) + except json.JSONDecodeError: + raise HTTPException(status_code=400, detail=f'Поле "{field_name}" должно быть JSON-массивом строк') + else: + source = [chunk.strip() for chunk in text.replace("\n", ",").split(",")] + + if not isinstance(source, (list, tuple, set)): + raise HTTPException(status_code=400, detail=f'Поле "{field_name}" должно быть массивом строк') + + out: list[str] = [] + seen: set[str] = set() + for item in source: + text = str(item or "").strip() + if not text: + continue + lowered = text.lower() + if lowered in seen: + continue + seen.add(lowered) + out.append(text) + return out + + +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 + if "value_type" in data: + value_type = str(data.get("value_type") or "").strip().lower() + if value_type not in ALLOWED_REQUEST_DATA_VALUE_TYPES: + raise HTTPException(status_code=400, detail='Поле "value_type" должно быть одним из: string, text, date, number, file') + data["value_type"] = value_type + if "document_name" in data: + data["document_name"] = _normalize_optional_string(data.get("document_name")) + return data + + +def _apply_request_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 "name" in data: + name = str(data.get("name") or "").strip() + if not name: + raise HTTPException(status_code=400, detail='Поле "name" не может быть пустым') + data["name"] = name + if "description" in data: + data["description"] = _normalize_optional_string(data.get("description")) + if "created_by_admin_id" in data and data.get("created_by_admin_id") is not None: + admin_id = _parse_uuid_or_400(data.get("created_by_admin_id"), "created_by_admin_id") + admin_user = db.get(AdminUser, admin_id) + if admin_user is None: + raise HTTPException(status_code=400, detail="Пользователь не найден") + data["created_by_admin_id"] = admin_id + return data + + +def _apply_request_data_template_items_fields(db: Session, payload: dict[str, Any]) -> dict[str, Any]: + data = dict(payload) + template = None + if "request_data_template_id" in data: + template_id = _parse_uuid_or_400(data.get("request_data_template_id"), "request_data_template_id") + template = db.get(RequestDataTemplate, template_id) + if template is None: + raise HTTPException(status_code=400, detail="Шаблон не найден") + data["request_data_template_id"] = template_id + if "topic_data_template_id" in data and data.get("topic_data_template_id") is not None: + catalog_id = _parse_uuid_or_400(data.get("topic_data_template_id"), "topic_data_template_id") + catalog = db.get(TopicDataTemplate, catalog_id) + if catalog is None: + raise HTTPException(status_code=400, detail="Поле доп. данных не найдено") + data["topic_data_template_id"] = catalog_id + if "key" not in data or not str(data.get("key") or "").strip(): + data["key"] = str(catalog.key or "").strip() + if "label" not in data or not str(data.get("label") or "").strip(): + data["label"] = str(catalog.label or catalog.key or "").strip() + if "value_type" not in data or not str(data.get("value_type") or "").strip(): + data["value_type"] = str(catalog.value_type or "string") + if template is not None and str(template.topic_code or "").strip() and str(catalog.topic_code or "").strip(): + if str(template.topic_code) != str(catalog.topic_code): + raise HTTPException(status_code=400, detail="Поле не соответствует теме шаблона") + if "key" in data: + key = str(data.get("key") or "").strip() + if not key: + raise HTTPException(status_code=400, detail='Поле "key" не может быть пустым') + data["key"] = key[:80] + if "label" in data: + label = str(data.get("label") or "").strip() + if not label: + raise HTTPException(status_code=400, detail='Поле "label" не может быть пустым') + data["label"] = label + if "value_type" in data: + value_type = str(data.get("value_type") or "").strip().lower() + if value_type not in ALLOWED_REQUEST_DATA_VALUE_TYPES: + raise HTTPException(status_code=400, detail='Поле "value_type" должно быть одним из: string, text, date, number, file') + data["value_type"] = value_type + if "sort_order" in data: + raw = data.get("sort_order") + if raw is None or str(raw).strip() == "": + data["sort_order"] = 0 + else: + try: + data["sort_order"] = int(raw) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail='Поле "sort_order" должно быть целым числом') + 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 "request_message_id" in data and data.get("request_message_id") is not None: + data["request_message_id"] = _parse_uuid_or_400(data.get("request_message_id"), "request_message_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 + if "field_type" in data: + field_type = str(data.get("field_type") or "").strip().lower() + if field_type not in ALLOWED_REQUEST_DATA_VALUE_TYPES: + raise HTTPException(status_code=400, detail='Поле "field_type" должно быть одним из: string, text, date, number, file') + data["field_type"] = field_type + if "document_name" in data: + data["document_name"] = _normalize_optional_string(data.get("document_name")) + if "value_text" in data: + data["value_text"] = _normalize_optional_string(data.get("value_text")) + if "sort_order" in data: + raw_sort = data.get("sort_order") + if raw_sort is None or str(raw_sort).strip() == "": + data["sort_order"] = 0 + else: + try: + data["sort_order"] = int(raw_sort) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail='Поле "sort_order" должно быть целым числом') + 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") + if "required_data_keys" in data: + data["required_data_keys"] = _normalize_string_list_or_400(data.get("required_data_keys"), "required_data_keys") + if "required_mime_types" in data: + data["required_mime_types"] = _normalize_string_list_or_400(data.get("required_mime_types"), "required_mime_types") + + return data + + +def _apply_status_fields(db: Session, payload: dict[str, Any]) -> dict[str, Any]: + data = dict(payload) + if "kind" in data: + data["kind"] = normalize_status_kind_or_400(data.get("kind")) + if "status_group_id" in data: + raw_group = data.get("status_group_id") + if raw_group is None or str(raw_group).strip() == "": + data["status_group_id"] = None + else: + group_id = _parse_uuid_or_400(raw_group, "status_group_id") + group = db.get(StatusGroup, group_id) + if group is None: + raise HTTPException(status_code=400, detail="Группа статусов не найдена") + data["status_group_id"] = group_id + if "invoice_template" in data: + text = str(data.get("invoice_template") or "").strip() + data["invoice_template"] = text or None + 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 diff --git a/app/api/admin/crud_modules/router.py b/app/api/admin/crud_modules/router.py new file mode 100644 index 0000000..b7cd854 --- /dev/null +++ b/app/api/admin/crud_modules/router.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from app.core.deps import get_current_admin +from app.db.session import get_db +from app.schemas.universal import UniversalQuery + +from .service import ( + create_row_service, + delete_row_service, + get_row_service, + list_available_tables_service, + list_tables_meta_service, + query_table_service, + update_available_table_service, + update_row_service, +) + +router = APIRouter() + + +class TableAvailabilityUpdatePayload(BaseModel): + is_active: bool + + +@router.get("/meta/tables") +def list_tables_meta(db: Session = Depends(get_db), admin: dict = Depends(get_current_admin)): + return list_tables_meta_service(db, admin) + + +@router.get("/meta/available-tables") +def list_available_tables(db: Session = Depends(get_db), admin: dict = Depends(get_current_admin)): + return list_available_tables_service(db, admin) + + +@router.patch("/meta/available-tables/{table_name}") +def update_available_table( + table_name: str, + payload: TableAvailabilityUpdatePayload, + db: Session = Depends(get_db), + admin: dict = Depends(get_current_admin), +): + return update_available_table_service(table_name, payload.is_active, db, admin) + + +@router.post("/{table_name}/query") +def query_table( + table_name: str, + uq: UniversalQuery, + db: Session = Depends(get_db), + admin: dict = Depends(get_current_admin), +): + return query_table_service(table_name, uq, db, admin) + + +@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), +): + return get_row_service(table_name, row_id, db, admin) + + +@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), +): + return create_row_service(table_name, payload, db, admin) + + +@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), +): + return update_row_service(table_name, row_id, payload, db, admin) + + +@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), +): + return delete_row_service(table_name, row_id, db, admin) diff --git a/app/api/admin/crud_modules/service.py b/app/api/admin/crud_modules/service.py new file mode 100644 index 0000000..0131e5a --- /dev/null +++ b/app/api/admin/crud_modules/service.py @@ -0,0 +1,552 @@ +from __future__ import annotations + +import uuid +from datetime import datetime, timedelta, timezone +from typing import Any + +from fastapi import HTTPException +from sqlalchemy import or_ +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +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.models.request_service_request import RequestServiceRequest +from app.models.table_availability import TableAvailability +from app.schemas.universal import UniversalQuery +from app.services.billing_flow import apply_billing_transition_effects +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.request_templates import validate_required_topic_fields_or_400 +from app.services.status_flow import transition_allowed_for_topic +from app.services.status_transition_requirements import validate_transition_requirements_or_400 +from app.services.universal_query import apply_universal_query + +from .access import ( + REQUEST_FINANCIAL_FIELDS, + _ensure_lawyer_can_manage_request_or_403, + _ensure_lawyer_can_view_request_or_403, + _is_lawyer, + _lawyer_actor_id_or_401, + _request_for_related_row_or_404, + _require_table_action, + _resolve_table_model, +) +from .audit import _actor_role, _append_audit, _integrity_error, _resolve_responsible, _strip_hidden_fields +from .meta import ( + _columns_map, + _meta_tables_payload, + _row_to_dict, + _serialize_value, + _table_availability_map, +) +from .payloads import ( + _active_lawyer_or_400, + _apply_admin_user_fields_for_update, + _apply_admin_user_topics_fields, + _apply_auto_fields_for_create, + _apply_request_data_requirements_fields, + _apply_request_data_template_items_fields, + _apply_request_data_templates_fields, + _apply_status_fields, + _apply_topic_data_templates_fields, + _apply_topic_required_fields_fields, + _apply_topic_status_transitions_fields, + _load_row_or_404, + _parse_uuid_or_400, + _prepare_create_payload, + _request_for_uuid_or_400, + _sanitize_payload, + _upsert_client_or_400, +) + + +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, + ) + + +def list_tables_meta_service(db: Session, admin: dict) -> dict[str, Any]: + role = str(admin.get("role") or "").upper() + if role != "ADMIN": + raise HTTPException(status_code=403, detail="Недостаточно прав") + return {"tables": _meta_tables_payload(db, role=role, include_inactive_dictionaries=False)} + + +def list_available_tables_service(db: Session, admin: dict) -> dict[str, Any]: + role = str(admin.get("role") or "").upper() + if role != "ADMIN": + raise HTTPException(status_code=403, detail="Недостаточно прав") + + availability = _table_availability_map(db) + rows = [] + for item in _meta_tables_payload(db, role=role, include_inactive_dictionaries=True): + table_name = str(item.get("table") or "") + state = availability.get(table_name) + rows.append( + { + "table": table_name, + "label": item.get("label"), + "section": item.get("section"), + "is_active": bool(item.get("is_active")), + "responsible": state.responsible if state is not None else None, + "updated_at": _serialize_value(state.updated_at) if state is not None else None, + } + ) + return {"rows": rows, "total": len(rows)} + + +def update_available_table_service(table_name: str, is_active: bool, db: Session, admin: dict) -> dict[str, Any]: + role = str(admin.get("role") or "").upper() + if role != "ADMIN": + raise HTTPException(status_code=403, detail="Недостаточно прав") + + normalized, _ = _resolve_table_model(table_name) + row = db.query(TableAvailability).filter(TableAvailability.table_name == normalized).first() + responsible = _resolve_responsible(admin) + next_is_active = bool(is_active) + if row is None: + row = TableAvailability( + table_name=normalized, + is_active=next_is_active, + responsible=responsible, + ) + db.add(row) + else: + row.is_active = next_is_active + row.updated_at = datetime.now(timezone.utc) + row.responsible = responsible + db.add(row) + db.commit() + db.refresh(row) + return { + "table": normalized, + "is_active": bool(row.is_active), + "responsible": row.responsible, + "updated_at": _serialize_value(row.updated_at), + } + + +def query_table_service(table_name: str, uq: UniversalQuery, db: Session, admin: dict) -> dict[str, Any]: + normalized, model = _resolve_table_model(table_name) + _require_table_action(admin, normalized, "query") + base_query = db.query(model) + if normalized == "requests" and _is_lawyer(admin): + actor_id = _lawyer_actor_id_or_401(admin) + base_query = base_query.filter( + or_( + Request.assigned_lawyer_id == actor_id, + Request.assigned_lawyer_id.is_(None), + ) + ) + if normalized == "messages" and _is_lawyer(admin): + actor_id = _lawyer_actor_id_or_401(admin) + base_query = base_query.join(Request, Request.id == Message.request_id).filter( + or_( + Request.assigned_lawyer_id == actor_id, + Request.assigned_lawyer_id.is_(None), + ) + ) + if normalized == "attachments" and _is_lawyer(admin): + actor_id = _lawyer_actor_id_or_401(admin) + base_query = base_query.join(Request, Request.id == Attachment.request_id).filter( + or_( + Request.assigned_lawyer_id == actor_id, + Request.assigned_lawyer_id.is_(None), + ) + ) + if normalized == "request_service_requests" and _is_lawyer(admin): + actor_id = _lawyer_actor_id_or_401(admin) + base_query = base_query.filter( + RequestServiceRequest.type == "CURATOR_CONTACT", + RequestServiceRequest.assigned_lawyer_id == actor_id, + ) + query = apply_universal_query(base_query, 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} + + +def get_row_service(table_name: str, row_id: str, db: Session, admin: dict) -> dict[str, Any]: + 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: + _ensure_lawyer_can_view_request_or_403(admin, req) + 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 + if normalized == "messages" and isinstance(row, Message): + req = _request_for_related_row_or_404(db, row) + _ensure_lawyer_can_view_request_or_403(admin, req) + if normalized == "attachments" and isinstance(row, Attachment): + req = _request_for_related_row_or_404(db, row) + _ensure_lawyer_can_view_request_or_403(admin, req) + if normalized == "request_service_requests" and _is_lawyer(admin): + actor_id = _lawyer_actor_id_or_401(admin) + row_type = str(getattr(row, "type", "") or "").strip().upper() + assigned = str(getattr(row, "assigned_lawyer_id", "") or "").strip() + if row_type != "CURATOR_CONTACT" or not assigned or assigned != actor_id: + raise HTTPException(status_code=403, detail="Недостаточно прав") + payload = _strip_hidden_fields(normalized, _row_to_dict(row)) + if normalized == "requests" and isinstance(row, Request): + assigned_lawyer_id = str(row.assigned_lawyer_id or "").strip() + if assigned_lawyer_id: + try: + lawyer_uuid = uuid.UUID(assigned_lawyer_id) + except ValueError: + lawyer_uuid = None + if lawyer_uuid is not None: + lawyer = db.get(AdminUser, lawyer_uuid) + if lawyer is not None: + payload["assigned_lawyer_name"] = lawyer.name or lawyer.email or assigned_lawyer_id + payload["assigned_lawyer_phone"] = _serialize_value(getattr(lawyer, "phone", None)) + return payload + + +def create_row_service(table_name: str, payload: dict[str, Any], db: Session, admin: dict) -> dict[str, Any]: + normalized, model = _resolve_table_model(table_name) + _require_table_action(admin, normalized, "create") + responsible = _resolve_responsible(admin) + resolved_request_client_id: uuid.UUID | None = None + resolved_invoice_client_id: uuid.UUID | None = None + if normalized == "requests" and _is_lawyer(admin) and isinstance(payload, dict): + assigned_lawyer_id = payload.get("assigned_lawyer_id") + if str(assigned_lawyer_id or "").strip(): + raise HTTPException(status_code=403, detail='Юрист не может назначать заявку при создании') + forbidden_fields = sorted(REQUEST_FINANCIAL_FIELDS.intersection(set(payload.keys()))) + if forbidden_fields: + raise HTTPException(status_code=403, detail="Юрист не может изменять финансовые поля заявки") + + prepared = _prepare_create_payload(normalized, payload) + if normalized == "messages": + request_uuid = _parse_uuid_or_400(prepared.get("request_id"), "request_id") + req = db.get(Request, request_uuid) + if req is None: + raise HTTPException(status_code=404, detail="Заявка не найдена") + if _is_lawyer(admin): + _ensure_lawyer_can_manage_request_or_403(admin, req) + prepared["author_type"] = "LAWYER" + prepared["author_name"] = str(admin.get("email") or "").strip() or "Юрист" + prepared["immutable"] = False + prepared["request_id"] = request_uuid + if normalized == "requests": + validate_required_topic_fields_or_400(db, prepared.get("topic_code"), prepared.get("extra_fields")) + client = _upsert_client_or_400( + db, + full_name=prepared.get("client_name"), + phone=prepared.get("client_phone"), + responsible=responsible, + ) + resolved_request_client_id = client.id + prepared["client_name"] = client.full_name + prepared["client_phone"] = client.phone + if not _is_lawyer(admin): + assigned_raw = prepared.get("assigned_lawyer_id") + if assigned_raw is None or not str(assigned_raw).strip(): + if "assigned_lawyer_id" in prepared: + prepared["assigned_lawyer_id"] = None + else: + assigned_lawyer = _active_lawyer_or_400(db, assigned_raw) + prepared["assigned_lawyer_id"] = str(assigned_lawyer.id) + if prepared.get("effective_rate") is None: + prepared["effective_rate"] = assigned_lawyer.default_rate + if normalized == "invoices": + req = _request_for_uuid_or_400(db, prepared.get("request_id")) + prepared["request_id"] = req.id + resolved_invoice_client_id = req.client_id + 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_templates": + clean_payload = _apply_request_data_templates_fields(db, clean_payload) + if normalized == "request_data_template_items": + clean_payload = _apply_request_data_template_items_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 normalized == "statuses": + clean_payload = _apply_status_fields(db, clean_payload) + if normalized == "requests": + clean_payload["client_id"] = resolved_request_client_id + if normalized == "invoices": + clean_payload["client_id"] = resolved_invoice_client_id + if "responsible" in _columns_map(model): + clean_payload["responsible"] = responsible + 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)) + + +def update_row_service(table_name: str, row_id: str, payload: dict[str, Any], db: Session, admin: dict) -> dict[str, Any]: + normalized, model = _resolve_table_model(table_name) + _require_table_action(admin, normalized, "update") + responsible = _resolve_responsible(admin) + if normalized == "requests" and _is_lawyer(admin) and isinstance(payload, dict): + if "assigned_lawyer_id" in payload: + raise HTTPException(status_code=403, detail='Назначение доступно только через действие "Взять в работу"') + forbidden_fields = sorted(REQUEST_FINANCIAL_FIELDS.intersection(set(payload.keys()))) + if forbidden_fields: + raise HTTPException(status_code=403, detail="Юрист не может изменять финансовые поля заявки") + row = _load_row_or_404(db, model, row_id) + if normalized == "requests" and isinstance(row, Request): + _ensure_lawyer_can_manage_request_or_403(admin, row) + 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_templates": + clean_payload = _apply_request_data_templates_fields(db, clean_payload) + if normalized == "request_data_template_items": + clean_payload = _apply_request_data_template_items_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 normalized == "statuses": + clean_payload = _apply_status_fields(db, clean_payload) + if normalized == "requests" and isinstance(row, Request): + if {"client_name", "client_phone"}.intersection(set(clean_payload.keys())) or row.client_id is None: + client = _upsert_client_or_400( + db, + full_name=clean_payload.get("client_name", row.client_name), + phone=clean_payload.get("client_phone", row.client_phone), + responsible=responsible, + ) + clean_payload["client_id"] = client.id + clean_payload["client_name"] = client.full_name + clean_payload["client_phone"] = client.phone + if normalized == "invoices": + if "request_id" in clean_payload: + req = _request_for_uuid_or_400(db, clean_payload.get("request_id")) + clean_payload["request_id"] = req.id + clean_payload["client_id"] = req.client_id + elif getattr(row, "client_id", None) is None: + req = db.get(Request, getattr(row, "request_id", None)) + if req is not None: + clean_payload["client_id"] = req.client_id + if normalized == "requests" and not _is_lawyer(admin) and "assigned_lawyer_id" in clean_payload: + assigned_raw = clean_payload.get("assigned_lawyer_id") + if assigned_raw is None or not str(assigned_raw).strip(): + clean_payload["assigned_lawyer_id"] = None + else: + assigned_lawyer = _active_lawyer_or_400(db, assigned_raw) + clean_payload["assigned_lawyer_id"] = str(assigned_lawyer.id) + if isinstance(row, Request) and row.effective_rate is None and "effective_rate" not in clean_payload: + clean_payload["effective_rate"] = assigned_lawyer.default_rate + if "responsible" in _columns_map(model): + clean_payload["responsible"] = responsible + 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 "") + if before_status != after_status and isinstance(row, Request): + if not transition_allowed_for_topic( + db, + str(row.topic_code or "").strip() or None, + before_status, + after_status, + ): + raise HTTPException(status_code=400, detail="Переход статуса не разрешен для выбранной темы") + extra_fields_override = clean_payload.get("extra_fields") + validate_transition_requirements_or_400( + db, + row, + before_status, + after_status, + extra_fields_override=extra_fields_override if isinstance(extra_fields_override, dict) else None, + ) + if "important_date_at" not in clean_payload or clean_payload.get("important_date_at") is None: + clean_payload["important_date_at"] = datetime.now(timezone.utc) + timedelta(days=3) + billing_note = apply_billing_transition_effects( + db, + req=row, + from_status=before_status, + to_status=after_status, + admin=admin, + responsible=responsible, + ) + mark_unread_for_client(row, EVENT_STATUS) + apply_status_change_effects( + db, + row, + from_status=before_status, + to_status=after_status, + admin=admin, + important_date_at=clean_payload.get("important_date_at"), + responsible=responsible, + ) + 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}" + + ( + f"\nВажная дата: {clean_payload.get('important_date_at').isoformat()}" + if isinstance(clean_payload.get("important_date_at"), datetime) + else "" + ) + + (f"\n{billing_note}" if billing_note else "") + ), + responsible=responsible, + ) + 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)) + + +def delete_row_service(table_name: str, row_id: str, db: Session, admin: dict) -> dict[str, Any]: + 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 == "requests" and isinstance(row, Request): + _ensure_lawyer_can_manage_request_or_403(admin, row) + 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 a957c08..d2b94fc 100644 --- a/app/api/admin/metrics.py +++ b/app/api/admin/metrics.py @@ -13,6 +13,7 @@ from app.db.session import get_db from app.models.admin_user import AdminUser from app.models.audit_log import AuditLog from app.models.request import Request +from app.models.request_service_request import RequestServiceRequest from app.models.status import Status from app.models.status_history import StatusHistory from app.services.sla_metrics import compute_sla_snapshot @@ -88,7 +89,7 @@ def _extract_assigned_lawyer_from_audit(diff: dict | None, action: str | None) - @router.get("/overview") -def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER"))): +def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER", "CURATOR"))): role = str(admin.get("role") or "").upper() actor_id = str(admin.get("sub") or "").strip() actor_uuid = _uuid_or_none(actor_id) @@ -110,6 +111,26 @@ def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", .scalar() or 0 ) + if role == "LAWYER" and actor_uuid is not None: + service_request_unread_total = int( + db.query(func.count(RequestServiceRequest.id)) + .filter( + RequestServiceRequest.type == "CURATOR_CONTACT", + RequestServiceRequest.assigned_lawyer_id == str(actor_uuid), + RequestServiceRequest.lawyer_unread.is_(True), + ) + .scalar() + or 0 + ) + elif role == "LAWYER": + service_request_unread_total = 0 + else: + service_request_unread_total = int( + db.query(func.count(RequestServiceRequest.id)) + .filter(RequestServiceRequest.admin_unread.is_(True)) + .scalar() + or 0 + ) active_load_rows = ( db.query(Request.assigned_lawyer_id, func.count(Request.id)) @@ -290,7 +311,7 @@ def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", deadline_alert_query = deadline_alert_query.filter(Request.id.is_(None)) deadline_alert_total = int(deadline_alert_query.scalar() or 0) return { - "scope": role if role in {"ADMIN", "LAWYER"} else "ADMIN", + "scope": role if role in {"ADMIN", "LAWYER", "CURATOR"} else "ADMIN", "new": int(by_status.get("NEW", 0)), "by_status": by_status, "assigned_total": assigned_total, @@ -310,6 +331,7 @@ def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "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), + "service_request_unread_total": int(service_request_unread_total), "lawyer_loads": scoped_lawyer_loads, } diff --git a/app/api/admin/requests.py b/app/api/admin/requests.py index ec09a44..3b8ffdb 100644 --- a/app/api/admin/requests.py +++ b/app/api/admin/requests.py @@ -1,1596 +1,8 @@ -import json -from datetime import datetime, timedelta, timezone -from uuid import UUID, uuid4 +"""Backward-compatible entrypoint for Admin Requests router. -from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy.orm import Session -from sqlalchemy.exc import IntegrityError -from sqlalchemy import case, or_, update +Implementation moved to app.api.admin.requests_modules. +""" -from app.db.session import get_db -from app.core.deps import require_role -from app.schemas.universal import FilterClause, Page, UniversalQuery -from app.schemas.admin import ( - RequestAdminCreate, - RequestAdminPatch, - RequestDataRequirementCreate, - RequestDataRequirementPatch, - RequestReassign, - RequestStatusChange, -) -from app.models.admin_user import AdminUser -from app.models.audit_log import AuditLog -from app.models.client import Client -from app.models.request_data_requirement import RequestDataRequirement -from app.models.request import Request -from app.models.status import Status -from app.models.status_group import StatusGroup -from app.models.status_history import StatusHistory -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.request_templates import validate_required_topic_fields_or_400 -from app.services.status_transition_requirements import normalize_string_list -from app.services.billing_flow import apply_billing_transition_effects -from app.services.universal_query import apply_universal_query +from app.api.admin.requests_modules.router import router -router = APIRouter() -REQUEST_FINANCIAL_FIELDS = {"effective_rate", "invoice_amount", "paid_at", "paid_by_admin_id"} -ALLOWED_KANBAN_FILTER_FIELDS = {"assigned_lawyer_id", "client_name", "status_code", "created_at", "topic_code", "overdue"} -ALLOWED_KANBAN_SORT_MODES = {"created_newest", "lawyer", "deadline"} -FALLBACK_KANBAN_GROUPS = [ - ("fallback_new", "Новые", 10), - ("fallback_in_progress", "В работе", 20), - ("fallback_waiting", "Ожидание", 30), - ("fallback_done", "Завершены", 40), -] - - -def _status_meta_or_default(meta_map: dict[str, dict[str, object]], status_code: str) -> dict[str, object]: - return meta_map.get(status_code) or { - "name": status_code, - "kind": "DEFAULT", - "is_terminal": False, - "status_group_id": None, - "status_group_name": None, - "status_group_order": None, - } - - -def _fallback_group_for_status(status_code: str, status_meta: dict[str, object]) -> tuple[str, str, int]: - code = str(status_code or "").strip().upper() - kind = str(status_meta.get("kind") or "DEFAULT").upper() - name = str(status_meta.get("name") or "").upper() - is_terminal = bool(status_meta.get("is_terminal")) - - if is_terminal: - return FALLBACK_KANBAN_GROUPS[3] - if kind == "PAID": - return FALLBACK_KANBAN_GROUPS[3] - if code.startswith("NEW") or "НОВ" in name: - return FALLBACK_KANBAN_GROUPS[0] - waiting_tokens = ("WAIT", "PEND", "HOLD", "SUSPEND", "BLOCK") - waiting_ru_tokens = ("ОЖИД", "ПАУЗ", "СОГЛАС", "ОПЛАТ", "СУД") - if kind == "INVOICE": - return FALLBACK_KANBAN_GROUPS[2] - if any(token in code for token in waiting_tokens) or any(token in name for token in waiting_ru_tokens): - return FALLBACK_KANBAN_GROUPS[2] - done_tokens = ("CLOSE", "RESOLV", "REJECT", "DONE", "PAID") - done_ru_tokens = ("ЗАВЕРШ", "ЗАКРЫ", "РЕШЕН", "ОТКЛОН", "ОПЛАЧ") - if any(token in code for token in done_tokens) or any(token in name for token in done_ru_tokens): - return FALLBACK_KANBAN_GROUPS[3] - return FALLBACK_KANBAN_GROUPS[1] - - -def _parse_datetime_safe(value: object) -> datetime | None: - if value is None: - return None - if isinstance(value, datetime): - return value if value.tzinfo else value.replace(tzinfo=timezone.utc) - text = str(value).strip() - if not text: - return None - if text.endswith("Z"): - text = text[:-1] + "+00:00" - try: - parsed = datetime.fromisoformat(text) - except ValueError: - return None - if parsed.tzinfo is None: - parsed = parsed.replace(tzinfo=timezone.utc) - return parsed - - -def _normalize_important_date_or_default(raw: object, *, default_days: int = 3) -> datetime: - parsed = _parse_datetime_safe(raw) - if parsed: - return parsed - return datetime.now(timezone.utc) + timedelta(days=default_days) - - -def _terminal_status_codes(db: Session) -> set[str]: - rows = db.query(Status.code).filter(Status.is_terminal.is_(True)).all() - codes = {str(code or "").strip() for (code,) in rows if str(code or "").strip()} - return codes or {"RESOLVED", "CLOSED", "REJECTED"} - - -def _coerce_request_bool_filter_or_400(value: object) -> bool: - if isinstance(value, bool): - return value - text = str(value or "").strip().lower() - if text in {"1", "true", "yes", "y", "да"}: - return True - if text in {"0", "false", "no", "n", "нет"}: - return False - raise HTTPException(status_code=400, detail="Значение фильтра должно быть boolean") - - -def _split_request_special_filters(uq: UniversalQuery) -> tuple[UniversalQuery, list[FilterClause]]: - filters = list(uq.filters or []) - special: list[FilterClause] = [] - regular: list[FilterClause] = [] - for clause in filters: - field = str(getattr(clause, "field", "") or "").strip() - if field in {"has_unread_updates", "deadline_alert"}: - special.append(clause) - else: - regular.append(clause) - return UniversalQuery(filters=regular, sort=list(uq.sort or []), page=uq.page), special - - -def _apply_request_special_filters( - base_query, - *, - db: Session, - role: str, - actor_id: str, - special_filters: list[FilterClause], -): - if not special_filters: - return base_query - terminal_codes_cache: set[str] | None = None - for clause in special_filters: - field = str(clause.field or "").strip() - op = str(clause.op or "").strip() - if op not in {"=", "!="}: - raise HTTPException(status_code=400, detail=f'Оператор "{op}" не поддерживается для фильтра "{field}"') - expected = _coerce_request_bool_filter_or_400(clause.value) - if field == "has_unread_updates": - if role == "LAWYER": - expr = Request.lawyer_has_unread_updates.is_(True) - else: - expr = or_( - Request.lawyer_has_unread_updates.is_(True), - Request.client_has_unread_updates.is_(True), - ) - elif field == "deadline_alert": - now_utc = datetime.now(timezone.utc) - next_day_start = datetime(now_utc.year, now_utc.month, now_utc.day, tzinfo=timezone.utc) + timedelta(days=1) - if terminal_codes_cache is None: - terminal_codes_cache = _terminal_status_codes(db) - expr = ( - Request.important_date_at.is_not(None) - & (Request.important_date_at < next_day_start) - & (Request.status_code.notin_(terminal_codes_cache)) - ) - if role == "LAWYER": - expr = expr & (Request.assigned_lawyer_id == actor_id) - else: - continue - base_query = base_query.filter(expr if expected else ~expr) - return base_query - - -def _normalize_client_phone(value: object) -> str: - text = "".join(ch for ch in str(value or "") if ch.isdigit() or ch == "+") - if not text: - return "" - if text.startswith("8") and len(text) == 11: - text = "+7" + text[1:] - if not text.startswith("+") and text.isdigit(): - text = "+" + text - return text - - -def _client_uuid_or_none(value: object) -> UUID | None: - raw = str(value or "").strip() - if not raw: - return None - try: - return UUID(raw) - except ValueError: - raise HTTPException(status_code=400, detail='Некорректный "client_id"') - - -def _client_for_request_payload_or_400( - db: Session, - *, - client_id: object, - client_name: object, - client_phone: object, - responsible: str, -) -> Client: - client_uuid = _client_uuid_or_none(client_id) - if client_uuid is not None: - row = db.get(Client, client_uuid) - if row is None: - raise HTTPException(status_code=404, detail="Клиент не найден") - return row - - normalized_phone = _normalize_client_phone(client_phone) - if not normalized_phone: - raise HTTPException(status_code=400, detail='Поле "client_phone" обязательно') - normalized_name = str(client_name or "").strip() or "Клиент" - - row = db.query(Client).filter(Client.phone == normalized_phone).first() - if row is None: - row = Client( - full_name=normalized_name, - phone=normalized_phone, - responsible=responsible, - ) - db.add(row) - db.flush() - return row - - changed = False - if normalized_name and row.full_name != normalized_name: - row.full_name = normalized_name - changed = True - if changed: - row.responsible = responsible - db.add(row) - db.flush() - return row - - -def _extract_case_deadline(extra_fields: object) -> datetime | None: - if not isinstance(extra_fields, dict): - return None - deadline_keys = ( - "deadline_at", - "deadline", - "due_date", - "due_at", - "case_deadline", - "court_date", - "hearing_date", - "next_action_deadline", - ) - for key in deadline_keys: - parsed = _parse_datetime_safe(extra_fields.get(key)) - if parsed: - return parsed - return None - - -def _coerce_kanban_bool(value: object) -> bool: - if isinstance(value, bool): - return value - text = str(value or "").strip().lower() - if text in {"1", "true", "yes", "y", "on"}: - return True - if text in {"0", "false", "no", "n", "off"}: - return False - raise HTTPException(status_code=400, detail='Поле "overdue" должно быть boolean') - - -def _parse_kanban_filters_or_400(raw_filters: str | None) -> tuple[list[FilterClause], list[tuple[str, bool]]]: - if not raw_filters: - return [], [] - try: - parsed = json.loads(raw_filters) - except json.JSONDecodeError as exc: - raise HTTPException(status_code=400, detail="Некорректный JSON фильтров канбана") from exc - if not isinstance(parsed, list): - raise HTTPException(status_code=400, detail="Фильтры канбана должны быть массивом") - - universal_filters: list[FilterClause] = [] - overdue_filters: list[tuple[str, bool]] = [] - for index, item in enumerate(parsed): - if not isinstance(item, dict): - raise HTTPException(status_code=400, detail=f"Фильтр #{index + 1} должен быть объектом") - field = str(item.get("field") or "").strip() - op = str(item.get("op") or "").strip() - value = item.get("value") - if field not in ALLOWED_KANBAN_FILTER_FIELDS: - raise HTTPException(status_code=400, detail=f'Недоступное поле фильтра: "{field}"') - if op not in {"=", "!=", ">", "<", ">=", "<=", "~"}: - raise HTTPException(status_code=400, detail=f'Недопустимый оператор фильтра: "{op}"') - if field == "overdue": - if op not in {"=", "!="}: - raise HTTPException(status_code=400, detail='Для поля "overdue" доступны только операторы "=" и "!="') - overdue_filters.append((op, _coerce_kanban_bool(value))) - continue - universal_filters.append(FilterClause(field=field, op=op, value=value)) - return universal_filters, overdue_filters - - -def _apply_overdue_filters(items: list[dict[str, object]], overdue_filters: list[tuple[str, bool]]) -> list[dict[str, object]]: - if not overdue_filters: - return items - now = datetime.now(timezone.utc) - out: list[dict[str, object]] = [] - for item in items: - raw_deadline = item.get("sla_deadline_at") or item.get("case_deadline_at") - deadline_at = _parse_datetime_safe(raw_deadline) - is_overdue = bool(deadline_at and deadline_at <= now) - ok = True - for op, expected in overdue_filters: - if op == "=": - ok = ok and (is_overdue == expected) - elif op == "!=": - ok = ok and (is_overdue != expected) - if not ok: - break - if ok: - out.append(item) - return out - - -def _sort_kanban_items(items: list[dict[str, object]], sort_mode: str) -> list[dict[str, object]]: - mode = sort_mode if sort_mode in ALLOWED_KANBAN_SORT_MODES else "created_newest" - epoch = datetime(1970, 1, 1, tzinfo=timezone.utc) - - if mode == "lawyer": - return sorted( - items, - key=lambda row: ( - 1 if not str(row.get("assigned_lawyer_name") or "").strip() else 0, - str(row.get("assigned_lawyer_name") or "").lower(), - -int((_parse_datetime_safe(row.get("created_at")) or epoch).timestamp()), - ), - ) - - if mode == "deadline": - far_future = datetime(9999, 12, 31, tzinfo=timezone.utc) - return sorted( - items, - key=lambda row: ( - _parse_datetime_safe(row.get("sla_deadline_at") or row.get("case_deadline_at")) or far_future, - -int((_parse_datetime_safe(row.get("created_at")) or epoch).timestamp()), - ), - ) - - return sorted( - items, - key=lambda row: _parse_datetime_safe(row.get("created_at")) or epoch, - reverse=True, - ) - - -def _request_uuid_or_400(request_id: str) -> UUID: - try: - return UUID(str(request_id)) - except ValueError: - raise HTTPException(status_code=400, detail="Некорректный идентификатор заявки") - - -def _active_lawyer_or_400(db: Session, lawyer_id: str) -> AdminUser: - try: - lawyer_uuid = UUID(str(lawyer_id)) - except ValueError: - raise HTTPException(status_code=400, 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=400, detail="Можно назначить только активного юриста") - return lawyer - - -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() - if not actor: - raise HTTPException(status_code=401, detail="Некорректный токен") - assigned = str(req.assigned_lawyer_id or "").strip() - if not actor or not assigned or actor != assigned: - raise HTTPException(status_code=403, detail="Юрист может работать только со своими назначенными заявками") - - -def _ensure_lawyer_can_view_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() - if not actor: - raise HTTPException(status_code=401, detail="Некорректный токен") - assigned = str(req.assigned_lawyer_id or "").strip() - if assigned and 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"))): - base_query = db.query(Request) - role = str(admin.get("role") or "").upper() - actor = str(admin.get("sub") or "").strip() - if role == "LAWYER": - if not actor: - raise HTTPException(status_code=401, detail="Некорректный токен") - base_query = base_query.filter( - or_( - Request.assigned_lawyer_id == actor, - Request.assigned_lawyer_id.is_(None), - ) - ) - - regular_uq, special_filters = _split_request_special_filters(uq) - base_query = _apply_request_special_filters( - base_query, - db=db, - role=role, - actor_id=actor, - special_filters=special_filters, - ) - q = apply_universal_query(base_query, Request, regular_uq) - total = q.count() - rows = q.offset(uq.page.offset).limit(uq.page.limit).all() - return { - "rows": [ - { - "id": str(r.id), - "track_number": r.track_number, - "client_id": str(r.client_id) if r.client_id else None, - "status_code": r.status_code, - "client_name": r.client_name, - "client_phone": r.client_phone, - "topic_code": r.topic_code, - "important_date_at": r.important_date_at.isoformat() if r.important_date_at else None, - "effective_rate": float(r.effective_rate) if r.effective_rate is not None else None, - "request_cost": float(r.request_cost) if r.request_cost 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, - } - for r in rows - ], - "total": total, - } - - -@router.get("/kanban") -def get_requests_kanban( - db: Session = Depends(get_db), - admin=Depends(require_role("ADMIN", "LAWYER")), - limit: int = Query(default=400, ge=1, le=1000), - filters: str | None = Query(default=None), - sort_mode: str = Query(default="created_newest"), -): - role = str(admin.get("role") or "").upper() - actor = str(admin.get("sub") or "").strip() - - base_query = db.query(Request) - if role == "LAWYER": - if not actor: - raise HTTPException(status_code=401, detail="Некорректный токен") - base_query = base_query.filter( - or_( - Request.assigned_lawyer_id == actor, - Request.assigned_lawyer_id.is_(None), - ) - ) - - normalized_sort_mode = sort_mode if sort_mode in ALLOWED_KANBAN_SORT_MODES else "created_newest" - query_filters, overdue_filters = _parse_kanban_filters_or_400(filters) - if query_filters: - base_query = apply_universal_query( - base_query, - Request, - UniversalQuery( - filters=query_filters, - sort=[], - page=Page(limit=limit, offset=0), - ), - ) - - request_rows: list[Request] = base_query.all() - - request_id_to_row = {str(row.id): row for row in request_rows} - request_ids = [row.id for row in request_rows] - status_codes = {str(row.status_code or "").strip() for row in request_rows if str(row.status_code or "").strip()} - - status_meta_map: dict[str, dict[str, object]] = {} - if status_codes: - status_rows = ( - db.query(Status, StatusGroup) - .outerjoin(StatusGroup, StatusGroup.id == Status.status_group_id) - .filter(Status.code.in_(list(status_codes))) - .all() - ) - status_meta_map = { - str(status_row.code): { - "name": str(status_row.name or status_row.code), - "kind": str(status_row.kind or "DEFAULT"), - "is_terminal": bool(status_row.is_terminal), - "sort_order": int(status_row.sort_order or 0), - "status_group_id": str(status_row.status_group_id) if status_row.status_group_id else None, - "status_group_name": (str(group_row.name) if group_row is not None and group_row.name else None), - "status_group_order": (int(group_row.sort_order or 0) if group_row is not None else None), - } - for status_row, group_row in status_rows - } - - assigned_ids = {str(row.assigned_lawyer_id or "").strip() for row in request_rows if str(row.assigned_lawyer_id or "").strip()} - lawyer_name_map: dict[str, str] = {} - if assigned_ids: - valid_lawyer_ids: list[UUID] = [] - for raw in assigned_ids: - try: - valid_lawyer_ids.append(UUID(raw)) - except ValueError: - continue - if valid_lawyer_ids: - lawyer_rows = db.query(AdminUser).filter(AdminUser.id.in_(valid_lawyer_ids)).all() - lawyer_name_map = { - str(row.id): str(row.name or row.email or row.id) - for row in lawyer_rows - } - - history_rows: list[StatusHistory] = [] - if request_ids: - history_rows = ( - db.query(StatusHistory) - .filter(StatusHistory.request_id.in_(request_ids)) - .order_by(StatusHistory.request_id.asc(), StatusHistory.created_at.desc()) - .all() - ) - - current_status_changed_at: dict[str, datetime] = {} - previous_status_by_request: dict[str, str] = {} - for row in history_rows: - request_id = str(row.request_id) - request_row = request_id_to_row.get(request_id) - if request_row is None: - continue - current_status = str(request_row.status_code or "").strip() - to_status = str(row.to_status or "").strip() - if not current_status or to_status != current_status: - continue - if request_id not in current_status_changed_at and row.created_at: - current_status_changed_at[request_id] = row.created_at - previous_status_by_request[request_id] = str(row.from_status or "").strip() - - all_enabled_status_rows = ( - db.query(Status, StatusGroup) - .outerjoin(StatusGroup, StatusGroup.id == Status.status_group_id) - .filter(Status.enabled.is_(True)) - .order_by(Status.sort_order.asc(), Status.name.asc(), Status.code.asc()) - .all() - ) - all_enabled_statuses: list[dict[str, object]] = [] - for status_row, group_row in all_enabled_status_rows: - code = str(status_row.code or "").strip() - if not code: - continue - meta = { - "code": code, - "name": str(status_row.name or code), - "kind": str(status_row.kind or "DEFAULT"), - "is_terminal": bool(status_row.is_terminal), - "status_group_id": str(status_row.status_group_id) if status_row.status_group_id else None, - "status_group_name": (str(group_row.name) if group_row is not None and group_row.name else None), - "status_group_order": (int(group_row.sort_order or 0) if group_row is not None else None), - "sort_order": int(status_row.sort_order or 0), - } - status_meta_map.setdefault(code, meta) - all_enabled_statuses.append(meta) - - status_groups_rows = db.query(StatusGroup).order_by(StatusGroup.sort_order.asc(), StatusGroup.name.asc()).all() - columns_catalog = [ - { - "key": str(group.id), - "label": str(group.name), - "sort_order": int(group.sort_order or 0), - } - for group in status_groups_rows - ] - columns_by_key = {row["key"]: row for row in columns_catalog} - - items: list[dict[str, object]] = [] - group_totals: dict[str, int] = {row["key"]: 0 for row in columns_catalog} - for row in request_rows: - request_id = str(row.id) - status_code = str(row.status_code or "").strip() - status_meta = _status_meta_or_default(status_meta_map, status_code) - status_group = str(status_meta.get("status_group_id") or "").strip() - status_group_name = str(status_meta.get("status_group_name") or "").strip() - status_group_order = status_meta.get("status_group_order") - if not status_group: - fallback_key, fallback_label, fallback_order = _fallback_group_for_status(status_code, status_meta) - status_group = fallback_key - status_group_name = fallback_label - status_group_order = fallback_order - if fallback_key not in columns_by_key: - columns_by_key[fallback_key] = {"key": fallback_key, "label": fallback_label, "sort_order": fallback_order} - columns_catalog.append(columns_by_key[fallback_key]) - elif status_group not in columns_by_key: - columns_by_key[status_group] = { - "key": status_group, - "label": status_group_name or status_group, - "sort_order": int(status_group_order or 999), - } - columns_catalog.append(columns_by_key[status_group]) - available_transitions = [] - for status_def in all_enabled_statuses: - to_status = str(status_def.get("code") or "").strip() - if not to_status or to_status == status_code: - continue - to_meta = _status_meta_or_default(status_meta_map, to_status) - target_group = str(to_meta.get("status_group_id") or "").strip() - if not target_group: - target_group, fallback_label, fallback_order = _fallback_group_for_status(to_status, to_meta) - if target_group not in columns_by_key: - columns_by_key[target_group] = {"key": target_group, "label": fallback_label, "sort_order": fallback_order} - columns_catalog.append(columns_by_key[target_group]) - if target_group not in group_totals: - group_totals[target_group] = 0 - available_transitions.append( - { - "to_status": to_status, - "to_status_name": str(to_meta.get("name") or to_status), - "target_group": target_group, - "is_terminal": bool(to_meta.get("is_terminal")), - } - ) - - case_deadline = row.important_date_at or _extract_case_deadline(row.extra_fields) - sla_deadline = None - - assigned_id = str(row.assigned_lawyer_id or "").strip() or None - items.append( - { - "id": request_id, - "track_number": row.track_number, - "client_name": row.client_name, - "client_phone": row.client_phone, - "topic_code": row.topic_code, - "status_code": status_code, - "important_date_at": row.important_date_at.isoformat() if row.important_date_at else None, - "status_name": str(status_meta.get("name") or status_code), - "status_group": status_group, - "status_group_name": status_group_name or None, - "status_group_order": int(status_group_order or 0) if status_group_order is not None else None, - "assigned_lawyer_id": assigned_id, - "assigned_lawyer_name": lawyer_name_map.get(assigned_id or "", assigned_id), - "description": row.description, - "created_at": row.created_at.isoformat() if row.created_at else None, - "updated_at": row.updated_at.isoformat() if row.updated_at else None, - "lawyer_has_unread_updates": bool(row.lawyer_has_unread_updates), - "lawyer_unread_event_type": row.lawyer_unread_event_type, - "client_has_unread_updates": bool(row.client_has_unread_updates), - "client_unread_event_type": row.client_unread_event_type, - "case_deadline_at": case_deadline.isoformat() if case_deadline else None, - "sla_deadline_at": sla_deadline.isoformat() if sla_deadline else None, - "available_transitions": available_transitions, - } - ) - - items = _apply_overdue_filters(items, overdue_filters) - items = _sort_kanban_items(items, normalized_sort_mode) - total = len(items) - if total > limit: - items = items[:limit] - - for row in items: - key = str(row.get("status_group") or "").strip() - if not key: - continue - group_totals[key] = int(group_totals.get(key, 0)) + 1 - - columns = [] - for item in sorted( - columns_catalog, - key=lambda row: ( - int(row.get("sort_order") or 0), - str(row.get("label") or "").lower(), - ), - ): - key = str(item.get("key") or "") - if not key: - continue - columns.append( - { - "key": key, - "label": str(item.get("label") or key), - "sort_order": int(item.get("sort_order") or 0), - "total": int(group_totals.get(key, 0)), - } - ) - - return { - "scope": role, - "rows": items, - "columns": columns, - "total": total, - "limit": int(limit), - "sort_mode": normalized_sort_mode, - "truncated": bool(total > len(items)), - } - - -@router.post("", status_code=201) -def create_request(payload: RequestAdminCreate, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER"))): - actor_role = str(admin.get("role") or "").upper() - if actor_role == "LAWYER" and str(payload.assigned_lawyer_id or "").strip(): - raise HTTPException(status_code=403, detail="Юрист не может назначать заявку при создании") - if actor_role == "LAWYER": - forbidden_fields = sorted(REQUEST_FINANCIAL_FIELDS.intersection(set(payload.model_fields_set))) - if forbidden_fields: - 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 "Администратор системы" - client = _client_for_request_payload_or_400( - db, - client_id=payload.client_id, - client_name=payload.client_name, - client_phone=payload.client_phone, - responsible=responsible, - ) - assigned_lawyer_id = str(payload.assigned_lawyer_id or "").strip() or None - effective_rate = payload.effective_rate - if assigned_lawyer_id: - assigned_lawyer = _active_lawyer_or_400(db, assigned_lawyer_id) - assigned_lawyer_id = str(assigned_lawyer.id) - if effective_rate is None: - effective_rate = assigned_lawyer.default_rate - row = Request( - track_number=track, - client_id=client.id, - client_name=client.full_name, - client_phone=client.phone, - topic_code=payload.topic_code, - status_code=payload.status_code, - important_date_at=payload.important_date_at, - description=payload.description, - extra_fields=payload.extra_fields, - assigned_lawyer_id=assigned_lawyer_id, - effective_rate=effective_rate, - request_cost=payload.request_cost, - 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) - db.commit() - db.refresh(row) - except IntegrityError: - db.rollback() - raise HTTPException(status_code=400, detail="Заявка с таким номером уже существует") - return {"id": str(row.id), "track_number": row.track_number} - - -@router.patch("/{request_id}") -def update_request( - request_id: str, - payload: RequestAdminPatch, - db: Session = Depends(get_db), - admin=Depends(require_role("ADMIN", "LAWYER")), -): - request_uuid = _request_uuid_or_400(request_id) - row = db.get(Request, request_uuid) - if not row: - raise HTTPException(status_code=404, detail="Заявка не найдена") - _ensure_lawyer_can_manage_request_or_403(admin, row) - changes = payload.model_dump(exclude_unset=True) - actor_role = str(admin.get("role") or "").upper() - if actor_role == "LAWYER": - if "assigned_lawyer_id" in changes: - raise HTTPException(status_code=403, detail='Назначение доступно только через действие "Взять в работу"') - forbidden_fields = sorted(REQUEST_FINANCIAL_FIELDS.intersection(set(changes.keys()))) - if forbidden_fields: - raise HTTPException(status_code=403, detail="Юрист не может изменять финансовые поля заявки") - if actor_role == "ADMIN" and "assigned_lawyer_id" in changes: - assigned_raw = changes.get("assigned_lawyer_id") - if assigned_raw is None or not str(assigned_raw).strip(): - changes["assigned_lawyer_id"] = None - else: - assigned_lawyer = _active_lawyer_or_400(db, str(assigned_raw)) - changes["assigned_lawyer_id"] = str(assigned_lawyer.id) - if row.effective_rate is None and "effective_rate" not in changes: - changes["effective_rate"] = assigned_lawyer.default_rate - old_status = str(row.status_code or "") - responsible = str(admin.get("email") or "").strip() or "Администратор системы" - if {"client_id", "client_name", "client_phone"}.intersection(set(changes.keys())): - client = _client_for_request_payload_or_400( - db, - client_id=changes.get("client_id", row.client_id), - client_name=changes.get("client_name", row.client_name), - client_phone=changes.get("client_phone", row.client_phone), - responsible=responsible, - ) - changes["client_id"] = client.id - changes["client_name"] = client.full_name - changes["client_phone"] = client.phone - status_changed = "status_code" in changes and str(changes.get("status_code") or "") != old_status - if status_changed and ("important_date_at" not in changes or changes.get("important_date_at") is None): - changes["important_date_at"] = _normalize_important_date_or_default(None) - for key, value in changes.items(): - setattr(row, key, value) - if status_changed: - next_status = str(changes.get("status_code") or "") - important_date_at = row.important_date_at - billing_note = apply_billing_transition_effects( - db, - req=row, - from_status=old_status, - to_status=next_status, - admin=admin, - important_date_at=important_date_at, - responsible=responsible, - ) - mark_unread_for_client(row, EVENT_STATUS) - apply_status_change_effects( - db, - row, - from_status=old_status, - to_status=next_status, - 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} -> {next_status}" - + (f"\nВажная дата: {important_date_at.isoformat()}" if important_date_at else "") - + (f"\n{billing_note}" if billing_note else "") - ), - responsible=responsible, - ) - try: - db.add(row) - db.commit() - db.refresh(row) - except IntegrityError: - db.rollback() - raise HTTPException(status_code=400, detail="Заявка с таким номером уже существует") - return {"status": "обновлено", "id": str(row.id), "track_number": row.track_number} - - -@router.delete("/{request_id}") -def delete_request(request_id: str, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER"))): - request_uuid = _request_uuid_or_400(request_id) - row = db.get(Request, request_uuid) - if not row: - raise HTTPException(status_code=404, detail="Заявка не найдена") - _ensure_lawyer_can_manage_request_or_403(admin, row) - db.delete(row) - db.commit() - return {"status": "удалено"} - -@router.get("/{request_id}") -def get_request(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 not req: - raise HTTPException(status_code=404, detail="Заявка не найдена") - _ensure_lawyer_can_view_request_or_403(admin, req) - 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, - "client_id": str(req.client_id) if req.client_id else None, - "client_name": req.client_name, - "client_phone": req.client_phone, - "topic_code": req.topic_code, - "status_code": req.status_code, - "important_date_at": req.important_date_at.isoformat() if req.important_date_at else None, - "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, - "request_cost": float(req.request_cost) if req.request_cost 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}/status-change") -def change_request_status( - request_id: str, - payload: RequestStatusChange, - 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 not req: - raise HTTPException(status_code=404, detail="Заявка не найдена") - _ensure_lawyer_can_manage_request_or_403(admin, req) - - next_status = str(payload.status_code or "").strip() - if not next_status: - raise HTTPException(status_code=400, detail='Поле "status_code" обязательно') - - status_row = db.query(Status).filter(Status.code == next_status, Status.enabled.is_(True)).first() - if status_row is None: - raise HTTPException(status_code=400, detail="Указан несуществующий или неактивный статус") - - old_status = str(req.status_code or "").strip() - if old_status == next_status: - raise HTTPException(status_code=400, detail="Выберите новый статус") - - important_date_at = _normalize_important_date_or_default(payload.important_date_at) - comment = str(payload.comment or "").strip() or None - responsible = str(admin.get("email") or "").strip() or "Администратор системы" - - req.status_code = next_status - req.important_date_at = important_date_at - req.responsible = responsible - - billing_note = apply_billing_transition_effects( - db, - req=req, - from_status=old_status, - to_status=next_status, - admin=admin, - responsible=responsible, - ) - mark_unread_for_client(req, EVENT_STATUS) - apply_status_change_effects( - db, - req, - from_status=old_status, - to_status=next_status, - admin=admin, - comment=comment, - important_date_at=important_date_at, - responsible=responsible, - ) - notify_request_event( - db, - request=req, - 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} -> {next_status}" - + f"\nВажная дата: {important_date_at.isoformat()}" - + (f"\n{comment}" if comment else "") - + (f"\n{billing_note}" if billing_note else "") - ), - responsible=responsible, - ) - - db.add(req) - db.commit() - db.refresh(req) - return { - "status": "ok", - "request_id": str(req.id), - "track_number": req.track_number, - "from_status": old_status or None, - "to_status": next_status, - "important_date_at": req.important_date_at.isoformat() if req.important_date_at else None, - } - - -@router.get("/{request_id}/status-route") -def get_request_status_route( - 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 not req: - raise HTTPException(status_code=404, detail="Заявка не найдена") - _ensure_lawyer_can_view_request_or_403(admin, req) - - topic_code = str(req.topic_code or "").strip() - current_status = str(req.status_code or "").strip() - - history_rows = ( - db.query(StatusHistory) - .filter(StatusHistory.request_id == req.id) - .order_by(StatusHistory.created_at.asc()) - .all() - ) - - known_codes: set[str] = set() - if current_status: - known_codes.add(current_status) - for row in history_rows: - from_code = str(row.from_status or "").strip() - to_code = str(row.to_status or "").strip() - if from_code: - known_codes.add(from_code) - if to_code: - known_codes.add(to_code) - statuses_map: dict[str, dict[str, str]] = {} - all_enabled_status_rows = db.query(Status, StatusGroup).outerjoin(StatusGroup, StatusGroup.id == Status.status_group_id).filter(Status.enabled.is_(True)).all() - for status_row, _group_row in all_enabled_status_rows: - code = str(status_row.code or "").strip() - if code: - known_codes.add(code) - if known_codes: - status_rows = ( - db.query(Status, StatusGroup) - .outerjoin(StatusGroup, StatusGroup.id == Status.status_group_id) - .filter(Status.code.in_(list(known_codes))) - .all() - ) - statuses_map = { - str(status_row.code): { - "name": str(status_row.name or status_row.code), - "kind": str(status_row.kind or "DEFAULT"), - "is_terminal": bool(status_row.is_terminal), - "status_group_id": str(status_row.status_group_id) if status_row.status_group_id else None, - "status_group_name": (str(group_row.name) if group_row is not None and group_row.name else None), - } - for status_row, group_row in status_rows - } - - sequence_from_history: list[str] = [] - if history_rows: - first_from = str(history_rows[0].from_status or "").strip() - if first_from: - sequence_from_history.append(first_from) - for row in history_rows: - to_code = str(row.to_status or "").strip() - if to_code: - sequence_from_history.append(to_code) - elif current_status: - sequence_from_history.append(current_status) - - ordered_codes: list[str] = [] - seen_codes: set[str] = set() - - def add_code(code: str) -> None: - normalized = str(code or "").strip() - if not normalized or normalized in seen_codes: - return - seen_codes.add(normalized) - ordered_codes.append(normalized) - - for code in sequence_from_history: - add_code(code) - - add_code(current_status) - - changed_at_by_status: dict[str, str] = {} - for row in history_rows: - to_code = str(row.to_status or "").strip() - if to_code and row.created_at: - changed_at_by_status[to_code] = row.created_at.isoformat() - - visited_codes = {code for code in sequence_from_history if code} - current_index = ordered_codes.index(current_status) if current_status in ordered_codes else -1 - - def status_name(code: str) -> str: - meta = statuses_map.get(code) or {} - return str(meta.get("name") or code) - - nodes: list[dict[str, str | int | None]] = [] - for index, code in enumerate(ordered_codes): - meta = statuses_map.get(code) or {} - state = "pending" - if code == current_status: - state = "current" - elif code in visited_codes or (current_index >= 0 and index < current_index): - state = "completed" - - note_parts: list[str] = [] - kind = str(meta.get("kind") or "DEFAULT") - if kind == "INVOICE": - note_parts.append("Этап выставления счета") - elif kind == "PAID": - note_parts.append("Этап подтверждения оплаты") - - nodes.append( - { - "code": code, - "name": status_name(code), - "kind": kind, - "state": state, - "changed_at": changed_at_by_status.get(code), - "note": " • ".join(note_parts), - } - ) - - history_entries: list[dict[str, object]] = [] - timeline: list[dict[str, object]] = [] - for row in history_rows: - timeline.append( - { - "id": str(row.id), - "from_status": str(row.from_status or "").strip() or None, - "to_status": str(row.to_status or "").strip() or None, - "to_status_name": status_name(str(row.to_status or "").strip()) if str(row.to_status or "").strip() else None, - "created_at": row.created_at, - "important_date_at": row.important_date_at, - "comment": row.comment, - } - ) - if not timeline: - timeline.append( - { - "id": "current", - "from_status": None, - "to_status": current_status or None, - "to_status_name": status_name(current_status) if current_status else None, - "created_at": req.updated_at or req.created_at, - "important_date_at": req.important_date_at, - "comment": None, - } - ) - for index, item in enumerate(timeline): - current_at = item.get("created_at") - next_at = timeline[index + 1].get("created_at") if index + 1 < len(timeline) else datetime.now(timezone.utc) - duration_seconds = None - if isinstance(current_at, datetime) and isinstance(next_at, datetime): - delta = next_at - current_at - duration_seconds = max(0, int(delta.total_seconds())) - history_entries.append( - { - "id": item.get("id"), - "from_status": item.get("from_status"), - "to_status": item.get("to_status"), - "to_status_name": item.get("to_status_name"), - "changed_at": current_at.isoformat() if isinstance(current_at, datetime) else None, - "important_date_at": item.get("important_date_at").isoformat() if isinstance(item.get("important_date_at"), datetime) else None, - "comment": item.get("comment"), - "duration_seconds": duration_seconds, - } - ) - - available_statuses: list[dict[str, object]] = [] - for status_row, group_row in sorted( - all_enabled_status_rows, - key=lambda pair: ( - int(pair[1].sort_order or 0) if pair[1] is not None else 999, - int(pair[0].sort_order or 0), - str(pair[0].name or pair[0].code).lower(), - ), - ): - code = str(status_row.code or "").strip() - if not code: - continue - available_statuses.append( - { - "code": code, - "name": str(status_row.name or code), - "kind": str(status_row.kind or "DEFAULT"), - "is_terminal": bool(status_row.is_terminal), - "status_group_id": str(status_row.status_group_id) if status_row.status_group_id else None, - "status_group_name": (str(group_row.name) if group_row is not None and group_row.name else None), - } - ) - - return { - "request_id": str(req.id), - "track_number": req.track_number, - "topic_code": req.topic_code, - "current_status": current_status or None, - "current_important_date_at": req.important_date_at.isoformat() if req.important_date_at else None, - "available_statuses": available_statuses, - "history": list(reversed(history_entries)), - "nodes": nodes, - } - - -@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), - effective_rate=case((Request.effective_rate.is_(None), lawyer.default_rate), else_=Request.effective_rate), - 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), - effective_rate=case((Request.effective_rate.is_(None), target_lawyer.default_rate), else_=Request.effective_rate), - 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)} +__all__ = ["router"] diff --git a/app/api/admin/requests_modules/__init__.py b/app/api/admin/requests_modules/__init__.py new file mode 100644 index 0000000..5bc0c2e --- /dev/null +++ b/app/api/admin/requests_modules/__init__.py @@ -0,0 +1,3 @@ +from .router import router + +__all__ = ["router"] diff --git a/app/api/admin/requests_modules/common.py b/app/api/admin/requests_modules/common.py new file mode 100644 index 0000000..a5755b6 --- /dev/null +++ b/app/api/admin/requests_modules/common.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + + +def parse_datetime_safe(value: object) -> datetime | None: + if value is None: + return None + if isinstance(value, datetime): + return value if value.tzinfo else value.replace(tzinfo=timezone.utc) + text = str(value).strip() + if not text: + return None + if text.endswith("Z"): + text = text[:-1] + "+00:00" + try: + parsed = datetime.fromisoformat(text) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed + + +def normalize_important_date_or_default(raw: object, *, default_days: int = 3) -> datetime: + parsed = parse_datetime_safe(raw) + if parsed: + return parsed + return datetime.now(timezone.utc) + timedelta(days=default_days) diff --git a/app/api/admin/requests_modules/data_templates.py b/app/api/admin/requests_modules/data_templates.py new file mode 100644 index 0000000..da30489 --- /dev/null +++ b/app/api/admin/requests_modules/data_templates.py @@ -0,0 +1,233 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from app.models.request import Request +from app.models.request_data_requirement import RequestDataRequirement +from app.models.topic_data_template import TopicDataTemplate +from app.schemas.admin import RequestDataRequirementCreate, RequestDataRequirementPatch +from app.services.request_status import actor_admin_uuid + +from .permissions import ensure_lawyer_can_manage_request_or_403, request_uuid_or_400 + + +def request_data_requirement_row(row: RequestDataRequirement) -> dict[str, Any]: + 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, + } + + +def get_request_data_template_service(request_id: str, db: Session, admin: dict) -> dict[str, Any]: + 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], + } + + +def sync_request_data_template_from_topic_service(request_id: str, db: Session, admin: dict) -> dict[str, Any]: + 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)} + + +def create_request_data_requirement_service( + request_id: str, + payload: RequestDataRequirementCreate, + db: Session, + admin: dict, +) -> dict[str, Any]: + 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) + + +def update_request_data_requirement_service( + request_id: str, + item_id: str, + payload: RequestDataRequirementPatch, + db: Session, + admin: dict, +) -> dict[str, Any]: + 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) + + +def delete_request_data_requirement_service(request_id: str, item_id: str, db: Session, admin: dict) -> dict[str, Any]: + 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/requests_modules/kanban.py b/app/api/admin/requests_modules/kanban.py new file mode 100644 index 0000000..5e5a31c --- /dev/null +++ b/app/api/admin/requests_modules/kanban.py @@ -0,0 +1,519 @@ +from __future__ import annotations + +import json +from datetime import datetime, timedelta, timezone +from typing import Any +from uuid import UUID + +from fastapi import HTTPException +from sqlalchemy import or_ +from sqlalchemy.orm import Session + +from app.models.admin_user import AdminUser +from app.models.request import Request +from app.models.status import Status +from app.models.status_group import StatusGroup +from app.models.status_history import StatusHistory +from app.models.topic_status_transition import TopicStatusTransition +from app.schemas.universal import FilterClause, Page, UniversalQuery +from app.services.universal_query import apply_universal_query + +from .common import parse_datetime_safe + +ALLOWED_KANBAN_FILTER_FIELDS = {"assigned_lawyer_id", "client_name", "status_code", "created_at", "topic_code", "overdue"} +ALLOWED_KANBAN_SORT_MODES = {"created_newest", "lawyer", "deadline"} +FALLBACK_KANBAN_GROUPS = [ + ("fallback_new", "Новые", 10), + ("fallback_in_progress", "В работе", 20), + ("fallback_waiting", "Ожидание", 30), + ("fallback_done", "Завершены", 40), +] + + +def status_meta_or_default(meta_map: dict[str, dict[str, object]], status_code: str) -> dict[str, object]: + return meta_map.get(status_code) or { + "name": status_code, + "kind": "DEFAULT", + "is_terminal": False, + "status_group_id": None, + "status_group_name": None, + "status_group_order": None, + } + + +def fallback_group_for_status(status_code: str, status_meta: dict[str, object]) -> tuple[str, str, int]: + code = str(status_code or "").strip().upper() + kind = str(status_meta.get("kind") or "DEFAULT").upper() + name = str(status_meta.get("name") or "").upper() + is_terminal = bool(status_meta.get("is_terminal")) + + if is_terminal: + return FALLBACK_KANBAN_GROUPS[3] + if kind == "PAID": + return FALLBACK_KANBAN_GROUPS[3] + if code.startswith("NEW") or "НОВ" in name: + return FALLBACK_KANBAN_GROUPS[0] + waiting_tokens = ("WAIT", "PEND", "HOLD", "SUSPEND", "BLOCK") + waiting_ru_tokens = ("ОЖИД", "ПАУЗ", "СОГЛАС", "ОПЛАТ", "СУД") + if kind == "INVOICE": + return FALLBACK_KANBAN_GROUPS[2] + if any(token in code for token in waiting_tokens) or any(token in name for token in waiting_ru_tokens): + return FALLBACK_KANBAN_GROUPS[2] + done_tokens = ("CLOSE", "RESOLV", "REJECT", "DONE", "PAID") + done_ru_tokens = ("ЗАВЕРШ", "ЗАКРЫ", "РЕШЕН", "ОТКЛОН", "ОПЛАЧ") + if any(token in code for token in done_tokens) or any(token in name for token in done_ru_tokens): + return FALLBACK_KANBAN_GROUPS[3] + return FALLBACK_KANBAN_GROUPS[1] + + +def extract_case_deadline(extra_fields: object) -> datetime | None: + if not isinstance(extra_fields, dict): + return None + deadline_keys = ( + "deadline_at", + "deadline", + "due_date", + "due_at", + "case_deadline", + "court_date", + "hearing_date", + "next_action_deadline", + ) + for key in deadline_keys: + parsed = parse_datetime_safe(extra_fields.get(key)) + if parsed: + return parsed + return None + + +def coerce_kanban_bool(value: object) -> bool: + if isinstance(value, bool): + return value + text = str(value or "").strip().lower() + if text in {"1", "true", "yes", "y", "on"}: + return True + if text in {"0", "false", "no", "n", "off"}: + return False + raise HTTPException(status_code=400, detail='Поле "overdue" должно быть boolean') + + +def parse_kanban_filters_or_400(raw_filters: str | None) -> tuple[list[FilterClause], list[tuple[str, bool]]]: + if not raw_filters: + return [], [] + try: + parsed = json.loads(raw_filters) + except json.JSONDecodeError as exc: + raise HTTPException(status_code=400, detail="Некорректный JSON фильтров канбана") from exc + if not isinstance(parsed, list): + raise HTTPException(status_code=400, detail="Фильтры канбана должны быть массивом") + + universal_filters: list[FilterClause] = [] + overdue_filters: list[tuple[str, bool]] = [] + for index, item in enumerate(parsed): + if not isinstance(item, dict): + raise HTTPException(status_code=400, detail=f"Фильтр #{index + 1} должен быть объектом") + field = str(item.get("field") or "").strip() + op = str(item.get("op") or "").strip() + value = item.get("value") + if field not in ALLOWED_KANBAN_FILTER_FIELDS: + raise HTTPException(status_code=400, detail=f'Недоступное поле фильтра: "{field}"') + if op not in {"=", "!=", ">", "<", ">=", "<=", "~"}: + raise HTTPException(status_code=400, detail=f'Недопустимый оператор фильтра: "{op}"') + if field == "overdue": + if op not in {"=", "!="}: + raise HTTPException(status_code=400, detail='Для поля "overdue" доступны только операторы "=" и "!="') + overdue_filters.append((op, coerce_kanban_bool(value))) + continue + universal_filters.append(FilterClause(field=field, op=op, value=value)) + return universal_filters, overdue_filters + + +def apply_overdue_filters(items: list[dict[str, object]], overdue_filters: list[tuple[str, bool]]) -> list[dict[str, object]]: + if not overdue_filters: + return items + now = datetime.now(timezone.utc) + out: list[dict[str, object]] = [] + for item in items: + raw_deadline = item.get("sla_deadline_at") or item.get("case_deadline_at") + deadline_at = parse_datetime_safe(raw_deadline) + is_overdue = bool(deadline_at and deadline_at <= now) + ok = True + for op, expected in overdue_filters: + if op == "=": + ok = ok and (is_overdue == expected) + elif op == "!=": + ok = ok and (is_overdue != expected) + if not ok: + break + if ok: + out.append(item) + return out + + +def sort_kanban_items(items: list[dict[str, object]], sort_mode: str) -> list[dict[str, object]]: + mode = sort_mode if sort_mode in ALLOWED_KANBAN_SORT_MODES else "created_newest" + epoch = datetime(1970, 1, 1, tzinfo=timezone.utc) + + if mode == "lawyer": + return sorted( + items, + key=lambda row: ( + 1 if not str(row.get("assigned_lawyer_name") or "").strip() else 0, + str(row.get("assigned_lawyer_name") or "").lower(), + -int((parse_datetime_safe(row.get("created_at")) or epoch).timestamp()), + ), + ) + + if mode == "deadline": + far_future = datetime(9999, 12, 31, tzinfo=timezone.utc) + return sorted( + items, + key=lambda row: ( + parse_datetime_safe(row.get("sla_deadline_at") or row.get("case_deadline_at")) or far_future, + -int((parse_datetime_safe(row.get("created_at")) or epoch).timestamp()), + ), + ) + + return sorted( + items, + key=lambda row: parse_datetime_safe(row.get("created_at")) or epoch, + reverse=True, + ) + + +def get_requests_kanban_service( + db: Session, + admin: dict, + *, + limit: int, + filters: str | None, + sort_mode: str, +) -> dict[str, Any]: + role = str(admin.get("role") or "").upper() + actor = str(admin.get("sub") or "").strip() + + base_query = db.query(Request) + if role == "LAWYER": + if not actor: + raise HTTPException(status_code=401, detail="Некорректный токен") + base_query = base_query.filter( + or_( + Request.assigned_lawyer_id == actor, + Request.assigned_lawyer_id.is_(None), + ) + ) + + normalized_sort_mode = sort_mode if sort_mode in ALLOWED_KANBAN_SORT_MODES else "created_newest" + query_filters, overdue_filters = parse_kanban_filters_or_400(filters) + if query_filters: + base_query = apply_universal_query( + base_query, + Request, + UniversalQuery( + filters=query_filters, + sort=[], + page=Page(limit=limit, offset=0), + ), + ) + + request_rows: list[Request] = base_query.all() + + request_id_to_row = {str(row.id): row for row in request_rows} + request_ids = [row.id for row in request_rows] + status_codes = {str(row.status_code or "").strip() for row in request_rows if str(row.status_code or "").strip()} + + status_meta_map: dict[str, dict[str, object]] = {} + if status_codes: + status_rows = ( + db.query(Status, StatusGroup) + .outerjoin(StatusGroup, StatusGroup.id == Status.status_group_id) + .filter(Status.code.in_(list(status_codes))) + .all() + ) + status_meta_map = { + str(status_row.code): { + "name": str(status_row.name or status_row.code), + "kind": str(status_row.kind or "DEFAULT"), + "is_terminal": bool(status_row.is_terminal), + "sort_order": int(status_row.sort_order or 0), + "status_group_id": str(status_row.status_group_id) if status_row.status_group_id else None, + "status_group_name": (str(group_row.name) if group_row is not None and group_row.name else None), + "status_group_order": (int(group_row.sort_order or 0) if group_row is not None else None), + } + for status_row, group_row in status_rows + } + + topic_codes = {str(row.topic_code or "").strip() for row in request_rows if str(row.topic_code or "").strip()} + transition_rows: list[TopicStatusTransition] = [] + if topic_codes: + transition_rows = ( + db.query(TopicStatusTransition) + .filter( + TopicStatusTransition.topic_code.in_(list(topic_codes)), + TopicStatusTransition.enabled.is_(True), + ) + .order_by( + TopicStatusTransition.topic_code.asc(), + TopicStatusTransition.sort_order.asc(), + TopicStatusTransition.created_at.asc(), + ) + .all() + ) + transitions_by_topic: dict[str, list[TopicStatusTransition]] = {} + transition_lookup: dict[tuple[str, str, str], TopicStatusTransition] = {} + first_incoming_by_topic_to: dict[tuple[str, str], TopicStatusTransition] = {} + for transition in transition_rows: + topic = str(transition.topic_code or "").strip() + from_status = str(transition.from_status or "").strip() + to_status = str(transition.to_status or "").strip() + if not topic or not from_status or not to_status: + continue + transitions_by_topic.setdefault(topic, []).append(transition) + transition_lookup[(topic, from_status, to_status)] = transition + first_incoming_by_topic_to.setdefault((topic, to_status), transition) + + assigned_ids = { + str(row.assigned_lawyer_id or "").strip() + for row in request_rows + if str(row.assigned_lawyer_id or "").strip() + } + lawyer_name_map: dict[str, str] = {} + if assigned_ids: + valid_lawyer_ids: list[UUID] = [] + for raw in assigned_ids: + try: + valid_lawyer_ids.append(UUID(raw)) + except ValueError: + continue + if valid_lawyer_ids: + lawyer_rows = db.query(AdminUser).filter(AdminUser.id.in_(valid_lawyer_ids)).all() + lawyer_name_map = { + str(row.id): str(row.name or row.email or row.id) + for row in lawyer_rows + } + + history_rows: list[StatusHistory] = [] + if request_ids: + history_rows = ( + db.query(StatusHistory) + .filter(StatusHistory.request_id.in_(request_ids)) + .order_by(StatusHistory.request_id.asc(), StatusHistory.created_at.desc()) + .all() + ) + + current_status_changed_at: dict[str, datetime] = {} + previous_status_by_request: dict[str, str] = {} + for row in history_rows: + request_id = str(row.request_id) + request_row = request_id_to_row.get(request_id) + if request_row is None: + continue + current_status = str(request_row.status_code or "").strip() + to_status = str(row.to_status or "").strip() + if not current_status or to_status != current_status: + continue + if request_id not in current_status_changed_at and row.created_at: + current_status_changed_at[request_id] = row.created_at + previous_status_by_request[request_id] = str(row.from_status or "").strip() + + all_enabled_status_rows = ( + db.query(Status, StatusGroup) + .outerjoin(StatusGroup, StatusGroup.id == Status.status_group_id) + .filter(Status.enabled.is_(True)) + .order_by(Status.sort_order.asc(), Status.name.asc(), Status.code.asc()) + .all() + ) + all_enabled_statuses: list[dict[str, object]] = [] + for status_row, group_row in all_enabled_status_rows: + code = str(status_row.code or "").strip() + if not code: + continue + meta = { + "code": code, + "name": str(status_row.name or code), + "kind": str(status_row.kind or "DEFAULT"), + "is_terminal": bool(status_row.is_terminal), + "status_group_id": str(status_row.status_group_id) if status_row.status_group_id else None, + "status_group_name": (str(group_row.name) if group_row is not None and group_row.name else None), + "status_group_order": (int(group_row.sort_order or 0) if group_row is not None else None), + "sort_order": int(status_row.sort_order or 0), + } + status_meta_map.setdefault(code, meta) + all_enabled_statuses.append(meta) + + status_groups_rows = db.query(StatusGroup).order_by(StatusGroup.sort_order.asc(), StatusGroup.name.asc()).all() + columns_catalog = [ + { + "key": str(group.id), + "label": str(group.name), + "sort_order": int(group.sort_order or 0), + } + for group in status_groups_rows + ] + columns_by_key = {row["key"]: row for row in columns_catalog} + + items: list[dict[str, object]] = [] + group_totals: dict[str, int] = {row["key"]: 0 for row in columns_catalog} + for row in request_rows: + request_id = str(row.id) + status_code = str(row.status_code or "").strip() + topic_code = str(row.topic_code or "").strip() + status_meta = status_meta_or_default(status_meta_map, status_code) + status_group = str(status_meta.get("status_group_id") or "").strip() + status_group_name = str(status_meta.get("status_group_name") or "").strip() + status_group_order = status_meta.get("status_group_order") + if not status_group: + fallback_key, fallback_label, fallback_order = fallback_group_for_status(status_code, status_meta) + status_group = fallback_key + status_group_name = fallback_label + status_group_order = fallback_order + if fallback_key not in columns_by_key: + columns_by_key[fallback_key] = {"key": fallback_key, "label": fallback_label, "sort_order": fallback_order} + columns_catalog.append(columns_by_key[fallback_key]) + elif status_group not in columns_by_key: + columns_by_key[status_group] = { + "key": status_group, + "label": status_group_name or status_group, + "sort_order": int(status_group_order or 999), + } + columns_catalog.append(columns_by_key[status_group]) + + available_transitions = [] + topic_rules = transitions_by_topic.get(topic_code) or [] + if topic_rules: + for rule in topic_rules: + from_status = str(rule.from_status or "").strip() + to_status = str(rule.to_status or "").strip() + if from_status != status_code or not to_status: + continue + to_meta = status_meta_or_default(status_meta_map, to_status) + target_group = str(to_meta.get("status_group_id") or "").strip() + if not target_group: + target_group, fallback_label, fallback_order = fallback_group_for_status(to_status, to_meta) + if target_group not in columns_by_key: + columns_by_key[target_group] = {"key": target_group, "label": fallback_label, "sort_order": fallback_order} + columns_catalog.append(columns_by_key[target_group]) + if target_group not in group_totals: + group_totals[target_group] = 0 + available_transitions.append( + { + "to_status": to_status, + "to_status_name": str(to_meta.get("name") or to_status), + "target_group": target_group, + "is_terminal": bool(to_meta.get("is_terminal")), + } + ) + else: + for status_def in all_enabled_statuses: + to_status = str(status_def.get("code") or "").strip() + if not to_status or to_status == status_code: + continue + to_meta = status_meta_or_default(status_meta_map, to_status) + target_group = str(to_meta.get("status_group_id") or "").strip() + if not target_group: + target_group, fallback_label, fallback_order = fallback_group_for_status(to_status, to_meta) + if target_group not in columns_by_key: + columns_by_key[target_group] = {"key": target_group, "label": fallback_label, "sort_order": fallback_order} + columns_catalog.append(columns_by_key[target_group]) + if target_group not in group_totals: + group_totals[target_group] = 0 + available_transitions.append( + { + "to_status": to_status, + "to_status_name": str(to_meta.get("name") or to_status), + "target_group": target_group, + "is_terminal": bool(to_meta.get("is_terminal")), + } + ) + + case_deadline = row.important_date_at or extract_case_deadline(row.extra_fields) + entered_at = parse_datetime_safe(current_status_changed_at.get(request_id)) + if entered_at is None: + entered_at = parse_datetime_safe(row.updated_at) or parse_datetime_safe(row.created_at) + sla_deadline = None + previous_status = str(previous_status_by_request.get(request_id) or "").strip() + transition_rule = ( + transition_lookup.get((topic_code, previous_status, status_code)) + if previous_status + else None + ) + if transition_rule is None: + transition_rule = first_incoming_by_topic_to.get((topic_code, status_code)) + if ( + transition_rule is not None + and transition_rule.sla_hours is not None + and int(transition_rule.sla_hours) > 0 + and entered_at is not None + ): + sla_deadline = entered_at + timedelta(hours=int(transition_rule.sla_hours)) + + assigned_id = str(row.assigned_lawyer_id or "").strip() or None + items.append( + { + "id": str(row.id), + "track_number": row.track_number, + "client_name": row.client_name, + "client_phone": row.client_phone, + "topic_code": row.topic_code, + "status_code": status_code, + "important_date_at": row.important_date_at.isoformat() if row.important_date_at else None, + "status_name": str(status_meta.get("name") or status_code), + "status_group": status_group, + "status_group_name": status_group_name or None, + "status_group_order": int(status_group_order or 0) if status_group_order is not None else None, + "assigned_lawyer_id": assigned_id, + "assigned_lawyer_name": lawyer_name_map.get(assigned_id or "", assigned_id), + "description": row.description, + "created_at": row.created_at.isoformat() if row.created_at else None, + "updated_at": row.updated_at.isoformat() if row.updated_at else None, + "lawyer_has_unread_updates": bool(row.lawyer_has_unread_updates), + "lawyer_unread_event_type": row.lawyer_unread_event_type, + "client_has_unread_updates": bool(row.client_has_unread_updates), + "client_unread_event_type": row.client_unread_event_type, + "case_deadline_at": case_deadline.isoformat() if case_deadline else None, + "sla_deadline_at": sla_deadline.isoformat() if sla_deadline is not None else None, + "available_transitions": available_transitions, + } + ) + + items = apply_overdue_filters(items, overdue_filters) + items = sort_kanban_items(items, normalized_sort_mode) + total = len(items) + if total > limit: + items = items[:limit] + + for row in items: + key = str(row.get("status_group") or "").strip() + if not key: + continue + group_totals[key] = int(group_totals.get(key, 0)) + 1 + + columns = [] + for item in sorted( + columns_catalog, + key=lambda row: ( + int(row.get("sort_order") or 0), + str(row.get("label") or "").lower(), + ), + ): + key = str(item.get("key") or "") + if not key: + continue + columns.append( + { + "key": key, + "label": str(item.get("label") or key), + "sort_order": int(item.get("sort_order") or 0), + "total": int(group_totals.get(key, 0)), + } + ) + + return { + "scope": role, + "rows": items, + "columns": columns, + "total": total, + "limit": int(limit), + "sort_mode": normalized_sort_mode, + "truncated": bool(total > len(items)), + } diff --git a/app/api/admin/requests_modules/permissions.py b/app/api/admin/requests_modules/permissions.py new file mode 100644 index 0000000..c6787b7 --- /dev/null +++ b/app/api/admin/requests_modules/permissions.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +from uuid import UUID + +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from app.models.admin_user import AdminUser +from app.models.client import Client +from app.models.request import Request + +REQUEST_FINANCIAL_FIELDS = {"effective_rate", "invoice_amount", "paid_at", "paid_by_admin_id"} + + +def normalize_client_phone(value: object) -> str: + text = "".join(ch for ch in str(value or "") if ch.isdigit() or ch == "+") + if not text: + return "" + if text.startswith("8") and len(text) == 11: + text = "+7" + text[1:] + if not text.startswith("+") and text.isdigit(): + text = "+" + text + return text + + +def client_uuid_or_none(value: object) -> UUID | None: + raw = str(value or "").strip() + if not raw: + return None + try: + return UUID(raw) + except ValueError as exc: + raise HTTPException(status_code=400, detail='Некорректный "client_id"') from exc + + +def client_for_request_payload_or_400( + db: Session, + *, + client_id: object, + client_name: object, + client_phone: object, + responsible: str, +) -> Client: + client_uuid = client_uuid_or_none(client_id) + if client_uuid is not None: + row = db.get(Client, client_uuid) + if row is None: + raise HTTPException(status_code=404, detail="Клиент не найден") + return row + + normalized_phone = normalize_client_phone(client_phone) + if not normalized_phone: + raise HTTPException(status_code=400, detail='Поле "client_phone" обязательно') + normalized_name = str(client_name or "").strip() or "Клиент" + + row = db.query(Client).filter(Client.phone == normalized_phone).first() + if row is None: + row = Client( + full_name=normalized_name, + phone=normalized_phone, + responsible=responsible, + ) + db.add(row) + db.flush() + return row + + changed = False + if normalized_name and row.full_name != normalized_name: + row.full_name = normalized_name + changed = True + if changed: + row.responsible = responsible + db.add(row) + db.flush() + return row + + +def request_uuid_or_400(request_id: str) -> UUID: + try: + return UUID(str(request_id)) + except ValueError as exc: + raise HTTPException(status_code=400, detail="Некорректный идентификатор заявки") from exc + + +def active_lawyer_or_400(db: Session, lawyer_id: str) -> AdminUser: + try: + lawyer_uuid = UUID(str(lawyer_id)) + except ValueError as exc: + raise HTTPException(status_code=400, detail="Некорректный идентификатор юриста") from exc + 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=400, detail="Можно назначить только активного юриста") + return lawyer + + +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() + if not actor: + raise HTTPException(status_code=401, detail="Некорректный токен") + assigned = str(req.assigned_lawyer_id or "").strip() + if not actor or not assigned or actor != assigned: + raise HTTPException(status_code=403, detail="Юрист может работать только со своими назначенными заявками") + + +def ensure_lawyer_can_view_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() + if not actor: + raise HTTPException(status_code=401, detail="Некорректный токен") + assigned = str(req.assigned_lawyer_id or "").strip() + if assigned and actor != assigned: + raise HTTPException(status_code=403, detail="Юрист может видеть только свои и неназначенные заявки") diff --git a/app/api/admin/requests_modules/router.py b/app/api/admin/requests_modules/router.py new file mode 100644 index 0000000..5c9ff1a --- /dev/null +++ b/app/api/admin/requests_modules/router.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, 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 ( + RequestAdminCreate, + RequestAdminPatch, + RequestDataRequirementCreate, + RequestDataRequirementPatch, + RequestReassign, + RequestServiceRequestPatch, + RequestStatusChange, +) +from app.schemas.universal import UniversalQuery + +from .data_templates import ( + create_request_data_requirement_service, + delete_request_data_requirement_service, + get_request_data_template_service, + sync_request_data_template_from_topic_service, + update_request_data_requirement_service, +) +from .kanban import get_requests_kanban_service +from .service import ( + claim_request_service, + create_request_service, + delete_request_service, + get_request_service, + query_requests_service, + reassign_request_service, + update_request_service, +) +from .service_requests import ( + list_request_service_requests_service, + mark_service_request_read_service, + update_service_request_status_service, +) +from .status_flow import change_request_status_service, get_request_status_route_service + +router = APIRouter() + + +@router.post("/query") +def query_requests(uq: UniversalQuery, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER", "CURATOR"))): + return query_requests_service(uq, db, admin) + + +@router.get("/kanban") +def get_requests_kanban( + db: Session = Depends(get_db), + admin=Depends(require_role("ADMIN", "LAWYER")), + limit: int = Query(default=400, ge=1, le=1000), + filters: str | None = Query(default=None), + sort_mode: str = Query(default="created_newest"), +): + return get_requests_kanban_service(db, admin, limit=limit, filters=filters, sort_mode=sort_mode) + + +@router.post("", status_code=201) +def create_request(payload: RequestAdminCreate, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER"))): + return create_request_service(payload, db, admin) + + +@router.patch("/{request_id}") +def update_request( + request_id: str, + payload: RequestAdminPatch, + db: Session = Depends(get_db), + admin=Depends(require_role("ADMIN", "LAWYER")), +): + return update_request_service(request_id, payload, db, admin) + + +@router.delete("/{request_id}") +def delete_request(request_id: str, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER"))): + return delete_request_service(request_id, db, admin) + + +@router.get("/{request_id}") +def get_request(request_id: str, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER", "CURATOR"))): + return get_request_service(request_id, db, admin) + + +@router.post("/{request_id}/status-change") +def change_request_status( + request_id: str, + payload: RequestStatusChange, + db: Session = Depends(get_db), + admin=Depends(require_role("ADMIN", "LAWYER")), +): + return change_request_status_service(request_id, payload, db, admin) + + +@router.get("/{request_id}/status-route") +def get_request_status_route( + request_id: str, + db: Session = Depends(get_db), + admin=Depends(require_role("ADMIN", "LAWYER", "CURATOR")), +): + return get_request_status_route_service(request_id, db, admin) + + +@router.post("/{request_id}/claim") +def claim_request(request_id: str, db: Session = Depends(get_db), admin=Depends(require_role("LAWYER"))): + return claim_request_service(request_id, db, admin) + + +@router.post("/{request_id}/reassign") +def reassign_request( + request_id: str, + payload: RequestReassign, + db: Session = Depends(get_db), + admin=Depends(require_role("ADMIN")), +): + return reassign_request_service(request_id, payload.lawyer_id, db, admin) + + +@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", "CURATOR")), +): + return get_request_data_template_service(request_id, db, admin) + + +@router.get("/{request_id}/service-requests") +def list_request_service_requests( + request_id: str, + db: Session = Depends(get_db), + admin=Depends(require_role("ADMIN", "LAWYER", "CURATOR")), +): + return list_request_service_requests_service(request_id, db, admin) + + +@router.post("/service-requests/{service_request_id}/read") +def mark_service_request_read( + service_request_id: str, + db: Session = Depends(get_db), + admin=Depends(require_role("ADMIN", "LAWYER", "CURATOR")), +): + return mark_service_request_read_service(service_request_id, db, admin) + + +@router.patch("/service-requests/{service_request_id}") +def update_service_request_status( + service_request_id: str, + payload: RequestServiceRequestPatch, + db: Session = Depends(get_db), + admin=Depends(require_role("ADMIN", "CURATOR")), +): + return update_service_request_status_service(service_request_id, payload, db, admin) + + +@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")), +): + return sync_request_data_template_from_topic_service(request_id, db, admin) + + +@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")), +): + return create_request_data_requirement_service(request_id, payload, db, admin) + + +@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")), +): + return update_request_data_requirement_service(request_id, item_id, payload, db, admin) + + +@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")), +): + return delete_request_data_requirement_service(request_id, item_id, db, admin) diff --git a/app/api/admin/requests_modules/service.py b/app/api/admin/requests_modules/service.py new file mode 100644 index 0000000..ca6764c --- /dev/null +++ b/app/api/admin/requests_modules/service.py @@ -0,0 +1,471 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any +from uuid import UUID, uuid4 + +from fastapi import HTTPException +from sqlalchemy import case, func, or_, update +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.models.admin_user import AdminUser +from app.models.audit_log import AuditLog +from app.models.request import Request +from app.models.request_service_request import RequestServiceRequest +from app.schemas.admin import RequestAdminCreate, RequestAdminPatch +from app.schemas.universal import UniversalQuery +from app.services.billing_flow import apply_billing_transition_effects +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 apply_status_change_effects +from app.services.request_templates import validate_required_topic_fields_or_400 +from app.services.status_flow import transition_allowed_for_topic +from app.services.status_transition_requirements import validate_transition_requirements_or_400 +from app.services.universal_query import apply_universal_query + +from .common import normalize_important_date_or_default +from .permissions import ( + REQUEST_FINANCIAL_FIELDS, + active_lawyer_or_400, + client_for_request_payload_or_400, + ensure_lawyer_can_manage_request_or_403, + ensure_lawyer_can_view_request_or_403, + request_uuid_or_400, +) +from .status_flow import apply_request_special_filters, split_request_special_filters + + +def query_requests_service(uq: UniversalQuery, db: Session, admin: dict) -> dict[str, Any]: + base_query = db.query(Request) + role = str(admin.get("role") or "").upper() + actor = str(admin.get("sub") or "").strip() + if role == "LAWYER": + if not actor: + raise HTTPException(status_code=401, detail="Некорректный токен") + base_query = base_query.filter( + or_( + Request.assigned_lawyer_id == actor, + Request.assigned_lawyer_id.is_(None), + ) + ) + + regular_uq, special_filters = split_request_special_filters(uq) + base_query = apply_request_special_filters( + base_query, + db=db, + role=role, + actor_id=actor, + special_filters=special_filters, + ) + q = apply_universal_query(base_query, Request, regular_uq) + total = q.count() + rows = q.offset(uq.page.offset).limit(uq.page.limit).all() + row_ids = [str(row.id) for row in rows if row and row.id] + + unread_service_requests_by_request: dict[str, int] = {} + if row_ids: + unread_query = ( + db.query(RequestServiceRequest.request_id, func.count(RequestServiceRequest.id)) + .filter(RequestServiceRequest.request_id.in_(row_ids)) + ) + if role == "LAWYER": + unread_query = unread_query.filter( + RequestServiceRequest.type == "CURATOR_CONTACT", + RequestServiceRequest.assigned_lawyer_id == actor, + RequestServiceRequest.lawyer_unread.is_(True), + ) + else: + unread_query = unread_query.filter(RequestServiceRequest.admin_unread.is_(True)) + unread_rows = unread_query.group_by(RequestServiceRequest.request_id).all() + unread_service_requests_by_request = {str(request_id): int(count or 0) for request_id, count in unread_rows if request_id} + + return { + "rows": [ + { + "id": str(r.id), + "track_number": r.track_number, + "client_id": str(r.client_id) if r.client_id else None, + "status_code": r.status_code, + "client_name": r.client_name, + "client_phone": r.client_phone, + "topic_code": r.topic_code, + "important_date_at": r.important_date_at.isoformat() if r.important_date_at else None, + "effective_rate": float(r.effective_rate) if r.effective_rate is not None else None, + "request_cost": float(r.request_cost) if r.request_cost 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, + "service_requests_unread_count": int(unread_service_requests_by_request.get(str(r.id), 0)), + "has_service_requests_unread": bool(unread_service_requests_by_request.get(str(r.id), 0)), + "created_at": r.created_at.isoformat() if r.created_at else None, + "updated_at": r.updated_at.isoformat() if r.updated_at else None, + } + for r in rows + ], + "total": total, + } + + +def create_request_service(payload: RequestAdminCreate, db: Session, admin: dict) -> dict[str, Any]: + actor_role = str(admin.get("role") or "").upper() + if actor_role == "LAWYER" and str(payload.assigned_lawyer_id or "").strip(): + raise HTTPException(status_code=403, detail="Юрист не может назначать заявку при создании") + if actor_role == "LAWYER": + forbidden_fields = sorted(REQUEST_FINANCIAL_FIELDS.intersection(set(payload.model_fields_set))) + if forbidden_fields: + 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 "Администратор системы" + client = client_for_request_payload_or_400( + db, + client_id=payload.client_id, + client_name=payload.client_name, + client_phone=payload.client_phone, + responsible=responsible, + ) + assigned_lawyer_id = str(payload.assigned_lawyer_id or "").strip() or None + effective_rate = payload.effective_rate + if assigned_lawyer_id: + assigned_lawyer = active_lawyer_or_400(db, assigned_lawyer_id) + assigned_lawyer_id = str(assigned_lawyer.id) + if effective_rate is None: + effective_rate = assigned_lawyer.default_rate + row = Request( + track_number=track, + client_id=client.id, + client_name=client.full_name, + client_phone=client.phone, + topic_code=payload.topic_code, + status_code=payload.status_code, + important_date_at=payload.important_date_at, + description=payload.description, + extra_fields=payload.extra_fields, + assigned_lawyer_id=assigned_lawyer_id, + effective_rate=effective_rate, + request_cost=payload.request_cost, + 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) + db.commit() + db.refresh(row) + except IntegrityError as exc: + db.rollback() + raise HTTPException(status_code=400, detail="Заявка с таким номером уже существует") from exc + return {"id": str(row.id), "track_number": row.track_number} + + +def update_request_service(request_id: str, payload: RequestAdminPatch, db: Session, admin: dict) -> dict[str, Any]: + request_uuid = request_uuid_or_400(request_id) + row = db.get(Request, request_uuid) + if not row: + raise HTTPException(status_code=404, detail="Заявка не найдена") + ensure_lawyer_can_manage_request_or_403(admin, row) + changes = payload.model_dump(exclude_unset=True) + actor_role = str(admin.get("role") or "").upper() + if actor_role == "LAWYER": + if "assigned_lawyer_id" in changes: + raise HTTPException(status_code=403, detail='Назначение доступно только через действие "Взять в работу"') + forbidden_fields = sorted(REQUEST_FINANCIAL_FIELDS.intersection(set(changes.keys()))) + if forbidden_fields: + raise HTTPException(status_code=403, detail="Юрист не может изменять финансовые поля заявки") + if actor_role == "ADMIN" and "assigned_lawyer_id" in changes: + assigned_raw = changes.get("assigned_lawyer_id") + if assigned_raw is None or not str(assigned_raw).strip(): + changes["assigned_lawyer_id"] = None + else: + assigned_lawyer = active_lawyer_or_400(db, str(assigned_raw)) + changes["assigned_lawyer_id"] = str(assigned_lawyer.id) + if row.effective_rate is None and "effective_rate" not in changes: + changes["effective_rate"] = assigned_lawyer.default_rate + old_status = str(row.status_code or "") + responsible = str(admin.get("email") or "").strip() or "Администратор системы" + if {"client_id", "client_name", "client_phone"}.intersection(set(changes.keys())): + client = client_for_request_payload_or_400( + db, + client_id=changes.get("client_id", row.client_id), + client_name=changes.get("client_name", row.client_name), + client_phone=changes.get("client_phone", row.client_phone), + responsible=responsible, + ) + changes["client_id"] = client.id + changes["client_name"] = client.full_name + changes["client_phone"] = client.phone + status_changed = "status_code" in changes and str(changes.get("status_code") or "") != old_status + if status_changed and ("important_date_at" not in changes or changes.get("important_date_at") is None): + changes["important_date_at"] = normalize_important_date_or_default(None) + if status_changed: + next_status = str(changes.get("status_code") or "").strip() + if not transition_allowed_for_topic( + db, + str(row.topic_code or "").strip() or None, + old_status, + next_status, + ): + raise HTTPException(status_code=400, detail="Переход статуса не разрешен для выбранной темы") + extra_fields_override = changes.get("extra_fields") + validate_transition_requirements_or_400( + db, + row, + old_status, + next_status, + extra_fields_override=extra_fields_override if isinstance(extra_fields_override, dict) else None, + ) + for key, value in changes.items(): + setattr(row, key, value) + if status_changed: + next_status = str(changes.get("status_code") or "") + important_date_at = row.important_date_at + billing_note = apply_billing_transition_effects( + db, + req=row, + from_status=old_status, + to_status=next_status, + admin=admin, + responsible=responsible, + ) + mark_unread_for_client(row, EVENT_STATUS) + apply_status_change_effects( + db, + row, + from_status=old_status, + to_status=next_status, + 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} -> {next_status}" + + (f"\nВажная дата: {important_date_at.isoformat()}" if important_date_at else "") + + (f"\n{billing_note}" if billing_note else "") + ), + responsible=responsible, + ) + try: + db.add(row) + db.commit() + db.refresh(row) + except IntegrityError as exc: + db.rollback() + raise HTTPException(status_code=400, detail="Заявка с таким номером уже существует") from exc + return {"status": "обновлено", "id": str(row.id), "track_number": row.track_number} + + +def delete_request_service(request_id: str, db: Session, admin: dict) -> dict[str, Any]: + request_uuid = request_uuid_or_400(request_id) + row = db.get(Request, request_uuid) + if not row: + raise HTTPException(status_code=404, detail="Заявка не найдена") + ensure_lawyer_can_manage_request_or_403(admin, row) + db.delete(row) + db.commit() + return {"status": "удалено"} + + +def get_request_service(request_id: str, db: Session, admin: dict) -> dict[str, Any]: + request_uuid = request_uuid_or_400(request_id) + req = db.get(Request, request_uuid) + if not req: + raise HTTPException(status_code=404, detail="Заявка не найдена") + ensure_lawyer_can_view_request_or_403(admin, req) + 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, + "client_id": str(req.client_id) if req.client_id else None, + "client_name": req.client_name, + "client_phone": req.client_phone, + "topic_code": req.topic_code, + "status_code": req.status_code, + "important_date_at": req.important_date_at.isoformat() if req.important_date_at else None, + "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, + "request_cost": float(req.request_cost) if req.request_cost 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, + } + + +def claim_request_service(request_id: str, db: Session, admin: dict) -> dict[str, Any]: + 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 as exc: + raise HTTPException(status_code=401, detail="Некорректный токен") from exc + + 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), + effective_rate=case((Request.effective_rate.is_(None), lawyer.default_rate), else_=Request.effective_rate), + 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, + } + + +def reassign_request_service(request_id: str, lawyer_id: str, db: Session, admin: dict) -> dict[str, Any]: + request_uuid = request_uuid_or_400(request_id) + + try: + lawyer_uuid = UUID(str(lawyer_id)) + except ValueError as exc: + raise HTTPException(status_code=400, detail="Некорректный идентификатор юриста") from exc + + 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), + effective_rate=case((Request.effective_rate.is_(None), target_lawyer.default_rate), else_=Request.effective_rate), + 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, + } diff --git a/app/api/admin/requests_modules/service_requests.py b/app/api/admin/requests_modules/service_requests.py new file mode 100644 index 0000000..6956a60 --- /dev/null +++ b/app/api/admin/requests_modules/service_requests.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from uuid import UUID + +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from app.models.audit_log import AuditLog +from app.models.request import Request +from app.models.request_service_request import RequestServiceRequest +from app.schemas.admin import RequestServiceRequestPatch + +from .permissions import ensure_lawyer_can_view_request_or_403, request_uuid_or_400 + +SERVICE_REQUEST_TYPES = {"CURATOR_CONTACT", "LAWYER_CHANGE_REQUEST"} +SERVICE_REQUEST_STATUSES = {"NEW", "IN_PROGRESS", "RESOLVED", "REJECTED"} + + +def _parse_service_request_uuid_or_400(service_request_id: str) -> UUID: + try: + return UUID(str(service_request_id)) + except ValueError as exc: + raise HTTPException(status_code=400, detail="Некорректный идентификатор запроса") from exc + + +def _service_request_for_id_or_404(db: Session, service_request_id: str) -> RequestServiceRequest: + row = db.get(RequestServiceRequest, _parse_service_request_uuid_or_400(service_request_id)) + if row is None: + raise HTTPException(status_code=404, detail="Запрос не найден") + return row + + +def _resolve_responsible(admin: dict) -> str: + return str(admin.get("email") or "").strip() or "Администратор системы" + + +def _actor_id_or_none(admin: dict) -> str | None: + raw = str(admin.get("sub") or "").strip() + if not raw: + return None + try: + UUID(raw) + return raw + except ValueError: + return None + + +def _actor_uuid_or_none(admin: dict) -> UUID | None: + raw = str(admin.get("sub") or "").strip() + if not raw: + return None + try: + return UUID(raw) + except ValueError: + return None + + +def _ensure_lawyer_can_view_service_request_or_403(admin: dict, row: RequestServiceRequest) -> None: + role = str(admin.get("role") or "").upper() + if role != "LAWYER": + return + actor = str(admin.get("sub") or "").strip() + row_type = str(row.type or "").strip().upper() + assigned = str(row.assigned_lawyer_id or "").strip() + if row_type != "CURATOR_CONTACT" or not actor or not assigned or assigned != actor: + raise HTTPException(status_code=403, detail="Недостаточно прав") + + +def _serialize_service_request(row: RequestServiceRequest) -> dict: + return { + "id": str(row.id), + "request_id": str(row.request_id), + "client_id": str(row.client_id) if row.client_id else None, + "assigned_lawyer_id": str(row.assigned_lawyer_id) if row.assigned_lawyer_id else None, + "resolved_by_admin_id": str(row.resolved_by_admin_id) if row.resolved_by_admin_id else None, + "type": str(row.type or ""), + "status": str(row.status or "NEW"), + "body": str(row.body or ""), + "created_by_client": bool(row.created_by_client), + "admin_unread": bool(row.admin_unread), + "lawyer_unread": bool(row.lawyer_unread), + "admin_read_at": row.admin_read_at.isoformat() if row.admin_read_at else None, + "lawyer_read_at": row.lawyer_read_at.isoformat() if row.lawyer_read_at else None, + "resolved_at": row.resolved_at.isoformat() if row.resolved_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 list_request_service_requests_service(request_id: str, db: Session, admin: dict) -> dict: + 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_view_request_or_403(admin, req) + + role = str(admin.get("role") or "").upper() + query = db.query(RequestServiceRequest).filter(RequestServiceRequest.request_id == str(req.id)) + if role == "LAWYER": + actor_id = _actor_id_or_none(admin) + if actor_id is None: + raise HTTPException(status_code=401, detail="Некорректный токен") + query = query.filter( + RequestServiceRequest.type == "CURATOR_CONTACT", + RequestServiceRequest.assigned_lawyer_id == actor_id, + ) + rows = query.order_by(RequestServiceRequest.created_at.desc(), RequestServiceRequest.id.desc()).all() + return {"rows": [_serialize_service_request(row) for row in rows], "total": len(rows)} + + +def mark_service_request_read_service(service_request_id: str, db: Session, admin: dict) -> dict: + row = _service_request_for_id_or_404(db, service_request_id) + role = str(admin.get("role") or "").upper() + _ensure_lawyer_can_view_service_request_or_403(admin, row) + + now = datetime.now(timezone.utc) + changed = False + responsible = _resolve_responsible(admin) + actor_uuid = _actor_uuid_or_none(admin) + action = None + if role == "LAWYER": + if row.lawyer_unread: + row.lawyer_unread = False + row.lawyer_read_at = now + action = "READ_MARK_LAWYER" + changed = True + else: + if row.admin_unread: + row.admin_unread = False + row.admin_read_at = now + action = "READ_MARK_ADMIN" + changed = True + + if changed: + row.responsible = responsible + db.add(row) + db.add( + AuditLog( + actor_admin_id=actor_uuid, + entity="request_service_requests", + entity_id=str(row.id), + action=str(action or "READ_MARK"), + diff={"status": str(row.status or "NEW")}, + responsible=responsible, + ) + ) + db.commit() + db.refresh(row) + return {"status": "ok", "changed": int(changed), "row": _serialize_service_request(row)} + + +def update_service_request_status_service(service_request_id: str, payload: RequestServiceRequestPatch, db: Session, admin: dict) -> dict: + row = _service_request_for_id_or_404(db, service_request_id) + next_status = str(payload.status or "").strip().upper() + if next_status not in SERVICE_REQUEST_STATUSES: + raise HTTPException(status_code=400, detail="Некорректный статус запроса") + + previous_status = str(row.status or "NEW") + if next_status == previous_status: + return {"status": "ok", "changed": 0, "row": _serialize_service_request(row)} + + now = datetime.now(timezone.utc) + responsible = _resolve_responsible(admin) + actor_id = _actor_id_or_none(admin) + actor_uuid = _actor_uuid_or_none(admin) + + row.status = next_status + if next_status in {"RESOLVED", "REJECTED"}: + row.resolved_at = now + row.resolved_by_admin_id = actor_id + row.responsible = responsible + db.add(row) + db.add( + AuditLog( + actor_admin_id=actor_uuid, + entity="request_service_requests", + entity_id=str(row.id), + action="STATUS_UPDATE", + diff={"before": {"status": previous_status}, "after": {"status": next_status}}, + responsible=responsible, + ) + ) + db.commit() + db.refresh(row) + return {"status": "ok", "changed": 1, "row": _serialize_service_request(row)} diff --git a/app/api/admin/requests_modules/status_flow.py b/app/api/admin/requests_modules/status_flow.py new file mode 100644 index 0000000..16490be --- /dev/null +++ b/app/api/admin/requests_modules/status_flow.py @@ -0,0 +1,431 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Any + +from fastapi import HTTPException +from sqlalchemy import or_ +from sqlalchemy.orm import Session + +from app.models.request import Request +from app.models.status import Status +from app.models.status_group import StatusGroup +from app.models.status_history import StatusHistory +from app.models.topic_status_transition import TopicStatusTransition +from app.schemas.admin import RequestStatusChange +from app.schemas.universal import FilterClause, UniversalQuery +from app.services.billing_flow import apply_billing_transition_effects +from app.services.notifications import ( + EVENT_STATUS as NOTIFICATION_EVENT_STATUS, + notify_request_event, +) +from app.services.request_read_markers import EVENT_STATUS, mark_unread_for_client +from app.services.request_status import apply_status_change_effects +from app.services.status_flow import transition_allowed_for_topic +from app.services.status_transition_requirements import validate_transition_requirements_or_400 + +from .common import normalize_important_date_or_default, parse_datetime_safe +from .permissions import ensure_lawyer_can_manage_request_or_403, ensure_lawyer_can_view_request_or_403, request_uuid_or_400 + + +def terminal_status_codes(db: Session) -> set[str]: + rows = db.query(Status.code).filter(Status.is_terminal.is_(True)).all() + codes = {str(code or "").strip() for (code,) in rows if str(code or "").strip()} + return codes or {"RESOLVED", "CLOSED", "REJECTED"} + + +def coerce_request_bool_filter_or_400(value: object) -> bool: + if isinstance(value, bool): + return value + text = str(value or "").strip().lower() + if text in {"1", "true", "yes", "y", "да"}: + return True + if text in {"0", "false", "no", "n", "нет"}: + return False + raise HTTPException(status_code=400, detail="Значение фильтра должно быть boolean") + + +def split_request_special_filters(uq: UniversalQuery) -> tuple[UniversalQuery, list[FilterClause]]: + filters = list(uq.filters or []) + special: list[FilterClause] = [] + regular: list[FilterClause] = [] + for clause in filters: + field = str(getattr(clause, "field", "") or "").strip() + if field in {"has_unread_updates", "deadline_alert"}: + special.append(clause) + else: + regular.append(clause) + return UniversalQuery(filters=regular, sort=list(uq.sort or []), page=uq.page), special + + +def apply_request_special_filters( + base_query, + *, + db: Session, + role: str, + actor_id: str, + special_filters: list[FilterClause], +): + if not special_filters: + return base_query + terminal_codes_cache: set[str] | None = None + for clause in special_filters: + field = str(clause.field or "").strip() + op = str(clause.op or "").strip() + if op not in {"=", "!="}: + raise HTTPException(status_code=400, detail=f'Оператор "{op}" не поддерживается для фильтра "{field}"') + expected = coerce_request_bool_filter_or_400(clause.value) + if field == "has_unread_updates": + if role == "LAWYER": + expr = Request.lawyer_has_unread_updates.is_(True) + else: + expr = or_( + Request.lawyer_has_unread_updates.is_(True), + Request.client_has_unread_updates.is_(True), + ) + elif field == "deadline_alert": + now_utc = datetime.now(timezone.utc) + next_day_start = datetime(now_utc.year, now_utc.month, now_utc.day, tzinfo=timezone.utc) + timedelta(days=1) + if terminal_codes_cache is None: + terminal_codes_cache = terminal_status_codes(db) + expr = ( + Request.important_date_at.is_not(None) + & (Request.important_date_at < next_day_start) + & (Request.status_code.notin_(terminal_codes_cache)) + ) + if role == "LAWYER": + expr = expr & (Request.assigned_lawyer_id == actor_id) + else: + continue + base_query = base_query.filter(expr if expected else ~expr) + return base_query + + +def change_request_status_service( + request_id: str, + payload: RequestStatusChange, + db: Session, + admin: dict, +) -> dict[str, Any]: + request_uuid = request_uuid_or_400(request_id) + req = db.get(Request, request_uuid) + if not req: + raise HTTPException(status_code=404, detail="Заявка не найдена") + ensure_lawyer_can_manage_request_or_403(admin, req) + + next_status = str(payload.status_code or "").strip() + if not next_status: + raise HTTPException(status_code=400, detail='Поле "status_code" обязательно') + + status_row = db.query(Status).filter(Status.code == next_status, Status.enabled.is_(True)).first() + if status_row is None: + raise HTTPException(status_code=400, detail="Указан несуществующий или неактивный статус") + + old_status = str(req.status_code or "").strip() + if old_status == next_status: + raise HTTPException(status_code=400, detail="Выберите новый статус") + if not transition_allowed_for_topic( + db, + str(req.topic_code or "").strip() or None, + old_status, + next_status, + ): + raise HTTPException(status_code=400, detail="Переход статуса не разрешен для выбранной темы") + + important_date_at = normalize_important_date_or_default(payload.important_date_at) + comment = str(payload.comment or "").strip() or None + responsible = str(admin.get("email") or "").strip() or "Администратор системы" + + validate_transition_requirements_or_400(db, req, old_status, next_status) + + req.status_code = next_status + req.important_date_at = important_date_at + req.responsible = responsible + + billing_note = apply_billing_transition_effects( + db, + req=req, + from_status=old_status, + to_status=next_status, + admin=admin, + responsible=responsible, + ) + mark_unread_for_client(req, EVENT_STATUS) + apply_status_change_effects( + db, + req, + from_status=old_status, + to_status=next_status, + admin=admin, + comment=comment, + important_date_at=important_date_at, + responsible=responsible, + ) + notify_request_event( + db, + request=req, + 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} -> {next_status}" + + f"\nВажная дата: {important_date_at.isoformat()}" + + (f"\n{comment}" if comment else "") + + (f"\n{billing_note}" if billing_note else "") + ), + responsible=responsible, + ) + + db.add(req) + db.commit() + db.refresh(req) + return { + "status": "ok", + "request_id": str(req.id), + "track_number": req.track_number, + "from_status": old_status or None, + "to_status": next_status, + "important_date_at": req.important_date_at.isoformat() if req.important_date_at else None, + } + + +def get_request_status_route_service( + request_id: str, + db: Session, + admin: dict, +) -> dict[str, Any]: + request_uuid = request_uuid_or_400(request_id) + req = db.get(Request, request_uuid) + if not req: + raise HTTPException(status_code=404, detail="Заявка не найдена") + ensure_lawyer_can_view_request_or_403(admin, req) + + topic_code = str(req.topic_code or "").strip() + current_status = str(req.status_code or "").strip() + + history_rows = ( + db.query(StatusHistory) + .filter(StatusHistory.request_id == req.id) + .order_by(StatusHistory.created_at.asc()) + .all() + ) + + known_codes: set[str] = set() + if current_status: + known_codes.add(current_status) + for row in history_rows: + from_code = str(row.from_status or "").strip() + to_code = str(row.to_status or "").strip() + if from_code: + known_codes.add(from_code) + if to_code: + known_codes.add(to_code) + statuses_map: dict[str, dict[str, Any]] = {} + all_enabled_status_rows = ( + db.query(Status, StatusGroup) + .outerjoin(StatusGroup, StatusGroup.id == Status.status_group_id) + .filter(Status.enabled.is_(True)) + .all() + ) + for status_row, _group_row in all_enabled_status_rows: + code = str(status_row.code or "").strip() + if code: + known_codes.add(code) + if known_codes: + status_rows = ( + db.query(Status, StatusGroup) + .outerjoin(StatusGroup, StatusGroup.id == Status.status_group_id) + .filter(Status.code.in_(list(known_codes))) + .all() + ) + statuses_map = { + str(status_row.code): { + "name": str(status_row.name or status_row.code), + "kind": str(status_row.kind or "DEFAULT"), + "is_terminal": bool(status_row.is_terminal), + "status_group_id": str(status_row.status_group_id) if status_row.status_group_id else None, + "status_group_name": (str(group_row.name) if group_row is not None and group_row.name else None), + } + for status_row, group_row in status_rows + } + + transition_rows = ( + db.query(TopicStatusTransition) + .filter( + TopicStatusTransition.topic_code == topic_code, + TopicStatusTransition.enabled.is_(True), + ) + .order_by(TopicStatusTransition.sort_order.asc(), TopicStatusTransition.created_at.asc()) + .all() + if topic_code + else [] + ) + transition_sla_by_edge: dict[tuple[str, str], int] = {} + outgoing_by_status: dict[str, list[str]] = {} + incoming_sla_by_status: dict[str, int] = {} + for transition in transition_rows: + from_status = str(transition.from_status or "").strip() + to_status = str(transition.to_status or "").strip() + if not from_status or not to_status: + continue + outgoing_by_status.setdefault(from_status, []).append(to_status) + sla_hours = int(transition.sla_hours or 0) + if sla_hours > 0: + transition_sla_by_edge[(from_status, to_status)] = sla_hours + incoming_sla_by_status.setdefault(to_status, sla_hours) + + sequence_from_history: list[str] = [] + if history_rows: + first_from = str(history_rows[0].from_status or "").strip() + if first_from: + sequence_from_history.append(first_from) + for row in history_rows: + to_code = str(row.to_status or "").strip() + if to_code: + sequence_from_history.append(to_code) + elif current_status: + sequence_from_history.append(current_status) + + ordered_codes: list[str] = [] + seen_codes: set[str] = set() + + def add_code(code: str) -> None: + normalized = str(code or "").strip() + if not normalized or normalized in seen_codes: + return + seen_codes.add(normalized) + ordered_codes.append(normalized) + + for code in sequence_from_history: + add_code(code) + + add_code(current_status) + for to_status in outgoing_by_status.get(current_status, []): + add_code(to_status) + + changed_at_by_status: dict[str, str] = {} + for row in history_rows: + to_code = str(row.to_status or "").strip() + if to_code and row.created_at: + changed_at_by_status[to_code] = row.created_at.isoformat() + + visited_codes = {code for code in sequence_from_history if code} + current_index = ordered_codes.index(current_status) if current_status in ordered_codes else -1 + + def status_name(code: str) -> str: + meta = statuses_map.get(code) or {} + return str(meta.get("name") or code) + + nodes: list[dict[str, str | int | None]] = [] + for index, code in enumerate(ordered_codes): + meta = statuses_map.get(code) or {} + state = "pending" + if code == current_status: + state = "current" + elif code in visited_codes or (current_index >= 0 and index < current_index): + state = "completed" + + note_parts: list[str] = [] + kind = str(meta.get("kind") or "DEFAULT") + if kind == "INVOICE": + note_parts.append("Этап выставления счета") + elif kind == "PAID": + note_parts.append("Этап подтверждения оплаты") + + nodes.append( + { + "code": code, + "name": status_name(code), + "kind": kind, + "state": state, + "changed_at": changed_at_by_status.get(code), + "sla_hours": ( + transition_sla_by_edge.get((ordered_codes[index - 1], code)) + if index > 0 + else None + ) + or incoming_sla_by_status.get(code), + "note": " • ".join(note_parts), + } + ) + + history_entries: list[dict[str, object]] = [] + timeline: list[dict[str, object]] = [] + for row in history_rows: + timeline.append( + { + "id": str(row.id), + "from_status": str(row.from_status or "").strip() or None, + "to_status": str(row.to_status or "").strip() or None, + "to_status_name": status_name(str(row.to_status or "").strip()) if str(row.to_status or "").strip() else None, + "created_at": row.created_at, + "important_date_at": row.important_date_at, + "comment": row.comment, + } + ) + if not timeline: + timeline.append( + { + "id": "current", + "from_status": None, + "to_status": current_status or None, + "to_status_name": status_name(current_status) if current_status else None, + "created_at": req.updated_at or req.created_at, + "important_date_at": req.important_date_at, + "comment": None, + } + ) + for index, item in enumerate(timeline): + current_at = parse_datetime_safe(item.get("created_at")) + next_at = parse_datetime_safe(timeline[index + 1].get("created_at")) if index + 1 < len(timeline) else datetime.now(timezone.utc) + important_date_at = parse_datetime_safe(item.get("important_date_at")) + duration_seconds = None + if isinstance(current_at, datetime) and isinstance(next_at, datetime): + delta = next_at - current_at + duration_seconds = max(0, int(delta.total_seconds())) + history_entries.append( + { + "id": item.get("id"), + "from_status": item.get("from_status"), + "to_status": item.get("to_status"), + "to_status_name": item.get("to_status_name"), + "changed_at": current_at.isoformat() if isinstance(current_at, datetime) else None, + "important_date_at": important_date_at.isoformat() if important_date_at else None, + "comment": item.get("comment"), + "duration_seconds": duration_seconds, + } + ) + + available_statuses: list[dict[str, object]] = [] + for status_row, group_row in sorted( + all_enabled_status_rows, + key=lambda pair: ( + int(pair[1].sort_order or 0) if pair[1] is not None else 999, + int(pair[0].sort_order or 0), + str(pair[0].name or pair[0].code).lower(), + ), + ): + code = str(status_row.code or "").strip() + if not code: + continue + available_statuses.append( + { + "code": code, + "name": str(status_row.name or code), + "kind": str(status_row.kind or "DEFAULT"), + "is_terminal": bool(status_row.is_terminal), + "status_group_id": str(status_row.status_group_id) if status_row.status_group_id else None, + "status_group_name": (str(group_row.name) if group_row is not None and group_row.name else None), + } + ) + + return { + "request_id": str(req.id), + "track_number": req.track_number, + "topic_code": req.topic_code, + "current_status": current_status or None, + "current_important_date_at": req.important_date_at.isoformat() if req.important_date_at else None, + "available_statuses": available_statuses, + "history": list(reversed(history_entries)), + "nodes": nodes, + } diff --git a/app/api/admin/router.py b/app/api/admin/router.py index 4e1ea7f..2f0c703 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, crud, notifications, invoices, chat, test_utils +from app.api.admin import auth, requests, quotes, meta, config, uploads, metrics, crud, notifications, invoices, chat, test_utils, system router = APIRouter() router.include_router(auth.router, prefix="/auth", tags=["AdminAuth"]) @@ -14,3 +14,4 @@ router.include_router(invoices.router, prefix="/invoices", tags=["AdminInvoices" router.include_router(chat.router, prefix="/chat", tags=["AdminChat"]) router.include_router(crud.router, prefix="/crud", tags=["AdminCrud"]) router.include_router(test_utils.router, prefix="/test-utils", tags=["AdminTestUtils"]) +router.include_router(system.router, prefix="/system", tags=["AdminSystem"]) diff --git a/app/api/admin/system.py b/app/api/admin/system.py new file mode 100644 index 0000000..367e06b --- /dev/null +++ b/app/api/admin/system.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends + +from app.core.deps import require_role +from app.services.sms_service import sms_provider_health + +router = APIRouter() + + +@router.get("/sms-provider-health") +def get_sms_provider_health(admin: dict = Depends(require_role("ADMIN"))): + _ = admin + return sms_provider_health() diff --git a/app/api/public/otp.py b/app/api/public/otp.py index d3d9734..8d4b3cb 100644 --- a/app/api/public/otp.py +++ b/app/api/public/otp.py @@ -14,6 +14,7 @@ from app.models.otp_session import OtpSession from app.models.request import Request as RequestModel from app.schemas.public import OtpSend, OtpVerify from app.services.rate_limit import get_rate_limiter +from app.services.sms_service import SmsDeliveryError, send_otp_message router = APIRouter() @@ -112,16 +113,6 @@ def _set_public_cookie(response: Response, *, subject: str, purpose: str) -> Non ) -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, request: Request, db: Session = Depends(get_db)): purpose = _normalize_purpose(payload.purpose) @@ -160,6 +151,11 @@ def send_otp(payload: OtpSend, request: Request, db: Session = Depends(get_db)): ) code = _generate_code() + try: + sms_response = send_otp_message(phone=phone, code=code, purpose=purpose, track_number=track_number) + except SmsDeliveryError as exc: + raise HTTPException(status_code=502, detail=f"Не удалось отправить OTP: {exc}") from exc + now = _now_utc() expires_at = now + timedelta(minutes=OTP_TTL_MINUTES) @@ -183,7 +179,6 @@ def send_otp(payload: OtpSend, request: Request, db: Session = Depends(get_db)): db.commit() db.refresh(row) - sms_response = _mock_sms_send(phone, code, purpose, track_number) return { "status": "sent", "purpose": purpose, diff --git a/app/api/public/requests.py b/app/api/public/requests.py index ebaebd4..b161490 100644 --- a/app/api/public/requests.py +++ b/app/api/public/requests.py @@ -17,7 +17,9 @@ from app.models.attachment import Attachment from app.models.client import Client from app.models.invoice import Invoice from app.models.message import Message +from app.models.audit_log import AuditLog from app.models.request import Request +from app.models.request_service_request import RequestServiceRequest from app.models.status_history import StatusHistory from app.models.topic import Topic from app.services.invoice_crypto import decrypt_requisites @@ -37,6 +39,8 @@ from app.schemas.public import ( PublicMessageRead, PublicRequestCreate, PublicRequestCreated, + PublicServiceRequestCreate, + PublicServiceRequestRead, PublicStatusHistoryRead, PublicTimelineEvent, ) @@ -50,6 +54,7 @@ INVOICE_STATUS_LABELS = { "PAID": "Оплачен", "CANCELED": "Отменен", } +SERVICE_REQUEST_TYPES = {"CURATOR_CONTACT", "LAWYER_CHANGE_REQUEST"} def _normalize_phone(raw: str | None) -> str: @@ -145,6 +150,21 @@ def _to_iso(value) -> str | None: return value.isoformat() if value is not None else None +def _serialize_public_service_request(row: RequestServiceRequest) -> PublicServiceRequestRead: + return PublicServiceRequestRead( + id=row.id, + request_id=row.request_id, + client_id=row.client_id, + type=str(row.type or ""), + status=str(row.status or "NEW"), + body=str(row.body or ""), + created_by_client=bool(row.created_by_client), + created_at=_to_iso(row.created_at), + updated_at=_to_iso(row.updated_at), + resolved_at=_to_iso(row.resolved_at), + ) + + def _public_invoice_payload(row: Invoice, track_number: str) -> dict: status_code = str(row.status or "").upper() return { @@ -484,6 +504,81 @@ def list_timeline_by_track( return events +@router.post("/{track_number}/service-requests", response_model=PublicServiceRequestRead, status_code=201) +def create_service_request_by_track( + track_number: str, + payload: PublicServiceRequestCreate, + db: Session = Depends(get_db), + session: dict = Depends(get_public_session), +): + req = _request_for_track_or_404(db, session, track_number) + request_type = str(payload.type or "").strip().upper() + if request_type not in SERVICE_REQUEST_TYPES: + raise HTTPException(status_code=400, detail="Некорректный тип запроса") + + body = str(payload.body or "").strip() + if len(body) < 3: + raise HTTPException(status_code=400, detail='Поле "body" должно содержать минимум 3 символа') + + assigned_lawyer_value = None + assigned_lawyer_raw = str(req.assigned_lawyer_id or "").strip() + if assigned_lawyer_raw: + assigned_lawyer_value = assigned_lawyer_raw + + lawyer_unread = request_type == "CURATOR_CONTACT" and assigned_lawyer_value is not None + row = RequestServiceRequest( + request_id=str(req.id), + client_id=str(req.client_id) if req.client_id else None, + assigned_lawyer_id=assigned_lawyer_value, + type=request_type, + status="NEW", + body=body, + created_by_client=True, + admin_unread=True, + lawyer_unread=lawyer_unread, + responsible="Клиент", + ) + db.add(row) + db.flush() + db.add( + AuditLog( + actor_admin_id=None, + entity="request_service_requests", + entity_id=str(row.id), + action="CREATE_CLIENT_REQUEST", + diff={ + "request_id": str(req.id), + "track_number": req.track_number, + "type": request_type, + "status": "NEW", + }, + responsible="Клиент", + ) + ) + db.commit() + db.refresh(row) + return _serialize_public_service_request(row) + + +@router.get("/{track_number}/service-requests", response_model=list[PublicServiceRequestRead]) +def list_service_requests_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(RequestServiceRequest) + .filter( + RequestServiceRequest.request_id == str(req.id), + RequestServiceRequest.created_by_client.is_(True), + ) + .order_by(RequestServiceRequest.created_at.desc(), RequestServiceRequest.id.desc()) + .all() + ) + return [_serialize_public_service_request(row) for row in rows] + + @router.get("/{track_number}/notifications") def list_notifications_by_track( track_number: str, diff --git a/app/core/config.py b/app/core/config.py index 44ae140..4822b4f 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -28,6 +28,9 @@ class Settings(BaseSettings): TELEGRAM_BOT_TOKEN: str = "change_me" TELEGRAM_CHAT_ID: str = "0" SMS_PROVIDER: str = "dummy" + SMSAERO_EMAIL: str = "" + SMSAERO_API_KEY: str = "" + OTP_SMS_TEMPLATE: str = "Your verification code: {code}" DATA_ENCRYPTION_SECRET: str = "change_me_data_encryption" OTP_RATE_LIMIT_WINDOW_SECONDS: int = 300 OTP_SEND_RATE_LIMIT: int = 8 diff --git a/app/models/request_service_request.py b/app/models/request_service_request.py new file mode 100644 index 0000000..1a76478 --- /dev/null +++ b/app/models/request_service_request.py @@ -0,0 +1,27 @@ +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.session import Base +from app.models.common import TimestampMixin, UUIDMixin + + +class RequestServiceRequest(Base, UUIDMixin, TimestampMixin): + __tablename__ = "request_service_requests" + + request_id: Mapped[str] = mapped_column(String(60), nullable=False, index=True) + client_id: Mapped[str | None] = mapped_column(String(60), nullable=True, index=True) + assigned_lawyer_id: Mapped[str | None] = mapped_column(String(60), nullable=True, index=True) + resolved_by_admin_id: Mapped[str | None] = mapped_column(String(60), nullable=True, index=True) + + type: Mapped[str] = mapped_column(String(40), nullable=False, index=True) + status: Mapped[str] = mapped_column(String(30), nullable=False, default="NEW", index=True) + body: Mapped[str] = mapped_column(Text, nullable=False) + created_by_client: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + + admin_unread: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True) + lawyer_unread: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True) + admin_read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + lawyer_read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/app/schemas/admin.py b/app/schemas/admin.py index a6837f9..367f121 100644 --- a/app/schemas/admin.py +++ b/app/schemas/admin.py @@ -125,3 +125,7 @@ class RequestDataRequirementPatch(BaseModel): class NotificationsReadAll(BaseModel): request_id: Optional[str] = None + + +class RequestServiceRequestPatch(BaseModel): + status: str diff --git a/app/schemas/public.py b/app/schemas/public.py index 2bd2854..d50be20 100644 --- a/app/schemas/public.py +++ b/app/schemas/public.py @@ -65,3 +65,21 @@ class PublicTimelineEvent(BaseModel): type: Literal["status_change", "message", "attachment"] created_at: Optional[str] = None payload: Dict[str, Any] = Field(default_factory=dict) + + +class PublicServiceRequestCreate(BaseModel): + type: Literal["CURATOR_CONTACT", "LAWYER_CHANGE_REQUEST"] + body: str = Field(min_length=3, max_length=4000) + + +class PublicServiceRequestRead(BaseModel): + id: UUID + request_id: UUID + client_id: Optional[UUID] = None + type: str + status: str + body: str + created_by_client: bool + created_at: Optional[str] = None + updated_at: Optional[str] = None + resolved_at: Optional[str] = None diff --git a/app/services/sms_service.py b/app/services/sms_service.py new file mode 100644 index 0000000..e81b9c1 --- /dev/null +++ b/app/services/sms_service.py @@ -0,0 +1,188 @@ +from __future__ import annotations + +import asyncio +import importlib.util +from typing import Any + +from app.core.config import settings + + +class SmsDeliveryError(Exception): + pass + + +def _module_available(module_name: str) -> bool: + return importlib.util.find_spec(module_name) is not None + + +def _normalize_phone_to_int(phone: str) -> int: + digits = "".join(ch for ch in str(phone or "") if ch.isdigit()) + if not digits: + raise SmsDeliveryError("Некорректный номер телефона") + try: + return int(digits) + except ValueError as exc: + raise SmsDeliveryError("Некорректный номер телефона") from exc + + +def _build_otp_message(*, code: str, purpose: str, track_number: str | None) -> str: + template = str(settings.OTP_SMS_TEMPLATE or "").strip() or "Ваш код подтверждения: {code}" + try: + rendered = template.format(code=code, purpose=purpose, track_number=track_number or "") + except Exception: + rendered = f"Ваш код подтверждения: {code}" + return rendered + + +def _mock_sms_send(*, phone: str, code: str, purpose: str, track_number: str | None) -> dict[str, Any]: + 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", + "sent": False, + "mocked": True, + } + + +async def _send_sms_aero_async(*, phone: int, message: str) -> dict[str, Any]: + try: + import smsaero + except Exception as exc: # pragma: no cover - runtime dependency branch + raise SmsDeliveryError("Библиотека smsaero-api-async не установлена") from exc + + email = str(settings.SMSAERO_EMAIL or "").strip() + api_key = str(settings.SMSAERO_API_KEY or "").strip() + if not email or not api_key: + raise SmsDeliveryError("Не заданы SMSAERO_EMAIL и/или SMSAERO_API_KEY") + + api = smsaero.SmsAero(email, api_key) + try: + result = await api.send_sms(phone, message) + except Exception as exc: # pragma: no cover - network/runtime branch + raise SmsDeliveryError(f"Ошибка отправки SMS через SMS Aero: {exc}") from exc + finally: + await api.close_session() + return { + "provider": "smsaero", + "status": "accepted", + "message": "SMS отправлено", + "sent": True, + "response": result, + } + + +def _send_sms_aero(*, phone: str, message: str) -> dict[str, Any]: + phone_int = _normalize_phone_to_int(phone) + return asyncio.run(_send_sms_aero_async(phone=phone_int, message=message)) + + +async def _get_sms_aero_balance_async() -> dict[str, Any]: + try: + import smsaero + except Exception as exc: # pragma: no cover - runtime dependency branch + raise SmsDeliveryError("Библиотека smsaero-api-async не установлена") from exc + + email = str(settings.SMSAERO_EMAIL or "").strip() + api_key = str(settings.SMSAERO_API_KEY or "").strip() + if not email or not api_key: + raise SmsDeliveryError("Не заданы SMSAERO_EMAIL и/или SMSAERO_API_KEY") + + api = smsaero.SmsAero(email, api_key) + try: + result = await api.balance() + except Exception as exc: # pragma: no cover - network/runtime branch + raise SmsDeliveryError(f"Ошибка получения баланса SMS Aero: {exc}") from exc + finally: + await api.close_session() + return dict(result or {}) + + +def _get_sms_aero_balance() -> tuple[float | None, dict[str, Any] | None, str | None]: + try: + raw = _get_sms_aero_balance_async() + data = asyncio.run(raw) + amount = data.get("balance") + number = float(amount) + return number, data, None + except Exception as exc: + return None, None, str(exc) + + +def sms_provider_health() -> dict[str, Any]: + provider = str(settings.SMS_PROVIDER or "dummy").strip().lower() + if provider in {"", "dummy", "mock", "console"}: + return { + "provider": "dummy", + "status": "ok", + "mode": "mock", + "can_send": True, + "balance_available": False, + "balance_amount": None, + "balance_currency": "RUB", + "checks": {"mock_mode": True}, + "issues": [], + } + + if provider in {"smsaero", "sms_aero"}: + email = str(settings.SMSAERO_EMAIL or "").strip() + api_key = str(settings.SMSAERO_API_KEY or "").strip() + installed = _module_available("smsaero") + checks = { + "smsaero_installed": bool(installed), + "email_configured": bool(email), + "api_key_configured": bool(api_key), + } + issues: list[str] = [] + if not checks["smsaero_installed"]: + issues.append("Не установлена библиотека smsaero-api-async") + if not checks["email_configured"]: + issues.append("Не задан SMSAERO_EMAIL") + if not checks["api_key_configured"]: + issues.append("Не задан SMSAERO_API_KEY") + can_send = all(checks.values()) + balance_available = False + balance_amount: float | None = None + balance_raw: dict[str, Any] | None = None + if can_send: + amount, raw_balance, balance_error = _get_sms_aero_balance() + if amount is None: + issues.append(str(balance_error or "Не удалось получить баланс SMS Aero")) + else: + balance_available = True + balance_amount = amount + balance_raw = raw_balance + return { + "provider": "smsaero", + "status": "ok" if can_send and balance_available else "degraded", + "mode": "real", + "can_send": can_send, + "balance_available": balance_available, + "balance_amount": balance_amount, + "balance_currency": "RUB", + "balance_raw": balance_raw, + "checks": checks, + "issues": issues, + } + + return { + "provider": provider, + "status": "error", + "mode": "unknown", + "can_send": False, + "balance_available": False, + "balance_amount": None, + "balance_currency": "RUB", + "checks": {"provider_supported": False}, + "issues": [f"Неизвестный SMS_PROVIDER: {provider}"], + } + + +def send_otp_message(*, phone: str, code: str, purpose: str, track_number: str | None = None) -> dict[str, Any]: + provider = str(settings.SMS_PROVIDER or "dummy").strip().lower() + if provider in {"", "dummy", "mock", "console"}: + return _mock_sms_send(phone=phone, code=code, purpose=purpose, track_number=track_number) + if provider in {"smsaero", "sms_aero"}: + message = _build_otp_message(code=code, purpose=purpose, track_number=track_number) + return _send_sms_aero(phone=phone, message=message) + raise SmsDeliveryError(f"Неизвестный SMS_PROVIDER: {provider}") diff --git a/app/services/test_data_cleanup.py b/app/services/test_data_cleanup.py index 57c96b9..341efd7 100644 --- a/app/services/test_data_cleanup.py +++ b/app/services/test_data_cleanup.py @@ -20,6 +20,7 @@ from app.models.request import Request from app.models.request_data_requirement import RequestDataRequirement from app.models.request_data_template import RequestDataTemplate from app.models.request_data_template_item import RequestDataTemplateItem +from app.models.request_service_request import RequestServiceRequest from app.models.security_audit_log import SecurityAuditLog from app.models.status_history import StatusHistory from app.models.topic import Topic @@ -105,6 +106,7 @@ def cleanup_test_data(db: Session, spec: CleanupSpec | None = None) -> dict[str, "invoices": 0, "notifications": 0, "request_data_requirements": 0, + "request_service_requests": 0, "security_audit_log": 0, "audit_log": 0, "otp_sessions": 0, @@ -119,12 +121,19 @@ def cleanup_test_data(db: Session, spec: CleanupSpec | None = None) -> dict[str, } if request_ids: + request_id_strs = {str(item) for item in request_ids} deleted_counts["notifications"] += ( db.query(Notification).filter(Notification.request_id.in_(request_ids)).delete(synchronize_session=False) or 0 ) deleted_counts["request_data_requirements"] += ( db.query(RequestDataRequirement).filter(RequestDataRequirement.request_id.in_(request_ids)).delete(synchronize_session=False) or 0 ) + deleted_counts["request_service_requests"] += ( + db.query(RequestServiceRequest) + .filter(RequestServiceRequest.request_id.in_(list(request_id_strs))) + .delete(synchronize_session=False) + or 0 + ) deleted_counts["status_history"] += ( db.query(StatusHistory).filter(StatusHistory.request_id.in_(request_ids)).delete(synchronize_session=False) or 0 ) @@ -144,7 +153,6 @@ def cleanup_test_data(db: Session, spec: CleanupSpec | None = None) -> dict[str, deleted_counts["security_audit_log"] += ( db.query(SecurityAuditLog).filter(SecurityAuditLog.attachment_id.in_(attachment_ids)).delete(synchronize_session=False) or 0 ) - request_id_strs = {str(item) for item in request_ids} deleted_counts["audit_log"] += ( db.query(AuditLog) .filter(AuditLog.entity == "requests", AuditLog.entity_id.in_(list(request_id_strs))) diff --git a/app/web/admin.jsx b/app/web/admin.jsx index 3329320..35b1247 100644 --- a/app/web/admin.jsx +++ b/app/web/admin.jsx @@ -19,6 +19,7 @@ import { DashboardSection } from "./admin/features/dashboard/DashboardSection.js import { InvoicesSection } from "./admin/features/invoices/InvoicesSection.jsx"; import { RequestsSection } from "./admin/features/requests/RequestsSection.jsx"; import { QuotesSection } from "./admin/features/quotes/QuotesSection.jsx"; +import { ServiceRequestsSection } from "./admin/features/service-requests/ServiceRequestsSection.jsx"; import { RequestWorkspace } from "./admin/features/requests/RequestWorkspace.jsx"; import { AvailableTablesSection } from "./admin/features/tables/AvailableTablesSection.jsx"; import { useAdminApi } from "./admin/hooks/useAdminApi.js"; @@ -897,6 +898,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__"; myUnreadTotal: 0, unreadForClients: 0, unreadForLawyers: 0, + serviceRequestUnreadTotal: 0, deadlineAlertTotal: 0, monthRevenue: 0, monthExpenses: 0, @@ -922,6 +924,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__"; }); const [statusMap, setStatusMap] = useState({}); + const [smsProviderHealth, setSmsProviderHealth] = useState(null); const [recordModal, setRecordModal] = useState({ open: false, @@ -1169,6 +1172,19 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__"; { field: "created_at", label: "Дата создания", type: "date" }, ]; } + if (tableKey === "serviceRequests") { + return [ + { field: "type", label: "Тип", type: "text" }, + { field: "status", label: "Статус", type: "text" }, + { field: "request_id", label: "ID заявки", type: "text" }, + { field: "client_id", label: "ID клиента", type: "text" }, + { field: "assigned_lawyer_id", label: "Назначенный юрист", type: "reference", options: getLawyerOptions }, + { field: "admin_unread", label: "Непрочитано администратором", type: "boolean" }, + { field: "lawyer_unread", label: "Непрочитано юристом", type: "boolean" }, + { field: "resolved_at", label: "Дата обработки", type: "date" }, + { field: "created_at", label: "Дата создания", type: "date" }, + ]; + } if (tableKey === "invoices") { return [ { field: "invoice_number", label: "Номер счета", type: "text" }, @@ -1314,6 +1330,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__"; const getTableLabel = useCallback((tableKey) => { if (tableKey === "kanban") return "Канбан"; if (tableKey === "requests") return "Заявки"; + if (tableKey === "serviceRequests") return "Запросы"; if (tableKey === "invoices") return "Счета"; if (tableKey === "quotes") return "Цитаты"; if (tableKey === "topics") return "Темы"; @@ -1769,6 +1786,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__"; myUnreadTotal: Number(data.my_unread_updates || 0), unreadForClients: Number(data.unread_for_clients || 0), unreadForLawyers: Number(data.unread_for_lawyers || 0), + serviceRequestUnreadTotal: Number(data.service_request_unread_total || 0), deadlineAlertTotal: Number(data.deadline_alert_total || 0), monthRevenue: Number(data.month_revenue || 0), monthExpenses: Number(data.month_expenses || 0), @@ -1796,12 +1814,50 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__"; [api, metaEntity, setStatus] ); + const loadSmsProviderHealth = useCallback( + async (tokenOverride, options) => { + const opts = options || {}; + const silent = Boolean(opts.silent); + const currentRole = String(role || "").toUpperCase(); + const authToken = tokenOverride !== undefined ? tokenOverride : token; + if (!authToken || currentRole !== "ADMIN") { + setSmsProviderHealth(null); + return null; + } + if (!silent) setStatus("smsProviderHealth", "Обновляем баланс SMS Aero...", ""); + try { + const payload = await api("/api/admin/system/sms-provider-health", {}, tokenOverride); + const enriched = { ...(payload || {}), loaded_at: new Date().toISOString() }; + setSmsProviderHealth(enriched); + if (!silent) setStatus("smsProviderHealth", "Баланс SMS Aero обновлен", "ok"); + return enriched; + } catch (error) { + const fallback = { + provider: "smsaero", + status: "error", + mode: "real", + can_send: false, + balance_available: false, + balance_amount: null, + balance_currency: "RUB", + issues: [error.message], + loaded_at: new Date().toISOString(), + }; + setSmsProviderHealth(fallback); + if (!silent) setStatus("smsProviderHealth", "Ошибка: " + error.message, "error"); + return null; + } + }, + [api, role, setStatus, token] + ); + const refreshSection = useCallback( async (section, tokenOverride) => { if (!(tokenOverride !== undefined ? tokenOverride : token)) return; if (section === "dashboard") return loadDashboard(tokenOverride); if (section === "kanban") return loadKanban(tokenOverride); if (section === "requests") return loadTable("requests", {}, tokenOverride); + if (section === "serviceRequests") return loadTable("serviceRequests", {}, tokenOverride); if (section === "invoices") return loadTable("invoices", {}, tokenOverride); if (section === "quotes" && canAccessSection(role, "quotes")) return loadTable("quotes", {}, tokenOverride); if (section === "config" && canAccessSection(role, "config")) return loadCurrentConfigTable(false, tokenOverride); @@ -2504,6 +2560,55 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__"; await applyRequestsQuickFilterPreset([{ field: "deadline_alert", op: "=", value: true }], "Показаны заявки с горящими дедлайнами"); }, [applyRequestsQuickFilterPreset]); + const applyServiceRequestsQuickFilterPreset = useCallback( + async (filters, statusMessage) => { + const nextFilters = Array.isArray(filters) ? filters.filter((item) => item && item.field) : []; + resetAdminRoute(); + setActiveSection("serviceRequests"); + const currentState = tablesRef.current.serviceRequests || createTableState(); + setTableState("serviceRequests", { + ...currentState, + filters: nextFilters, + offset: 0, + showAll: false, + }); + if (statusMessage) setStatus("serviceRequests", statusMessage, ""); + await loadTable("serviceRequests", { resetOffset: true, filtersOverride: nextFilters }); + }, + [loadTable, resetAdminRoute, setStatus, setTableState, tablesRef] + ); + + const openServiceRequestsWithUnreadAlerts = useCallback(async () => { + if (String(role || "").toUpperCase() === "LAWYER") { + await applyServiceRequestsQuickFilterPreset( + [{ field: "lawyer_unread", op: "=", value: true }], + "Показаны непрочитанные запросы клиента" + ); + return; + } + await applyServiceRequestsQuickFilterPreset( + [{ field: "admin_unread", op: "=", value: true }], + "Показаны непрочитанные запросы клиента" + ); + }, [applyServiceRequestsQuickFilterPreset, role]); + + const markServiceRequestRead = useCallback( + async (serviceRequestId) => { + const rowId = String(serviceRequestId || "").trim(); + if (!rowId) return; + try { + setStatus("serviceRequests", "Отмечаем как прочитанный...", ""); + await api("/api/admin/requests/service-requests/" + encodeURIComponent(rowId) + "/read", { method: "POST" }); + await Promise.all([loadTable("serviceRequests", { resetOffset: true }), loadDashboard()]); + await loadTable("requests", { resetOffset: true }); + setStatus("serviceRequests", "Запрос отмечен как прочитанный", "ok"); + } catch (error) { + setStatus("serviceRequests", "Ошибка: " + error.message, "error"); + } + }, + [api, loadDashboard, loadTable, setStatus] + ); + const logout = useCallback(() => { localStorage.removeItem(LS_TOKEN); setToken(""); @@ -2524,6 +2629,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__"; myUnreadTotal: 0, unreadForClients: 0, unreadForLawyers: 0, + serviceRequestUnreadTotal: 0, deadlineAlertTotal: 0, monthRevenue: 0, monthExpenses: 0, @@ -2540,6 +2646,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__"; users: [], }); setStatusMap({}); + setSmsProviderHealth(null); setActiveSection("dashboard"); }, [resetKanbanState, resetRequestWorkspaceState, resetTablesState]); @@ -2628,6 +2735,19 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__"; } }, [isRequestWorkspaceRoute, loadRequestModalData, refreshSection, resetAdminRoute, role, routeInfo.requestId, routeInfo.section, token]); + useEffect(() => { + if (!token) { + setSmsProviderHealth(null); + return; + } + if (String(role || "").toUpperCase() !== "ADMIN") { + setSmsProviderHealth(null); + return; + } + if (activeSection !== "config" || configActiveKey !== "otp_sessions") return; + loadSmsProviderHealth(undefined, { silent: true }); + }, [activeSection, configActiveKey, loadSmsProviderHealth, role, token]); + useEffect(() => { if (!dictionaryTableItems.length) { if (configActiveKey) setConfigActiveKey(""); @@ -2660,6 +2780,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__"; { key: "dashboard", label: "Обзор" }, { key: "kanban", label: "Канбан" }, { key: "requests", label: "Заявки" }, + { key: "serviceRequests", label: "Запросы" }, { key: "invoices", label: "Счета" }, ]; }, []); @@ -2671,6 +2792,10 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__"; }, [dashboardData.myUnreadTotal, dashboardData.unreadForClients, dashboardData.unreadForLawyers, role]); const topbarDeadlineAlertCount = useMemo(() => Number(dashboardData.deadlineAlertTotal || 0), [dashboardData.deadlineAlertTotal]); + const topbarServiceRequestUnreadCount = useMemo( + () => Number(dashboardData.serviceRequestUnreadTotal || 0), + [dashboardData.serviceRequestUnreadTotal] + ); const activeFilterFields = useMemo(() => { if (!filterModal.tableKey) return []; @@ -2790,6 +2915,27 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
UniversalQuery, RBAC и аудит действий по ключевым сущностям системы.
{configActiveKey ? getTableLabel(configActiveKey) : "Справочник не выбран"}
+ {configActiveKey === "otp_sessions" ? ( ++ {smsBalanceSummary(smsProviderHealth)} + {smsProviderHealth?.loaded_at ? " • обновлено " + fmtDate(smsProviderHealth.loaded_at) : ""} +
+ ) : null} +Запросы клиента к куратору и обращения на смену юриста.
+