"""Celery-задача уведомления о готовом саммари — финальный шаг AI-пайплайна. `notify_session` завершает пайплайн пост-обработки: `pipeline_status='summarizing'` + `summary_data IS NOT NULL` → рассылка писем получателям → `pipeline_status='notified'`. Получатели определяются эффективным режимом рассылки `conference.summary_recipients or cfg.summary_recipients`: `all` — зарегистрированные участники сеанса и гости с указанным email; `owner` — только владелец конференции. Идемпотентность — таблица `email_deliveries` с уникальным частичным индексом `(session_id, recipient_email)` при `kind='summary'`: повторный запуск (ретрай/восстановление) отправляет только тем, кому ещё не доставлено; каждая успешная отправка коммитится немедленно — точка возобновления при обрыве процесса или временном сбое SMTP (тот же паттерн, что по-трековый/по-шаговый commit в `workers.tasks.pipeline`/`summarize`). Постоянный отказ конкретного получателя (`EmailSendError(retryable=False)`, например `SMTPRecipientsRefused`) не ставит retry всей задачи и не блокирует переход в `notified` — такой адресат пропускается с предупреждением в лог (письмо ему не доставлено, повторных попыток для него не будет). Временный сбой транспорта (`retryable=True`) — `task.retry` с нарастающим countdown, как в `workers.tasks.summarize`; исчерпание попыток — `pipeline_status='failed'`. """ import logging import uuid from datetime import UTC from typing import Any, Protocol from zoneinfo import ZoneInfo from celery.exceptions import MaxRetriesExceededError from sqlalchemy import select from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.ext.asyncio import AsyncSession from core.config import get_settings from core.plugins.config import InstanceConfig from models.conference import Conference from models.email_delivery import EmailDelivery from models.guest import GuestAccess from models.participant import ConferenceParticipant from models.session import ConferenceSession from models.user import User from services.email import EmailSendError, create_email_backend from services.email_templates import SummaryEmailContext, build_summary_email from services.instance_settings import load_effective_config from workers.celery_app import app as app from workers.db import open_session, run_async logger = logging.getLogger(__name__) RETRY_COUNTDOWN_BASE_S = 60 """Базовая пауза (сек) перед повтором при временном сбое SMTP; фактический countdown — `RETRY_COUNTDOWN_BASE_S * (attempt + 1)` (нарастающий backoff, тот же паттерн, что в `workers.tasks.summarize`).""" _DEFAULT_SPEAKER_NAME = "Участник" """Резервное имя участника — см. одноимённую константу в `workers.tasks.summarize`.""" class RetryableTask(Protocol): """Минимальный протокол bound-задачи Celery (см. одноимённый протокол в `workers.tasks.summarize` — здесь та же причина: нужен `request.retries`).""" request: Any def retry(self, countdown: int | None = None) -> None: ... @app.task( name="workers.tasks.notify.notify_session", bind=True, max_retries=5, acks_late=True, ) def notify_session(self: RetryableTask, session_id: str) -> None: """Точка входа Celery — синхронная обёртка над асинхронной логикой уведомления.""" run_async(lambda: notify_session_async(self, uuid.UUID(session_id))) async def notify_session_async( task: RetryableTask, session_id: uuid.UUID, *, plugins_config: InstanceConfig | None = None, ) -> None: """Разослать саммари сеанса `session_id` получателям и перевести пайплайн в `notified`. Guard'ы (строго по порядку): 1. Сеанс не найден — выход. 2. `pipeline_status != 'summarizing'` — no-op (идемпотентность повторной доставки задачи либо запуска раньше срока). 3. `summary_data IS NULL` — no-op (саммари ещё не готово). Далее: собрать получателей по эффективному режиму рассылки, отфильтровать уже получивших письмо (`email_deliveries`), разослать оставшимся — commit после каждого успеха. Получателей не осталось (после фильтра или изначально) → `pipeline_status='notified'`. """ async with open_session() as session: session_record = await session.get(ConferenceSession, session_id) if session_record is None: logger.warning("notify_session: сеанс %s не найден", session_id) return if session_record.pipeline_status != "summarizing": logger.info( "notify_session: сеанс %s не на шаге уведомления (pipeline_status=%s) — no-op", session_id, session_record.pipeline_status, ) return if session_record.summary_data is None: logger.info( "notify_session: у сеанса %s ещё нет summary_data — no-op", session_id, ) return conference = await session.get(Conference, session_record.conference_id) if conference is None: logger.warning( "notify_session: конференция %s сеанса %s не найдена", session_record.conference_id, session_id, ) return cfg = plugins_config or await load_effective_config(session) mode = conference.summary_recipients or cfg.summary_recipients recipients = await _collect_recipients(session, session_record, conference, mode=mode) already_sent = await _fetch_already_sent(session, session_id) pending = [email for email in recipients if email not in already_sent] if not pending: session_record.pipeline_status = "notified" await session.commit() logger.info( "notify_session: сеанс %s — получателей нет либо все уже уведомлены, " "статус=notified", session_id, ) return subject = _build_subject(session_record, conference, timezone=cfg.display_timezone) participant_names = await _collect_participant_names(session, session_id) text_body, html_body = build_summary_email( _build_email_context( session_record, conference, participant_names=participant_names, timezone=cfg.display_timezone, ) ) backend = create_email_backend(get_settings()) reply_to = cfg.contact_email if cfg.contact_email_enabled else None for email in pending: try: await backend.send( to=email, subject=subject, body=text_body, html_body=html_body, reply_to=reply_to, ) except EmailSendError as exc: if not exc.retryable: logger.warning( "notify_session: получатель %s сеанса %s отклонён сервером — " "пропущен без повторных попыток: %s", email, session_id, exc, ) continue attempt = getattr(task.request, "retries", 0) countdown = RETRY_COUNTDOWN_BASE_S * (attempt + 1) try: task.retry(countdown=countdown) except MaxRetriesExceededError: session_record.pipeline_status = "failed" await session.commit() logger.warning( "notify_session: исчерпаны попытки уведомления сеанса %s " "(SMTP недоступен: %s) — pipeline failed", session_id, exc, ) # Реальный `Task.retry()` сам бросает исключение Retry (не # возвращает управление) — до сюда доходим только с # моком/заглушкой `task.retry` в тестах. return else: await _mark_delivered(session, session_id=session_id, recipient_email=email) session_record.pipeline_status = "notified" await session.commit() logger.info( "notify_session: сеанс %s — уведомлено %d получателей, статус=notified", session_id, len(pending), ) async def _collect_recipients( session: AsyncSession, session_record: ConferenceSession, conference: Conference, *, mode: str, ) -> list[str]: """Собрать email получателей саммари по режиму рассылки, дедуп по `lower(email)`. `owner` — email владельца конференции (пусто, если владелец не задан — `conferences.owner_id` nullable). `all` — email зарегистрированных участников сеанса (`users.email`) и гостей с указанным email (`guest_access.email IS NOT NULL`), объединённые и дедуплицированные. """ if mode == "owner": if conference.owner_id is None: return [] owner = await session.get(User, conference.owner_id) return [owner.email.lower()] if owner is not None else [] user_rows = await session.execute( select(User.email) .join(ConferenceParticipant, ConferenceParticipant.user_id == User.id) .where(ConferenceParticipant.session_id == session_record.id) .distinct() ) guest_rows = await session.execute( select(GuestAccess.email) .join(ConferenceParticipant, ConferenceParticipant.guest_id == GuestAccess.id) .where( ConferenceParticipant.session_id == session_record.id, GuestAccess.email.isnot(None), ) .distinct() ) seen: dict[str, None] = {} for email in [*user_rows.scalars().all(), *guest_rows.scalars().all()]: if email: seen.setdefault(email.lower(), None) return list(seen.keys()) async def _fetch_already_sent(session: AsyncSession, session_id: uuid.UUID) -> set[str]: """Email, которым саммари этого сеанса уже отправлено (`email_deliveries`).""" result = await session.execute( select(EmailDelivery.recipient_email).where( EmailDelivery.session_id == session_id, EmailDelivery.kind == "summary", ) ) return set(result.scalars().all()) async def _mark_delivered( session: AsyncSession, *, session_id: uuid.UUID, recipient_email: str ) -> None: """Зафиксировать успешную отправку и закоммитить (точка возобновления).""" stmt = ( pg_insert(EmailDelivery) .values(session_id=session_id, recipient_email=recipient_email, kind="summary") .on_conflict_do_nothing( index_elements=["session_id", "recipient_email"], index_where=EmailDelivery.__table__.c.kind == "summary", ) ) await session.execute(stmt) await session.commit() async def _collect_participant_names(session: AsyncSession, session_id: uuid.UUID) -> list[str]: """Отображаемые имена участников сеанса (для тела письма), без дублей.""" result = await session.execute( select(User.name_user, GuestAccess.display_name) .select_from(ConferenceParticipant) .outerjoin(User, ConferenceParticipant.user_id == User.id) .outerjoin(GuestAccess, ConferenceParticipant.guest_id == GuestAccess.id) .where(ConferenceParticipant.session_id == session_id) ) names = { (name_user or display_name or _DEFAULT_SPEAKER_NAME) for name_user, display_name in result } return sorted(names) def _build_subject( session_record: ConferenceSession, conference: Conference, *, timezone: str ) -> str: """Тема письма: «Саммари встречи {ДД.ММ.ГГГГ} {ЧЧ:ММ}–{ЧЧ:ММ} — {title|номер}».""" date_label, time_label = _format_session_time(session_record, timezone=timezone) title = conference.title or f"№{conference.number}" return f"Саммари встречи {date_label} {time_label} — {title}" def _build_email_context( session_record: ConferenceSession, conference: Conference, *, participant_names: list[str], timezone: str, ) -> SummaryEmailContext: """Собрать данные для рендера тела письма (`services.email_templates`).""" date_label, time_label = _format_session_time(session_record, timezone=timezone) t_end = session_record.t_end or session_record.t_start duration_minutes = max(0, round((t_end - session_record.t_start).total_seconds() / 60)) title = conference.title or f"№{conference.number}" return SummaryEmailContext( conference_title=title, date_label=date_label, time_label=time_label, duration_minutes=duration_minutes, participant_names=participant_names, summary_text=session_record.summary_data or "", ) def _format_session_time( session_record: ConferenceSession, *, timezone: str ) -> tuple[str, str]: """Дата/время сеанса в `display_timezone` (в БД — только UTC).""" tz = ZoneInfo(timezone) t_start = session_record.t_start.astimezone(UTC).astimezone(tz) t_end = (session_record.t_end or session_record.t_start).astimezone(UTC).astimezone(tz) date_label = t_start.strftime("%d.%m.%Y") time_label = f"{t_start.strftime('%H:%M')}–{t_end.strftime('%H:%M')}" return date_label, time_label