Second commit

This commit is contained in:
TronoSfera 2026-02-23 15:20:00 +03:00
parent 112ab43b34
commit fb13d93ab3
78 changed files with 13202 additions and 357 deletions

View file

@ -6,3 +6,6 @@ migrate:
test:
docker compose exec backend python -m unittest discover -s tests -p "test_*.py" -v
seed-quotes:
docker compose exec backend python -m app.scripts.upsert_quotes

View file

@ -15,3 +15,9 @@ Swagger: http://localhost:8002/docs
```bash
docker compose exec backend alembic upgrade head
```
## Seed Quotes (Upsert)
```bash
make seed-quotes
```
Loads 50 justice-themed quotes into `quotes` with idempotent upsert by `(author, text)`.

View file

@ -16,6 +16,8 @@ from app.models.status_history import StatusHistory
from app.models.audit_log import AuditLog
from app.models.otp_session import OtpSession
from app.models.quote import Quote
from app.models.admin_user_topic import AdminUserTopic
from app.models.notification import Notification
config = context.config
fileConfig(config.config_file_name)

View file

@ -0,0 +1,46 @@
"""add responsible to all tables
Revision ID: 0002_add_responsible
Revises: 0001_init
Create Date: 2026-02-22
"""
from alembic import op
import sqlalchemy as sa
revision = "0002_add_responsible"
down_revision = "0001_init"
branch_labels = None
depends_on = None
TABLES = [
"admin_users",
"topics",
"statuses",
"form_fields",
"requests",
"messages",
"attachments",
"status_history",
"audit_log",
"otp_sessions",
"quotes",
]
def upgrade():
for table in TABLES:
op.add_column(
table,
sa.Column(
"responsible",
sa.String(length=200),
nullable=False,
server_default=sa.text("'Администратор системы'"),
),
)
def downgrade():
for table in reversed(TABLES):
op.drop_column(table, "responsible")

View file

@ -0,0 +1,24 @@
"""add primary topic profile to admin users
Revision ID: 0003_admin_user_primary_topic
Revises: 0002_add_responsible
Create Date: 2026-02-22
"""
from alembic import op
import sqlalchemy as sa
revision = "0003_admin_user_primary_topic"
down_revision = "0002_add_responsible"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("admin_users", sa.Column("primary_topic_code", sa.String(length=50), nullable=True))
op.create_index("ix_admin_users_primary_topic_code", "admin_users", ["primary_topic_code"])
def downgrade():
op.drop_index("ix_admin_users_primary_topic_code", table_name="admin_users")
op.drop_column("admin_users", "primary_topic_code")

View file

@ -0,0 +1,22 @@
"""add avatar url to admin users
Revision ID: 0004_admin_user_avatar
Revises: 0003_admin_user_primary_topic
Create Date: 2026-02-22
"""
from alembic import op
import sqlalchemy as sa
revision = "0004_admin_user_avatar"
down_revision = "0003_admin_user_primary_topic"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("admin_users", sa.Column("avatar_url", sa.String(length=500), nullable=True))
def downgrade():
op.drop_column("admin_users", "avatar_url")

View file

@ -0,0 +1,41 @@
"""add admin user topics relation table
Revision ID: 0005_admin_user_topics
Revises: 0004_admin_user_avatar
Create Date: 2026-02-22
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "0005_admin_user_topics"
down_revision = "0004_admin_user_avatar"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"admin_user_topics",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"responsible",
sa.String(length=200),
nullable=False,
server_default=sa.text("'Администратор системы'"),
),
sa.Column("admin_user_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("topic_code", sa.String(length=50), nullable=False),
sa.UniqueConstraint("admin_user_id", "topic_code", name="uq_admin_user_topics_user_topic"),
)
op.create_index("ix_admin_user_topics_admin_user_id", "admin_user_topics", ["admin_user_id"])
op.create_index("ix_admin_user_topics_topic_code", "admin_user_topics", ["topic_code"])
def downgrade():
op.drop_index("ix_admin_user_topics_topic_code", table_name="admin_user_topics")
op.drop_index("ix_admin_user_topics_admin_user_id", table_name="admin_user_topics")
op.drop_table("admin_user_topics")

View file

@ -0,0 +1,34 @@
"""add request read/unread markers
Revision ID: 0006_request_read_markers
Revises: 0005_admin_user_topics
Create Date: 2026-02-23
"""
from alembic import op
import sqlalchemy as sa
revision = "0006_request_read_markers"
down_revision = "0005_admin_user_topics"
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"requests",
sa.Column("client_has_unread_updates", sa.Boolean(), nullable=False, server_default=sa.text("false")),
)
op.add_column("requests", sa.Column("client_unread_event_type", sa.String(length=32), nullable=True))
op.add_column(
"requests",
sa.Column("lawyer_has_unread_updates", sa.Boolean(), nullable=False, server_default=sa.text("false")),
)
op.add_column("requests", sa.Column("lawyer_unread_event_type", sa.String(length=32), nullable=True))
def downgrade():
op.drop_column("requests", "lawyer_unread_event_type")
op.drop_column("requests", "lawyer_has_unread_updates")
op.drop_column("requests", "client_unread_event_type")
op.drop_column("requests", "client_has_unread_updates")

View file

@ -0,0 +1,51 @@
"""add topic status transitions
Revision ID: 0007_topic_status_transitions
Revises: 0006_request_read_markers
Create Date: 2026-02-23
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "0007_topic_status_transitions"
down_revision = "0006_request_read_markers"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"topic_status_transitions",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"responsible",
sa.String(length=200),
nullable=False,
server_default=sa.text("'Администратор системы'"),
),
sa.Column("topic_code", sa.String(length=50), nullable=False),
sa.Column("from_status", sa.String(length=50), nullable=False),
sa.Column("to_status", sa.String(length=50), nullable=False),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
sa.UniqueConstraint(
"topic_code",
"from_status",
"to_status",
name="uq_topic_status_transitions_topic_from_to",
),
)
op.create_index("ix_topic_status_transitions_topic_code", "topic_status_transitions", ["topic_code"])
op.create_index("ix_topic_status_transitions_from_status", "topic_status_transitions", ["from_status"])
op.create_index("ix_topic_status_transitions_to_status", "topic_status_transitions", ["to_status"])
def downgrade():
op.drop_index("ix_topic_status_transitions_to_status", table_name="topic_status_transitions")
op.drop_index("ix_topic_status_transitions_from_status", table_name="topic_status_transitions")
op.drop_index("ix_topic_status_transitions_topic_code", table_name="topic_status_transitions")
op.drop_table("topic_status_transitions")

View file

