"""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'] # 仅 GET / POST:查=GET,增删改=POST(编辑→{id}/update/,停用→{id}/disable/) http_method_names = ['get', 'post', '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_relation'): 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) @extend_schema(summary='CMS-REL-3 编辑师生关系', tags=['CMS-师生关系']) @action(detail=True, methods=['post'], url_path='update') def update_relation(self, request, pk=None): """编辑师生关系(POST 局部更新,等价旧 PATCH /{id}/)。""" instance = self.get_object() write = self.get_serializer(instance, data=request.data, partial=True) write.is_valid(raise_exception=True) rel = write.save() return Response(CmsRelationSerializer(rel).data) @extend_schema(summary='CMS-REL-4 停用师生关系(逻辑删除)', tags=['CMS-师生关系']) @action(detail=True, methods=['post'], url_path='disable') def disable(self, request, pk=None): """停用师生关系(软删除,等价旧 DELETE /{id}/)。""" self.get_object().delete() return Response({'message': '已停用'}) @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})