Test-5 commit

This commit is contained in:
TronoSfera 2026-02-25 22:14:34 +03:00
parent 7754a6fedf
commit 4d87cefcee
16 changed files with 1520 additions and 187 deletions

View file

@ -0,0 +1,136 @@
"""add status groups dictionary and status.status_group_id
Revision ID: 0018_status_groups
Revises: 0017_transition_requirements
Create Date: 2026-02-25 20:05:00.000000
"""
from __future__ import annotations
import uuid
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = "0018_status_groups"
down_revision = "0017_transition_requirements"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"status_groups",
sa.Column("name", sa.String(length=200), nullable=False),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.Column("responsible", sa.String(length=200), nullable=False, server_default="Администратор системы"),
)
op.create_index("ix_status_groups_name", "status_groups", ["name"], unique=True)
op.add_column("statuses", sa.Column("status_group_id", postgresql.UUID(as_uuid=True), nullable=True))
op.create_index("ix_statuses_status_group_id", "statuses", ["status_group_id"])
conn = op.get_bind()
groups = [
("Новые", 10),
("В работе", 20),
("Ожидание", 30),
("Завершены", 40),
]
group_ids: dict[str, str] = {}
for name, sort_order in groups:
group_id = str(uuid.uuid4())
group_ids[name] = group_id
conn.execute(
sa.text(
"""
INSERT INTO status_groups (id, name, sort_order, responsible)
VALUES (:id, :name, :sort_order, :responsible)
"""
),
{
"id": group_id,
"name": name,
"sort_order": sort_order,
"responsible": "Администратор системы",
},
)
conn.execute(
sa.text(
"""
UPDATE statuses
SET status_group_id = :group_done
WHERE
COALESCE(is_terminal, false) = true
OR UPPER(COALESCE(kind, 'DEFAULT')) = 'PAID'
OR UPPER(COALESCE(code, '')) LIKE '%CLOSE%'
OR UPPER(COALESCE(code, '')) LIKE '%RESOLV%'
OR UPPER(COALESCE(code, '')) LIKE '%REJECT%'
OR UPPER(COALESCE(code, '')) LIKE '%DONE%'
OR UPPER(COALESCE(code, '')) LIKE '%PAID%'
"""
),
{"group_done": group_ids["Завершены"]},
)
conn.execute(
sa.text(
"""
UPDATE statuses
SET status_group_id = :group_waiting
WHERE
status_group_id IS NULL
AND (
UPPER(COALESCE(kind, 'DEFAULT')) = 'INVOICE'
OR UPPER(COALESCE(code, '')) LIKE '%WAIT%'
OR UPPER(COALESCE(code, '')) LIKE '%PEND%'
OR UPPER(COALESCE(code, '')) LIKE '%HOLD%'
OR UPPER(COALESCE(code, '')) LIKE '%SUSPEND%'
OR UPPER(COALESCE(code, '')) LIKE '%BLOCK%'
)
"""
),
{"group_waiting": group_ids["Ожидание"]},
)
conn.execute(
sa.text(
"""
UPDATE statuses
SET status_group_id = :group_new
WHERE
status_group_id IS NULL
AND (
UPPER(COALESCE(code, '')) LIKE 'NEW%'
OR UPPER(COALESCE(code, '')) LIKE '%_NEW'
)
"""
),
{"group_new": group_ids["Новые"]},
)
conn.execute(
sa.text(
"""
UPDATE statuses
SET status_group_id = :group_in_progress
WHERE status_group_id IS NULL
"""
),
{"group_in_progress": group_ids["В работе"]},
)
op.alter_column("status_groups", "sort_order", server_default=None)
op.alter_column("status_groups", "created_at", server_default=None)
op.alter_column("status_groups", "updated_at", server_default=None)
op.alter_column("status_groups", "responsible", server_default=None)
def downgrade() -> None:
op.drop_index("ix_statuses_status_group_id", table_name="statuses")
op.drop_column("statuses", "status_group_id")
op.drop_index("ix_status_groups_name", table_name="status_groups")
op.drop_table("status_groups")

View file

