From 96649f8cc7f928462ea3185fc567a96d904fb68c Mon Sep 17 00:00:00 2001 From: TronoSfera <119615520+TronoSfera@users.noreply.github.com> Date: Mon, 23 Feb 2026 17:54:19 +0300 Subject: [PATCH] Third commit --- .gitignore | 4 + alembic/env.py | 2 + alembic/versions/0012_add_invoices_table.py | 56 ++ .../0013_add_status_kind_for_billing.py | 33 ++ .../versions/0014_add_security_audit_log.py | 51 ++ app/api/admin/config.py | 2 + app/api/admin/crud.py | 353 ++++++++++- app/api/admin/invoices.py | 439 ++++++++++++++ app/api/admin/requests.py | 68 ++- app/api/admin/router.py | 3 +- app/api/admin/uploads.py | 421 ++++++++----- app/api/public/otp.py | 71 ++- app/api/public/requests.py | 78 +++ app/api/public/uploads.py | 294 +++++++--- app/core/config.py | 3 + app/core/http_hardening.py | 54 ++ app/main.py | 2 + app/models/invoice.py | 25 + app/models/security_audit_log.py | 29 + app/models/status.py | 4 +- app/schemas/admin.py | 18 +- app/services/billing_flow.py | 288 +++++++++ app/services/invoice_crypto.py | 56 ++ app/services/invoice_pdf.py | 84 +++ app/services/rate_limit.py | 87 +++ app/services/security_audit.py | 129 ++++ app/web/admin.jsx | 554 +++++++++++++++--- app/web/landing.html | 164 +++++- app/workers/tasks/assign.py | 11 +- celerybeat-schedule | Bin 16384 -> 16384 bytes context/03_admin_panel_service.md | 28 + context/08_security_model.md | 16 +- context/10_development_execution_plan.md | 16 +- context/11_test_runbook.md | 19 +- tests/test_admin_universal_crud.py | 35 ++ tests/test_billing_flow.py | 356 +++++++++++ tests/test_http_hardening.py | 59 ++ tests/test_invoices.py | 295 ++++++++++ tests/test_migrations.py | 25 +- tests/test_otp_rate_limit.py | 141 +++++ tests/test_rates.py | 508 ++++++++++++++++ tests/test_security_audit.py | 226 +++++++ tmp/admin.bundle.js | 543 +++++++++++++---- 43 files changed, 5194 insertions(+), 456 deletions(-) create mode 100644 .gitignore create mode 100644 alembic/versions/0012_add_invoices_table.py create mode 100644 alembic/versions/0013_add_status_kind_for_billing.py create mode 100644 alembic/versions/0014_add_security_audit_log.py create mode 100644 app/api/admin/invoices.py create mode 100644 app/core/http_hardening.py create mode 100644 app/models/invoice.py create mode 100644 app/models/security_audit_log.py create mode 100644 app/services/billing_flow.py create mode 100644 app/services/invoice_crypto.py create mode 100644 app/services/invoice_pdf.py create mode 100644 app/services/rate_limit.py create mode 100644 app/services/security_audit.py create mode 100644 tests/test_billing_flow.py create mode 100644 tests/test_http_hardening.py create mode 100644 tests/test_invoices.py create mode 100644 tests/test_otp_rate_limit.py create mode 100644 tests/test_rates.py create mode 100644 tests/test_security_audit.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..022d0ea --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/tmp/ +*.idea +.env + diff --git a/alembic/env.py b/alembic/env.py index d6ea437..d15bac0 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -18,6 +18,8 @@ from app.models.otp_session import OtpSession from app.models.quote import Quote from app.models.admin_user_topic import AdminUserTopic from app.models.notification import Notification +from app.models.invoice import Invoice +from app.models.security_audit_log import SecurityAuditLog config = context.config fileConfig(config.config_file_name) diff --git a/alembic/versions/0012_add_invoices_table.py b/alembic/versions/0012_add_invoices_table.py new file mode 100644 index 0000000..316263c --- /dev/null +++ b/alembic/versions/0012_add_invoices_table.py @@ -0,0 +1,56 @@ +"""add invoices table + +Revision ID: 0012_add_invoices_table +Revises: 0011_dashboard_financial_fields +Create Date: 2026-02-23 +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision = "0012_add_invoices_table" +down_revision = "0011_dashboard_financial_fields" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "invoices", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("responsible", sa.String(length=200), nullable=False, server_default="Администратор системы"), + sa.Column("request_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("invoice_number", sa.String(length=40), nullable=False, unique=True), + sa.Column("status", sa.String(length=20), nullable=False, server_default="WAITING_PAYMENT"), + sa.Column("amount", sa.Numeric(14, 2), nullable=False), + sa.Column("currency", sa.String(length=3), nullable=False, server_default="RUB"), + sa.Column("payer_display_name", sa.String(length=300), nullable=False), + sa.Column("payer_details_encrypted", sa.Text(), nullable=True), + sa.Column("issued_by_admin_user_id", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("issued_by_role", sa.String(length=20), nullable=True), + sa.Column("issued_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("paid_at", sa.DateTime(timezone=True), nullable=True), + ) + op.create_index("ix_invoices_request_id", "invoices", ["request_id"]) + op.create_index("ix_invoices_invoice_number", "invoices", ["invoice_number"], unique=True) + op.create_index("ix_invoices_status", "invoices", ["status"]) + op.create_index("ix_invoices_issued_by_admin_user_id", "invoices", ["issued_by_admin_user_id"]) + op.create_check_constraint("ck_invoices_amount_non_negative", "invoices", "amount >= 0") + op.create_check_constraint( + "ck_invoices_status_allowed", + "invoices", + "status IN ('WAITING_PAYMENT', 'PAID', 'CANCELED')", + ) + + +def downgrade(): + op.drop_constraint("ck_invoices_status_allowed", "invoices", type_="check") + op.drop_constraint("ck_invoices_amount_non_negative", "invoices", type_="check") + op.drop_index("ix_invoices_issued_by_admin_user_id", table_name="invoices") + op.drop_index("ix_invoices_status", table_name="invoices") + op.drop_index("ix_invoices_invoice_number", table_name="invoices") + op.drop_index("ix_invoices_request_id", table_name="invoices") + op.drop_table("invoices") diff --git a/alembic/versions/0013_add_status_kind_for_billing.py b/alembic/versions/0013_add_status_kind_for_billing.py new file mode 100644 index 0000000..33c6521 --- /dev/null +++ b/alembic/versions/0013_add_status_kind_for_billing.py @@ -0,0 +1,33 @@ +"""add status kind and invoice template for billing flow + +Revision ID: 0013_status_kind_billing +Revises: 0012_add_invoices_table +Create Date: 2026-02-23 +""" + +from alembic import op +import sqlalchemy as sa + +revision = "0013_status_kind_billing" +down_revision = "0012_add_invoices_table" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column("statuses", sa.Column("kind", sa.String(length=20), nullable=False, server_default="DEFAULT")) + op.add_column("statuses", sa.Column("invoice_template", sa.Text(), nullable=True)) + op.create_check_constraint( + "ck_statuses_kind_allowed", + "statuses", + "kind IN ('DEFAULT', 'INVOICE', 'PAID')", + ) + op.create_index("ix_statuses_kind", "statuses", ["kind"]) + op.alter_column("statuses", "kind", server_default=None) + + +def downgrade(): + op.drop_index("ix_statuses_kind", table_name="statuses") + op.drop_constraint("ck_statuses_kind_allowed", "statuses", type_="check") + op.drop_column("statuses", "invoice_template") + op.drop_column("statuses", "kind") diff --git a/alembic/versions/0014_add_security_audit_log.py b/alembic/versions/0014_add_security_audit_log.py new file mode 100644 index 0000000..8983845 --- /dev/null +++ b/alembic/versions/0014_add_security_audit_log.py @@ -0,0 +1,51 @@ +"""add security audit log table for file access events + +Revision ID: 0014_security_audit_log +Revises: 0013_status_kind_billing +Create Date: 2026-02-23 +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision = "0014_security_audit_log" +down_revision = "0013_status_kind_billing" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "security_audit_log", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("responsible", sa.String(length=200), nullable=False, server_default="Администратор системы"), + sa.Column("actor_role", sa.String(length=30), nullable=False), + sa.Column("actor_subject", sa.String(length=200), nullable=False, server_default=""), + sa.Column("actor_ip", sa.String(length=64), nullable=True), + sa.Column("action", sa.String(length=50), nullable=False), + sa.Column("scope", sa.String(length=50), nullable=False), + sa.Column("object_key", sa.String(length=500), nullable=True), + sa.Column("request_id", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("attachment_id", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("allowed", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("reason", sa.String(length=400), nullable=True), + sa.Column("details", sa.JSON(), nullable=False, server_default=sa.text("'{}'::json")), + ) + op.create_index("ix_security_audit_log_created_at", "security_audit_log", ["created_at"]) + op.create_index("ix_security_audit_log_allowed", "security_audit_log", ["allowed"]) + op.create_index("ix_security_audit_log_action", "security_audit_log", ["action"]) + op.create_index("ix_security_audit_log_actor_subject", "security_audit_log", ["actor_subject"]) + op.alter_column("security_audit_log", "details", server_default=None) + op.alter_column("security_audit_log", "allowed", server_default=None) + op.alter_column("security_audit_log", "actor_subject", server_default=None) + + +def downgrade(): + op.drop_index("ix_security_audit_log_actor_subject", table_name="security_audit_log") + op.drop_index("ix_security_audit_log_action", table_name="security_audit_log") + op.drop_index("ix_security_audit_log_allowed", table_name="security_audit_log") + op.drop_index("ix_security_audit_log_created_at", table_name="security_audit_log") + op.drop_table("security_audit_log") diff --git a/app/api/admin/config.py b/app/api/admin/config.py index c502a2a..cea74cd 100644 --- a/app/api/admin/config.py +++ b/app/api/admin/config.py @@ -25,6 +25,8 @@ def _status_row(row: Status): "enabled": row.enabled, "sort_order": row.sort_order, "is_terminal": row.is_terminal, + "kind": row.kind, + "invoice_template": row.invoice_template, } diff --git a/app/api/admin/crud.py b/app/api/admin/crud.py index 8fb026a..8aaefbe 100644 --- a/app/api/admin/crud.py +++ b/app/api/admin/crud.py @@ -12,6 +12,7 @@ from fastapi import APIRouter, Depends, HTTPException 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 import app.models as models_pkg from app.core.deps import get_current_admin @@ -47,6 +48,7 @@ from app.services.request_read_markers import ( 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.billing_flow import apply_billing_transition_effects, normalize_status_kind_or_400 from app.services.universal_query import apply_universal_query router = APIRouter() @@ -62,6 +64,7 @@ SYSTEM_FIELDS = { "lawyer_has_unread_updates", "lawyer_unread_event_type", } +REQUEST_FINANCIAL_FIELDS = {"effective_rate", "invoice_amount", "paid_at", "paid_by_admin_id"} ALLOWED_ADMIN_ROLES = {"ADMIN", "LAWYER"} # Per-table RBAC: table -> role -> actions. @@ -76,6 +79,7 @@ TABLE_ROLE_ACTIONS: dict[str, dict[str, set[str]]] = { "statuses": {"ADMIN": set(CRUD_ACTIONS)}, "form_fields": {"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)}, @@ -168,6 +172,259 @@ def _columns_map(model: type) -> dict[str, Any]: 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": "Статусы", + "form_fields": "Поля формы", + "topic_required_fields": "Обязательные поля темы", + "topic_data_templates": "Шаблоны данных темы", + "topic_status_transitions": "Переходы статусов темы", + "admin_users": "Пользователи", + "admin_user_topics": "Дополнительные темы юристов", + "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": "Метка", + "text": "Текст", + "description": "Описание", + "author": "Автор", + "source": "Источник", + "email": "Email", + "role": "Роль", + "kind": "Тип", + "status": "Статус", + "status_code": "Статус", + "topic_code": "Тема", + "from_status": "Из статуса", + "to_status": "В статус", + "track_number": "Номер заявки", + "invoice_number": "Номер счета", + "invoice_template": "Шаблон счета", + "amount": "Сумма", + "currency": "Валюта", + "client_name": "Клиент", + "client_phone": "Телефон", + "payer_display_name": "Плательщик", + "payer_details_encrypted": "Реквизиты (шифр.)", + "issued_at": "Дата формирования", + "paid_at": "Дата оплаты", + "created_at": "Дата создания", + "updated_at": "Дата обновления", + "responsible": "Ответственный", + "sort_order": "Порядок", + "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": "Ставка (фикс.)", + "salary_percent": "Процент зарплаты", + "invoice_amount": "Сумма счета", + "paid_by_admin_id": "Оплату подтвердил", + "extra_fields": "Доп. поля", + "total_attachments_bytes": "Размер вложений (байт)", + "type": "Тип", + "options": "Опции", + "field_key": "Поле формы", + "sla_hours": "SLA (часы)", + "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": "Истекает", + "jwt_token": "JWT-токен", + "action": "Действие", + "entity": "Сущность", + "entity_id": "ID сущности", + "actor_admin_id": "ID автора", + "actor_role": "Роль субъекта", + "actor_subject": "Субъект", + "actor_ip": "IP адрес", + "allowed": "Разрешено", + "reason": "Причина", + "diff": "Изменения", + "details": "Детали", + } + if normalized_column in explicit: + return explicit[normalized_column] + + return _humanize_identifier_ru(normalized_column) + + +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 + out.append( + { + "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, + } + ) + return out + + def _hidden_response_fields(table_name: str) -> set[str]: if table_name == "admin_users": return {"password_hash"} @@ -270,6 +527,14 @@ def _normalize_optional_string(value: Any) -> str | None: return text or None +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: @@ -466,6 +731,16 @@ def _apply_topic_status_transitions_fields(db: Session, payload: dict[str, Any]) return data +def _apply_status_fields(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 "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", @@ -655,6 +930,36 @@ def _apply_create_side_effects(db: Session, *, table_name: str, row: Any, admin: ) +@router.get("/meta/tables") +def list_tables_meta(admin: dict = Depends(get_current_admin)): + role = str(admin.get("role") or "").upper() + if role != "ADMIN": + raise HTTPException(status_code=403, detail="Недостаточно прав") + + table_models = _table_model_map() + rows: list[dict[str, Any]] = [] + for table_name in sorted(table_models.keys()): + model = table_models[table_name] + actions = sorted(_allowed_actions(role, table_name)) + rows.append( + { + "key": table_name, + "table": table_name, + "label": _table_label(table_name), + "section": "main" if table_name in {"requests", "invoices"} else "dictionary", + "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 {"tables": rows} + + @router.post("/{table_name}/query") def query_table( table_name: str, @@ -711,14 +1016,27 @@ def create_row( ): normalized, model = _resolve_table_model(table_name) _require_table_action(admin, normalized, "create") - if normalized == "requests" and _is_lawyer(admin): - assigned_lawyer_id = payload.get("assigned_lawyer_id") if isinstance(payload, dict) else None + if 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 == "requests": validate_required_topic_fields_or_400(db, prepared.get("topic_code"), prepared.get("extra_fields")) + 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 prepared = _apply_auto_fields_for_create(db, model, normalized, prepared) clean_payload = _sanitize_payload( model, @@ -737,6 +1055,8 @@ def create_row( 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(clean_payload) if "responsible" in _columns_map(model): clean_payload["responsible"] = _resolve_responsible(admin) row = model(**clean_payload) @@ -766,8 +1086,12 @@ def update_row( ): normalized, model = _resolve_table_model(table_name) _require_table_action(admin, normalized, "update") - if normalized == "requests" and _is_lawyer(admin) and isinstance(payload, dict) and "assigned_lawyer_id" in payload: - raise HTTPException(status_code=403, detail='Назначение доступно только через действие "Взять в работу"') + 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 in {"messages", "attachments"} and bool(getattr(row, "immutable", False)): raise HTTPException(status_code=400, detail="Запись зафиксирована и недоступна для редактирования") @@ -791,6 +1115,17 @@ def update_row( 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(clean_payload) + 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 before = _row_to_dict(row) if normalized == "topic_status_transitions": next_from = str(clean_payload.get("from_status", before.get("from_status") or "")).strip() @@ -807,6 +1142,14 @@ def update_row( detail="Переход статуса не разрешен для выбранной темы", ) if before_status != after_status and isinstance(row, Request): + billing_note = apply_billing_transition_effects( + db, + req=row, + from_status=before_status, + to_status=after_status, + admin=admin, + responsible=_resolve_responsible(admin), + ) mark_unread_for_client(row, EVENT_STATUS) apply_status_change_effects( db, @@ -822,7 +1165,7 @@ def update_row( event_type=NOTIFICATION_EVENT_STATUS, actor_role=_actor_role(admin), actor_admin_user_id=admin.get("sub"), - body=f"{before_status} -> {after_status}", + body=(f"{before_status} -> {after_status}" + (f"\n{billing_note}" if billing_note else "")), responsible=_resolve_responsible(admin), ) for key, value in clean_payload.items(): diff --git a/app/api/admin/invoices.py b/app/api/admin/invoices.py new file mode 100644 index 0000000..425fef6 --- /dev/null +++ b/app/api/admin/invoices.py @@ -0,0 +1,439 @@ +from __future__ import annotations + +import json +from datetime import datetime, timezone +from decimal import Decimal +from uuid import UUID, uuid4 + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import StreamingResponse +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.core.deps import require_role +from app.db.session import get_db +from app.models.admin_user import AdminUser +from app.models.invoice import Invoice +from app.models.request import Request +from app.schemas.universal import UniversalQuery +from app.services.invoice_crypto import decrypt_requisites, encrypt_requisites +from app.services.invoice_pdf import build_invoice_pdf_bytes +from app.services.universal_query import apply_universal_query + +router = APIRouter() + +STATUS_WAITING = "WAITING_PAYMENT" +STATUS_PAID = "PAID" +STATUS_CANCELED = "CANCELED" +ALLOWED_STATUSES = {STATUS_WAITING, STATUS_PAID, STATUS_CANCELED} +STATUS_LABELS = { + STATUS_WAITING: "Ожидает оплату", + STATUS_PAID: "Оплачен", + STATUS_CANCELED: "Отменен", +} + + +def _to_float(value) -> float | None: + if value is None: + return None + if isinstance(value, Decimal): + return float(value) + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _to_iso(value: datetime | None) -> str | None: + return value.isoformat() if value else None + + +def _actor_uuid_or_401(admin: dict) -> UUID: + try: + return UUID(str(admin.get("sub") or "")) + except ValueError: + raise HTTPException(status_code=401, detail="Некорректный токен") + + +def _uuid_or_400(raw: str | None, field: str) -> UUID: + if not raw: + raise HTTPException(status_code=400, detail=f'Поле "{field}" обязательно') + try: + return UUID(str(raw)) + except ValueError: + raise HTTPException(status_code=400, detail=f'Некорректное поле "{field}"') + + +def _normalize_status(raw: str | None) -> str: + value = str(raw or STATUS_WAITING).strip().upper() + if value not in ALLOWED_STATUSES: + raise HTTPException(status_code=400, detail="Некорректный статус счета") + return value + + +def _normalize_currency(raw: str | None) -> str: + value = str(raw or "RUB").strip().upper()[:3] + return value or "RUB" + + +def _amount_or_400(raw) -> float: + value = _to_float(raw) + if value is None: + raise HTTPException(status_code=400, detail='Поле "amount" обязательно и должно быть числом') + if value < 0: + raise HTTPException(status_code=400, detail='Поле "amount" не может быть отрицательным') + return round(value, 2) + + +def _now_utc() -> datetime: + return datetime.now(timezone.utc) + + +def _invoice_number(db: Session) -> str: + prefix = _now_utc().strftime("%Y%m%d") + candidate = f"INV-{prefix}-{uuid4().hex[:8].upper()}" + exists = db.query(Invoice.id).filter(Invoice.invoice_number == candidate).first() + if exists is None: + return candidate + return f"INV-{prefix}-{uuid4().hex[:12].upper()}" + + +def _parse_requisites(raw) -> dict: + if raw is None: + return {} + if isinstance(raw, dict): + return dict(raw) + text = str(raw).strip() + if not text: + return {} + try: + data = json.loads(text) + except Exception: + raise HTTPException(status_code=400, detail='Поле "payer_details" должно быть JSON-объектом') + if not isinstance(data, dict): + raise HTTPException(status_code=400, detail='Поле "payer_details" должно быть JSON-объектом') + return data + + +def _ensure_lawyer_owns_request_or_403(role: str, actor_id: UUID, req: Request) -> None: + if role != "LAWYER": + return + assigned = str(req.assigned_lawyer_id or "").strip() + if not assigned or assigned != str(actor_id): + raise HTTPException(status_code=403, detail="Юрист видит и изменяет только свои счета") + + +def _serialize_invoice( + row: Invoice, + request_track: str | None, + issuer_name: str | None, + *, + include_payer_details: bool = False, +) -> dict: + payload = { + "id": str(row.id), + "invoice_number": row.invoice_number, + "request_id": str(row.request_id), + "request_track_number": request_track, + "status": row.status, + "status_label": STATUS_LABELS.get(str(row.status or "").upper(), row.status), + "amount": _to_float(row.amount), + "currency": row.currency, + "payer_display_name": row.payer_display_name, + "issued_by_admin_user_id": str(row.issued_by_admin_user_id) if row.issued_by_admin_user_id else None, + "issued_by_name": issuer_name, + "issued_by_role": row.issued_by_role, + "issued_at": _to_iso(row.issued_at), + "paid_at": _to_iso(row.paid_at), + "created_at": _to_iso(row.created_at), + "updated_at": _to_iso(row.updated_at), + "responsible": row.responsible, + "pdf_url": f"/api/admin/invoices/{row.id}/pdf", + } + if include_payer_details: + payload["payer_details"] = decrypt_requisites(row.payer_details_encrypted) + return payload + + +def _apply_paid_flags(req: Request, invoice: Invoice, *, admin_id: UUID | None) -> None: + req.invoice_amount = invoice.amount + req.paid_at = invoice.paid_at + req.paid_by_admin_id = str(admin_id) if admin_id else None + + +def _request_from_payload_or_404(db: Session, payload: dict) -> Request: + request_id_raw = payload.get("request_id") + track_number_raw = str(payload.get("request_track_number") or "").strip().upper() + + if request_id_raw: + request_id = _uuid_or_400(request_id_raw, "request_id") + req = db.get(Request, request_id) + if req is None: + raise HTTPException(status_code=404, detail="Заявка не найдена") + return req + + if track_number_raw: + req = db.query(Request).filter(Request.track_number == track_number_raw).first() + if req is None: + raise HTTPException(status_code=404, detail="Заявка не найдена") + return req + + raise HTTPException(status_code=400, detail='Поле "request_id" или "request_track_number" обязательно') + + +def _commit_or_400(db: Session, detail: str) -> None: + try: + db.commit() + except IntegrityError: + db.rollback() + raise HTTPException(status_code=400, detail=detail) + + +@router.post("/query") +def query_invoices( + uq: UniversalQuery, + db: Session = Depends(get_db), + admin: dict = Depends(require_role("ADMIN", "LAWYER")), +): + role = str(admin.get("role") or "").upper() + actor_id = _actor_uuid_or_401(admin) + + query = db.query(Invoice) + if role == "LAWYER": + query = query.join(Request, Request.id == Invoice.request_id).filter(Request.assigned_lawyer_id == str(actor_id)) + query = apply_universal_query(query, Invoice, uq) + + total = query.count() + rows = query.offset(uq.page.offset).limit(uq.page.limit).all() + + request_ids = {row.request_id for row in rows} + requests = db.query(Request.id, Request.track_number).filter(Request.id.in_(request_ids)).all() if request_ids else [] + request_map = {str(row_id): track for row_id, track in requests} + + issuer_ids = {row.issued_by_admin_user_id for row in rows if row.issued_by_admin_user_id} + users = db.query(AdminUser.id, AdminUser.name, AdminUser.email).filter(AdminUser.id.in_(issuer_ids)).all() if issuer_ids else [] + issuer_map = {str(user_id): (name or email or str(user_id)) for user_id, name, email in users} + + data = [ + _serialize_invoice( + row, + request_track=request_map.get(str(row.request_id)), + issuer_name=issuer_map.get(str(row.issued_by_admin_user_id)) if row.issued_by_admin_user_id else None, + ) + for row in rows + ] + return {"rows": data, "total": int(total)} + + +@router.get("/{invoice_id}") +def get_invoice( + invoice_id: str, + db: Session = Depends(get_db), + admin: dict = Depends(require_role("ADMIN", "LAWYER")), +): + role = str(admin.get("role") or "").upper() + actor_id = _actor_uuid_or_401(admin) + + invoice = db.get(Invoice, _uuid_or_400(invoice_id, "invoice_id")) + if invoice is None: + raise HTTPException(status_code=404, detail="Счет не найден") + + req = db.get(Request, invoice.request_id) + if req is None: + raise HTTPException(status_code=404, detail="Заявка не найдена") + _ensure_lawyer_owns_request_or_403(role, actor_id, req) + + issuer = db.get(AdminUser, invoice.issued_by_admin_user_id) if invoice.issued_by_admin_user_id else None + return _serialize_invoice( + invoice, + request_track=req.track_number, + issuer_name=issuer.name if issuer else None, + include_payer_details=True, + ) + + +@router.post("", status_code=201) +def create_invoice( + payload: dict, + db: Session = Depends(get_db), + admin: dict = Depends(require_role("ADMIN", "LAWYER")), +): + role = str(admin.get("role") or "").upper() + actor_id = _actor_uuid_or_401(admin) + actor_email = str(admin.get("email") or "").strip() or "Администратор системы" + + req = _request_from_payload_or_404(db, payload) + _ensure_lawyer_owns_request_or_403(role, actor_id, req) + + status = _normalize_status(payload.get("status")) + if role == "LAWYER" and status == STATUS_PAID: + raise HTTPException(status_code=403, detail='Юрист не может ставить статус "Оплачен"') + + payer_display_name = str(payload.get("payer_display_name") or "").strip() + if not payer_display_name: + raise HTTPException(status_code=400, detail='Поле "payer_display_name" обязательно') + + invoice = Invoice( + request_id=req.id, + invoice_number=str(payload.get("invoice_number") or "").strip() or _invoice_number(db), + status=status, + amount=_amount_or_400(payload.get("amount")), + currency=_normalize_currency(payload.get("currency")), + payer_display_name=payer_display_name, + payer_details_encrypted=encrypt_requisites(_parse_requisites(payload.get("payer_details"))), + issued_by_admin_user_id=actor_id, + issued_by_role=role, + issued_at=_now_utc(), + paid_at=None, + responsible=actor_email, + ) + + req.invoice_amount = invoice.amount + req.responsible = actor_email + + if status == STATUS_PAID: + invoice.paid_at = _now_utc() + _apply_paid_flags(req, invoice, admin_id=actor_id if role == "ADMIN" else None) + + db.add(invoice) + db.add(req) + _commit_or_400(db, "Счет с таким номером уже существует") + db.refresh(invoice) + + issuer = db.get(AdminUser, invoice.issued_by_admin_user_id) if invoice.issued_by_admin_user_id else None + return _serialize_invoice(invoice, request_track=req.track_number, issuer_name=issuer.name if issuer else None) + + +@router.patch("/{invoice_id}") +def update_invoice( + invoice_id: str, + payload: dict, + db: Session = Depends(get_db), + admin: dict = Depends(require_role("ADMIN", "LAWYER")), +): + role = str(admin.get("role") or "").upper() + actor_id = _actor_uuid_or_401(admin) + actor_email = str(admin.get("email") or "").strip() or "Администратор системы" + + invoice = db.get(Invoice, _uuid_or_400(invoice_id, "invoice_id")) + if invoice is None: + raise HTTPException(status_code=404, detail="Счет не найден") + + req = db.get(Request, invoice.request_id) + if req is None: + raise HTTPException(status_code=404, detail="Заявка не найдена") + _ensure_lawyer_owns_request_or_403(role, actor_id, req) + + prev_status = str(invoice.status or "").upper() + prev_paid_at = invoice.paid_at + + if "amount" in payload: + invoice.amount = _amount_or_400(payload.get("amount")) + req.invoice_amount = invoice.amount + if prev_status == STATUS_PAID: + req.paid_at = invoice.paid_at + if "currency" in payload: + invoice.currency = _normalize_currency(payload.get("currency")) + if "payer_display_name" in payload: + name = str(payload.get("payer_display_name") or "").strip() + if not name: + raise HTTPException(status_code=400, detail='Поле "payer_display_name" не может быть пустым') + invoice.payer_display_name = name + if "payer_details" in payload: + invoice.payer_details_encrypted = encrypt_requisites(_parse_requisites(payload.get("payer_details"))) + if "invoice_number" in payload and str(payload.get("invoice_number") or "").strip(): + invoice.invoice_number = str(payload.get("invoice_number") or "").strip() + + if "status" in payload: + next_status = _normalize_status(payload.get("status")) + if role == "LAWYER" and next_status == STATUS_PAID: + raise HTTPException(status_code=403, detail='Юрист не может ставить статус "Оплачен"') + if role == "LAWYER" and prev_status == STATUS_PAID and next_status != STATUS_PAID: + raise HTTPException(status_code=403, detail="Юрист не может менять статус уже оплаченного счета") + + invoice.status = next_status + if next_status == STATUS_PAID: + if role != "ADMIN": + raise HTTPException(status_code=403, detail='Юрист не может ставить статус "Оплачен"') + invoice.paid_at = _now_utc() + _apply_paid_flags(req, invoice, admin_id=actor_id) + else: + invoice.paid_at = None + req.invoice_amount = invoice.amount + if prev_paid_at is not None and req.paid_at == prev_paid_at: + req.paid_at = None + req.paid_by_admin_id = None + + invoice.responsible = actor_email + req.responsible = actor_email + + db.add(invoice) + db.add(req) + _commit_or_400(db, "Счет с таким номером уже существует") + db.refresh(invoice) + + issuer = db.get(AdminUser, invoice.issued_by_admin_user_id) if invoice.issued_by_admin_user_id else None + return _serialize_invoice(invoice, request_track=req.track_number, issuer_name=issuer.name if issuer else None) + + +@router.delete("/{invoice_id}") +def delete_invoice( + invoice_id: str, + db: Session = Depends(get_db), + admin: dict = Depends(require_role("ADMIN")), +): + actor_email = str(admin.get("email") or "").strip() or "Администратор системы" + + invoice = db.get(Invoice, _uuid_or_400(invoice_id, "invoice_id")) + if invoice is None: + raise HTTPException(status_code=404, detail="Счет не найден") + + req = db.get(Request, invoice.request_id) + if req is not None: + if invoice.paid_at is not None and req.paid_at == invoice.paid_at: + req.paid_at = None + req.paid_by_admin_id = None + req.responsible = actor_email + db.add(req) + + db.delete(invoice) + db.commit() + return {"status": "удалено", "id": invoice_id, "responsible": actor_email} + + +@router.get("/{invoice_id}/pdf") +def download_invoice_pdf( + invoice_id: str, + db: Session = Depends(get_db), + admin: dict = Depends(require_role("ADMIN", "LAWYER")), +): + role = str(admin.get("role") or "").upper() + actor_id = _actor_uuid_or_401(admin) + + invoice = db.get(Invoice, _uuid_or_400(invoice_id, "invoice_id")) + if invoice is None: + raise HTTPException(status_code=404, detail="Счет не найден") + + req = db.get(Request, invoice.request_id) + if req is None: + raise HTTPException(status_code=404, detail="Заявка не найдена") + _ensure_lawyer_owns_request_or_403(role, actor_id, req) + + issuer = db.get(AdminUser, invoice.issued_by_admin_user_id) if invoice.issued_by_admin_user_id else None + requisites = decrypt_requisites(invoice.payer_details_encrypted) + pdf_bytes = build_invoice_pdf_bytes( + invoice_number=invoice.invoice_number, + amount=_to_float(invoice.amount) or 0.0, + currency=invoice.currency, + status=STATUS_LABELS.get(str(invoice.status or "").upper(), invoice.status or "-"), + issued_at=invoice.issued_at, + paid_at=invoice.paid_at, + payer_display_name=invoice.payer_display_name, + request_track_number=req.track_number, + issued_by_name=(issuer.name if issuer else None), + requisites=requisites, + ) + + file_name = f"{invoice.invoice_number}.pdf" + headers = {"Content-Disposition": f'attachment; filename="{file_name}"'} + return StreamingResponse(iter([pdf_bytes]), media_type="application/pdf", headers=headers) diff --git a/app/api/admin/requests.py b/app/api/admin/requests.py index 9a26925..5ea4acd 100644 --- a/app/api/admin/requests.py +++ b/app/api/admin/requests.py @@ -4,7 +4,7 @@ from uuid import UUID, uuid4 from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session from sqlalchemy.exc import IntegrityError -from sqlalchemy import update +from sqlalchemy import case, update from app.db.session import get_db from app.core.deps import require_role @@ -30,9 +30,11 @@ from app.services.request_read_markers import EVENT_STATUS, clear_unread_for_law from app.services.request_status import actor_admin_uuid, apply_status_change_effects from app.services.status_flow import transition_allowed_for_topic from app.services.request_templates import validate_required_topic_fields_or_400 +from app.services.billing_flow import apply_billing_transition_effects from app.services.universal_query import apply_universal_query router = APIRouter() +REQUEST_FINANCIAL_FIELDS = {"effective_rate", "invoice_amount", "paid_at", "paid_by_admin_id"} def _request_uuid_or_400(request_id: str) -> UUID: @@ -42,6 +44,17 @@ def _request_uuid_or_400(request_id: str) -> UUID: 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": @@ -99,11 +112,23 @@ def query_requests(uq: UniversalQuery, db: Session = Depends(get_db), admin=Depe @router.post("", status_code=201) def create_request(payload: RequestAdminCreate, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER"))): - if str(admin.get("role") or "").upper() == "LAWYER" and str(payload.assigned_lawyer_id or "").strip(): + 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 "Администратор системы" + 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_name=payload.client_name, @@ -112,8 +137,8 @@ def create_request(payload: RequestAdminCreate, db: Session = Depends(get_db), a status_code=payload.status_code, description=payload.description, extra_fields=payload.extra_fields, - assigned_lawyer_id=payload.assigned_lawyer_id, - effective_rate=payload.effective_rate, + assigned_lawyer_id=assigned_lawyer_id, + effective_rate=effective_rate, invoice_amount=payload.invoice_amount, paid_at=payload.paid_at, paid_by_admin_id=payload.paid_by_admin_id, @@ -142,26 +167,49 @@ def update_request( if not row: raise HTTPException(status_code=404, detail="Заявка не найдена") changes = payload.model_dump(exclude_unset=True) - if str(admin.get("role") or "").upper() == "LAWYER" and "assigned_lawyer_id" in changes: - raise HTTPException(status_code=403, detail='Назначение доступно только через действие "Взять в работу"') + 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 "Администратор системы" for key, value in changes.items(): setattr(row, key, value) if "status_code" in changes and str(changes.get("status_code") or "") != old_status: + next_status = str(changes.get("status_code") or "") if not transition_allowed_for_topic( db, str(row.topic_code or "").strip() or None, old_status, - str(changes.get("status_code") or ""), + next_status, ): raise HTTPException(status_code=400, detail="Переход статуса не разрешен для выбранной темы") + 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=str(changes.get("status_code") or ""), + to_status=next_status, admin=admin, responsible=responsible, ) @@ -171,7 +219,7 @@ def update_request( event_type=NOTIFICATION_EVENT_STATUS, actor_role=str(admin.get("role") or "").upper() or "ADMIN", actor_admin_user_id=admin.get("sub"), - body=f"{old_status} -> {str(changes.get('status_code') or '').strip()}", + body=(f"{old_status} -> {next_status}" + (f"\n{billing_note}" if billing_note else "")), responsible=responsible, ) try: @@ -263,6 +311,7 @@ def claim_request(request_id: str, db: Session = Depends(get_db), admin=Depends( .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, ) @@ -346,6 +395,7 @@ def reassign_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, ) diff --git a/app/api/admin/router.py b/app/api/admin/router.py index 43edaf4..b54af52 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 +from app.api.admin import auth, requests, quotes, meta, config, uploads, metrics, crud, notifications, invoices router = APIRouter() router.include_router(auth.router, prefix="/auth", tags=["AdminAuth"]) @@ -10,4 +10,5 @@ router.include_router(config.router, prefix="/config", tags=["AdminConfig"]) router.include_router(uploads.router, prefix="/uploads", tags=["AdminFiles"]) router.include_router(metrics.router, prefix="/metrics", tags=["AdminMetrics"]) router.include_router(notifications.router, prefix="/notifications", tags=["AdminNotifications"]) +router.include_router(invoices.router, prefix="/invoices", tags=["AdminInvoices"]) router.include_router(crud.router, prefix="/crud", tags=["AdminCrud"]) diff --git a/app/api/admin/uploads.py b/app/api/admin/uploads.py index 9232ea5..1bd5139 100644 --- a/app/api/admin/uploads.py +++ b/app/api/admin/uploads.py @@ -4,7 +4,7 @@ import uuid from typing import Tuple from botocore.exceptions import ClientError -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query, Request as FastapiRequest from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session @@ -19,6 +19,7 @@ from app.models.request import Request from app.schemas.uploads import UploadCompletePayload, UploadCompleteResponse, UploadInitPayload, UploadInitResponse, UploadScope from app.services.notifications import EVENT_ATTACHMENT as NOTIFICATION_EVENT_ATTACHMENT, notify_request_event from app.services.request_read_markers import EVENT_ATTACHMENT, mark_unread_for_client +from app.services.security_audit import record_file_security_event from app.services.s3_storage import build_object_key, get_s3_storage router = APIRouter() @@ -77,169 +78,327 @@ def _uuid_or_none(raw: str) -> uuid.UUID | None: return None +def _client_ip(http_request: FastapiRequest) -> str | None: + if http_request is None: + return None + forwarded = str(http_request.headers.get("x-forwarded-for") or "").strip() + if forwarded: + first = forwarded.split(",")[0].strip() + if first: + return first + if http_request.client and http_request.client.host: + return str(http_request.client.host) + return None + + @router.post("/init", response_model=UploadInitResponse) def upload_init( payload: UploadInitPayload, + http_request: FastapiRequest, db: Session = Depends(get_db), admin: dict = Depends(require_role("ADMIN", "LAWYER")), ): - _validate_size_or_400(payload.size_bytes) - storage = get_s3_storage() - role = str(admin.get("role") or "") - actor_id = str(admin.get("sub") or "") + role = str(admin.get("role") or "").upper() or "UNKNOWN" + actor_id = str(admin.get("sub") or "").strip() + actor_ip = _client_ip(http_request) + responsible = str(admin.get("email") or "").strip() or "Администратор системы" + scope_name = str(payload.scope.value if hasattr(payload.scope, "value") else payload.scope) - if payload.scope == UploadScope.REQUEST_ATTACHMENT: - request_uuid = _uuid_or_400(payload.request_id, "request_id") - request = db.get(Request, request_uuid) - if request is None: - raise HTTPException(status_code=404, detail="Заявка не найдена") - _ensure_case_capacity_or_400(request, payload.size_bytes) - key = build_object_key(f"requests/{request.id}", payload.file_name) - return UploadInitResponse(key=key, presigned_url=storage.create_presigned_put_url(key, payload.mime_type)) + try: + _validate_size_or_400(payload.size_bytes) + storage = get_s3_storage() - if payload.scope == UploadScope.USER_AVATAR: - target_user_id = str(payload.user_id or actor_id) - target_uuid = _uuid_or_400(target_user_id, "user_id") - if role != "ADMIN" and str(target_uuid) != actor_id: - raise HTTPException(status_code=403, detail="Недостаточно прав для загрузки аватара") - user = db.get(AdminUser, target_uuid) - if user is None: - raise HTTPException(status_code=404, detail="Пользователь не найден") - key = build_object_key(f"avatars/{user.id}", payload.file_name) - return UploadInitResponse(key=key, presigned_url=storage.create_presigned_put_url(key, payload.mime_type)) + if payload.scope == UploadScope.REQUEST_ATTACHMENT: + request_uuid = _uuid_or_400(payload.request_id, "request_id") + request = db.get(Request, request_uuid) + if request is None: + raise HTTPException(status_code=404, detail="Заявка не найдена") + _ensure_case_capacity_or_400(request, payload.size_bytes) + key = build_object_key(f"requests/{request.id}", payload.file_name) + response = UploadInitResponse(key=key, presigned_url=storage.create_presigned_put_url(key, payload.mime_type)) + record_file_security_event( + db, + actor_role=role, + actor_subject=actor_id, + actor_ip=actor_ip, + action="UPLOAD_INIT", + scope=scope_name, + allowed=True, + object_key=key, + request_id=request.id, + details={"mime_type": payload.mime_type, "size_bytes": int(payload.size_bytes or 0)}, + responsible=responsible, + persist_now=True, + ) + return response - raise HTTPException(status_code=400, detail="Неподдерживаемый scope") + if payload.scope == UploadScope.USER_AVATAR: + target_user_id = str(payload.user_id or actor_id) + target_uuid = _uuid_or_400(target_user_id, "user_id") + if role != "ADMIN" and str(target_uuid) != actor_id: + raise HTTPException(status_code=403, detail="Недостаточно прав для загрузки аватара") + user = db.get(AdminUser, target_uuid) + if user is None: + raise HTTPException(status_code=404, detail="Пользователь не найден") + key = build_object_key(f"avatars/{user.id}", payload.file_name) + response = UploadInitResponse(key=key, presigned_url=storage.create_presigned_put_url(key, payload.mime_type)) + record_file_security_event( + db, + actor_role=role, + actor_subject=actor_id, + actor_ip=actor_ip, + action="UPLOAD_INIT", + scope=scope_name, + allowed=True, + object_key=key, + details={"mime_type": payload.mime_type, "size_bytes": int(payload.size_bytes or 0)}, + responsible=responsible, + persist_now=True, + ) + return response + + raise HTTPException(status_code=400, detail="Неподдерживаемый scope") + except HTTPException as exc: + record_file_security_event( + db, + actor_role=role, + actor_subject=actor_id, + actor_ip=actor_ip, + action="UPLOAD_INIT", + scope=scope_name, + allowed=False, + reason=str(exc.detail), + request_id=_uuid_or_none(payload.request_id), + details={"mime_type": payload.mime_type, "size_bytes": int(payload.size_bytes or 0)}, + responsible=responsible, + persist_now=True, + ) + raise @router.post("/complete", response_model=UploadCompleteResponse) def upload_complete( payload: UploadCompletePayload, + http_request: FastapiRequest, db: Session = Depends(get_db), admin: dict = Depends(require_role("ADMIN", "LAWYER")), ): - _validate_size_or_400(payload.size_bytes) - storage = get_s3_storage() - role = str(admin.get("role") or "") + role = str(admin.get("role") or "").upper() or "UNKNOWN" actor_id = str(admin.get("sub") or "") + actor_ip = _client_ip(http_request) responsible = str(admin.get("email") or "").strip() or "Администратор системы" + scope_name = str(payload.scope.value if hasattr(payload.scope, "value") else payload.scope) + try: - head = storage.head_object(payload.key) - except ClientError: - raise HTTPException(status_code=400, detail="Файл не найден в хранилище") + _validate_size_or_400(payload.size_bytes) + storage = get_s3_storage() + try: + head = storage.head_object(payload.key) + except ClientError: + raise HTTPException(status_code=400, detail="Файл не найден в хранилище") - actual_size = int(head.get("ContentLength") or payload.size_bytes) - if actual_size <= 0: - raise HTTPException(status_code=400, detail="Некорректный размер файла") - if actual_size > _max_file_bytes(): - raise HTTPException(status_code=400, detail=f"Превышен лимит файла ({settings.MAX_FILE_MB} МБ)") + actual_size = int(head.get("ContentLength") or payload.size_bytes) + if actual_size <= 0: + raise HTTPException(status_code=400, detail="Некорректный размер файла") + if actual_size > _max_file_bytes(): + raise HTTPException(status_code=400, detail=f"Превышен лимит файла ({settings.MAX_FILE_MB} МБ)") - if payload.scope == UploadScope.REQUEST_ATTACHMENT: - request_uuid = _uuid_or_400(payload.request_id, "request_id") - request = db.get(Request, request_uuid) - if request is None: - raise HTTPException(status_code=404, detail="Заявка не найдена") - _ensure_object_key_prefix_or_400(payload.key, f"requests/{request.id}/") - _ensure_case_capacity_or_400(request, actual_size) + if payload.scope == UploadScope.REQUEST_ATTACHMENT: + request_uuid = _uuid_or_400(payload.request_id, "request_id") + request = db.get(Request, request_uuid) + if request is None: + raise HTTPException(status_code=404, detail="Заявка не найдена") + _ensure_object_key_prefix_or_400(payload.key, f"requests/{request.id}/") + _ensure_case_capacity_or_400(request, actual_size) - message_uuid = None - if payload.message_id: - message_uuid = _uuid_or_400(payload.message_id, "message_id") - message = db.get(Message, message_uuid) - if message is None or message.request_id != request.id: - raise HTTPException(status_code=400, detail="Сообщение не найдено для указанной заявки") - if bool(message.immutable): - raise HTTPException(status_code=400, detail="Нельзя прикрепить файл к зафиксированному сообщению") + message_uuid = None + if payload.message_id: + message_uuid = _uuid_or_400(payload.message_id, "message_id") + message = db.get(Message, message_uuid) + if message is None or message.request_id != request.id: + raise HTTPException(status_code=400, detail="Сообщение не найдено для указанной заявки") + if bool(message.immutable): + raise HTTPException(status_code=400, detail="Нельзя прикрепить файл к зафиксированному сообщению") - row = Attachment( - request_id=request.id, - message_id=message_uuid, - file_name=payload.file_name, - mime_type=payload.mime_type, - size_bytes=actual_size, - s3_key=payload.key, - responsible=responsible, - ) - mark_unread_for_client(request, EVENT_ATTACHMENT) - notify_request_event( + row = Attachment( + request_id=request.id, + message_id=message_uuid, + file_name=payload.file_name, + mime_type=payload.mime_type, + size_bytes=actual_size, + s3_key=payload.key, + responsible=responsible, + ) + mark_unread_for_client(request, EVENT_ATTACHMENT) + notify_request_event( + db, + request=request, + event_type=NOTIFICATION_EVENT_ATTACHMENT, + actor_role=str(admin.get("role") or "").upper() or "ADMIN", + actor_admin_user_id=admin.get("sub"), + body=f'Файл: {payload.file_name}', + responsible=responsible, + ) + request.total_attachments_bytes = int(request.total_attachments_bytes or 0) + actual_size + request.responsible = responsible + db.add(row) + db.add(request) + record_file_security_event( + db, + actor_role=role, + actor_subject=actor_id, + actor_ip=actor_ip, + action="UPLOAD_COMPLETE", + scope=scope_name, + allowed=True, + object_key=payload.key, + request_id=request.id, + details={"mime_type": payload.mime_type, "size_bytes": int(actual_size)}, + responsible=responsible, + ) + db.commit() + db.refresh(row) + return UploadCompleteResponse(status="ok", attachment_id=str(row.id)) + + if payload.scope == UploadScope.USER_AVATAR: + target_user_id = str(payload.user_id or actor_id) + target_uuid = _uuid_or_400(target_user_id, "user_id") + if role != "ADMIN" and str(target_uuid) != actor_id: + raise HTTPException(status_code=403, detail="Недостаточно прав для загрузки аватара") + user = db.get(AdminUser, target_uuid) + if user is None: + raise HTTPException(status_code=404, detail="Пользователь не найден") + _ensure_object_key_prefix_or_400(payload.key, f"avatars/{user.id}/") + user.avatar_url = f"s3://{payload.key}" + user.responsible = responsible + db.add(user) + record_file_security_event( + db, + actor_role=role, + actor_subject=actor_id, + actor_ip=actor_ip, + action="UPLOAD_COMPLETE", + scope=scope_name, + allowed=True, + object_key=payload.key, + details={"mime_type": payload.mime_type, "size_bytes": int(actual_size)}, + responsible=responsible, + ) + db.commit() + return UploadCompleteResponse(status="ok", avatar_url=user.avatar_url) + + raise HTTPException(status_code=400, detail="Неподдерживаемый scope") + except HTTPException as exc: + record_file_security_event( db, - request=request, - event_type=NOTIFICATION_EVENT_ATTACHMENT, - actor_role=str(admin.get("role") or "").upper() or "ADMIN", - actor_admin_user_id=admin.get("sub"), - body=f'Файл: {payload.file_name}', + actor_role=role, + actor_subject=actor_id, + actor_ip=actor_ip, + action="UPLOAD_COMPLETE", + scope=scope_name, + allowed=False, + reason=str(exc.detail), + object_key=payload.key, + request_id=_uuid_or_none(payload.request_id), + details={"mime_type": payload.mime_type, "size_bytes": int(payload.size_bytes or 0)}, responsible=responsible, + persist_now=True, ) - request.total_attachments_bytes = int(request.total_attachments_bytes or 0) + actual_size - request.responsible = responsible - db.add(row) - db.add(request) - db.commit() - db.refresh(row) - return UploadCompleteResponse(status="ok", attachment_id=str(row.id)) - - if payload.scope == UploadScope.USER_AVATAR: - target_user_id = str(payload.user_id or actor_id) - target_uuid = _uuid_or_400(target_user_id, "user_id") - if role != "ADMIN" and str(target_uuid) != actor_id: - raise HTTPException(status_code=403, detail="Недостаточно прав для загрузки аватара") - user = db.get(AdminUser, target_uuid) - if user is None: - raise HTTPException(status_code=404, detail="Пользователь не найден") - _ensure_object_key_prefix_or_400(payload.key, f"avatars/{user.id}/") - user.avatar_url = f"s3://{payload.key}" - user.responsible = responsible - db.add(user) - db.commit() - return UploadCompleteResponse(status="ok", avatar_url=user.avatar_url) - - raise HTTPException(status_code=400, detail="Неподдерживаемый scope") + raise @router.get("/object/{object_key:path}") -def get_object_proxy(object_key: str, token: str = Query(...), db: Session = Depends(get_db)): - try: - claims = decode_jwt(token, settings.ADMIN_JWT_SECRET) - except Exception: - raise HTTPException(status_code=401, detail="Некорректный токен") - role = str(claims.get("role") or "").upper() - if role not in {"ADMIN", "LAWYER"}: - raise HTTPException(status_code=403, detail="Недостаточно прав") - +def get_object_proxy( + object_key: str, + http_request: FastapiRequest, + token: str = Query(...), + db: Session = Depends(get_db), +): key = str(object_key or "").strip() - if not key: - raise HTTPException(status_code=400, detail="Некорректный ключ объекта") + scope = "UNKNOWN" + scoped_uuid: uuid.UUID | None = None + actor_role = "UNKNOWN" + actor_subject = "" + actor_ip = _client_ip(http_request) + responsible = "Администратор системы" - scope, scoped_id_raw = _parse_scoped_object_key(key) - if role == "LAWYER": - actor_id = _uuid_or_none(claims.get("sub")) - if actor_id is None: + try: + try: + claims = decode_jwt(token, settings.ADMIN_JWT_SECRET) + except Exception: raise HTTPException(status_code=401, detail="Некорректный токен") - scoped_uuid = _uuid_or_none(scoped_id_raw) - if scope == "avatars": - if scoped_uuid is None or scoped_uuid != actor_id: - raise HTTPException(status_code=403, detail="Недостаточно прав") - elif scope == "requests": - if scoped_uuid is None: - raise HTTPException(status_code=403, detail="Недостаточно прав") - # LAWYER can download files from own or unassigned requests only. - request = db.get(Request, scoped_uuid) - if request is None: - raise HTTPException(status_code=404, detail="Заявка не найдена") - assigned = str(request.assigned_lawyer_id or "").strip() - if assigned and assigned != str(actor_id): - raise HTTPException(status_code=403, detail="Недостаточно прав") - else: + actor_role = str(claims.get("role") or "").upper() + actor_subject = str(claims.get("sub") or "").strip() + responsible = str(claims.get("email") or "").strip() or "Администратор системы" + if actor_role not in {"ADMIN", "LAWYER"}: raise HTTPException(status_code=403, detail="Недостаточно прав") - try: - obj = get_s3_storage().get_object(key) - except ClientError: - raise HTTPException(status_code=404, detail="Файл не найден") + if not key: + raise HTTPException(status_code=400, detail="Некорректный ключ объекта") - body = obj["Body"] - content_length = obj.get("ContentLength") - media_type = obj.get("ContentType") or "application/octet-stream" - headers = {} - if content_length is not None: - headers["Content-Length"] = str(content_length) - return StreamingResponse(body.iter_chunks(chunk_size=64 * 1024), media_type=media_type, headers=headers) + scope, scoped_id_raw = _parse_scoped_object_key(key) + scoped_uuid = _uuid_or_none(scoped_id_raw) + if actor_role == "LAWYER": + actor_id = _uuid_or_none(claims.get("sub")) + if actor_id is None: + raise HTTPException(status_code=401, detail="Некорректный токен") + if scope == "avatars": + if scoped_uuid is None or scoped_uuid != actor_id: + raise HTTPException(status_code=403, detail="Недостаточно прав") + elif scope == "requests": + if scoped_uuid is None: + raise HTTPException(status_code=403, detail="Недостаточно прав") + # LAWYER can download files from own or unassigned requests only. + request = db.get(Request, scoped_uuid) + if request is None: + raise HTTPException(status_code=404, detail="Заявка не найдена") + assigned = str(request.assigned_lawyer_id or "").strip() + if assigned and assigned != str(actor_id): + raise HTTPException(status_code=403, detail="Недостаточно прав") + else: + raise HTTPException(status_code=403, detail="Недостаточно прав") + + try: + obj = get_s3_storage().get_object(key) + except ClientError: + raise HTTPException(status_code=404, detail="Файл не найден") + + record_file_security_event( + db, + actor_role=actor_role, + actor_subject=actor_subject, + actor_ip=actor_ip, + action="DOWNLOAD_OBJECT", + scope=scope, + allowed=True, + object_key=key, + request_id=scoped_uuid if scope == "requests" else None, + details={}, + responsible=responsible, + persist_now=True, + ) + + body = obj["Body"] + content_length = obj.get("ContentLength") + media_type = obj.get("ContentType") or "application/octet-stream" + headers = {} + if content_length is not None: + headers["Content-Length"] = str(content_length) + return StreamingResponse(body.iter_chunks(chunk_size=64 * 1024), media_type=media_type, headers=headers) + except HTTPException as exc: + record_file_security_event( + db, + actor_role=actor_role, + actor_subject=actor_subject, + actor_ip=actor_ip, + action="DOWNLOAD_OBJECT", + scope=scope, + allowed=False, + reason=str(exc.detail), + object_key=key or None, + request_id=scoped_uuid if scope == "requests" else None, + details={}, + responsible=responsible, + persist_now=True, + ) + raise diff --git a/app/api/public/otp.py b/app/api/public/otp.py index 21046e2..ec4bca3 100644 --- a/app/api/public/otp.py +++ b/app/api/public/otp.py @@ -1,17 +1,19 @@ from __future__ import annotations import secrets +import hashlib from datetime import datetime, timedelta, timezone -from fastapi import APIRouter, Depends, HTTPException, Response +from fastapi import APIRouter, Depends, HTTPException, Request, Response from sqlalchemy.orm import Session from app.core.config import settings from app.core.security import create_jwt, hash_password, verify_password from app.db.session import get_db from app.models.otp_session import OtpSession -from app.models.request import Request +from app.models.request import Request as RequestModel from app.schemas.public import OtpSend, OtpVerify +from app.services.rate_limit import get_rate_limiter router = APIRouter() @@ -55,6 +57,45 @@ def _generate_code() -> str: return f"{secrets.randbelow(1_000_000):06d}" +def _client_ip(request: Request) -> str: + xff = str(request.headers.get("x-forwarded-for") or "").strip() + if xff: + first = xff.split(",")[0].strip() + if first: + return first + client = request.client + return str(client.host if client else "unknown") + + +def _hash_key_part(value: str | None) -> str: + raw = str(value or "").strip() + if not raw: + return "-" + return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:20] + + +def _rate_limit_or_429(action: str, *, purpose: str, client_ip: str, phone: str | None, track_number: str | None) -> None: + limiter = get_rate_limiter() + window = int(max(settings.OTP_RATE_LIMIT_WINDOW_SECONDS, 1)) + limit = int(max(settings.OTP_SEND_RATE_LIMIT if action == "send" else settings.OTP_VERIFY_RATE_LIMIT, 1)) + purpose_norm = str(purpose or "").strip().upper() + keys = [ + f"otp:{action}:ip:{_hash_key_part(client_ip)}:purpose:{purpose_norm}", + ] + if phone: + keys.append(f"otp:{action}:phone:{_hash_key_part(phone)}:purpose:{purpose_norm}") + if track_number: + keys.append(f"otp:{action}:track:{_hash_key_part(track_number)}:purpose:{purpose_norm}") + + for key in keys: + result = limiter.hit(key, limit=limit, window_seconds=window) + if not result.allowed: + raise HTTPException( + status_code=429, + detail=f"Слишком много OTP-запросов. Повторите через {max(result.retry_after_seconds, 1)} сек.", + ) + + def _set_public_cookie(response: Response, *, subject: str, purpose: str) -> None: token = create_jwt( {"sub": subject, "purpose": purpose}, @@ -82,7 +123,7 @@ def _mock_sms_send(phone: str, code: str, purpose: str, track_number: str | None @router.post("/send") -def send_otp(payload: OtpSend, db: Session = Depends(get_db)): +def send_otp(payload: OtpSend, request: Request, db: Session = Depends(get_db)): purpose = _normalize_purpose(payload.purpose) if purpose not in ALLOWED_PURPOSES: raise HTTPException(status_code=400, detail="Некорректная цель OTP") @@ -97,13 +138,21 @@ def send_otp(payload: OtpSend, db: Session = Depends(get_db)): track_number = _normalize_track(payload.track_number) if not track_number: raise HTTPException(status_code=400, detail='Поле "track_number" обязательно для VIEW_REQUEST') - request = db.query(Request).filter(Request.track_number == track_number).first() - if request is None: + request_row = db.query(RequestModel).filter(RequestModel.track_number == track_number).first() + if request_row is None: raise HTTPException(status_code=404, detail="Заявка не найдена") - phone = _normalize_phone(request.client_phone) + phone = _normalize_phone(request_row.client_phone) if not phone: raise HTTPException(status_code=400, detail="У заявки отсутствует номер телефона") + _rate_limit_or_429( + "send", + purpose=purpose, + client_ip=_client_ip(request), + phone=phone or None, + track_number=track_number, + ) + code = _generate_code() now = _now_utc() expires_at = now + timedelta(minutes=OTP_TTL_MINUTES) @@ -139,7 +188,7 @@ def send_otp(payload: OtpSend, db: Session = Depends(get_db)): @router.post("/verify") -def verify_otp(payload: OtpVerify, response: Response, db: Session = Depends(get_db)): +def verify_otp(payload: OtpVerify, request: Request, response: Response, db: Session = Depends(get_db)): purpose = _normalize_purpose(payload.purpose) if purpose not in ALLOWED_PURPOSES: raise HTTPException(status_code=400, detail="Некорректная цель OTP") @@ -155,6 +204,14 @@ def verify_otp(payload: OtpVerify, response: Response, db: Session = Depends(get if not track_number: raise HTTPException(status_code=400, detail='Поле "track_number" обязательно для VIEW_REQUEST') + _rate_limit_or_429( + "verify", + purpose=purpose, + client_ip=_client_ip(request), + phone=phone, + track_number=track_number, + ) + query = db.query(OtpSession).filter( OtpSession.purpose == purpose, OtpSession.track_number == track_number, diff --git a/app/api/public/requests.py b/app/api/public/requests.py index 32c95de..c295147 100644 --- a/app/api/public/requests.py +++ b/app/api/public/requests.py @@ -5,16 +5,21 @@ from uuid import UUID from uuid import uuid4 from fastapi import APIRouter, Depends, HTTPException, Response +from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session from app.core.config import settings from app.core.deps import get_public_session from app.core.security import create_jwt from app.db.session import get_db +from app.models.admin_user import AdminUser from app.models.attachment import Attachment +from app.models.invoice import Invoice from app.models.message import Message from app.models.request import Request from app.models.status_history import StatusHistory +from app.services.invoice_crypto import decrypt_requisites +from app.services.invoice_pdf import build_invoice_pdf_bytes from app.services.notifications import ( EVENT_MESSAGE as NOTIFICATION_EVENT_MESSAGE, get_client_notification, @@ -39,6 +44,11 @@ router = APIRouter() OTP_CREATE_PURPOSE = "CREATE_REQUEST" OTP_VIEW_PURPOSE = "VIEW_REQUEST" +INVOICE_STATUS_LABELS = { + "WAITING_PAYMENT": "Ожидает оплату", + "PAID": "Оплачен", + "CANCELED": "Отменен", +} def _normalize_phone(raw: str | None) -> str: @@ -92,6 +102,22 @@ def _to_iso(value) -> str | None: return value.isoformat() if value is not None else None +def _public_invoice_payload(row: Invoice, track_number: str) -> dict: + status_code = str(row.status or "").upper() + return { + "id": str(row.id), + "invoice_number": row.invoice_number, + "status": row.status, + "status_label": INVOICE_STATUS_LABELS.get(status_code, row.status), + "amount": float(row.amount) if row.amount is not None else 0.0, + "currency": row.currency, + "payer_display_name": row.payer_display_name, + "issued_at": _to_iso(row.issued_at), + "paid_at": _to_iso(row.paid_at), + "download_url": f"/api/public/requests/{track_number}/invoices/{row.id}/pdf", + } + + @router.post("", response_model=PublicRequestCreated, status_code=201) def create_request( payload: PublicRequestCreate, @@ -258,6 +284,58 @@ def list_attachments_by_track( ] +@router.get("/{track_number}/invoices") +def list_invoices_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(Invoice) + .filter(Invoice.request_id == req.id) + .order_by(Invoice.issued_at.desc(), Invoice.created_at.desc(), Invoice.id.desc()) + .all() + ) + return [_public_invoice_payload(row, req.track_number) for row in rows] + + +@router.get("/{track_number}/invoices/{invoice_id}/pdf") +def download_invoice_pdf_by_track( + track_number: str, + invoice_id: str, + db: Session = Depends(get_db), + session: dict = Depends(get_public_session), +): + req = _request_for_track_or_404(db, session, track_number) + try: + invoice_uuid = UUID(str(invoice_id)) + except ValueError: + raise HTTPException(status_code=400, detail="Некорректный invoice_id") + + invoice = db.get(Invoice, invoice_uuid) + if invoice is None or str(invoice.request_id) != str(req.id): + raise HTTPException(status_code=404, detail="Счет не найден") + + issuer = db.get(AdminUser, invoice.issued_by_admin_user_id) if invoice.issued_by_admin_user_id else None + requisites = decrypt_requisites(invoice.payer_details_encrypted) + pdf_bytes = build_invoice_pdf_bytes( + invoice_number=invoice.invoice_number, + amount=float(invoice.amount) if invoice.amount is not None else 0.0, + currency=invoice.currency, + status=INVOICE_STATUS_LABELS.get(str(invoice.status or "").upper(), invoice.status or "-"), + issued_at=invoice.issued_at, + paid_at=invoice.paid_at, + payer_display_name=invoice.payer_display_name, + request_track_number=req.track_number, + issued_by_name=(issuer.name if issuer else invoice.issued_by_role), + requisites=requisites, + ) + file_name = f"{invoice.invoice_number}.pdf" + headers = {"Content-Disposition": f'attachment; filename="{file_name}"'} + return StreamingResponse(iter([pdf_bytes]), media_type="application/pdf", headers=headers) + + @router.get("/{track_number}/history", response_model=list[PublicStatusHistoryRead]) def list_status_history_by_track( track_number: str, diff --git a/app/api/public/uploads.py b/app/api/public/uploads.py index 323dd6b..5175cab 100644 --- a/app/api/public/uploads.py +++ b/app/api/public/uploads.py @@ -4,7 +4,7 @@ import uuid from urllib.parse import quote from botocore.exceptions import ClientError -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request as FastapiRequest from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session @@ -16,6 +16,7 @@ from app.models.request import Request from app.schemas.uploads import UploadCompletePayload, UploadCompleteResponse, UploadInitPayload, UploadInitResponse, UploadScope from app.services.notifications import EVENT_ATTACHMENT as NOTIFICATION_EVENT_ATTACHMENT, notify_request_event from app.services.request_read_markers import EVENT_ATTACHMENT, mark_unread_for_lawyer +from app.services.security_audit import record_file_security_event from app.services.s3_storage import build_object_key, get_s3_storage router = APIRouter() @@ -64,101 +65,236 @@ def _load_attachment_with_access_or_4xx(attachment_id: str, db: Session, session return attachment +def _client_ip(http_request: FastapiRequest) -> str | None: + if http_request is None: + return None + forwarded = str(http_request.headers.get("x-forwarded-for") or "").strip() + if forwarded: + first = forwarded.split(",")[0].strip() + if first: + return first + if http_request.client and http_request.client.host: + return str(http_request.client.host) + return None + + @router.post("/init", response_model=UploadInitResponse) -def upload_init(payload: UploadInitPayload, db: Session = Depends(get_db), session: dict = Depends(get_public_session)): - if payload.scope != UploadScope.REQUEST_ATTACHMENT: - raise HTTPException(status_code=400, detail="Публичная загрузка поддерживает только REQUEST_ATTACHMENT") - if int(payload.size_bytes or 0) <= 0: - raise HTTPException(status_code=400, detail="Некорректный размер файла") - if int(payload.size_bytes) > _max_file_bytes(): - raise HTTPException(status_code=400, detail=f"Превышен лимит файла ({settings.MAX_FILE_MB} МБ)") +def upload_init( + payload: UploadInitPayload, + http_request: FastapiRequest, + db: Session = Depends(get_db), + session: dict = Depends(get_public_session), +): + actor_subject = str(session.get("sub") or "").strip() + actor_ip = _client_ip(http_request) + scope_name = str(payload.scope.value if hasattr(payload.scope, "value") else payload.scope) + try: + if payload.scope != UploadScope.REQUEST_ATTACHMENT: + raise HTTPException(status_code=400, detail="Публичная загрузка поддерживает только REQUEST_ATTACHMENT") + if int(payload.size_bytes or 0) <= 0: + raise HTTPException(status_code=400, detail="Некорректный размер файла") + if int(payload.size_bytes) > _max_file_bytes(): + raise HTTPException(status_code=400, detail=f"Превышен лимит файла ({settings.MAX_FILE_MB} МБ)") - request_uuid = _uuid_or_400(payload.request_id, "request_id") - request = db.get(Request, request_uuid) - if request is None: - raise HTTPException(status_code=404, detail="Заявка не найдена") - _ensure_public_request_access_or_403(request, session) + request_uuid = _uuid_or_400(payload.request_id, "request_id") + request = db.get(Request, request_uuid) + if request is None: + raise HTTPException(status_code=404, detail="Заявка не найдена") + _ensure_public_request_access_or_403(request, session) - current = int(request.total_attachments_bytes or 0) - if current + int(payload.size_bytes) > _max_case_bytes(): - raise HTTPException(status_code=400, detail=f"Превышен лимит вложений заявки ({settings.MAX_CASE_MB} МБ)") + current = int(request.total_attachments_bytes or 0) + if current + int(payload.size_bytes) > _max_case_bytes(): + raise HTTPException(status_code=400, detail=f"Превышен лимит вложений заявки ({settings.MAX_CASE_MB} МБ)") - key = build_object_key(f"requests/{request.id}", payload.file_name) - presigned_url = get_s3_storage().create_presigned_put_url(key, payload.mime_type) - return UploadInitResponse(key=key, presigned_url=presigned_url) + key = build_object_key(f"requests/{request.id}", payload.file_name) + presigned_url = get_s3_storage().create_presigned_put_url(key, payload.mime_type) + record_file_security_event( + db, + actor_role="CLIENT", + actor_subject=actor_subject, + actor_ip=actor_ip, + action="UPLOAD_INIT", + scope=scope_name, + allowed=True, + object_key=key, + request_id=request.id, + details={"mime_type": payload.mime_type, "size_bytes": int(payload.size_bytes or 0)}, + responsible="Клиент", + persist_now=True, + ) + return UploadInitResponse(key=key, presigned_url=presigned_url) + except HTTPException as exc: + record_file_security_event( + db, + actor_role="CLIENT", + actor_subject=actor_subject, + actor_ip=actor_ip, + action="UPLOAD_INIT", + scope=scope_name, + allowed=False, + reason=str(exc.detail), + object_key=None, + request_id=payload.request_id, + details={"mime_type": payload.mime_type, "size_bytes": int(payload.size_bytes or 0)}, + responsible="Клиент", + persist_now=True, + ) + raise @router.post("/complete", response_model=UploadCompleteResponse) -def upload_complete(payload: UploadCompletePayload, db: Session = Depends(get_db), session: dict = Depends(get_public_session)): - if payload.scope != UploadScope.REQUEST_ATTACHMENT: - raise HTTPException(status_code=400, detail="Публичная загрузка поддерживает только REQUEST_ATTACHMENT") - request_uuid = _uuid_or_400(payload.request_id, "request_id") - request = db.get(Request, request_uuid) - if request is None: - raise HTTPException(status_code=404, detail="Заявка не найдена") - _ensure_public_request_access_or_403(request, session) - _ensure_object_key_prefix_or_400(payload.key, f"requests/{request.id}/") - - storage = get_s3_storage() +def upload_complete( + payload: UploadCompletePayload, + http_request: FastapiRequest, + db: Session = Depends(get_db), + session: dict = Depends(get_public_session), +): + actor_subject = str(session.get("sub") or "").strip() + actor_ip = _client_ip(http_request) + scope_name = str(payload.scope.value if hasattr(payload.scope, "value") else payload.scope) try: - head = storage.head_object(payload.key) - except ClientError: - raise HTTPException(status_code=400, detail="Файл не найден в хранилище") + if payload.scope != UploadScope.REQUEST_ATTACHMENT: + raise HTTPException(status_code=400, detail="Публичная загрузка поддерживает только REQUEST_ATTACHMENT") + request_uuid = _uuid_or_400(payload.request_id, "request_id") + request = db.get(Request, request_uuid) + if request is None: + raise HTTPException(status_code=404, detail="Заявка не найдена") + _ensure_public_request_access_or_403(request, session) + _ensure_object_key_prefix_or_400(payload.key, f"requests/{request.id}/") - actual_size = int(head.get("ContentLength") or payload.size_bytes or 0) - if actual_size <= 0: - raise HTTPException(status_code=400, detail="Некорректный размер файла") - if actual_size > _max_file_bytes(): - raise HTTPException(status_code=400, detail=f"Превышен лимит файла ({settings.MAX_FILE_MB} МБ)") - if int(request.total_attachments_bytes or 0) + actual_size > _max_case_bytes(): - raise HTTPException(status_code=400, detail=f"Превышен лимит вложений заявки ({settings.MAX_CASE_MB} МБ)") + storage = get_s3_storage() + try: + head = storage.head_object(payload.key) + except ClientError: + raise HTTPException(status_code=400, detail="Файл не найден в хранилище") - row = Attachment( - request_id=request.id, - message_id=None, - file_name=payload.file_name, - mime_type=payload.mime_type, - size_bytes=actual_size, - s3_key=payload.key, - responsible="Клиент", - ) - mark_unread_for_lawyer(request, EVENT_ATTACHMENT) - notify_request_event( - db, - request=request, - event_type=NOTIFICATION_EVENT_ATTACHMENT, - actor_role="CLIENT", - body=f'Файл: {payload.file_name}', - responsible="Клиент", - ) - request.total_attachments_bytes = int(request.total_attachments_bytes or 0) + actual_size - request.responsible = "Клиент" - db.add(row) - db.add(request) - db.commit() - db.refresh(row) - return UploadCompleteResponse(status="ok", attachment_id=str(row.id)) + actual_size = int(head.get("ContentLength") or payload.size_bytes or 0) + if actual_size <= 0: + raise HTTPException(status_code=400, detail="Некорректный размер файла") + if actual_size > _max_file_bytes(): + raise HTTPException(status_code=400, detail=f"Превышен лимит файла ({settings.MAX_FILE_MB} МБ)") + if int(request.total_attachments_bytes or 0) + actual_size > _max_case_bytes(): + raise HTTPException(status_code=400, detail=f"Превышен лимит вложений заявки ({settings.MAX_CASE_MB} МБ)") + + row = Attachment( + request_id=request.id, + message_id=None, + file_name=payload.file_name, + mime_type=payload.mime_type, + size_bytes=actual_size, + s3_key=payload.key, + responsible="Клиент", + ) + mark_unread_for_lawyer(request, EVENT_ATTACHMENT) + notify_request_event( + db, + request=request, + event_type=NOTIFICATION_EVENT_ATTACHMENT, + actor_role="CLIENT", + body=f'Файл: {payload.file_name}', + responsible="Клиент", + ) + request.total_attachments_bytes = int(request.total_attachments_bytes or 0) + actual_size + request.responsible = "Клиент" + db.add(row) + db.add(request) + record_file_security_event( + db, + actor_role="CLIENT", + actor_subject=actor_subject, + actor_ip=actor_ip, + action="UPLOAD_COMPLETE", + scope=scope_name, + allowed=True, + object_key=payload.key, + request_id=request.id, + details={"mime_type": payload.mime_type, "size_bytes": int(actual_size)}, + responsible="Клиент", + ) + db.commit() + db.refresh(row) + return UploadCompleteResponse(status="ok", attachment_id=str(row.id)) + except HTTPException as exc: + record_file_security_event( + db, + actor_role="CLIENT", + actor_subject=actor_subject, + actor_ip=actor_ip, + action="UPLOAD_COMPLETE", + scope=scope_name, + allowed=False, + reason=str(exc.detail), + object_key=payload.key, + request_id=payload.request_id, + details={"mime_type": payload.mime_type, "size_bytes": int(payload.size_bytes or 0)}, + responsible="Клиент", + persist_now=True, + ) + raise @router.get("/object/{attachment_id}") def get_public_attachment_object( attachment_id: str, + http_request: FastapiRequest, db: Session = Depends(get_db), session: dict = Depends(get_public_session), ): - attachment = _load_attachment_with_access_or_4xx(attachment_id, db, session) + actor_subject = str(session.get("sub") or "").strip() + actor_ip = _client_ip(http_request) + attachment_uuid = _uuid_or_400(attachment_id, "attachment_id") + request_id = None + key = None try: - obj = get_s3_storage().get_object(attachment.s3_key) - except ClientError: - raise HTTPException(status_code=404, detail="Файл не найден в хранилище") + attachment = _load_attachment_with_access_or_4xx(attachment_id, db, session) + key = attachment.s3_key + request_id = attachment.request_id + try: + obj = get_s3_storage().get_object(attachment.s3_key) + except ClientError: + raise HTTPException(status_code=404, detail="Файл не найден в хранилище") - body = obj["Body"] - content_length = obj.get("ContentLength") - media_type = obj.get("ContentType") or attachment.mime_type or "application/octet-stream" - encoded_name = quote(str(attachment.file_name or "file"), safe="") - headers = { - "Content-Disposition": f"inline; filename*=UTF-8''{encoded_name}", - } - if content_length is not None: - headers["Content-Length"] = str(content_length) - return StreamingResponse(body.iter_chunks(chunk_size=64 * 1024), media_type=media_type, headers=headers) + record_file_security_event( + db, + actor_role="CLIENT", + actor_subject=actor_subject, + actor_ip=actor_ip, + action="DOWNLOAD_OBJECT", + scope="REQUEST_ATTACHMENT", + allowed=True, + object_key=key, + request_id=request_id, + attachment_id=attachment.id, + details={}, + responsible="Клиент", + persist_now=True, + ) + + body = obj["Body"] + content_length = obj.get("ContentLength") + media_type = obj.get("ContentType") or attachment.mime_type or "application/octet-stream" + encoded_name = quote(str(attachment.file_name or "file"), safe="") + headers = { + "Content-Disposition": f"inline; filename*=UTF-8''{encoded_name}", + } + if content_length is not None: + headers["Content-Length"] = str(content_length) + return StreamingResponse(body.iter_chunks(chunk_size=64 * 1024), media_type=media_type, headers=headers) + except HTTPException as exc: + record_file_security_event( + db, + actor_role="CLIENT", + actor_subject=actor_subject, + actor_ip=actor_ip, + action="DOWNLOAD_OBJECT", + scope="REQUEST_ATTACHMENT", + allowed=False, + reason=str(exc.detail), + object_key=key, + request_id=request_id, + attachment_id=attachment_uuid, + details={}, + responsible="Клиент", + persist_now=True, + ) + raise diff --git a/app/core/config.py b/app/core/config.py index 4029e81..07419c9 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -29,6 +29,9 @@ class Settings(BaseSettings): TELEGRAM_CHAT_ID: str = "0" SMS_PROVIDER: str = "dummy" DATA_ENCRYPTION_SECRET: str = "change_me_data_encryption" + OTP_RATE_LIMIT_WINDOW_SECONDS: int = 300 + OTP_SEND_RATE_LIMIT: int = 8 + OTP_VERIFY_RATE_LIMIT: int = 20 @property def cors_origins_list(self) -> List[str]: diff --git a/app/core/http_hardening.py b/app/core/http_hardening.py new file mode 100644 index 0000000..ad916cb --- /dev/null +++ b/app/core/http_hardening.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import logging +import re +from time import perf_counter +from uuid import uuid4 + +from fastapi import FastAPI, Request + +REQUEST_ID_HEADER = "X-Request-ID" +_REQUEST_ID_RE = re.compile(r"^[A-Za-z0-9._-]{1,128}$") +_LOG = logging.getLogger("app.http") + +SECURITY_HEADERS = { + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "Referrer-Policy": "no-referrer", + "X-Permitted-Cross-Domain-Policies": "none", + "Cross-Origin-Opener-Policy": "same-origin", +} + + +def _request_id_from_header(raw: str | None) -> str: + value = str(raw or "").strip() + if not value: + return uuid4().hex + if not _REQUEST_ID_RE.fullmatch(value): + return uuid4().hex + return value + + +def install_http_hardening(app: FastAPI) -> None: + @app.middleware("http") + async def _http_hardening_middleware(request: Request, call_next): + request_id = _request_id_from_header(request.headers.get(REQUEST_ID_HEADER)) + request.state.request_id = request_id + started_at = perf_counter() + + response = await call_next(request) + + for key, value in SECURITY_HEADERS.items(): + response.headers[key] = value + response.headers[REQUEST_ID_HEADER] = request_id + + duration_ms = (perf_counter() - started_at) * 1000.0 + _LOG.info( + "%s %s status=%s duration_ms=%.2f request_id=%s", + request.method, + request.url.path, + response.status_code, + duration_ms, + request_id, + ) + return response diff --git a/app/main.py b/app/main.py index d0d258a..16d671b 100644 --- a/app/main.py +++ b/app/main.py @@ -3,6 +3,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse from app.core.config import settings +from app.core.http_hardening import install_http_hardening from app.api.public.router import router as public_router from app.api.admin.router import router as admin_router @@ -17,6 +18,7 @@ app.add_middleware( allow_methods=["*"], allow_headers=["*"], ) +install_http_hardening(app) app.include_router(public_router, prefix="/api/public") app.include_router(admin_router, prefix="/api/admin") diff --git a/app/models/invoice.py b/app/models/invoice.py new file mode 100644 index 0000000..dcdf521 --- /dev/null +++ b/app/models/invoice.py @@ -0,0 +1,25 @@ +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, Numeric, String, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.session import Base +from app.models.common import TimestampMixin, UUIDMixin + + +class Invoice(Base, UUIDMixin, TimestampMixin): + __tablename__ = "invoices" + + request_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) + invoice_number: Mapped[str] = mapped_column(String(40), unique=True, nullable=False, index=True) + status: Mapped[str] = mapped_column(String(20), nullable=False, index=True, default="WAITING_PAYMENT") + amount: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False) + currency: Mapped[str] = mapped_column(String(3), nullable=False, default="RUB") + payer_display_name: Mapped[str] = mapped_column(String(300), nullable=False) + payer_details_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True) + issued_by_admin_user_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), index=True, nullable=True) + issued_by_role: Mapped[str | None] = mapped_column(String(20), nullable=True) + issued_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + paid_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/app/models/security_audit_log.py b/app/models/security_audit_log.py new file mode 100644 index 0000000..1235313 --- /dev/null +++ b/app/models/security_audit_log.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import uuid + +from sqlalchemy import Boolean, JSON, String +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.session import Base +from app.models.common import TimestampMixin, UUIDMixin + + +class SecurityAuditLog(Base, UUIDMixin, TimestampMixin): + __tablename__ = "security_audit_log" + + actor_role: Mapped[str] = mapped_column(String(30), nullable=False) + actor_subject: Mapped[str] = mapped_column(String(200), nullable=False, default="") + actor_ip: Mapped[str | None] = mapped_column(String(64), nullable=True) + + action: Mapped[str] = mapped_column(String(50), nullable=False) + scope: Mapped[str] = mapped_column(String(50), nullable=False) + object_key: Mapped[str | None] = mapped_column(String(500), nullable=True) + + request_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True) + attachment_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True) + + allowed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + reason: Mapped[str | None] = mapped_column(String(400), nullable=True) + details: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict) diff --git a/app/models/status.py b/app/models/status.py index eeba0a1..5a54324 100644 --- a/app/models/status.py +++ b/app/models/status.py @@ -1,4 +1,4 @@ -from sqlalchemy import String, Boolean, Integer +from sqlalchemy import String, Boolean, Integer, Text from sqlalchemy.orm import Mapped, mapped_column from app.db.session import Base from app.models.common import UUIDMixin, TimestampMixin @@ -10,3 +10,5 @@ class Status(Base, UUIDMixin, TimestampMixin): enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False) is_terminal: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + kind: Mapped[str] = mapped_column(String(20), default="DEFAULT", nullable=False) + invoice_template: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/app/schemas/admin.py b/app/schemas/admin.py index b18b7c5..5fd6c29 100644 --- a/app/schemas/admin.py +++ b/app/schemas/admin.py @@ -1,6 +1,6 @@ from datetime import datetime -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator from typing import Optional class AdminLogin(BaseModel): @@ -32,6 +32,22 @@ class StatusUpsert(BaseModel): enabled: bool = True sort_order: int = 0 is_terminal: bool = False + kind: str = "DEFAULT" + invoice_template: Optional[str] = None + + @field_validator("kind") + @classmethod + def validate_kind(cls, value: str) -> str: + normalized = str(value or "DEFAULT").strip().upper() + if normalized not in {"DEFAULT", "INVOICE", "PAID"}: + raise ValueError('kind должен быть одним из: DEFAULT, INVOICE, PAID') + return normalized + + @field_validator("invoice_template") + @classmethod + def normalize_template(cls, value: Optional[str]) -> Optional[str]: + text = str(value or "").strip() + return text or None class FormFieldUpsert(BaseModel): diff --git a/app/services/billing_flow.py b/app/services/billing_flow.py new file mode 100644 index 0000000..7bfeaf1 --- /dev/null +++ b/app/services/billing_flow.py @@ -0,0 +1,288 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from decimal import Decimal +from string import Formatter +from typing import Any +from uuid import UUID, uuid4 + +from fastapi import HTTPException +from sqlalchemy import inspect +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session + +from app.models.invoice import Invoice +from app.models.request import Request +from app.models.status import Status +from app.services.invoice_crypto import encrypt_requisites + +STATUS_KIND_DEFAULT = "DEFAULT" +STATUS_KIND_INVOICE = "INVOICE" +STATUS_KIND_PAID = "PAID" +ALLOWED_STATUS_KINDS = {STATUS_KIND_DEFAULT, STATUS_KIND_INVOICE, STATUS_KIND_PAID} + +INVOICE_STATUS_WAITING = "WAITING_PAYMENT" +INVOICE_STATUS_PAID = "PAID" + +FALLBACK_INVOICE_CODES = {"INVOICE", "BILLING", "WAITING_PAYMENT"} +FALLBACK_PAID_CODES = {"PAID", "ОПЛАЧЕНО"} + +DEFAULT_INVOICE_TEMPLATE = ( + "Счет по заявке {track_number}. " + "Клиент: {client_name}. " + "Тема: {topic_code}. " + "Сумма: {amount} RUB." +) + + +def _now_utc() -> datetime: + return datetime.now(timezone.utc) + + +def _to_float(value: Any) -> float | None: + if value is None: + return None + if isinstance(value, Decimal): + return float(value) + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _actor_uuid_or_none(admin: dict[str, Any] | None) -> UUID | None: + if not admin: + return None + try: + return UUID(str(admin.get("sub") or "")) + except ValueError: + return None + + +def _normalize_kind(raw: str | None) -> str: + value = str(raw or STATUS_KIND_DEFAULT).strip().upper() + if value not in ALLOWED_STATUS_KINDS: + return STATUS_KIND_DEFAULT + return value + + +def normalize_status_kind_or_400(raw: str | None) -> str: + value = str(raw or STATUS_KIND_DEFAULT).strip().upper() + if value not in ALLOWED_STATUS_KINDS: + raise HTTPException(status_code=400, detail='Поле "kind" должно быть одним из: DEFAULT, INVOICE, PAID') + return value + + +def _table_exists(db: Session, table_name: str) -> bool: + try: + bind = db.get_bind() + if bind is None: + return False + return table_name in set(inspect(bind).get_table_names()) + except SQLAlchemyError: + return False + + +def _status_kind(db: Session, status_code: str) -> str: + code = str(status_code or "").strip() + if not code: + return STATUS_KIND_DEFAULT + row = db.query(Status.kind).filter(Status.code == code).first() + if row and row[0]: + return _normalize_kind(row[0]) + upper = code.upper() + if upper in FALLBACK_INVOICE_CODES: + return STATUS_KIND_INVOICE + if upper in FALLBACK_PAID_CODES: + return STATUS_KIND_PAID + return STATUS_KIND_DEFAULT + + +def _status_template(db: Session, status_code: str) -> str | None: + code = str(status_code or "").strip() + if not code: + return None + row = db.query(Status.invoice_template).filter(Status.code == code).first() + if row is None: + return None + value = str(row[0] or "").strip() + return value or None + + +def _invoice_number(db: Session) -> str: + prefix = _now_utc().strftime("%Y%m%d") + candidate = f"INV-{prefix}-{uuid4().hex[:8].upper()}" + exists = db.query(Invoice.id).filter(Invoice.invoice_number == candidate).first() + if exists is None: + return candidate + return f"INV-{prefix}-{uuid4().hex[:12].upper()}" + + +def _safe_render_template(template: str, values: dict[str, Any]) -> str: + source = str(template or "").strip() or DEFAULT_INVOICE_TEMPLATE + allowed = { + "request_id", + "track_number", + "client_name", + "client_phone", + "topic_code", + "from_status", + "to_status", + "effective_rate", + "invoice_amount", + "amount", + } + formatter = Formatter() + out = source + for _, field_name, _, _ in formatter.parse(source): + if not field_name: + continue + if field_name not in allowed: + raise HTTPException(status_code=400, detail=f'Шаблон счета содержит недопустимый placeholder: "{field_name}"') + try: + out = source.format_map({key: values.get(key) for key in allowed}) + except Exception as exc: + raise HTTPException(status_code=400, detail=f"Ошибка рендера шаблона счета: {exc}") + return out + + +def _create_waiting_invoice( + db: Session, + *, + req: Request, + to_status: str, + from_status: str, + admin: dict[str, Any] | None, + responsible: str, +) -> str: + waiting = ( + db.query(Invoice) + .filter(Invoice.request_id == req.id, Invoice.status == INVOICE_STATUS_WAITING) + .order_by(Invoice.issued_at.desc(), Invoice.created_at.desc(), Invoice.id.desc()) + .first() + ) + if waiting is not None: + return waiting.invoice_number + + base_amount = _to_float(req.invoice_amount) + if base_amount is None or base_amount <= 0: + base_amount = _to_float(req.effective_rate) + amount = round(float(base_amount or 0.0), 2) + + template = _status_template(db, to_status) or DEFAULT_INVOICE_TEMPLATE + rendered_template = _safe_render_template( + template, + { + "request_id": str(req.id), + "track_number": req.track_number, + "client_name": req.client_name, + "client_phone": req.client_phone, + "topic_code": req.topic_code, + "from_status": from_status, + "to_status": to_status, + "effective_rate": _to_float(req.effective_rate), + "invoice_amount": _to_float(req.invoice_amount), + "amount": amount, + }, + ) + + actor = _actor_uuid_or_none(admin) + role = str((admin or {}).get("role") or "").strip().upper() or None + invoice = Invoice( + request_id=req.id, + invoice_number=_invoice_number(db), + status=INVOICE_STATUS_WAITING, + amount=amount, + currency="RUB", + payer_display_name=str(req.client_name or "").strip() or "Клиент", + payer_details_encrypted=encrypt_requisites( + { + "template_rendered": rendered_template, + "request_track_number": req.track_number, + "topic_code": req.topic_code, + } + ), + issued_by_admin_user_id=actor, + issued_by_role=role, + issued_at=_now_utc(), + paid_at=None, + responsible=responsible, + ) + db.add(invoice) + if req.invoice_amount is None: + req.invoice_amount = amount + req.responsible = responsible + db.add(req) + return invoice.invoice_number + + +def _mark_waiting_invoice_paid_or_400( + db: Session, + *, + req: Request, + admin: dict[str, Any] | None, + responsible: str, +) -> tuple[str, float]: + actor = _actor_uuid_or_none(admin) + role = str((admin or {}).get("role") or "").strip().upper() + if role != "ADMIN": + raise HTTPException(status_code=403, detail='Статус "Оплачено" может поставить только администратор') + + waiting = ( + db.query(Invoice) + .filter(Invoice.request_id == req.id, Invoice.status == INVOICE_STATUS_WAITING) + .order_by(Invoice.issued_at.desc(), Invoice.created_at.desc(), Invoice.id.desc()) + .first() + ) + if waiting is None: + raise HTTPException(status_code=400, detail='Для перехода в статус "Оплачено" нужен счет в статусе "Ожидает оплату"') + + waiting.status = INVOICE_STATUS_PAID + waiting.paid_at = _now_utc() + waiting.responsible = responsible + db.add(waiting) + + req.invoice_amount = waiting.amount + req.paid_at = waiting.paid_at + req.paid_by_admin_id = str(actor) if actor else None + req.responsible = responsible + db.add(req) + return waiting.invoice_number, round(float(_to_float(waiting.amount) or 0.0), 2) + + +def apply_billing_transition_effects( + db: Session, + *, + req: Request, + from_status: str, + to_status: str, + admin: dict[str, Any] | None, + responsible: str, +) -> str | None: + if not _table_exists(db, "invoices"): + return None + + from_kind = _status_kind(db, from_status) + to_kind = _status_kind(db, to_status) + + if to_kind == STATUS_KIND_INVOICE and from_kind != STATUS_KIND_INVOICE: + number = _create_waiting_invoice( + db, + req=req, + to_status=to_status, + from_status=from_status, + admin=admin, + responsible=responsible, + ) + return f"Выставлен счет {number}" + + if to_kind == STATUS_KIND_PAID: + number, amount = _mark_waiting_invoice_paid_or_400( + db, + req=req, + admin=admin, + responsible=responsible, + ) + return f"Оплачен счет {number} на сумму {amount:.2f}" + + return None diff --git a/app/services/invoice_crypto.py b/app/services/invoice_crypto.py new file mode 100644 index 0000000..803dbe6 --- /dev/null +++ b/app/services/invoice_crypto.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import secrets +from typing import Any + +from app.core.config import settings + +_VERSION = b"v1" + + +def _key() -> bytes: + secret = str(settings.DATA_ENCRYPTION_SECRET or "").strip() + if not secret or secret == "change_me_data_encryption": + secret = str(settings.ADMIN_JWT_SECRET or "change_me_admin") + return hashlib.sha256(secret.encode("utf-8")).digest() + + +def _xor_bytes(a: bytes, b: bytes) -> bytes: + return bytes(x ^ y for x, y in zip(a, b)) + + +def encrypt_requisites(data: dict[str, Any] | None) -> str: + payload = dict(data or {}) + raw = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + nonce = secrets.token_bytes(16) + stream = hashlib.pbkdf2_hmac("sha256", _key(), nonce, 120_000, dklen=len(raw)) + cipher = _xor_bytes(raw, stream) + tag = hmac.new(_key(), _VERSION + nonce + cipher, hashlib.sha256).digest() + token = _VERSION + nonce + tag + cipher + return base64.urlsafe_b64encode(token).decode("ascii") + + +def decrypt_requisites(token: str | None) -> dict[str, Any]: + encoded = str(token or "").strip() + if not encoded: + return {} + blob = base64.urlsafe_b64decode(encoded.encode("ascii")) + if len(blob) < 2 + 16 + 32: + raise ValueError("Некорректные зашифрованные реквизиты") + version = blob[:2] + nonce = blob[2:18] + tag = blob[18:50] + cipher = blob[50:] + if version != _VERSION: + raise ValueError("Неподдерживаемая версия шифрования") + expected = hmac.new(_key(), version + nonce + cipher, hashlib.sha256).digest() + if not hmac.compare_digest(tag, expected): + raise ValueError("Поврежденные зашифрованные реквизиты") + stream = hashlib.pbkdf2_hmac("sha256", _key(), nonce, 120_000, dklen=len(cipher)) + raw = _xor_bytes(cipher, stream) + data = json.loads(raw.decode("utf-8")) + return data if isinstance(data, dict) else {} diff --git a/app/services/invoice_pdf.py b/app/services/invoice_pdf.py new file mode 100644 index 0000000..f01fe03 --- /dev/null +++ b/app/services/invoice_pdf.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any +import unicodedata + + +def _ascii_text(value: Any) -> str: + text = str(value or "") + normalized = unicodedata.normalize("NFKD", text) + return normalized.encode("ascii", "ignore").decode("ascii") + + +def _escape_pdf_text(value: str) -> str: + return value.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)") + + +def _build_content_stream(lines: list[str]) -> bytes: + safe_lines = [_escape_pdf_text(_ascii_text(line)) for line in lines] + if not safe_lines: + safe_lines = ["Invoice"] + parts = ["BT", "/F1 11 Tf", "50 800 Td"] + for index, line in enumerate(safe_lines): + if index == 0: + parts.append(f"({line}) Tj") + else: + parts.append("T*") + parts.append(f"({line}) Tj") + parts.append("ET") + return "\n".join(parts).encode("latin-1", errors="ignore") + + +def build_invoice_pdf_bytes( + *, + invoice_number: str, + amount: float, + currency: str, + status: str, + issued_at: datetime | None, + paid_at: datetime | None, + payer_display_name: str, + request_track_number: str, + issued_by_name: str | None, + requisites: dict[str, Any] | None, +) -> bytes: + lines = [ + f"Invoice: {invoice_number}", + f"Request: {request_track_number}", + f"Payer: {payer_display_name}", + f"Amount: {amount:.2f} {currency}", + f"Status: {status}", + f"Issued at: {issued_at.isoformat() if issued_at else '-'}", + f"Paid at: {paid_at.isoformat() if paid_at else '-'}", + f"Issued by: {issued_by_name or '-'}", + "Requisites:", + ] + req = dict(requisites or {}) + if req: + for key in sorted(req.keys()): + lines.append(f"{key}: {req.get(key)}") + else: + lines.append("-") + + stream = _build_content_stream(lines) + objects = [ + b"1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n", + b"2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n", + b"3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >> endobj\n", + b"4 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj\n", + f"5 0 obj << /Length {len(stream)} >> stream\n".encode("latin-1") + stream + b"\nendstream endobj\n", + ] + + body = b"%PDF-1.4\n" + offsets = [0] + for obj in objects: + offsets.append(len(body)) + body += obj + xref_offset = len(body) + body += f"xref\n0 {len(objects)+1}\n".encode("latin-1") + body += b"0000000000 65535 f \n" + for offset in offsets[1:]: + body += f"{offset:010d} 00000 n \n".encode("latin-1") + body += f"trailer << /Size {len(objects)+1} /Root 1 0 R >>\nstartxref\n{xref_offset}\n%%EOF\n".encode("latin-1") + return body diff --git a/app/services/rate_limit.py b/app/services/rate_limit.py new file mode 100644 index 0000000..a051e6d --- /dev/null +++ b/app/services/rate_limit.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import logging +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from threading import Lock +from typing import Protocol + +import redis + +from app.core.config import settings + +_LOG = logging.getLogger("app.rate_limit") + + +@dataclass +class RateLimitResult: + allowed: bool + retry_after_seconds: int + current_value: int + + +class RateLimiter(Protocol): + def hit(self, key: str, *, limit: int, window_seconds: int) -> RateLimitResult: + ... + + +class InMemoryRateLimiter: + def __init__(self): + self._data: dict[str, tuple[int, datetime]] = {} + self._lock = Lock() + + def hit(self, key: str, *, limit: int, window_seconds: int) -> RateLimitResult: + now = datetime.now(timezone.utc) + with self._lock: + count, expires_at = self._data.get(key, (0, now)) + if expires_at <= now: + count = 0 + expires_at = now + timedelta(seconds=max(int(window_seconds), 1)) + count += 1 + self._data[key] = (count, expires_at) + retry_after = max(0, int((expires_at - now).total_seconds())) + return RateLimitResult(allowed=count <= limit, retry_after_seconds=retry_after, current_value=count) + + +class RedisRateLimiter: + def __init__(self, client: redis.Redis): + self.client = client + + def hit(self, key: str, *, limit: int, window_seconds: int) -> RateLimitResult: + count = int(self.client.incr(key)) + if count == 1: + self.client.expire(key, int(max(window_seconds, 1))) + ttl = int(self.client.ttl(key)) + if ttl < 0: + ttl = int(max(window_seconds, 1)) + return RateLimitResult(allowed=count <= limit, retry_after_seconds=ttl, current_value=count) + + +_cached_limiter: RateLimiter | None = None + + +def _build_limiter() -> RateLimiter: + try: + client = redis.Redis.from_url( + settings.REDIS_URL, + decode_responses=True, + socket_timeout=0.4, + socket_connect_timeout=0.4, + ) + client.ping() + return RedisRateLimiter(client) + except Exception: + _LOG.warning("Redis limiter unavailable; fallback to in-memory limiter") + return InMemoryRateLimiter() + + +def get_rate_limiter() -> RateLimiter: + global _cached_limiter + if _cached_limiter is None: + _cached_limiter = _build_limiter() + return _cached_limiter + + +def reset_rate_limiter_for_tests() -> None: + global _cached_limiter + _cached_limiter = None diff --git a/app/services/security_audit.py b/app/services/security_audit.py new file mode 100644 index 0000000..f6706d0 --- /dev/null +++ b/app/services/security_audit.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import logging +import uuid +from datetime import timedelta +from typing import Any + +from sqlalchemy import func, inspect +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session + +from app.models.security_audit_log import SecurityAuditLog +from app.models.common import utcnow + +logger = logging.getLogger(__name__) + +SUSPICIOUS_DENY_WINDOW_MINUTES = 10 +SUSPICIOUS_DENY_THRESHOLD = 5 + + +def _uuid_or_none(raw: str | uuid.UUID | None) -> uuid.UUID | None: + if raw is None: + return None + if isinstance(raw, uuid.UUID): + return raw + try: + return uuid.UUID(str(raw)) + except (TypeError, ValueError): + return None + + +def _safe_details(details: dict[str, Any] | None) -> dict[str, Any]: + if not isinstance(details, dict): + return {} + safe: dict[str, Any] = {} + for key, value in details.items(): + if value is None or isinstance(value, (str, int, float, bool)): + safe[str(key)] = value + else: + safe[str(key)] = str(value) + return safe + + +def _emit_suspicious_denied_download_alert( + db: Session, + *, + actor_role: str, + actor_subject: str, + actor_ip: str | None, +) -> None: + if not actor_subject and not actor_ip: + return + since = utcnow() - timedelta(minutes=SUSPICIOUS_DENY_WINDOW_MINUTES) + query = db.query(func.count(SecurityAuditLog.id)).filter( + SecurityAuditLog.created_at >= since, + SecurityAuditLog.action == "DOWNLOAD_OBJECT", + SecurityAuditLog.allowed.is_(False), + ) + if actor_subject: + query = query.filter(SecurityAuditLog.actor_subject == actor_subject) + elif actor_ip: + query = query.filter(SecurityAuditLog.actor_ip == actor_ip) + denied_count = int(query.scalar() or 0) + if denied_count >= SUSPICIOUS_DENY_THRESHOLD: + logger.warning( + "SECURITY_ALERT repeated denied download attempts role=%s subject=%s ip=%s count=%s window_min=%s", + actor_role, + actor_subject or "-", + actor_ip or "-", + denied_count, + SUSPICIOUS_DENY_WINDOW_MINUTES, + ) + + +def record_file_security_event( + db: Session, + *, + actor_role: str, + actor_subject: str, + actor_ip: str | None, + action: str, + scope: str, + allowed: bool, + reason: str | None = None, + object_key: str | None = None, + request_id: str | uuid.UUID | None = None, + attachment_id: str | uuid.UUID | None = None, + details: dict[str, Any] | None = None, + responsible: str | None = None, + persist_now: bool = False, +) -> None: + # Security telemetry must not block business flow if DB log write fails. + try: + bind = db.get_bind() + if bind is None or not inspect(bind).has_table("security_audit_log"): + return + row = SecurityAuditLog( + actor_role=str(actor_role or "UNKNOWN").upper(), + actor_subject=str(actor_subject or "").strip(), + actor_ip=str(actor_ip or "").strip() or None, + action=str(action or "").strip().upper() or "UNKNOWN", + scope=str(scope or "").strip().upper() or "UNKNOWN", + object_key=str(object_key or "").strip() or None, + request_id=_uuid_or_none(request_id), + attachment_id=_uuid_or_none(attachment_id), + allowed=bool(allowed), + reason=(str(reason)[:400] if reason is not None else None), + details=_safe_details(details), + responsible=str(responsible or "Администратор системы").strip() or "Администратор системы", + ) + db.add(row) + db.flush() + + if not bool(allowed) and str(action or "").upper() == "DOWNLOAD_OBJECT": + _emit_suspicious_denied_download_alert( + db, + actor_role=row.actor_role, + actor_subject=row.actor_subject, + actor_ip=row.actor_ip, + ) + + if persist_now: + db.commit() + except SQLAlchemyError: + if persist_now: + try: + db.rollback() + except Exception: + pass diff --git a/app/web/admin.jsx b/app/web/admin.jsx index b9b8d78..a11fd08 100644 --- a/app/web/admin.jsx +++ b/app/web/admin.jsx @@ -29,6 +29,16 @@ CLOSED: "Закрыта", REJECTED: "Отклонена", }; + const INVOICE_STATUS_LABELS = { + WAITING_PAYMENT: "Ожидает оплату", + PAID: "Оплачен", + CANCELED: "Отменен", + }; + const STATUS_KIND_LABELS = { + DEFAULT: "Обычный", + INVOICE: "Выставление счета", + PAID: "Оплачено", + }; const REQUEST_UPDATE_EVENT_LABELS = { MESSAGE: "сообщение", @@ -42,6 +52,11 @@ endpoint: "/api/admin/crud/requests/query", sort: [{ field: "created_at", dir: "desc" }], }, + invoices: { + table: "invoices", + endpoint: "/api/admin/invoices/query", + sort: [{ field: "issued_at", dir: "desc" }], + }, quotes: { table: "quotes", endpoint: "/api/admin/crud/quotes/query", @@ -99,6 +114,31 @@ }, ]) ); + TABLE_MUTATION_CONFIG.invoices = { + create: "/api/admin/invoices", + update: (id) => "/api/admin/invoices/" + id, + delete: (id) => "/api/admin/invoices/" + id, + }; + const TABLE_KEY_ALIASES = { + form_fields: "formFields", + topic_required_fields: "topicRequiredFields", + topic_data_templates: "topicDataTemplates", + topic_status_transitions: "statusTransitions", + admin_users: "users", + admin_user_topics: "userTopics", + }; + const TABLE_UNALIASES = Object.fromEntries(Object.entries(TABLE_KEY_ALIASES).map(([table, alias]) => [alias, table])); + const KNOWN_CONFIG_TABLE_KEYS = new Set([ + "quotes", + "topics", + "statuses", + "formFields", + "topicRequiredFields", + "topicDataTemplates", + "statusTransitions", + "users", + "userTopics", + ]); function createTableState() { return { @@ -111,6 +151,29 @@ }; } + function humanizeKey(value) { + const text = String(value || "") + .replace(/[_-]+/g, " ") + .replace(/\s+/g, " ") + .trim(); + if (!text) return "-"; + return text.charAt(0).toUpperCase() + text.slice(1); + } + + function metaKindToFilterType(kind) { + if (kind === "boolean") return "boolean"; + if (kind === "number") return "number"; + if (kind === "date" || kind === "datetime") return "date"; + return "text"; + } + + function metaKindToRecordType(kind) { + if (kind === "boolean") return "boolean"; + if (kind === "number") return "number"; + if (kind === "json") return "json"; + return "text"; + } + function decodeJwtPayload(token) { try { const payload = token.split(".")[1] || ""; @@ -139,6 +202,14 @@ return STATUS_LABELS[code] || code || "-"; } + function invoiceStatusLabel(code) { + return INVOICE_STATUS_LABELS[code] || code || "-"; + } + + function statusKindLabel(code) { + return STATUS_KIND_LABELS[code] || code || "-"; + } + function boolLabel(value) { return value ? "Да" : "Нет"; } @@ -821,6 +892,7 @@ const [tables, setTables] = useState({ requests: createTableState(), + invoices: createTableState(), quotes: createTableState(), topics: createTableState(), statuses: createTableState(), @@ -831,6 +903,7 @@ users: createTableState(), userTopics: createTableState(), }); + const [tableCatalog, setTableCatalog] = useState([]); const [dictionaries, setDictionaries] = useState({ topics: [], @@ -851,7 +924,7 @@ form: {}, }); - const [configActiveKey, setConfigActiveKey] = useState("quotes"); + const [configActiveKey, setConfigActiveKey] = useState(""); const [referencesExpanded, setReferencesExpanded] = useState(true); const [metaEntity, setMetaEntity] = useState("quotes"); @@ -924,6 +997,14 @@ .map((item) => ({ value: item.code, label: (item.name || statusLabel(item.code)) + " (" + item.code + ")" })); }, [dictionaries.statuses]); + const getInvoiceStatusOptions = useCallback(() => { + return Object.entries(INVOICE_STATUS_LABELS).map(([code, name]) => ({ value: code, label: name + " (" + code + ")" })); + }, []); + + const getStatusKindOptions = useCallback(() => { + return Object.entries(STATUS_KIND_LABELS).map(([code, name]) => ({ value: code, label: name + " (" + code + ")" })); + }, []); + const getTopicOptions = useCallback(() => { return (dictionaries.topics || []) .filter((item) => item && item.code) @@ -953,6 +1034,51 @@ return Object.entries(ROLE_LABELS).map(([code, label]) => ({ value: code, label: label + " (" + code + ")" })); }, []); + const tableCatalogMap = useMemo(() => { + const map = {}; + (tableCatalog || []).forEach((item) => { + if (!item || !item.key) return; + map[item.key] = item; + }); + return map; + }, [tableCatalog]); + + const dictionaryTableItems = useMemo(() => { + return (tableCatalog || []) + .filter((item) => item && item.section === "dictionary" && Array.isArray(item.actions) && item.actions.includes("query")) + .sort((a, b) => String(a.label || a.key).localeCompare(String(b.label || b.key), "ru")); + }, [tableCatalog]); + + const resolveTableConfig = useCallback( + (tableKey) => { + if (TABLE_SERVER_CONFIG[tableKey]) return TABLE_SERVER_CONFIG[tableKey]; + const meta = tableCatalogMap[tableKey]; + if (!meta || !meta.table) return null; + const tableName = String(meta.table || tableKey); + return { + table: tableName, + endpoint: String(meta.query_endpoint || ("/api/admin/crud/" + tableName + "/query")), + sort: Array.isArray(meta.default_sort) && meta.default_sort.length ? meta.default_sort : [{ field: "created_at", dir: "desc" }], + }; + }, + [tableCatalogMap] + ); + + const resolveMutationConfig = useCallback( + (tableKey) => { + if (TABLE_MUTATION_CONFIG[tableKey]) return TABLE_MUTATION_CONFIG[tableKey]; + const meta = tableCatalogMap[tableKey]; + if (!meta || !meta.table) return null; + const tableName = String(meta.table || tableKey); + return { + create: String(meta.create_endpoint || ("/api/admin/crud/" + tableName)), + update: (id) => String(meta.update_endpoint_template || ("/api/admin/crud/" + tableName + "/{id}")).replace("{id}", String(id)), + delete: (id) => String(meta.delete_endpoint_template || ("/api/admin/crud/" + tableName + "/{id}")).replace("{id}", String(id)), + }; + }, + [tableCatalogMap] + ); + const getFilterFields = useCallback( (tableKey) => { if (tableKey === "requests") { @@ -968,6 +1094,20 @@ { field: "created_at", label: "Дата создания", type: "date" }, ]; } + if (tableKey === "invoices") { + return [ + { field: "invoice_number", label: "Номер счета", type: "text" }, + { field: "status", label: "Статус", type: "enum", options: getInvoiceStatusOptions }, + { field: "amount", label: "Сумма", type: "number" }, + { field: "currency", label: "Валюта", type: "text" }, + { field: "payer_display_name", label: "Плательщик", type: "text" }, + { field: "request_id", label: "ID заявки", type: "text" }, + { field: "issued_by_admin_user_id", label: "ID сотрудника", type: "text" }, + { field: "issued_at", label: "Дата формирования", type: "date" }, + { field: "paid_at", label: "Дата оплаты", type: "date" }, + { field: "created_at", label: "Дата создания", type: "date" }, + ]; + } if (tableKey === "quotes") { return [ { field: "author", label: "Автор", type: "text" }, @@ -990,6 +1130,7 @@ return [ { field: "code", label: "Код", type: "text" }, { field: "name", label: "Название", type: "text" }, + { field: "kind", label: "Тип", type: "enum", options: getStatusKindOptions }, { field: "enabled", label: "Активен", type: "boolean" }, { field: "sort_order", label: "Порядок", type: "number" }, { field: "is_terminal", label: "Терминальный", type: "boolean" }, @@ -1056,13 +1197,37 @@ { field: "created_at", label: "Дата создания", type: "date" }, ]; } - return []; + const meta = tableCatalogMap[tableKey]; + if (!meta || !Array.isArray(meta.columns)) return []; + return (meta.columns || []) + .filter((column) => column && column.name && column.filterable !== false) + .map((column) => { + const name = String(column.name); + const label = String(column.label || humanizeKey(name)); + if (name === "topic_code") return { field: name, label, type: "reference", options: getTopicOptions }; + if (name === "status_code" || name === "from_status" || name === "to_status") { + return { field: name, label, type: "reference", options: getStatusOptions }; + } + if (name === "field_key") return { field: name, label, type: "reference", options: getFormFieldKeyOptions }; + return { field: name, label, type: metaKindToFilterType(column.kind) }; + }); }, - [getFormFieldKeyOptions, getFormFieldTypeOptions, getLawyerOptions, getRoleOptions, getStatusOptions, getTopicOptions] + [ + tableCatalogMap, + getFormFieldKeyOptions, + getFormFieldTypeOptions, + getInvoiceStatusOptions, + getLawyerOptions, + getRoleOptions, + getStatusKindOptions, + getStatusOptions, + getTopicOptions, + ] ); const getTableLabel = useCallback((tableKey) => { if (tableKey === "requests") return "Заявки"; + if (tableKey === "invoices") return "Счета"; if (tableKey === "quotes") return "Цитаты"; if (tableKey === "topics") return "Темы"; if (tableKey === "statuses") return "Статусы"; @@ -1072,8 +1237,11 @@ if (tableKey === "statusTransitions") return "Переходы статусов"; if (tableKey === "users") return "Пользователи"; if (tableKey === "userTopics") return "Дополнительные темы юристов"; - return "Таблица"; - }, []); + const meta = tableCatalogMap[tableKey]; + if (meta && meta.label) return String(meta.label); + const raw = TABLE_UNALIASES[tableKey] || tableKey; + return humanizeKey(raw); + }, [tableCatalogMap]); const getRecordFields = useCallback( (tableKey) => { @@ -1094,6 +1262,17 @@ { key: "total_attachments_bytes", label: "Размер вложений (байт)", type: "number", optional: true, defaultValue: "0" }, ]; } + if (tableKey === "invoices") { + return [ + { key: "request_track_number", label: "Номер заявки", type: "text", required: true, createOnly: true }, + { key: "invoice_number", label: "Номер счета", type: "text", optional: true, placeholder: "Оставьте пустым для автогенерации" }, + { key: "status", label: "Статус", type: "enum", required: true, options: getInvoiceStatusOptions, defaultValue: "WAITING_PAYMENT" }, + { key: "amount", label: "Сумма", type: "number", required: true }, + { key: "currency", label: "Валюта", type: "text", optional: true, defaultValue: "RUB" }, + { key: "payer_display_name", label: "Плательщик (ФИО / компания)", type: "text", required: true }, + { key: "payer_details", label: "Реквизиты (JSON, шифруется)", type: "json", optional: true, omitIfEmpty: true, placeholder: "{\"inn\":\"...\"}" }, + ]; + } if (tableKey === "quotes") { return [ { key: "author", label: "Автор", type: "text", required: true }, @@ -1115,6 +1294,8 @@ return [ { key: "code", label: "Код", type: "text", required: true }, { key: "name", label: "Название", type: "text", required: true }, + { key: "kind", label: "Тип", type: "enum", required: true, options: getStatusKindOptions, defaultValue: "DEFAULT" }, + { key: "invoice_template", label: "Шаблон счета", type: "textarea", optional: true, placeholder: "Доступные поля: {track_number}, {client_name}, {topic_code}, {amount}" }, { key: "enabled", label: "Активен", type: "boolean", defaultValue: "true" }, { key: "sort_order", label: "Порядок", type: "number", defaultValue: "0" }, { key: "is_terminal", label: "Терминальный", type: "boolean", defaultValue: "false" }, @@ -1188,9 +1369,33 @@ { key: "topic_code", label: "Дополнительная тема", type: "reference", required: true, options: getTopicOptions }, ]; } - return []; + const meta = tableCatalogMap[tableKey]; + if (!meta || !Array.isArray(meta.columns)) return []; + return (meta.columns || []) + .filter((column) => column && column.name && column.editable) + .map((column) => { + const key = String(column.name || ""); + const requiredOnCreate = Boolean(column.required_on_create); + return { + key, + label: String(column.label || humanizeKey(key)), + type: metaKindToRecordType(column.kind), + requiredOnCreate, + optional: !requiredOnCreate, + }; + }); }, - [getFormFieldKeyOptions, getFormFieldTypeOptions, getLawyerOptions, getRoleOptions, getStatusOptions, getTopicOptions] + [ + tableCatalogMap, + getFormFieldKeyOptions, + getFormFieldTypeOptions, + getInvoiceStatusOptions, + getLawyerOptions, + getRoleOptions, + getStatusKindOptions, + getStatusOptions, + getTopicOptions, + ] ); const getFieldDef = useCallback( @@ -1228,7 +1433,7 @@ const loadTable = useCallback( async (tableKey, options, tokenOverride) => { const opts = options || {}; - const config = TABLE_SERVER_CONFIG[tableKey]; + const config = resolveTableConfig(tableKey); if (!config) return false; const current = tablesRef.current[tableKey] || createTableState(); @@ -1318,7 +1523,7 @@ }); } - if (tableKey === "formFields") { + if (tableKey === "formFields" || tableKey === "form_fields") { setDictionaries((prev) => { const set = new Set(DEFAULT_FORM_FIELD_TYPES); (next.rows || []).forEach((row) => { @@ -1336,7 +1541,7 @@ }); } - if (tableKey === "users") { + if (tableKey === "users" || tableKey === "admin_users") { setDictionaries((prev) => { const map = new Map((prev.users || []).map((user) => [user.id, user])); (next.rows || []).forEach((row) => { @@ -1359,12 +1564,16 @@ return false; } }, - [api, setStatus, setTableState] + [api, resolveTableConfig, setStatus, setTableState] ); const loadCurrentConfigTable = useCallback( async (resetOffset, tokenOverride, keyOverride) => { const currentKey = keyOverride || configActiveKey; + if (!currentKey) { + setStatus("config", "Выберите справочник", ""); + return false; + } setStatus("config", "Загрузка...", ""); const ok = await loadTable(currentKey, { resetOffset: Boolean(resetOffset) }, tokenOverride); if (ok) { @@ -1438,6 +1647,7 @@ if (!(tokenOverride !== undefined ? tokenOverride : token)) return; if (section === "dashboard") return loadDashboard(tokenOverride); if (section === "requests") return loadTable("requests", {}, 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); if (section === "meta") return loadMeta(tokenOverride); @@ -1451,18 +1661,28 @@ ...prev, statuses: Object.entries(STATUS_LABELS).map(([code, name]) => ({ code, name })), })); + setTableCatalog([]); if (roleOverride !== "ADMIN") return; try { const body = buildUniversalQuery([], [{ field: "sort_order", dir: "asc" }], 500, 0); const usersBody = buildUniversalQuery([], [{ field: "created_at", dir: "desc" }], 500, 0); - const [topicsData, statusesData, fieldsData, usersData] = await Promise.all([ + const [catalogData, topicsData, statusesData, fieldsData, usersData] = await Promise.all([ + api("/api/admin/crud/meta/tables", {}, tokenOverride), api("/api/admin/crud/topics/query", { method: "POST", body }, tokenOverride), api("/api/admin/crud/statuses/query", { method: "POST", body }, tokenOverride), api("/api/admin/crud/form_fields/query", { method: "POST", body }, tokenOverride), api("/api/admin/crud/admin_users/query", { method: "POST", body: usersBody }, tokenOverride), ]); + const catalogRows = (catalogData.tables || []) + .filter((row) => row && row.table) + .map((row) => { + const tableName = String(row.table || ""); + const key = TABLE_KEY_ALIASES[tableName] || String(row.key || tableName); + return { ...row, key, table: tableName }; + }); + setTableCatalog(catalogRows); const statusesMap = new Map(Object.entries(STATUS_LABELS).map(([code, name]) => [code, { code, name }])); (statusesData.rows || []).forEach((row) => { @@ -1630,6 +1850,7 @@ if (field.type === "json") { const text = String(raw || "").trim(); if (!text) { + if (field.omitIfEmpty) return; if (field.optional) payload[field.key] = null; else payload[field.key] = {}; return; @@ -1656,6 +1877,7 @@ }); if (tableKey === "requests" && !payload.extra_fields) payload.extra_fields = {}; + if (tableKey === "invoices" && mode === "edit") delete payload.request_track_number; return payload; }, [getRecordFields] @@ -1666,7 +1888,7 @@ event.preventDefault(); const tableKey = recordModal.tableKey; if (!tableKey) return; - const endpoints = TABLE_MUTATION_CONFIG[tableKey]; + const endpoints = resolveMutationConfig(tableKey); if (!endpoints) return; try { setStatus("recordForm", "Сохранение...", ""); @@ -1683,12 +1905,12 @@ setStatus("recordForm", "Ошибка: " + error.message, "error"); } }, - [api, buildRecordPayload, closeRecordModal, loadTable, recordModal, setStatus] + [api, buildRecordPayload, closeRecordModal, loadTable, recordModal, resolveMutationConfig, setStatus] ); const deleteRecord = useCallback( async (tableKey, id) => { - const endpoints = TABLE_MUTATION_CONFIG[tableKey]; + const endpoints = resolveMutationConfig(tableKey); if (!endpoints) return; if (!confirm("Удалить запись?")) return; try { @@ -1699,7 +1921,7 @@ setStatus(tableKey, "Ошибка удаления: " + error.message, "error"); } }, - [api, loadTable, setStatus] + [api, loadTable, resolveMutationConfig, setStatus] ); const claimRequest = useCallback( @@ -1717,6 +1939,57 @@ [api, loadTable, setStatus] ); + const openInvoiceRequest = useCallback( + async (row) => { + if (!row || !row.request_id) return; + try { + setActiveSection("requests"); + await loadTable("requests", {}); + await openRequestDetails(row.request_id); + } catch (_) { + // Ignore navigation errors and keep current state. + } + }, + [loadTable, openRequestDetails] + ); + + const downloadInvoicePdf = useCallback( + async (row) => { + if (!row || !row.id || !token) return; + try { + setStatus("invoices", "Формируем PDF...", ""); + const response = await fetch("/api/admin/invoices/" + row.id + "/pdf", { + headers: { Authorization: "Bearer " + token }, + }); + if (!response.ok) { + const text = await response.text(); + let payload = {}; + try { + payload = text ? JSON.parse(text) : {}; + } catch (_) { + payload = { raw: text }; + } + const message = payload.detail || payload.error || payload.raw || ("HTTP " + response.status); + throw new Error(translateApiError(String(message))); + } + const blob = await response.blob(); + const fileName = (row.invoice_number || "invoice") + ".pdf"; + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = fileName; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + setStatus("invoices", "PDF скачан", "ok"); + } catch (error) { + setStatus("invoices", "Ошибка скачивания: " + error.message, "error"); + } + }, + [setStatus, token] + ); + const openReassignModal = useCallback( (row) => { const options = getLawyerOptions(); @@ -2025,10 +2298,12 @@ setReassignModal({ open: false, requestId: null, trackNumber: "", lawyerId: "" }); setDashboardData({ scope: "", cards: [], byStatus: {}, lawyerLoads: [], myUnreadByEvent: {} }); setMetaJson(""); - setConfigActiveKey("quotes"); + setConfigActiveKey(""); setReferencesExpanded(true); + setTableCatalog([]); setTables({ requests: createTableState(), + invoices: createTableState(), quotes: createTableState(), topics: createTableState(), statuses: createTableState(), @@ -2110,6 +2385,15 @@ }; }, [bootstrapReferenceData, refreshSection, role, token]); + useEffect(() => { + if (!dictionaryTableItems.length) { + if (configActiveKey) setConfigActiveKey(""); + return; + } + const hasCurrent = dictionaryTableItems.some((item) => item.key === configActiveKey); + if (!hasCurrent) setConfigActiveKey(dictionaryTableItems[0].key); + }, [configActiveKey, dictionaryTableItems]); + const anyOverlayOpen = requestModal.open || recordModal.open || filterModal.open || reassignModal.open; useEffect(() => { document.body.classList.toggle("modal-open", anyOverlayOpen); @@ -2132,6 +2416,7 @@ return [ { key: "dashboard", label: "Обзор" }, { key: "requests", label: "Заявки" }, + { key: "invoices", label: "Счета" }, { key: "meta", label: "Метаданные" }, ]; }, []); @@ -2145,10 +2430,39 @@ const recordModalFields = useMemo(() => { const all = getRecordFields(recordModal.tableKey); - if (recordModal.mode !== "create") return all; + if (recordModal.mode !== "create") return all.filter((field) => !field.createOnly); return all.filter((field) => !field.autoCreate); }, [getRecordFields, recordModal.mode, recordModal.tableKey]); + const activeConfigTableState = useMemo(() => { + return tables[configActiveKey] || createTableState(); + }, [configActiveKey, tables]); + + const activeConfigMeta = useMemo(() => tableCatalogMap[configActiveKey] || null, [configActiveKey, tableCatalogMap]); + const activeConfigActions = useMemo(() => { + return Array.isArray(activeConfigMeta?.actions) ? activeConfigMeta.actions : []; + }, [activeConfigMeta]); + const canCreateInConfig = activeConfigActions.includes("create"); + const canUpdateInConfig = activeConfigActions.includes("update"); + const canDeleteInConfig = activeConfigActions.includes("delete"); + + const genericConfigHeaders = useMemo(() => { + if (!activeConfigMeta || !Array.isArray(activeConfigMeta.columns)) return []; + const headers = (activeConfigMeta.columns || []) + .filter((column) => column && column.name) + .map((column) => { + const name = String(column.name); + return { + key: name, + label: String(column.label || humanizeKey(name)), + sortable: Boolean(column.sortable !== false), + field: name, + }; + }); + if (canUpdateInConfig || canDeleteInConfig) headers.push({ key: "actions", label: "Действия" }); + return headers; + }, [activeConfigMeta, canDeleteInConfig, canUpdateInConfig]); + return ( <>
Выставленные счета клиентам, статусы оплаты и выгрузка PDF.
+{row.invoice_number || "-"}
+ {row.code || "-"}