"""CMS 各角色概览大屏聚合接口(只读 GET)。 依据《各角色首页数据展示.pdf》与本文档第八章,**全部基于现有库真实列**: - 训练聚合主表 `TrainingRecord`(有 `user_id`→机构/科室、`case_id`、`status`、`total_score`、`ai_feedback_structured`); - `TrainingSession`(仅平台级「发起/完成」,无机构外键);得分按 `score_type` 归一(five_point×20)。 - 维度雷达固定 5 维(信息获取/分析推理/处置决策/沟通人文/临床整合,复用 `apps/user/stats`)。 - 科室为全局表:凡「按科室分组」走 `case_base.department_id`,不按机构过滤科室。 不做项:AI 调用量 / AI 响应时长(超管平台总览)、知识文档数(内容概览)、带教任务/分配任务(无任务表)。 """ from collections import defaultdict from datetime import datetime, timedelta from django.db.models import Count, Sum, Avg, Q from django.utils import timezone from rest_framework.decorators import api_view, permission_classes from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from apps.user.models import User, Institution, Department, TeacherStudentRelation from apps.case.models import CaseBase from apps.training.models import TrainingRecord, TrainingSession from apps.user.stats import STANDARD_DIMS, DIMENSION_MAP, _dimension_scores, _NORM_TOTAL_EXPR from apps.cms.permissions import IsSuperAdmin, IsHospitalAdmin, IsContentAdmin, IsTeacher PASS_SCORE = 60 # 通过线(归一百分制) # ── 通用小工具 ────────────────────────────────────────────────────────────── def _recent_months(n=6): """返回最近 n 个月 (year, month),由旧到新。""" now = timezone.localtime() out, y, m = [], now.year, now.month for _ in range(n): out.append((y, m)) m -= 1 if m == 0: m, y = 12, y - 1 return list(reversed(out)) def _month_label(y, m): return f'{y}-{m:02d}' def _month_start(y, m): return timezone.make_aware(datetime(y, m, 1)) def _norm_avg(qs): """TrainingRecord 查询集 → 归一百分制平均分(保留 1 位)或 None。""" v = qs.aggregate(a=Avg(_NORM_TOTAL_EXPR))['a'] return round(float(v), 1) if v is not None else None def _norm(score, score_type): if score is None: return None return float(score) * 20 if score_type == 'five_point' else float(score) def _mom(this, last): """环比%(本期 vs 上期),上期为 0/None 返回 None。""" if not last: return None return round((this - last) / last * 100) def _hours(seconds): return round((seconds or 0) / 3600, 1) def _radar(records): """固定 5 维雷达:各维得分率均值(0~100),无数据维度记 0。""" 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 [{'dimension': d, 'score': (round(sum(buckets[d]) / len(buckets[d])) if buckets[d] else 0)} for d in STANDARD_DIMS] def _inst_names(ids): return {i.id: i.name for i in Institution.all_objects.filter(id__in=[x for x in ids if x])} def _dept_names(ids): return {d.id: d.name for d in Department.all_objects.filter(id__in=[x for x in ids if x])} def _case_titles(ids): return {c.id: c.title for c in CaseBase.all_objects.filter(id__in=[x for x in ids if x])} def _pass_rates(tr_qs): """按 case 统计通过率(归一分≥60)。返回 [{case_id, pass_rate, total}](小数据集 Python 聚合)。""" agg = defaultdict(lambda: [0, 0]) # case_id -> [passed, total] for r in tr_qs.only('case_id', 'total_score', 'score_type'): n = _norm(r.total_score, r.score_type) if n is None: continue agg[r.case_id][1] += 1 if n >= PASS_SCORE: agg[r.case_id][0] += 1 out = [{'case_id': cid, 'pass_rate': round(p / t * 100) if t else 0, 'total': t} for cid, (p, t) in agg.items()] return out # 注:MySQL 未装时区表,DB 侧 TruncMonth/ExtractHour(CONVERT_TZ)会报错; # 故月/小时分桶一律在 Python 用 localtime 完成(与 apps/user/stats 一致)。 def _tr_monthly(qs, n=6): """TrainingRecord 月度:返回 (labels, counts, active_users)。Python 分桶。""" months = _recent_months(n) idx = {ym: i for i, ym in enumerate(months)} counts = [0] * n users = [set() for _ in range(n)] for created_at, uid in qs.filter(created_at__gte=_month_start(*months[0])).values_list('created_at', 'user_id'): if not created_at: continue lt = timezone.localtime(created_at) i = idx.get((lt.year, lt.month)) if i is not None: counts[i] += 1 users[i].add(uid) return [_month_label(*ym) for ym in months], counts, [len(s) for s in users] def _ts_monthly(n=6): """TrainingSession 月度发起次数:返回 (labels, counts)。Python 分桶。""" months = _recent_months(n) idx = {ym: i for i, ym in enumerate(months)} counts = [0] * n for (created_at,) in (TrainingSession.objects.filter(created_at__gte=_month_start(*months[0])) .values_list('created_at')): if not created_at: continue lt = timezone.localtime(created_at) i = idx.get((lt.year, lt.month)) if i is not None: counts[i] += 1 return [_month_label(*ym) for ym in months], counts def _named_rank(rows, key, name_map, label='name', value='value'): """把 [{key:id, ...value}] 附上名称。""" return [{**{label: name_map.get(r[key], ''), 'id': r[key]}, value: r.get(value)} for r in rows] # ── 8.1 超级管理员 · 平台总览 ──────────────────────────────────────────────── @api_view(['GET']) @permission_classes([IsAuthenticated, IsSuperAdmin]) def platform_overview(request): now = timezone.now() d30 = now - timedelta(days=30) TR = TrainingRecord.objects.all() TS = TrainingSession.objects.all() ms = _month_start(now.year, now.month) pm_y, pm_m = (now.year, now.month - 1) if now.month > 1 else (now.year - 1, 12) pms = _month_start(pm_y, pm_m) # KPI kpi = { 'institution_count': Institution.objects.count(), 'mau_institution': TR.filter(created_at__gte=d30).exclude(user__institution_id__isnull=True) .values('user__institution_id').distinct().count(), 'user_total': User.objects.filter(status=1).count(), 'mau_user': TR.filter(created_at__gte=d30).values('user_id').distinct().count(), } # 核心指标 sess_total = TS.count() sess_done = TS.filter(completed_at__isnull=False).count() train_this = TS.filter(created_at__gte=ms).count() train_last = TS.filter(created_at__gte=pms, created_at__lt=ms).count() core = { 'train_new_month': train_this, 'train_new_mom': _mom(train_this, train_last), 'train_total': sess_total, 'complete_rate': round(sess_done / sess_total * 100, 1) if sess_total else 0, 'avg_score': _norm_avg(TR.filter(status='completed')) or 0, } # 用户与活跃度趋势 labels, sess_counts = _ts_monthly(6) _, _, active_users = _tr_monthly(TR, 6) trend = {'months': labels, 'train_counts': sess_counts, 'active_users': active_users} user_compose = [{'role': r['role_type'] or 'unknown', 'count': r['n']} for r in User.objects.filter(status=1).values('role_type').annotate(n=Count('id'))] since7 = now - timedelta(days=7) hbucket = defaultdict(int) for (created_at,) in TR.filter(created_at__gte=since7).values_list('created_at'): if created_at: hbucket[timezone.localtime(created_at).hour] += 1 hourly_7d = [{'hour': h, 'avg': round(hbucket.get(h, 0) / 7, 2)} for h in range(24)] # 各机构用户人数 / 活跃 inst_users = {r['institution_id']: r['n'] for r in User.objects.filter(status=1) .exclude(institution_id__isnull=True).values('institution_id').annotate(n=Count('id'))} inst_active = {r['user__institution_id']: r['c'] for r in TR.filter(created_at__gte=d30) .exclude(user__institution_id__isnull=True).values('user__institution_id') .annotate(c=Count('user_id', distinct=True))} inames = _inst_names(set(inst_users) | set(inst_active)) institution_dist = [{'id': iid, 'institution': inames.get(iid, ''), 'users': inst_users[iid], 'active': inst_active.get(iid, 0)} for iid in inst_users] # 医院使用排行 by_train = list(TR.exclude(user__institution_id__isnull=True).values('user__institution_id') .annotate(value=Count('id')).order_by('-value')[:10]) by_score = list(TR.exclude(user__institution_id__isnull=True).values('user__institution_id') .annotate(value=Avg(_NORM_TOTAL_EXPR)).order_by('-value')[:10]) rnames = _inst_names([r['user__institution_id'] for r in by_train + by_score]) hospital_rank = { 'by_train': [{'id': r['user__institution_id'], 'institution': rnames.get(r['user__institution_id'], ''), 'count': r['value']} for r in by_train], 'by_avg_score': [{'id': r['user__institution_id'], 'institution': rnames.get(r['user__institution_id'], ''), 'avg_score': round(float(r['value']), 1) if r['value'] is not None else None} for r in by_score], } # 病例资产与使用 case_total = CaseBase.objects.count() case_this = CaseBase.objects.filter(created_at__gte=ms).count() case_last = CaseBase.objects.filter(created_at__gte=pms, created_at__lt=ms).count() type_dist = [{'case_type': r['case_type'], 'count': r['n']} for r in CaseBase.objects.values('case_type').annotate(n=Count('id'))] # 每种类型使用率 = 该类型被训练过的去重病例数 / 该类型病例总数 type_total = {r['case_type']: r['n'] for r in CaseBase.objects.values('case_type').annotate(n=Count('id'))} used_by_type = defaultdict(set) for r in TR.exclude(case_id__isnull=True).values('case_id', 'case__case_type'): used_by_type[r['case__case_type']].add(r['case_id']) type_usage_rate = [{'case_type': t, 'rate': round(len(used_by_type.get(t, set())) / n * 100) if n else 0} for t, n in type_total.items()] top_used_rows = list(TR.exclude(case_id__isnull=True).values('case_id') .annotate(value=Count('id')).order_by('-value')[:10]) ctitles = _case_titles([r['case_id'] for r in top_used_rows]) top_used = [{'case_id': r['case_id'], 'title': ctitles.get(r['case_id'], ''), 'count': r['value']} for r in top_used_rows] prates = _pass_rates(TR) ptitles = _case_titles([p['case_id'] for p in prates]) low_pass = sorted(prates, key=lambda x: x['pass_rate'])[:10] low_pass = [{**p, 'title': ptitles.get(p['case_id'], '')} for p in low_pass] case_asset = { 'total': case_total, 'new_month': case_this, 'new_mom': _mom(case_this, case_last), 'type_dist': type_dist, 'type_usage_rate': type_usage_rate, 'top_used': top_used, 'low_pass': low_pass, } return Response({ 'kpi': kpi, 'core': core, 'trend': trend, 'user_compose': user_compose, 'hourly_7d': hourly_7d, 'institution_dist': institution_dist, 'hospital_rank': hospital_rank, 'case_asset': case_asset, }) # ── 8.2 医院管理员 · 医院驾驶舱(本院)────────────────────────────────────── @api_view(['GET']) @permission_classes([IsAuthenticated, IsHospitalAdmin]) def hospital_overview(request): inst_id = request.user.institution_id now = timezone.now() d30 = now - timedelta(days=30) ms = _month_start(now.year, now.month) pm_y, pm_m = (now.year, now.month - 1) if now.month > 1 else (now.year - 1, 12) pms = _month_start(pm_y, pm_m) inst = Institution.all_objects.filter(id=inst_id).first() TR_h = TrainingRecord.objects.filter(user__institution_id=inst_id) # 本院学员的训练 CASE_h = CaseBase.objects.filter(institution_id=inst_id) # 本院病例 profile = { 'institution_id': inst_id, 'name': inst.name if inst else '', 'logo': inst.banner_url if inst else '', 'level': inst.level if inst else '', 'cooperation_days': (now - inst.created_at).days if inst and inst.created_at else None, } summary = { 'dept_count': CASE_h.exclude(department_id__isnull=True).values('department_id').distinct().count(), 'doctor_count': User.objects.filter(institution_id=inst_id, role_type='doctor', status=1).count(), 'student_count': User.objects.filter(institution_id=inst_id, role_type='student', status=1).count(), 'train_total': TR_h.count(), 'complete_rate': round(TR_h.filter(status='completed').count() / TR_h.count() * 100, 1) if TR_h.count() else 0, 'avg_score': _norm_avg(TR_h.filter(status='completed')) or 0, } labels, counts, _ = _tr_monthly(TR_h, 6) summary['train_months'] = labels summary['train_monthly'] = counts # 科室排行(全局科室,按病例所属科室聚合本院数据) dept_case = {r['department_id']: r['n'] for r in CASE_h.exclude(department_id__isnull=True) .values('department_id').annotate(n=Count('id'))} dept_train = {r['case__department_id']: (r['c'], r['done']) for r in TR_h .exclude(case__department_id__isnull=True).values('case__department_id') .annotate(c=Count('id'), done=Count('id', filter=Q(status='completed')))} dept_active = {r['case__department_id']: r['u'] for r in TR_h.filter(created_at__gte=d30) .exclude(case__department_id__isnull=True).values('case__department_id') .annotate(u=Count('user_id', distinct=True))} dept_score = {r['case__department_id']: r['a'] for r in TR_h.exclude(case__department_id__isnull=True) .values('case__department_id').annotate(a=Avg(_NORM_TOTAL_EXPR))} dnames = _dept_names(set(dept_case) | set(dept_train) | set(dept_active) | set(dept_score)) dept_rank = [{ 'id': did, 'department': dnames.get(did, ''), 'case_count': dept_case.get(did, 0), 'train_count': dept_train.get(did, (0, 0))[0], 'effective_train': dept_train.get(did, (0, 0))[1], 'active_users': dept_active.get(did, 0), 'avg_score': round(float(dept_score[did]), 1) if dept_score.get(did) is not None else None, } for did in (set(dept_case) | set(dept_train))] case_asset = { 'total': CASE_h.count(), 'new_month': CASE_h.filter(created_at__gte=ms).count(), 'top_used': [], 'pass_high': [], 'pass_low': [], } top_rows = list(TR_h.exclude(case_id__isnull=True).values('case_id') .annotate(value=Count('id')).order_by('-value')[:10]) ctitles = _case_titles([r['case_id'] for r in top_rows]) case_asset['top_used'] = [{'case_id': r['case_id'], 'title': ctitles.get(r['case_id'], ''), 'count': r['value']} for r in top_rows] prates = _pass_rates(TR_h) ptitles = _case_titles([p['case_id'] for p in prates]) prates = [{**p, 'title': ptitles.get(p['case_id'], '')} for p in prates] case_asset['pass_high'] = sorted(prates, key=lambda x: -x['pass_rate'])[:5] case_asset['pass_low'] = sorted(prates, key=lambda x: x['pass_rate'])[:5] competency = { 'student_avg': _norm_avg(TR_h.filter(status='completed')) or 0, 'platform_avg': _norm_avg(TrainingRecord.objects.filter(status='completed')) or 0, 'radar': _radar(TR_h.only('ai_feedback_structured')), 'radar_platform': _radar(TrainingRecord.objects.only('ai_feedback_structured')), } return Response({'profile': profile, 'summary': summary, 'dept_rank': dept_rank, 'case_asset': case_asset, 'competency': competency}) # ── 8.3 内容管理员 · 内容概览(本院)──────────────────────────────────────── @api_view(['GET']) @permission_classes([IsAuthenticated, IsContentAdmin]) def content_overview(request): inst_id = request.user.institution_id now = timezone.now() ms = _month_start(now.year, now.month) pm_y, pm_m = (now.year, now.month - 1) if now.month > 1 else (now.year - 1, 12) pms = _month_start(pm_y, pm_m) CASE_h = CaseBase.objects.filter(institution_id=inst_id) TR_h = TrainingRecord.objects.filter(case__institution_id=inst_id) case_total = CASE_h.count() used_cases = TR_h.exclude(case_id__isnull=True).values('case_id').distinct().count() case_this = CASE_h.filter(created_at__gte=ms).count() case_last = CASE_h.filter(created_at__gte=pms, created_at__lt=ms).count() tr_this = TR_h.filter(created_at__gte=ms).count() tr_last = TR_h.filter(created_at__gte=pms, created_at__lt=ms).count() summary = { 'case_total': case_total, 'pending_publish': CASE_h.filter(publish_status=1).count(), # 1=正常=待发布 'case_new_month': case_this, 'case_new_mom': _mom(case_this, case_last), 'usage_rate': round(used_cases / case_total * 100) if case_total else 0, 'train_total': TR_h.count(), 'train_mom': _mom(tr_this, tr_last), } type_dist = [{'case_type': r['case_type'], 'count': r['n']} for r in CASE_h.values('case_type').annotate(n=Count('id'))] type_train = {r['case__case_type']: r['c'] for r in TR_h.values('case__case_type').annotate(c=Count('id'))} # 不同科室(全局科室)病例分布 / 使用数(去重) / 训练次数 dept_case = {r['department_id']: r['n'] for r in CASE_h.exclude(department_id__isnull=True) .values('department_id').annotate(n=Count('id'))} dept_train = {r['case__department_id']: r['c'] for r in TR_h.exclude(case__department_id__isnull=True) .values('case__department_id').annotate(c=Count('id'))} dept_used = defaultdict(set) for r in TR_h.exclude(case__department_id__isnull=True).values('case__department_id', 'case_id'): dept_used[r['case__department_id']].add(r['case_id']) dnames = _dept_names(set(dept_case) | set(dept_train)) dept_dist = [{'id': did, 'department': dnames.get(did, ''), 'case_count': dept_case.get(did, 0), 'used_count': len(dept_used.get(did, set())), 'train_count': dept_train.get(did, 0)} for did in (set(dept_case) | set(dept_train))] # 难度分布与使用次数 diff_case = {r['difficulty'] or '未分级': r['n'] for r in CASE_h.values('difficulty').annotate(n=Count('id'))} diff_train = defaultdict(int) for r in TR_h.values('case__difficulty').annotate(c=Count('id')): diff_train[r['case__difficulty'] or '未分级'] += r['c'] difficulty_dist = [{'difficulty': k, 'case_count': v, 'train_count': diff_train.get(k, 0)} for k, v in diff_case.items()] dist = {'type_dist': type_dist, 'type_train': type_train, 'dept_dist': dept_dist, 'difficulty_dist': difficulty_dist} prates = _pass_rates(TR_h) ptitles = _case_titles([p['case_id'] for p in prates]) prates = [{**p, 'title': ptitles.get(p['case_id'], '')} for p in prates] warning = sorted(prates, key=lambda x: x['pass_rate'])[:5] # 低通过率预警 hot_rows = list(TR_h.exclude(case_id__isnull=True).values('case_id') .annotate(value=Count('id')).order_by('-value')[:5]) htitles = _case_titles([r['case_id'] for r in hot_rows]) hot = [{'case_id': r['case_id'], 'title': htitles.get(r['case_id'], ''), 'count': r['value']} for r in hot_rows] return Response({'summary': summary, 'dist': dist, 'warning': warning, 'hot': hot}) # ── 8.4 带教医生 · 教学概览(名下学生)────────────────────────────────────── @api_view(['GET']) @permission_classes([IsAuthenticated, IsTeacher]) def teaching_overview(request): teacher = request.user student_ids = list(TeacherStudentRelation.objects.filter(teacher=teacher, status=1) .values_list('student_id', flat=True)) students_qs = User.objects.filter(id__in=student_ids) TR_s = TrainingRecord.objects.filter(user_id__in=student_ids) # 每个学生派生指标 by_user = defaultdict(lambda: {'total': 0, 'done': 0, 'score_sum': 0.0, 'score_n': 0, 'last': None, 'types': defaultdict(int)}) for r in TR_s.only('user_id', 'status', 'total_score', 'score_type', 'end_time', 'case_type'): b = by_user[r.user_id] b['total'] += 1 if r.status == 'completed': b['done'] += 1 n = _norm(r.total_score, r.score_type) if n is not None: b['score_sum'] += n b['score_n'] += 1 if r.case_type: b['types'][r.case_type] += 1 if r.end_time and (b['last'] is None or r.end_time > b['last']): b['last'] = r.end_time students = [] for u in students_qs.select_related('department'): b = by_user.get(u.id) most_type = max(b['types'].items(), key=lambda x: x[1])[0] if (b and b['types']) else None students.append({ 'id': u.id, 'real_name': u.real_name, 'username': u.username, 'department': u.department.name if u.department_id else '', 'train_total': b['total'] if b else 0, 'complete_rate': round(b['done'] / b['total'] * 100) if (b and b['total']) else 0, 'avg_score': round(b['score_sum'] / b['score_n'], 1) if (b and b['score_n']) else None, 'weak_dimensions': u.weak_dimensions or [], 'most_trained_type': most_type, 'last_trained_at': b['last'] if b else None, 'pending_tasks': None, # ⛔ 无任务表,不做 }) # 整体概览 inst_id = teacher.institution_id labels, counts, _ = _tr_monthly(TR_s, 6) overview = { 'student_count': len(student_ids), 'radar': _radar(TR_s.only('ai_feedback_structured')), 'students_avg': _norm_avg(TR_s.filter(status='completed')) or 0, 'institution_avg': _norm_avg(TrainingRecord.objects.filter( user__institution_id=inst_id, status='completed')) or 0, 'train_months': labels, 'train_monthly': counts, 'task_summary': None, # ⛔ 无任务表,不做(进行中任务/完成情况/得分分布) } return Response({'students': students, 'overview': overview})