from django.conf import settings from rest_framework import viewsets, filters, status from rest_framework.decorators import action, api_view, permission_classes from rest_framework.permissions import IsAuthenticated, AllowAny from rest_framework.response import Response from django_filters.rest_framework import DjangoFilterBackend from drf_spectacular.utils import extend_schema from config.exceptions import AppError from .auth import TRIAL_INSTITUTION_NAME from .models import User, Role, TeacherStudentRelation, Institution, Department from .serializers import ( UserSerializer, UserCreateSerializer, UserUpdateSerializer, StudentProfileConfigSerializer, ProfileUpdateSerializer, RoleSerializer, TeacherStudentRelationSerializer, InstitutionSerializer, DepartmentSerializer ) from .permissions import IsUserListPermitted, IsUserDetailPermitted from .utils.password import validate_password_strength from .utils.jwt_redis import invalidate_user_tokens from .audit import log_password_change, log_password_reset, log_user_list class UserViewSet(viewsets.ModelViewSet): """用户管理 list: 获取用户列表(支持过滤、搜索、排序)— U9 角色分级权限 create: 创建用户 retrieve: 获取用户详情 — U10 对象级权限 update: 更新用户信息 destroy: 删除用户 """ queryset = User.objects.all() # 保留供 DRF router basename 检测 filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] filterset_fields = ['role_type', 'status', 'institution', 'department', 'gender'] search_fields = ['username', 'real_name', 'phone'] ordering_fields = ['created_at', 'last_login_time', 'total_training_count'] # ── 权限分派 ────────────────────────────────────────────────────────────── def get_permissions(self): if self.action == 'list': return [IsAuthenticated(), IsUserListPermitted()] elif self.action == 'retrieve': return [IsAuthenticated(), IsUserDetailPermitted()] return super().get_permissions() # ── 序列化器分派 ────────────────────────────────────────────────────────── def get_serializer_class(self): if self.action == 'create': return UserCreateSerializer elif self.action in ['update', 'partial_update']: return UserUpdateSerializer return UserSerializer # ── 查询集:U9 角色分级 + N+1 优化 ─────────────────────────────────────── def get_queryset(self): qs = User.objects.select_related('institution', 'department') user = self.request.user if not user.is_authenticated: return qs.none() # list 动作按角色限制可见范围 if self.action == 'list': if user.role_type in ('super_admin', 'content_admin') or user.is_staff: return qs # 管理员:全员 elif user.role_type == 'teacher': # 教师:仅自己名下活跃学生 student_ids = TeacherStudentRelation.objects.filter( teacher=user, status=1 ).values_list('student_id', flat=True) return qs.filter(id__in=student_ids, role_type='student') else: return qs.none() # 兜底(被 IsUserListPermitted 拦截在前) return qs # ── U9 list 审计 ───────────────────────────────────────────────────────── def list(self, request, *args, **kwargs): filters_dict = {k: v for k, v in request.query_params.items() if k in ('role_type', 'status', 'search', 'ordering', 'page')} log_user_list(request.user.id, filters=filters_dict) return super().list(request, *args, **kwargs) # ── U6 修改密码(已登录) ───────────────────────────────────────────────── @action(detail=False, methods=['post'], url_path='change-password') def change_password(self, request): """U6 修改当前用户密码""" old_password = request.data.get('old_password', '') new_password = request.data.get('new_password', '') if not old_password or not new_password: raise AppError('VALIDATION_ERROR', '请提供旧密码和新密码') user = request.user # 1. 校验旧密码 if not user.check_password(old_password): raise AppError('AUTH_BAD_OLD_PASSWORD', '原密码错误') # 2. 新密码不得与旧密码相同 if user.check_password(new_password): raise AppError('AUTH_PASSWORD_SAME_AS_OLD', '新密码不能与旧密码相同') # 3. 密码强度校验 pwd_errors = validate_password_strength( new_password, phone=user.phone, real_name=user.real_name, ) if pwd_errors: raise AppError('AUTH_PASSWORD_WEAK', pwd_errors[0], details=pwd_errors) # 4. 改密 + 全设备登出 user.set_password(new_password) user.save(update_fields=['password']) invalidate_user_tokens(user.id) # 5. 审计 log_password_change(user.id) return Response({'message': '密码修改成功,请重新登录'}) # ── 管理员重置用户密码 ──────────────────────────────────────────────────── @action(detail=True, methods=['post'], url_path='reset-password') def reset_password(self, request, pk=None): """重置用户密码(管理员操作)""" user = self.get_object() new_password = request.data.get('password', '') if not new_password: raise AppError('VALIDATION_ERROR', '请提供新密码') # 密码强度校验 pwd_errors = validate_password_strength(new_password, phone=user.phone, real_name=user.real_name) if pwd_errors: raise AppError('AUTH_PASSWORD_WEAK', pwd_errors[0], details=pwd_errors) user.set_password(new_password) user.save(update_fields=['password']) invalidate_user_tokens(user.id) log_password_reset(user.id) return Response({'message': '密码重置成功'}) # ── 当前用户信息 ───────────────────────────────────────────────────────── @action(detail=False, methods=['get'], url_path='me') def me(self, request): """获取当前登录用户信息""" serializer = self.get_serializer(request.user) return Response(serializer.data) class RoleViewSet(viewsets.ModelViewSet): """角色管理""" queryset = Role.objects.all() serializer_class = RoleSerializer filter_backends = [filters.SearchFilter] search_fields = ['role_code', 'role_name'] class TeacherStudentRelationViewSet(viewsets.ModelViewSet): """师生关系管理""" queryset = TeacherStudentRelation.objects.all() serializer_class = TeacherStudentRelationSerializer filter_backends = [DjangoFilterBackend, filters.OrderingFilter] filterset_fields = ['teacher', 'student', 'relation_type', 'status'] ordering_fields = ['start_time', 'end_time', 'created_at'] class InstitutionViewSet(viewsets.ModelViewSet): """机构管理(医院/学校)""" queryset = Institution.objects.all() serializer_class = InstitutionSerializer filter_backends = [DjangoFilterBackend, filters.SearchFilter] filterset_fields = ['type', 'province', 'city'] search_fields = ['name'] class DepartmentViewSet(viewsets.ModelViewSet): """科室管理(全局科室,与机构无关)""" queryset = Department.objects.all() serializer_class = DepartmentSerializer filter_backends = [DjangoFilterBackend, filters.SearchFilter] filterset_fields = ['category'] search_fields = ['name'] # ── 移动端机构列表(不分页,登录前可调用)───────────────────────────────────── @extend_schema( summary='移动端机构列表(不分页)', description='返回当前可选的全部机构,供移动端学生登录时选择所属机构。' f'is_trial=true 标识预留试用机构({TRIAL_INSTITUTION_NAME})。', responses={200: None}, tags=['机构'], ) @api_view(['GET']) @permission_classes([AllowAny]) def institution_list(request): """移动端机构列表 — 全部机构、不分页""" institutions = Institution.objects.all().order_by('name') data = [ { 'id': inst.id, 'code': inst.code, 'name': inst.name, 'type': inst.type, 'level': inst.level, 'province': inst.province, 'city': inst.city, 'is_trial': inst.name == TRIAL_INSTITUTION_NAME, } for inst in institutions ] return Response(data) # ── 配置页相关接口(移动端,首次进入系统)───────────────────────────────────── def _build_banner_url(request, banner_value): """把机构 banner 字段转成完整可访问 URL。 - 空值回退到 settings.DEFAULT_INSTITUTION_BANNER - 已是 http(s) 完整 URL 时原样返回 - 否则视为相对 STATIC_URL 的静态路径,拼成绝对 URL; 若配置了 settings.STATIC_PUBLIC_PREFIX(如线上 '/server'),则补在前面, 以适配 Nginx 砍掉 /server 子路径前缀的部署。 """ value = banner_value or settings.DEFAULT_INSTITUTION_BANNER if value.startswith(('http://', 'https://')): return value prefix = settings.STATIC_PUBLIC_PREFIX.rstrip('/') path = prefix + '/' + settings.STATIC_URL.strip('/') + '/' + value.lstrip('/') return request.build_absolute_uri(path) @extend_schema( summary='机构信息获取接口', description='返回当前登录学生所属机构的信息,含机构专属 Banner 图 URL(用于配置页/首页顶部)。', responses={200: None}, tags=['机构'], ) @api_view(['GET']) @permission_classes([IsAuthenticated]) def institution_info(request): """机构信息获取接口 — 当前用户所属机构 + Banner 图 URL""" inst = request.user.institution if inst is None: raise AppError('USER_INSTITUTION_NOT_FOUND', '当前账号未关联机构', status_code=404) return Response({ 'id': inst.id, 'code': inst.code, 'name': inst.name, 'type': inst.type, 'level': inst.level, 'province': inst.province, 'city': inst.city, 'banner_url': _build_banner_url(request, inst.banner_url), }) @extend_schema( summary='科室列表接口(全局、不分页)', description='返回全部科室(全局科室表,与机构无关),不分页,供配置页/个人中心选择想学习的科室。', responses={200: None}, tags=['机构'], ) @api_view(['GET']) @permission_classes([IsAuthenticated]) def my_departments(request): """科室列表接口 — 全部全局科室、不分页(用户可自由选择想学习的科室)""" departments = Department.objects.all().order_by('name') data = [ { 'id': dept.id, 'name': dept.name, 'category': dept.category, } for dept in departments ] return Response(data) @extend_schema( summary='医学生信息配置接口', description='首次进入系统时录入学生信息:执业科室、专业职称、执业年限。' '机构在登录时已选定,此处不再修改。', request=StudentProfileConfigSerializer, responses={200: UserSerializer}, tags=['用户'], ) @api_view(['POST']) @permission_classes([IsAuthenticated]) def student_profile_config(request): """医学生信息配置接口 — 录入科室、职称、执业年限""" serializer = StudentProfileConfigSerializer( data=request.data, context={'request': request} ) serializer.is_valid(raise_exception=True) data = serializer.validated_data user = request.user user.department = data['department'] user.title_name = data['title_name'] user.practice_years = data['practice_years'] user.save(update_fields=['department', 'title_name', 'practice_years', 'updated_at']) return Response({ '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, })