@ -0,0 +1,100 @@
"""add topic/request template tables
Revision ID: 0008_request_templates
Revises: 0007_topic_status_transitions
Create Date: 2026-02-23
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "0008_request_templates"
down_revision = "0007_topic_status_transitions"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"topic_required_fields",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"responsible",
sa.String(length=200),
nullable=False,
server_default=sa.text("'Администратор системы'"),
),
sa.Column("topic_code", sa.String(length=50), nullable=False),
sa.Column("field_key", sa.String(length=80), nullable=False),
sa.Column("required", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
sa.UniqueConstraint("topic_code", "field_key", name="uq_topic_required_fields_topic_field"),
)
op.create_index("ix_topic_required_fields_topic_code", "topic_required_fields", ["topic_code"])
op.create_index("ix_topic_required_fields_field_key", "topic_required_fields", ["field_key"])
op.create_table(
"topic_data_templates",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"responsible",
sa.String(length=200),
nullable=False,
server_default=sa.text("'Администратор системы'"),
),
sa.Column("topic_code", sa.String(length=50), nullable=False),
sa.Column("key", sa.String(length=80), nullable=False),
sa.Column("label", sa.String(length=200), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("required", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
sa.UniqueConstraint("topic_code", "key", name="uq_topic_data_templates_topic_key"),
)
op.create_index("ix_topic_data_templates_topic_code", "topic_data_templates", ["topic_code"])
op.create_index("ix_topic_data_templates_key", "topic_data_templates", ["key"])
op.create_table(
"request_data_requirements",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"responsible",
sa.String(length=200),
nullable=False,
server_default=sa.text("'Администратор системы'"),
),
sa.Column("request_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("topic_template_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("key", sa.String(length=80), nullable=False),
sa.Column("label", sa.String(length=200), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("required", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column("created_by_admin_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.UniqueConstraint("request_id", "key", name="uq_request_data_requirements_request_key"),
)
op.create_index("ix_request_data_requirements_request_id", "request_data_requirements", ["request_id"])
op.create_index("ix_request_data_requirements_topic_template_id", "request_data_requirements", ["topic_template_id"])
op.create_index("ix_request_data_requirements_key", "request_data_requirements", ["key"])
def downgrade():
op.drop_index("ix_request_data_requirements_key", table_name="request_data_requirements")
op.drop_index("ix_request_data_requirements_topic_template_id", table_name="request_data_requirements")
op.drop_index("ix_request_data_requirements_request_id", table_name="request_data_requirements")
op.drop_table("request_data_requirements")
op.drop_index("ix_topic_data_templates_key", table_name="topic_data_templates")
op.drop_index("ix_topic_data_templates_topic_code", table_name="topic_data_templates")
op.drop_table("topic_data_templates")
op.drop_index("ix_topic_required_fields_field_key", table_name="topic_required_fields")
op.drop_index("ix_topic_required_fields_topic_code", table_name="topic_required_fields")
op.drop_table("topic_required_fields")

View file

@ -0,0 +1,28 @@
"""add sla_hours to topic status transitions
Revision ID: 0009_sla_transition_config
Revises: 0008_request_templates
Create Date: 2026-02-23
"""
from alembic import op
import sqlalchemy as sa
revision = "0009_sla_transition_config"
down_revision = "0008_request_templates"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("topic_status_transitions", sa.Column("sla_hours", sa.Integer(), nullable=True))
op.create_check_constraint(
"ck_topic_status_transitions_sla_hours_positive",
"topic_status_transitions",
"sla_hours IS NULL OR sla_hours > 0",
)
def downgrade():
op.drop_constraint("ck_topic_status_transitions_sla_hours_positive", "topic_status_transitions", type_="check")
op.drop_column("topic_status_transitions", "sla_hours")

View file

@ -0,0 +1,68 @@
"""add notifications table
Revision ID: 0010_notifications
Revises: 0009_sla_transition_config
Create Date: 2026-02-23
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "0010_notifications"
down_revision = "0009_sla_transition_config"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"notifications",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("responsible", sa.String(length=200), nullable=False, server_default="Администратор системы"),
sa.Column("request_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("recipient_type", sa.String(length=20), nullable=False),
sa.Column("recipient_admin_user_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("recipient_track_number", sa.String(length=40), nullable=True),
sa.Column("event_type", sa.String(length=50), nullable=False),
sa.Column("title", sa.String(length=200), nullable=False),
sa.Column("body", sa.Text(), nullable=True),
sa.Column("payload", sa.JSON(), nullable=False, server_default=sa.text("'{}'::json")),
sa.Column("is_read", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("read_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("dedupe_key", sa.String(length=255), nullable=True, unique=True),
)
op.create_index("ix_notifications_request_id", "notifications", ["request_id"])
op.create_index("ix_notifications_recipient_type", "notifications", ["recipient_type"])
op.create_index("ix_notifications_recipient_admin_user_id", "notifications", ["recipient_admin_user_id"])
op.create_index("ix_notifications_recipient_track_number", "notifications", ["recipient_track_number"])
op.create_index("ix_notifications_event_type", "notifications", ["event_type"])
op.create_index("ix_notifications_is_read", "notifications", ["is_read"])
op.create_check_constraint(
"ck_notifications_recipient_type",
"notifications",
"recipient_type IN ('CLIENT','ADMIN_USER')",
)
op.create_check_constraint(
"ck_notifications_recipient_binding",
"notifications",
"("
"(recipient_type = 'CLIENT' AND recipient_track_number IS NOT NULL AND recipient_admin_user_id IS NULL)"
" OR "
"(recipient_type = 'ADMIN_USER' AND recipient_admin_user_id IS NOT NULL AND recipient_track_number IS NULL)"
")",
)
def downgrade():
op.drop_constraint("ck_notifications_recipient_binding", "notifications", type_="check")
op.drop_constraint("ck_notifications_recipient_type", "notifications", type_="check")
op.drop_index("ix_notifications_is_read", table_name="notifications")
op.drop_index("ix_notifications_event_type", table_name="notifications")
op.drop_index("ix_notifications_recipient_track_number", table_name="notifications")
op.drop_index("ix_notifications_recipient_admin_user_id", table_name="notifications")
op.drop_index("ix_notifications_recipient_type", table_name="notifications")
op.drop_index("ix_notifications_request_id", table_name="notifications")
op.drop_table("notifications")

View file

@ -0,0 +1,54 @@
"""add financial fields for dashboard metrics
Revision ID: 0011_dashboard_financial_fields
Revises: 0010_notifications
Create Date: 2026-02-23
"""
from alembic import op
import sqlalchemy as sa
revision = "0011_dashboard_financial_fields"
down_revision = "0010_notifications"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("admin_users", sa.Column("default_rate", sa.Numeric(12, 2), nullable=True))
op.add_column("admin_users", sa.Column("salary_percent", sa.Numeric(5, 2), nullable=True))
op.create_check_constraint(
"ck_admin_users_salary_percent_range",
"admin_users",
"salary_percent IS NULL OR (salary_percent >= 0 AND salary_percent <= 100)",
)
op.add_column("requests", sa.Column("effective_rate", sa.Numeric(12, 2), nullable=True))
op.add_column("requests", sa.Column("invoice_amount", sa.Numeric(14, 2), nullable=True))
op.add_column("requests", sa.Column("paid_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("requests", sa.Column("paid_by_admin_id", sa.String(length=64), nullable=True))
op.create_index("ix_requests_paid_at", "requests", ["paid_at"])
op.create_check_constraint(
"ck_requests_invoice_amount_non_negative",
"requests",
"invoice_amount IS NULL OR invoice_amount >= 0",
)
op.create_check_constraint(
"ck_requests_effective_rate_non_negative",
"requests",
"effective_rate IS NULL OR effective_rate >= 0",
)
def downgrade():
op.drop_constraint("ck_requests_effective_rate_non_negative", "requests", type_="check")
op.drop_constraint("ck_requests_invoice_amount_non_negative", "requests", type_="check")
op.drop_index("ix_requests_paid_at", table_name="requests")
op.drop_column("requests", "paid_by_admin_id")
op.drop_column("requests", "paid_at")
op.drop_column("requests", "invoice_amount")
op.drop_column("requests", "effective_rate")
op.drop_constraint("ck_admin_users_salary_percent_range", "admin_users", type_="check")
op.drop_column("admin_users", "salary_percent")
op.drop_column("admin_users", "default_rate")

View file

@ -50,7 +50,8 @@ def query_topics(uq: UniversalQuery, db: Session = Depends(get_db), admin=Depend
@router.post("/topics", status_code=201)
def create_topic(payload: TopicUpsert, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN"))):
row = Topic(**payload.model_dump())
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
row = Topic(**payload.model_dump(), responsible=responsible)
try:
db.add(row)
db.commit()
@ -97,7 +98,8 @@ def query_statuses(uq: UniversalQuery, db: Session = Depends(get_db), admin=Depe
@router.post("/statuses", status_code=201)
def create_status(payload: StatusUpsert, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN"))):
row = Status(**payload.model_dump())
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
row = Status(**payload.model_dump(), responsible=responsible)
try:
db.add(row)
db.commit()
@ -144,7 +146,8 @@ def query_form_fields(uq: UniversalQuery, db: Session = Depends(get_db), admin=D
@router.post("/form-fields", status_code=201)
def create_form_field(payload: FormFieldUpsert, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN"))):
row = FormField(**payload.model_dump())
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
row = FormField(**payload.model_dump(), responsible=responsible)
try:
db.add(row)
db.commit()

871
app/api/admin/crud.py Normal file
View file

@ -0,0 +1,871 @@
from __future__ import annotations
import importlib
import pkgutil
import uuid
from datetime import date, datetime
from decimal import Decimal
from functools import lru_cache
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.exc import IntegrityError
from sqlalchemy.inspection import inspect as sa_inspect
from sqlalchemy.orm import Session
import app.models as models_pkg
from app.core.deps import get_current_admin
from app.core.security import hash_password
from app.db.session import Base, get_db
from app.models.admin_user import AdminUser
from app.models.audit_log import AuditLog
from app.models.form_field import FormField
from app.models.request_data_requirement import RequestDataRequirement
from app.models.attachment import Attachment
from app.models.message import Message
from app.models.request import Request
from app.models.status import Status
from app.models.topic_data_template import TopicDataTemplate
from app.models.topic_required_field import TopicRequiredField
from app.models.topic import Topic
from app.schemas.universal import UniversalQuery
from app.services.notifications import (
EVENT_ATTACHMENT as NOTIFICATION_EVENT_ATTACHMENT,
EVENT_MESSAGE as NOTIFICATION_EVENT_MESSAGE,
EVENT_STATUS as NOTIFICATION_EVENT_STATUS,
mark_admin_notifications_read,
notify_request_event,
)
from app.services.request_read_markers import (
EVENT_ATTACHMENT,
EVENT_MESSAGE,
EVENT_STATUS,
clear_unread_for_lawyer,
mark_unread_for_client,
mark_unread_for_lawyer,
)
from app.services.request_status import apply_status_change_effects
from app.services.status_flow import transition_allowed_for_topic
from app.services.request_templates import validate_required_topic_fields_or_400
from app.services.universal_query import apply_universal_query
router = APIRouter()
CRUD_ACTIONS = {"query", "read", "create", "update", "delete"}
SYSTEM_FIELDS = {
"id",
"created_at",
"updated_at",
"responsible",
"client_has_unread_updates",
"client_unread_event_type",
"lawyer_has_unread_updates",
"lawyer_unread_event_type",
}
ALLOWED_ADMIN_ROLES = {"ADMIN", "LAWYER"}
# Per-table RBAC: table -> role -> actions.
# If a table is missing here, fallback rules are used.
TABLE_ROLE_ACTIONS: dict[str, dict[str, set[str]]] = {
"requests": {
"ADMIN": set(CRUD_ACTIONS),
"LAWYER": set(CRUD_ACTIONS),
},
"quotes": {"ADMIN": set(CRUD_ACTIONS)},
"topics": {"ADMIN": set(CRUD_ACTIONS)},
"statuses": {"ADMIN": set(CRUD_ACTIONS)},
"form_fields": {"ADMIN": set(CRUD_ACTIONS)},
"audit_log": {"ADMIN": {"query", "read"}},
"otp_sessions": {"ADMIN": {"query", "read"}},
"admin_users": {"ADMIN": set(CRUD_ACTIONS)},
"admin_user_topics": {"ADMIN": set(CRUD_ACTIONS)},
"topic_status_transitions": {"ADMIN": set(CRUD_ACTIONS)},
"topic_required_fields": {"ADMIN": set(CRUD_ACTIONS)},
"topic_data_templates": {"ADMIN": set(CRUD_ACTIONS)},
"request_data_requirements": {"ADMIN": set(CRUD_ACTIONS)},
"notifications": {"ADMIN": {"query", "read", "update"}},
}
DEFAULT_ROLE_ACTIONS: dict[str, set[str]] = {
"ADMIN": set(CRUD_ACTIONS),
}
def _normalize_table_name(table_name: str) -> str:
raw = (table_name or "").strip().replace("-", "_")
if not raw:
return ""
chars: list[str] = []
for index, ch in enumerate(raw):
if ch.isupper() and index > 0 and raw[index - 1].isalnum() and raw[index - 1] != "_":
chars.append("_")
chars.append(ch.lower())
return "".join(chars)
@lru_cache(maxsize=1)
def _table_model_map() -> dict[str, type]:
for module in pkgutil.iter_modules(models_pkg.__path__):
if module.name.startswith("_"):
continue
importlib.import_module(f"{models_pkg.__name__}.{module.name}")
return {
mapper.class_.__tablename__: mapper.class_
for mapper in Base.registry.mappers
if getattr(mapper.class_, "__tablename__", None)
}
def _resolve_table_model(table_name: str) -> tuple[str, type]:
normalized = _normalize_table_name(table_name)
model = _table_model_map().get(normalized)
if model is None:
raise HTTPException(status_code=404, detail="Таблица не найдена")
return normalized, model
def _allowed_actions(role: str, table_name: str) -> set[str]:
per_table = TABLE_ROLE_ACTIONS.get(table_name)
if per_table is not None:
return set(per_table.get(role, set()))
return set(DEFAULT_ROLE_ACTIONS.get(role, set()))
def _require_table_action(admin: dict, table_name: str, action: str) -> None:
role = str(admin.get("role") or "").upper()
allowed = _allowed_actions(role, table_name)
if action not in allowed:
raise HTTPException(status_code=403, detail="Недостаточно прав")
def _is_lawyer(admin: dict) -> bool:
return str(admin.get("role") or "").upper() == "LAWYER"
def _serialize_value(value: Any) -> Any:
if isinstance(value, dict):
return {key: _serialize_value(val) for key, val in value.items()}
if isinstance(value, list):
return [_serialize_value(item) for item in value]
if isinstance(value, tuple):
return [_serialize_value(item) for item in value]
if isinstance(value, (datetime, date)):
return value.isoformat()
if isinstance(value, uuid.UUID):
return str(value)
if isinstance(value, Decimal):
return float(value)
return value
def _row_to_dict(row: Any) -> dict[str, Any]:
mapper = sa_inspect(type(row))
return {column.key: _serialize_value(getattr(row, column.key)) for column in mapper.columns}
def _columns_map(model: type) -> dict[str, Any]:
mapper = sa_inspect(model)
return {column.key: column for column in mapper.columns}
def _hidden_response_fields(table_name: str) -> set[str]:
if table_name == "admin_users":
return {"password_hash"}
return set()
def _protected_input_fields(table_name: str) -> set[str]:
if table_name == "admin_users":
return {"password_hash"}
return set()
def _sanitize_payload(
model: type,
table_name: str,
payload: dict[str, Any],
*,
is_update: bool,
allow_protected_fields: set[str] | None = None,
) -> dict[str, Any]:
if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail="Тело запроса должно быть JSON-объектом")
columns = _columns_map(model)
allowed_hidden = set(allow_protected_fields or set())
mutable_columns = {
name
for name in columns.keys()
if name not in SYSTEM_FIELDS and (name not in _protected_input_fields(table_name) or name in allowed_hidden)
}
unknown_fields = sorted(set(payload.keys()) - mutable_columns)
if unknown_fields:
raise HTTPException(status_code=400, detail="Неизвестные поля: " + ", ".join(unknown_fields))
cleaned: dict[str, Any] = {}
for key, value in payload.items():
column = columns[key]
if value is None and not column.nullable:
raise HTTPException(status_code=400, detail=f'Поле "{key}" не может быть null')
cleaned[key] = value
if is_update:
if not cleaned:
raise HTTPException(status_code=400, detail="Нет полей для обновления")
return cleaned
required_missing: list[str] = []
for name, column in columns.items():
if name in SYSTEM_FIELDS:
continue
if column.nullable:
continue
if column.default is not None or column.server_default is not None:
continue
if name not in cleaned:
required_missing.append(name)
if required_missing:
raise HTTPException(status_code=400, detail="Отсутствуют обязательные поля: " + ", ".join(sorted(required_missing)))
return cleaned
def _pk_value(model: type, row_id: str) -> Any:
pk = sa_inspect(model).primary_key
if len(pk) != 1:
raise HTTPException(status_code=400, detail="Поддерживаются только таблицы с одним первичным ключом")
pk_column = pk[0]
try:
python_type = pk_column.type.python_type
except Exception:
python_type = str
if python_type is uuid.UUID:
try:
return uuid.UUID(str(row_id))
except ValueError:
raise HTTPException(status_code=400, detail="Некорректный идентификатор")
return row_id
def _load_row_or_404(db: Session, model: type, row_id: str):
entity = db.get(model, _pk_value(model, row_id))
if entity is None:
raise HTTPException(status_code=404, detail="Запись не найдена")
return entity
def _prepare_create_payload(table_name: str, payload: dict[str, Any]) -> dict[str, Any]:
data = dict(payload)
if table_name == "requests":
track_number = str(data.get("track_number") or "").strip()
data["track_number"] = track_number or f"TRK-{uuid.uuid4().hex[:10].upper()}"
if data.get("extra_fields") is None:
data["extra_fields"] = {}
return data
def _normalize_optional_string(value: Any) -> str | None:
text = str(value or "").strip()
return text or None
def _apply_admin_user_fields_for_create(payload: dict[str, Any]) -> dict[str, Any]:
data = dict(payload)
if "password_hash" in data:
raise HTTPException(status_code=400, detail='Поле "password_hash" недоступно для записи')
raw_password = str(data.pop("password", "")).strip()
if not raw_password:
raise HTTPException(status_code=400, detail="Пароль обязателен")
role = str(data.get("role") or "").strip().upper()
if role not in ALLOWED_ADMIN_ROLES:
raise HTTPException(status_code=400, detail="Некорректная роль")
email = str(data.get("email") or "").strip().lower()
if not email:
raise HTTPException(status_code=400, detail="Email обязателен")
data["email"] = email
data["role"] = role
data["avatar_url"] = _normalize_optional_string(data.get("avatar_url"))
data["primary_topic_code"] = _normalize_optional_string(data.get("primary_topic_code"))
data["password_hash"] = hash_password(raw_password)
return data
def _apply_admin_user_fields_for_update(payload: dict[str, Any]) -> dict[str, Any]:
data = dict(payload)
if "password_hash" in data:
raise HTTPException(status_code=400, detail='Поле "password_hash" недоступно для записи')
if "password" in data:
raw_password = str(data.pop("password") or "").strip()
if not raw_password:
raise HTTPException(status_code=400, detail="Пароль не может быть пустым")
data["password_hash"] = hash_password(raw_password)
if "role" in data:
role = str(data.get("role") or "").strip().upper()
if role not in ALLOWED_ADMIN_ROLES:
raise HTTPException(status_code=400, detail="Некорректная роль")
data["role"] = role
if "email" in data:
email = str(data.get("email") or "").strip().lower()
if not email:
raise HTTPException(status_code=400, detail="Email не может быть пустым")
data["email"] = email
if "avatar_url" in data:
data["avatar_url"] = _normalize_optional_string(data.get("avatar_url"))
if "primary_topic_code" in data:
data["primary_topic_code"] = _normalize_optional_string(data.get("primary_topic_code"))
return data
def _parse_uuid_or_400(value: Any, field_name: str) -> uuid.UUID:
try:
return uuid.UUID(str(value))
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail=f'Поле "{field_name}" должно быть UUID')
def _apply_admin_user_topics_fields(db: Session, payload: dict[str, Any]) -> dict[str, Any]:
data = dict(payload)
if "admin_user_id" in data:
user_id = _parse_uuid_or_400(data.get("admin_user_id"), "admin_user_id")
user = db.get(AdminUser, user_id)
if user is None:
raise HTTPException(status_code=400, detail="Пользователь не найден")
if str(user.role or "").upper() != "LAWYER":
raise HTTPException(status_code=400, detail="Дополнительные темы доступны только для юриста")
data["admin_user_id"] = user_id
if "topic_code" in data:
topic_code = str(data.get("topic_code") or "").strip()
if not topic_code:
raise HTTPException(status_code=400, detail='Поле "topic_code" не может быть пустым')
topic_exists = db.query(Topic.id).filter(Topic.code == topic_code).first()
if topic_exists is None:
raise HTTPException(status_code=400, detail="Тема не найдена")
data["topic_code"] = topic_code
return data
def _ensure_topic_exists_or_400(db: Session, topic_code: str) -> None:
exists = db.query(Topic.id).filter(Topic.code == topic_code).first()
if exists is None:
raise HTTPException(status_code=400, detail="Тема не найдена")
def _ensure_form_field_exists_or_400(db: Session, field_key: str) -> None:
exists = db.query(FormField.id).filter(FormField.key == field_key).first()
if exists is None:
raise HTTPException(status_code=400, detail="Поле формы не найдено")
def _ensure_status_exists_or_400(db: Session, status_code: str) -> None:
exists = db.query(Status.id).filter(Status.code == status_code).first()
if exists is None:
raise HTTPException(status_code=400, detail="Статус не найден")
def _as_positive_int_or_400(value: Any, field_name: str) -> int:
try:
number = int(value)
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail=f'Поле "{field_name}" должно быть целым числом')
if number <= 0:
raise HTTPException(status_code=400, detail=f'Поле "{field_name}" должно быть больше 0')
return number
def _apply_topic_required_fields_fields(db: Session, payload: dict[str, Any]) -> dict[str, Any]:
data = dict(payload)
if "topic_code" in data:
topic_code = str(data.get("topic_code") or "").strip()
if not topic_code:
raise HTTPException(status_code=400, detail='Поле "topic_code" не может быть пустым')
_ensure_topic_exists_or_400(db, topic_code)
data["topic_code"] = topic_code
if "field_key" in data:
field_key = str(data.get("field_key") or "").strip()
if not field_key:
raise HTTPException(status_code=400, detail='Поле "field_key" не может быть пустым')
_ensure_form_field_exists_or_400(db, field_key)
data["field_key"] = field_key
return data
def _apply_topic_data_templates_fields(db: Session, payload: dict[str, Any]) -> dict[str, Any]:
data = dict(payload)
if "topic_code" in data:
topic_code = str(data.get("topic_code") or "").strip()
if not topic_code:
raise HTTPException(status_code=400, detail='Поле "topic_code" не может быть пустым')
_ensure_topic_exists_or_400(db, topic_code)
data["topic_code"] = topic_code
if "key" in data:
key = str(data.get("key") or "").strip()
if not key:
raise HTTPException(status_code=400, detail='Поле "key" не может быть пустым')
data["key"] = key
return data
def _apply_request_data_requirements_fields(db: Session, payload: dict[str, Any]) -> dict[str, Any]:
data = dict(payload)
if "request_id" in data:
request_id = _parse_uuid_or_400(data.get("request_id"), "request_id")
request = db.get(Request, request_id)
if request is None:
raise HTTPException(status_code=400, detail="Заявка не найдена")
data["request_id"] = request_id
if "topic_template_id" in data and data.get("topic_template_id") is not None:
template_id = _parse_uuid_or_400(data.get("topic_template_id"), "topic_template_id")
template = db.get(TopicDataTemplate, template_id)
if template is None:
raise HTTPException(status_code=400, detail="Шаблон темы не найден")
data["topic_template_id"] = template_id
if "key" in data:
key = str(data.get("key") or "").strip()
if not key:
raise HTTPException(status_code=400, detail='Поле "key" не может быть пустым')
data["key"] = key
return data
def _apply_topic_status_transitions_fields(db: Session, payload: dict[str, Any]) -> dict[str, Any]:
data = dict(payload)
topic_code = None
from_status = None
to_status = None
if "topic_code" in data:
topic_code = str(data.get("topic_code") or "").strip()
if not topic_code:
raise HTTPException(status_code=400, detail='Поле "topic_code" не может быть пустым')
_ensure_topic_exists_or_400(db, topic_code)
data["topic_code"] = topic_code
if "from_status" in data:
from_status = str(data.get("from_status") or "").strip()
if not from_status:
raise HTTPException(status_code=400, detail='Поле "from_status" не может быть пустым')
_ensure_status_exists_or_400(db, from_status)
data["from_status"] = from_status
if "to_status" in data:
to_status = str(data.get("to_status") or "").strip()
if not to_status:
raise HTTPException(status_code=400, detail='Поле "to_status" не может быть пустым')
_ensure_status_exists_or_400(db, to_status)
data["to_status"] = to_status
if from_status and to_status and from_status == to_status:
raise HTTPException(status_code=400, detail='Поля "from_status" и "to_status" не должны совпадать')
if "sla_hours" in data:
raw = data.get("sla_hours")
if raw is None or str(raw).strip() == "":
data["sla_hours"] = None
else:
data["sla_hours"] = _as_positive_int_or_400(raw, "sla_hours")
return data
_RU_TO_LATIN = {
"а": "a",
"б": "b",
"в": "v",
"г": "g",
"д": "d",
"е": "e",
"ё": "e",
"ж": "zh",
"з": "z",
"и": "i",
"й": "y",
"к": "k",
"л": "l",
"м": "m",
"н": "n",
"о": "o",
"п": "p",
"р": "r",
"с": "s",
"т": "t",
"у": "u",
"ф": "f",
"х": "h",
"ц": "ts",
"ч": "ch",
"ш": "sh",
"щ": "sch",
"ъ": "",
"ы": "y",
"ь": "",
"э": "e",
"ю": "yu",
"я": "ya",
}
def _slugify(value: str, fallback: str) -> str:
raw = str(value or "").strip().lower()
if not raw:
return fallback
latin = "".join(_RU_TO_LATIN.get(ch, ch) for ch in raw)
out: list[str] = []
prev_dash = False
for ch in latin:
if ("a" <= ch <= "z") or ("0" <= ch <= "9"):
out.append(ch)
prev_dash = False
continue
if not prev_dash:
out.append("-")
prev_dash = True
slug = "".join(out).strip("-")
return slug or fallback
def _make_unique_value(db: Session, model: type, field_name: str, base_value: str) -> str:
columns = _columns_map(model)
column = columns[field_name]
max_len = getattr(column.type, "length", None)
base = base_value.strip("-") or field_name
if max_len:
base = base[:max_len]
field = getattr(model, field_name)
if not db.query(model).filter(field == base).first():
return base
idx = 2
while True:
suffix = f"-{idx}"
candidate = base
if max_len and len(candidate) + len(suffix) > max_len:
candidate = candidate[: max_len - len(suffix)]
candidate = (candidate + suffix).strip("-")
if not db.query(model).filter(field == candidate).first():
return candidate
idx += 1
def _apply_auto_fields_for_create(db: Session, model: type, table_name: str, payload: dict[str, Any]) -> dict[str, Any]:
data = dict(payload)
if table_name == "topics" and not str(data.get("code") or "").strip():
base = _slugify(str(data.get("name") or ""), "topic")
data["code"] = _make_unique_value(db, model, "code", base)
if table_name == "statuses" and not str(data.get("code") or "").strip():
base = _slugify(str(data.get("name") or ""), "status")
data["code"] = _make_unique_value(db, model, "code", base)
if table_name == "form_fields" and not str(data.get("key") or "").strip():
base = _slugify(str(data.get("label") or ""), "field")
data["key"] = _make_unique_value(db, model, "key", base)
if table_name == "admin_users":
data = _apply_admin_user_fields_for_create(data)
return data
def _resolve_responsible(admin: dict | None) -> str:
if not admin:
return "Администратор системы"
email = str(admin.get("email") or "").strip()
return email or "Администратор системы"
def _strip_hidden_fields(table_name: str, payload: dict[str, Any]) -> dict[str, Any]:
hidden = _hidden_response_fields(table_name)
if not hidden:
return payload
return {k: v for k, v in payload.items() if k not in hidden}
def _actor_uuid(admin: dict) -> uuid.UUID | None:
sub = admin.get("sub")
if not sub:
return None
try:
return uuid.UUID(str(sub))
except ValueError:
return None
def _append_audit(db: Session, admin: dict, table_name: str, entity_id: str, action: str, diff: dict[str, Any]) -> None:
db.add(
AuditLog(
actor_admin_id=_actor_uuid(admin),
entity=table_name,
entity_id=str(entity_id),
action=action,
diff=diff,
)
)
def _integrity_error(detail: str = "Нарушение ограничений данных") -> HTTPException:
return HTTPException(status_code=400, detail=detail)
def _actor_role(admin: dict) -> str:
role = str(admin.get("role") or "").strip().upper()
return role or "ADMIN"
def _apply_create_side_effects(db: Session, *, table_name: str, row: Any, admin: dict) -> None:
if table_name == "messages" and isinstance(row, Message):
req = db.get(Request, row.request_id)
if req is None:
return
author_type = str(row.author_type or "").strip().upper()
if author_type == "CLIENT":
mark_unread_for_lawyer(req, EVENT_MESSAGE)
responsible = "Клиент"
actor_role = "CLIENT"
actor_admin_user_id = None
else:
mark_unread_for_client(req, EVENT_MESSAGE)
responsible = _resolve_responsible(admin)
actor_role = _actor_role(admin)
actor_admin_user_id = admin.get("sub")
req.responsible = responsible
db.add(req)
notify_request_event(
db,
request=req,
event_type=NOTIFICATION_EVENT_MESSAGE,
actor_role=actor_role,
actor_admin_user_id=actor_admin_user_id,
body=str(row.body or "").strip() or None,
responsible=responsible,
)
return
if table_name == "attachments" and isinstance(row, Attachment):
req = db.get(Request, row.request_id)
if req is None:
return
mark_unread_for_client(req, EVENT_ATTACHMENT)
responsible = _resolve_responsible(admin)
req.responsible = responsible
db.add(req)
notify_request_event(
db,
request=req,
event_type=NOTIFICATION_EVENT_ATTACHMENT,
actor_role=_actor_role(admin),
actor_admin_user_id=admin.get("sub"),
body=f"Файл: {row.file_name}",
responsible=responsible,
)
@router.post("/{table_name}/query")
def query_table(
table_name: str,
uq: UniversalQuery,
db: Session = Depends(get_db),
admin: dict = Depends(get_current_admin),
):
normalized, model = _resolve_table_model(table_name)
_require_table_action(admin, normalized, "query")
query = apply_universal_query(db.query(model), model, uq)
total = query.count()
rows = query.offset(uq.page.offset).limit(uq.page.limit).all()
return {"rows": [_strip_hidden_fields(normalized, _row_to_dict(row)) for row in rows], "total": total}
@router.get("/{table_name}/{row_id}")
def get_row(
table_name: str,
row_id: str,
db: Session = Depends(get_db),
admin: dict = Depends(get_current_admin),
):
normalized, model = _resolve_table_model(table_name)
_require_table_action(admin, normalized, "read")
row = _load_row_or_404(db, model, row_id)
if normalized == "requests":
req = row if isinstance(row, Request) else None
if req is not None:
changed = False
if _is_lawyer(admin) and clear_unread_for_lawyer(req):
changed = True
db.add(req)
read_count = mark_admin_notifications_read(
db,
admin_user_id=admin.get("sub"),
request_id=req.id,
responsible=_resolve_responsible(admin),
)
if read_count:
changed = True
if changed:
db.commit()
db.refresh(req)
row = req
return _strip_hidden_fields(normalized, _row_to_dict(row))
@router.post("/{table_name}", status_code=201)
def create_row(
table_name: str,
payload: dict[str, Any],
db: Session = Depends(get_db),
admin: dict = Depends(get_current_admin),
):
normalized, model = _resolve_table_model(table_name)
_require_table_action(admin, normalized, "create")
if normalized == "requests" and _is_lawyer(admin):
assigned_lawyer_id = payload.get("assigned_lawyer_id") if isinstance(payload, dict) else None
if str(assigned_lawyer_id or "").strip():
raise HTTPException(status_code=403, detail='Юрист не может назначать заявку при создании')
prepared = _prepare_create_payload(normalized, payload)
if normalized == "requests":
validate_required_topic_fields_or_400(db, prepared.get("topic_code"), prepared.get("extra_fields"))
prepared = _apply_auto_fields_for_create(db, model, normalized, prepared)
clean_payload = _sanitize_payload(
model,
normalized,
prepared,
is_update=False,
allow_protected_fields={"password_hash"} if normalized == "admin_users" else None,
)
if normalized == "admin_user_topics":
clean_payload = _apply_admin_user_topics_fields(db, clean_payload)
if normalized == "topic_required_fields":
clean_payload = _apply_topic_required_fields_fields(db, clean_payload)
if normalized == "topic_data_templates":
clean_payload = _apply_topic_data_templates_fields(db, clean_payload)
if normalized == "request_data_requirements":
clean_payload = _apply_request_data_requirements_fields(db, clean_payload)
if normalized == "topic_status_transitions":
clean_payload = _apply_topic_status_transitions_fields(db, clean_payload)
if "responsible" in _columns_map(model):
clean_payload["responsible"] = _resolve_responsible(admin)
row = model(**clean_payload)
try:
db.add(row)
db.flush()
_apply_create_side_effects(db, table_name=normalized, row=row, admin=admin)
snapshot = _row_to_dict(row)
_append_audit(db, admin, normalized, str(snapshot.get("id") or ""), "CREATE", {"after": snapshot})
db.commit()
db.refresh(row)
except IntegrityError:
db.rollback()
raise _integrity_error()
return _strip_hidden_fields(normalized, _row_to_dict(row))
@router.patch("/{table_name}/{row_id}")
def update_row(
table_name: str,
row_id: str,
payload: dict[str, Any],
db: Session = Depends(get_db),
admin: dict = Depends(get_current_admin),
):
normalized, model = _resolve_table_model(table_name)
_require_table_action(admin, normalized, "update")
if normalized == "requests" and _is_lawyer(admin) and isinstance(payload, dict) and "assigned_lawyer_id" in payload:
raise HTTPException(status_code=403, detail='Назначение доступно только через действие "Взять в работу"')
row = _load_row_or_404(db, model, row_id)
if normalized in {"messages", "attachments"} and bool(getattr(row, "immutable", False)):
raise HTTPException(status_code=400, detail="Запись зафиксирована и недоступна для редактирования")
prepared = dict(payload)
if normalized == "admin_users":
prepared = _apply_admin_user_fields_for_update(prepared)
clean_payload = _sanitize_payload(
model,
normalized,
prepared,
is_update=True,
allow_protected_fields={"password_hash"} if normalized == "admin_users" else None,
)
if normalized == "admin_user_topics":
clean_payload = _apply_admin_user_topics_fields(db, clean_payload)
if normalized == "topic_required_fields":
clean_payload = _apply_topic_required_fields_fields(db, clean_payload)
if normalized == "topic_data_templates":
clean_payload = _apply_topic_data_templates_fields(db, clean_payload)
if normalized == "request_data_requirements":
clean_payload = _apply_request_data_requirements_fields(db, clean_payload)
if normalized == "topic_status_transitions":
clean_payload = _apply_topic_status_transitions_fields(db, clean_payload)
before = _row_to_dict(row)
if normalized == "topic_status_transitions":
next_from = str(clean_payload.get("from_status", before.get("from_status") or "")).strip()
next_to = str(clean_payload.get("to_status", before.get("to_status") or "")).strip()
if next_from and next_to and next_from == next_to:
raise HTTPException(status_code=400, detail='Поля "from_status" и "to_status" не должны совпадать')
if normalized == "requests" and "status_code" in clean_payload:
before_status = str(before.get("status_code") or "")
after_status = str(clean_payload.get("status_code") or "")
topic_code = str(before.get("topic_code") or "").strip() or None
if not transition_allowed_for_topic(db, topic_code, before_status, after_status):
raise HTTPException(
status_code=400,
detail="Переход статуса не разрешен для выбранной темы",
)
if before_status != after_status and isinstance(row, Request):
mark_unread_for_client(row, EVENT_STATUS)
apply_status_change_effects(
db,
row,
from_status=before_status,
to_status=after_status,
admin=admin,
responsible=_resolve_responsible(admin),
)
notify_request_event(
db,
request=row,
event_type=NOTIFICATION_EVENT_STATUS,
actor_role=_actor_role(admin),
actor_admin_user_id=admin.get("sub"),
body=f"{before_status} -> {after_status}",
responsible=_resolve_responsible(admin),
)
for key, value in clean_payload.items():
setattr(row, key, value)
try:
db.add(row)
db.flush()
after = _row_to_dict(row)
_append_audit(db, admin, normalized, str(after.get("id") or row_id), "UPDATE", {"before": before, "after": after})
db.commit()
db.refresh(row)
except IntegrityError:
db.rollback()
raise _integrity_error()
return _strip_hidden_fields(normalized, _row_to_dict(row))
@router.delete("/{table_name}/{row_id}")
def delete_row(
table_name: str,
row_id: str,
db: Session = Depends(get_db),
admin: dict = Depends(get_current_admin),
):
normalized, model = _resolve_table_model(table_name)
_require_table_action(admin, normalized, "delete")
if normalized == "admin_users" and str(admin.get("sub") or "") == str(row_id):
raise HTTPException(status_code=400, detail="Нельзя удалить собственную учетную запись")
row = _load_row_or_404(db, model, row_id)
if normalized in {"messages", "attachments"} and bool(getattr(row, "immutable", False)):
raise HTTPException(status_code=400, detail="Запись зафиксирована и недоступна для удаления")
before = _row_to_dict(row)
entity_id = str(before.get("id") or row_id)
try:
db.delete(row)
_append_audit(db, admin, normalized, entity_id, "DELETE", {"before": before})
db.commit()
except IntegrityError:
db.rollback()
raise _integrity_error("Невозможно удалить запись из-за ограничений связанных данных")
return {"status": "удалено", "id": entity_id}

View file

@ -1,20 +1,241 @@
from __future__ import annotations
from datetime import datetime, timezone
from decimal import Decimal
from uuid import UUID
from fastapi import APIRouter, Depends
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.core.deps import require_role
from app.db.session import get_db
from app.models.admin_user import AdminUser
from app.models.request import Request
from app.models.status import Status
from app.models.status_history import StatusHistory
from app.services.sla_metrics import compute_sla_snapshot
router = APIRouter()
DEFAULT_TERMINAL_STATUS_CODES = {"RESOLVED", "CLOSED", "REJECTED"}
PAID_STATUS_CODES = {"PAID", "ОПЛАЧЕНО"}
def _terminal_status_codes(db: Session) -> set[str]:
rows = db.query(Status.code).filter(Status.is_terminal.is_(True)).all()
codes = {str(code).strip() for (code,) in rows if code}
return codes or set(DEFAULT_TERMINAL_STATUS_CODES)
def _paid_status_codes() -> set[str]:
return set(PAID_STATUS_CODES)
def _month_bounds(now_utc: datetime) -> tuple[datetime, datetime]:
start = datetime(now_utc.year, now_utc.month, 1, tzinfo=timezone.utc)
if now_utc.month == 12:
end = datetime(now_utc.year + 1, 1, 1, tzinfo=timezone.utc)
else:
end = datetime(now_utc.year, now_utc.month + 1, 1, tzinfo=timezone.utc)
return start, end
def _to_float(value) -> float:
if value is None:
return 0.0
if isinstance(value, Decimal):
return float(value)
try:
return float(value)
except (TypeError, ValueError):
return 0.0
def _uuid_or_none(value: str | None) -> UUID | None:
try:
return UUID(str(value or ""))
except ValueError:
return None
@router.get("/overview")
def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN","LAWYER"))):
by_status_rows = db.query(Request.status_code, func.count(Request.id)).group_by(Request.status_code).all()
by_status = {status: count for status, count in by_status_rows}
return {
"new": by_status.get("NEW", 0),
"by_status": by_status,
"frt_avg_minutes": None,
"sla_overdue": 0,
"avg_time_in_status_hours": {},
def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER"))):
role = str(admin.get("role") or "").upper()
actor_id = str(admin.get("sub") or "").strip()
actor_uuid = _uuid_or_none(actor_id)
terminal_codes = _terminal_status_codes(db)
paid_codes = _paid_status_codes()
now_utc = datetime.now(timezone.utc)
month_start, next_month_start = _month_bounds(now_utc)
unread_for_clients = (
db.query(func.count(Request.id))
.filter(Request.client_has_unread_updates.is_(True))
.scalar()
or 0
)
unread_for_lawyers = (
db.query(func.count(Request.id))
.filter(Request.lawyer_has_unread_updates.is_(True))
.scalar()
or 0
)
active_load_rows = (
db.query(Request.assigned_lawyer_id, func.count(Request.id))
.filter(Request.assigned_lawyer_id.is_not(None))
.filter(Request.status_code.notin_(terminal_codes))
.group_by(Request.assigned_lawyer_id)
.all()
)
total_load_rows = (
db.query(Request.assigned_lawyer_id, func.count(Request.id))
.filter(Request.assigned_lawyer_id.is_not(None))
.group_by(Request.assigned_lawyer_id)
.all()
)
active_amount_rows = (
db.query(Request.assigned_lawyer_id, func.coalesce(func.sum(func.coalesce(Request.invoice_amount, 0)), 0))
.filter(Request.assigned_lawyer_id.is_not(None))
.filter(Request.status_code.notin_(terminal_codes))
.group_by(Request.assigned_lawyer_id)
.all()
)
paid_rows = (
db.query(
Request.assigned_lawyer_id,
func.count(StatusHistory.id),
func.coalesce(func.sum(func.coalesce(Request.invoice_amount, 0)), 0),
)
.join(StatusHistory, StatusHistory.request_id == Request.id)
.filter(Request.assigned_lawyer_id.is_not(None))
.filter(StatusHistory.created_at >= month_start, StatusHistory.created_at < next_month_start)
.filter(func.upper(StatusHistory.to_status).in_(paid_codes))
.group_by(Request.assigned_lawyer_id)
.all()
)
active_load_map = {str(lawyer_id): int(count) for lawyer_id, count in active_load_rows if lawyer_id}
total_load_map = {str(lawyer_id): int(count) for lawyer_id, count in total_load_rows if lawyer_id}
active_amount_map = {str(lawyer_id): _to_float(amount) for lawyer_id, amount in active_amount_rows if lawyer_id}
paid_events_map = {str(lawyer_id): int(events) for lawyer_id, events, _ in paid_rows if lawyer_id}
monthly_gross_map = {str(lawyer_id): _to_float(gross) for lawyer_id, _, gross in paid_rows if lawyer_id}
lawyers = (
db.query(AdminUser)
.filter(AdminUser.role == "LAWYER", AdminUser.is_active.is_(True))
.all()
)
lawyer_loads = []
for lawyer in lawyers:
lawyer_id = str(lawyer.id)
salary_percent = _to_float(lawyer.salary_percent)
monthly_paid_gross = monthly_gross_map.get(lawyer_id, 0.0)
monthly_salary = monthly_paid_gross * salary_percent / 100.0
lawyer_loads.append(
{
"lawyer_id": lawyer_id,
"name": lawyer.name,
"email": lawyer.email,
"avatar_url": lawyer.avatar_url,
"primary_topic_code": lawyer.primary_topic_code,
"default_rate": _to_float(lawyer.default_rate),
"salary_percent": salary_percent,
"active_load": active_load_map.get(lawyer_id, 0),
"total_assigned": total_load_map.get(lawyer_id, 0),
"active_amount": round(active_amount_map.get(lawyer_id, 0.0), 2),
"monthly_paid_events": paid_events_map.get(lawyer_id, 0),
"monthly_paid_gross": round(monthly_paid_gross, 2),
"monthly_salary": round(monthly_salary, 2),
}
)
lawyer_loads.sort(key=lambda row: (-row["active_load"], row["name"] or "", row["email"] or ""))
if role == "LAWYER" and actor_uuid is not None:
scoped_by_status_rows = (
db.query(Request.status_code, func.count(Request.id))
.filter(Request.assigned_lawyer_id == str(actor_uuid))
.group_by(Request.status_code)
.all()
)
by_status = {status: int(count) for status, count in scoped_by_status_rows}
assigned_total = int(sum(by_status.values()))
active_assigned_total = int(
db.query(func.count(Request.id))
.filter(Request.assigned_lawyer_id == str(actor_uuid))
.filter(Request.status_code.notin_(terminal_codes))
.scalar()
or 0
)
unassigned_total = int(db.query(func.count(Request.id)).filter(Request.assigned_lawyer_id.is_(None)).scalar() or 0)
my_unread_updates = int(
db.query(func.count(Request.id))
.filter(
Request.assigned_lawyer_id == str(actor_uuid),
Request.lawyer_has_unread_updates.is_(True),
)
.scalar()
or 0
)
my_unread_by_event_rows = (
db.query(Request.lawyer_unread_event_type, func.count(Request.id))
.filter(
Request.assigned_lawyer_id == str(actor_uuid),
Request.lawyer_has_unread_updates.is_(True),
Request.lawyer_unread_event_type.is_not(None),
)
.group_by(Request.lawyer_unread_event_type)
.all()
)
my_unread_by_event = {str(event_type): int(count) for event_type, count in my_unread_by_event_rows if event_type}
scoped_lawyer_loads = [row for row in lawyer_loads if str(row["lawyer_id"]) == str(actor_uuid)]
elif role == "LAWYER":
by_status = {}
assigned_total = 0
active_assigned_total = 0
unassigned_total = int(db.query(func.count(Request.id)).filter(Request.assigned_lawyer_id.is_(None)).scalar() or 0)
my_unread_updates = 0
my_unread_by_event = {}
scoped_lawyer_loads = []
else:
scoped_by_status_rows = db.query(Request.status_code, func.count(Request.id)).group_by(Request.status_code).all()
by_status = {status: int(count) for status, count in scoped_by_status_rows}
assigned_total = int(
db.query(func.count(Request.id))
.filter(Request.assigned_lawyer_id.is_not(None))
.scalar()
or 0
)
active_assigned_total = int(
db.query(func.count(Request.id))
.filter(Request.assigned_lawyer_id.is_not(None))
.filter(Request.status_code.notin_(terminal_codes))
.scalar()
or 0
)
unassigned_total = int(db.query(func.count(Request.id)).filter(Request.assigned_lawyer_id.is_(None)).scalar() or 0)
my_unread_updates = 0
my_unread_by_event = {}
scoped_lawyer_loads = lawyer_loads
sla_snapshot = compute_sla_snapshot(db)
return {
"scope": role if role in {"ADMIN", "LAWYER"} else "ADMIN",
"new": int(by_status.get("NEW", 0)),
"by_status": by_status,
"assigned_total": assigned_total,
"active_assigned_total": active_assigned_total,
"unassigned_total": unassigned_total,
"my_unread_updates": my_unread_updates,
"my_unread_by_event": my_unread_by_event,
"frt_avg_minutes": sla_snapshot.get("frt_avg_minutes"),
"sla_overdue": sla_snapshot.get("overdue_total", 0),
"overdue_by_status": sla_snapshot.get("overdue_by_status", {}),
"overdue_by_transition": sla_snapshot.get("overdue_by_transition", {}),
"avg_time_in_status_hours": sla_snapshot.get("avg_time_in_status_hours", {}),
"unread_for_clients": int(unread_for_clients),
"unread_for_lawyers": int(unread_for_lawyers),
"lawyer_loads": scoped_lawyer_loads,
}

View file

@ -0,0 +1,120 @@
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from app.core.deps import require_role
from app.db.session import get_db
from app.schemas.admin import NotificationsReadAll
from app.services.notifications import (
get_admin_notification,
list_admin_notifications,
mark_admin_notifications_read,
serialize_notification,
)
router = APIRouter()
def _actor_uuid_or_401(admin: dict) -> uuid.UUID:
try:
return uuid.UUID(str(admin.get("sub") or ""))
except ValueError:
raise HTTPException(status_code=401, detail="Некорректный токен")
def _optional_uuid_or_400(raw: str | None, field_name: str) -> uuid.UUID | None:
if raw is None:
return None
value = str(raw).strip()
if not value:
return None
try:
return uuid.UUID(value)
except ValueError:
raise HTTPException(status_code=400, detail=f'Некорректный "{field_name}"')
@router.get("")
def list_notifications(
unread_only: bool = Query(default=False),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
request_id: str | None = Query(default=None),
db: Session = Depends(get_db),
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
):
actor_id = _actor_uuid_or_401(admin)
request_uuid = _optional_uuid_or_400(request_id, "request_id")
rows, total = list_admin_notifications(
db,
admin_user_id=actor_id,
unread_only=unread_only,
request_id=request_uuid,
limit=limit,
offset=offset,
)
_, unread_total = list_admin_notifications(
db,
admin_user_id=actor_id,
unread_only=True,
request_id=request_uuid,
limit=1,
offset=0,
)
return {
"rows": [serialize_notification(row) for row in rows],
"total": total,
"unread_total": int(unread_total),
}
@router.post("/{notification_id}/read")
def read_single_notification(
notification_id: str,
db: Session = Depends(get_db),
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
):
actor_id = _actor_uuid_or_401(admin)
try:
notification_uuid = uuid.UUID(str(notification_id))
except ValueError:
raise HTTPException(status_code=400, detail="Некорректный notification_id")
row = get_admin_notification(db, admin_user_id=actor_id, notification_id=notification_uuid)
if row is None:
raise HTTPException(status_code=404, detail="Уведомление не найдено")
changed = mark_admin_notifications_read(
db,
admin_user_id=actor_id,
notification_id=notification_uuid,
responsible=str(admin.get("email") or "").strip() or "Администратор системы",
)
db.commit()
refreshed = get_admin_notification(db, admin_user_id=actor_id, notification_id=notification_uuid)
return {
"status": "ok",
"changed": int(changed),
"notification": serialize_notification(refreshed) if refreshed else None,
}
@router.post("/read-all")
def read_all_notifications(
payload: NotificationsReadAll,
db: Session = Depends(get_db),
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
):
actor_id = _actor_uuid_or_401(admin)
request_uuid = _optional_uuid_or_400(payload.request_id, "request_id")
changed = mark_admin_notifications_read(
db,
admin_user_id=actor_id,
request_id=request_uuid,
responsible=str(admin.get("email") or "").strip() or "Администратор системы",
)
db.commit()
return {"status": "ok", "changed": int(changed)}

View file

@ -32,7 +32,8 @@ def query_quotes(uq: UniversalQuery, db: Session = Depends(get_db), admin=Depend
@router.post("", status_code=201)
def create_quote(payload: QuoteUpsert, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN"))):
q = Quote(**payload.model_dump())
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
q = Quote(**payload.model_dump(), responsible=responsible)
db.add(q); db.commit(); db.refresh(q)
return {"id": str(q.id)}

View file

@ -1,16 +1,71 @@
from uuid import uuid4
from datetime import datetime, timezone
from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from sqlalchemy import update
from app.db.session import get_db
from app.core.deps import require_role
from app.schemas.universal import UniversalQuery
from app.schemas.admin import RequestAdminCreate, RequestAdminPatch
from app.schemas.admin import (
RequestAdminCreate,
RequestAdminPatch,
RequestDataRequirementCreate,
RequestDataRequirementPatch,
RequestReassign,
)
from app.models.admin_user import AdminUser
from app.models.audit_log import AuditLog
from app.models.request_data_requirement import RequestDataRequirement
from app.models.request import Request
from app.models.topic_data_template import TopicDataTemplate
from app.services.notifications import (
EVENT_STATUS as NOTIFICATION_EVENT_STATUS,
mark_admin_notifications_read,
notify_request_event,
)
from app.services.request_read_markers import EVENT_STATUS, clear_unread_for_lawyer, mark_unread_for_client
from app.services.request_status import actor_admin_uuid, apply_status_change_effects
from app.services.status_flow import transition_allowed_for_topic
from app.services.request_templates import validate_required_topic_fields_or_400
from app.services.universal_query import apply_universal_query
router = APIRouter()
def _request_uuid_or_400(request_id: str) -> UUID:
try:
return UUID(str(request_id))
except ValueError:
raise HTTPException(status_code=400, detail="Некорректный идентификатор заявки")
def _ensure_lawyer_can_manage_request_or_403(admin: dict, req: Request) -> None:
role = str(admin.get("role") or "").upper()
if role != "LAWYER":
return
actor = str(admin.get("sub") or "").strip()
assigned = str(req.assigned_lawyer_id or "").strip()
if not actor or not assigned or actor != assigned:
raise HTTPException(status_code=403, detail="Юрист может работать только со своими назначенными заявками")
def _request_data_requirement_row(row: RequestDataRequirement) -> dict:
return {
"id": str(row.id),
"request_id": str(row.request_id),
"topic_template_id": str(row.topic_template_id) if row.topic_template_id else None,
"key": row.key,
"label": row.label,
"description": row.description,
"required": bool(row.required),
"created_by_admin_id": str(row.created_by_admin_id) if row.created_by_admin_id else None,
"created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
}
@router.post("/query")
def query_requests(uq: UniversalQuery, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN","LAWYER"))):
q = apply_universal_query(db.query(Request), Request, uq)
@ -25,6 +80,14 @@ def query_requests(uq: UniversalQuery, db: Session = Depends(get_db), admin=Depe
"client_name": r.client_name,
"client_phone": r.client_phone,
"topic_code": r.topic_code,
"effective_rate": float(r.effective_rate) if r.effective_rate is not None else None,
"invoice_amount": float(r.invoice_amount) if r.invoice_amount is not None else None,
"paid_at": r.paid_at.isoformat() if r.paid_at else None,
"paid_by_admin_id": r.paid_by_admin_id,
"client_has_unread_updates": r.client_has_unread_updates,
"client_unread_event_type": r.client_unread_event_type,
"lawyer_has_unread_updates": r.lawyer_has_unread_updates,
"lawyer_unread_event_type": r.lawyer_unread_event_type,
"created_at": r.created_at.isoformat() if r.created_at else None,
"updated_at": r.updated_at.isoformat() if r.updated_at else None,
}
@ -36,7 +99,11 @@ def query_requests(uq: UniversalQuery, db: Session = Depends(get_db), admin=Depe
@router.post("", status_code=201)
def create_request(payload: RequestAdminCreate, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER"))):
if str(admin.get("role") or "").upper() == "LAWYER" and str(payload.assigned_lawyer_id or "").strip():
raise HTTPException(status_code=403, detail="Юрист не может назначать заявку при создании")
validate_required_topic_fields_or_400(db, payload.topic_code, payload.extra_fields)
track = payload.track_number or f"TRK-{uuid4().hex[:10].upper()}"
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
row = Request(
track_number=track,
client_name=payload.client_name,
@ -46,7 +113,12 @@ def create_request(payload: RequestAdminCreate, db: Session = Depends(get_db), a
description=payload.description,
extra_fields=payload.extra_fields,
assigned_lawyer_id=payload.assigned_lawyer_id,
effective_rate=payload.effective_rate,
invoice_amount=payload.invoice_amount,
paid_at=payload.paid_at,
paid_by_admin_id=payload.paid_by_admin_id,
total_attachments_bytes=payload.total_attachments_bytes,
responsible=responsible,
)
try:
db.add(row)
@ -65,11 +137,43 @@ def update_request(
db: Session = Depends(get_db),
admin=Depends(require_role("ADMIN", "LAWYER")),
):
row = db.query(Request).filter(Request.id == request_id).first()
request_uuid = _request_uuid_or_400(request_id)
row = db.get(Request, request_uuid)
if not row:
raise HTTPException(status_code=404, detail="Заявка не найдена")
for key, value in payload.model_dump(exclude_unset=True).items():
changes = payload.model_dump(exclude_unset=True)
if str(admin.get("role") or "").upper() == "LAWYER" and "assigned_lawyer_id" in changes:
raise HTTPException(status_code=403, detail='Назначение доступно только через действие "Взять в работу"')
old_status = str(row.status_code or "")
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
for key, value in changes.items():
setattr(row, key, value)
if "status_code" in changes and str(changes.get("status_code") or "") != old_status:
if not transition_allowed_for_topic(
db,
str(row.topic_code or "").strip() or None,
old_status,
str(changes.get("status_code") or ""),
):
raise HTTPException(status_code=400, detail="Переход статуса не разрешен для выбранной темы")
mark_unread_for_client(row, EVENT_STATUS)
apply_status_change_effects(
db,
row,
from_status=old_status,
to_status=str(changes.get("status_code") or ""),
admin=admin,
responsible=responsible,
)
notify_request_event(
db,
request=row,
event_type=NOTIFICATION_EVENT_STATUS,
actor_role=str(admin.get("role") or "").upper() or "ADMIN",
actor_admin_user_id=admin.get("sub"),
body=f"{old_status} -> {str(changes.get('status_code') or '').strip()}",
responsible=responsible,
)
try:
db.add(row)
db.commit()
@ -82,7 +186,8 @@ def update_request(
@router.delete("/{request_id}")
def delete_request(request_id: str, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER"))):
row = db.query(Request).filter(Request.id == request_id).first()
request_uuid = _request_uuid_or_400(request_id)
row = db.get(Request, request_uuid)
if not row:
raise HTTPException(status_code=404, detail="Заявка не найдена")
db.delete(row)
@ -91,9 +196,25 @@ def delete_request(request_id: str, db: Session = Depends(get_db), admin=Depends
@router.get("/{request_id}")
def get_request(request_id: str, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN","LAWYER"))):
req = db.query(Request).filter(Request.id == request_id).first()
request_uuid = _request_uuid_or_400(request_id)
req = db.get(Request, request_uuid)
if not req:
raise HTTPException(status_code=404, detail="Заявка не найдена")
changed = False
if str(admin.get("role") or "").upper() == "LAWYER" and clear_unread_for_lawyer(req):
changed = True
db.add(req)
read_count = mark_admin_notifications_read(
db,
admin_user_id=admin.get("sub"),
request_id=req.id,
responsible=str(admin.get("email") or "").strip() or "Администратор системы",
)
if read_count:
changed = True
if changed:
db.commit()
db.refresh(req)
return {
"id": str(req.id),
"track_number": req.track_number,
@ -104,7 +225,384 @@ def get_request(request_id: str, db: Session = Depends(get_db), admin=Depends(re
"description": req.description,
"extra_fields": req.extra_fields,
"assigned_lawyer_id": req.assigned_lawyer_id,
"effective_rate": float(req.effective_rate) if req.effective_rate is not None else None,
"invoice_amount": float(req.invoice_amount) if req.invoice_amount is not None else None,
"paid_at": req.paid_at.isoformat() if req.paid_at else None,
"paid_by_admin_id": req.paid_by_admin_id,
"total_attachments_bytes": req.total_attachments_bytes,
"client_has_unread_updates": req.client_has_unread_updates,
"client_unread_event_type": req.client_unread_event_type,
"lawyer_has_unread_updates": req.lawyer_has_unread_updates,
"lawyer_unread_event_type": req.lawyer_unread_event_type,
"created_at": req.created_at.isoformat() if req.created_at else None,
"updated_at": req.updated_at.isoformat() if req.updated_at else None,
}
@router.post("/{request_id}/claim")
def claim_request(request_id: str, db: Session = Depends(get_db), admin=Depends(require_role("LAWYER"))):
request_uuid = _request_uuid_or_400(request_id)
lawyer_sub = str(admin.get("sub") or "").strip()
if not lawyer_sub:
raise HTTPException(status_code=401, detail="Некорректный токен")
try:
lawyer_uuid = UUID(lawyer_sub)
except ValueError:
raise HTTPException(status_code=401, detail="Некорректный токен")
lawyer = db.get(AdminUser, lawyer_uuid)
if not lawyer or str(lawyer.role or "").upper() != "LAWYER" or not bool(lawyer.is_active):
raise HTTPException(status_code=403, detail="Доступно только активному юристу")
now = datetime.now(timezone.utc)
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
stmt = (
update(Request)
.where(Request.id == request_uuid, Request.assigned_lawyer_id.is_(None))
.values(
assigned_lawyer_id=str(lawyer_uuid),
updated_at=now,
responsible=responsible,
)
)
try:
updated_rows = db.execute(stmt).rowcount or 0
if updated_rows == 0:
existing = db.get(Request, request_uuid)
if existing is None:
db.rollback()
raise HTTPException(status_code=404, detail="Заявка не найдена")
db.rollback()
raise HTTPException(status_code=409, detail="Заявка уже назначена")
db.add(
AuditLog(
actor_admin_id=lawyer_uuid,
entity="requests",
entity_id=str(request_uuid),
action="MANUAL_CLAIM",
diff={"assigned_lawyer_id": str(lawyer_uuid)},
)
)
db.commit()
except HTTPException:
raise
except Exception:
db.rollback()
raise
row = db.get(Request, request_uuid)
if row is None:
raise HTTPException(status_code=404, detail="Заявка не найдена")
return {
"status": "claimed",
"id": str(row.id),
"track_number": row.track_number,
"assigned_lawyer_id": row.assigned_lawyer_id,
}
@router.post("/{request_id}/reassign")
def reassign_request(
request_id: str,
payload: RequestReassign,
db: Session = Depends(get_db),
admin=Depends(require_role("ADMIN")),
):
request_uuid = _request_uuid_or_400(request_id)
try:
lawyer_uuid = UUID(str(payload.lawyer_id))
except ValueError:
raise HTTPException(status_code=400, detail="Некорректный идентификатор юриста")
target_lawyer = db.get(AdminUser, lawyer_uuid)
if not target_lawyer or str(target_lawyer.role or "").upper() != "LAWYER" or not bool(target_lawyer.is_active):
raise HTTPException(status_code=400, detail="Можно переназначить только на активного юриста")
req = db.get(Request, request_uuid)
if req is None:
raise HTTPException(status_code=404, detail="Заявка не найдена")
if req.assigned_lawyer_id is None:
raise HTTPException(status_code=400, detail="Заявка не назначена")
if str(req.assigned_lawyer_id) == str(lawyer_uuid):
raise HTTPException(status_code=400, detail="Заявка уже назначена на выбранного юриста")
old_assigned = str(req.assigned_lawyer_id)
now = datetime.now(timezone.utc)
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
admin_actor_id = None
try:
admin_actor_id = UUID(str(admin.get("sub") or ""))
except ValueError:
admin_actor_id = None
stmt = (
update(Request)
.where(Request.id == request_uuid, Request.assigned_lawyer_id == old_assigned)
.values(
assigned_lawyer_id=str(lawyer_uuid),
updated_at=now,
responsible=responsible,
)
)
try:
updated_rows = db.execute(stmt).rowcount or 0
if updated_rows == 0:
db.rollback()
raise HTTPException(status_code=409, detail="Заявка уже была переназначена")
db.add(
AuditLog(
actor_admin_id=admin_actor_id,
entity="requests",
entity_id=str(request_uuid),
action="MANUAL_REASSIGN",
diff={"from_lawyer_id": old_assigned, "to_lawyer_id": str(lawyer_uuid)},
)
)
db.commit()
except HTTPException:
raise
except Exception:
db.rollback()
raise
row = db.get(Request, request_uuid)
if row is None:
raise HTTPException(status_code=404, detail="Заявка не найдена")
return {
"status": "reassigned",
"id": str(row.id),
"track_number": row.track_number,
"from_lawyer_id": old_assigned,
"assigned_lawyer_id": row.assigned_lawyer_id,
}
@router.get("/{request_id}/data-template")
def get_request_data_template(
request_id: str,
db: Session = Depends(get_db),
admin=Depends(require_role("ADMIN", "LAWYER")),
):
request_uuid = _request_uuid_or_400(request_id)
req = db.get(Request, request_uuid)
if req is None:
raise HTTPException(status_code=404, detail="Заявка не найдена")
_ensure_lawyer_can_manage_request_or_403(admin, req)
topic_items = (
db.query(TopicDataTemplate)
.filter(
TopicDataTemplate.topic_code == str(req.topic_code or ""),
TopicDataTemplate.enabled.is_(True),
)
.order_by(TopicDataTemplate.sort_order.asc(), TopicDataTemplate.key.asc())
.all()
)
request_items = (
db.query(RequestDataRequirement)
.filter(RequestDataRequirement.request_id == req.id)
.order_by(RequestDataRequirement.created_at.asc(), RequestDataRequirement.key.asc())
.all()
)
return {
"request_id": str(req.id),
"topic_code": req.topic_code,
"topic_items": [
{
"id": str(row.id),
"key": row.key,
"label": row.label,
"description": row.description,
"required": bool(row.required),
"sort_order": row.sort_order,
}
for row in topic_items
],
"request_items": [_request_data_requirement_row(row) for row in request_items],
}
@router.post("/{request_id}/data-template/sync")
def sync_request_data_template_from_topic(
request_id: str,
db: Session = Depends(get_db),
admin=Depends(require_role("ADMIN", "LAWYER")),
):
request_uuid = _request_uuid_or_400(request_id)
req = db.get(Request, request_uuid)
if req is None:
raise HTTPException(status_code=404, detail="Заявка не найдена")
_ensure_lawyer_can_manage_request_or_403(admin, req)
topic_code = str(req.topic_code or "").strip()
if not topic_code:
return {"status": "ok", "created": 0, "request_id": str(req.id)}
topic_items = (
db.query(TopicDataTemplate)
.filter(
TopicDataTemplate.topic_code == topic_code,
TopicDataTemplate.enabled.is_(True),
)
.order_by(TopicDataTemplate.sort_order.asc(), TopicDataTemplate.key.asc())
.all()
)
existing_keys = {
str(key).strip()
for (key,) in db.query(RequestDataRequirement.key).filter(RequestDataRequirement.request_id == req.id).all()
if key
}
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
actor_id = actor_admin_uuid(admin)
created = 0
for template in topic_items:
key = str(template.key or "").strip()
if not key or key in existing_keys:
continue
db.add(
RequestDataRequirement(
request_id=req.id,
topic_template_id=template.id,
key=key,
label=template.label,
description=template.description,
required=bool(template.required),
created_by_admin_id=actor_id,
responsible=responsible,
)
)
existing_keys.add(key)
created += 1
db.commit()
return {"status": "ok", "created": created, "request_id": str(req.id)}
@router.post("/{request_id}/data-template/items", status_code=201)
def create_request_data_requirement(
request_id: str,
payload: RequestDataRequirementCreate,
db: Session = Depends(get_db),
admin=Depends(require_role("ADMIN", "LAWYER")),
):
request_uuid = _request_uuid_or_400(request_id)
req = db.get(Request, request_uuid)
if req is None:
raise HTTPException(status_code=404, detail="Заявка не найдена")
_ensure_lawyer_can_manage_request_or_403(admin, req)
key = str(payload.key or "").strip()
label = str(payload.label or "").strip()
if not key:
raise HTTPException(status_code=400, detail='Поле "key" обязательно')
if not label:
raise HTTPException(status_code=400, detail='Поле "label" обязательно')
exists = (
db.query(RequestDataRequirement.id)
.filter(RequestDataRequirement.request_id == req.id, RequestDataRequirement.key == key)
.first()
)
if exists is not None:
raise HTTPException(status_code=400, detail="Элемент с таким key уже существует в шаблоне заявки")
row = RequestDataRequirement(
request_id=req.id,
topic_template_id=None,
key=key,
label=label,
description=payload.description,
required=bool(payload.required),
created_by_admin_id=actor_admin_uuid(admin),
responsible=str(admin.get("email") or "").strip() or "Администратор системы",
)
db.add(row)
db.commit()
db.refresh(row)
return _request_data_requirement_row(row)
@router.patch("/{request_id}/data-template/items/{item_id}")
def update_request_data_requirement(
request_id: str,
item_id: str,
payload: RequestDataRequirementPatch,
db: Session = Depends(get_db),
admin=Depends(require_role("ADMIN", "LAWYER")),
):
request_uuid = _request_uuid_or_400(request_id)
req = db.get(Request, request_uuid)
if req is None:
raise HTTPException(status_code=404, detail="Заявка не найдена")
_ensure_lawyer_can_manage_request_or_403(admin, req)
item_uuid = _request_uuid_or_400(item_id)
row = db.get(RequestDataRequirement, item_uuid)
if row is None or row.request_id != req.id:
raise HTTPException(status_code=404, detail="Элемент шаблона заявки не найден")
changes = payload.model_dump(exclude_unset=True)
if not changes:
raise HTTPException(status_code=400, detail="Нет полей для обновления")
if "key" in changes:
key = str(changes.get("key") or "").strip()
if not key:
raise HTTPException(status_code=400, detail='Поле "key" не может быть пустым')
duplicate = (
db.query(RequestDataRequirement.id)
.filter(
RequestDataRequirement.request_id == req.id,
RequestDataRequirement.key == key,
RequestDataRequirement.id != row.id,
)
.first()
)
if duplicate is not None:
raise HTTPException(status_code=400, detail="Элемент с таким key уже существует в шаблоне заявки")
row.key = key
if "label" in changes:
label = str(changes.get("label") or "").strip()
if not label:
raise HTTPException(status_code=400, detail='Поле "label" не может быть пустым')
row.label = label
if "description" in changes:
row.description = changes.get("description")
if "required" in changes:
row.required = bool(changes.get("required"))
row.responsible = str(admin.get("email") or "").strip() or "Администратор системы"
db.add(row)
db.commit()
db.refresh(row)
return _request_data_requirement_row(row)
@router.delete("/{request_id}/data-template/items/{item_id}")
def delete_request_data_requirement(
request_id: str,
item_id: str,
db: Session = Depends(get_db),
admin=Depends(require_role("ADMIN", "LAWYER")),
):
request_uuid = _request_uuid_or_400(request_id)
req = db.get(Request, request_uuid)
if req is None:
raise HTTPException(status_code=404, detail="Заявка не найдена")
_ensure_lawyer_can_manage_request_or_403(admin, req)
item_uuid = _request_uuid_or_400(item_id)
row = db.get(RequestDataRequirement, item_uuid)
if row is None or row.request_id != req.id:
raise HTTPException(status_code=404, detail="Элемент шаблона заявки не найден")
db.delete(row)
db.commit()
return {"status": "удалено", "id": str(row.id)}

View file

@ -1,5 +1,5 @@
from fastapi import APIRouter
from app.api.admin import auth, requests, quotes, meta, config, uploads, metrics
from app.api.admin import auth, requests, quotes, meta, config, uploads, metrics, crud, notifications
router = APIRouter()
router.include_router(auth.router, prefix="/auth", tags=["AdminAuth"])
@ -9,3 +9,5 @@ router.include_router(meta.router, prefix="/meta", tags=["AdminMeta"])
router.include_router(config.router, prefix="/config", tags=["AdminConfig"])
router.include_router(uploads.router, prefix="/uploads", tags=["AdminFiles"])
router.include_router(metrics.router, prefix="/metrics", tags=["AdminMetrics"])
router.include_router(notifications.router, prefix="/notifications", tags=["AdminNotifications"])
router.include_router(crud.router, prefix="/crud", tags=["AdminCrud"])

View file

@ -1,12 +1,245 @@
from fastapi import APIRouter, Depends
from __future__ import annotations
import uuid
from typing import Tuple
from botocore.exceptions import ClientError
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.deps import require_role
from app.core.security import decode_jwt
from app.db.session import get_db
from app.models.admin_user import AdminUser
from app.models.attachment import Attachment
from app.models.message import Message
from app.models.request import Request
from app.schemas.uploads import UploadCompletePayload, UploadCompleteResponse, UploadInitPayload, UploadInitResponse, UploadScope
from app.services.notifications import EVENT_ATTACHMENT as NOTIFICATION_EVENT_ATTACHMENT, notify_request_event
from app.services.request_read_markers import EVENT_ATTACHMENT, mark_unread_for_client
from app.services.s3_storage import build_object_key, get_s3_storage
router = APIRouter()
@router.post("/init")
def upload_init(admin=Depends(require_role("ADMIN","LAWYER"))):
return {"method": "PRESIGNED_PUT", "presigned_url": "https://s3.local/..."}
@router.post("/complete")
def upload_complete(admin=Depends(require_role("ADMIN","LAWYER"))):
return {"status": "ok"}
def _max_file_bytes() -> int:
return int(settings.MAX_FILE_MB) * 1024 * 1024
def _max_case_bytes() -> int:
return int(settings.MAX_CASE_MB) * 1024 * 1024
def _validate_size_or_400(size_bytes: int) -> None:
if int(size_bytes or 0) <= 0:
raise HTTPException(status_code=400, detail="Некорректный размер файла")
if int(size_bytes) > _max_file_bytes():
raise HTTPException(status_code=400, detail=f"Превышен лимит файла ({settings.MAX_FILE_MB} МБ)")
def _uuid_or_400(raw: str | None, field_name: str) -> uuid.UUID:
if not raw:
raise HTTPException(status_code=400, detail=f'Поле "{field_name}" обязательно')
try:
return uuid.UUID(str(raw))
except ValueError:
raise HTTPException(status_code=400, detail=f'Некорректный "{field_name}"')
def _ensure_case_capacity_or_400(request: Request, add_bytes: int) -> None:
current = int(request.total_attachments_bytes or 0)
if current + int(add_bytes) > _max_case_bytes():
raise HTTPException(status_code=400, detail=f"Превышен лимит вложений заявки ({settings.MAX_CASE_MB} МБ)")
def _ensure_object_key_prefix_or_400(key: str, prefix: str) -> None:
if not str(key or "").startswith(prefix):
raise HTTPException(status_code=400, detail="Некорректный ключ объекта для выбранной сущности")
def _parse_scoped_object_key(key: str) -> Tuple[str, str]:
raw = str(key or "").strip()
if not raw or "/" not in raw:
return "", ""
first = raw.split("/", 1)[0].strip().lower()
parts = raw.split("/")
if len(parts) < 3:
return first, ""
return first, parts[1].strip()
def _uuid_or_none(raw: str) -> uuid.UUID | None:
try:
return uuid.UUID(str(raw))
except (TypeError, ValueError):
return None
@router.post("/init", response_model=UploadInitResponse)
def upload_init(
payload: UploadInitPayload,
db: Session = Depends(get_db),
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
):
_validate_size_or_400(payload.size_bytes)
storage = get_s3_storage()
role = str(admin.get("role") or "")
actor_id = str(admin.get("sub") or "")
if payload.scope == UploadScope.REQUEST_ATTACHMENT:
request_uuid = _uuid_or_400(payload.request_id, "request_id")
request = db.get(Request, request_uuid)
if request is None:
raise HTTPException(status_code=404, detail="Заявка не найдена")
_ensure_case_capacity_or_400(request, payload.size_bytes)
key = build_object_key(f"requests/{request.id}", payload.file_name)
return UploadInitResponse(key=key, presigned_url=storage.create_presigned_put_url(key, payload.mime_type))
if payload.scope == UploadScope.USER_AVATAR:
target_user_id = str(payload.user_id or actor_id)
target_uuid = _uuid_or_400(target_user_id, "user_id")
if role != "ADMIN" and str(target_uuid) != actor_id:
raise HTTPException(status_code=403, detail="Недостаточно прав для загрузки аватара")
user = db.get(AdminUser, target_uuid)
if user is None:
raise HTTPException(status_code=404, detail="Пользователь не найден")
key = build_object_key(f"avatars/{user.id}", payload.file_name)
return UploadInitResponse(key=key, presigned_url=storage.create_presigned_put_url(key, payload.mime_type))
raise HTTPException(status_code=400, detail="Неподдерживаемый scope")
@router.post("/complete", response_model=UploadCompleteResponse)
def upload_complete(
payload: UploadCompletePayload,
db: Session = Depends(get_db),
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
):
_validate_size_or_400(payload.size_bytes)
storage = get_s3_storage()
role = str(admin.get("role") or "")
actor_id = str(admin.get("sub") or "")
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
try:
head = storage.head_object(payload.key)
except ClientError:
raise HTTPException(status_code=400, detail="Файл не найден в хранилище")
actual_size = int(head.get("ContentLength") or payload.size_bytes)
if actual_size <= 0:
raise HTTPException(status_code=400, detail="Некорректный размер файла")
if actual_size > _max_file_bytes():
raise HTTPException(status_code=400, detail=f"Превышен лимит файла ({settings.MAX_FILE_MB} МБ)")
if payload.scope == UploadScope.REQUEST_ATTACHMENT:
request_uuid = _uuid_or_400(payload.request_id, "request_id")
request = db.get(Request, request_uuid)
if request is None:
raise HTTPException(status_code=404, detail="Заявка не найдена")
_ensure_object_key_prefix_or_400(payload.key, f"requests/{request.id}/")
_ensure_case_capacity_or_400(request, actual_size)
message_uuid = None
if payload.message_id:
message_uuid = _uuid_or_400(payload.message_id, "message_id")
message = db.get(Message, message_uuid)
if message is None or message.request_id != request.id:
raise HTTPException(status_code=400, detail="Сообщение не найдено для указанной заявки")
if bool(message.immutable):
raise HTTPException(status_code=400, detail="Нельзя прикрепить файл к зафиксированному сообщению")
row = Attachment(
request_id=request.id,
message_id=message_uuid,
file_name=payload.file_name,
mime_type=payload.mime_type,
size_bytes=actual_size,
s3_key=payload.key,
responsible=responsible,
)
mark_unread_for_client(request, EVENT_ATTACHMENT)
notify_request_event(
db,
request=request,
event_type=NOTIFICATION_EVENT_ATTACHMENT,
actor_role=str(admin.get("role") or "").upper() or "ADMIN",
actor_admin_user_id=admin.get("sub"),
body=f'Файл: {payload.file_name}',
responsible=responsible,
)
request.total_attachments_bytes = int(request.total_attachments_bytes or 0) + actual_size
request.responsible = responsible
db.add(row)
db.add(request)
db.commit()
db.refresh(row)
return UploadCompleteResponse(status="ok", attachment_id=str(row.id))
if payload.scope == UploadScope.USER_AVATAR:
target_user_id = str(payload.user_id or actor_id)
target_uuid = _uuid_or_400(target_user_id, "user_id")
if role != "ADMIN" and str(target_uuid) != actor_id:
raise HTTPException(status_code=403, detail="Недостаточно прав для загрузки аватара")
user = db.get(AdminUser, target_uuid)
if user is None:
raise HTTPException(status_code=404, detail="Пользователь не найден")
_ensure_object_key_prefix_or_400(payload.key, f"avatars/{user.id}/")
user.avatar_url = f"s3://{payload.key}"
user.responsible = responsible
db.add(user)
db.commit()
return UploadCompleteResponse(status="ok", avatar_url=user.avatar_url)
raise HTTPException(status_code=400, detail="Неподдерживаемый scope")
@router.get("/object/{object_key:path}")
def get_object_proxy(object_key: str, token: str = Query(...), db: Session = Depends(get_db)):
try:
claims = decode_jwt(token, settings.ADMIN_JWT_SECRET)
except Exception:
raise HTTPException(status_code=401, detail="Некорректный токен")
role = str(claims.get("role") or "").upper()
if role not in {"ADMIN", "LAWYER"}:
raise HTTPException(status_code=403, detail="Недостаточно прав")
key = str(object_key or "").strip()
if not key:
raise HTTPException(status_code=400, detail="Некорректный ключ объекта")
scope, scoped_id_raw = _parse_scoped_object_key(key)
if role == "LAWYER":
actor_id = _uuid_or_none(claims.get("sub"))
if actor_id is None:
raise HTTPException(status_code=401, detail="Некорректный токен")
scoped_uuid = _uuid_or_none(scoped_id_raw)
if scope == "avatars":
if scoped_uuid is None or scoped_uuid != actor_id:
raise HTTPException(status_code=403, detail="Недостаточно прав")
elif scope == "requests":
if scoped_uuid is None:
raise HTTPException(status_code=403, detail="Недостаточно прав")
# LAWYER can download files from own or unassigned requests only.
request = db.get(Request, scoped_uuid)
if request is None:
raise HTTPException(status_code=404, detail="Заявка не найдена")
assigned = str(request.assigned_lawyer_id or "").strip()
if assigned and assigned != str(actor_id):
raise HTTPException(status_code=403, detail="Недостаточно прав")
else:
raise HTTPException(status_code=403, detail="Недостаточно прав")
try:
obj = get_s3_storage().get_object(key)
except ClientError:
raise HTTPException(status_code=404, detail="Файл не найден")
body = obj["Body"]
content_length = obj.get("ContentLength")
media_type = obj.get("ContentType") or "application/octet-stream"
headers = {}
if content_length is not None:
headers["Content-Length"] = str(content_length)
return StreamingResponse(body.iter_chunks(chunk_size=64 * 1024), media_type=media_type, headers=headers)

View file

@ -1,19 +1,66 @@
from fastapi import APIRouter, Response
from datetime import timedelta
from app.schemas.public import OtpSend, OtpVerify
from __future__ import annotations
import secrets
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.security import create_jwt
from app.core.security import create_jwt, hash_password, verify_password
from app.db.session import get_db
from app.models.otp_session import OtpSession
from app.models.request import Request
from app.schemas.public import OtpSend, OtpVerify
router = APIRouter()
@router.post("/send")
def send_otp(payload: OtpSend):
return {"status": "sent"}
OTP_TTL_MINUTES = 10
OTP_MAX_ATTEMPTS = 5
OTP_CREATE_PURPOSE = "CREATE_REQUEST"
OTP_VIEW_PURPOSE = "VIEW_REQUEST"
ALLOWED_PURPOSES = {OTP_CREATE_PURPOSE, OTP_VIEW_PURPOSE}
@router.post("/verify")
def verify_otp(payload: OtpVerify, response: Response):
token = create_jwt({"sub": payload.track_number or "unknown", "purpose": payload.purpose},
settings.PUBLIC_JWT_SECRET, timedelta(days=settings.PUBLIC_JWT_TTL_DAYS))
def _now_utc() -> datetime:
return datetime.now(timezone.utc)
def _as_utc(dt: datetime | None) -> datetime:
if dt is None:
return _now_utc()
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def _normalize_purpose(raw: str | None) -> str:
return str(raw or "").strip().upper()
def _normalize_phone(raw: str | None) -> str:
phone = str(raw or "").strip()
if not phone:
return ""
allowed = {"+", "(", ")", "-", " "}
digits = [ch for ch in phone if ch.isdigit() or ch in allowed]
return "".join(digits).strip()
def _normalize_track(raw: str | None) -> str:
return str(raw or "").strip().upper()
def _generate_code() -> str:
return f"{secrets.randbelow(1_000_000):06d}"
def _set_public_cookie(response: Response, *, subject: str, purpose: str) -> None:
token = create_jwt(
{"sub": subject, "purpose": purpose},
settings.PUBLIC_JWT_SECRET,
timedelta(days=settings.PUBLIC_JWT_TTL_DAYS),
)
response.set_cookie(
key=settings.PUBLIC_COOKIE_NAME,
value=token,
@ -22,4 +69,125 @@ def verify_otp(payload: OtpVerify, response: Response):
samesite="lax",
max_age=settings.PUBLIC_JWT_TTL_DAYS * 24 * 3600,
)
return {"status": "verified"}
def _mock_sms_send(phone: str, code: str, purpose: str, track_number: str | None = None) -> dict:
# Dev-only behavior: emit OTP in console instead of sending real SMS.
print(f"[OTP MOCK] purpose={purpose} phone={phone} track={track_number or '-'} code={code}")
return {
"provider": "mock_sms",
"status": "accepted",
"message": "SMS provider response mocked",
}
@router.post("/send")
def send_otp(payload: OtpSend, db: Session = Depends(get_db)):
purpose = _normalize_purpose(payload.purpose)
if purpose not in ALLOWED_PURPOSES:
raise HTTPException(status_code=400, detail="Некорректная цель OTP")
track_number: str | None = None
phone = ""
if purpose == OTP_CREATE_PURPOSE:
phone = _normalize_phone(payload.client_phone)
if not phone:
raise HTTPException(status_code=400, detail='Поле "client_phone" обязательно для CREATE_REQUEST')
else:
track_number = _normalize_track(payload.track_number)
if not track_number:
raise HTTPException(status_code=400, detail='Поле "track_number" обязательно для VIEW_REQUEST')
request = db.query(Request).filter(Request.track_number == track_number).first()
if request is None:
raise HTTPException(status_code=404, detail="Заявка не найдена")
phone = _normalize_phone(request.client_phone)
if not phone:
raise HTTPException(status_code=400, detail="У заявки отсутствует номер телефона")
code = _generate_code()
now = _now_utc()
expires_at = now + timedelta(minutes=OTP_TTL_MINUTES)
existing_query = db.query(OtpSession).filter(
OtpSession.purpose == purpose,
OtpSession.phone == phone,
OtpSession.track_number == track_number,
)
existing_query.delete(synchronize_session=False)
row = OtpSession(
purpose=purpose,
track_number=track_number,
phone=phone,
code_hash=hash_password(code),
attempts=0,
expires_at=expires_at,
responsible="Система OTP",
)
db.add(row)
db.commit()
db.refresh(row)
sms_response = _mock_sms_send(phone, code, purpose, track_number)
return {
"status": "sent",
"purpose": purpose,
"track_number": track_number,
"ttl_seconds": OTP_TTL_MINUTES * 60,
"sms_response": sms_response,
}
@router.post("/verify")
def verify_otp(payload: OtpVerify, response: Response, db: Session = Depends(get_db)):
purpose = _normalize_purpose(payload.purpose)
if purpose not in ALLOWED_PURPOSES:
raise HTTPException(status_code=400, detail="Некорректная цель OTP")
track_number: str | None = None
phone: str | None = None
if purpose == OTP_CREATE_PURPOSE:
phone = _normalize_phone(payload.client_phone)
if not phone:
raise HTTPException(status_code=400, detail='Поле "client_phone" обязательно для CREATE_REQUEST')
else:
track_number = _normalize_track(payload.track_number)
if not track_number:
raise HTTPException(status_code=400, detail='Поле "track_number" обязательно для VIEW_REQUEST')
query = db.query(OtpSession).filter(
OtpSession.purpose == purpose,
OtpSession.track_number == track_number,
)
if phone is not None:
query = query.filter(OtpSession.phone == phone)
row = query.order_by(OtpSession.created_at.desc()).first()
if row is None:
raise HTTPException(status_code=400, detail="OTP не найден или истек")
now = _now_utc()
if _as_utc(row.expires_at) <= now:
db.delete(row)
db.commit()
raise HTTPException(status_code=400, detail="OTP не найден или истек")
if int(row.attempts or 0) >= OTP_MAX_ATTEMPTS:
raise HTTPException(status_code=429, detail="Превышено количество попыток")
code = str(payload.code or "").strip()
if not code or not verify_password(code, row.code_hash):
row.attempts = int(row.attempts or 0) + 1
db.add(row)
db.commit()
raise HTTPException(status_code=400, detail="Неверный OTP-код")
subject = row.phone if purpose == OTP_CREATE_PURPOSE else str(row.track_number or "")
if not subject:
raise HTTPException(status_code=400, detail="Некорректная OTP-сессия")
_set_public_cookie(response, subject=subject, purpose=purpose)
db.delete(row)
db.commit()
return {"status": "verified", "purpose": purpose}

View file

@ -1,16 +1,421 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from __future__ import annotations
from datetime import timedelta
from uuid import UUID
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.deps import get_public_session
from app.core.security import create_jwt
from app.db.session import get_db
from app.schemas.public import PublicRequestCreate, PublicRequestCreated
from app.models.attachment import Attachment
from app.models.message import Message
from app.models.request import Request
from app.models.status_history import StatusHistory
from app.services.notifications import (
EVENT_MESSAGE as NOTIFICATION_EVENT_MESSAGE,
get_client_notification,
list_client_notifications,
mark_client_notifications_read,
notify_request_event,
serialize_notification,
)
from app.services.request_read_markers import EVENT_MESSAGE, clear_unread_for_client, mark_unread_for_lawyer
from app.services.request_templates import validate_required_topic_fields_or_400
from app.schemas.public import (
PublicAttachmentRead,
PublicMessageCreate,
PublicMessageRead,
PublicRequestCreate,
PublicRequestCreated,
PublicStatusHistoryRead,
PublicTimelineEvent,
)
router = APIRouter()
OTP_CREATE_PURPOSE = "CREATE_REQUEST"
OTP_VIEW_PURPOSE = "VIEW_REQUEST"
def _normalize_phone(raw: str | None) -> str:
return str(raw or "").strip()
def _normalize_track(raw: str | None) -> str:
return str(raw or "").strip().upper()
def _set_view_cookie(response: Response, track_number: str) -> None:
token = create_jwt(
{"sub": track_number, "purpose": OTP_VIEW_PURPOSE},
settings.PUBLIC_JWT_SECRET,
timedelta(days=settings.PUBLIC_JWT_TTL_DAYS),
)
response.set_cookie(
key=settings.PUBLIC_COOKIE_NAME,
value=token,
httponly=True,
secure=False,
samesite="lax",
max_age=settings.PUBLIC_JWT_TTL_DAYS * 24 * 3600,
)
def _require_create_session_or_403(session: dict, client_phone: str) -> None:
purpose = str(session.get("purpose") or "").strip().upper()
sub = _normalize_phone(session.get("sub"))
if purpose != OTP_CREATE_PURPOSE or not sub or sub != _normalize_phone(client_phone):
raise HTTPException(status_code=403, detail="Требуется подтверждение телефона через OTP")
def _require_view_session_for_track_or_403(session: dict, track_number: str) -> None:
purpose = str(session.get("purpose") or "").strip().upper()
sub = _normalize_track(session.get("sub"))
if purpose != OTP_VIEW_PURPOSE or not sub or sub != _normalize_track(track_number):
raise HTTPException(status_code=403, detail="Нет доступа к заявке")
def _request_for_track_or_404(db: Session, session: dict, track_number: str) -> Request:
normalized_track = _normalize_track(track_number)
_require_view_session_for_track_or_403(session, normalized_track)
req = db.query(Request).filter(Request.track_number == normalized_track).first()
if req is None:
raise HTTPException(status_code=404, detail="Заявка не найдена")
return req
def _to_iso(value) -> str | None:
return value.isoformat() if value is not None else None
@router.post("", response_model=PublicRequestCreated, status_code=201)
def create_request(payload: PublicRequestCreate, db: Session = Depends(get_db)):
def create_request(
payload: PublicRequestCreate,
response: Response,
db: Session = Depends(get_db),
session: dict = Depends(get_public_session),
):
_require_create_session_or_403(session, payload.client_phone)
validate_required_topic_fields_or_400(db, payload.topic_code, payload.extra_fields)
track = f"TRK-{uuid4().hex[:10].upper()}"
r = Request(track_number=track, client_name=payload.client_name, client_phone=payload.client_phone,
topic_code=payload.topic_code, description=payload.description, extra_fields=payload.extra_fields)
db.add(r); db.commit(); db.refresh(r)
return PublicRequestCreated(request_id=r.id, track_number=r.track_number, otp_required=True)
row = Request(
track_number=track,
client_name=payload.client_name,
client_phone=payload.client_phone,
topic_code=payload.topic_code,
description=payload.description,
extra_fields=payload.extra_fields,
responsible="Клиент",
)
db.add(row)
db.commit()
db.refresh(row)
_set_view_cookie(response, track)
return PublicRequestCreated(request_id=row.id, track_number=row.track_number, otp_required=False)
@router.get("/{track_number}")
def get_request_by_track(
track_number: str,
db: Session = Depends(get_db),
session: dict = Depends(get_public_session),
):
req = _request_for_track_or_404(db, session, track_number)
markers_cleared = clear_unread_for_client(req)
notifications_cleared = mark_client_notifications_read(
db,
track_number=req.track_number,
request_id=req.id,
responsible="Клиент",
)
if markers_cleared or notifications_cleared:
db.add(req)
db.commit()
db.refresh(req)
return {
"id": str(req.id),
"track_number": req.track_number,
"client_name": req.client_name,
"client_phone": req.client_phone,
"topic_code": req.topic_code,
"status_code": req.status_code,
"description": req.description,
"extra_fields": req.extra_fields,
"assigned_lawyer_id": req.assigned_lawyer_id,
"client_has_unread_updates": req.client_has_unread_updates,
"client_unread_event_type": req.client_unread_event_type,
"lawyer_has_unread_updates": req.lawyer_has_unread_updates,
"lawyer_unread_event_type": req.lawyer_unread_event_type,
"created_at": _to_iso(req.created_at),
"updated_at": _to_iso(req.updated_at),
}
@router.get("/{track_number}/messages", response_model=list[PublicMessageRead])
def list_messages_by_track(
track_number: str,
db: Session = Depends(get_db),
session: dict = Depends(get_public_session),
):
req = _request_for_track_or_404(db, session, track_number)
rows = (
db.query(Message)
.filter(Message.request_id == req.id)
.order_by(Message.created_at.asc(), Message.id.asc())
.all()
)
return [
PublicMessageRead(
id=row.id,
request_id=row.request_id,
author_type=row.author_type,
author_name=row.author_name,
body=row.body,
created_at=_to_iso(row.created_at),
updated_at=_to_iso(row.updated_at),
)
for row in rows
]
@router.post("/{track_number}/messages", response_model=PublicMessageRead, status_code=201)
def create_message_by_track(
track_number: str,
payload: PublicMessageCreate,
db: Session = Depends(get_db),
session: dict = Depends(get_public_session),
):
req = _request_for_track_or_404(db, session, track_number)
body = str(payload.body or "").strip()
if not body:
raise HTTPException(status_code=400, detail='Поле "body" обязательно')
row = Message(
request_id=req.id,
author_type="CLIENT",
author_name=req.client_name,
body=body,
responsible="Клиент",
)
mark_unread_for_lawyer(req, EVENT_MESSAGE)
req.responsible = "Клиент"
notify_request_event(
db,
request=req,
event_type=NOTIFICATION_EVENT_MESSAGE,
actor_role="CLIENT",
body=body,
responsible="Клиент",
)
db.add(row)
db.add(req)
db.commit()
db.refresh(row)
return PublicMessageRead(
id=row.id,
request_id=row.request_id,
author_type=row.author_type,
author_name=row.author_name,
body=row.body,
created_at=_to_iso(row.created_at),
updated_at=_to_iso(row.updated_at),
)
@router.get("/{track_number}/attachments", response_model=list[PublicAttachmentRead])
def list_attachments_by_track(
track_number: str,
db: Session = Depends(get_db),
session: dict = Depends(get_public_session),
):
req = _request_for_track_or_404(db, session, track_number)
rows = (
db.query(Attachment)
.filter(Attachment.request_id == req.id)
.order_by(Attachment.created_at.desc(), Attachment.id.desc())
.all()
)
return [
PublicAttachmentRead(
id=row.id,
request_id=row.request_id,
message_id=row.message_id,
file_name=row.file_name,
mime_type=row.mime_type,
size_bytes=row.size_bytes,
created_at=_to_iso(row.created_at),
download_url=f"/api/public/uploads/object/{row.id}",
)
for row in rows
]
@router.get("/{track_number}/history", response_model=list[PublicStatusHistoryRead])
def list_status_history_by_track(
track_number: str,
db: Session = Depends(get_db),
session: dict = Depends(get_public_session),
):
req = _request_for_track_or_404(db, session, track_number)
rows = (
db.query(StatusHistory)
.filter(StatusHistory.request_id == req.id)
.order_by(StatusHistory.created_at.asc(), StatusHistory.id.asc())
.all()
)
return [
PublicStatusHistoryRead(
id=row.id,
request_id=row.request_id,
from_status=row.from_status,
to_status=row.to_status,
comment=row.comment,
created_at=_to_iso(row.created_at),
)
for row in rows
]
@router.get("/{track_number}/timeline", response_model=list[PublicTimelineEvent])
def list_timeline_by_track(
track_number: str,
db: Session = Depends(get_db),
session: dict = Depends(get_public_session),
):
req = _request_for_track_or_404(db, session, track_number)
messages = db.query(Message).filter(Message.request_id == req.id).all()
attachments = db.query(Attachment).filter(Attachment.request_id == req.id).all()
statuses = db.query(StatusHistory).filter(StatusHistory.request_id == req.id).all()
events: list[PublicTimelineEvent] = []
for row in statuses:
events.append(
PublicTimelineEvent(
type="status_change",
created_at=_to_iso(row.created_at),
payload={
"id": str(row.id),
"from_status": row.from_status,
"to_status": row.to_status,
"comment": row.comment,
},
)
)
for row in messages:
events.append(
PublicTimelineEvent(
type="message",
created_at=_to_iso(row.created_at),
payload={
"id": str(row.id),
"author_type": row.author_type,
"author_name": row.author_name,
"body": row.body,
},
)
)
for row in attachments:
events.append(
PublicTimelineEvent(
type="attachment",
created_at=_to_iso(row.created_at),
payload={
"id": str(row.id),
"file_name": row.file_name,
"mime_type": row.mime_type,
"size_bytes": row.size_bytes,
"download_url": f"/api/public/uploads/object/{row.id}",
},
)
)
def _sort_key(event: PublicTimelineEvent):
return event.created_at or ""
events.sort(key=_sort_key)
return events
@router.get("/{track_number}/notifications")
def list_notifications_by_track(
track_number: str,
unread_only: bool = False,
limit: int = 50,
offset: int = 0,
db: Session = Depends(get_db),
session: dict = Depends(get_public_session),
):
req = _request_for_track_or_404(db, session, track_number)
rows, total = list_client_notifications(
db,
track_number=req.track_number,
unread_only=bool(unread_only),
request_id=req.id,
limit=limit,
offset=offset,
)
_, unread_total = list_client_notifications(
db,
track_number=req.track_number,
unread_only=True,
request_id=req.id,
limit=1,
offset=0,
)
return {
"rows": [serialize_notification(row) for row in rows],
"total": int(total),
"unread_total": int(unread_total),
}
@router.post("/{track_number}/notifications/{notification_id}/read")
def read_notification_by_track(
track_number: str,
notification_id: str,
db: Session = Depends(get_db),
session: dict = Depends(get_public_session),
):
req = _request_for_track_or_404(db, session, track_number)
try:
notification_uuid = UUID(str(notification_id))
except ValueError:
raise HTTPException(status_code=400, detail="Некорректный notification_id")
row = get_client_notification(db, track_number=req.track_number, notification_id=notification_uuid)
if row is None or str(row.request_id) != str(req.id):
raise HTTPException(status_code=404, detail="Уведомление не найдено")
changed = mark_client_notifications_read(
db,
track_number=req.track_number,
request_id=req.id,
notification_id=notification_uuid,
responsible="Клиент",
)
db.commit()
refreshed = get_client_notification(db, track_number=req.track_number, notification_id=notification_uuid)
return {"status": "ok", "changed": int(changed), "notification": serialize_notification(refreshed) if refreshed else None}
@router.post("/{track_number}/notifications/read-all")
def read_all_notifications_by_track(
track_number: str,
db: Session = Depends(get_db),
session: dict = Depends(get_public_session),
):
req = _request_for_track_or_404(db, session, track_number)
changed = mark_client_notifications_read(
db,
track_number=req.track_number,
request_id=req.id,
responsible="Клиент",
)
db.commit()
return {"status": "ok", "changed": int(changed)}

View file

@ -1,10 +1,164 @@
from fastapi import APIRouter
from __future__ import annotations
import uuid
from urllib.parse import quote
from botocore.exceptions import ClientError
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.deps import get_public_session
from app.db.session import get_db
from app.models.attachment import Attachment
from app.models.request import Request
from app.schemas.uploads import UploadCompletePayload, UploadCompleteResponse, UploadInitPayload, UploadInitResponse, UploadScope
from app.services.notifications import EVENT_ATTACHMENT as NOTIFICATION_EVENT_ATTACHMENT, notify_request_event
from app.services.request_read_markers import EVENT_ATTACHMENT, mark_unread_for_lawyer
from app.services.s3_storage import build_object_key, get_s3_storage
router = APIRouter()
@router.post("/init")
def upload_init():
return {"method": "PRESIGNED_PUT", "presigned_url": "https://s3.local/..."}
@router.post("/complete")
def upload_complete():
return {"status": "ok"}
def _max_file_bytes() -> int:
return int(settings.MAX_FILE_MB) * 1024 * 1024
def _max_case_bytes() -> int:
return int(settings.MAX_CASE_MB) * 1024 * 1024
def _uuid_or_400(raw: str | None, field_name: str) -> uuid.UUID:
if not raw:
raise HTTPException(status_code=400, detail=f'Поле "{field_name}" обязательно')
try:
return uuid.UUID(str(raw))
except ValueError:
raise HTTPException(status_code=400, detail=f'Некорректный "{field_name}"')
def _ensure_object_key_prefix_or_400(key: str, prefix: str) -> None:
if not str(key or "").startswith(prefix):
raise HTTPException(status_code=400, detail="Некорректный ключ объекта для выбранной заявки")
def _ensure_public_request_access_or_403(request: Request, session: dict) -> None:
purpose = str(session.get("purpose") or "").strip().upper()
if purpose != "VIEW_REQUEST":
raise HTTPException(status_code=403, detail="Нет доступа к заявке")
track_from_session = str(session.get("sub") or "").strip()
if not track_from_session or track_from_session != str(request.track_number):
raise HTTPException(status_code=403, detail="Нет доступа к заявке")
def _load_attachment_with_access_or_4xx(attachment_id: str, db: Session, session: dict) -> Attachment:
attachment_uuid = _uuid_or_400(attachment_id, "attachment_id")
attachment = db.get(Attachment, attachment_uuid)
if attachment is None:
raise HTTPException(status_code=404, detail="Файл не найден")
request = db.get(Request, attachment.request_id)
if request is None:
raise HTTPException(status_code=404, detail="Заявка не найдена")
_ensure_public_request_access_or_403(request, session)
return attachment
@router.post("/init", response_model=UploadInitResponse)
def upload_init(payload: UploadInitPayload, db: Session = Depends(get_db), session: dict = Depends(get_public_session)):
if payload.scope != UploadScope.REQUEST_ATTACHMENT:
raise HTTPException(status_code=400, detail="Публичная загрузка поддерживает только REQUEST_ATTACHMENT")
if int(payload.size_bytes or 0) <= 0:
raise HTTPException(status_code=400, detail="Некорректный размер файла")
if int(payload.size_bytes) > _max_file_bytes():
raise HTTPException(status_code=400, detail=f"Превышен лимит файла ({settings.MAX_FILE_MB} МБ)")
request_uuid = _uuid_or_400(payload.request_id, "request_id")
request = db.get(Request, request_uuid)
if request is None:
raise HTTPException(status_code=404, detail="Заявка не найдена")
_ensure_public_request_access_or_403(request, session)
current = int(request.total_attachments_bytes or 0)
if current + int(payload.size_bytes) > _max_case_bytes():
raise HTTPException(status_code=400, detail=f"Превышен лимит вложений заявки ({settings.MAX_CASE_MB} МБ)")
key = build_object_key(f"requests/{request.id}", payload.file_name)
presigned_url = get_s3_storage().create_presigned_put_url(key, payload.mime_type)
return UploadInitResponse(key=key, presigned_url=presigned_url)
@router.post("/complete", response_model=UploadCompleteResponse)
def upload_complete(payload: UploadCompletePayload, db: Session = Depends(get_db), session: dict = Depends(get_public_session)):
if payload.scope != UploadScope.REQUEST_ATTACHMENT:
raise HTTPException(status_code=400, detail="Публичная загрузка поддерживает только REQUEST_ATTACHMENT")
request_uuid = _uuid_or_400(payload.request_id, "request_id")
request = db.get(Request, request_uuid)
if request is None:
raise HTTPException(status_code=404, detail="Заявка не найдена")
_ensure_public_request_access_or_403(request, session)
_ensure_object_key_prefix_or_400(payload.key, f"requests/{request.id}/")
storage = get_s3_storage()
try:
head = storage.head_object(payload.key)
except ClientError:
raise HTTPException(status_code=400, detail="Файл не найден в хранилище")
actual_size = int(head.get("ContentLength") or payload.size_bytes or 0)
if actual_size <= 0:
raise HTTPException(status_code=400, detail="Некорректный размер файла")
if actual_size > _max_file_bytes():
raise HTTPException(status_code=400, detail=f"Превышен лимит файла ({settings.MAX_FILE_MB} МБ)")
if int(request.total_attachments_bytes or 0) + actual_size > _max_case_bytes():
raise HTTPException(status_code=400, detail=f"Превышен лимит вложений заявки ({settings.MAX_CASE_MB} МБ)")
row = Attachment(
request_id=request.id,
message_id=None,
file_name=payload.file_name,
mime_type=payload.mime_type,
size_bytes=actual_size,
s3_key=payload.key,
responsible="Клиент",
)
mark_unread_for_lawyer(request, EVENT_ATTACHMENT)
notify_request_event(
db,
request=request,
event_type=NOTIFICATION_EVENT_ATTACHMENT,
actor_role="CLIENT",
body=f'Файл: {payload.file_name}',
responsible="Клиент",
)
request.total_attachments_bytes = int(request.total_attachments_bytes or 0) + actual_size
request.responsible = "Клиент"
db.add(row)
db.add(request)
db.commit()
db.refresh(row)
return UploadCompleteResponse(status="ok", attachment_id=str(row.id))
@router.get("/object/{attachment_id}")
def get_public_attachment_object(
attachment_id: str,
db: Session = Depends(get_db),
session: dict = Depends(get_public_session),
):
attachment = _load_attachment_with_access_or_4xx(attachment_id, db, session)
try:
obj = get_s3_storage().get_object(attachment.s3_key)
except ClientError:
raise HTTPException(status_code=404, detail="Файл не найден в хранилище")
body = obj["Body"]
content_length = obj.get("ContentLength")
media_type = obj.get("ContentType") or attachment.mime_type or "application/octet-stream"
encoded_name = quote(str(attachment.file_name or "file"), safe="")
headers = {
"Content-Disposition": f"inline; filename*=UTF-8''{encoded_name}",
}
if content_length is not None:
headers["Content-Length"] = str(content_length)
return StreamingResponse(body.iter_chunks(chunk_size=64 * 1024), media_type=media_type, headers=headers)

View file

@ -23,11 +23,12 @@ class Settings(BaseSettings):
S3_REGION: str = "us-east-1"
S3_USE_SSL: bool = False
MAX_FILE_MB: int = 25
MAX_CASE_MB: int = 350
MAX_CASE_MB: int = 250
TELEGRAM_BOT_TOKEN: str = "change_me"
TELEGRAM_CHAT_ID: str = "0"
SMS_PROVIDER: str = "dummy"
DATA_ENCRYPTION_SECRET: str = "change_me_data_encryption"
@property
def cors_origins_list(self) -> List[str]:

1
app/data/__init__.py Normal file
View file

@ -0,0 +1 @@
# Data package for static seed content.

View file

@ -0,0 +1,52 @@
JUSTICE_QUOTES = [
{"author": "Платон", "text": "Справедливость состоит в том, чтобы каждый занимался своим делом.", "source": "Государство"},
{"author": "Аристотель", "text": "Справедливость есть совершенная добродетель в отношении к другому.", "source": "Никомахова этика"},
{"author": "Сократ", "text": "Лучше терпеть несправедливость, чем самому ее совершать.", "source": "Горгий"},
{"author": "Цицерон", "text": "Мы рабы законов, чтобы быть свободными.", "source": "О законах"},
{"author": "Ульпиан", "text": "Правосудие есть постоянная и неизменная воля воздавать каждому свое.", "source": "Дигесты Юстиниана"},
{"author": "Аврелий Августин", "text": "Без справедливости государство - лишь большая шайка разбойников.", "source": "О граде Божьем"},
{"author": "Фома Аквинский", "text": "Несправедливый закон не есть закон.", "source": "Сумма теологии"},
{"author": "Джон Локк", "text": "Там, где нет закона, нет и свободы.", "source": "Два трактата о правлении"},
{"author": "Шарль де Монтескье", "text": "Нет большей тирании, чем та, которая совершается под сенью закона и именем правосудия.", "source": "О духе законов"},
{"author": "Жан-Жак Руссо", "text": "Между сильным и слабым свобода угнетает, а закон освобождает.", "source": "Об общественном договоре"},
{"author": "Чезаре Беккариа", "text": "Лучше предупреждать преступления, чем наказывать их.", "source": "О преступлениях и наказаниях"},
{"author": "Вольтер", "text": "Лучше оправдать виновного, чем осудить невиновного.", "source": "Афоризм"},
{"author": "Иммануил Кант", "text": "Право - это совокупность условий, при которых свобода каждого совместима со свободой всех.", "source": "Метафизика нравов"},
{"author": "Георг Гегель", "text": "Наказание есть право преступника.", "source": "Философия права"},
{"author": "Джереми Бентам", "text": "Цель наказания - не месть, а предупреждение зла.", "source": "Введение в основания нравственности и законодательства"},
{"author": "Уильям Блэкстоун", "text": "Лучше, чтобы десять виновных избежали наказания, чем один невиновный пострадал.", "source": "Комментарии к законам Англии"},
{"author": "Эдмунд Берк", "text": "Плохие законы - худший вид тирании.", "source": "Речь в парламенте"},
{"author": "Александр Гамильтон", "text": "Первая обязанность общества - справедливость.", "source": "Федералист"},
{"author": "Джеймс Мэдисон", "text": "Справедливость - цель правительства.", "source": "Федералист No. 51"},
{"author": "Томас Джефферсон", "text": "Равная и точная справедливость ко всем людям - первый принцип доброго правления.", "source": "Первая инаугурационная речь"},
{"author": "Авраам Линкольн", "text": "С твердостью в правом деле постараемся довести до конца дело справедливого мира.", "source": "Вторая инаугурационная речь"},
{"author": "Теодор Паркер", "text": "Дуга нравственной вселенной длинна, но она склоняется к справедливости.", "source": "Проповедь 1853 года"},
{"author": "Мартин Лютер Кинг-младший", "text": "Несправедливость где бы то ни было - угроза справедливости повсюду.", "source": "Письмо из бирмингемской тюрьмы"},
{"author": "Нельсон Мандела", "text": "Лишать людей их прав - значит бросать вызов их человечности.", "source": "Речь о правах человека"},
{"author": "Махатма Ганди", "text": "Закон любви выше закона насилия.", "source": "Публичные выступления"},
{"author": "Франклин Д. Рузвельт", "text": "Истинная личная свобода не может существовать без экономической безопасности и независимости.", "source": "Послание о четырех свободах"},
{"author": "Уинстон Черчилль", "text": "Сила нации измеряется не только армией, но и доверием к ее правосудию.", "source": "Парламентская речь"},
{"author": "Шарль де Голль", "text": "Власть должна служить праву, иначе она превращается в произвол.", "source": "Политические мемуары"},
{"author": "Мустафа Кемаль Ататюрк", "text": "Справедливость - фундамент государства.", "source": "Речь в Великом национальном собрании"},
{"author": "Ли Куан Ю", "text": "Где закон применяется одинаково, там появляется доверие к государству.", "source": "Интервью о государственном управлении"},
{"author": "Анатоль Франс", "text": "Закон в своем величественном равенстве запрещает и богатым, и бедным спать под мостами.", "source": "Красная лилия"},
{"author": "Федор Достоевский", "text": "Степень цивилизации общества определяется тем, как оно относится к заключенным.", "source": "Записки из Мертвого дома"},
{"author": "Анатолий Кони", "text": "Суд должен быть не только справедливым, но и убедительно справедливым.", "source": "Судебные речи"},
{"author": "Василий Ключевский", "text": "Государство крепко не строгостью законов, а их уважением.", "source": "Лекции по русской истории"},
{"author": "Тацит", "text": "Чем больше в государстве испорченности, тем больше законов.", "source": "Анналы"},
{"author": "Фукидид", "text": "Право имеет силу там, где силы сторон равны.", "source": "История Пелопоннесской войны"},
{"author": "Геродот", "text": "Обычай и закон для каждого народа - царь.", "source": "История"},
{"author": "Солон", "text": "Закон подобен паутине: слабого он удержит, а сильный прорвет.", "source": "Античная традиция"},
{"author": "Перикл", "text": "В нашей жизни мы свободны, но в общественных делах подчиняемся законам.", "source": "Надгробная речь"},
{"author": "Гераклит", "text": "Народу следует сражаться за закон, как за городскую стену.", "source": "Фрагменты"},
{"author": "Гуго Гроций", "text": "Даже война должна иметь свои законы.", "source": "О праве войны и мира"},
{"author": "Томас Мор", "text": "Там, где собственность - привилегия немногих, правосудие редко бывает равным.", "source": "Утопия"},
{"author": "Роберт Х. Джексон", "text": "Нюрнбергский процесс показал: даже государственная власть отвечает перед законом.", "source": "Речь на Нюрнбергском процессе"},
{"author": "Луи Брандейс", "text": "В демократическом обществе лучший защитник свободы - открытое правосудие.", "source": "Судебные мнения"},
{"author": "Оливер Уэнделл Холмс-младший", "text": "Право живет не логикой, а опытом.", "source": "Общее право"},
{"author": "Бенджамин Кардозо", "text": "Правосудие - это не механика формул, а разумное применение принципов.", "source": "Природа судебного процесса"},
{"author": "Ханна Арендт", "text": "Там, где исчезает ответственность, исчезает и справедливость.", "source": "Истоки тоталитаризма"},
{"author": "Альбер Камю", "text": "Справедливость без свободы становится новой формой насилия.", "source": "Бунтующий человек"},
{"author": "Конфуций", "text": "Править - значит исправлять: кто правит справедливо, тому следуют без приказа.", "source": "Лунь юй"},
{"author": "Сунь-цзы", "text": "Когда наказания справедливы и неизбежны, в войске устанавливается порядок.", "source": "Искусство войны"},
]

View file

@ -1,4 +1,4 @@
from sqlalchemy import String, Boolean
from sqlalchemy import Boolean, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.session import Base
from app.models.common import UUIDMixin, TimestampMixin
@ -9,4 +9,8 @@ class AdminUser(Base, UUIDMixin, TimestampMixin):
name: Mapped[str] = mapped_column(String(200), nullable=False)
email: Mapped[str] = mapped_column(String(200), unique=True, nullable=False)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
avatar_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
primary_topic_code: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True)
default_rate: Mapped[float | None] = mapped_column(Numeric(12, 2), nullable=True)
salary_percent: Mapped[float | None] = mapped_column(Numeric(5, 2), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)

View file

@ -0,0 +1,16 @@
import uuid
from sqlalchemy import String, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.session import Base
from app.models.common import UUIDMixin, TimestampMixin
class AdminUserTopic(Base, UUIDMixin, TimestampMixin):
__tablename__ = "admin_user_topics"
__table_args__ = (UniqueConstraint("admin_user_id", "topic_code", name="uq_admin_user_topics_user_topic"),)
admin_user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False, index=True)
topic_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True)

View file

@ -2,7 +2,7 @@ import uuid
from datetime import datetime, timezone
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy import DateTime
from sqlalchemy import DateTime, String
def utcnow():
return datetime.now(timezone.utc)
@ -13,3 +13,4 @@ class UUIDMixin:
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
responsible: Mapped[str] = mapped_column(String(200), nullable=False, default="Администратор системы")

View file

@ -0,0 +1,25 @@
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, JSON, String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.session import Base
from app.models.common import TimestampMixin, UUIDMixin
class Notification(Base, UUIDMixin, TimestampMixin):
__tablename__ = "notifications"
request_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), index=True, nullable=True)
recipient_type: Mapped[str] = mapped_column(String(20), index=True, nullable=False) # CLIENT|ADMIN_USER
recipient_admin_user_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), index=True, nullable=True)
recipient_track_number: Mapped[str | None] = mapped_column(String(40), index=True, nullable=True)
event_type: Mapped[str] = mapped_column(String(50), index=True, nullable=False)
title: Mapped[str] = mapped_column(String(200), nullable=False)
body: Mapped[str | None] = mapped_column(Text, nullable=True)
payload: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
is_read: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True)
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
dedupe_key: Mapped[str | None] = mapped_column(String(255), unique=True, nullable=True)

View file

@ -1,4 +1,6 @@
from sqlalchemy import String, Integer, Text, JSON
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Integer, JSON, Numeric, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.db.session import Base
from app.models.common import UUIDMixin, TimestampMixin
@ -13,4 +15,12 @@ class Request(Base, UUIDMixin, TimestampMixin):
description: Mapped[str | None] = mapped_column(Text, nullable=True)
extra_fields: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
assigned_lawyer_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
effective_rate: Mapped[float | None] = mapped_column(Numeric(12, 2), nullable=True)
invoice_amount: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True)
paid_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
paid_by_admin_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
total_attachments_bytes: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
client_has_unread_updates: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
client_unread_event_type: Mapped[str | None] = mapped_column(String(32), nullable=True)
lawyer_has_unread_updates: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
lawyer_unread_event_type: Mapped[str | None] = mapped_column(String(32), nullable=True)

View file

@ -0,0 +1,27 @@
import uuid
from sqlalchemy import String, Boolean, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID
from app.db.session import Base
from app.models.common import UUIDMixin, TimestampMixin
class RequestDataRequirement(Base, UUIDMixin, TimestampMixin):
__tablename__ = "request_data_requirements"
__table_args__ = (
UniqueConstraint(
"request_id",
"key",
name="uq_request_data_requirements_request_key",
),
)
request_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False, index=True)
topic_template_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True, index=True)
key: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
label: Mapped[str] = mapped_column(String(200), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
required: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_by_admin_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)

View file

@ -0,0 +1,24 @@
from sqlalchemy import String, Integer, Boolean, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.db.session import Base
from app.models.common import UUIDMixin, TimestampMixin
class TopicDataTemplate(Base, UUIDMixin, TimestampMixin):
__tablename__ = "topic_data_templates"
__table_args__ = (
UniqueConstraint(
"topic_code",
"key",
name="uq_topic_data_templates_topic_key",
),
)
topic_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
key: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
label: Mapped[str] = mapped_column(String(200), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
required: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)

View file

@ -0,0 +1,22 @@
from sqlalchemy import String, Integer, Boolean, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.db.session import Base
from app.models.common import UUIDMixin, TimestampMixin
class TopicRequiredField(Base, UUIDMixin, TimestampMixin):
__tablename__ = "topic_required_fields"
__table_args__ = (
UniqueConstraint(
"topic_code",
"field_key",
name="uq_topic_required_fields_topic_field",
),
)
topic_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
field_key: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
required: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)

View file

@ -0,0 +1,24 @@
from sqlalchemy import String, Integer, Boolean, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.db.session import Base
from app.models.common import UUIDMixin, TimestampMixin
class TopicStatusTransition(Base, UUIDMixin, TimestampMixin):
__tablename__ = "topic_status_transitions"
__table_args__ = (
UniqueConstraint(
"topic_code",
"from_status",
"to_status",
name="uq_topic_status_transitions_topic_from_to",
),
)
topic_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
from_status: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
to_status: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
sla_hours: Mapped[int | None] = mapped_column(Integer, nullable=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)

View file

@ -1,5 +1,7 @@
from datetime import datetime
from pydantic import BaseModel, Field
from typing import Optional, Any
from typing import Optional
class AdminLogin(BaseModel):
email: str
@ -51,6 +53,10 @@ class RequestAdminCreate(BaseModel):
description: Optional[str] = None
extra_fields: dict = Field(default_factory=dict)
assigned_lawyer_id: Optional[str] = None
effective_rate: Optional[float] = None
invoice_amount: Optional[float] = None
paid_at: Optional[datetime] = None
paid_by_admin_id: Optional[str] = None
total_attachments_bytes: int = 0
@ -63,4 +69,30 @@ class RequestAdminPatch(BaseModel):
description: Optional[str] = None
extra_fields: Optional[dict] = None
assigned_lawyer_id: Optional[str] = None
effective_rate: Optional[float] = None
invoice_amount: Optional[float] = None
paid_at: Optional[datetime] = None
paid_by_admin_id: Optional[str] = None
total_attachments_bytes: Optional[int] = None
class RequestReassign(BaseModel):
lawyer_id: str
class RequestDataRequirementCreate(BaseModel):
key: str
label: str
description: Optional[str] = None
required: bool = True
class RequestDataRequirementPatch(BaseModel):
key: Optional[str] = None
label: Optional[str] = None
description: Optional[str] = None
required: Optional[bool] = None
class NotificationsReadAll(BaseModel):
request_id: Optional[str] = None

View file

@ -1,5 +1,5 @@
from pydantic import BaseModel, Field
from typing import Optional, Dict, Any, List
from typing import Optional, Dict, Any, List, Literal
from uuid import UUID
class PublicRequestCreate(BaseModel):
@ -23,4 +23,45 @@ class OtpSend(BaseModel):
class OtpVerify(BaseModel):
purpose: str
track_number: Optional[str] = None
client_phone: Optional[str] = None
code: str
class PublicMessageCreate(BaseModel):
body: str
class PublicMessageRead(BaseModel):
id: UUID
request_id: UUID
author_type: str
author_name: Optional[str] = None
body: Optional[str] = None
created_at: Optional[str] = None
updated_at: Optional[str] = None
class PublicAttachmentRead(BaseModel):
id: UUID
request_id: UUID
message_id: Optional[UUID] = None
file_name: str
mime_type: str
size_bytes: int
created_at: Optional[str] = None
download_url: str
class PublicStatusHistoryRead(BaseModel):
id: UUID
request_id: UUID
from_status: Optional[str] = None
to_status: str
comment: Optional[str] = None
created_at: Optional[str] = None
class PublicTimelineEvent(BaseModel):
type: Literal["status_change", "message", "attachment"]
created_at: Optional[str] = None
payload: Dict[str, Any] = Field(default_factory=dict)

43
app/schemas/uploads.py Normal file
View file

@ -0,0 +1,43 @@
from __future__ import annotations
from enum import Enum
from typing import Optional
from pydantic import BaseModel
class UploadScope(str, Enum):
REQUEST_ATTACHMENT = "REQUEST_ATTACHMENT"
USER_AVATAR = "USER_AVATAR"
class UploadInitPayload(BaseModel):
file_name: str
mime_type: str
size_bytes: int
scope: UploadScope
request_id: Optional[str] = None
user_id: Optional[str] = None
class UploadInitResponse(BaseModel):
method: str = "PRESIGNED_PUT"
key: str
presigned_url: str
class UploadCompletePayload(BaseModel):
key: str
file_name: str
mime_type: str
size_bytes: int
scope: UploadScope
request_id: Optional[str] = None
message_id: Optional[str] = None
user_id: Optional[str] = None
class UploadCompleteResponse(BaseModel):
status: str = "ok"
attachment_id: Optional[str] = None
avatar_url: Optional[str] = None

1
app/scripts/__init__.py Normal file
View file

@ -0,0 +1 @@
# Utility scripts for operational tasks.

View file

@ -0,0 +1,68 @@
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from app.data.quotes_justice_seed import JUSTICE_QUOTES
from app.db.session import SessionLocal
from app.models.quote import Quote
def upsert_quotes(db: Session, quotes: list[dict]) -> tuple[int, int]:
created = 0
updated = 0
for index, item in enumerate(quotes, start=1):
author = str(item["author"]).strip()
text = str(item["text"]).strip()
source = str(item.get("source") or "").strip() or None
sort_order = int(item.get("sort_order") or index)
is_active = bool(item.get("is_active", True))
row = db.query(Quote).filter(Quote.author == author, Quote.text == text).first()
if row is None:
db.add(
Quote(
author=author,
text=text,
source=source,
sort_order=sort_order,
is_active=is_active,
)
)
created += 1
continue
changed = False
if row.source != source:
row.source = source
changed = True
if row.sort_order != sort_order:
row.sort_order = sort_order
changed = True
if row.is_active != is_active:
row.is_active = is_active
changed = True
if changed:
row.updated_at = datetime.now(timezone.utc)
db.add(row)
updated += 1
db.commit()
return created, updated
def main() -> None:
db = SessionLocal()
try:
created, updated = upsert_quotes(db, JUSTICE_QUOTES)
total = db.query(Quote).count()
finally:
db.close()
print(f"quotes upsert done: created={created}, updated={updated}, total={total}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,436 @@
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import and_
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from app.models.admin_user import AdminUser
from app.models.notification import Notification
from app.models.request import Request
from app.services.telegram_notify import send_telegram_message
RECIPIENT_CLIENT = "CLIENT"
RECIPIENT_ADMIN_USER = "ADMIN_USER"
EVENT_MESSAGE = "MESSAGE"
EVENT_ATTACHMENT = "ATTACHMENT"
EVENT_STATUS = "STATUS"
EVENT_SLA_OVERDUE = "SLA_OVERDUE"
_EVENT_LABELS = {
EVENT_MESSAGE: "Новое сообщение",
EVENT_ATTACHMENT: "Новый файл",
EVENT_STATUS: "Изменен статус",
EVENT_SLA_OVERDUE: "SLA просрочен",
}
def _as_utc_now() -> datetime:
return datetime.now(timezone.utc)
def _as_uuid_or_none(value: Any) -> uuid.UUID | None:
try:
return uuid.UUID(str(value))
except (TypeError, ValueError):
return None
def _normalize_track(value: Any) -> str | None:
track = str(value or "").strip().upper()
return track or None
def _normalized_event(event_type: str) -> str:
return str(event_type or "").strip().upper()
def _title_for_event(event_type: str, request: Request) -> str:
prefix = _EVENT_LABELS.get(event_type, "Обновление")
track = str(request.track_number or "").strip() or str(request.id)
return f"{prefix} по заявке {track}"
def _telegram_text_for_event(event_type: str, request: Request, body: str | None = None) -> str:
label = _EVENT_LABELS.get(event_type, "Обновление")
track = str(request.track_number or "").strip() or str(request.id)
topic = str(request.topic_code or "").strip() or "-"
status = str(request.status_code or "").strip() or "-"
tail = f"\n{body.strip()}" if str(body or "").strip() else ""
return f"#{track}\n{label}\nТема: {topic}\nСтатус: {status}{tail}"
def _active_admin_ids(db: Session, *, exclude_admin_user_id: uuid.UUID | None = None) -> list[uuid.UUID]:
try:
rows = (
db.query(AdminUser.id)
.filter(
AdminUser.role == "ADMIN",
AdminUser.is_active.is_(True),
)
.all()
)
except SQLAlchemyError:
# Some isolated tests bootstrap only a subset of tables.
return []
out: list[uuid.UUID] = []
for (admin_id,) in rows:
if not admin_id:
continue
if exclude_admin_user_id is not None and admin_id == exclude_admin_user_id:
continue
out.append(admin_id)
return out
def _create_notification(
db: Session,
*,
request: Request,
recipient_type: str,
recipient_admin_user_id: uuid.UUID | None = None,
recipient_track_number: str | None = None,
event_type: str,
title: str,
body: str | None = None,
payload: dict[str, Any] | None = None,
responsible: str = "Система уведомлений",
dedupe_key: str | None = None,
) -> Notification | None:
recipient_kind = str(recipient_type or "").strip().upper()
if recipient_kind not in {RECIPIENT_CLIENT, RECIPIENT_ADMIN_USER}:
return None
if recipient_kind == RECIPIENT_CLIENT and not _normalize_track(recipient_track_number):
return None
if recipient_kind == RECIPIENT_ADMIN_USER and recipient_admin_user_id is None:
return None
normalized_dedupe = str(dedupe_key or "").strip() or None
if normalized_dedupe:
exists = db.query(Notification.id).filter(Notification.dedupe_key == normalized_dedupe).first()
if exists is not None:
return None
row = Notification(
request_id=request.id,
recipient_type=recipient_kind,
recipient_admin_user_id=recipient_admin_user_id if recipient_kind == RECIPIENT_ADMIN_USER else None,
recipient_track_number=_normalize_track(recipient_track_number) if recipient_kind == RECIPIENT_CLIENT else None,
event_type=_normalized_event(event_type),
title=str(title or "").strip() or _title_for_event(event_type, request),
body=str(body or "").strip() or None,
payload=dict(payload or {}),
is_read=False,
read_at=None,
responsible=str(responsible or "").strip() or "Система уведомлений",
dedupe_key=normalized_dedupe,
)
db.add(row)
return row
def notify_request_event(
db: Session,
*,
request: Request,
event_type: str,
actor_role: str,
actor_admin_user_id: str | uuid.UUID | None = None,
body: str | None = None,
responsible: str = "Система уведомлений",
send_telegram: bool = True,
dedupe_prefix: str | None = None,
) -> dict[str, int]:
event = _normalized_event(event_type)
actor = str(actor_role or "").strip().upper() or "SYSTEM"
actor_uuid = _as_uuid_or_none(actor_admin_user_id)
title = _title_for_event(event, request)
payload = {
"request_id": str(request.id),
"track_number": request.track_number,
"topic_code": request.topic_code,
"status_code": request.status_code,
"event_type": event,
"actor_role": actor,
}
internal_created = 0
telegram_sent = 0
def _dedupe_key_for(recipient_marker: str) -> str | None:
prefix = str(dedupe_prefix or "").strip()
if not prefix:
return None
return f"{prefix}:{recipient_marker}"
def _notify_client() -> None:
nonlocal internal_created
track = _normalize_track(request.track_number)
if not track:
return
dedupe_key = _dedupe_key_for(f"client:{track}")
row = _create_notification(
db,
request=request,
recipient_type=RECIPIENT_CLIENT,
recipient_track_number=track,
event_type=event,
title=title,
body=body,
payload=payload,
responsible=responsible,
dedupe_key=dedupe_key,
)
if row is not None:
internal_created += 1
def _notify_lawyer_if_any() -> None:
nonlocal internal_created
lawyer_uuid = _as_uuid_or_none(request.assigned_lawyer_id)
if lawyer_uuid is None:
return
if actor_uuid is not None and lawyer_uuid == actor_uuid:
return
dedupe_key = _dedupe_key_for(f"lawyer:{lawyer_uuid}")
row = _create_notification(
db,
request=request,
recipient_type=RECIPIENT_ADMIN_USER,
recipient_admin_user_id=lawyer_uuid,
event_type=event,
title=title,
body=body,
payload=payload,
responsible=responsible,
dedupe_key=dedupe_key,
)
if row is not None:
internal_created += 1
def _notify_admins() -> None:
nonlocal internal_created
admin_ids = _active_admin_ids(db, exclude_admin_user_id=actor_uuid)
for admin_id in admin_ids:
dedupe_key = _dedupe_key_for(f"admin:{admin_id}")
row = _create_notification(
db,
request=request,
recipient_type=RECIPIENT_ADMIN_USER,
recipient_admin_user_id=admin_id,
event_type=event,
title=title,
body=body,
payload=payload,
responsible=responsible,
dedupe_key=dedupe_key,
)
if row is not None:
internal_created += 1
if event in {EVENT_MESSAGE, EVENT_ATTACHMENT}:
if actor == "CLIENT":
_notify_lawyer_if_any()
_notify_admins()
else:
_notify_client()
elif event == EVENT_STATUS:
_notify_client()
if actor == "ADMIN":
_notify_lawyer_if_any()
elif event == EVENT_SLA_OVERDUE:
_notify_lawyer_if_any()
_notify_admins()
else:
_notify_client()
_notify_lawyer_if_any()
if send_telegram and internal_created > 0:
result = send_telegram_message(_telegram_text_for_event(event, request, body))
if bool(result.get("sent")):
telegram_sent += 1
return {"internal_created": int(internal_created), "telegram_sent": int(telegram_sent)}
def serialize_notification(row: Notification) -> dict[str, Any]:
return {
"id": str(row.id),
"request_id": str(row.request_id) if row.request_id else None,
"recipient_type": row.recipient_type,
"recipient_admin_user_id": str(row.recipient_admin_user_id) if row.recipient_admin_user_id else None,
"recipient_track_number": row.recipient_track_number,
"event_type": row.event_type,
"title": row.title,
"body": row.body,
"payload": row.payload or {},
"is_read": bool(row.is_read),
"read_at": row.read_at.isoformat() if row.read_at else None,
"created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
}
def mark_admin_notifications_read(
db: Session,
*,
admin_user_id: str | uuid.UUID,
request_id: uuid.UUID | None = None,
notification_id: uuid.UUID | None = None,
responsible: str = "Система уведомлений",
) -> int:
admin_uuid = _as_uuid_or_none(admin_user_id)
if admin_uuid is None:
return 0
query = db.query(Notification).filter(
Notification.recipient_type == RECIPIENT_ADMIN_USER,
Notification.recipient_admin_user_id == admin_uuid,
Notification.is_read.is_(False),
)
if request_id is not None:
query = query.filter(Notification.request_id == request_id)
if notification_id is not None:
query = query.filter(Notification.id == notification_id)
rows = query.all()
now = _as_utc_now()
for row in rows:
row.is_read = True
row.read_at = now
row.responsible = responsible
db.add(row)
return len(rows)
def mark_client_notifications_read(
db: Session,
*,
track_number: str,
request_id: uuid.UUID | None = None,
notification_id: uuid.UUID | None = None,
responsible: str = "Клиент",
) -> int:
track = _normalize_track(track_number)
if not track:
return 0
query = db.query(Notification).filter(
Notification.recipient_type == RECIPIENT_CLIENT,
Notification.recipient_track_number == track,
Notification.is_read.is_(False),
)
if request_id is not None:
query = query.filter(Notification.request_id == request_id)
if notification_id is not None:
query = query.filter(Notification.id == notification_id)
rows = query.all()
now = _as_utc_now()
for row in rows:
row.is_read = True
row.read_at = now
row.responsible = responsible
db.add(row)
return len(rows)
def list_admin_notifications(
db: Session,
*,
admin_user_id: str | uuid.UUID,
unread_only: bool = False,
request_id: uuid.UUID | None = None,
limit: int = 50,
offset: int = 0,
) -> tuple[list[Notification], int]:
admin_uuid = _as_uuid_or_none(admin_user_id)
if admin_uuid is None:
return [], 0
query = db.query(Notification).filter(
Notification.recipient_type == RECIPIENT_ADMIN_USER,
Notification.recipient_admin_user_id == admin_uuid,
)
if unread_only:
query = query.filter(Notification.is_read.is_(False))
if request_id is not None:
query = query.filter(Notification.request_id == request_id)
total = query.count()
rows = (
query.order_by(Notification.created_at.desc(), Notification.id.desc())
.offset(int(max(offset, 0)))
.limit(int(min(max(limit, 1), 200)))
.all()
)
return rows, int(total)
def list_client_notifications(
db: Session,
*,
track_number: str,
unread_only: bool = False,
request_id: uuid.UUID | None = None,
limit: int = 50,
offset: int = 0,
) -> tuple[list[Notification], int]:
track = _normalize_track(track_number)
if not track:
return [], 0
query = db.query(Notification).filter(
Notification.recipient_type == RECIPIENT_CLIENT,
Notification.recipient_track_number == track,
)
if unread_only:
query = query.filter(Notification.is_read.is_(False))
if request_id is not None:
query = query.filter(Notification.request_id == request_id)
total = query.count()
rows = (
query.order_by(Notification.created_at.desc(), Notification.id.desc())
.offset(int(max(offset, 0)))
.limit(int(min(max(limit, 1), 200)))
.all()
)
return rows, int(total)
def get_admin_notification(
db: Session,
*,
admin_user_id: str | uuid.UUID,
notification_id: uuid.UUID,
) -> Notification | None:
admin_uuid = _as_uuid_or_none(admin_user_id)
if admin_uuid is None:
return None
return (
db.query(Notification)
.filter(
Notification.id == notification_id,
Notification.recipient_type == RECIPIENT_ADMIN_USER,
Notification.recipient_admin_user_id == admin_uuid,
)
.first()
)
def get_client_notification(
db: Session,
*,
track_number: str,
notification_id: uuid.UUID,
) -> Notification | None:
track = _normalize_track(track_number)
if not track:
return None
return (
db.query(Notification)
.filter(
and_(
Notification.id == notification_id,
Notification.recipient_type == RECIPIENT_CLIENT,
Notification.recipient_track_number == track,
)
)
.first()
)

View file

@ -0,0 +1,31 @@
from __future__ import annotations
from app.models.request import Request
EVENT_MESSAGE = "MESSAGE"
EVENT_ATTACHMENT = "ATTACHMENT"
EVENT_STATUS = "STATUS"
def mark_unread_for_client(request: Request, event_type: str) -> None:
request.client_has_unread_updates = True
request.client_unread_event_type = str(event_type or "").strip().upper() or None
def mark_unread_for_lawyer(request: Request, event_type: str) -> None:
request.lawyer_has_unread_updates = True
request.lawyer_unread_event_type = str(event_type or "").strip().upper() or None
def clear_unread_for_client(request: Request) -> bool:
changed = bool(request.client_has_unread_updates or request.client_unread_event_type)
request.client_has_unread_updates = False
request.client_unread_event_type = None
return changed
def clear_unread_for_lawyer(request: Request) -> bool:
changed = bool(request.lawyer_has_unread_updates or request.lawyer_unread_event_type)
request.lawyer_has_unread_updates = False
request.lawyer_unread_event_type = None
return changed

View file

@ -0,0 +1,82 @@
from __future__ import annotations
import uuid
from typing import Any
from sqlalchemy.orm import Session
from app.models.attachment import Attachment
from app.models.message import Message
from app.models.request import Request
from app.models.status_history import StatusHistory
def actor_admin_uuid(admin: dict[str, Any] | None) -> uuid.UUID | None:
if not admin:
return None
raw = str(admin.get("sub") or "").strip()
if not raw:
return None
try:
return uuid.UUID(raw)
except ValueError:
return None
def freeze_request_messages_and_attachments(db: Session, request_id: uuid.UUID) -> None:
db.query(Message).filter(Message.request_id == request_id, Message.immutable.is_(False)).update(
{"immutable": True},
synchronize_session=False,
)
db.query(Attachment).filter(Attachment.request_id == request_id, Attachment.immutable.is_(False)).update(
{"immutable": True},
synchronize_session=False,
)
def register_status_history(
db: Session,
request: Request,
from_status: str,
to_status: str,
*,
admin: dict[str, Any] | None = None,
comment: str | None = None,
responsible: str = "Администратор системы",
) -> None:
db.add(
StatusHistory(
request_id=request.id,
from_status=str(from_status or "").strip() or None,
to_status=str(to_status or "").strip(),
changed_by_admin_id=actor_admin_uuid(admin),
comment=comment,
responsible=responsible,
)
)
def apply_status_change_effects(
db: Session,
request: Request,
*,
from_status: str,
to_status: str,
admin: dict[str, Any] | None = None,
comment: str | None = None,
responsible: str = "Администратор системы",
) -> None:
old_code = str(from_status or "").strip()
new_code = str(to_status or "").strip()
if not new_code or old_code == new_code:
return
freeze_request_messages_and_attachments(db, request.id)
register_status_history(
db,
request,
old_code,
new_code,
admin=admin,
comment=comment,
responsible=responsible,
)

View file

@ -0,0 +1,48 @@
from __future__ import annotations
from fastapi import HTTPException
from sqlalchemy.orm import Session
from app.models.topic_required_field import TopicRequiredField
def _is_missing_value(value) -> bool:
if value is None:
return True
if isinstance(value, str):
return not value.strip()
if isinstance(value, (list, tuple, dict, set)):
return len(value) == 0
return False
def validate_required_topic_fields_or_400(
db: Session,
topic_code: str | None,
extra_fields: dict | None,
) -> None:
topic = str(topic_code or "").strip()
if not topic:
return
required_rows = (
db.query(TopicRequiredField.field_key)
.filter(
TopicRequiredField.topic_code == topic,
TopicRequiredField.enabled.is_(True),
TopicRequiredField.required.is_(True),
)
.order_by(TopicRequiredField.sort_order.asc(), TopicRequiredField.field_key.asc())
.all()
)
required_keys = [str(field_key).strip() for (field_key,) in required_rows if field_key]
if not required_keys:
return
payload = extra_fields if isinstance(extra_fields, dict) else {}
missing = [key for key in required_keys if _is_missing_value(payload.get(key))]
if missing:
raise HTTPException(
status_code=400,
detail="Для выбранной темы обязательны поля: " + ", ".join(missing),
)

View file

@ -0,0 +1,75 @@
from __future__ import annotations
import re
import uuid
from functools import lru_cache
from urllib.parse import quote
import boto3
from botocore.exceptions import ClientError
from app.core.config import settings
def _safe_file_name(file_name: str) -> str:
raw = str(file_name or "").strip() or "file.bin"
return re.sub(r"[^A-Za-z0-9._-]+", "_", raw)
def build_object_key(prefix: str, file_name: str) -> str:
safe_name = _safe_file_name(file_name)
return f"{prefix.strip('/')}/{uuid.uuid4().hex}-{safe_name}"
class S3Storage:
def __init__(self):
self.bucket = settings.S3_BUCKET
self.client = boto3.client(
"s3",
endpoint_url=settings.S3_ENDPOINT,
aws_access_key_id=settings.S3_ACCESS_KEY,
aws_secret_access_key=settings.S3_SECRET_KEY,
region_name=settings.S3_REGION,
use_ssl=settings.S3_USE_SSL,
)
self._bucket_checked = False
def ensure_bucket(self) -> None:
if self._bucket_checked:
return
try:
self.client.head_bucket(Bucket=self.bucket)
except ClientError as exc:
code = str(exc.response.get("Error", {}).get("Code", ""))
if code not in {"404", "NoSuchBucket", "NotFound"}:
raise
kwargs: dict = {"Bucket": self.bucket}
if settings.S3_REGION and settings.S3_REGION != "us-east-1":
kwargs["CreateBucketConfiguration"] = {"LocationConstraint": settings.S3_REGION}
self.client.create_bucket(**kwargs)
self._bucket_checked = True
def create_presigned_put_url(self, key: str, mime_type: str, expires_sec: int = 900) -> str:
self.ensure_bucket()
return self.client.generate_presigned_url(
"put_object",
Params={"Bucket": self.bucket, "Key": key, "ContentType": mime_type},
ExpiresIn=expires_sec,
HttpMethod="PUT",
)
def head_object(self, key: str) -> dict:
self.ensure_bucket()
return self.client.head_object(Bucket=self.bucket, Key=key)
def get_object(self, key: str) -> dict:
self.ensure_bucket()
return self.client.get_object(Bucket=self.bucket, Key=key)
def get_avatar_proxy_path(self, key: str, token: str) -> str:
return "/api/admin/uploads/object/" + quote(key, safe="") + "?token=" + quote(token, safe="")
@lru_cache(maxsize=1)
def get_s3_storage() -> S3Storage:
return S3Storage()

180
app/services/sla_metrics.py Normal file
View file

@ -0,0 +1,180 @@
from __future__ import annotations
from collections import defaultdict
from datetime import datetime, timezone
from typing import Any
from sqlalchemy.orm import Session
from app.models.message import Message
from app.models.request import Request
from app.models.status import Status
from app.models.status_history import StatusHistory
from app.models.topic_status_transition import TopicStatusTransition
DEFAULT_TERMINAL_STATUS_CODES = {"RESOLVED", "CLOSED", "REJECTED"}
DEFAULT_SLA_HOURS_BY_STATUS = {
"NEW": 24,
"IN_PROGRESS": 72,
"WAITING_CLIENT": 168,
"WAITING_COURT": 336,
}
DEFAULT_SLA_HOURS = 72
def _terminal_status_codes(db: Session) -> set[str]:
rows = db.query(Status.code).filter(Status.is_terminal.is_(True)).all()
codes = {str(code).strip() for (code,) in rows if code}
return codes or set(DEFAULT_TERMINAL_STATUS_CODES)
def _as_utc(value: datetime | None, fallback: datetime) -> datetime:
if value is None:
return fallback
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
def _load_topic_sla_maps(db: Session) -> tuple[dict[tuple[str, str], int], dict[tuple[str, str, str], int]]:
rows = (
db.query(
TopicStatusTransition.topic_code,
TopicStatusTransition.from_status,
TopicStatusTransition.to_status,
TopicStatusTransition.sla_hours,
)
.filter(
TopicStatusTransition.enabled.is_(True),
TopicStatusTransition.sla_hours.is_not(None),
TopicStatusTransition.sla_hours > 0,
)
.all()
)
outgoing_sla: dict[tuple[str, str], int] = {}
exact_sla: dict[tuple[str, str, str], int] = {}
for topic_code, from_status, to_status, sla_hours in rows:
topic = str(topic_code or "").strip()
from_code = str(from_status or "").strip()
to_code = str(to_status or "").strip()
if not topic or not from_code or not to_code:
continue
sla = int(sla_hours or 0)
if sla <= 0:
continue
exact_sla[(topic, from_code, to_code)] = sla
key = (topic, from_code)
if key not in outgoing_sla or sla < outgoing_sla[key]:
outgoing_sla[key] = sla
return outgoing_sla, exact_sla
def _current_status_started_at(req: Request, request_rows: list[StatusHistory], now_utc: datetime) -> datetime:
current_status = str(req.status_code or "").strip()
if current_status and request_rows:
for row in reversed(request_rows):
if str(row.to_status or "").strip() == current_status:
return _as_utc(row.created_at, now_utc)
return _as_utc(req.updated_at or req.created_at, now_utc)
def compute_sla_snapshot(
db: Session,
now: datetime | None = None,
*,
include_overdue_requests: bool = False,
) -> dict[str, Any]:
now_utc = _as_utc(now, datetime.now(timezone.utc))
terminal_codes = _terminal_status_codes(db)
active_requests = db.query(Request).filter(Request.status_code.notin_(terminal_codes)).all()
status_rows = db.query(StatusHistory).order_by(StatusHistory.request_id.asc(), StatusHistory.created_at.asc()).all()
rows_by_request: dict[str, list[StatusHistory]] = defaultdict(list)
for row in status_rows:
rows_by_request[str(row.request_id)].append(row)
outgoing_sla_map, _ = _load_topic_sla_maps(db)
overdue_by_status: dict[str, int] = defaultdict(int)
overdue_by_transition: dict[str, int] = defaultdict(int)
overdue_requests: list[dict[str, Any]] = []
for req in active_requests:
status_code = str(req.status_code or "").strip() or "UNKNOWN"
topic_code = str(req.topic_code or "").strip()
threshold_hours = outgoing_sla_map.get(
(topic_code, status_code),
int(DEFAULT_SLA_HOURS_BY_STATUS.get(status_code, DEFAULT_SLA_HOURS)),
)
status_started_at = _current_status_started_at(req, rows_by_request.get(str(req.id), []), now_utc)
hours_in_status = (now_utc - status_started_at).total_seconds() / 3600.0
if hours_in_status > threshold_hours:
overdue_by_status[status_code] += 1
transition_key = f"{topic_code or '*'}:{status_code}->*"
overdue_by_transition[transition_key] += 1
if include_overdue_requests:
overdue_requests.append(
{
"request_id": str(req.id),
"track_number": req.track_number,
"topic_code": req.topic_code,
"status_code": req.status_code,
"assigned_lawyer_id": req.assigned_lawyer_id,
"hours_in_status": round(hours_in_status, 2),
"threshold_hours": int(threshold_hours),
}
)
first_response_rows = (
db.query(Message.request_id, Message.created_at)
.filter(Message.author_type == "LAWYER")
.order_by(Message.request_id.asc(), Message.created_at.asc())
.all()
)
first_response_map = {}
for request_id, created_at in first_response_rows:
key = str(request_id)
if key not in first_response_map and created_at is not None:
first_response_map[key] = created_at
frt_minutes: list[float] = []
for req in active_requests:
first_response_at = first_response_map.get(str(req.id))
if not first_response_at or not req.created_at:
continue
first_dt = _as_utc(first_response_at, now_utc)
req_created = _as_utc(req.created_at, now_utc)
delta_min = (first_dt - req_created).total_seconds() / 60.0
if delta_min >= 0:
frt_minutes.append(delta_min)
durations_by_status: dict[str, list[float]] = defaultdict(list)
for req in active_requests:
request_rows = rows_by_request.get(str(req.id), [])
if not request_rows:
started_at = _as_utc(req.created_at, now_utc)
status_code = str(req.status_code or "").strip() or "UNKNOWN"
durations_by_status[status_code].append(max((now_utc - started_at).total_seconds() / 3600.0, 0.0))
continue
for idx, row in enumerate(request_rows):
start = _as_utc(row.created_at, now_utc)
end_raw = request_rows[idx + 1].created_at if idx + 1 < len(request_rows) else now_utc
end = _as_utc(end_raw, now_utc)
status_code = str(row.to_status or "").strip() or "UNKNOWN"
duration_hours = max((end - start).total_seconds() / 3600.0, 0.0)
durations_by_status[status_code].append(duration_hours)
result = {
"checked_active_requests": int(len(active_requests)),
"overdue_total": int(sum(overdue_by_status.values())),
"overdue_by_status": dict(overdue_by_status),
"overdue_by_transition": dict(overdue_by_transition),
"frt_avg_minutes": round(sum(frt_minutes) / len(frt_minutes), 2) if frt_minutes else None,
"avg_time_in_status_hours": {
code: round(sum(values) / len(values), 2) for code, values in durations_by_status.items() if values
},
}
if include_overdue_requests:
result["overdue_requests"] = overdue_requests
return result

View file

@ -0,0 +1,46 @@
from __future__ import annotations
from sqlalchemy.orm import Session
from app.models.topic_status_transition import TopicStatusTransition
def transition_allowed_for_topic(
db: Session,
topic_code: str | None,
from_status: str,
to_status: str,
) -> bool:
from_code = str(from_status or "").strip()
to_code = str(to_status or "").strip()
if not from_code or not to_code:
return False
if from_code == to_code:
return True
topic = str(topic_code or "").strip()
if not topic:
return True
has_any_rules = (
db.query(TopicStatusTransition.id)
.filter(
TopicStatusTransition.topic_code == topic,
TopicStatusTransition.enabled.is_(True),
)
.first()
)
if has_any_rules is None:
return True
matched = (
db.query(TopicStatusTransition.id)
.filter(
TopicStatusTransition.topic_code == topic,
TopicStatusTransition.from_status == from_code,
TopicStatusTransition.to_status == to_code,
TopicStatusTransition.enabled.is_(True),
)
.first()
)
return matched is not None

View file

@ -0,0 +1,51 @@
from __future__ import annotations
from typing import Any
import httpx
from app.core.config import settings
def _telegram_enabled() -> bool:
token = str(settings.TELEGRAM_BOT_TOKEN or "").strip()
chat_id = str(settings.TELEGRAM_CHAT_ID or "").strip()
if not token or token == "change_me":
return False
if not chat_id or chat_id == "0":
return False
return True
def send_telegram_message(text: str) -> dict[str, Any]:
payload_text = str(text or "").strip()
if not payload_text:
return {"ok": False, "sent": False, "reason": "empty_text"}
if not _telegram_enabled():
# Dev-safe fallback: show delivery payload in logs.
print(f"[TELEGRAM MOCK] {payload_text}")
return {"ok": True, "sent": False, "mocked": True}
token = str(settings.TELEGRAM_BOT_TOKEN).strip()
chat_id = str(settings.TELEGRAM_CHAT_ID).strip()
url = f"https://api.telegram.org/bot{token}/sendMessage"
try:
with httpx.Client(timeout=5.0) as client:
response = client.post(
url,
json={
"chat_id": chat_id,
"text": payload_text,
"disable_web_page_preview": True,
},
)
data = response.json() if response.content else {}
if response.status_code >= 400 or not bool(data.get("ok")):
print(f"[TELEGRAM ERROR] status={response.status_code} body={data}")
return {"ok": False, "sent": False, "status_code": response.status_code, "response": data}
return {"ok": True, "sent": True}
except Exception as exc:
print(f"[TELEGRAM ERROR] {exc}")
return {"ok": False, "sent": False, "error": str(exc)}

View file

@ -1,26 +1,45 @@
import uuid
from fastapi import HTTPException
from sqlalchemy.orm import Query
from sqlalchemy import asc, desc
from app.schemas.universal import UniversalQuery
def _coerce_filter_value(column, value):
try:
python_type = column.property.columns[0].type.python_type
except Exception:
return value
if python_type is uuid.UUID and isinstance(value, str):
try:
return uuid.UUID(value)
except ValueError:
raise HTTPException(status_code=400, detail=f'Некорректный UUID в фильтре поля "{column.key}"')
return value
def apply_universal_query(q: Query, model, uq: UniversalQuery) -> Query:
for f in uq.filters:
col = getattr(model, f.field, None)
if col is None:
continue
value = _coerce_filter_value(col, f.value)
if f.op == "=":
q = q.filter(col == f.value)
q = q.filter(col == value)
elif f.op == "!=":
q = q.filter(col != f.value)
q = q.filter(col != value)
elif f.op == ">":
q = q.filter(col > f.value)
q = q.filter(col > value)
elif f.op == "<":
q = q.filter(col < f.value)
q = q.filter(col < value)
elif f.op == ">=":
q = q.filter(col >= f.value)
q = q.filter(col >= value)
elif f.op == "<=":
q = q.filter(col <= f.value)
q = q.filter(col <= value)
elif f.op == "~":
q = q.filter(col.ilike(f"%{f.value}%"))
q = q.filter(col.ilike(f"%{value}%"))
for s in uq.sort:
col = getattr(model, s.field, None)
if col is None:

View file

@ -95,6 +95,21 @@
color: #fde5c2;
}
.menu-tree {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding-left: 0.6rem;
border-left: 1px dashed rgba(212, 168, 106, 0.3);
margin: 0.2rem 0 0.1rem 0.2rem;
}
.menu-tree button {
font-size: 0.85rem;
padding: 0.52rem 0.62rem;
color: #c8d8ea;
}
.auth-box {
margin-top: 1.2rem;
border: 1px solid var(--line);
@ -309,6 +324,18 @@
font-weight: 700;
}
.field-inline {
display: flex;
align-items: center;
gap: 0.45rem;
}
.btn-sm {
min-height: 38px;
padding: 0.5rem 0.68rem;
font-size: 0.82rem;
}
input, textarea, select {
width: 100%;
border: 1px solid #3c4d62;
@ -380,12 +407,92 @@
color: #f7dfb8;
}
.avatar {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
color: #f5f8ff;
font-size: 0.75rem;
font-weight: 700;
flex-shrink: 0;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.24);
text-transform: uppercase;
letter-spacing: 0.02em;
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.user-identity {
display: inline-flex;
align-items: center;
gap: 0.52rem;
min-width: 0;
}
.user-identity-text {
display: flex;
flex-direction: column;
min-width: 0;
}
.user-identity-text b {
font-size: 0.88rem;
font-weight: 700;
color: #eaf2fd;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 230px;
}
.table-actions {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.request-updates-stack {
display: inline-flex;
flex-direction: column;
gap: 0.24rem;
align-items: flex-start;
}
.request-update-chip {
display: inline-flex;
align-items: center;
gap: 0.32rem;
border: 1px solid rgba(95, 182, 145, 0.34);
border-radius: 999px;
background: rgba(77, 190, 147, 0.14);
color: #bef5df;
font-size: 0.74rem;
line-height: 1.1;
padding: 0.18rem 0.45rem;
white-space: nowrap;
}
.request-update-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #3ed692;
box-shadow: 0 0 0 3px rgba(62, 214, 146, 0.18);
flex-shrink: 0;
}
.request-update-empty {
color: #8ea1b8;
font-size: 0.8rem;
}
.icon-btn {
border: 1px solid var(--line);
border-radius: 9px;
@ -472,54 +579,10 @@
.config-layout {
display: grid;
grid-template-columns: 260px 1fr;
grid-template-columns: 1fr;
gap: 0.85rem;
}
.config-tree {
border: 1px solid var(--line);
border-radius: 12px;
background: rgba(255, 255, 255, 0.02);
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.45rem;
align-self: start;
position: sticky;
top: 1rem;
}
.tree-title {
margin: 0 0 0.2rem;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #8da2bc;
font-weight: 700;
}
.tree-node {
border: 1px solid transparent;
border-radius: 10px;
background: rgba(255, 255, 255, 0.02);
color: #d7e4f5;
cursor: pointer;
text-align: left;
padding: 0.52rem 0.6rem;
font-weight: 600;
}
.tree-node:hover {
border-color: var(--line);
background: rgba(255, 255, 255, 0.04);
}
.tree-node.active {
border-color: rgba(212, 168, 106, 0.5);
background: var(--brand-soft);
color: #f7dfb8;
}
.config-panel {
min-width: 0;
}
@ -635,7 +698,6 @@
.filters { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.triple { grid-template-columns: 1fr; }
.config-layout { grid-template-columns: 1fr; }
.config-tree { position: static; }
}
@media (max-width: 920px) {

File diff suppressed because it is too large Load diff

View file

@ -519,6 +519,102 @@
.status.ok { color: var(--ok); }
.status.error { color: var(--danger); }
.cabinet-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.9rem;
}
.cabinet-card {
border: 1px solid var(--line);
border-radius: 16px;
background: linear-gradient(160deg, rgba(23, 32, 42, 0.9), rgba(17, 24, 33, 0.95));
padding: 1rem;
}
.cabinet-card h3 {
margin: 0 0 0.65rem;
font-size: 1.03rem;
}
.cabinet-meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
margin-top: 0.7rem;
}
.meta-row {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.58rem 0.65rem;
background: rgba(255, 255, 255, 0.02);
}
.meta-row small {
display: block;
color: #9fb0c6;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 0.2rem;
}
.meta-row b {
display: block;
color: #eaf2ff;
font-size: 0.9rem;
font-weight: 700;
line-height: 1.4;
}
.simple-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.5rem;
max-height: 280px;
overflow: auto;
}
.simple-item {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.58rem 0.65rem;
background: rgba(255, 255, 255, 0.02);
}
.simple-item p {
margin: 0.24rem 0 0;
color: #d8e3f3;
line-height: 1.5;
font-size: 0.92rem;
}
.simple-item time {
color: #9eb1ca;
font-size: 0.78rem;
}
.chat-form {
margin-top: 0.7rem;
display: grid;
gap: 0.55rem;
}
.chat-form textarea {
min-height: 84px;
}
.file-row {
margin-top: 0.7rem;
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
@keyframes rise {
to {
opacity: 1;
@ -552,6 +648,14 @@
.form {
grid-template-columns: 1fr;
}
.cabinet-layout {
grid-template-columns: 1fr;
}
.cabinet-meta {
grid-template-columns: 1fr;
}
}
</style>
</head>
@ -563,6 +667,7 @@
<a href="#practices">Компетенции</a>
<a href="#approach">Подход</a>
<a href="#expert">Эксперт</a>
<a href="#cabinet">Кабинет клиента</a>
<a href="/admin" class="btn btn-ghost">Админ-панель</a>
<button class="btn btn-ghost" type="button" data-open-modal>Оставить заявку</button>
</nav>
@ -691,6 +796,71 @@
</article>
</section>
<section id="cabinet">
<div class="section-head">
<div>
<h2>Кабинет клиента</h2>
<p class="subtitle">Введите номер заявки, подтвердите доступ по OTP и отслеживайте статус, переписку и файлы в одном окне.</p>
</div>
</div>
<div class="cabinet-layout">
<article class="cabinet-card">
<h3>Доступ по номеру заявки</h3>
<div class="field">
<label for="cabinet-track">Номер заявки</label>
<input id="cabinet-track" type="text" placeholder="TRK-XXXXXXXXXX">
</div>
<div class="form-foot">
<button class="btn btn-primary" id="cabinet-open" type="button">Открыть кабинет</button>
<p class="status" id="cabinet-status"></p>
</div>
<div id="cabinet-summary" hidden>
<div class="cabinet-meta">
<div class="meta-row">
<small>Статус</small>
<b id="cabinet-request-status">-</b>
</div>
<div class="meta-row">
<small>Тема</small>
<b id="cabinet-request-topic">-</b>
</div>
<div class="meta-row">
<small>Создана</small>
<b id="cabinet-request-created">-</b>
</div>
<div class="meta-row">
<small>Обновлена</small>
<b id="cabinet-request-updated">-</b>
</div>
</div>
</div>
</article>
<article class="cabinet-card">
<h3>Чат с юристом</h3>
<ul class="simple-list" id="cabinet-messages"></ul>
<form class="chat-form" id="cabinet-chat-form">
<textarea id="cabinet-chat-body" placeholder="Введите сообщение" disabled></textarea>
<button class="btn btn-ghost" type="submit" id="cabinet-chat-send" disabled>Отправить сообщение</button>
</form>
</article>
<article class="cabinet-card">
<h3>Файлы по заявке</h3>
<ul class="simple-list" id="cabinet-files"></ul>
<div class="file-row">
<input id="cabinet-file-input" type="file" disabled>
<button class="btn btn-ghost" id="cabinet-file-upload" type="button" disabled>Загрузить файл</button>
</div>
</article>
<article class="cabinet-card">
<h3>История изменений</h3>
<ul class="simple-list" id="cabinet-timeline"></ul>
</article>
</div>
</section>
<section class="cta-band">
<p>
Если вы пришли на сайт по рекомендации, укажите имя рекомендателя при отправке заявки.
@ -747,6 +917,25 @@
const status = document.getElementById("form-status");
const quoteText = document.getElementById("quote-text");
const quoteMeta = document.getElementById("quote-meta");
const cabinetTrackInput = document.getElementById("cabinet-track");
const cabinetOpenButton = document.getElementById("cabinet-open");
const cabinetStatus = document.getElementById("cabinet-status");
const cabinetSummary = document.getElementById("cabinet-summary");
const cabinetRequestStatus = document.getElementById("cabinet-request-status");
const cabinetRequestTopic = document.getElementById("cabinet-request-topic");
const cabinetRequestCreated = document.getElementById("cabinet-request-created");
const cabinetRequestUpdated = document.getElementById("cabinet-request-updated");
const cabinetMessages = document.getElementById("cabinet-messages");
const cabinetFiles = document.getElementById("cabinet-files");
const cabinetTimeline = document.getElementById("cabinet-timeline");
const cabinetChatForm = document.getElementById("cabinet-chat-form");
const cabinetChatBody = document.getElementById("cabinet-chat-body");
const cabinetChatSend = document.getElementById("cabinet-chat-send");
const cabinetFileInput = document.getElementById("cabinet-file-input");
const cabinetFileUpload = document.getElementById("cabinet-file-upload");
let activeTrack = "";
let activeRequestId = "";
function openModal() {
modal.classList.add("open");
@ -771,6 +960,136 @@
if (event.key === "Escape" && modal.classList.contains("open")) closeModal();
});
function formatDate(value) {
if (!value) return "-";
try {
const dt = new Date(value);
if (Number.isNaN(dt.getTime())) return value;
return dt.toLocaleString("ru-RU");
} catch (_) {
return value;
}
}
function setStatus(el, message, kind) {
el.className = "status";
if (kind === "ok") el.classList.add("ok");
if (kind === "error") el.classList.add("error");
el.textContent = message;
}
async function parseJsonSafe(response) {
try {
return await response.json();
} catch (_) {
return null;
}
}
function apiErrorDetail(data, fallbackMessage) {
if (data && typeof data.detail === "string" && data.detail.trim()) return data.detail;
return fallbackMessage;
}
function setCabinetEnabled(enabled) {
cabinetChatBody.disabled = !enabled;
cabinetChatSend.disabled = !enabled;
cabinetFileInput.disabled = !enabled;
cabinetFileUpload.disabled = !enabled;
}
function clearList(node, emptyMessage) {
node.innerHTML = "";
const li = document.createElement("li");
li.className = "simple-item";
const p = document.createElement("p");
p.textContent = emptyMessage;
li.appendChild(p);
node.appendChild(li);
}
function renderMessages(items) {
cabinetMessages.innerHTML = "";
if (!Array.isArray(items) || items.length === 0) {
clearList(cabinetMessages, "Сообщений пока нет.");
return;
}
items.forEach((item) => {
const li = document.createElement("li");
li.className = "simple-item";
const time = document.createElement("time");
time.textContent = formatDate(item.created_at);
li.appendChild(time);
const p = document.createElement("p");
const author = item.author_name || item.author_type || "Участник";
p.textContent = author + ": " + (item.body || "");
li.appendChild(p);
cabinetMessages.appendChild(li);
});
}
function renderFiles(items) {
cabinetFiles.innerHTML = "";
if (!Array.isArray(items) || items.length === 0) {
clearList(cabinetFiles, "Файлы пока не загружены.");
return;
}
items.forEach((item) => {
const li = document.createElement("li");
li.className = "simple-item";
const time = document.createElement("time");
time.textContent = formatDate(item.created_at);
li.appendChild(time);
const p = document.createElement("p");
const sizeKb = Math.max(1, Math.round(Number(item.size_bytes || 0) / 1024));
p.textContent = item.file_name + " (" + sizeKb + " КБ)";
li.appendChild(p);
const link = document.createElement("a");
link.href = item.download_url;
link.textContent = "Открыть / скачать";
link.target = "_blank";
link.rel = "noopener noreferrer";
link.style.color = "#f6d7a8";
li.appendChild(link);
cabinetFiles.appendChild(li);
});
}
function renderTimeline(items) {
cabinetTimeline.innerHTML = "";
if (!Array.isArray(items) || items.length === 0) {
clearList(cabinetTimeline, "История пока пуста.");
return;
}
items.forEach((item) => {
const li = document.createElement("li");
li.className = "simple-item";
const time = document.createElement("time");
time.textContent = formatDate(item.created_at);
li.appendChild(time);
const p = document.createElement("p");
if (item.type === "status_change") {
p.textContent = "Статус: " + (item.payload?.from_status || "NEW") + " -> " + (item.payload?.to_status || "-");
} else if (item.type === "message") {
const author = item.payload?.author_name || item.payload?.author_type || "Участник";
p.textContent = "Сообщение от " + author + ": " + (item.payload?.body || "");
} else if (item.type === "attachment") {
p.textContent = "Файл: " + (item.payload?.file_name || "вложение");
} else {
p.textContent = "Событие";
}
li.appendChild(p);
cabinetTimeline.appendChild(li);
});
}
async function loadQuotes() {
try {
const response = await fetch("/api/public/quotes?limit=8&order=random");
@ -792,10 +1111,201 @@
}
}
async function fetchRequestByTrack(trackNumber) {
const response = await fetch("/api/public/requests/" + encodeURIComponent(trackNumber));
const data = await parseJsonSafe(response);
return { response, data };
}
async function ensureViewAccess(trackNumber) {
let { response, data } = await fetchRequestByTrack(trackNumber);
if (response.ok) return data;
if (response.status !== 401 && response.status !== 403) {
throw new Error(apiErrorDetail(data, "Не удалось открыть заявку"));
}
setStatus(cabinetStatus, "Отправляем OTP-код...", null);
const sendResponse = await fetch("/api/public/otp/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
purpose: "VIEW_REQUEST",
track_number: trackNumber
})
});
const sendData = await parseJsonSafe(sendResponse);
if (!sendResponse.ok) {
throw new Error(apiErrorDetail(sendData, "Не удалось отправить OTP"));
}
const code = window.prompt("Введите OTP-код из SMS (в dev-режиме смотрите backend console):");
if (!code) {
throw new Error("Код OTP не введен");
}
setStatus(cabinetStatus, "Проверяем OTP...", null);
const verifyResponse = await fetch("/api/public/otp/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
purpose: "VIEW_REQUEST",
track_number: trackNumber,
code: String(code).trim()
})
});
const verifyData = await parseJsonSafe(verifyResponse);
if (!verifyResponse.ok) {
throw new Error(apiErrorDetail(verifyData, "OTP не подтвержден"));
}
({ response, data } = await fetchRequestByTrack(trackNumber));
if (!response.ok) {
throw new Error(apiErrorDetail(data, "Нет доступа к заявке"));
}
return data;
}
async function refreshCabinetData() {
if (!activeTrack) return;
const [messagesRes, filesRes, timelineRes] = await Promise.all([
fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/messages"),
fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/attachments"),
fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/timeline")
]);
const messagesData = await parseJsonSafe(messagesRes);
const filesData = await parseJsonSafe(filesRes);
const timelineData = await parseJsonSafe(timelineRes);
if (!messagesRes.ok) throw new Error(apiErrorDetail(messagesData, "Не удалось загрузить сообщения"));
if (!filesRes.ok) throw new Error(apiErrorDetail(filesData, "Не удалось загрузить файлы"));
if (!timelineRes.ok) throw new Error(apiErrorDetail(timelineData, "Не удалось загрузить историю"));
renderMessages(messagesData);
renderFiles(filesData);
renderTimeline(timelineData);
}
async function openCabinetByTrack() {
const trackNumber = String(cabinetTrackInput.value || "").trim().toUpperCase();
if (!trackNumber) {
setStatus(cabinetStatus, "Введите номер заявки.", "error");
return;
}
try {
setStatus(cabinetStatus, "Открываем кабинет...", null);
const requestData = await ensureViewAccess(trackNumber);
activeTrack = trackNumber;
activeRequestId = requestData.id;
cabinetRequestStatus.textContent = requestData.status_code || "-";
cabinetRequestTopic.textContent = requestData.topic_code || "Не указана";
cabinetRequestCreated.textContent = formatDate(requestData.created_at);
cabinetRequestUpdated.textContent = formatDate(requestData.updated_at);
cabinetSummary.hidden = false;
setCabinetEnabled(true);
await refreshCabinetData();
setStatus(cabinetStatus, "Кабинет открыт: " + trackNumber, "ok");
} catch (error) {
setStatus(cabinetStatus, error?.message || "Не удалось открыть кабинет", "error");
}
}
cabinetOpenButton.addEventListener("click", () => {
openCabinetByTrack();
});
cabinetChatForm.addEventListener("submit", async (event) => {
event.preventDefault();
if (!activeTrack) {
setStatus(cabinetStatus, "Сначала откройте кабинет по номеру заявки.", "error");
return;
}
const body = String(cabinetChatBody.value || "").trim();
if (!body) return;
try {
setStatus(cabinetStatus, "Отправляем сообщение...", null);
const response = await fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/messages", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body })
});
const data = await parseJsonSafe(response);
if (!response.ok) throw new Error(apiErrorDetail(data, "Не удалось отправить сообщение"));
cabinetChatBody.value = "";
await refreshCabinetData();
setStatus(cabinetStatus, "Сообщение отправлено.", "ok");
} catch (error) {
setStatus(cabinetStatus, error?.message || "Ошибка отправки сообщения", "error");
}
});
cabinetFileUpload.addEventListener("click", async () => {
if (!activeTrack || !activeRequestId) {
setStatus(cabinetStatus, "Сначала откройте кабинет по номеру заявки.", "error");
return;
}
const file = cabinetFileInput.files && cabinetFileInput.files[0];
if (!file) {
setStatus(cabinetStatus, "Выберите файл для загрузки.", "error");
return;
}
try {
setStatus(cabinetStatus, "Подготавливаем загрузку файла...", null);
const initResponse = await fetch("/api/public/uploads/init", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
file_name: file.name,
mime_type: file.type || "application/octet-stream",
size_bytes: file.size,
scope: "REQUEST_ATTACHMENT",
request_id: activeRequestId
})
});
const initData = await parseJsonSafe(initResponse);
if (!initResponse.ok) throw new Error(apiErrorDetail(initData, "Не удалось начать загрузку"));
const putResponse = await fetch(initData.presigned_url, {
method: "PUT",
headers: { "Content-Type": file.type || "application/octet-stream" },
body: file
});
if (!putResponse.ok) throw new Error("Ошибка передачи файла в хранилище");
const completeResponse = await fetch("/api/public/uploads/complete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
key: initData.key,
file_name: file.name,
mime_type: file.type || "application/octet-stream",
size_bytes: file.size,
scope: "REQUEST_ATTACHMENT",
request_id: activeRequestId
})
});
const completeData = await parseJsonSafe(completeResponse);
if (!completeResponse.ok) throw new Error(apiErrorDetail(completeData, "Не удалось завершить загрузку"));
cabinetFileInput.value = "";
await refreshCabinetData();
setStatus(cabinetStatus, "Файл загружен.", "ok");
} catch (error) {
setStatus(cabinetStatus, error?.message || "Ошибка загрузки файла", "error");
}
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
status.className = "status";
status.textContent = "Отправляем заявку...";
setStatus(status, "Отправляем заявку...", null);
const payload = {
client_name: document.getElementById("name").value.trim(),
@ -808,6 +1318,33 @@
};
try {
setStatus(status, "Отправляем OTP-код...", null);
const otpSend = await fetch("/api/public/otp/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
purpose: "CREATE_REQUEST",
client_phone: payload.client_phone
})
});
if (!otpSend.ok) throw new Error("otp send failed");
const code = window.prompt("Введите OTP-код из SMS (в dev-режиме смотрите backend console):");
if (!code) throw new Error("otp code required");
setStatus(status, "Проверяем OTP...", null);
const otpVerify = await fetch("/api/public/otp/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
purpose: "CREATE_REQUEST",
client_phone: payload.client_phone,
code: String(code).trim()
})
});
if (!otpVerify.ok) throw new Error("otp verify failed");
setStatus(status, "Создаем заявку...", null);
const response = await fetch("/api/public/requests", {
method: "POST",
headers: { "Content-Type": "application/json" },
@ -816,17 +1353,20 @@
if (!response.ok) throw new Error("create request failed");
const data = await response.json();
status.className = "status ok";
status.textContent = "Заявка принята. Номер: " + data.track_number;
setStatus(status, "Заявка принята. Номер: " + data.track_number, "ok");
cabinetTrackInput.value = data.track_number;
form.reset();
setTimeout(closeModal, 1200);
} catch (error) {
status.className = "status error";
status.textContent = "Не удалось отправить заявку. Повторите попытку позже.";
setStatus(status, "Не удалось отправить заявку. Повторите попытку позже.", "error");
}
});
loadQuotes();
setCabinetEnabled(false);
clearList(cabinetMessages, "Сообщений пока нет.");
clearList(cabinetFiles, "Файлы пока не загружены.");
clearList(cabinetTimeline, "История пока пуста.");
})();
</script>
</body>

