Law/app/api/public/chat.py
2026-02-28 11:45:08 +03:00

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}