From 90450b8918a084778d64fa81b7520a2cfd777305 Mon Sep 17 00:00:00 2001 From: TronoSfera <119615520+TronoSfera@users.noreply.github.com> Date: Wed, 25 Feb 2026 18:18:05 +0300 Subject: [PATCH] Test-3 commit --- .../0015_add_clients_table_and_links.py | 106 +++ .../versions/0016_add_table_availability.py | 38 + app/api/admin/auth.py | 7 +- app/api/admin/chat.py | 86 ++ app/api/admin/crud.py | 277 ++++++- app/api/admin/invoices.py | 2 + app/api/admin/requests.py | 171 ++++ app/api/admin/router.py | 3 +- app/api/public/chat.py | 76 ++ app/api/public/otp.py | 42 +- app/api/public/requests.py | 150 +++- app/api/public/router.py | 3 +- app/api/public/uploads.py | 17 +- app/core/config.py | 4 + app/models/client.py | 14 + app/models/invoice.py | 1 + app/models/request.py | 3 + app/models/table_availability.py | 12 + app/services/admin_bootstrap.py | 71 ++ app/services/chat_service.py | 105 +++ app/web/admin.css | 393 +++++++++ app/web/admin.html | 4 +- app/web/admin.jsx | 775 ++++++++++++++++-- app/web/client.css | 397 +++++++++ app/web/client.html | 104 +++ app/web/client.js | 502 ++++++++++++ app/web/landing.css | 43 +- app/web/landing.html | 131 +-- app/web/landing.js | 718 ++++++---------- celerybeat-schedule | Bin 16384 -> 16384 bytes context/10_development_execution_plan.md | 40 + context/11_test_runbook.md | 42 +- context/12_iteration_checkpoint_2026-02-24.md | 49 ++ docker-compose.yml | 15 + e2e/Dockerfile | 10 + e2e/package-lock.json | 246 ++++++ e2e/playwright.config.js | 29 + e2e/tests/admin_entry_flow.spec.js | 13 + e2e/tests/admin_role_flow.spec.js | 110 +++ e2e/tests/helpers.js | 215 +++++ e2e/tests/lawyer_role_flow.spec.js | 95 +++ e2e/tests/public_client_flow.spec.js | 33 + frontend/nginx.conf | 1 + tests/test_admin_auth.py | 125 +++ tests/test_admin_universal_crud.py | 439 +++++++++- tests/test_migrations.py | 16 +- tests/test_public_cabinet.py | 31 + tests/test_public_requests.py | 110 +++ tests/test_rates.py | 4 + tests/test_uploads_s3.py | 38 + 50 files changed, 5202 insertions(+), 714 deletions(-) create mode 100644 alembic/versions/0015_add_clients_table_and_links.py create mode 100644 alembic/versions/0016_add_table_availability.py create mode 100644 app/api/admin/chat.py create mode 100644 app/api/public/chat.py create mode 100644 app/models/client.py create mode 100644 app/models/table_availability.py create mode 100644 app/services/admin_bootstrap.py create mode 100644 app/services/chat_service.py create mode 100644 app/web/client.css create mode 100644 app/web/client.html create mode 100644 app/web/client.js create mode 100644 context/12_iteration_checkpoint_2026-02-24.md create mode 100644 e2e/Dockerfile create mode 100644 e2e/package-lock.json create mode 100644 e2e/playwright.config.js create mode 100644 e2e/tests/admin_entry_flow.spec.js create mode 100644 e2e/tests/admin_role_flow.spec.js create mode 100644 e2e/tests/helpers.js create mode 100644 e2e/tests/lawyer_role_flow.spec.js create mode 100644 e2e/tests/public_client_flow.spec.js create mode 100644 tests/test_admin_auth.py diff --git a/alembic/versions/0015_add_clients_table_and_links.py b/alembic/versions/0015_add_clients_table_and_links.py new file mode 100644 index 0000000..ddbdebc --- /dev/null +++ b/alembic/versions/0015_add_clients_table_and_links.py @@ -0,0 +1,106 @@ +"""add clients table and links from requests/invoices + +Revision ID: 0015_clients_table_links +Revises: 0014_security_audit_log +Create Date: 2026-02-24 +""" + +from __future__ import annotations + +import uuid + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision = "0015_clients_table_links" +down_revision = "0014_security_audit_log" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "clients", + 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("full_name", sa.String(length=200), nullable=False), + sa.Column("phone", sa.String(length=30), nullable=False, unique=True), + ) + op.create_index("ix_clients_phone", "clients", ["phone"], unique=True) + + op.add_column("requests", sa.Column("client_id", postgresql.UUID(as_uuid=True), nullable=True)) + op.add_column("invoices", sa.Column("client_id", postgresql.UUID(as_uuid=True), nullable=True)) + op.create_index("ix_requests_client_id", "requests", ["client_id"]) + op.create_index("ix_invoices_client_id", "invoices", ["client_id"]) + op.create_foreign_key("fk_requests_client_id_clients", "requests", "clients", ["client_id"], ["id"]) + op.create_foreign_key("fk_invoices_client_id_clients", "invoices", "clients", ["client_id"], ["id"]) + + bind = op.get_bind() + rows = bind.execute( + sa.text( + """ + SELECT DISTINCT ON (client_phone) + client_phone, + client_name + FROM requests + WHERE client_phone IS NOT NULL AND client_phone <> '' + ORDER BY client_phone, created_at ASC NULLS FIRST + """ + ) + ).mappings() + + for row in rows: + client_id = uuid.uuid4() + bind.execute( + sa.text( + """ + INSERT INTO clients (id, created_at, updated_at, responsible, full_name, phone) + VALUES (:id, now(), now(), :responsible, :full_name, :phone) + ON CONFLICT (phone) DO NOTHING + """ + ), + { + "id": client_id, + "responsible": "Миграция системы", + "full_name": str(row.get("client_name") or "").strip() or "Клиент", + "phone": str(row.get("client_phone") or "").strip(), + }, + ) + + bind.execute( + sa.text( + """ + UPDATE requests AS r + SET client_id = c.id + FROM clients AS c + WHERE r.client_phone = c.phone + AND (r.client_id IS NULL OR r.client_id <> c.id) + """ + ) + ) + + bind.execute( + sa.text( + """ + UPDATE invoices AS i + SET client_id = r.client_id + FROM requests AS r + WHERE i.request_id = r.id + AND (i.client_id IS NULL OR i.client_id <> r.client_id) + """ + ) + ) + + +def downgrade(): + op.drop_constraint("fk_invoices_client_id_clients", "invoices", type_="foreignkey") + op.drop_constraint("fk_requests_client_id_clients", "requests", type_="foreignkey") + op.drop_index("ix_invoices_client_id", table_name="invoices") + op.drop_index("ix_requests_client_id", table_name="requests") + op.drop_column("invoices", "client_id") + op.drop_column("requests", "client_id") + op.drop_index("ix_clients_phone", table_name="clients") + op.drop_table("clients") diff --git a/alembic/versions/0016_add_table_availability.py b/alembic/versions/0016_add_table_availability.py new file mode 100644 index 0000000..f9e1d3a --- /dev/null +++ b/alembic/versions/0016_add_table_availability.py @@ -0,0 +1,38 @@ +"""add table availability controls for dictionaries + +Revision ID: 0016_table_availability +Revises: 0015_clients_table_links +Create Date: 2026-02-24 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +revision = "0016_table_availability" +down_revision = "0015_clients_table_links" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "table_availability", + 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("table_name", sa.String(length=120), nullable=False, unique=True), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + ) + op.create_index("ix_table_availability_table_name", "table_availability", ["table_name"], unique=True) + op.create_index("ix_table_availability_is_active", "table_availability", ["is_active"], unique=False) + + +def downgrade(): + op.drop_index("ix_table_availability_is_active", table_name="table_availability") + op.drop_index("ix_table_availability_table_name", table_name="table_availability") + op.drop_table("table_availability") diff --git a/app/api/admin/auth.py b/app/api/admin/auth.py index 8737005..db6cbe2 100644 --- a/app/api/admin/auth.py +++ b/app/api/admin/auth.py @@ -5,13 +5,16 @@ from app.schemas.admin import AdminLogin, AdminToken from app.core.security import create_jwt, verify_password from app.core.config import settings from app.db.session import get_db -from app.models.admin_user import AdminUser +from app.services.admin_bootstrap import ensure_bootstrap_admin_for_login, get_active_admin_by_email, normalize_admin_email router = APIRouter() @router.post("/login", response_model=AdminToken) def login(payload: AdminLogin, db: Session = Depends(get_db)): - user = db.query(AdminUser).filter(AdminUser.email == payload.email, AdminUser.is_active == True).first() + email = normalize_admin_email(payload.email) + user = ensure_bootstrap_admin_for_login(db, email, payload.password) + if user is None: + user = get_active_admin_by_email(db, email) if not user or not verify_password(payload.password, user.password_hash): raise HTTPException(status_code=401, detail="Неверный логин или пароль") token = create_jwt({"sub": str(user.id), "email": user.email, "role": user.role}, diff --git a/app/api/admin/chat.py b/app/api/admin/chat.py new file mode 100644 index 0000000..0ba32ba --- /dev/null +++ b/app/api/admin/chat.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.core.deps import require_role +from app.db.session import get_db +from app.models.request import Request +from app.services.chat_service import create_admin_or_lawyer_message, list_messages_for_request, serialize_message + +router = APIRouter() + + +def _request_uuid_or_400(request_id: str) -> UUID: + try: + return UUID(str(request_id)) + except ValueError: + raise HTTPException(status_code=400, detail="Некорректный идентификатор заявки") + + +def _request_for_id_or_404(db: Session, request_id: str) -> Request: + req = db.get(Request, _request_uuid_or_400(request_id)) + if req is None: + raise HTTPException(status_code=404, detail="Заявка не найдена") + return req + + +def _ensure_lawyer_can_view_request_or_403(admin: dict, req: Request) -> None: + role = str(admin.get("role") or "").upper() + if role != "LAWYER": + return + actor = str(admin.get("sub") or "").strip() + if not actor: + raise HTTPException(status_code=401, detail="Некорректный токен") + assigned = str(req.assigned_lawyer_id or "").strip() + if assigned and actor != assigned: + raise HTTPException(status_code=403, detail="Юрист может видеть только свои и неназначенные заявки") + + +def _ensure_lawyer_can_manage_request_or_403(admin: dict, req: Request) -> None: + role = str(admin.get("role") or "").upper() + if role != "LAWYER": + return + actor = str(admin.get("sub") or "").strip() + if not actor: + raise HTTPException(status_code=401, detail="Некорректный токен") + assigned = str(req.assigned_lawyer_id or "").strip() + if not assigned or actor != assigned: + raise HTTPException(status_code=403, detail="Юрист может работать только со своими назначенными заявками") + + +@router.get("/requests/{request_id}/messages") +def list_request_messages( + request_id: str, + db: Session = Depends(get_db), + admin: dict = Depends(require_role("ADMIN", "LAWYER")), +): + req = _request_for_id_or_404(db, request_id) + _ensure_lawyer_can_view_request_or_403(admin, req) + rows = list_messages_for_request(db, req.id) + return {"rows": [serialize_message(row) for row in rows], "total": len(rows)} + + +@router.post("/requests/{request_id}/messages", status_code=201) +def create_request_message( + request_id: str, + payload: dict, + db: Session = Depends(get_db), + admin: dict = Depends(require_role("ADMIN", "LAWYER")), +): + req = _request_for_id_or_404(db, request_id) + _ensure_lawyer_can_manage_request_or_403(admin, req) + body = str((payload or {}).get("body") or "").strip() + role = str(admin.get("role") or "").upper() + actor_name = str(admin.get("email") or "").strip() or ("Юрист" if role == "LAWYER" else "Администратор") + row = create_admin_or_lawyer_message( + db, + request=req, + body=body, + actor_role=role, + actor_name=actor_name, + actor_admin_user_id=str(admin.get("sub") or "").strip() or None, + ) + return serialize_message(row) diff --git a/app/api/admin/crud.py b/app/api/admin/crud.py index d87d7a2..655d550 100644 --- a/app/api/admin/crud.py +++ b/app/api/admin/crud.py @@ -3,12 +3,13 @@ from __future__ import annotations import importlib import pkgutil import uuid -from datetime import date, datetime +from datetime import date, datetime, timezone from decimal import Decimal from functools import lru_cache from typing import Any from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel from sqlalchemy import or_ from sqlalchemy.exc import IntegrityError from sqlalchemy.inspection import inspect as sa_inspect @@ -22,6 +23,8 @@ from app.db.session import Base, get_db from app.models.admin_user import AdminUser from app.models.audit_log import AuditLog from app.models.form_field import FormField +from app.models.client import Client +from app.models.table_availability import TableAvailability from app.models.request_data_requirement import RequestDataRequirement from app.models.attachment import Attachment from app.models.message import Message @@ -66,6 +69,8 @@ SYSTEM_FIELDS = { "lawyer_unread_event_type", } REQUEST_FINANCIAL_FIELDS = {"effective_rate", "invoice_amount", "paid_at", "paid_by_admin_id"} +REQUEST_CALCULATED_FIELDS = {"invoice_amount", "paid_at", "paid_by_admin_id", "total_attachments_bytes"} +INVOICE_CALCULATED_FIELDS = {"issued_by_admin_user_id", "issued_by_role", "issued_at", "paid_at"} ALLOWED_ADMIN_ROLES = {"ADMIN", "LAWYER"} # Per-table RBAC: table -> role -> actions. @@ -75,10 +80,20 @@ TABLE_ROLE_ACTIONS: dict[str, dict[str, set[str]]] = { "ADMIN": set(CRUD_ACTIONS), "LAWYER": set(CRUD_ACTIONS), }, + "messages": { + "ADMIN": set(CRUD_ACTIONS), + "LAWYER": {"query", "read", "create"}, + }, + "attachments": { + "ADMIN": set(CRUD_ACTIONS), + "LAWYER": {"query", "read"}, + }, "quotes": {"ADMIN": set(CRUD_ACTIONS)}, "topics": {"ADMIN": set(CRUD_ACTIONS)}, "statuses": {"ADMIN": set(CRUD_ACTIONS)}, "form_fields": {"ADMIN": set(CRUD_ACTIONS)}, + "clients": {"ADMIN": set(CRUD_ACTIONS)}, + "table_availability": {"ADMIN": set(CRUD_ACTIONS)}, "audit_log": {"ADMIN": {"query", "read"}}, "security_audit_log": {"ADMIN": {"query", "read"}}, "otp_sessions": {"ADMIN": {"query", "read"}}, @@ -172,6 +187,16 @@ def _ensure_lawyer_can_manage_request_or_403(admin: dict, req: Request) -> None: raise HTTPException(status_code=403, detail="Юрист может работать только со своими назначенными заявками") +def _request_for_related_row_or_404(db: Session, row: Any) -> Request: + request_id = getattr(row, "request_id", None) + if request_id is None: + raise HTTPException(status_code=400, detail="Связанная заявка не найдена") + req = db.get(Request, request_id) + if req is None: + raise HTTPException(status_code=404, detail="Заявка не найдена") + return req + + def _serialize_value(value: Any) -> Any: if isinstance(value, dict): return {key: _serialize_value(val) for key, val in value.items()} @@ -231,6 +256,8 @@ def _table_label(table_name: str) -> str: "topics": "Темы", "statuses": "Статусы", "form_fields": "Поля формы", + "clients": "Клиенты", + "table_availability": "Доступность таблиц", "topic_required_fields": "Обязательные поля темы", "topic_data_templates": "Шаблоны данных темы", "topic_status_transitions": "Переходы статусов темы", @@ -341,6 +368,7 @@ def _column_label(table_name: str, column_name: str) -> str: "amount": "Сумма", "currency": "Валюта", "client_name": "Клиент", + "client_id": "Клиент (ID)", "client_phone": "Телефон", "payer_display_name": "Плательщик", "payer_details_encrypted": "Реквизиты (шифр.)", @@ -401,6 +429,7 @@ def _column_label(table_name: str, column_name: str) -> str: "reason": "Причина", "diff": "Изменения", "details": "Детали", + "table_name": "Таблица", } if normalized_column in explicit: return explicit[normalized_column] @@ -459,6 +488,10 @@ def _hidden_response_fields(table_name: str) -> set[str]: def _protected_input_fields(table_name: str) -> set[str]: if table_name == "admin_users": return {"password_hash"} + if table_name == "requests": + return {"client_id", *REQUEST_CALCULATED_FIELDS} + if table_name == "invoices": + return {"client_id", *INVOICE_CALCULATED_FIELDS} return set() @@ -552,6 +585,52 @@ def _normalize_optional_string(value: Any) -> str | None: return text or None +def _normalize_client_phone(value: Any) -> str: + text = str(value or "").strip() + if not text: + return "" + allowed = {"+", "(", ")", "-", " "} + return "".join(ch for ch in text if ch.isdigit() or ch in allowed).strip() + + +def _upsert_client_or_400(db: Session, *, full_name: Any, phone: Any, responsible: str) -> Client: + normalized_phone = _normalize_client_phone(phone) + if not normalized_phone: + raise HTTPException(status_code=400, detail='Поле "client_phone" обязательно') + normalized_name = str(full_name or "").strip() or "Клиент" + + row = db.query(Client).filter(Client.phone == normalized_phone).first() + if row is None: + row = Client( + full_name=normalized_name, + phone=normalized_phone, + responsible=responsible or "Администратор системы", + ) + db.add(row) + db.flush() + return row + + changed = False + if normalized_name and row.full_name != normalized_name: + row.full_name = normalized_name + changed = True + if responsible and row.responsible != responsible: + row.responsible = responsible + changed = True + if changed: + db.add(row) + db.flush() + return row + + +def _request_for_uuid_or_400(db: Session, raw_request_id: Any) -> Request: + request_uuid = _parse_uuid_or_400(raw_request_id, "request_id") + req = db.get(Request, request_uuid) + if req is None: + raise HTTPException(status_code=400, detail="Заявка не найдена") + return req + + def _active_lawyer_or_400(db: Session, lawyer_id: Any) -> AdminUser: lawyer_uuid = _parse_uuid_or_400(lawyer_id, "assigned_lawyer_id") lawyer = db.get(AdminUser, lawyer_uuid) @@ -955,23 +1034,49 @@ 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="Недостаточно прав") +def _table_section(table_name: str) -> str: + if table_name in {"requests", "invoices"}: + return "main" + if table_name == "table_availability": + return "system" + return "dictionary" + +def _table_availability_map(db: Session) -> dict[str, TableAvailability]: + rows = db.query(TableAvailability).all() + return {str(row.table_name): row for row in rows if row and row.table_name} + + +def _table_is_active(table_name: str, availability: dict[str, TableAvailability]) -> bool: + row = availability.get(table_name) + if row is None: + return True + return bool(row.is_active) + + +def _meta_tables_payload( + db: Session, + *, + role: str, + include_inactive_dictionaries: bool, +) -> list[dict[str, Any]]: table_models = _table_model_map() + availability = _table_availability_map(db) rows: list[dict[str, Any]] = [] for table_name in sorted(table_models.keys()): model = table_models[table_name] + section = _table_section(table_name) + is_active = _table_is_active(table_name, availability) + if section == "dictionary" and not include_inactive_dictionaries and not is_active: + continue actions = sorted(_allowed_actions(role, table_name)) rows.append( { "key": table_name, "table": table_name, "label": _table_label(table_name), - "section": "main" if table_name in {"requests", "invoices"} else "dictionary", + "section": section, + "is_active": is_active, "actions": actions, "query_endpoint": f"/api/admin/crud/{table_name}/query", "create_endpoint": f"/api/admin/crud/{table_name}", @@ -981,8 +1086,80 @@ def list_tables_meta(admin: dict = Depends(get_current_admin)): "columns": _table_columns_meta(table_name, model), } ) + return rows - return {"tables": rows} + +class TableAvailabilityUpdatePayload(BaseModel): + is_active: bool + + +@router.get("/meta/tables") +def list_tables_meta(db: Session = Depends(get_db), admin: dict = Depends(get_current_admin)): + role = str(admin.get("role") or "").upper() + if role != "ADMIN": + raise HTTPException(status_code=403, detail="Недостаточно прав") + return {"tables": _meta_tables_payload(db, role=role, include_inactive_dictionaries=False)} + + +@router.get("/meta/available-tables") +def list_available_tables(db: Session = Depends(get_db), admin: dict = Depends(get_current_admin)): + role = str(admin.get("role") or "").upper() + if role != "ADMIN": + raise HTTPException(status_code=403, detail="Недостаточно прав") + + availability = _table_availability_map(db) + rows = [] + for item in _meta_tables_payload(db, role=role, include_inactive_dictionaries=True): + table_name = str(item.get("table") or "") + state = availability.get(table_name) + rows.append( + { + "table": table_name, + "label": item.get("label"), + "section": item.get("section"), + "is_active": bool(item.get("is_active")), + "responsible": state.responsible if state is not None else None, + "updated_at": _serialize_value(state.updated_at) if state is not None else None, + } + ) + return {"rows": rows, "total": len(rows)} + + +@router.patch("/meta/available-tables/{table_name}") +def update_available_table( + table_name: str, + payload: TableAvailabilityUpdatePayload, + db: Session = Depends(get_db), + admin: dict = Depends(get_current_admin), +): + role = str(admin.get("role") or "").upper() + if role != "ADMIN": + raise HTTPException(status_code=403, detail="Недостаточно прав") + + normalized, _ = _resolve_table_model(table_name) + row = db.query(TableAvailability).filter(TableAvailability.table_name == normalized).first() + responsible = _resolve_responsible(admin) + is_active = bool(payload.is_active) + if row is None: + row = TableAvailability( + table_name=normalized, + is_active=is_active, + responsible=responsible, + ) + db.add(row) + else: + row.is_active = is_active + row.updated_at = datetime.now(timezone.utc) + row.responsible = responsible + db.add(row) + db.commit() + db.refresh(row) + return { + "table": normalized, + "is_active": bool(row.is_active), + "responsible": row.responsible, + "updated_at": _serialize_value(row.updated_at), + } @router.post("/{table_name}/query") @@ -1003,6 +1180,22 @@ def query_table( Request.assigned_lawyer_id.is_(None), ) ) + if normalized == "messages" and _is_lawyer(admin): + actor_id = _lawyer_actor_id_or_401(admin) + base_query = base_query.join(Request, Request.id == Message.request_id).filter( + or_( + Request.assigned_lawyer_id == actor_id, + Request.assigned_lawyer_id.is_(None), + ) + ) + if normalized == "attachments" and _is_lawyer(admin): + actor_id = _lawyer_actor_id_or_401(admin) + base_query = base_query.join(Request, Request.id == Attachment.request_id).filter( + or_( + Request.assigned_lawyer_id == actor_id, + Request.assigned_lawyer_id.is_(None), + ) + ) query = apply_universal_query(base_query, model, uq) total = query.count() rows = query.offset(uq.page.offset).limit(uq.page.limit).all() @@ -1039,6 +1232,12 @@ def get_row( db.commit() db.refresh(req) row = req + if normalized == "messages" and isinstance(row, Message): + req = _request_for_related_row_or_404(db, row) + _ensure_lawyer_can_view_request_or_403(admin, req) + if normalized == "attachments" and isinstance(row, Attachment): + req = _request_for_related_row_or_404(db, row) + _ensure_lawyer_can_view_request_or_403(admin, req) return _strip_hidden_fields(normalized, _row_to_dict(row)) @@ -1051,6 +1250,9 @@ def create_row( ): normalized, model = _resolve_table_model(table_name) _require_table_action(admin, normalized, "create") + responsible = _resolve_responsible(admin) + resolved_request_client_id: uuid.UUID | None = None + resolved_invoice_client_id: uuid.UUID | None = None if normalized == "requests" and _is_lawyer(admin) and isinstance(payload, dict): assigned_lawyer_id = payload.get("assigned_lawyer_id") if str(assigned_lawyer_id or "").strip(): @@ -1060,8 +1262,28 @@ def create_row( raise HTTPException(status_code=403, detail="Юрист не может изменять финансовые поля заявки") prepared = _prepare_create_payload(normalized, payload) + if normalized == "messages": + request_uuid = _parse_uuid_or_400(prepared.get("request_id"), "request_id") + req = db.get(Request, request_uuid) + if req is None: + raise HTTPException(status_code=404, detail="Заявка не найдена") + if _is_lawyer(admin): + _ensure_lawyer_can_manage_request_or_403(admin, req) + prepared["author_type"] = "LAWYER" + prepared["author_name"] = str(admin.get("email") or "").strip() or "Юрист" + prepared["immutable"] = False + prepared["request_id"] = request_uuid if normalized == "requests": validate_required_topic_fields_or_400(db, prepared.get("topic_code"), prepared.get("extra_fields")) + client = _upsert_client_or_400( + db, + full_name=prepared.get("client_name"), + phone=prepared.get("client_phone"), + responsible=responsible, + ) + resolved_request_client_id = client.id + prepared["client_name"] = client.full_name + prepared["client_phone"] = client.phone if not _is_lawyer(admin): assigned_raw = prepared.get("assigned_lawyer_id") if assigned_raw is None or not str(assigned_raw).strip(): @@ -1072,6 +1294,10 @@ def create_row( prepared["assigned_lawyer_id"] = str(assigned_lawyer.id) if prepared.get("effective_rate") is None: prepared["effective_rate"] = assigned_lawyer.default_rate + if normalized == "invoices": + req = _request_for_uuid_or_400(db, prepared.get("request_id")) + prepared["request_id"] = req.id + resolved_invoice_client_id = req.client_id prepared = _apply_auto_fields_for_create(db, model, normalized, prepared) clean_payload = _sanitize_payload( model, @@ -1092,8 +1318,12 @@ def create_row( clean_payload = _apply_topic_status_transitions_fields(db, clean_payload) if normalized == "statuses": clean_payload = _apply_status_fields(clean_payload) + if normalized == "requests": + clean_payload["client_id"] = resolved_request_client_id + if normalized == "invoices": + clean_payload["client_id"] = resolved_invoice_client_id if "responsible" in _columns_map(model): - clean_payload["responsible"] = _resolve_responsible(admin) + clean_payload["responsible"] = responsible row = model(**clean_payload) try: @@ -1121,6 +1351,7 @@ def update_row( ): normalized, model = _resolve_table_model(table_name) _require_table_action(admin, normalized, "update") + responsible = _resolve_responsible(admin) if normalized == "requests" and _is_lawyer(admin) and isinstance(payload, dict): if "assigned_lawyer_id" in payload: raise HTTPException(status_code=403, detail='Назначение доступно только через действие "Взять в работу"') @@ -1154,6 +1385,26 @@ def update_row( clean_payload = _apply_topic_status_transitions_fields(db, clean_payload) if normalized == "statuses": clean_payload = _apply_status_fields(clean_payload) + if normalized == "requests" and isinstance(row, Request): + if {"client_name", "client_phone"}.intersection(set(clean_payload.keys())) or row.client_id is None: + client = _upsert_client_or_400( + db, + full_name=clean_payload.get("client_name", row.client_name), + phone=clean_payload.get("client_phone", row.client_phone), + responsible=responsible, + ) + clean_payload["client_id"] = client.id + clean_payload["client_name"] = client.full_name + clean_payload["client_phone"] = client.phone + if normalized == "invoices": + if "request_id" in clean_payload: + req = _request_for_uuid_or_400(db, clean_payload.get("request_id")) + clean_payload["request_id"] = req.id + clean_payload["client_id"] = req.client_id + elif getattr(row, "client_id", None) is None: + req = db.get(Request, getattr(row, "request_id", None)) + if req is not None: + clean_payload["client_id"] = req.client_id if normalized == "requests" and not _is_lawyer(admin) and "assigned_lawyer_id" in clean_payload: assigned_raw = clean_payload.get("assigned_lawyer_id") if assigned_raw is None or not str(assigned_raw).strip(): @@ -1163,6 +1414,8 @@ def update_row( clean_payload["assigned_lawyer_id"] = str(assigned_lawyer.id) if isinstance(row, Request) and row.effective_rate is None and "effective_rate" not in clean_payload: clean_payload["effective_rate"] = assigned_lawyer.default_rate + if "responsible" in _columns_map(model): + clean_payload["responsible"] = responsible before = _row_to_dict(row) if normalized == "topic_status_transitions": next_from = str(clean_payload.get("from_status", before.get("from_status") or "")).strip() @@ -1185,7 +1438,7 @@ def update_row( from_status=before_status, to_status=after_status, admin=admin, - responsible=_resolve_responsible(admin), + responsible=responsible, ) mark_unread_for_client(row, EVENT_STATUS) apply_status_change_effects( @@ -1194,7 +1447,7 @@ def update_row( from_status=before_status, to_status=after_status, admin=admin, - responsible=_resolve_responsible(admin), + responsible=responsible, ) notify_request_event( db, @@ -1203,7 +1456,7 @@ def update_row( actor_role=_actor_role(admin), actor_admin_user_id=admin.get("sub"), body=(f"{before_status} -> {after_status}" + (f"\n{billing_note}" if billing_note else "")), - responsible=_resolve_responsible(admin), + responsible=responsible, ) for key, value in clean_payload.items(): setattr(row, key, value) diff --git a/app/api/admin/invoices.py b/app/api/admin/invoices.py index 425fef6..becaa01 100644 --- a/app/api/admin/invoices.py +++ b/app/api/admin/invoices.py @@ -134,6 +134,7 @@ def _serialize_invoice( "id": str(row.id), "invoice_number": row.invoice_number, "request_id": str(row.request_id), + "client_id": str(row.client_id) if row.client_id else None, "request_track_number": request_track, "status": row.status, "status_label": STATUS_LABELS.get(str(row.status or "").upper(), row.status), @@ -275,6 +276,7 @@ def create_invoice( invoice = Invoice( request_id=req.id, + client_id=req.client_id, invoice_number=str(payload.get("invoice_number") or "").strip() or _invoice_number(db), status=status, amount=_amount_or_400(payload.get("amount")), diff --git a/app/api/admin/requests.py b/app/api/admin/requests.py index 951e40e..fa834da 100644 --- a/app/api/admin/requests.py +++ b/app/api/admin/requests.py @@ -20,7 +20,10 @@ from app.models.admin_user import AdminUser from app.models.audit_log import AuditLog from app.models.request_data_requirement import RequestDataRequirement from app.models.request import Request +from app.models.status import Status +from app.models.status_history import StatusHistory from app.models.topic_data_template import TopicDataTemplate +from app.models.topic_status_transition import TopicStatusTransition from app.services.notifications import ( EVENT_STATUS as NOTIFICATION_EVENT_STATUS, mark_admin_notifications_read, @@ -317,6 +320,174 @@ def get_request(request_id: str, db: Session = Depends(get_db), admin=Depends(re } +@router.get("/{request_id}/status-route") +def get_request_status_route( + request_id: str, + db: Session = Depends(get_db), + admin=Depends(require_role("ADMIN", "LAWYER")), +): + request_uuid = _request_uuid_or_400(request_id) + req = db.get(Request, request_uuid) + if not req: + raise HTTPException(status_code=404, detail="Заявка не найдена") + _ensure_lawyer_can_view_request_or_403(admin, req) + + topic_code = str(req.topic_code or "").strip() + current_status = str(req.status_code or "").strip() + + transitions: list[TopicStatusTransition] = [] + if topic_code: + transitions = ( + db.query(TopicStatusTransition) + .filter( + TopicStatusTransition.topic_code == topic_code, + TopicStatusTransition.enabled.is_(True), + ) + .order_by( + TopicStatusTransition.sort_order.asc(), + TopicStatusTransition.from_status.asc(), + TopicStatusTransition.to_status.asc(), + ) + .all() + ) + + history_rows = ( + db.query(StatusHistory) + .filter(StatusHistory.request_id == req.id) + .order_by(StatusHistory.created_at.asc()) + .all() + ) + + known_codes: set[str] = set() + if current_status: + known_codes.add(current_status) + for row in history_rows: + from_code = str(row.from_status or "").strip() + to_code = str(row.to_status or "").strip() + if from_code: + known_codes.add(from_code) + if to_code: + known_codes.add(to_code) + for row in transitions: + from_code = str(row.from_status or "").strip() + to_code = str(row.to_status or "").strip() + if from_code: + known_codes.add(from_code) + if to_code: + known_codes.add(to_code) + + statuses_map: dict[str, dict[str, str]] = {} + if known_codes: + status_rows = db.query(Status).filter(Status.code.in_(list(known_codes))).all() + statuses_map = { + str(row.code): { + "name": str(row.name or row.code), + "kind": str(row.kind or "DEFAULT"), + } + for row in status_rows + } + + sequence_from_history: list[str] = [] + if history_rows: + first_from = str(history_rows[0].from_status or "").strip() + if first_from: + sequence_from_history.append(first_from) + for row in history_rows: + to_code = str(row.to_status or "").strip() + if to_code: + sequence_from_history.append(to_code) + elif current_status: + sequence_from_history.append(current_status) + + ordered_codes: list[str] = [] + seen_codes: set[str] = set() + + def add_code(code: str) -> None: + normalized = str(code or "").strip() + if not normalized or normalized in seen_codes: + return + seen_codes.add(normalized) + ordered_codes.append(normalized) + + for code in sequence_from_history: + add_code(code) + + for row in transitions: + add_code(str(row.from_status or "")) + add_code(str(row.to_status or "")) + + add_code(current_status) + + transition_by_to_status: dict[str, dict[str, str | int | None]] = {} + for row in transitions: + to_code = str(row.to_status or "").strip() + if not to_code: + continue + current = transition_by_to_status.get(to_code) + if current is None or int(row.sort_order or 0) < int(current.get("sort_order") or 0): + transition_by_to_status[to_code] = { + "from_status": str(row.from_status or "").strip() or None, + "sla_hours": row.sla_hours, + "sort_order": int(row.sort_order or 0), + } + + changed_at_by_status: dict[str, str] = {} + for row in history_rows: + to_code = str(row.to_status or "").strip() + if to_code and row.created_at: + changed_at_by_status[to_code] = row.created_at.isoformat() + + visited_codes = {code for code in sequence_from_history if code} + current_index = ordered_codes.index(current_status) if current_status in ordered_codes else -1 + + def status_name(code: str) -> str: + meta = statuses_map.get(code) or {} + return str(meta.get("name") or code) + + nodes: list[dict[str, str | int | None]] = [] + for index, code in enumerate(ordered_codes): + meta = statuses_map.get(code) or {} + transition_meta = transition_by_to_status.get(code) or {} + state = "pending" + if code == current_status: + state = "current" + elif code in visited_codes or (current_index >= 0 and index < current_index): + state = "completed" + + note_parts: list[str] = [] + from_status = transition_meta.get("from_status") + if from_status: + note_parts.append(f"Переход из статуса «{status_name(str(from_status))}»") + sla_hours = transition_meta.get("sla_hours") + if sla_hours is not None: + note_parts.append(f"SLA: {sla_hours} ч") + kind = str(meta.get("kind") or "DEFAULT") + if kind == "INVOICE": + note_parts.append("Этап выставления счета") + elif kind == "PAID": + note_parts.append("Этап подтверждения оплаты") + + nodes.append( + { + "code": code, + "name": status_name(code), + "kind": kind, + "state": state, + "sla_hours": sla_hours, + "changed_at": changed_at_by_status.get(code), + "note": " • ".join(note_parts), + } + ) + + return { + "request_id": str(req.id), + "track_number": req.track_number, + "topic_code": req.topic_code, + "current_status": current_status or None, + "nodes": nodes, + } + + @router.post("/{request_id}/claim") def claim_request(request_id: str, db: Session = Depends(get_db), admin=Depends(require_role("LAWYER"))): request_uuid = _request_uuid_or_400(request_id) diff --git a/app/api/admin/router.py b/app/api/admin/router.py index b54af52..12f4089 100644 --- a/app/api/admin/router.py +++ b/app/api/admin/router.py @@ -1,5 +1,5 @@ from fastapi import APIRouter -from app.api.admin import auth, requests, quotes, meta, config, uploads, metrics, crud, notifications, invoices +from app.api.admin import auth, requests, quotes, meta, config, uploads, metrics, crud, notifications, invoices, chat router = APIRouter() router.include_router(auth.router, prefix="/auth", tags=["AdminAuth"]) @@ -11,4 +11,5 @@ 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(chat.router, prefix="/chat", tags=["AdminChat"]) router.include_router(crud.router, prefix="/crud", tags=["AdminCrud"]) diff --git a/app/api/public/chat.py b/app/api/public/chat.py new file mode 100644 index 0000000..a4ae402 --- /dev/null +++ b/app/api/public/chat.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.core.deps import get_public_session +from app.db.session import get_db +from app.models.request import Request +from app.schemas.public import PublicMessageCreate +from app.services.chat_service import create_client_message, list_messages_for_request, serialize_message + +router = APIRouter() + + +def _normalize_phone(raw: str | None) -> str: + value = str(raw or "").strip() + if not value: + return "" + allowed = {"+", "(", ")", "-", " "} + return "".join(ch for ch in value if ch.isdigit() or ch in allowed).strip() + + +def _normalize_track(raw: str | None) -> str: + return str(raw or "").strip().upper() + + +def _require_view_session_or_403(session: dict) -> str: + purpose = str(session.get("purpose") or "").strip().upper() + subject = str(session.get("sub") or "").strip() + if purpose != "VIEW_REQUEST" or not subject: + raise HTTPException(status_code=403, detail="Нет доступа к заявке") + return subject + + +def _request_for_track_or_404(db: Session, track_number: str) -> Request: + req = db.query(Request).filter(Request.track_number == _normalize_track(track_number)).first() + if req is None: + raise HTTPException(status_code=404, detail="Заявка не найдена") + return req + + +def _ensure_view_access_or_403(session: dict, req: Request) -> None: + subject = _require_view_session_or_403(session) + subject_track = _normalize_track(subject) + if subject_track.startswith("TRK-") and subject_track != _normalize_track(req.track_number): + raise HTTPException(status_code=403, detail="Нет доступа к заявке") + if subject_track == _normalize_track(req.track_number): + return + if _normalize_phone(subject) and _normalize_phone(subject) == _normalize_phone(req.client_phone): + return + raise HTTPException(status_code=403, detail="Нет доступа к заявке") + + +@router.get("/requests/{track_number}/messages") +def list_messages_by_track( + track_number: str, + db: Session = Depends(get_db), + session: dict = Depends(get_public_session), +): + req = _request_for_track_or_404(db, track_number) + _ensure_view_access_or_403(session, req) + rows = list_messages_for_request(db, req.id) + return [serialize_message(row) for row in rows] + + +@router.post("/requests/{track_number}/messages", status_code=201) +def create_message_by_track( + track_number: str, + payload: PublicMessageCreate, + db: Session = Depends(get_db), + session: dict = Depends(get_public_session), +): + req = _request_for_track_or_404(db, track_number) + _ensure_view_access_or_403(session, req) + row = create_client_message(db, request=req, body=payload.body) + return serialize_message(row) diff --git a/app/api/public/otp.py b/app/api/public/otp.py index ec4bca3..d3d9734 100644 --- a/app/api/public/otp.py +++ b/app/api/public/otp.py @@ -136,12 +136,18 @@ def send_otp(payload: OtpSend, request: Request, db: Session = Depends(get_db)): raise HTTPException(status_code=400, detail='Поле "client_phone" обязательно для CREATE_REQUEST') else: track_number = _normalize_track(payload.track_number) - if not track_number: - raise HTTPException(status_code=400, detail='Поле "track_number" обязательно для VIEW_REQUEST') - request_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_row.client_phone) + phone = _normalize_phone(payload.client_phone) + if track_number: + 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_row.client_phone) + elif phone: + has_requests = db.query(RequestModel.id).filter(RequestModel.client_phone == phone).first() + if has_requests is None: + raise HTTPException(status_code=404, detail="Заявки по номеру телефона не найдены") + else: + raise HTTPException(status_code=400, detail='Для VIEW_REQUEST укажите "track_number" или "client_phone"') if not phone: raise HTTPException(status_code=400, detail="У заявки отсутствует номер телефона") @@ -201,8 +207,9 @@ def verify_otp(payload: OtpVerify, request: Request, response: Response, db: Ses raise HTTPException(status_code=400, detail='Поле "client_phone" обязательно для CREATE_REQUEST') else: track_number = _normalize_track(payload.track_number) - if not track_number: - raise HTTPException(status_code=400, detail='Поле "track_number" обязательно для VIEW_REQUEST') + phone = _normalize_phone(payload.client_phone) + if not track_number and not phone: + raise HTTPException(status_code=400, detail='Для VIEW_REQUEST укажите "track_number" или "client_phone"') _rate_limit_or_429( "verify", @@ -212,11 +219,10 @@ def verify_otp(payload: OtpVerify, request: Request, response: Response, db: Ses track_number=track_number, ) - query = db.query(OtpSession).filter( - OtpSession.purpose == purpose, - OtpSession.track_number == track_number, - ) - if phone is not None: + query = db.query(OtpSession).filter(OtpSession.purpose == purpose) + if track_number is not None and track_number != "": + query = query.filter(OtpSession.track_number == track_number) + if phone is not None and phone != "": query = query.filter(OtpSession.phone == phone) row = query.order_by(OtpSession.created_at.desc()).first() @@ -239,7 +245,15 @@ def verify_otp(payload: OtpVerify, request: Request, response: Response, db: Ses db.commit() raise HTTPException(status_code=400, detail="Неверный OTP-код") - subject = row.phone if purpose == OTP_CREATE_PURPOSE else str(row.track_number or "") + if purpose == OTP_CREATE_PURPOSE: + subject = str(row.phone or "") + else: + if phone: + subject = str(row.phone or "") + elif track_number: + subject = str(row.track_number or "") + else: + subject = str(row.phone or row.track_number or "") if not subject: raise HTTPException(status_code=400, detail="Некорректная OTP-сессия") diff --git a/app/api/public/requests.py b/app/api/public/requests.py index c295147..ebaebd4 100644 --- a/app/api/public/requests.py +++ b/app/api/public/requests.py @@ -14,21 +14,22 @@ 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.client import Client 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.models.topic import Topic from app.services.invoice_crypto import decrypt_requisites from app.services.invoice_pdf import build_invoice_pdf_bytes +from app.services.chat_service import create_client_message, list_messages_for_request from app.services.notifications import ( - EVENT_MESSAGE as NOTIFICATION_EVENT_MESSAGE, get_client_notification, list_client_notifications, mark_client_notifications_read, - notify_request_event, serialize_notification, ) -from app.services.request_read_markers import EVENT_MESSAGE, clear_unread_for_client, mark_unread_for_lawyer +from app.services.request_read_markers import clear_unread_for_client from app.services.request_templates import validate_required_topic_fields_or_400 from app.schemas.public import ( PublicAttachmentRead, @@ -52,16 +53,20 @@ INVOICE_STATUS_LABELS = { def _normalize_phone(raw: str | None) -> str: - return str(raw or "").strip() + value = str(raw or "").strip() + if not value: + return "" + allowed = {"+", "(", ")", "-", " "} + return "".join(ch for ch in value if ch.isdigit() or ch in allowed).strip() def _normalize_track(raw: str | None) -> str: return str(raw or "").strip().upper() -def _set_view_cookie(response: Response, track_number: str) -> None: +def _set_view_cookie(response: Response, subject: str) -> None: token = create_jwt( - {"sub": track_number, "purpose": OTP_VIEW_PURPOSE}, + {"sub": subject, "purpose": OTP_VIEW_PURPOSE}, settings.PUBLIC_JWT_SECRET, timedelta(days=settings.PUBLIC_JWT_TTL_DAYS), ) @@ -82,22 +87,60 @@ def _require_create_session_or_403(session: dict, client_phone: str) -> None: raise HTTPException(status_code=403, detail="Требуется подтверждение телефона через OTP") -def _require_view_session_for_track_or_403(session: dict, track_number: str) -> None: +def _require_view_session_or_403(session: dict) -> str: purpose = str(session.get("purpose") or "").strip().upper() - sub = _normalize_track(session.get("sub")) - if purpose != OTP_VIEW_PURPOSE or not sub or sub != _normalize_track(track_number): + subject = str(session.get("sub") or "").strip() + if purpose != OTP_VIEW_PURPOSE or not subject: raise HTTPException(status_code=403, detail="Нет доступа к заявке") + return subject + + +def _ensure_view_access_or_403(session: dict, req: Request) -> None: + subject = _require_view_session_or_403(session) + if _normalize_track(subject) == _normalize_track(req.track_number): + return + if _normalize_phone(subject) and _normalize_phone(subject) == _normalize_phone(req.client_phone): + return + raise HTTPException(status_code=403, detail="Нет доступа к заявке") def _request_for_track_or_404(db: Session, session: dict, track_number: str) -> Request: normalized_track = _normalize_track(track_number) - _require_view_session_for_track_or_403(session, normalized_track) + subject = _require_view_session_or_403(session) + subject_track = _normalize_track(subject) + if subject_track.startswith("TRK-") and subject_track != normalized_track: + raise HTTPException(status_code=403, detail="Нет доступа к заявке") req = db.query(Request).filter(Request.track_number == normalized_track).first() if req is None: raise HTTPException(status_code=404, detail="Заявка не найдена") + _ensure_view_access_or_403(session, req) return req +def _upsert_client_by_phone(db: Session, *, full_name: str, phone: str) -> Client: + normalized_phone = _normalize_phone(phone) + if not normalized_phone: + raise HTTPException(status_code=400, detail='Поле "client_phone" обязательно') + normalized_name = str(full_name or "").strip() or "Клиент" + + client = db.query(Client).filter(Client.phone == normalized_phone).first() + if client is None: + client = Client( + full_name=normalized_name, + phone=normalized_phone, + responsible="Клиент", + ) + db.add(client) + db.flush() + return client + if client.full_name != normalized_name: + client.full_name = normalized_name + client.responsible = "Клиент" + db.add(client) + db.flush() + return client + + def _to_iso(value) -> str | None: return value.isoformat() if value is not None else None @@ -127,12 +170,14 @@ def create_request( ): _require_create_session_or_403(session, payload.client_phone) validate_required_topic_fields_or_400(db, payload.topic_code, payload.extra_fields) + client = _upsert_client_by_phone(db, full_name=payload.client_name, phone=payload.client_phone) track = f"TRK-{uuid4().hex[:10].upper()}" row = Request( track_number=track, - client_name=payload.client_name, - client_phone=payload.client_phone, + client_id=client.id, + client_name=client.full_name, + client_phone=client.phone, topic_code=payload.topic_code, description=payload.description, extra_fields=payload.extra_fields, @@ -142,10 +187,55 @@ def create_request( db.commit() db.refresh(row) - _set_view_cookie(response, track) + _set_view_cookie(response, client.phone) return PublicRequestCreated(request_id=row.id, track_number=row.track_number, otp_required=False) +@router.get("/topics") +def list_public_topics(db: Session = Depends(get_db)): + rows = ( + db.query(Topic) + .filter(Topic.enabled.is_(True)) + .order_by(Topic.sort_order.asc(), Topic.name.asc(), Topic.code.asc()) + .all() + ) + return [{"code": row.code, "name": row.name} for row in rows] + + +@router.get("/my") +def list_my_requests( + db: Session = Depends(get_db), + session: dict = Depends(get_public_session), +): + subject = _require_view_session_or_403(session) + normalized_track = _normalize_track(subject) + normalized_phone = _normalize_phone(subject) + + query = db.query(Request) + if normalized_track.startswith("TRK-"): + query = query.filter(Request.track_number == normalized_track) + else: + query = query.filter(Request.client_phone == normalized_phone) + + rows = query.order_by(Request.updated_at.desc(), Request.created_at.desc(), Request.id.desc()).all() + return { + "rows": [ + { + "id": str(row.id), + "track_number": row.track_number, + "topic_code": row.topic_code, + "status_code": row.status_code, + "client_has_unread_updates": bool(row.client_has_unread_updates), + "client_unread_event_type": row.client_unread_event_type, + "created_at": _to_iso(row.created_at), + "updated_at": _to_iso(row.updated_at), + } + for row in rows + ], + "total": len(rows), + } + + @router.get("/{track_number}") def get_request_by_track( track_number: str, @@ -167,6 +257,7 @@ def get_request_by_track( return { "id": str(req.id), + "client_id": str(req.client_id) if req.client_id else None, "track_number": req.track_number, "client_name": req.client_name, "client_phone": req.client_phone, @@ -191,12 +282,7 @@ def list_messages_by_track( session: dict = Depends(get_public_session), ): req = _request_for_track_or_404(db, session, track_number) - rows = ( - db.query(Message) - .filter(Message.request_id == req.id) - .order_by(Message.created_at.asc(), Message.id.asc()) - .all() - ) + rows = list_messages_for_request(db, req.id) return [ PublicMessageRead( id=row.id, @@ -219,31 +305,7 @@ def create_message_by_track( session: dict = Depends(get_public_session), ): req = _request_for_track_or_404(db, session, track_number) - body = str(payload.body or "").strip() - if not body: - raise HTTPException(status_code=400, detail='Поле "body" обязательно') - - row = Message( - request_id=req.id, - author_type="CLIENT", - author_name=req.client_name, - body=body, - responsible="Клиент", - ) - mark_unread_for_lawyer(req, EVENT_MESSAGE) - req.responsible = "Клиент" - notify_request_event( - db, - request=req, - event_type=NOTIFICATION_EVENT_MESSAGE, - actor_role="CLIENT", - body=body, - responsible="Клиент", - ) - db.add(row) - db.add(req) - db.commit() - db.refresh(row) + row = create_client_message(db, request=req, body=payload.body) return PublicMessageRead( id=row.id, diff --git a/app/api/public/router.py b/app/api/public/router.py index 846e6d0..60f48e0 100644 --- a/app/api/public/router.py +++ b/app/api/public/router.py @@ -1,8 +1,9 @@ from fastapi import APIRouter -from app.api.public import requests, otp, quotes, uploads +from app.api.public import requests, otp, quotes, uploads, chat router = APIRouter() router.include_router(requests.router, prefix="/requests", tags=["Public"]) router.include_router(otp.router, prefix="/otp", tags=["Public"]) router.include_router(quotes.router, prefix="/quotes", tags=["Public"]) router.include_router(uploads.router, prefix="/uploads", tags=["PublicFiles"]) +router.include_router(chat.router, prefix="/chat", tags=["PublicChat"]) diff --git a/app/api/public/uploads.py b/app/api/public/uploads.py index 5175cab..1f16996 100644 --- a/app/api/public/uploads.py +++ b/app/api/public/uploads.py @@ -48,10 +48,23 @@ def _ensure_public_request_access_or_403(request: Request, session: dict) -> Non purpose = str(session.get("purpose") or "").strip().upper() if purpose != "VIEW_REQUEST": raise HTTPException(status_code=403, detail="Нет доступа к заявке") - track_from_session = str(session.get("sub") or "").strip() - if not track_from_session or track_from_session != str(request.track_number): + subject = str(session.get("sub") or "").strip() + if not subject: raise HTTPException(status_code=403, detail="Нет доступа к заявке") + normalized_track = str(subject).strip().upper() + if normalized_track == str(request.track_number or "").strip().upper(): + return + + def _normalize_phone(value: str | None) -> str: + raw = str(value or "").strip() + allowed = {"+", "(", ")", "-", " "} + return "".join(ch for ch in raw if ch.isdigit() or ch in allowed).strip() + + if _normalize_phone(subject) and _normalize_phone(subject) == _normalize_phone(request.client_phone): + return + raise HTTPException(status_code=403, detail="Нет доступа к заявке") + def _load_attachment_with_access_or_4xx(attachment_id: str, db: Session, session: dict) -> Attachment: attachment_uuid = _uuid_or_400(attachment_id, "attachment_id") diff --git a/app/core/config.py b/app/core/config.py index 07419c9..44ae140 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -32,6 +32,10 @@ class Settings(BaseSettings): OTP_RATE_LIMIT_WINDOW_SECONDS: int = 300 OTP_SEND_RATE_LIMIT: int = 8 OTP_VERIFY_RATE_LIMIT: int = 20 + ADMIN_BOOTSTRAP_ENABLED: bool = True + ADMIN_BOOTSTRAP_EMAIL: str = "admin@example.com" + ADMIN_BOOTSTRAP_PASSWORD: str = "admin123" + ADMIN_BOOTSTRAP_NAME: str = "Администратор системы" @property def cors_origins_list(self) -> List[str]: diff --git a/app/models/client.py b/app/models/client.py new file mode 100644 index 0000000..b1700bf --- /dev/null +++ b/app/models/client.py @@ -0,0 +1,14 @@ +import uuid + +from sqlalchemy import String +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.session import Base +from app.models.common import TimestampMixin, UUIDMixin + + +class Client(Base, UUIDMixin, TimestampMixin): + __tablename__ = "clients" + + full_name: Mapped[str] = mapped_column(String(200), nullable=False) + phone: Mapped[str] = mapped_column(String(30), nullable=False, unique=True, index=True) diff --git a/app/models/invoice.py b/app/models/invoice.py index dcdf521..ec1a72f 100644 --- a/app/models/invoice.py +++ b/app/models/invoice.py @@ -13,6 +13,7 @@ class Invoice(Base, UUIDMixin, TimestampMixin): __tablename__ = "invoices" request_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) + client_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), index=True, nullable=True) 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) diff --git a/app/models/request.py b/app/models/request.py index b7e322e..a25a667 100644 --- a/app/models/request.py +++ b/app/models/request.py @@ -1,6 +1,8 @@ from datetime import datetime +import uuid from sqlalchemy import Boolean, DateTime, Integer, JSON, 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 UUIDMixin, TimestampMixin @@ -8,6 +10,7 @@ from app.models.common import UUIDMixin, TimestampMixin class Request(Base, UUIDMixin, TimestampMixin): __tablename__ = "requests" track_number: Mapped[str] = mapped_column(String(40), unique=True, nullable=False, index=True) + client_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True, index=True) client_name: Mapped[str] = mapped_column(String(200), nullable=False) client_phone: Mapped[str] = mapped_column(String(30), nullable=False, index=True) topic_code: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True) diff --git a/app/models/table_availability.py b/app/models/table_availability.py new file mode 100644 index 0000000..7f29fb8 --- /dev/null +++ b/app/models/table_availability.py @@ -0,0 +1,12 @@ +from sqlalchemy import Boolean, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.session import Base +from app.models.common import TimestampMixin, UUIDMixin + + +class TableAvailability(Base, UUIDMixin, TimestampMixin): + __tablename__ = "table_availability" + + table_name: Mapped[str] = mapped_column(String(120), unique=True, index=True, nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True) diff --git a/app/services/admin_bootstrap.py b/app/services/admin_bootstrap.py new file mode 100644 index 0000000..404efcc --- /dev/null +++ b/app/services/admin_bootstrap.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from sqlalchemy import func +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.core.security import hash_password, verify_password +from app.models.admin_user import AdminUser + + +def normalize_admin_email(raw: str | None) -> str: + return str(raw or "").strip().lower() + + +def get_active_admin_by_email(db: Session, email: str) -> AdminUser | None: + normalized = normalize_admin_email(email) + if not normalized: + return None + return ( + db.query(AdminUser) + .filter(func.lower(AdminUser.email) == normalized, AdminUser.is_active.is_(True)) + .first() + ) + + +def get_admin_by_email_any_state(db: Session, email: str) -> AdminUser | None: + normalized = normalize_admin_email(email) + if not normalized: + return None + return db.query(AdminUser).filter(func.lower(AdminUser.email) == normalized).first() + + +def ensure_bootstrap_admin_for_login(db: Session, email: str, password: str) -> AdminUser | None: + if not settings.ADMIN_BOOTSTRAP_ENABLED: + return None + + normalized_email = normalize_admin_email(email) + bootstrap_email = normalize_admin_email(settings.ADMIN_BOOTSTRAP_EMAIL) + if normalized_email != bootstrap_email: + return None + if str(password or "") != str(settings.ADMIN_BOOTSTRAP_PASSWORD or ""): + return None + + user = get_admin_by_email_any_state(db, bootstrap_email) + if user is None: + user = AdminUser( + role="ADMIN", + name=str(settings.ADMIN_BOOTSTRAP_NAME or "Администратор системы"), + email=bootstrap_email, + password_hash=hash_password(str(settings.ADMIN_BOOTSTRAP_PASSWORD or "")), + is_active=True, + ) + db.add(user) + else: + user.role = "ADMIN" + user.email = bootstrap_email + user.is_active = True + if not str(user.name or "").strip(): + user.name = str(settings.ADMIN_BOOTSTRAP_NAME or "Администратор системы") + if not verify_password(str(settings.ADMIN_BOOTSTRAP_PASSWORD or ""), str(user.password_hash or "")): + user.password_hash = hash_password(str(settings.ADMIN_BOOTSTRAP_PASSWORD or "")) + db.add(user) + + try: + db.commit() + except IntegrityError: + db.rollback() + return get_active_admin_by_email(db, bootstrap_email) + db.refresh(user) + return user diff --git a/app/services/chat_service.py b/app/services/chat_service.py new file mode 100644 index 0000000..b8ebd76 --- /dev/null +++ b/app/services/chat_service.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from app.models.message import Message +from app.models.request import Request +from app.services.notifications import EVENT_MESSAGE as NOTIFICATION_EVENT_MESSAGE, notify_request_event +from app.services.request_read_markers import EVENT_MESSAGE, mark_unread_for_client, mark_unread_for_lawyer + + +def list_messages_for_request(db: Session, request_id: Any) -> list[Message]: + return ( + db.query(Message) + .filter(Message.request_id == request_id) + .order_by(Message.created_at.asc(), Message.id.asc()) + .all() + ) + + +def serialize_message(row: Message) -> dict[str, Any]: + return { + "id": str(row.id), + "request_id": str(row.request_id), + "author_type": row.author_type, + "author_name": row.author_name, + "body": row.body, + "created_at": row.created_at.isoformat() if row.created_at else None, + "updated_at": row.updated_at.isoformat() if row.updated_at else None, + } + + +def create_client_message(db: Session, *, request: Request, body: str) -> Message: + message_body = str(body or "").strip() + if not message_body: + raise HTTPException(status_code=400, detail='Поле "body" обязательно') + + row = Message( + request_id=request.id, + author_type="CLIENT", + author_name=request.client_name, + body=message_body, + responsible="Клиент", + ) + mark_unread_for_lawyer(request, EVENT_MESSAGE) + request.responsible = "Клиент" + notify_request_event( + db, + request=request, + event_type=NOTIFICATION_EVENT_MESSAGE, + actor_role="CLIENT", + body=message_body, + responsible="Клиент", + ) + db.add(row) + db.add(request) + db.commit() + db.refresh(row) + return row + + +def create_admin_or_lawyer_message( + db: Session, + *, + request: Request, + body: str, + actor_role: str, + actor_name: str, + actor_admin_user_id: str | None = None, +) -> Message: + message_body = str(body or "").strip() + if not message_body: + raise HTTPException(status_code=400, detail='Поле "body" обязательно') + + normalized_role = str(actor_role or "").strip().upper() + if normalized_role not in {"ADMIN", "LAWYER"}: + raise HTTPException(status_code=400, detail="Некорректная роль автора сообщения") + author_type = "LAWYER" if normalized_role == "LAWYER" else "SYSTEM" + responsible = str(actor_name or "").strip() or "Администратор системы" + + row = Message( + request_id=request.id, + author_type=author_type, + author_name=str(actor_name or "").strip() or author_type, + body=message_body, + responsible=responsible, + ) + mark_unread_for_client(request, EVENT_MESSAGE) + request.responsible = responsible + notify_request_event( + db, + request=request, + event_type=NOTIFICATION_EVENT_MESSAGE, + actor_role=normalized_role, + actor_admin_user_id=actor_admin_user_id, + body=message_body, + responsible=responsible, + ) + db.add(row) + db.add(request) + db.commit() + db.refresh(row) + return row diff --git a/app/web/admin.css b/app/web/admin.css index 7370a47..1397361 100644 --- a/app/web/admin.css +++ b/app/web/admin.css @@ -90,8 +90,12 @@ flex-direction: column; gap: 0.35rem; padding-left: 0.6rem; + padding-right: 0.2rem; border-left: 1px dashed rgba(212, 168, 106, 0.3); margin: 0.2rem 0 0.1rem 0.2rem; + max-height: 38vh; + overflow-y: auto; + overflow-x: hidden; } .menu-tree button { @@ -210,6 +214,18 @@ font-size: 0.94rem; } + .breadcrumbs { + margin: 0.35rem 0 0; + color: var(--muted); + font-size: 0.86rem; + line-height: 1.4; + } + + .breadcrumbs b { + color: #dce7f7; + font-weight: 700; + } + .cards { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); @@ -607,6 +623,380 @@ word-break: break-word; } + .simple-list { + margin: 0; + padding: 0; + list-style: none; + } + + .simple-list li { + padding: 0.52rem 0.55rem; + border: 1px solid var(--line); + border-radius: 10px; + background: rgba(255, 255, 255, 0.025); + margin-bottom: 0.4rem; + font-size: 0.86rem; + } + + .simple-list li:last-child { + margin-bottom: 0; + } + + .request-modal { + width: min(1120px, 100%); + } + + .request-modal-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.75rem; + } + + .request-workspace-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.7rem; + margin-bottom: 0.8rem; + flex-wrap: wrap; + } + + .request-workspace-layout { + display: grid; + grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.9fr); + gap: 0.75rem; + align-items: start; + } + + .request-main-column { + display: grid; + gap: 0.75rem; + } + + .request-card-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.55rem; + } + + .request-field { + border: 1px solid var(--line); + border-radius: 10px; + background: rgba(255, 255, 255, 0.02); + padding: 0.5rem 0.55rem; + display: grid; + gap: 0.2rem; + } + + .request-field-label { + font-size: 0.72rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #9fb0c5; + font-weight: 700; + } + + .request-field-value { + font-size: 0.9rem; + color: #d8e5f7; + word-break: break-word; + line-height: 1.35; + } + + .request-description-block, + .request-extra-block { + margin-top: 0.7rem; + border: 1px solid var(--line); + border-radius: 10px; + background: rgba(255, 255, 255, 0.02); + padding: 0.58rem; + } + + .request-description-block p { + margin: 0.4rem 0 0; + color: #d8e5f7; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; + font-size: 0.9rem; + } + + .request-extra-list { + margin-top: 0.45rem; + max-height: 220px; + overflow: auto; + } + + .request-status-route { + margin-top: 0.85rem; + padding-top: 0.8rem; + border-top: 1px solid var(--line); + } + + .request-status-route h4 { + margin: 0 0 0.7rem; + font-size: 0.96rem; + } + + .request-route-list { + margin: 0; + padding: 0; + list-style: none; + display: grid; + gap: 0.2rem; + } + + .route-item { + position: relative; + padding: 0 0 0.85rem 2.1rem; + } + + .route-item::before { + content: ""; + position: absolute; + left: 0.56rem; + top: 0.52rem; + bottom: -0.2rem; + width: 2px; + background: #5c6573; + opacity: 0.6; + } + + .route-item:last-child::before { + display: none; + } + + .route-dot { + position: absolute; + left: 0; + top: 0.28rem; + width: 1.12rem; + height: 1.12rem; + border-radius: 50%; + background: #818999; + border: 2px solid rgba(18, 30, 43, 0.95); + box-shadow: 0 0 0 1px rgba(129, 137, 153, 0.35); + } + + .route-item.completed .route-dot, + .route-item.current .route-dot { + background: #3f72ff; + box-shadow: 0 0 0 1px rgba(63, 114, 255, 0.35); + } + + .route-item.completed::before, + .route-item.current::before { + background: #3f72ff; + opacity: 0.85; + } + + .route-body b { + display: block; + font-size: 1.02rem; + color: #ebf2ff; + text-transform: uppercase; + letter-spacing: 0.01em; + } + + .route-body p { + margin: 0.25rem 0 0; + color: #9fb0c5; + line-height: 1.45; + font-size: 0.9rem; + white-space: pre-wrap; + } + + .route-time { + margin-top: 0.22rem; + font-size: 0.78rem; + } + + .request-modal-list { + max-height: 220px; + overflow: auto; + margin-bottom: 0.75rem; + } + + .request-modal-item-meta { + margin-top: 0.18rem; + font-size: 0.78rem; + } + + .request-file-actions { + margin-top: 0.45rem; + display: flex; + gap: 0.45rem; + flex-wrap: wrap; + } + + .request-file-link { + text-decoration: none; + display: inline-flex; + align-items: center; + justify-content: center; + } + + .request-attachments-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.6rem; + margin-bottom: 0.65rem; + } + + .request-upload-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; + } + + .request-chat-block .request-modal-list { + max-height: 480px; + } + + .request-chat-list { + max-height: 520px; + overflow: auto; + display: flex; + flex-direction: column; + gap: 0.28rem; + padding: 0.45rem 0.28rem 0.28rem; + border: 1px solid var(--line); + border-radius: 12px; + background: + radial-gradient(ellipse at top left, rgba(82, 109, 156, 0.16), transparent 45%), + radial-gradient(ellipse at bottom right, rgba(57, 86, 126, 0.12), transparent 45%), + rgba(15, 23, 34, 0.72); + } + + .request-chat-list .chat-message { + padding: 0; + margin: 0; + border: none; + background: transparent; + display: flex; + flex-direction: column; + gap: 0.2rem; + max-width: min(79%, 680px); + } + + .request-chat-list .chat-message.incoming { + align-self: flex-start; + align-items: flex-start; + } + + .request-chat-list .chat-message.outgoing { + align-self: flex-end; + align-items: flex-end; + } + + .chat-message-author { + font-size: 0.72rem; + color: #9db3cf; + line-height: 1.3; + padding-inline: 0.3rem; + } + + .chat-message-bubble { + border: 1px solid #44556f; + border-radius: 15px 15px 15px 6px; + background: linear-gradient(165deg, rgba(39, 52, 69, 0.94), rgba(24, 35, 49, 0.98)); + padding: 0.5rem 0.62rem 0.44rem; + min-width: 120px; + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.16); + } + + .chat-message.outgoing .chat-message-bubble { + border-color: #5f86d1; + border-radius: 15px 15px 6px 15px; + background: linear-gradient(165deg, rgba(63, 98, 169, 0.94), rgba(44, 73, 130, 0.98)); + } + + .chat-message-text { + margin: 0; + font-size: 0.9rem; + line-height: 1.43; + color: #e6eef9; + white-space: pre-wrap; + word-break: break-word; + } + + .chat-message-time { + margin-top: 0.32rem; + font-size: 0.74rem; + color: #aab9cc; + text-align: right; + } + + .chat-date-divider { + margin: 0.32rem 0 0.24rem; + padding: 0; + border: none; + background: transparent; + display: flex; + justify-content: center; + } + + .chat-date-divider span { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.16rem 0.56rem; + border-radius: 999px; + border: 1px solid rgba(131, 151, 178, 0.34); + background: rgba(46, 61, 84, 0.5); + color: #b8c9df; + font-size: 0.72rem; + letter-spacing: 0.02em; + line-height: 1.2; + } + + .request-preview-modal { + width: min(980px, 100%); + } + + .request-preview-body { + width: 100%; + min-height: 280px; + max-height: calc(92vh - 90px); + overflow: auto; + border: 1px solid var(--line); + border-radius: 12px; + background: #0f1722; + display: grid; + place-items: center; + gap: 0.7rem; + padding: 0.45rem; + } + + .request-preview-frame { + width: 100%; + height: min(72vh, 760px); + border: none; + } + + .request-preview-image { + max-width: 100%; + max-height: 72vh; + object-fit: contain; + } + + .request-preview-video { + width: min(100%, 860px); + max-height: 72vh; + } + + .request-preview-note { + color: var(--muted); + text-align: center; + margin: 0; + } + + .request-preview-download { + text-decoration: none; + } + .overlay { position: fixed; inset: 0; @@ -688,6 +1078,9 @@ .filters { grid-template-columns: repeat(2, minmax(0, 1fr)); } .triple { grid-template-columns: 1fr; } .config-layout { grid-template-columns: 1fr; } + .request-modal-grid { grid-template-columns: 1fr; } + .request-workspace-layout { grid-template-columns: 1fr; } + .request-card-grid { grid-template-columns: 1fr; } } @media (max-width: 920px) { diff --git a/app/web/admin.html b/app/web/admin.html index bb9e9ff..7099c96 100644 --- a/app/web/admin.html +++ b/app/web/admin.html @@ -4,12 +4,12 @@ Административная панель • Правовой трекер - +
- + diff --git a/app/web/admin.jsx b/app/web/admin.jsx index d25d09b..bcd4ec8 100644 --- a/app/web/admin.jsx +++ b/app/web/admin.jsx @@ -151,6 +151,29 @@ }; } + function createRequestModalState() { + return { + loading: false, + requestId: null, + trackNumber: "", + requestData: null, + statusRouteNodes: [], + messages: [], + attachments: [], + messageDraft: "", + selectedFile: null, + fileUploading: false, + }; + } + + function resolveAdminRoute(search) { + const params = new URLSearchParams(String(search || "")); + const section = String(params.get("section") || "").trim(); + const view = String(params.get("view") || "").trim(); + const requestId = String(params.get("requestId") || "").trim(); + return { section, view, requestId }; + } + function humanizeKey(value) { const text = String(value || "") .replace(/[_-]+/g, " ") @@ -224,6 +247,42 @@ return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString("ru-RU"); } + function fmtDateOnly(value) { + if (!value) return "-"; + const date = new Date(value); + return Number.isNaN(date.getTime()) + ? String(value) + : date.toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit", year: "numeric" }); + } + + function fmtTimeOnly(value) { + if (!value) return "-"; + const date = new Date(value); + return Number.isNaN(date.getTime()) + ? String(value) + : date.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" }); + } + + function fmtAmount(value) { + if (value === null || value === undefined || value === "") return "-"; + const numeric = Number(value); + if (!Number.isFinite(numeric)) return String(value); + return numeric.toLocaleString("ru-RU"); + } + + function fmtBytes(value) { + const size = Number(value || 0); + if (!Number.isFinite(size) || size <= 0) return "0 Б"; + const units = ["Б", "КБ", "МБ", "ГБ"]; + let index = 0; + let normalized = size; + while (normalized >= 1024 && index < units.length - 1) { + normalized /= 1024; + index += 1; + } + return normalized.toLocaleString("ru-RU", { maximumFractionDigits: index === 0 ? 0 : 1 }) + " " + units[index]; + } + function userInitials(name, email) { const source = String(name || "").trim(); if (source) { @@ -254,6 +313,21 @@ return raw; } + function resolveAdminObjectSrc(s3Key, accessToken) { + const key = String(s3Key || "").trim(); + if (!key || !accessToken) return ""; + return "/api/admin/uploads/object/" + encodeURIComponent(key) + "?token=" + encodeURIComponent(accessToken); + } + + function detectAttachmentPreviewKind(fileName, mimeType) { + const name = String(fileName || "").toLowerCase(); + const mime = String(mimeType || "").toLowerCase(); + if (mime.startsWith("image/") || /\.(png|jpe?g|gif|webp|bmp|svg)$/.test(name)) return "image"; + if (mime.startsWith("video/") || /\.(mp4|webm|ogg|mov|m4v)$/.test(name)) return "video"; + if (mime === "application/pdf" || /\.pdf$/.test(name)) return "pdf"; + return "none"; + } + function buildUniversalQuery(filters, sort, limit, offset) { return { filters: filters || [], @@ -263,7 +337,7 @@ } function canAccessSection(role, section) { - if (section === "quotes" || section === "config") return role === "ADMIN"; + if (section === "quotes" || section === "config" || section === "availableTables") return role === "ADMIN"; return true; } @@ -514,8 +588,13 @@ } function IconButton({ icon, tooltip, onClick, tone }) { + const handleClick = (event) => { + event.preventDefault(); + event.stopPropagation(); + if (typeof onClick === "function") onClick(event); + }; return ( - ); @@ -737,28 +816,312 @@ ); } - function RequestModal({ open, jsonText, onClose }) { - if (!open) return null; + function AttachmentPreviewModal({ open, title, url, fileName, mimeType, onClose }) { + if (!open || !url) return null; + const kind = detectAttachmentPreviewKind(fileName, mimeType); return ( - event.target.id === "request-overlay" && onClose()}> -
event.stopPropagation()}> + event.target.id === "request-file-preview-overlay" && onClose()}> +
event.stopPropagation()}>
-
-

Детали заявки

-

- Подробная карточка заявки. -

-
+

{title || fileName || "Предпросмотр файла"}

-
{jsonText}
+
+ {kind === "image" ? {fileName : null} + {kind === "video" ?