Files
medical_training/apps/case/cms.py
T
2026-06-12 17:19:23 +08:00

230 lines
13 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.
"""CMS 病例库 + AI 病例生成 + 病例审核。
挂载于 `/api/cms/cases/`,仅 GET/POSTCMS v0.6 约定:查=GET,增删改=POST)。三类角色共用:
- **超级管理员**:全平台病例,建/改/导入/AI生成/编辑关联/提交/停用;**不做病例审核发布**。
- **内容管理员**:仅本机构(`institution`)病例,可建/导入/AI生成/编辑关联/提交/停用;不审核发布。
- **医院管理员**:仅本机构病例,做**病例审核**——查看 + 发布(正常 → 已发布);不建/改。
按设计文档实现(已为 `case_base` 加 `institution` 外键、`is_deleted` 软删除):
- 数据范围以 `institution` 收口(内容/医院管理员仅本院)。
- 病例状态机 `publish_status`0 草稿 →[提交]→ 1 正常 →[医院管理员发布]→ 2 已发布。
- 停用/下架 = **软删除**`is_deleted=1`,默认管理器自动过滤);编辑关联可改所属**机构 + 科室**。
"""
import logging
from django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiResponse, inline_serializer
from rest_framework import viewsets, filters, status, serializers as drf_serializers
from rest_framework.decorators import action
from rest_framework.parsers import MultiPartParser
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from config.exceptions import AppError
from apps.cms.permissions import (
IsSuperContentOrHospitalAdmin, is_super, is_content_admin, is_hospital_admin,
)
from apps.user.models import Institution, Department
from apps.user.throttling import PdfParseUserThrottle
from .models import CaseBase
from .serializers import CaseBaseListSerializer
from .services import case_importer
from .services.case_writer import create_case_full, build_full_response
audit = logging.getLogger('audit')
@extend_schema_view(
list=extend_schema(summary='CMS-CASE-1 病例列表', tags=['CMS-病例']),
create=extend_schema(summary='CMS-CASE-3 表单新增病例(草稿)', tags=['CMS-病例']),
)
class CmsCaseViewSet(viewsets.ModelViewSet):
"""CMS 病例库 + 审核。超管全平台、内容/医院管理员仅本机构(`institution`)。"""
serializer_class = CaseBaseListSerializer
permission_classes = [IsAuthenticated, IsSuperContentOrHospitalAdmin]
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
filterset_fields = ['case_type', 'publish_status', 'department', 'institution', 'status', 'osce_enabled']
search_fields = ['title', 'chief_complaint', 'tags', 'icd_codes']
ordering_fields = ['created_at', 'difficulty_score', 'estimated_minutes']
# 仅 GET / POST:查=GET,增删改=POST(编辑关联→{id}/relations/,停用→{id}/disable/,发布→{id}/publish/
http_method_names = ['get', 'post', 'head', 'options']
def get_queryset(self):
# 默认管理器已过滤软删(is_deleted=False
qs = CaseBase.objects.select_related('institution', 'department', 'created_by').all().order_by('-created_at')
user = self.request.user
if is_super(user):
return qs
# 内容管理员 / 医院管理员:仅本机构
return qs.filter(institution_id=user.institution_id)
# ── 角色守卫 ─────────────────────────────────────────────────────────
def _require_editor(self):
"""建/改/导入/AI生成/提交/停用:仅超管或内容管理员。"""
if not (is_super(self.request.user) or is_content_admin(self.request.user)):
raise AppError('CMS_PERMISSION_DENIED', '仅超级管理员 / 内容管理员可操作病例内容', status_code=403)
def _require_publisher(self):
"""审核发布:仅医院管理员(超级管理员不做病例审核)。"""
if not is_hospital_admin(self.request.user):
raise AppError('CMS_PERMISSION_DENIED', '病例审核发布仅医院管理员可操作', status_code=403)
def _resolve_institution(self):
"""新建病例的所属机构:内容管理员强制本院;超管可传 institution_id,缺省落本院。"""
user = self.request.user
if is_content_admin(user):
return user.institution
inst_id = self.request.data.get('institution_id')
if inst_id in (None, ''):
return user.institution
inst = Institution.objects.filter(id=inst_id).first()
if not inst:
raise AppError('CASE_INSTITUTION_NOT_FOUND', f'机构 id={inst_id} 不存在', status_code=400)
return inst
# ── CMS-CASE-3 表单新增(草稿)/ CMS-CASE-1 列表 ──────────────────────
def create(self, request, *args, **kwargs):
self._require_editor()
case, n_rules, n_items = create_case_full(request.data, request.user, institution=self._resolve_institution())
audit.info('CMS_CASE_CREATE case_id=%s by=%s inst=%s scoring_rules=%d exam_items=%d',
case.id, request.user.id, case.institution_id, n_rules, n_items)
return Response(build_full_response(case), status=status.HTTP_201_CREATED)
# list 用默认实现(默认管理器已排除软删;医院管理员审核用 ?publish_status=1 取待审核)
# ── CMS-CASE-7 / CMS-AUDIT-2 病例查看(完整结构)─────────────────────
@extend_schema(summary='CMS-CASE-7 病例查看(完整结构)', tags=['CMS-病例'])
@action(detail=True, methods=['get'])
def full(self, request, pk=None):
return Response(build_full_response(self.get_object()))
# ── CMS-CASE-2 PDF 导入(解析预览,不落库)────────────────────────────
@extend_schema(
summary='CMS-CASE-2 PDF 导入(解析预览)',
request={'multipart/form-data': {'type': 'object', 'properties': {
'files': {'type': 'array', 'items': {'type': 'string', 'format': 'binary'}},
'case_type': {'type': 'string', 'enum': ['traditional', 'teaching']},
}, 'required': ['files', 'case_type']}},
responses={200: OpenApiResponse(description='解析结果(parse_id + data')},
tags=['CMS-病例'],
)
@action(detail=False, methods=['post'], url_path='import-pdf',
parser_classes=[MultiPartParser], throttle_classes=[PdfParseUserThrottle])
def import_pdf(self, request):
"""解析 PDF → 结构化病例数据(不落库;前端审核后调 CMS-CASE-3 入库)。"""
self._require_editor()
files = request.FILES.getlist('files')
case_type = request.data.get('case_type', '')
return Response(case_importer.parse_pdf(files, case_type, request.user))
# ── CMS-CASE-AI-1 AI 生成病例内容(不落库)───────────────────────────
@extend_schema(
summary='CMS-CASE-AI-1 AI 生成病例内容',
request=inline_serializer('CmsCaseAiGenerateRequest', fields={
'prompt': drf_serializers.CharField(help_text='病例长描述 prompt'),
'case_type': drf_serializers.ChoiceField(choices=['traditional', 'teaching']),
}),
responses={200: OpenApiResponse(description='生成结果(parse_id + data')},
tags=['CMS-病例'],
)
@action(detail=False, methods=['post'], url_path='ai-generate',
throttle_classes=[PdfParseUserThrottle])
def ai_generate(self, request):
"""一段长 prompt → DeepSeek 按病例模板生成内容(与 PDF 导入同构,不落库)。"""
self._require_editor()
prompt = request.data.get('prompt', '')
case_type = request.data.get('case_type', '')
return Response(case_importer.generate_from_prompt(prompt, case_type, request.user))
# ── CMS-CASE-4 编辑关联信息(改所属机构 / 科室)──────────────────────
@extend_schema(
summary='CMS-CASE-4 编辑关联信息(改所属机构 / 科室)',
request=inline_serializer('CmsCaseRelationsRequest', fields={
'institution_id': drf_serializers.IntegerField(required=False, allow_null=True),
'department_id': drf_serializers.IntegerField(required=False, allow_null=True),
'department_name': drf_serializers.CharField(required=False),
}),
tags=['CMS-病例'],
)
@action(detail=True, methods=['post'])
def relations(self, request, pk=None):
"""改病例「属于哪个机构 / 科室」,不改病例内容。"""
self._require_editor()
case = self.get_object()
data = request.data
changed = []
if 'institution_id' in data:
inst_id = data.get('institution_id')
if inst_id in (None, ''):
case.institution = None
else:
inst = Institution.objects.filter(id=inst_id).first()
if not inst:
raise AppError('CASE_INSTITUTION_NOT_FOUND', f'机构 id={inst_id} 不存在', status_code=400)
case.institution = inst
changed.append('institution')
if 'department_id' in data:
dept_id = data.get('department_id')
if dept_id in (None, ''):
case.department = None
else:
dept = Department.objects.filter(id=dept_id).first()
if not dept:
raise AppError('CASE_DEPARTMENT_NOT_FOUND', f'科室 id={dept_id} 不存在', status_code=400)
case.department = dept
changed.append('department')
elif 'department_name' in data:
from .services.department_resolver import resolve_department
case.department = resolve_department(data.get('department_name', ''))
changed.append('department')
if not changed:
raise AppError('CASE_VALIDATION_ERROR', '请提供 institution_id 或 department_id/department_name', status_code=400)
case.save(update_fields=changed + ['updated_at'])
audit.info('CMS_CASE_RELATIONS case_id=%s by=%s inst=%s dept=%s',
case.id, request.user.id, case.institution_id, case.department_id)
return Response({
'message': '关联信息已更新', 'id': case.id,
'institution': case.institution_id,
'institution_name': case.institution.name if case.institution else None,
'department': case.department_id,
'department_name': case.department.name if case.department else None,
})
# ── CMS-CASE-5 提交(草稿 → 正常)────────────────────────────────────
@extend_schema(summary='CMS-CASE-5 提交(草稿 → 正常)', tags=['CMS-病例'])
@action(detail=True, methods=['post'])
def submit(self, request, pk=None):
self._require_editor()
case = self.get_object()
if case.publish_status != 0:
raise AppError('CASE_NOT_SUBMITTABLE', '仅草稿可提交', status_code=400)
case.publish_status = 1 # 正常(待医院管理员审核发布)
case.save(update_fields=['publish_status', 'updated_at'])
audit.info('CMS_CASE_SUBMIT case_id=%s by=%s', case.id, request.user.id)
return Response({'message': '已提交', 'id': case.id, 'publish_status': case.publish_status})
# ── CMS-CASE-6 停用 / 重录(下架 = 软删除)──────────────────────────
@extend_schema(summary='CMS-CASE-6 停用 / 重录(下架)', tags=['CMS-病例'])
@action(detail=True, methods=['post'])
def disable(self, request, pk=None):
"""停用 = 下架 = 软删除(is_deleted=1),不物理删除。"""
self._require_editor()
case = self.get_object()
case.delete() # SoftDeleteModel:置 is_deleted=True, deleted_at=now
audit.info('CMS_CASE_DISABLE case_id=%s by=%s', case.id, request.user.id)
return Response({'message': '已下架', 'id': case.id})
# ── CMS-AUDIT-3 发布(正常 → 已发布,仅医院管理员审核)───────────────
@extend_schema(summary='CMS-AUDIT-3 发布(正常 → 已发布)', tags=['CMS-病例审核'])
@action(detail=True, methods=['post'])
def publish(self, request, pk=None):
"""医院管理员审核发布:正常(1) → 已发布(2)。发布后对本院移动端医学生可见。超管不做审核。"""
self._require_publisher()
case = self.get_object()
if case.publish_status != 1:
raise AppError('CASE_NOT_PUBLISHABLE', '仅「正常」状态病例可发布', status_code=400)
case.publish_status = 2 # 已发布
case.save(update_fields=['publish_status', 'updated_at'])
audit.info('CMS_CASE_PUBLISH case_id=%s by=%s', case.id, request.user.id)
return Response({'message': '已发布', 'id': case.id, 'publish_status': case.publish_status})