mirror of
https://github.com/TronoSfera/Law.git
synced 2026-05-18 10:03:45 +03:00
600 lines
20 KiB
Python
600 lines
20 KiB
Python
from __future__ import annotations
|
|
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
from sqlalchemy import and_, func
|
|
from sqlalchemy.exc import SQLAlchemyError
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.models.admin_user import AdminUser
|
|
from app.models.notification import Notification
|
|
from app.models.request import Request
|
|
from app.services.telegram_notify import send_telegram_message
|
|
|
|
RECIPIENT_CLIENT = "CLIENT"
|
|
RECIPIENT_ADMIN_USER = "ADMIN_USER"
|
|
|
|
EVENT_MESSAGE = "MESSAGE"
|
|
EVENT_ATTACHMENT = "ATTACHMENT"
|
|
EVENT_STATUS = "STATUS"
|
|
EVENT_SLA_OVERDUE = "SLA_OVERDUE"
|
|
EVENT_REQUEST_DATA = "REQUEST_DATA"
|
|
EVENT_ASSIGNMENT = "ASSIGNMENT"
|
|
EVENT_REASSIGNMENT = "REASSIGNMENT"
|
|
|
|
_EVENT_LABELS = {
|
|
EVENT_MESSAGE: "Новое сообщение",
|
|
EVENT_ATTACHMENT: "Новый файл",
|
|
EVENT_STATUS: "Изменен статус",
|
|
EVENT_SLA_OVERDUE: "SLA просрочен",
|
|
EVENT_REQUEST_DATA: "Запрос/обновление данных",
|
|
EVENT_ASSIGNMENT: "Заявка назначена",
|
|
EVENT_REASSIGNMENT: "Заявка переназначена",
|
|
}
|
|
CHAT_PARTICIPANT_ADMIN_IDS_KEY = "chat_participant_admin_ids"
|
|
|
|
|
|
def _as_utc_now() -> datetime:
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
def _as_uuid_or_none(value: Any) -> uuid.UUID | None:
|
|
try:
|
|
return uuid.UUID(str(value))
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def _normalize_track(value: Any) -> str | None:
|
|
track = str(value or "").strip().upper()
|
|
return track or None
|
|
|
|
|
|
def _normalized_event(event_type: str) -> str:
|
|
return str(event_type or "").strip().upper()
|
|
|
|
|
|
def _title_for_event(event_type: str, request: Request) -> str:
|
|
prefix = _EVENT_LABELS.get(event_type, "Обновление")
|
|
track = str(request.track_number or "").strip() or str(request.id)
|
|
return f"{prefix} по заявке {track}"
|
|
|
|
|
|
def _telegram_text_for_event(event_type: str, request: Request, body: str | None = None) -> str:
|
|
label = _EVENT_LABELS.get(event_type, "Обновление")
|
|
track = str(request.track_number or "").strip() or str(request.id)
|
|
topic = str(request.topic_code or "").strip() or "-"
|
|
status = str(request.status_code or "").strip() or "-"
|
|
tail = f"\n{body.strip()}" if str(body or "").strip() else ""
|
|
return f"#{track}\n{label}\nТема: {topic}\nСтатус: {status}{tail}"
|
|
|
|
|
|
def _active_admin_ids(db: Session, *, exclude_admin_user_id: uuid.UUID | None = None) -> list[uuid.UUID]:
|
|
try:
|
|
rows = (
|
|
db.query(AdminUser.id)
|
|
.filter(
|
|
AdminUser.role == "ADMIN",
|
|
AdminUser.is_active.is_(True),
|
|
)
|
|
.all()
|
|
)
|
|
except SQLAlchemyError:
|
|
# Some isolated tests bootstrap only a subset of tables.
|
|
return []
|
|
out: list[uuid.UUID] = []
|
|
for (admin_id,) in rows:
|
|
if not admin_id:
|
|
continue
|
|
if exclude_admin_user_id is not None and admin_id == exclude_admin_user_id:
|
|
continue
|
|
out.append(admin_id)
|
|
return out
|
|
|
|
|
|
def _chat_participant_admin_ids(request: Request) -> set[uuid.UUID]:
|
|
if not isinstance(request.extra_fields, dict):
|
|
return set()
|
|
raw = request.extra_fields.get(CHAT_PARTICIPANT_ADMIN_IDS_KEY)
|
|
values = raw if isinstance(raw, list) else [raw]
|
|
out: set[uuid.UUID] = set()
|
|
for value in values:
|
|
parsed = _as_uuid_or_none(value)
|
|
if parsed is not None:
|
|
out.add(parsed)
|
|
return out
|
|
|
|
|
|
def _create_notification(
|
|
db: Session,
|
|
*,
|
|
request: Request,
|
|
recipient_type: str,
|
|
recipient_admin_user_id: uuid.UUID | None = None,
|
|
recipient_track_number: str | None = None,
|
|
event_type: str,
|
|
title: str,
|
|
body: str | None = None,
|
|
payload: dict[str, Any] | None = None,
|
|
responsible: str = "Система уведомлений",
|
|
dedupe_key: str | None = None,
|
|
) -> Notification | None:
|
|
recipient_kind = str(recipient_type or "").strip().upper()
|
|
if recipient_kind not in {RECIPIENT_CLIENT, RECIPIENT_ADMIN_USER}:
|
|
return None
|
|
if recipient_kind == RECIPIENT_CLIENT and not _normalize_track(recipient_track_number):
|
|
return None
|
|
if recipient_kind == RECIPIENT_ADMIN_USER and recipient_admin_user_id is None:
|
|
return None
|
|
|
|
normalized_dedupe = str(dedupe_key or "").strip() or None
|
|
if normalized_dedupe:
|
|
exists = db.query(Notification.id).filter(Notification.dedupe_key == normalized_dedupe).first()
|
|
if exists is not None:
|
|
return None
|
|
|
|
row = Notification(
|
|
request_id=request.id,
|
|
recipient_type=recipient_kind,
|
|
recipient_admin_user_id=recipient_admin_user_id if recipient_kind == RECIPIENT_ADMIN_USER else None,
|
|
recipient_track_number=_normalize_track(recipient_track_number) if recipient_kind == RECIPIENT_CLIENT else None,
|
|
event_type=_normalized_event(event_type),
|
|
title=str(title or "").strip() or _title_for_event(event_type, request),
|
|
body=str(body or "").strip() or None,
|
|
payload=dict(payload or {}),
|
|
is_read=False,
|
|
read_at=None,
|
|
responsible=str(responsible or "").strip() or "Система уведомлений",
|
|
dedupe_key=normalized_dedupe,
|
|
)
|
|
db.add(row)
|
|
return row
|
|
|
|
|
|
def notify_request_event(
|
|
db: Session,
|
|
*,
|
|
request: Request,
|
|
event_type: str,
|
|
actor_role: str,
|
|
actor_admin_user_id: str | uuid.UUID | None = None,
|
|
body: str | None = None,
|
|
responsible: str = "Система уведомлений",
|
|
send_telegram: bool = True,
|
|
dedupe_prefix: str | None = None,
|
|
) -> dict[str, int]:
|
|
event = _normalized_event(event_type)
|
|
actor = str(actor_role or "").strip().upper() or "SYSTEM"
|
|
actor_uuid = _as_uuid_or_none(actor_admin_user_id)
|
|
title = _title_for_event(event, request)
|
|
payload = {
|
|
"request_id": str(request.id),
|
|
"track_number": request.track_number,
|
|
"topic_code": request.topic_code,
|
|
"status_code": request.status_code,
|
|
"event_type": event,
|
|
"actor_role": actor,
|
|
}
|
|
|
|
internal_created = 0
|
|
telegram_sent = 0
|
|
|
|
def _dedupe_key_for(recipient_marker: str) -> str | None:
|
|
prefix = str(dedupe_prefix or "").strip()
|
|
if not prefix:
|
|
return None
|
|
return f"{prefix}:{recipient_marker}"
|
|
|
|
def _notify_client() -> None:
|
|
nonlocal internal_created
|
|
track = _normalize_track(request.track_number)
|
|
if not track:
|
|
return
|
|
dedupe_key = _dedupe_key_for(f"client:{track}")
|
|
row = _create_notification(
|
|
db,
|
|
request=request,
|
|
recipient_type=RECIPIENT_CLIENT,
|
|
recipient_track_number=track,
|
|
event_type=event,
|
|
title=title,
|
|
body=body,
|
|
payload=payload,
|
|
responsible=responsible,
|
|
dedupe_key=dedupe_key,
|
|
)
|
|
if row is not None:
|
|
internal_created += 1
|
|
|
|
def _notify_lawyer_if_any() -> None:
|
|
nonlocal internal_created
|
|
lawyer_uuid = _as_uuid_or_none(request.assigned_lawyer_id)
|
|
if lawyer_uuid is None:
|
|
return
|
|
if actor_uuid is not None and lawyer_uuid == actor_uuid:
|
|
return
|
|
dedupe_key = _dedupe_key_for(f"lawyer:{lawyer_uuid}")
|
|
row = _create_notification(
|
|
db,
|
|
request=request,
|
|
recipient_type=RECIPIENT_ADMIN_USER,
|
|
recipient_admin_user_id=lawyer_uuid,
|
|
event_type=event,
|
|
title=title,
|
|
body=body,
|
|
payload=payload,
|
|
responsible=responsible,
|
|
dedupe_key=dedupe_key,
|
|
)
|
|
if row is not None:
|
|
internal_created += 1
|
|
|
|
def _notify_admins() -> None:
|
|
nonlocal internal_created
|
|
admin_ids = _active_admin_ids(db, exclude_admin_user_id=actor_uuid)
|
|
for admin_id in admin_ids:
|
|
dedupe_key = _dedupe_key_for(f"admin:{admin_id}")
|
|
row = _create_notification(
|
|
db,
|
|
request=request,
|
|
recipient_type=RECIPIENT_ADMIN_USER,
|
|
recipient_admin_user_id=admin_id,
|
|
event_type=event,
|
|
title=title,
|
|
body=body,
|
|
payload=payload,
|
|
responsible=responsible,
|
|
dedupe_key=dedupe_key,
|
|
)
|
|
if row is not None:
|
|
internal_created += 1
|
|
|
|
def _notify_chat_participant_lawyers() -> None:
|
|
nonlocal internal_created
|
|
participant_ids = _chat_participant_admin_ids(request)
|
|
if not participant_ids:
|
|
return
|
|
assigned_lawyer_uuid = _as_uuid_or_none(request.assigned_lawyer_id)
|
|
target_ids = [item for item in participant_ids if actor_uuid is None or item != actor_uuid]
|
|
if not target_ids:
|
|
return
|
|
try:
|
|
rows = (
|
|
db.query(AdminUser.id, AdminUser.role, AdminUser.is_active)
|
|
.filter(AdminUser.id.in_(target_ids))
|
|
.all()
|
|
)
|
|
except SQLAlchemyError:
|
|
return
|
|
for admin_id, role, is_active in rows:
|
|
if not admin_id or not bool(is_active):
|
|
continue
|
|
role_code = str(role or "").strip().upper()
|
|
if role_code not in {"LAWYER", "CURATOR"}:
|
|
continue
|
|
if assigned_lawyer_uuid is not None and admin_id != assigned_lawyer_uuid:
|
|
continue
|
|
if assigned_lawyer_uuid is not None and admin_id == assigned_lawyer_uuid:
|
|
# Assigned lawyer already gets notification via _notify_lawyer_if_any.
|
|
continue
|
|
dedupe_key = _dedupe_key_for(f"participant:{admin_id}")
|
|
row = _create_notification(
|
|
db,
|
|
request=request,
|
|
recipient_type=RECIPIENT_ADMIN_USER,
|
|
recipient_admin_user_id=admin_id,
|
|
event_type=event,
|
|
title=title,
|
|
body=body,
|
|
payload=payload,
|
|
responsible=responsible,
|
|
dedupe_key=dedupe_key,
|
|
)
|
|
if row is not None:
|
|
internal_created += 1
|
|
|
|
if event in {EVENT_MESSAGE, EVENT_ATTACHMENT, EVENT_REQUEST_DATA}:
|
|
if actor == "CLIENT":
|
|
_notify_lawyer_if_any()
|
|
_notify_admins()
|
|
_notify_chat_participant_lawyers()
|
|
else:
|
|
_notify_client()
|
|
elif event == EVENT_STATUS:
|
|
_notify_client()
|
|
if actor == "ADMIN":
|
|
_notify_lawyer_if_any()
|
|
elif event in {EVENT_ASSIGNMENT, EVENT_REASSIGNMENT}:
|
|
_notify_client()
|
|
_notify_lawyer_if_any()
|
|
_notify_admins()
|
|
elif event == EVENT_SLA_OVERDUE:
|
|
_notify_lawyer_if_any()
|
|
_notify_admins()
|
|
else:
|
|
_notify_client()
|
|
_notify_lawyer_if_any()
|
|
|
|
if send_telegram and internal_created > 0:
|
|
result = send_telegram_message(_telegram_text_for_event(event, request, body))
|
|
if bool(result.get("sent")):
|
|
telegram_sent += 1
|
|
|
|
return {"internal_created": int(internal_created), "telegram_sent": int(telegram_sent)}
|
|
|
|
|
|
def serialize_notification(row: Notification) -> dict[str, Any]:
|
|
return {
|
|
"id": str(row.id),
|
|
"request_id": str(row.request_id) if row.request_id else None,
|
|
"recipient_type": row.recipient_type,
|
|
"recipient_admin_user_id": str(row.recipient_admin_user_id) if row.recipient_admin_user_id else None,
|
|
"recipient_track_number": row.recipient_track_number,
|
|
"event_type": row.event_type,
|
|
"title": row.title,
|
|
"body": row.body,
|
|
"payload": row.payload or {},
|
|
"is_read": bool(row.is_read),
|
|
"read_at": row.read_at.isoformat() if row.read_at else None,
|
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
|
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
|
}
|
|
|
|
|
|
def mark_admin_notifications_read(
|
|
db: Session,
|
|
*,
|
|
admin_user_id: str | uuid.UUID,
|
|
request_id: uuid.UUID | None = None,
|
|
notification_id: uuid.UUID | None = None,
|
|
responsible: str = "Система уведомлений",
|
|
) -> int:
|
|
admin_uuid = _as_uuid_or_none(admin_user_id)
|
|
if admin_uuid is None:
|
|
return 0
|
|
query = db.query(Notification).filter(
|
|
Notification.recipient_type == RECIPIENT_ADMIN_USER,
|
|
Notification.recipient_admin_user_id == admin_uuid,
|
|
Notification.is_read.is_(False),
|
|
)
|
|
if request_id is not None:
|
|
query = query.filter(Notification.request_id == request_id)
|
|
if notification_id is not None:
|
|
query = query.filter(Notification.id == notification_id)
|
|
rows = query.all()
|
|
now = _as_utc_now()
|
|
for row in rows:
|
|
row.is_read = True
|
|
row.read_at = now
|
|
row.responsible = responsible
|
|
db.add(row)
|
|
return len(rows)
|
|
|
|
|
|
def mark_client_notifications_read(
|
|
db: Session,
|
|
*,
|
|
track_number: str,
|
|
request_id: uuid.UUID | None = None,
|
|
notification_id: uuid.UUID | None = None,
|
|
responsible: str = "Клиент",
|
|
) -> int:
|
|
track = _normalize_track(track_number)
|
|
if not track:
|
|
return 0
|
|
query = db.query(Notification).filter(
|
|
Notification.recipient_type == RECIPIENT_CLIENT,
|
|
Notification.recipient_track_number == track,
|
|
Notification.is_read.is_(False),
|
|
)
|
|
if request_id is not None:
|
|
query = query.filter(Notification.request_id == request_id)
|
|
if notification_id is not None:
|
|
query = query.filter(Notification.id == notification_id)
|
|
rows = query.all()
|
|
now = _as_utc_now()
|
|
for row in rows:
|
|
row.is_read = True
|
|
row.read_at = now
|
|
row.responsible = responsible
|
|
db.add(row)
|
|
return len(rows)
|
|
|
|
|
|
def list_admin_notifications(
|
|
db: Session,
|
|
*,
|
|
admin_user_id: str | uuid.UUID,
|
|
unread_only: bool = False,
|
|
request_id: uuid.UUID | None = None,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
) -> tuple[list[Notification], int]:
|
|
admin_uuid = _as_uuid_or_none(admin_user_id)
|
|
if admin_uuid is None:
|
|
return [], 0
|
|
query = db.query(Notification).filter(
|
|
Notification.recipient_type == RECIPIENT_ADMIN_USER,
|
|
Notification.recipient_admin_user_id == admin_uuid,
|
|
)
|
|
if unread_only:
|
|
query = query.filter(Notification.is_read.is_(False))
|
|
if request_id is not None:
|
|
query = query.filter(Notification.request_id == request_id)
|
|
total = query.count()
|
|
rows = (
|
|
query.order_by(Notification.created_at.desc(), Notification.id.desc())
|
|
.offset(int(max(offset, 0)))
|
|
.limit(int(min(max(limit, 1), 200)))
|
|
.all()
|
|
)
|
|
return rows, int(total)
|
|
|
|
|
|
def list_client_notifications(
|
|
db: Session,
|
|
*,
|
|
track_number: str,
|
|
unread_only: bool = False,
|
|
request_id: uuid.UUID | None = None,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
) -> tuple[list[Notification], int]:
|
|
track = _normalize_track(track_number)
|
|
if not track:
|
|
return [], 0
|
|
query = db.query(Notification).filter(
|
|
Notification.recipient_type == RECIPIENT_CLIENT,
|
|
Notification.recipient_track_number == track,
|
|
)
|
|
if unread_only:
|
|
query = query.filter(Notification.is_read.is_(False))
|
|
if request_id is not None:
|
|
query = query.filter(Notification.request_id == request_id)
|
|
total = query.count()
|
|
rows = (
|
|
query.order_by(Notification.created_at.desc(), Notification.id.desc())
|
|
.offset(int(max(offset, 0)))
|
|
.limit(int(min(max(limit, 1), 200)))
|
|
.all()
|
|
)
|
|
return rows, int(total)
|
|
|
|
|
|
def get_admin_notification(
|
|
db: Session,
|
|
*,
|
|
admin_user_id: str | uuid.UUID,
|
|
notification_id: uuid.UUID,
|
|
) -> Notification | None:
|
|
admin_uuid = _as_uuid_or_none(admin_user_id)
|
|
if admin_uuid is None:
|
|
return None
|
|
return (
|
|
db.query(Notification)
|
|
.filter(
|
|
Notification.id == notification_id,
|
|
Notification.recipient_type == RECIPIENT_ADMIN_USER,
|
|
Notification.recipient_admin_user_id == admin_uuid,
|
|
)
|
|
.first()
|
|
)
|
|
|
|
|
|
def get_client_notification(
|
|
db: Session,
|
|
*,
|
|
track_number: str,
|
|
notification_id: uuid.UUID,
|
|
) -> Notification | None:
|
|
track = _normalize_track(track_number)
|
|
if not track:
|
|
return None
|
|
return (
|
|
db.query(Notification)
|
|
.filter(
|
|
and_(
|
|
Notification.id == notification_id,
|
|
Notification.recipient_type == RECIPIENT_CLIENT,
|
|
Notification.recipient_track_number == track,
|
|
)
|
|
)
|
|
.first()
|
|
)
|
|
|
|
|
|
def unread_admin_summary(
|
|
db: Session,
|
|
*,
|
|
admin_user_id: str | uuid.UUID,
|
|
request_id: uuid.UUID | None = None,
|
|
) -> dict[str, Any]:
|
|
admin_uuid = _as_uuid_or_none(admin_user_id)
|
|
if admin_uuid is None:
|
|
return {"total": 0, "by_event": {}}
|
|
query = db.query(Notification.event_type, func.count(Notification.id)).filter(
|
|
Notification.recipient_type == RECIPIENT_ADMIN_USER,
|
|
Notification.recipient_admin_user_id == admin_uuid,
|
|
Notification.is_read.is_(False),
|
|
)
|
|
if request_id is not None:
|
|
query = query.filter(Notification.request_id == request_id)
|
|
try:
|
|
rows = query.group_by(Notification.event_type).all()
|
|
except SQLAlchemyError:
|
|
return {"total": 0, "by_event": {}}
|
|
by_event = {str(event_type): int(count or 0) for event_type, count in rows if event_type}
|
|
total = int(sum(by_event.values()))
|
|
return {"total": total, "by_event": by_event}
|
|
|
|
|
|
def unread_client_summary(
|
|
db: Session,
|
|
*,
|
|
track_number: str,
|
|
request_id: uuid.UUID | None = None,
|
|
) -> dict[str, Any]:
|
|
track = _normalize_track(track_number)
|
|
if not track:
|
|
return {"total": 0, "by_event": {}}
|
|
query = db.query(Notification.event_type, func.count(Notification.id)).filter(
|
|
Notification.recipient_type == RECIPIENT_CLIENT,
|
|
Notification.recipient_track_number == track,
|
|
Notification.is_read.is_(False),
|
|
)
|
|
if request_id is not None:
|
|
query = query.filter(Notification.request_id == request_id)
|
|
try:
|
|
rows = query.group_by(Notification.event_type).all()
|
|
except SQLAlchemyError:
|
|
return {"total": 0, "by_event": {}}
|
|
by_event = {str(event_type): int(count or 0) for event_type, count in rows if event_type}
|
|
total = int(sum(by_event.values()))
|
|
return {"total": total, "by_event": by_event}
|
|
|
|
|
|
def unread_global_summary_for_clients(
|
|
db: Session,
|
|
*,
|
|
request_id: uuid.UUID | None = None,
|
|
) -> dict[str, Any]:
|
|
query = db.query(Notification.event_type, func.count(Notification.id)).filter(
|
|
Notification.recipient_type == RECIPIENT_CLIENT,
|
|
Notification.is_read.is_(False),
|
|
)
|
|
if request_id is not None:
|
|
query = query.filter(Notification.request_id == request_id)
|
|
try:
|
|
rows = query.group_by(Notification.event_type).all()
|
|
except SQLAlchemyError:
|
|
return {"total": 0, "by_event": {}}
|
|
by_event = {str(event_type): int(count or 0) for event_type, count in rows if event_type}
|
|
total = int(sum(by_event.values()))
|
|
return {"total": total, "by_event": by_event}
|
|
|
|
|
|
def unread_global_summary_for_lawyers(
|
|
db: Session,
|
|
*,
|
|
request_id: uuid.UUID | None = None,
|
|
) -> dict[str, Any]:
|
|
query = (
|
|
db.query(Notification.event_type, func.count(Notification.id))
|
|
.join(AdminUser, Notification.recipient_admin_user_id == AdminUser.id)
|
|
.filter(
|
|
Notification.recipient_type == RECIPIENT_ADMIN_USER,
|
|
Notification.is_read.is_(False),
|
|
AdminUser.role == "LAWYER",
|
|
)
|
|
)
|
|
if request_id is not None:
|
|
query = query.filter(Notification.request_id == request_id)
|
|
try:
|
|
rows = query.group_by(Notification.event_type).all()
|
|
except SQLAlchemyError:
|
|
return {"total": 0, "by_event": {}}
|
|
by_event = {str(event_type): int(count or 0) for event_type, count in rows if event_type}
|
|
total = int(sum(by_event.values()))
|
|
return {"total": total, "by_event": by_event}
|