Первоначальная версия VidConf
This commit is contained in:
325
workers/tasks/notify.py
Normal file
325
workers/tasks/notify.py
Normal file
@@ -0,0 +1,325 @@
|
||||
"""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())
|
||||
for email in pending:
|
||||
try:
|
||||
await backend.send(to=email, subject=subject, body=text_body, html_body=html_body)
|
||||
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
|
||||
Reference in New Issue
Block a user