"""CMS 病例库 + AI 病例生成 + 病例审核。 挂载于 `/api/cms/cases/`,仅 GET/POST(CMS 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})