"""Персистентные уведомления игрокам (приглашения, старт/финиш партии, титулы). Запись в БД + живой сигнал по SSE (`notify.notifications_changed`) — клиент по сигналу подтягивает список (`GET /api/notifications`). Текст (RU) и ссылку рендерим здесь, на сервере. Хранение: непрочитанные живут до прочтения (игрок должен увидеть их, когда бы ни зашёл), прочитанные удаляются спустя RETENTION_HOURS после прочтения — при чтении списка и фоновой задачей. От роста у незаходящих защищает кап MAX_PER_USER при создании. """ from __future__ import annotations from datetime import datetime, timedelta, timezone from sqlmodel import Session, select from app.core.timeutil import iso_utc from app.models import Group, Match, MatchParticipant, Notification from app.services import notify RETENTION_HOURS = 72 # сколько живёт ПРОЧИТАННОЕ уведомление после прочтения MAX_PER_USER = 100 # кап на пользователя: старейшие сверх него удаляются при создании def _now() -> datetime: return datetime.now(timezone.utc) def _cutoff() -> datetime: # Наивный UTC — сопоставимо с тем, как SQLite отдаёт сохранённые datetime. return _now().replace(tzinfo=None) - timedelta(hours=RETENTION_HOURS) # ─── Базовые операции ───────────────────────────────────────────────────────── def create_for( session: Session, user_ids, *, type: str, title: str, body: str | None = None, link: str | None = None, ) -> list[Notification]: """Создать одно уведомление каждому из user_ids (один commit) и пушнуть им SSE-сигнал.""" ids = [uid for uid in dict.fromkeys(user_ids) if uid is not None] if not ids: return [] rows = [ Notification(user_id=uid, type=type, title=title, body=body, link=link) for uid in ids ] session.add_all(rows) session.flush() for uid in ids: _cap_user(session, uid) session.commit() for uid in ids: notify.notifications_changed(uid) return rows def create( session: Session, user_id: int, *, type: str, title: str, body: str | None = None, link: str | None = None, ) -> Notification | None: rows = create_for(session, [user_id], type=type, title=title, body=body, link=link) return rows[0] if rows else None def _cap_user(session: Session, user_id: int) -> None: """Удалить старейшие уведомления пользователя сверх MAX_PER_USER (без commit).""" extra = session.exec( select(Notification) .where(Notification.user_id == user_id) .order_by(Notification.created_at.desc(), Notification.id.desc()) .offset(MAX_PER_USER) ).all() for row in extra: session.delete(row) def purge_expired(session: Session) -> int: """Удалить ПРОЧИТАННЫЕ уведомления спустя RETENTION_HOURS после прочтения. Непрочитанные по времени не протухают — иначе игрок, не заходивший дольше окна хранения, не увидел бы их вовсе. Возвращает число удалённых.""" rows = session.exec( select(Notification).where( Notification.read_at.is_not(None), Notification.read_at < _cutoff() ) ).all() for row in rows: session.delete(row) if rows: session.commit() return len(rows) def list_for_user(session: Session, user_id: int) -> dict: """Уведомления пользователя + число непрочитанных. Попутно чистит давно прочитанные.""" purge_expired(session) rows = session.exec( select(Notification) .where(Notification.user_id == user_id) .order_by(Notification.created_at.desc()) ).all() items = [ { "id": n.id, "type": n.type, "title": n.title, "body": n.body, "link": n.link, "read_at": iso_utc(n.read_at), "created_at": iso_utc(n.created_at), } for n in rows ] unread = sum(1 for n in rows if n.read_at is None) return {"items": items, "unread_count": unread} def mark_read(session: Session, user_id: int, ids: list[int] | None = None) -> int: """Отметить прочитанными все непрочитанные пользователя (или указанные по id).""" query = select(Notification).where( Notification.user_id == user_id, Notification.read_at.is_(None) ) if ids: query = query.where(Notification.id.in_(ids)) rows = session.exec(query).all() now = _now() for row in rows: row.read_at = now session.add(row) if rows: session.commit() # Счётчик непрочитанных изменился — толкаем тот же сигнал, что и create_for, # иначе вкладка на другом устройстве держит устаревший бейдж до перезагрузки. notify.notifications_changed(user_id) return len(rows) # ─── Типовые хелперы под триггеры ────────────────────────────────────────────── def _match_participant_ids(session: Session, match_id: int) -> list[int]: return list( session.exec( select(MatchParticipant.user_id).where(MatchParticipant.match_id == match_id) ).all() ) def _group_name(session: Session, group_id: int) -> str | None: group = session.get(Group, group_id) return group.name if group else None def invited_to_group( session: Session, user_id: int, group_name: str, inviter_nickname: str | None = None ) -> None: create( session, user_id, type="invite", title=f"Приглашение в группу «{group_name}»", body=f"пригласил: {inviter_nickname}" if inviter_nickname else None, link="/group", ) def match_started(session: Session, match: Match, actor_id: int) -> None: ids = [uid for uid in _match_participant_ids(session, match.id) if uid != actor_id] # type: ignore[arg-type] name = _group_name(session, match.group_id) create_for( session, ids, type="match_started", title="Началась партия", body=f"Группа «{name}»" if name else None, link=f"/match/{match.id}", ) def match_finished(session: Session, match: Match, actor_id: int) -> None: ids = [uid for uid in _match_participant_ids(session, match.id) if uid != actor_id] # type: ignore[arg-type] name = _group_name(session, match.group_id) create_for( session, ids, type="match_finished", title="Партия завершена", body=f"Группа «{name}»" if name else None, link=f"/match/{match.id}", ) def title_earned(session: Session, user_id: int, achievement_slug: str) -> None: """ЗАДЕЛ: уведомление о полученном титуле. Пока нигде не вызывается — заработает, когда появится механизм выдачи титулов игрокам.""" name = achievement_slug try: from app.services import achievement_service name = achievement_service.get(achievement_slug).get("name", achievement_slug) except Exception: # noqa: BLE001 pass create( session, user_id, type="title", title=f"Вы получили титул: {name}", link="/account", )