import logging from django.db import transaction from drf_spectacular.utils import extend_schema, 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.response import Response from django_filters.rest_framework import DjangoFilterBackend from config.exceptions import AppError from apps.user.permissions import IsCaseOperationPermitted from apps.user.throttling import PdfParseUserThrottle, ScoringRuleGenerateUserThrottle from .models import ( CaseBase, TraditionalCase, ScriptCase, TeachingCase, CaseStage, ScoringRule, CaseExamItem, ) from .serializers import ( CaseBaseListSerializer, CaseBaseDetailSerializer, CaseBaseCreateSerializer, TraditionalCaseSerializer, ScriptCaseSerializer, TeachingCaseSerializer, CaseStageSerializer, ScoringRuleSerializer ) from .services import case_importer, scoring_rule_generator from .services.department_resolver import resolve_department from .services.exam_items import ( normalize_exam_items, assert_no_duplicate_exam_items, EXAM_ITEM_FIELDS, ) audit = logging.getLogger('audit') TRADITIONAL_FIELDS = {'standard_diagnosis', 'standard_treatment', 'guideline_reference'} TEACHING_FIELDS = {'teaching_goal', 'discussion_questions', 'teacher_guide', 'scoring_focus'} SCORING_RULE_FIELDS = { 'dimension', 'competency_dimension', 'score_weight', 'ai_auto_score', 'osce_dimension', 'scoring_standard', 'rubric_json', } CASE_BASE_FIELDS = { 'title', 'case_type', 'difficulty', 'difficulty_score', 'chief_complaint', 'description', 'patient_age', 'patient_gender', 'tags', 'symptom_tags', 'disease_tags', 'competency_tags', 'guideline_tags', 'knowledge_points', 'icd_codes', 'estimated_minutes', 'osce_enabled', 'rag_enabled', 'ai_prompt_template', 'multimodal_assets', } class CaseBaseViewSet(viewsets.ModelViewSet): """病例管理""" queryset = CaseBase.objects.all() filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] filterset_fields = [ 'case_type', 'difficulty', 'department', 'publish_status', 'status', 'osce_enabled' ] search_fields = ['title', 'chief_complaint', 'tags', 'icd_codes'] ordering_fields = ['created_at', 'difficulty_score', 'estimated_minutes'] def get_serializer_class(self): if self.action == 'list': return CaseBaseListSerializer elif self.action == 'create': return CaseBaseCreateSerializer return CaseBaseDetailSerializer # ── C1: parse-pdf ──────────────────────────────────────────────────── @extend_schema( summary='C1: PDF 解析', description='上传 1~5 份 PDF,调用 DeepSeek 提取结构化病例数据。不落库,不含评分规则。', 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=['病例'], ) @action( detail=False, methods=['post'], url_path='parse-pdf', parser_classes=[MultiPartParser], permission_classes=[IsCaseOperationPermitted], throttle_classes=[PdfParseUserThrottle], ) def parse_pdf(self, request): """C1: PDF 解析 → 结构化数据(不落库,不含评分规则)""" files = request.FILES.getlist('files') case_type = request.data.get('case_type', '') result = case_importer.parse_pdf(files, case_type, request.user) return Response(result) # ── C2: generate-scoring-rules ─────────────────────────────────────── @extend_schema( summary='C2: AI 生成评分规则预览', description='传入病例数据 JSON,调用 DeepSeek 生成评分规则。不落库,返回规则列表供前端审核。', request=inline_serializer('GenerateScoringRulesRequest', fields={ 'case_type': drf_serializers.ChoiceField(choices=['traditional', 'teaching'], help_text='病例类型'), 'title': drf_serializers.CharField(help_text='病例标题'), 'chief_complaint': drf_serializers.CharField(required=False, help_text='主诉'), 'traditional': drf_serializers.DictField(required=False, help_text='传统病例子表(case_type=traditional 时传)'), 'teaching': drf_serializers.DictField(required=False, help_text='教学病例子表(case_type=teaching 时传)'), }), responses={200: OpenApiResponse(description='评分规则列表 + AI 用量信息')}, tags=['病例'], ) @action( detail=False, methods=['post'], url_path='generate-scoring-rules', permission_classes=[IsCaseOperationPermitted], throttle_classes=[ScoringRuleGenerateUserThrottle], ) def generate_scoring_rules(self, request): """C2: AI 生成评分规则预览(不落库)""" result = scoring_rule_generator.generate(request.data) audit.info( 'CASE_SCORING_RULE_PREVIEW user=%s case_type=%s rules=%d prompt_version=%s', request.user.id, request.data.get('case_type', ''), len(result['scoring_rules']), result['prompt_version'], ) return Response({ 'generated': len(result['scoring_rules']), 'ai_usage': result['usage'], 'prompt_version': result['prompt_version'], 'scoring_rules': result['scoring_rules'], }) # ── C3: full-create ────────────────────────────────────────────────── @extend_schema( summary='C3: 创建病例(统一落库入口)', description='病例主表 + 子表 + scoring_rules(≥1 条)同一事务入库。scoring_rules 必填。', request=inline_serializer('FullCreateRequest', fields={ 'title': drf_serializers.CharField(help_text='病例标题'), 'case_type': drf_serializers.ChoiceField(choices=['traditional', 'teaching']), 'department_name': drf_serializers.CharField(required=False, help_text='科室名称(后端解析为 department_id)'), 'traditional': drf_serializers.DictField(required=False), 'teaching': drf_serializers.DictField(required=False), 'scoring_rules': drf_serializers.ListField(child=drf_serializers.DictField(), help_text='评分规则(≥1 条,必填)'), 'exam_items': drf_serializers.ListField( child=drf_serializers.DictField(), required=False, help_text='检查项(可选;同一病例 item_code 不可重复)', ), 'parse_id': drf_serializers.CharField(required=False, help_text='来自 parse-pdf 的 parse_id(审计用)'), 'auto_publish': drf_serializers.BooleanField(required=False, default=False), }), responses={201: OpenApiResponse(description='完整病例结构(同 GET full)')}, tags=['病例'], ) @action( detail=False, methods=['post'], url_path='full-create', permission_classes=[IsCaseOperationPermitted], ) def full_create(self, request): """C3: 创建病例(主表+子表+评分规则同一事务)""" data = request.data case_type = data.get('case_type', '') if case_type not in ('traditional', 'teaching'): raise AppError('CASE_TYPE_NOT_SUPPORTED', f'case_type 不支持: {case_type}', status_code=400) if 'stages' in data: raise AppError('CASE_FIELD_NOT_ALLOWED', '本接口不接收 stages 字段', status_code=400) sub_data = data.get(case_type) other_type = 'teaching' if case_type == 'traditional' else 'traditional' if not sub_data: raise AppError('CASE_SUBTYPE_REQUIRED', f'{case_type} 子表数据缺失', status_code=400) if data.get(other_type): raise AppError('CASE_SUBTYPE_CONFLICT', '不允许同时传入两种子表数据', status_code=400) scoring_rules_data = data.get('scoring_rules', []) if not scoring_rules_data: raise AppError('CASE_VALIDATION_ERROR', 'scoring_rules 必填且至少 1 条', status_code=400) _validate_scoring_rules(scoring_rules_data) exam_items_data = normalize_exam_items(data.get('exam_items') or []) assert_no_duplicate_exam_items(exam_items_data) department = resolve_department(data.get('department_name', '')) with transaction.atomic(): case_kwargs = {k: data[k] for k in CASE_BASE_FIELDS if k in data} case_kwargs['department'] = department case_kwargs['created_by'] = request.user case_kwargs['status'] = 1 case_kwargs['vector_status'] = 0 case_kwargs['publish_status'] = 1 if data.get('auto_publish') else 0 case = CaseBase.objects.create(**case_kwargs) sub_model = TraditionalCase if case_type == 'traditional' else TeachingCase allowed_sub = TRADITIONAL_FIELDS if case_type == 'traditional' else TEACHING_FIELDS sub_model.objects.create(case=case, **{k: v for k, v in sub_data.items() if k in allowed_sub}) rule_objs = [ ScoringRule(case=case, **{k: v for k, v in rule.items() if k in SCORING_RULE_FIELDS}) for rule in scoring_rules_data ] ScoringRule.objects.bulk_create(rule_objs) if exam_items_data: exam_objs = [ CaseExamItem( case=case, **{k: v for k, v in item.items() if k in EXAM_ITEM_FIELDS}, ) for item in exam_items_data ] CaseExamItem.objects.bulk_create(exam_objs) audit.info( 'CASE_CREATE case_id=%s from=%s by=%s scoring_rules=%d exam_items=%d', case.id, data.get('parse_id', 'form'), request.user.id, len(rule_objs), len(exam_items_data), ) return Response(_build_full_response(case), status=status.HTTP_201_CREATED) # ── C4 / C5: GET + PATCH full ─────────────────────────────────────── @extend_schema( summary='C4: GET 完整查看 / C5: PATCH 局部编辑草稿', description='GET: 返回主表+子表+scoring_rules。PATCH: 仅草稿可编辑,scoring_rules 传入时整体替换。', responses={200: OpenApiResponse(description='完整病例结构')}, tags=['病例'], ) @action(detail=True, methods=['get', 'patch'], url_path='full') def full(self, request, pk=None): """C4: GET 完整查看 / C5: PATCH 局部编辑草稿""" if request.method == 'GET': return self._full_detail(request, pk) return self._full_update(request, pk) def _full_detail(self, request, pk): case = CaseBase.objects.select_related( 'department', 'created_by' ).prefetch_related('scoring_rules', 'exam_items').filter(pk=pk).first() if not case: raise AppError('NOT_FOUND', '病例不存在', status_code=404) if case.publish_status != 1: if not request.user.is_authenticated: raise AppError('AUTH_UNAUTHORIZED', '请先登录', status_code=401) if not (request.user.id == case.created_by_id or request.user.role_type in ('super_admin', 'content_admin') or request.user.is_staff): raise AppError('CASE_PERMISSION_DENIED', '无权查看该草稿', status_code=403) return Response(_build_full_response(case)) def _full_update(self, request, pk): case = CaseBase.objects.select_related('department', 'created_by').filter(pk=pk).first() if not case: raise AppError('NOT_FOUND', '病例不存在', status_code=404) if not (request.user.id == case.created_by_id or request.user.role_type in ('super_admin', 'content_admin') or request.user.is_staff): raise AppError('CASE_PERMISSION_DENIED', '无权编辑该病例', status_code=403) if case.publish_status != 0: raise AppError('CASE_NOT_EDITABLE', '仅草稿可编辑,请先下架', status_code=400) data = request.data with transaction.atomic(): changed = False for field in CASE_BASE_FIELDS: if field in data: setattr(case, field, data[field]) changed = True if 'department_name' in data: case.department = resolve_department(data['department_name']) changed = True if changed: case.save() case_type = case.case_type sub_data = data.get(case_type) if sub_data: sub_model = TraditionalCase if case_type == 'traditional' else TeachingCase allowed_sub = TRADITIONAL_FIELDS if case_type == 'traditional' else TEACHING_FIELDS sub_obj, _ = sub_model.objects.get_or_create(case=case) for k, v in sub_data.items(): if k in allowed_sub: setattr(sub_obj, k, v) sub_obj.save() scoring_rules_data = data.get('scoring_rules') if scoring_rules_data is not None: _validate_scoring_rules(scoring_rules_data) case.scoring_rules.all().delete() rule_objs = [ ScoringRule(case=case, **{k: v for k, v in rule.items() if k in SCORING_RULE_FIELDS}) for rule in scoring_rules_data ] ScoringRule.objects.bulk_create(rule_objs) audit.info('CASE_UPDATE case_id=%s by=%s', case.id, request.user.id) case.refresh_from_db() return Response(_build_full_response(case)) # ── existing actions ───────────────────────────────────────────────── @action(detail=True, methods=['get']) def stages(self, request, pk=None): case = self.get_object() serializer = CaseStageSerializer(case.stages.all(), many=True) return Response(serializer.data) @action(detail=True, methods=['post']) def add_stage(self, request, pk=None): case = self.get_object() serializer = CaseStageSerializer(data=request.data) serializer.is_valid(raise_exception=True) serializer.save(case=case) return Response(serializer.data, status=status.HTTP_201_CREATED) @action(detail=True, methods=['get']) def scoring_rules_list(self, request, pk=None): case = self.get_object() serializer = ScoringRuleSerializer(case.scoring_rules.all(), many=True) return Response(serializer.data) @action(detail=True, methods=['post']) def add_scoring_rule(self, request, pk=None): case = self.get_object() serializer = ScoringRuleSerializer(data=request.data) serializer.is_valid(raise_exception=True) serializer.save(case=case) return Response(serializer.data, status=status.HTTP_201_CREATED) @action(detail=True, methods=['post']) def publish(self, request, pk=None): case = self.get_object() case.publish_status = 1 case.save() return Response({'message': '病例已发布'}) @action(detail=True, methods=['post']) def unpublish(self, request, pk=None): case = self.get_object() case.publish_status = 2 case.save() return Response({'message': '病例已下架'}) class TraditionalCaseViewSet(viewsets.ModelViewSet): queryset = TraditionalCase.objects.all() serializer_class = TraditionalCaseSerializer filter_backends = [DjangoFilterBackend] filterset_fields = ['case'] class ScriptCaseViewSet(viewsets.ModelViewSet): queryset = ScriptCase.objects.all() serializer_class = ScriptCaseSerializer filter_backends = [DjangoFilterBackend] filterset_fields = ['case'] class TeachingCaseViewSet(viewsets.ModelViewSet): queryset = TeachingCase.objects.all() serializer_class = TeachingCaseSerializer filter_backends = [DjangoFilterBackend] filterset_fields = ['case'] class CaseStageViewSet(viewsets.ModelViewSet): queryset = CaseStage.objects.all() serializer_class = CaseStageSerializer filter_backends = [DjangoFilterBackend, filters.OrderingFilter] filterset_fields = ['case', 'stage_type', 'stage_mode'] ordering_fields = ['sort_order', 'created_at'] class ScoringRuleViewSet(viewsets.ModelViewSet): queryset = ScoringRule.objects.all() serializer_class = ScoringRuleSerializer filter_backends = [DjangoFilterBackend] filterset_fields = ['case', 'dimension', 'ai_auto_score', 'osce_dimension'] # ── helpers ────────────────────────────────────────────────────────────── def _validate_scoring_rules(rules): if not isinstance(rules, list): raise AppError('CASE_VALIDATION_ERROR', 'scoring_rules 必须为数组', status_code=400) for i, rule in enumerate(rules): if not isinstance(rule, dict): raise AppError('CASE_VALIDATION_ERROR', f'scoring_rules[{i}] 必须为对象', status_code=400) if not rule.get('dimension'): raise AppError('CASE_VALIDATION_ERROR', f'scoring_rules[{i}].dimension 必填', status_code=400) weight = rule.get('score_weight') if weight is not None: try: weight = float(weight) except (TypeError, ValueError): raise AppError('CASE_VALIDATION_ERROR', f'scoring_rules[{i}].score_weight 须为数字', status_code=400) if weight <= 0 or weight > 1: raise AppError('CASE_VALIDATION_ERROR', f'scoring_rules[{i}].score_weight 须在 (0, 1]', status_code=400) def _build_full_response(case): if hasattr(case, '_prefetched_objects_cache') and 'exam_items' in getattr( case, '_prefetched_objects_cache', {} ): exam_qs = case.exam_items.all() else: exam_qs = CaseExamItem.objects.filter(case_id=case.id).order_by('display_order', 'id') result = { 'case': { 'id': case.id, 'title': case.title, 'case_type': case.case_type, 'difficulty': case.difficulty, 'difficulty_score': case.difficulty_score, 'department': case.department_id, 'department_name': case.department.name if case.department else None, 'chief_complaint': case.chief_complaint, 'description': case.description, 'patient_age': case.patient_age, 'patient_gender': case.patient_gender, 'tags': case.tags, 'symptom_tags': case.symptom_tags, 'disease_tags': case.disease_tags, 'competency_tags': case.competency_tags, 'guideline_tags': case.guideline_tags, 'knowledge_points': case.knowledge_points, 'icd_codes': case.icd_codes, 'estimated_minutes': case.estimated_minutes, 'osce_enabled': case.osce_enabled, 'rag_enabled': case.rag_enabled, 'ai_prompt_template': case.ai_prompt_template, 'multimodal_assets': case.multimodal_assets, 'vector_status': case.vector_status, 'publish_status': case.publish_status, 'status': case.status, 'created_by': case.created_by_id, 'created_by_name': case.created_by.real_name if case.created_by else None, 'created_at': case.created_at.isoformat() if case.created_at else None, 'updated_at': case.updated_at.isoformat() if case.updated_at else None, }, } if case.case_type == 'traditional': try: tc = case.traditionalcase result['traditional'] = { 'standard_diagnosis': tc.standard_diagnosis, 'standard_treatment': tc.standard_treatment, 'guideline_reference': tc.guideline_reference, } except TraditionalCase.DoesNotExist: result['traditional'] = None elif case.case_type == 'teaching': try: tc = case.teachingcase result['teaching'] = { 'teaching_goal': tc.teaching_goal, 'discussion_questions': tc.discussion_questions, 'teacher_guide': tc.teacher_guide, 'scoring_focus': tc.scoring_focus, } except TeachingCase.DoesNotExist: result['teaching'] = None rules = case.scoring_rules.all().order_by('id') result['scoring_rules'] = [ { 'id': r.id, 'dimension': r.dimension, 'competency_dimension': r.competency_dimension, 'score_weight': float(r.score_weight), 'ai_auto_score': r.ai_auto_score, 'osce_dimension': r.osce_dimension, 'scoring_standard': r.scoring_standard, 'rubric_json': r.rubric_json, } for r in rules ] result['exam_items'] = [ { 'id': e.id, 'item_code': e.item_code, 'item_name': e.item_name, 'item_type': e.item_type, 'category': e.category, 'result_text': e.result_text, 'result_structured': e.result_structured, 'is_key': e.is_key, 'is_abnormal': e.is_abnormal, 'score_weight': float(e.score_weight), 'display_order': e.display_order, } for e in exam_qs ] return result