lct-03 и lct-04: журнал сессий и библиотека сценариев

БД: 11 таблиц, первая миграция. Группы и связь trainee → group заложены
сразу, даже пустыми — размечать накопленные сессии задним числом значит
делать лишнюю миграцию. Номер попытки живёт в сессии, отдельной таблицы
попыток нет: дельта считается запросом по (trainee_id, scenario_id).

Сценарии: строгая схема — опечатка в имени поля падает на старте, а не
игнорируется молча. ground_truth собирается кодом, попытка задать
incident_type, dds или required_facts в YAML отвергается: иначе генератор
разведёт факты и эталон и курсанта оштрафуют за правильный ответ. Руками
задаются только нормализованные адрес и число пострадавших — из фразы
«улица Ленина, 14, квартира 47, 5-й этаж» кодом «улица Ленина, 14»
не достать.

GET /api/scenarios/{id} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.

Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
This commit is contained in:
Ivan Gerasimov 2026-09-15 20:10:05 +03:00
commit 6b2c96bab6
22 changed files with 1380 additions and 8 deletions

View file

36
backend/app/db/base.py Normal file
View file

@ -0,0 +1,36 @@
"""Подключение к Postgres. Асинхронный движок, одна фабрика сессий на процесс."""
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.config import get_settings
class Base(DeclarativeBase):
"""Общая база моделей. Alembic сравнивает миграции с `Base.metadata`."""
_engine = None
_sessionmaker: async_sessionmaker[AsyncSession] | None = None
def get_engine():
global _engine
if _engine is None:
_engine = create_async_engine(get_settings().database_url, pool_pre_ping=True)
return _engine
def get_sessionmaker() -> async_sessionmaker[AsyncSession]:
global _sessionmaker
if _sessionmaker is None:
_sessionmaker = async_sessionmaker(get_engine(), expire_on_commit=False)
return _sessionmaker
async def get_session() -> AsyncIterator[AsyncSession]:
"""Зависимость FastAPI: сессия на запрос, коммит явный."""
async with get_sessionmaker()() as session:
yield session

View file

@ -0,0 +1,54 @@
"""Alembic в асинхронном режиме. Строка подключения — из настроек приложения."""
import asyncio
from logging.config import fileConfig
from alembic import context
from sqlalchemy.ext.asyncio import async_engine_from_config
from sqlalchemy import pool
from app.config import get_settings
from app.db.base import Base
from app.db import models # noqa: F401 — регистрирует таблицы в Base.metadata
config = context.config
config.set_main_option("sqlalchemy.url", get_settings().database_url)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
context.configure(
url=config.get_main_option("sqlalchemy.url"),
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())

View file

@ -0,0 +1,23 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View file

