"""Бизнес-логика конференций: создание, «Мои конференции», календарь, резолв, join, правки. Тонкий API-роутер (`api/conferences.py`) делегирует сюда всю логику; проверки статуса/пароля и генерация ответа join вынесены в `services/conference_access.py`. """ import logging import uuid from collections.abc import Callable from datetime import UTC, datetime, timedelta from pathlib import Path from typing import Any, cast from sqlalchemy import null from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from core.config import get_settings from core.plugins.config import SummaryRecipientsMode from core.security import hash_password from models.conference import Conference from models.guest import GuestAccess from models.invitee import ConferenceInvitee from models.user import User from repositories.conferences import ConferenceInviteeRepository, ConferenceRepository from schemas.conferences import ( ConferenceCreateIn, ConferenceOut, ConferenceUpdateIn, GuestJoinIn, InviteeIn, InviteeOut, JoinOut, OccurrenceOut, ) from services.avatars import avatar_url as resolve_avatar_url from services.conference_access import build_join, ensure_joinable from services.conference_ids import generate_number, generate_slug from services.instance_settings import InstanceSettingsService from services.invitations_producer import enqueue_invitations from services.recurrence import RecurrenceRule, expand_occurrences logger = logging.getLogger(__name__) # Число попыток сгенерировать уникальные номер/slug при коллизии unique-constraint # (крайне маловероятной при выбранной энтропии — см. ADR-001, п.4). MAX_ID_GENERATION_ATTEMPTS = 5 # Горизонт поиска "следующего вхождения" закреплённой конференции с повторением # для «Моих конференций»; с запасом покрывает самый частый шаг (weekly/monthly). NEXT_OCCURRENCE_HORIZON = timedelta(days=400) # Длительность вхождения календаря по умолчанию, если у разовой плановой # конференции не указан `duration_minutes`. DEFAULT_OCCURRENCE_DURATION_MINUTES = 60 class ConferenceNotFoundError(Exception): """Конференция с таким id не найдена.""" class NotConferenceOwnerError(Exception): """Действие разрешено только владельцу конференции или администратору.""" class ConferenceActiveError(Exception): """Нельзя удалить конференцию, которая сейчас активна.""" class InvalidConferenceStateError(Exception): """Итоговое состояние конференции после правки нарушает бизнес-инварианты.""" class InviteeUserNotFoundError(Exception): """Один или несколько `user_id` в составе приглашённых не существуют (ADR-003).""" def __init__(self, missing_user_ids: set[uuid.UUID]) -> None: self.missing_user_ids = missing_user_ids super().__init__(f"неизвестные user_id приглашённых: {missing_user_ids}") class ConferenceService: """Инкапсулирует сценарии создания/просмотра/входа/правки конференций.""" def __init__(self, session: AsyncSession, *, media_root: Path | None = None) -> None: self._session = session self._conferences = ConferenceRepository(session) self._invitees = ConferenceInviteeRepository(session) self._media_root = media_root or Path(get_settings().media_root) async def create( self, *, owner: User, data: ConferenceCreateIn ) -> tuple[Conference, JoinOut | None]: """Создать конференцию. Мгновенная (`status=active`, создатель входит сразу же — второй элемент кортежа тогда содержит готовый `JoinOut`) — только если не указаны ни `scheduled_at`, ни `recurrence`; закреплённая с повторением без явного `scheduled_at` — плановая конференция, ожидающая своего первого вхождения, а не мгновенный вход. """ password_hash = hash_password(data.password) if data.password else None is_instant = data.scheduled_at is None and data.recurrence is None conference_status = "active" if is_instant else "scheduled" recurrence_json = data.recurrence.model_dump(mode="json") if data.recurrence else None # Примитивы читаются из `owner` один раз, до цикла retry: после # `session.rollback()` (при коллизии number/slug) ORM помечает все # загруженные объекты, включая `owner`, протухшими, а повторное # обращение к их атрибутам вне greenlet-контекста упало бы с # `MissingGreenlet` (ленивая подгрузка синхронным геттером). owner_id = owner.id owner_name = owner.name_user owner_email = owner.email owner_avatar_path = owner.avatar_path # Раннее падение при неизвестном `user_id` приглашённого — ДО # создания конференции (см. `InviteeUserNotFoundError`), чтобы не # оставлять во flush-буфере сессии несохранённую строку `conferences`. await self._validate_participants(data.participants) def _factory(number: str, slug: str) -> Conference: conference = Conference( number=number, slug=slug, title=data.title, owner_id=owner_id, status=conference_status, is_pinned=data.is_pinned, is_closed=data.is_closed, password_hash=password_hash, scheduled_at=data.scheduled_at, duration_minutes=data.duration_minutes, summary_recipients=data.summary_recipients, ) # Атрибут намеренно не устанавливается вовсе, если правила нет: # JSONB-колонка сериализует явно присвоенный Python `None` в # JSON-литерал `null`, а не в SQL NULL (нет `none_as_null=True` — # модель не трогаем, см. блок A); неустановленный атрибут при # INSERT просто опускается, и колонка получает настоящий NULL. if recurrence_json is not None: conference.recurrence = recurrence_json return conference conference = await self._create_with_unique_ids(_factory) await self._apply_participants(conference, data.participants, owner_email=owner_email) await self._session.commit() join = None if is_instant: chat_enabled = (await InstanceSettingsService(self._session).get()).chat.enabled join = build_join( conference, identity=str(owner_id), name=owner_name, chat_enabled=chat_enabled, avatar_url=resolve_avatar_url(self._media_root, owner_avatar_path), is_organizer=True, ) else: # Плановая (разовая) либо закреплённая с повторением/датой — есть # расписание, на которое имеет смысл прислать .ics-приглашение # («.ics»). Ставится ПОСЛЕ commit — воркер должен # видеть уже зафиксированную строку конференции. self._enqueue_invitations_safely(conference.id) return conference, join async def list_my(self, *, owner: User) -> list[ConferenceOut]: """Закреплённые конференции + предстоящие разовые владельца ИЛИ приглашённого. Решение от 2026-07-20 (поверх ADR-003): приглашённый должен видеть конференцию в своих списках — `owner` здесь означает «текущий пользователь», а не только фактического владельца конференции. `participants` НЕ заполняется (пусто) — список не раздувает состав (ADR-003, п.5); `organizer_name`/`is_owner` теперь честно вычисляются на конференцию (для приглашённого — имя РЕАЛЬНОГО владельца и `is_owner=False`), а не берутся из `owner` — небольшой N+1 на строки, где владелец конференции не совпадает с viewer (`_resolve_organizer_name`), список короткий. """ now = datetime.now(UTC) conferences = await self._conferences.list_owned(owner.id, email=owner.email, now=now) result: list[ConferenceOut] = [] for conference in conferences: organizer_name = await self._resolve_organizer_name(conference, viewer=owner) result.append( self.to_out( conference, next_occurrence=self._next_occurrence(conference, now=now), viewer_id=owner.id, organizer_name=organizer_name, ) ) return result async def list_calendar( self, *, owner: User, t_from: datetime, t_to: datetime ) -> list[OccurrenceOut]: """Развернуть вхождения закреплённых/разовых конференций владельца ИЛИ приглашённого (решение от 2026-07-20 поверх ADR-003 — тот же принцип видимости, что `list_my`).""" candidates = await self._conferences.list_calendar_candidates(owner.id, email=owner.email) occurrences: list[OccurrenceOut] = [] for conference in candidates: if conference.recurrence is not None: rule = RecurrenceRule.model_validate(conference.recurrence) for starts_at in expand_occurrences(rule, t_from, t_to): occurrences.append( self._occurrence_out( conference, starts_at=starts_at, ends_at=starts_at + timedelta(minutes=rule.duration_minutes), ) ) elif conference.scheduled_at is not None and t_from <= conference.scheduled_at <= t_to: duration = conference.duration_minutes or DEFAULT_OCCURRENCE_DURATION_MINUTES occurrences.append( self._occurrence_out( conference, starts_at=conference.scheduled_at, ends_at=conference.scheduled_at + timedelta(minutes=duration), ) ) occurrences.sort(key=lambda occurrence: occurrence.starts_at) return occurrences async def resolve(self, q: str) -> Conference | None: """Найти конференцию по slug или номеру (пробелы в номере игнорируются).""" query = q.strip() if not query: return None conference = await self._conferences.get_by_slug(query) if conference is not None: return conference number_candidate = query.replace(" ", "") if number_candidate.isdigit(): return await self._conferences.get_by_number(number_candidate) return None async def join_as_user( self, conference_id: uuid.UUID, *, user: User, password: str | None ) -> JoinOut: """Войти в конференцию зарегистрированным пользователем.""" conference = await self._get_or_raise(conference_id) ensure_joinable(conference, password=password) chat_enabled = (await InstanceSettingsService(self._session).get()).chat.enabled return build_join( conference, identity=str(user.id), name=user.name_user, chat_enabled=chat_enabled, avatar_url=resolve_avatar_url(self._media_root, user.avatar_path), is_organizer=conference.owner_id is not None and conference.owner_id == user.id, ) async def join_as_guest(self, conference_id: uuid.UUID, *, data: GuestJoinIn) -> JoinOut: """Войти в конференцию гостем: создать `GuestAccess` и выдать токен.""" conference = await self._get_or_raise(conference_id) ensure_joinable(conference, password=data.password) guest = GuestAccess( conference_id=conference.id, display_name=data.display_name, email=data.email ) self._session.add(guest) await self._session.flush() await self._session.commit() chat_enabled = (await InstanceSettingsService(self._session).get()).chat.enabled return build_join( conference, identity=f"guest:{guest.id}", name=data.display_name, chat_enabled=chat_enabled, ) async def update( self, conference_id: uuid.UUID, *, actor: User, data: ConferenceUpdateIn ) -> Conference: """Частично обновить конференцию; разрешено владельцу или администратору.""" conference = await self._get_or_raise(conference_id) self._ensure_owner_or_admin(conference, actor) # Раннее падение при неизвестном `user_id` приглашённого — ДО любых # мутаций конференции (см. `InviteeUserNotFoundError`). await self._validate_participants(data.participants) # Снимок «расписательных» полей ДО правки — чтобы после всех мутаций # (включая recurrence, разрешаемый ниже отдельно) определить, нужно # ли инкрементировать `ics_sequence` и переслать .ics-приглашение. title_before = conference.title scheduled_at_before = conference.scheduled_at duration_before = conference.duration_minutes if data.title is not None: conference.title = data.title if data.scheduled_at is not None: conference.scheduled_at = data.scheduled_at if data.duration_minutes is not None: conference.duration_minutes = data.duration_minutes if data.is_closed is not None: conference.is_closed = data.is_closed if data.password is not None: conference.password_hash = hash_password(data.password) if "summary_recipients" in data.model_fields_set: # Явная передача (в т.ч. `null`) — сбросить/установить # переопределение; отсутствие поля в запросе значение не трогает. conference.summary_recipients = data.summary_recipients is_pinned = data.is_pinned if data.is_pinned is not None else conference.is_pinned conference.is_pinned = is_pinned # Итоговое значение recurrence считаем как обычный Python dict|None — # чтобы бизнес-проверка ниже не путала ORM-сентинел `null()` (см. далее) # с «правила нет». `model_fields_set` различает «поле не передано» и # «передано явным `null`» — оба случая иначе выглядят одинаково # (`data.recurrence is None`). current_recurrence = conference.recurrence recurrence_explicitly_cleared = ( "recurrence" in data.model_fields_set and data.recurrence is None ) # Открепление обязано снимать и правило повторения: recurrence без # is_pinned нарушает CHECK-constraint `ck_conferences_recurrence_requires_pinned`, # даже если PATCH вовсе не упоминал `recurrence` (например, только # `{"is_pinned": false}`). must_clear = recurrence_explicitly_cleared or ( not is_pinned and current_recurrence is not None ) cleared_via_null_sentinel = False if data.recurrence is not None: recurrence: dict[str, Any] | None = data.recurrence.model_dump(mode="json") conference.recurrence = recurrence elif must_clear: recurrence = None # JSONB-колонка без `none_as_null=True` (модель не трогаем, см. # блок A) сериализует явно присвоенный Python `None` в # JSON-литерал `null`, а не в SQL NULL — что уронило бы # CHECK-constraint. `sqlalchemy.null()` форсирует настоящий SQL # NULL, но помечает атрибут "expired" после flush (ORM не знает # результат SQL-выражения без похода в БД) — обновляем объект # явным awaited-рефрешем ниже перед возвратом, иначе синхронное # чтение `conference.recurrence` вне greenlet (например, в # `to_out`) упадёт `MissingGreenlet`. conference.recurrence = cast(Any, null()) cleared_via_null_sentinel = True else: # Ничего менять не нужно — атрибут не трогаем вовсе, чтобы не # провоцировать лишнюю expiry на пустом месте (см. выше). recurrence = current_recurrence if conference.is_closed and conference.password_hash is None: raise InvalidConferenceStateError("closed_conference_requires_password") if recurrence is not None and not is_pinned: raise InvalidConferenceStateError("recurrence_requires_pinned") # Расписание изменилось, если сдвинулись заголовок/дата/длительность # либо правило повторения (сравниваем уже разрешённое значение # `recurrence`, а не «сырой» атрибут — иначе сентинел `null()` ниже # спутал бы сравнение). Такая правка обязана переслать .ics-приглашение # с новым `SEQUENCE` — иначе уже принятое # приглашение в календаре адресата разойдётся с фактическим временем. schedule_changed = ( conference.title != title_before or conference.scheduled_at != scheduled_at_before or conference.duration_minutes != duration_before or recurrence != current_recurrence ) if schedule_changed: conference.ics_sequence += 1 # Состав приглашённых — после всех проверок бизнес-инвариантов выше # (иначе при `InvalidConferenceStateError` DELETE/INSERT состава # пришлось бы объяснять исполнением, которое всё равно не закоммитится # — но проще просто не выполнять его до финальной валидации). # `ics_sequence` НЕ растёт от одной только правки состава # (анти-спам) — рассылка всё равно ставится в очередь. owner_email = await self._resolve_owner_email(conference) participants_changed = await self._apply_participants( conference, data.participants, owner_email=owner_email ) await self._session.flush() await self._session.commit() if cleared_via_null_sentinel: await self._session.refresh(conference, attribute_names=["recurrence"]) if schedule_changed or participants_changed: # Ставится ПОСЛЕ commit — воркер должен видеть уже # зафиксированные `ics_sequence`/новое расписание/состав. self._enqueue_invitations_safely(conference.id) return conference def _enqueue_invitations_safely(self, conference_id: uuid.UUID) -> None: """Поставить рассылку .ics-приглашений в очередь, не роняя запрос при сбое. Конференция к этому моменту уже закоммичена — создание/правка это главный результат запроса, а рассылка приглашений вторична. При недоступности Redis `Celery.send_task` (используется `enqueue_invitations`) бросает исключение синхронно — без этого перехвата POST/PATCH `/conferences` отдал бы 500, хотя запись уже сохранена. Восстановление: пропавшая постановка компенсируется ручной рассылкой администратором (`POST /admin/conferences/{id}/invitations`) — в отличие от шагов AI-пайплайна, здесь нет beat-задачи уровня 2 защиты, т.к. отсутствие приглашения не блокирует использование конференции. """ try: enqueue_invitations(conference_id) except Exception: # noqa: BLE001 — недоступность брокера не должна ронять запрос logger.warning( "ConferenceService: не удалось поставить в очередь рассылку " "приглашений для конференции %s (Redis недоступен?) — конференция " "сохранена, разослать приглашения можно вручную из админки", conference_id, exc_info=True, ) async def delete(self, conference_id: uuid.UUID, *, actor: User) -> None: """Удалить конференцию; запрещено для активной (409 на уровне роутера).""" conference = await self._get_or_raise(conference_id) self._ensure_owner_or_admin(conference, actor) if conference.status == "active": raise ConferenceActiveError await self._conferences.delete(conference) await self._session.commit() async def get_detail(self, conference_id: uuid.UUID, *, actor: User) -> Conference: """Получить конференцию для детального просмотра. Разрешено владельцу, администратору ИЛИ приглашённому (решение от 2026-07-20 поверх ADR-003 — иначе ховер-карточка/детальная страница приглашённого получали бы 403). PATCH/DELETE эту проверку НЕ переиспользуют — там по-прежнему только владелец/администратор (`_ensure_owner_or_admin`). """ conference = await self._get_or_raise(conference_id) await self._ensure_viewable(conference, actor) return conference async def to_detail_out( self, conference: Conference, *, viewer: User, join: JoinOut | None = None ) -> ConferenceOut: """Собрать `ConferenceOut` с полным составом участников (детальный ответ). Используется создание/правка/`GET /conferences/{id}` — списочные эндпоинты (`list_my`) состав не раздувают (ADR-003, п.5) и строят ответ через `to_out` напрямую. """ organizer_name = await self._resolve_organizer_name(conference, viewer=viewer) participants = await self._build_participants_out(conference) return self.to_out( conference, join=join, organizer_name=organizer_name, participants=participants, viewer_id=viewer.id, ) def to_out( self, conference: Conference, *, join: JoinOut | None = None, next_occurrence: datetime | None = None, viewer_id: uuid.UUID | None = None, organizer_name: str | None = None, participants: list[InviteeOut] | None = None, ) -> ConferenceOut: """Собрать `ConferenceOut` из ORM-модели.""" recurrence = ( RecurrenceRule.model_validate(conference.recurrence) if conference.recurrence else None ) return ConferenceOut( id=conference.id, number=conference.number, slug=conference.slug, title=conference.title, status=conference.status, is_pinned=conference.is_pinned, is_closed=conference.is_closed, scheduled_at=conference.scheduled_at, duration_minutes=conference.duration_minutes, recurrence=recurrence, next_occurrence=next_occurrence, created_at=conference.created_at, join=join, summary_recipients=cast("SummaryRecipientsMode | None", conference.summary_recipients), owner_id=conference.owner_id, is_owner=viewer_id is not None and conference.owner_id == viewer_id, organizer_name=organizer_name, participants=participants or [], ) async def _resolve_organizer_name(self, conference: Conference, *, viewer: User) -> str | None: """Имя организатора; переиспользует уже загруженного `viewer`, если он и есть владелец.""" if conference.owner_id is None: return None if conference.owner_id == viewer.id: return viewer.name_user return await self._resolve_owner_name(conference) async def _resolve_owner_name(self, conference: Conference) -> str | None: if conference.owner_id is None: return None owner = await self._session.get(User, conference.owner_id) return owner.name_user if owner is not None else None async def _resolve_owner_email(self, conference: Conference) -> str | None: if conference.owner_id is None: return None owner = await self._session.get(User, conference.owner_id) return owner.email if owner is not None else None async def _build_participants_out(self, conference: Conference) -> list[InviteeOut]: """Состав приглашённых: организатор всегда первым (ADR-003, п.2), затем остальные.""" items: list[InviteeOut] = [] if conference.owner_id is not None: owner = await self._session.get(User, conference.owner_id) if owner is not None: items.append( InviteeOut( user_id=owner.id, email=owner.email, name=owner.name_user, avatar_url=resolve_avatar_url(self._media_root, owner.avatar_path), is_organizer=True, ) ) rows = await self._invitees.list_with_user(conference.id) for invitee, user_name, user_email, user_avatar_path in rows: items.append( InviteeOut( user_id=invitee.user_id, email=invitee.email if invitee.email is not None else user_email, name=user_name, avatar_url=( resolve_avatar_url(self._media_root, user_avatar_path) if invitee.user_id is not None else None ), is_organizer=False, ) ) return items async def _validate_participants(self, participants: list[InviteeIn] | None) -> None: """Проверить существование всех `user_id` состава — ДО любых мутаций (ADR-003).""" if participants is None: return user_ids = {item.user_id for item in participants if item.user_id is not None} if not user_ids: return missing = user_ids - await self._invitees.existing_user_ids(user_ids) if missing: raise InviteeUserNotFoundError(missing) async def _apply_participants( self, conference: Conference, participants: list[InviteeIn] | None, *, owner_email: str | None, ) -> bool: """Заменить состав приглашённых; вернуть `True`, если фактический состав изменился. `participants=None` — не менять (ADR-003, п.3), no-op. Организатор (по `user_id` владельца или его email) молча дедуплицируется — он и так всегда в составе (ADR-003, п.2), а неудаляем конструктивно. `_validate_participants` должен быть вызван заранее. """ if participants is None: return False owner_id = conference.owner_id desired: dict[tuple[str, str], ConferenceInvitee] = {} for item in participants: if item.user_id is not None: if item.user_id == owner_id: continue key = ("user", str(item.user_id)) desired.setdefault( key, ConferenceInvitee(conference_id=conference.id, user_id=item.user_id) ) else: assert item.email is not None # гарантировано `InviteeIn._validate` # email пользователя в БД может быть в смешанном регистре, # InviteeIn.email всегда нормализован в lower — сравниваем без регистра. if owner_email is not None and item.email == owner_email.lower(): continue key = ("email", item.email) desired.setdefault( key, ConferenceInvitee(conference_id=conference.id, email=item.email) ) existing_rows = await self._invitees.list_with_user(conference.id) existing_keys = { ("user", str(invitee.user_id)) if invitee.user_id is not None else ("email", invitee.email) for invitee, _, _, _ in existing_rows } changed = existing_keys != set(desired.keys()) await self._invitees.replace_all(conference.id, list(desired.values())) return changed async def _get_or_raise(self, conference_id: uuid.UUID) -> Conference: conference = await self._conferences.get_by_id(conference_id) if conference is None: raise ConferenceNotFoundError return conference def _ensure_owner_or_admin(self, conference: Conference, actor: User) -> None: if conference.owner_id != actor.id and actor.role != "admin": raise NotConferenceOwnerError async def _ensure_viewable(self, conference: Conference, actor: User) -> None: """Владелец/администратор/приглашённый — иначе `NotConferenceOwnerError` (403). Только для чтения (`get_detail`) — решение от 2026-07-20 поверх ADR-003; PATCH/DELETE используют `_ensure_owner_or_admin` (синхронный, без приглашённых). """ if conference.owner_id == actor.id or actor.role == "admin": return if await self._invitees.exists_for_user(conference.id, user_id=actor.id, email=actor.email): return raise NotConferenceOwnerError def _next_occurrence(self, conference: Conference, *, now: datetime) -> datetime | None: if conference.recurrence is not None: rule = RecurrenceRule.model_validate(conference.recurrence) horizon = NEXT_OCCURRENCE_HORIZON if rule.type == "every_n_days" and rule.interval_days is not None: horizon = max(horizon, timedelta(days=rule.interval_days + 2)) occurrences = expand_occurrences(rule, now, now + horizon) return occurrences[0] if occurrences else None if conference.scheduled_at is not None and conference.scheduled_at >= now: return conference.scheduled_at return None def _occurrence_out( self, conference: Conference, *, starts_at: datetime, ends_at: datetime ) -> OccurrenceOut: return OccurrenceOut( conference_id=conference.id, title=conference.title, starts_at=starts_at, ends_at=ends_at, number=conference.number, slug=conference.slug, is_pinned=conference.is_pinned, is_closed=conference.is_closed, ) async def _create_with_unique_ids( self, factory: Callable[[str, str], Conference] ) -> Conference: """Создать конференцию, повторяя генерацию номера/slug при коллизии unique. До `MAX_ID_GENERATION_ATTEMPTS` попыток; откат до последнего savepoint обязателен после `IntegrityError` — иначе сессия SQLAlchemy становится непригодной для дальнейших операций (в т.ч. в тестах со savepoint). """ last_error: IntegrityError | None = None for _ in range(MAX_ID_GENERATION_ATTEMPTS): conference = factory(generate_number(), generate_slug()) try: return await self._conferences.add(conference) except IntegrityError as exc: await self._session.rollback() last_error = exc assert last_error is not None raise last_error