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