View file

@ -1,5 +1,119 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from sqlalchemy import func
from app.db.session import SessionLocal
from app.models.admin_user import AdminUser
from app.models.admin_user_topic import AdminUserTopic
from app.models.audit_log import AuditLog
from app.models.request import Request
from app.models.status import Status
from app.workers.celery_app import celery_app
@celery_app.task(name='app.workers.tasks.assign.auto_assign_unclaimed')
DEFAULT_TERMINAL_STATUS_CODES = {"RESOLVED", "CLOSED", "REJECTED"}
def _terminal_status_codes(db) -> set[str]:
rows = db.query(Status.code).filter(Status.is_terminal.is_(True)).all()
codes = {str(code).strip() for (code,) in rows if code}
return codes or set(DEFAULT_TERMINAL_STATUS_CODES)
@celery_app.task(name="app.workers.tasks.assign.auto_assign_unclaimed")
def auto_assign_unclaimed():
return 'ok'
now = datetime.now(timezone.utc)
cutoff = now - timedelta(hours=24)
checked = 0
assigned = 0
db = SessionLocal()
try:
terminal_codes = _terminal_status_codes(db)
active_load_rows = (
db.query(Request.assigned_lawyer_id, func.count(Request.id))
.filter(Request.assigned_lawyer_id.is_not(None))
.filter(Request.status_code.notin_(terminal_codes))
.group_by(Request.assigned_lawyer_id)
.all()
)
lawyer_load: dict[str, int] = {str(lawyer_id): int(count) for lawyer_id, count in active_load_rows if lawyer_id}
active_lawyers = (
db.query(AdminUser.id, AdminUser.primary_topic_code)
.filter(AdminUser.role == "LAWYER", AdminUser.is_active.is_(True))
.all()
)
active_lawyer_ids = {str(lawyer_id) for lawyer_id, _ in active_lawyers if lawyer_id}
primary_by_topic: dict[str, list[str]] = {}
for lawyer_id, primary_topic_code in active_lawyers:
topic_code = str(primary_topic_code or "").strip()
if not topic_code:
continue
primary_by_topic.setdefault(topic_code, []).append(str(lawyer_id))
additional_by_topic: dict[str, set[str]] = {}
additional_rows = (
db.query(AdminUserTopic.topic_code, AdminUserTopic.admin_user_id)
.join(AdminUser, AdminUser.id == AdminUserTopic.admin_user_id)
.filter(AdminUser.role == "LAWYER", AdminUser.is_active.is_(True))
.all()
)
for topic_code_raw, lawyer_id in additional_rows:
topic_code = str(topic_code_raw or "").strip()
lawyer_key = str(lawyer_id or "").strip()
if not topic_code or not lawyer_key or lawyer_key not in active_lawyer_ids:
continue
additional_by_topic.setdefault(topic_code, set()).add(lawyer_key)
queue = (
db.query(Request)
.filter(
Request.assigned_lawyer_id.is_(None),
Request.created_at <= cutoff,
Request.topic_code.is_not(None),
)
.order_by(Request.created_at.asc())
.all()
)
checked = len(queue)
for req in queue:
topic_code = str(req.topic_code or "").strip()
if not topic_code:
continue
primary_candidates = primary_by_topic.get(topic_code) or []
if primary_candidates:
candidates = primary_candidates
assignment_basis = "primary_topic"
else:
candidates = sorted(additional_by_topic.get(topic_code) or [])
assignment_basis = "additional_topic"
if not candidates:
continue
selected = min(candidates, key=lambda lawyer_id: (lawyer_load.get(lawyer_id, 0), lawyer_id))
req.assigned_lawyer_id = selected
req.updated_at = now
req.responsible = "Администратор системы"
lawyer_load[selected] = lawyer_load.get(selected, 0) + 1
assigned += 1
db.add(
AuditLog(
actor_admin_id=None,
entity="requests",
entity_id=str(req.id),
action="AUTO_ASSIGN",
diff={"topic_code": topic_code, "assigned_lawyer_id": selected, "basis": assignment_basis},
)
)
db.commit()
return {"checked": checked, "assigned": assigned}
except Exception:
db.rollback()
raise
finally:
db.close()

