234 lines
9.4 KiB
Python
234 lines
9.4 KiB
Python
|
|
"""移动端病例列表(首页 / 病例页 5 个入口)。
|
|||
|
|
|
|||
|
|
均读 `case_base`,只取**已发布**病例(publish_status=2 & status=1 & is_deleted=0):
|
|||
|
|
|
|||
|
|
- 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 暂无指派,先同教学互动)
|
|||
|
|
|
|||
|
|
个性化/薄弱口径读只读表 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 推荐病例(个性化)────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
def _recommend_score(case, *, dept_id, trained_ids, weak_dims):
|
|||
|
|
"""个性化推荐分:未训练 > 同科室 > 命中薄弱能力标签。"""
|
|||
|
|
score = 0
|
|||
|
|
if case.id not in trained_ids:
|
|||
|
|
score += 2
|
|||
|
|
if dept_id and case.department_id == dept_id:
|
|||
|
|
score += 1
|
|||
|
|
if weak_dims and case.competency_tags:
|
|||
|
|
tags = ' '.join(str(t) for t in case.competency_tags)
|
|||
|
|
if any(w and w in tags for w in weak_dims):
|
|||
|
|
score += 1
|
|||
|
|
return score
|
|||
|
|
|
|||
|
|
|
|||
|
|
@extend_schema(summary='推荐病例(个性化)', tags=['病例列表'])
|
|||
|
|
@api_view(['GET'])
|
|||
|
|
@permission_classes([IsAuthenticated])
|
|||
|
|
def recommended(request):
|
|||
|
|
user = request.user
|
|||
|
|
qs = _apply_common_filters(_published(), request)
|
|||
|
|
cases = list(qs.select_related('department'))
|
|||
|
|
|
|||
|
|
trained_ids = set(
|
|||
|
|
_completed_records(user).values_list('case_id', flat=True)
|
|||
|
|
)
|
|||
|
|
weak_dims = [str(w) for w in (user.weak_dimensions or [])]
|
|||
|
|
dept_id = user.department_id
|
|||
|
|
|
|||
|
|
cases.sort(
|
|||
|
|
key=lambda c: (
|
|||
|
|
_recommend_score(c, dept_id=dept_id, trained_ids=trained_ids, weak_dims=weak_dims),
|
|||
|
|
c.created_at or c.id,
|
|||
|
|
),
|
|||
|
|
reverse=True,
|
|||
|
|
)
|
|||
|
|
return _paginate(cases, 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)
|