Law/app/api/admin/chat.py
2026-03-17 00:49:39 +03:00

942 lines
38 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
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
from app.db.session import get_db
from app.models.admin_user import AdminUser
from app.models.attachment import Attachment
from app.models.message import Message
from app.models.request import Request
from app.models.request_data_requirement import RequestDataRequirement
from app.models.request_data_template import RequestDataTemplate
from app.models.request_data_template_item import RequestDataTemplateItem
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,
serialize_message,
serialize_messages_for_request,
)
from app.services.chat_presence import list_typing_presence, set_typing_presence
from app.services.security_audit import extract_client_ip, record_pii_access_event
router = APIRouter()
ALLOWED_VALUE_TYPES = {"string", "text", "date", "number", "file"}
def _audit_admin_chat_read(
db: Session,
*,
admin: dict,
http_request: FastapiRequest,
req: Request,
action: str,
details: dict | None = None,
) -> None:
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=action,
scope="CHAT",
request_id=req.id,
details=details or {},
responsible=str(admin.get("email") or "").strip() or "Администратор системы",
persist_now=True,
)
def _parse_cursor(raw: str | None) -> datetime | None:
value = str(raw or "").strip()
if not value:
return None
normalized = value.replace("Z", "+00:00")
try:
dt = datetime.fromisoformat(normalized)
except ValueError:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def _iso_or_none(dt: datetime | None) -> str | None:
if dt is None:
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
else:
dt = dt.astimezone(timezone.utc)
return dt.isoformat()
def _as_utc_datetime(value) -> datetime | None:
if value is None:
return None
if isinstance(value, datetime):
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
if isinstance(value, str):
return _parse_cursor(value)
return None
def _request_uuid_or_400(request_id: str) -> UUID:
try:
return UUID(str(request_id))
except ValueError:
raise HTTPException(status_code=400, detail="Некорректный идентификатор заявки")
def _request_for_id_or_404(db: Session, request_id: str) -> Request:
req = db.get(Request, _request_uuid_or_400(request_id))
if req is None:
raise HTTPException(status_code=404, detail="Заявка не найдена")
return req
def _ensure_lawyer_can_view_request_or_403(admin: dict, req: Request) -> None:
role = str(admin.get("role") or "").upper()
if role != "LAWYER":
return
actor = str(admin.get("sub") or "").strip()
if not actor:
raise HTTPException(status_code=401, detail="Некорректный токен")
assigned = str(req.assigned_lawyer_id or "").strip()
if assigned and actor != assigned:
raise HTTPException(status_code=403, detail="Юрист может видеть только свои и неназначенные заявки")
def _ensure_lawyer_can_manage_request_or_403(admin: dict, req: Request) -> None:
role = str(admin.get("role") or "").upper()
if role != "LAWYER":
return
actor = str(admin.get("sub") or "").strip()
if not actor:
raise HTTPException(status_code=401, detail="Некорректный токен")
assigned = str(req.assigned_lawyer_id or "").strip()
if not assigned or actor != assigned:
raise HTTPException(status_code=403, detail="Юрист может работать только со своими назначенными заявками")
def _parse_uuid_or_400(raw: str, field_name: str) -> UUID:
try:
return UUID(str(raw))
except ValueError:
raise HTTPException(status_code=400, detail=f'Некорректное поле "{field_name}"')
def _slugify_key(raw: str) -> str:
text = str(raw or "").strip().lower()
out = []
dash = False
for ch in text:
if ch.isalnum():
out.append(ch)
dash = False
continue
if not dash:
out.append("-")
dash = True
slug = "".join(out).strip("-")
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:
raise HTTPException(status_code=400, detail='Тип поля должен быть одним из: string, text, date, number, file')
return value
def _serialize_template(row: TopicDataTemplate) -> dict:
return {
"id": str(row.id),
"topic_code": row.topic_code,
"key": row.key,
"label": row.label,
"value_type": str(row.value_type or "text"),
"document_name": row.document_name,
"description": row.description,
"sort_order": int(row.sort_order or 0),
"enabled": bool(row.enabled),
}
def _serialize_request_data_template(row: RequestDataTemplate) -> dict:
return {
"id": str(row.id),
"topic_code": row.topic_code,
"name": row.name,
"description": row.description,
"enabled": bool(row.enabled),
"sort_order": int(row.sort_order or 0),
"created_by_admin_id": str(row.created_by_admin_id) if row.created_by_admin_id else None,
}
def _serialize_request_data_template_item(row: RequestDataTemplateItem) -> dict:
return {
"id": str(row.id),
"request_data_template_id": str(row.request_data_template_id),
"topic_data_template_id": str(row.topic_data_template_id) if row.topic_data_template_id else None,
"key": row.key,
"label": row.label,
"value_type": str(row.value_type or "string"),
"sort_order": int(row.sort_order or 0),
}
def _serialize_data_request_items(db: Session, rows: list[RequestDataRequirement]) -> list[dict]:
attachment_ids: list[UUID] = []
for row in rows:
if str(row.field_type or "").lower() != "file":
continue
try:
attachment_ids.append(UUID(str(row.value_text or "").strip()))
except Exception:
continue
attachment_map = {}
if attachment_ids:
attachment_rows = db.query(Attachment).filter(Attachment.id.in_(attachment_ids)).all() # type: ignore[name-defined]
attachment_map = {str(item.id): item for item in attachment_rows}
out = []
for index, row in enumerate(rows, start=1):
value_text = str(row.value_text or "").strip()
value_file = None
if str(row.field_type or "").lower() == "file" and value_text:
attachment = attachment_map.get(value_text)
if attachment is not None:
value_file = {
"attachment_id": str(attachment.id),
"file_name": attachment.file_name,
"mime_type": attachment.mime_type,
"size_bytes": int(attachment.size_bytes or 0),
"download_url": f"/api/admin/uploads/object/{attachment.id}",
}
out.append(
{
"id": str(row.id),
"request_message_id": str(row.request_message_id) if row.request_message_id else None,
"topic_template_id": str(row.topic_template_id) if row.topic_template_id else None,
"key": row.key,
"label": row.label,
"field_type": str(row.field_type or "text"),
"document_name": row.document_name,
"description": row.description,
"value_text": row.value_text,
"value_file": value_file,
"is_filled": bool(value_text),
"sort_order": int(row.sort_order or 0),
"index": index,
}
)
return out
@router.get("/requests/{request_id}/messages")
def list_request_messages(
request_id: str,
http_request: FastapiRequest,
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 = list_messages_for_request(db, req.id)
payload = {"rows": serialize_messages_for_request(db, req.id, rows), "total": len(rows)}
_audit_admin_chat_read(
db,
admin=admin,
http_request=http_request,
req=req,
action="READ_CHAT_MESSAGES",
details={"rows": len(rows)},
)
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,
payload: dict,
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_manage_request_or_403(admin, req)
body = str((payload or {}).get("body") or "").strip()
role = str(admin.get("role") or "").upper()
actor_name = str(admin.get("email") or "").strip() or ("Юрист" if role == "LAWYER" else "Администратор")
actor_admin_user_id = str(admin.get("sub") or "").strip() or None
if actor_admin_user_id:
try:
actor_uuid = UUID(actor_admin_user_id)
except ValueError:
actor_uuid = None
if actor_uuid is not None:
actor_user = db.get(AdminUser, actor_uuid)
if actor_user is not None:
actor_name = str(actor_user.name or actor_user.email or actor_name)
row = create_admin_or_lawyer_message(
db,
request=req,
body=body,
actor_role=role,
actor_name=actor_name,
actor_admin_user_id=actor_admin_user_id,
)
return serialize_message(row)
@router.get("/requests/{request_id}/live")
def get_request_live_state(
request_id: str,
http_request: FastapiRequest,
cursor: str | None = None,
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_delivered_for_staff(db, request_id=req.id)
summary = get_chat_activity_summary(db, req.id)
latest_activity_at = _as_utc_datetime(summary.get("latest_activity_at"))
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"
actor_key = f"{actor_role}:{actor_sub}"
typing_rows = list_typing_presence(request_key=str(req.id), exclude_actor_key=actor_key)
payload = {
"request_id": str(req.id),
"cursor": latest_activity_iso,
"has_updates": has_updates,
"message_count": int(summary.get("message_count") or 0),
"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,
admin_user_id=str(admin.get("sub") or ""),
request_id=req.id,
),
}
_audit_admin_chat_read(
db,
admin=admin,
http_request=http_request,
req=req,
action="READ_CHAT_LIVE_STATE",
details={"has_updates": bool(has_updates)},
)
return payload
@router.post("/requests/{request_id}/typing")
def set_request_typing_state(
request_id: str,
payload: dict,
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_manage_request_or_403(admin, req)
actor_role = str(admin.get("role") or "").strip().upper() or "UNKNOWN"
actor_sub = str(admin.get("sub") or "").strip() or "unknown"
actor_email = str(admin.get("email") or "").strip()
actor_key = f"{actor_role}:{actor_sub}"
actor_label = actor_email or ("Юрист" if actor_role == "LAWYER" else "Администратор")
typing = bool((payload or {}).get("typing"))
set_typing_presence(
request_key=str(req.id),
actor_key=actor_key,
actor_label=actor_label,
actor_role=actor_role,
typing=typing,
)
return {"status": "ok", "typing": typing}
@router.get("/requests/{request_id}/data-request-templates")
def list_data_request_templates(
request_id: str,
document: str | None = None,
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_manage_request_or_403(admin, req)
topic_code = str(req.topic_code or "").strip()
if not topic_code:
return {"rows": [], "documents": [], "templates": []}
query = db.query(TopicDataTemplate).filter(TopicDataTemplate.topic_code == topic_code)
document_name = str(document or "").strip()
if document_name:
query = query.filter(TopicDataTemplate.document_name == document_name)
rows = (
query.order_by(
TopicDataTemplate.document_name.asc().nullsfirst(),
TopicDataTemplate.sort_order.asc(),
TopicDataTemplate.label.asc(),
TopicDataTemplate.key.asc(),
).all()
)
all_docs_rows = (
db.query(TopicDataTemplate.document_name)
.filter(TopicDataTemplate.topic_code == topic_code, TopicDataTemplate.enabled.is_(True))
.distinct()
.all()
)
documents = sorted({str(row[0]).strip() for row in all_docs_rows if row[0]}, key=lambda x: x.lower())
template_rows = (
db.query(RequestDataTemplate)
.filter(RequestDataTemplate.topic_code == topic_code, RequestDataTemplate.enabled.is_(True))
.order_by(RequestDataTemplate.sort_order.asc(), RequestDataTemplate.name.asc())
.all()
)
return {
"rows": [_serialize_template(row) for row in rows if row.enabled], # legacy catalog payload
"documents": documents, # legacy
"templates": [_serialize_request_data_template(row) for row in template_rows],
}
@router.get("/requests/{request_id}/data-requests/{message_id}")
def get_data_request_batch(
request_id: str,
message_id: str,
http_request: FastapiRequest,
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)
msg_uuid = _parse_uuid_or_400(message_id, "message_id")
message = db.get(Message, msg_uuid)
if message is None or message.request_id != req.id:
raise HTTPException(status_code=404, detail="Сообщение запроса не найдено")
rows = (
db.query(RequestDataRequirement)
.filter(
RequestDataRequirement.request_id == req.id,
RequestDataRequirement.request_message_id == msg_uuid,
)
.order_by(RequestDataRequirement.sort_order.asc(), RequestDataRequirement.created_at.asc(), RequestDataRequirement.id.asc())
.all()
)
if not rows:
raise HTTPException(status_code=404, detail="Набор данных для сообщения не найден")
payload = {
"message_id": str(message.id),
"request_id": str(req.id),
"track_number": req.track_number,
"document_name": rows[0].document_name if rows else None,
"items": _serialize_data_request_items(db, rows),
}
_audit_admin_chat_read(
db,
admin=admin,
http_request=http_request,
req=req,
action="READ_CHAT_DATA_REQUEST",
details={"message_id": str(message.id), "rows": len(rows)},
)
return payload
@router.get("/requests/{request_id}/data-request-templates/{template_id}")
def get_data_request_template(
request_id: str,
template_id: str,
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_manage_request_or_403(admin, req)
template_uuid = _parse_uuid_or_400(template_id, "template_id")
template = db.get(RequestDataTemplate, template_uuid)
if template is None:
raise HTTPException(status_code=404, detail="Шаблон не найден")
if str(template.topic_code or "").strip() != str(req.topic_code or "").strip():
raise HTTPException(status_code=400, detail="Шаблон не соответствует теме заявки")
rows = (
db.query(RequestDataTemplateItem)
.filter(RequestDataTemplateItem.request_data_template_id == template.id)
.order_by(RequestDataTemplateItem.sort_order.asc(), RequestDataTemplateItem.created_at.asc(), RequestDataTemplateItem.id.asc())
.all()
)
return {
"template": _serialize_request_data_template(template),
"items": [_serialize_request_data_template_item(row) for row in rows],
}
@router.post("/requests/{request_id}/data-request-templates", status_code=201)
def save_data_request_template(
request_id: str,
payload: dict,
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_manage_request_or_403(admin, req)
topic_code = str(req.topic_code or "").strip()
if not topic_code:
raise HTTPException(status_code=400, detail="У заявки не указана тема")
body = payload or {}
name = str(body.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="Укажите название шаблона")
raw_items = body.get("items")
if not isinstance(raw_items, list) or not raw_items:
raise HTTPException(status_code=400, detail="Шаблон должен содержать хотя бы одно поле")
actor_uuid = None
raw_actor = str(admin.get("sub") or "").strip()
if raw_actor:
try:
actor_uuid = UUID(raw_actor)
except ValueError:
actor_uuid = None
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
template = None
template_id_raw = str(body.get("template_id") or "").strip()
if template_id_raw:
template = db.get(RequestDataTemplate, _parse_uuid_or_400(template_id_raw, "template_id"))
if template is None:
raise HTTPException(status_code=404, detail="Шаблон не найден")
if str(template.topic_code or "").strip() != topic_code:
raise HTTPException(status_code=400, detail="Шаблон не соответствует теме заявки")
else:
template = (
db.query(RequestDataTemplate)
.filter(RequestDataTemplate.topic_code == topic_code, RequestDataTemplate.name == name)
.first()
)
if template is None:
template = RequestDataTemplate(
topic_code=topic_code,
name=name,
enabled=True,
sort_order=0,
created_by_admin_id=actor_uuid,
responsible=responsible,
)
db.add(template)
db.flush()
else:
actor_role = str(admin.get("role") or "").upper()
if actor_role == "LAWYER":
owner_id = str(template.created_by_admin_id or "").strip()
actor_id = str(actor_uuid or "").strip()
if owner_id and actor_id and owner_id != actor_id:
raise HTTPException(status_code=403, detail="Юрист может изменять только свои шаблоны")
template.name = name
template.responsible = responsible
db.add(template)
db.flush()
touched_keys: set[str] = set()
normalized_items: list[tuple[int, TopicDataTemplate | None, str, str, str]] = []
for index, item in enumerate(raw_items):
if not isinstance(item, dict):
raise HTTPException(status_code=400, detail="Элемент шаблона должен быть объектом")
catalog = None
catalog_id_raw = str(item.get("topic_data_template_id") or item.get("topic_template_id") or "").strip()
if catalog_id_raw:
catalog = db.get(TopicDataTemplate, _parse_uuid_or_400(catalog_id_raw, "topic_data_template_id"))
if catalog is None:
raise HTTPException(status_code=400, detail="Поле справочника не найдено")
label = str(item.get("label") or (catalog.label if catalog else "")).strip()
if not label:
raise HTTPException(status_code=400, detail="Укажите наименование поля")
value_type = _normalize_value_type(item.get("value_type") or item.get("field_type") or (catalog.value_type if catalog else None))
key = str(item.get("key") or (catalog.key if catalog else "")).strip()
if not key:
key = _slugify_key(label)
key = key[:80]
if key in touched_keys:
raise HTTPException(status_code=400, detail=f'Поле "{label}" добавлено дважды')
touched_keys.add(key)
if catalog is None:
catalog = (
db.query(TopicDataTemplate)
.filter(TopicDataTemplate.topic_code == topic_code, TopicDataTemplate.key == key)
.first()
)
if catalog is None:
catalog = TopicDataTemplate(
topic_code=topic_code,
key=key,
label=label,
value_type=value_type,
enabled=True,
required=True,
sort_order=index,
responsible=responsible,
)
db.add(catalog)
db.flush()
else:
changed = False
if str(catalog.label or "") != label:
catalog.label = label
changed = True
if str(catalog.value_type or "string") != value_type:
catalog.value_type = value_type
changed = True
if changed:
catalog.responsible = responsible
db.add(catalog)
db.flush()
normalized_items.append((index, catalog, key, label, value_type))
existing_items = (
db.query(RequestDataTemplateItem)
.filter(RequestDataTemplateItem.request_data_template_id == template.id)
.all()
)
by_key = {str(row.key): row for row in existing_items}
for index, catalog, key, label, value_type in normalized_items:
row = by_key.get(key)
if row is None:
row = RequestDataTemplateItem(
request_data_template_id=template.id,
topic_data_template_id=catalog.id if catalog else None,
key=key,
label=label,
value_type=value_type,
sort_order=index,
responsible=responsible,
)
else:
row.topic_data_template_id = catalog.id if catalog else None
row.label = label
row.value_type = value_type
row.sort_order = index
row.responsible = responsible
db.add(row)
for row in existing_items:
if str(row.key) not in touched_keys:
db.delete(row)
db.commit()
db.refresh(template)
items = (
db.query(RequestDataTemplateItem)
.filter(RequestDataTemplateItem.request_data_template_id == template.id)
.order_by(RequestDataTemplateItem.sort_order.asc(), RequestDataTemplateItem.created_at.asc(), RequestDataTemplateItem.id.asc())
.all()
)
return {"template": _serialize_request_data_template(template), "items": [_serialize_request_data_template_item(row) for row in items]}
@router.post("/requests/{request_id}/data-requests", status_code=201)
def upsert_data_request_batch(
request_id: str,
payload: dict,
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_manage_request_or_403(admin, req)
actor_role = str(admin.get("role") or "").strip().upper()
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
body = payload or {}
raw_items = body.get("items")
if not isinstance(raw_items, list) or not raw_items:
raise HTTPException(status_code=400, detail="Нужно передать список полей запроса")
message_id_raw = str(body.get("message_id") or "").strip()
existing_message = None
existing_message_rows: list[RequestDataRequirement] = []
if message_id_raw:
msg_uuid = _parse_uuid_or_400(message_id_raw, "message_id")
existing_message = db.get(Message, msg_uuid)
if existing_message is None or existing_message.request_id != req.id:
raise HTTPException(status_code=404, detail="Сообщение запроса не найдено")
existing_message_rows = (
db.query(RequestDataRequirement)
.filter(
RequestDataRequirement.request_id == req.id,
RequestDataRequirement.request_message_id == existing_message.id,
)
.all()
)
else:
role = actor_role
actor_name = str(admin.get("email") or "").strip() or ("Юрист" if role == "LAWYER" else "Администратор")
actor_admin_user_id = str(admin.get("sub") or "").strip() or None
if actor_admin_user_id:
try:
actor_uuid = UUID(actor_admin_user_id)
except ValueError:
actor_uuid = None
if actor_uuid is not None:
actor_user = db.get(AdminUser, actor_uuid)
if actor_user is not None:
actor_name = str(actor_user.name or actor_user.email or actor_name)
existing_message = create_admin_or_lawyer_message(
db,
request=req,
body="Запрос",
actor_role=role,
actor_name=actor_name,
actor_admin_user_id=actor_admin_user_id,
event_type=NOTIFICATION_EVENT_REQUEST_DATA,
)
message_uuid = existing_message.id
topic_code = str(req.topic_code or "").strip()
document_name_default = str(body.get("document_name") or "").strip() or None
actor_uuid = None
raw_actor = str(admin.get("sub") or "").strip()
if raw_actor:
try:
actor_uuid = UUID(raw_actor)
except ValueError:
actor_uuid = None
normalized_rows: list[RequestDataRequirement] = []
touched_keys: set[str] = set()
for index, item in enumerate(raw_items):
if not isinstance(item, dict):
raise HTTPException(status_code=400, detail="Элемент списка полей должен быть объектом")
template = None
template_id_raw = str(item.get("topic_template_id") or "").strip()
if template_id_raw:
template = db.get(TopicDataTemplate, _parse_uuid_or_400(template_id_raw, "topic_template_id"))
if template is None:
raise HTTPException(status_code=400, detail="Шаблон дополнительного поля не найден")
label = str(item.get("label") or (template.label if template else "")).strip()
if not label:
raise HTTPException(status_code=400, detail="Укажите наименование поля")
field_type = _normalize_value_type(item.get("field_type") or (template.value_type if template else None))
doc_name = str(item.get("document_name") or (template.document_name if template else "") or (document_name_default or "")).strip() or None
key = str(item.get("key") or (template.key if template else "")).strip()
if not key:
key = _slugify_key(label)
key = key[:80]
if key in touched_keys:
raise HTTPException(status_code=400, detail=f'Поле "{label}" добавлено дважды')
touched_keys.add(key)
topic_template_id = None
if template is not None:
topic_template_id = template.id
elif topic_code:
existing_template = (
db.query(TopicDataTemplate)
.filter(TopicDataTemplate.topic_code == topic_code, TopicDataTemplate.key == key)
.first()
)
if existing_template is None:
existing_template = TopicDataTemplate(
topic_code=topic_code,
key=key,
label=label,
value_type=field_type,
document_name=doc_name,
enabled=True,
required=True,
sort_order=index,
responsible=str(admin.get("email") or "").strip() or "Администратор системы",
)
db.add(existing_template)
db.flush()
else:
changed = False
if str(existing_template.label or "") != label:
existing_template.label = label
changed = True
if str(existing_template.value_type or "text") != field_type:
existing_template.value_type = field_type
changed = True
if str(existing_template.document_name or "") != str(doc_name or ""):
existing_template.document_name = doc_name
changed = True
if changed:
db.add(existing_template)
db.flush()
topic_template_id = existing_template.id
req_row = (
db.query(RequestDataRequirement)
.filter(RequestDataRequirement.request_id == req.id, RequestDataRequirement.key == key)
.first()
)
if req_row is None:
req_row = RequestDataRequirement(
request_id=req.id,
request_message_id=message_uuid,
topic_template_id=topic_template_id,
key=key,
label=label,
field_type=field_type,
document_name=doc_name,
required=True,
sort_order=index,
created_by_admin_id=actor_uuid,
responsible=str(admin.get("email") or "").strip() or "Администратор системы",
)
else:
if actor_role == "LAWYER" and str(req_row.value_text or "").strip():
current_message_id = str(req_row.request_message_id or "")
incoming_message_id = str(message_uuid or "")
current_topic_template_id = str(req_row.topic_template_id or "")
incoming_topic_template_id = str(topic_template_id or "")
current_doc_name = str(req_row.document_name or "") if req_row.document_name is not None else ""
incoming_doc_name = str(doc_name or "")
if (
str(req_row.label or "") != label
or str(req_row.field_type or "text") != field_type
or current_doc_name != incoming_doc_name
or current_topic_template_id != incoming_topic_template_id
or current_message_id != incoming_message_id
or int(req_row.sort_order or 0) != int(index)
):
raise HTTPException(status_code=403, detail="Юрист не может изменять заполненные клиентом данные")
req_row.request_message_id = message_uuid
req_row.topic_template_id = topic_template_id
req_row.label = label
req_row.field_type = field_type
req_row.document_name = doc_name
req_row.sort_order = index
req_row.responsible = str(admin.get("email") or "").strip() or "Администратор системы"
db.add(req_row)
normalized_rows.append(req_row)
if message_id_raw:
if actor_role == "LAWYER":
for row in existing_message_rows:
if row.key not in touched_keys and str(row.value_text or "").strip():
raise HTTPException(status_code=403, detail="Юрист не может удалять заполненные клиентом данные")
for row in existing_message_rows:
if row.key not in touched_keys:
db.delete(row)
if existing_message is not None:
existing_message.updated_at = datetime.now(timezone.utc)
db.add(existing_message)
mark_unread_for_client(req, EVENT_REQUEST_DATA)
req.responsible = responsible
db.add(req)
notify_request_event(
db,
request=req,
event_type=NOTIFICATION_EVENT_REQUEST_DATA,
actor_role=actor_role,
actor_admin_user_id=admin.get("sub"),
body=f"Обновлен запрос дополнительных данных ({len(normalized_rows)})",
responsible=responsible,
)
db.commit()
fresh_messages = list_messages_for_request(db, req.id)
serialized = serialize_messages_for_request(db, req.id, fresh_messages)
payload_row = next((item for item in serialized if str(item.get("id")) == str(message_uuid)), None)
if payload_row is None:
raise HTTPException(status_code=500, detail="Не удалось сформировать сообщение запроса")
return payload_row