Implement DDS exercise, customer UI and local demo

This commit is contained in:
andreysk0304 2026-09-21 19:56:13 +03:00
commit 0526b11f91
46 changed files with 2504 additions and 253 deletions

View file

@ -10,6 +10,7 @@
"""
import logging
import re
from datetime import datetime
from uuid import UUID
@ -18,9 +19,10 @@ from pydantic import BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.concurrency import run_in_threadpool
from app.admin import backup as backup_service
from app.api.auth import audit, hash_password, require
from app.api.auth import audit, hash_password, invalidate_login, require
from app.config import get_settings
from app.db.base import get_session
from app.db.models import AuditLog, Session as SessionRow, Trainee, User
@ -122,6 +124,13 @@ async def patch_user(
changed: list[str] = []
if body.role is not None:
if user.login == who.login and body.role is not Role.ADMIN:
raise HTTPException(status_code=409, detail="cannot_demote_yourself")
if body.role is Role.TRAINEE and user.trainee_id is None:
trainee = Trainee(name=user.full_name)
db.add(trainee)
await db.flush()
user.trainee_id = trainee.id
user.role = body.role.value
changed.append(f"роль {body.role.value}")
if body.service is not None:
@ -137,7 +146,10 @@ async def patch_user(
user.password_hash = hash_password(body.password)
changed.append("пароль сброшен")
if not changed:
return _out(user)
await db.commit()
invalidate_login(user.login)
await audit(who.login, who.role.value, "user.update", user.login, ", ".join(changed))
return _out(user)
@ -162,7 +174,7 @@ async def audit_log(
"""Журнал действий. Администратор его читает, но не правит: точки удаления
или изменения записи здесь нет — ТЗ требует хранения, а не управления."""
require(request, Role.ADMIN)
query = select(AuditLog).order_by(AuditLog.at.desc()).limit(min(limit, 1000))
query = select(AuditLog).order_by(AuditLog.at.desc()).limit(max(1, min(limit, 1000)))
if action:
query = query.where(AuditLog.action == action)
if actor:
@ -261,7 +273,19 @@ class BackupOut(BaseModel):
@router.get("/backups", response_model=list[BackupOut])
async def backups(request: Request) -> list[BackupOut]:
require(request, Role.ADMIN)
return [BackupOut(**item) for item in backup_service.listing()]
return [BackupOut(**item) for item in await run_in_threadpool(backup_service.listing)]
def _safe_backup_error(exc: backup_service.BackupError) -> str:
"""Never echo DATABASE_URL/its password from backup diagnostics to HTTP."""
message = str(exc)
dsn = get_settings().database_url
if dsn:
message = message.replace(dsn, "[DATABASE_URL скрыт]")
match = re.search(r"://[^:]+:([^@]+)@", dsn)
if match and match.group(1):
message = message.replace(match.group(1), "[пароль скрыт]")
return message
@router.post("/backups", response_model=BackupOut, status_code=201)
@ -270,8 +294,11 @@ async def make_backup(request: Request) -> BackupOut:
кнопка нужна перед занятием, расписание — чтобы о нём не вспоминали."""
who = require(request, Role.ADMIN)
try:
created = backup_service.create()
# pg_dump may run for two minutes; never block the event loop for it.
created = await run_in_threadpool(backup_service.create)
except backup_service.BackupError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
detail = _safe_backup_error(exc)
await audit(who.login, who.role.value, "backup.failed", detail=detail)
raise HTTPException(status_code=503, detail=detail) from exc
await audit(who.login, who.role.value, "backup.create", created["name"])
return BackupOut(**created)

View file