View file

@ -1,5 +1,23 @@
from __future__ import annotations
from datetime import datetime, timezone
from app.db.session import SessionLocal
from app.models.otp_session import OtpSession
from app.workers.celery_app import celery_app
@celery_app.task(name='app.workers.tasks.security.cleanup_expired_otps')
@celery_app.task(name="app.workers.tasks.security.cleanup_expired_otps")
def cleanup_expired_otps():
return 'ok'
now = datetime.now(timezone.utc)
db = SessionLocal()
try:
total = db.query(OtpSession).count()
deleted = db.query(OtpSession).filter(OtpSession.expires_at <= now).delete(synchronize_session=False)
db.commit()
return {"checked": int(total), "deleted": int(deleted)}
except Exception:
db.rollback()
raise
finally:
db.close()

View file

@ -1,5 +1,61 @@
from __future__ import annotations
from uuid import UUID
from app.db.session import SessionLocal
from app.models.request import Request
from app.services.notifications import EVENT_SLA_OVERDUE, notify_request_event
from app.services.sla_metrics import compute_sla_snapshot
from app.workers.celery_app import celery_app
@celery_app.task(name='app.workers.tasks.sla.sla_check')
def _emit_sla_overdue_notifications(db, overdue_rows: list[dict]) -> dict[str, int]:
internal_created = 0
telegram_sent = 0
for item in overdue_rows:
request_id_raw = str(item.get("request_id") or "").strip()
if not request_id_raw:
continue
try:
request_uuid = UUID(request_id_raw)
except ValueError:
continue
req = db.get(Request, request_uuid)
if req is None:
continue
threshold = item.get("threshold_hours")
spent = item.get("hours_in_status")
body = f"Просрочка SLA: {spent}ч > {threshold}ч"
dedupe_prefix = f"sla:{req.id}:{req.status_code}"
result = notify_request_event(
db,
request=req,
event_type=EVENT_SLA_OVERDUE,
actor_role="SYSTEM",
body=body,
responsible="SLA сервис",
dedupe_prefix=dedupe_prefix,
)
internal_created += int(result.get("internal_created", 0))
telegram_sent += int(result.get("telegram_sent", 0))
return {"internal_created": int(internal_created), "telegram_sent": int(telegram_sent)}
@celery_app.task(name="app.workers.tasks.sla.sla_check")
def sla_check():
return 'ok'
db = SessionLocal()
try:
snapshot = compute_sla_snapshot(db, include_overdue_requests=True)
overdue_rows = list(snapshot.get("overdue_requests") or [])
notify_result = _emit_sla_overdue_notifications(db, overdue_rows)
if notify_result["internal_created"] > 0:
db.commit()
snapshot.pop("overdue_requests", None)
snapshot["notifications_created"] = int(notify_result["internal_created"])
snapshot["telegram_sent"] = int(notify_result["telegram_sent"])
return snapshot
except Exception:
db.rollback()
raise
finally:
db.close()

