Files
medical_training/apps/user/cms.py
T
2026-06-11 13:57:46 +08:00

333 lines
16 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.
"""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')
)