2026-06-11 10:37:29 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-06-11 13:57:46 +08:00
|
|
|
|
# 含已停用科室:避免导入与软删科室同名而产生重复行
|
|
|
|
|
|
existing = set(Department.all_objects.values_list('name', flat=True))
|
2026-06-11 10:37:29 +08:00
|
|
|
|
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:
|
2026-06-11 13:57:46 +08:00
|
|
|
|
errors.append({'row': idx, 'reason': f'科室已存在(含已停用):{name}'}); continue
|
2026-06-11 10:37:29 +08:00
|
|
|
|
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})
|