From 2fab2be0a1976269fb7e4c66c1eff2af98dce4d7 Mon Sep 17 00:00:00 2001 From: shihan11 Date: Fri, 12 Jun 2026 11:11:48 +0800 Subject: [PATCH] feat: update personal stats and cms change reuqest method --- apps/organization/views.py | 43 +++++- apps/training/models.py | 21 ++- apps/user/cms.py | 20 ++- apps/user/cms_relation.py | 20 ++- apps/user/stats.py | 224 +++++++++++++++++++++++++++++ apps/user/urls.py | 5 + test/test_cms_department.py | 16 ++- test/test_cms_huser.py | 10 +- test/test_cms_institution.py | 24 ++-- test/test_cms_relation.py | 8 +- test/test_cms_user.py | 20 ++- test/test_mobile_training_stats.py | 178 +++++++++++++++++++++++ 12 files changed, 546 insertions(+), 43 deletions(-) create mode 100644 apps/user/stats.py create mode 100644 test/test_mobile_training_stats.py diff --git a/apps/organization/views.py b/apps/organization/views.py index 4bd0d6a..e57abba 100644 --- a/apps/organization/views.py +++ b/apps/organization/views.py @@ -47,10 +47,25 @@ class CmsInstitutionViewSet(viewsets.ModelViewSet): filter_backends = [DjangoFilterBackend, filters.SearchFilter] filterset_fields = ['type', 'province', 'city'] search_fields = ['name', 'code'] - # 仅允许 GET/POST/PATCH/DELETE,编辑统一走 PATCH(屏蔽 PUT) - http_method_names = ['get', 'post', 'patch', 'delete', 'head', 'options'] + # 仅 GET / POST:查=GET,增删改=POST(编辑→{id}/update/,停用→{id}/disable/) + http_method_names = ['get', 'post', 'head', 'options'] - # destroy 走 ModelViewSet 默认实现:instance.delete() 经 SoftDeleteModel 改为逻辑删除(停用) + @extend_schema(summary='CMS-INST-3 编辑机构', tags=['CMS-机构']) + @action(detail=True, methods=['post'], url_path='update') + def update_inst(self, request, pk=None): + """编辑机构(POST 局部更新,等价旧 PATCH /{id}/)。""" + instance = self.get_object() + serializer = self.get_serializer(instance, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data) + + @extend_schema(summary='CMS-INST-4 停用机构(逻辑删除)', tags=['CMS-机构']) + @action(detail=True, methods=['post'], url_path='disable') + def disable(self, request, pk=None): + """停用机构(软删除,等价旧 DELETE /{id}/)。""" + self.get_object().delete() + return Response({'message': '已停用'}) @extend_schema(summary='CMS-INST-6 上传机构 Banner 图', tags=['CMS-机构']) @action(detail=True, methods=['post'], url_path='banner', @@ -134,8 +149,6 @@ class CmsInstitutionViewSet(viewsets.ModelViewSet): list=extend_schema(summary='CMS-DEPT-1 科室列表(全局)', tags=['CMS-科室']), create=extend_schema(summary='CMS-DEPT-2 新增科室', tags=['CMS-科室']), retrieve=extend_schema(summary='科室详情', tags=['CMS-科室']), - partial_update=extend_schema(summary='CMS-DEPT-3 编辑科室', tags=['CMS-科室']), - destroy=extend_schema(summary='CMS-DEPT-4 停用科室(逻辑删除)', tags=['CMS-科室']), ) class CmsDepartmentViewSet(viewsets.ModelViewSet): """CMS 科室管理(全局科室)—— 仅超级管理员。停用为逻辑删除。""" @@ -145,7 +158,25 @@ class CmsDepartmentViewSet(viewsets.ModelViewSet): filter_backends = [DjangoFilterBackend, filters.SearchFilter] filterset_fields = ['category'] search_fields = ['name'] - http_method_names = ['get', 'post', 'patch', 'delete', 'head', 'options'] + # 仅 GET / POST:查=GET,增删改=POST(编辑→{id}/update/,停用→{id}/disable/) + http_method_names = ['get', 'post', 'head', 'options'] + + @extend_schema(summary='CMS-DEPT-3 编辑科室', tags=['CMS-科室']) + @action(detail=True, methods=['post'], url_path='update') + def update_dept(self, request, pk=None): + """编辑科室(POST 局部更新,等价旧 PATCH /{id}/)。""" + instance = self.get_object() + serializer = self.get_serializer(instance, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data) + + @extend_schema(summary='CMS-DEPT-4 停用科室(逻辑删除)', tags=['CMS-科室']) + @action(detail=True, methods=['post'], url_path='disable') + def disable(self, request, pk=None): + """停用科室(软删除,等价旧 DELETE /{id}/)。""" + self.get_object().delete() + return Response({'message': '已停用'}) @extend_schema(summary='CMS-DEPT-6b 下载科室导入模板', tags=['CMS-科室']) @action(detail=False, methods=['get'], url_path='import-template') diff --git a/apps/training/models.py b/apps/training/models.py index 6aa3aff..7a142ad 100644 --- a/apps/training/models.py +++ b/apps/training/models.py @@ -6,9 +6,11 @@ from apps.case.models import CaseBase # ─── 只读镜像(fastapi 属主)──────────────────────────────────────────────────── # 训练相关表(training_record / training_session / training_submission / # user_learning_profiles 等)的 schema 属主是 fastapi 服务。Django 侧一律 managed=False、 -# 只读接入,仅供 CMS 查询训练记录/统计,不写。 -# ⚠️ 下方字段为当前最佳镜像,正式接入前应以 `python manage.py inspectdb` 对真实库反向校准 -# (真实表清单见《项目架构设计.md》第二节;注意真实库中没有 training_score_detail)。 +# 只读接入,供 CMS 查询训练记录、移动端个人中心统计/分析,不写。 +# ✅ 已用 `python manage.py inspectdb training_record training_score_detail` 对本地同步库 +# (medical_platform) 反向校准:下方字段与真实列一致;两张表均已存在。 +# 注意 training_score_detail 真实表「无 max_score 列」,故得分率需从 +# training_record.ai_feedback_structured.dimension_scores(带 max_score)取数。 class TrainingRecord(BaseModel): @@ -66,6 +68,12 @@ class TrainingRecord(BaseModel): emotion_analysis = models.JSONField('情绪分析', default=dict, blank=True) prompt_version = models.CharField('Prompt版本', max_length=50, blank=True) rag_context_version = models.CharField('知识上下文版本', max_length=50, blank=True) + # 与公网真实表对齐(inspectdb 校准补充列) + external_user_id = models.CharField('宿主系统用户ID', max_length=128, blank=True, default='') + session_id = models.BigIntegerField('训练会话ID', null=True, blank=True) + evaluation_record_id = models.BigIntegerField('评价记录ID', null=True, blank=True) + score_type = models.CharField('分数类型', max_length=20, blank=True, default='percentage') + pdf_file_path = models.CharField('报告PDF路径', max_length=512, null=True, blank=True) class Meta: managed = False @@ -78,7 +86,12 @@ class TrainingRecord(BaseModel): class TrainingScoreDetail(BaseModel): - """评分明细表(只读占位,managed=False;真实 fastapi 库无此表,接入时以真实 schema 为准)""" + """评分明细表(只读,managed=False,fastapi 属主)。 + + 经 inspectdb 校准:真实表含 record_id/rule_id/dimension/score/deducted_reason/ + evidence_message_ids/ai_confidence/comment 等列,**无 max_score 列**。 + 雷达/得分率改从 training_record.ai_feedback_structured.dimension_scores 取数(带 max_score)。 + """ id = models.BigAutoField(primary_key=True) record = models.ForeignKey( TrainingRecord, on_delete=models.CASCADE, diff --git a/apps/user/cms.py b/apps/user/cms.py index 60ceede..c91d37b 100644 --- a/apps/user/cms.py +++ b/apps/user/cms.py @@ -165,7 +165,8 @@ class CmsUserViewSet(viewsets.ModelViewSet): filter_backends = [DjangoFilterBackend, filters.SearchFilter] filterset_fields = ['role_type', 'institution', 'status', 'gender'] search_fields = ['username', 'real_name', 'phone'] - http_method_names = ['get', 'post', 'patch', 'delete', 'head', 'options'] + # 仅 GET / POST:查=GET,增删改=POST(编辑→{id}/update/,停用→{id}/disable/) + http_method_names = ['get', 'post', 'head', 'options'] def get_queryset(self): qs = User.objects.select_related('institution', 'department').all().order_by('-created_at') @@ -176,7 +177,7 @@ class CmsUserViewSet(viewsets.ModelViewSet): return qs.filter(institution_id=user.institution_id, role_type__in=HOSPITAL_ADMIN_ROLES) def get_serializer_class(self): - if self.action in ('create', 'update', 'partial_update'): + if self.action in ('create', 'update_user'): return CmsUserWriteSerializer return CmsUserSerializer @@ -186,14 +187,23 @@ class CmsUserViewSet(viewsets.ModelViewSet): user = write.save() return Response(CmsUserSerializer(user).data, status=status.HTTP_201_CREATED) - def update(self, request, *args, **kwargs): - partial = kwargs.pop('partial', False) + @extend_schema(summary='CMS-USER-3 编辑用户', tags=['CMS-用户']) + @action(detail=True, methods=['post'], url_path='update') + def update_user(self, request, pk=None): + """编辑用户(POST 局部更新,等价旧 PATCH /{id}/)。""" instance = self.get_object() - write = self.get_serializer(instance, data=request.data, partial=partial) + write = self.get_serializer(instance, data=request.data, partial=True) write.is_valid(raise_exception=True) user = write.save() return Response(CmsUserSerializer(user).data) + @extend_schema(summary='CMS-USER-4 停用用户(逻辑删除)', tags=['CMS-用户']) + @action(detail=True, methods=['post'], url_path='disable') + def disable(self, request, pk=None): + """停用用户(软删除,等价旧 DELETE /{id}/)。""" + self.get_object().delete() + return Response({'message': '已停用'}) + @extend_schema(summary='CMS-USER-5 重置密码', tags=['CMS-用户']) @action(detail=True, methods=['post'], url_path='reset-password') def reset_password(self, request, pk=None): diff --git a/apps/user/cms_relation.py b/apps/user/cms_relation.py index 8bef743..e116f73 100644 --- a/apps/user/cms_relation.py +++ b/apps/user/cms_relation.py @@ -78,7 +78,8 @@ class CmsTeacherStudentRelationViewSet(viewsets.ModelViewSet): filter_backends = [DjangoFilterBackend, filters.SearchFilter] filterset_fields = ['teacher', 'student', 'status'] search_fields = ['teacher__real_name', 'teacher__phone', 'student__real_name', 'student__phone'] - http_method_names = ['get', 'post', 'patch', 'delete', 'head', 'options'] + # 仅 GET / POST:查=GET,增删改=POST(编辑→{id}/update/,停用→{id}/disable/) + http_method_names = ['get', 'post', 'head', 'options'] def get_queryset(self): qs = TeacherStudentRelation.objects.select_related('teacher', 'student').all().order_by('-created_at') @@ -88,7 +89,7 @@ class CmsTeacherStudentRelationViewSet(viewsets.ModelViewSet): return qs.filter(teacher__institution_id=user.institution_id) def get_serializer_class(self): - if self.action in ('create', 'update', 'partial_update'): + if self.action in ('create', 'update_relation'): return CmsRelationWriteSerializer return CmsRelationSerializer @@ -98,14 +99,23 @@ class CmsTeacherStudentRelationViewSet(viewsets.ModelViewSet): rel = write.save() return Response(CmsRelationSerializer(rel).data, status=status.HTTP_201_CREATED) - def update(self, request, *args, **kwargs): - partial = kwargs.pop('partial', False) + @extend_schema(summary='CMS-REL-3 编辑师生关系', tags=['CMS-师生关系']) + @action(detail=True, methods=['post'], url_path='update') + def update_relation(self, request, pk=None): + """编辑师生关系(POST 局部更新,等价旧 PATCH /{id}/)。""" instance = self.get_object() - write = self.get_serializer(instance, data=request.data, partial=partial) + write = self.get_serializer(instance, data=request.data, partial=True) write.is_valid(raise_exception=True) rel = write.save() return Response(CmsRelationSerializer(rel).data) + @extend_schema(summary='CMS-REL-4 停用师生关系(逻辑删除)', tags=['CMS-师生关系']) + @action(detail=True, methods=['post'], url_path='disable') + def disable(self, request, pk=None): + """停用师生关系(软删除,等价旧 DELETE /{id}/)。""" + self.get_object().delete() + return Response({'message': '已停用'}) + @extend_schema(summary='CMS-REL-4 下载师生关系导入模板', tags=['CMS-师生关系']) @action(detail=False, methods=['get'], url_path='import-template') def import_template(self, request): diff --git a/apps/user/stats.py b/apps/user/stats.py new file mode 100644 index 0000000..ae66524 --- /dev/null +++ b/apps/user/stats.py @@ -0,0 +1,224 @@ +"""移动端个人中心 - 训练统计 / 智能分析(读 fastapi 只读训练表)。 + +- 4.3 临床核心能力指标 GET /api/user/competency-metrics/ +- 4.4 训练记录(统计信息) GET /api/user/training-records/ +- 4.5 智能分析(关联评价表)GET /api/user/analysis/ + +数据源:training_record(managed=False 只读,user_id = 当前用户.id)。 +各能力维度得分 + 满分来自 training_record.ai_feedback_structured.dimension_scores +(与 training_score_detail 同源,但 json 里带 max_score,便于算「得分率」)。 +""" +import json +from datetime import timedelta + +from django.db.models import Sum, Avg, Case, When, F, DecimalField +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 drf_spectacular.utils import extend_schema + +from apps.training.models import TrainingRecord + +# 临床核心能力标准 6 维(A 组) +STANDARD_DIMS = ['病史采集', '查体能力', '检查决策', '诊断能力', '治疗决策', '医患沟通'] + +# 评分维度(fastapi 实际打分维度,名称随病例评分规则而变)→ 标准 6 维 的归并映射。 +# ⚠️ 数据中维度名不统一(已观察到两套来源、且随病例不同),此映射为按实测维度的最佳归并, +# 最终口径需与评分/内容团队对齐。未在映射内的维度会被忽略。 +DIMENSION_MAP = { + # 病史采集 + '信息获取': '病史采集', '病史采集': '病史采集', '问诊': '病史采集', + # 查体能力(注:当前 ai_feedback 数据未单列查体维度,多由内容侧 rubric 决定) + '查体能力': '查体能力', '体格检查': '查体能力', '查体': '查体能力', + # 检查决策 + '检查决策': '检查决策', '检查利用': '检查决策', '检查理解': '检查决策', + '辅助检查': '检查决策', '检验决策': '检查决策', + # 诊断能力 + '诊断推理': '诊断能力', '诊断能力': '诊断能力', '鉴别诊断': '诊断能力', + '分析推理': '诊断能力', '临床推理': '诊断能力', '临床整合': '诊断能力', + '知识掌握': '诊断能力', '知识运用': '诊断能力', + # 治疗决策 + '治疗决策': '治疗决策', '处置决策': '治疗决策', '治疗': '治疗决策', + # 医患沟通 + '沟通技巧': '医患沟通', '医患沟通': '医患沟通', + '沟通人文': '医患沟通', '人文沟通': '医患沟通', '沟通': '医患沟通', +} +# 诊断相关维度(诊断准确率口径:这些维度的平均得分率) +DIAGNOSIS_DIMS = {'诊断推理', '诊断能力', '鉴别诊断', '分析推理', '临床推理', '临床整合'} + + +def _completed_qs(user): + return TrainingRecord.objects.filter(user_id=user.id, status='completed') + + +# total_score 归一到百分制:五分制(0-5) ×20,其余按原值(percentage)。 +# 用于 avg_score / current_score / 趋势 / 较上周,避免百分制与五分制混平均失真。 +# (诊断准确率/雷达用各维度 score/max_score 比值自归一,不走这里。) +def _norm_total(score, score_type): + if score is None: + return None + return float(score) * 20 if score_type == 'five_point' else float(score) + + +# DB 侧等价表达式,供 aggregate(Avg(...)) 使用 +_NORM_TOTAL_EXPR = Case( + When(score_type='five_point', then=F('total_score') * 20), + default=F('total_score'), + output_field=DecimalField(max_digits=7, decimal_places=2), +) + + +def _week_start(offset_weeks=0): + now = timezone.localtime() + monday = now - timedelta(days=now.weekday(), weeks=-offset_weeks) + return monday.replace(hour=0, minute=0, second=0, microsecond=0) + + +def _dimension_scores(rec): + """从 ai_feedback_structured.dimension_scores 取 [(dimension, score, max_score)]。""" + fb = rec.ai_feedback_structured + if isinstance(fb, str): + try: + fb = json.loads(fb) + except Exception: + fb = {} + if not isinstance(fb, dict): + return [] + out = [] + for d in (fb.get('dimension_scores') or []): + if isinstance(d, dict) and d.get('dimension'): + out.append((d['dimension'], d.get('score'), d.get('max_score'))) + return out + + +def _diagnosis_accuracy(records): + """诊断相关维度的平均得分率(%)。无数据返回 None。""" + rates = [] + for rec in records: + for dim, score, mx in _dimension_scores(rec): + if dim in DIAGNOSIS_DIMS and score is not None and mx and float(mx) > 0: + rates.append(float(score) / float(mx)) + return round(sum(rates) / len(rates) * 100) if rates else None + + +def _hours(seconds): + return round((seconds or 0) / 3600, 1) + + +# ── 4.3 临床核心能力指标 ───────────────────────────────────────────────────── + +@extend_schema(summary='临床核心能力指标', tags=['个人中心']) +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def competency_metrics(request): + qs = _completed_qs(request.user) + agg = qs.aggregate(total=Sum('duration_seconds'), avg=Avg(_NORM_TOTAL_EXPR)) + avg = agg['avg'] + return Response({ + 'completed_cases': qs.count(), + 'completed_cases_week': qs.filter(end_time__gte=_week_start()).count(), + 'total_hours': _hours(agg['total']), + 'avg_score': round(float(avg), 1) if avg is not None else 0, + 'diagnosis_accuracy': _diagnosis_accuracy(qs.only('ai_feedback_structured')), + }) + + +# ── 4.4 训练记录(统计信息)───────────────────────────────────────────────── + +@extend_schema(summary='训练记录(统计信息)', tags=['个人中心']) +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def training_records(request): + base = _completed_qs(request.user) + search = (request.query_params.get('search') or '').strip() + if search: + from django.db.models import Q + base = base.filter(Q(case__title__icontains=search) | Q(case__department__name__icontains=search)) + + agg = base.aggregate(total=Sum('duration_seconds')) + summary = { + 'total_cases': base.count(), + 'total_hours': _hours(agg['total']), + 'avg_accuracy': _diagnosis_accuracy(base.only('ai_feedback_structured')), + } + + page_qs = base.select_related('case', 'case__department').order_by('-end_time', '-id') + paginator = PageNumberPagination() + page = paginator.paginate_queryset(page_qs, request) + results = [] + for r in page: + case = r.case if r.case_id else None # 孤立外键(本地无对应病例)时为 None + dept = case.department if case else None # 病例无科室或科室缺失时为 None + results.append({ + 'record_id': r.id, + 'case_id': r.case_id, + 'case_title': case.title if case else '', + 'department': dept.name if dept else '', + 'trained_at': r.end_time or r.created_at, + 'score': float(r.total_score) if r.total_score is not None else None, + 'score_type': r.score_type, + 'evaluation_level': r.evaluation_level, + 'training_mode': r.training_mode, + 'case_type': r.case_type, + }) + resp = paginator.get_paginated_response(results) + resp.data['summary'] = summary + return resp + + +# ── 4.5 智能分析(关联评价表)───────────────────────────────────────────────── + +@extend_schema(summary='智能分析(关联评价表)', tags=['个人中心']) +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def analysis(request): + recs = list(_completed_qs(request.user).order_by('end_time', 'id')) + + # 趋势 / 当前评分(按 total_score,按 score_type 归一到百分制) + scored = [(r.end_time or r.created_at, _norm_total(r.total_score, r.score_type)) + for r in recs if r.total_score is not None] + recent = scored[-7:] + recent_trend = [{'label': (dt.strftime('%m-%d') if dt else ''), 'score': round(s)} for dt, s in recent] + current_score = round(sum(s for _, s in recent) / len(recent), 1) if recent else 0 + + # 较上周提升% + ws, ws_prev = _week_start(), _week_start(offset_weeks=-1) + this_week = [s for dt, s in scored if dt and dt >= ws] + last_week = [s for dt, s in scored if dt and ws_prev <= dt < ws] + score_delta_pct = None + if this_week and last_week: + a, b = sum(this_week) / len(this_week), sum(last_week) / len(last_week) + if b: + score_delta_pct = round((a - b) / b * 100) + + # 雷达:维度得分率归一到 0-100,按 A 组归并求均 + from collections import defaultdict + buckets = defaultdict(list) + for r in recs: + 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) + radar = [{'dimension': d, 'score': (round(sum(buckets[d]) / len(buckets[d])) if buckets[d] else 0)} + for d in STANDARD_DIMS] + # 仅在「有数据」的维度里排序取强/弱,避免无数据维度(0 分)被误判为薄弱项 + ordered = sorted((x for x in radar if buckets[x['dimension']]), key=lambda x: x['score']) + weak_dimensions = [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']}仍有提升空间,建议增加相关模拟训练。" + elif ordered: + comment = f"各维度表现较为均衡,可针对{weak_dimensions[0]}做进一步强化。" + + return Response({ + 'current_score': current_score, + 'score_delta_pct': score_delta_pct, + 'recent_trend': recent_trend, + 'radar': radar, + 'weak_dimensions': weak_dimensions, + 'comment': comment, + }) diff --git a/apps/user/urls.py b/apps/user/urls.py index 6d24bf7..53638fe 100644 --- a/apps/user/urls.py +++ b/apps/user/urls.py @@ -7,6 +7,7 @@ from .auth.login import login_password, login_code from .auth.logout import logout from .auth.refresh import refresh_token from .auth.reset_password import reset_password +from .stats import competency_metrics, training_records, analysis router = DefaultRouter() router.register(r'users', views.UserViewSet, basename='user') @@ -25,6 +26,10 @@ urlpatterns = [ path('profile/config/', views.student_profile_config, name='student-profile-config'), # 移动端个人中心:个人信息获取(GET) / 更新(PATCH) path('profile/', views.profile, name='profile'), + # 移动端个人中心 - 训练统计 / 智能分析(读 fastapi 只读训练表) + path('competency-metrics/', competency_metrics, name='competency-metrics'), + path('training-records/', training_records, name='training-records'), + path('analysis/', analysis, name='analysis'), # 认证相关 path('auth/send-code/', send_code, name='send-code'), path('auth/register/', register, name='register'), diff --git a/test/test_cms_department.py b/test/test_cms_department.py index 8edf0a7..0b6d385 100644 --- a/test/test_cms_department.py +++ b/test/test_cms_department.py @@ -17,6 +17,14 @@ def d_detail(pk): return f'/api/cms/departments/{pk}/' +def d_update(pk): + return f'/api/cms/departments/{pk}/update/' # 编辑:POST(原 PATCH /{id}/) + + +def d_disable(pk): + return f'/api/cms/departments/{pk}/disable/' # 停用:POST(原 DELETE /{id}/) + + def make_xlsx(headers, rows): wb = Workbook(); ws = wb.active ws.append(headers) @@ -51,7 +59,7 @@ class CmsDepartmentTest(CacheTestCase): self.assertEqual(resp.status_code, 200) self.assertIn('results', resp.json()) # 编辑 - resp = self.client.patch(d_detail(did), {'category': '医技'}) + resp = self.client.post(d_update(did), {'category': '医技'}) self.assertEqual(resp.status_code, 200, resp.content) self.assertEqual(resp.json()['category'], '医技') @@ -63,8 +71,8 @@ class CmsDepartmentTest(CacheTestCase): def test_soft_delete(self): d = Department.objects.create(name='儿科', category='临床') - resp = self.client.delete(d_detail(d.id)) - self.assertEqual(resp.status_code, 204, resp.content) + resp = self.client.post(d_disable(d.id)) + self.assertEqual(resp.status_code, 200, resp.content) self.assertFalse(Department.objects.filter(id=d.id).exists()) self.assertTrue(Department.all_objects.get(id=d.id).is_deleted) @@ -74,7 +82,7 @@ class CmsDepartmentTest(CacheTestCase): 按 all_objects 校验,避免与已停用科室同名而静默新建重复记录。 """ d = Department.objects.create(name='康复科', category='临床') - self.client.delete(d_detail(d.id)) + self.client.post(d_disable(d.id)) self.assertFalse(Department.objects.filter(name='康复科').exists()) resp = self.client.post(CMS_DEPT_URL, {'name': '康复科', 'category': '临床'}) self.assertEqual(resp.status_code, 400, resp.content) diff --git a/test/test_cms_huser.py b/test/test_cms_huser.py index 49d91c4..f06c62a 100644 --- a/test/test_cms_huser.py +++ b/test/test_cms_huser.py @@ -14,6 +14,10 @@ def u_detail(pk): return f'/api/cms/users/{pk}/' +def u_disable(pk): + return f'/api/cms/users/{pk}/disable/' # 停用:POST(原 DELETE /{id}/) + + class HospitalAdminUserScopeTest(CacheTestCase): def setUp(self): super().setUp() @@ -68,11 +72,11 @@ class HospitalAdminUserScopeTest(CacheTestCase): def test_cannot_touch_other_institution_user(self): # 他院学生不在 queryset → 404 self.assertEqual(self.client.get(u_detail(self.other_stu.id)).status_code, 404) - self.assertEqual(self.client.delete(u_detail(self.other_stu.id)).status_code, 404) + self.assertEqual(self.client.post(u_disable(self.other_stu.id)).status_code, 404) def test_soft_delete_own_student(self): - resp = self.client.delete(u_detail(self.stu.id)) - self.assertEqual(resp.status_code, 204, resp.content) + resp = self.client.post(u_disable(self.stu.id)) + self.assertEqual(resp.status_code, 200, resp.content) self.assertFalse(User.objects.filter(id=self.stu.id).exists()) def test_reset_password_own(self): diff --git a/test/test_cms_institution.py b/test/test_cms_institution.py index 7cb55a8..462391e 100644 --- a/test/test_cms_institution.py +++ b/test/test_cms_institution.py @@ -31,6 +31,14 @@ def inst_detail_url(pk): return f'/api/cms/institutions/{pk}/' +def inst_update_url(pk): + return f'/api/cms/institutions/{pk}/update/' # 编辑:POST(原 PATCH /{id}/) + + +def inst_disable_url(pk): + return f'/api/cms/institutions/{pk}/disable/' # 停用:POST(原 DELETE /{id}/) + + def inst_banner_url(pk): return f'/api/cms/institutions/{pk}/banner/' @@ -113,9 +121,9 @@ class CmsInstitutionCrudTest(CacheTestCase): self.assertEqual(resp.status_code, 200, resp.content) self.assertEqual(resp.json()['id'], inst.id) - def test_update_patch(self): + def test_update_post(self): inst = ensure_institution(name='旧名', code='CMS-UPD') - resp = self.client.patch(inst_detail_url(inst.id), {'name': '新名', 'level': '二甲'}) + resp = self.client.post(inst_update_url(inst.id), {'name': '新名', 'level': '二甲'}) self.assertEqual(resp.status_code, 200, resp.content) self.assertEqual(resp.json()['name'], '新名') inst.refresh_from_db() @@ -125,21 +133,21 @@ class CmsInstitutionCrudTest(CacheTestCase): def test_update_duplicate_code(self): ensure_institution(name='A', code='CMS-A') inst_b = ensure_institution(name='B', code='CMS-B') - resp = self.client.patch(inst_detail_url(inst_b.id), {'code': 'CMS-A'}) + resp = self.client.post(inst_update_url(inst_b.id), {'code': 'CMS-A'}) self.assertEqual(resp.status_code, 400, resp.content) self.assertEqual(resp.json()['code'], 'CMS_INSTITUTION_CODE_EXISTS') def test_update_same_code_ok(self): """编辑时传自己原 code 不算冲突。""" inst = ensure_institution(name='自身', code='CMS-SELF') - resp = self.client.patch(inst_detail_url(inst.id), {'code': 'CMS-SELF', 'name': '改名'}) + resp = self.client.post(inst_update_url(inst.id), {'code': 'CMS-SELF', 'name': '改名'}) self.assertEqual(resp.status_code, 200, resp.content) def test_delete_is_soft(self): """停用 = 逻辑删除:默认管理器查不到,但库里仍在(all_objects 可见)。""" inst = ensure_institution(name='可停用', code='CMS-DEL') - resp = self.client.delete(inst_detail_url(inst.id)) - self.assertEqual(resp.status_code, 204, resp.content) + resp = self.client.post(inst_disable_url(inst.id)) + self.assertEqual(resp.status_code, 200, resp.content) # 默认管理器(已过滤 is_deleted)查不到 self.assertFalse(Institution.objects.filter(id=inst.id).exists()) # 实际未物理删除 @@ -150,7 +158,7 @@ class CmsInstitutionCrudTest(CacheTestCase): def test_deleted_not_in_list(self): """软删后不出现在列表。""" inst = ensure_institution(name='停用后隐藏', code='CMS-HIDE') - self.client.delete(inst_detail_url(inst.id)) + self.client.post(inst_disable_url(inst.id)) resp = self.client.get(CMS_INST_URL, {'search': 'CMS-HIDE'}) codes = {i['code'] for i in resp.json()['results']} self.assertNotIn('CMS-HIDE', codes) @@ -166,7 +174,7 @@ class CmsInstitutionCrudTest(CacheTestCase): 编码唯一约束对软删行仍生效,须按 all_objects 校验,避免写库时撞约束抛 500。 """ inst = ensure_institution(name='待停用', code='CMS-SOFT-DUP') - self.client.delete(inst_detail_url(inst.id)) + self.client.post(inst_disable_url(inst.id)) self.assertFalse(Institution.objects.filter(code='CMS-SOFT-DUP').exists()) resp = self.client.post(CMS_INST_URL, {'code': 'CMS-SOFT-DUP', 'name': '重建'}) self.assertEqual(resp.status_code, 400, resp.content) diff --git a/test/test_cms_relation.py b/test/test_cms_relation.py index cb222ef..0d564ca 100644 --- a/test/test_cms_relation.py +++ b/test/test_cms_relation.py @@ -16,6 +16,10 @@ def rel_detail(pk): return f'/api/cms/teacher-student-relations/{pk}/' +def rel_disable(pk): + return f'/api/cms/teacher-student-relations/{pk}/disable/' # 停用:POST(原 DELETE /{id}/) + + def make_xlsx(headers, rows): wb = Workbook(); ws = wb.active ws.append(headers) @@ -69,8 +73,8 @@ class CmsRelationTest(CacheTestCase): def test_soft_delete(self): r = TeacherStudentRelation.objects.create(teacher=self.doc, student=self.stu, status=1) - resp = self.client.delete(rel_detail(r.id)) - self.assertEqual(resp.status_code, 204, resp.content) + resp = self.client.post(rel_disable(r.id)) + self.assertEqual(resp.status_code, 200, resp.content) self.assertFalse(TeacherStudentRelation.objects.filter(id=r.id).exists()) self.assertTrue(TeacherStudentRelation.all_objects.get(id=r.id).is_deleted) diff --git a/test/test_cms_user.py b/test/test_cms_user.py index ff3ec84..6027cc6 100644 --- a/test/test_cms_user.py +++ b/test/test_cms_user.py @@ -17,6 +17,14 @@ def u_detail(pk): return f'/api/cms/users/{pk}/' +def u_update(pk): + return f'/api/cms/users/{pk}/update/' # 编辑:POST(原 PATCH /{id}/) + + +def u_disable(pk): + return f'/api/cms/users/{pk}/disable/' # 停用:POST(原 DELETE /{id}/) + + def make_xlsx(headers, rows): wb = Workbook(); ws = wb.active ws.append(headers) @@ -96,7 +104,7 @@ class CmsUserCrudTest(CacheTestCase): """方案B:只改姓名、不带角色/机构 → 200,角色与机构保持原值。""" u = create_test_user(phone='13922200050', real_name='原名', role_type='student', institution=self.inst) - resp = self.client.patch(u_detail(u.id), {'real_name': '新名'}) + resp = self.client.post(u_update(u.id), {'real_name': '新名'}) self.assertEqual(resp.status_code, 200, resp.content) u.refresh_from_db() self.assertEqual(u.real_name, '新名') @@ -107,7 +115,7 @@ class CmsUserCrudTest(CacheTestCase): """方案B:传了 role_type 但为空 → 400(不可清空角色)。""" u = create_test_user(phone='13922200051', real_name='x', role_type='student', institution=self.inst) - resp = self.client.patch(u_detail(u.id), {'role_type': ''}) + resp = self.client.post(u_update(u.id), {'role_type': ''}) self.assertEqual(resp.status_code, 400, resp.content) self.assertEqual(resp.json()['code'], 'CMS_VALIDATION_ERROR') @@ -115,14 +123,14 @@ class CmsUserCrudTest(CacheTestCase): """方案B:传了 institution=null → 400(不可清空机构)。""" u = create_test_user(phone='13922200052', real_name='x', role_type='student', institution=self.inst) - resp = self.client.patch(u_detail(u.id), {'institution': None}, format='json') + resp = self.client.post(u_update(u.id), {'institution': None}, format='json') self.assertEqual(resp.status_code, 400, resp.content) self.assertEqual(resp.json()['code'], 'CMS_VALIDATION_ERROR') def test_soft_delete(self): u = create_test_user(phone='13922200060', role_type='student') - resp = self.client.delete(u_detail(u.id)) - self.assertEqual(resp.status_code, 204, resp.content) + resp = self.client.post(u_disable(u.id)) + self.assertEqual(resp.status_code, 200, resp.content) self.assertFalse(User.objects.filter(id=u.id).exists()) # 默认管理器过滤 obj = User.all_objects.get(id=u.id) self.assertTrue(obj.is_deleted) # 实际未物删 @@ -130,7 +138,7 @@ class CmsUserCrudTest(CacheTestCase): def test_recreate_soft_deleted_phone_returns_400(self): """软删后用相同手机号重建:返回 400 CMS_USER_PHONE_EXISTS(不产生重复行)。""" u = create_test_user(phone='13922200061', role_type='student', institution=self.inst) - self.client.delete(u_detail(u.id)) + self.client.post(u_disable(u.id)) self.assertFalse(User.objects.filter(phone='13922200061').exists()) resp = self.client.post(CMS_USER_URL, { 'phone': '13922200061', 'real_name': '重建', 'role_type': 'student', diff --git a/test/test_mobile_training_stats.py b/test/test_mobile_training_stats.py new file mode 100644 index 0000000..c361863 --- /dev/null +++ b/test/test_mobile_training_stats.py @@ -0,0 +1,178 @@ +"""移动端个人中心 - 训练统计/智能分析三接口测试。 + +training_record 是 managed=False 只读表,迁移里的占位结构缺新列,故在 setUpClass 里 +按真实列重建该表(仅测试库),用 TransactionTestCase 允许 DDL。 +""" +import json + +from django.core.cache import cache +from django.db import connection +from django.test import TransactionTestCase +from django.utils import timezone +from rest_framework.test import APIClient + +from apps.user.models import Department, Institution, User +from apps.case.models import CaseBase +from .conftest import create_test_user, get_auth_client, ensure_institution + +CREATE_TR = """ +CREATE TABLE training_record ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NULL, case_id BIGINT NULL, teacher_id BIGINT NULL, + training_mode VARCHAR(50) NULL, case_type VARCHAR(30) NULL, + start_time DATETIME NULL, end_time DATETIME NULL, duration_seconds INT NULL, + total_score DECIMAL(5,2) NULL, ai_score DECIMAL(5,2) NULL, teacher_score DECIMAL(5,2) NULL, + evaluation_level VARCHAR(20) NULL, status VARCHAR(30) NULL, + feedback TEXT NULL, thinking_chain TEXT NULL, diagnosis_path TEXT NULL, + wrong_points JSON NULL, missed_questions JSON NULL, recommendation_result JSON NULL, + ai_feedback_structured JSON NULL, osce_station_score JSON NULL, + interruption_count INT NULL, emotion_analysis JSON NULL, + prompt_version VARCHAR(50) NULL, rag_context_version VARCHAR(50) NULL, + external_user_id VARCHAR(128) NULL, session_id BIGINT NULL, evaluation_record_id BIGINT NULL, + score_type VARCHAR(20) NULL, pdf_file_path VARCHAR(512) NULL, + created_at DATETIME NULL, updated_at DATETIME NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 +""" + +COMP_URL = '/api/user/competency-metrics/' +LIST_URL = '/api/user/training-records/' +ANALYSIS_URL = '/api/user/analysis/' + + +def _dim(name, score, mx): + return {'dimension': name, 'score': score, 'max_score': mx} + + +# 两条记录的维度评分(得分率:见注释) +DIMS_98 = [_dim('信息获取', 20, 25), _dim('体格检查', 8, 10), _dim('检查决策', 9, 10), + _dim('诊断推理', 18, 20), _dim('治疗决策', 8, 10), _dim('医患沟通', 7, 10)] # 80/80/90/90/80/70 +DIMS_80 = [_dim('信息获取', 20, 25), _dim('体格检查', 8, 10), _dim('检查决策', 8, 10), + _dim('诊断推理', 16, 20), _dim('治疗决策', 8, 10), _dim('医患沟通', 8, 10)] # 80/80/80/80/80/80 + + +class TrainingStatsTest(TransactionTestCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + with connection.cursor() as c: + c.execute('SET FOREIGN_KEY_CHECKS=0') + c.execute('DROP TABLE IF EXISTS training_record') + c.execute(CREATE_TR) + c.execute('SET FOREIGN_KEY_CHECKS=1') + + def _insert(self, user_id, case_id, total_score, duration, dims, status='completed', end_time=None, + score_type='percentage'): + end_time = end_time or timezone.now() + with connection.cursor() as c: + c.execute( + "INSERT INTO training_record (user_id, case_id, training_mode, case_type, status, " + "total_score, duration_seconds, end_time, start_time, score_type, evaluation_level, " + "ai_feedback_structured, pdf_file_path, created_at, updated_at) " + "VALUES (%s,%s,'practice','diagnosis_treatment',%s,%s,%s,%s,%s,%s,'good',%s,%s,%s,%s)", + [user_id, case_id, status, total_score, duration, end_time, end_time, score_type, + json.dumps({'dimension_scores': dims}, ensure_ascii=False), + '/app/storage/reports/r.pdf', end_time, end_time], + ) + return c.lastrowid + + def setUp(self): + cache.clear() + with connection.cursor() as c: + c.execute('DELETE FROM training_record') + self.inst = ensure_institution(name='测试医院', code='TS-H1') + self.dept = Department.objects.create(name='心内科', category='临床') + self.case = CaseBase.objects.create(title='急性心肌梗死', case_type='traditional', department=self.dept) + self.user = create_test_user(phone='13955500001', role_type='student', institution=self.inst) + self.other = create_test_user(phone='13955500002', role_type='student', institution=self.inst) + self.client = get_auth_client(self.user) + # 本人 2 条已完成 + 1 条进行中;他人 1 条 + now = timezone.now() + self.rec98 = self._insert(self.user.id, self.case.id, 98, 3600, DIMS_98, end_time=now) + self.rec80 = self._insert(self.user.id, self.case.id, 80, 1800, DIMS_80, end_time=now - timezone.timedelta(days=1)) + self._insert(self.user.id, self.case.id, 50, 600, DIMS_80, status='in_progress') + self._insert(self.other.id, self.case.id, 10, 600, DIMS_80) + + # ── 4.3 临床核心能力指标 ────────────────────────────────────────────────── + def test_competency_metrics(self): + resp = self.client.get(COMP_URL) + self.assertEqual(resp.status_code, 200, resp.content) + d = resp.json() + self.assertEqual(d['completed_cases'], 2) # 仅本人已完成,排除进行中/他人 + self.assertEqual(d['total_hours'], 1.5) # (3600+1800)/3600 + self.assertEqual(d['avg_score'], 89.0) # avg(98,80) + self.assertEqual(d['diagnosis_accuracy'], 85) # 诊断推理 avg(90%,80%) + + def test_competency_requires_auth(self): + self.assertEqual(APIClient().get(COMP_URL).status_code, 401) + + def test_score_type_normalized(self): + # 五分制(0-5)记录应 ×20 归一到百分制后再与百分制平均,避免量纲混算 + with connection.cursor() as c: + c.execute('DELETE FROM training_record') + u = create_test_user(phone='13955500007', role_type='student', institution=self.inst) + self._insert(u.id, self.case.id, 90, 3600, DIMS_80, score_type='percentage') # 90 分 + self._insert(u.id, self.case.id, 4, 3600, DIMS_80, score_type='five_point') # 4×20=80 分 + client = get_auth_client(u) + comp = client.get(COMP_URL).json() + self.assertEqual(comp['avg_score'], 85.0) # avg(90, 80) 而非 avg(90, 4)=47 + ana = client.get(ANALYSIS_URL).json() + self.assertEqual(ana['current_score'], 85.0) + self.assertTrue(all(0 <= x['score'] <= 100 for x in ana['recent_trend'])) + + # ── 4.4 训练记录(统计信息)────────────────────────────────────────────── + def test_training_records(self): + resp = self.client.get(LIST_URL) + self.assertEqual(resp.status_code, 200, resp.content) + d = resp.json() + self.assertEqual(d['count'], 2) + self.assertEqual(d['summary'], {'total_cases': 2, 'total_hours': 1.5, 'avg_accuracy': 85}) + first = d['results'][0] # 按 end_time 倒序 → 98 分那条 + self.assertEqual(first['score'], 98.0) + self.assertEqual(first['case_title'], '急性心肌梗死') + self.assertEqual(first['department'], '心内科') + self.assertEqual(first['score_type'], 'percentage') + self.assertNotIn('pdf_file_path', first) # fastapi 内部路径,Django 不返回 + + def test_training_records_search(self): + resp = self.client.get(LIST_URL, {'search': '心肌'}) + self.assertEqual(resp.json()['count'], 2) + resp = self.client.get(LIST_URL, {'search': '不存在的病例'}) + self.assertEqual(resp.json()['count'], 0) + + # ── 4.5 智能分析(关联评价表)───────────────────────────────────────────── + def test_analysis(self): + resp = self.client.get(ANALYSIS_URL) + self.assertEqual(resp.status_code, 200, resp.content) + d = resp.json() + self.assertEqual(d['current_score'], 89.0) + radar = {x['dimension']: x['score'] for x in d['radar']} + self.assertEqual(set(radar), {'病史采集', '查体能力', '检查决策', '诊断能力', '治疗决策', '医患沟通'}) + self.assertEqual(radar['病史采集'], 80) # avg(80,80) + self.assertEqual(radar['检查决策'], 85) # avg(90,80) + self.assertEqual(radar['诊断能力'], 85) # 诊断推理 avg(90,80) + self.assertEqual(radar['医患沟通'], 75) # avg(70,80) → 最低 + self.assertEqual(d['weak_dimensions'], ['医患沟通']) + self.assertIn('医患沟通', d['comment']) # 强/弱不同 → 走对比文案 + self.assertIn('突出', d['comment']) + + def test_analysis_balanced_single_record(self): + # 单条记录、各维度并列:强==弱,文案应走「均衡」分支而非自相矛盾 + with connection.cursor() as c: + c.execute('DELETE FROM training_record') + u = create_test_user(phone='13955500008', role_type='student', institution=self.inst) + flat = [_dim('信息获取', 8, 10), _dim('诊断推理', 8, 10), _dim('治疗决策', 8, 10)] # 全 80% + self._insert(u.id, self.case.id, 80, 1800, flat) + d = get_auth_client(u).get(ANALYSIS_URL).json() + # 最强与最弱同分,不应出现「您的X表现突出,但X仍有提升」式自相矛盾 + self.assertNotIn('表现突出', d['comment']) + self.assertIn('均衡', d['comment']) + + def test_analysis_empty_user(self): + # 无训练记录的用户 → 全 0、不报错 + client = get_auth_client(self.other) if False else None + u = create_test_user(phone='13955500009', role_type='student', institution=self.inst) + resp = get_auth_client(u).get(ANALYSIS_URL) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(resp.json()['current_score'], 0) + self.assertEqual(resp.json()['radar'][0]['score'], 0)