feat: cms overview and mobile case query
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
"""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}
|
||||
Reference in New Issue
Block a user