feat: update cms case create
This commit is contained in:
+74
-9
@@ -12,6 +12,7 @@
|
||||
"""
|
||||
import logging
|
||||
|
||||
from django.db import transaction
|
||||
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
|
||||
@@ -26,10 +27,13 @@ from apps.cms.permissions import (
|
||||
)
|
||||
from apps.user.models import Institution, Department
|
||||
from apps.user.throttling import PdfParseUserThrottle
|
||||
from .models import CaseBase
|
||||
from .models import CaseBase, ScoringRule
|
||||
from .serializers import CaseBaseListSerializer
|
||||
from .services import case_importer
|
||||
from .services.case_writer import create_case_full, build_full_response
|
||||
from .services import case_importer, scoring_rule_generator
|
||||
from .services.case_writer import (
|
||||
create_case_full, update_case_full, build_full_response, build_scoring_rule_input,
|
||||
validate_scoring_rules, SCORING_RULE_FIELDS,
|
||||
)
|
||||
|
||||
audit = logging.getLogger('audit')
|
||||
|
||||
@@ -84,14 +88,50 @@ class CmsCaseViewSet(viewsets.ModelViewSet):
|
||||
|
||||
# ── CMS-CASE-3 表单新增(草稿)/ CMS-CASE-1 列表 ──────────────────────
|
||||
def create(self, request, *args, **kwargs):
|
||||
"""新增病例(草稿)。评分规则非必填——评分规则在「发布」时由 AI 生成并落库。"""
|
||||
self._require_editor()
|
||||
case, n_rules, n_items = create_case_full(request.data, request.user, institution=self._resolve_institution())
|
||||
case, n_rules, n_items = create_case_full(
|
||||
request.data, request.user,
|
||||
institution=self._resolve_institution(),
|
||||
require_scoring_rules=False,
|
||||
)
|
||||
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-8 编辑草稿(再次保存)──────────────────────────────────
|
||||
@extend_schema(
|
||||
summary='CMS-CASE-8 编辑草稿(再次保存)',
|
||||
description='对已保存的草稿病例,编辑录入后的表单(表单/PDF/AI 三种录入数据均在表单中)'
|
||||
'再次保存,整体更新草稿内容。仅草稿(publish_status=0)可编辑。',
|
||||
request=inline_serializer('CmsCaseSaveDraftRequest', fields={
|
||||
'title': drf_serializers.CharField(required=False),
|
||||
'case_type': drf_serializers.ChoiceField(choices=['traditional', 'teaching'], required=False,
|
||||
help_text='只读校验,不支持修改;传入须与原病例一致'),
|
||||
'department_name': drf_serializers.CharField(required=False, help_text='科室名称(解析为 department_id)'),
|
||||
'traditional': drf_serializers.DictField(required=False),
|
||||
'teaching': drf_serializers.DictField(required=False),
|
||||
'exam_items': drf_serializers.ListField(
|
||||
child=drf_serializers.DictField(), required=False,
|
||||
help_text='检查项(传入即整表替换;同一病例 item_code 不可重复)'),
|
||||
'scoring_rules': drf_serializers.ListField(
|
||||
child=drf_serializers.DictField(), required=False,
|
||||
help_text='评分规则(一般发布时自动生成;如传入则整表替换)'),
|
||||
}),
|
||||
responses={200: OpenApiResponse(description='完整病例结构(同 CMS-CASE-7)')},
|
||||
tags=['CMS-病例'],
|
||||
)
|
||||
@action(detail=True, methods=['post'], url_path='save-draft')
|
||||
def save_draft(self, request, pk=None):
|
||||
"""编辑录入后的表单再次保存:更新草稿内容(主表+子表+检查项,评分规则可选)。"""
|
||||
self._require_editor()
|
||||
case = self.get_object()
|
||||
case = update_case_full(case, request.data, request.user)
|
||||
audit.info('CMS_CASE_SAVE_DRAFT case_id=%s by=%s', case.id, request.user.id)
|
||||
return Response(build_full_response(case))
|
||||
|
||||
# ── CMS-CASE-7 / CMS-AUDIT-2 病例查看(完整结构)─────────────────────
|
||||
@extend_schema(summary='CMS-CASE-7 病例查看(完整结构)', tags=['CMS-病例'])
|
||||
@action(detail=True, methods=['get'])
|
||||
@@ -218,12 +258,37 @@ class CmsCaseViewSet(viewsets.ModelViewSet):
|
||||
@extend_schema(summary='CMS-AUDIT-3 发布(正常 → 已发布)', tags=['CMS-病例审核'])
|
||||
@action(detail=True, methods=['post'])
|
||||
def publish(self, request, pk=None):
|
||||
"""医院管理员审核发布:正常(1) → 已发布(2)。发布后对本院移动端医学生可见。超管不做审核。"""
|
||||
"""审核发布:正常(1) → 已发布(2)。
|
||||
|
||||
发布前先由 AI 生成该病例评分规则并落库;评分规则生成或入库失败则不发布
|
||||
(评分规则落库与发布状态变更在同一事务内,保证「无评分规则不发布」)。
|
||||
发布后对本院移动端医学生可见。
|
||||
"""
|
||||
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})
|
||||
|
||||
# 1) AI 生成评分规则(慢 / 可能失败,放在事务外,失败则不触碰数据库)
|
||||
gen = scoring_rule_generator.generate(build_scoring_rule_input(case))
|
||||
rules = gen['scoring_rules']
|
||||
validate_scoring_rules(rules)
|
||||
|
||||
# 2) 评分规则落库 + 发布状态变更,同一事务原子完成
|
||||
with transaction.atomic():
|
||||
case.scoring_rules.all().delete() # 幂等:清掉历史规则后重新写入
|
||||
ScoringRule.objects.bulk_create([
|
||||
ScoringRule(case=case, **{k: v for k, v in r.items() if k in SCORING_RULE_FIELDS})
|
||||
for r in rules
|
||||
])
|
||||
case.publish_status = 2 # 已发布
|
||||
case.save(update_fields=['publish_status', 'updated_at'])
|
||||
|
||||
audit.info('CMS_CASE_PUBLISH case_id=%s by=%s scoring_rules=%d prompt_version=%s',
|
||||
case.id, request.user.id, len(rules), gen.get('prompt_version'))
|
||||
return Response({
|
||||
'message': '已发布',
|
||||
'id': case.id,
|
||||
'publish_status': case.publish_status,
|
||||
'scoring_rules_generated': len(rules),
|
||||
})
|
||||
|
||||
@@ -51,10 +51,12 @@ def validate_scoring_rules(rules):
|
||||
_UNSET = object()
|
||||
|
||||
|
||||
def create_case_full(data, user, institution=_UNSET):
|
||||
"""主表 + 子表 + scoring_rules(≥1) + exam_items 同一事务入库。
|
||||
def create_case_full(data, user, institution=_UNSET, require_scoring_rules=True):
|
||||
"""主表 + 子表 + scoring_rules + exam_items 同一事务入库。
|
||||
|
||||
institution:病例所属机构;默认取创建者所属机构(`user.institution`)。
|
||||
require_scoring_rules:是否强制评分规则(≥1 条)。CMS 新增病例时为 False
|
||||
(评分规则延迟到「发布」时由 AI 生成并落库);业务域 full-create 仍为 True。
|
||||
返回 (case, scoring_rule_count, exam_item_count)。校验失败抛 AppError。
|
||||
"""
|
||||
if institution is _UNSET:
|
||||
@@ -74,9 +76,10 @@ def create_case_full(data, user, institution=_UNSET):
|
||||
raise AppError('CASE_SUBTYPE_CONFLICT', '不允许同时传入两种子表数据', status_code=400)
|
||||
|
||||
scoring_rules_data = data.get('scoring_rules', [])
|
||||
if not scoring_rules_data:
|
||||
if require_scoring_rules and not scoring_rules_data:
|
||||
raise AppError('CASE_VALIDATION_ERROR', 'scoring_rules 必填且至少 1 条', status_code=400)
|
||||
validate_scoring_rules(scoring_rules_data)
|
||||
if scoring_rules_data:
|
||||
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)
|
||||
@@ -113,6 +116,119 @@ def create_case_full(data, user, institution=_UNSET):
|
||||
return case, len(rule_objs), len(exam_items_data)
|
||||
|
||||
|
||||
# 与 scoring_rule_generator.CONTEXT_FIELDS 对应的主表字段(发布时据此构建生成入参)
|
||||
_SCORING_CONTEXT_FIELDS = (
|
||||
'title', 'chief_complaint', 'description', 'patient_age', 'patient_gender',
|
||||
'icd_codes', 'symptom_tags', 'disease_tags', 'competency_tags',
|
||||
'guideline_tags', 'knowledge_points',
|
||||
)
|
||||
|
||||
|
||||
def build_scoring_rule_input(case):
|
||||
"""由已落库的病例对象构建评分规则生成入参(供发布时调用 AI 生成评分规则)。"""
|
||||
data = {'case_type': case.case_type}
|
||||
for field in _SCORING_CONTEXT_FIELDS:
|
||||
data[field] = getattr(case, field, None)
|
||||
|
||||
if case.case_type == 'traditional':
|
||||
try:
|
||||
tc = case.traditionalcase
|
||||
data['traditional'] = {
|
||||
'standard_diagnosis': tc.standard_diagnosis,
|
||||
'standard_treatment': tc.standard_treatment,
|
||||
'guideline_reference': tc.guideline_reference,
|
||||
}
|
||||
except TraditionalCase.DoesNotExist:
|
||||
data['traditional'] = {}
|
||||
else:
|
||||
try:
|
||||
tc = case.teachingcase
|
||||
data['teaching'] = {
|
||||
'teaching_goal': tc.teaching_goal,
|
||||
'discussion_questions': tc.discussion_questions,
|
||||
'teacher_guide': tc.teacher_guide,
|
||||
'scoring_focus': tc.scoring_focus,
|
||||
}
|
||||
except TeachingCase.DoesNotExist:
|
||||
data['teaching'] = {}
|
||||
return data
|
||||
|
||||
|
||||
def update_case_full(case, data, user):
|
||||
"""更新草稿病例内容(主表 + 子表 + exam_items,scoring_rules 可选)。
|
||||
|
||||
供 CMS「编辑录入后的表单再次保存」复用:三种录入方式(表单 / PDF / AI)的病例数据
|
||||
都汇总到前端表单,再次保存时整体更新草稿。仅草稿(publish_status=0)可更新。
|
||||
- 主表字段:传入即覆盖(CASE_BASE_FIELDS 白名单)。
|
||||
- 子表:按 case_type 局部更新;不允许传入另一种子表。
|
||||
- exam_items:传入即整表替换(去重校验);不传则保持不变。
|
||||
- scoring_rules:通常发布时才生成;如传入则整表替换(校验)。
|
||||
返回更新后的 case。校验失败抛 AppError。
|
||||
"""
|
||||
if case.publish_status != 0:
|
||||
raise AppError('CASE_NOT_EDITABLE', '仅草稿可编辑,请先下架', status_code=400)
|
||||
|
||||
if 'stages' in data:
|
||||
raise AppError('CASE_FIELD_NOT_ALLOWED', '本接口不接收 stages 字段', status_code=400)
|
||||
|
||||
case_type = case.case_type
|
||||
new_type = data.get('case_type')
|
||||
if new_type and new_type != case_type:
|
||||
raise AppError('CASE_TYPE_NOT_SUPPORTED', '不支持修改 case_type', status_code=400)
|
||||
|
||||
other_type = 'teaching' if case_type == 'traditional' else 'traditional'
|
||||
if data.get(other_type):
|
||||
raise AppError('CASE_SUBTYPE_CONFLICT', '不允许传入另一种子表数据', status_code=400)
|
||||
|
||||
scoring_rules_data = data.get('scoring_rules')
|
||||
if scoring_rules_data is not None:
|
||||
validate_scoring_rules(scoring_rules_data)
|
||||
|
||||
exam_items_data = None
|
||||
if 'exam_items' in data:
|
||||
exam_items_data = normalize_exam_items(data.get('exam_items') or [])
|
||||
assert_no_duplicate_exam_items(exam_items_data)
|
||||
|
||||
with transaction.atomic():
|
||||
changed = []
|
||||
for field in CASE_BASE_FIELDS:
|
||||
if field in data:
|
||||
setattr(case, field, data[field])
|
||||
changed.append(field)
|
||||
if 'department_name' in data:
|
||||
case.department = resolve_department(data['department_name'])
|
||||
changed.append('department')
|
||||
if changed:
|
||||
case.save()
|
||||
|
||||
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()
|
||||
|
||||
if scoring_rules_data is not None:
|
||||
case.scoring_rules.all().delete()
|
||||
ScoringRule.objects.bulk_create([
|
||||
ScoringRule(case=case, **{k: v for k, v in rule.items() if k in SCORING_RULE_FIELDS})
|
||||
for rule in scoring_rules_data
|
||||
])
|
||||
|
||||
if exam_items_data is not None:
|
||||
case.exam_items.all().delete()
|
||||
CaseExamItem.objects.bulk_create([
|
||||
CaseExamItem(case=case, **{k: v for k, v in item.items() if k in EXAM_ITEM_FIELDS})
|
||||
for item in exam_items_data
|
||||
])
|
||||
|
||||
case.refresh_from_db()
|
||||
return case
|
||||
|
||||
|
||||
def build_full_response(case):
|
||||
"""组装病例完整结构(主表 + 子表 + scoring_rules + exam_items)。"""
|
||||
if hasattr(case, '_prefetched_objects_cache') and 'exam_items' in getattr(
|
||||
|
||||
Reference in New Issue
Block a user