fix speed up 02

This commit is contained in:
TronoSfera 2026-03-17 00:49:39 +03:00
parent cf3b56deeb
commit 585b6bcfc1
30 changed files with 2458 additions and 1022 deletions

View file

@ -4,6 +4,7 @@ from datetime import datetime, timezone
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Request as FastapiRequest
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.core.deps import require_role
@ -19,8 +20,11 @@ from app.models.topic_data_template import TopicDataTemplate
from app.services.notifications import EVENT_REQUEST_DATA as NOTIFICATION_EVENT_REQUEST_DATA, notify_request_event, unread_admin_summary
from app.services.request_read_markers import EVENT_REQUEST_DATA, mark_unread_for_client
from app.services.chat_secure_service import (
clamp_chat_window_limit,
DEFAULT_CHAT_WINDOW_LIMIT,
create_admin_or_lawyer_message,
get_chat_activity_summary,
list_messages_for_request_window,
list_messages_for_request,
mark_messages_delivered_for_staff,
mark_messages_read_for_staff,
@ -154,6 +158,21 @@ def _slugify_key(raw: str) -> str:
return (slug or "data-field")[:80]
def _serialize_live_attachment(row: Attachment) -> dict:
return {
"id": str(row.id),
"request_id": str(row.request_id),
"message_id": str(row.message_id) if row.message_id else None,
"file_name": row.file_name,
"mime_type": row.mime_type,
"size_bytes": int(row.size_bytes or 0),
"s3_key": row.s3_key,
"immutable": bool(row.immutable),
"created_at": _iso_or_none(row.created_at),
"updated_at": _iso_or_none(row.updated_at),
}
def _normalize_value_type(raw: str | None) -> str:
value = str(raw or "text").strip().lower()
if value not in ALLOWED_VALUE_TYPES:
@ -269,6 +288,42 @@ def list_request_messages(
return payload
@router.get("/requests/{request_id}/messages-window")
def list_request_messages_window(
request_id: str,
http_request: FastapiRequest,
before_count: int = 0,
limit: int = DEFAULT_CHAT_WINDOW_LIMIT,
db: Session = Depends(get_db),
admin: dict = Depends(require_role("ADMIN", "LAWYER", "CURATOR")),
):
req = _request_for_id_or_404(db, request_id)
_ensure_lawyer_can_view_request_or_403(admin, req)
mark_messages_read_for_staff(db, request_id=req.id)
rows, total, has_more, loaded_count = list_messages_for_request_window(
db,
req.id,
limit=limit,
before_count=before_count,
)
payload = {
"rows": serialize_messages_for_request(db, req.id, rows),
"total": total,
"has_more": has_more,
"loaded_count": loaded_count,
"limit": clamp_chat_window_limit(limit),
}
_audit_admin_chat_read(
db,
admin=admin,
http_request=http_request,
req=req,
action="READ_CHAT_MESSAGES",
details={"rows": len(rows), "window": True},
)
return payload
@router.post("/requests/{request_id}/messages", status_code=201)
def create_request_message(
request_id: str,
@ -318,6 +373,29 @@ def get_request_live_state(
latest_activity_iso = _iso_or_none(latest_activity_at)
cursor_dt = _parse_cursor(cursor)
has_updates = bool(latest_activity_at and (cursor_dt is None or latest_activity_at > cursor_dt))
delta_messages = []
delta_attachments = []
if has_updates and cursor_dt is not None:
message_rows = (
db.query(Message)
.filter(
Message.request_id == req.id,
func.coalesce(Message.updated_at, Message.created_at) > cursor_dt,
)
.order_by(Message.created_at.asc(), Message.id.asc())
.all()
)
attachment_rows = (
db.query(Attachment)
.filter(
Attachment.request_id == req.id,
func.coalesce(Attachment.updated_at, Attachment.created_at) > cursor_dt,
)
.order_by(Attachment.created_at.asc(), Attachment.id.asc())
.all()
)
delta_messages = serialize_messages_for_request(db, req.id, message_rows)
delta_attachments = [_serialize_live_attachment(row) for row in attachment_rows]
actor_sub = str(admin.get("sub") or "").strip() or "unknown"
actor_role = str(admin.get("role") or "").strip().upper() or "UNKNOWN"
@ -331,6 +409,8 @@ def get_request_live_state(
"attachment_count": int(summary.get("attachment_count") or 0),
"latest_message_at": _iso_or_none(_as_utc_datetime(summary.get("latest_message_at"))),
"latest_attachment_at": _iso_or_none(_as_utc_datetime(summary.get("latest_attachment_at"))),
"messages": delta_messages,
"attachments": delta_attachments,
"typing": typing_rows,
"unread": unread_admin_summary(
db,

View file

@ -233,6 +233,52 @@ def query_invoices(
return payload
@router.get("/by-request/{request_id}")
def list_invoices_by_request(
request_id: str,
http_request: FastapiRequest,
db: Session = Depends(get_db),
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
):
role = str(admin.get("role") or "").upper()
actor_id = _actor_uuid_or_401(admin)
req = db.get(Request, _uuid_or_400(request_id, "request_id"))
if req is None:
raise HTTPException(status_code=404, detail="Заявка не найдена")
_ensure_lawyer_owns_request_or_403(role, actor_id, req)
rows = db.query(Invoice).filter(Invoice.request_id == req.id).order_by(Invoice.issued_at.desc(), Invoice.id.desc()).all()
issuer_ids = {row.issued_by_admin_user_id for row in rows if row.issued_by_admin_user_id}
users = db.query(AdminUser.id, AdminUser.name, AdminUser.email).filter(AdminUser.id.in_(issuer_ids)).all() if issuer_ids else []
issuer_map = {str(user_id): (name or email or str(user_id)) for user_id, name, email in users}
payload = {
"rows": [
_serialize_invoice(
row,
request_track=req.track_number,
issuer_name=issuer_map.get(str(row.issued_by_admin_user_id)) if row.issued_by_admin_user_id else None,
)
for row in rows
],
"total": len(rows),
}
record_pii_access_event(
db,
actor_role=role,
actor_subject=str(admin.get("sub") or admin.get("email") or ""),
actor_ip=extract_client_ip(http_request),
action="READ_REQUEST_INVOICE_LIST",
scope="INVOICE",
request_id=req.id,
details={"rows": int(len(rows)), "track_number": req.track_number},
responsible=str(admin.get("email") or "").strip() or "Администратор системы",
persist_now=True,
)
return payload
@router.get("/{invoice_id}")
def get_invoice(
invoice_id: str,

View file

@ -93,8 +93,33 @@ def _extract_assigned_lawyer_from_audit(diff: dict | None, action: str | None) -
return None
def _empty_sla_snapshot() -> dict[str, object]:
return {
"frt_avg_minutes": None,
"sla_overdue": 0,
"overdue_by_status": {},
"overdue_by_transition": {},
"avg_time_in_status_hours": {},
}
def _overview_sla_payload(db: Session) -> dict[str, object]:
sla_snapshot = compute_sla_snapshot(db)
return {
"frt_avg_minutes": sla_snapshot.get("frt_avg_minutes"),
"sla_overdue": sla_snapshot.get("overdue_total", 0),
"overdue_by_status": sla_snapshot.get("overdue_by_status", {}),
"overdue_by_transition": sla_snapshot.get("overdue_by_transition", {}),
"avg_time_in_status_hours": sla_snapshot.get("avg_time_in_status_hours", {}),
}
@router.get("/overview")
def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER", "CURATOR"))):
def overview(
include_sla: bool = True,
db: Session = Depends(get_db),
admin=Depends(require_role("ADMIN", "LAWYER", "CURATOR")),
):
role = str(admin.get("role") or "").upper()
actor_id = str(admin.get("sub") or "").strip()
actor_uuid = _uuid_or_none(actor_id)
@ -314,7 +339,6 @@ def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN",
my_unread_by_event = dict(my_unread_notifications.get("by_event") or {})
scoped_lawyer_loads = lawyer_loads
sla_snapshot = compute_sla_snapshot(db)
next_day_start = datetime(now_utc.year, now_utc.month, now_utc.day, tzinfo=timezone.utc) + timedelta(days=1)
deadline_alert_query = (
db.query(func.count(Request.id))
@ -327,7 +351,7 @@ def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN",
elif role == "LAWYER":
deadline_alert_query = deadline_alert_query.filter(Request.id.is_(None))
deadline_alert_total = int(deadline_alert_query.scalar() or 0)
return {
payload = {
"scope": role if role in {"ADMIN", "LAWYER", "CURATOR"} else "ADMIN",
"new": int(by_status.get("NEW", 0)),
"by_status": by_status,
@ -343,11 +367,6 @@ def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN",
"month_expenses": round(sum(_to_float(row.get("monthly_salary")) for row in scoped_lawyer_loads), 2)
if role == "LAWYER"
else round(sum(_to_float(row.get("monthly_salary")) for row in lawyer_loads), 2),
"frt_avg_minutes": sla_snapshot.get("frt_avg_minutes"),
"sla_overdue": sla_snapshot.get("overdue_total", 0),
"overdue_by_status": sla_snapshot.get("overdue_by_status", {}),
"overdue_by_transition": sla_snapshot.get("overdue_by_transition", {}),
"avg_time_in_status_hours": sla_snapshot.get("avg_time_in_status_hours", {}),
"unread_for_clients": int(unread_for_clients),
"unread_for_lawyers": int(unread_for_lawyers),
"unread_for_clients_by_event": dict(unread_for_clients_notifications.get("by_event") or {}),
@ -357,6 +376,14 @@ def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN",
"service_request_unread_total": int(service_request_unread_total),
"lawyer_loads": scoped_lawyer_loads,
}
payload.update(_overview_sla_payload(db) if include_sla else _empty_sla_snapshot())
return payload
@router.get("/overview-sla")
def overview_sla(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER", "CURATOR"))):
_ = admin
return _overview_sla_payload(db)
@router.get("/lawyers/{lawyer_id}/active-requests")

View file

@ -6,7 +6,7 @@ from typing import Any
from uuid import UUID
from fastapi import HTTPException
from sqlalchemy import or_
from sqlalchemy import case, func, or_
from sqlalchemy.orm import Session
from app.models.admin_user import AdminUser
@ -202,6 +202,87 @@ def sort_kanban_items(items: list[dict[str, object]], sort_mode: str) -> list[di
)
def _apply_sql_safe_boolean_filters(
query,
*,
boolean_filters: list[tuple[str, str, bool]],
role: str,
actor: str,
terminal_codes: set[str],
next_day_start: datetime,
):
remaining_filters: list[tuple[str, str, bool]] = []
for field, op, expected in boolean_filters:
if field != "deadline_alert":
remaining_filters.append((field, op, expected))
continue
actual_true_expr = (
Request.important_date_at.is_not(None)
& (Request.important_date_at < next_day_start)
& Request.status_code.notin_(terminal_codes)
)
if role == "LAWYER":
actual_true_expr = actual_true_expr & (Request.assigned_lawyer_id == actor)
target_true = expected if op == "=" else not expected
query = query.filter(actual_true_expr if target_true else ~actual_true_expr)
return query, remaining_filters
def _build_lawyer_sort_order(query, db: Session) -> list[str]:
assigned_id_rows = (
query.with_entities(Request.assigned_lawyer_id)
.filter(Request.assigned_lawyer_id.is_not(None))
.distinct()
.all()
)
assigned_ids = [str(raw_id).strip() for (raw_id,) in assigned_id_rows if str(raw_id or "").strip()]
if not assigned_ids:
return []
valid_lawyer_ids: list[UUID] = []
for raw in assigned_ids:
try:
valid_lawyer_ids.append(UUID(raw))
except ValueError:
continue
lawyer_name_rows = db.query(AdminUser.id, AdminUser.name).filter(AdminUser.id.in_(valid_lawyer_ids)).all() if valid_lawyer_ids else []
lawyer_name_map = {
str(lawyer_id): str(name or "").strip()
for lawyer_id, name in lawyer_name_rows
if str(lawyer_id or "").strip()
}
return sorted(
lawyer_name_map.keys(),
key=lambda lawyer_id: (
1 if not lawyer_name_map.get(lawyer_id) else 0,
lawyer_name_map.get(lawyer_id, "").lower(),
lawyer_id,
),
)
def _apply_sql_sort(query, *, sort_mode: str, lawyer_sort_order: list[str] | None = None):
if sort_mode == "lawyer":
ordered_ids = lawyer_sort_order or []
if ordered_ids:
lawyer_rank_case = case(
*[(Request.assigned_lawyer_id == lawyer_id, index) for index, lawyer_id in enumerate(ordered_ids)],
else_=len(ordered_ids) + 1,
)
return query.order_by(
lawyer_rank_case.asc(),
case((Request.assigned_lawyer_id.is_(None), 1), else_=0).asc(),
Request.created_at.desc(),
)
return query.order_by(
case((Request.assigned_lawyer_id.is_(None), 1), else_=0).asc(),
Request.created_at.desc(),
)
return query.order_by(Request.created_at.desc())
def get_requests_kanban_service(
db: Session,
admin: dict,
@ -226,6 +307,13 @@ def get_requests_kanban_service(
normalized_sort_mode = sort_mode if sort_mode in ALLOWED_KANBAN_SORT_MODES else "created_newest"
query_filters, boolean_filters = parse_kanban_filters_or_400(filters)
now_utc = datetime.now(timezone.utc)
next_day_start = datetime(now_utc.year, now_utc.month, now_utc.day, tzinfo=timezone.utc) + timedelta(days=1)
terminal_codes = {
str(code).strip()
for (code,) in db.query(Status.code).filter(Status.is_terminal.is_(True)).all()
if str(code or "").strip()
} or {"RESOLVED", "CLOSED", "REJECTED"}
if query_filters:
base_query = apply_universal_query(
base_query,
@ -237,7 +325,28 @@ def get_requests_kanban_service(
),
)
request_rows: list[Request] = base_query.all()
base_query, boolean_filters = _apply_sql_safe_boolean_filters(
base_query,
boolean_filters=boolean_filters,
role=role,
actor=actor,
terminal_codes=terminal_codes,
next_day_start=next_day_start,
)
can_apply_sql_window = normalized_sort_mode in {"created_newest", "lawyer"} and not boolean_filters
total = 0
request_query = base_query
if can_apply_sql_window:
lawyer_sort_order = _build_lawyer_sort_order(request_query, db) if normalized_sort_mode == "lawyer" else []
total = request_query.count()
request_query = _apply_sql_sort(
request_query,
sort_mode=normalized_sort_mode,
lawyer_sort_order=lawyer_sort_order,
).limit(limit)
request_rows: list[Request] = request_query.all()
request_id_to_row = {str(row.id): row for row in request_rows}
request_ids = [row.id for row in request_rows]
@ -266,9 +375,6 @@ def get_requests_kanban_service(
if notification_request_id is not None
}
status_codes = {str(row.status_code or "").strip() for row in request_rows if str(row.status_code or "").strip()}
now_utc = datetime.now(timezone.utc)
next_day_start = datetime(now_utc.year, now_utc.month, now_utc.day, tzinfo=timezone.utc) + timedelta(days=1)
status_meta_map: dict[str, dict[str, object]] = {}
if status_codes:
status_rows = (
@ -537,11 +643,15 @@ def get_requests_kanban_service(
}
)
if boolean_filters:
items = apply_boolean_kanban_filters(items, boolean_filters)
if not can_apply_sql_window:
items = sort_kanban_items(items, normalized_sort_mode)
total = len(items)
if total > limit:
items = items[:limit]
else:
items = sort_kanban_items(items, normalized_sort_mode)
for row in items:
key = str(row.get("status_group") or "").strip()

View file

@ -30,6 +30,7 @@ from .service import (
create_request_service,
delete_request_service,
get_request_service,
get_request_workspace_service,
query_requests_service,
reassign_request_service,
update_request_service,
@ -103,6 +104,30 @@ def get_request(
return payload
@router.get("/{request_id}/workspace")
def get_request_workspace(
request_id: str,
http_request: FastapiRequest,
db: Session = Depends(get_db),
admin=Depends(require_role("ADMIN", "LAWYER", "CURATOR")),
):
payload = get_request_workspace_service(request_id, db, admin)
request_payload = payload.get("request") or {}
record_pii_access_event(
db,
actor_role=str(admin.get("role") or "ADMIN").upper(),
actor_subject=str(admin.get("sub") or admin.get("email") or ""),
actor_ip=extract_client_ip(http_request),
action="READ_REQUEST_WORKSPACE",
scope="REQUEST_CARD",
request_id=request_payload.get("id"),
details={"track_number": request_payload.get("track_number")},
responsible=str(admin.get("email") or "").strip() or "Администратор системы",
persist_now=True,
)
return payload
@router.post("/{request_id}/status-change")
def change_request_status(
request_id: str,

View file

@ -10,11 +10,19 @@ from sqlalchemy.exc import IntegrityError, SQLAlchemyError
from sqlalchemy.orm import Session
from app.models.admin_user import AdminUser
from app.models.attachment import Attachment
from app.models.audit_log import AuditLog
from app.models.invoice import Invoice
from app.models.notification import Notification
from app.models.request import Request
from app.models.request_service_request import RequestServiceRequest
from app.schemas.admin import RequestAdminCreate, RequestAdminPatch
from app.services.chat_secure_service import (
DEFAULT_CHAT_WINDOW_LIMIT,
list_messages_for_request_window,
mark_messages_read_for_staff,
serialize_messages_for_request,
)
from app.schemas.universal import UniversalQuery
from app.services.billing_flow import apply_billing_transition_effects
from app.services.notifications import (
@ -44,7 +52,7 @@ from .permissions import (
ensure_lawyer_can_view_request_or_403,
request_uuid_or_400,
)
from .status_flow import apply_request_special_filters, split_request_special_filters
from .status_flow import apply_request_special_filters, get_request_status_route_service, split_request_special_filters
def query_requests_service(uq: UniversalQuery, db: Session, admin: dict) -> dict[str, Any]:
@ -399,6 +407,104 @@ def get_request_service(request_id: str, db: Session, admin: dict) -> dict[str,
}
def _serialize_request_attachment(row: Attachment) -> dict[str, Any]:
return {
"id": str(row.id),
"request_id": str(row.request_id),
"message_id": str(row.message_id) if row.message_id else None,
"file_name": row.file_name,
"mime_type": row.mime_type,
"size_bytes": int(row.size_bytes or 0),
"s3_key": row.s3_key,
"immutable": bool(row.immutable),
"scan_status": row.scan_status,
"scan_signature": row.scan_signature,
"scan_error": row.scan_error,
"scanned_at": row.scanned_at.isoformat() if row.scanned_at else None,
"created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
"responsible": row.responsible,
}
def _serialize_request_invoice(row: Invoice) -> dict[str, Any]:
return {
"id": str(row.id),
"invoice_number": row.invoice_number,
"request_id": str(row.request_id),
"client_id": str(row.client_id) if row.client_id else None,
"status": row.status,
"amount": float(row.amount) if row.amount is not None else None,
"currency": row.currency,
"payer_display_name": row.payer_display_name,
"issued_by_admin_user_id": str(row.issued_by_admin_user_id) if row.issued_by_admin_user_id else None,
"issued_by_role": row.issued_by_role,
"issued_at": row.issued_at.isoformat() if row.issued_at else None,
"paid_at": row.paid_at.isoformat() if row.paid_at else None,
"created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
"responsible": row.responsible,
"pdf_url": f"/api/admin/invoices/{row.id}/pdf",
}
def get_request_workspace_service(request_id: str, db: Session, admin: dict) -> dict[str, Any]:
request_payload = get_request_service(request_id, db, admin)
request_uuid = request_uuid_or_400(request_id)
req = db.get(Request, request_uuid)
if req is None:
raise HTTPException(status_code=404, detail="Заявка не найдена")
mark_messages_read_for_staff(db, request_id=req.id)
message_rows, messages_total, messages_has_more, messages_loaded_count = list_messages_for_request_window(
db,
req.id,
limit=DEFAULT_CHAT_WINDOW_LIMIT,
before_count=0,
)
attachment_rows = (
db.query(Attachment)
.filter(Attachment.request_id == req.id)
.order_by(Attachment.created_at.asc(), Attachment.id.asc())
.all()
)
role = str(admin.get("role") or "").upper()
invoice_rows: list[Invoice] = []
if role in {"ADMIN", "LAWYER"}:
invoice_rows = (
db.query(Invoice)
.filter(Invoice.request_id == req.id)
.order_by(Invoice.issued_at.desc(), Invoice.id.desc())
.all()
)
paid_invoices = [row for row in invoice_rows if str(row.status or "").upper() == "PAID"]
paid_total = round(sum(float(row.amount or 0) for row in paid_invoices), 2)
latest_paid_at = None
for row in paid_invoices:
if row.paid_at is None:
continue
if latest_paid_at is None or row.paid_at > latest_paid_at:
latest_paid_at = row.paid_at
return {
"request": request_payload,
"messages": serialize_messages_for_request(db, req.id, message_rows),
"messages_total": messages_total,
"messages_has_more": messages_has_more,
"messages_loaded_count": messages_loaded_count,
"attachments": [_serialize_request_attachment(row) for row in attachment_rows],
"invoices": [_serialize_request_invoice(row) for row in invoice_rows],
"finance_summary": {
"request_cost": request_payload.get("request_cost"),
"effective_rate": request_payload.get("effective_rate"),
"paid_total": paid_total,
"last_paid_at": latest_paid_at.isoformat() if latest_paid_at else request_payload.get("paid_at"),
},
"status_route": get_request_status_route_service(request_id, db, admin),
}
def claim_request_service(request_id: str, db: Session, admin: dict) -> dict[str, Any]:
request_uuid = request_uuid_or_400(request_id)

View file

@ -19,6 +19,7 @@ from app.models.attachment import Attachment
from app.models.message import Message
from app.models.request import Request
from app.schemas.uploads import UploadCompletePayload, UploadCompleteResponse, UploadInitPayload, UploadInitResponse, UploadScope
from app.api.admin.requests_modules.permissions import ensure_lawyer_can_view_request_or_403
from app.services.notifications import EVENT_ATTACHMENT as NOTIFICATION_EVENT_ATTACHMENT, notify_request_event
from app.services.request_read_markers import EVENT_ATTACHMENT, mark_unread_for_client
from app.services.security_audit import record_file_security_event
@ -165,6 +166,26 @@ def _normalize_avatar_to_webp_or_400(storage, *, key: str) -> tuple[int, str]:
return int(len(optimized)), "image/webp"
def _serialize_attachment(row: Attachment) -> dict:
return {
"id": str(row.id),
"request_id": str(row.request_id),
"message_id": str(row.message_id) if row.message_id else None,
"file_name": row.file_name,
"mime_type": row.mime_type,
"size_bytes": int(row.size_bytes or 0),
"s3_key": row.s3_key,
"immutable": bool(row.immutable),
"scan_status": row.scan_status,
"scan_signature": row.scan_signature,
"scan_error": row.scan_error,
"scanned_at": row.scanned_at.isoformat() if row.scanned_at else None,
"created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
"responsible": row.responsible,
}
@router.post("/init", response_model=UploadInitResponse)
def upload_init(
payload: UploadInitPayload,
@ -424,6 +445,22 @@ def upload_complete(
raise
@router.get("/request-attachments/{request_id}")
def list_request_attachments(
request_id: str,
db: Session = Depends(get_db),
admin: dict = Depends(require_role("ADMIN", "LAWYER", "CURATOR")),
):
request_uuid = _uuid_or_400(request_id, "request_id")
req = db.get(Request, request_uuid)
if req is None:
raise HTTPException(status_code=404, detail="Заявка не найдена")
ensure_lawyer_can_view_request_or_403(admin, req)
rows = db.query(Attachment).filter(Attachment.request_id == req.id).order_by(Attachment.created_at.asc(), Attachment.id.asc()).all()
return {"rows": [_serialize_attachment(row) for row in rows], "total": len(rows)}
@router.get("/object/{object_key:path}")
def get_object_proxy(
object_key: str,

View file

@ -3,6 +3,7 @@ from datetime import datetime, timezone
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Request as FastapiRequest
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.core.deps import get_public_session
@ -15,8 +16,11 @@ from app.schemas.public import PublicMessageCreate
from app.services.chat_presence import list_typing_presence, set_typing_presence
from app.services.notifications import EVENT_REQUEST_DATA as NOTIFICATION_EVENT_REQUEST_DATA, notify_request_event, unread_client_summary
from app.services.chat_secure_service import (
DEFAULT_CHAT_WINDOW_LIMIT,
clamp_chat_window_limit,
create_client_message,
get_chat_activity_summary,
list_messages_for_request_window,
list_messages_for_request,
mark_messages_delivered_for_client,
mark_messages_read_for_client,
@ -86,6 +90,19 @@ def _attachment_meta_for_public(req: Request, value_text: str | None, db: Sessio
}
def _serialize_public_attachment(row: Attachment) -> dict:
return {
"id": str(row.id),
"request_id": str(row.request_id),
"message_id": str(row.message_id) if row.message_id else None,
"file_name": row.file_name,
"mime_type": row.mime_type,
"size_bytes": int(row.size_bytes or 0),
"created_at": _iso_or_none(row.created_at),
"download_url": f"/api/public/uploads/object/{row.id}",
}
def _normalize_phone(raw: str | None) -> str:
value = str(raw or "").strip()
if not value:
@ -181,6 +198,42 @@ def list_messages_by_track(
return payload
@router.get("/requests/{track_number}/messages-window")
def list_messages_window_by_track(
track_number: str,
http_request: FastapiRequest,
before_count: int = 0,
limit: int = DEFAULT_CHAT_WINDOW_LIMIT,
db: Session = Depends(get_db),
session: dict = Depends(get_public_session),
):
req = _request_for_track_or_404(db, track_number)
_ensure_view_access_or_403(session, req)
mark_messages_read_for_client(db, request_id=req.id)
rows, total, has_more, loaded_count = list_messages_for_request_window(
db,
req.id,
limit=limit,
before_count=before_count,
)
payload = {
"rows": serialize_messages_for_request(db, req.id, rows),
"total": total,
"has_more": has_more,
"loaded_count": loaded_count,
"limit": clamp_chat_window_limit(limit),
}
_audit_public_chat_read(
db,
session=session,
http_request=http_request,
req=req,
action="READ_CHAT_MESSAGES",
details={"rows": len(rows), "window": True},
)
return payload
@router.post("/requests/{track_number}/messages", status_code=201)
def create_message_by_track(
track_number: str,
@ -212,6 +265,29 @@ def get_live_chat_state_by_track(
latest_activity_iso = _iso_or_none(latest_activity_at)
cursor_dt = _parse_cursor(cursor)
has_updates = bool(latest_activity_at and (cursor_dt is None or latest_activity_at > cursor_dt))
delta_messages = []
delta_attachments = []
if has_updates and cursor_dt is not None:
message_rows = (
db.query(Message)
.filter(
Message.request_id == req.id,
func.coalesce(Message.updated_at, Message.created_at) > cursor_dt,
)
.order_by(Message.created_at.asc(), Message.id.asc())
.all()
)
attachment_rows = (
db.query(Attachment)
.filter(
Attachment.request_id == req.id,
func.coalesce(Attachment.updated_at, Attachment.created_at) > cursor_dt,
)
.order_by(Attachment.created_at.asc(), Attachment.id.asc())
.all()
)
delta_messages = serialize_messages_for_request(db, req.id, message_rows)
delta_attachments = [_serialize_public_attachment(row) for row in attachment_rows]
subject = _require_view_session_or_403(session)
actor_key = f"CLIENT:{_normalize_track(subject) or _normalize_phone(subject)}"
@ -224,6 +300,8 @@ def get_live_chat_state_by_track(
"attachment_count": int(summary.get("attachment_count") or 0),
"latest_message_at": _iso_or_none(_as_utc_datetime(summary.get("latest_message_at"))),
"latest_attachment_at": _iso_or_none(_as_utc_datetime(summary.get("latest_attachment_at"))),
"messages": delta_messages,
"attachments": delta_attachments,
"typing": typing_rows,
"unread": unread_client_summary(
db,

View file

@ -59,15 +59,23 @@ _FRAMEABLE_PATH_PATTERNS = (
)
_PERF_PATH_PATTERNS = (
("admin_metrics_overview", re.compile(r"^/api/admin/metrics/overview$")),
("admin_metrics_overview_sla", re.compile(r"^/api/admin/metrics/overview-sla$")),
("admin_kanban", re.compile(r"^/api/admin/requests/kanban$")),
("admin_request_workspace", re.compile(r"^/api/admin/requests/[^/]+/workspace$")),
("admin_request_detail", re.compile(r"^/api/admin/requests/[^/]+$")),
("admin_request_detail", re.compile(r"^/api/admin/crud/requests/[^/]+$")),
("admin_chat_messages", re.compile(r"^/api/admin/chat/requests/[^/]+/messages$")),
("admin_chat_messages_window", re.compile(r"^/api/admin/chat/requests/[^/]+/messages-window$")),
("admin_chat_live", re.compile(r"^/api/admin/chat/requests/[^/]+/live$")),
("admin_request_status_route", re.compile(r"^/api/admin/requests/[^/]+/status-route$")),
("admin_request_attachments_query", re.compile(r"^/api/admin/uploads/request-attachments/[^/]+$")),
("admin_request_attachments_query", re.compile(r"^/api/admin/crud/attachments/query$")),
("admin_request_invoices_query", re.compile(r"^/api/admin/invoices/by-request/[^/]+$")),
("admin_request_invoices_query", re.compile(r"^/api/admin/invoices/query$")),
("public_request_detail", re.compile(r"^/api/public/requests/[^/]+$")),
("public_chat_messages", re.compile(r"^/api/public/chat/requests/[^/]+/messages$")),
("public_chat_messages_window", re.compile(r"^/api/public/chat/requests/[^/]+/messages-window$")),
("public_chat_live", re.compile(r"^/api/public/chat/requests/[^/]+/live$")),
("public_request_attachments", re.compile(r"^/api/public/requests/[^/]+/attachments$")),
("public_request_invoices", re.compile(r"^/api/public/requests/[^/]+/invoices$")),

View file

@ -16,6 +16,8 @@ from app.services.notifications import EVENT_MESSAGE as NOTIFICATION_EVENT_MESSA
from app.services.request_read_markers import EVENT_MESSAGE, mark_unread_for_client, mark_unread_for_lawyer
MAX_CHAT_MESSAGE_LEN = 12_000
DEFAULT_CHAT_WINDOW_LIMIT = 50
MAX_CHAT_WINDOW_LIMIT = 200
CHAT_PARTICIPANT_ADMIN_IDS_KEY = "chat_participant_admin_ids"
@ -37,6 +39,45 @@ def list_messages_for_request(db: Session, request_id: Any) -> list[Message]:
)
def clamp_chat_window_limit(limit: int | None) -> int:
if limit is None:
return DEFAULT_CHAT_WINDOW_LIMIT
try:
normalized = int(limit)
except (TypeError, ValueError):
normalized = DEFAULT_CHAT_WINDOW_LIMIT
return max(1, min(normalized, MAX_CHAT_WINDOW_LIMIT))
def list_messages_for_request_window(
db: Session,
request_id: Any,
*,
limit: int | None,
before_count: int = 0,
) -> tuple[list[Message], int, bool, int]:
window_limit = clamp_chat_window_limit(limit)
loaded_count = max(0, int(before_count or 0))
base_query = db.query(Message).filter(Message.request_id == request_id)
total = int(base_query.count() or 0)
if total <= 0 or loaded_count >= total:
return [], total, False, loaded_count
remaining = total - loaded_count
window_size = min(window_limit, remaining)
offset = max(total - loaded_count - window_size, 0)
rows = (
base_query
.order_by(Message.created_at.asc(), Message.id.asc())
.offset(offset)
.limit(window_size)
.all()
)
next_loaded_count = loaded_count + len(rows)
has_more = offset > 0
return rows, total, has_more, next_loaded_count
def _iso_or_none(value: datetime | None) -> str | None:
if value is None:
return None

View file

@ -88,8 +88,27 @@ def compute_sla_snapshot(
now_utc = _as_utc(now, datetime.now(timezone.utc))
terminal_codes = _terminal_status_codes(db)
active_requests = db.query(Request).filter(Request.status_code.notin_(terminal_codes)).all()
active_request_ids = [row.id for row in active_requests if row.id is not None]
status_rows = db.query(StatusHistory).order_by(StatusHistory.request_id.asc(), StatusHistory.created_at.asc()).all()
if not active_request_ids:
result = {
"checked_active_requests": 0,
"overdue_total": 0,
"overdue_by_status": {},
"overdue_by_transition": {},
"frt_avg_minutes": None,
"avg_time_in_status_hours": {},
}
if include_overdue_requests:
result["overdue_requests"] = []
return result
status_rows = (
db.query(StatusHistory)
.filter(StatusHistory.request_id.in_(active_request_ids))
.order_by(StatusHistory.request_id.asc(), StatusHistory.created_at.asc())
.all()
)
rows_by_request: dict[str, list[StatusHistory]] = defaultdict(list)
for row in status_rows:
rows_by_request[str(row.request_id)].append(row)
@ -127,7 +146,10 @@ def compute_sla_snapshot(
first_response_rows = (
db.query(Message.request_id, Message.created_at)
.filter(Message.author_type == "LAWYER")
.filter(
Message.author_type == "LAWYER",
Message.request_id.in_(active_request_ids),
)
.order_by(Message.request_id.asc(), Message.created_at.asc())
.all()
)

File diff suppressed because one or more lines are too long

View file

@ -1156,6 +1156,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
const [email, setEmail] = useState("");
const [userId, setUserId] = useState("");
const [activeSection, setActiveSection] = useState(initialSection);
const dashboardLoadRef = useRef(0);
const [dashboardData, setDashboardData] = useState({
scope: "",
@ -1289,6 +1290,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
submitRequestStatusChange,
submitRequestModalMessage,
probeRequestLive,
loadOlderRequestMessages,
setRequestTyping,
loadRequestDataTemplates,
loadRequestDataBatch,
@ -2154,37 +2156,39 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
const loadDashboard = useCallback(
async (tokenOverride) => {
const loadId = Date.now();
dashboardLoadRef.current = loadId;
setStatus("dashboard", "Загрузка...", "");
try {
const data = await api("/api/admin/metrics/overview", {}, tokenOverride);
const scope = String(data.scope || role || "");
const cards =
const buildDashboardCards = (scope, payload) =>
scope === "LAWYER"
? [
{ label: "Мои заявки", value: data.assigned_total ?? 0 },
{ label: "Мои активные", value: data.active_assigned_total ?? 0 },
{ label: "Неназначенные", value: data.unassigned_total ?? 0 },
{ label: "Мои непрочитанные", value: data.my_unread_notifications_total ?? data.my_unread_updates ?? 0 },
{ label: "Просрочено SLA", value: data.sla_overdue ?? 0 },
{ label: "Мои заявки", value: payload.assigned_total ?? 0 },
{ label: "Мои активные", value: payload.active_assigned_total ?? 0 },
{ label: "Неназначенные", value: payload.unassigned_total ?? 0 },
{ label: "Мои непрочитанные", value: payload.my_unread_notifications_total ?? payload.my_unread_updates ?? 0 },
{ label: "Просрочено SLA", value: payload.sla_overdue ?? 0 },
]
: [
{ label: "Новые", value: data.new ?? 0 },
{ label: "Назначенные", value: data.assigned_total ?? 0 },
{ label: "Неназначенные", value: data.unassigned_total ?? 0 },
{ label: "Просрочено SLA", value: data.sla_overdue ?? 0 },
{ label: "Мои непрочитанные", value: data.my_unread_notifications_total ?? data.my_unread_updates ?? 0 },
{ label: "Выручка (мес.)", value: Number(data.month_revenue ?? 0).toFixed(2) },
{ label: "Расходы (мес.)", value: Number(data.month_expenses ?? 0).toFixed(2) },
{ label: "Непрочитано юристами", value: data.unread_for_lawyers ?? 0 },
{ label: "Непрочитано клиентами", value: data.unread_for_clients ?? 0 },
{ label: "Новые", value: payload.new ?? 0 },
{ label: "Назначенные", value: payload.assigned_total ?? 0 },
{ label: "Неназначенные", value: payload.unassigned_total ?? 0 },
{ label: "Просрочено SLA", value: payload.sla_overdue ?? 0 },
{ label: "Мои непрочитанные", value: payload.my_unread_notifications_total ?? payload.my_unread_updates ?? 0 },
{ label: "Выручка (мес.)", value: Number(payload.month_revenue ?? 0).toFixed(2) },
{ label: "Расходы (мес.)", value: Number(payload.month_expenses ?? 0).toFixed(2) },
{ label: "Непрочитано юристами", value: payload.unread_for_lawyers ?? 0 },
{ label: "Непрочитано клиентами", value: payload.unread_for_clients ?? 0 },
];
const data = await api("/api/admin/metrics/overview?include_sla=false", {}, tokenOverride);
const scope = String(data.scope || role || "");
const localized = {};
Object.entries(data.by_status || {}).forEach(([code, count]) => {
localized[statusLabel(code)] = count;
});
setDashboardData({
scope,
cards,
cards: buildDashboardCards(scope, data),
byStatus: localized,
lawyerLoads: data.lawyer_loads || [],
myUnreadByEvent: data.my_unread_by_event || {},
@ -2198,6 +2202,18 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
monthExpenses: Number(data.month_expenses || 0),
});
setStatus("dashboard", "Данные обновлены", "ok");
void (async () => {
try {
const slaData = await api("/api/admin/metrics/overview-sla", {}, tokenOverride);
if (dashboardLoadRef.current !== loadId) return;
setDashboardData((prev) => ({
...prev,
cards: buildDashboardCards(String(prev.scope || scope || ""), { ...data, ...slaData }),
}));
} catch (_) {
// Keep fast dashboard payload if SLA snapshot is unavailable.
}
})();
} catch (error) {
setStatus("dashboard", "Ошибка: " + error.message, "error");
}
@ -3480,17 +3496,13 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
setEmail(payload.email);
setUserId(String(payload.sub || ""));
await bootstrapReferenceData(nextToken, payload.role);
setActiveSection("dashboard");
await loadDashboard(nextToken);
await loadTotpStatus(nextToken);
setStatus("login", "Успешный вход", "ok");
} catch (error) {
setStatus("login", "Ошибка входа: " + error.message, "error");
}
},
[api, bootstrapReferenceData, loadDashboard, loadTotpStatus, setStatus]
[api, setStatus]
);
useEffect(() => {
@ -3523,14 +3535,14 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
if (!token || !role) return;
let cancelled = false;
(async () => {
await bootstrapReferenceData(token, role);
if (!cancelled) await loadDashboard(token);
bootstrapReferenceData(token, role);
if (!cancelled && !isRequestWorkspaceRoute && !routeInfo.section) await loadDashboard(token);
if (!cancelled) await loadTotpStatus(token);
})();
return () => {
cancelled = true;
};
}, [bootstrapReferenceData, loadDashboard, loadTotpStatus, role, token]);
}, [bootstrapReferenceData, isRequestWorkspaceRoute, loadDashboard, loadTotpStatus, role, routeInfo.section, token]);
useEffect(() => {
if (!token || !role) return;
@ -3970,6 +3982,8 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
currentImportantDateAt={requestModal.currentImportantDateAt || ""}
pendingStatusChangePreset={requestModal.pendingStatusChangePreset}
messages={requestModal.messages || []}
messagesHasMore={Boolean(requestModal.messagesHasMore)}
messagesLoadingMore={Boolean(requestModal.messagesLoadingMore)}
attachments={requestModal.attachments || []}
messageDraft={requestModal.messageDraft || ""}
selectedFiles={requestModal.selectedFiles || []}
@ -3977,6 +3991,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
status={getStatus("requestModal")}
onMessageChange={updateRequestModalMessageDraft}
onSendMessage={submitRequestModalMessage}
onLoadOlderMessages={loadOlderRequestMessages}
onFilesSelect={appendRequestModalFiles}
onRemoveSelectedFile={removeRequestModalFile}
onClearSelectedFiles={clearRequestModalFiles}

View file

@ -25,6 +25,8 @@ export function RequestWorkspace({
currentImportantDateAt,
pendingStatusChangePreset,
messages,
messagesHasMore,
messagesLoadingMore,
attachments,
messageDraft,
selectedFiles,
@ -32,6 +34,7 @@ export function RequestWorkspace({
status,
onMessageChange,
onSendMessage,
onLoadOlderMessages,
onFilesSelect,
onRemoveSelectedFile,
onClearSelectedFiles,
@ -1660,6 +1663,18 @@ export function RequestWorkspace({
{chatTab === "chat" ? (
<>
{messagesHasMore ? (
<div className="request-chat-history-actions">
<button
type="button"
className="btn secondary"
onClick={onLoadOlderMessages}
disabled={loading || fileUploading || messagesLoadingMore}
>
{messagesLoadingMore ? "Загрузка истории..." : "Показать предыдущие сообщения"}
</button>
</div>
) : null}
<ul className="simple-list request-modal-list request-chat-list" id={idMap.messagesList} ref={chatListRef}>
{chatTimelineItems.length ? (
chatTimelineItems.map((entry) =>

View file

@ -55,6 +55,46 @@ function isRetryableUploadError(error) {
);
}
function sortRowsByCreatedAt(rows) {
return [...rows].sort((left, right) => {
const leftTs = new Date(left?.created_at || left?.updated_at || 0).getTime();
const rightTs = new Date(right?.created_at || right?.updated_at || 0).getTime();
if (Number.isFinite(leftTs) && Number.isFinite(rightTs) && leftTs !== rightTs) return leftTs - rightTs;
return String(left?.id || "").localeCompare(String(right?.id || ""), "ru");
});
}
function mergeRowsById(existingRows, incomingRows) {
const merged = new Map();
(Array.isArray(existingRows) ? existingRows : []).forEach((row) => {
const key = String(row?.id || "").trim();
if (key) merged.set(key, row);
});
(Array.isArray(incomingRows) ? incomingRows : []).forEach((row) => {
const key = String(row?.id || "").trim();
if (key) merged.set(key, row);
});
return sortRowsByCreatedAt(Array.from(merged.values()));
}
function normalizeMessageAuthors(rows, users) {
const usersByEmail = new Map(
(Array.isArray(users) ? users : [])
.filter((user) => user && user.email)
.map((user) => [String(user.email).toLowerCase(), String(user.name || user.email)])
);
return (Array.isArray(rows) ? rows : []).map((item) => {
if (!item || typeof item !== "object") return item;
const authorType = String(item.author_type || "").toUpperCase();
const authorName = String(item.author_name || "").trim();
if ((authorType === "LAWYER" || authorType === "SYSTEM") && authorName.includes("@")) {
const mapped = usersByEmail.get(authorName.toLowerCase());
if (mapped) return { ...item, author_name: mapped };
}
return item;
});
}
export function useRequestWorkspace(options) {
const { useCallback, useRef, useState } = React;
const opts = options || {};
@ -63,7 +103,6 @@ export function useRequestWorkspace(options) {
const setActiveSection = opts.setActiveSection;
const token = opts.token || "";
const users = Array.isArray(opts.users) ? opts.users : [];
const buildUniversalQuery = opts.buildUniversalQuery;
const resolveAdminObjectSrc = opts.resolveAdminObjectSrc;
const [requestModal, setRequestModal] = useState(createRequestModalState());
@ -193,24 +232,21 @@ export function useRequestWorkspace(options) {
financeSummary: null,
invoices: [],
statusRouteNodes: [],
messagesHasMore: false,
messagesLoadingMore: false,
messagesLoadedCount: 0,
messagesTotal: 0,
}));
}
const requestFilter = [{ field: "request_id", op: "=", value: String(requestId) }];
try {
const [row, messagesData, attachmentsData, statusRouteData, invoicesData] = await Promise.all([
api("/api/admin/crud/requests/" + requestId),
api("/api/admin/chat/requests/" + requestId + "/messages"),
api("/api/admin/crud/attachments/query", {
method: "POST",
body: buildUniversalQuery(requestFilter, [{ field: "created_at", dir: "asc" }], 500, 0),
}),
api("/api/admin/requests/" + requestId + "/status-route").catch(() => ({ nodes: [] })),
api("/api/admin/invoices/query", {
method: "POST",
body: buildUniversalQuery(requestFilter, [{ field: "issued_at", dir: "desc" }], 500, 0),
}).catch(() => ({ rows: [] })),
]);
const workspaceData = await api("/api/admin/requests/" + requestId + "/workspace");
const row = workspaceData?.request || null;
const messagesData = { rows: workspaceData?.messages || [] };
const attachmentsData = { rows: workspaceData?.attachments || [] };
const statusRouteData = workspaceData?.status_route || { nodes: [] };
const invoicesData = { rows: workspaceData?.invoices || [] };
const financeSummaryData = workspaceData?.finance_summary || null;
const usersById = new Map(users.filter((user) => user && user.id).map((user) => [String(user.id), user]));
const rowData = row && typeof row === "object" ? { ...row } : row;
if (rowData && typeof rowData === "object") {
@ -227,19 +263,7 @@ export function useRequestWorkspace(options) {
...item,
download_url: resolveAdminObjectSrc(item.s3_key, token),
}));
const usersByEmail = new Map(
users.filter((user) => user && user.email).map((user) => [String(user.email).toLowerCase(), String(user.name || user.email)])
);
const normalizedMessages = (messagesData.rows || []).map((item) => {
if (!item || typeof item !== "object") return item;
const authorType = String(item.author_type || "").toUpperCase();
const authorName = String(item.author_name || "").trim();
if ((authorType === "LAWYER" || authorType === "SYSTEM") && authorName.includes("@")) {
const mapped = usersByEmail.get(authorName.toLowerCase());
if (mapped) return { ...item, author_name: mapped };
}
return item;
});
const normalizedMessages = normalizeMessageAuthors(messagesData.rows || [], users);
const invoices = Array.isArray(invoicesData?.rows) ? invoicesData.rows : [];
const paidInvoices = invoices.filter(
(item) => String(item?.status || "").toUpperCase() === "PAID"
@ -262,7 +286,7 @@ export function useRequestWorkspace(options) {
requestId: rowData?.id || requestId,
trackNumber: String(rowData?.track_number || ""),
requestData: rowData,
financeSummary: {
financeSummary: financeSummaryData || {
request_cost: rowData?.request_cost ?? null,
effective_rate: rowData?.effective_rate ?? null,
paid_total: Math.round((paidTotal + Number.EPSILON) * 100) / 100,
@ -274,6 +298,10 @@ export function useRequestWorkspace(options) {
availableStatuses: Array.isArray(statusRouteData?.available_statuses) ? statusRouteData.available_statuses : [],
currentImportantDateAt: String(statusRouteData?.current_important_date_at || rowData?.important_date_at || ""),
messages: normalizedMessages,
messagesHasMore: Boolean(workspaceData?.messages_has_more),
messagesLoadingMore: false,
messagesLoadedCount: Number(workspaceData?.messages_loaded_count || normalizedMessages.length || 0),
messagesTotal: Number(workspaceData?.messages_total || normalizedMessages.length || 0),
attachments,
selectedFiles: [],
fileUploading: false,
@ -292,6 +320,10 @@ export function useRequestWorkspace(options) {
availableStatuses: [],
currentImportantDateAt: "",
messages: [],
messagesHasMore: false,
messagesLoadingMore: false,
messagesLoadedCount: 0,
messagesTotal: 0,
attachments: [],
selectedFiles: [],
fileUploading: false,
@ -299,7 +331,7 @@ export function useRequestWorkspace(options) {
if (typeof setStatus === "function") setStatus("requestModal", "Ошибка: " + error.message, "error");
}
},
[api, buildUniversalQuery, resolveAdminObjectSrc, setStatus, token, users]
[api, resolveAdminObjectSrc, setStatus, token, users]
);
const refreshRequestModal = useCallback(async () => {
@ -450,13 +482,68 @@ export function useRequestWorkspace(options) {
const query = cursor ? "?cursor=" + encodeURIComponent(String(cursor)) : "";
const payload = await api("/api/admin/chat/requests/" + requestId + "/live" + query);
if (payload && payload.has_updates) {
await loadRequestModalData(requestId, { showLoading: false });
const nextMessages = normalizeMessageAuthors(payload?.messages || [], users);
const nextAttachments = (payload?.attachments || []).map((item) => ({
...item,
download_url: resolveAdminObjectSrc(item?.s3_key, token),
}));
if (nextMessages.length || nextAttachments.length) {
setRequestModal((prev) => {
const mergedMessages = mergeRowsById(prev.messages, nextMessages);
const previousCount = Array.isArray(prev.messages) ? prev.messages.length : 0;
const addedCount = Math.max(0, mergedMessages.length - previousCount);
return {
...prev,
messages: mergedMessages,
messagesLoadedCount: Number(prev.messagesLoadedCount || previousCount) + addedCount,
messagesTotal: Number(prev.messagesTotal || previousCount) + addedCount,
attachments: mergeRowsById(prev.attachments, nextAttachments),
};
});
}
}
return payload || { has_updates: false, typing: [], cursor: null };
},
[api, loadRequestModalData, requestModal.requestId]
[api, requestModal.requestId, resolveAdminObjectSrc, token, users]
);
const loadOlderRequestMessages = useCallback(async () => {
const requestId = String(requestModal.requestId || "").trim();
const loadedCount = Number(requestModal.messagesLoadedCount || 0);
if (!api || !requestId || requestModal.messagesLoadingMore || !requestModal.messagesHasMore) return null;
setRequestModal((prev) => ({ ...prev, messagesLoadingMore: true }));
try {
const payload = await api(
"/api/admin/chat/requests/" +
requestId +
"/messages-window?before_count=" +
encodeURIComponent(String(loadedCount))
);
const nextMessages = normalizeMessageAuthors(payload?.rows || [], users);
setRequestModal((prev) => ({
...prev,
messagesLoadingMore: false,
messages: mergeRowsById(nextMessages, prev.messages),
messagesHasMore: Boolean(payload?.has_more),
messagesLoadedCount: Number(payload?.loaded_count || prev.messagesLoadedCount || 0),
messagesTotal: Number(payload?.total || prev.messagesTotal || 0),
}));
return payload || null;
} catch (error) {
setRequestModal((prev) => ({ ...prev, messagesLoadingMore: false }));
if (typeof setStatus === "function") setStatus("requestModal", "Ошибка загрузки истории: " + error.message, "error");
return null;
}
}, [
api,
requestModal.messagesHasMore,
requestModal.messagesLoadedCount,
requestModal.messagesLoadingMore,
requestModal.requestId,
setStatus,
users,
]);
const setRequestTyping = useCallback(
async ({ typing } = {}) => {
const requestId = requestModal.requestId;
@ -583,6 +670,7 @@ export function useRequestWorkspace(options) {
submitRequestStatusChange,
submitRequestModalMessage,
probeRequestLive,
loadOlderRequestMessages,
setRequestTyping,
loadRequestDataTemplates,
loadRequestDataBatch,

View file

@ -23,6 +23,10 @@ export function createRequestModalState() {
currentImportantDateAt: "",
pendingStatusChangePreset: null,
messages: [],
messagesHasMore: false,
messagesLoadingMore: false,
messagesLoadedCount: 0,
messagesTotal: 0,
attachments: [],
messageDraft: "",
selectedFiles: [],

File diff suppressed because it is too large Load diff

View file

@ -5,6 +5,28 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
(function () {
const { useCallback, useEffect, useMemo, useRef, useState } = React;
function sortRowsByCreatedAt(rows) {
return [...rows].sort((left, right) => {
const leftTs = new Date(left?.created_at || left?.updated_at || 0).getTime();
const rightTs = new Date(right?.created_at || right?.updated_at || 0).getTime();
if (Number.isFinite(leftTs) && Number.isFinite(rightTs) && leftTs !== rightTs) return leftTs - rightTs;
return String(left?.id || "").localeCompare(String(right?.id || ""), "ru");
});
}
function mergeRowsById(existingRows, incomingRows) {
const merged = new Map();
(Array.isArray(existingRows) ? existingRows : []).forEach((row) => {
const key = String(row?.id || "").trim();
if (key) merged.set(key, row);
});
(Array.isArray(incomingRows) ? incomingRows : []).forEach((row) => {
const key = String(row?.id || "").trim();
if (key) merged.set(key, row);
});
return sortRowsByCreatedAt(Array.from(merged.values()));
}
function StatusLine({ status }) {
return <p className={"status" + (status?.kind ? " " + status.kind : "")}>{status?.message || ""}</p>;
}
@ -586,7 +608,7 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
}
const [requestData, messagesData, attachmentsData, invoicesData, statusRouteData, serviceRequestsData] = await Promise.all([
apiJson("/api/public/requests/" + encodeURIComponent(track), null, "Не удалось открыть заявку"),
apiJson("/api/public/chat/requests/" + encodeURIComponent(track) + "/messages", null, "Не удалось загрузить сообщения"),
apiJson("/api/public/chat/requests/" + encodeURIComponent(track) + "/messages-window", null, "Не удалось загрузить сообщения"),
apiJson("/api/public/requests/" + encodeURIComponent(track) + "/attachments", null, "Не удалось загрузить файлы"),
apiJson("/api/public/requests/" + encodeURIComponent(track) + "/invoices", null, "Не удалось загрузить счета"),
apiJson("/api/public/requests/" + encodeURIComponent(track) + "/status-route", null, "Не удалось загрузить маршрут статусов"),
@ -627,7 +649,11 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
availableStatuses: [],
currentImportantDateAt: String(statusRouteData?.current_important_date_at || requestData?.important_date_at || ""),
invoices,
messages: Array.isArray(messagesData) ? messagesData : [],
messages: Array.isArray(messagesData?.rows) ? messagesData.rows : [],
messagesHasMore: Boolean(messagesData?.has_more),
messagesLoadingMore: false,
messagesLoadedCount: Number(messagesData?.loaded_count || 0),
messagesTotal: Number(messagesData?.total || 0),
attachments: Array.isArray(attachmentsData) ? attachmentsData : [],
fileUploading: false,
}));
@ -635,6 +661,44 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
[apiJson]
);
const loadOlderPublicMessages = useCallback(async () => {
const track = String(activeTrack || requestModal.trackNumber || "").trim().toUpperCase();
const loadedCount = Number(requestModal.messagesLoadedCount || 0);
if (!track || requestModal.messagesLoadingMore || !requestModal.messagesHasMore) return null;
setRequestModal((prev) => ({ ...prev, messagesLoadingMore: true }));
try {
const payload = await apiJson(
"/api/public/chat/requests/" +
encodeURIComponent(track) +
"/messages-window?before_count=" +
encodeURIComponent(String(loadedCount)),
null,
"Не удалось загрузить историю сообщений"
);
setRequestModal((prev) => ({
...prev,
messagesLoadingMore: false,
messages: mergeRowsById(payload?.rows || [], prev.messages),
messagesHasMore: Boolean(payload?.has_more),
messagesLoadedCount: Number(payload?.loaded_count || prev.messagesLoadedCount || 0),
messagesTotal: Number(payload?.total || prev.messagesTotal || 0),
}));
return payload || null;
} catch (error) {
setRequestModal((prev) => ({ ...prev, messagesLoadingMore: false }));
setPageStatus(error?.message || "Не удалось загрузить историю сообщений", "error");
return null;
}
}, [
activeTrack,
apiJson,
requestModal.messagesHasMore,
requestModal.messagesLoadedCount,
requestModal.messagesLoadingMore,
requestModal.trackNumber,
setPageStatus,
]);
const refreshRequestsList = useCallback(async () => {
const data = await apiJson("/api/public/requests/my", null, "Не удалось загрузить список заявок");
const rows = Array.isArray(data?.rows) ? data.rows : [];
@ -657,6 +721,10 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
statusRouteNodes: [],
statusHistory: [],
messages: [],
messagesHasMore: false,
messagesLoadingMore: false,
messagesLoadedCount: 0,
messagesTotal: 0,
attachments: [],
fileUploading: false,
selectedFiles: [],
@ -878,11 +946,26 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
"Не удалось получить live-обновления чата"
);
if (payload && payload.has_updates) {
await loadRequestWorkspace(track, false);
const nextMessages = Array.isArray(payload?.messages) ? payload.messages : [];
const nextAttachments = Array.isArray(payload?.attachments) ? payload.attachments : [];
if (nextMessages.length || nextAttachments.length) {
setRequestModal((prev) => {
const mergedMessages = mergeRowsById(prev.messages, nextMessages);
const previousCount = Array.isArray(prev.messages) ? prev.messages.length : 0;
const addedCount = Math.max(0, mergedMessages.length - previousCount);
return {
...prev,
messages: mergedMessages,
messagesLoadedCount: Number(prev.messagesLoadedCount || previousCount) + addedCount,
messagesTotal: Number(prev.messagesTotal || previousCount) + addedCount,
attachments: mergeRowsById(prev.attachments, nextAttachments),
};
});
}
}
return payload || { has_updates: false, typing: [], cursor: null };
},
[activeTrack, apiJson, loadRequestWorkspace]
[activeTrack, apiJson]
);
const setTypingSignal = useCallback(
@ -1107,6 +1190,8 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
currentImportantDateAt={requestModal.currentImportantDateAt || ""}
pendingStatusChangePreset={null}
messages={requestModal.messages || []}
messagesHasMore={Boolean(requestModal.messagesHasMore)}
messagesLoadingMore={Boolean(requestModal.messagesLoadingMore)}
attachments={requestModal.attachments || []}
messageDraft={requestModal.messageDraft || ""}
selectedFiles={requestModal.selectedFiles || []}
@ -1114,6 +1199,7 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
status={status}
onMessageChange={updateMessageDraft}
onSendMessage={submitMessage}
onLoadOlderMessages={loadOlderPublicMessages}
onFilesSelect={appendFiles}
onRemoveSelectedFile={removeFile}
onClearSelectedFiles={clearFiles}

View file

@ -18,11 +18,11 @@
|---|---|---|---|---|
| PERF-01 | Зафиксировать baseline по ключевым endpoint и сценариям | in_progress | P0 | — |
| PERF-02 | Добавить индекс на `requests.assigned_lawyer_id` | completed | P0 | — |
| PERF-03 | Убрать full reload карточки заявки при live-обновлениях | planned | P0 | PERF-01 |
| PERF-04 | Собрать единый endpoint карточки заявки | planned | P0 | PERF-01 |
| PERF-05 | Выделить узкие request-scoped endpoints для вложений и счетов | planned | P0 | PERF-04 |
| PERF-06 | Переписать kanban на SQL-first фильтрацию/limit | planned | P0 | PERF-01, PERF-02 |
| PERF-07 | Ограничить initial chat payload и добавить догрузку истории | planned | P1 | PERF-03, PERF-04 |
| PERF-03 | Убрать full reload карточки заявки при live-обновлениях | completed | P0 | PERF-01 |
| PERF-04 | Собрать единый endpoint карточки заявки | in_progress | P0 | PERF-01 |
| PERF-05 | Выделить узкие request-scoped endpoints для вложений и счетов | completed | P0 | PERF-04 |
| PERF-06 | Переписать kanban на SQL-first фильтрацию/limit | in_progress | P0 | PERF-01, PERF-02 |
| PERF-07 | Ограничить initial chat payload и добавить догрузку истории | in_progress | P1 | PERF-03, PERF-04 |
| PERF-08 | Добавить нужные вспомогательные индексы и повторный profiling | planned | P1 | PERF-01 |
## PERF-01
@ -48,8 +48,33 @@
- 2026-03-16: добавлен ops-скрипт `scripts/ops/perf_baseline.sh` для repeatable baseline по admin workspace.
- 2026-03-16: baseline еще не снят, потому что локальный контур на `localhost:8081` не поднят.
- 2026-03-16: выполнен `PERF-02` - добавлен индекс `ix_requests_assigned_lawyer_id`, миграционный тест пройден.
- 2026-03-16: dashboard перестроен на приоритетную загрузку `overview`, справочники уходят в неблокирующий bootstrap.
- 2026-03-16: карточка заявки переведена с generic `attachments/query` и `invoices/query` на узкие request-scoped endpoint.
- 2026-03-16: `PERF-05` закрыт, backend регресс по чатам/счетам/hardening пройден.
- 2026-03-16: `PERF-03` начат - admin `/live` отдает delta `messages/attachments`, hook больше не делает полный `loadRequestModalData()` на polling.
- 2026-03-16: регресс `tests.admin.test_lawyer_chat` пройден, локальная сборка `admin/index.jsx` пройдена.
- 2026-03-16: `PERF-03` завершен - client `/live` тоже переведен на delta без полного reload workspace.
- 2026-03-16: `PERF-04` начат - admin карточка заявки переведена на единый endpoint `/api/admin/requests/{id}/workspace`.
- 2026-03-16: `PERF-06` начат - для канбана в сценарии `created_newest` без boolean-фильтров `count/order_by/limit` перенесены в SQL, чтобы не загружать весь filtered set в Python.
- 2026-03-16: добавлен регресс на канбан `limit + total + truncated`, контейнерный тест `tests.admin.test_status_flow_kanban` пройден.
- 2026-03-16: в `compute_sla_snapshot` выборки `StatusHistory` и первых lawyer messages ограничены только активными заявками; это должно ускорить `/api/admin/metrics/overview` на первом запросе.
- 2026-03-16: `overview` добавлен в perf-labels и в `scripts/ops/perf_baseline.sh`, чтобы дальше мерить dashboard отдельно от канбана/workspace.
- 2026-03-16: контейнерный регресс `tests.admin.test_metrics_templates tests.test_dashboard_finance` пройден после оптимизации overview/SLA.
- 2026-03-16: поднят минимальный локальный контур для живых замеров (`db`, `redis`, `minio`, `email-service`, `chat-service`, `backend`, `frontend`); `frontend` ушел в restart-loop из-за `host not found in upstream "minio"` в nginx-конфиге, поэтому full baseline через `:8081` не собран.
- 2026-03-16: снят manual backend-baseline напрямую через `:8002` для `metrics_overview`, `kanban`, `request_workspace`; цифры получились низкими на локальном seed и полезны только как smoke-check, а не как репрезентативный perf baseline.
- 2026-03-16: исправлен restart-loop `frontend` через lazy DNS resolve для `minio` в nginx, полноценный baseline через `http://localhost:8081` снова доступен.
- 2026-03-16: получен baseline через `:8081` после оптимизаций dashboard/workspace: `kanban ~17.8 ms avg`, `metrics_overview core ~12.9 ms avg`, `metrics_overview_sla ~8.2 ms avg`, `request_workspace ~14.9 ms avg`, `chat_live ~14.4 ms avg` на локальном seed.
- 2026-03-16: `overview` переведен на двухфазную загрузку: быстрый `include_sla=false` + фоновый `/api/admin/metrics/overview-sla`, чтобы убрать `compute_sla_snapshot()` из критического пути dashboard.
- 2026-03-16: `PERF-07` начат - initial chat payload ограничен окном сообщений, добавлены `messages-window` endpoints для admin/public и догрузка старой истории в UI по кнопке.
- 2026-03-16: контейнерные регрессы после paged-chat пройдены: `tests.admin.test_lawyer_chat`, `tests.test_public_cabinet`, `tests.admin.test_metrics_templates`, `tests.test_dashboard_finance`, `tests.test_http_hardening`.
- 2026-03-16: добавлен сценарий `scripts/ops/perf_long_chat_workspace.sh`, который сидит request с длинным чатом и меряет first open workspace и `messages-window` на живом контуре.
- 2026-03-16: long-chat baseline снят на `2000` сообщениях, отчет `reports/perf/perf-long-chat-workspace-20260316-201459.md`: `request_workspace ~579 ms avg`, `messages_window ~650 ms avg`, initial payload и older-page оба возвращают только `50` сообщений из `2000`.
- 2026-03-16: при первом прогоне long-chat сценария выяснилось, что `chat-service` работал на старом контейнере без актуальных `X-Perf-*` headers; после rebuild `chat-service` server timing для `messages-window` подтвержден на живом контуре.
- 2026-03-16: `PERF-06` продвинут дальше - SQL-first window теперь покрывает не только `created_newest`, но и `sort_mode=lawyer`, а boolean-фильтр `deadline_alert` переносится в SQL до загрузки строк.
- 2026-03-16: добавлен контейнерный регресс `test_requests_kanban_lawyer_sort_uses_limit_without_losing_total`, подтверждающий `limit/truncated/total` для `sort_mode=lawyer`.
## Дальше
1. Поднять локальный контур и выполнить `./scripts/ops/perf_baseline.sh http://localhost:8081`.
2. После снятия baseline перейти к `PERF-06` и убирать `base_query.all()` из kanban.
1. Разобрать server-side стоимость `request_workspace` и `messages-window` на длинном чате: window-пагинация уже работает, но оба endpoint остаются около `0.6-0.65s`, значит узкое место теперь в запросах/сериализации, а не в объеме initial payload.
2. Довести `PERF-04` до конца и решить, нужен ли такой же unified endpoint для client workspace.
3. Продолжить `PERF-06` для оставшихся режимов канбана, где все еще остается Python-side post-processing: `deadline`, `overdue`, `has_unread_updates`.

View file

@ -4,6 +4,7 @@ server {
server_tokens off;
absolute_redirect off;
client_max_body_size 25m;
resolver 127.0.0.11 valid=30s ipv6=off;
root /usr/share/nginx/html;
index index.html;
@ -89,7 +90,8 @@ server {
}
location /s3/ {
proxy_pass http://minio:9000/;
set $minio_upstream http://minio:9000;
proxy_pass $minio_upstream/;
proxy_http_version 1.1;
proxy_set_header Host minio:9000;
proxy_set_header X-Real-IP $remote_addr;

View file

@ -4,6 +4,7 @@ server {
server_tokens off;
absolute_redirect off;
client_max_body_size 25m;
resolver 127.0.0.11 valid=30s ipv6=off;
root /usr/share/nginx/html;
index index.html;
@ -89,7 +90,8 @@ server {
}
location /s3/ {
proxy_pass https://minio:9000/;
set $minio_upstream https://minio:9000;
proxy_pass $minio_upstream/;
proxy_http_version 1.1;
proxy_set_header Host minio:9000;
proxy_set_header X-Real-IP $remote_addr;

View file

@ -77,9 +77,6 @@ print(request_id)
PY
)"
ATTACHMENTS_BODY="$(printf '{"filters":[{"field":"request_id","op":"=","value":%s}],"sort":[{"field":"created_at","dir":"asc"}],"page":{"limit":500,"offset":0}}' "$(json_escape "$REQUEST_ID")")"
INVOICES_BODY="$(printf '{"filters":[{"field":"request_id","op":"=","value":%s}],"sort":[{"field":"issued_at","dir":"desc"}],"page":{"limit":500,"offset":0}}' "$(json_escape "$REQUEST_ID")")"
measure_endpoint() {
local name="$1"
local method="$2"
@ -144,12 +141,10 @@ PY
: >"$TMP_DIR/raw.tsv"
measure_endpoint "kanban" "GET" "/api/admin/requests/kanban?limit=${KANBAN_LIMIT}&sort_mode=created_newest"
measure_endpoint "request_detail" "GET" "/api/admin/crud/requests/${REQUEST_ID}"
measure_endpoint "chat_messages" "GET" "/api/admin/chat/requests/${REQUEST_ID}/messages"
measure_endpoint "metrics_overview" "GET" "/api/admin/metrics/overview?include_sla=false"
measure_endpoint "metrics_overview_sla" "GET" "/api/admin/metrics/overview-sla"
measure_endpoint "request_workspace" "GET" "/api/admin/requests/${REQUEST_ID}/workspace"
measure_endpoint "chat_live" "GET" "/api/admin/chat/requests/${REQUEST_ID}/live"
measure_endpoint "status_route" "GET" "/api/admin/requests/${REQUEST_ID}/status-route"
measure_endpoint "attachments_query" "POST" "/api/admin/crud/attachments/query" "$ATTACHMENTS_BODY"
measure_endpoint "invoices_query" "POST" "/api/admin/invoices/query" "$INVOICES_BODY"
python3 - "$TMP_DIR/raw.tsv" "$REPORT_FILE" "$TS_HUMAN" "$BASE_URL" "$REQUEST_ID" "$ITERATIONS" <<'PY'
import csv
@ -190,12 +185,10 @@ with open(report_path, "w", encoding="utf-8") as out:
out.write("|---|---|---:|---:|---:|\n")
for name in [
"kanban",
"request_detail",
"chat_messages",
"metrics_overview",
"metrics_overview_sla",
"request_workspace",
"chat_live",
"status_route",
"attachments_query",
"invoices_query",
]:
items = rows.get(name, [])
totals = sorted(item["total_ms"] for item in items)

View file

@ -0,0 +1,266 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
BASE_URL="${1:-http://localhost:8081}"
REPORT_DIR="${REPORT_DIR:-reports/perf}"
ITERATIONS="${PERF_ITERATIONS:-5}"
ADMIN_EMAIL="${PERF_ADMIN_EMAIL:-admin@example.com}"
ADMIN_PASSWORD="${PERF_ADMIN_PASSWORD:-admin123}"
MESSAGE_COUNT="${PERF_LONG_CHAT_MESSAGES:-2000}"
WINDOW_LIMIT="${PERF_CHAT_WINDOW_LIMIT:-50}"
TS_HUMAN="$(date -u +"%Y-%m-%d %H:%M:%S UTC")"
TS_FILE="$(date -u +"%Y%m%d-%H%M%S")"
REPORT_FILE="${REPORT_DIR}/perf-long-chat-workspace-${TS_FILE}.md"
mkdir -p "$REPORT_DIR"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
require_cmd() {
command -v "$1" >/dev/null 2>&1 || {
echo "missing command: $1" >&2
exit 1
}
}
require_cmd curl
require_cmd python3
require_cmd docker
json_escape() {
python3 - "$1" <<'PY'
import json
import sys
print(json.dumps(sys.argv[1]))
PY
}
SEED_JSON_FILE="$TMP_DIR/seed.json"
PERF_LONG_CHAT_MESSAGES="$MESSAGE_COUNT" docker compose -f docker-compose.yml -f docker-compose.local.yml exec -T backend python - <<'PY' >"$SEED_JSON_FILE"
import json
import os
from datetime import datetime, timedelta, timezone
from app.db.session import SessionLocal
from app.models.request import Request
from app.models.message import Message
message_count = max(1, int(os.environ.get("PERF_LONG_CHAT_MESSAGES") or "2000"))
now = datetime.now(timezone.utc)
track = f"TRK-PERF-CHAT-{now.strftime('%Y%m%d%H%M%S')}"
db = SessionLocal()
try:
req = Request(
track_number=track,
client_name="Perf Chat Client",
client_phone="+79990009999",
topic_code="consulting",
status_code="IN_PROGRESS",
description=f"Perf long chat seed ({message_count})",
extra_fields={},
)
db.add(req)
db.flush()
started_at = now - timedelta(minutes=message_count)
batch = []
for index in range(message_count):
created_at = started_at + timedelta(minutes=index)
batch.append(
Message(
request_id=req.id,
author_type="CLIENT" if index % 2 == 0 else "LAWYER",
author_name="Клиент" if index % 2 == 0 else "Юрист",
body=f"perf message {index}",
created_at=created_at,
updated_at=created_at,
)
)
if len(batch) >= 500:
db.add_all(batch)
db.flush()
batch.clear()
if batch:
db.add_all(batch)
db.flush()
db.commit()
print(json.dumps({"request_id": str(req.id), "track_number": req.track_number, "message_count": message_count}))
finally:
db.close()
PY
REQUEST_ID="$(python3 - "$SEED_JSON_FILE" <<'PY'
import json
import sys
with open(sys.argv[1], "r", encoding="utf-8") as fh:
data = json.load(fh)
print(str(data["request_id"]))
PY
)"
TRACK_NUMBER="$(python3 - "$SEED_JSON_FILE" <<'PY'
import json
import sys
with open(sys.argv[1], "r", encoding="utf-8") as fh:
data = json.load(fh)
print(str(data["track_number"]))
PY
)"
LOGIN_BODY="$(printf '{"email":%s,"password":%s}' "$(json_escape "$ADMIN_EMAIL")" "$(json_escape "$ADMIN_PASSWORD")")"
LOGIN_RESPONSE_FILE="$TMP_DIR/login.json"
curl -fsS \
-H "Content-Type: application/json" \
-X POST \
-d "$LOGIN_BODY" \
"$BASE_URL/api/admin/auth/login" >"$LOGIN_RESPONSE_FILE"
AUTH_TOKEN="$(python3 - "$LOGIN_RESPONSE_FILE" <<'PY'
import json
import sys
with open(sys.argv[1], "r", encoding="utf-8") as fh:
data = json.load(fh)
token = str(data.get("access_token") or "").strip()
if not token:
raise SystemExit("login did not return access_token")
print(token)
PY
)"
measure_endpoint() {
local name="$1"
local path="$2"
local headers_file body_file curl_meta status_code total_ms
for run in $(seq 1 "$ITERATIONS"); do
headers_file="$TMP_DIR/${name}-${run}.headers"
body_file="$TMP_DIR/${name}-${run}.body"
curl_meta="$(curl -sS \
-D "$headers_file" \
-o "$body_file" \
-H "Authorization: Bearer $AUTH_TOKEN" \
-w '%{http_code} %{time_total}' \
"$BASE_URL$path")"
status_code="$(echo "$curl_meta" | awk '{print $1}')"
total_ms="$(echo "$curl_meta" | awk '{printf "%.2f", $2 * 1000}')"
if [[ "$status_code" != "200" ]]; then
echo "endpoint ${name} failed: HTTP ${status_code}" >&2
cat "$body_file" >&2 || true
exit 1
fi
python3 - "$headers_file" "$body_file" "$name" "$run" "$total_ms" >>"$TMP_DIR/raw.tsv" <<'PY'
import json
import sys
headers_path, body_path, name, run, total_ms = sys.argv[1:6]
headers = {}
with open(headers_path, "r", encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line or ":" not in line:
continue
key, value = line.split(":", 1)
headers[key.strip().lower()] = value.strip()
payload = {}
with open(body_path, "r", encoding="utf-8") as fh:
try:
payload = json.load(fh)
except Exception:
payload = {}
rows = payload.get("rows") or payload.get("messages") or []
print("\t".join([
name,
run,
total_ms,
headers.get("x-perf-label", ""),
headers.get("x-perf-duration-ms", ""),
str(len(rows) if isinstance(rows, list) else 0),
str(payload.get("total", payload.get("messages_total", 0)) or 0),
str(payload.get("has_more", payload.get("messages_has_more", False))),
]))
PY
done
}
: >"$TMP_DIR/raw.tsv"
measure_endpoint "request_workspace_long_chat" "/api/admin/requests/${REQUEST_ID}/workspace"
measure_endpoint "messages_window_older_page" "/api/admin/chat/requests/${REQUEST_ID}/messages-window?before_count=${WINDOW_LIMIT}&limit=${WINDOW_LIMIT}"
python3 - "$TMP_DIR/raw.tsv" "$REPORT_FILE" "$TS_HUMAN" "$BASE_URL" "$REQUEST_ID" "$TRACK_NUMBER" "$MESSAGE_COUNT" "$ITERATIONS" <<'PY'
import csv
import statistics
import sys
from collections import defaultdict
raw_path, report_path, ts_human, base_url, request_id, track_number, message_count, iterations = sys.argv[1:9]
rows = defaultdict(list)
with open(raw_path, "r", encoding="utf-8") as fh:
reader = csv.reader(fh, delimiter="\t")
for name, run, total_ms, perf_label, perf_duration, rows_len, total_items, has_more in reader:
rows[name].append(
{
"run": int(run),
"total_ms": float(total_ms or 0),
"perf_label": perf_label or "-",
"perf_duration_ms": float(perf_duration or 0),
"rows_len": int(rows_len or 0),
"total_items": int(total_items or 0),
"has_more": str(has_more).strip().lower() == "true",
}
)
def percentile(sorted_values, ratio):
if not sorted_values:
return 0.0
if len(sorted_values) == 1:
return sorted_values[0]
index = round((len(sorted_values) - 1) * ratio)
return sorted_values[index]
with open(report_path, "w", encoding="utf-8") as out:
out.write("# Perf Long Chat Workspace Report\n\n")
out.write(f"- Timestamp: `{ts_human}`\n")
out.write(f"- Base URL: `{base_url}`\n")
out.write(f"- Request ID: `{request_id}`\n")
out.write(f"- Track Number: `{track_number}`\n")
out.write(f"- Seeded Messages: `{message_count}`\n")
out.write(f"- Iterations per endpoint: `{iterations}`\n\n")
out.write("| Endpoint | Perf Label | Avg Total ms | P95 Total ms | Avg Server ms | Rows | Total | Has More |\n")
out.write("|---|---|---:|---:|---:|---:|---:|---|\n")
for name in ["request_workspace_long_chat", "messages_window_older_page"]:
items = rows.get(name, [])
totals = sorted(item["total_ms"] for item in items)
servers = [item["perf_duration_ms"] for item in items if item["perf_duration_ms"] > 0]
avg_total = statistics.mean(totals) if totals else 0.0
p95_total = percentile(totals, 0.95)
avg_server = statistics.mean(servers) if servers else 0.0
label = items[0]["perf_label"] if items else "-"
sample = items[0] if items else {"rows_len": 0, "total_items": 0, "has_more": False}
out.write(
f"| {name} | `{label}` | {avg_total:.2f} | {p95_total:.2f} | {avg_server:.2f} | "
f"{sample['rows_len']} | {sample['total_items']} | {sample['has_more']} |\n"
)
out.write("\n## Raw Runs\n\n")
out.write("| Endpoint | Run | Total ms | Server ms | Rows | Total | Has More |\n")
out.write("|---|---:|---:|---:|---:|---:|---|\n")
for name, items in rows.items():
for item in sorted(items, key=lambda value: value["run"]):
out.write(
f"| {name} | {item['run']} | {item['total_ms']:.2f} | {item['perf_duration_ms']:.2f} | "
f"{item['rows_len']} | {item['total_items']} | {item['has_more']} |\n"
)
PY
echo "report: $REPORT_FILE"

View file

@ -30,6 +30,7 @@ from app.models.client import Client
from app.models.form_field import FormField
from app.models.message import Message
from app.models.notification import Notification
from app.models.invoice import Invoice
from app.models.table_availability import TableAvailability
from app.models.quote import Quote
from app.models.request import Request
@ -71,6 +72,7 @@ class AdminUniversalCrudBase(unittest.TestCase):
TopicStatusTransition.__table__.create(bind=cls.engine)
AdminUserTopic.__table__.create(bind=cls.engine)
Notification.__table__.create(bind=cls.engine)
Invoice.__table__.create(bind=cls.engine)
TableAvailability.__table__.create(bind=cls.engine)
AuditLog.__table__.create(bind=cls.engine)
@ -78,6 +80,7 @@ class AdminUniversalCrudBase(unittest.TestCase):
def tearDownClass(cls):
AuditLog.__table__.drop(bind=cls.engine)
Notification.__table__.drop(bind=cls.engine)
Invoice.__table__.drop(bind=cls.engine)
TableAvailability.__table__.drop(bind=cls.engine)
AdminUserTopic.__table__.drop(bind=cls.engine)
RequestDataRequirement.__table__.drop(bind=cls.engine)
@ -117,6 +120,7 @@ class AdminUniversalCrudBase(unittest.TestCase):
db.execute(delete(TopicStatusTransition))
db.execute(delete(AdminUserTopic))
db.execute(delete(Notification))
db.execute(delete(Invoice))
db.execute(delete(TableAvailability))
db.execute(delete(Quote))
db.execute(delete(AdminUser))

View file

@ -257,6 +257,7 @@ class AdminLawyerChatTests(AdminUniversalCrudBase):
db.commit()
own_id = str(own.id)
foreign_id = str(foreign.id)
unassigned_id = str(unassigned.id)
foreign_msg_id = str(msg_foreign.id)
foreign_att_id = str(att_foreign.id)
@ -281,6 +282,17 @@ class AdminLawyerChatTests(AdminUniversalCrudBase):
attachment_request_ids = {str(row.get("request_id")) for row in (attachments_query.json().get("rows") or [])}
self.assertEqual(attachment_request_ids, {own_id, unassigned_id})
own_request_attachments = self.client.get(f"/api/admin/uploads/request-attachments/{own_id}", headers=headers)
self.assertEqual(own_request_attachments.status_code, 200)
self.assertEqual(len(own_request_attachments.json().get("rows") or []), 1)
unassigned_request_attachments = self.client.get(f"/api/admin/uploads/request-attachments/{unassigned_id}", headers=headers)
self.assertEqual(unassigned_request_attachments.status_code, 200)
self.assertEqual(len(unassigned_request_attachments.json().get("rows") or []), 1)
blocked_request_attachments = self.client.get(f"/api/admin/uploads/request-attachments/{foreign_id}", headers=headers)
self.assertEqual(blocked_request_attachments.status_code, 403)
foreign_message_get = self.client.get(f"/api/admin/crud/messages/{foreign_msg_id}", headers=headers)
self.assertEqual(foreign_message_get.status_code, 403)
foreign_attachment_get = self.client.get(f"/api/admin/crud/attachments/{foreign_att_id}", headers=headers)
@ -302,6 +314,98 @@ class AdminLawyerChatTests(AdminUniversalCrudBase):
)
self.assertEqual(blocked_unassigned_create.status_code, 403)
def test_request_workspace_endpoint_returns_compound_payload_with_role_scope(self):
with self.SessionLocal() as db:
lawyer_self = AdminUser(
role="LAWYER",
name="Юрист Workspace",
email="lawyer.workspace@example.com",
password_hash="hash",
is_active=True,
)
lawyer_other = AdminUser(
role="LAWYER",
name="Юрист Чужой Workspace",
email="lawyer.workspace.other@example.com",
password_hash="hash",
is_active=True,
)
db.add_all([lawyer_self, lawyer_other])
db.flush()
self_id = str(lawyer_self.id)
other_id = str(lawyer_other.id)
own = Request(
track_number="TRK-WORKSPACE-OWN",
client_name="Клиент Workspace",
client_phone="+79990010111",
status_code="IN_PROGRESS",
description="workspace own",
extra_fields={},
assigned_lawyer_id=self_id,
)
foreign = Request(
track_number="TRK-WORKSPACE-FOREIGN",
client_name="Клиент Workspace Foreign",
client_phone="+79990010112",
status_code="IN_PROGRESS",
description="workspace foreign",
extra_fields={},
assigned_lawyer_id=other_id,
)
db.add_all([own, foreign])
db.flush()
own_id = str(own.id)
foreign_id = str(foreign.id)
for index in range(55):
db.add(
Message(
request_id=own.id,
author_type="CLIENT",
author_name="Клиент",
body=f"workspace msg {index}",
)
)
db.add(
Attachment(
request_id=own.id,
file_name="workspace.pdf",
mime_type="application/pdf",
size_bytes=64,
s3_key=f"requests/{own.id}/workspace.pdf",
immutable=False,
)
)
db.commit()
headers = self._auth_headers("LAWYER", email="lawyer.workspace@example.com", sub=self_id)
own_workspace = self.client.get(f"/api/admin/requests/{own_id}/workspace", headers=headers)
self.assertEqual(own_workspace.status_code, 200)
payload = own_workspace.json()
self.assertEqual(str((payload.get("request") or {}).get("id")), own_id)
self.assertEqual(len(payload.get("messages") or []), 50)
self.assertTrue(bool(payload.get("messages_has_more")))
self.assertEqual(int(payload.get("messages_total") or 0), 55)
self.assertEqual(int(payload.get("messages_loaded_count") or 0), 50)
self.assertEqual(len(payload.get("attachments") or []), 1)
self.assertIn("status_route", payload)
self.assertIn("finance_summary", payload)
older_messages = self.chat_client.get(
f"/api/admin/chat/requests/{own_id}/messages-window",
headers=headers,
params={"before_count": 50, "limit": 10},
)
self.assertEqual(older_messages.status_code, 200)
older_payload = older_messages.json()
self.assertEqual(len(older_payload.get("rows") or []), 5)
self.assertFalse(bool(older_payload.get("has_more")))
self.assertEqual(int(older_payload.get("loaded_count") or 0), 55)
foreign_workspace = self.client.get(f"/api/admin/requests/{foreign_id}/workspace", headers=headers)
self.assertEqual(foreign_workspace.status_code, 403)
def test_topic_status_flow_supports_branching_transitions(self):
headers = self._auth_headers("ADMIN", email="root@example.com")
with self.SessionLocal() as db:
@ -508,6 +612,35 @@ class AdminLawyerChatTests(AdminUniversalCrudBase):
self.assertEqual(own_live_no_delta.status_code, 200)
self.assertFalse(bool(own_live_no_delta.json().get("has_updates")))
with self.SessionLocal() as db:
own_req = db.get(Request, UUID(own_id))
self.assertIsNotNone(own_req)
live_message = Message(request_id=own_req.id, author_type="CLIENT", author_name="Клиент", body="live delta", immutable=False)
db.add(live_message)
db.flush()
db.add(
Attachment(
request_id=own_req.id,
message_id=live_message.id,
file_name="live-delta.pdf",
mime_type="application/pdf",
size_bytes=321,
s3_key=f"requests/{own_req.id}/live-delta.pdf",
immutable=False,
)
)
db.commit()
own_live_delta = self.chat_client.get(
f"/api/admin/chat/requests/{own_id}/live",
headers=lawyer_headers,
params={"cursor": own_cursor},
)
self.assertEqual(own_live_delta.status_code, 200)
self.assertTrue(bool(own_live_delta.json().get("has_updates")))
self.assertEqual(len(own_live_delta.json().get("messages") or []), 1)
self.assertEqual(len(own_live_delta.json().get("attachments") or []), 1)
foreign_live = self.chat_client.get(f"/api/admin/chat/requests/{foreign_id}/live", headers=lawyer_headers)
self.assertEqual(foreign_live.status_code, 403)

View file

@ -181,6 +181,63 @@ class AdminMetricsTemplatesTests(AdminUniversalCrudBase):
self.assertAlmostEqual(float(body["frt_avg_minutes"]), 20.0, places=1)
self.assertIn("NEW", body.get("avg_time_in_status_hours") or {})
def test_dashboard_metrics_can_skip_sla_for_fast_overview(self):
headers = self._auth_headers("ADMIN", email="root@example.com")
now = datetime.now(timezone.utc)
with self.SessionLocal() as db:
db.add_all(
[
Status(code="NEW", name="Новая", enabled=True, sort_order=0, is_terminal=False),
Status(code="CLOSED", name="Закрыта", enabled=True, sort_order=1, is_terminal=True),
]
)
req = Request(
track_number="TRK-SLA-M-FAST-1",
client_name="Клиент SLA Fast",
client_phone="+79990000013",
topic_code="civil-law",
status_code="NEW",
extra_fields={},
created_at=now - timedelta(hours=30),
updated_at=now - timedelta(hours=30),
)
db.add(req)
db.flush()
db.add(
Message(
request_id=req.id,
author_type="LAWYER",
author_name="Юрист",
body="Ответ",
created_at=req.created_at + timedelta(minutes=20),
updated_at=req.created_at + timedelta(minutes=20),
)
)
db.add(
StatusHistory(
request_id=req.id,
from_status=None,
to_status="NEW",
changed_by_admin_id=None,
created_at=now - timedelta(hours=30),
updated_at=now - timedelta(hours=30),
)
)
db.commit()
response = self.client.get("/api/admin/metrics/overview?include_sla=false", headers=headers)
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertEqual(int(body.get("sla_overdue") or 0), 0)
self.assertIsNone(body.get("frt_avg_minutes"))
self.assertEqual(body.get("avg_time_in_status_hours"), {})
sla_response = self.client.get("/api/admin/metrics/overview-sla", headers=headers)
self.assertEqual(sla_response.status_code, 200)
sla_body = sla_response.json()
self.assertGreaterEqual(int(sla_body.get("sla_overdue") or 0), 1)
self.assertIsNotNone(sla_body.get("frt_avg_minutes"))
def test_admin_can_manage_admin_user_topics_only_for_lawyers(self):
headers = self._auth_headers("ADMIN", email="root@example.com")
with self.SessionLocal() as db:

View file

@ -896,3 +896,104 @@ class AdminStatusFlowKanbanTests(AdminUniversalCrudBase):
sorted_rows = sorted_by_deadline.json().get("rows") or []
self.assertTrue(sorted_rows)
self.assertEqual(sorted_rows[0]["id"], request_overdue_id)
def test_requests_kanban_created_newest_uses_limit_without_losing_total(self):
with self.SessionLocal() as db:
db.add(Status(code="NEW", name="Новая", enabled=True, sort_order=1, is_terminal=False, kind="DEFAULT"))
db.flush()
base_created_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
request_ids = []
for index in range(4):
request_row = Request(
track_number=f"TRK-KANBAN-LIMIT-{index}",
client_name=f"Клиент {index}",
client_phone=f"+7999000010{index}",
status_code="NEW",
description=f"limit-{index}",
extra_fields={},
created_at=base_created_at + timedelta(minutes=index),
)
db.add(request_row)
db.flush()
request_ids.append(str(request_row.id))
db.commit()
headers = self._auth_headers("ADMIN", email="root@example.com")
response = self.client.get("/api/admin/requests/kanban?limit=2", headers=headers)
self.assertEqual(response.status_code, 200)
payload = response.json()
rows = payload.get("rows") or []
self.assertEqual(payload["total"], 4)
self.assertTrue(bool(payload["truncated"]))
self.assertEqual(len(rows), 2)
self.assertEqual([row["id"] for row in rows], [request_ids[3], request_ids[2]])
def test_requests_kanban_lawyer_sort_uses_limit_without_losing_total(self):
with self.SessionLocal() as db:
db.add(Status(code="NEW", name="Новая", enabled=True, sort_order=1, is_terminal=False, kind="DEFAULT"))
lawyer_b = AdminUser(
role="LAWYER",
name="Boris Lawyer",
email="lawyer-boris@example.com",
password_hash="hash",
is_active=True,
)
lawyer_a = AdminUser(
role="LAWYER",
name="Alex Lawyer",
email="lawyer-alexey@example.com",
password_hash="hash",
is_active=True,
)
db.add_all([lawyer_b, lawyer_a])
db.flush()
base_created_at = datetime(2026, 1, 2, tzinfo=timezone.utc)
rows = [
Request(
track_number="TRK-KANBAN-LAWYER-1",
client_name="Клиент 1",
client_phone="+79990001111",
status_code="NEW",
description="lawyer-sort-1",
extra_fields={},
assigned_lawyer_id=str(lawyer_b.id),
created_at=base_created_at + timedelta(minutes=1),
),
Request(
track_number="TRK-KANBAN-LAWYER-2",
client_name="Клиент 2",
client_phone="+79990001112",
status_code="NEW",
description="lawyer-sort-2",
extra_fields={},
assigned_lawyer_id=str(lawyer_a.id),
created_at=base_created_at + timedelta(minutes=2),
),
Request(
track_number="TRK-KANBAN-LAWYER-3",
client_name="Клиент 3",
client_phone="+79990001113",
status_code="NEW",
description="lawyer-sort-3",
extra_fields={},
assigned_lawyer_id=None,
created_at=base_created_at + timedelta(minutes=3),
),
]
db.add_all(rows)
db.commit()
headers = self._auth_headers("ADMIN", email="root@example.com")
response = self.client.get("/api/admin/requests/kanban?limit=2&sort_mode=lawyer", headers=headers)
self.assertEqual(response.status_code, 200)
payload = response.json()
response_rows = payload.get("rows") or []
self.assertEqual(payload["total"], 3)
self.assertTrue(bool(payload["truncated"]))
self.assertEqual(len(response_rows), 2)
self.assertEqual(
[str(item.get("assigned_lawyer_name") or "") for item in response_rows],
["Alex Lawyer", "Boris Lawyer"],
)

View file

@ -106,6 +106,66 @@ class HttpHardeningTests(unittest.TestCase):
}
self.assertEqual(_performance_label(Request(scope)), "public_request_status_route")
def test_performance_label_maps_admin_chat_messages_window(self):
scope = {
"type": "http",
"http_version": "1.1",
"method": "GET",
"scheme": "http",
"path": "/api/admin/chat/requests/123/messages-window",
"raw_path": b"/api/admin/chat/requests/123/messages-window",
"query_string": b"",
"headers": [],
"client": ("127.0.0.1", 12345),
"server": ("testserver", 80),
}
self.assertEqual(_performance_label(Request(scope)), "admin_chat_messages_window")
def test_performance_label_maps_public_chat_messages_window(self):
scope = {
"type": "http",
"http_version": "1.1",
"method": "GET",
"scheme": "http",
"path": "/api/public/chat/requests/TRK-1/messages-window",
"raw_path": b"/api/public/chat/requests/TRK-1/messages-window",
"query_string": b"",
"headers": [],
"client": ("127.0.0.1", 12345),
"server": ("testserver", 80),
}
self.assertEqual(_performance_label(Request(scope)), "public_chat_messages_window")
def test_performance_label_maps_admin_metrics_overview(self):
scope = {
"type": "http",
"http_version": "1.1",
"method": "GET",
"scheme": "http",
"path": "/api/admin/metrics/overview",
"raw_path": b"/api/admin/metrics/overview",
"query_string": b"",
"headers": [],
"client": ("127.0.0.1", 12345),
"server": ("testserver", 80),
}
self.assertEqual(_performance_label(Request(scope)), "admin_metrics_overview")
def test_performance_label_maps_admin_metrics_overview_sla(self):
scope = {
"type": "http",
"http_version": "1.1",
"method": "GET",
"scheme": "http",
"path": "/api/admin/metrics/overview-sla",
"raw_path": b"/api/admin/metrics/overview-sla",
"query_string": b"",
"headers": [],
"client": ("127.0.0.1", 12345),
"server": ("testserver", 80),
}
self.assertEqual(_performance_label(Request(scope)), "admin_metrics_overview_sla")
def test_non_file_paths_keep_deny_framing(self):
scope = {
"type": "http",

View file

@ -259,6 +259,15 @@ class InvoiceApiTests(unittest.TestCase):
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["id"], own_invoice_id)
listed_by_request = self.client.get(f"/api/admin/invoices/by-request/{self.request_a_id}", headers=lawyer_a_headers)
self.assertEqual(listed_by_request.status_code, 200)
direct_rows = listed_by_request.json()["rows"]
self.assertEqual(len(direct_rows), 1)
self.assertEqual(direct_rows[0]["id"], own_invoice_id)
foreign_by_request = self.client.get(f"/api/admin/invoices/by-request/{self.request_b_id}", headers=lawyer_a_headers)
self.assertEqual(foreign_by_request.status_code, 403)
foreign_get = self.client.get(f"/api/admin/invoices/{foreign_invoice_id}", headers=lawyer_a_headers)
self.assertEqual(foreign_get.status_code, 403)

View file

@ -277,6 +277,26 @@ class PublicCabinetTests(unittest.TestCase):
self.assertEqual(len(listed.json()), 1)
self.assertIn("выделенный сервис", listed.json()[0]["body"])
for index in range(4):
created_extra = self.chat_client.post(
"/api/public/chat/requests/TRK-CHAT-001/messages",
cookies=cookies,
json={"body": f"Сообщение {index}"},
)
self.assertEqual(created_extra.status_code, 201)
listed_window = self.chat_client.get(
"/api/public/chat/requests/TRK-CHAT-001/messages-window",
cookies=cookies,
params={"limit": 2},
)
self.assertEqual(listed_window.status_code, 200)
window_payload = listed_window.json()
self.assertEqual(len(window_payload.get("rows") or []), 2)
self.assertTrue(bool(window_payload.get("has_more")))
self.assertEqual(int(window_payload.get("loaded_count") or 0), 2)
self.assertEqual(int(window_payload.get("total") or 0), 5)
denied = self.chat_client.get("/api/public/chat/requests/TRK-CHAT-001/messages", cookies=self._public_cookies("TRK-OTHER"))
self.assertEqual(denied.status_code, 404)
@ -441,6 +461,39 @@ class PublicCabinetTests(unittest.TestCase):
self.assertEqual(live_no_delta.status_code, 200)
self.assertFalse(bool(live_no_delta.json().get("has_updates")))
with self.SessionLocal() as db:
req = db.query(Request).filter(Request.track_number == "TRK-LIVE-001").first()
self.assertIsNotNone(req)
live_message = Message(
request_id=req.id,
author_type="LAWYER",
author_name="Юрист",
body="Новое сообщение live",
)
db.add(live_message)
db.flush()
db.add(
Attachment(
request_id=req.id,
message_id=live_message.id,
file_name="live-public.pdf",
mime_type="application/pdf",
size_bytes=512,
s3_key=f"requests/{req.id}/live-public.pdf",
)
)
db.commit()
live_delta = self.chat_client.get(
"/api/public/chat/requests/TRK-LIVE-001/live",
params={"cursor": current_cursor},
cookies=cookies,
)
self.assertEqual(live_delta.status_code, 200)
self.assertTrue(bool(live_delta.json().get("has_updates")))
self.assertEqual(len(live_delta.json().get("messages") or []), 1)
self.assertEqual(len(live_delta.json().get("attachments") or []), 1)
typing_on = self.chat_client.post(
"/api/public/chat/requests/TRK-LIVE-001/typing",
cookies=cookies,