View file

@ -1,5 +1,56 @@
from __future__ import annotations
from sqlalchemy import func
from app.db.session import SessionLocal
from app.models.attachment import Attachment
from app.models.request import Request
from app.workers.celery_app import celery_app
@celery_app.task(name='app.workers.tasks.uploads.cleanup_stale_uploads')
@celery_app.task(name="app.workers.tasks.uploads.cleanup_stale_uploads")
def cleanup_stale_uploads():
return 'ok'
db = SessionLocal()
try:
requests = db.query(Request).all()
existing_request_ids = {str(req.id) for req in requests}
deleted_orphan = 0
deleted_invalid = 0
attachment_rows = db.query(Attachment.id, Attachment.request_id, Attachment.size_bytes, Attachment.s3_key).all()
for att_id, request_id, size_bytes, s3_key in attachment_rows:
request_id_str = str(request_id)
if request_id_str not in existing_request_ids:
db.query(Attachment).filter(Attachment.id == att_id).delete(synchronize_session=False)
deleted_orphan += 1
continue
if int(size_bytes or 0) <= 0 or not str(s3_key or "").strip():
db.query(Attachment).filter(Attachment.id == att_id).delete(synchronize_session=False)
deleted_invalid += 1
if deleted_orphan or deleted_invalid:
db.flush()
totals_rows = db.query(Attachment.request_id, func.coalesce(func.sum(Attachment.size_bytes), 0)).group_by(Attachment.request_id).all()
totals_map = {str(request_id): int(total or 0) for request_id, total in totals_rows}
fixed_requests = 0
for req in requests:
request_total = totals_map.get(str(req.id), 0)
if int(req.total_attachments_bytes or 0) != request_total:
req.total_attachments_bytes = request_total
req.responsible = "Администратор системы"
db.add(req)
fixed_requests += 1
db.commit()
return {
"deleted_orphan_attachments": int(deleted_orphan),
"deleted_invalid_attachments": int(deleted_invalid),
"fixed_requests": int(fixed_requests),
}
except Exception:
db.rollback()
raise
finally:
db.close()

View file

