first commit
This commit is contained in:
0
backend/repositories/__init__.py
Normal file
0
backend/repositories/__init__.py
Normal file
133
backend/repositories/admin.py
Normal file
133
backend/repositories/admin.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""Репозитории списков конференций/пользователей/команд для админ-API (пагинация, поиск)."""
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.conference import Conference
|
||||
from models.team import Team
|
||||
from models.user import User
|
||||
|
||||
|
||||
class AdminConferenceRepository:
|
||||
"""Постраничный список конференций с фильтром по статусу и текстовым поиском."""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
|
||||
async def list_paginated(
|
||||
self, *, status: str | None, q: str | None, limit: int, offset: int
|
||||
) -> tuple[list[tuple[Conference, str | None, str | None]], int]:
|
||||
"""Вернуть страницу конференций (+ имя/email владельца) и общее число совпадений.
|
||||
|
||||
`q` ищет по названию/номеру/ссылке (регистронезависимо, `ILIKE`).
|
||||
Владелец — `LEFT JOIN` (может отсутствовать, ADR-001) — имя/email
|
||||
нужны колонке «Владелец» в таблице админки.
|
||||
"""
|
||||
filters = []
|
||||
if status is not None:
|
||||
filters.append(Conference.status == status)
|
||||
if q:
|
||||
like = f"%{q}%"
|
||||
filters.append(
|
||||
or_(
|
||||
Conference.title.ilike(like),
|
||||
Conference.number.ilike(like),
|
||||
Conference.slug.ilike(like),
|
||||
)
|
||||
)
|
||||
|
||||
count_stmt = select(func.count()).select_from(Conference)
|
||||
items_stmt = (
|
||||
select(Conference, User.name_user, User.email)
|
||||
.outerjoin(User, Conference.owner_id == User.id)
|
||||
.order_by(Conference.created_at.desc())
|
||||
)
|
||||
for condition in filters:
|
||||
count_stmt = count_stmt.where(condition)
|
||||
items_stmt = items_stmt.where(condition)
|
||||
items_stmt = items_stmt.limit(limit).offset(offset)
|
||||
|
||||
total = (await self._session.execute(count_stmt)).scalar_one()
|
||||
rows = (await self._session.execute(items_stmt)).all()
|
||||
items = [(conference, name, email) for conference, name, email in rows]
|
||||
return items, total
|
||||
|
||||
|
||||
class AdminUserRepository:
|
||||
"""Постраничный список пользователей с текстовым поиском по email/имени."""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
|
||||
async def list_paginated(
|
||||
self, *, q: str | None, limit: int, offset: int
|
||||
) -> tuple[list[tuple[User, str | None]], int]:
|
||||
"""Вернуть страницу пользователей (+ имя команды) и общее число совпадений.
|
||||
|
||||
`LEFT JOIN` на `teams` — имя команды нужно карточке профиля/таблице
|
||||
админки, у пользователя без команды — `None`.
|
||||
"""
|
||||
filters = []
|
||||
if q:
|
||||
like = f"%{q}%"
|
||||
filters.append(or_(User.email.ilike(like), User.name_user.ilike(like)))
|
||||
|
||||
count_stmt = select(func.count()).select_from(User)
|
||||
items_stmt = (
|
||||
select(User, Team.name)
|
||||
.outerjoin(Team, User.team_id == Team.id)
|
||||
.order_by(User.created_at.desc())
|
||||
)
|
||||
for condition in filters:
|
||||
count_stmt = count_stmt.where(condition)
|
||||
items_stmt = items_stmt.where(condition)
|
||||
items_stmt = items_stmt.limit(limit).offset(offset)
|
||||
|
||||
total = (await self._session.execute(count_stmt)).scalar_one()
|
||||
rows = (await self._session.execute(items_stmt)).all()
|
||||
return [(user, team_name) for user, team_name in rows], total
|
||||
|
||||
async def get_with_team(self, user_id: uuid.UUID) -> tuple[User, str | None] | None:
|
||||
"""Пользователь + имя команды по id (карточка профиля); `None` — не найден."""
|
||||
result = await self._session.execute(
|
||||
select(User, Team.name)
|
||||
.outerjoin(Team, User.team_id == Team.id)
|
||||
.where(User.id == user_id)
|
||||
)
|
||||
row = result.first()
|
||||
return (row[0], row[1]) if row is not None else None
|
||||
|
||||
|
||||
class TeamRepository:
|
||||
"""Справочник команд: список (сортировка по названию), поиск по имени, CRUD."""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
|
||||
async def list_all(self) -> tuple[list[Team], int]:
|
||||
"""Все команды, отсортированные по названию, и их общее число."""
|
||||
items_stmt = select(Team).order_by(Team.name)
|
||||
items = (await self._session.execute(items_stmt)).scalars().all()
|
||||
return list(items), len(items)
|
||||
|
||||
async def get(self, team_id: uuid.UUID) -> Team | None:
|
||||
"""Найти команду по id."""
|
||||
return await self._session.get(Team, team_id)
|
||||
|
||||
async def get_by_name(self, name: str) -> Team | None:
|
||||
"""Найти команду по точному названию (проверка дубля перед созданием/переименованием)."""
|
||||
result = await self._session.execute(select(Team).where(Team.name == name))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def create(self, name: str) -> Team:
|
||||
"""Создать команду."""
|
||||
team = Team(name=name)
|
||||
self._session.add(team)
|
||||
await self._session.flush()
|
||||
return team
|
||||
|
||||
async def delete(self, team: Team) -> None:
|
||||
"""Удалить команду (у пользователей `team_id` обнулится через ON DELETE SET NULL)."""
|
||||
await self._session.delete(team)
|
||||
48
backend/repositories/chat.py
Normal file
48
backend/repositories/chat.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Репозиторий доступа к сообщениям чата (`chat_messages`)."""
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.chat import ChatMessage
|
||||
|
||||
|
||||
class ChatMessageRepository:
|
||||
"""Инкапсулирует SQL-запросы к сообщениям чата конкретной сессии конференции."""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
|
||||
async def add(
|
||||
self,
|
||||
*,
|
||||
session_id: uuid.UUID,
|
||||
user_id: uuid.UUID | None,
|
||||
guest_access_id: uuid.UUID | None,
|
||||
author_name: str,
|
||||
text: str,
|
||||
) -> ChatMessage:
|
||||
"""Добавить сообщение чата и вернуть строку с проставленными `id`/`created_at`."""
|
||||
message = ChatMessage(
|
||||
session_id=session_id,
|
||||
user_id=user_id,
|
||||
guest_access_id=guest_access_id,
|
||||
author_name=author_name,
|
||||
text=text,
|
||||
)
|
||||
self._session.add(message)
|
||||
await self._session.flush()
|
||||
return message
|
||||
|
||||
async def last_for_session(self, session_id: uuid.UUID, *, limit: int) -> list[ChatMessage]:
|
||||
"""Последние `limit` сообщений сессии в хронологическом порядке (от старых к новым)."""
|
||||
result = await self._session.execute(
|
||||
select(ChatMessage)
|
||||
.where(ChatMessage.session_id == session_id)
|
||||
.order_by(ChatMessage.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
rows = list(result.scalars().all())
|
||||
rows.reverse()
|
||||
return rows
|
||||
543
backend/repositories/conferences.py
Normal file
543
backend/repositories/conferences.py
Normal file
@@ -0,0 +1,543 @@
|
||||
"""Репозитории доступа к `conferences` (сущность) и `conference_sessions` (сеанс, ADR-001).
|
||||
|
||||
`ConferenceSessionRepository` используется webhook-обработчиками LiveKit
|
||||
(`services/webhook_handlers.py`) и beat-задачей обслуживания
|
||||
(`workers/tasks/maintenance.py`) — методы get-or-create/guard-стиля, чтобы
|
||||
быть безопасными при пропущенных или дублирующихся событиях (шаги
|
||||
пост-обработки идемпотентны).
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import cast
|
||||
|
||||
from sqlalchemy import delete, func, or_, select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.audio_track import SessionAudioTrack
|
||||
from models.conference import Conference
|
||||
from models.invitee import ConferenceInvitee
|
||||
from models.participant import ConferenceParticipant
|
||||
from models.session import ConferenceSession
|
||||
from models.user import User
|
||||
|
||||
|
||||
class ConferenceRepository:
|
||||
"""Инкапсулирует SQL-запросы к конференциям (`conferences`)."""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
|
||||
async def get_by_id(self, conference_id: uuid.UUID) -> Conference | None:
|
||||
"""Найти конференцию по id."""
|
||||
return await self._session.get(Conference, conference_id)
|
||||
|
||||
async def get_by_slug(self, slug: str) -> Conference | None:
|
||||
"""Найти конференцию по постоянной ссылке (= имени LiveKit-комнаты)."""
|
||||
result = await self._session.execute(select(Conference).where(Conference.slug == slug))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_by_number(self, number: str) -> Conference | None:
|
||||
"""Найти конференцию по человеко-диктуемому номеру."""
|
||||
result = await self._session.execute(select(Conference).where(Conference.number == number))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_status_by_id(self, conference_id: uuid.UUID) -> str | None:
|
||||
"""Прочитать АКТУАЛЬНЫЙ статус конференции, минуя identity map сессии.
|
||||
|
||||
В отличие от `get_by_id`/`session.get(...)`, SELECT одной колонки не
|
||||
возвращает уже загруженный в эту сессию ORM-объект `Conference` из
|
||||
кэша identity map — а при `expire_on_commit=False` (`core/db.py`)
|
||||
такой объект, однажды загруженный долгоживущей WS-сессией чата,
|
||||
никогда сам не увидит статус, изменённый вебхуком в ДРУГОЙ сессии/
|
||||
процессе (например, `room_finished` -> `ended`). Используется
|
||||
`ChatService.persist_and_publish` перед созданием новой сессии
|
||||
пайплайна.
|
||||
"""
|
||||
result = await self._session.execute(
|
||||
select(Conference.status).where(Conference.id == conference_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def add(self, conference: Conference) -> Conference:
|
||||
"""Добавить конференцию в сессию и сделать flush (нарушение unique — здесь же)."""
|
||||
self._session.add(conference)
|
||||
await self._session.flush()
|
||||
return conference
|
||||
|
||||
async def delete(self, conference: Conference) -> None:
|
||||
"""Удалить конференцию (сеансы/гости удаляются каскадом на уровне БД)."""
|
||||
await self._session.delete(conference)
|
||||
await self._session.flush()
|
||||
|
||||
async def list_owned(
|
||||
self, user_id: uuid.UUID, *, email: str, now: datetime
|
||||
) -> list[Conference]:
|
||||
"""Конференции владельца ИЛИ приглашённого для «Моих конференций» (решение от
|
||||
2026-07-20 поверх ADR-003: приглашённый видит конференцию в своих списках) —
|
||||
закреплённые + предстоящие разовые.
|
||||
|
||||
«Приглашённый» — есть строка `conference_invitees` с `user_id == user_id`
|
||||
ИЛИ с `lower(email) == lower(этого email)` (внешнее приглашение на адрес,
|
||||
под которым человек впоследствии зарегистрировался). `EXISTS`-подзапрос
|
||||
не размножает строки `Conference` — `DISTINCT` не требуется.
|
||||
"""
|
||||
is_invitee = (
|
||||
select(ConferenceInvitee.id)
|
||||
.where(
|
||||
ConferenceInvitee.conference_id == Conference.id,
|
||||
or_(
|
||||
ConferenceInvitee.user_id == user_id,
|
||||
func.lower(ConferenceInvitee.email) == email.lower(),
|
||||
),
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
result = await self._session.execute(
|
||||
select(Conference)
|
||||
.where(
|
||||
or_(Conference.owner_id == user_id, is_invitee),
|
||||
(Conference.is_pinned.is_(True))
|
||||
| (
|
||||
(Conference.scheduled_at.isnot(None))
|
||||
& (Conference.scheduled_at >= now)
|
||||
& (Conference.status == "scheduled")
|
||||
),
|
||||
)
|
||||
.order_by(Conference.is_pinned.desc(), Conference.scheduled_at.asc().nulls_last())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def list_calendar_candidates(self, user_id: uuid.UUID, *, email: str) -> list[Conference]:
|
||||
"""Конференции владельца ИЛИ приглашённого, потенциально дающие вхождения в календаре.
|
||||
|
||||
Тот же принцип видимости приглашённого, что и `list_owned` (решение от
|
||||
2026-07-20). Развёртка диапазона — в сервисном слое
|
||||
(`services/conferences.py::list_calendar`), здесь только грубая выборка
|
||||
кандидатов (есть recurrence или указано scheduled_at).
|
||||
"""
|
||||
is_invitee = (
|
||||
select(ConferenceInvitee.id)
|
||||
.where(
|
||||
ConferenceInvitee.conference_id == Conference.id,
|
||||
or_(
|
||||
ConferenceInvitee.user_id == user_id,
|
||||
func.lower(ConferenceInvitee.email) == email.lower(),
|
||||
),
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
result = await self._session.execute(
|
||||
select(Conference).where(
|
||||
or_(Conference.owner_id == user_id, is_invitee),
|
||||
(Conference.recurrence.isnot(None)) | (Conference.scheduled_at.isnot(None)),
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def list_expired_unpinned_scheduled(self, *, now: datetime) -> list[Conference]:
|
||||
"""Незакреплённые плановые конференции без единого сеанса (кандидаты на `ended`).
|
||||
|
||||
Точная проверка истечения времени (с учётом `duration_minutes` и
|
||||
запаса) — в `workers/tasks/maintenance.py`; здесь — грубая выборка по
|
||||
`NOT EXISTS` сеанса, чтобы не тянуть в память всё лишнее.
|
||||
"""
|
||||
has_session = (
|
||||
select(ConferenceSession.id)
|
||||
.where(ConferenceSession.conference_id == Conference.id)
|
||||
.exists()
|
||||
)
|
||||
result = await self._session.execute(
|
||||
select(Conference).where(
|
||||
Conference.is_pinned.is_(False),
|
||||
Conference.status == "scheduled",
|
||||
Conference.scheduled_at.isnot(None),
|
||||
Conference.scheduled_at < now,
|
||||
~has_session,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
class ConferenceInviteeRepository:
|
||||
"""Инкапсулирует SQL-запросы к приглашённым на конференцию (`conference_invitees`, ADR-003)."""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
|
||||
async def list_with_user(
|
||||
self, conference_id: uuid.UUID
|
||||
) -> list[tuple[ConferenceInvitee, str | None, str | None, str | None]]:
|
||||
"""Приглашённые конференции + имя/email/путь аватара их пользователя (LEFT JOIN).
|
||||
|
||||
Для внешних приглашённых (`user_id IS NULL`) три последних элемента
|
||||
кортежа — `None` (нет привязанного `User`).
|
||||
"""
|
||||
result = await self._session.execute(
|
||||
select(ConferenceInvitee, User.name_user, User.email, User.avatar_path)
|
||||
.outerjoin(User, ConferenceInvitee.user_id == User.id)
|
||||
.where(ConferenceInvitee.conference_id == conference_id)
|
||||
)
|
||||
# `User.name_user`/`User.email` типизированы как non-optional (NOT NULL
|
||||
# в модели) — но при LEFT JOIN без совпадения (внешний приглашённый,
|
||||
# `user_id IS NULL`) значения реально приходят `NULL`; mypy не видит
|
||||
# nullability, вносимую `outerjoin`, отсюда явный `cast`.
|
||||
return cast(
|
||||
"list[tuple[ConferenceInvitee, str | None, str | None, str | None]]",
|
||||
list(result.all()),
|
||||
)
|
||||
|
||||
async def existing_user_ids(self, user_ids: set[uuid.UUID]) -> set[uuid.UUID]:
|
||||
"""Подмножество `user_ids`, реально существующее в `users` (проверка перед вставкой)."""
|
||||
if not user_ids:
|
||||
return set()
|
||||
result = await self._session.execute(select(User.id).where(User.id.in_(user_ids)))
|
||||
return set(result.scalars().all())
|
||||
|
||||
async def exists_for_user(
|
||||
self, conference_id: uuid.UUID, *, user_id: uuid.UUID, email: str
|
||||
) -> bool:
|
||||
"""Приглашён ли `user_id` на конференцию — по `user_id` или по `lower(email)`.
|
||||
|
||||
Используется проверкой доступа к детальной карточке (`GET /conferences/{id}`,
|
||||
решение от 2026-07-20 поверх ADR-003) — приглашённый должен её видеть
|
||||
наравне с владельцем/администратором.
|
||||
"""
|
||||
result = await self._session.execute(
|
||||
select(ConferenceInvitee.id)
|
||||
.where(
|
||||
ConferenceInvitee.conference_id == conference_id,
|
||||
or_(
|
||||
ConferenceInvitee.user_id == user_id,
|
||||
func.lower(ConferenceInvitee.email) == email.lower(),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
async def replace_all(
|
||||
self, conference_id: uuid.UUID, invitees: list[ConferenceInvitee]
|
||||
) -> None:
|
||||
"""Полностью заменить состав приглашённых конференции (ADR-003, п.3 — PUT-семантика)."""
|
||||
await self._session.execute(
|
||||
delete(ConferenceInvitee).where(ConferenceInvitee.conference_id == conference_id)
|
||||
)
|
||||
for invitee in invitees:
|
||||
self._session.add(invitee)
|
||||
await self._session.flush()
|
||||
|
||||
|
||||
class ConferenceSessionRepository:
|
||||
"""Инкапсулирует SQL-запросы к сеансам конференций и их участникам."""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
|
||||
async def get_open_by_conference(self, conference_id: uuid.UUID) -> ConferenceSession | None:
|
||||
"""Вернуть открытый (`t_end IS NULL`) сеанс конференции, если есть."""
|
||||
result = await self._session.execute(
|
||||
select(ConferenceSession)
|
||||
.where(
|
||||
ConferenceSession.conference_id == conference_id,
|
||||
ConferenceSession.t_end.is_(None),
|
||||
)
|
||||
.order_by(ConferenceSession.t_start.desc())
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def create(
|
||||
self, *, conference_id: uuid.UUID, title: str | None, t_start: datetime
|
||||
) -> ConferenceSession:
|
||||
"""Создать новый (открытый) сеанс конференции."""
|
||||
record = ConferenceSession(conference_id=conference_id, title=title, t_start=t_start)
|
||||
self._session.add(record)
|
||||
await self._session.flush()
|
||||
return record
|
||||
|
||||
async def get_or_create_open(
|
||||
self, *, conference_id: uuid.UUID, title: str | None, t_start: datetime
|
||||
) -> ConferenceSession:
|
||||
"""Get-or-create открытого сеанса конференции.
|
||||
|
||||
Гвард на случай, если `room_started` было пропущено и первым пришло
|
||||
`participant_joined`.
|
||||
"""
|
||||
existing = await self.get_open_by_conference(conference_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
return await self.create(conference_id=conference_id, title=title, t_start=t_start)
|
||||
|
||||
async def close(self, session_record: ConferenceSession, *, t_end: datetime) -> None:
|
||||
"""Закрыть сеанс, проставив `t_end`."""
|
||||
session_record.t_end = t_end
|
||||
|
||||
async def list_open(self) -> list[ConferenceSession]:
|
||||
"""Список всех открытых (`t_end IS NULL`) сеансов.
|
||||
|
||||
Используется maintenance-задачей (`workers/tasks/maintenance.py`)
|
||||
для обхода всех "зависших" сеансов разом.
|
||||
"""
|
||||
result = await self._session.execute(
|
||||
select(ConferenceSession).where(ConferenceSession.t_end.is_(None))
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def list_stuck_summarizing(self, *, older_than: datetime) -> list[ConferenceSession]:
|
||||
"""Сеансы, зависшие на шаге суммаризации: `pipeline_status='summarizing'`,
|
||||
`summary_data` ещё не заполнен, а сеанс завершился раньше `older_than`.
|
||||
|
||||
Кандидаты на повторную постановку `summarize_session` —
|
||||
`workers/tasks/maintenance.py::recover_stuck_summaries` (уровень 2
|
||||
защиты от потери постановки задачи при сбое брокера в `run_pipeline`).
|
||||
"""
|
||||
result = await self._session.execute(
|
||||
select(ConferenceSession).where(
|
||||
ConferenceSession.pipeline_status == "summarizing",
|
||||
ConferenceSession.summary_data.is_(None),
|
||||
ConferenceSession.t_end.isnot(None),
|
||||
ConferenceSession.t_end < older_than,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def list_stuck_notifying(self, *, older_than: datetime) -> list[ConferenceSession]:
|
||||
"""Сеансы, зависшие на шаге уведомления: саммари готово, но `notify_session`
|
||||
так и не перевела пайплайн в `notified`.
|
||||
|
||||
`pipeline_status='summarizing'` + `summary_data IS NOT NULL` +
|
||||
`t_end < older_than` — кандидаты на повторную постановку
|
||||
`notify_session` (`workers/tasks/maintenance.py::recover_stuck_notifications`,
|
||||
уровень 2 защиты от потери постановки задачи при сбое брокера в
|
||||
`summarize_session`, аналог `list_stuck_summarizing`).
|
||||
"""
|
||||
result = await self._session.execute(
|
||||
select(ConferenceSession).where(
|
||||
ConferenceSession.pipeline_status == "summarizing",
|
||||
ConferenceSession.summary_data.isnot(None),
|
||||
ConferenceSession.t_end.isnot(None),
|
||||
ConferenceSession.t_end < older_than,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def count_by_pipeline_status(self) -> dict[str, int]:
|
||||
"""Число сеансов в каждом статусе пайплайна (`pipeline_status`).
|
||||
|
||||
Используется метриками Prometheus (`backend/api/metrics.py`,
|
||||
блок 3) для gauge `vidconf_pipeline_sessions{status=...}` — считается
|
||||
заново при каждом scrape, не кешируется. Статусы без единого сеанса в
|
||||
результат не попадают (пустая группа), это ожидаемо: вызывающая
|
||||
сторона сама проставляет 0 для отсутствующих в словаре статусов
|
||||
(полный перечень — `models.session.PipelineStatus`), чтобы метрика не
|
||||
"пропадала" из `/metrics` между сборами.
|
||||
"""
|
||||
result = await self._session.execute(
|
||||
select(ConferenceSession.pipeline_status, func.count()).group_by(
|
||||
ConferenceSession.pipeline_status
|
||||
)
|
||||
)
|
||||
return {status: count for status, count in result.all()}
|
||||
|
||||
async def has_active_participants(self, session_id: uuid.UUID) -> bool:
|
||||
"""Есть ли у сеанса хотя бы один участник без `left_at` (кто-то ещё внутри)."""
|
||||
result = await self._session.execute(
|
||||
select(ConferenceParticipant.id)
|
||||
.where(
|
||||
ConferenceParticipant.session_id == session_id,
|
||||
ConferenceParticipant.left_at.is_(None),
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
async def add_participant(
|
||||
self,
|
||||
*,
|
||||
session_id: uuid.UUID,
|
||||
user_id: uuid.UUID | None,
|
||||
guest_id: uuid.UUID | None,
|
||||
joined_at: datetime,
|
||||
) -> ConferenceParticipant:
|
||||
"""Get-or-create активной (не покинувшей) записи участия — пользователя ИЛИ гостя.
|
||||
|
||||
Защищает от дублей при повторной доставке `participant_joined` для
|
||||
уже присутствующего участника. Ровно один из `user_id`/`guest_id`
|
||||
должен быть передан (см. CHECK-constraint модели).
|
||||
"""
|
||||
stmt = select(ConferenceParticipant).where(
|
||||
ConferenceParticipant.session_id == session_id,
|
||||
ConferenceParticipant.left_at.is_(None),
|
||||
)
|
||||
stmt = stmt.where(
|
||||
ConferenceParticipant.user_id == user_id
|
||||
if user_id is not None
|
||||
else ConferenceParticipant.guest_id == guest_id
|
||||
)
|
||||
existing = (await self._session.execute(stmt)).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
participant = ConferenceParticipant(
|
||||
session_id=session_id, user_id=user_id, guest_id=guest_id, joined_at=joined_at
|
||||
)
|
||||
self._session.add(participant)
|
||||
await self._session.flush()
|
||||
return participant
|
||||
|
||||
async def get_active_participant(
|
||||
self,
|
||||
session_id: uuid.UUID,
|
||||
*,
|
||||
user_id: uuid.UUID | None = None,
|
||||
guest_id: uuid.UUID | None = None,
|
||||
) -> ConferenceParticipant | None:
|
||||
"""Найти активную (`left_at IS NULL`) запись присутствия пользователя/гостя в сеансе.
|
||||
|
||||
Используется атрибуцией аудиотрека к участнику (`track_published`,
|
||||
ADR-002): ровно один из `user_id`/`guest_id` должен быть передан.
|
||||
"""
|
||||
stmt = select(ConferenceParticipant).where(
|
||||
ConferenceParticipant.session_id == session_id,
|
||||
ConferenceParticipant.left_at.is_(None),
|
||||
)
|
||||
stmt = stmt.where(
|
||||
ConferenceParticipant.user_id == user_id
|
||||
if user_id is not None
|
||||
else ConferenceParticipant.guest_id == guest_id
|
||||
)
|
||||
stmt = stmt.order_by(ConferenceParticipant.joined_at.desc()).limit(1)
|
||||
return (await self._session.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
async def mark_participant_left(
|
||||
self,
|
||||
*,
|
||||
session_id: uuid.UUID,
|
||||
user_id: uuid.UUID | None,
|
||||
guest_id: uuid.UUID | None,
|
||||
left_at: datetime,
|
||||
) -> None:
|
||||
"""Проставить `left_at` последней открытой записи участника (пользователя или гостя)."""
|
||||
stmt = select(ConferenceParticipant).where(
|
||||
ConferenceParticipant.session_id == session_id,
|
||||
ConferenceParticipant.left_at.is_(None),
|
||||
)
|
||||
stmt = stmt.where(
|
||||
ConferenceParticipant.user_id == user_id
|
||||
if user_id is not None
|
||||
else ConferenceParticipant.guest_id == guest_id
|
||||
)
|
||||
stmt = stmt.order_by(ConferenceParticipant.joined_at.desc()).limit(1)
|
||||
result = await self._session.execute(stmt)
|
||||
participant = result.scalar_one_or_none()
|
||||
if participant is not None:
|
||||
participant.left_at = left_at
|
||||
|
||||
async def close_all_open_participants(
|
||||
self, *, session_id: uuid.UUID, left_at: datetime
|
||||
) -> None:
|
||||
"""Закрыть все записи участников сеанса с `left_at IS NULL` (`room_finished`)."""
|
||||
open_participants = await self._session.scalars(
|
||||
select(ConferenceParticipant).where(
|
||||
ConferenceParticipant.session_id == session_id,
|
||||
ConferenceParticipant.left_at.is_(None),
|
||||
)
|
||||
)
|
||||
for participant in open_participants:
|
||||
participant.left_at = left_at
|
||||
|
||||
|
||||
class AudioTrackRepository:
|
||||
"""Инкапсулирует SQL-запросы к записанным аудиотрекам сеансов (`session_audio_tracks`)."""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
|
||||
async def get_by_session_and_track(
|
||||
self, session_id: uuid.UUID, track_sid: str
|
||||
) -> SessionAudioTrack | None:
|
||||
"""Найти строку трека по (`session_id`, `track_sid`) — ключ идемпотентности `create`."""
|
||||
result = await self._session.execute(
|
||||
select(SessionAudioTrack).where(
|
||||
SessionAudioTrack.session_id == session_id,
|
||||
SessionAudioTrack.track_sid == track_sid,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def create(
|
||||
self,
|
||||
*,
|
||||
session_id: uuid.UUID,
|
||||
participant_id: uuid.UUID,
|
||||
track_sid: str,
|
||||
egress_id: str | None,
|
||||
file_path: str | None,
|
||||
started_at: datetime,
|
||||
) -> SessionAudioTrack:
|
||||
"""Идемпотентно создать строку трека по (`session_id`, `track_sid`).
|
||||
|
||||
Повторная доставка `track_published` (например, гонка между двумя
|
||||
одновременными вебхуками) — `INSERT ... ON CONFLICT DO NOTHING` по
|
||||
уникальному индексу `uq_session_track`, затем возврат уже
|
||||
существующей строки. `status` по умолчанию `recording`.
|
||||
"""
|
||||
insert_stmt = (
|
||||
pg_insert(SessionAudioTrack)
|
||||
.values(
|
||||
session_id=session_id,
|
||||
participant_id=participant_id,
|
||||
track_sid=track_sid,
|
||||
egress_id=egress_id,
|
||||
file_path=file_path,
|
||||
started_at=started_at,
|
||||
)
|
||||
.on_conflict_do_nothing(constraint="uq_session_track")
|
||||
.returning(SessionAudioTrack.id)
|
||||
)
|
||||
inserted_id = (await self._session.execute(insert_stmt)).scalar_one_or_none()
|
||||
if inserted_id is None:
|
||||
existing = await self.get_by_session_and_track(session_id, track_sid)
|
||||
assert existing is not None # конфликт гарантирует существование строки
|
||||
return existing
|
||||
|
||||
await self._session.flush()
|
||||
record = await self._session.get(SessionAudioTrack, inserted_id)
|
||||
assert record is not None
|
||||
return record
|
||||
|
||||
async def finalize(
|
||||
self,
|
||||
*,
|
||||
egress_id: str,
|
||||
status: str,
|
||||
ended_at: datetime,
|
||||
file_path: str | None = None,
|
||||
) -> SessionAudioTrack | None:
|
||||
"""Финализировать строку трека по `egress_id` (обработка webhook `egress_ended`).
|
||||
|
||||
`status` — `'recorded'` при успехе, `'failed'` при ошибке egress.
|
||||
Отсутствие строки (например, `track_published` был потерян) — не
|
||||
ошибка, лог оставляет вызывающая сторона.
|
||||
"""
|
||||
result = await self._session.execute(
|
||||
select(SessionAudioTrack).where(SessionAudioTrack.egress_id == egress_id)
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
if record is None:
|
||||
return None
|
||||
|
||||
record.status = status
|
||||
record.ended_at = ended_at
|
||||
if file_path is not None:
|
||||
record.file_path = file_path
|
||||
return record
|
||||
|
||||
async def list_by_session(self, session_id: uuid.UUID) -> list[SessionAudioTrack]:
|
||||
"""Список всех треков сеанса (используется оркестрацией пайплайна)."""
|
||||
result = await self._session.execute(
|
||||
select(SessionAudioTrack).where(SessionAudioTrack.session_id == session_id)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
59
backend/repositories/users.py
Normal file
59
backend/repositories/users.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Репозиторий доступа к таблице `users`."""
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.user import User
|
||||
|
||||
|
||||
class UserRepository:
|
||||
"""Инкапсулирует SQL-запросы к пользователям."""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
|
||||
async def get_by_email(self, email: str) -> User | None:
|
||||
"""Найти пользователя по email (регистр значим, как задано в БД)."""
|
||||
result = await self._session.execute(select(User).where(User.email == email))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_by_id(self, user_id: uuid.UUID) -> User | None:
|
||||
"""Найти пользователя по id."""
|
||||
return await self._session.get(User, user_id)
|
||||
|
||||
async def create(
|
||||
self,
|
||||
*,
|
||||
email: str,
|
||||
name_user: str,
|
||||
password_hash: str,
|
||||
team_id: uuid.UUID | None = None,
|
||||
) -> User:
|
||||
"""Создать нового пользователя (role='user', email_verified=False по умолчанию)."""
|
||||
user = User(email=email, name_user=name_user, password_hash=password_hash, team_id=team_id)
|
||||
self._session.add(user)
|
||||
await self._session.flush()
|
||||
return user
|
||||
|
||||
async def list_all(self) -> list[User]:
|
||||
"""Список всех пользователей (для мультиселекта участников брони, без пагинации)."""
|
||||
result = await self._session.execute(select(User).order_by(User.name_user))
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def search(self, *, q: str | None, limit: int) -> list[User]:
|
||||
"""Пикер участников конференции: без `q` — полный список (как `list_all`);
|
||||
с `q` — поиск по имени/email (`ILIKE`), ограниченный `limit`.
|
||||
"""
|
||||
if not q:
|
||||
return await self.list_all()
|
||||
like = f"%{q}%"
|
||||
stmt = (
|
||||
select(User)
|
||||
.where(or_(User.name_user.ilike(like), User.email.ilike(like)))
|
||||
.order_by(User.name_user)
|
||||
.limit(limit)
|
||||
)
|
||||
result = await self._session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
Reference in New Issue
Block a user