Implement DDS exercise, customer UI and local demo
This commit is contained in:
parent
cec84ffcd0
commit
0526b11f91
46 changed files with 2504 additions and 253 deletions
|
|
@ -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)
|
||||
|
|
|
|||
125
backend/app/api/http/groups.py
Normal file
125
backend/app/api/http/groups.py
Normal 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)),
|
||||
)
|
||||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue