Files
medical_training/apps/user/cms.py
T

328 lines
15 KiB
Python
Raw Normal View History

"""CMS 用户管理 + 师生关系管理 + 带教医生「我的学生」。
用户管理(超管全平台 / 医院管理员本院):CMS-USER-1~8、CMS-HUSER-1~3。
师生关系管理(医院管理员):CMS-REL-1~4。
带教医生「我的学生」(doctor 名下学生,只读):CMS-TEA-1~2。
"""
import re
from django.db import IntegrityError
from rest_framework import viewsets, filters, status
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 rest_framework import serializers
from django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.utils import extend_schema, extend_schema_view
from config.exceptions import AppError
from apps.cms.permissions import IsSuperOrHospitalAdmin, IsTeacher, is_super
from apps.common.excel import xlsx_response, rows_from_xlsx
from .models import User, Institution, TeacherStudentRelation
from .utils.jwt_redis import invalidate_user_tokens
ALL_ROLES = ('super_admin', 'hospital_admin', 'content_admin', 'doctor', 'student')
# 医院管理员可管理 / 创建的角色(本院:医生、学生、内容管理员)
# —— 医院管理员可给本院工作人员授予内容管理员权限,故内容管理员也纳入其人员管理范围。
HOSPITAL_ADMIN_ROLES = ('doctor', 'student', 'content_admin')
ROLE_LABEL_BY_CODE = {
'super_admin': '超级管理员', 'hospital_admin': '医院管理员',
'content_admin': '内容管理员', 'doctor': '医生', 'student': '学生',
}
ROLE_CODE_BY_LABEL = {
'超级管理员': 'super_admin', '医院管理员': 'hospital_admin',
'内容管理员': 'content_admin', '医生': 'doctor', '带教医生': 'doctor',
'带教老师': 'doctor', '学生': 'student', '医学生': 'student',
}
USER_IMPORT_HEADERS = ['手机号', '姓名', '角色', '机构编码']
USER_EXPORT_HEADERS = ['ID', '手机号', '姓名', '角色', '机构', '状态']
def _resolve_role(value):
value = (value or '').strip()
if value in ALL_ROLES:
return value
return ROLE_CODE_BY_LABEL.get(value)
# ── 序列化器 ──────────────────────────────────────────────────────────────────
class CmsUserSerializer(serializers.ModelSerializer):
"""读取(列表 / 详情)。"""
institution_name = serializers.CharField(source='institution.name', read_only=True, default=None)
role_label = serializers.SerializerMethodField()
class Meta:
model = User
fields = [
'id', 'username', 'real_name', 'phone', 'role_type', 'role_label',
'institution', 'institution_name',
'gender', 'title_name', 'major', 'training_stage', 'status', 'created_at',
]
def get_role_label(self, obj):
return ROLE_LABEL_BY_CODE.get(obj.role_type, obj.role_type)
class CmsUserWriteSerializer(serializers.ModelSerializer):
"""新增 / 编辑(role 必填)。"""
phone = serializers.CharField(max_length=20)
class Meta:
model = User
# 科室不在 CMS 维护(科室仅用于病例分类,用户在移动端自选学习科室)
fields = [
'real_name', 'phone', 'role_type', 'institution',
'gender', 'title_name', 'major', 'training_stage', 'status',
]
def validate_phone(self, value):
value = (value or '').strip()
if not re.match(r'^1[3-9]\d{9}$', value):
raise AppError('CMS_VALIDATION_ERROR', '手机号格式不合法', status_code=400)
qs = User.all_objects.filter(phone=value)
if self.instance is not None:
qs = qs.exclude(pk=self.instance.pk)
if qs.exists():
raise AppError('CMS_USER_PHONE_EXISTS', '手机号已存在', status_code=400)
return value
def validate(self, attrs):
"""新增:角色/机构/姓名必填。
编辑:局部更新——角色/机构不必每次带(不传保持原值),但**若传则不能为空/非法**。
医院管理员:角色仅限 doctor/student,机构强制本院。
"""
actor = self.context['request'].user
actor_is_super = is_super(actor)
is_create = self.instance is None
# 角色:新增必填;编辑时若出现在请求里则校验非空且合法
if is_create or 'role_type' in attrs:
role = attrs.get('role_type')
if not role:
raise AppError('CMS_VALIDATION_ERROR', '角色(role_type)不能为空', status_code=400)
if role not in ALL_ROLES:
raise AppError('CMS_VALIDATION_ERROR', '角色非法', status_code=400)
if not actor_is_super and role not in HOSPITAL_ADMIN_ROLES:
raise AppError('CMS_ROLE_NOT_ALLOWED', '医院管理员只能管理医生 / 学生 / 内容管理员', status_code=403)
# 机构
if actor_is_super:
# 超管:机构必填(编辑时若传则不能为空)
if is_create or 'institution' in attrs:
if not attrs.get('institution'):
raise AppError('CMS_VALIDATION_ERROR', '机构(institution)不能为空', status_code=400)
else:
# 医院管理员:机构强制为本院(忽略请求里传的机构)
if not actor.institution_id:
raise AppError('CMS_NO_INSTITUTION', '当前医院管理员无所属机构,无法管理用户', status_code=403)
attrs['institution'] = actor.institution
# 姓名:仅新增必填
if is_create and not (attrs.get('real_name') or '').strip():
raise AppError('CMS_VALIDATION_ERROR', '姓名必填', status_code=400)
return attrs
def create(self, validated_data):
phone = validated_data['phone']
try:
return User.objects.create_user(
username=phone, password=f'Pass{phone}', **validated_data
)
except IntegrityError:
raise AppError('CMS_USER_PHONE_EXISTS', '手机号已存在', status_code=400)
def update(self, instance, validated_data):
for key, val in validated_data.items():
setattr(instance, key, val)
instance.save()
return instance
# ── ViewSet ──────────────────────────────────────────────────────────────────
@extend_schema_view(
list=extend_schema(summary='CMS-USER-1 用户列表', tags=['CMS-用户']),
create=extend_schema(summary='CMS-USER-2 新增用户', tags=['CMS-用户']),
retrieve=extend_schema(summary='用户详情', tags=['CMS-用户']),
partial_update=extend_schema(summary='CMS-USER-3 编辑用户', tags=['CMS-用户']),
destroy=extend_schema(summary='CMS-USER-4 停用用户(逻辑删除)', tags=['CMS-用户']),
)
class CmsUserViewSet(viewsets.ModelViewSet):
"""CMS 用户管理。停用为逻辑删除(User 软删除)。
- 超级管理员:全平台、任意角色。
- 医院管理员(CMS-HUSER-*):仅本院、仅 doctor/student(医生管理 / 医学生管理)。
前端按 `?role_type=doctor` / `?role_type=student` 区分两个页面。
"""
permission_classes = [IsAuthenticated, IsSuperOrHospitalAdmin]
filter_backends = [DjangoFilterBackend, filters.SearchFilter]
filterset_fields = ['role_type', 'institution', 'status', 'gender']
search_fields = ['username', 'real_name', 'phone']
http_method_names = ['get', 'post', 'patch', 'delete', 'head', 'options']
def get_queryset(self):
qs = User.objects.select_related('institution', 'department').all().order_by('-created_at')
user = self.request.user
if is_super(user):
return qs
# 医院管理员:仅本院 + 仅医生/学生(也保证只能查/改/停用本院 doctor/student
return qs.filter(institution_id=user.institution_id, role_type__in=HOSPITAL_ADMIN_ROLES)
def get_serializer_class(self):
if self.action in ('create', 'update', 'partial_update'):
return CmsUserWriteSerializer
return CmsUserSerializer
def create(self, request, *args, **kwargs):
write = self.get_serializer(data=request.data)
write.is_valid(raise_exception=True)
user = write.save()
return Response(CmsUserSerializer(user).data, status=status.HTTP_201_CREATED)
def update(self, request, *args, **kwargs):
partial = kwargs.pop('partial', False)
instance = self.get_object()
write = self.get_serializer(instance, data=request.data, partial=partial)
write.is_valid(raise_exception=True)
user = write.save()
return Response(CmsUserSerializer(user).data)
@extend_schema(summary='CMS-USER-5 重置密码', tags=['CMS-用户'])
@action(detail=True, methods=['post'], url_path='reset-password')
def reset_password(self, request, pk=None):
"""重置为默认密码 `Pass{手机号}`(或请求体 password),并踢下线。"""
user = self.get_object()
new_password = (request.data.get('password') or '').strip() or f'Pass{user.phone}'
user.set_password(new_password)
user.save(update_fields=['password'])
invalidate_user_tokens(user.id)
return Response({'message': '密码重置成功', 'password': new_password})
@extend_schema(summary='CMS-USER-7 下载导入模板', tags=['CMS-用户'])
@action(detail=False, methods=['get'], url_path='import-template')
def import_template(self, request):
return xlsx_response('用户导入模板.xlsx', USER_IMPORT_HEADERS, [])
@extend_schema(summary='CMS-USER-8 导出用户', tags=['CMS-用户'])
@action(detail=False, methods=['get'], url_path='export')
def export(self, request):
qs = self.filter_queryset(self.get_queryset())
rows = [
[u.id, u.phone, u.real_name, ROLE_LABEL_BY_CODE.get(u.role_type, u.role_type),
u.institution.name if u.institution_id else '',
'正常' if u.status == 1 else '禁用']
for u in qs
]
return xlsx_response('用户列表.xlsx', USER_EXPORT_HEADERS, rows)
@extend_schema(summary='CMS-USER-6 导入用户', tags=['CMS-用户'])
@action(detail=False, methods=['post'], url_path='import',
parser_classes=[MultiPartParser, FormParser])
def import_users(self, request):
"""Excel 批量导入用户。
- 超管:列 手机号 | 姓名 | 角色 | 机构编码(均必填)。
- 医院管理员:机构强制本院(忽略机构编码列),角色仅限 医生 / 学生。
"""
actor = request.user
actor_is_super = is_super(actor)
if not actor_is_super and not actor.institution_id:
raise AppError('CMS_NO_INSTITUTION', '当前医院管理员无所属机构,无法导入', status_code=403)
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)
inst_by_code = {i.code: i for i in Institution.objects.all()}
success, errors = 0, []
for idx, row in enumerate(rows, start=2): # 第 2 行起为数据
phone = (row.get('手机号') or '').strip()
real_name = (row.get('姓名') or '').strip()
role = _resolve_role(row.get('角色'))
if not re.match(r'^1[3-9]\d{9}$', phone):
errors.append({'row': idx, 'reason': f'手机号格式不合法:{phone}'}); continue
if not real_name:
errors.append({'row': idx, 'reason': '姓名为空'}); continue
if not role:
errors.append({'row': idx, 'reason': f"角色非法:{row.get('角色')}"}); continue
if not actor_is_super and role not in HOSPITAL_ADMIN_ROLES:
errors.append({'row': idx, 'reason': '医院管理员只能导入医生 / 学生 / 内容管理员'}); continue
# 机构:超管按机构编码、医院管理员强制本院
if actor_is_super:
inst_code = (row.get('机构编码') or '').strip()
if not inst_code:
errors.append({'row': idx, 'reason': '机构编码为空'}); continue
inst = inst_by_code.get(inst_code)
if inst is None:
errors.append({'row': idx, 'reason': f'机构编码不存在:{inst_code}'}); continue
else:
inst = actor.institution
if User.all_objects.filter(phone=phone).exists():
errors.append({'row': idx, 'reason': '手机号已存在'}); continue
try:
User.objects.create_user(
username=phone, password=f'Pass{phone}', phone=phone,
real_name=real_name, role_type=role, institution=inst, status=1,
)
success += 1
except IntegrityError:
errors.append({'row': idx, 'reason': '手机号已存在'})
return Response({
'total': len(rows), 'success': success, 'failed': len(errors), 'errors': errors,
})
# ── 带教医生「我的学生」(CMS-TEA-1~2)─────────────────────────────────────────
class CmsStudentSerializer(CmsUserSerializer):
"""学生基础信息(读取)。
复用 `CmsUserSerializer` 的用户对象字段,额外补科室与训练统计,便于带教医生
在「我的学生」列表 / 详情查看学习概况。
"""
department_name = serializers.CharField(source='department.name', read_only=True, default=None)
class Meta(CmsUserSerializer.Meta):
fields = CmsUserSerializer.Meta.fields + [
'department', 'department_name', 'practice_years',
'total_training_count', 'total_case_count', 'current_level',
]
@extend_schema_view(
list=extend_schema(summary='CMS-TEA-1 我的学生列表', tags=['CMS-我的学生']),
retrieve=extend_schema(summary='CMS-TEA-2 学生基础信息', tags=['CMS-我的学生']),
)
class CmsStudentViewSet(viewsets.ReadOnlyModelViewSet):
"""带教医生「我的学生」:名下进行中(status=1)的学生,只读。
数据范围 = `teacher_student_relation` 中 `teacher=当前医生 且 status=1` 的学生。
非名下学生访问详情时不在 queryset 内 → 404。
"""
permission_classes = [IsAuthenticated, IsTeacher]
serializer_class = CmsStudentSerializer
filter_backends = [DjangoFilterBackend, filters.SearchFilter]
filterset_fields = ['gender', 'status']
search_fields = ['username', 'real_name', 'phone']
def get_queryset(self):
student_ids = TeacherStudentRelation.objects.filter(
teacher=self.request.user, status=1
).values_list('student_id', flat=True)
return (
User.objects.filter(id__in=student_ids)
.select_related('institution', 'department')
.order_by('-created_at')
)