Complete DDS training workflow and delivery package
This commit is contained in:
parent
68dd83c7c2
commit
4c4b91064f
229 changed files with 11969 additions and 1024 deletions
|
|
@ -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")
|
||||
|
|
@ -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")
|
||||
|
|
@ -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")
|
||||
|
|
@ -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")
|
||||
|
|
@ -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")
|
||||
|
|
@ -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())
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue