230 lines
13 KiB
Python
230 lines
13 KiB
Python
"""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})
|