@ -9,11 +9,32 @@ One-page landing + public case tracking (OTP + JWT cookie) + admin panel (ADMIN/
- Backend: Python 3.12 + FastAPI
- DB: PostgreSQL
- Queue: Redis + Celery
- Immutable data after status change
- Full audit log for admin changes
- OTP is required for request creation and for public request access
- Public JWT cookie is stored for 7 days on one device to reduce repeated OTP sends
- Request assignment: manual lawyer claim or automatic assignment after 24h if still unassigned
- Automatic assignment priority: primary lawyer topic, then additional topics, then lowest active load
- Additional lawyer topics are stored in a separate link table (many-to-many), not in `admin_users` array/json
- Active load means requests in non-terminal statuses (`is_terminal = false`)
- Topic-specific status flow + SLA per status transition
- Topic template split: required create fields (`topic_required_fields`) + per-topic request template (`topic_data_templates`) + per-request expansion (`request_data_requirements`)
- Topic-specific status flow rules are stored in `topic_status_transitions` and validated server-side on status update
- Each lawyer has default rate; each request stores fixed effective rate (can be overridden by ADMIN)
- Request fixed rate is immutable for billing history and does not follow future lawyer rate edits
- Lawyer rates are internal data and must not be exposed in public client API/UI
- Each lawyer has salary percent used for payroll calculation
- Payment fact is recorded on ADMIN status change to "Оплачено"; this timestamp is used in monthly gross/payroll
- A request can have multiple invoice-payment cycles and multiple payment events
- Status flow supports billing step type ("выставление счета") with invoice generation from template and delivery to client
- On status change, previous messages and attachments become immutable
- Manual claim is allowed only for unassigned requests; no lawyer-to-lawyer takeover
- Reassignment of already assigned request is allowed for ADMIN only
- Read state is tracked per request (not per message/file); opening request marks updates as seen
- UI shows one-time green dot indicators for changed entities (messages/files/status) until request is opened
- Full audit log for admin actions
- UniversalTable + UniversalRecordModal (meta-driven admin UI)
- Security controls for S3/PII: access audit trail, encryption, retention policy, and incident visibility
## Roles
- PUBLIC (via OTP + cookie)
- LAWYER
- ADMIN
- LAWYER (assigned + unassigned queue visibility)
- ADMIN (access to all platform data and configuration)

View file

@ -1,18 +1,45 @@
# Public Requests Service Context
## Responsibilities
- Accept new legal case requests
- Generate track_number
- Store configurable form fields (form_fields table)
- Trigger OTP flow
- Allow client to view request (after OTP verify)
- Show landing page and accept new legal case requests
- Request base fields: full name, phone, topic, problem description
- Require OTP verification before request creation (phone confirmation)
- Generate and return unique `track_number`
- Allow client to reopen request by `track_number` and continue communication with lawyer
- Allow client to see status/history, upload requested files, and read/write chat messages
- Track unread updates at request level for both client and lawyer side
- Store extra fields as JSON from admin-configured topic form template
## Key Rules
- Phone is mandatory
- Extra fields stored as JSON (validated against form_fields config)
- Phone is mandatory and must be OTP-verified for create flow
- Base request fields are mandatory (`client_name`, `client_phone`, `topic_code`, `description`)
- Topic-specific required fields are configured by ADMIN in `topic_required_fields`
- File size limit: 25MB per file
- Case size limit: 350MB total
- Case size limit: 250MB total
- Public file interaction for now: download/open (no inline preview requirement)
- New message/file/status update sets request-level "has updates" marker for target side
- Opening request resets marker and counts as acknowledgment
- Internal lawyer rates are hidden from public API/UI
- Client can receive generated invoice documents when request enters billing status
## Security
- Rate limit by IP/phone/track_number
- No direct access without OTP verification
- After successful OTP verification, device keeps JWT cookie for 7 days
- Public create endpoint requires cookie `purpose=CREATE_REQUEST` with `sub=<phone>`
- Public view endpoint (`GET /api/public/requests/{track_number}`) requires cookie `purpose=VIEW_REQUEST` with `sub=<track_number>`
## Implemented Public Cabinet Endpoints (`P12`)
- `GET /api/public/requests/{track_number}`: карточка заявки
- `GET /api/public/requests/{track_number}/messages`: чат заявки
- `POST /api/public/requests/{track_number}/messages`: сообщение клиента
- `GET /api/public/requests/{track_number}/attachments`: список файлов заявки
- `GET /api/public/requests/{track_number}/history`: история смены статусов
- `GET /api/public/requests/{track_number}/timeline`: объединенная лента событий (статусы/сообщения/файлы)
- `GET /api/public/uploads/object/{attachment_id}`: открыть/скачать файл с проверкой доступа по `track_number`
## UI
- На лендинге добавлен блок «Кабинет клиента»:
- вход по `track_number` с OTP (`VIEW_REQUEST`) при отсутствии валидной 7-дневной cookie
- отображение статуса заявки, чата, файлов и таймлайна
- отправка сообщения клиентом и загрузка файла через public upload flow (`init` -> `PUT` -> `complete`)

View file

@ -1,18 +1,27 @@
# OTP Service Context
## Purpose
Secure access for:
- Creating request
- Viewing request
Secure public access for:
- Creating request (phone confirmation is mandatory)
- Viewing request status/chat/files by `track_number`
## Flow
1. Send OTP (CREATE_REQUEST / VIEW_REQUEST)
1. Send OTP (`CREATE_REQUEST` / `VIEW_REQUEST`)
2. Store hashed code
3. Expire in 10 minutes
4. Max attempts limit
5. On verify -> issue public JWT cookie (7 days)
5. On verify -> issue public JWT cookie (7 days, same device)
6. If valid JWT exists on device, do not resend OTP until cookie expiration
## Current Dev Mode
- OTP code is printed to backend console log (`[OTP MOCK] ... code=XXXXXX`)
- SMS provider call is mocked (`sms_response.provider = mock_sms`)
- `CREATE_REQUEST` verification issues cookie with `purpose=CREATE_REQUEST` and `sub=<phone>`
- Request creation endpoint requires that cookie and then switches cookie to `purpose=VIEW_REQUEST`, `sub=<track_number>`
- `VIEW_REQUEST` verification issues cookie with `purpose=VIEW_REQUEST` and `sub=<track_number>`
## Anti-abuse
- Rate limit (Redis)
- Cooldown between sends
- Lock after N failed attempts
- Throttling by phone + track number + IP

View file

@ -1,17 +1,150 @@
# Admin Panel Service Context
## Roles
- ADMIN: full CRUD + config + SLA + quotes
- LAWYER: work with assigned requests only
- ADMIN: full CRUD + all dictionaries + SLA + users + quotes
- LAWYER:
- see assigned requests
- see unassigned queue
- can manually claim unassigned request ("Take in work")
## Core Features
- Universal table with filters (= != > < >= <= ~)
- Universal table with filters (`= != > < >= <= ~`)
- Universal record modal (meta-driven)
- Manual editing of any table
- AuditLog for any CREATE/UPDATE/DELETE
- Manual editing of available entities
- AuditLog for any CREATE/UPDATE/DELETE and system assignment events
- Atomic claim action to prevent race conditions between lawyers
- User profile avatar (`avatar_url`) with fallback initials in UI
- Lawyer profile includes default lawyer rate (editable by ADMIN)
- Lawyer profile includes salary percent (editable by ADMIN)
## Assignment Logic
- Request can be claimed manually by lawyer
- Manual claim is allowed only when `assigned_lawyer_id` is null
- Lawyer takeover is forbidden
- Manual reassignment of assigned request is ADMIN-only action
- If not claimed within 24h and still unassigned, auto-assign is applied
- Lawyer profile includes:
- one primary topic
- additional topics
- Additional topics are stored via separate link table (`admin_user_topics`)
- Assignment priority: primary topic matches first, then additional topics, then lowest active load
- Active load = count of assigned requests with non-terminal status (`is_terminal=false`)
## Status Logic
- Status flow is configured per topic
- Base model is linear, but with allowed flow variations (Jira-like)
- On any status change:
- All previous messages immutable
- All previous attachments immutable
- Add status_history record
- all previous messages immutable
- all previous attachments immutable
- add `status_history` record
### Implemented Flow Configuration (`P14`)
- New dictionary table: `topic_status_transitions`
- Transition rule fields:
- `topic_code`
- `from_status`
- `to_status`
- `sla_hours` (SLA для перехода, в часах)
- `enabled`
- `sort_order`
- ADMIN manages flow rules in "Справочники -> Переходы статусов"
- Server-side validation:
- if topic has configured enabled rules, transition is allowed only when rule exists
- if topic has no rules yet, backward-compatible free transition is kept
### Planned Billing Status Extension
- Add status type: `INVOICE` / "Выставление счета"
- For this status type:
- invoice is generated from admin-managed template
- invoice is attached/sent to client through platform notification channel
- billing status can be included in topic-specific flow as regular transition node
### Implemented SLA Transition Config (`P18`)
- SLA configuration is stored in `topic_status_transitions.sla_hours`
- `sla_hours` is optional but if set must be integer > 0
- CRUD validation prevents:
- unknown `topic_code` / status codes
- `from_status == to_status`
- non-positive `sla_hours`
- Admin UI for "Переходы статусов" includes `SLA (часы)` in table, filters, and edit/create modal
### Implemented Status Immutability (`P15`)
- On request status change:
- all existing `messages` for request are marked `immutable=true`
- all existing `attachments` for request are marked `immutable=true`
- new row is written into `status_history` (`from_status`, `to_status`, `changed_by_admin_id`)
- Immutable records protection:
- `PATCH /api/admin/crud/messages/{id}` and `DELETE /api/admin/crud/messages/{id}` are blocked for immutable rows
- `PATCH /api/admin/crud/attachments/{id}` and `DELETE /api/admin/crud/attachments/{id}` are blocked for immutable rows
- upload complete rejects binding file to immutable message (`message_id`)
## Templates
- ADMIN configures required client fields for request creation (by topic)
- For in-progress work, lawyer can use topic template for requested docs/data
- Lawyer can extend that template for a specific request only
- No template versioning requirement
### Implemented Template Split (`P16`)
- New dictionaries:
- `topic_required_fields`: required request creation fields per topic (`topic_code`, `field_key`, `required`, `enabled`, `sort_order`)
- `topic_data_templates`: topic-level data/doc request template (`topic_code`, `key`, `label`, `description`, `required`, `enabled`, `sort_order`)
- Request-level table:
- `request_data_requirements`: per-request expanded template items, including lawyer-added custom items
- Validation:
- create request (`/api/public/requests`, `/api/admin/requests`, `/api/admin/crud/requests`) validates `extra_fields` against active required keys from `topic_required_fields`
- Request template API:
- `GET /api/admin/requests/{request_id}/data-template`
- `POST /api/admin/requests/{request_id}/data-template/sync`
- `POST /api/admin/requests/{request_id}/data-template/items`
- `PATCH /api/admin/requests/{request_id}/data-template/items/{item_id}`
- `DELETE /api/admin/requests/{request_id}/data-template/items/{item_id}`
- RBAC:
- ADMIN has full access
- LAWYER can work with template items only for assigned request
## Rates & Billing Rules (planned)
- ADMIN sets default lawyer rate in user profile
- ADMIN sets lawyer salary percent in user profile
- ADMIN can override rate for a specific request
- Effective request rate is stored in request and frozen for financial traceability
- Request rate is not returned in public client endpoints and not shown in public UI
- Effective request amount is stored in request and frozen for financial traceability
- Fact of payment is recorded when ADMIN changes request status to "Оплачено" (business paid event)
- Payment event stores who changed status and when (for salary/month reports)
- A request may contain more than one payment event (multiple invoice-payment cycles)
### Implemented Baseline For Dashboard (`P21`)
- Financial profile fields are persisted:
- `admin_users.default_rate`
- `admin_users.salary_percent`
- Request financial fields are persisted:
- `requests.effective_rate`
- `requests.invoice_amount`
- `requests.paid_at`
- `requests.paid_by_admin_id`
- Admin UI record forms/tables include these fields.
- Public API still does not expose internal financial fields.
## Read / Unread UX
- Unread state is tracked per request for both LAWYER and PUBLIC user
- New message/file/status change marks request as updated
- Opening request marks updates as read
- UI can show one-time green indicator for what changed (message/file/status)
## Implemented Marker Model (`P13`)
- `requests.client_has_unread_updates` / `requests.client_unread_event_type`
- `requests.lawyer_has_unread_updates` / `requests.lawyer_unread_event_type`
- Event types: `MESSAGE`, `ATTACHMENT`, `STATUS`
- LAWYER opening request (`GET /api/admin/crud/requests/{id}` or `GET /api/admin/requests/{id}`) clears lawyer marker
- Client opening request (`GET /api/public/requests/{track_number}`) clears client marker
## Admin Dashboard Financial Metrics (planned)
- For each lawyer show:
- active requests count (current load)
- sum of active requests amounts (if amount exists)
- monthly gross of paid requests
- monthly gross of paid events
- monthly salary amount
- Salary calculation base:
- paid event = ADMIN changes request status to "Оплачено"
- salary = paid request amount * lawyer salary percent

View file

@ -3,10 +3,31 @@
## Storage
- Self-hosted S3 (MinIO)
- Presigned PUT or multipart upload
- Store metadata in attachments table
- Store metadata in `attachments` table
## Rules
- Max 25MB per file
- Max 350MB per request
- Immutable after status change
- Max 250MB per request
- Attachments created in previous statuses become immutable after status change
- Current UX target: download/open file (no mandatory inline preview yet)
- Download via presigned GET or proxy endpoint
## Implemented Enforcement (`P17`)
- Server-side limit checks in both public/admin upload flows:
- `init`: checks requested size and current request total
- `complete`: re-checks actual object size from S3 `head_object` and request total
- Object key scope validation:
- public attachment upload accepts only keys under `requests/{request_id}/...`
- admin request attachment upload accepts only keys under `requests/{request_id}/...`
- admin avatar upload accepts only keys under `avatars/{user_id}/...`
- Download access guard (`/api/admin/uploads/object/{key}`):
- `ADMIN`: full access
- `LAWYER`: only own avatar and files from own/unassigned requests
## Planned Security Audit (`P27`)
- Security event log for every file operation:
- upload init/complete
- download/open
- denied access attempts
- Logging fields: actor, role, IP/device, object key, request_id, outcome, timestamp
- Add periodic integrity/security checks for object metadata and access anomalies

View file

@ -12,12 +12,59 @@
- cleanup_stale_uploads (daily)
## Auto Assign Logic
- If request unclaimed for 24h
- Match by topic
- Assign to lawyer with lowest active load
- Apply to any request that is still unassigned for 24h
- Candidate selection order:
1. lawyers with matching primary topic
2. lawyers with matching additional topics
3. among candidates -> lowest active load
- Additional topics source: link table `admin_user_topics`
- Active load definition: assigned requests in non-terminal statuses (`is_terminal=false`)
- Manual lawyer claim has priority if request already claimed before scheduler run
- Auto-assign never overrides already assigned request
## SLA Metrics
- First response time
- Time in status
- Overdue detection
- Telegram notification to group chat
- SLA is configured per topic and per status transition
- Telegram notification to group chat (if connected)
- In-site notifications for new updates/events
## Implemented SLA Config (`P18`)
- SLA config storage: `topic_status_transitions.sla_hours`
- Config is editable by ADMIN via universal CRUD / admin panel dictionary "Переходы статусов"
- Validation rules:
- `topic_code`, `from_status`, `to_status` must reference existing dictionaries
- `from_status` and `to_status` must differ
- `sla_hours` if provided must be integer > 0
- Applying transition-level SLA thresholds in `sla_check` remains in `P19`
- SLA should also apply to billing transitions (including "выставление счета" step) once billing status type is enabled
## Implemented SLA Check (`P19`)
- `sla_check` uses transition SLA config for active requests:
- source: `topic_status_transitions.sla_hours`
- matching: by `topic_code` + current `from_status` (outgoing transitions)
- when several outgoing transitions exist, minimal configured `sla_hours` is used as active threshold
- fallback: status defaults (`NEW`, `IN_PROGRESS`, etc.) when transition SLA is absent
- outputs:
- `overdue_total`
- `overdue_by_status`
- `overdue_by_transition` (format: `topic:status->*`)
- `frt_avg_minutes`
- `avg_time_in_status_hours`
## Implemented Notifications (`P20`)
- Internal notifications table: `notifications`
- Notification recipients:
- `CLIENT` by `track_number`
- `ADMIN_USER` by `admin_user_id` (admin/lawyer)
- Event sources integrated:
- public/client message (`MESSAGE`)
- public/client file upload (`ATTACHMENT`)
- admin/lawyer file upload (`ATTACHMENT`)
- admin/lawyer status change (`STATUS`)
- SLA overdue worker event (`SLA_OVERDUE`)
- Telegram delivery:
- if bot/chat configured -> send via Telegram Bot API
- if not configured -> safe mock output in console (`[TELEGRAM MOCK]`)
- SLA-overdue notifications are deduplicated per `(request, status, recipient)`.

View file

@ -1,7 +1,7 @@
# Security Model Context
## Public
- OTP verification required
- OTP verification required for request creation and request access
- JWT in httpOnly cookie (7 days)
- Rate limiting
- Protection from brute force
@ -12,5 +12,28 @@
- Audit log required
## Data Protection
- Immutable after status change
- Messages and attachments from previous statuses are immutable after status change
- All actions logged
## S3 & Personal Data (planned hardening)
- Files in S3 are treated as personal data (PII/ПДн)
- Security baseline for implementation:
- Access model:
- strict RBAC/least-privilege for object read/write
- scoped object keys and server-side authorization checks on every download
- no direct anonymous public bucket/object access
- Cryptography:
- encryption in transit (TLS) for all client<->API and API<->S3 paths
- encryption at rest for object storage and backups
- key rotation policy and secret management (no static secrets in code)
- Audit & accountability:
- immutable security audit trail for file operations (who, when, what object, action, result)
- alerting on suspicious access patterns (mass download, repeated denied attempts)
- periodic access review reports
- Data lifecycle:
- retention rules by data category/status
- controlled deletion and archival procedures
- backup restore testing and disaster recovery runbook
- Compliance posture:
- map controls to РФ requirements for personal data protection and internal cyber policies
- formalize security checklist for release gates (threat review + access review + logging verification)

View file

@ -6,9 +6,85 @@
- SLA overdue
- Avg first response time
- Avg time in status
- SLA by topic + status transition
- Overdue by transition (`topic:status->*`)
- Per-lawyer workload (active and total assigned requests)
- Per-lawyer financial block:
- active requests amount (sum of fixed request amounts for active requests)
- monthly paid gross (sum of paid requests in current month)
- monthly salary (sum of paid request amount * lawyer percent)
## Lawyer Dashboard
- Assigned requests
- Unassigned requests queue
- Active requests by statuses
- New/unseen messages
- New/unseen files
- Unseen status changes
- Unseen state is request-level (single marker per request)
- Opening request resets unseen marker
- One-time green dot can be shown for changed entity type (message/file/status)
## Data Sources
- requests
- status_history
- messages
- sla config
- attachments
- sla config (`topic_status_transitions`, field `sla_hours`)
- notification/read markers
- lawyer financial profile (`default_rate`, `salary_percent`)
- fixed financial fields in request (`effective_rate`, `invoice_amount`, `paid_at`, `paid_by_admin_id`)
## Payment Event Rule
- Fact of payment is recognized only on ADMIN status change to "Оплачено"
- This event timestamp is used as payment date for monthly gross/salary
- A request may produce multiple paid events; monthly aggregates sum all paid events in month
- Salary formula: `invoice_amount * salary_percent`
## Implemented Marker Fields (`P13`)
- request-level booleans: `client_has_unread_updates`, `lawyer_has_unread_updates`
- request-level event types: `client_unread_event_type`, `lawyer_unread_event_type`
- `overview` now exposes aggregate counters:
- `unread_for_clients`
- `unread_for_lawyers`
## Implemented SLA Snapshot (`P19`)
- `overview` includes:
- `sla_overdue` (total)
- `overdue_by_status`
- `overdue_by_transition`
- `frt_avg_minutes`
- `avg_time_in_status_hours`
## Implemented Notifications (`P20`)
- Notification storage: table `notifications`
- Channels:
- in-site notifications for `CLIENT` and `ADMIN_USER`
- Telegram (if bot configured; otherwise mock logging)
- Events:
- `MESSAGE`, `ATTACHMENT`, `STATUS`, `SLA_OVERDUE`
- Read behavior:
- opening request marks related notifications as read for viewer side
## Implemented Dashboard Role Split (`P21`)
- `/api/admin/metrics/overview` is role-aware:
- `ADMIN` sees global metrics and full per-lawyer block
- `LAWYER` sees scoped metrics for own assigned requests + unassigned queue
- Added counters:
- `assigned_total`
- `active_assigned_total`
- `unassigned_total`
- `my_unread_updates`
- `my_unread_by_event`
- Per-lawyer financial metrics:
- `active_amount` (sum `requests.invoice_amount` for active assigned requests)
- `monthly_paid_events` (count of `status_history.to_status == PAID/ОПЛАЧЕНО` in current month)
- `monthly_paid_gross` (sum invoice amount per paid event in current month)
- `monthly_salary` (`monthly_paid_gross * salary_percent / 100`)
- Financial source fields added:
- `admin_users.default_rate`
- `admin_users.salary_percent`
- `requests.effective_rate`
- `requests.invoice_amount`
- `requests.paid_at`
- `requests.paid_by_admin_id`

View file

@ -0,0 +1,64 @@
# План Разработки (Execution Plan)
## Назначение
Этот файл фиксирует последовательность работ до завершения проекта.
Используется ИИ-агентом как рабочий чеклист реализации.
## Правило статусов
- `сделано` — реализовано в коде, покрыто тестами или подтверждено рабочим сценарием.
- `к разработке` — еще не реализовано полностью или реализовано как заглушка.
## Порядок выполнения
Работы выполняются строго сверху вниз по `ID`.
Переход к следующему пункту возможен только после:
1. Обновления кода.
2. Обновления/добавления тестов.
3. Проверки `unittest` и миграций.
4. Обновления контекста (`context/*.md`) при изменении требований.
## Дорожная карта
| ID | Статус | Блок | Задача для ИИ-агента | Критерий готовности |
|---|---|---|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---|
| P01 | сделано | База проекта | Поднять базовую архитектуру FastAPI + PostgreSQL + Redis + Celery + админку и лендинг | API запускается, миграции применяются, базовые страницы доступны |
| P02 | сделано | Модели и миграции | Создать основные таблицы (`requests`, `topics`, `statuses`, `form_fields`, `messages`, `attachments`, `status_history`, `audit_log`, `admin_users`, `otp_sessions`, `quotes`) + системные поля | Миграции `0001-0003` проходят, тесты миграций зеленые |
| P03 | сделано | Универсальный CRUD | Реализовать универсальный CRUD + RBAC по таблицам + аудит изменений | CRUD работает для справочников и пользователей, аудит пишется |
| P04 | сделано | Пользователи и роли | CRUD пользователей, хеширование паролей, роль `ADMIN/LAWYER`, профильная тема (`primary_topic_code`) | Тесты на CRUD пользователей и пароли проходят |
| P05 | сделано | Базовый auto-assign | Реализовать автоназначение через 24 часа по профильной теме и активной нагрузке | `auto_assign_unclaimed` назначает корректно, тесты проходят |
| P06 | сделано | Базовая админ-панель | Перевод `admin.html` -> `admin.jsx`, универсальные таблицы/модалки/фильтры | Работа с сущностями через единый UI доступна |
| P07 | сделано | Темы юриста (расширение) | Добавить связующую таблицу `admin_user_topics` для дополнительных тем юриста; обновить CRUD/UI/фильтры | У юриста: 1 основная + N дополнительных тем; выбор тем в UI и в API |
| P08 | сделано | Назначение заявок (ручное) | Добавить endpoint «Взять в работу» для юриста с атомарной блокировкой (без гонок), только если заявка не назначена | Два юриста не могут одновременно взять одну заявку; takeover запрещен |
| P09 | сделано | Переназначение | Добавить ADMIN-only ручное переназначение уже назначенной заявки | Юрист не может перехватывать назначенные заявки, админ может переназначить |
| P10 | сделано | Алгоритм auto-assign v2 | Доработать автоназначение: приоритет `primary_topic` -> `admin_user_topics` -> минимальная активная нагрузка (`is_terminal=false`) | Воркеры и тесты покрывают новый приоритет и edge-cases |
| P11 | сделано | OTP create/view | Внедрить полноценный OTP-поток: OTP обязателен для создания заявки и просмотра по `track_number`, cookie JWT 7 дней | Без OTP нельзя создать/смотреть заявку; повторный OTP не нужен при валидной cookie |
| P12 | сделано | Публичный кабинет клиента | Реализовать публичные endpoints и UI: просмотр статуса, чат, файлы, история изменений по `track_number` | Клиент может вести диалог и видеть прогресс заявки в одном контуре |
| P13 | сделано | Read/unread маркеры | Добавить request-level маркеры «есть обновления» для клиента/юриста; открытие заявки сбрасывает маркер; одноразовая зеленая точка типа события | В списке заявок корректно отображаются непрочитанные обновления |
| P14 | сделано | Статусные флоу по темам | Добавить настройку линейных флоу с допустимыми вариациями переходов (Jira-like), валидировать переходы | Нельзя выполнить переход вне разрешенной цепочки |
| P15 | сделано | Иммутабельность по статусам | На смене статуса «замораживать» сообщения и вложения предыдущих статусов + писать `status_history` | Попытка изменения старых сообщений/файлов отклоняется API |
| P16 | сделано | Шаблоны данных | Разделить шаблоны: (1) обязательные поля создания по теме; (2) шаблон дозапроса документов в работе, расширяемый юристом только для текущей заявки | Админ настраивает базу, юрист расширяет только в рамках конкретной заявки |
| P17 | сделано | Файловый контур | Довести загрузку/скачивание файлов: лимиты 25MB/файл и 250MB/заявка, хранение метаданных, контроль суммарного объема | Лимиты enforced сервером, загрузки и скачивание стабильны |
| P18 | сделано | SLA-конфиг | Настройка SLA на каждый переход статуса для каждой темы | SLA-конфиг хранится в БД, валидируется в админке |
| P19 | сделано | SLA-check и overdue | Реализовать `sla_check`: контроль просрочек по переходам, расчет FRT/времени в статусе | Метрики и флаги просрочек обновляются по расписанию |
| P20 | сделано | Уведомления | Уведомления в Telegram (если подключен) + внутренние уведомления сайта по изменениям | При событиях (сообщения/файлы/статусы/SLA) уведомления доставляются |
| P21 | сделано | Dashboard LAWYER/ADMIN | Расширить дашборды: назначенные/неназначенные, активные по статусам, непрочитанные, SLA, по каждому юристу: активная загрузка, сумма активных заявок, вал оплаченных за месяц, зарплата за месяц | Дашборды соответствуют ролям и данным из БД |
| P22 | к разработке | Тестирование E2E | Покрыть ключевые бизнес-сценарии: OTP, claim, auto-assign v2, чат, файлы, SLA, уведомления, read markers | Набор автотестов фиксирует регрессии критичных сценариев |
| P23 | к разработке | Hardening/release | Полировка безопасности, логирования, лимитов, отказоустойчивости, документации API/UI и runbook | Проект готов к стабилизации и приемке |
| P24 | к разработке | Mobile UX | Мобильная адаптация лендинга и клиентских форм (заявка, OTP, кабинет клиента: чат, файлы, история) | UI корректно работает на 320-768px, элементы доступны и читаемы без горизонтального скролла |
| P25 | к разработке | Тарифы юристов | Добавить ставку и процент юриста (по умолчанию в профиле), а также фиксируемые в заявке поля ставки/суммы (override админом) | Финансовые поля заявки фиксируются и не зависят от последующих правок профиля; клиенту не показываются |
| P26 | к разработке | Биллинг-статус | Добавить тип статуса «выставление счета»: генерация счета из шаблона, отправка клиенту и фиксация события оплаты по смене статуса администратором на `Оплачено` | Для темы можно включить billing-этап, счет формируется и доставляется; факт оплаты фиксируется по событиям `Оплачено` (возможны множественные события в одной заявке) |
| P27 | к разработке | Security Audit | Внедрить аудит безопасности и защиту ПДн для S3/файлов по требованиям РФ и кибербезопасности | Реализован журнал доступа, шифрование, RBAC/least-privilege, политика хранения и контроль инцидентов |
## Критический маршрут (обязательный порядок)
1. `P07 -> P08 -> P09 -> P10` (полный контур назначения).
2. `P11 -> P12 -> P13` (публичный клиентский контур).
3. `P14 -> P15 -> P16` (процесс работы по заявке).
4. `P17 -> P18 -> P25 -> P26 -> P19 -> P20 -> P21` (файлы, SLA, тарифы/биллинг, аналитика).
5. `P22 -> P23 -> P24 -> P27` (стабилизация, mobile UX, security-аудит).
## Правила выполнения для ИИ-агента
1. Не менять бизнес-правила без обновления `context/*.md`.
2. Любую новую таблицу добавлять только через миграции + тест миграций.
3. На каждый новый endpoint добавлять позитивный и негативный автотест.
4. Для RBAC: сначала ограничить доступ, затем открывать минимально необходимое.
5. Для операций назначения использовать транзакционную защиту от гонок.
6. Для статусов и SLA использовать только серверную валидацию (не доверять фронту).
7. Перед переводом пункта в `сделано` выполнять проверки из `context/11_test_runbook.md`.

View file

@ -0,0 +1,63 @@
# Runbook Проверок (Тесты и Валидация по Плану)
## Назначение
Этот файл фиксирует, где находятся проверки для каждого пункта `P01-P23` и как их запускать.
Использовать перед переводом пункта в статус `сделано`.
## Базовые команды
1. Применить миграции:
```bash
docker compose exec -T backend alembic upgrade head
```
2. Полный прогон автотестов:
```bash
docker compose exec -T backend python -m unittest discover -s tests -p 'test_*.py' -v
```
3. Быстрая проверка импорта/синтаксиса Python:
```bash
docker compose exec -T backend python -m compileall app tests alembic
```
4. Проверка сборки `admin.jsx` через Docker Compose (на образе `frontend`):
```bash
docker compose build frontend
docker compose run --rm --no-deps --entrypoint sh frontend -lc "apk add --no-cache nodejs npm >/dev/null && npx --yes esbuild /usr/share/nginx/html/admin.jsx --loader:.jsx=jsx --bundle --outfile=/tmp/admin.bundle.js"
```
## Матрица проверок по задачам
| ID | Что проверяем | Где тесты | Как запускать |
|---|---|---|---|
| P01 | Базовый запуск сервисов и API | smoke + общие тесты | `docker compose up -d`; затем базовые команды 1-3 |
| P02 | Таблицы и миграции | `tests/test_migrations.py` | `docker compose exec -T backend python -m unittest tests.test_migrations -v` |
| P03 | Universal CRUD + RBAC + audit | `tests/test_admin_universal_crud.py` | `docker compose exec -T backend python -m unittest tests.test_admin_universal_crud.AdminUniversalCrudTests -v` |
| P04 | Пользователи, роли, пароли | `tests/test_admin_universal_crud.py` (тесты про `admin_users`) | команда как для `P03` |
| P05 | Базовый auto-assign | `tests/test_auto_assign.py` | `docker compose exec -T backend python -m unittest tests.test_auto_assign -v` |
| P06 | Админка `admin.jsx` + базовый UI контур | сборка `admin.jsx` + CRUD/API тесты | базовая команда 4 + тесты `P03` |
| P07 | Доп. темы юристов (`admin_user_topics`) | `tests/test_admin_universal_crud.py` | команда как для `P03` |
| P08 | Ручной claim (без гонок) | `tests/test_admin_universal_crud.py` (claim-тесты) | команда как для `P03` |
| P09 | ADMIN-only переназначение | `tests/test_admin_universal_crud.py` (reassign-тесты) | команда как для `P03` |
| P10 | Auto-assign v2 приоритетов | `tests/test_auto_assign.py` | команда как для `P05` |
| P11 | OTP create/view + 7-day cookie | `tests/test_public_requests.py` | `docker compose exec -T backend python -m unittest tests.test_public_requests -v` |
| P12 | Публичный кабинет (статус/чат/файлы/таймлайн) | `tests/test_public_cabinet.py` | `docker compose exec -T backend python -m unittest tests.test_public_cabinet -v` |
| P13 | Read/unread маркеры | `tests/test_public_requests.py`, `tests/test_admin_universal_crud.py`, `tests/test_uploads_s3.py` | запустить 3 набора: `test_public_requests`, `test_admin_universal_crud`, `test_uploads_s3` |
| P14 | Валидация флоу статусов по темам | `tests/test_admin_universal_crud.py` (status-flow тесты) | команда как для `P03` |
| P15 | Иммутабельность сообщений/файлов на смене статуса | `tests/test_admin_universal_crud.py`, `tests/test_uploads_s3.py` | `test_admin_universal_crud` + `test_uploads_s3` |
| P16 | Шаблоны данных (required + request template) | `tests/test_public_requests.py`, `tests/test_admin_universal_crud.py`, `tests/test_migrations.py` | запустить 3 набора + миграции |
| P17 | Файловый контур и лимиты | `tests/test_uploads_s3.py`, `tests/test_worker_maintenance.py` | `docker compose exec -T backend python -m unittest tests.test_uploads_s3 tests.test_worker_maintenance -v` |
| P18 | SLA-конфиг | `tests/test_admin_universal_crud.py`, `tests/test_migrations.py` | `alembic upgrade head`; затем `python -m unittest tests.test_admin_universal_crud tests.test_migrations -v` |
| P19 | SLA overdue/FRT расчеты | `tests/test_worker_maintenance.py`, `tests/test_admin_universal_crud.py` (metrics) | `docker compose exec -T backend python -m unittest tests.test_worker_maintenance tests.test_admin_universal_crud -v`; проверить `overdue_by_transition` |
| P20 | Уведомления | `tests/test_notifications.py`, а также регрессии `tests/test_public_cabinet.py`, `tests/test_uploads_s3.py`, `tests/test_worker_maintenance.py` | `docker compose exec -T backend python -m unittest tests.test_notifications tests.test_public_cabinet tests.test_uploads_s3 tests.test_worker_maintenance -v`; затем полный прогон |
| P21 | Dashboard ADMIN/LAWYER | `tests/test_admin_universal_crud.py` (metrics/dashboard) + `tests/test_dashboard_finance.py` | `docker compose exec -T backend python -m unittest tests.test_dashboard_finance tests.test_admin_universal_crud -v`; проверить role-scope и метрики юристов: загрузка, сумма активных, вал за месяц, зарплата за месяц |
| P22 | E2E критические сценарии | набор `tests/test_*.py` + новые E2E-тесты | базовые команды 1-3 + полный прогон |
| P23 | Hardening/release | весь regression + compile + миграции + UI build | базовые команды 1-4 |
| P24 | Мобильная адаптация лендинга/клиентских форм | `app/web/landing.html` + ручная проверка в mobile viewport | собрать `admin.jsx` при затрагивании админки + открыть `landing.html` в 320px/375px/768px, проверить формы/чат/файлы без горизонтального скролла |
| P25 | Ставки юриста и ставка заявки | новые тесты `tests/test_rates.py` + интеграционные в `tests/test_admin_universal_crud.py` | прогон `test_rates` + `test_admin_universal_crud`; проверка что public API не отдает поля ставок/процентов |
| P26 | Billing-статус и шаблон счета | новые тесты `tests/test_billing_flow.py` + e2e статусных переходов | прогон `test_billing_flow` + `test_admin_universal_crud`; валидация генерации счета и фиксации оплаты при ADMIN->\"Оплачено\" (в т.ч. множественные оплаты в одной заявке) |
| P27 | Security audit S3/ПДн | новые тесты `tests/test_security_audit.py` + `tests/test_uploads_s3.py` | прогон `test_security_audit` + `test_uploads_s3`; проверка логирования и ограничений доступа |
## Минимальный чеклист закрытия пункта
1. Выполнить миграции (если были изменения схемы).
2. Выполнить целевые тесты пункта по матрице выше.
3. Выполнить полный прогон `unittest discover`.
4. Выполнить `compileall`.
5. Для изменений `admin.jsx` выполнить сборку `admin.jsx` через Docker Compose.
6. После успешной проверки обновить статус пункта в `context/10_development_execution_plan.md`.

File diff suppressed because it is too large Load diff

356
tests/test_auto_assign.py Normal file
View file

@ -0,0 +1,356 @@
import os
import unittest
from datetime import datetime, timedelta, timezone
from uuid import UUID
from sqlalchemy import create_engine, delete
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0")
os.environ.setdefault("S3_ENDPOINT", "http://localhost:9000")
os.environ.setdefault("S3_ACCESS_KEY", "test")
os.environ.setdefault("S3_SECRET_KEY", "test")
os.environ.setdefault("S3_BUCKET", "test")
from app.models.admin_user import AdminUser
from app.models.admin_user_topic import AdminUserTopic
from app.models.audit_log import AuditLog
from app.models.request import Request
from app.models.status import Status
from app.workers.tasks import assign as assign_task
class AutoAssignTaskTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
AdminUser.__table__.create(bind=cls.engine)
AdminUserTopic.__table__.create(bind=cls.engine)
Status.__table__.create(bind=cls.engine)
Request.__table__.create(bind=cls.engine)
AuditLog.__table__.create(bind=cls.engine)
cls._old_session_local = assign_task.SessionLocal
assign_task.SessionLocal = cls.SessionLocal
@classmethod
def tearDownClass(cls):
assign_task.SessionLocal = cls._old_session_local
AuditLog.__table__.drop(bind=cls.engine)
Request.__table__.drop(bind=cls.engine)
Status.__table__.drop(bind=cls.engine)
AdminUserTopic.__table__.drop(bind=cls.engine)
AdminUser.__table__.drop(bind=cls.engine)
cls.engine.dispose()
def setUp(self):
with self.SessionLocal() as db:
db.execute(delete(AuditLog))
db.execute(delete(Request))
db.execute(delete(Status))
db.execute(delete(AdminUserTopic))
db.execute(delete(AdminUser))
db.commit()
def _create_lawyer(self, db, *, name, email, topic_code=None, is_active=True):
lawyer = AdminUser(
role="LAWYER",
name=name,
email=email,
password_hash="hash",
is_active=is_active,
primary_topic_code=topic_code,
)
db.add(lawyer)
db.flush()
return lawyer
def _link_additional_topic(self, db, *, lawyer_id, topic_code):
row = AdminUserTopic(admin_user_id=lawyer_id, topic_code=topic_code)
db.add(row)
db.flush()
return row
def _create_request(
self,
db,
*,
track_number,
topic_code,
created_at,
status_code="NEW",
assigned_lawyer_id=None,
):
req = Request(
track_number=track_number,
client_name="Тестовый клиент",
client_phone="+79990000000",
topic_code=topic_code,
status_code=status_code,
description="Описание",
extra_fields={},
assigned_lawyer_id=assigned_lawyer_id,
total_attachments_bytes=0,
created_at=created_at,
updated_at=created_at,
)
db.add(req)
db.flush()
return req
def test_auto_assign_matches_topic_and_uses_lowest_active_load(self):
now = datetime.now(timezone.utc)
with self.SessionLocal() as db:
db.add_all(
[
Status(code="NEW", name="Новая", enabled=True, sort_order=0, is_terminal=False),
Status(code="IN_PROGRESS", name="В работе", enabled=True, sort_order=1, is_terminal=False),
Status(code="CLOSED", name="Закрыта", enabled=True, sort_order=99, is_terminal=True),
]
)
lawyer_low = self._create_lawyer(db, name="Юрист 1", email="law1@example.com", topic_code="family")
lawyer_high = self._create_lawyer(db, name="Юрист 2", email="law2@example.com", topic_code="family")
self._create_lawyer(db, name="Юрист 3", email="law3@example.com", topic_code="tax")
self._create_request(
db,
track_number="TRK-HIGH-1",
topic_code="family",
created_at=now - timedelta(hours=30),
status_code="NEW",
assigned_lawyer_id=str(lawyer_high.id),
)
self._create_request(
db,
track_number="TRK-HIGH-2",
topic_code="family",
created_at=now - timedelta(hours=29),
status_code="IN_PROGRESS",
assigned_lawyer_id=str(lawyer_high.id),
)
self._create_request(
db,
track_number="TRK-LOW-CLOSED",
topic_code="family",
created_at=now - timedelta(hours=28),
status_code="CLOSED",
assigned_lawyer_id=str(lawyer_low.id),
)
target = self._create_request(
db,
track_number="TRK-TARGET",
topic_code="family",
created_at=now - timedelta(hours=25),
status_code="NEW",
assigned_lawyer_id=None,
)
fresh = self._create_request(
db,
track_number="TRK-FRESH",
topic_code="family",
created_at=now - timedelta(hours=3),
status_code="NEW",
assigned_lawyer_id=None,
)
unknown_topic = self._create_request(
db,
track_number="TRK-UNKNOWN",
topic_code="banking",
created_at=now - timedelta(hours=25),
status_code="NEW",
assigned_lawyer_id=None,
)
db.commit()
target_id = str(target.id)
target_expected_lawyer_id = str(lawyer_low.id)
fresh_id = str(fresh.id)
unknown_topic_id = str(unknown_topic.id)
result = assign_task.auto_assign_unclaimed()
self.assertEqual(result["checked"], 2)
self.assertEqual(result["assigned"], 1)
with self.SessionLocal() as db:
assigned_target = db.get(Request, UUID(target_id))
self.assertIsNotNone(assigned_target)
self.assertEqual(assigned_target.assigned_lawyer_id, target_expected_lawyer_id)
still_fresh = db.get(Request, UUID(fresh_id))
self.assertIsNotNone(still_fresh)
self.assertIsNone(still_fresh.assigned_lawyer_id)
still_unknown = db.get(Request, UUID(unknown_topic_id))
self.assertIsNotNone(still_unknown)
self.assertIsNone(still_unknown.assigned_lawyer_id)
audit_rows = (
db.query(AuditLog)
.filter(AuditLog.entity == "requests", AuditLog.entity_id == target_id, AuditLog.action == "AUTO_ASSIGN")
.all()
)
self.assertEqual(len(audit_rows), 1)
def test_auto_assign_uses_default_terminal_statuses_when_dictionary_is_empty(self):
now = datetime.now(timezone.utc)
with self.SessionLocal() as db:
lawyer_low = self._create_lawyer(db, name="Юрист A", email="la@example.com", topic_code="civil")
lawyer_high = self._create_lawyer(db, name="Юрист B", email="lb@example.com", topic_code="civil")
self._create_request(
db,
track_number="TRK-CLOSED",
topic_code="civil",
created_at=now - timedelta(hours=40),
status_code="CLOSED",
assigned_lawyer_id=str(lawyer_low.id),
)
self._create_request(
db,
track_number="TRK-ACTIVE",
topic_code="civil",
created_at=now - timedelta(hours=40),
status_code="NEW",
assigned_lawyer_id=str(lawyer_high.id),
)
target = self._create_request(
db,
track_number="TRK-TARGET2",
topic_code="civil",
created_at=now - timedelta(hours=26),
status_code="NEW",
assigned_lawyer_id=None,
)
db.commit()
target_id = str(target.id)
expected_lawyer_id = str(lawyer_low.id)
result = assign_task.auto_assign_unclaimed()
self.assertEqual(result["assigned"], 1)
with self.SessionLocal() as db:
assigned_target = db.get(Request, UUID(target_id))
self.assertIsNotNone(assigned_target)
self.assertEqual(assigned_target.assigned_lawyer_id, expected_lawyer_id)
def test_auto_assign_prefers_primary_topic_over_additional_topic(self):
now = datetime.now(timezone.utc)
with self.SessionLocal() as db:
db.add_all(
[
Status(code="NEW", name="Новая", enabled=True, sort_order=0, is_terminal=False),
Status(code="CLOSED", name="Закрыта", enabled=True, sort_order=99, is_terminal=True),
]
)
lawyer_primary = self._create_lawyer(db, name="Primary", email="primary@example.com", topic_code="family")
lawyer_additional = self._create_lawyer(db, name="Additional", email="additional@example.com", topic_code="tax")
self._link_additional_topic(db, lawyer_id=lawyer_additional.id, topic_code="family")
self._create_request(
db,
track_number="TRK-PRI-LOAD-1",
topic_code="family",
created_at=now - timedelta(hours=40),
status_code="NEW",
assigned_lawyer_id=str(lawyer_primary.id),
)
self._create_request(
db,
track_number="TRK-PRI-LOAD-2",
topic_code="family",
created_at=now - timedelta(hours=39),
status_code="NEW",
assigned_lawyer_id=str(lawyer_primary.id),
)
target = self._create_request(
db,
track_number="TRK-PRI-TARGET",
topic_code="family",
created_at=now - timedelta(hours=30),
status_code="NEW",
assigned_lawyer_id=None,
)
db.commit()
target_id = str(target.id)
expected_lawyer_id = str(lawyer_primary.id)
result = assign_task.auto_assign_unclaimed()
self.assertEqual(result["checked"], 1)
self.assertEqual(result["assigned"], 1)
with self.SessionLocal() as db:
assigned_target = db.get(Request, UUID(target_id))
self.assertIsNotNone(assigned_target)
self.assertEqual(assigned_target.assigned_lawyer_id, expected_lawyer_id)
audit = (
db.query(AuditLog)
.filter(AuditLog.entity == "requests", AuditLog.entity_id == target_id, AuditLog.action == "AUTO_ASSIGN")
.first()
)
self.assertIsNotNone(audit)
self.assertEqual((audit.diff or {}).get("basis"), "primary_topic")
def test_auto_assign_falls_back_to_additional_topics_and_uses_lowest_load(self):
now = datetime.now(timezone.utc)
with self.SessionLocal() as db:
db.add_all(
[
Status(code="NEW", name="Новая", enabled=True, sort_order=0, is_terminal=False),
Status(code="CLOSED", name="Закрыта", enabled=True, sort_order=99, is_terminal=True),
]
)
lawyer_busy = self._create_lawyer(db, name="Busy", email="busy@example.com", topic_code="tax")
lawyer_free = self._create_lawyer(db, name="Free", email="free@example.com", topic_code="corporate")
lawyer_inactive = self._create_lawyer(db, name="Inactive", email="inactive@example.com", topic_code=None, is_active=False)
self._link_additional_topic(db, lawyer_id=lawyer_busy.id, topic_code="family")
self._link_additional_topic(db, lawyer_id=lawyer_free.id, topic_code="family")
self._link_additional_topic(db, lawyer_id=lawyer_inactive.id, topic_code="family")
self._create_request(
db,
track_number="TRK-BUSY-1",
topic_code="tax",
created_at=now - timedelta(hours=40),
status_code="NEW",
assigned_lawyer_id=str(lawyer_busy.id),
)
target = self._create_request(
db,
track_number="TRK-ADD-TARGET",
topic_code="family",
created_at=now - timedelta(hours=30),
status_code="NEW",
assigned_lawyer_id=None,
)
db.commit()
target_id = str(target.id)
expected_lawyer_id = str(lawyer_free.id)
result = assign_task.auto_assign_unclaimed()
self.assertEqual(result["checked"], 1)
self.assertEqual(result["assigned"], 1)
with self.SessionLocal() as db:
assigned_target = db.get(Request, UUID(target_id))
self.assertIsNotNone(assigned_target)
self.assertEqual(assigned_target.assigned_lawyer_id, expected_lawyer_id)
audit = (
db.query(AuditLog)
.filter(AuditLog.entity == "requests", AuditLog.entity_id == target_id, AuditLog.action == "AUTO_ASSIGN")
.first()
)
self.assertIsNotNone(audit)
self.assertEqual((audit.diff or {}).get("basis"), "additional_topic")

View file

@ -0,0 +1,301 @@
import os
import unittest
from datetime import datetime, timedelta, timezone
from uuid import uuid4
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, delete
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0")
os.environ.setdefault("S3_ENDPOINT", "http://localhost:9000")
os.environ.setdefault("S3_ACCESS_KEY", "test")
os.environ.setdefault("S3_SECRET_KEY", "test")
os.environ.setdefault("S3_BUCKET", "test")
from app.core.config import settings
from app.core.security import create_jwt
from app.db.session import get_db
from app.main import app
from app.models.admin_user import AdminUser
from app.models.message import Message
from app.models.request import Request
from app.models.status import Status
from app.models.status_history import StatusHistory
from app.models.topic_status_transition import TopicStatusTransition
class DashboardFinanceTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
AdminUser.__table__.create(bind=cls.engine)
Request.__table__.create(bind=cls.engine)
Status.__table__.create(bind=cls.engine)
Message.__table__.create(bind=cls.engine)
StatusHistory.__table__.create(bind=cls.engine)
TopicStatusTransition.__table__.create(bind=cls.engine)
@classmethod
def tearDownClass(cls):
StatusHistory.__table__.drop(bind=cls.engine)
TopicStatusTransition.__table__.drop(bind=cls.engine)
Message.__table__.drop(bind=cls.engine)
Status.__table__.drop(bind=cls.engine)
Request.__table__.drop(bind=cls.engine)
AdminUser.__table__.drop(bind=cls.engine)
cls.engine.dispose()
def setUp(self):
with self.SessionLocal() as db:
db.execute(delete(StatusHistory))
db.execute(delete(TopicStatusTransition))
db.execute(delete(Message))
db.execute(delete(Request))
db.execute(delete(Status))
db.execute(delete(AdminUser))
db.commit()
def override_get_db():
db = self.SessionLocal()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
self.client = TestClient(app)
def tearDown(self):
self.client.close()
app.dependency_overrides.clear()
@staticmethod
def _headers(role: str, sub: str | None = None, email: str | None = None) -> dict[str, str]:
token = create_jwt(
{"sub": sub or str(uuid4()), "email": email or f"{role.lower()}@example.com", "role": role},
settings.ADMIN_JWT_SECRET,
timedelta(minutes=30),
)
return {"Authorization": f"Bearer {token}"}
def test_admin_dashboard_contains_lawyer_financial_metrics(self):
now = datetime.now(timezone.utc)
current_month_event = now - timedelta(days=2)
with self.SessionLocal() as db:
db.add_all(
[
Status(code="NEW", name="Новая", enabled=True, sort_order=0, is_terminal=False),
Status(code="CLOSED", name="Закрыта", enabled=True, sort_order=1, is_terminal=True),
Status(code="PAID", name="Оплачено", enabled=True, sort_order=2, is_terminal=False),
]
)
lawyer_a = AdminUser(
role="LAWYER",
name="Юрист A",
email="lawyer.a@example.com",
password_hash="hash",
salary_percent=30,
default_rate=5000,
is_active=True,
)
lawyer_b = AdminUser(
role="LAWYER",
name="Юрист B",
email="lawyer.b@example.com",
password_hash="hash",
salary_percent=10,
default_rate=3000,
is_active=True,
)
db.add_all([lawyer_a, lawyer_b])
db.flush()
req_a_active = Request(
track_number="TRK-FIN-A1",
client_name="Клиент A1",
client_phone="+79990000010",
topic_code="civil",
status_code="NEW",
assigned_lawyer_id=str(lawyer_a.id),
invoice_amount=1000,
extra_fields={},
)
req_a_closed = Request(
track_number="TRK-FIN-A2",
client_name="Клиент A2",
client_phone="+79990000011",
topic_code="civil",
status_code="CLOSED",
assigned_lawyer_id=str(lawyer_a.id),
invoice_amount=500,
extra_fields={},
)
req_b_active = Request(
track_number="TRK-FIN-B1",
client_name="Клиент B1",
client_phone="+79990000012",
topic_code="civil",
status_code="NEW",
assigned_lawyer_id=str(lawyer_b.id),
invoice_amount=2000,
extra_fields={},
)
db.add_all([req_a_active, req_a_closed, req_b_active])
db.flush()
db.add_all(
[
StatusHistory(
request_id=req_a_active.id,
from_status="INVOICE",
to_status="PAID",
changed_by_admin_id=None,
created_at=current_month_event,
updated_at=current_month_event,
),
StatusHistory(
request_id=req_a_active.id,
from_status="INVOICE",
to_status="PAID",
changed_by_admin_id=None,
created_at=current_month_event + timedelta(hours=1),
updated_at=current_month_event + timedelta(hours=1),
),
StatusHistory(
request_id=req_a_closed.id,
from_status="INVOICE",
to_status="PAID",
changed_by_admin_id=None,
created_at=current_month_event + timedelta(hours=2),
updated_at=current_month_event + timedelta(hours=2),
),
StatusHistory(
request_id=req_b_active.id,
from_status="INVOICE",
to_status="PAID",
changed_by_admin_id=None,
created_at=now - timedelta(days=40),
updated_at=now - timedelta(days=40),
),
]
)
db.commit()
response = self.client.get("/api/admin/metrics/overview", headers=self._headers("ADMIN"))
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertEqual(body.get("scope"), "ADMIN")
self.assertIn("lawyer_loads", body)
by_email = {row["email"]: row for row in body["lawyer_loads"]}
self.assertEqual(by_email["lawyer.a@example.com"]["active_load"], 1)
self.assertEqual(by_email["lawyer.a@example.com"]["total_assigned"], 2)
self.assertAlmostEqual(float(by_email["lawyer.a@example.com"]["active_amount"]), 1000.0, places=2)
self.assertEqual(by_email["lawyer.a@example.com"]["monthly_paid_events"], 3)
self.assertAlmostEqual(float(by_email["lawyer.a@example.com"]["monthly_paid_gross"]), 2500.0, places=2)
self.assertAlmostEqual(float(by_email["lawyer.a@example.com"]["monthly_salary"]), 750.0, places=2)
self.assertEqual(by_email["lawyer.b@example.com"]["active_load"], 1)
self.assertAlmostEqual(float(by_email["lawyer.b@example.com"]["active_amount"]), 2000.0, places=2)
self.assertEqual(by_email["lawyer.b@example.com"]["monthly_paid_events"], 0)
self.assertAlmostEqual(float(by_email["lawyer.b@example.com"]["monthly_paid_gross"]), 0.0, places=2)
self.assertAlmostEqual(float(by_email["lawyer.b@example.com"]["monthly_salary"]), 0.0, places=2)
def test_lawyer_dashboard_is_scoped_to_current_lawyer(self):
with self.SessionLocal() as db:
db.add_all(
[
Status(code="NEW", name="Новая", enabled=True, sort_order=0, is_terminal=False),
Status(code="CLOSED", name="Закрыта", enabled=True, sort_order=1, is_terminal=True),
]
)
lawyer_a = AdminUser(
role="LAWYER",
name="Юрист A",
email="lawyer.scope.a@example.com",
password_hash="hash",
salary_percent=20,
default_rate=4000,
is_active=True,
)
lawyer_b = AdminUser(
role="LAWYER",
name="Юрист B",
email="lawyer.scope.b@example.com",
password_hash="hash",
salary_percent=15,
default_rate=3500,
is_active=True,
)
db.add_all([lawyer_a, lawyer_b])
db.flush()
db.add_all(
[
Request(
track_number="TRK-SCOPE-A1",
client_name="Клиент A1",
client_phone="+79990001001",
topic_code="civil",
status_code="NEW",
assigned_lawyer_id=str(lawyer_a.id),
lawyer_has_unread_updates=True,
lawyer_unread_event_type="MESSAGE",
extra_fields={},
),
Request(
track_number="TRK-SCOPE-A2",
client_name="Клиент A2",
client_phone="+79990001002",
topic_code="civil",
status_code="CLOSED",
assigned_lawyer_id=str(lawyer_a.id),
extra_fields={},
),
Request(
track_number="TRK-SCOPE-B1",
client_name="Клиент B1",
client_phone="+79990001003",
topic_code="civil",
status_code="NEW",
assigned_lawyer_id=str(lawyer_b.id),
extra_fields={},
),
Request(
track_number="TRK-SCOPE-U1",
client_name="Клиент U1",
client_phone="+79990001004",
topic_code="civil",
status_code="NEW",
assigned_lawyer_id=None,
extra_fields={},
),
]
)
db.commit()
lawyer_a_id = str(lawyer_a.id)
response = self.client.get(
"/api/admin/metrics/overview",
headers=self._headers("LAWYER", sub=lawyer_a_id, email="lawyer.scope.a@example.com"),
)
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertEqual(body.get("scope"), "LAWYER")
self.assertEqual(int(body.get("assigned_total") or 0), 2)
self.assertEqual(int(body.get("active_assigned_total") or 0), 1)
self.assertEqual(int(body.get("unassigned_total") or 0), 1)
self.assertEqual(int(body.get("my_unread_updates") or 0), 1)
self.assertEqual(int((body.get("my_unread_by_event") or {}).get("MESSAGE") or 0), 1)
self.assertEqual(int((body.get("by_status") or {}).get("NEW") or 0), 1)
self.assertEqual(int((body.get("by_status") or {}).get("CLOSED") or 0), 1)
self.assertEqual(len(body.get("lawyer_loads") or []), 1)
self.assertEqual((body.get("lawyer_loads") or [])[0].get("lawyer_id"), lawyer_a_id)

View file

@ -83,6 +83,9 @@ class MigrationTests(unittest.TestCase):
"topics",
"statuses",
"form_fields",
"topic_required_fields",
"topic_data_templates",
"request_data_requirements",
"requests",
"messages",
"attachments",
@ -90,6 +93,9 @@ class MigrationTests(unittest.TestCase):
"audit_log",
"otp_sessions",
"quotes",
"admin_user_topics",
"topic_status_transitions",
"notifications",
"alembic_version",
}
tables = set(self.inspector.get_table_names())
@ -98,4 +104,70 @@ class MigrationTests(unittest.TestCase):
def test_alembic_version_is_set(self):
with self.engine.connect() as conn:
version = conn.execute(text("SELECT version_num FROM alembic_version")).scalar_one()
self.assertEqual(version, "0001_init")
self.assertEqual(version, "0011_dashboard_financial_fields")
def test_responsible_column_exists_in_all_domain_tables(self):
tables = {
"admin_users",
"topics",
"statuses",
"form_fields",
"topic_required_fields",
"topic_data_templates",
"request_data_requirements",
"requests",
"messages",
"attachments",
"status_history",
"audit_log",
"otp_sessions",
"quotes",
"admin_user_topics",
"topic_status_transitions",
"notifications",
}
for table in tables:
columns = {column["name"] for column in self.inspector.get_columns(table)}
self.assertIn("id", columns)
self.assertIn("created_at", columns)
self.assertIn("responsible", columns)
def test_admin_users_contains_primary_topic_profile_column(self):
columns = {column["name"] for column in self.inspector.get_columns("admin_users")}
self.assertIn("primary_topic_code", columns)
def test_admin_users_contains_avatar_column(self):
columns = {column["name"] for column in self.inspector.get_columns("admin_users")}
self.assertIn("avatar_url", columns)
def test_requests_contains_read_marker_columns(self):
columns = {column["name"] for column in self.inspector.get_columns("requests")}
self.assertIn("client_has_unread_updates", columns)
self.assertIn("client_unread_event_type", columns)
self.assertIn("lawyer_has_unread_updates", columns)
self.assertIn("lawyer_unread_event_type", columns)
def test_status_transitions_contains_sla_hours_column(self):
columns = {column["name"] for column in self.inspector.get_columns("topic_status_transitions")}
self.assertIn("sla_hours", columns)
def test_notifications_has_recipient_and_read_columns(self):
columns = {column["name"] for column in self.inspector.get_columns("notifications")}
self.assertIn("recipient_type", columns)
self.assertIn("recipient_admin_user_id", columns)
self.assertIn("recipient_track_number", columns)
self.assertIn("event_type", columns)
self.assertIn("is_read", columns)
self.assertIn("read_at", columns)
def test_admin_users_contains_rate_columns(self):
columns = {column["name"] for column in self.inspector.get_columns("admin_users")}
self.assertIn("default_rate", columns)
self.assertIn("salary_percent", columns)
def test_requests_contains_financial_columns(self):
columns = {column["name"] for column in self.inspector.get_columns("requests")}
self.assertIn("effective_rate", columns)
self.assertIn("invoice_amount", columns)
self.assertIn("paid_at", columns)
self.assertIn("paid_by_admin_id", columns)

