249 lines
11 KiB
Python
249 lines
11 KiB
Python
|
|
"""CMS 训练记录查询 + 带教能力画像 / 排行榜(只读,fastapi 属主表 training_record)。
|
|||
|
|
|
|||
|
|
- CMS-TRN-1 超管训练记录列表:`GET /api/cms/training-records/`(全平台,`IsSuperAdmin`)。
|
|||
|
|
- CMS-TEA-3 带教教学工具训练记录:`GET /api/cms/students/training-records/`
|
|||
|
|
(名下学生,由 `CmsStudentViewSet` 的 action 复用 `build_training_records`)。
|
|||
|
|
- CMS-TEA-4 带教能力画像:`GET /api/cms/students/{id}/competency/`(复用移动端智能分析 5 维口径)。
|
|||
|
|
- CMS-TEA-5 带教排行榜:`GET /api/cms/students/ranking/`(名下学生多维度排名)。
|
|||
|
|
|
|||
|
|
得分按 `score_type` 归一(five_point×20);雷达/得分率走 `ai_feedback_structured.dimension_scores`
|
|||
|
|
(带 max_score)。全程只读,不写训练表。
|
|||
|
|
"""
|
|||
|
|
from collections import defaultdict
|
|||
|
|
from datetime import datetime, timedelta
|
|||
|
|
|
|||
|
|
from django.db.models import Q, Avg, Sum
|
|||
|
|
from django.utils import timezone
|
|||
|
|
from rest_framework.decorators import api_view, permission_classes
|
|||
|
|
from rest_framework.permissions import IsAuthenticated
|
|||
|
|
from rest_framework.pagination import PageNumberPagination
|
|||
|
|
from rest_framework.response import Response
|
|||
|
|
|
|||
|
|
from apps.training.models import TrainingRecord
|
|||
|
|
from apps.user.models import User
|
|||
|
|
from apps.case.models import CaseBase
|
|||
|
|
from apps.user.stats import (
|
|||
|
|
STANDARD_DIMS, DIMENSION_MAP, _dimension_scores, _diagnosis_accuracy,
|
|||
|
|
_norm_total, _NORM_TOTAL_EXPR,
|
|||
|
|
)
|
|||
|
|
from apps.cms.permissions import IsSuperAdmin
|
|||
|
|
|
|||
|
|
# 排行榜支持的维度:分数 / 训练次数 / 完成数 + 固定 5 个能力维度(得分率)
|
|||
|
|
RANK_BASE_DIMS = ('score', 'train_count', 'completed')
|
|||
|
|
RANK_DIMS = RANK_BASE_DIMS + tuple(STANDARD_DIMS)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _is_number(s):
|
|||
|
|
try:
|
|||
|
|
float(s)
|
|||
|
|
return True
|
|||
|
|
except (TypeError, ValueError):
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _parse_date(s):
|
|||
|
|
"""'YYYY-MM-DD' / 'YYYY/MM/DD' → aware datetime(当天 00:00)或 None。"""
|
|||
|
|
s = (s or '').strip()
|
|||
|
|
for fmt in ('%Y-%m-%d', '%Y/%m/%d'):
|
|||
|
|
try:
|
|||
|
|
return timezone.make_aware(datetime.strptime(s, fmt))
|
|||
|
|
except ValueError:
|
|||
|
|
continue
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ── 训练记录列表(CMS-TRN-1 / CMS-TEA-3 共用)──────────────────────────────────
|
|||
|
|
|
|||
|
|
def _filter_records(base, request):
|
|||
|
|
p = request.query_params
|
|||
|
|
search = (p.get('search') or '').strip()
|
|||
|
|
if search:
|
|||
|
|
base = base.filter(
|
|||
|
|
Q(case__title__icontains=search)
|
|||
|
|
| Q(user__real_name__icontains=search)
|
|||
|
|
| Q(user__phone__icontains=search)
|
|||
|
|
)
|
|||
|
|
for key, field in (('user_id', 'user_id'), ('case_id', 'case_id')):
|
|||
|
|
v = (p.get(key) or '').strip()
|
|||
|
|
if v.isdigit():
|
|||
|
|
base = base.filter(**{field: int(v)})
|
|||
|
|
status = (p.get('status') or '').strip()
|
|||
|
|
if status:
|
|||
|
|
base = base.filter(status=status)
|
|||
|
|
for f in ('training_mode', 'case_type'):
|
|||
|
|
v = (p.get(f) or '').strip()
|
|||
|
|
if v:
|
|||
|
|
base = base.filter(**{f: v})
|
|||
|
|
inst = (p.get('institution') or '').strip()
|
|||
|
|
if inst.isdigit():
|
|||
|
|
base = base.filter(user__institution_id=int(inst))
|
|||
|
|
d0, d1 = _parse_date(p.get('start_date')), _parse_date(p.get('end_date'))
|
|||
|
|
if d0:
|
|||
|
|
base = base.filter(created_at__gte=d0)
|
|||
|
|
if d1:
|
|||
|
|
base = base.filter(created_at__lt=d1 + timedelta(days=1))
|
|||
|
|
# 归一分数范围过滤
|
|||
|
|
min_s, max_s = (p.get('min_score') or '').strip(), (p.get('max_score') or '').strip()
|
|||
|
|
if _is_number(min_s) or _is_number(max_s):
|
|||
|
|
base = base.annotate(_norm=_NORM_TOTAL_EXPR)
|
|||
|
|
if _is_number(min_s):
|
|||
|
|
base = base.filter(_norm__gte=float(min_s))
|
|||
|
|
if _is_number(max_s):
|
|||
|
|
base = base.filter(_norm__lte=float(max_s))
|
|||
|
|
return base
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _serialize_record(r, users, cases):
|
|||
|
|
"""users/cases 为按 id 预取的字典(避免对 fastapi 表的非空 FK 做 INNER JOIN
|
|||
|
|
丢掉宿主系统外部用户/孤立病例的记录)。"""
|
|||
|
|
user = users.get(r.user_id)
|
|||
|
|
case = cases.get(r.case_id)
|
|||
|
|
dept = case.department if (case and case.department_id) else None
|
|||
|
|
return {
|
|||
|
|
'record_id': r.id,
|
|||
|
|
'user_id': r.user_id,
|
|||
|
|
'user_name': user.real_name if user else '',
|
|||
|
|
'institution': user.institution_id if user else None,
|
|||
|
|
'institution_name': (user.institution.name if (user and user.institution_id) else ''),
|
|||
|
|
'case_id': r.case_id,
|
|||
|
|
'case_title': case.title if case else '',
|
|||
|
|
'department': dept.name if dept else '',
|
|||
|
|
'training_mode': r.training_mode,
|
|||
|
|
'case_type': r.case_type,
|
|||
|
|
'status': r.status,
|
|||
|
|
'score': float(r.total_score) if r.total_score is not None else None,
|
|||
|
|
'score_type': r.score_type,
|
|||
|
|
'score_normalized': (round(_norm_total(r.total_score, r.score_type), 1)
|
|||
|
|
if r.total_score is not None else None),
|
|||
|
|
'evaluation_level': r.evaluation_level,
|
|||
|
|
'trained_at': r.end_time or r.created_at,
|
|||
|
|
'duration_seconds': r.duration_seconds,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def build_training_records(base, request):
|
|||
|
|
"""对给定 base 查询集应用过滤 + 分页 + 汇总,返回分页 Response。
|
|||
|
|
|
|||
|
|
超管传 `TrainingRecord.objects.all()`;带教传名下学生范围的查询集。
|
|||
|
|
⚠️ 不用 select_related(user/case 在 fastapi 表里是非空 FK,INNER JOIN 会丢掉
|
|||
|
|
宿主系统外部用户/本地缺失病例的记录,导致 count 与 results 不一致);改为按 id 批量预取。
|
|||
|
|
"""
|
|||
|
|
base = _filter_records(base, request)
|
|||
|
|
avg = base.filter(status='completed').aggregate(a=Avg(_NORM_TOTAL_EXPR))['a']
|
|||
|
|
summary = {
|
|||
|
|
'total': base.count(),
|
|||
|
|
'completed': base.filter(status='completed').count(),
|
|||
|
|
'avg_score': round(float(avg), 1) if avg is not None else None,
|
|||
|
|
}
|
|||
|
|
paginator = PageNumberPagination()
|
|||
|
|
page = paginator.paginate_queryset(base.order_by('-end_time', '-id'), request)
|
|||
|
|
users = User.all_objects.filter(id__in={r.user_id for r in page}).select_related('institution')
|
|||
|
|
cases = CaseBase.all_objects.filter(id__in={r.case_id for r in page}).select_related('department')
|
|||
|
|
users = {u.id: u for u in users}
|
|||
|
|
cases = {c.id: c for c in cases}
|
|||
|
|
resp = paginator.get_paginated_response([_serialize_record(r, users, cases) for r in page])
|
|||
|
|
resp.data['summary'] = summary
|
|||
|
|
return resp
|
|||
|
|
|
|||
|
|
|
|||
|
|
@api_view(['GET'])
|
|||
|
|
@permission_classes([IsAuthenticated, IsSuperAdmin])
|
|||
|
|
def training_records(request):
|
|||
|
|
"""CMS-TRN-1 超管训练记录列表(全平台只读)。"""
|
|||
|
|
return build_training_records(TrainingRecord.objects.all(), request)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ── 能力画像(CMS-TEA-4)──────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
def _radar_buckets(records):
|
|||
|
|
"""各标准维度得分率列表。返回 {标准维度: [得分率,...]}。"""
|
|||
|
|
buckets = defaultdict(list)
|
|||
|
|
for r in records:
|
|||
|
|
for dim, score, mx in _dimension_scores(r):
|
|||
|
|
std = DIMENSION_MAP.get(dim)
|
|||
|
|
if std and score is not None and mx and float(mx) > 0:
|
|||
|
|
buckets[std].append(float(score) / float(mx) * 100)
|
|||
|
|
return buckets
|
|||
|
|
|
|||
|
|
|
|||
|
|
def student_competency(student):
|
|||
|
|
"""单个学生的能力画像(复用移动端「智能分析」固定 5 维口径)。"""
|
|||
|
|
qs = TrainingRecord.objects.filter(user_id=student.id, status='completed')
|
|||
|
|
recs = list(qs.only('total_score', 'score_type', 'duration_seconds', 'ai_feedback_structured'))
|
|||
|
|
agg = qs.aggregate(total=Sum('duration_seconds'), avg=Avg(_NORM_TOTAL_EXPR))
|
|||
|
|
avg = agg['avg']
|
|||
|
|
|
|||
|
|
buckets = _radar_buckets(recs)
|
|||
|
|
radar = [{'dimension': d, 'score': (round(sum(buckets[d]) / len(buckets[d])) if buckets[d] else 0)}
|
|||
|
|
for d in STANDARD_DIMS]
|
|||
|
|
ordered = sorted((x for x in radar if buckets[x['dimension']]), key=lambda x: x['score'])
|
|||
|
|
weak = [ordered[0]['dimension']] if ordered else []
|
|||
|
|
comment = ''
|
|||
|
|
if len(ordered) >= 2 and ordered[0]['score'] != ordered[-1]['score']:
|
|||
|
|
comment = (f"该学生{ordered[-1]['dimension']}表现突出,但{ordered[0]['dimension']}"
|
|||
|
|
f"仍有提升空间,建议加强相关训练。")
|
|||
|
|
elif ordered:
|
|||
|
|
comment = f"各维度表现较为均衡,可针对{weak[0]}做进一步强化。"
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
'student_id': student.id,
|
|||
|
|
'real_name': student.real_name,
|
|||
|
|
'completed_cases': qs.count(),
|
|||
|
|
'total_hours': round((agg['total'] or 0) / 3600, 1),
|
|||
|
|
'avg_score': round(float(avg), 1) if avg is not None else 0,
|
|||
|
|
'diagnosis_accuracy': _diagnosis_accuracy(recs),
|
|||
|
|
'radar': radar,
|
|||
|
|
'weak_dimensions': weak,
|
|||
|
|
'comment': comment,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ── 排行榜(CMS-TEA-5)────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
def build_ranking(students, dimension):
|
|||
|
|
"""名下学生按指定维度排名。
|
|||
|
|
|
|||
|
|
dimension ∈ {score(默认), train_count, completed} ∪ STANDARD_DIMS(各维度得分率)。
|
|||
|
|
无数据的学生 value=None,排在末尾。
|
|||
|
|
"""
|
|||
|
|
dimension = (dimension or 'score').strip()
|
|||
|
|
if dimension not in RANK_DIMS:
|
|||
|
|
dimension = 'score'
|
|||
|
|
|
|||
|
|
info = {u.id: (u.real_name, (u.department.name if u.department_id else '')) for u in students}
|
|||
|
|
ids = list(info)
|
|||
|
|
metrics = {i: {'train_count': 0, 'completed': 0, 'score_sum': 0.0, 'score_n': 0,
|
|||
|
|
'dim': defaultdict(list)} for i in ids}
|
|||
|
|
for r in (TrainingRecord.objects.filter(user_id__in=ids)
|
|||
|
|
.only('user_id', 'status', 'total_score', 'score_type', 'ai_feedback_structured')):
|
|||
|
|
m = metrics[r.user_id]
|
|||
|
|
m['train_count'] += 1
|
|||
|
|
if r.status == 'completed':
|
|||
|
|
m['completed'] += 1
|
|||
|
|
n = _norm_total(r.total_score, r.score_type)
|
|||
|
|
if n is not None:
|
|||
|
|
m['score_sum'] += n
|
|||
|
|
m['score_n'] += 1
|
|||
|
|
for dim, score, mx in _dimension_scores(r):
|
|||
|
|
std = DIMENSION_MAP.get(dim)
|
|||
|
|
if std and score is not None and mx and float(mx) > 0:
|
|||
|
|
m['dim'][std].append(float(score) / float(mx) * 100)
|
|||
|
|
|
|||
|
|
def value_of(i):
|
|||
|
|
m = metrics[i]
|
|||
|
|
if dimension == 'train_count':
|
|||
|
|
return m['train_count']
|
|||
|
|
if dimension == 'completed':
|
|||
|
|
return m['completed']
|
|||
|
|
if dimension in STANDARD_DIMS:
|
|||
|
|
vals = m['dim'].get(dimension)
|
|||
|
|
return round(sum(vals) / len(vals), 1) if vals else None
|
|||
|
|
return round(m['score_sum'] / m['score_n'], 1) if m['score_n'] else None
|
|||
|
|
|
|||
|
|
rows = [{'student_id': i, 'real_name': info[i][0], 'department': info[i][1],
|
|||
|
|
'value': value_of(i), 'train_count': metrics[i]['train_count']} for i in ids]
|
|||
|
|
rows.sort(key=lambda x: (x['value'] is None, -(x['value'] or 0)))
|
|||
|
|
for idx, row in enumerate(rows, 1):
|
|||
|
|
row['rank'] = idx
|
|||
|
|
return {'dimension': dimension, 'count': len(rows), 'ranking': rows}
|