feat: update cms case api

This commit is contained in:
2026-06-12 17:19:23 +08:00
parent 2fab2be0a1
commit 8fecaeeb54
14 changed files with 1375 additions and 237 deletions
+229
View File
@@ -0,0 +1,229 @@
"""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})