Новая настройка instance_settings.contact_email (включён/адрес, с валидацией формата) — подставляется в заголовок Reply-To писем подтверждения регистрации, приглашений и саммари. Админ-эндпоинт POST /admin/settings/test-email отправляет проверочное письмо синхронно и возвращает внятный результат (успех либо текст ошибки транспорта), не раскрывая логин/пароль SMTP.
333 lines
15 KiB
Python
333 lines
15 KiB
Python
"""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
|