375
tests/test_notifications.py Normal file
View file

@ -0,0 +1,375 @@
import os
import unittest
from datetime import datetime, timedelta, timezone
from uuid import UUID
from unittest.mock import patch
from botocore.exceptions import ClientError
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, delete
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0")
os.environ.setdefault("S3_ENDPOINT", "http://localhost:9000")
os.environ.setdefault("S3_ACCESS_KEY", "test")
os.environ.setdefault("S3_SECRET_KEY", "test")
os.environ.setdefault("S3_BUCKET", "test")
from app.core.config import settings
from app.core.security import create_jwt
from app.db.session import get_db
from app.main import app
from app.models.admin_user import AdminUser
from app.models.attachment import Attachment
from app.models.message import Message
from app.models.notification import Notification
from app.models.request import Request
from app.models.status import Status
from app.models.status_history import StatusHistory
from app.models.topic_status_transition import TopicStatusTransition
from app.workers.tasks import sla as sla_task
class _FakeS3Storage:
def __init__(self):
self.objects = {}
def create_presigned_put_url(self, key: str, mime_type: str, expires_sec: int = 900) -> str:
return f"https://s3.local/{key}?expires={expires_sec}"
def head_object(self, key: str) -> dict:
obj = self.objects.get(key)
if obj is None:
raise ClientError({"Error": {"Code": "404", "Message": "Not Found"}}, "HeadObject")
return {"ContentLength": obj["size"], "ContentType": obj["mime"]}
class NotificationFlowTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
AdminUser.__table__.create(bind=cls.engine)
Request.__table__.create(bind=cls.engine)
Message.__table__.create(bind=cls.engine)
Attachment.__table__.create(bind=cls.engine)
StatusHistory.__table__.create(bind=cls.engine)
TopicStatusTransition.__table__.create(bind=cls.engine)
Notification.__table__.create(bind=cls.engine)
@classmethod
def tearDownClass(cls):
Notification.__table__.drop(bind=cls.engine)
TopicStatusTransition.__table__.drop(bind=cls.engine)
StatusHistory.__table__.drop(bind=cls.engine)
Attachment.__table__.drop(bind=cls.engine)
Message.__table__.drop(bind=cls.engine)
Request.__table__.drop(bind=cls.engine)
AdminUser.__table__.drop(bind=cls.engine)
cls.engine.dispose()
def setUp(self):
with self.SessionLocal() as db:
db.execute(delete(Notification))
db.execute(delete(StatusHistory))
db.execute(delete(TopicStatusTransition))
db.execute(delete(Attachment))
db.execute(delete(Message))
db.execute(delete(Request))
db.execute(delete(AdminUser))
db.commit()
def override_get_db():
db = self.SessionLocal()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
self.client = TestClient(app)
def tearDown(self):
self.client.close()
app.dependency_overrides.clear()
@staticmethod
def _admin_headers(sub: str, role: str, email: str) -> dict[str, str]:
token = create_jwt(
{"sub": sub, "email": email, "role": role},
settings.ADMIN_JWT_SECRET,
timedelta(minutes=30),
)
return {"Authorization": f"Bearer {token}"}
@staticmethod
def _public_cookies(track_number: str) -> dict[str, str]:
token = create_jwt({"sub": track_number, "purpose": "VIEW_REQUEST"}, settings.PUBLIC_JWT_SECRET, timedelta(days=1))
return {settings.PUBLIC_COOKIE_NAME: token}
def test_public_message_creates_internal_notification_for_lawyer(self):
with self.SessionLocal() as db:
lawyer = AdminUser(
role="LAWYER",
name="Юрист",
email="lawyer@example.com",
password_hash="hash",
is_active=True,
)
db.add(lawyer)
db.flush()
req = Request(
track_number="TRK-NOTIF-MSG",
client_name="Клиент",
client_phone="+79990000001",
topic_code="civil",
status_code="NEW",
description="notification",
extra_fields={},
assigned_lawyer_id=str(lawyer.id),
)
db.add(req)
db.commit()
lawyer_id = str(lawyer.id)
created = self.client.post(
"/api/public/requests/TRK-NOTIF-MSG/messages",
cookies=self._public_cookies("TRK-NOTIF-MSG"),
json={"body": "Есть новое сообщение"},
)
self.assertEqual(created.status_code, 201)
with self.SessionLocal() as db:
rows = db.query(Notification).all()
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0].event_type, "MESSAGE")
self.assertEqual(str(rows[0].recipient_admin_user_id), lawyer_id)
self.assertFalse(rows[0].is_read)
notif_id = str(rows[0].id)
headers = self._admin_headers(lawyer_id, "LAWYER", "lawyer@example.com")
listed = self.client.get("/api/admin/notifications", headers=headers)
self.assertEqual(listed.status_code, 200)
self.assertEqual(listed.json()["total"], 1)
self.assertEqual(listed.json()["unread_total"], 1)
marked = self.client.post(f"/api/admin/notifications/{notif_id}/read", headers=headers)
self.assertEqual(marked.status_code, 200)
self.assertEqual(marked.json()["changed"], 1)
unread = self.client.get("/api/admin/notifications?unread_only=true", headers=headers)
self.assertEqual(unread.status_code, 200)
self.assertEqual(unread.json()["total"], 0)
def test_admin_status_change_creates_client_notification_and_open_marks_read(self):
with self.SessionLocal() as db:
req = Request(
track_number="TRK-NOTIF-STATUS",
client_name="Клиент",
client_phone="+79990000002",
topic_code="civil",
status_code="NEW",
description="notification status",
extra_fields={},
)
db.add(req)
db.commit()
request_id = str(req.id)
headers = self._admin_headers(sub=str(UUID(int=1)), role="ADMIN", email="admin@example.com")
updated = self.client.patch(
f"/api/admin/requests/{request_id}",
headers=headers,
json={"status_code": "IN_PROGRESS"},
)
self.assertEqual(updated.status_code, 200)
listed = self.client.get(
"/api/public/requests/TRK-NOTIF-STATUS/notifications",
cookies=self._public_cookies("TRK-NOTIF-STATUS"),
)
self.assertEqual(listed.status_code, 200)
self.assertEqual(listed.json()["total"], 1)
self.assertEqual(listed.json()["rows"][0]["event_type"], "STATUS")
self.assertEqual(listed.json()["unread_total"], 1)
opened = self.client.get(
"/api/public/requests/TRK-NOTIF-STATUS",
cookies=self._public_cookies("TRK-NOTIF-STATUS"),
)
self.assertEqual(opened.status_code, 200)
unread = self.client.get(
"/api/public/requests/TRK-NOTIF-STATUS/notifications?unread_only=true",
cookies=self._public_cookies("TRK-NOTIF-STATUS"),
)
self.assertEqual(unread.status_code, 200)
self.assertEqual(unread.json()["total"], 0)
def test_public_attachment_creates_lawyer_notification(self):
fake_s3 = _FakeS3Storage()
with self.SessionLocal() as db:
lawyer = AdminUser(
role="LAWYER",
name="Юрист",
email="lawyer-file@example.com",
password_hash="hash",
is_active=True,
)
db.add(lawyer)
db.flush()
req = Request(
track_number="TRK-NOTIF-FILE",
client_name="Клиент",
client_phone="+79990000003",
topic_code="civil",
status_code="NEW",
description="notification file",
extra_fields={},
assigned_lawyer_id=str(lawyer.id),
)
db.add(req)
db.commit()
request_id = str(req.id)
lawyer_id = str(lawyer.id)
with patch("app.api.public.uploads.get_s3_storage", return_value=fake_s3):
init_resp = self.client.post(
"/api/public/uploads/init",
cookies=self._public_cookies("TRK-NOTIF-FILE"),
json={
"file_name": "doc.pdf",
"mime_type": "application/pdf",
"size_bytes": 1024,
"scope": "REQUEST_ATTACHMENT",
"request_id": request_id,
},
)
self.assertEqual(init_resp.status_code, 200)
key = init_resp.json()["key"]
fake_s3.objects[key] = {"size": 1024, "mime": "application/pdf"}
complete = self.client.post(
"/api/public/uploads/complete",
cookies=self._public_cookies("TRK-NOTIF-FILE"),
json={
"key": key,
"file_name": "doc.pdf",
"mime_type": "application/pdf",
"size_bytes": 1024,
"scope": "REQUEST_ATTACHMENT",
"request_id": request_id,
},
)
self.assertEqual(complete.status_code, 200)
with self.SessionLocal() as db:
rows = db.query(Notification).filter(Notification.event_type == "ATTACHMENT").all()
self.assertEqual(len(rows), 1)
self.assertEqual(str(rows[0].recipient_admin_user_id), lawyer_id)
class NotificationSlaTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
AdminUser.__table__.create(bind=cls.engine)
Request.__table__.create(bind=cls.engine)
Status.__table__.create(bind=cls.engine)
Message.__table__.create(bind=cls.engine)
TopicStatusTransition.__table__.create(bind=cls.engine)
StatusHistory.__table__.create(bind=cls.engine)
Notification.__table__.create(bind=cls.engine)
cls._old_sla_session_local = sla_task.SessionLocal
sla_task.SessionLocal = cls.SessionLocal
@classmethod
def tearDownClass(cls):
sla_task.SessionLocal = cls._old_sla_session_local
Notification.__table__.drop(bind=cls.engine)
StatusHistory.__table__.drop(bind=cls.engine)
TopicStatusTransition.__table__.drop(bind=cls.engine)
Message.__table__.drop(bind=cls.engine)
Status.__table__.drop(bind=cls.engine)
Request.__table__.drop(bind=cls.engine)
AdminUser.__table__.drop(bind=cls.engine)
cls.engine.dispose()
def setUp(self):
with self.SessionLocal() as db:
db.execute(delete(Notification))
db.execute(delete(StatusHistory))
db.execute(delete(TopicStatusTransition))
db.execute(delete(Message))
db.execute(delete(Status))
db.execute(delete(Request))
db.execute(delete(AdminUser))
db.commit()
def test_sla_overdue_notifications_are_deduplicated(self):
now = datetime.now(timezone.utc)
with self.SessionLocal() as db:
admin = AdminUser(
role="ADMIN",
name="Админ",
email="root@example.com",
password_hash="hash",
is_active=True,
)
lawyer = AdminUser(
role="LAWYER",
name="Юрист",
email="lawyer-sla@example.com",
password_hash="hash",
is_active=True,
)
db.add_all([admin, lawyer])
db.flush()
db.add(Status(code="NEW", name="Новая", enabled=True, sort_order=0, is_terminal=False))
db.add(Status(code="IN_PROGRESS", name="В работе", enabled=True, sort_order=1, is_terminal=False))
db.add(
TopicStatusTransition(
topic_code="civil",
from_status="NEW",
to_status="IN_PROGRESS",
enabled=True,
sla_hours=1,
sort_order=1,
)
)
req = Request(
track_number="TRK-NOTIF-SLA",
client_name="Клиент",
client_phone="+79990000009",
topic_code="civil",
status_code="NEW",
description="sla",
extra_fields={},
assigned_lawyer_id=str(lawyer.id),
created_at=now - timedelta(hours=2),
updated_at=now - timedelta(hours=2),
)
db.add(req)
db.commit()
first = sla_task.sla_check()
second = sla_task.sla_check()
self.assertGreaterEqual(first.get("notifications_created", 0), 2)
self.assertEqual(second.get("notifications_created", 0), 0)
with self.SessionLocal() as db:
rows = db.query(Notification).filter(Notification.event_type == "SLA_OVERDUE").all()
self.assertGreaterEqual(len(rows), 2)

View file

@ -0,0 +1,266 @@
import os
import unittest
from datetime import timedelta
from uuid import UUID
from unittest.mock import patch
from botocore.exceptions import ClientError
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, delete
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0")
os.environ.setdefault("S3_ENDPOINT", "http://localhost:9000")
os.environ.setdefault("S3_ACCESS_KEY", "test")
os.environ.setdefault("S3_SECRET_KEY", "test")
os.environ.setdefault("S3_BUCKET", "test")
from app.core.config import settings
from app.core.security import create_jwt
from app.db.session import get_db
from app.main import app
from app.models.attachment import Attachment
from app.models.message import Message
from app.models.notification import Notification
from app.models.request import Request
from app.models.status_history import StatusHistory
class _FakeBody:
def __init__(self, payload: bytes):
self.payload = payload
def iter_chunks(self, chunk_size=65536):
for i in range(0, len(self.payload), chunk_size):
yield self.payload[i : i + chunk_size]
class _FakeS3Storage:
def __init__(self):
self.objects = {}
def get_object(self, key: str) -> dict:
row = self.objects.get(key)
if row is None:
raise ClientError({"Error": {"Code": "404", "Message": "Not Found"}}, "GetObject")
return {"Body": _FakeBody(row["content"]), "ContentType": row["mime"], "ContentLength": row["size"]}
class PublicCabinetTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
Request.__table__.create(bind=cls.engine)
Notification.__table__.create(bind=cls.engine)
Message.__table__.create(bind=cls.engine)
Attachment.__table__.create(bind=cls.engine)
StatusHistory.__table__.create(bind=cls.engine)
@classmethod
def tearDownClass(cls):
StatusHistory.__table__.drop(bind=cls.engine)
Attachment.__table__.drop(bind=cls.engine)
Message.__table__.drop(bind=cls.engine)
Notification.__table__.drop(bind=cls.engine)
Request.__table__.drop(bind=cls.engine)
cls.engine.dispose()
def setUp(self):
with self.SessionLocal() as db:
db.execute(delete(Notification))
db.execute(delete(StatusHistory))
db.execute(delete(Attachment))
db.execute(delete(Message))
db.execute(delete(Request))
db.commit()
def override_get_db():
db = self.SessionLocal()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
self.client = TestClient(app)
def tearDown(self):
self.client.close()
app.dependency_overrides.clear()
@staticmethod
def _public_cookies(track_number: str) -> dict[str, str]:
token = create_jwt({"sub": track_number, "purpose": "VIEW_REQUEST"}, settings.PUBLIC_JWT_SECRET, timedelta(days=1))
return {settings.PUBLIC_COOKIE_NAME: token}
def test_cabinet_lists_messages_attachments_history_and_timeline(self):
with self.SessionLocal() as db:
req = Request(
track_number="TRK-CAB-001",
client_name="Тест Клиент",
client_phone="+79991110000",
topic_code="consulting",
status_code="IN_PROGRESS",
description="Проверка кабинета",
extra_fields={},
)
db.add(req)
db.commit()
db.refresh(req)
db.add(
Message(
request_id=req.id,
author_type="LAWYER",
author_name="Юрист",
body="Принял в работу.",
)
)
db.add(
Attachment(
request_id=req.id,
file_name="doc.pdf",
mime_type="application/pdf",
size_bytes=1234,
s3_key="requests/key/doc.pdf",
)
)
db.add(
StatusHistory(
request_id=req.id,
from_status="NEW",
to_status="IN_PROGRESS",
comment="Юрист взял заявку",
)
)
db.commit()
cookies = self._public_cookies("TRK-CAB-001")
messages = self.client.get("/api/public/requests/TRK-CAB-001/messages", cookies=cookies)
self.assertEqual(messages.status_code, 200)
self.assertEqual(len(messages.json()), 1)
self.assertEqual(messages.json()[0]["author_type"], "LAWYER")
attachments = self.client.get("/api/public/requests/TRK-CAB-001/attachments", cookies=cookies)
self.assertEqual(attachments.status_code, 200)
self.assertEqual(len(attachments.json()), 1)
self.assertIn("/api/public/uploads/object/", attachments.json()[0]["download_url"])
history = self.client.get("/api/public/requests/TRK-CAB-001/history", cookies=cookies)
self.assertEqual(history.status_code, 200)
self.assertEqual(len(history.json()), 1)
self.assertEqual(history.json()[0]["to_status"], "IN_PROGRESS")
timeline = self.client.get("/api/public/requests/TRK-CAB-001/timeline", cookies=cookies)
self.assertEqual(timeline.status_code, 200)
events = timeline.json()
self.assertEqual(len(events), 3)
self.assertEqual({event["type"] for event in events}, {"status_change", "message", "attachment"})
def test_client_can_create_message_in_public_cabinet(self):
with self.SessionLocal() as db:
req = Request(
track_number="TRK-CAB-MSG",
client_name="Клиент Сообщение",
client_phone="+79992220000",
topic_code="consulting",
status_code="NEW",
description="Проверка отправки",
extra_fields={},
)
db.add(req)
db.commit()
request_id = req.id
cookies = self._public_cookies("TRK-CAB-MSG")
created = self.client.post(
"/api/public/requests/TRK-CAB-MSG/messages",
cookies=cookies,
json={"body": "Добрый день, есть вопрос по документам."},
)
self.assertEqual(created.status_code, 201)
message_id = UUID(created.json()["id"])
with self.SessionLocal() as db:
row = db.get(Message, message_id)
self.assertIsNotNone(row)
self.assertEqual(row.request_id, request_id)
self.assertEqual(row.author_type, "CLIENT")
self.assertEqual(row.body, "Добрый день, есть вопрос по документам.")
req = db.get(Request, request_id)
self.assertIsNotNone(req)
self.assertEqual(req.responsible, "Клиент")
self.assertTrue(req.lawyer_has_unread_updates)
self.assertEqual(req.lawyer_unread_event_type, "MESSAGE")
def test_public_cabinet_respects_track_access(self):
with self.SessionLocal() as db:
req = Request(
track_number="TRK-REAL",
client_name="Клиент Ограничение",
client_phone="+79993330000",
topic_code="consulting",
status_code="NEW",
description="Проверка доступа",
extra_fields={},
)
db.add(req)
db.commit()
cookies = self._public_cookies("TRK-OTHER")
denied = self.client.get("/api/public/requests/TRK-REAL/messages", cookies=cookies)
self.assertEqual(denied.status_code, 403)
def test_public_attachment_download_requires_access(self):
fake_s3 = _FakeS3Storage()
with self.SessionLocal() as db:
req = Request(
track_number="TRK-FILE-1",
client_name="Клиент Файл",
client_phone="+79994440000",
topic_code="consulting",
status_code="NEW",
description="Файл",
extra_fields={},
)
db.add(req)
db.commit()
db.refresh(req)
att = Attachment(
request_id=req.id,
file_name="act.pdf",
mime_type="application/pdf",
size_bytes=4,
s3_key="requests/a/act.pdf",
)
db.add(att)
db.commit()
attachment_id = str(att.id)
fake_s3.objects["requests/a/act.pdf"] = {
"content": b"test",
"mime": "application/pdf",
"size": 4,
}
with patch("app.api.public.uploads.get_s3_storage", return_value=fake_s3):
allowed = self.client.get(
f"/api/public/uploads/object/{attachment_id}",
cookies=self._public_cookies("TRK-FILE-1"),
)
self.assertEqual(allowed.status_code, 200)
self.assertEqual(allowed.content, b"test")
denied = self.client.get(
f"/api/public/uploads/object/{attachment_id}",
cookies=self._public_cookies("TRK-OTHER"),
)
self.assertEqual(denied.status_code, 403)

View file

