Files
medical_training/apps/case/mobile.py
T

234 lines
9.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""移动端病例列表(首页 / 病例页 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_recorduser_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)