231 lines
9.6 KiB
Python
231 lines
9.6 KiB
Python
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,
|
|
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']
|
|
|
|
@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']
|
|
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)
|