mirror of
https://github.com/TronoSfera/Law.git
synced 2026-05-18 18:13:46 +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.core.deps import require_role
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
|
from app.models.admin_user import AdminUser
|
||||||
from app.models.request import Request
|
from app.models.request import Request
|
||||||
from app.services.chat_service import create_admin_or_lawyer_message, list_messages_for_request, serialize_message
|
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()
|
body = str((payload or {}).get("body") or "").strip()
|
||||||
role = str(admin.get("role") or "").upper()
|
role = str(admin.get("role") or "").upper()
|
||||||
actor_name = str(admin.get("email") or "").strip() or ("Юрист" if role == "LAWYER" else "Администратор")
|
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(
|
row = create_admin_or_lawyer_message(
|
||||||
db,
|
db,
|
||||||
request=req,
|
request=req,
|
||||||
body=body,
|
body=body,
|
||||||
actor_role=role,
|
actor_role=role,
|
||||||
actor_name=actor_name,
|
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)
|
return serialize_message(row)
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from uuid import UUID
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.core.deps import require_role
|
from app.core.deps import require_role
|
||||||
from app.schemas.universal import UniversalQuery
|
from app.schemas.universal import UniversalQuery
|
||||||
from app.schemas.admin import TopicUpsert, StatusUpsert, FormFieldUpsert
|
from app.schemas.admin import TopicUpsert, StatusUpsert, FormFieldUpsert
|
||||||
from app.models.topic import Topic
|
from app.models.topic import Topic
|
||||||
from app.models.status import Status
|
from app.models.status import Status
|
||||||
|
from app.models.status_group import StatusGroup
|
||||||
from app.models.form_field import FormField
|
from app.models.form_field import FormField
|
||||||
from app.services.universal_query import apply_universal_query
|
from app.services.universal_query import apply_universal_query
|
||||||
|
|
||||||
|
|
@ -22,6 +24,7 @@ def _status_row(row: Status):
|
||||||
"id": str(row.id),
|
"id": str(row.id),
|
||||||
"code": row.code,
|
"code": row.code,
|
||||||
"name": row.name,
|
"name": row.name,
|
||||||
|
"status_group_id": str(row.status_group_id) if row.status_group_id else None,
|
||||||
"enabled": row.enabled,
|
"enabled": row.enabled,
|
||||||
"sort_order": row.sort_order,
|
"sort_order": row.sort_order,
|
||||||
"is_terminal": row.is_terminal,
|
"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)
|
@router.post("/statuses", status_code=201)
|
||||||
def create_status(payload: StatusUpsert, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN"))):
|
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 "Администратор системы"
|
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
|
||||||
row = Status(**payload.model_dump(), responsible=responsible)
|
row = Status(**data, responsible=responsible)
|
||||||
try:
|
try:
|
||||||
db.add(row)
|
db.add(row)
|
||||||
db.commit()
|
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()
|
row = db.query(Status).filter(Status.id == id).first()
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(status_code=404, detail="Статус не найден")
|
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)
|
setattr(row, k, v)
|
||||||
try:
|
try:
|
||||||
db.add(row)
|
db.add(row)
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ from app.models.attachment import Attachment
|
||||||
from app.models.message import Message
|
from app.models.message import Message
|
||||||
from app.models.request import Request
|
from app.models.request import Request
|
||||||
from app.models.status import Status
|
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_data_template import TopicDataTemplate
|
||||||
from app.models.topic_required_field import TopicRequiredField
|
from app.models.topic_required_field import TopicRequiredField
|
||||||
from app.models.topic import Topic
|
from app.models.topic import Topic
|
||||||
|
|
@ -93,6 +94,7 @@ TABLE_ROLE_ACTIONS: dict[str, dict[str, set[str]]] = {
|
||||||
"quotes": {"ADMIN": set(CRUD_ACTIONS)},
|
"quotes": {"ADMIN": set(CRUD_ACTIONS)},
|
||||||
"topics": {"ADMIN": set(CRUD_ACTIONS)},
|
"topics": {"ADMIN": set(CRUD_ACTIONS)},
|
||||||
"statuses": {"ADMIN": set(CRUD_ACTIONS)},
|
"statuses": {"ADMIN": set(CRUD_ACTIONS)},
|
||||||
|
"status_groups": {"ADMIN": set(CRUD_ACTIONS)},
|
||||||
"form_fields": {"ADMIN": set(CRUD_ACTIONS)},
|
"form_fields": {"ADMIN": set(CRUD_ACTIONS)},
|
||||||
"clients": {"ADMIN": set(CRUD_ACTIONS)},
|
"clients": {"ADMIN": set(CRUD_ACTIONS)},
|
||||||
"table_availability": {"ADMIN": set(CRUD_ACTIONS)},
|
"table_availability": {"ADMIN": set(CRUD_ACTIONS)},
|
||||||
|
|
@ -257,6 +259,7 @@ def _table_label(table_name: str) -> str:
|
||||||
"quotes": "Цитаты",
|
"quotes": "Цитаты",
|
||||||
"topics": "Темы",
|
"topics": "Темы",
|
||||||
"statuses": "Статусы",
|
"statuses": "Статусы",
|
||||||
|
"status_groups": "Группы статусов",
|
||||||
"form_fields": "Поля формы",
|
"form_fields": "Поля формы",
|
||||||
"clients": "Клиенты",
|
"clients": "Клиенты",
|
||||||
"table_availability": "Доступность таблиц",
|
"table_availability": "Доступность таблиц",
|
||||||
|
|
@ -359,6 +362,7 @@ def _column_label(table_name: str, column_name: str) -> str:
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
"role": "Роль",
|
"role": "Роль",
|
||||||
"kind": "Тип",
|
"kind": "Тип",
|
||||||
|
"status_group_id": "Группа",
|
||||||
"status": "Статус",
|
"status": "Статус",
|
||||||
"status_code": "Статус",
|
"status_code": "Статус",
|
||||||
"topic_code": "Тема",
|
"topic_code": "Тема",
|
||||||
|
|
@ -441,6 +445,126 @@ def _column_label(table_name: str, column_name: str) -> str:
|
||||||
return _humanize_identifier_ru(normalized_column)
|
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]]:
|
def _default_sort_for_table(model: type) -> list[dict[str, str]]:
|
||||||
columns = _columns_map(model)
|
columns = _columns_map(model)
|
||||||
if "sort_order" in columns:
|
if "sort_order" in columns:
|
||||||
|
|
@ -466,8 +590,7 @@ def _table_columns_meta(table_name: str, model: type) -> list[dict[str, Any]]:
|
||||||
kind = _column_kind(column)
|
kind = _column_kind(column)
|
||||||
has_default = column.default is not None or column.server_default is not None or name in primary_keys
|
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
|
editable = name not in SYSTEM_FIELDS and name not in protected and name not in primary_keys
|
||||||
out.append(
|
item = {
|
||||||
{
|
|
||||||
"name": name,
|
"name": name,
|
||||||
"label": _column_label(table_name, name),
|
"label": _column_label(table_name, name),
|
||||||
"kind": kind,
|
"kind": kind,
|
||||||
|
|
@ -479,7 +602,10 @@ def _table_columns_meta(table_name: str, model: type) -> list[dict[str, Any]]:
|
||||||
"has_default": bool(has_default),
|
"has_default": bool(has_default),
|
||||||
"is_primary_key": name in primary_keys,
|
"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
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -877,10 +1003,20 @@ def _apply_topic_status_transitions_fields(db: Session, payload: dict[str, Any])
|
||||||
return data
|
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)
|
data = dict(payload)
|
||||||
if "kind" in data:
|
if "kind" in data:
|
||||||
data["kind"] = normalize_status_kind_or_400(data.get("kind"))
|
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:
|
if "invoice_template" in data:
|
||||||
text = str(data.get("invoice_template") or "").strip()
|
text = str(data.get("invoice_template") or "").strip()
|
||||||
data["invoice_template"] = text or None
|
data["invoice_template"] = text or None
|
||||||
|
|
@ -1359,7 +1495,7 @@ def create_row(
|
||||||
if normalized == "topic_status_transitions":
|
if normalized == "topic_status_transitions":
|
||||||
clean_payload = _apply_topic_status_transitions_fields(db, clean_payload)
|
clean_payload = _apply_topic_status_transitions_fields(db, clean_payload)
|
||||||
if normalized == "statuses":
|
if normalized == "statuses":
|
||||||
clean_payload = _apply_status_fields(clean_payload)
|
clean_payload = _apply_status_fields(db, clean_payload)
|
||||||
if normalized == "requests":
|
if normalized == "requests":
|
||||||
clean_payload["client_id"] = resolved_request_client_id
|
clean_payload["client_id"] = resolved_request_client_id
|
||||||
if normalized == "invoices":
|
if normalized == "invoices":
|
||||||
|
|
@ -1426,7 +1562,7 @@ def update_row(
|
||||||
if normalized == "topic_status_transitions":
|
if normalized == "topic_status_transitions":
|
||||||
clean_payload = _apply_topic_status_transitions_fields(db, clean_payload)
|
clean_payload = _apply_topic_status_transitions_fields(db, clean_payload)
|
||||||
if normalized == "statuses":
|
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 normalized == "requests" and isinstance(row, Request):
|
||||||
if {"client_name", "client_phone"}.intersection(set(clean_payload.keys())) or row.client_id is None:
|
if {"client_name", "client_phone"}.intersection(set(clean_payload.keys())) or row.client_id is None:
|
||||||
client = _upsert_client_or_400(
|
client = _upsert_client_or_400(
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import json
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
|
@ -8,7 +9,7 @@ from sqlalchemy import case, or_, update
|
||||||
|
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.core.deps import require_role
|
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 (
|
from app.schemas.admin import (
|
||||||
RequestAdminCreate,
|
RequestAdminCreate,
|
||||||
RequestAdminPatch,
|
RequestAdminPatch,
|
||||||
|
|
@ -21,6 +22,7 @@ from app.models.audit_log import AuditLog
|
||||||
from app.models.request_data_requirement import RequestDataRequirement
|
from app.models.request_data_requirement import RequestDataRequirement
|
||||||
from app.models.request import Request
|
from app.models.request import Request
|
||||||
from app.models.status import Status
|
from app.models.status import Status
|
||||||
|
from app.models.status_group import StatusGroup
|
||||||
from app.models.status_history import StatusHistory
|
from app.models.status_history import StatusHistory
|
||||||
from app.models.topic_data_template import TopicDataTemplate
|
from app.models.topic_data_template import TopicDataTemplate
|
||||||
from app.models.topic_status_transition import TopicStatusTransition
|
from app.models.topic_status_transition import TopicStatusTransition
|
||||||
|
|
@ -39,41 +41,50 @@ from app.services.universal_query import apply_universal_query
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
REQUEST_FINANCIAL_FIELDS = {"effective_rate", "invoice_amount", "paid_at", "paid_by_admin_id"}
|
REQUEST_FINANCIAL_FIELDS = {"effective_rate", "invoice_amount", "paid_at", "paid_by_admin_id"}
|
||||||
KANBAN_GROUP_LABELS = {
|
ALLOWED_KANBAN_FILTER_FIELDS = {"assigned_lawyer_id", "client_name", "status_code", "created_at", "topic_code", "overdue"}
|
||||||
"NEW": "Новые",
|
ALLOWED_KANBAN_SORT_MODES = {"created_newest", "lawyer", "deadline"}
|
||||||
"IN_PROGRESS": "В работе",
|
FALLBACK_KANBAN_GROUPS = [
|
||||||
"WAITING": "Ожидание",
|
("fallback_new", "Новые", 10),
|
||||||
"DONE": "Завершены",
|
("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]:
|
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()
|
code = str(status_code or "").strip().upper()
|
||||||
kind = str(status_meta.get("kind") or "DEFAULT").upper()
|
kind = str(status_meta.get("kind") or "DEFAULT").upper()
|
||||||
name = str(status_meta.get("name") or "").upper()
|
name = str(status_meta.get("name") or "").upper()
|
||||||
is_terminal = bool(status_meta.get("is_terminal"))
|
is_terminal = bool(status_meta.get("is_terminal"))
|
||||||
|
|
||||||
if is_terminal:
|
if is_terminal:
|
||||||
return "DONE"
|
return FALLBACK_KANBAN_GROUPS[3]
|
||||||
if kind == "PAID":
|
if kind == "PAID":
|
||||||
return "DONE"
|
return FALLBACK_KANBAN_GROUPS[3]
|
||||||
if code.startswith("NEW") or "НОВ" in name:
|
if code.startswith("NEW") or "НОВ" in name:
|
||||||
return "NEW"
|
return FALLBACK_KANBAN_GROUPS[0]
|
||||||
waiting_tokens = ("WAIT", "PEND", "HOLD", "SUSPEND", "BLOCK")
|
waiting_tokens = ("WAIT", "PEND", "HOLD", "SUSPEND", "BLOCK")
|
||||||
waiting_ru_tokens = ("ОЖИД", "ПАУЗ", "СОГЛАС", "ОПЛАТ", "СУД")
|
waiting_ru_tokens = ("ОЖИД", "ПАУЗ", "СОГЛАС", "ОПЛАТ", "СУД")
|
||||||
if kind == "INVOICE":
|
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):
|
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_tokens = ("CLOSE", "RESOLV", "REJECT", "DONE", "PAID")
|
||||||
done_ru_tokens = ("ЗАВЕРШ", "ЗАКРЫ", "РЕШЕН", "ОТКЛОН", "ОПЛАЧ")
|
done_ru_tokens = ("ЗАВЕРШ", "ЗАКРЫ", "РЕШЕН", "ОТКЛОН", "ОПЛАЧ")
|
||||||
if any(token in code for token in done_tokens) or any(token in name for token in 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 FALLBACK_KANBAN_GROUPS[3]
|
||||||
return "IN_PROGRESS"
|
return FALLBACK_KANBAN_GROUPS[1]
|
||||||
|
|
||||||
|
|
||||||
def _parse_datetime_safe(value: object) -> datetime | None:
|
def _parse_datetime_safe(value: object) -> datetime | None:
|
||||||
|
|
@ -115,6 +126,101 @@ def _extract_case_deadline(extra_fields: object) -> datetime | None:
|
||||||
return 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:
|
def _request_uuid_or_400(request_id: str) -> UUID:
|
||||||
try:
|
try:
|
||||||
return UUID(str(request_id))
|
return UUID(str(request_id))
|
||||||
|
|
@ -220,6 +326,8 @@ def get_requests_kanban(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
admin=Depends(require_role("ADMIN", "LAWYER")),
|
admin=Depends(require_role("ADMIN", "LAWYER")),
|
||||||
limit: int = Query(default=400, ge=1, le=1000),
|
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()
|
role = str(admin.get("role") or "").upper()
|
||||||
actor = str(admin.get("sub") or "").strip()
|
actor = str(admin.get("sub") or "").strip()
|
||||||
|
|
@ -235,18 +343,23 @@ def get_requests_kanban(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
request_rows: list[Request] = (
|
normalized_sort_mode = sort_mode if sort_mode in ALLOWED_KANBAN_SORT_MODES else "created_newest"
|
||||||
base_query
|
query_filters, overdue_filters = _parse_kanban_filters_or_400(filters)
|
||||||
.order_by(Request.created_at.desc())
|
if query_filters:
|
||||||
.limit(limit)
|
base_query = apply_universal_query(
|
||||||
.all()
|
base_query,
|
||||||
|
Request,
|
||||||
|
UniversalQuery(
|
||||||
|
filters=query_filters,
|
||||||
|
sort=[],
|
||||||
|
page=Page(limit=limit, offset=0),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
total = int(base_query.count() or 0)
|
|
||||||
|
request_rows: list[Request] = base_query.all()
|
||||||
|
|
||||||
request_id_to_row = {str(row.id): row for row in request_rows}
|
request_id_to_row = {str(row.id): row for row in request_rows}
|
||||||
request_ids = [row.id 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()}
|
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()}
|
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]] = {}
|
status_meta_map: dict[str, dict[str, object]] = {}
|
||||||
if status_codes:
|
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 = {
|
status_meta_map = {
|
||||||
str(row.code): {
|
str(status_row.code): {
|
||||||
"name": str(row.name or row.code),
|
"name": str(status_row.name or status_row.code),
|
||||||
"kind": str(row.kind or "DEFAULT"),
|
"kind": str(status_row.kind or "DEFAULT"),
|
||||||
"is_terminal": bool(row.is_terminal),
|
"is_terminal": bool(status_row.is_terminal),
|
||||||
"sort_order": int(row.sort_order or 0),
|
"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()}
|
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_by_key.setdefault((topic_code, from_status), []).append(row)
|
||||||
transitions_to_key.setdefault((topic_code, to_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]] = []
|
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:
|
for row in request_rows:
|
||||||
request_id = str(row.id)
|
request_id = str(row.id)
|
||||||
topic_code = str(row.topic_code or "").strip()
|
topic_code = str(row.topic_code or "").strip()
|
||||||
status_code = str(row.status_code or "").strip()
|
status_code = str(row.status_code or "").strip()
|
||||||
status_meta = _status_meta_or_default(status_meta_map, status_code)
|
status_meta = _status_meta_or_default(status_meta_map, status_code)
|
||||||
status_group = _kanban_group_for_status(status_code, status_meta)
|
status_group = str(status_meta.get("status_group_id") or "").strip()
|
||||||
group_totals[status_group] = int(group_totals.get(status_group, 0)) + 1
|
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 = []
|
available_transitions = []
|
||||||
for transition in transitions_by_key.get((topic_code, status_code), []):
|
for transition in transitions_by_key.get((topic_code, status_code), []):
|
||||||
to_status = str(transition.to_status or "").strip()
|
to_status = str(transition.to_status or "").strip()
|
||||||
if not to_status:
|
if not to_status:
|
||||||
continue
|
continue
|
||||||
to_meta = _status_meta_or_default(status_meta_map, to_status)
|
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(
|
available_transitions.append(
|
||||||
{
|
{
|
||||||
"to_status": to_status,
|
"to_status": to_status,
|
||||||
"to_status_name": str(to_meta.get("name") or 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,
|
"sla_hours": transition.sla_hours,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -391,6 +546,8 @@ def get_requests_kanban(
|
||||||
"status_code": status_code,
|
"status_code": status_code,
|
||||||
"status_name": str(status_meta.get("name") or status_code),
|
"status_name": str(status_meta.get("name") or status_code),
|
||||||
"status_group": status_group,
|
"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_id": assigned_id,
|
||||||
"assigned_lawyer_name": lawyer_name_map.get(assigned_id or "", assigned_id),
|
"assigned_lawyer_name": lawyer_name_map.get(assigned_id or "", assigned_id),
|
||||||
"description": row.description,
|
"description": row.description,
|
||||||
|
|
@ -406,14 +563,37 @@ def get_requests_kanban(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
columns = [
|
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,
|
"key": key,
|
||||||
"label": label,
|
"label": str(item.get("label") or key),
|
||||||
|
"sort_order": int(item.get("sort_order") or 0),
|
||||||
"total": int(group_totals.get(key, 0)),
|
"total": int(group_totals.get(key, 0)),
|
||||||
}
|
}
|
||||||
for key, label in KANBAN_GROUP_LABELS.items()
|
)
|
||||||
]
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"scope": role,
|
"scope": role,
|
||||||
|
|
@ -421,6 +601,7 @@ def get_requests_kanban(
|
||||||
"columns": columns,
|
"columns": columns,
|
||||||
"total": total,
|
"total": total,
|
||||||
"limit": int(limit),
|
"limit": int(limit),
|
||||||
|
"sort_mode": normalized_sort_mode,
|
||||||
"truncated": bool(total > len(items)),
|
"truncated": bool(total > len(items)),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
|
import uuid
|
||||||
|
|
||||||
from sqlalchemy import String, Boolean, Integer, Text
|
from sqlalchemy import String, Boolean, Integer, Text
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
from app.db.session import Base
|
from app.db.session import Base
|
||||||
from app.models.common import UUIDMixin, TimestampMixin
|
from app.models.common import UUIDMixin, TimestampMixin
|
||||||
|
|
@ -7,6 +10,7 @@ class Status(Base, UUIDMixin, TimestampMixin):
|
||||||
__tablename__ = "statuses"
|
__tablename__ = "statuses"
|
||||||
code: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
|
code: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
|
||||||
name: Mapped[str] = mapped_column(String(200), 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)
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
is_terminal: Mapped[bool] = mapped_column(Boolean, default=False, 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):
|
class StatusUpsert(BaseModel):
|
||||||
code: str
|
code: str
|
||||||
name: str
|
name: str
|
||||||
|
status_group_id: Optional[str] = None
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
sort_order: int = 0
|
sort_order: int = 0
|
||||||
is_terminal: bool = False
|
is_terminal: bool = False
|
||||||
|
|
|
||||||
|
|
@ -175,6 +175,12 @@
|
||||||
color: #dbe6f5;
|
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 {
|
.btn.danger {
|
||||||
border-color: rgba(255, 127, 127, 0.3);
|
border-color: rgba(255, 127, 127, 0.3);
|
||||||
background: rgba(255, 127, 127, 0.13);
|
background: rgba(255, 127, 127, 0.13);
|
||||||
|
|
@ -259,14 +265,18 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.kanban-board {
|
.kanban-board {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: repeat(4, minmax(260px, 1fr));
|
flex-wrap: wrap;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
overflow-x: auto;
|
overflow-x: hidden;
|
||||||
padding-bottom: 0.25rem;
|
padding-bottom: 0.25rem;
|
||||||
|
align-items: stretch;
|
||||||
|
align-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kanban-column {
|
.kanban-column {
|
||||||
|
flex: 1 1 300px;
|
||||||
|
min-width: 260px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background: rgba(255, 255, 255, 0.02);
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
|
@ -321,13 +331,22 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-card.draggable {
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kanban-card:active {
|
.kanban-card.draggable:active {
|
||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.kanban-card:focus-visible {
|
||||||
|
outline: 2px solid rgba(137, 178, 255, 0.55);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
.kanban-card-head {
|
.kanban-card-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|
@ -403,11 +422,73 @@
|
||||||
margin-top: 0.1rem;
|
margin-top: 0.1rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: flex-start;
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
flex-wrap: wrap;
|
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 {
|
.kanban-transition-select {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 140px;
|
min-width: 140px;
|
||||||
|
|
@ -1258,6 +1339,45 @@
|
||||||
text-align: right;
|
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 {
|
.request-chat-list li.chat-date-divider {
|
||||||
margin: 0.32rem 0 0.24rem;
|
margin: 0.32rem 0 0.24rem;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
@ -1381,6 +1501,12 @@
|
||||||
width: min(980px, 100%);
|
width: min(980px, 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.request-preview-head-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
.request-preview-body {
|
.request-preview-body {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 280px;
|
min-height: 280px;
|
||||||
|
|
@ -1418,10 +1544,17 @@
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.request-preview-download {
|
.request-preview-download-icon {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workspace-head-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 0.96rem;
|
||||||
|
}
|
||||||
|
|
||||||
.overlay {
|
.overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|
@ -1500,7 +1633,10 @@
|
||||||
|
|
||||||
@media (max-width: 1160px) {
|
@media (max-width: 1160px) {
|
||||||
.cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.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)); }
|
.filters { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
.triple { grid-template-columns: 1fr; }
|
.triple { grid-template-columns: 1fr; }
|
||||||
.config-layout { grid-template-columns: 1fr; }
|
.config-layout { grid-template-columns: 1fr; }
|
||||||
|
|
@ -1522,8 +1658,9 @@
|
||||||
.filters {
|
.filters {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
.kanban-board {
|
.kanban-column {
|
||||||
grid-template-columns: 1fr;
|
flex-basis: 100%;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.filter-toolbar {
|
.filter-toolbar {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,12 @@
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Административная панель • Правовой трекер</title>
|
<title>Административная панель • Правовой трекер</title>
|
||||||
<link rel="stylesheet" href="/admin.css?v=20260225-7">
|
<link rel="stylesheet" href="/admin.css?v=20260225-12">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="admin-root"></div>
|
<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@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="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>
|
</body>
|
||||||
</html>
|
</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 page.locator("aside .menu button[data-section='kanban']").click();
|
||||||
await expect(page.locator("#section-kanban h2")).toHaveText("Канбан заявок");
|
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();
|
const card = page.locator("#section-kanban .kanban-card").filter({ hasText: trackNumber }).first();
|
||||||
await expect(card).toBeVisible();
|
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;
|
const pagesBeforeOpen = context.pages().length;
|
||||||
await page
|
await page.locator("#section-kanban .kanban-card").filter({ hasText: trackNumber }).first().click();
|
||||||
.locator("#section-kanban .kanban-card")
|
|
||||||
.filter({ hasText: trackNumber })
|
|
||||||
.first()
|
|
||||||
.getByRole("button", { name: "Открыть заявку" })
|
|
||||||
.click();
|
|
||||||
await page.waitForTimeout(250);
|
await page.waitForTimeout(250);
|
||||||
await expect.poll(() => context.pages().length).toBe(pagesBeforeOpen);
|
await expect.poll(() => context.pages().length).toBe(pagesBeforeOpen);
|
||||||
await expect(page.locator("#section-request-workspace h2")).toHaveText("Карточка заявки");
|
await expect(page.locator("#section-request-workspace h2")).toContainText("Карточка заявки");
|
||||||
await page.getByRole("button", { name: "Назад к заявкам" }).click();
|
await page.getByRole("button", { name: "Назад" }).click();
|
||||||
await expect(page.locator("#section-requests h2")).toHaveText("Заявки");
|
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 page.waitForTimeout(250);
|
||||||
await expect.poll(() => context.pages().length).toBe(pagesBeforeOpen);
|
await expect.poll(() => context.pages().length).toBe(pagesBeforeOpen);
|
||||||
const requestPage = page;
|
const requestPage = page;
|
||||||
await expect(requestPage.getByRole("heading", { name: "Карточка заявки" })).toBeVisible();
|
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("button", { name: "Назад к заявкам" })).toBeVisible();
|
|
||||||
await expect(requestPage.locator("#request-modal-messages")).toContainText("Сообщение юристу");
|
await expect(requestPage.locator("#request-modal-messages")).toContainText("Сообщение юристу");
|
||||||
await requestPage.getByRole("tab", { name: /Файлы/ }).click();
|
await requestPage.getByRole("tab", { name: /Файлы/ }).click();
|
||||||
await expect(requestPage.locator("#request-modal-files")).toContainText(clientFileName);
|
await expect(requestPage.locator("#request-modal-files")).toContainText(clientFileName);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import os
|
import os
|
||||||
|
import json
|
||||||
import re
|
import re
|
||||||
import unittest
|
import unittest
|
||||||
from datetime import datetime, timedelta, timezone
|
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.quote import Quote
|
||||||
from app.models.request import Request
|
from app.models.request import Request
|
||||||
from app.models.status import Status
|
from app.models.status import Status
|
||||||
|
from app.models.status_group import StatusGroup
|
||||||
from app.models.status_history import StatusHistory
|
from app.models.status_history import StatusHistory
|
||||||
from app.models.topic_data_template import TopicDataTemplate
|
from app.models.topic_data_template import TopicDataTemplate
|
||||||
from app.models.topic import Topic
|
from app.models.topic import Topic
|
||||||
|
|
@ -55,6 +57,7 @@ class AdminUniversalCrudTests(unittest.TestCase):
|
||||||
Quote.__table__.create(bind=cls.engine)
|
Quote.__table__.create(bind=cls.engine)
|
||||||
FormField.__table__.create(bind=cls.engine)
|
FormField.__table__.create(bind=cls.engine)
|
||||||
Request.__table__.create(bind=cls.engine)
|
Request.__table__.create(bind=cls.engine)
|
||||||
|
StatusGroup.__table__.create(bind=cls.engine)
|
||||||
Status.__table__.create(bind=cls.engine)
|
Status.__table__.create(bind=cls.engine)
|
||||||
Message.__table__.create(bind=cls.engine)
|
Message.__table__.create(bind=cls.engine)
|
||||||
Attachment.__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)
|
Attachment.__table__.drop(bind=cls.engine)
|
||||||
Message.__table__.drop(bind=cls.engine)
|
Message.__table__.drop(bind=cls.engine)
|
||||||
Status.__table__.drop(bind=cls.engine)
|
Status.__table__.drop(bind=cls.engine)
|
||||||
|
StatusGroup.__table__.drop(bind=cls.engine)
|
||||||
Request.__table__.drop(bind=cls.engine)
|
Request.__table__.drop(bind=cls.engine)
|
||||||
FormField.__table__.drop(bind=cls.engine)
|
FormField.__table__.drop(bind=cls.engine)
|
||||||
Quote.__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(Attachment))
|
||||||
db.execute(delete(Message))
|
db.execute(delete(Message))
|
||||||
db.execute(delete(Request))
|
db.execute(delete(Request))
|
||||||
|
db.execute(delete(StatusGroup))
|
||||||
db.execute(delete(Client))
|
db.execute(delete(Client))
|
||||||
db.execute(delete(Status))
|
db.execute(delete(Status))
|
||||||
db.execute(delete(FormField))
|
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()]
|
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"})
|
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):
|
def test_admin_table_catalog_lists_db_tables_for_dynamic_references(self):
|
||||||
admin_headers = self._auth_headers("ADMIN")
|
admin_headers = self._auth_headers("ADMIN")
|
||||||
response = self.client.get("/api/admin/crud/meta/tables", headers=admin_headers)
|
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("clients", by_table)
|
||||||
self.assertIn("quotes", by_table)
|
self.assertIn("quotes", by_table)
|
||||||
self.assertIn("statuses", by_table)
|
self.assertIn("statuses", by_table)
|
||||||
|
self.assertIn("status_groups", by_table)
|
||||||
|
|
||||||
self.assertEqual(by_table["requests"]["section"], "main")
|
self.assertEqual(by_table["requests"]["section"], "main")
|
||||||
self.assertEqual(by_table["invoices"]["section"], "main")
|
self.assertEqual(by_table["invoices"]["section"], "main")
|
||||||
self.assertEqual(by_table["quotes"]["section"], "dictionary")
|
self.assertEqual(by_table["quotes"]["section"], "dictionary")
|
||||||
self.assertTrue(by_table["quotes"]["default_sort"])
|
self.assertTrue(by_table["quotes"]["default_sort"])
|
||||||
self.assertEqual(by_table["quotes"]["label"], "Цитаты")
|
self.assertEqual(by_table["quotes"]["label"], "Цитаты")
|
||||||
|
self.assertEqual(by_table["status_groups"]["label"], "Группы статусов")
|
||||||
self.assertEqual(by_table["request_data_requirements"]["label"], "Требования данных заявки")
|
self.assertEqual(by_table["request_data_requirements"]["label"], "Требования данных заявки")
|
||||||
quotes_columns = {col["name"]: col for col in (by_table["quotes"].get("columns") or [])}
|
quotes_columns = {col["name"]: col for col in (by_table["quotes"].get("columns") or [])}
|
||||||
self.assertEqual(quotes_columns["author"]["label"], "Автор")
|
self.assertEqual(quotes_columns["author"]["label"], "Автор")
|
||||||
self.assertEqual(quotes_columns["sort_order"]["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 [])))
|
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():
|
for table_name, table_meta in by_table.items():
|
||||||
if table_name in {"requests", "invoices"}:
|
if table_name in {"requests", "invoices"}:
|
||||||
expected_section = "main"
|
expected_section = "main"
|
||||||
|
|
@ -1202,12 +1268,50 @@ class AdminUniversalCrudTests(unittest.TestCase):
|
||||||
|
|
||||||
def test_requests_kanban_returns_grouped_cards_and_role_scope(self):
|
def test_requests_kanban_returns_grouped_cards_and_role_scope(self):
|
||||||
with self.SessionLocal() as db:
|
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(
|
db.add_all(
|
||||||
[
|
[
|
||||||
Status(code="NEW", name="Новая", enabled=True, sort_order=1, is_terminal=False, kind="DEFAULT"),
|
Status(
|
||||||
Status(code="IN_PROGRESS", name="В работе", enabled=True, sort_order=2, is_terminal=False, kind="DEFAULT"),
|
code="NEW",
|
||||||
Status(code="WAITING_CLIENT", name="Ожидание клиента", enabled=True, sort_order=3, is_terminal=False, kind="DEFAULT"),
|
name="Новая",
|
||||||
Status(code="CLOSED", name="Закрыта", enabled=True, sort_order=4, is_terminal=True, kind="DEFAULT"),
|
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))
|
db.add(Topic(code="civil-law", name="Гражданское право", enabled=True, sort_order=1))
|
||||||
|
|
@ -1287,10 +1391,21 @@ class AdminUniversalCrudTests(unittest.TestCase):
|
||||||
extra_fields={},
|
extra_fields={},
|
||||||
assigned_lawyer_id=str(lawyer_other.id),
|
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()
|
db.flush()
|
||||||
|
|
||||||
entered_progress_at = datetime.now(timezone.utc) - timedelta(hours=2)
|
entered_progress_at = datetime.now(timezone.utc) - timedelta(hours=2)
|
||||||
|
entered_overdue_at = datetime.now(timezone.utc) - timedelta(hours=30)
|
||||||
db.add(
|
db.add(
|
||||||
StatusHistory(
|
StatusHistory(
|
||||||
request_id=request_progress.id,
|
request_id=request_progress.id,
|
||||||
|
|
@ -1301,31 +1416,46 @@ class AdminUniversalCrudTests(unittest.TestCase):
|
||||||
created_at=entered_progress_at,
|
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()
|
db.commit()
|
||||||
|
|
||||||
request_new_id = str(request_new.id)
|
request_new_id = str(request_new.id)
|
||||||
request_progress_id = str(request_progress.id)
|
request_progress_id = str(request_progress.id)
|
||||||
request_waiting_id = str(request_waiting.id)
|
request_waiting_id = str(request_waiting.id)
|
||||||
|
request_overdue_id = str(request_overdue.id)
|
||||||
lawyer_main_id = str(lawyer_main.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_headers = self._auth_headers("ADMIN", email="root@example.com")
|
||||||
admin_response = self.client.get("/api/admin/requests/kanban?limit=100", headers=admin_headers)
|
admin_response = self.client.get("/api/admin/requests/kanban?limit=100", headers=admin_headers)
|
||||||
self.assertEqual(admin_response.status_code, 200)
|
self.assertEqual(admin_response.status_code, 200)
|
||||||
admin_payload = admin_response.json()
|
admin_payload = admin_response.json()
|
||||||
self.assertEqual(admin_payload["scope"], "ADMIN")
|
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 [])}
|
rows = {item["id"]: item for item in (admin_payload.get("rows") or [])}
|
||||||
self.assertIn(request_new_id, rows)
|
self.assertIn(request_new_id, rows)
|
||||||
self.assertIn(request_progress_id, rows)
|
self.assertIn(request_progress_id, rows)
|
||||||
self.assertIn(request_waiting_id, rows)
|
self.assertIn(request_waiting_id, rows)
|
||||||
self.assertEqual(rows[request_new_id]["status_group"], "NEW")
|
self.assertIn(request_overdue_id, rows)
|
||||||
self.assertEqual(rows[request_progress_id]["status_group"], "IN_PROGRESS")
|
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)
|
self.assertEqual(rows[request_progress_id]["assigned_lawyer_id"], lawyer_main_id)
|
||||||
transitions = rows[request_progress_id].get("available_transitions") or []
|
transitions = rows[request_progress_id].get("available_transitions") or []
|
||||||
self.assertTrue(any(item.get("to_status") == "WAITING_CLIENT" for item in transitions))
|
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.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.assertIsNotNone(rows[request_progress_id]["sla_deadline_at"])
|
||||||
self.assertFalse(bool(admin_payload.get("truncated")))
|
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_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)
|
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 [])}
|
lawyer_rows = {item["id"]: item for item in (lawyer_payload.get("rows") or [])}
|
||||||
self.assertIn(request_new_id, lawyer_rows)
|
self.assertIn(request_new_id, lawyer_rows)
|
||||||
self.assertIn(request_progress_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.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):
|
def test_lawyer_can_claim_unassigned_request_and_takeover_is_forbidden(self):
|
||||||
with self.SessionLocal() as db:
|
with self.SessionLocal() as db:
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,7 @@ class MigrationTests(unittest.TestCase):
|
||||||
"table_availability",
|
"table_availability",
|
||||||
"topics",
|
"topics",
|
||||||
"statuses",
|
"statuses",
|
||||||
|
"status_groups",
|
||||||
"form_fields",
|
"form_fields",
|
||||||
"topic_required_fields",
|
"topic_required_fields",
|
||||||
"topic_data_templates",
|
"topic_data_templates",
|
||||||
|
|
@ -108,7 +109,7 @@ class MigrationTests(unittest.TestCase):
|
||||||
def test_alembic_version_is_set(self):
|
def test_alembic_version_is_set(self):
|
||||||
with self.engine.connect() as conn:
|
with self.engine.connect() as conn:
|
||||||
version = conn.execute(text("SELECT version_num FROM alembic_version")).scalar_one()
|
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):
|
def test_responsible_column_exists_in_all_domain_tables(self):
|
||||||
tables = {
|
tables = {
|
||||||
|
|
@ -117,6 +118,7 @@ class MigrationTests(unittest.TestCase):
|
||||||
"table_availability",
|
"table_availability",
|
||||||
"topics",
|
"topics",
|
||||||
"statuses",
|
"statuses",
|
||||||
|
"status_groups",
|
||||||
"form_fields",
|
"form_fields",
|
||||||
"topic_required_fields",
|
"topic_required_fields",
|
||||||
"topic_data_templates",
|
"topic_data_templates",
|
||||||
|
|
@ -202,6 +204,15 @@ class MigrationTests(unittest.TestCase):
|
||||||
columns = {column["name"] for column in self.inspector.get_columns("statuses")}
|
columns = {column["name"] for column in self.inspector.get_columns("statuses")}
|
||||||
self.assertIn("kind", columns)
|
self.assertIn("kind", columns)
|
||||||
self.assertIn("invoice_template", 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):
|
def test_clients_contains_core_columns(self):
|
||||||
columns = {column["name"] for column in self.inspector.get_columns("clients")}
|
columns = {column["name"] for column in self.inspector.get_columns("clients")}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue