477 lines
23 KiB
Python
477 lines
23 KiB
Python
|
|
"""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})
|