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
|
|
@ -10,9 +10,11 @@ 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.db.models import Group, Score, Session, Trainee, User
|
||||
from app.domain.roles import Role
|
||||
from app.scoring.group import ScoredAttempt, summarize
|
||||
from app.scoring.group_insight import InsightInvalid, generate_group_insight
|
||||
from app.dialog.llm import LlmUnavailable
|
||||
|
||||
router = APIRouter(prefix="/api/groups", tags=["groups"])
|
||||
|
||||
|
|
@ -26,6 +28,10 @@ class GroupCreate(BaseModel):
|
|||
name: str = Field(min_length=1, max_length=120)
|
||||
|
||||
|
||||
class GroupOwnerPatch(BaseModel):
|
||||
owner_login: str | None
|
||||
|
||||
|
||||
class GroupErrorOut(BaseModel):
|
||||
code: str
|
||||
title: str
|
||||
|
|
@ -44,62 +50,23 @@ class GroupAnalyticsOut(BaseModel):
|
|||
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]
|
||||
class GroupInsightOut(BaseModel):
|
||||
summary: str
|
||||
priorities: list[str]
|
||||
source: str = "local_qwen"
|
||||
personal_data_sent: bool = False
|
||||
|
||||
|
||||
@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)
|
||||
async def _analytics(
|
||||
group_id: UUID, db: AsyncSession, *, owner_login: str | None = None
|
||||
) -> GroupAnalyticsOut:
|
||||
require(request, Role.INSTRUCTOR, Role.ADMIN)
|
||||
group = await db.get(Group, group_id)
|
||||
if group is None:
|
||||
if group is None or (owner_login is not None and group.owner_login != owner_login):
|
||||
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(
|
||||
attempts_query = (
|
||||
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)
|
||||
|
|
@ -111,6 +78,9 @@ async def analytics(
|
|||
Session.ended_at.is_not(None),
|
||||
)
|
||||
)
|
||||
if owner_login is not None:
|
||||
attempts_query = attempts_query.where(Session.owner_login == owner_login)
|
||||
rows = await db.execute(attempts_query)
|
||||
attempts = [
|
||||
ScoredAttempt(
|
||||
trainee_id=trainee_id,
|
||||
|
|
@ -123,3 +93,116 @@ async def analytics(
|
|||
group=GroupOut(id=group.id, name=group.name),
|
||||
**summarize(attempts, int(enrolled or 0)),
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[GroupOut])
|
||||
async def listing(request: Request, db: AsyncSession = Depends(get_session)) -> list[GroupOut]:
|
||||
who = require(request, Role.INSTRUCTOR, Role.ADMIN)
|
||||
query = select(Group).order_by(Group.name)
|
||||
if who.role is Role.INSTRUCTOR:
|
||||
query = query.where(Group.owner_login == who.login)
|
||||
groups = await db.scalars(query)
|
||||
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, owner_login=who.login if who.role is Role.INSTRUCTOR else None)
|
||||
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.patch("/{group_id}/owner", response_model=GroupOut)
|
||||
async def transfer_ownership(
|
||||
group_id: UUID,
|
||||
body: GroupOwnerPatch,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_session),
|
||||
) -> GroupOut:
|
||||
"""Администратор безопасно закрепляет legacy-группу за преподавателем."""
|
||||
who = require(request, Role.ADMIN)
|
||||
group = await db.get(Group, group_id)
|
||||
if group is None:
|
||||
raise HTTPException(status_code=404, detail="group_not_found")
|
||||
if body.owner_login is not None:
|
||||
user = await db.scalar(select(User).where(User.login == body.owner_login))
|
||||
if user is None or user.role != Role.INSTRUCTOR.value or user.blocked:
|
||||
raise HTTPException(status_code=422, detail="active_instructor_required")
|
||||
previous_owner = group.owner_login
|
||||
group.owner_login = body.owner_login
|
||||
await db.commit()
|
||||
await audit(
|
||||
who.login,
|
||||
who.role.value,
|
||||
"group.transfer",
|
||||
str(group.id),
|
||||
f"{previous_owner or 'admin'} -> {body.owner_login or 'admin'}",
|
||||
)
|
||||
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 or (who.role is Role.INSTRUCTOR and group.owner_login != who.login):
|
||||
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")
|
||||
if (
|
||||
who.role is Role.INSTRUCTOR
|
||||
and trainee.group_id is not None
|
||||
and trainee.group_id != group_id
|
||||
):
|
||||
current_group = await db.get(Group, trainee.group_id)
|
||||
if current_group is None or current_group.owner_login != who.login:
|
||||
raise HTTPException(status_code=409, detail="trainee_in_other_instructor_group")
|
||||
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:
|
||||
who = require(request, Role.INSTRUCTOR, Role.ADMIN)
|
||||
return await _analytics(
|
||||
group_id, db, owner_login=who.login if who.role is Role.INSTRUCTOR else None
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{group_id}/analytics/insight", response_model=GroupInsightOut)
|
||||
async def ai_insight(
|
||||
group_id: UUID, request: Request, db: AsyncSession = Depends(get_session)
|
||||
) -> GroupInsightOut:
|
||||
"""Сформировать по запросу преподавателя локальный обезличенный инсайт."""
|
||||
who = require(request, Role.INSTRUCTOR)
|
||||
data = await _analytics(group_id, db, owner_login=who.login)
|
||||
if data.scored_attempts == 0:
|
||||
raise HTTPException(status_code=409, detail="no_scored_attempts")
|
||||
try:
|
||||
insight = await generate_group_insight(data.model_dump(mode="json"))
|
||||
except LlmUnavailable as exc:
|
||||
raise HTTPException(status_code=503, detail=f"локальная модель недоступна: {exc}") from exc
|
||||
except InsightInvalid as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
await audit(who.login, who.role.value, "group.ai_insight", str(group_id), "aggregated_only")
|
||||
return GroupInsightOut(**insight)
|
||||
|
|
|
|||
Loading…
Reference in a new issue