mirror of
https://github.com/TronoSfera/Law.git
synced 2026-05-18 10:03:45 +03:00
Test-4 commit
This commit is contained in:
parent
90450b8918
commit
7754a6fedf
16 changed files with 2286 additions and 258 deletions
27
alembic/versions/0017_add_transition_requirements.py
Normal file
27
alembic/versions/0017_add_transition_requirements.py
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
"""add transition requirements fields for status designer
|
||||||
|
|
||||||
|
Revision ID: 0017_transition_requirements
|
||||||
|
Revises: 0016_table_availability
|
||||||
|
Create Date: 2026-02-25
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0017_transition_requirements"
|
||||||
|
down_revision = "0016_table_availability"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.add_column("topic_status_transitions", sa.Column("required_data_keys", sa.JSON(), nullable=True))
|
||||||
|
op.add_column("topic_status_transitions", sa.Column("required_mime_types", sa.JSON(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_column("topic_status_transitions", "required_mime_types")
|
||||||
|
op.drop_column("topic_status_transitions", "required_data_keys")
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import importlib
|
import importlib
|
||||||
|
import json
|
||||||
import pkgutil
|
import pkgutil
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import date, datetime, timezone
|
from datetime import date, datetime, timezone
|
||||||
|
|
@ -52,6 +53,7 @@ from app.services.request_read_markers import (
|
||||||
from app.services.request_status import apply_status_change_effects
|
from app.services.request_status import apply_status_change_effects
|
||||||
from app.services.status_flow import transition_allowed_for_topic
|
from app.services.status_flow import transition_allowed_for_topic
|
||||||
from app.services.request_templates import validate_required_topic_fields_or_400
|
from app.services.request_templates import validate_required_topic_fields_or_400
|
||||||
|
from app.services.status_transition_requirements import validate_transition_requirements_or_400
|
||||||
from app.services.billing_flow import apply_billing_transition_effects, normalize_status_kind_or_400
|
from app.services.billing_flow import apply_billing_transition_effects, normalize_status_kind_or_400
|
||||||
from app.services.universal_query import apply_universal_query
|
from app.services.universal_query import apply_universal_query
|
||||||
|
|
||||||
|
|
@ -399,6 +401,8 @@ def _column_label(table_name: str, column_name: str) -> str:
|
||||||
"options": "Опции",
|
"options": "Опции",
|
||||||
"field_key": "Поле формы",
|
"field_key": "Поле формы",
|
||||||
"sla_hours": "SLA (часы)",
|
"sla_hours": "SLA (часы)",
|
||||||
|
"required_data_keys": "Обязательные данные шага",
|
||||||
|
"required_mime_types": "Обязательные файлы шага",
|
||||||
"avatar_url": "Аватар",
|
"avatar_url": "Аватар",
|
||||||
"file_name": "Имя файла",
|
"file_name": "Имя файла",
|
||||||
"mime_type": "MIME-тип",
|
"mime_type": "MIME-тип",
|
||||||
|
|
@ -742,6 +746,40 @@ def _as_positive_int_or_400(value: Any, field_name: str) -> int:
|
||||||
return number
|
return number
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_string_list_or_400(value: Any, field_name: str) -> list[str] | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
source = value
|
||||||
|
if isinstance(source, str):
|
||||||
|
text = source.strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
if text.startswith("["):
|
||||||
|
try:
|
||||||
|
source = json.loads(text)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
raise HTTPException(status_code=400, detail=f'Поле "{field_name}" должно быть JSON-массивом строк')
|
||||||
|
else:
|
||||||
|
source = [chunk.strip() for chunk in text.replace("\n", ",").split(",")]
|
||||||
|
|
||||||
|
if not isinstance(source, (list, tuple, set)):
|
||||||
|
raise HTTPException(status_code=400, detail=f'Поле "{field_name}" должно быть массивом строк')
|
||||||
|
|
||||||
|
out: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for item in source:
|
||||||
|
text = str(item or "").strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
lowered = text.lower()
|
||||||
|
if lowered in seen:
|
||||||
|
continue
|
||||||
|
seen.add(lowered)
|
||||||
|
out.append(text)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _apply_topic_required_fields_fields(db: Session, payload: dict[str, Any]) -> dict[str, Any]:
|
def _apply_topic_required_fields_fields(db: Session, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
data = dict(payload)
|
data = dict(payload)
|
||||||
if "topic_code" in data:
|
if "topic_code" in data:
|
||||||
|
|
@ -831,6 +869,10 @@ def _apply_topic_status_transitions_fields(db: Session, payload: dict[str, Any])
|
||||||
data["sla_hours"] = None
|
data["sla_hours"] = None
|
||||||
else:
|
else:
|
||||||
data["sla_hours"] = _as_positive_int_or_400(raw, "sla_hours")
|
data["sla_hours"] = _as_positive_int_or_400(raw, "sla_hours")
|
||||||
|
if "required_data_keys" in data:
|
||||||
|
data["required_data_keys"] = _normalize_string_list_or_400(data.get("required_data_keys"), "required_data_keys")
|
||||||
|
if "required_mime_types" in data:
|
||||||
|
data["required_mime_types"] = _normalize_string_list_or_400(data.get("required_mime_types"), "required_mime_types")
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
@ -1432,6 +1474,16 @@ def update_row(
|
||||||
detail="Переход статуса не разрешен для выбранной темы",
|
detail="Переход статуса не разрешен для выбранной темы",
|
||||||
)
|
)
|
||||||
if before_status != after_status and isinstance(row, Request):
|
if before_status != after_status and isinstance(row, Request):
|
||||||
|
extra_fields_override = clean_payload.get("extra_fields")
|
||||||
|
if not isinstance(extra_fields_override, dict):
|
||||||
|
extra_fields_override = row.extra_fields if isinstance(row.extra_fields, dict) else None
|
||||||
|
validate_transition_requirements_or_400(
|
||||||
|
db,
|
||||||
|
row,
|
||||||
|
from_status=before_status,
|
||||||
|
to_status=after_status,
|
||||||
|
extra_fields_override=extra_fields_override,
|
||||||
|
)
|
||||||
billing_note = apply_billing_transition_effects(
|
billing_note = apply_billing_transition_effects(
|
||||||
db,
|
db,
|
||||||
req=row,
|
req=row,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy import case, or_, update
|
from sqlalchemy import case, or_, update
|
||||||
|
|
@ -33,11 +33,86 @@ from app.services.request_read_markers import EVENT_STATUS, clear_unread_for_law
|
||||||
from app.services.request_status import actor_admin_uuid, apply_status_change_effects
|
from app.services.request_status import actor_admin_uuid, apply_status_change_effects
|
||||||
from app.services.status_flow import transition_allowed_for_topic
|
from app.services.status_flow import transition_allowed_for_topic
|
||||||
from app.services.request_templates import validate_required_topic_fields_or_400
|
from app.services.request_templates import validate_required_topic_fields_or_400
|
||||||
|
from app.services.status_transition_requirements import normalize_string_list, validate_transition_requirements_or_400
|
||||||
from app.services.billing_flow import apply_billing_transition_effects
|
from app.services.billing_flow import apply_billing_transition_effects
|
||||||
from app.services.universal_query import apply_universal_query
|
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 = {
|
||||||
|
"NEW": "Новые",
|
||||||
|
"IN_PROGRESS": "В работе",
|
||||||
|
"WAITING": "Ожидание",
|
||||||
|
"DONE": "Завершены",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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}
|
||||||
|
|
||||||
|
|
||||||
|
def _kanban_group_for_status(status_code: str, status_meta: dict[str, object]) -> str:
|
||||||
|
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"
|
||||||
|
if kind == "PAID":
|
||||||
|
return "DONE"
|
||||||
|
if code.startswith("NEW") or "НОВ" in name:
|
||||||
|
return "NEW"
|
||||||
|
waiting_tokens = ("WAIT", "PEND", "HOLD", "SUSPEND", "BLOCK")
|
||||||
|
waiting_ru_tokens = ("ОЖИД", "ПАУЗ", "СОГЛАС", "ОПЛАТ", "СУД")
|
||||||
|
if kind == "INVOICE":
|
||||||
|
return "WAITING"
|
||||||
|
if any(token in code for token in waiting_tokens) or any(token in name for token in waiting_ru_tokens):
|
||||||
|
return "WAITING"
|
||||||
|
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"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_datetime_safe(value: object) -> datetime | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||||
|
text = str(value).strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
if text.endswith("Z"):
|
||||||
|
text = text[:-1] + "+00:00"
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(text)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_case_deadline(extra_fields: object) -> datetime | None:
|
||||||
|
if not isinstance(extra_fields, dict):
|
||||||
|
return None
|
||||||
|
deadline_keys = (
|
||||||
|
"deadline_at",
|
||||||
|
"deadline",
|
||||||
|
"due_date",
|
||||||
|
"due_at",
|
||||||
|
"case_deadline",
|
||||||
|
"court_date",
|
||||||
|
"hearing_date",
|
||||||
|
"next_action_deadline",
|
||||||
|
)
|
||||||
|
for key in deadline_keys:
|
||||||
|
parsed = _parse_datetime_safe(extra_fields.get(key))
|
||||||
|
if parsed:
|
||||||
|
return parsed
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _request_uuid_or_400(request_id: str) -> UUID:
|
def _request_uuid_or_400(request_id: str) -> UUID:
|
||||||
|
|
@ -140,6 +215,216 @@ def query_requests(uq: UniversalQuery, db: Session = Depends(get_db), admin=Depe
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/kanban")
|
||||||
|
def get_requests_kanban(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
admin=Depends(require_role("ADMIN", "LAWYER")),
|
||||||
|
limit: int = Query(default=400, ge=1, le=1000),
|
||||||
|
):
|
||||||
|
role = str(admin.get("role") or "").upper()
|
||||||
|
actor = str(admin.get("sub") or "").strip()
|
||||||
|
|
||||||
|
base_query = db.query(Request)
|
||||||
|
if role == "LAWYER":
|
||||||
|
if not actor:
|
||||||
|
raise HTTPException(status_code=401, detail="Некорректный токен")
|
||||||
|
base_query = base_query.filter(
|
||||||
|
or_(
|
||||||
|
Request.assigned_lawyer_id == actor,
|
||||||
|
Request.assigned_lawyer_id.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
request_rows: list[Request] = (
|
||||||
|
base_query
|
||||||
|
.order_by(Request.created_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
total = int(base_query.count() or 0)
|
||||||
|
|
||||||
|
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()}
|
||||||
|
|
||||||
|
transition_rows: list[TopicStatusTransition] = []
|
||||||
|
if topic_codes:
|
||||||
|
transition_rows = (
|
||||||
|
db.query(TopicStatusTransition)
|
||||||
|
.filter(
|
||||||
|
TopicStatusTransition.enabled.is_(True),
|
||||||
|
TopicStatusTransition.topic_code.in_(list(topic_codes)),
|
||||||
|
)
|
||||||
|
.order_by(
|
||||||
|
TopicStatusTransition.topic_code.asc(),
|
||||||
|
TopicStatusTransition.from_status.asc(),
|
||||||
|
TopicStatusTransition.sort_order.asc(),
|
||||||
|
TopicStatusTransition.to_status.asc(),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for row in transition_rows:
|
||||||
|
from_code = str(row.from_status or "").strip()
|
||||||
|
to_code = str(row.to_status or "").strip()
|
||||||
|
if from_code:
|
||||||
|
status_codes.add(from_code)
|
||||||
|
if to_code:
|
||||||
|
status_codes.add(to_code)
|
||||||
|
|
||||||
|
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_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),
|
||||||
|
}
|
||||||
|
for 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()}
|
||||||
|
lawyer_name_map: dict[str, str] = {}
|
||||||
|
if assigned_ids:
|
||||||
|
valid_lawyer_ids: list[UUID] = []
|
||||||
|
for raw in assigned_ids:
|
||||||
|
try:
|
||||||
|
valid_lawyer_ids.append(UUID(raw))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if valid_lawyer_ids:
|
||||||
|
lawyer_rows = db.query(AdminUser).filter(AdminUser.id.in_(valid_lawyer_ids)).all()
|
||||||
|
lawyer_name_map = {
|
||||||
|
str(row.id): str(row.name or row.email or row.id)
|
||||||
|
for row in lawyer_rows
|
||||||
|
}
|
||||||
|
|
||||||
|
history_rows: list[StatusHistory] = []
|
||||||
|
if request_ids:
|
||||||
|
history_rows = (
|
||||||
|
db.query(StatusHistory)
|
||||||
|
.filter(StatusHistory.request_id.in_(request_ids))
|
||||||
|
.order_by(StatusHistory.request_id.asc(), StatusHistory.created_at.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
current_status_changed_at: dict[str, datetime] = {}
|
||||||
|
previous_status_by_request: dict[str, str] = {}
|
||||||
|
for row in history_rows:
|
||||||
|
request_id = str(row.request_id)
|
||||||
|
request_row = request_id_to_row.get(request_id)
|
||||||
|
if request_row is None:
|
||||||
|
continue
|
||||||
|
current_status = str(request_row.status_code or "").strip()
|
||||||
|
to_status = str(row.to_status or "").strip()
|
||||||
|
if not current_status or to_status != current_status:
|
||||||
|
continue
|
||||||
|
if request_id not in current_status_changed_at and row.created_at:
|
||||||
|
current_status_changed_at[request_id] = row.created_at
|
||||||
|
previous_status_by_request[request_id] = str(row.from_status or "").strip()
|
||||||
|
|
||||||
|
transitions_by_key: dict[tuple[str, str], list[TopicStatusTransition]] = {}
|
||||||
|
transitions_to_key: dict[tuple[str, str], list[TopicStatusTransition]] = {}
|
||||||
|
for row in transition_rows:
|
||||||
|
topic_code = str(row.topic_code or "").strip()
|
||||||
|
from_status = str(row.from_status or "").strip()
|
||||||
|
to_status = str(row.to_status or "").strip()
|
||||||
|
if not topic_code or not from_status or not to_status:
|
||||||
|
continue
|
||||||
|
transitions_by_key.setdefault((topic_code, from_status), []).append(row)
|
||||||
|
transitions_to_key.setdefault((topic_code, to_status), []).append(row)
|
||||||
|
|
||||||
|
items: list[dict[str, object]] = []
|
||||||
|
group_totals = {key: 0 for key in KANBAN_GROUP_LABELS.keys()}
|
||||||
|
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
|
||||||
|
|
||||||
|
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)
|
||||||
|
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),
|
||||||
|
"sla_hours": transition.sla_hours,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
case_deadline = _extract_case_deadline(row.extra_fields)
|
||||||
|
entered_at = current_status_changed_at.get(request_id) or row.created_at
|
||||||
|
previous_status = previous_status_by_request.get(request_id)
|
||||||
|
transition_candidates = transitions_to_key.get((topic_code, status_code), [])
|
||||||
|
matched_transition = None
|
||||||
|
if previous_status:
|
||||||
|
for transition in transition_candidates:
|
||||||
|
if str(transition.from_status or "").strip() == previous_status:
|
||||||
|
matched_transition = transition
|
||||||
|
break
|
||||||
|
if matched_transition is None and transition_candidates:
|
||||||
|
matched_transition = transition_candidates[0]
|
||||||
|
|
||||||
|
sla_deadline = None
|
||||||
|
if entered_at and matched_transition and matched_transition.sla_hours is not None:
|
||||||
|
sla_deadline = entered_at + timedelta(hours=int(matched_transition.sla_hours))
|
||||||
|
|
||||||
|
assigned_id = str(row.assigned_lawyer_id or "").strip() or None
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"id": request_id,
|
||||||
|
"track_number": row.track_number,
|
||||||
|
"client_name": row.client_name,
|
||||||
|
"client_phone": row.client_phone,
|
||||||
|
"topic_code": row.topic_code,
|
||||||
|
"status_code": status_code,
|
||||||
|
"status_name": str(status_meta.get("name") or status_code),
|
||||||
|
"status_group": status_group,
|
||||||
|
"assigned_lawyer_id": assigned_id,
|
||||||
|
"assigned_lawyer_name": lawyer_name_map.get(assigned_id or "", assigned_id),
|
||||||
|
"description": row.description,
|
||||||
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||||
|
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||||
|
"lawyer_has_unread_updates": bool(row.lawyer_has_unread_updates),
|
||||||
|
"lawyer_unread_event_type": row.lawyer_unread_event_type,
|
||||||
|
"client_has_unread_updates": bool(row.client_has_unread_updates),
|
||||||
|
"client_unread_event_type": row.client_unread_event_type,
|
||||||
|
"case_deadline_at": case_deadline.isoformat() if case_deadline else None,
|
||||||
|
"sla_deadline_at": sla_deadline.isoformat() if sla_deadline else None,
|
||||||
|
"available_transitions": available_transitions,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
columns = [
|
||||||
|
{
|
||||||
|
"key": key,
|
||||||
|
"label": label,
|
||||||
|
"total": int(group_totals.get(key, 0)),
|
||||||
|
}
|
||||||
|
for key, label in KANBAN_GROUP_LABELS.items()
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"scope": role,
|
||||||
|
"rows": items,
|
||||||
|
"columns": columns,
|
||||||
|
"total": total,
|
||||||
|
"limit": int(limit),
|
||||||
|
"truncated": bool(total > len(items)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
def create_request(payload: RequestAdminCreate, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER"))):
|
def create_request(payload: RequestAdminCreate, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER"))):
|
||||||
actor_role = str(admin.get("role") or "").upper()
|
actor_role = str(admin.get("role") or "").upper()
|
||||||
|
|
@ -227,6 +512,13 @@ def update_request(
|
||||||
next_status,
|
next_status,
|
||||||
):
|
):
|
||||||
raise HTTPException(status_code=400, detail="Переход статуса не разрешен для выбранной темы")
|
raise HTTPException(status_code=400, detail="Переход статуса не разрешен для выбранной темы")
|
||||||
|
validate_transition_requirements_or_400(
|
||||||
|
db,
|
||||||
|
row,
|
||||||
|
from_status=old_status,
|
||||||
|
to_status=next_status,
|
||||||
|
extra_fields_override=row.extra_fields if isinstance(row.extra_fields, dict) else None,
|
||||||
|
)
|
||||||
billing_note = apply_billing_transition_effects(
|
billing_note = apply_billing_transition_effects(
|
||||||
db,
|
db,
|
||||||
req=row,
|
req=row,
|
||||||
|
|
@ -418,7 +710,7 @@ def get_request_status_route(
|
||||||
|
|
||||||
add_code(current_status)
|
add_code(current_status)
|
||||||
|
|
||||||
transition_by_to_status: dict[str, dict[str, str | int | None]] = {}
|
transition_by_to_status: dict[str, dict[str, object]] = {}
|
||||||
for row in transitions:
|
for row in transitions:
|
||||||
to_code = str(row.to_status or "").strip()
|
to_code = str(row.to_status or "").strip()
|
||||||
if not to_code:
|
if not to_code:
|
||||||
|
|
@ -428,6 +720,8 @@ def get_request_status_route(
|
||||||
transition_by_to_status[to_code] = {
|
transition_by_to_status[to_code] = {
|
||||||
"from_status": str(row.from_status or "").strip() or None,
|
"from_status": str(row.from_status or "").strip() or None,
|
||||||
"sla_hours": row.sla_hours,
|
"sla_hours": row.sla_hours,
|
||||||
|
"required_data_keys": normalize_string_list(row.required_data_keys),
|
||||||
|
"required_mime_types": normalize_string_list(row.required_mime_types),
|
||||||
"sort_order": int(row.sort_order or 0),
|
"sort_order": int(row.sort_order or 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -461,6 +755,12 @@ def get_request_status_route(
|
||||||
sla_hours = transition_meta.get("sla_hours")
|
sla_hours = transition_meta.get("sla_hours")
|
||||||
if sla_hours is not None:
|
if sla_hours is not None:
|
||||||
note_parts.append(f"SLA: {sla_hours} ч")
|
note_parts.append(f"SLA: {sla_hours} ч")
|
||||||
|
required_data_keys = transition_meta.get("required_data_keys") or []
|
||||||
|
if required_data_keys:
|
||||||
|
note_parts.append("Данные: " + ", ".join(str(item) for item in required_data_keys))
|
||||||
|
required_mime_types = transition_meta.get("required_mime_types") or []
|
||||||
|
if required_mime_types:
|
||||||
|
note_parts.append("Файлы: " + ", ".join(str(item) for item in required_mime_types))
|
||||||
kind = str(meta.get("kind") or "DEFAULT")
|
kind = str(meta.get("kind") or "DEFAULT")
|
||||||
if kind == "INVOICE":
|
if kind == "INVOICE":
|
||||||
note_parts.append("Этап выставления счета")
|
note_parts.append("Этап выставления счета")
|
||||||
|
|
@ -474,6 +774,8 @@ def get_request_status_route(
|
||||||
"kind": kind,
|
"kind": kind,
|
||||||
"state": state,
|
"state": state,
|
||||||
"sla_hours": sla_hours,
|
"sla_hours": sla_hours,
|
||||||
|
"required_data_keys": required_data_keys,
|
||||||
|
"required_mime_types": required_mime_types,
|
||||||
"changed_at": changed_at_by_status.get(code),
|
"changed_at": changed_at_by_status.get(code),
|
||||||
"note": " • ".join(note_parts),
|
"note": " • ".join(note_parts),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
from sqlalchemy import String, Integer, Boolean, UniqueConstraint
|
from sqlalchemy import String, Integer, Boolean, UniqueConstraint, JSON
|
||||||
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
|
||||||
|
|
@ -21,4 +21,6 @@ class TopicStatusTransition(Base, UUIDMixin, TimestampMixin):
|
||||||
to_status: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
to_status: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
sla_hours: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
sla_hours: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
required_data_keys: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
|
||||||
|
required_mime_types: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
|
||||||
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
|
|
||||||
120
app/services/status_transition_requirements.py
Normal file
120
app/services/status_transition_requirements.py
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.attachment import Attachment
|
||||||
|
from app.models.request import Request
|
||||||
|
from app.models.topic_status_transition import TopicStatusTransition
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_string_list(value: Any) -> list[str]:
|
||||||
|
if not isinstance(value, (list, tuple, set)):
|
||||||
|
return []
|
||||||
|
out: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for item in value:
|
||||||
|
text = str(item or "").strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
lowered = text.lower()
|
||||||
|
if lowered in seen:
|
||||||
|
continue
|
||||||
|
seen.add(lowered)
|
||||||
|
out.append(text)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _is_missing_value(value: Any) -> bool:
|
||||||
|
if value is None:
|
||||||
|
return True
|
||||||
|
if isinstance(value, str):
|
||||||
|
return not value.strip()
|
||||||
|
if isinstance(value, (list, tuple, dict, set)):
|
||||||
|
return len(value) == 0
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _find_transition_rule(
|
||||||
|
db: Session,
|
||||||
|
topic_code: str | None,
|
||||||
|
from_status: str,
|
||||||
|
to_status: str,
|
||||||
|
) -> TopicStatusTransition | None:
|
||||||
|
topic = str(topic_code or "").strip()
|
||||||
|
from_code = str(from_status or "").strip()
|
||||||
|
to_code = str(to_status or "").strip()
|
||||||
|
if not topic or not from_code or not to_code or from_code == to_code:
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
db.query(TopicStatusTransition)
|
||||||
|
.filter(
|
||||||
|
TopicStatusTransition.topic_code == topic,
|
||||||
|
TopicStatusTransition.from_status == from_code,
|
||||||
|
TopicStatusTransition.to_status == to_code,
|
||||||
|
TopicStatusTransition.enabled.is_(True),
|
||||||
|
)
|
||||||
|
.order_by(TopicStatusTransition.sort_order.asc(), TopicStatusTransition.created_at.asc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _mime_matches(requirement: str, value: str) -> bool:
|
||||||
|
required = str(requirement or "").strip().lower()
|
||||||
|
actual = str(value or "").strip().lower()
|
||||||
|
if not required or not actual:
|
||||||
|
return False
|
||||||
|
if required.endswith("/*"):
|
||||||
|
return actual.startswith(required[:-1])
|
||||||
|
return actual == required
|
||||||
|
|
||||||
|
|
||||||
|
def validate_transition_requirements_or_400(
|
||||||
|
db: Session,
|
||||||
|
req: Request,
|
||||||
|
from_status: str,
|
||||||
|
to_status: str,
|
||||||
|
*,
|
||||||
|
extra_fields_override: dict[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
transition = _find_transition_rule(
|
||||||
|
db,
|
||||||
|
topic_code=str(req.topic_code or "").strip() or None,
|
||||||
|
from_status=from_status,
|
||||||
|
to_status=to_status,
|
||||||
|
)
|
||||||
|
if transition is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
required_data_keys = normalize_string_list(transition.required_data_keys)
|
||||||
|
required_mime_types = normalize_string_list(transition.required_mime_types)
|
||||||
|
if not required_data_keys and not required_mime_types:
|
||||||
|
return
|
||||||
|
|
||||||
|
payload = extra_fields_override if isinstance(extra_fields_override, dict) else req.extra_fields
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
payload = {}
|
||||||
|
missing_data_keys = [key for key in required_data_keys if _is_missing_value(payload.get(key))]
|
||||||
|
|
||||||
|
available_mime_types = [
|
||||||
|
str(mime_type or "").strip().lower()
|
||||||
|
for (mime_type,) in db.query(Attachment.mime_type).filter(Attachment.request_id == req.id).all()
|
||||||
|
if str(mime_type or "").strip()
|
||||||
|
]
|
||||||
|
missing_mime_types: list[str] = []
|
||||||
|
for required in required_mime_types:
|
||||||
|
if any(_mime_matches(required, mime) for mime in available_mime_types):
|
||||||
|
continue
|
||||||
|
missing_mime_types.append(required)
|
||||||
|
|
||||||
|
if not missing_data_keys and not missing_mime_types:
|
||||||
|
return
|
||||||
|
|
||||||
|
parts: list[str] = []
|
||||||
|
if missing_data_keys:
|
||||||
|
parts.append("обязательные данные: " + ", ".join(missing_data_keys))
|
||||||
|
if missing_mime_types:
|
||||||
|
parts.append("обязательные файлы: " + ", ".join(missing_mime_types))
|
||||||
|
raise HTTPException(status_code=400, detail="Переход требует заполнения шага: " + "; ".join(parts))
|
||||||
|
|
@ -252,6 +252,178 @@
|
||||||
color: #f6dab0;
|
color: #f6dab0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.kanban-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-board {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(260px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-column {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
min-height: 460px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transition: border-color 0.18s ease, background 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-column.drag-over {
|
||||||
|
border-color: rgba(212, 168, 106, 0.55);
|
||||||
|
background: rgba(212, 168, 106, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-column-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.7rem 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-column-head b {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #e5eefb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-column-head span {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.12rem 0.45rem;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
color: var(--muted);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-column-body {
|
||||||
|
padding: 0.65rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 68vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-card {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
padding: 0.6rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.45rem;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-card:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-card-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-card-head code {
|
||||||
|
color: #f5dbb5;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-status-badge {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: #dce8f9;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
max-width: 68%;
|
||||||
|
text-align: right;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-status-badge.group-new {
|
||||||
|
border-color: rgba(76, 160, 255, 0.5);
|
||||||
|
color: #b8d8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-status-badge.group-in_progress {
|
||||||
|
border-color: rgba(212, 168, 106, 0.5);
|
||||||
|
color: #f8d8aa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-status-badge.group-waiting {
|
||||||
|
border-color: rgba(137, 165, 218, 0.5);
|
||||||
|
color: #c9dbff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-status-badge.group-done {
|
||||||
|
border-color: rgba(77, 190, 147, 0.55);
|
||||||
|
color: #b7efda;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-card-desc {
|
||||||
|
margin: 0;
|
||||||
|
color: #e8f0fb;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 2.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-card-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-text {
|
||||||
|
color: #ffb7b7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-card-actions {
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.45rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-transition-select {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 140px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: #dbe7f8;
|
||||||
|
padding: 0.34rem 0.55rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-empty {
|
||||||
|
margin: 0.3rem 0 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.filters {
|
.filters {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
|
@ -609,6 +781,116 @@
|
||||||
min-width: 640px;
|
min-width: 640px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-designer {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.65rem;
|
||||||
|
background: rgba(255, 255, 255, 0.015);
|
||||||
|
margin-bottom: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-designer-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.7rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-designer-head h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-designer-head p {
|
||||||
|
margin: 0.3rem 0 0;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-designer-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-designer-controls select {
|
||||||
|
min-width: 220px;
|
||||||
|
max-width: 340px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-designer-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-node-card {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.52rem;
|
||||||
|
background: rgba(255, 255, 255, 0.018);
|
||||||
|
display: grid;
|
||||||
|
gap: 0.46rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-node-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-node-head b {
|
||||||
|
display: block;
|
||||||
|
color: #e6effd;
|
||||||
|
margin-bottom: 0.1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-node-head code {
|
||||||
|
font-size: 0.74rem;
|
||||||
|
color: #aac0dd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-node-terminal {
|
||||||
|
border: 1px solid rgba(214, 144, 95, 0.45);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.14rem 0.48rem;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: #f3d2a8;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-node-links li {
|
||||||
|
margin-bottom: 0.34rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-link-chip {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
border: 1px solid rgba(118, 145, 184, 0.45);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(45, 67, 98, 0.28);
|
||||||
|
color: #dce8f8;
|
||||||
|
padding: 0.34rem 0.46rem;
|
||||||
|
cursor: pointer;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-link-chip small {
|
||||||
|
color: #9eb1ca;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-link-chip:hover {
|
||||||
|
border-color: rgba(138, 168, 210, 0.7);
|
||||||
|
background: rgba(71, 102, 148, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
.json {
|
.json {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
|
|
@ -836,6 +1118,17 @@
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.file-action-btn {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #d8e4f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-file-link-icon {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
.request-attachments-head {
|
.request-attachments-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -855,8 +1148,44 @@
|
||||||
max-height: 480px;
|
max-height: 480px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.request-chat-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.65rem;
|
||||||
|
margin-bottom: 0.58rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-chat-tabs {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.2rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #9fb3cf;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.26rem 0.64rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn.active {
|
||||||
|
background: rgba(90, 126, 194, 0.38);
|
||||||
|
color: #e8f1ff;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(104, 145, 223, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
.request-chat-list {
|
.request-chat-list {
|
||||||
max-height: 520px;
|
max-height: 470px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -929,16 +1258,18 @@
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
max-width: none;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-date-divider span {
|
.request-chat-list li.chat-date-divider span {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
@ -952,6 +1283,100 @@
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.request-chat-composer-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-attach-btn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-chat-composer-dropzone {
|
||||||
|
border: 1px dashed rgba(111, 140, 186, 0.42);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.55rem;
|
||||||
|
background: rgba(255, 255, 255, 0.015);
|
||||||
|
transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-chat-composer-dropzone.drag-active {
|
||||||
|
border-color: rgba(119, 165, 241, 0.88);
|
||||||
|
background: rgba(87, 128, 206, 0.12);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(119, 165, 241, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-drop-hint {
|
||||||
|
margin-top: 0.38rem;
|
||||||
|
font-size: 0.77rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-pending-files {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-top: -0.18rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-file-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.34rem;
|
||||||
|
border: 1px solid rgba(112, 142, 191, 0.4);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(49, 73, 109, 0.32);
|
||||||
|
padding: 0.2rem 0.34rem 0.2rem 0.42rem;
|
||||||
|
max-width: min(100%, 360px);
|
||||||
|
min-height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-file-icon {
|
||||||
|
color: #a4bde0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-file-name {
|
||||||
|
font-size: 0.79rem;
|
||||||
|
color: #dce7f8;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-file-remove {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid rgba(156, 184, 224, 0.4);
|
||||||
|
background: rgba(17, 29, 44, 0.72);
|
||||||
|
color: #d7e4f8;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-files-tab .request-modal-list {
|
||||||
|
max-height: 520px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-files-tab-actions {
|
||||||
|
margin-top: 0.55rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 0.45rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.request-preview-modal {
|
.request-preview-modal {
|
||||||
width: min(980px, 100%);
|
width: min(980px, 100%);
|
||||||
}
|
}
|
||||||
|
|
@ -1075,6 +1500,7 @@
|
||||||
|
|
||||||
@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)); }
|
||||||
.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; }
|
||||||
|
|
@ -1096,6 +1522,9 @@
|
||||||
.filters {
|
.filters {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
.kanban-board {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
.filter-toolbar {
|
.filter-toolbar {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
|
|
|
||||||
|
|
@ -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-1">
|
<link rel="stylesheet" href="/admin.css?v=20260225-7">
|
||||||
</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-1"></script>
|
<script src="/admin.js?v=20260225-7"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
1180
app/web/admin.jsx
1180
app/web/admin.jsx
File diff suppressed because it is too large
Load diff
Binary file not shown.
|
|
@ -56,8 +56,8 @@
|
||||||
| P35 | сделано | Предпросмотр документов | Добавить предпросмотр загруженных документов (pdf/jpg/mp4) в модальном окне или выделенной зоне страницы заявки, не ломая текущую загрузку/скачивание | Предпросмотр реализован в `client.html` и в рабочей вкладке заявки `admin.jsx` (`/admin.html?view=request&requestId=...`); сохранено действие «Открыть / скачать», добавлен backend тест inline-preview |
|
| P35 | сделано | Предпросмотр документов | Добавить предпросмотр загруженных документов (pdf/jpg/mp4) в модальном окне или выделенной зоне страницы заявки, не ломая текущую загрузку/скачивание | Предпросмотр реализован в `client.html` и в рабочей вкладке заявки `admin.jsx` (`/admin.html?view=request&requestId=...`); сохранено действие «Открыть / скачать», добавлен backend тест inline-preview |
|
||||||
| P36 | сделано | Навигация в админ-панель | Убрать кнопку «Админ-панель» с лендинга, исправить редиректы/роутинг (`/admin`, `/admin.html`) чтобы не было перехода на неверный host/port | Кнопка админки удалена с лендинга; `/admin` корректно переводит на `/admin.html`; добавлен e2e smoke `admin_entry_flow` |
|
| P36 | сделано | Навигация в админ-панель | Убрать кнопку «Админ-панель» с лендинга, исправить редиректы/роутинг (`/admin`, `/admin.html`) чтобы не было перехода на неверный host/port | Кнопка админки удалена с лендинга; `/admin` корректно переводит на `/admin.html`; добавлен e2e smoke `admin_entry_flow` |
|
||||||
| P37 | сделано | Админ-авторизация и креды | Привести к единому правилу bootstrap-креды администратора (`admin@example.com` + согласованный пароль), обновить документацию/контекст и smoke-проверки логина | Реализован bootstrap-login с автосозданием администратора `admin@example.com` / `admin123`; добавлены автотесты `tests/test_admin_auth.py` |
|
| P37 | сделано | Админ-авторизация и креды | Привести к единому правилу bootstrap-креды администратора (`admin@example.com` + согласованный пароль), обновить документацию/контекст и smoke-проверки логина | Реализован bootstrap-login с автосозданием администратора `admin@example.com` / `admin123`; добавлены автотесты `tests/test_admin_auth.py` |
|
||||||
| P38 | к разработке | Конструктор маршрутов статусов | Реализовать для администратора визуальный конструктор маршрутов статусов по каждой теме: вариативные переходы (в т.ч. возврат на предыдущий статус, переход в завершение и альтернативные ветки), SLA на переход, список обязательных документов/данных для закрытия шага | Админ может собрать/изменить граф переходов для темы, задать SLA и требования на каждом шаге; API валидирует переходы и требования, UI отображает и редактирует граф без ручного JSON |
|
| P38 | сделано | Конструктор маршрутов статусов | Реализовать для администратора визуальный конструктор маршрутов статусов по каждой теме: вариативные переходы (в т.ч. возврат на предыдущий статус, переход в завершение и альтернативные ветки), SLA на переход, список обязательных документов/данных для закрытия шага | Добавлен UI-конструктор в справочнике переходов статусов (выбор темы, визуальные карточки статусов и исходящих переходов), расширены поля перехода (`required_data_keys`, `required_mime_types`), переходы валидируются API по требованиям шага (данные заявки + MIME вложений), добавлены backend/e2e тесты |
|
||||||
| P39 | к разработке | Канбан по заявкам (LAWYER/ADMIN) | Реализовать канбан-доску заявок с унификацией разных статусных флоу через группы колонок (например: `Новые`, `В работе`, `Ожидание`, `Завершены`) + карточки заявок с ключевыми данными (дата создания, клиент, описание, новые сообщения/файлы, SLA deadline/дедлайн дела) | Для `LAWYER` видны свои + неназначенные заявки, для `ADMIN` — все юристы и заявки; карточки перетаскиваются/переводятся между допустимыми этапами с серверной валидацией |
|
| P39 | сделано | Канбан по заявкам (LAWYER/ADMIN) | Реализовать канбан-доску заявок с унификацией разных статусных флоу через группы колонок (например: `Новые`, `В работе`, `Ожидание`, `Завершены`) + карточки заявок с ключевыми данными (дата создания, клиент, описание, новые сообщения/файлы, SLA deadline/дедлайн дела) | Добавлен backend endpoint `/api/admin/requests/kanban` (role-scope, группы статусов, SLA/case deadline, допустимые переходы); в `admin.jsx` добавлена секция `Канбан` с карточками, claim для юриста, drag&drop/быстрый перевод по допустимым переходам, open-in-place; покрыто unittest и e2e (`kanban_role_flow`) |
|
||||||
|
|
||||||
## Критический маршрут (обязательный порядок)
|
## Критический маршрут (обязательный порядок)
|
||||||
1. `P07 -> P08 -> P09 -> P10` (полный контур назначения).
|
1. `P07 -> P08 -> P09 -> P10` (полный контур назначения).
|
||||||
|
|
|
||||||
|
|
@ -76,8 +76,8 @@ docker compose run --rm --no-deps \
|
||||||
| P35 | Предпросмотр документов | `tests/test_uploads_s3.py` (`test_public_attachment_object_preview_returns_inline_response`) + Playwright (`e2e/tests/public_client_flow.spec.js`, `e2e/tests/lawyer_role_flow.spec.js`) | `docker compose run --rm backend python -m unittest tests.test_uploads_s3 -v` + Playwright UI-прогон preview в клиенте и во вкладке работы с заявкой юриста/админа через сервис `e2e` |
|
| P35 | Предпросмотр документов | `tests/test_uploads_s3.py` (`test_public_attachment_object_preview_returns_inline_response`) + Playwright (`e2e/tests/public_client_flow.spec.js`, `e2e/tests/lawyer_role_flow.spec.js`) | `docker compose run --rm backend python -m unittest tests.test_uploads_s3 -v` + Playwright UI-прогон preview в клиенте и во вкладке работы с заявкой юриста/админа через сервис `e2e` |
|
||||||
| P36 | Навигация в админку и редиректы | `e2e/tests/admin_entry_flow.spec.js` + redirect checks | Playwright `admin_entry_flow` + `curl -I -H 'Host: localhost:8081' http://localhost:8081/admin` (ожидается `302` и `Location: /admin.html`) + `curl -I http://localhost:8081/admin.html` |
|
| P36 | Навигация в админку и редиректы | `e2e/tests/admin_entry_flow.spec.js` + redirect checks | Playwright `admin_entry_flow` + `curl -I -H 'Host: localhost:8081' http://localhost:8081/admin` (ожидается `302` и `Location: /admin.html`) + `curl -I http://localhost:8081/admin.html` |
|
||||||
| P37 | Единые bootstrap-креды админа | `tests/test_admin_auth.py` + auth smoke (`/api/admin/auth/login`) + docs consistency check | `docker compose run --rm backend python -m unittest tests.test_admin_auth -v` + UI/API login smoke с `admin@example.com` / `admin123` |
|
| P37 | Единые bootstrap-креды админа | `tests/test_admin_auth.py` + auth smoke (`/api/admin/auth/login`) + docs consistency check | `docker compose run --rm backend python -m unittest tests.test_admin_auth -v` + UI/API login smoke с `admin@example.com` / `admin123` |
|
||||||
| P38 | Конструктор маршрутов статусов (темы) | `tests/test_admin_universal_crud.py`, `tests/test_worker_maintenance.py` + новый e2e `e2e/tests/admin_status_designer_flow.spec.js` | backend: валидация графа переходов/SLA/требуемых документов; UI: создание/редактирование ветвлений, возвратов, терминальных переходов |
|
| P38 | Конструктор маршрутов статусов (темы) | `tests/test_admin_universal_crud.py`, `tests/test_worker_maintenance.py` + e2e `e2e/tests/admin_status_designer_flow.spec.js` | `docker compose exec -T backend python -m unittest tests.test_admin_universal_crud tests.test_worker_maintenance -v` + `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend e2e playwright test --config=playwright.config.js e2e/tests/admin_status_designer_flow.spec.js` |
|
||||||
| P39 | Канбан заявок для LAWYER/ADMIN | `tests/test_admin_universal_crud.py`, `tests/test_dashboard_finance.py` + новые e2e `e2e/tests/lawyer_kanban_flow.spec.js`, `e2e/tests/admin_kanban_flow.spec.js` | Проверить группировку статусов, ролевой scope карточек, перемещение по допустимым переходам, отображение дедлайнов SLA и индикаторов новых сообщений/файлов |
|
| P39 | Канбан заявок для LAWYER/ADMIN | `tests/test_admin_universal_crud.py` (`test_requests_kanban_returns_grouped_cards_and_role_scope`) + e2e `e2e/tests/kanban_role_flow.spec.js` | `docker compose exec -T backend python -m unittest tests.test_admin_universal_crud.AdminUniversalCrudTests.test_requests_kanban_returns_grouped_cards_and_role_scope -v` и `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend e2e playwright test --config=playwright.config.js e2e/tests/kanban_role_flow.spec.js`; дополнительно регресс `admin_role_flow`, `lawyer_role_flow` |
|
||||||
|
|
||||||
## Ролевое покрытие (PUBLIC / LAWYER / ADMIN)
|
## Ролевое покрытие (PUBLIC / LAWYER / ADMIN)
|
||||||
### PUBLIC (клиент)
|
### PUBLIC (клиент)
|
||||||
|
|
@ -122,4 +122,4 @@ docker compose run --rm --no-deps \
|
||||||
- `docker compose run --rm backend python -m unittest discover -s tests -p 'test_*.py' -v` — `105 passed`.
|
- `docker compose run --rm backend python -m unittest discover -s tests -p 'test_*.py' -v` — `105 passed`.
|
||||||
- `docker compose run --rm backend python -m compileall app tests alembic` — успешно.
|
- `docker compose run --rm backend python -m compileall app tests alembic` — успешно.
|
||||||
- `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend e2e playwright test tests/admin_entry_flow.spec.js --config=playwright.config.js` — `1 passed`.
|
- `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend e2e playwright test tests/admin_entry_flow.spec.js --config=playwright.config.js` — `1 passed`.
|
||||||
- `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend -e E2E_ADMIN_EMAIL=admin@example.com -e E2E_ADMIN_PASSWORD=admin123 -e E2E_LAWYER_EMAIL=ivan@mail.ru -e E2E_LAWYER_PASSWORD='LawyerPass-123!' e2e playwright test --config=playwright.config.js` — `4 passed` (рольовые e2e: `admin_entry_flow`, `admin_role_flow`, `lawyer_role_flow`, `public_client_flow`).
|
- `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend e2e playwright test --config=playwright.config.js` — `6 passed` (рольовые e2e + конструктор статусов + канбан: `admin_entry_flow`, `admin_role_flow`, `admin_status_designer_flow`, `kanban_role_flow`, `lawyer_role_flow`, `public_client_flow`).
|
||||||
|
|
|
||||||
29
e2e/tests/admin_status_designer_flow.spec.js
Normal file
29
e2e/tests/admin_status_designer_flow.spec.js
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
const { test, expect } = require("@playwright/test");
|
||||||
|
const { loginAdminPanel, openDictionaryTree } = require("./helpers");
|
||||||
|
|
||||||
|
const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || "admin@example.com";
|
||||||
|
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD || "admin123";
|
||||||
|
|
||||||
|
test("admin status designer: open transitions dictionary and prefill topic in create modal", async ({ page }) => {
|
||||||
|
await loginAdminPanel(page, { email: ADMIN_EMAIL, password: ADMIN_PASSWORD });
|
||||||
|
|
||||||
|
await openDictionaryTree(page);
|
||||||
|
await page.locator("aside .menu .menu-tree button").filter({ hasText: /Переходы статусов/ }).first().click();
|
||||||
|
|
||||||
|
await expect(page.locator("#section-config .config-panel h3")).toContainText("Переходы статусов");
|
||||||
|
await expect(page.getByRole("heading", { name: "Конструктор маршрута статусов" })).toBeVisible();
|
||||||
|
|
||||||
|
const topicSelect = page.locator("#status-designer-topic");
|
||||||
|
await expect(topicSelect).toBeVisible();
|
||||||
|
const optionCount = await topicSelect.locator("option").count();
|
||||||
|
expect(optionCount).toBeGreaterThan(1);
|
||||||
|
|
||||||
|
await topicSelect.selectOption({ index: 1 });
|
||||||
|
const selectedTopic = await topicSelect.inputValue();
|
||||||
|
expect(selectedTopic).not.toBe("");
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Добавить переход" }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: /Создание • Переходы статусов/ })).toBeVisible();
|
||||||
|
await expect(page.locator("#record-field-topic_code")).toHaveValue(selectedTopic);
|
||||||
|
await page.locator("#record-overlay .close").click();
|
||||||
|
});
|
||||||
61
e2e/tests/kanban_role_flow.spec.js
Normal file
61
e2e/tests/kanban_role_flow.spec.js
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
const { test, expect } = require("@playwright/test");
|
||||||
|
const {
|
||||||
|
preparePublicSession,
|
||||||
|
createRequestViaLanding,
|
||||||
|
randomPhone,
|
||||||
|
loginAdminPanel,
|
||||||
|
} = require("./helpers");
|
||||||
|
|
||||||
|
const LAWYER_EMAIL = process.env.E2E_LAWYER_EMAIL || "ivan@mail.ru";
|
||||||
|
const LAWYER_PASSWORD = process.env.E2E_LAWYER_PASSWORD || "LawyerPass-123!";
|
||||||
|
|
||||||
|
test("kanban flow via UI: lawyer sees unassigned card, claims and opens request in same tab", async ({ context, page }) => {
|
||||||
|
const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
|
||||||
|
const phone = randomPhone();
|
||||||
|
|
||||||
|
await preparePublicSession(context, page, appUrl, phone);
|
||||||
|
const { trackNumber } = await createRequestViaLanding(page, {
|
||||||
|
phone,
|
||||||
|
description: "Заявка для проверки канбана юриста",
|
||||||
|
});
|
||||||
|
|
||||||
|
await loginAdminPanel(page, { email: LAWYER_EMAIL, password: LAWYER_PASSWORD });
|
||||||
|
await page.locator("aside .menu button[data-section='kanban']").click();
|
||||||
|
await expect(page.locator("#section-kanban h2")).toHaveText("Канбан заявок");
|
||||||
|
|
||||||
|
const card = page.locator("#section-kanban .kanban-card").filter({ hasText: trackNumber }).first();
|
||||||
|
await expect(card).toBeVisible();
|
||||||
|
|
||||||
|
const claimBtn = card.getByRole("button", { name: "Взять в работу" });
|
||||||
|
if (await claimBtn.count()) {
|
||||||
|
await claimBtn.click();
|
||||||
|
await expect(page.locator("#section-kanban .status")).toContainText(/Заявка взята в работу|Канбан обновлен/);
|
||||||
|
}
|
||||||
|
|
||||||
|
const transitionSelect = page.locator("#section-kanban .kanban-card").filter({ hasText: trackNumber }).first().locator(".kanban-transition-select");
|
||||||
|
if (await transitionSelect.count()) {
|
||||||
|
const targetValue = await transitionSelect
|
||||||
|
.first()
|
||||||
|
.locator("option:not([value=''])")
|
||||||
|
.first()
|
||||||
|
.getAttribute("value")
|
||||||
|
.catch(() => "");
|
||||||
|
if (targetValue) {
|
||||||
|
await transitionSelect.first().selectOption(targetValue);
|
||||||
|
await expect(page.locator("#section-kanban .status")).toContainText(/Статус заявки обновлен|Ошибка перехода/);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pagesBeforeOpen = context.pages().length;
|
||||||
|
await page
|
||||||
|
.locator("#section-kanban .kanban-card")
|
||||||
|
.filter({ hasText: trackNumber })
|
||||||
|
.first()
|
||||||
|
.getByRole("button", { name: "Открыть заявку" })
|
||||||
|
.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-requests h2")).toHaveText("Заявки");
|
||||||
|
});
|
||||||
|
|
@ -44,20 +44,23 @@ test("lawyer flow via UI: claim request -> chat and files in request workspace t
|
||||||
await claimBtn.click();
|
await claimBtn.click();
|
||||||
await expect(page.locator("#section-requests .status")).toContainText(/Заявка взята в работу|Список обновлен/);
|
await expect(page.locator("#section-requests .status")).toContainText(/Заявка взята в работу|Список обновлен/);
|
||||||
|
|
||||||
const requestPagePromise = context.waitForEvent("page");
|
const pagesBeforeOpen = context.pages().length;
|
||||||
await row.first().getByRole("button", { name: "Открыть заявку" }).click();
|
await row.first().getByRole("button", { name: "Открыть заявку" }).click();
|
||||||
const requestPage = await requestPagePromise;
|
await page.waitForTimeout(250);
|
||||||
await requestPage.waitForLoadState("domcontentloaded");
|
await expect.poll(() => context.pages().length).toBe(pagesBeforeOpen);
|
||||||
|
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.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 expect(requestPage.locator("#request-modal-files")).toContainText(clientFileName);
|
await expect(requestPage.locator("#request-modal-files")).toContainText(clientFileName);
|
||||||
const clientFileRow = requestPage.locator("#request-modal-files li").filter({ hasText: clientFileName }).first();
|
const clientFileRow = requestPage.locator("#request-modal-files li").filter({ hasText: clientFileName }).first();
|
||||||
await clientFileRow.getByRole("button", { name: /Предпросмотр/ }).click();
|
await clientFileRow.getByRole("button", { name: /Предпросмотр/ }).click();
|
||||||
await expect(requestPage.locator("#request-file-preview-overlay")).toBeVisible();
|
await expect(requestPage.locator("#request-file-preview-overlay")).toBeVisible();
|
||||||
await expect(requestPage.locator("#request-file-preview-overlay .request-preview-frame")).toBeVisible();
|
await expect(requestPage.locator("#request-file-preview-overlay .request-preview-frame")).toBeVisible();
|
||||||
await requestPage.locator("#request-file-preview-overlay .close").click();
|
await requestPage.locator("#request-file-preview-overlay .close").click();
|
||||||
|
await requestPage.getByRole("tab", { name: "Чат" }).click();
|
||||||
|
|
||||||
const lawyerMessage = `Ответ юриста ${Date.now()}`;
|
const lawyerMessage = `Ответ юриста ${Date.now()}`;
|
||||||
await requestPage.locator("#request-modal-message-body").fill(lawyerMessage);
|
await requestPage.locator("#request-modal-message-body").fill(lawyerMessage);
|
||||||
|
|
@ -66,16 +69,28 @@ test("lawyer flow via UI: claim request -> chat and files in request workspace t
|
||||||
await expect(requestPage.locator("#request-modal-messages")).toContainText(lawyerMessage);
|
await expect(requestPage.locator("#request-modal-messages")).toContainText(lawyerMessage);
|
||||||
|
|
||||||
const lawyerFileName = `lawyer-admin-${Date.now()}.pdf`;
|
const lawyerFileName = `lawyer-admin-${Date.now()}.pdf`;
|
||||||
await requestPage.locator("#request-modal-file-input").setInputFiles({
|
const droppedFileName = `lawyer-drop-${Date.now()}.txt`;
|
||||||
name: lawyerFileName,
|
await requestPage.locator("#request-modal-file-input").setInputFiles([
|
||||||
mimeType: "application/pdf",
|
{
|
||||||
buffer: Buffer.from("lawyer file from admin modal", "utf-8"),
|
name: lawyerFileName,
|
||||||
});
|
mimeType: "application/pdf",
|
||||||
await requestPage.locator("#request-modal-file-upload").click();
|
buffer: Buffer.from("lawyer file from admin modal", "utf-8"),
|
||||||
await expect(requestPage.locator("#section-request-workspace .status")).toContainText("Файл загружен");
|
},
|
||||||
|
{
|
||||||
|
name: droppedFileName,
|
||||||
|
mimeType: "text/plain",
|
||||||
|
buffer: Buffer.from("temporary upload file", "utf-8"),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await expect(requestPage.locator(".pending-file-chip").filter({ hasText: lawyerFileName })).toHaveCount(1);
|
||||||
|
await expect(requestPage.locator(".pending-file-chip").filter({ hasText: droppedFileName })).toHaveCount(1);
|
||||||
|
await requestPage.getByRole("button", { name: new RegExp("Удалить файл " + droppedFileName) }).click();
|
||||||
|
await expect(requestPage.locator(".pending-file-chip").filter({ hasText: droppedFileName })).toHaveCount(0);
|
||||||
|
await requestPage.locator("#request-modal-message-send").click();
|
||||||
|
await expect(requestPage.locator("#section-request-workspace .status")).toContainText(/Файлы отправлены|Сообщение и файлы отправлены/);
|
||||||
|
await requestPage.getByRole("tab", { name: /Файлы/ }).click();
|
||||||
await expect(requestPage.locator("#request-modal-files")).toContainText(lawyerFileName);
|
await expect(requestPage.locator("#request-modal-files")).toContainText(lawyerFileName);
|
||||||
await requestPage.close();
|
await expect(requestPage.locator("#request-modal-files")).not.toContainText(droppedFileName);
|
||||||
|
|
||||||
await page.locator("aside .menu button[data-section='requests']").click();
|
await page.locator("aside .menu button[data-section='requests']").click();
|
||||||
await expect(page.locator("#section-requests h2")).toHaveText("Заявки");
|
await expect(page.locator("#section-requests h2")).toHaveText("Заявки");
|
||||||
await page.locator("#section-requests").getByRole("button", { name: "Обновить" }).click();
|
await page.locator("#section-requests").getByRole("button", { name: "Обновить" }).click();
|
||||||
|
|
|
||||||
|
|
@ -865,6 +865,139 @@ class AdminUniversalCrudTests(unittest.TestCase):
|
||||||
)
|
)
|
||||||
self.assertEqual(invalid_same_status.status_code, 400)
|
self.assertEqual(invalid_same_status.status_code, 400)
|
||||||
|
|
||||||
|
def test_admin_can_configure_transition_step_requirements(self):
|
||||||
|
headers = self._auth_headers("ADMIN", email="root@example.com")
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
db.add(Topic(code="civil-designer", name="Гражданское (конструктор)", enabled=True, sort_order=1))
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
Status(code="NEW", name="Новая", enabled=True, sort_order=0, is_terminal=False),
|
||||||
|
Status(code="IN_PROGRESS", name="В работе", enabled=True, sort_order=1, is_terminal=False),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
created = self.client.post(
|
||||||
|
"/api/admin/crud/topic_status_transitions",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"topic_code": "civil-designer",
|
||||||
|
"from_status": "NEW",
|
||||||
|
"to_status": "IN_PROGRESS",
|
||||||
|
"enabled": True,
|
||||||
|
"sort_order": 1,
|
||||||
|
"sla_hours": 24,
|
||||||
|
"required_data_keys": ["passport_scan", "client_address"],
|
||||||
|
"required_mime_types": ["application/pdf", "image/*"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(created.status_code, 201)
|
||||||
|
body = created.json()
|
||||||
|
self.assertEqual(body["required_data_keys"], ["passport_scan", "client_address"])
|
||||||
|
self.assertEqual(body["required_mime_types"], ["application/pdf", "image/*"])
|
||||||
|
|
||||||
|
row_id = body["id"]
|
||||||
|
updated = self.client.patch(
|
||||||
|
f"/api/admin/crud/topic_status_transitions/{row_id}",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"required_data_keys": ["passport_scan"],
|
||||||
|
"required_mime_types": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(updated.status_code, 200)
|
||||||
|
self.assertEqual(updated.json()["required_data_keys"], ["passport_scan"])
|
||||||
|
self.assertEqual(updated.json()["required_mime_types"], [])
|
||||||
|
|
||||||
|
def test_request_status_transition_requires_step_data_and_files(self):
|
||||||
|
headers = self._auth_headers("ADMIN", email="root@example.com")
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
db.add(Topic(code="civil-step-check", name="Проверка шага", enabled=True, sort_order=1))
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
Status(code="NEW", name="Новая", enabled=True, sort_order=0, is_terminal=False),
|
||||||
|
Status(code="IN_PROGRESS", name="В работе", enabled=True, sort_order=1, is_terminal=False),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db.add(
|
||||||
|
TopicStatusTransition(
|
||||||
|
topic_code="civil-step-check",
|
||||||
|
from_status="NEW",
|
||||||
|
to_status="IN_PROGRESS",
|
||||||
|
enabled=True,
|
||||||
|
sort_order=1,
|
||||||
|
sla_hours=48,
|
||||||
|
required_data_keys=["passport_scan"],
|
||||||
|
required_mime_types=["application/pdf"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
req = Request(
|
||||||
|
track_number="TRK-STEP-REQ-1",
|
||||||
|
client_name="Клиент шага",
|
||||||
|
client_phone="+79990042211",
|
||||||
|
topic_code="civil-step-check",
|
||||||
|
status_code="NEW",
|
||||||
|
description="step requirements",
|
||||||
|
extra_fields={},
|
||||||
|
)
|
||||||
|
db.add(req)
|
||||||
|
db.commit()
|
||||||
|
request_id = str(req.id)
|
||||||
|
request_uuid = UUID(request_id)
|
||||||
|
|
||||||
|
blocked_without_all = self.client.patch(
|
||||||
|
f"/api/admin/crud/requests/{request_id}",
|
||||||
|
headers=headers,
|
||||||
|
json={"status_code": "IN_PROGRESS"},
|
||||||
|
)
|
||||||
|
self.assertEqual(blocked_without_all.status_code, 400)
|
||||||
|
self.assertIn("обязательные данные", blocked_without_all.json().get("detail", ""))
|
||||||
|
self.assertIn("обязательные файлы", blocked_without_all.json().get("detail", ""))
|
||||||
|
|
||||||
|
blocked_without_all_legacy = self.client.patch(
|
||||||
|
f"/api/admin/requests/{request_id}",
|
||||||
|
headers=headers,
|
||||||
|
json={"status_code": "IN_PROGRESS"},
|
||||||
|
)
|
||||||
|
self.assertEqual(blocked_without_all_legacy.status_code, 400)
|
||||||
|
self.assertIn("обязательные данные", blocked_without_all_legacy.json().get("detail", ""))
|
||||||
|
|
||||||
|
with_data_only = self.client.patch(
|
||||||
|
f"/api/admin/crud/requests/{request_id}",
|
||||||
|
headers=headers,
|
||||||
|
json={"extra_fields": {"passport_scan": "добавлено"}},
|
||||||
|
)
|
||||||
|
self.assertEqual(with_data_only.status_code, 200)
|
||||||
|
|
||||||
|
blocked_without_file = self.client.patch(
|
||||||
|
f"/api/admin/crud/requests/{request_id}",
|
||||||
|
headers=headers,
|
||||||
|
json={"status_code": "IN_PROGRESS"},
|
||||||
|
)
|
||||||
|
self.assertEqual(blocked_without_file.status_code, 400)
|
||||||
|
self.assertIn("обязательные файлы", blocked_without_file.json().get("detail", ""))
|
||||||
|
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
db.add(
|
||||||
|
Attachment(
|
||||||
|
request_id=request_uuid,
|
||||||
|
file_name="passport.pdf",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
size_bytes=1024,
|
||||||
|
s3_key="requests/passport.pdf",
|
||||||
|
immutable=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
moved = self.client.patch(
|
||||||
|
f"/api/admin/crud/requests/{request_id}",
|
||||||
|
headers=headers,
|
||||||
|
json={"status_code": "IN_PROGRESS"},
|
||||||
|
)
|
||||||
|
self.assertEqual(moved.status_code, 200)
|
||||||
|
self.assertEqual(moved.json().get("status_code"), "IN_PROGRESS")
|
||||||
|
|
||||||
def test_status_change_freezes_previous_messages_and_attachments_and_writes_history(self):
|
def test_status_change_freezes_previous_messages_and_attachments_and_writes_history(self):
|
||||||
headers = self._auth_headers("ADMIN", email="root@example.com")
|
headers = self._auth_headers("ADMIN", email="root@example.com")
|
||||||
with self.SessionLocal() as db:
|
with self.SessionLocal() as db:
|
||||||
|
|
@ -1067,6 +1200,144 @@ class AdminUniversalCrudTests(unittest.TestCase):
|
||||||
outsider_forbidden = self.client.get(f"/api/admin/requests/{request_id}/status-route", headers=outsider_headers)
|
outsider_forbidden = self.client.get(f"/api/admin/requests/{request_id}/status-route", headers=outsider_headers)
|
||||||
self.assertEqual(outsider_forbidden.status_code, 403)
|
self.assertEqual(outsider_forbidden.status_code, 403)
|
||||||
|
|
||||||
|
def test_requests_kanban_returns_grouped_cards_and_role_scope(self):
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
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"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db.add(Topic(code="civil-law", name="Гражданское право", enabled=True, sort_order=1))
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
TopicStatusTransition(
|
||||||
|
topic_code="civil-law",
|
||||||
|
from_status="NEW",
|
||||||
|
to_status="IN_PROGRESS",
|
||||||
|
enabled=True,
|
||||||
|
sla_hours=24,
|
||||||
|
sort_order=1,
|
||||||
|
),
|
||||||
|
TopicStatusTransition(
|
||||||
|
topic_code="civil-law",
|
||||||
|
from_status="IN_PROGRESS",
|
||||||
|
to_status="WAITING_CLIENT",
|
||||||
|
enabled=True,
|
||||||
|
sla_hours=12,
|
||||||
|
sort_order=2,
|
||||||
|
),
|
||||||
|
TopicStatusTransition(
|
||||||
|
topic_code="civil-law",
|
||||||
|
from_status="WAITING_CLIENT",
|
||||||
|
to_status="CLOSED",
|
||||||
|
enabled=True,
|
||||||
|
sla_hours=8,
|
||||||
|
sort_order=3,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
lawyer_main = AdminUser(
|
||||||
|
role="LAWYER",
|
||||||
|
name="Юрист канбана",
|
||||||
|
email="lawyer.kanban@example.com",
|
||||||
|
password_hash="hash",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
lawyer_other = AdminUser(
|
||||||
|
role="LAWYER",
|
||||||
|
name="Другой юрист",
|
||||||
|
email="lawyer.kanban.other@example.com",
|
||||||
|
password_hash="hash",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add_all([lawyer_main, lawyer_other])
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
request_new = Request(
|
||||||
|
track_number="TRK-KANBAN-NEW",
|
||||||
|
client_name="Клиент 1",
|
||||||
|
client_phone="+79990000001",
|
||||||
|
topic_code="civil-law",
|
||||||
|
status_code="NEW",
|
||||||
|
description="Новая неназначенная",
|
||||||
|
extra_fields={},
|
||||||
|
assigned_lawyer_id=None,
|
||||||
|
)
|
||||||
|
request_progress = Request(
|
||||||
|
track_number="TRK-KANBAN-PROGRESS",
|
||||||
|
client_name="Клиент 2",
|
||||||
|
client_phone="+79990000002",
|
||||||
|
topic_code="civil-law",
|
||||||
|
status_code="IN_PROGRESS",
|
||||||
|
description="Заявка в работе",
|
||||||
|
extra_fields={"deadline_at": "2031-01-01T10:00:00+00:00"},
|
||||||
|
assigned_lawyer_id=str(lawyer_main.id),
|
||||||
|
)
|
||||||
|
request_waiting = Request(
|
||||||
|
track_number="TRK-KANBAN-WAITING",
|
||||||
|
client_name="Клиент 3",
|
||||||
|
client_phone="+79990000003",
|
||||||
|
topic_code="civil-law",
|
||||||
|
status_code="WAITING_CLIENT",
|
||||||
|
description="Чужая заявка",
|
||||||
|
extra_fields={},
|
||||||
|
assigned_lawyer_id=str(lawyer_other.id),
|
||||||
|
)
|
||||||
|
db.add_all([request_new, request_progress, request_waiting])
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
entered_progress_at = datetime.now(timezone.utc) - timedelta(hours=2)
|
||||||
|
db.add(
|
||||||
|
StatusHistory(
|
||||||
|
request_id=request_progress.id,
|
||||||
|
from_status="NEW",
|
||||||
|
to_status="IN_PROGRESS",
|
||||||
|
changed_by_admin_id=None,
|
||||||
|
comment="started",
|
||||||
|
created_at=entered_progress_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
request_new_id = str(request_new.id)
|
||||||
|
request_progress_id = str(request_progress.id)
|
||||||
|
request_waiting_id = str(request_waiting.id)
|
||||||
|
lawyer_main_id = str(lawyer_main.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)
|
||||||
|
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.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")))
|
||||||
|
|
||||||
|
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)
|
||||||
|
self.assertEqual(lawyer_response.status_code, 200)
|
||||||
|
lawyer_payload = lawyer_response.json()
|
||||||
|
self.assertEqual(lawyer_payload["scope"], "LAWYER")
|
||||||
|
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.assertNotIn(request_waiting_id, lawyer_rows)
|
||||||
|
self.assertEqual(lawyer_payload["total"], 2)
|
||||||
|
|
||||||
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:
|
||||||
lawyer1 = AdminUser(
|
lawyer1 = AdminUser(
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,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, "0016_table_availability")
|
self.assertEqual(version, "0017_transition_requirements")
|
||||||
|
|
||||||
def test_responsible_column_exists_in_all_domain_tables(self):
|
def test_responsible_column_exists_in_all_domain_tables(self):
|
||||||
tables = {
|
tables = {
|
||||||
|
|
@ -158,6 +158,8 @@ class MigrationTests(unittest.TestCase):
|
||||||
def test_status_transitions_contains_sla_hours_column(self):
|
def test_status_transitions_contains_sla_hours_column(self):
|
||||||
columns = {column["name"] for column in self.inspector.get_columns("topic_status_transitions")}
|
columns = {column["name"] for column in self.inspector.get_columns("topic_status_transitions")}
|
||||||
self.assertIn("sla_hours", columns)
|
self.assertIn("sla_hours", columns)
|
||||||
|
self.assertIn("required_data_keys", columns)
|
||||||
|
self.assertIn("required_mime_types", columns)
|
||||||
|
|
||||||
def test_notifications_has_recipient_and_read_columns(self):
|
def test_notifications_has_recipient_and_read_columns(self):
|
||||||
columns = {column["name"] for column in self.inspector.get_columns("notifications")}
|
columns = {column["name"] for column in self.inspector.get_columns("notifications")}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue