from __future__ import annotations from datetime import datetime, timezone from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Request as FastapiRequest 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 ( create_admin_or_lawyer_message, get_chat_activity_summary, 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 _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.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)) 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"))), "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