@ -1,5 +1,7 @@
import os
import unittest
from datetime import timedelta
from unittest.mock import patch
from uuid import UUID
from fastapi.testclient import TestClient
@ -16,8 +18,13 @@ os.environ.setdefault("S3_SECRET_KEY", "test")
os.environ.setdefault("S3_BUCKET", "test")
from app.main import app
from app.core.config import settings
from app.core.security import create_jwt
from app.db.session import get_db
from app.models.notification import Notification
from app.models.otp_session import OtpSession
from app.models.request import Request
from app.models.topic_required_field import TopicRequiredField
class PublicRequestCreateTests(unittest.TestCase):
@ -30,14 +37,23 @@ class PublicRequestCreateTests(unittest.TestCase):
)
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
Request.__table__.create(bind=cls.engine)
Notification.__table__.create(bind=cls.engine)
OtpSession.__table__.create(bind=cls.engine)
TopicRequiredField.__table__.create(bind=cls.engine)
@classmethod
def tearDownClass(cls):
Notification.__table__.drop(bind=cls.engine)
OtpSession.__table__.drop(bind=cls.engine)
TopicRequiredField.__table__.drop(bind=cls.engine)
Request.__table__.drop(bind=cls.engine)
cls.engine.dispose()
def setUp(self):
with self.SessionLocal() as db:
db.execute(delete(Notification))
db.execute(delete(OtpSession))
db.execute(delete(TopicRequiredField))
db.execute(delete(Request))
db.commit()
@ -55,7 +71,25 @@ class PublicRequestCreateTests(unittest.TestCase):
self.client.close()
app.dependency_overrides.clear()
def test_create_request_persists_in_database(self):
def _send_and_verify_create_otp(self, phone: str) -> None:
with patch("app.api.public.otp._generate_code", return_value="123456"):
sent = self.client.post(
"/api/public/otp/send",
json={"purpose": "CREATE_REQUEST", "client_phone": phone},
)
self.assertEqual(sent.status_code, 200)
body = sent.json()
self.assertEqual(body["status"], "sent")
self.assertEqual(body["sms_response"]["provider"], "mock_sms")
verified = self.client.post(
"/api/public/otp/verify",
json={"purpose": "CREATE_REQUEST", "client_phone": phone, "code": "123456"},
)
self.assertEqual(verified.status_code, 200)
self.assertEqual(verified.json()["status"], "verified")
def test_create_request_requires_verified_otp_cookie(self):
payload = {
"client_name": "ООО Ромашка",
"client_phone": "+79990000001",
@ -63,16 +97,16 @@ class PublicRequestCreateTests(unittest.TestCase):
"description": "Тестируем создание заявки",
"extra_fields": {"referral_name": "Партнер"},
}
response = self.client.post("/api/public/requests", json=payload)
self.assertEqual(response.status_code, 401)
self._send_and_verify_create_otp(payload["client_phone"])
response = self.client.post("/api/public/requests", json=payload)
self.assertEqual(response.status_code, 201)
body = response.json()
self.assertTrue(body["track_number"].startswith("TRK-"))
self.assertTrue(body["otp_required"])
self.assertIsNotNone(body["request_id"])
self.assertFalse(body["otp_required"])
request_id = UUID(body["request_id"])
with self.SessionLocal() as db:
@ -85,3 +119,128 @@ class PublicRequestCreateTests(unittest.TestCase):
self.assertEqual(created.extra_fields, payload["extra_fields"])
self.assertEqual(created.status_code, "NEW")
self.assertEqual(created.track_number, body["track_number"])
self.assertEqual(created.responsible, "Клиент")
# After creation, cookie is switched to VIEW_REQUEST for this track.
read = self.client.get(f"/api/public/requests/{body['track_number']}")
self.assertEqual(read.status_code, 200)
self.assertEqual(read.json()["track_number"], body["track_number"])
def test_view_request_requires_view_otp_and_uses_track_cookie(self):
with self.SessionLocal() as db:
row = Request(
track_number="TRK-VIEW-OTP",
client_name="Клиент",
client_phone="+79991112233",
topic_code="consulting",
status_code="NEW",
description="Проверка просмотра",
extra_fields={},
)
db.add(row)
db.commit()
no_session = self.client.get("/api/public/requests/TRK-VIEW-OTP")
self.assertEqual(no_session.status_code, 401)
with patch("app.api.public.otp._generate_code", return_value="654321"):
sent = self.client.post(
"/api/public/otp/send",
json={"purpose": "VIEW_REQUEST", "track_number": "TRK-VIEW-OTP"},
)
self.assertEqual(sent.status_code, 200)
self.assertEqual(sent.json()["status"], "sent")
wrong_code = self.client.post(
"/api/public/otp/verify",
json={"purpose": "VIEW_REQUEST", "track_number": "TRK-VIEW-OTP", "code": "000000"},
)
self.assertEqual(wrong_code.status_code, 400)
verified = self.client.post(
"/api/public/otp/verify",
json={"purpose": "VIEW_REQUEST", "track_number": "TRK-VIEW-OTP", "code": "654321"},
)
self.assertEqual(verified.status_code, 200)
ok = self.client.get("/api/public/requests/TRK-VIEW-OTP")
self.assertEqual(ok.status_code, 200)
self.assertEqual(ok.json()["track_number"], "TRK-VIEW-OTP")
denied_other_track = self.client.get("/api/public/requests/TRK-OTHER")
self.assertEqual(denied_other_track.status_code, 403)
def test_open_request_marks_client_updates_as_read(self):
with self.SessionLocal() as db:
row = Request(
track_number="TRK-READ-1",
client_name="Клиент",
client_phone="+79995550011",
topic_code="consulting",
status_code="IN_PROGRESS",
description="Проверка чтения",
extra_fields={},
client_has_unread_updates=True,
client_unread_event_type="STATUS",
)
db.add(row)
db.commit()
request_id = row.id
public_token = create_jwt({"sub": "TRK-READ-1", "purpose": "VIEW_REQUEST"}, settings.PUBLIC_JWT_SECRET, timedelta(days=1))
cookies = {settings.PUBLIC_COOKIE_NAME: public_token}
opened = self.client.get("/api/public/requests/TRK-READ-1", cookies=cookies)
self.assertEqual(opened.status_code, 200)
body = opened.json()
self.assertFalse(body["client_has_unread_updates"])
self.assertIsNone(body["client_unread_event_type"])
with self.SessionLocal() as db:
refreshed = db.get(Request, request_id)
self.assertIsNotNone(refreshed)
self.assertFalse(refreshed.client_has_unread_updates)
self.assertIsNone(refreshed.client_unread_event_type)
def test_create_request_checks_required_topic_fields(self):
phone = "+79990000005"
self._send_and_verify_create_otp(phone)
with self.SessionLocal() as db:
db.add(
TopicRequiredField(
topic_code="consulting",
field_key="passport_series",
required=True,
enabled=True,
sort_order=1,
responsible="root@example.com",
)
)
db.commit()
missing = self.client.post(
"/api/public/requests",
json={
"client_name": "ООО Поле",
"client_phone": phone,
"topic_code": "consulting",
"description": "Проверка обязательного поля",
"extra_fields": {},
},
)
self.assertEqual(missing.status_code, 400)
self.assertIn("passport_series", missing.json().get("detail", ""))
created = self.client.post(
"/api/public/requests",
json={
"client_name": "ООО Поле",
"client_phone": phone,
"topic_code": "consulting",
"description": "Проверка обязательного поля",
"extra_fields": {"passport_series": "1234"},
},
)
self.assertEqual(created.status_code, 201)
self.assertTrue(created.json()["track_number"].startswith("TRK-"))

77
tests/test_quotes_seed.py Normal file
View file

@ -0,0 +1,77 @@
import os
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
# Ensure settings can be initialized in test environments
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0")
os.environ.setdefault("S3_ENDPOINT", "http://localhost:9000")
os.environ.setdefault("S3_ACCESS_KEY", "test")
os.environ.setdefault("S3_SECRET_KEY", "test")
os.environ.setdefault("S3_BUCKET", "test")
from app.data.quotes_justice_seed import JUSTICE_QUOTES
from app.models.quote import Quote
from app.scripts.upsert_quotes import upsert_quotes
class QuotesSeedTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
Quote.__table__.create(bind=cls.engine)
@classmethod
def tearDownClass(cls):
Quote.__table__.drop(bind=cls.engine)
cls.engine.dispose()
def setUp(self):
self.db = self.SessionLocal()
self.db.query(Quote).delete()
self.db.commit()
def tearDown(self):
self.db.close()
def test_upsert_creates_all_50_quotes_and_is_idempotent(self):
created, updated = upsert_quotes(self.db, JUSTICE_QUOTES)
self.assertEqual(created, 50)
self.assertEqual(updated, 0)
self.assertEqual(self.db.query(Quote).count(), 50)
created2, updated2 = upsert_quotes(self.db, JUSTICE_QUOTES)
self.assertEqual(created2, 0)
self.assertEqual(updated2, 0)
self.assertEqual(self.db.query(Quote).count(), 50)
def test_upsert_updates_existing_quote(self):
base = JUSTICE_QUOTES[0]
self.db.add(
Quote(
author=base["author"],
text=base["text"],
source="wrong",
is_active=False,
sort_order=999,
)
)
self.db.commit()
created, updated = upsert_quotes(self.db, [base])
self.assertEqual(created, 0)
self.assertEqual(updated, 1)
row = self.db.query(Quote).filter(Quote.author == base["author"], Quote.text == base["text"]).first()
self.assertIsNotNone(row)
self.assertEqual(row.source, base.get("source"))
self.assertTrue(row.is_active)
self.assertEqual(row.sort_order, 1)

645
tests/test_uploads_s3.py Normal file
View file

@ -0,0 +1,645 @@
import os
import unittest
from datetime import timedelta
from uuid import UUID, uuid4
from unittest.mock import patch
from botocore.exceptions import ClientError
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, delete
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0")
os.environ.setdefault("S3_ENDPOINT", "http://localhost:9000")
os.environ.setdefault("S3_ACCESS_KEY", "test")
os.environ.setdefault("S3_SECRET_KEY", "test")
os.environ.setdefault("S3_BUCKET", "test")
from app.core.config import settings
from app.core.security import create_jwt
from app.db.session import get_db
from app.main import app
from app.models.admin_user import AdminUser
from app.models.attachment import Attachment
from app.models.message import Message
from app.models.notification import Notification
from app.models.request import Request
class _FakeBody:
def __init__(self, payload: bytes):
self.payload = payload
def iter_chunks(self, chunk_size=65536):
for i in range(0, len(self.payload), chunk_size):
yield self.payload[i : i + chunk_size]
class _FakeS3Storage:
def __init__(self):
self.objects = {}
def create_presigned_put_url(self, key: str, mime_type: str, expires_sec: int = 900) -> str:
return f"https://s3.local/{key}?expires={expires_sec}"
def head_object(self, key: str) -> dict:
obj = self.objects.get(key)
if obj is None:
raise ClientError({"Error": {"Code": "404", "Message": "Not Found"}}, "HeadObject")
return {"ContentLength": obj["size"], "ContentType": obj["mime"]}
def get_object(self, key: str) -> dict:
obj = self.objects.get(key)
if obj is None:
raise ClientError({"Error": {"Code": "404", "Message": "Not Found"}}, "GetObject")
return {"Body": _FakeBody(obj["content"]), "ContentType": obj["mime"], "ContentLength": obj["size"]}
class UploadsS3Tests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
AdminUser.__table__.create(bind=cls.engine)
Request.__table__.create(bind=cls.engine)
Notification.__table__.create(bind=cls.engine)
Message.__table__.create(bind=cls.engine)
Attachment.__table__.create(bind=cls.engine)
@classmethod
def tearDownClass(cls):
Attachment.__table__.drop(bind=cls.engine)
Message.__table__.drop(bind=cls.engine)
Notification.__table__.drop(bind=cls.engine)
Request.__table__.drop(bind=cls.engine)
AdminUser.__table__.drop(bind=cls.engine)
cls.engine.dispose()
def setUp(self):
with self.SessionLocal() as db:
db.execute(delete(Notification))
db.execute(delete(Attachment))
db.execute(delete(Message))
db.execute(delete(Request))
db.execute(delete(AdminUser))
db.commit()
def override_get_db():
db = self.SessionLocal()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
self.client = TestClient(app)
def tearDown(self):
self.client.close()
app.dependency_overrides.clear()
@staticmethod
def _admin_headers(sub: str, role: str = "ADMIN", email: str = "admin@example.com") -> dict[str, str]:
token = create_jwt(
{"sub": sub, "email": email, "role": role},
settings.ADMIN_JWT_SECRET,
timedelta(minutes=30),
)
return {"Authorization": f"Bearer {token}"}
def test_admin_avatar_upload_flow_updates_user_avatar_key(self):
fake_s3 = _FakeS3Storage()
with self.SessionLocal() as db:
user = AdminUser(
role="LAWYER",
name="Юрист Аватар",
email="avatar@example.com",
password_hash="hash",
is_active=True,
)
db.add(user)
db.commit()
user_id = str(user.id)
headers = self._admin_headers(sub=user_id, role="LAWYER", email="avatar@example.com")
with patch("app.api.admin.uploads.get_s3_storage", return_value=fake_s3):
init_resp = self.client.post(
"/api/admin/uploads/init",
headers=headers,
json={
"file_name": "photo.png",
"mime_type": "image/png",
"size_bytes": 2048,
"scope": "USER_AVATAR",
"user_id": user_id,
},
)
self.assertEqual(init_resp.status_code, 200)
key = init_resp.json()["key"]
self.assertTrue(key.startswith("avatars/"))
fake_s3.objects[key] = {"size": 2048, "mime": "image/png", "content": b"x" * 2048}
done_resp = self.client.post(
"/api/admin/uploads/complete",
headers=headers,
json={
"key": key,
"file_name": "photo.png",
"mime_type": "image/png",
"size_bytes": 2048,
"scope": "USER_AVATAR",
"user_id": user_id,
},
)
self.assertEqual(done_resp.status_code, 200)
self.assertEqual(done_resp.json()["avatar_url"], f"s3://{key}")
token = headers["Authorization"].replace("Bearer ", "")
view_resp = self.client.get(f"/api/admin/uploads/object/{key}?token={token}")
self.assertEqual(view_resp.status_code, 200)
self.assertEqual(view_resp.content, b"x" * 2048)
with self.SessionLocal() as db:
refreshed = db.get(AdminUser, UUID(user_id))
self.assertIsNotNone(refreshed)
self.assertEqual(refreshed.avatar_url, f"s3://{key}")
def test_public_request_attachment_upload_flow_creates_attachment(self):
fake_s3 = _FakeS3Storage()
with self.SessionLocal() as db:
req = Request(
track_number="TRK-PUB-UPLOAD",
client_name="Клиент",
client_phone="+79991112233",
topic_code="civil-law",
status_code="NEW",
extra_fields={},
total_attachments_bytes=0,
)
db.add(req)
db.commit()
request_id = str(req.id)
track = req.track_number
public_token = create_jwt({"sub": track, "purpose": "VIEW_REQUEST"}, settings.PUBLIC_JWT_SECRET, timedelta(days=1))
cookies = {settings.PUBLIC_COOKIE_NAME: public_token}
with patch("app.api.public.uploads.get_s3_storage", return_value=fake_s3):
init_resp = self.client.post(
"/api/public/uploads/init",
cookies=cookies,
json={
"file_name": "contract.pdf",
"mime_type": "application/pdf",
"size_bytes": 4096,
"scope": "REQUEST_ATTACHMENT",
"request_id": request_id,
},
)
self.assertEqual(init_resp.status_code, 200)
key = init_resp.json()["key"]
self.assertTrue(key.startswith("requests/"))
fake_s3.objects[key] = {"size": 4096, "mime": "application/pdf", "content": b"p" * 4096}
done_resp = self.client.post(
"/api/public/uploads/complete",
cookies=cookies,
json={
"key": key,
"file_name": "contract.pdf",
"mime_type": "application/pdf",
"size_bytes": 4096,
"scope": "REQUEST_ATTACHMENT",
"request_id": request_id,
},
)
self.assertEqual(done_resp.status_code, 200)
self.assertTrue(done_resp.json().get("attachment_id"))
with self.SessionLocal() as db:
req = db.get(Request, UUID(request_id))
self.assertIsNotNone(req)
self.assertEqual(req.total_attachments_bytes, 4096)
self.assertTrue(req.lawyer_has_unread_updates)
self.assertEqual(req.lawyer_unread_event_type, "ATTACHMENT")
rows = db.query(Attachment).filter(Attachment.request_id == UUID(request_id)).all()
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0].s3_key, key)
def test_admin_request_attachment_upload_sets_client_unread_marker(self):
fake_s3 = _FakeS3Storage()
with self.SessionLocal() as db:
lawyer = AdminUser(
role="LAWYER",
name="Юрист Загрузка",
email="lawyer-upload@example.com",
password_hash="hash",
is_active=True,
)
db.add(lawyer)
db.flush()
req = Request(
track_number="TRK-ADM-UPLOAD",
client_name="Клиент",
client_phone="+79995554433",
topic_code="civil-law",
status_code="IN_PROGRESS",
extra_fields={},
assigned_lawyer_id=str(lawyer.id),
total_attachments_bytes=0,
)
db.add(req)
db.commit()
request_id = str(req.id)
lawyer_id = str(lawyer.id)
headers = self._admin_headers(sub=lawyer_id, role="LAWYER", email="lawyer-upload@example.com")
with patch("app.api.admin.uploads.get_s3_storage", return_value=fake_s3):
init_resp = self.client.post(
"/api/admin/uploads/init",
headers=headers,
json={
"file_name": "evidence.pdf",
"mime_type": "application/pdf",
"size_bytes": 2048,
"scope": "REQUEST_ATTACHMENT",
"request_id": request_id,
},
)
self.assertEqual(init_resp.status_code, 200)
key = init_resp.json()["key"]
fake_s3.objects[key] = {"size": 2048, "mime": "application/pdf", "content": b"x" * 2048}
done_resp = self.client.post(
"/api/admin/uploads/complete",
headers=headers,
json={
"key": key,
"file_name": "evidence.pdf",
"mime_type": "application/pdf",
"size_bytes": 2048,
"scope": "REQUEST_ATTACHMENT",
"request_id": request_id,
},
)
self.assertEqual(done_resp.status_code, 200)
with self.SessionLocal() as db:
req = db.get(Request, UUID(request_id))
self.assertIsNotNone(req)
self.assertEqual(req.total_attachments_bytes, 2048)
self.assertTrue(req.client_has_unread_updates)
self.assertEqual(req.client_unread_event_type, "ATTACHMENT")
def test_admin_upload_rejects_attachment_for_immutable_message(self):
fake_s3 = _FakeS3Storage()
with self.SessionLocal() as db:
lawyer = AdminUser(
role="LAWYER",
name="Юрист Иммутабельный",
email="lawyer-immutable@example.com",
password_hash="hash",
is_active=True,
)
db.add(lawyer)
db.flush()
req = Request(
track_number="TRK-ADM-IMM-MSG",
client_name="Клиент",
client_phone="+79995554434",
topic_code="civil-law",
status_code="IN_PROGRESS",
extra_fields={},
assigned_lawyer_id=str(lawyer.id),
total_attachments_bytes=0,
)
db.add(req)
db.flush()
msg = Message(
request_id=req.id,
author_type="CLIENT",
author_name="Клиент",
body="Старое сообщение",
immutable=True,
)
db.add(msg)
db.commit()
request_id = str(req.id)
message_id = str(msg.id)
lawyer_id = str(lawyer.id)
headers = self._admin_headers(sub=lawyer_id, role="LAWYER", email="lawyer-immutable@example.com")
with patch("app.api.admin.uploads.get_s3_storage", return_value=fake_s3):
init_resp = self.client.post(
"/api/admin/uploads/init",
headers=headers,
json={
"file_name": "appendix.pdf",
"mime_type": "application/pdf",
"size_bytes": 1024,
"scope": "REQUEST_ATTACHMENT",
"request_id": request_id,
},
)
self.assertEqual(init_resp.status_code, 200)
key = init_resp.json()["key"]
fake_s3.objects[key] = {"size": 1024, "mime": "application/pdf", "content": b"x" * 1024}
blocked = self.client.post(
"/api/admin/uploads/complete",
headers=headers,
json={
"key": key,
"file_name": "appendix.pdf",
"mime_type": "application/pdf",
"size_bytes": 1024,
"scope": "REQUEST_ATTACHMENT",
"request_id": request_id,
"message_id": message_id,
},
)
self.assertEqual(blocked.status_code, 400)
self.assertIn("зафиксированному", blocked.json().get("detail", ""))
def test_public_upload_rejects_file_over_limit_on_init(self):
with self.SessionLocal() as db:
req = Request(
track_number="TRK-PUB-LIMIT-FILE",
client_name="Клиент",
client_phone="+79990001111",
topic_code="civil-law",
status_code="NEW",
extra_fields={},
total_attachments_bytes=0,
)
db.add(req)
db.commit()
request_id = str(req.id)
track = req.track_number
public_token = create_jwt({"sub": track, "purpose": "VIEW_REQUEST"}, settings.PUBLIC_JWT_SECRET, timedelta(days=1))
cookies = {settings.PUBLIC_COOKIE_NAME: public_token}
with patch("app.api.public.uploads.settings.MAX_FILE_MB", 1):
response = self.client.post(
"/api/public/uploads/init",
cookies=cookies,
json={
"file_name": "big.mp4",
"mime_type": "video/mp4",
"size_bytes": 2 * 1024 * 1024,
"scope": "REQUEST_ATTACHMENT",
"request_id": request_id,
},
)
self.assertEqual(response.status_code, 400)
self.assertIn("лимит файла", response.json().get("detail", ""))
def test_public_upload_rejects_case_limit_on_complete(self):
fake_s3 = _FakeS3Storage()
with self.SessionLocal() as db:
req = Request(
track_number="TRK-PUB-LIMIT-CASE",
client_name="Клиент",
client_phone="+79990002222",
topic_code="civil-law",
status_code="NEW",
extra_fields={},
total_attachments_bytes=(1024 * 1024) - 512,
)
db.add(req)
db.commit()
request_id = str(req.id)
track = req.track_number
public_token = create_jwt({"sub": track, "purpose": "VIEW_REQUEST"}, settings.PUBLIC_JWT_SECRET, timedelta(days=1))
cookies = {settings.PUBLIC_COOKIE_NAME: public_token}
with (
patch("app.api.public.uploads.get_s3_storage", return_value=fake_s3),
patch("app.api.public.uploads.settings.MAX_CASE_MB", 1),
patch("app.api.public.uploads.settings.MAX_FILE_MB", 5),
):
init_resp = self.client.post(
"/api/public/uploads/init",
cookies=cookies,
json={
"file_name": "edge.pdf",
"mime_type": "application/pdf",
"size_bytes": 256,
"scope": "REQUEST_ATTACHMENT",
"request_id": request_id,
},
)
self.assertEqual(init_resp.status_code, 200)
key = init_resp.json()["key"]
fake_s3.objects[key] = {"size": 1024, "mime": "application/pdf", "content": b"x" * 1024}
done_resp = self.client.post(
"/api/public/uploads/complete",
cookies=cookies,
json={
"key": key,
"file_name": "edge.pdf",
"mime_type": "application/pdf",
"size_bytes": 256,
"scope": "REQUEST_ATTACHMENT",
"request_id": request_id,
},
)
self.assertEqual(done_resp.status_code, 400)
self.assertIn("лимит вложений заявки", done_resp.json().get("detail", ""))
def test_public_upload_rejects_foreign_object_key(self):
fake_s3 = _FakeS3Storage()
with self.SessionLocal() as db:
req = Request(
track_number="TRK-PUB-KEY",
client_name="Клиент",
client_phone="+79990003333",
topic_code="civil-law",
status_code="NEW",
extra_fields={},
total_attachments_bytes=0,
)
db.add(req)
db.commit()
request_id = str(req.id)
track = req.track_number
public_token = create_jwt({"sub": track, "purpose": "VIEW_REQUEST"}, settings.PUBLIC_JWT_SECRET, timedelta(days=1))
cookies = {settings.PUBLIC_COOKIE_NAME: public_token}
foreign_key = f"requests/{uuid4()}/foreign.pdf"
fake_s3.objects[foreign_key] = {"size": 1024, "mime": "application/pdf", "content": b"x" * 1024}
with patch("app.api.public.uploads.get_s3_storage", return_value=fake_s3):
done_resp = self.client.post(
"/api/public/uploads/complete",
cookies=cookies,
json={
"key": foreign_key,
"file_name": "foreign.pdf",
"mime_type": "application/pdf",
"size_bytes": 1024,
"scope": "REQUEST_ATTACHMENT",
"request_id": request_id,
},
)
self.assertEqual(done_resp.status_code, 400)
self.assertIn("Некорректный ключ объекта", done_resp.json().get("detail", ""))
def test_admin_upload_rejects_file_over_limit_on_complete(self):
fake_s3 = _FakeS3Storage()
with self.SessionLocal() as db:
admin = AdminUser(
role="ADMIN",
name="Админ Ограничений",
email="admin-limits@example.com",
password_hash="hash",
is_active=True,
)
req = Request(
track_number="TRK-ADM-LIMIT-FILE",
client_name="Клиент",
client_phone="+79990004444",
topic_code="civil-law",
status_code="NEW",
extra_fields={},
total_attachments_bytes=0,
)
db.add_all([admin, req])
db.commit()
admin_id = str(admin.id)
request_id = str(req.id)
headers = self._admin_headers(sub=admin_id, role="ADMIN", email="admin-limits@example.com")
with (
patch("app.api.admin.uploads.get_s3_storage", return_value=fake_s3),
patch("app.api.admin.uploads.settings.MAX_FILE_MB", 1),
):
init_resp = self.client.post(
"/api/admin/uploads/init",
headers=headers,
json={
"file_name": "proof.mp4",
"mime_type": "video/mp4",
"size_bytes": 1024,
"scope": "REQUEST_ATTACHMENT",
"request_id": request_id,
},
)
self.assertEqual(init_resp.status_code, 200)
key = init_resp.json()["key"]
fake_s3.objects[key] = {"size": 2 * 1024 * 1024, "mime": "video/mp4", "content": b"x" * 1024}
done_resp = self.client.post(
"/api/admin/uploads/complete",
headers=headers,
json={
"key": key,
"file_name": "proof.mp4",
"mime_type": "video/mp4",
"size_bytes": 1024,
"scope": "REQUEST_ATTACHMENT",
"request_id": request_id,
},
)
self.assertEqual(done_resp.status_code, 400)
self.assertIn("лимит файла", done_resp.json().get("detail", ""))
def test_admin_upload_rejects_foreign_object_key(self):
fake_s3 = _FakeS3Storage()
with self.SessionLocal() as db:
admin = AdminUser(
role="ADMIN",
name="Админ Ключей",
email="admin-keys@example.com",
password_hash="hash",
is_active=True,
)
req = Request(
track_number="TRK-ADM-KEY",
client_name="Клиент",
client_phone="+79990005555",
topic_code="civil-law",
status_code="NEW",
extra_fields={},
total_attachments_bytes=0,
)
db.add_all([admin, req])
db.commit()
admin_id = str(admin.id)
request_id = str(req.id)
headers = self._admin_headers(sub=admin_id, role="ADMIN", email="admin-keys@example.com")
foreign_key = f"requests/{uuid4()}/another.pdf"
fake_s3.objects[foreign_key] = {"size": 2048, "mime": "application/pdf", "content": b"x" * 2048}
with patch("app.api.admin.uploads.get_s3_storage", return_value=fake_s3):
done_resp = self.client.post(
"/api/admin/uploads/complete",
headers=headers,
json={
"key": foreign_key,
"file_name": "another.pdf",
"mime_type": "application/pdf",
"size_bytes": 2048,
"scope": "REQUEST_ATTACHMENT",
"request_id": request_id,
},
)
self.assertEqual(done_resp.status_code, 400)
self.assertIn("Некорректный ключ объекта", done_resp.json().get("detail", ""))
def test_admin_object_proxy_blocks_lawyer_for_foreign_assigned_request(self):
fake_s3 = _FakeS3Storage()
with self.SessionLocal() as db:
lawyer_a = AdminUser(
role="LAWYER",
name="Юрист А",
email="lawyer-a@example.com",
password_hash="hash",
is_active=True,
)
lawyer_b = AdminUser(
role="LAWYER",
name="Юрист Б",
email="lawyer-b@example.com",
password_hash="hash",
is_active=True,
)
db.add_all([lawyer_a, lawyer_b])
db.flush()
req = Request(
track_number="TRK-ADM-PROXY-LOCK",
client_name="Клиент",
client_phone="+79990006666",
topic_code="civil-law",
status_code="IN_PROGRESS",
assigned_lawyer_id=str(lawyer_b.id),
extra_fields={},
total_attachments_bytes=0,
)
db.add(req)
db.flush()
key = f"requests/{req.id}/proof.pdf"
att = Attachment(
request_id=req.id,
file_name="proof.pdf",
mime_type="application/pdf",
size_bytes=1024,
s3_key=key,
)
db.add(att)
db.commit()
lawyer_a_id = str(lawyer_a.id)
token = self._admin_headers(sub=lawyer_a_id, role="LAWYER", email="lawyer-a@example.com")["Authorization"].replace("Bearer ", "")
fake_s3.objects[key] = {"size": 1024, "mime": "application/pdf", "content": b"x" * 1024}
with patch("app.api.admin.uploads.get_s3_storage", return_value=fake_s3):
response = self.client.get(f"/api/admin/uploads/object/{key}?token={token}")
self.assertEqual(response.status_code, 403)

View file

@ -0,0 +1,289 @@
import os
import unittest
from datetime import datetime, timedelta, timezone
from uuid import uuid4
from sqlalchemy import create_engine, delete
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0")
os.environ.setdefault("S3_ENDPOINT", "http://localhost:9000")
os.environ.setdefault("S3_ACCESS_KEY", "test")
os.environ.setdefault("S3_SECRET_KEY", "test")
os.environ.setdefault("S3_BUCKET", "test")
from app.models.attachment import Attachment
from app.models.message import Message
from app.models.notification import Notification
from app.models.otp_session import OtpSession
from app.models.request import Request
from app.models.status import Status
from app.models.status_history import StatusHistory
from app.models.topic_status_transition import TopicStatusTransition
from app.workers.tasks import security as security_task
from app.workers.tasks import sla as sla_task
from app.workers.tasks import uploads as uploads_task
class WorkerMaintenanceTaskTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
OtpSession.__table__.create(bind=cls.engine)
Request.__table__.create(bind=cls.engine)
Attachment.__table__.create(bind=cls.engine)
Status.__table__.create(bind=cls.engine)
Message.__table__.create(bind=cls.engine)
StatusHistory.__table__.create(bind=cls.engine)
TopicStatusTransition.__table__.create(bind=cls.engine)
Notification.__table__.create(bind=cls.engine)
cls._old_security_session_local = security_task.SessionLocal
cls._old_uploads_session_local = uploads_task.SessionLocal
cls._old_sla_session_local = sla_task.SessionLocal
security_task.SessionLocal = cls.SessionLocal
uploads_task.SessionLocal = cls.SessionLocal
sla_task.SessionLocal = cls.SessionLocal
@classmethod
def tearDownClass(cls):
security_task.SessionLocal = cls._old_security_session_local
uploads_task.SessionLocal = cls._old_uploads_session_local
sla_task.SessionLocal = cls._old_sla_session_local
StatusHistory.__table__.drop(bind=cls.engine)
Notification.__table__.drop(bind=cls.engine)
TopicStatusTransition.__table__.drop(bind=cls.engine)
Message.__table__.drop(bind=cls.engine)
Status.__table__.drop(bind=cls.engine)
Attachment.__table__.drop(bind=cls.engine)
Request.__table__.drop(bind=cls.engine)
OtpSession.__table__.drop(bind=cls.engine)
cls.engine.dispose()
def setUp(self):
with self.SessionLocal() as db:
db.execute(delete(StatusHistory))
db.execute(delete(Message))
db.execute(delete(Status))
db.execute(delete(TopicStatusTransition))
db.execute(delete(Notification))
db.execute(delete(Attachment))
db.execute(delete(Request))
db.execute(delete(OtpSession))
db.commit()
def test_cleanup_expired_otps_deletes_only_expired_rows(self):
now = datetime.now(timezone.utc)
with self.SessionLocal() as db:
db.add_all(
[
OtpSession(
purpose="VIEW_REQUEST",
track_number="TRK-EXP",
phone="+79990000001",
code_hash="hash-exp",
attempts=0,
expires_at=now - timedelta(minutes=1),
),
OtpSession(
purpose="VIEW_REQUEST",
track_number="TRK-ACT",
phone="+79990000002",
code_hash="hash-act",
attempts=0,
expires_at=now + timedelta(minutes=10),
),
]
)
db.commit()
result = security_task.cleanup_expired_otps()
self.assertEqual(result["checked"], 2)
self.assertEqual(result["deleted"], 1)
with self.SessionLocal() as db:
remaining = db.query(OtpSession).all()
self.assertEqual(len(remaining), 1)
self.assertEqual(remaining[0].track_number, "TRK-ACT")
def test_cleanup_stale_uploads_removes_invalid_and_fixes_totals(self):
with self.SessionLocal() as db:
req1 = Request(
track_number="TRK-UP-1",
client_name="Клиент 1",
client_phone="+79990001001",
topic_code="civil",
status_code="NEW",
extra_fields={},
total_attachments_bytes=999,
)
req2 = Request(
track_number="TRK-UP-2",
client_name="Клиент 2",
client_phone="+79990001002",
topic_code="civil",
status_code="NEW",
extra_fields={},
total_attachments_bytes=0,
)
db.add_all([req1, req2])
db.flush()
db.add_all(
[
Attachment(request_id=req1.id, message_id=None, file_name="a.pdf", mime_type="application/pdf", size_bytes=100, s3_key="k1"),
Attachment(request_id=req1.id, message_id=None, file_name="b.pdf", mime_type="application/pdf", size_bytes=200, s3_key="k2"),
Attachment(request_id=req1.id, message_id=None, file_name="bad-size.pdf", mime_type="application/pdf", size_bytes=0, s3_key="k3"),
Attachment(request_id=req1.id, message_id=None, file_name="bad-key.pdf", mime_type="application/pdf", size_bytes=20, s3_key=""),
Attachment(request_id=uuid4(), message_id=None, file_name="orphan.pdf", mime_type="application/pdf", size_bytes=50, s3_key="orphan"),
Attachment(request_id=req2.id, message_id=None, file_name="c.pdf", mime_type="application/pdf", size_bytes=70, s3_key="k4"),
]
)
db.commit()
req1_id = req1.id
req2_id = req2.id
result = uploads_task.cleanup_stale_uploads()
self.assertEqual(result["deleted_orphan_attachments"], 1)
self.assertEqual(result["deleted_invalid_attachments"], 2)
self.assertEqual(result["fixed_requests"], 2)
with self.SessionLocal() as db:
req1 = db.get(Request, req1_id)
req2 = db.get(Request, req2_id)
self.assertIsNotNone(req1)
self.assertIsNotNone(req2)
self.assertEqual(req1.total_attachments_bytes, 300)
self.assertEqual(req2.total_attachments_bytes, 70)
all_attachments = db.query(Attachment).all()
self.assertEqual(len(all_attachments), 3)
def test_sla_check_computes_overdue_and_frt(self):
now = datetime.now(timezone.utc)
with self.SessionLocal() as db:
db.add_all(
[
Status(code="NEW", name="Новая", enabled=True, sort_order=0, is_terminal=False),
Status(code="IN_PROGRESS", name="В работе", enabled=True, sort_order=1, is_terminal=False),
Status(code="CLOSED", name="Закрыта", enabled=True, sort_order=2, is_terminal=True),
]
)
req_overdue = Request(
track_number="TRK-SLA-1",
client_name="Клиент SLA 1",
client_phone="+79990002001",
topic_code="civil",
status_code="NEW",
extra_fields={},
created_at=now - timedelta(hours=30),
updated_at=now - timedelta(hours=30),
)
req_ok = Request(
track_number="TRK-SLA-2",
client_name="Клиент SLA 2",
client_phone="+79990002002",
topic_code="civil",
status_code="NEW",
extra_fields={},
created_at=now - timedelta(hours=2),
updated_at=now - timedelta(hours=2),
)
req_closed = Request(
track_number="TRK-SLA-3",
client_name="Клиент SLA 3",
client_phone="+79990002003",
topic_code="civil",
status_code="CLOSED",
extra_fields={},
created_at=now - timedelta(hours=50),
updated_at=now - timedelta(hours=50),
)
db.add_all([req_overdue, req_ok, req_closed])
db.flush()
db.add(
Message(
request_id=req_overdue.id,
author_type="LAWYER",
author_name="Юрист",
body="Первый ответ",
created_at=req_overdue.created_at + timedelta(minutes=30),
updated_at=req_overdue.created_at + timedelta(minutes=30),
)
)
db.add_all(
[
StatusHistory(
request_id=req_overdue.id,
from_status=None,
to_status="NEW",
changed_by_admin_id=None,
created_at=now - timedelta(hours=30),
updated_at=now - timedelta(hours=30),
),
StatusHistory(
request_id=req_overdue.id,
from_status="NEW",
to_status="IN_PROGRESS",
changed_by_admin_id=None,
created_at=now - timedelta(hours=10),
updated_at=now - timedelta(hours=10),
),
]
)
db.commit()
result = sla_task.sla_check()
self.assertEqual(result["checked_active_requests"], 2)
self.assertGreaterEqual(result["overdue_total"], 1)
self.assertGreaterEqual(result["overdue_by_status"].get("NEW", 0), 1)
self.assertIsNotNone(result["frt_avg_minutes"])
self.assertAlmostEqual(result["frt_avg_minutes"], 30.0, places=1)
self.assertIn("NEW", result["avg_time_in_status_hours"])
def test_sla_check_uses_topic_transition_sla_for_active_status(self):
now = datetime.now(timezone.utc)
with self.SessionLocal() as db:
db.add_all(
[
Status(code="NEW", name="Новая", enabled=True, sort_order=0, is_terminal=False),
Status(code="IN_PROGRESS", name="В работе", enabled=True, sort_order=1, is_terminal=False),
]
)
db.add(
TopicStatusTransition(
topic_code="civil",
from_status="NEW",
to_status="IN_PROGRESS",
enabled=True,
sla_hours=1,
sort_order=1,
)
)
req = Request(
track_number="TRK-SLA-T-1",
client_name="Клиент SLA T",
client_phone="+79990002101",
topic_code="civil",
status_code="NEW",
extra_fields={},
created_at=now - timedelta(hours=2),
updated_at=now - timedelta(hours=2),
)
db.add(req)
db.commit()
result = sla_task.sla_check()
self.assertEqual(result["checked_active_requests"], 1)
self.assertEqual(result["overdue_total"], 1)
self.assertGreaterEqual(result["overdue_by_status"].get("NEW", 0), 1)
self.assertGreaterEqual(result["overdue_by_transition"].get("civil:NEW->*", 0), 1)

2100
tmp/admin.bundle.js Normal file

File diff suppressed because it is too large Load diff