@ -0,0 +1,125 @@
"""Сводка ошибок и рекомендаций учебной группы для преподавателя."""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from sqlalchemy import and_, func, or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.auth import audit, require
from app.db.base import get_session
from app.db.models import Group, Score, Session, Trainee
from app.domain.roles import Role
from app.scoring.group import ScoredAttempt, summarize
router = APIRouter(prefix="/api/groups", tags=["groups"])
class GroupOut(BaseModel):
id: UUID
name: str
class GroupCreate(BaseModel):
name: str = Field(min_length=1, max_length=120)
class GroupErrorOut(BaseModel):
code: str
title: str
affected_trainees: int
occurrences: int
rate_percent: float
recommendation: str
class GroupAnalyticsOut(BaseModel):
group: GroupOut
enrolled_trainees: int
active_trainees: int
scored_attempts: int
average_score: float | None
errors: list[GroupErrorOut]
@router.get("", response_model=list[GroupOut])
async def listing(request: Request, db: AsyncSession = Depends(get_session)) -> list[GroupOut]:
require(request, Role.INSTRUCTOR, Role.ADMIN)
groups = await db.scalars(select(Group).order_by(Group.name))
return [GroupOut(id=group.id, name=group.name) for group in groups]
@router.post("", response_model=GroupOut, status_code=201)
async def create(
body: GroupCreate, request: Request, db: AsyncSession = Depends(get_session)
) -> GroupOut:
who = require(request, Role.INSTRUCTOR, Role.ADMIN)
name = body.name.strip()
if not name:
raise HTTPException(status_code=422, detail="group_name_required")
group = Group(name=name)
db.add(group)
try:
await db.commit()
except IntegrityError as exc:
await db.rollback()
raise HTTPException(status_code=409, detail="group_exists") from exc
await audit(who.login, who.role.value, "group.create", str(group.id), group.name)
return GroupOut(id=group.id, name=group.name)
@router.put("/{group_id}/trainees/{trainee_id}", response_model=GroupOut)
async def assign_trainee(
group_id: UUID, trainee_id: UUID, request: Request,
db: AsyncSession = Depends(get_session),
) -> GroupOut:
who = require(request, Role.INSTRUCTOR, Role.ADMIN)
group = await db.get(Group, group_id)
if group is None:
raise HTTPException(status_code=404, detail="group_not_found")
trainee = await db.get(Trainee, trainee_id)
if trainee is None:
raise HTTPException(status_code=404, detail="trainee_not_found")
trainee.group_id = group_id
await db.commit()
await audit(who.login, who.role.value, "group.assign", str(group.id), str(trainee_id))
return GroupOut(id=group.id, name=group.name)
@router.get("/{group_id}/analytics", response_model=GroupAnalyticsOut)
async def analytics(
group_id: UUID, request: Request, db: AsyncSession = Depends(get_session)
) -> GroupAnalyticsOut:
require(request, Role.INSTRUCTOR, Role.ADMIN)
group = await db.get(Group, group_id)
if group is None:
raise HTTPException(status_code=404, detail="group_not_found")
enrolled = await db.scalar(
select(func.count()).select_from(Trainee).where(Trainee.group_id == group_id)
)
rows = await db.execute(
select(Session.trainee_id, Score.score_final, Score.report)
.join(Score, Score.session_id == Session.id)
.join(Trainee, Trainee.id == Session.trainee_id, isouter=True)
.where(
or_(
Session.group_id == group_id,
and_(Session.group_id.is_(None), Trainee.group_id == group_id),
),
Session.ended_at.is_not(None),
)
)
attempts = [
ScoredAttempt(
trainee_id=trainee_id,
score=score,
codes=(report or {}).get("summary", {}).get("codes", {}),
)
for trainee_id, score, report in rows
]
return GroupAnalyticsOut(
group=GroupOut(id=group.id, name=group.name),
**summarize(attempts, int(enrolled or 0)),
)

View file

@ -8,7 +8,7 @@ from datetime import datetime
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from pydantic import BaseModel
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.auth import audit, require
@ -117,9 +117,8 @@ async def checklist(session_id: UUID, request: Request) -> list[ChecklistItemOut
class ScoreOverride(BaseModel):
"""Коррекция оценки преподавателем. Автооценка сохраняется рядом."""
score_final: float
score_final: float = Field(ge=0, le=100)
comment: str = ""
author: str = "преподаватель"
def _live(session_id: UUID):
@ -188,9 +187,13 @@ async def override(session_id: UUID, body: ScoreOverride, request: Request) -> S
state.score = {
**state.score,
"score_final": body.score_final,
"overridden_by": body.author,
"overridden_by": who.login,
"override_comment": body.comment,
}
if hub.journal:
await hub.journal.score_override(
session_id, body.score_final, who.login, body.comment
)
await audit(
who.login, who.role.value, "score.override", str(session_id),
f"{state.score.get('score_auto')} → {body.score_final}: {body.comment}",

View file

@ -13,9 +13,10 @@ from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.auth import require
from app.api.auth import DEMO_TRAINEE_ID, require
from app.config import get_settings
from app.domain.roles import Role
from app.db.base import get_session
from app.db.base import get_session, get_sessionmaker
from app.db.models import Group, Score, Session, Trainee
router = APIRouter(prefix="/api/trainees", tags=["trainees"])
@ -61,14 +62,17 @@ class ProfileOut(BaseModel):
@router.get("", response_model=list[TraineeOut])
async def listing(request: Request, db: AsyncSession = Depends(get_session)) -> list[TraineeOut]:
async def listing(request: Request) -> list[TraineeOut]:
"""Список курсантов — преподавателю и администратору: обучающемуся он
не нужен, а чужие фамилии из него видны."""
require(request, Role.INSTRUCTOR, Role.ADMIN)
rows = await db.execute(
select(Trainee, Group.name).join(Group, Group.id == Trainee.group_id, isouter=True)
)
return [TraineeOut(id=trainee.id, name=trainee.name, group=group) for trainee, group in rows]
if get_settings().demo_no_db:
return [TraineeOut(id=DEMO_TRAINEE_ID, name="Демо-курсант")]
async with get_sessionmaker()() as db:
rows = await db.execute(
select(Trainee, Group.name).join(Group, Group.id == Trainee.group_id, isouter=True)
)
return [TraineeOut(id=trainee.id, name=trainee.name, group=group) for trainee, group in rows]
@router.get("/{trainee_id}/profile", response_model=ProfileOut)