Law/app/services/notifications.py
2026-03-17 09:07:54 +03:00

610 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)
now = _as_utc_now()
return int(
query.update(
{
Notification.is_read: True,
Notification.read_at: now,
Notification.responsible: responsible,
Notification.updated_at: now,
},
synchronize_session=False,
)
or 0
)
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)
now = _as_utc_now()
return int(
query.update(
{
Notification.is_read: True,
Notification.read_at: now,
Notification.responsible: responsible,
Notification.updated_at: now,
},
synchronize_session=False,
)
or 0
)
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}