@ -7,6 +7,7 @@ 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.services.chat_service import create_admin_or_lawyer_message, list_messages_for_request, serialize_message
@ -75,12 +76,22 @@ def create_request_message(
body = str((payload or {}).get("body") or "").strip()
role = str(admin.get("role") or "").upper()
actor_name = str(admin.get("email") or "").strip() or ("Юрист" if role == "LAWYER" else "Администратор")
actor_admin_user_id = str(admin.get("sub") or "").strip() or None
if actor_admin_user_id:
try:
actor_uuid = UUID(actor_admin_user_id)
except ValueError:
actor_uuid = None
if actor_uuid is not None:
actor_user = db.get(AdminUser, actor_uuid)
if actor_user is not None:
actor_name = str(actor_user.name or actor_user.email or actor_name)
row = create_admin_or_lawyer_message(
db,
request=req,
body=body,
actor_role=role,
actor_name=actor_name,
actor_admin_user_id=str(admin.get("sub") or "").strip() or None,
actor_admin_user_id=actor_admin_user_id,
)
return serialize_message(row)

View file

@ -1,12 +1,14 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from uuid import UUID
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 TopicUpsert, StatusUpsert, FormFieldUpsert
from app.models.topic import Topic
from app.models.status import Status
from app.models.status_group import StatusGroup
from app.models.form_field import FormField
from app.services.universal_query import apply_universal_query
@ -22,6 +24,7 @@ def _status_row(row: Status):
"id": str(row.id),
"code": row.code,
"name": row.name,
"status_group_id": str(row.status_group_id) if row.status_group_id else None,
"enabled": row.enabled,
"sort_order": row.sort_order,
"is_terminal": row.is_terminal,
@ -100,8 +103,20 @@ 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"))):
data = payload.model_dump()
raw_group = data.get("status_group_id")
if raw_group:
try:
group_id = UUID(str(raw_group))
except ValueError:
raise HTTPException(status_code=400, detail="Некорректная группа статусов")
if db.get(StatusGroup, group_id) is None:
raise HTTPException(status_code=400, detail="Группа статусов не найдена")
data["status_group_id"] = group_id
else:
data["status_group_id"] = None
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
row = Status(**payload.model_dump(), responsible=responsible)
row = Status(**data, responsible=responsible)
try:
db.add(row)
db.commit()
@ -117,7 +132,19 @@ def update_status(id: str, payload: StatusUpsert, db: Session = Depends(get_db),
row = db.query(Status).filter(Status.id == id).first()
if not row:
raise HTTPException(status_code=404, detail="Статус не найден")
for k, v in payload.model_dump().items():
data = payload.model_dump()
raw_group = data.get("status_group_id")
if raw_group:
try:
group_id = UUID(str(raw_group))
except ValueError:
raise HTTPException(status_code=400, detail="Некорректная группа статусов")
if db.get(StatusGroup, group_id) is None:
raise HTTPException(status_code=400, detail="Группа статусов не найдена")
data["status_group_id"] = group_id
else:
data["status_group_id"] = None
for k, v in data.items():
setattr(row, k, v)
try:
db.add(row)

View file

@ -31,6 +31,7 @@ from app.models.attachment import Attachment
from app.models.message import Message
from app.models.request import Request
from app.models.status import Status
from app.models.status_group import StatusGroup
from app.models.topic_data_template import TopicDataTemplate
from app.models.topic_required_field import TopicRequiredField
from app.models.topic import Topic
@ -93,6 +94,7 @@ TABLE_ROLE_ACTIONS: dict[str, dict[str, set[str]]] = {
"quotes": {"ADMIN": set(CRUD_ACTIONS)},
"topics": {"ADMIN": set(CRUD_ACTIONS)},
"statuses": {"ADMIN": set(CRUD_ACTIONS)},
"status_groups": {"ADMIN": set(CRUD_ACTIONS)},
"form_fields": {"ADMIN": set(CRUD_ACTIONS)},
"clients": {"ADMIN": set(CRUD_ACTIONS)},
"table_availability": {"ADMIN": set(CRUD_ACTIONS)},
@ -257,6 +259,7 @@ def _table_label(table_name: str) -> str:
"quotes": "Цитаты",
"topics": "Темы",
"statuses": "Статусы",
"status_groups": "Группы статусов",
"form_fields": "Поля формы",
"clients": "Клиенты",
"table_availability": "Доступность таблиц",
@ -359,6 +362,7 @@ def _column_label(table_name: str, column_name: str) -> str:
"email": "Email",
"role": "Роль",
"kind": "Тип",
"status_group_id": "Группа",
"status": "Статус",
"status_code": "Статус",
"topic_code": "Тема",
@ -441,6 +445,126 @@ def _column_label(table_name: str, column_name: str) -> str:
return _humanize_identifier_ru(normalized_column)
def _pluralize_identifier(base: str) -> list[str]:
token = _normalize_table_name(base)
if not token:
return []
candidates = [token]
if token.endswith("y"):
candidates.append(token[:-1] + "ies")
candidates.append(token + "s")
return list(dict.fromkeys(candidates))
def _reference_override(table_name: str, column_name: str) -> tuple[str, str] | None:
normalized_table = _normalize_table_name(table_name)
normalized_column = _normalize_table_name(column_name)
explicit: dict[tuple[str, str], tuple[str, str]] = {
("requests", "assigned_lawyer_id"): ("admin_users", "id"),
("requests", "paid_by_admin_id"): ("admin_users", "id"),
("requests", "topic_code"): ("topics", "code"),
("requests", "status_code"): ("statuses", "code"),
("statuses", "status_group_id"): ("status_groups", "id"),
("topic_required_fields", "topic_code"): ("topics", "code"),
("topic_required_fields", "field_key"): ("form_fields", "key"),
("topic_data_templates", "topic_code"): ("topics", "code"),
("topic_status_transitions", "topic_code"): ("topics", "code"),
("topic_status_transitions", "from_status"): ("statuses", "code"),
("topic_status_transitions", "to_status"): ("statuses", "code"),
("admin_users", "primary_topic_code"): ("topics", "code"),
("admin_user_topics", "admin_user_id"): ("admin_users", "id"),
("admin_user_topics", "topic_code"): ("topics", "code"),
("request_data_requirements", "request_id"): ("requests", "id"),
("request_data_requirements", "topic_template_id"): ("topic_data_templates", "id"),
("request_data_requirements", "created_by_admin_id"): ("admin_users", "id"),
("messages", "request_id"): ("requests", "id"),
("attachments", "request_id"): ("requests", "id"),
("attachments", "message_id"): ("messages", "id"),
("invoices", "request_id"): ("requests", "id"),
("invoices", "client_id"): ("clients", "id"),
("invoices", "issued_by_admin_user_id"): ("admin_users", "id"),
("notifications", "recipient_admin_user_id"): ("admin_users", "id"),
("status_history", "request_id"): ("requests", "id"),
("status_history", "changed_by_admin_id"): ("admin_users", "id"),
("audit_log", "actor_admin_id"): ("admin_users", "id"),
}
if (normalized_table, normalized_column) in explicit:
return explicit[(normalized_table, normalized_column)]
return None
def _detect_reference_for_column(table_name: str, column_name: str) -> tuple[str, str] | None:
override = _reference_override(table_name, column_name)
if override is not None:
return override
normalized = _normalize_table_name(column_name)
table_models = _table_model_map()
if normalized.endswith("_id") and normalized not in {"id"}:
base = normalized[:-3]
for candidate in _pluralize_identifier(base):
if candidate in table_models:
return candidate, "id"
if base.endswith("_admin_user"):
return "admin_users", "id"
if base.endswith("_lawyer"):
return "admin_users", "id"
if normalized.endswith("_code"):
base = normalized[:-5]
for candidate in _pluralize_identifier(base):
if candidate in table_models:
return candidate, "code"
return None
def _reference_label_field(table_name: str, value_field: str) -> str:
explicit = {
"admin_users": "name",
"clients": "full_name",
"requests": "track_number",
"topics": "name",
"statuses": "name",
"status_groups": "name",
"form_fields": "label",
"topic_data_templates": "label",
"invoices": "invoice_number",
"messages": "body",
"attachments": "file_name",
}
if table_name in explicit:
return explicit[table_name]
_, model = _resolve_table_model(table_name)
mapper = sa_inspect(model)
hidden = _hidden_response_fields(table_name)
blocked = {"id", value_field, "created_at", "updated_at", "responsible"}
for column in mapper.columns:
name = str(column.key)
if name in hidden or name in blocked:
continue
return name
return value_field
def _reference_meta_for_column(table_name: str, column_name: str) -> dict[str, str] | None:
detected = _detect_reference_for_column(table_name, column_name)
if detected is None:
return None
ref_table, value_field = detected
try:
label_field = _reference_label_field(ref_table, value_field)
except HTTPException:
return None
return {
"table": ref_table,
"value_field": value_field,
"label_field": label_field,
}
def _default_sort_for_table(model: type) -> list[dict[str, str]]:
columns = _columns_map(model)
if "sort_order" in columns:
@ -466,20 +590,22 @@ def _table_columns_meta(table_name: str, model: type) -> list[dict[str, Any]]:
kind = _column_kind(column)
has_default = column.default is not None or column.server_default is not None or name in primary_keys
editable = name not in SYSTEM_FIELDS and name not in protected and name not in primary_keys
out.append(
{
"name": name,
"label": _column_label(table_name, name),
"kind": kind,
"nullable": bool(column.nullable),
"editable": bool(editable),
"sortable": True,
"filterable": kind != "json",
"required_on_create": not bool(column.nullable) and not bool(has_default) and bool(editable),
"has_default": bool(has_default),
"is_primary_key": name in primary_keys,
}
)
item = {
"name": name,
"label": _column_label(table_name, name),
"kind": kind,
"nullable": bool(column.nullable),
"editable": bool(editable),
"sortable": True,
"filterable": kind != "json",
"required_on_create": not bool(column.nullable) and not bool(has_default) and bool(editable),
"has_default": bool(has_default),
"is_primary_key": name in primary_keys,
}
reference = _reference_meta_for_column(table_name, name)
if reference is not None:
item["reference"] = reference
out.append(item)
return out
@ -877,10 +1003,20 @@ def _apply_topic_status_transitions_fields(db: Session, payload: dict[str, Any])
return data
def _apply_status_fields(payload: dict[str, Any]) -> dict[str, Any]:
def _apply_status_fields(db: Session, payload: dict[str, Any]) -> dict[str, Any]:
data = dict(payload)
if "kind" in data:
data["kind"] = normalize_status_kind_or_400(data.get("kind"))
if "status_group_id" in data:
raw_group = data.get("status_group_id")
if raw_group is None or str(raw_group).strip() == "":
data["status_group_id"] = None
else:
group_id = _parse_uuid_or_400(raw_group, "status_group_id")
group = db.get(StatusGroup, group_id)
if group is None:
raise HTTPException(status_code=400, detail="Группа статусов не найдена")
data["status_group_id"] = group_id
if "invoice_template" in data:
text = str(data.get("invoice_template") or "").strip()
data["invoice_template"] = text or None
@ -1359,7 +1495,7 @@ def create_row(
if normalized == "topic_status_transitions":
clean_payload = _apply_topic_status_transitions_fields(db, clean_payload)
if normalized == "statuses":
clean_payload = _apply_status_fields(clean_payload)
clean_payload = _apply_status_fields(db, clean_payload)
if normalized == "requests":
clean_payload["client_id"] = resolved_request_client_id
if normalized == "invoices":
@ -1426,7 +1562,7 @@ def update_row(
if normalized == "topic_status_transitions":
clean_payload = _apply_topic_status_transitions_fields(db, clean_payload)
if normalized == "statuses":
clean_payload = _apply_status_fields(clean_payload)
clean_payload = _apply_status_fields(db, clean_payload)
if normalized == "requests" and isinstance(row, Request):
if {"client_name", "client_phone"}.intersection(set(clean_payload.keys())) or row.client_id is None:
client = _upsert_client_or_400(

View file

@ -1,3 +1,4 @@
import json
from datetime import datetime, timedelta, timezone
from uuid import UUID, uuid4
@ -8,7 +9,7 @@ from sqlalchemy import case, or_, update
from app.db.session import get_db
from app.core.deps import require_role
from app.schemas.universal import UniversalQuery
from app.schemas.universal import FilterClause, Page, UniversalQuery
from app.schemas.admin import (
RequestAdminCreate,
RequestAdminPatch,
@ -21,6 +22,7 @@ from app.models.audit_log import AuditLog
from app.models.request_data_requirement import RequestDataRequirement
from app.models.request import Request
from app.models.status import Status
from app.models.status_group import StatusGroup
from app.models.status_history import StatusHistory
from app.models.topic_data_template import TopicDataTemplate
from app.models.topic_status_transition import TopicStatusTransition
@ -39,41 +41,50 @@ from app.services.universal_query import apply_universal_query
router = APIRouter()
REQUEST_FINANCIAL_FIELDS = {"effective_rate", "invoice_amount", "paid_at", "paid_by_admin_id"}
KANBAN_GROUP_LABELS = {
"NEW": "Новые",
"IN_PROGRESS": "В работе",
"WAITING": "Ожидание",
"DONE": "Завершены",
}
ALLOWED_KANBAN_FILTER_FIELDS = {"assigned_lawyer_id", "client_name", "status_code", "created_at", "topic_code", "overdue"}
ALLOWED_KANBAN_SORT_MODES = {"created_newest", "lawyer", "deadline"}
FALLBACK_KANBAN_GROUPS = [
("fallback_new", "Новые", 10),
("fallback_in_progress", "В работе", 20),
("fallback_waiting", "Ожидание", 30),
("fallback_done", "Завершены", 40),
]
def _status_meta_or_default(meta_map: dict[str, dict[str, object]], status_code: str) -> dict[str, object]:
return meta_map.get(status_code) or {"name": status_code, "kind": "DEFAULT", "is_terminal": False}
return meta_map.get(status_code) or {
"name": status_code,
"kind": "DEFAULT",
"is_terminal": False,
"status_group_id": None,
"status_group_name": None,
"status_group_order": None,
}
def _kanban_group_for_status(status_code: str, status_meta: dict[str, object]) -> str:
def _fallback_group_for_status(status_code: str, status_meta: dict[str, object]) -> tuple[str, str, int]:
code = str(status_code or "").strip().upper()
kind = str(status_meta.get("kind") or "DEFAULT").upper()
name = str(status_meta.get("name") or "").upper()
is_terminal = bool(status_meta.get("is_terminal"))
if is_terminal:
return "DONE"
return FALLBACK_KANBAN_GROUPS[3]
if kind == "PAID":
return "DONE"
return FALLBACK_KANBAN_GROUPS[3]
if code.startswith("NEW") or "НОВ" in name:
return "NEW"
return FALLBACK_KANBAN_GROUPS[0]
waiting_tokens = ("WAIT", "PEND", "HOLD", "SUSPEND", "BLOCK")
waiting_ru_tokens = ("ОЖИД", "ПАУЗ", "СОГЛАС", "ОПЛАТ", "СУД")
if kind == "INVOICE":
return "WAITING"
return FALLBACK_KANBAN_GROUPS[2]
if any(token in code for token in waiting_tokens) or any(token in name for token in waiting_ru_tokens):
return "WAITING"
return FALLBACK_KANBAN_GROUPS[2]
done_tokens = ("CLOSE", "RESOLV", "REJECT", "DONE", "PAID")
done_ru_tokens = ("ЗАВЕРШ", "ЗАКРЫ", "РЕШЕН", "ОТКЛОН", "ОПЛАЧ")
if any(token in code for token in done_tokens) or any(token in name for token in done_ru_tokens):
return "DONE"
return "IN_PROGRESS"
return FALLBACK_KANBAN_GROUPS[3]
return FALLBACK_KANBAN_GROUPS[1]
def _parse_datetime_safe(value: object) -> datetime | None:
@ -115,6 +126,101 @@ def _extract_case_deadline(extra_fields: object) -> datetime | None:
return None
def _coerce_kanban_bool(value: object) -> bool:
if isinstance(value, bool):
return value
text = str(value or "").strip().lower()
if text in {"1", "true", "yes", "y", "on"}:
return True
if text in {"0", "false", "no", "n", "off"}:
return False
raise HTTPException(status_code=400, detail='Поле "overdue" должно быть boolean')
def _parse_kanban_filters_or_400(raw_filters: str | None) -> tuple[list[FilterClause], list[tuple[str, bool]]]:
if not raw_filters:
return [], []
try:
parsed = json.loads(raw_filters)
except json.JSONDecodeError as exc:
raise HTTPException(status_code=400, detail="Некорректный JSON фильтров канбана") from exc
if not isinstance(parsed, list):
raise HTTPException(status_code=400, detail="Фильтры канбана должны быть массивом")
universal_filters: list[FilterClause] = []
overdue_filters: list[tuple[str, bool]] = []
for index, item in enumerate(parsed):
if not isinstance(item, dict):
raise HTTPException(status_code=400, detail=f"Фильтр #{index + 1} должен быть объектом")
field = str(item.get("field") or "").strip()
op = str(item.get("op") or "").strip()
value = item.get("value")
if field not in ALLOWED_KANBAN_FILTER_FIELDS:
raise HTTPException(status_code=400, detail=f'Недоступное поле фильтра: "{field}"')
if op not in {"=", "!=", ">", "<", ">=", "<=", "~"}:
raise HTTPException(status_code=400, detail=f'Недопустимый оператор фильтра: "{op}"')
if field == "overdue":
if op not in {"=", "!="}:
raise HTTPException(status_code=400, detail='Для поля "overdue" доступны только операторы "=" и "!="')
overdue_filters.append((op, _coerce_kanban_bool(value)))
continue
universal_filters.append(FilterClause(field=field, op=op, value=value))
return universal_filters, overdue_filters
def _apply_overdue_filters(items: list[dict[str, object]], overdue_filters: list[tuple[str, bool]]) -> list[dict[str, object]]:
if not overdue_filters:
return items
now = datetime.now(timezone.utc)
out: list[dict[str, object]] = []
for item in items:
raw_deadline = item.get("sla_deadline_at") or item.get("case_deadline_at")
deadline_at = _parse_datetime_safe(raw_deadline)
is_overdue = bool(deadline_at and deadline_at <= now)
ok = True
for op, expected in overdue_filters:
if op == "=":
ok = ok and (is_overdue == expected)
elif op == "!=":
ok = ok and (is_overdue != expected)
if not ok:
break
if ok:
out.append(item)
return out
def _sort_kanban_items(items: list[dict[str, object]], sort_mode: str) -> list[dict[str, object]]:
mode = sort_mode if sort_mode in ALLOWED_KANBAN_SORT_MODES else "created_newest"
epoch = datetime(1970, 1, 1, tzinfo=timezone.utc)
if mode == "lawyer":
return sorted(
items,
key=lambda row: (
1 if not str(row.get("assigned_lawyer_name") or "").strip() else 0,
str(row.get("assigned_lawyer_name") or "").lower(),
-int((_parse_datetime_safe(row.get("created_at")) or epoch).timestamp()),
),
)
if mode == "deadline":
far_future = datetime(9999, 12, 31, tzinfo=timezone.utc)
return sorted(
items,
key=lambda row: (
_parse_datetime_safe(row.get("sla_deadline_at") or row.get("case_deadline_at")) or far_future,
-int((_parse_datetime_safe(row.get("created_at")) or epoch).timestamp()),
),
)
return sorted(
items,
key=lambda row: _parse_datetime_safe(row.get("created_at")) or epoch,
reverse=True,
)
def _request_uuid_or_400(request_id: str) -> UUID:
try:
return UUID(str(request_id))
@ -220,6 +326,8 @@ def get_requests_kanban(
db: Session = Depends(get_db),
admin=Depends(require_role("ADMIN", "LAWYER")),
limit: int = Query(default=400, ge=1, le=1000),
filters: str | None = Query(default=None),
sort_mode: str = Query(default="created_newest"),
):
role = str(admin.get("role") or "").upper()
actor = str(admin.get("sub") or "").strip()
@ -235,18 +343,23 @@ def get_requests_kanban(
)
)
request_rows: list[Request] = (
base_query
.order_by(Request.created_at.desc())
.limit(limit)
.all()
)
total = int(base_query.count() or 0)
normalized_sort_mode = sort_mode if sort_mode in ALLOWED_KANBAN_SORT_MODES else "created_newest"
query_filters, overdue_filters = _parse_kanban_filters_or_400(filters)
if query_filters:
base_query = apply_universal_query(
base_query,
Request,
UniversalQuery(
filters=query_filters,
sort=[],
page=Page(limit=limit, offset=0),
),
)
request_rows: list[Request] = base_query.all()
request_id_to_row = {str(row.id): row for row in request_rows}
request_ids = [row.id for row in request_rows]
request_ids_str = list(request_id_to_row.keys())
topic_codes = {str(row.topic_code or "").strip() for row in request_rows if str(row.topic_code or "").strip()}
status_codes = {str(row.status_code or "").strip() for row in request_rows if str(row.status_code or "").strip()}
@ -276,15 +389,23 @@ def get_requests_kanban(
status_meta_map: dict[str, dict[str, object]] = {}
if status_codes:
status_rows = db.query(Status).filter(Status.code.in_(list(status_codes))).all()
status_rows = (
db.query(Status, StatusGroup)
.outerjoin(StatusGroup, StatusGroup.id == Status.status_group_id)
.filter(Status.code.in_(list(status_codes)))
.all()
)
status_meta_map = {
str(row.code): {
"name": str(row.name or row.code),
"kind": str(row.kind or "DEFAULT"),
"is_terminal": bool(row.is_terminal),
"sort_order": int(row.sort_order or 0),
str(status_row.code): {
"name": str(status_row.name or status_row.code),
"kind": str(status_row.kind or "DEFAULT"),
"is_terminal": bool(status_row.is_terminal),
"sort_order": int(status_row.sort_order or 0),
"status_group_id": str(status_row.status_group_id) if status_row.status_group_id else None,
"status_group_name": (str(group_row.name) if group_row is not None and group_row.name else None),
"status_group_order": (int(group_row.sort_order or 0) if group_row is not None else None),
}
for row in status_rows
for status_row, group_row in status_rows
}
assigned_ids = {str(row.assigned_lawyer_id or "").strip() for row in request_rows if str(row.assigned_lawyer_id or "").strip()}
@ -338,27 +459,61 @@ def get_requests_kanban(
transitions_by_key.setdefault((topic_code, from_status), []).append(row)
transitions_to_key.setdefault((topic_code, to_status), []).append(row)
status_groups_rows = db.query(StatusGroup).order_by(StatusGroup.sort_order.asc(), StatusGroup.name.asc()).all()
columns_catalog = [
{
"key": str(group.id),
"label": str(group.name),
"sort_order": int(group.sort_order or 0),
}
for group in status_groups_rows
]
columns_by_key = {row["key"]: row for row in columns_catalog}
items: list[dict[str, object]] = []
group_totals = {key: 0 for key in KANBAN_GROUP_LABELS.keys()}
group_totals: dict[str, int] = {row["key"]: 0 for row in columns_catalog}
for row in request_rows:
request_id = str(row.id)
topic_code = str(row.topic_code or "").strip()
status_code = str(row.status_code or "").strip()
status_meta = _status_meta_or_default(status_meta_map, status_code)
status_group = _kanban_group_for_status(status_code, status_meta)
group_totals[status_group] = int(group_totals.get(status_group, 0)) + 1
status_group = str(status_meta.get("status_group_id") or "").strip()
status_group_name = str(status_meta.get("status_group_name") or "").strip()
status_group_order = status_meta.get("status_group_order")
if not status_group:
fallback_key, fallback_label, fallback_order = _fallback_group_for_status(status_code, status_meta)
status_group = fallback_key
status_group_name = fallback_label
status_group_order = fallback_order
if fallback_key not in columns_by_key:
columns_by_key[fallback_key] = {"key": fallback_key, "label": fallback_label, "sort_order": fallback_order}
columns_catalog.append(columns_by_key[fallback_key])
elif status_group not in columns_by_key:
columns_by_key[status_group] = {
"key": status_group,
"label": status_group_name or status_group,
"sort_order": int(status_group_order or 999),
}
columns_catalog.append(columns_by_key[status_group])
available_transitions = []
for transition in transitions_by_key.get((topic_code, status_code), []):
to_status = str(transition.to_status or "").strip()
if not to_status:
continue
to_meta = _status_meta_or_default(status_meta_map, to_status)
target_group = str(to_meta.get("status_group_id") or "").strip()
if not target_group:
target_group, fallback_label, fallback_order = _fallback_group_for_status(to_status, to_meta)
if target_group not in columns_by_key:
columns_by_key[target_group] = {"key": target_group, "label": fallback_label, "sort_order": fallback_order}
columns_catalog.append(columns_by_key[target_group])
if target_group not in group_totals:
group_totals[target_group] = 0
available_transitions.append(
{
"to_status": to_status,
"to_status_name": str(to_meta.get("name") or to_status),
"target_group": _kanban_group_for_status(to_status, to_meta),
"target_group": target_group,
"sla_hours": transition.sla_hours,
}
)
@ -391,6 +546,8 @@ def get_requests_kanban(
"status_code": status_code,
"status_name": str(status_meta.get("name") or status_code),
"status_group": status_group,
"status_group_name": status_group_name or None,
"status_group_order": int(status_group_order or 0) if status_group_order is not None else None,
"assigned_lawyer_id": assigned_id,
"assigned_lawyer_name": lawyer_name_map.get(assigned_id or "", assigned_id),
"description": row.description,
@ -406,14 +563,37 @@ def get_requests_kanban(
}
)
columns = [
{
"key": key,
"label": label,
"total": int(group_totals.get(key, 0)),
}
for key, label in KANBAN_GROUP_LABELS.items()
]
items = _apply_overdue_filters(items, overdue_filters)
items = _sort_kanban_items(items, normalized_sort_mode)
total = len(items)
if total > limit:
items = items[:limit]
for row in items:
key = str(row.get("status_group") or "").strip()
if not key:
continue
group_totals[key] = int(group_totals.get(key, 0)) + 1
columns = []
for item in sorted(
columns_catalog,
key=lambda row: (
int(row.get("sort_order") or 0),
str(row.get("label") or "").lower(),
),
):
key = str(item.get("key") or "")
if not key:
continue
columns.append(
{
"key": key,
"label": str(item.get("label") or key),
"sort_order": int(item.get("sort_order") or 0),
"total": int(group_totals.get(key, 0)),
}
)
return {
"scope": role,
@ -421,6 +601,7 @@ def get_requests_kanban(
"columns": columns,
"total": total,
"limit": int(limit),
"sort_mode": normalized_sort_mode,
"truncated": bool(total > len(items)),
}

View file

@ -1,4 +1,7 @@
import uuid
from sqlalchemy import String, Boolean, Integer, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.session import Base
from app.models.common import UUIDMixin, TimestampMixin
@ -7,6 +10,7 @@ class Status(Base, UUIDMixin, TimestampMixin):
__tablename__ = "statuses"
code: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
name: Mapped[str] = mapped_column(String(200), nullable=False)
status_group_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True, index=True)
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
is_terminal: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)

View file

@ -0,0 +1,12 @@
from sqlalchemy import Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.session import Base
from app.models.common import TimestampMixin, UUIDMixin
class StatusGroup(Base, UUIDMixin, TimestampMixin):
__tablename__ = "status_groups"
name: Mapped[str] = mapped_column(String(200), nullable=False, unique=True, index=True)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)

View file

@ -29,6 +29,7 @@ class TopicUpsert(BaseModel):
class StatusUpsert(BaseModel):
code: str
name: str
status_group_id: Optional[str] = None
enabled: bool = True
sort_order: int = 0
is_terminal: bool = False

View file

@ -175,6 +175,12 @@
color: #dbe6f5;
}
.btn.secondary.active-success {
border-color: rgba(77, 190, 147, 0.48);
background: rgba(77, 190, 147, 0.22);
color: #c8f5e4;
}
.btn.danger {
border-color: rgba(255, 127, 127, 0.3);
background: rgba(255, 127, 127, 0.13);
@ -259,14 +265,18 @@
}
.kanban-board {
display: grid;
grid-template-columns: repeat(4, minmax(260px, 1fr));
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
overflow-x: auto;
overflow-x: hidden;
padding-bottom: 0.25rem;
align-items: stretch;
align-content: flex-start;
}
.kanban-column {
flex: 1 1 300px;
min-width: 260px;
border: 1px solid var(--line);
border-radius: 14px;
background: rgba(255, 255, 255, 0.02);
@ -321,13 +331,22 @@
display: flex;
flex-direction: column;
gap: 0.45rem;
cursor: pointer;
}
.kanban-card.draggable {
cursor: grab;
}
.kanban-card:active {
.kanban-card.draggable:active {
cursor: grabbing;
}
.kanban-card:focus-visible {
outline: 2px solid rgba(137, 178, 255, 0.55);
outline-offset: 1px;
}
.kanban-card-head {
display: flex;
justify-content: space-between;
@ -403,11 +422,73 @@
margin-top: 0.1rem;
display: flex;
align-items: center;
justify-content: space-between;
justify-content: flex-start;
gap: 0.45rem;
flex-wrap: wrap;
}
.kanban-update-icons {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.kanban-update-icon {
width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
border: 1px solid rgba(122, 139, 163, 0.38);
background: rgba(111, 133, 160, 0.14);
color: #9daec4;
font-size: 0.76rem;
line-height: 1;
}
.kanban-update-icon.is-unread {
border-color: rgba(74, 197, 143, 0.52);
background: rgba(74, 197, 143, 0.2);
color: #b9f3dd;
box-shadow: 0 0 0 2px rgba(74, 197, 143, 0.14);
}
.kanban-deadline-chip {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 120px;
padding: 0.18rem 0.55rem;
border-radius: 999px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.05);
color: #dde9fa;
font-size: 0.75rem;
line-height: 1.2;
font-weight: 700;
letter-spacing: 0.01em;
white-space: nowrap;
}
.kanban-deadline-chip.tone-ok {
border-color: rgba(76, 197, 145, 0.5);
background: rgba(76, 197, 145, 0.2);
color: #c5f8e3;
}
.kanban-deadline-chip.tone-warn {
border-color: rgba(228, 182, 92, 0.52);
background: rgba(228, 182, 92, 0.22);
color: #f9e0ac;
}
.kanban-deadline-chip.tone-danger {
border-color: rgba(255, 98, 98, 0.58);
background: rgba(255, 98, 98, 0.24);
color: #ffd4d4;
}
.kanban-transition-select {
flex: 1;
min-width: 140px;
@ -1258,6 +1339,45 @@
text-align: right;
}
.chat-message-files {
margin-top: 0.35rem;
display: flex;
flex-wrap: wrap;
gap: 0.32rem;
}
.chat-message-file-chip {
border: 1px solid rgba(130, 153, 183, 0.45);
background: rgba(31, 45, 63, 0.58);
color: #d8e6f8;
border-radius: 999px;
padding: 0.16rem 0.46rem 0.16rem 0.36rem;
display: inline-flex;
align-items: center;
gap: 0.28rem;
cursor: pointer;
max-width: 100%;
font-size: 0.75rem;
line-height: 1.2;
}
.chat-message-file-chip:hover {
border-color: rgba(170, 198, 236, 0.65);
background: rgba(52, 74, 104, 0.66);
}
.chat-message-file-icon {
color: #a9c1df;
flex-shrink: 0;
}
.chat-message-file-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 220px;
}
.request-chat-list li.chat-date-divider {
margin: 0.32rem 0 0.24rem;
padding: 0;
@ -1381,6 +1501,12 @@
width: min(980px, 100%);
}
.request-preview-head-actions {
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.request-preview-body {
width: 100%;
min-height: 280px;
@ -1418,10 +1544,17 @@
margin: 0;
}
.request-preview-download {
.request-preview-download-icon {
text-decoration: none;
}
.workspace-head-icon {
width: 36px;
height: 36px;
border-radius: 50%;
font-size: 0.96rem;
}
.overlay {
position: fixed;
inset: 0;
@ -1500,7 +1633,10 @@
@media (max-width: 1160px) {
.cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.kanban-board { grid-template-columns: repeat(2, minmax(240px, 1fr)); }
.kanban-column {
flex-basis: calc(50% - 0.375rem);
min-width: 240px;
}
.filters { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.triple { grid-template-columns: 1fr; }
.config-layout { grid-template-columns: 1fr; }
@ -1522,8 +1658,9 @@
.filters {
grid-template-columns: 1fr;
}
.kanban-board {
grid-template-columns: 1fr;
.kanban-column {
flex-basis: 100%;
min-width: 0;
}
.filter-toolbar {
flex-direction: column;

View file

@ -4,12 +4,12 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Административная панель • Правовой трекер</title>
<link rel="stylesheet" href="/admin.css?v=20260225-7">
<link rel="stylesheet" href="/admin.css?v=20260225-12">
</head>
<body>
<div id="admin-root"></div>
<script src="https://unpkg.com/react@18.2.0/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@18.2.0/umd/react-dom.production.min.js"></script>
<script src="/admin.js?v=20260225-7"></script>
<script src="/admin.js?v=20260225-12"></script>
</body>
</html>

File diff suppressed because it is too large Load diff

Binary file not shown.

View file

@ -23,6 +23,21 @@ test("kanban flow via UI: lawyer sees unassigned card, claims and opens request
await page.locator("aside .menu button[data-section='kanban']").click();
await expect(page.locator("#section-kanban h2")).toHaveText("Канбан заявок");
await page.locator("#section-kanban .filter-toolbar").getByRole("button", { name: "Фильтр" }).click();
await expect(page.getByRole("heading", { name: "Фильтр таблицы" })).toBeVisible();
await page.locator("#filter-field").selectOption("client_name");
await page.locator("#filter-op").selectOption("~");
await page.locator("#filter-value").fill("Клиент");
await page.locator("#filter-overlay").getByRole("button", { name: "Добавить/Сохранить" }).click();
await expect(page.locator("#section-kanban .filter-chip")).toHaveCount(1);
const sortButton = page.locator("#section-kanban .section-head").getByRole("button", { name: "Сортировка" });
await sortButton.click();
await expect(page.getByRole("heading", { name: "Сортировка канбана" })).toBeVisible();
await page.locator("#kanban-sort-mode").selectOption("deadline");
await page.locator("#kanban-sort-overlay").getByRole("button", { name: "Ок" }).click();
await expect(sortButton).toHaveClass(/active-success/);
const card = page.locator("#section-kanban .kanban-card").filter({ hasText: trackNumber }).first();
await expect(card).toBeVisible();
@ -47,15 +62,10 @@ test("kanban flow via UI: lawyer sees unassigned card, claims and opens request
}
const pagesBeforeOpen = context.pages().length;
await page
.locator("#section-kanban .kanban-card")
.filter({ hasText: trackNumber })
.first()
.getByRole("button", { name: "Открыть заявку" })
.click();
await page.locator("#section-kanban .kanban-card").filter({ hasText: trackNumber }).first().click();
await page.waitForTimeout(250);
await expect.poll(() => context.pages().length).toBe(pagesBeforeOpen);
await expect(page.locator("#section-request-workspace h2")).toHaveText("Карточка заявки");
await page.getByRole("button", { name: "Назад к заявкам" }).click();
await expect(page.locator("#section-request-workspace h2")).toContainText("Карточка заявки");
await page.getByRole("button", { name: "Назад" }).click();
await expect(page.locator("#section-requests h2")).toHaveText("Заявки");
});

View file

@ -49,9 +49,8 @@ test("lawyer flow via UI: claim request -> chat and files in request workspace t
await page.waitForTimeout(250);
await expect.poll(() => context.pages().length).toBe(pagesBeforeOpen);
const requestPage = page;
await expect(requestPage.getByRole("heading", { name: "Карточка заявки" })).toBeVisible();
await expect(requestPage.locator("#section-request-workspace .breadcrumbs")).toContainText("Заявки -> Заявка");
await expect(requestPage.getByRole("button", { name: "Назад к заявкам" })).toBeVisible();
await expect(requestPage.getByRole("heading", { name: /Карточка заявки/ })).toBeVisible();
await expect(requestPage.getByRole("button", { name: "Назад" })).toBeVisible();
await expect(requestPage.locator("#request-modal-messages")).toContainText("Сообщение юристу");
await requestPage.getByRole("tab", { name: /Файлы/ }).click();
await expect(requestPage.locator("#request-modal-files")).toContainText(clientFileName);

View file

@ -1,4 +1,5 @@
import os
import json
import re
import unittest
from datetime import datetime, timedelta, timezone
@ -33,6 +34,7 @@ from app.models.table_availability import TableAvailability
from app.models.quote import Quote
from app.models.request import Request
from app.models.status import Status
from app.models.status_group import StatusGroup
from app.models.status_history import StatusHistory
from app.models.topic_data_template import TopicDataTemplate
from app.models.topic import Topic
@ -55,6 +57,7 @@ class AdminUniversalCrudTests(unittest.TestCase):
Quote.__table__.create(bind=cls.engine)
FormField.__table__.create(bind=cls.engine)
Request.__table__.create(bind=cls.engine)
StatusGroup.__table__.create(bind=cls.engine)
Status.__table__.create(bind=cls.engine)
Message.__table__.create(bind=cls.engine)
Attachment.__table__.create(bind=cls.engine)
@ -84,6 +87,7 @@ class AdminUniversalCrudTests(unittest.TestCase):
Attachment.__table__.drop(bind=cls.engine)
Message.__table__.drop(bind=cls.engine)
Status.__table__.drop(bind=cls.engine)
StatusGroup.__table__.drop(bind=cls.engine)
Request.__table__.drop(bind=cls.engine)
FormField.__table__.drop(bind=cls.engine)
Quote.__table__.drop(bind=cls.engine)
@ -98,6 +102,7 @@ class AdminUniversalCrudTests(unittest.TestCase):
db.execute(delete(Attachment))
db.execute(delete(Message))
db.execute(delete(Request))
db.execute(delete(StatusGroup))
db.execute(delete(Client))
db.execute(delete(Status))
db.execute(delete(FormField))
@ -174,6 +179,54 @@ class AdminUniversalCrudTests(unittest.TestCase):
actions = [row.action for row in db.query(AuditLog).filter(AuditLog.entity == "quotes", AuditLog.entity_id == quote_id).all()]
self.assertEqual(set(actions), {"CREATE", "UPDATE", "DELETE"})
def test_status_can_be_bound_to_status_group_via_crud(self):
headers = self._auth_headers("ADMIN")
created_group = self.client.post(
"/api/admin/crud/status_groups",
headers=headers,
json={"name": "Этапы рассмотрения", "sort_order": 15},
)
self.assertEqual(created_group.status_code, 201)
group_id = created_group.json()["id"]
UUID(group_id)
created_status = self.client.post(
"/api/admin/crud/statuses",
headers=headers,
json={
"code": "GROUPED_STATUS",
"name": "Статус с группой",
"status_group_id": group_id,
"kind": "DEFAULT",
"enabled": True,
"sort_order": 11,
"is_terminal": False,
},
)
self.assertEqual(created_status.status_code, 201)
status_id = created_status.json()["id"]
self.assertEqual(created_status.json()["status_group_id"], group_id)
got_status = self.client.get(f"/api/admin/crud/statuses/{status_id}", headers=headers)
self.assertEqual(got_status.status_code, 200)
self.assertEqual(got_status.json()["status_group_id"], group_id)
bad_status = self.client.post(
"/api/admin/crud/statuses",
headers=headers,
json={
"code": "GROUPED_STATUS_BAD",
"name": "Статус с невалидной группой",
"status_group_id": str(uuid4()),
"kind": "DEFAULT",
"enabled": True,
"sort_order": 12,
"is_terminal": False,
},
)
self.assertEqual(bad_status.status_code, 400)
def test_admin_table_catalog_lists_db_tables_for_dynamic_references(self):
admin_headers = self._auth_headers("ADMIN")
response = self.client.get("/api/admin/crud/meta/tables", headers=admin_headers)
@ -188,17 +241,30 @@ class AdminUniversalCrudTests(unittest.TestCase):
self.assertIn("clients", by_table)
self.assertIn("quotes", by_table)
self.assertIn("statuses", by_table)
self.assertIn("status_groups", by_table)
self.assertEqual(by_table["requests"]["section"], "main")
self.assertEqual(by_table["invoices"]["section"], "main")
self.assertEqual(by_table["quotes"]["section"], "dictionary")
self.assertTrue(by_table["quotes"]["default_sort"])
self.assertEqual(by_table["quotes"]["label"], "Цитаты")
self.assertEqual(by_table["status_groups"]["label"], "Группы статусов")
self.assertEqual(by_table["request_data_requirements"]["label"], "Требования данных заявки")
quotes_columns = {col["name"]: col for col in (by_table["quotes"].get("columns") or [])}
self.assertEqual(quotes_columns["author"]["label"], "Автор")
self.assertEqual(quotes_columns["sort_order"]["label"], "Порядок")
self.assertTrue(all(str(col.get("label") or "").strip() for col in (by_table["quotes"].get("columns") or [])))
statuses_columns = {col["name"]: col for col in (by_table["statuses"].get("columns") or [])}
self.assertEqual(statuses_columns["status_group_id"]["reference"]["table"], "status_groups")
self.assertEqual(statuses_columns["status_group_id"]["reference"]["label_field"], "name")
requests_columns = {col["name"]: col for col in (by_table["requests"].get("columns") or [])}
self.assertEqual(requests_columns["assigned_lawyer_id"]["reference"]["table"], "admin_users")
self.assertEqual(requests_columns["assigned_lawyer_id"]["reference"]["label_field"], "name")
invoices_columns = {col["name"]: col for col in (by_table["invoices"].get("columns") or [])}
self.assertEqual(invoices_columns["request_id"]["reference"]["table"], "requests")
self.assertEqual(invoices_columns["request_id"]["reference"]["label_field"], "track_number")
self.assertEqual(invoices_columns["client_id"]["reference"]["table"], "clients")
self.assertEqual(invoices_columns["client_id"]["reference"]["label_field"], "full_name")
for table_name, table_meta in by_table.items():
if table_name in {"requests", "invoices"}:
expected_section = "main"
@ -1202,12 +1268,50 @@ class AdminUniversalCrudTests(unittest.TestCase):
def test_requests_kanban_returns_grouped_cards_and_role_scope(self):
with self.SessionLocal() as db:
group_new = StatusGroup(name="Новые", sort_order=10)
group_progress = StatusGroup(name="В работе", sort_order=20)
group_waiting = StatusGroup(name="Ожидание", sort_order=30)
group_done = StatusGroup(name="Завершены", sort_order=40)
db.add_all([group_new, group_progress, group_waiting, group_done])
db.flush()
db.add_all(
[
Status(code="NEW", name="Новая", enabled=True, sort_order=1, is_terminal=False, kind="DEFAULT"),
Status(code="IN_PROGRESS", name="В работе", enabled=True, sort_order=2, is_terminal=False, kind="DEFAULT"),
Status(code="WAITING_CLIENT", name="Ожидание клиента", enabled=True, sort_order=3, is_terminal=False, kind="DEFAULT"),
Status(code="CLOSED", name="Закрыта", enabled=True, sort_order=4, is_terminal=True, kind="DEFAULT"),
Status(
code="NEW",
name="Новая",
enabled=True,
sort_order=1,
is_terminal=False,
kind="DEFAULT",
status_group_id=group_new.id,
),
Status(
code="IN_PROGRESS",
name="В работе",
enabled=True,
sort_order=2,
is_terminal=False,
kind="DEFAULT",
status_group_id=group_progress.id,
),
Status(
code="WAITING_CLIENT",
name="Ожидание клиента",
enabled=True,
sort_order=3,
is_terminal=False,
kind="DEFAULT",
status_group_id=group_waiting.id,
),
Status(
code="CLOSED",
name="Закрыта",
enabled=True,
sort_order=4,
is_terminal=True,
kind="DEFAULT",
status_group_id=group_done.id,
),
]
)
db.add(Topic(code="civil-law", name="Гражданское право", enabled=True, sort_order=1))
@ -1287,10 +1391,21 @@ class AdminUniversalCrudTests(unittest.TestCase):
extra_fields={},
assigned_lawyer_id=str(lawyer_other.id),
)
db.add_all([request_new, request_progress, request_waiting])
request_overdue = Request(
track_number="TRK-KANBAN-OVERDUE",
client_name="Клиент 4",
client_phone="+79990000004",
topic_code="civil-law",
status_code="IN_PROGRESS",
description="Просроченная заявка",
extra_fields={},
assigned_lawyer_id=str(lawyer_main.id),
)
db.add_all([request_new, request_progress, request_waiting, request_overdue])
db.flush()
entered_progress_at = datetime.now(timezone.utc) - timedelta(hours=2)
entered_overdue_at = datetime.now(timezone.utc) - timedelta(hours=30)
db.add(
StatusHistory(
request_id=request_progress.id,
@ -1301,31 +1416,46 @@ class AdminUniversalCrudTests(unittest.TestCase):
created_at=entered_progress_at,
)
)
db.add(
StatusHistory(
request_id=request_overdue.id,
from_status="NEW",
to_status="IN_PROGRESS",
changed_by_admin_id=None,
comment="overdue",
created_at=entered_overdue_at,
)
)
db.commit()
request_new_id = str(request_new.id)
request_progress_id = str(request_progress.id)
request_waiting_id = str(request_waiting.id)
request_overdue_id = str(request_overdue.id)
lawyer_main_id = str(lawyer_main.id)
group_new_id = str(group_new.id)
group_progress_id = str(group_progress.id)
admin_headers = self._auth_headers("ADMIN", email="root@example.com")
admin_response = self.client.get("/api/admin/requests/kanban?limit=100", headers=admin_headers)
self.assertEqual(admin_response.status_code, 200)
admin_payload = admin_response.json()
self.assertEqual(admin_payload["scope"], "ADMIN")
self.assertEqual(admin_payload["total"], 3)
self.assertEqual(admin_payload["total"], 4)
rows = {item["id"]: item for item in (admin_payload.get("rows") or [])}
self.assertIn(request_new_id, rows)
self.assertIn(request_progress_id, rows)
self.assertIn(request_waiting_id, rows)
self.assertEqual(rows[request_new_id]["status_group"], "NEW")
self.assertEqual(rows[request_progress_id]["status_group"], "IN_PROGRESS")
self.assertIn(request_overdue_id, rows)
self.assertEqual(rows[request_new_id]["status_group"], group_new_id)
self.assertEqual(rows[request_progress_id]["status_group"], group_progress_id)
self.assertEqual(rows[request_progress_id]["assigned_lawyer_id"], lawyer_main_id)
transitions = rows[request_progress_id].get("available_transitions") or []
self.assertTrue(any(item.get("to_status") == "WAITING_CLIENT" for item in transitions))
self.assertEqual(rows[request_progress_id]["case_deadline_at"], "2031-01-01T10:00:00+00:00")
self.assertIsNotNone(rows[request_progress_id]["sla_deadline_at"])
self.assertFalse(bool(admin_payload.get("truncated")))
self.assertEqual([item.get("label") for item in (admin_payload.get("columns") or [])][:4], ["Новые", "В работе", "Ожидание", "Завершены"])
lawyer_headers = self._auth_headers("LAWYER", email="lawyer.kanban@example.com", sub=lawyer_main_id)
lawyer_response = self.client.get("/api/admin/requests/kanban?limit=100", headers=lawyer_headers)
@ -1335,8 +1465,43 @@ class AdminUniversalCrudTests(unittest.TestCase):
lawyer_rows = {item["id"]: item for item in (lawyer_payload.get("rows") or [])}
self.assertIn(request_new_id, lawyer_rows)
self.assertIn(request_progress_id, lawyer_rows)
self.assertIn(request_overdue_id, lawyer_rows)
self.assertNotIn(request_waiting_id, lawyer_rows)
self.assertEqual(lawyer_payload["total"], 2)
self.assertEqual(lawyer_payload["total"], 3)
filtered_by_lawyer = self.client.get(
"/api/admin/requests/kanban",
headers=admin_headers,
params={
"limit": 100,
"filters": json.dumps([{"field": "assigned_lawyer_id", "op": "=", "value": lawyer_main_id}]),
},
)
self.assertEqual(filtered_by_lawyer.status_code, 200)
filtered_rows = {item["id"] for item in (filtered_by_lawyer.json().get("rows") or [])}
self.assertEqual(filtered_rows, {request_progress_id, request_overdue_id})
filtered_overdue = self.client.get(
"/api/admin/requests/kanban",
headers=admin_headers,
params={
"limit": 100,
"filters": json.dumps([{"field": "overdue", "op": "=", "value": True}]),
},
)
self.assertEqual(filtered_overdue.status_code, 200)
overdue_rows = {item["id"] for item in (filtered_overdue.json().get("rows") or [])}
self.assertEqual(overdue_rows, {request_overdue_id})
sorted_by_deadline = self.client.get(
"/api/admin/requests/kanban",
headers=admin_headers,
params={"limit": 100, "sort_mode": "deadline"},
)
self.assertEqual(sorted_by_deadline.status_code, 200)
sorted_rows = sorted_by_deadline.json().get("rows") or []
self.assertTrue(sorted_rows)
self.assertEqual(sorted_rows[0]["id"], request_overdue_id)
def test_lawyer_can_claim_unassigned_request_and_takeover_is_forbidden(self):
with self.SessionLocal() as db:

View file

@ -84,6 +84,7 @@ class MigrationTests(unittest.TestCase):
"table_availability",
"topics",
"statuses",
"status_groups",
"form_fields",
"topic_required_fields",
"topic_data_templates",
@ -108,7 +109,7 @@ 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, "0017_transition_requirements")
self.assertEqual(version, "0018_status_groups")
def test_responsible_column_exists_in_all_domain_tables(self):
tables = {
@ -117,6 +118,7 @@ class MigrationTests(unittest.TestCase):
"table_availability",
"topics",
"statuses",
"status_groups",
"form_fields",
"topic_required_fields",
"topic_data_templates",
@ -202,6 +204,15 @@ class MigrationTests(unittest.TestCase):
columns = {column["name"] for column in self.inspector.get_columns("statuses")}
self.assertIn("kind", columns)
self.assertIn("invoice_template", columns)
self.assertIn("status_group_id", columns)
def test_status_groups_contains_core_columns(self):
columns = {column["name"] for column in self.inspector.get_columns("status_groups")}
self.assertIn("id", columns)
self.assertIn("name", columns)
self.assertIn("sort_order", columns)
self.assertIn("created_at", columns)
self.assertIn("responsible", columns)
def test_clients_contains_core_columns(self):
columns = {column["name"] for column in self.inspector.get_columns("clients")}