Files

361 lines
14 KiB
Python

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,
})