Первоначальная версия VidConf
This commit is contained in:
286
workers/tasks/invitations.py
Normal file
286
workers/tasks/invitations.py
Normal file
@@ -0,0 +1,286 @@
|
||||
"""Celery-задача рассылки .ics-приглашений на конференцию.
|
||||
|
||||
Ставится из `services.conferences.ConferenceService.create` (при создании
|
||||
плановой/закреплённой конференции) и `.update` (при правке расписания —
|
||||
`scheduled_at`/`duration_minutes`/`recurrence`/`title`, см. инкремент
|
||||
`ics_sequence` там же, а также при изменении СОСТАВА участников без
|
||||
изменения расписания — без инкремента `ics_sequence`)
|
||||
через `services.invitations_producer.enqueue_invitations` — backend не
|
||||
импортирует пакет `workers` напрямую (тот же приём, что
|
||||
`services.pipeline_producer.enqueue_pipeline`). Получатели по умолчанию
|
||||
(`emails=None`) — владелец конференции + приглашённые (`conference_invitees`,
|
||||
ADR-003: зарегистрированные — по email пользователя, внешние — по указанному
|
||||
email) + для закреплённых дополнительно уникальные участники её прошлых
|
||||
сеансов (зарегистрированные пользователи и гости с указанным email).
|
||||
Явный список `emails` — ручная рассылка администратором
|
||||
(`POST /admin/conferences/{id}/invitations`).
|
||||
|
||||
Идемпотентность: `email_deliveries(kind='invitation')` — журнал БЕЗ unique
|
||||
(переслать обновлённое приглашение при изменении расписания обязано снова
|
||||
дойти до всех адресатов — задокументированный дубль лучше пропавшего
|
||||
уведомления об изменении времени встречи).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any, Protocol
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from celery.exceptions import MaxRetriesExceededError
|
||||
from sqlalchemy import select
|
||||
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.invitee import ConferenceInvitee
|
||||
from models.participant import ConferenceParticipant
|
||||
from models.session import ConferenceSession
|
||||
from models.user import User
|
||||
from services.email import EmailAttachment, EmailSendError, create_email_backend
|
||||
from services.ics import ConferenceHasNoScheduleError, build_invite
|
||||
from services.instance_settings import load_effective_config
|
||||
from services.recurrence import RecurrenceRule, expand_occurrences
|
||||
from workers.celery_app import app
|
||||
from workers.db import open_session, run_async
|
||||
|
||||
# Горизонт поиска первого вхождения повторяющейся серии для текста письма —
|
||||
# совпадает с `services.ics._OCCURRENCE_SEARCH_HORIZON` (не импортируется,
|
||||
# приватный модульный атрибут, но значение то же).
|
||||
_OCCURRENCE_SEARCH_HORIZON = timedelta(days=400)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RETRY_COUNTDOWN_BASE_S = 60
|
||||
"""Базовая пауза (сек) перед повтором при временном сбое SMTP; фактический
|
||||
countdown — `RETRY_COUNTDOWN_BASE_S * (attempt + 1)` (тот же паттерн, что в
|
||||
`workers.tasks.notify`/`summarize`)."""
|
||||
|
||||
_ICS_ATTACHMENT_FILENAME = "invite.ics"
|
||||
_ICS_MIME_TYPE = "text/calendar; method=REQUEST"
|
||||
|
||||
|
||||
class RetryableTask(Protocol):
|
||||
"""Минимальный протокол bound-задачи Celery (см. одноимённые протоколы
|
||||
в `workers.tasks.summarize`/`notify`)."""
|
||||
|
||||
request: Any
|
||||
|
||||
def retry(self, countdown: int | None = None) -> None: ...
|
||||
|
||||
|
||||
@app.task(
|
||||
name="workers.tasks.invitations.send_invitations",
|
||||
bind=True,
|
||||
max_retries=5,
|
||||
acks_late=True,
|
||||
)
|
||||
def send_invitations(
|
||||
self: RetryableTask, conference_id: str, emails: list[str] | None = None
|
||||
) -> None:
|
||||
"""Точка входа Celery — синхронная обёртка над асинхронной логикой рассылки."""
|
||||
run_async(lambda: send_invitations_async(self, uuid.UUID(conference_id), emails=emails))
|
||||
|
||||
|
||||
async def send_invitations_async(
|
||||
task: RetryableTask,
|
||||
conference_id: uuid.UUID,
|
||||
*,
|
||||
emails: list[str] | None = None,
|
||||
plugins_config: InstanceConfig | None = None,
|
||||
) -> None:
|
||||
"""Разослать .ics-приглашение на конференцию `conference_id` получателям.
|
||||
|
||||
Guard'ы:
|
||||
1. Конференция не найдена — выход.
|
||||
2. Нет ни `scheduled_at`, ни `recurrence` (расписания нет — строить
|
||||
приглашение не из чего, например, конференция уже разоткреплена и
|
||||
переведена в мгновенную) — выход без ошибки.
|
||||
3. Получателей нет (ни явного списка, ни владельца/участников) — выход.
|
||||
|
||||
Постоянный отказ конкретного получателя не роняет рассылку остальным
|
||||
(пропускается с предупреждением); временный сбой транспорта — `task.retry`
|
||||
с нарастающим countdown, исчерпание попыток — только лог (в отличие от
|
||||
`notify_session`, здесь нет `pipeline_status`, который можно провалить).
|
||||
"""
|
||||
async with open_session() as session:
|
||||
conference = await session.get(Conference, conference_id)
|
||||
if conference is None:
|
||||
logger.warning("send_invitations: конференция %s не найдена", conference_id)
|
||||
return
|
||||
|
||||
if conference.recurrence is None and conference.scheduled_at is None:
|
||||
logger.info(
|
||||
"send_invitations: у конференции %s нет расписания — no-op", conference_id
|
||||
)
|
||||
return
|
||||
|
||||
recipients = (
|
||||
[email.lower() for email in emails]
|
||||
if emails is not None
|
||||
else await _resolve_default_recipients(session, conference)
|
||||
)
|
||||
if not recipients:
|
||||
logger.info("send_invitations: у конференции %s нет получателей", conference_id)
|
||||
return
|
||||
|
||||
cfg = plugins_config or await load_effective_config(session)
|
||||
settings = get_settings()
|
||||
join_url = f"{settings.frontend_url}/j/{conference.slug}"
|
||||
organizer_email = await _resolve_owner_email(session, conference)
|
||||
|
||||
try:
|
||||
ics_bytes = build_invite(
|
||||
conference,
|
||||
organizer_email=organizer_email,
|
||||
join_url=join_url,
|
||||
display_timezone=cfg.display_timezone,
|
||||
)
|
||||
except ConferenceHasNoScheduleError:
|
||||
logger.warning(
|
||||
"send_invitations: не удалось построить .ics для конференции %s", conference_id
|
||||
)
|
||||
return
|
||||
|
||||
attachment = EmailAttachment(
|
||||
filename=_ICS_ATTACHMENT_FILENAME, content=ics_bytes, mime_type=_ICS_MIME_TYPE
|
||||
)
|
||||
title = conference.title or f"№{conference.number}"
|
||||
subject = f"Приглашение на конференцию: {title}"
|
||||
when = _format_when(conference, display_timezone=cfg.display_timezone)
|
||||
body_lines = [f"Вас пригласили на конференцию «{title}»."]
|
||||
if when is not None:
|
||||
body_lines.append(f"Дата и время: {when}")
|
||||
body_lines.append(f"Ссылка для входа: {join_url}")
|
||||
body_lines.append(f"Номер: {conference.number}")
|
||||
# Состав участников — переиспользуем уже
|
||||
# посчитанный список получателей, чтобы не делать лишний запрос.
|
||||
body_lines.append(f"Участники: {', '.join(recipients)}")
|
||||
body = "\n".join(body_lines)
|
||||
|
||||
backend = create_email_backend(settings)
|
||||
for email in recipients:
|
||||
try:
|
||||
await backend.send(to=email, subject=subject, body=body, attachments=[attachment])
|
||||
except EmailSendError as exc:
|
||||
if not exc.retryable:
|
||||
logger.warning(
|
||||
"send_invitations: получатель %s конференции %s отклонён сервером — "
|
||||
"пропущен без повторных попыток: %s",
|
||||
email,
|
||||
conference_id,
|
||||
exc,
|
||||
)
|
||||
continue
|
||||
attempt = getattr(task.request, "retries", 0)
|
||||
countdown = RETRY_COUNTDOWN_BASE_S * (attempt + 1)
|
||||
try:
|
||||
task.retry(countdown=countdown)
|
||||
except MaxRetriesExceededError:
|
||||
logger.warning(
|
||||
"send_invitations: исчерпаны попытки рассылки приглашения "
|
||||
"конференции %s (SMTP недоступен: %s)",
|
||||
conference_id,
|
||||
exc,
|
||||
)
|
||||
# Реальный `Task.retry()` сам бросает исключение Retry (не
|
||||
# возвращает управление) — до сюда доходим только с
|
||||
# моком/заглушкой `task.retry` в тестах.
|
||||
return
|
||||
else:
|
||||
session.add(
|
||||
EmailDelivery(
|
||||
conference_id=conference.id, recipient_email=email, kind="invitation"
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
"send_invitations: конференция %s — приглашение отправлено %d получателям",
|
||||
conference_id,
|
||||
len(recipients),
|
||||
)
|
||||
|
||||
|
||||
def _format_when(conference: Conference, *, display_timezone: str) -> str | None:
|
||||
"""Дата/время конференции в таймзоне отображения инстанса — для тела письма.
|
||||
|
||||
Разовая — `scheduled_at`; закреплённая с повторением — первое вхождение
|
||||
от `anchor_date` (тот же горизонт поиска, что `services/ics.py`). `None`,
|
||||
если расписания нет (проверяется раньше в вызывающем коде — сюда такая
|
||||
конференция не доходит).
|
||||
"""
|
||||
dt: datetime
|
||||
if conference.recurrence is not None:
|
||||
rule = RecurrenceRule.model_validate(conference.recurrence)
|
||||
t_from = rule.local_datetime(rule.anchor_date).astimezone(UTC)
|
||||
occurrences = expand_occurrences(rule, t_from, t_from + _OCCURRENCE_SEARCH_HORIZON)
|
||||
if not occurrences:
|
||||
return None
|
||||
dt = occurrences[0]
|
||||
elif conference.scheduled_at is not None:
|
||||
dt = conference.scheduled_at
|
||||
else:
|
||||
return None
|
||||
localized = dt.astimezone(ZoneInfo(display_timezone))
|
||||
return localized.strftime("%d.%m.%Y %H:%M %Z")
|
||||
|
||||
|
||||
async def _resolve_owner_email(session: AsyncSession, conference: Conference) -> str | None:
|
||||
"""Email владельца конференции (используется как `ORGANIZER` .ics), либо `None`."""
|
||||
if conference.owner_id is None:
|
||||
return None
|
||||
owner = await session.get(User, conference.owner_id)
|
||||
return owner.email if owner is not None else None
|
||||
|
||||
|
||||
async def _resolve_default_recipients(session: AsyncSession, conference: Conference) -> list[str]:
|
||||
"""Получатели по умолчанию: владелец + приглашённые + (для закреплённых) участники прошлых сеансов.
|
||||
|
||||
Дедуп по `lower(email)`. Приглашённые
|
||||
(`conference_invitees`, ADR-003) добавляются независимо от `is_pinned` —
|
||||
состав задаётся на уровне конференции, а не сеанса. Для незакреплённой
|
||||
(разовой плановой) конференции без приглашённых сеансов ещё нет —
|
||||
получатель только один (владелец).
|
||||
"""
|
||||
recipients: dict[str, None] = {}
|
||||
owner_email = await _resolve_owner_email(session, conference)
|
||||
if owner_email:
|
||||
recipients.setdefault(owner_email.lower(), None)
|
||||
|
||||
invitee_rows = await session.execute(
|
||||
select(ConferenceInvitee.email, User.email)
|
||||
.outerjoin(User, ConferenceInvitee.user_id == User.id)
|
||||
.where(ConferenceInvitee.conference_id == conference.id)
|
||||
)
|
||||
for invitee_email, invitee_user_email in invitee_rows.all():
|
||||
email = invitee_email or invitee_user_email
|
||||
if email:
|
||||
recipients.setdefault(email.lower(), None)
|
||||
|
||||
if conference.is_pinned:
|
||||
user_rows = await session.execute(
|
||||
select(User.email)
|
||||
.join(ConferenceParticipant, ConferenceParticipant.user_id == User.id)
|
||||
.join(ConferenceSession, ConferenceParticipant.session_id == ConferenceSession.id)
|
||||
.where(ConferenceSession.conference_id == conference.id)
|
||||
.distinct()
|
||||
)
|
||||
guest_rows = await session.execute(
|
||||
select(GuestAccess.email)
|
||||
.join(ConferenceParticipant, ConferenceParticipant.guest_id == GuestAccess.id)
|
||||
.join(ConferenceSession, ConferenceParticipant.session_id == ConferenceSession.id)
|
||||
.where(
|
||||
ConferenceSession.conference_id == conference.id,
|
||||
GuestAccess.email.isnot(None),
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
for email in [*user_rows.scalars().all(), *guest_rows.scalars().all()]:
|
||||
if email:
|
||||
recipients.setdefault(email.lower(), None)
|
||||
|
||||
return list(recipients.keys())
|
||||
Reference in New Issue
Block a user