Files
vidconf/backend/services/conferences.py
Max Ronzhin 42bfb88a22 feat(room): роль организатора в метаданных LiveKit-токена
Предварительная работа для очереди рук и принудительного мьюта (B1/B2):
build_join кладёт is_organizer:true в метаданные токена организатора
(создатель мгновенной конференции и владелец при обычном входе). Метаданные
токена — только подсказка для UI (см. предупреждение в докстринге
build_join), любое серверное действие организатора обязано перепроверяться
по conference.owner_id в БД — так и сделано в mute_participant (B2).

На фронте — общий парсер метаданных участника (lib/participantMetadata.ts,
переиспользован в RoomParticipantTile вместо локальной копии) и хук
useIsOrganizer (читает подсказку для локального участника через
useLocalParticipant — вызывается только внутри LiveKitRoom).
2026-08-01 22:06:10 +03:00

673 lines
36 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.
"""Бизнес-логика конференций: создание, «Мои конференции», календарь, резолв, 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