feat: cms users institution department manager

This commit is contained in:
2026-06-11 10:37:29 +08:00
parent 1dc9141856
commit 32915bc6b4
39 changed files with 2403 additions and 75 deletions
+186
View File
@@ -0,0 +1,186 @@
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.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})