mirror of
https://github.com/TronoSfera/Law.git
synced 2026-05-18 10:03:45 +03:00
Test-5 commit
This commit is contained in:
parent
7754a6fedf
commit
4d87cefcee
16 changed files with 1520 additions and 187 deletions
136
alembic/versions/0018_add_status_groups.py
Normal file
136
alembic/versions/0018_add_status_groups.py
Normal 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")
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
12
app/models/status_group.py
Normal file
12
app/models/status_group.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
|
@ -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("Заявки");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
Loading…
Reference in a new issue