Complete DDS training workflow and delivery package

This commit is contained in:
andreysk0304 2026-09-24 01:10:49 +03:00
commit 4c4b91064f
229 changed files with 11969 additions and 1024 deletions

View file

@ -0,0 +1,63 @@
"""учебные материалы и назначения
Revision ID: a4c9d1e2f3b4
Revises: e6e2ab171dd7
Create Date: 2026-09-23
"""
from alembic import op
import sqlalchemy as sa
revision = "a4c9d1e2f3b4"
down_revision = "e6e2ab171dd7"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"learning_materials",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("title", sa.String(length=200), nullable=False),
sa.Column("description", sa.Text(), nullable=False),
sa.Column("level", sa.String(length=4), nullable=False),
sa.Column("kind", sa.String(length=16), nullable=False),
sa.Column("body", sa.Text(), nullable=False),
sa.Column("scenario_id", sa.String(length=80), nullable=True),
sa.Column("file_name", sa.String(length=240), nullable=True),
sa.Column("media_type", sa.String(length=120), nullable=True),
sa.Column("file_data", sa.LargeBinary(), nullable=True),
sa.Column("file_sha256", sa.String(length=64), nullable=True),
sa.Column("active", sa.Boolean(), nullable=False),
sa.Column("created_by", sa.String(length=80), 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.ForeignKeyConstraint(["scenario_id"], ["scenarios.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"material_assignments",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("material_id", sa.Uuid(), nullable=False),
sa.Column("trainee_id", sa.Uuid(), nullable=False),
sa.Column("assigned_by", sa.String(length=80), nullable=False),
sa.Column("assigned_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["material_id"], ["learning_materials.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["trainee_id"], ["trainees.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("material_id", "trainee_id", name="uq_material_assignment"),
)
op.create_index(
"ix_material_assignments_trainee",
"material_assignments",
["trainee_id", "assigned_at"],
unique=False,
)
def downgrade() -> None:
op.drop_index("ix_material_assignments_trainee", table_name="material_assignments")
op.drop_table("material_assignments")
op.drop_table("learning_materials")

View file

@ -0,0 +1,37 @@
"""снимки активных занятий для восстановления
Revision ID: b5e0f2a3c4d5
Revises: a4c9d1e2f3b4
Create Date: 2026-09-23
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "b5e0f2a3c4d5"
down_revision = "a4c9d1e2f3b4"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("sessions", sa.Column("live_state", postgresql.JSONB(), nullable=True))
op.add_column(
"sessions",
sa.Column("checkpoint_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index(
"ix_sessions_active_checkpoint",
"sessions",
["checkpoint_at"],
unique=False,
postgresql_where=sa.text("ended_at IS NULL AND live_state IS NOT NULL"),
)
def downgrade() -> None:
op.drop_index("ix_sessions_active_checkpoint", table_name="sessions")
op.drop_column("sessions", "checkpoint_at")
op.drop_column("sessions", "live_state")

View file

@ -0,0 +1,26 @@
"""сохраняемая версия полномочий пользователя
Revision ID: c6f1a3b4d5e6
Revises: b5e0f2a3c4d5
Create Date: 2026-09-23
"""
from alembic import op
import sqlalchemy as sa
revision = "c6f1a3b4d5e6"
down_revision = "b5e0f2a3c4d5"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"users",
sa.Column("auth_version", sa.Integer(), server_default="0", nullable=False),
)
def downgrade() -> None:
op.drop_column("users", "auth_version")

View file

@ -0,0 +1,31 @@
"""ограничение авторского контента и занятий владельцем-преподавателем
Revision ID: d7e2f4a5b6c7
Revises: c6f1a3b4d5e6
Create Date: 2026-09-23
"""
from alembic import op
import sqlalchemy as sa
revision = "d7e2f4a5b6c7"
down_revision = "c6f1a3b4d5e6"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"scenarios",
sa.Column("owner_login", sa.String(length=80), nullable=True),
)
op.add_column(
"sessions",
sa.Column("owner_login", sa.String(length=80), nullable=True),
)
def downgrade() -> None:
op.drop_column("sessions", "owner_login")
op.drop_column("scenarios", "owner_login")

View file

@ -0,0 +1,25 @@
"""закрепить учебные группы за преподавателем
Revision ID: f7a3c9d1e2b4
Revises: d7e2f4a5b6c7
Create Date: 2026-09-23
"""
from alembic import op
import sqlalchemy as sa
revision = "f7a3c9d1e2b4"
down_revision = "d7e2f4a5b6c7"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Existing groups are not auto-assigned: historical membership/analytics may
# span instructors, so they remain admin-managed until explicitly migrated.
op.add_column("groups", sa.Column("owner_login", sa.String(length=80), nullable=True))
def downgrade() -> None:
op.drop_column("groups", "owner_login")

View file

@ -8,7 +8,7 @@
from datetime import datetime
from uuid import UUID, uuid4
from sqlalchemy import DateTime, ForeignKey, Index, String, Text, UniqueConstraint, func
from sqlalchemy import DateTime, ForeignKey, Index, LargeBinary, String, Text, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
@ -26,6 +26,8 @@ class Group(Base):
id: Mapped[UUID] = _uuid_pk()
name: Mapped[str] = mapped_column(String(120), unique=True)
# NULL — legacy/admin-managed group; instructor-created groups are private.
owner_login: Mapped[str | None] = mapped_column(String(80), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
trainees: Mapped[list["Trainee"]] = relationship(back_populates="group")
@ -43,6 +45,52 @@ class Trainee(Base):
sessions: Mapped[list["Session"]] = relationship(back_populates="trainee")
class LearningMaterial(Base):
"""Локальный учебный материал или загруженный преподавателем ресурс."""
__tablename__ = "learning_materials"
id: Mapped[UUID] = _uuid_pk()
title: Mapped[str] = mapped_column(String(200))
description: Mapped[str] = mapped_column(Text, default="")
level: Mapped[str] = mapped_column(String(4), default="L1")
kind: Mapped[str] = mapped_column(String(16), default="text")
body: Mapped[str] = mapped_column(Text, default="")
scenario_id: Mapped[str | None] = mapped_column(
ForeignKey("scenarios.id", ondelete="SET NULL"), nullable=True
)
file_name: Mapped[str | None] = mapped_column(String(240))
media_type: Mapped[str | None] = mapped_column(String(120))
file_data: Mapped[bytes | None] = mapped_column(LargeBinary)
file_sha256: Mapped[str | None] = mapped_column(String(64))
active: Mapped[bool] = mapped_column(default=True)
created_by: Mapped[str] = mapped_column(String(80))
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 MaterialAssignment(Base):
"""Назначение материала курсанту и факт изучения."""
__tablename__ = "material_assignments"
id: Mapped[UUID] = _uuid_pk()
material_id: Mapped[UUID] = mapped_column(
ForeignKey("learning_materials.id", ondelete="CASCADE")
)
trainee_id: Mapped[UUID] = mapped_column(ForeignKey("trainees.id", ondelete="CASCADE"))
assigned_by: Mapped[str] = mapped_column(String(80))
assigned_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
__table_args__ = (
UniqueConstraint("material_id", "trainee_id", name="uq_material_assignment"),
Index("ix_material_assignments_trainee", "trainee_id", "assigned_at"),
)
class Scenario(Base):
"""Сценарий целиком лежит в `body`: библиотека — контент, а не схема.
Отдельными колонками вынесено только то, по чему идёт выборка в списке."""
@ -56,6 +104,9 @@ class Scenario(Base):
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")
# У черновика и утверждённого авторского сценария ровно один владелец;
# NULL означает базовую/унаследованную системную библиотеку.
owner_login: Mapped[str | None] = mapped_column(String(80), nullable=True)
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(
@ -74,6 +125,7 @@ class Session(Base):
id: Mapped[UUID] = _uuid_pk()
scenario_id: Mapped[str] = mapped_column(ForeignKey("scenarios.id", ondelete="RESTRICT"))
owner_login: Mapped[str | None] = mapped_column(String(80), nullable=True)
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))
@ -82,6 +134,10 @@ class Session(Base):
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)
# Последний подтверждённый снимок незавершённого занятия. Он очищается при
# завершении и позволяет новому backend-процессу продолжить карточку/ДДС.
live_state: Mapped[dict | None] = mapped_column(JSONB)
checkpoint_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
trainee: Mapped["Trainee | None"] = relationship(back_populates="sessions")
@ -214,6 +270,9 @@ class User(Base):
service: Mapped[str | None] = mapped_column(String(120))
trainee_id: Mapped[UUID | None] = mapped_column(ForeignKey("trainees.id", ondelete="SET NULL"))
blocked: Mapped[bool] = mapped_column(default=False)
# Версия полномочий попадает в подписанную cookie. Смена роли, пароля или
# блокировки увеличивает её и отзывает старые cookie даже после restart.
auth_version: Mapped[int] = mapped_column(default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

View file

@ -30,10 +30,12 @@ async def create_session(
trainee_id: UUID | None = None,
group_id: UUID | None = None,
session_id: UUID | None = None,
owner_login: str | None = None,
) -> Session:
session = Session(
scenario_id=scenario_id,
mode=mode,
owner_login=owner_login,
trainee_id=trainee_id,
group_id=group_id,
attempt=await next_attempt(db, trainee_id, scenario_id),
@ -54,6 +56,7 @@ async def ensure_session(
trainee_name: str | None = None,
trainee_id: UUID | None = None,
group_name: str | None = None,
owner_login: str | None = None,
) -> Session:
"""Занятие, запущенное с пульта, должно иметь строку в журнале.
@ -62,9 +65,11 @@ async def ensure_session(
"""
existing = await db.get(Session, session_id)
if existing is not None:
if existing.owner_login != owner_login:
raise PermissionError("занятие принадлежит другому преподавателю")
return existing
group = await ensure_group(db, group_name) if group_name else None
group = await ensure_group(db, group_name, owner_login=owner_login) if group_name else None
trainee = await db.get(Trainee, trainee_id) if trainee_id else None
if trainee_id and trainee is None:
raise ValueError(f"курсант {trainee_id} не найден")
@ -77,6 +82,7 @@ async def ensure_session(
trainee_id=trainee.id if trainee else None,
group_id=group.id if group else trainee.group_id if trainee else None,
session_id=session_id,
owner_login=owner_login,
)
@ -137,6 +143,7 @@ async def history(
trainee_id: UUID | None = None,
group_id: UUID | None = None,
mode: str | None = None,
owner_login: str | None = None,
since: datetime | None = None,
limit: int = 100,
) -> list[Session]:
@ -149,17 +156,23 @@ async def history(
query = query.where(Session.group_id == group_id)
if mode is not None:
query = query.where(Session.mode == mode)
if owner_login is not None:
query = query.where(Session.owner_login == owner_login)
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:
async def ensure_group(
db: AsyncSession, name: str, *, owner_login: str | None = None
) -> Group:
group = await db.scalar(select(Group).where(Group.name == name))
if group is None:
group = Group(name=name)
group = Group(name=name, owner_login=owner_login)
db.add(group)
await db.commit()
elif group.owner_login != owner_login:
raise PermissionError("группа принадлежит другому преподавателю или администратору")
return group