@ -0,0 +1,166 @@
"""первая схема: курсанты, группы, сессии, транскрипт, отметки, оценки
Revision ID: ff5de7dfe4ce
Revises:
Create Date: 2026-09-15 19:51:15.671575
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = 'ff5de7dfe4ce'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('groups',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('name', sa.String(length=120), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_table('llm_cache',
sa.Column('context_hash', sa.String(length=64), nullable=False),
sa.Column('model', sa.String(length=80), nullable=False),
sa.Column('prompt', sa.Text(), nullable=False),
sa.Column('response', sa.Text(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('context_hash')
)
op.create_table('scenarios',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('title', sa.String(length=200), nullable=False),
sa.Column('incident_type', sa.String(length=20), nullable=False),
sa.Column('level', sa.String(length=4), nullable=False),
sa.Column('topics', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('modes', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('status', sa.String(length=16), nullable=False),
sa.Column('body', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_table('trainees',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('name', sa.String(length=120), nullable=False),
sa.Column('group_id', sa.Uuid(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id')
)
op.create_table('sessions',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('scenario_id', sa.String(length=80), nullable=False),
sa.Column('trainee_id', sa.Uuid(), nullable=True),
sa.Column('group_id', sa.Uuid(), nullable=True),
sa.Column('mode', sa.String(length=16), nullable=False),
sa.Column('attempt', sa.Integer(), nullable=False),
sa.Column('started_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('ended_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('end_reason', sa.String(length=16), nullable=True),
sa.Column('kio', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['scenario_id'], ['scenarios.id'], ondelete='RESTRICT'),
sa.ForeignKeyConstraint(['trainee_id'], ['trainees.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_sessions_group_created', 'sessions', ['group_id', 'created_at'], unique=False)
op.create_index('ix_sessions_trainee_scenario', 'sessions', ['trainee_id', 'scenario_id'], unique=False)
op.create_table('findings',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('session_id', sa.Uuid(), nullable=False),
sa.Column('code', sa.String(length=4), nullable=False),
sa.Column('source', sa.String(length=16), nullable=False),
sa.Column('summary', sa.Text(), nullable=False),
sa.Column('fact', sa.Text(), nullable=False),
sa.Column('norm', sa.Text(), nullable=True),
sa.Column('ref', sa.String(length=80), nullable=True),
sa.Column('competency', sa.String(length=24), nullable=True),
sa.Column('transcript_ref', sa.String(length=40), nullable=True),
sa.Column('at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['session_id'], ['sessions.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_findings_session_code', 'findings', ['session_id', 'code'], unique=False)
op.create_table('hint_uses',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('session_id', sa.Uuid(), nullable=False),
sa.Column('checklist_id', sa.String(length=60), nullable=False),
sa.Column('question', sa.Text(), nullable=False),
sa.Column('at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['session_id'], ['sessions.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_table('instructor_notes',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('session_id', sa.Uuid(), nullable=False),
sa.Column('transcript_ref', sa.String(length=40), nullable=False),
sa.Column('text', sa.Text(), nullable=False),
sa.Column('author', sa.String(length=120), nullable=False),
sa.Column('at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['session_id'], ['sessions.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_table('scores',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('session_id', sa.Uuid(), nullable=False),
sa.Column('score_auto', sa.Float(), nullable=False),
sa.Column('score_final', sa.Float(), nullable=False),
sa.Column('overridden_by', sa.String(length=120), nullable=True),
sa.Column('override_comment', sa.Text(), nullable=True),
sa.Column('report', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['session_id'], ['sessions.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('session_id')
)
op.create_table('self_assessments',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('session_id', sa.Uuid(), nullable=False),
sa.Column('missed', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('comment', sa.Text(), nullable=False),
sa.Column('submitted_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['session_id'], ['sessions.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('session_id')
)
op.create_table('utterances',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('session_id', sa.Uuid(), nullable=False),
sa.Column('ref', sa.String(length=40), nullable=False),
sa.Column('speaker', sa.String(length=10), nullable=False),
sa.Column('text', sa.Text(), nullable=False),
sa.Column('mood', sa.String(length=16), nullable=True),
sa.Column('at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['session_id'], ['sessions.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('session_id', 'ref', name='uq_utterance_ref')
)
op.create_index('ix_utterances_session', 'utterances', ['session_id', 'at'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index('ix_utterances_session', table_name='utterances')
op.drop_table('utterances')
op.drop_table('self_assessments')
op.drop_table('scores')
op.drop_table('instructor_notes')
op.drop_table('hint_uses')
op.drop_index('ix_findings_session_code', table_name='findings')
op.drop_table('findings')
op.drop_index('ix_sessions_trainee_scenario', table_name='sessions')
op.drop_index('ix_sessions_group_created', table_name='sessions')
op.drop_table('sessions')
op.drop_table('trainees')
op.drop_table('scenarios')
op.drop_table('llm_cache')
op.drop_table('groups')
# ### end Alembic commands ###

208
backend/app/db/models.py Normal file
View file

@ -0,0 +1,208 @@
"""Таблицы. Профиль курсанта, дельта попыток и аналитика группы строятся
по этому журналу, а не по памяти процесса (docs/arch/BACKEND.md).
Группы и связь `trainee → group` заложены здесь с первой миграции, даже пустыми:
размечать накопленные сессии задним числом — лишняя работа и лишняя миграция.
"""
from datetime import datetime
from uuid import UUID, uuid4
from sqlalchemy import DateTime, ForeignKey, Index, String, Text, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
def _uuid_pk() -> Mapped[UUID]:
return mapped_column(primary_key=True, default=uuid4)
class Group(Base):
"""Учебная группа. Единица аналитики: «70% группы не уточняют этаж»."""
__tablename__ = "groups"
id: Mapped[UUID] = _uuid_pk()
name: Mapped[str] = mapped_column(String(120), unique=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
trainees: Mapped[list["Trainee"]] = relationship(back_populates="group")
class Trainee(Base):
__tablename__ = "trainees"
id: Mapped[UUID] = _uuid_pk()
name: Mapped[str] = mapped_column(String(120))
group_id: Mapped[UUID | None] = mapped_column(ForeignKey("groups.id", ondelete="SET NULL"))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
group: Mapped["Group | None"] = relationship(back_populates="trainees")
sessions: Mapped[list["Session"]] = relationship(back_populates="trainee")
class Scenario(Base):
"""Сценарий целиком лежит в `body`: библиотека — контент, а не схема.
Отдельными колонками вынесено только то, по чему идёт выборка в списке."""
__tablename__ = "scenarios"
id: Mapped[str] = mapped_column(String(80), primary_key=True)
title: Mapped[str] = mapped_column(String(200))
incident_type: Mapped[str] = mapped_column(String(20))
level: Mapped[str] = mapped_column(String(4))
topics: Mapped[list] = mapped_column(JSONB, default=list)
modes: Mapped[list] = mapped_column(JSONB, default=list)
status: Mapped[str] = mapped_column(String(16), default="published")
body: Mapped[dict] = mapped_column(JSONB)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class Session(Base):
"""Одна попытка одного курсанта по одному сценарию.
Номер попытки хранится здесь, а не отдельной таблицей: дельта попыток
считается запросом по (trainee_id, scenario_id, attempt).
"""
__tablename__ = "sessions"
id: Mapped[UUID] = _uuid_pk()
scenario_id: Mapped[str] = mapped_column(ForeignKey("scenarios.id", ondelete="RESTRICT"))
trainee_id: Mapped[UUID | None] = mapped_column(ForeignKey("trainees.id", ondelete="SET NULL"))
group_id: Mapped[UUID | None] = mapped_column(ForeignKey("groups.id", ondelete="SET NULL"))
mode: Mapped[str] = mapped_column(String(16))
attempt: Mapped[int] = mapped_column(default=1)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
end_reason: Mapped[str | None] = mapped_column(String(16))
kio: Mapped[dict] = mapped_column(JSONB, default=dict)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
trainee: Mapped["Trainee | None"] = relationship(back_populates="sessions")
utterances: Mapped[list["Utterance"]] = relationship(
back_populates="session", cascade="all, delete-orphan"
)
__table_args__ = (
Index("ix_sessions_trainee_scenario", "trainee_id", "scenario_id"),
Index("ix_sessions_group_created", "group_id", "created_at"),
)
class Utterance(Base):
"""Реплика транскрипта. `ref` — якорь для пометок и отметок разбора."""
__tablename__ = "utterances"
id: Mapped[UUID] = _uuid_pk()
session_id: Mapped[UUID] = mapped_column(ForeignKey("sessions.id", ondelete="CASCADE"))
ref: Mapped[str] = mapped_column(String(40))
speaker: Mapped[str] = mapped_column(String(10))
text: Mapped[str] = mapped_column(Text)
mood: Mapped[str | None] = mapped_column(String(16))
at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
session: Mapped["Session"] = relationship(back_populates="utterances")
__table_args__ = (
UniqueConstraint("session_id", "ref", name="uq_utterance_ref"),
Index("ix_utterances_session", "session_id", "at"),
)
class Finding(Base):
"""Отметка разбора. Без `fact` отметки не бывает: код без обоснования
не появляется ни в одном интерфейсе."""
__tablename__ = "findings"
id: Mapped[UUID] = _uuid_pk()
session_id: Mapped[UUID] = mapped_column(ForeignKey("sessions.id", ondelete="CASCADE"))
code: Mapped[str] = mapped_column(String(4))
source: Mapped[str] = mapped_column(String(16))
summary: Mapped[str] = mapped_column(Text)
fact: Mapped[str] = mapped_column(Text)
norm: Mapped[str | None] = mapped_column(Text)
ref: Mapped[str | None] = mapped_column(String(80))
competency: Mapped[str | None] = mapped_column(String(24))
transcript_ref: Mapped[str | None] = mapped_column(String(40))
at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
__table_args__ = (Index("ix_findings_session_code", "session_id", "code"),)
class HintUse(Base):
"""Использованная подсказка: какой пункт и на какой минуте.
Штрафа нет — это информация преподавателю и материал разбора."""
__tablename__ = "hint_uses"
id: Mapped[UUID] = _uuid_pk()
session_id: Mapped[UUID] = mapped_column(ForeignKey("sessions.id", ondelete="CASCADE"))
checklist_id: Mapped[str] = mapped_column(String(60))
question: Mapped[str] = mapped_column(Text)
at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
class SelfAssessment(Base):
"""Самооценка до показа автооценки. Одна на сессию."""
__tablename__ = "self_assessments"
id: Mapped[UUID] = _uuid_pk()
session_id: Mapped[UUID] = mapped_column(
ForeignKey("sessions.id", ondelete="CASCADE"), unique=True
)
missed: Mapped[list] = mapped_column(JSONB, default=list)
comment: Mapped[str] = mapped_column(Text, default="")
submitted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
class InstructorNote(Base):
"""Пометка преподавателя к реплике. Видна курсанту в его истории."""
__tablename__ = "instructor_notes"
id: Mapped[UUID] = _uuid_pk()
session_id: Mapped[UUID] = mapped_column(ForeignKey("sessions.id", ondelete="CASCADE"))
transcript_ref: Mapped[str] = mapped_column(String(40))
text: Mapped[str] = mapped_column(Text)
author: Mapped[str] = mapped_column(String(120))
at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class Score(Base):
"""Оценка сессии. Автооценка сохраняется рядом с коррекцией:
видно, что скорректировано и кем."""
__tablename__ = "scores"
id: Mapped[UUID] = _uuid_pk()
session_id: Mapped[UUID] = mapped_column(
ForeignKey("sessions.id", ondelete="CASCADE"), unique=True
)
score_auto: Mapped[float] = mapped_column()
score_final: Mapped[float] = mapped_column()
overridden_by: Mapped[str | None] = mapped_column(String(120))
override_comment: Mapped[str | None] = mapped_column(Text)
report: Mapped[dict] = mapped_column(JSONB, default=dict)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class LlmCache(Base):
"""Кэш ответов LLM по хешу контекста. Работает и онлайн — экономия
на повторах, — и как накопитель материала для офлайн-дерева."""
__tablename__ = "llm_cache"
context_hash: Mapped[str] = mapped_column(String(64), primary_key=True)
model: Mapped[str] = mapped_column(String(80))
prompt: Mapped[str] = mapped_column(Text)
response: Mapped[str] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

135
backend/app/db/repo.py Normal file
View file

@ -0,0 +1,135 @@
"""Доступ к журналу. Всё, что не записано сюда, для оценки не существует."""
from datetime import datetime
from uuid import UUID
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import Group, HintUse, InstructorNote, Session, Trainee, Utterance
async def next_attempt(db: AsyncSession, trainee_id: UUID | None, scenario_id: str) -> int:
"""Номер попытки по этому сценарию. Отдельной таблицы попыток нет:
дельта считается запросом по (trainee_id, scenario_id, attempt)."""
if trainee_id is None:
return 1
done = await db.scalar(
select(func.count())
.select_from(Session)
.where(Session.trainee_id == trainee_id, Session.scenario_id == scenario_id)
)
return int(done or 0) + 1
async def create_session(
db: AsyncSession,
*,
scenario_id: str,
mode: str,
trainee_id: UUID | None = None,
group_id: UUID | None = None,
) -> Session:
session = Session(
scenario_id=scenario_id,
mode=mode,
trainee_id=trainee_id,
group_id=group_id,
attempt=await next_attempt(db, trainee_id, scenario_id),
)
db.add(session)
await db.commit()
return session
async def get_session(db: AsyncSession, session_id: UUID) -> Session | None:
return await db.get(Session, session_id)
async def append_utterance(
db: AsyncSession,
*,
session_id: UUID,
ref: str,
speaker: str,
text: str,
at: datetime,
mood: str | None = None,
) -> Utterance:
utterance = Utterance(
session_id=session_id, ref=ref, speaker=speaker, text=text, at=at, mood=mood
)
db.add(utterance)
await db.commit()
return utterance
async def transcript(db: AsyncSession, session_id: UUID) -> list[Utterance]:
rows = await db.scalars(
select(Utterance).where(Utterance.session_id == session_id).order_by(Utterance.at)
)
return list(rows)
async def record_hint(
db: AsyncSession, *, session_id: UUID, checklist_id: str, question: str, at: datetime
) -> HintUse:
"""Каждое использование подсказки попадает в журнал: счёт подсказок —
материал разбора, а не вычитаемое из баллов."""
hint = HintUse(session_id=session_id, checklist_id=checklist_id, question=question, at=at)
db.add(hint)
await db.commit()
return hint
async def add_note(
db: AsyncSession, *, session_id: UUID, transcript_ref: str, text: str, author: str
) -> InstructorNote:
note = InstructorNote(
session_id=session_id, transcript_ref=transcript_ref, text=text, author=author
)
db.add(note)
await db.commit()
return note
async def history(
db: AsyncSession,
*,
trainee_id: UUID | None = None,
group_id: UUID | None = None,
mode: str | None = None,
since: datetime | None = None,
limit: int = 100,
) -> list[Session]:
"""История с фильтрами: преподавателю нужно видеть прогресс группы
и самостоятельную работу курсантов."""
query = select(Session).order_by(Session.created_at.desc()).limit(limit)
if trainee_id is not None:
query = query.where(Session.trainee_id == trainee_id)
if group_id is not None:
query = query.where(Session.group_id == group_id)
if mode is not None:
query = query.where(Session.mode == mode)
if since is not None:
query = query.where(Session.created_at >= since)
return list(await db.scalars(query))
async def ensure_group(db: AsyncSession, name: str) -> Group:
group = await db.scalar(select(Group).where(Group.name == name))
if group is None:
group = Group(name=name)
db.add(group)
await db.commit()
return group
async def ensure_trainee(db: AsyncSession, name: str, group: Group | None = None) -> Trainee:
query = select(Trainee).where(Trainee.name == name)
trainee = await db.scalar(query)
if trainee is None:
trainee = Trainee(name=name, group_id=group.id if group else None)
db.add(trainee)
await db.commit()
return trainee