Files
medical_training/apps/organization/views.py
T

241 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, IsSuperOrHospitalAdmin, is_super
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:查=GET,增删改=POST(编辑→{id}/update/,停用→{id}/disable/
http_method_names = ['get', 'post', 'head', 'options']
def get_permissions(self):
# 编辑机构 / 上传 Banner:超管 + 医院管理员(医院管理员仅限本院,在动作内收口);
# 其余动作(列表/新增/停用/导入导出等)仍仅超级管理员。
if getattr(self, 'action', None) in ('update_inst', 'banner'):
return [IsAuthenticated(), IsSuperOrHospitalAdmin()]
return [IsAuthenticated(), IsSuperAdmin()]
@extend_schema(summary='CMS-INST-3 编辑机构', tags=['CMS-机构'])
@action(detail=True, methods=['post'], url_path='update')
def update_inst(self, request, pk=None):
"""编辑机构(POST 局部更新,等价旧 PATCH /{id}/)。
- 超级管理员:可编辑任意机构。
- 医院管理员:仅能编辑**本院**机构(非本院 → 403),可改除机构 ID 外的信息;
不能停用/删除机构(停用动作仍仅超管)。
"""
instance = self.get_object()
if not is_super(request.user) and instance.id != request.user.institution_id:
raise AppError('CMS_PERMISSION_DENIED', '医院管理员只能修改本机构信息', status_code=403)
serializer = self.get_serializer(instance, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data)
@extend_schema(summary='CMS-INST-4 停用机构(逻辑删除)', tags=['CMS-机构'])
@action(detail=True, methods=['post'], url_path='disable')
def disable(self, request, pk=None):
"""停用机构(软删除,等价旧 DELETE /{id}/)。"""
self.get_object().delete()
return Response({'message': '已停用'})
@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)。
- 超级管理员:可为任意机构上传。
- 医院管理员:仅可为**本院**机构上传(非本院 → 403)。
"""
inst = self.get_object()
if not is_super(request.user) and inst.id != request.user.institution_id:
raise AppError('CMS_PERMISSION_DENIED', '医院管理员只能上传本机构 Banner 图', status_code=403)
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)
# 上传图片落在 static/uploads/ 下——该目录由 Docker 持久卷挂载(容器重启不丢),
# 与镜像内置静态(如 institutions/default_hospital.png)分开,避免被卷覆盖。
rel_dir = 'uploads/institutions'
base_static = settings.STATICFILES_DIRS[0]
target_dir = os.path.join(base_static, 'uploads', 'institutions')
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}' # 例:uploads/institutions/inst_17_banner.png
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 批量导入机构。列:机构编码 | 名称 | 等级 | 省 | 市(类型固定 hospital,不在表内)。"""
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='hospital', # 类型固定 hospital,不从 Excel 读取
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-科室']),
)
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']
# 仅 GET / POST:查=GET,增删改=POST(编辑→{id}/update/,停用→{id}/disable/
http_method_names = ['get', 'post', 'head', 'options']
@extend_schema(summary='CMS-DEPT-3 编辑科室', tags=['CMS-科室'])
@action(detail=True, methods=['post'], url_path='update')
def update_dept(self, request, pk=None):
"""编辑科室(POST 局部更新,等价旧 PATCH /{id}/)。"""
instance = self.get_object()
serializer = self.get_serializer(instance, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data)
@extend_schema(summary='CMS-DEPT-4 停用科室(逻辑删除)', tags=['CMS-科室'])
@action(detail=True, methods=['post'], url_path='disable')
def disable(self, request, pk=None):
"""停用科室(软删除,等价旧 DELETE /{id}/)。"""
self.get_object().delete()
return Response({'message': '已停用'})
@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})