feat: update personal stats and cms change reuqest method
This commit is contained in:
@@ -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')
|
||||
|
||||
+17
-4
@@ -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,
|
||||
|
||||
+15
-5
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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'),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+14
-6
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user