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
+2 -2
View File
@@ -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']
+2 -1
View File
@@ -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}"不存在')
+327
View File
@@ -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')
)
+165
View File
@@ -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})
+15
View File
@@ -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)),
]
@@ -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
View File
@@ -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
View File
@@ -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__'
+2
View File
@@ -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
View File
@@ -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,
})