feat: cms users institution department manager
This commit is contained in:
+2
-2
@@ -60,6 +60,6 @@ class InstitutionAdmin(admin.ModelAdmin):
|
||||
|
||||
@admin.register(Department)
|
||||
class DepartmentAdmin(admin.ModelAdmin):
|
||||
list_display = ['id', 'name', 'institution', 'category']
|
||||
list_display = ['id', 'name', 'category']
|
||||
list_filter = ['category']
|
||||
search_fields = ['name', 'institution__name']
|
||||
search_fields = ['name']
|
||||
|
||||
@@ -100,7 +100,8 @@ def register(request):
|
||||
|
||||
department = None
|
||||
if department_name:
|
||||
qs = Department.objects.filter(name=department_name, institution=institution)
|
||||
# 科室为全局表,按名称解析(与机构无关)
|
||||
qs = Department.objects.filter(name=department_name)
|
||||
cnt = qs.count()
|
||||
if cnt == 0:
|
||||
raise AppError('USER_DEPARTMENT_NOT_FOUND', f'科室"{department_name}"不存在')
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
"""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')
|
||||
)
|
||||
@@ -0,0 +1,165 @@
|
||||
"""CMS 师生关系管理(CMS-REL-1~4)—— 医院管理员(本院)/ 超管(全平台)。"""
|
||||
from rest_framework import viewsets, filters, status, serializers
|
||||
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.cms.permissions import IsSuperOrHospitalAdmin, is_super
|
||||
from apps.common.excel import xlsx_response, rows_from_xlsx
|
||||
from .models import User, TeacherStudentRelation
|
||||
|
||||
REL_STATUS_LABEL = {0: '已结束', 1: '进行中'}
|
||||
REL_IMPORT_HEADERS = ['带教医生手机号', '学生手机号']
|
||||
REL_EXPORT_HEADERS = ['ID', '带教医生', '带教医生手机号', '学生', '学生手机号', '状态']
|
||||
|
||||
|
||||
class CmsRelationSerializer(serializers.ModelSerializer):
|
||||
"""读取(列表 / 详情)。"""
|
||||
teacher_name = serializers.CharField(source='teacher.real_name', read_only=True, default=None)
|
||||
teacher_phone = serializers.CharField(source='teacher.phone', read_only=True, default=None)
|
||||
student_name = serializers.CharField(source='student.real_name', read_only=True, default=None)
|
||||
student_phone = serializers.CharField(source='student.phone', read_only=True, default=None)
|
||||
|
||||
class Meta:
|
||||
model = TeacherStudentRelation
|
||||
fields = [
|
||||
'id', 'teacher', 'teacher_name', 'teacher_phone',
|
||||
'student', 'student_name', 'student_phone',
|
||||
'relation_type', 'status', 'created_at',
|
||||
]
|
||||
|
||||
|
||||
class CmsRelationWriteSerializer(serializers.ModelSerializer):
|
||||
"""新增 / 编辑:teacher 必须是医生、student 必须是学生;医院管理员限本院。"""
|
||||
teacher = serializers.PrimaryKeyRelatedField(queryset=User.objects.filter(role_type='doctor'))
|
||||
student = serializers.PrimaryKeyRelatedField(queryset=User.objects.filter(role_type='student'))
|
||||
|
||||
class Meta:
|
||||
model = TeacherStudentRelation
|
||||
fields = ['teacher', 'student', 'relation_type', 'status']
|
||||
|
||||
def validate(self, attrs):
|
||||
actor = self.context['request'].user
|
||||
teacher = attrs.get('teacher') or (self.instance.teacher if self.instance else None)
|
||||
student = attrs.get('student') or (self.instance.student if self.instance else None)
|
||||
if teacher is None or student is None:
|
||||
raise AppError('CMS_VALIDATION_ERROR', '带教医生和学生均必填', status_code=400)
|
||||
|
||||
if not is_super(actor):
|
||||
if not actor.institution_id:
|
||||
raise AppError('CMS_NO_INSTITUTION', '当前医院管理员无所属机构', status_code=403)
|
||||
if teacher.institution_id != actor.institution_id:
|
||||
raise AppError('CMS_REL_SCOPE_FORBIDDEN', '带教医生不属于本院', status_code=403)
|
||||
if student.institution_id != actor.institution_id:
|
||||
raise AppError('CMS_REL_SCOPE_FORBIDDEN', '学生不属于本院', status_code=403)
|
||||
|
||||
dup = TeacherStudentRelation.objects.filter(teacher=teacher, student=student)
|
||||
if self.instance is not None:
|
||||
dup = dup.exclude(pk=self.instance.pk)
|
||||
if dup.exists():
|
||||
raise AppError('CMS_REL_EXISTS', '该师生关系已存在', status_code=400)
|
||||
return attrs
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(summary='CMS-REL-1 师生关系列表', tags=['CMS-师生关系']),
|
||||
create=extend_schema(summary='CMS-REL-2 新增师生关系', tags=['CMS-师生关系']),
|
||||
retrieve=extend_schema(summary='师生关系详情', tags=['CMS-师生关系']),
|
||||
partial_update=extend_schema(summary='CMS-REL-2 编辑师生关系', tags=['CMS-师生关系']),
|
||||
destroy=extend_schema(summary='CMS-REL-3 停用师生关系(逻辑删除)', tags=['CMS-师生关系']),
|
||||
)
|
||||
class CmsTeacherStudentRelationViewSet(viewsets.ModelViewSet):
|
||||
"""CMS 师生关系管理。超管全平台、医院管理员仅本院。停用为逻辑删除。"""
|
||||
permission_classes = [IsAuthenticated, IsSuperOrHospitalAdmin]
|
||||
filter_backends = [DjangoFilterBackend, filters.SearchFilter]
|
||||
filterset_fields = ['teacher', 'student', 'status']
|
||||
search_fields = ['teacher__real_name', 'teacher__phone', 'student__real_name', 'student__phone']
|
||||
http_method_names = ['get', 'post', 'patch', 'delete', 'head', 'options']
|
||||
|
||||
def get_queryset(self):
|
||||
qs = TeacherStudentRelation.objects.select_related('teacher', 'student').all().order_by('-created_at')
|
||||
user = self.request.user
|
||||
if is_super(user):
|
||||
return qs
|
||||
return qs.filter(teacher__institution_id=user.institution_id)
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action in ('create', 'update', 'partial_update'):
|
||||
return CmsRelationWriteSerializer
|
||||
return CmsRelationSerializer
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
write = self.get_serializer(data=request.data)
|
||||
write.is_valid(raise_exception=True)
|
||||
rel = write.save()
|
||||
return Response(CmsRelationSerializer(rel).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)
|
||||
rel = write.save()
|
||||
return Response(CmsRelationSerializer(rel).data)
|
||||
|
||||
@extend_schema(summary='CMS-REL-4 下载师生关系导入模板', tags=['CMS-师生关系'])
|
||||
@action(detail=False, methods=['get'], url_path='import-template')
|
||||
def import_template(self, request):
|
||||
return xlsx_response('师生关系导入模板.xlsx', REL_IMPORT_HEADERS, [])
|
||||
|
||||
@extend_schema(summary='CMS-REL-4 导出师生关系', tags=['CMS-师生关系'])
|
||||
@action(detail=False, methods=['get'], url_path='export')
|
||||
def export(self, request):
|
||||
qs = self.filter_queryset(self.get_queryset())
|
||||
rows = [
|
||||
[r.id,
|
||||
r.teacher.real_name if r.teacher_id else '', r.teacher.phone if r.teacher_id else '',
|
||||
r.student.real_name if r.student_id else '', r.student.phone if r.student_id else '',
|
||||
REL_STATUS_LABEL.get(r.status, r.status)]
|
||||
for r in qs
|
||||
]
|
||||
return xlsx_response('师生关系列表.xlsx', REL_EXPORT_HEADERS, rows)
|
||||
|
||||
@extend_schema(summary='CMS-REL-4 导入师生关系', tags=['CMS-师生关系'])
|
||||
@action(detail=False, methods=['post'], url_path='import',
|
||||
parser_classes=[MultiPartParser, FormParser])
|
||||
def import_relations(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)
|
||||
|
||||
success, errors = 0, []
|
||||
for idx, row in enumerate(rows, start=2):
|
||||
t_phone = (row.get('带教医生手机号') or '').strip()
|
||||
s_phone = (row.get('学生手机号') or '').strip()
|
||||
teacher = User.objects.filter(phone=t_phone, role_type='doctor').first()
|
||||
if teacher is None:
|
||||
errors.append({'row': idx, 'reason': f'带教医生不存在或非医生:{t_phone}'}); continue
|
||||
student = User.objects.filter(phone=s_phone, role_type='student').first()
|
||||
if student is None:
|
||||
errors.append({'row': idx, 'reason': f'学生不存在或非学生:{s_phone}'}); continue
|
||||
if not actor_is_super and (
|
||||
teacher.institution_id != actor.institution_id
|
||||
or student.institution_id != actor.institution_id
|
||||
):
|
||||
errors.append({'row': idx, 'reason': '师生不属于本院'}); continue
|
||||
if TeacherStudentRelation.objects.filter(teacher=teacher, student=student).exists():
|
||||
errors.append({'row': idx, 'reason': '师生关系已存在'}); continue
|
||||
TeacherStudentRelation.objects.create(teacher=teacher, student=student, status=1)
|
||||
success += 1
|
||||
|
||||
return Response({'total': len(rows), 'success': success, 'failed': len(errors), 'errors': errors})
|
||||
@@ -0,0 +1,15 @@
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .cms import CmsUserViewSet, CmsStudentViewSet
|
||||
from .cms_relation import CmsTeacherStudentRelationViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'users', CmsUserViewSet, basename='cms-user')
|
||||
router.register(r'teacher-student-relations', CmsTeacherStudentRelationViewSet,
|
||||
basename='cms-teacher-student-relation')
|
||||
router.register(r'students', CmsStudentViewSet, basename='cms-student')
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
# Generated by Django 5.2.14 on 2026-06-10 08:42
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('user', '0005_institution_banner_url_user_practice_years'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='department',
|
||||
name='institution',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='department',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='删除时间'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='department',
|
||||
name='is_deleted',
|
||||
field=models.BooleanField(db_index=True, default=False, verbose_name='是否删除'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='institution',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='删除时间'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='institution',
|
||||
name='is_deleted',
|
||||
field=models.BooleanField(db_index=True, default=False, verbose_name='是否删除'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='teacherstudentrelation',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='删除时间'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='teacherstudentrelation',
|
||||
name='is_deleted',
|
||||
field=models.BooleanField(db_index=True, default=False, verbose_name='是否删除'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.14 on 2026-06-10 09:36
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('user', '0006_remove_department_institution_department_deleted_at_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='删除时间'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='is_deleted',
|
||||
field=models.BooleanField(db_index=True, default=False, verbose_name='是否删除'),
|
||||
),
|
||||
]
|
||||
+24
-10
@@ -3,10 +3,14 @@ from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.contrib.auth.base_user import BaseUserManager
|
||||
|
||||
from apps.common.models import BaseModel
|
||||
from apps.common.models import BaseModel, SoftDeleteModel
|
||||
|
||||
|
||||
class UserManager(BaseUserManager):
|
||||
def get_queryset(self):
|
||||
# 默认管理器只返回未删除(未停用)用户;停用用户无法登录/被列出
|
||||
return super().get_queryset().filter(is_deleted=False)
|
||||
|
||||
def create_user(self, username, password=None, **extra_fields):
|
||||
if not username:
|
||||
raise ValueError('用户名不能为空')
|
||||
@@ -70,7 +74,12 @@ class User(AbstractBaseUser, PermissionsMixin, BaseModel):
|
||||
is_active = models.BooleanField('active', default=True)
|
||||
date_joined = models.DateTimeField('date joined', default=timezone.now)
|
||||
|
||||
objects = UserManager()
|
||||
# 软删除(停用 = 逻辑删除)
|
||||
is_deleted = models.BooleanField('是否删除', default=False, db_index=True)
|
||||
deleted_at = models.DateTimeField('删除时间', null=True, blank=True)
|
||||
|
||||
objects = UserManager() # 默认:仅未删除
|
||||
all_objects = BaseUserManager() # 含已删除(管理/恢复用)
|
||||
|
||||
USERNAME_FIELD = 'username'
|
||||
REQUIRED_FIELDS = []
|
||||
@@ -83,6 +92,15 @@ class User(AbstractBaseUser, PermissionsMixin, BaseModel):
|
||||
def __str__(self):
|
||||
return self.username
|
||||
|
||||
def delete(self, using=None, keep_parents=False):
|
||||
"""停用 = 逻辑删除(不物理删除,避免级联丢数据)。"""
|
||||
self.is_deleted = True
|
||||
self.deleted_at = timezone.now()
|
||||
self.save(using=using, update_fields=['is_deleted', 'deleted_at', 'updated_at'])
|
||||
|
||||
def hard_delete(self, using=None, keep_parents=False):
|
||||
super().delete(using=using, keep_parents=keep_parents)
|
||||
|
||||
|
||||
class Role(BaseModel):
|
||||
"""角色表"""
|
||||
@@ -99,7 +117,7 @@ class Role(BaseModel):
|
||||
return self.role_name
|
||||
|
||||
|
||||
class TeacherStudentRelation(BaseModel):
|
||||
class TeacherStudentRelation(SoftDeleteModel):
|
||||
"""师生关系表"""
|
||||
STATUS_CHOICES = [
|
||||
(0, '已结束'),
|
||||
@@ -129,7 +147,7 @@ class TeacherStudentRelation(BaseModel):
|
||||
return f"{self.teacher.real_name or self.teacher.username} -> {self.student.real_name or self.student.username}"
|
||||
|
||||
|
||||
class Institution(BaseModel):
|
||||
class Institution(SoftDeleteModel):
|
||||
"""医疗机构表"""
|
||||
|
||||
id = models.BigAutoField(primary_key=True)
|
||||
@@ -153,13 +171,9 @@ class Institution(BaseModel):
|
||||
return self.name
|
||||
|
||||
|
||||
class Department(BaseModel):
|
||||
"""科室表"""
|
||||
class Department(SoftDeleteModel):
|
||||
"""科室表(全局分类表,与机构无关;仅超级管理员维护,用于给病例分类)"""
|
||||
id = models.BigAutoField(primary_key=True)
|
||||
institution = models.ForeignKey(
|
||||
Institution, on_delete=models.CASCADE,
|
||||
verbose_name='所属机构'
|
||||
)
|
||||
name = models.CharField('科室名称', max_length=100)
|
||||
category = models.CharField('科室分类', max_length=50, blank=True)
|
||||
|
||||
|
||||
+44
-11
@@ -1,3 +1,5 @@
|
||||
import re
|
||||
|
||||
from rest_framework import serializers
|
||||
from .models import User, Role, TeacherStudentRelation, Institution, Department
|
||||
|
||||
@@ -49,21 +51,54 @@ class UserUpdateSerializer(serializers.ModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class ProfileUpdateSerializer(serializers.ModelSerializer):
|
||||
"""个人信息更新(移动端个人中心)——仅允许用户自助修改的字段。
|
||||
|
||||
白名单字段:username / real_name / phone / avatar / gender / department /
|
||||
title_name / major / practice_years / training_stage / learning_target。
|
||||
机构(institution)、角色(role_type)、is_superuser 等不在字段内,无法被用户修改。
|
||||
"""
|
||||
# 显式声明以去掉 ModelSerializer 自动加的 UniqueValidator,改用自定义唯一性校验
|
||||
username = serializers.CharField(max_length=50)
|
||||
phone = serializers.CharField(max_length=20, required=False, allow_blank=True)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
'username', 'real_name', 'phone', 'avatar', 'gender',
|
||||
'department', 'title_name', 'major', 'practice_years',
|
||||
'training_stage', 'learning_target',
|
||||
]
|
||||
|
||||
def validate_username(self, value):
|
||||
value = (value or '').strip()
|
||||
if not value:
|
||||
raise serializers.ValidationError('用户名不能为空')
|
||||
if User.objects.filter(username=value).exclude(pk=self.instance.pk).exists():
|
||||
raise serializers.ValidationError('用户名已被占用')
|
||||
return value
|
||||
|
||||
def validate_phone(self, value):
|
||||
value = (value or '').strip()
|
||||
if value:
|
||||
if not re.match(r'^1[3-9]\d{9}$', value):
|
||||
raise serializers.ValidationError('手机号格式不合法')
|
||||
if User.objects.filter(phone=value).exclude(pk=self.instance.pk).exists():
|
||||
raise serializers.ValidationError('手机号已被占用')
|
||||
return value
|
||||
|
||||
|
||||
class StudentProfileConfigSerializer(serializers.Serializer):
|
||||
"""医学生信息配置(首次进入系统)——科室、专业职称、执业年限"""
|
||||
"""医学生信息配置(首次进入系统)——科室、专业职称、执业年限。
|
||||
|
||||
科室为全局表,用户可自由选择想学习的科室,不再校验机构归属。
|
||||
"""
|
||||
department = serializers.PrimaryKeyRelatedField(
|
||||
queryset=Department.objects.all(), help_text='执业科室 ID'
|
||||
queryset=Department.objects.all(), help_text='科室 ID(全局科室)'
|
||||
)
|
||||
title_name = serializers.CharField(max_length=50, help_text='专业职称,如:住院医师')
|
||||
practice_years = serializers.CharField(max_length=20, help_text='执业年限,如:1-3年')
|
||||
|
||||
def validate_department(self, value):
|
||||
"""科室必须属于当前用户所在机构"""
|
||||
user = self.context['request'].user
|
||||
if user.institution_id and value.institution_id != user.institution_id:
|
||||
raise serializers.ValidationError('所选科室不属于您所在的机构')
|
||||
return value
|
||||
|
||||
|
||||
class UserPasswordSerializer(serializers.Serializer):
|
||||
"""密码修改序列化器"""
|
||||
@@ -93,8 +128,6 @@ class InstitutionSerializer(serializers.ModelSerializer):
|
||||
|
||||
|
||||
class DepartmentSerializer(serializers.ModelSerializer):
|
||||
institution_name = serializers.CharField(source='institution.name', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Department
|
||||
fields = '__all__'
|
||||
|
||||
@@ -23,6 +23,8 @@ urlpatterns = [
|
||||
path('institution_info/', views.institution_info, name='institution-info'),
|
||||
path('my_departments/', views.my_departments, name='my-departments'),
|
||||
path('profile/config/', views.student_profile_config, name='student-profile-config'),
|
||||
# 移动端个人中心:个人信息获取(GET) / 更新(PATCH)
|
||||
path('profile/', views.profile, name='profile'),
|
||||
# 认证相关
|
||||
path('auth/send-code/', send_code, name='send-code'),
|
||||
path('auth/register/', register, name='register'),
|
||||
|
||||
+46
-19
@@ -11,7 +11,7 @@ from .auth import TRIAL_INSTITUTION_NAME
|
||||
from .models import User, Role, TeacherStudentRelation, Institution, Department
|
||||
from .serializers import (
|
||||
UserSerializer, UserCreateSerializer, UserUpdateSerializer,
|
||||
StudentProfileConfigSerializer,
|
||||
StudentProfileConfigSerializer, ProfileUpdateSerializer,
|
||||
RoleSerializer,
|
||||
TeacherStudentRelationSerializer, InstitutionSerializer, DepartmentSerializer
|
||||
)
|
||||
@@ -184,21 +184,13 @@ class InstitutionViewSet(viewsets.ModelViewSet):
|
||||
filterset_fields = ['type', 'province', 'city']
|
||||
search_fields = ['name']
|
||||
|
||||
@action(detail=True, methods=['get'])
|
||||
def departments(self, request, pk=None):
|
||||
"""获取机构下的科室列表"""
|
||||
institution = self.get_object()
|
||||
departments = institution.department_set.all()
|
||||
serializer = DepartmentSerializer(departments, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class DepartmentViewSet(viewsets.ModelViewSet):
|
||||
"""科室管理"""
|
||||
"""科室管理(全局科室,与机构无关)"""
|
||||
queryset = Department.objects.all()
|
||||
serializer_class = DepartmentSerializer
|
||||
filter_backends = [DjangoFilterBackend, filters.SearchFilter]
|
||||
filterset_fields = ['institution', 'category']
|
||||
filterset_fields = ['category']
|
||||
search_fields = ['name']
|
||||
|
||||
|
||||
@@ -278,20 +270,16 @@ def institution_info(request):
|
||||
|
||||
|
||||
@extend_schema(
|
||||
summary='所属机构科室列表接口(不分页)',
|
||||
description='返回当前登录学生所属机构下的全部科室,不分页,供配置页选择执业科室。',
|
||||
summary='科室列表接口(全局、不分页)',
|
||||
description='返回全部科室(全局科室表,与机构无关),不分页,供配置页/个人中心选择想学习的科室。',
|
||||
responses={200: None},
|
||||
tags=['机构'],
|
||||
)
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def my_departments(request):
|
||||
"""所属机构科室列表接口 — 当前用户机构下全部科室、不分页"""
|
||||
inst = request.user.institution
|
||||
if inst is None:
|
||||
raise AppError('USER_INSTITUTION_NOT_FOUND', '当前账号未关联机构', status_code=404)
|
||||
|
||||
departments = Department.objects.filter(institution=inst).order_by('name')
|
||||
"""科室列表接口 — 全部全局科室、不分页(用户可自由选择想学习的科室)"""
|
||||
departments = Department.objects.all().order_by('name')
|
||||
data = [
|
||||
{
|
||||
'id': dept.id,
|
||||
@@ -331,3 +319,42 @@ def student_profile_config(request):
|
||||
'message': '配置成功',
|
||||
'user': UserSerializer(user).data,
|
||||
})
|
||||
|
||||
|
||||
@extend_schema(
|
||||
methods=['GET'],
|
||||
summary='个人信息获取',
|
||||
description='获取当前登录用户的全部个人信息(个人中心)。',
|
||||
responses={200: UserSerializer},
|
||||
tags=['用户'],
|
||||
)
|
||||
@extend_schema(
|
||||
methods=['PATCH'],
|
||||
summary='个人信息更新',
|
||||
description='更新当前登录用户的个人信息。仅允许修改:用户名、真实姓名、手机号、头像、'
|
||||
'性别、所属科室、职称、专业、执业年限、培训阶段、学习目标。'
|
||||
'机构、角色、is_superuser 不可由用户自行修改。',
|
||||
request=ProfileUpdateSerializer,
|
||||
responses={200: UserSerializer},
|
||||
tags=['用户'],
|
||||
)
|
||||
@api_view(['GET', 'PATCH'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def profile(request):
|
||||
"""个人信息获取 / 更新(移动端个人中心)"""
|
||||
user = request.user
|
||||
|
||||
if request.method == 'GET':
|
||||
return Response(UserSerializer(user).data)
|
||||
|
||||
# PATCH:局部更新,仅白名单字段生效
|
||||
serializer = ProfileUpdateSerializer(
|
||||
user, data=request.data, partial=True, context={'request': request}
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save()
|
||||
|
||||
return Response({
|
||||
'message': '更新成功',
|
||||
'user': UserSerializer(user).data,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user