"""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) reply_to = cfg.contact_email if cfg.contact_email_enabled else None for email in recipients: try: await backend.send( to=email, subject=subject, body=body, attachments=[attachment], reply_to=reply_to, ) 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())