Files
vidconf/workers/tasks/notify.py
Max Ronzhin 74de51dbd8 feat(email): контактный адрес инстанса, Reply-To в письмах и тестовая отправка
Новая настройка instance_settings.contact_email (включён/адрес, с
валидацией формата) — подставляется в заголовок Reply-To писем
подтверждения регистрации, приглашений и саммари. Админ-эндпоинт
POST /admin/settings/test-email отправляет проверочное письмо синхронно
и возвращает внятный результат (успех либо текст ошибки транспорта),
не раскрывая логин/пароль SMTP.
2026-07-27 00:35:47 +03:00

333 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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