mirror of
https://github.com/TronoSfera/Law.git
synced 2026-05-18 18:13:46 +03:00
339 lines
13 KiB
Python
339 lines
13 KiB
Python
from __future__ import annotations
|
|
from datetime import datetime, timezone
|
|
from uuid import UUID
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.core.deps import get_public_session
|
|
from app.db.session import get_db
|
|
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.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 (
|
|
create_client_message,
|
|
get_chat_activity_summary,
|
|
list_messages_for_request,
|
|
serialize_message,
|
|
serialize_messages_for_request,
|
|
)
|
|
from app.services.request_read_markers import EVENT_REQUEST_DATA, mark_unread_for_lawyer
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
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 _attachment_meta_for_public(req: Request, value_text: str | None, db: Session) -> dict | None:
|
|
raw = str(value_text or "").strip()
|
|
if not raw:
|
|
return None
|
|
try:
|
|
attachment_uuid = UUID(raw)
|
|
except ValueError:
|
|
return None
|
|
attachment = db.get(Attachment, attachment_uuid)
|
|
if attachment is None or attachment.request_id != req.id:
|
|
return None
|
|
return {
|
|
"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/public/uploads/object/{attachment.id}",
|
|
}
|
|
|
|
|
|
def _normalize_phone(raw: str | None) -> str:
|
|
value = str(raw or "").strip()
|
|
if not value:
|
|
return ""
|
|
allowed = {"+", "(", ")", "-", " "}
|
|
return "".join(ch for ch in value if ch.isdigit() or ch in allowed).strip()
|
|
|
|
|
|
def _normalize_track(raw: str | None) -> str:
|
|
return str(raw or "").strip().upper()
|
|
|
|
|
|
def _require_view_session_or_403(session: dict) -> str:
|
|
purpose = str(session.get("purpose") or "").strip().upper()
|
|
subject = str(session.get("sub") or "").strip()
|
|
if purpose != "VIEW_REQUEST" or not subject:
|
|
raise HTTPException(status_code=403, detail="Нет доступа к заявке")
|
|
return subject
|
|
|
|
|
|
def _request_for_track_or_404(db: Session, track_number: str) -> Request:
|
|
req = db.query(Request).filter(Request.track_number == _normalize_track(track_number)).first()
|
|
if req is None:
|
|
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
|
return req
|
|
|
|
|
|
def _ensure_view_access_or_403(session: dict, req: Request) -> None:
|
|
subject = _require_view_session_or_403(session)
|
|
subject_track = _normalize_track(subject)
|
|
if subject_track.startswith("TRK-") and subject_track != _normalize_track(req.track_number):
|
|
raise HTTPException(status_code=403, detail="Нет доступа к заявке")
|
|
if subject_track == _normalize_track(req.track_number):
|
|
return
|
|
if _normalize_phone(subject) and _normalize_phone(subject) == _normalize_phone(req.client_phone):
|
|
return
|
|
raise HTTPException(status_code=403, detail="Нет доступа к заявке")
|
|
|
|
|
|
@router.get("/requests/{track_number}/messages")
|
|
def list_messages_by_track(
|
|
track_number: str,
|
|
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)
|
|
rows = list_messages_for_request(db, req.id)
|
|
return serialize_messages_for_request(db, req.id, rows)
|
|
|
|
|
|
@router.post("/requests/{track_number}/messages", status_code=201)
|
|
def create_message_by_track(
|
|
track_number: str,
|
|
payload: PublicMessageCreate,
|
|
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)
|
|
row = create_client_message(db, request=req, body=payload.body)
|
|
return serialize_message(row)
|
|
|
|
|
|
@router.get("/requests/{track_number}/live")
|
|
def get_live_chat_state_by_track(
|
|
track_number: str,
|
|
cursor: str | None = None,
|
|
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)
|
|
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))
|
|
|
|
subject = _require_view_session_or_403(session)
|
|
actor_key = f"CLIENT:{_normalize_track(subject) or _normalize_phone(subject)}"
|
|
typing_rows = list_typing_presence(request_key=str(req.id), exclude_actor_key=actor_key)
|
|
return {
|
|
"track_number": req.track_number,
|
|
"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_client_summary(
|
|
db,
|
|
track_number=req.track_number,
|
|
request_id=req.id,
|
|
),
|
|
}
|
|
|
|
|
|
@router.post("/requests/{track_number}/typing")
|
|
def set_live_chat_typing_by_track(
|
|
track_number: str,
|
|
payload: dict,
|
|
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)
|
|
subject = _require_view_session_or_403(session)
|
|
typing = bool((payload or {}).get("typing"))
|
|
actor_key = f"CLIENT:{_normalize_track(subject) or _normalize_phone(subject)}"
|
|
set_typing_presence(
|
|
request_key=str(req.id),
|
|
actor_key=actor_key,
|
|
actor_label=str(req.client_name or "Клиент"),
|
|
actor_role="CLIENT",
|
|
typing=typing,
|
|
)
|
|
return {"status": "ok", "typing": typing}
|
|
|
|
|
|
@router.get("/requests/{track_number}/data-requests/{message_id}")
|
|
def get_data_request_by_message(
|
|
track_number: str,
|
|
message_id: str,
|
|
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)
|
|
try:
|
|
message_uuid = UUID(str(message_id))
|
|
except ValueError:
|
|
raise HTTPException(status_code=400, detail="Некорректный идентификатор сообщения")
|
|
message = db.get(Message, message_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 == message_uuid,
|
|
)
|
|
.order_by(RequestDataRequirement.sort_order.asc(), RequestDataRequirement.created_at.asc(), RequestDataRequirement.id.asc())
|
|
.all()
|
|
)
|
|
if not rows:
|
|
raise HTTPException(status_code=404, detail="Запрос данных не найден")
|
|
return {
|
|
"message_id": str(message.id),
|
|
"request_id": str(req.id),
|
|
"track_number": req.track_number,
|
|
"items": [
|
|
{
|
|
"id": str(row.id),
|
|
"key": row.key,
|
|
"label": row.label,
|
|
"field_type": str(row.field_type or "text"),
|
|
"value_text": row.value_text,
|
|
"value_file": _attachment_meta_for_public(req, row.value_text, db) if str(row.field_type or "").lower() == "file" else None,
|
|
"is_filled": bool(str(row.value_text or "").strip()),
|
|
"sort_order": int(row.sort_order or 0),
|
|
}
|
|
for row in rows
|
|
],
|
|
}
|
|
|
|
|
|
@router.post("/requests/{track_number}/data-requests/{message_id}")
|
|
def save_data_request_values(
|
|
track_number: str,
|
|
message_id: str,
|
|
payload: dict,
|
|
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)
|
|
try:
|
|
message_uuid = UUID(str(message_id))
|
|
except ValueError:
|
|
raise HTTPException(status_code=400, detail="Некорректный идентификатор сообщения")
|
|
message = db.get(Message, message_uuid)
|
|
if message is None or message.request_id != req.id:
|
|
raise HTTPException(status_code=404, detail="Сообщение запроса не найдено")
|
|
|
|
raw_items = (payload or {}).get("items")
|
|
if not isinstance(raw_items, list):
|
|
raise HTTPException(status_code=400, detail="Ожидается список items")
|
|
|
|
rows = (
|
|
db.query(RequestDataRequirement)
|
|
.filter(
|
|
RequestDataRequirement.request_id == req.id,
|
|
RequestDataRequirement.request_message_id == message_uuid,
|
|
)
|
|
.all()
|
|
)
|
|
by_id = {str(row.id): row for row in rows}
|
|
by_key = {str(row.key): row for row in rows}
|
|
updated = 0
|
|
for item in raw_items:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
target = None
|
|
raw_id = str(item.get("id") or "").strip()
|
|
if raw_id:
|
|
target = by_id.get(raw_id)
|
|
if target is None:
|
|
raw_key = str(item.get("key") or "").strip()
|
|
if raw_key:
|
|
target = by_key.get(raw_key)
|
|
if target is None:
|
|
continue
|
|
if str(target.field_type or "").lower() == "file":
|
|
attachment_id_raw = str(item.get("attachment_id") or item.get("value_text") or "").strip()
|
|
if attachment_id_raw:
|
|
try:
|
|
attachment_uuid = UUID(attachment_id_raw)
|
|
except ValueError:
|
|
raise HTTPException(status_code=400, detail="Некорректный attachment_id для файла")
|
|
attachment = db.get(Attachment, attachment_uuid)
|
|
if attachment is None or attachment.request_id != req.id:
|
|
raise HTTPException(status_code=400, detail="Файл для поля не найден или недоступен")
|
|
normalized = str(attachment.id)
|
|
else:
|
|
normalized = ""
|
|
else:
|
|
value_text = item.get("value_text")
|
|
normalized = str(value_text).strip() if value_text is not None else ""
|
|
target.value_text = normalized or None
|
|
target.responsible = "Клиент"
|
|
db.add(target)
|
|
updated += 1
|
|
|
|
if updated:
|
|
mark_unread_for_lawyer(req, EVENT_REQUEST_DATA)
|
|
req.responsible = "Клиент"
|
|
notify_request_event(
|
|
db,
|
|
request=req,
|
|
event_type=NOTIFICATION_EVENT_REQUEST_DATA,
|
|
actor_role="CLIENT",
|
|
body=f"Клиент обновил дополнительные данные ({updated})",
|
|
responsible="Клиент",
|
|
)
|
|
db.add(req)
|
|
db.commit()
|
|
else:
|
|
db.rollback()
|
|
|
|
messages = list_messages_for_request(db, req.id)
|
|
serialized = serialize_messages_for_request(db, req.id, messages)
|
|
current = next((item for item in serialized if str(item.get("id")) == str(message_uuid)), None)
|
|
return {"updated": updated, "message": current}
|