import os from django.conf import settings from rest_framework import viewsets, filters from rest_framework.decorators import action from rest_framework.parsers import MultiPartParser, FormParser from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from django_filters.rest_framework import DjangoFilterBackend from drf_spectacular.utils import extend_schema, extend_schema_view from config.exceptions import AppError from apps.user.models import Institution, Department from apps.cms.permissions import IsSuperAdmin from apps.common.excel import xlsx_response, rows_from_xlsx from .serializers import CmsInstitutionSerializer, CmsDepartmentSerializer ALLOWED_BANNER_EXT = ('.png', '.jpg', '.jpeg', '.webp') MAX_BANNER_BYTES = 5 * 1024 * 1024 # 5MB INST_IMPORT_HEADERS = ['机构编码', '名称', '类型', '等级', '省', '市'] INST_EXPORT_HEADERS = ['ID', '机构编码', '名称', '类型', '等级', '省', '市'] DEPT_IMPORT_HEADERS = ['科室名称', '分类'] DEPT_EXPORT_HEADERS = ['ID', '科室名称', '分类'] @extend_schema_view( list=extend_schema(summary='CMS-INST-1 机构列表(分页+搜索)', tags=['CMS-机构']), create=extend_schema(summary='CMS-INST-2 新建机构', tags=['CMS-机构']), retrieve=extend_schema(summary='CMS-INST-3 机构详情', tags=['CMS-机构']), partial_update=extend_schema(summary='CMS-INST-4 编辑机构', tags=['CMS-机构']), destroy=extend_schema(summary='CMS-INST-5 停用机构(逻辑删除)', tags=['CMS-机构']), ) class CmsInstitutionViewSet(viewsets.ModelViewSet): """CMS 机构(医院)管理 —— 仅超级管理员(CMS-INST-1~6)。 list: 机构列表(分页、搜索 name/code、过滤 type/province/city) create: 新建机构(code 唯一) retrieve: 机构详情 partial_update: 编辑机构(PATCH) destroy: 停用机构(逻辑删除;Institution 继承 SoftDeleteModel) banner: 上传机构 Banner 图(CMS-INST-6) """ queryset = Institution.objects.all().order_by('-created_at') serializer_class = CmsInstitutionSerializer permission_classes = [IsAuthenticated, IsSuperAdmin] filter_backends = [DjangoFilterBackend, filters.SearchFilter] filterset_fields = ['type', 'province', 'city'] search_fields = ['name', 'code'] # 仅允许 GET/POST/PATCH/DELETE,编辑统一走 PATCH(屏蔽 PUT) http_method_names = ['get', 'post', 'patch', 'delete', 'head', 'options'] # destroy 走 ModelViewSet 默认实现:instance.delete() 经 SoftDeleteModel 改为逻辑删除(停用) @extend_schema(summary='CMS-INST-6 上传机构 Banner 图', tags=['CMS-机构']) @action(detail=True, methods=['post'], url_path='banner', parser_classes=[MultiPartParser, FormParser]) def banner(self, request, pk=None): """上传机构 Banner 图(multipart/form-data,字段名 file)。""" inst = self.get_object() file = request.FILES.get('file') if not file: raise AppError('CMS_BANNER_FILE_REQUIRED', '请上传图片文件(字段名 file)', status_code=400) if file.size > MAX_BANNER_BYTES: raise AppError('CMS_BANNER_TOO_LARGE', '图片不能超过 5MB', status_code=400) ext = os.path.splitext(file.name)[1].lower() if ext not in ALLOWED_BANNER_EXT: raise AppError('CMS_BANNER_BAD_TYPE', '仅支持 png/jpg/jpeg/webp 图片', status_code=400) rel_dir = 'institutions' base_static = settings.STATICFILES_DIRS[0] target_dir = os.path.join(base_static, rel_dir) os.makedirs(target_dir, exist_ok=True) filename = f'inst_{inst.id}_banner{ext}' with open(os.path.join(target_dir, filename), 'wb') as fh: for chunk in file.chunks(): fh.write(chunk) inst.banner_url = f'{rel_dir}/{filename}' inst.save(update_fields=['banner_url', 'updated_at']) serializer = self.get_serializer(inst) return Response({'message': '上传成功', 'banner_url': serializer.data['banner_url']}) @extend_schema(summary='CMS-INST-6b 下载机构导入模板', tags=['CMS-机构']) @action(detail=False, methods=['get'], url_path='import-template') def import_template(self, request): return xlsx_response('机构导入模板.xlsx', INST_IMPORT_HEADERS, []) @extend_schema(summary='CMS-INST-7 导出机构', tags=['CMS-机构']) @action(detail=False, methods=['get'], url_path='export') def export(self, request): qs = self.filter_queryset(self.get_queryset()) rows = [[i.id, i.code, i.name, i.type, i.level, i.province, i.city] for i in qs] return xlsx_response('机构列表.xlsx', INST_EXPORT_HEADERS, rows) @extend_schema(summary='CMS-INST-5 导入机构', tags=['CMS-机构']) @action(detail=False, methods=['post'], url_path='import', parser_classes=[MultiPartParser, FormParser]) def import_institutions(self, request): """Excel 批量导入机构。列:机构编码 | 名称 | 类型 | 等级 | 省 | 市。""" file = request.FILES.get('file') if not file: raise AppError('CMS_IMPORT_FILE_REQUIRED', '请上传 .xlsx 文件(字段名 file)', status_code=400) try: rows = rows_from_xlsx(file) except Exception: raise AppError('CMS_IMPORT_BAD_FILE', '文件解析失败,请使用导入模板', status_code=400) existing_codes = set(Institution.all_objects.values_list('code', flat=True)) success, errors = 0, [] for idx, row in enumerate(rows, start=2): code = (row.get('机构编码') or '').strip() name = (row.get('名称') or '').strip() if not code: errors.append({'row': idx, 'reason': '机构编码为空'}); continue if not name: errors.append({'row': idx, 'reason': '名称为空'}); continue if code in existing_codes: errors.append({'row': idx, 'reason': f'机构编码已存在:{code}'}); continue Institution.objects.create( code=code, name=name, type=(row.get('类型') or 'hospital').strip() or 'hospital', level=(row.get('等级') or '').strip(), province=(row.get('省') or '').strip(), city=(row.get('市') or '').strip(), ) existing_codes.add(code) success += 1 return Response({'total': len(rows), 'success': success, 'failed': len(errors), 'errors': errors}) @extend_schema_view( list=extend_schema(summary='CMS-DEPT-1 科室列表(全局)', tags=['CMS-科室']), create=extend_schema(summary='CMS-DEPT-2 新增科室', tags=['CMS-科室']), retrieve=extend_schema(summary='科室详情', tags=['CMS-科室']), partial_update=extend_schema(summary='CMS-DEPT-3 编辑科室', tags=['CMS-科室']), destroy=extend_schema(summary='CMS-DEPT-4 停用科室(逻辑删除)', tags=['CMS-科室']), ) class CmsDepartmentViewSet(viewsets.ModelViewSet): """CMS 科室管理(全局科室)—— 仅超级管理员。停用为逻辑删除。""" queryset = Department.objects.all().order_by('name') serializer_class = CmsDepartmentSerializer permission_classes = [IsAuthenticated, IsSuperAdmin] filter_backends = [DjangoFilterBackend, filters.SearchFilter] filterset_fields = ['category'] search_fields = ['name'] http_method_names = ['get', 'post', 'patch', 'delete', 'head', 'options'] @extend_schema(summary='CMS-DEPT-6b 下载科室导入模板', tags=['CMS-科室']) @action(detail=False, methods=['get'], url_path='import-template') def import_template(self, request): return xlsx_response('科室导入模板.xlsx', DEPT_IMPORT_HEADERS, []) @extend_schema(summary='CMS-DEPT-6 导出科室', tags=['CMS-科室']) @action(detail=False, methods=['get'], url_path='export') def export(self, request): qs = self.filter_queryset(self.get_queryset()) rows = [[d.id, d.name, d.category] for d in qs] return xlsx_response('科室列表.xlsx', DEPT_EXPORT_HEADERS, rows) @extend_schema(summary='CMS-DEPT-5 导入科室', tags=['CMS-科室']) @action(detail=False, methods=['post'], url_path='import', parser_classes=[MultiPartParser, FormParser]) def import_departments(self, request): """Excel 批量导入科室。列:科室名称 | 分类(可选)。""" file = request.FILES.get('file') if not file: raise AppError('CMS_IMPORT_FILE_REQUIRED', '请上传 .xlsx 文件(字段名 file)', status_code=400) try: rows = rows_from_xlsx(file) except Exception: raise AppError('CMS_IMPORT_BAD_FILE', '文件解析失败,请使用导入模板', status_code=400) # 含已停用科室:避免导入与软删科室同名而产生重复行 existing = set(Department.all_objects.values_list('name', flat=True)) success, errors = 0, [] for idx, row in enumerate(rows, start=2): name = (row.get('科室名称') or '').strip() if not name: errors.append({'row': idx, 'reason': '科室名称为空'}); continue if name in existing: errors.append({'row': idx, 'reason': f'科室已存在(含已停用):{name}'}); continue Department.objects.create(name=name, category=(row.get('分类') or '').strip()) existing.add(name) success += 1 return Response({'total': len(rows), 'success': success, 'failed': len(errors), 'errors': errors})