"""移动端病例列表(首页 / 病例页 5 个入口)。 - 5.1 推荐病例 GET /api/case/mobile/recommended/ ← **返回病例库全部已发布病例**(不做个性化) - 5.2 科室专项 GET /api/case/mobile/specialty/ - 5.3 薄弱环节 GET /api/case/mobile/weak/ - 5.4 教学互动 GET /api/case/mobile/teaching/ - 5.5 教师任务 GET /api/case/mobile/teacher-task/(CMS 暂无指派,先同教学互动) 五个入口均只取**已发布**病例(publish_status=2 & status=1 & is_deleted=0); 5.1 推荐返回全部已发布病例、按创建时间倒序(不再做个性化推荐排序)。 薄弱口径读只读表 training_record(user_id=当前用户、status='completed'),分数按 score_type 归一百分制后比较,与《个人中心》一致。 """ from django.db.models import Q from rest_framework import serializers 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 from .models import CaseBase # 薄弱阈值:归一百分制下,最好成绩低于该分数视为「薄弱病例」 WEAK_SCORE_THRESHOLD = 70 class MobilePagination(PageNumberPagination): page_size = 20 page_size_query_param = 'page_size' max_page_size = 100 class MobileCaseSerializer(serializers.ModelSerializer): """移动端病例列表元素(统一字段)。""" case_type_display = serializers.CharField(source='get_case_type_display', read_only=True) department_name = serializers.CharField(source='department.name', read_only=True) # 仅「薄弱环节」会注入;其余接口为 None my_best_score = serializers.SerializerMethodField() my_train_count = serializers.SerializerMethodField() class Meta: model = CaseBase fields = [ 'id', 'title', 'case_type', 'case_type_display', 'difficulty', 'difficulty_score', 'department', 'department_name', 'chief_complaint', 'description', 'patient_age', 'patient_gender', 'tags', 'competency_tags', 'estimated_minutes', 'osce_enabled', 'created_at', 'my_best_score', 'my_train_count', ] def _stat(self, obj): return (self.context.get('case_stats') or {}).get(obj.id) def get_my_best_score(self, obj): s = self._stat(obj) return s['best_score'] if s else None def get_my_train_count(self, obj): s = self._stat(obj) return s['train_count'] if s else None # ── 公共工具 ────────────────────────────────────────────────────────────── def _published(): """已发布病例基集(已过滤 is_deleted)。""" return CaseBase.objects.filter(publish_status=2, status=1) def _apply_common_filters(qs, request, *, allow_department=True): """通用查询参数:search / case_type / difficulty / department。""" p = request.query_params search = (p.get('search') or '').strip() if search: qs = qs.filter( Q(title__icontains=search) | Q(chief_complaint__icontains=search) | Q(tags__icontains=search) ) if p.get('case_type'): qs = qs.filter(case_type=p['case_type']) if p.get('difficulty'): qs = qs.filter(difficulty=p['difficulty']) if allow_department and p.get('department'): qs = qs.filter(department_id=p['department']) return qs def _completed_records(user): """当前用户已完成训练记录(只读表,不 JOIN,仅取 case_id/分数)。""" return TrainingRecord.objects.filter(user_id=user.id, status='completed') def _norm_score(score, score_type): if score is None: return None return float(score) * 20 if score_type == 'five_point' else float(score) def _user_case_stats(user): """{case_id: {best_score, train_count}}(归一百分制)。""" stats = {} rows = _completed_records(user).values('case_id', 'total_score', 'score_type') for r in rows: cid = r['case_id'] if cid is None: continue s = _norm_score(r['total_score'], r['score_type']) cur = stats.setdefault(cid, {'best_score': None, 'train_count': 0}) cur['train_count'] += 1 if s is not None and (cur['best_score'] is None or s > cur['best_score']): cur['best_score'] = round(s, 1) return stats def _paginate(qs, request, *, case_stats=None): paginator = MobilePagination() page = paginator.paginate_queryset(qs, request) ctx = {'request': request, 'case_stats': case_stats or {}} data = MobileCaseSerializer(page, many=True, context=ctx).data return paginator.get_paginated_response(data) # ── 5.1 推荐病例(返回病例库全部已发布病例)──────────────────────────────────── @extend_schema(summary='推荐病例(返回病例库全部已发布病例)', tags=['病例列表']) @api_view(['GET']) @permission_classes([IsAuthenticated]) def recommended(request): """返回病例库**全部已发布病例**(publish_status=2 & status=1 & is_deleted=0), 支持通用筛选(search / case_type / difficulty / department),按创建时间倒序。""" qs = _apply_common_filters(_published(), request) \ .select_related('department').order_by('-created_at', '-id') return _paginate(qs, request) # ── 5.2 科室专项 ──────────────────────────────────────────────────────────── @extend_schema(summary='科室专项病例', tags=['病例列表']) @api_view(['GET']) @permission_classes([IsAuthenticated]) def specialty(request): qs = _apply_common_filters(_published(), request, allow_department=False) dept = request.query_params.get('department') or request.user.department_id if dept: qs = qs.filter(department_id=dept) qs = qs.select_related('department').order_by('-created_at', '-id') return _paginate(qs, request) # ── 5.3 薄弱环节 ──────────────────────────────────────────────────────────── @extend_schema(summary='薄弱环节病例', tags=['病例列表']) @api_view(['GET']) @permission_classes([IsAuthenticated]) def weak(request): user = request.user stats = _user_case_stats(user) weak_ids = [cid for cid, s in stats.items() if s['best_score'] is not None and s['best_score'] < WEAK_SCORE_THRESHOLD] base = _apply_common_filters(_published(), request).select_related('department') if weak_ids: cases = list(base.filter(id__in=weak_ids)) # worst-first:最好成绩越低越靠前 cases.sort(key=lambda c: stats[c.id]['best_score']) return _paginate(cases, request, case_stats=stats) # 冷启动:无低分记录 → 回退到命中用户薄弱能力标签的已发布病例(competency_tags 为 JSON,按 Python 匹配) weak_dims = [str(w) for w in (user.weak_dimensions or []) if w] cases = [] if weak_dims: for c in base.order_by('-created_at', '-id'): tags = ' '.join(str(t) for t in (c.competency_tags or [])) if any(w in tags for w in weak_dims): cases.append(c) return _paginate(cases, request, case_stats=stats) # ── 5.4 教学互动 ──────────────────────────────────────────────────────────── @extend_schema(summary='教学互动病例', tags=['病例列表']) @api_view(['GET']) @permission_classes([IsAuthenticated]) def teaching(request): qs = _apply_common_filters( _published().filter(case_type='teaching'), request ).select_related('department').order_by('-created_at', '-id') return _paginate(qs, request) # ── 5.5 教师任务(暂同教学互动)────────────────────────────────────────────── @extend_schema( summary='教师任务病例', tags=['病例列表'], description='CMS 暂无「指派病例」功能,先与教学互动一致(已发布 case_type=teaching)。', ) @api_view(['GET']) @permission_classes([IsAuthenticated]) def teacher_task(request): qs = _apply_common_filters( _published().filter(case_type='teaching'), request ).select_related('department').order_by('-created_at', '-id') return _paginate(qs, request)