From 32915bc6b4a5ef30a6c919e06fb8ae258ad76a22 Mon Sep 17 00:00:00 2001 From: shihan11 Date: Thu, 11 Jun 2026 10:37:29 +0800 Subject: [PATCH] feat: cms users institution department manager --- apps/cms/__init__.py | 0 apps/cms/apps.py | 7 + apps/cms/permissions.py | 50 +++ apps/cms/urls.py | 8 + apps/common/excel.py | 58 ++++ apps/common/models.py | 62 +++- apps/organization/__init__.py | 0 apps/organization/apps.py | 7 + apps/organization/serializers.py | 66 ++++ apps/organization/urls.py | 12 + apps/organization/views.py | 186 ++++++++++ ...3_alter_trainingrecord_options_and_more.py | 21 ++ apps/training/models.py | 13 +- apps/user/admin.py | 4 +- apps/user/auth/register.py | 3 +- apps/user/cms.py | 327 ++++++++++++++++++ apps/user/cms_relation.py | 165 +++++++++ apps/user/cms_urls.py | 15 + ...titution_department_deleted_at_and_more.py | 47 +++ .../0007_user_deleted_at_user_is_deleted.py | 23 ++ apps/user/models.py | 34 +- apps/user/serializers.py | 55 ++- apps/user/urls.py | 2 + apps/user/views.py | 65 +++- config/settings.py | 2 + config/urls.py | 1 + requirements.txt | 7 +- test/conftest.py | 6 +- test/swagger_cms.py | 245 +++++++++++++ test/swagger_profile_config.py | 4 +- test/swagger_tryout.py | 2 +- test/test_cms_department.py | 86 +++++ test/test_cms_huser.py | 88 +++++ test/test_cms_institution.py | 237 +++++++++++++ test/test_cms_relation.py | 99 ++++++ test/test_cms_students.py | 128 +++++++ test/test_cms_user.py | 170 +++++++++ test/test_profile_config.py | 42 +-- test/test_profile_personal.py | 131 +++++++ 39 files changed, 2403 insertions(+), 75 deletions(-) create mode 100644 apps/cms/__init__.py create mode 100644 apps/cms/apps.py create mode 100644 apps/cms/permissions.py create mode 100644 apps/cms/urls.py create mode 100644 apps/common/excel.py create mode 100644 apps/organization/__init__.py create mode 100644 apps/organization/apps.py create mode 100644 apps/organization/serializers.py create mode 100644 apps/organization/urls.py create mode 100644 apps/organization/views.py create mode 100644 apps/training/migrations/0003_alter_trainingrecord_options_and_more.py create mode 100644 apps/user/cms.py create mode 100644 apps/user/cms_relation.py create mode 100644 apps/user/cms_urls.py create mode 100644 apps/user/migrations/0006_remove_department_institution_department_deleted_at_and_more.py create mode 100644 apps/user/migrations/0007_user_deleted_at_user_is_deleted.py create mode 100644 test/swagger_cms.py create mode 100644 test/test_cms_department.py create mode 100644 test/test_cms_huser.py create mode 100644 test/test_cms_institution.py create mode 100644 test/test_cms_relation.py create mode 100644 test/test_cms_students.py create mode 100644 test/test_cms_user.py create mode 100644 test/test_profile_personal.py diff --git a/apps/cms/__init__.py b/apps/cms/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/cms/apps.py b/apps/cms/apps.py new file mode 100644 index 0000000..500eef9 --- /dev/null +++ b/apps/cms/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class CmsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.cms' + verbose_name = 'CMS 管理端' diff --git a/apps/cms/permissions.py b/apps/cms/permissions.py new file mode 100644 index 0000000..b79f3d8 --- /dev/null +++ b/apps/cms/permissions.py @@ -0,0 +1,50 @@ +from rest_framework.permissions import BasePermission + +from config.exceptions import AppError + + +class IsSuperAdmin(BasePermission): + """仅超级管理员可访问(role_type=super_admin 或 Django is_superuser)。 + + 超管拥有全部 CMS 功能权限且不受机构隔离限制。 + """ + + def has_permission(self, request, view): + user = request.user + if user and user.is_authenticated and ( + getattr(user, 'role_type', '') == 'super_admin' or user.is_superuser + ): + return True + raise AppError('CMS_PERMISSION_DENIED', '仅超级管理员可访问该接口', status_code=403) + + +def is_super(user): + return getattr(user, 'role_type', '') == 'super_admin' or getattr(user, 'is_superuser', False) + + +class IsSuperOrHospitalAdmin(BasePermission): + """超级管理员(全平台)或医院管理员(仅本院)可访问。 + + 具体的数据范围(本院过滤、角色限制)由各 ViewSet 的 get_queryset / 序列化器实现。 + """ + + def has_permission(self, request, view): + user = request.user + if user and user.is_authenticated and ( + is_super(user) or getattr(user, 'role_type', '') == 'hospital_admin' + ): + return True + raise AppError('CMS_PERMISSION_DENIED', '需要超级管理员或医院管理员权限', status_code=403) + + +class IsTeacher(BasePermission): + """仅带教医生(role_type=doctor)可访问。 + + 数据范围(名下学生)由 ViewSet 的 get_queryset 通过师生关系收口。 + """ + + def has_permission(self, request, view): + user = request.user + if user and user.is_authenticated and getattr(user, 'role_type', '') == 'doctor': + return True + raise AppError('CMS_PERMISSION_DENIED', '仅带教医生可访问该接口', status_code=403) diff --git a/apps/cms/urls.py b/apps/cms/urls.py new file mode 100644 index 0000000..86e35bb --- /dev/null +++ b/apps/cms/urls.py @@ -0,0 +1,8 @@ +from django.urls import path, include + +# apps/cms 是 CMS 薄壳:聚合各领域 app 的 /api/cms/ 路由 + 未来的跨领域看板。 +# 机构管理已迁至 organization 域(apps/organization),在此聚合。 +urlpatterns = [ + path('', include('apps.organization.urls')), # 机构、科室 + path('', include('apps.user.cms_urls')), # 用户 +] diff --git a/apps/common/excel.py b/apps/common/excel.py new file mode 100644 index 0000000..59d3113 --- /dev/null +++ b/apps/common/excel.py @@ -0,0 +1,58 @@ +"""CMS 导入 / 导出通用工具(基于 openpyxl)。""" +import io + +from django.http import HttpResponse +from openpyxl import Workbook, load_workbook + +XLSX_CONTENT_TYPE = ( + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' +) + + +def xlsx_response(filename, headers, rows): + """生成 .xlsx 下载响应。 + + Args: + filename: 下载文件名(含 .xlsx) + headers: 表头列表 + rows: 二维数据(list[list]);空则只导出表头(可作模板) + """ + wb = Workbook() + ws = wb.active + ws.append(list(headers)) + for row in rows: + ws.append(list(row)) + buf = io.BytesIO() + wb.save(buf) + buf.seek(0) + resp = HttpResponse(buf.getvalue(), content_type=XLSX_CONTENT_TYPE) + resp['Content-Disposition'] = f'attachment; filename="{filename}"' + return resp + + +def rows_from_xlsx(file): + """解析上传的 .xlsx,首行为表头,返回 list[dict](表头→单元格字符串)。 + + 空行跳过;单元格统一转为去空格字符串(None → '')。 + """ + wb = load_workbook(file, read_only=True, data_only=True) + ws = wb.active + it = ws.iter_rows(values_only=True) + try: + header_cells = next(it) + except StopIteration: + return [] + headers = [str(h).strip() if h is not None else '' for h in header_cells] + + result = [] + for raw in it: + if raw is None or all(c is None or str(c).strip() == '' for c in raw): + continue + row = {} + for i, h in enumerate(headers): + if not h: + continue + val = raw[i] if i < len(raw) else None + row[h] = '' if val is None else str(val).strip() + result.append(row) + return result diff --git a/apps/common/models.py b/apps/common/models.py index 73b6fbc..8e946a2 100644 --- a/apps/common/models.py +++ b/apps/common/models.py @@ -1,10 +1,70 @@ from django.db import models +from django.utils import timezone class BaseModel(models.Model): - """基础模型,包含通用字段""" + """基础模型,包含通用时间字段""" created_at = models.DateTimeField('创建时间', auto_now_add=True) updated_at = models.DateTimeField('更新时间', auto_now=True) class Meta: abstract = True + + +# ─── 软删除(停用 = 逻辑删除)──────────────────────────────────────────────────── + +class SoftDeleteQuerySet(models.QuerySet): + """QuerySet.delete() 改为逻辑删除;hard_delete() 才物理删除。""" + + def delete(self): + return self.update(is_deleted=True, deleted_at=timezone.now()) + + def hard_delete(self): + return super().delete() + + def alive(self): + return self.filter(is_deleted=False) + + +class SoftDeleteManager(models.Manager): + """默认只返回未删除的数据。""" + + def get_queryset(self): + return SoftDeleteQuerySet(self.model, using=self._db).filter(is_deleted=False) + + +class AllObjectsManager(models.Manager): + """包含已删除数据(管理/恢复用)。""" + + def get_queryset(self): + return SoftDeleteQuerySet(self.model, using=self._db) + + +class SoftDeleteModel(BaseModel): + """软删除基类:停用/下架一律逻辑删除,绝不物理删除。 + + - `objects`:默认管理器,只含未删除数据。 + - `all_objects`:含已删除数据。 + - 实例 `.delete()` → 逻辑删除;`.hard_delete()` → 物理删除。 + """ + is_deleted = models.BooleanField('是否删除', default=False, db_index=True) + deleted_at = models.DateTimeField('删除时间', null=True, blank=True) + + objects = SoftDeleteManager() + all_objects = AllObjectsManager() + + class Meta: + abstract = True + + 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) + + def restore(self, using=None): + self.is_deleted = False + self.deleted_at = None + self.save(using=using, update_fields=['is_deleted', 'deleted_at', 'updated_at']) diff --git a/apps/organization/__init__.py b/apps/organization/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/organization/apps.py b/apps/organization/apps.py new file mode 100644 index 0000000..cdf61da --- /dev/null +++ b/apps/organization/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class OrganizationConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.organization' + verbose_name = '组织(机构 / 科室)' diff --git a/apps/organization/serializers.py b/apps/organization/serializers.py new file mode 100644 index 0000000..923c6be --- /dev/null +++ b/apps/organization/serializers.py @@ -0,0 +1,66 @@ +from django.conf import settings +from rest_framework import serializers + +from config.exceptions import AppError +from apps.user.models import Institution, Department + + +class CmsInstitutionSerializer(serializers.ModelSerializer): + """CMS 机构(医院)序列化器。 + + - `code` 显式声明以去掉默认 UniqueValidator,改用自定义错误码校验唯一性。 + - `banner_url` 返回可访问的完整 URL(未配置时为空串,便于管理端识别「未设图」)。 + """ + code = serializers.CharField(max_length=100) + banner_url = serializers.SerializerMethodField() + + class Meta: + model = Institution + fields = [ + 'id', 'code', 'name', 'type', 'level', + 'province', 'city', 'banner_url', 'created_at', 'updated_at', + ] + read_only_fields = ['id', 'created_at', 'updated_at'] + + def get_banner_url(self, obj): + if not obj.banner_url: + return '' + value = obj.banner_url + if value.startswith(('http://', 'https://')): + return value + request = self.context.get('request') + prefix = getattr(settings, 'STATIC_PUBLIC_PREFIX', '').rstrip('/') + path = prefix + '/' + settings.STATIC_URL.strip('/') + '/' + value.lstrip('/') + return request.build_absolute_uri(path) if request else path + + def validate_code(self, value): + value = (value or '').strip() + if not value: + raise AppError('CMS_VALIDATION_ERROR', '机构编码不能为空', status_code=400) + qs = Institution.objects.filter(code=value) + if self.instance is not None: + qs = qs.exclude(pk=self.instance.pk) + if qs.exists(): + raise AppError('CMS_INSTITUTION_CODE_EXISTS', '机构编码已存在', status_code=400) + return value + + +class CmsDepartmentSerializer(serializers.ModelSerializer): + """CMS 科室(全局表,与机构无关)。""" + name = serializers.CharField(max_length=100) + + class Meta: + model = Department + fields = ['id', 'name', 'category', 'created_at', 'updated_at'] + read_only_fields = ['id', 'created_at', 'updated_at'] + + def validate_name(self, value): + value = (value or '').strip() + if not value: + raise AppError('CMS_VALIDATION_ERROR', '科室名称不能为空', status_code=400) + qs = Department.objects.filter(name=value) + if self.instance is not None: + qs = qs.exclude(pk=self.instance.pk) + if qs.exists(): + raise AppError('CMS_DEPARTMENT_NAME_EXISTS', '科室名称已存在', status_code=400) + return value diff --git a/apps/organization/urls.py b/apps/organization/urls.py new file mode 100644 index 0000000..25b7374 --- /dev/null +++ b/apps/organization/urls.py @@ -0,0 +1,12 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter + +from .views import CmsInstitutionViewSet, CmsDepartmentViewSet + +router = DefaultRouter() +router.register(r'institutions', CmsInstitutionViewSet, basename='cms-institution') +router.register(r'departments', CmsDepartmentViewSet, basename='cms-department') + +urlpatterns = [ + path('', include(router.urls)), +] diff --git a/apps/organization/views.py b/apps/organization/views.py new file mode 100644 index 0000000..26ba2a2 --- /dev/null +++ b/apps/organization/views.py @@ -0,0 +1,186 @@ +import os + +from django.conf import settings +from rest_framework import viewsets, filters +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.user.models import Institution, Department +from apps.cms.permissions import IsSuperAdmin +from apps.common.excel import xlsx_response, rows_from_xlsx +from .serializers import CmsInstitutionSerializer, CmsDepartmentSerializer + +ALLOWED_BANNER_EXT = ('.png', '.jpg', '.jpeg', '.webp') +MAX_BANNER_BYTES = 5 * 1024 * 1024 # 5MB + +INST_IMPORT_HEADERS = ['机构编码', '名称', '类型', '等级', '省', '市'] +INST_EXPORT_HEADERS = ['ID', '机构编码', '名称', '类型', '等级', '省', '市'] +DEPT_IMPORT_HEADERS = ['科室名称', '分类'] +DEPT_EXPORT_HEADERS = ['ID', '科室名称', '分类'] + + +@extend_schema_view( + list=extend_schema(summary='CMS-INST-1 机构列表(分页+搜索)', tags=['CMS-机构']), + create=extend_schema(summary='CMS-INST-2 新建机构', tags=['CMS-机构']), + retrieve=extend_schema(summary='CMS-INST-3 机构详情', tags=['CMS-机构']), + partial_update=extend_schema(summary='CMS-INST-4 编辑机构', tags=['CMS-机构']), + destroy=extend_schema(summary='CMS-INST-5 停用机构(逻辑删除)', tags=['CMS-机构']), +) +class CmsInstitutionViewSet(viewsets.ModelViewSet): + """CMS 机构(医院)管理 —— 仅超级管理员(CMS-INST-1~6)。 + + list: 机构列表(分页、搜索 name/code、过滤 type/province/city) + create: 新建机构(code 唯一) + retrieve: 机构详情 + partial_update: 编辑机构(PATCH) + destroy: 停用机构(逻辑删除;Institution 继承 SoftDeleteModel) + banner: 上传机构 Banner 图(CMS-INST-6) + """ + queryset = Institution.objects.all().order_by('-created_at') + serializer_class = CmsInstitutionSerializer + permission_classes = [IsAuthenticated, IsSuperAdmin] + filter_backends = [DjangoFilterBackend, filters.SearchFilter] + filterset_fields = ['type', 'province', 'city'] + search_fields = ['name', 'code'] + # 仅允许 GET/POST/PATCH/DELETE,编辑统一走 PATCH(屏蔽 PUT) + http_method_names = ['get', 'post', 'patch', 'delete', 'head', 'options'] + + # destroy 走 ModelViewSet 默认实现:instance.delete() 经 SoftDeleteModel 改为逻辑删除(停用) + + @extend_schema(summary='CMS-INST-6 上传机构 Banner 图', tags=['CMS-机构']) + @action(detail=True, methods=['post'], url_path='banner', + parser_classes=[MultiPartParser, FormParser]) + def banner(self, request, pk=None): + """上传机构 Banner 图(multipart/form-data,字段名 file)。""" + inst = self.get_object() + file = request.FILES.get('file') + if not file: + raise AppError('CMS_BANNER_FILE_REQUIRED', '请上传图片文件(字段名 file)', status_code=400) + if file.size > MAX_BANNER_BYTES: + raise AppError('CMS_BANNER_TOO_LARGE', '图片不能超过 5MB', status_code=400) + ext = os.path.splitext(file.name)[1].lower() + if ext not in ALLOWED_BANNER_EXT: + raise AppError('CMS_BANNER_BAD_TYPE', '仅支持 png/jpg/jpeg/webp 图片', status_code=400) + + rel_dir = 'institutions' + base_static = settings.STATICFILES_DIRS[0] + target_dir = os.path.join(base_static, rel_dir) + os.makedirs(target_dir, exist_ok=True) + filename = f'inst_{inst.id}_banner{ext}' + with open(os.path.join(target_dir, filename), 'wb') as fh: + for chunk in file.chunks(): + fh.write(chunk) + + inst.banner_url = f'{rel_dir}/{filename}' + inst.save(update_fields=['banner_url', 'updated_at']) + + serializer = self.get_serializer(inst) + return Response({'message': '上传成功', 'banner_url': serializer.data['banner_url']}) + + @extend_schema(summary='CMS-INST-6b 下载机构导入模板', tags=['CMS-机构']) + @action(detail=False, methods=['get'], url_path='import-template') + def import_template(self, request): + return xlsx_response('机构导入模板.xlsx', INST_IMPORT_HEADERS, []) + + @extend_schema(summary='CMS-INST-7 导出机构', tags=['CMS-机构']) + @action(detail=False, methods=['get'], url_path='export') + def export(self, request): + qs = self.filter_queryset(self.get_queryset()) + rows = [[i.id, i.code, i.name, i.type, i.level, i.province, i.city] for i in qs] + return xlsx_response('机构列表.xlsx', INST_EXPORT_HEADERS, rows) + + @extend_schema(summary='CMS-INST-5 导入机构', tags=['CMS-机构']) + @action(detail=False, methods=['post'], url_path='import', + parser_classes=[MultiPartParser, FormParser]) + def import_institutions(self, request): + """Excel 批量导入机构。列:机构编码 | 名称 | 类型 | 等级 | 省 | 市。""" + 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) + + existing_codes = set(Institution.all_objects.values_list('code', flat=True)) + success, errors = 0, [] + for idx, row in enumerate(rows, start=2): + code = (row.get('机构编码') or '').strip() + name = (row.get('名称') or '').strip() + if not code: + errors.append({'row': idx, 'reason': '机构编码为空'}); continue + if not name: + errors.append({'row': idx, 'reason': '名称为空'}); continue + if code in existing_codes: + errors.append({'row': idx, 'reason': f'机构编码已存在:{code}'}); continue + Institution.objects.create( + code=code, name=name, + type=(row.get('类型') or 'hospital').strip() or 'hospital', + level=(row.get('等级') or '').strip(), + province=(row.get('省') or '').strip(), + city=(row.get('市') or '').strip(), + ) + existing_codes.add(code) + success += 1 + return Response({'total': len(rows), 'success': success, 'failed': len(errors), 'errors': errors}) + + +@extend_schema_view( + list=extend_schema(summary='CMS-DEPT-1 科室列表(全局)', tags=['CMS-科室']), + create=extend_schema(summary='CMS-DEPT-2 新增科室', tags=['CMS-科室']), + retrieve=extend_schema(summary='科室详情', tags=['CMS-科室']), + partial_update=extend_schema(summary='CMS-DEPT-3 编辑科室', tags=['CMS-科室']), + destroy=extend_schema(summary='CMS-DEPT-4 停用科室(逻辑删除)', tags=['CMS-科室']), +) +class CmsDepartmentViewSet(viewsets.ModelViewSet): + """CMS 科室管理(全局科室)—— 仅超级管理员。停用为逻辑删除。""" + queryset = Department.objects.all().order_by('name') + serializer_class = CmsDepartmentSerializer + permission_classes = [IsAuthenticated, IsSuperAdmin] + filter_backends = [DjangoFilterBackend, filters.SearchFilter] + filterset_fields = ['category'] + search_fields = ['name'] + http_method_names = ['get', 'post', 'patch', 'delete', 'head', 'options'] + + @extend_schema(summary='CMS-DEPT-6b 下载科室导入模板', tags=['CMS-科室']) + @action(detail=False, methods=['get'], url_path='import-template') + def import_template(self, request): + return xlsx_response('科室导入模板.xlsx', DEPT_IMPORT_HEADERS, []) + + @extend_schema(summary='CMS-DEPT-6 导出科室', tags=['CMS-科室']) + @action(detail=False, methods=['get'], url_path='export') + def export(self, request): + qs = self.filter_queryset(self.get_queryset()) + rows = [[d.id, d.name, d.category] for d in qs] + return xlsx_response('科室列表.xlsx', DEPT_EXPORT_HEADERS, rows) + + @extend_schema(summary='CMS-DEPT-5 导入科室', tags=['CMS-科室']) + @action(detail=False, methods=['post'], url_path='import', + parser_classes=[MultiPartParser, FormParser]) + def import_departments(self, request): + """Excel 批量导入科室。列:科室名称 | 分类(可选)。""" + 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) + + existing = set(Department.objects.values_list('name', flat=True)) + success, errors = 0, [] + for idx, row in enumerate(rows, start=2): + name = (row.get('科室名称') or '').strip() + if not name: + errors.append({'row': idx, 'reason': '科室名称为空'}); continue + if name in existing: + errors.append({'row': idx, 'reason': f'科室已存在:{name}'}); continue + Department.objects.create(name=name, category=(row.get('分类') or '').strip()) + existing.add(name) + success += 1 + return Response({'total': len(rows), 'success': success, 'failed': len(errors), 'errors': errors}) diff --git a/apps/training/migrations/0003_alter_trainingrecord_options_and_more.py b/apps/training/migrations/0003_alter_trainingrecord_options_and_more.py new file mode 100644 index 0000000..20ddcae --- /dev/null +++ b/apps/training/migrations/0003_alter_trainingrecord_options_and_more.py @@ -0,0 +1,21 @@ +# Generated by Django 5.2.14 on 2026-06-10 08:42 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('training', '0002_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='trainingrecord', + options={'managed': False, 'verbose_name': '训练记录', 'verbose_name_plural': '训练记录'}, + ), + migrations.AlterModelOptions( + name='trainingscoredetail', + options={'managed': False, 'verbose_name': '评分明细', 'verbose_name_plural': '评分明细'}, + ), + ] diff --git a/apps/training/models.py b/apps/training/models.py index 729d811..6aa3aff 100644 --- a/apps/training/models.py +++ b/apps/training/models.py @@ -3,9 +3,16 @@ from apps.common.models import BaseModel from apps.user.models import User from apps.case.models import CaseBase +# ─── 只读镜像(fastapi 属主)──────────────────────────────────────────────────── +# 训练相关表(training_record / training_session / training_submission / +# user_learning_profiles 等)的 schema 属主是 fastapi 服务。Django 侧一律 managed=False、 +# 只读接入,仅供 CMS 查询训练记录/统计,不写。 +# ⚠️ 下方字段为当前最佳镜像,正式接入前应以 `python manage.py inspectdb` 对真实库反向校准 +# (真实表清单见《项目架构设计.md》第二节;注意真实库中没有 training_score_detail)。 + class TrainingRecord(BaseModel): - """训练记录表""" + """训练记录表(只读,fastapi 属主,managed=False)""" TRAINING_MODE_CHOICES = [ ('novice', '新手'), ('practice', '练习'), @@ -61,6 +68,7 @@ class TrainingRecord(BaseModel): rag_context_version = models.CharField('知识上下文版本', max_length=50, blank=True) class Meta: + managed = False db_table = 'training_record' verbose_name = '训练记录' verbose_name_plural = '训练记录' @@ -70,7 +78,7 @@ class TrainingRecord(BaseModel): class TrainingScoreDetail(BaseModel): - """评分明细表""" + """评分明细表(只读占位,managed=False;真实 fastapi 库无此表,接入时以真实 schema 为准)""" id = models.BigAutoField(primary_key=True) record = models.ForeignKey( TrainingRecord, on_delete=models.CASCADE, @@ -88,6 +96,7 @@ class TrainingScoreDetail(BaseModel): comment = models.TextField('评语', blank=True) class Meta: + managed = False db_table = 'training_score_detail' verbose_name = '评分明细' verbose_name_plural = '评分明细' diff --git a/apps/user/admin.py b/apps/user/admin.py index c799c1c..1459f1b 100644 --- a/apps/user/admin.py +++ b/apps/user/admin.py @@ -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'] diff --git a/apps/user/auth/register.py b/apps/user/auth/register.py index bb2b3e9..4e7c937 100644 --- a/apps/user/auth/register.py +++ b/apps/user/auth/register.py @@ -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}"不存在') diff --git a/apps/user/cms.py b/apps/user/cms.py new file mode 100644 index 0000000..e6cd778 --- /dev/null +++ b/apps/user/cms.py @@ -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') + ) diff --git a/apps/user/cms_relation.py b/apps/user/cms_relation.py new file mode 100644 index 0000000..8bef743 --- /dev/null +++ b/apps/user/cms_relation.py @@ -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}) diff --git a/apps/user/cms_urls.py b/apps/user/cms_urls.py new file mode 100644 index 0000000..5793421 --- /dev/null +++ b/apps/user/cms_urls.py @@ -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)), +] diff --git a/apps/user/migrations/0006_remove_department_institution_department_deleted_at_and_more.py b/apps/user/migrations/0006_remove_department_institution_department_deleted_at_and_more.py new file mode 100644 index 0000000..1e4e0e3 --- /dev/null +++ b/apps/user/migrations/0006_remove_department_institution_department_deleted_at_and_more.py @@ -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='是否删除'), + ), + ] diff --git a/apps/user/migrations/0007_user_deleted_at_user_is_deleted.py b/apps/user/migrations/0007_user_deleted_at_user_is_deleted.py new file mode 100644 index 0000000..763f046 --- /dev/null +++ b/apps/user/migrations/0007_user_deleted_at_user_is_deleted.py @@ -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='是否删除'), + ), + ] diff --git a/apps/user/models.py b/apps/user/models.py index 3153a43..1035998 100644 --- a/apps/user/models.py +++ b/apps/user/models.py @@ -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) diff --git a/apps/user/serializers.py b/apps/user/serializers.py index 026b592..6127152 100644 --- a/apps/user/serializers.py +++ b/apps/user/serializers.py @@ -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__' diff --git a/apps/user/urls.py b/apps/user/urls.py index 3c736f2..6d24bf7 100644 --- a/apps/user/urls.py +++ b/apps/user/urls.py @@ -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'), diff --git a/apps/user/views.py b/apps/user/views.py index e62a227..e81ac27 100644 --- a/apps/user/views.py +++ b/apps/user/views.py @@ -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, + }) diff --git a/config/settings.py b/config/settings.py index 238d4f3..ec9cca5 100644 --- a/config/settings.py +++ b/config/settings.py @@ -38,6 +38,8 @@ INSTALLED_APPS = [ 'apps.user', 'apps.case', 'apps.training', + 'apps.organization', + 'apps.cms', ] MIDDLEWARE = [ diff --git a/config/urls.py b/config/urls.py index 3001d3c..92e564c 100644 --- a/config/urls.py +++ b/config/urls.py @@ -32,6 +32,7 @@ urlpatterns = [ path('api/user/', include('apps.user.urls')), path('api/case/', include('apps.case.urls')), path('api/training/', include('apps.training.urls')), + path('api/cms/', include('apps.cms.urls')), # JWT Token path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'), diff --git a/requirements.txt b/requirements.txt index 977c61d..59ab4fd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,12 +9,15 @@ mysqlclient>=2.2.0 redis>=5.0.0 django-redis>=5.4.0 -# 新增 - 短信 +# D2 新增 - 短信 alibabacloud_dysmsapi20170525>=2.0 alibabacloud_tea_util alibabacloud_tea_openapi -# 新增 - 病例端 +# D5 新增 - 病例端 openai>=1.30 pdfplumber>=0.11 jsonschema>=4.0 + +# CMS 新增 - Excel 导入/导出 +openpyxl>=3.1 diff --git a/test/conftest.py b/test/conftest.py index eea2409..40eadc2 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -123,11 +123,11 @@ def ensure_institution(name='测试医院', code='TEST-HOSP-001'): return inst -def ensure_department(name='儿科', institution_name='测试医院'): - inst = ensure_institution(institution_name) +def ensure_department(name='儿科'): + """科室为全局表,与机构无关。""" dept, _ = Department.objects.get_or_create( name=name, - defaults={'institution': inst, 'category': '临床'}, + defaults={'category': '临床'}, ) return dept diff --git a/test/swagger_cms.py b/test/swagger_cms.py new file mode 100644 index 0000000..adf31f7 --- /dev/null +++ b/test/swagger_cms.py @@ -0,0 +1,245 @@ +# -*- coding: utf-8 -*- +""" +CMS 端全接口 Swagger Try-it-out 脚本(真实 HTTP)。 + +- 覆盖:超级管理员(用户/医院/科室)、医院管理员(人员/师生关系)、带教医生(我的学生)。 +- 详细请求/响应写入 logs/test-swagger-cms-YYYY-MM-DD.log。 +- 导入/导出的 .xlsx 真实文件保存到 docx/CMS-excel样例/。 +- 真实请求/响应样例写入 logs/cms-swagger-examples.json(供回填 CSV)。 + +前提:Django dev server 运行在 http://127.0.0.1:8000,Redis 已启动。 +运行:.venv\\Scripts\\python.exe test/swagger_cms.py +""" +import io +import os +import sys +import json +import subprocess +from datetime import datetime + +import requests +from openpyxl import Workbook + +sys.stdout.reconfigure(encoding='utf-8') + +BASE = 'http://127.0.0.1:8000' +PYTHON = r'D:\01Agent\medical_training\.venv\Scripts\python.exe' +CWD = r'D:\01Agent\medical_training' + +LOG_DIR = os.path.join(CWD, 'logs') +EXCEL_DIR = os.path.join(CWD, 'docx', 'CMS-excel样例') +os.makedirs(EXCEL_DIR, exist_ok=True) +LOG_FILE = os.path.join(LOG_DIR, f'test-swagger-cms-{datetime.now():%Y-%m-%d}.log') +EXAMPLES_FILE = os.path.join(LOG_DIR, 'cms-swagger-examples.json') + +_fh = open(LOG_FILE, 'w', encoding='utf-8') +examples = {} +results = [] + + +def w(text=''): + _fh.write(text + '\n'); _fh.flush(); print(text) + + +def django_eval(code): + pre = ('import django,os;os.environ.setdefault("DJANGO_SETTINGS_MODULE","config.settings");django.setup();') + p = subprocess.run([PYTHON, '-c', pre + code], capture_output=True, text=True, cwd=CWD, encoding='utf-8') + return (p.stdout or '').strip() + + +def make_xlsx_bytes(headers, rows): + wb = Workbook(); ws = wb.active + ws.append(headers) + for r in rows: + ws.append(r) + buf = io.BytesIO(); wb.save(buf); return buf.getvalue() + + +def call(code, name, method, path, token=None, json_body=None, params=None, + file_bytes=None, file_name=None, save_as=None, expect=200): + """发起请求 + 记录日志 + 收集样例。""" + headers = {'Authorization': f'Bearer {token}'} if token else {} + files = {'file': (file_name, file_bytes, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')} if file_bytes else None + r = requests.request(method, BASE + path, headers=headers, json=json_body, params=params, files=files) + + ct = r.headers.get('content-type', '') + is_json = ct.startswith('application/json') + resp = r.json() if is_json else f'' + + if save_as: + full = os.path.join(EXCEL_DIR, save_as) + with open(full, 'wb') as f: + f.write(r.content) + + # 请求样例(给前端看:实际传了什么) + if json_body is not None: + req_example = json.dumps(json_body, ensure_ascii=False) + elif file_bytes is not None: + req_example = f'multipart/form-data: file={file_name}' + elif params: + req_example = '?' + '&'.join(f'{k}={v}' for k, v in params.items()) + else: + req_example = '(无 body,仅 Header Authorization)' if '{' not in path else '(路径参数 + Header Authorization)' + + ok = 'PASS' if r.status_code in (expect if isinstance(expect, (list, tuple)) else [expect]) else 'FAIL' + results.append((code, ok, r.status_code)) + examples[code] = {'name': name, 'method': method, 'path': path, 'status': r.status_code, + 'req': req_example, 'resp': resp, 'saved': save_as} + + w(f'\n[{ok}] {code} {name} -> {method} {path} (status={r.status_code})') + w(f' 请求: {req_example}') + body_str = json.dumps(resp, ensure_ascii=False, indent=2) if is_json else resp + if len(body_str) > 1500: + body_str = body_str[:1500] + f' ...(截断, 共{len(body_str)}字符)' + w(f' 响应: {body_str}') + return r + + +# ───────────────────────────────────────────────────────────────────────────── +w('=' * 90) +w(f' CMS 全接口 Swagger 测试 {datetime.now():%Y-%m-%d %H:%M:%S}') +w('=' * 90) + +# 1) 准备数据:两机构、两科室、超管/医院管理员/医生/学生/他院学生,并签发 token +w('\n[准备] 建测试机构/科室/用户、签发 token ...') +setup = django_eval(r''' +from apps.user.models import User, Institution, Department, TeacherStudentRelation +from rest_framework_simplejwt.tokens import RefreshToken +# 物理清理可能的残留(含软删),保证脚本可重复运行 +ph = ["13800009000","13800009001","13800009002","13800009003","13800009004","13800009010","13800009011","13800009012","13800009020","13800009021","13800009022"] +for u in User.all_objects.filter(phone__in=ph): u.hard_delete() +Institution.all_objects.filter(code__startswith="SWG-CMS").hard_delete() +for d in Department.all_objects.filter(name__startswith="Swagger"): d.hard_delete() +instA,_ = Institution.objects.get_or_create(code="SWG-CMS-A", defaults={"name":"Swagger甲医院","type":"hospital","level":"三甲","province":"北京","city":"北京"}) +instB,_ = Institution.objects.get_or_create(code="SWG-CMS-B", defaults={"name":"Swagger乙医院","type":"hospital"}) +dN,_ = Department.objects.get_or_create(name="内科", defaults={"category":"临床"}) +dW,_ = Department.objects.get_or_create(name="外科", defaults={"category":"临床"}) +sup = User.objects.create_user(username="13800009000", password="x", phone="13800009000", real_name="Swagger超管", role_type="super_admin", status=1) +hos = User.objects.create_user(username="13800009001", password="x", phone="13800009001", real_name="Swagger院管", role_type="hospital_admin", institution=instA, status=1) +doc = User.objects.create_user(username="13800009002", password="x", phone="13800009002", real_name="Swagger医生", role_type="doctor", institution=instA, status=1) +stu = User.objects.create_user(username="13800009003", password="x", phone="13800009003", real_name="Swagger学生", role_type="student", institution=instA, department=dN, status=1) +stuB = User.objects.create_user(username="13800009004", password="x", phone="13800009004", real_name="他院学生", role_type="student", institution=instB, status=1) +TeacherStudentRelation.objects.create(teacher=doc, student=stu, relation_type="指导", status=1) +def tok(u): return str(RefreshToken.for_user(u).access_token) +print("|".join([tok(sup), tok(hos), tok(doc), str(instA.id), str(instB.id), str(dN.id), str(stu.id), str(doc.id)])) +''') +T_SUP, T_HOS, T_DOC, INST_A, INST_B, DEPT_N, STU_ID, DOC_ID = setup.split('|') +w(f'[准备] 完成 instA={INST_A} instB={INST_B} deptN={DEPT_N} student={STU_ID} doctor={DOC_ID}') + +# ═══ 超级管理员 - 用户管理 ═══ +w('\n' + '#' * 70 + '\n# 超级管理员 - 用户管理\n' + '#' * 70) +call('CMS-USER-1', '用户列表', 'GET', '/api/cms/users/', T_SUP, params={'role_type': 'doctor', 'page': 1}) +r = call('CMS-USER-2', '新增用户', 'POST', '/api/cms/users/', T_SUP, + json_body={'phone': '13800009010', 'real_name': 'Swagger新医生', 'role_type': 'doctor', 'institution': int(INST_A)}, expect=201) +NEW_UID = r.json().get('id') +call('CMS-USER-3', '编辑用户', 'PATCH', f'/api/cms/users/{NEW_UID}/', T_SUP, json_body={'real_name': 'Swagger改名', 'title_name': '主治医师'}) +call('CMS-USER-5', '重置密码', 'POST', f'/api/cms/users/{NEW_UID}/reset-password/', T_SUP, json_body={}) +ufile = make_xlsx_bytes(['手机号', '姓名', '角色', '机构编码'], + [['13800009011', '导入医生', '医生', 'SWG-CMS-A'], ['13800009012', '导入学生', '学生', 'SWG-CMS-A']]) +with open(os.path.join(EXCEL_DIR, '用户导入_样例.xlsx'), 'wb') as f: + f.write(ufile) +call('CMS-USER-6', '导入用户', 'POST', '/api/cms/users/import/', T_SUP, file_bytes=ufile, file_name='用户导入_样例.xlsx') +call('CMS-USER-7', '导入模板', 'GET', '/api/cms/users/import-template/', T_SUP, save_as='用户导入_模板.xlsx') +call('CMS-USER-8', '导出用户', 'GET', '/api/cms/users/export/', T_SUP, params={'role_type': 'doctor'}, save_as='用户导出_结果.xlsx') +call('CMS-USER-4', '停用用户', 'DELETE', f'/api/cms/users/{NEW_UID}/', T_SUP, expect=204) + +# ═══ 超级管理员 - 医院管理 ═══ +w('\n' + '#' * 70 + '\n# 超级管理员 - 医院管理\n' + '#' * 70) +call('CMS-INST-1', '机构列表', 'GET', '/api/cms/institutions/', T_SUP, params={'search': 'Swagger'}) +r = call('CMS-INST-2', '新增机构', 'POST', '/api/cms/institutions/', T_SUP, + json_body={'code': 'SWG-CMS-NEW', 'name': 'Swagger新医院', 'type': 'hospital', 'level': '二甲', 'province': '上海', 'city': '上海'}, expect=201) +NEW_INST = r.json().get('id') +call('CMS-INST-3', '编辑机构', 'PATCH', f'/api/cms/institutions/{NEW_INST}/', T_SUP, json_body={'level': '三甲'}) +ifile = make_xlsx_bytes(['机构编码', '名称', '类型', '等级', '省', '市'], + [['SWG-CMS-IMP1', '导入医院A', 'hospital', '三甲', '广东', '广州']]) +with open(os.path.join(EXCEL_DIR, '机构导入_样例.xlsx'), 'wb') as f: + f.write(ifile) +call('CMS-INST-5', '导入机构', 'POST', '/api/cms/institutions/import/', T_SUP, file_bytes=ifile, file_name='机构导入_样例.xlsx') +call('CMS-INST-6', '导入模板', 'GET', '/api/cms/institutions/import-template/', T_SUP, save_as='机构导入_模板.xlsx') +call('CMS-INST-7', '导出机构', 'GET', '/api/cms/institutions/export/', T_SUP, save_as='机构导出_结果.xlsx') +call('CMS-INST-4', '停用机构', 'DELETE', f'/api/cms/institutions/{NEW_INST}/', T_SUP, expect=204) + +# ═══ 超级管理员 - 科室管理 ═══ +w('\n' + '#' * 70 + '\n# 超级管理员 - 科室管理\n' + '#' * 70) +call('CMS-DEPT-1', '科室列表', 'GET', '/api/cms/departments/', T_SUP) +r = call('CMS-DEPT-2', '新增科室', 'POST', '/api/cms/departments/', T_SUP, json_body={'name': 'Swagger儿科', 'category': '临床'}, expect=201) +NEW_DEPT = r.json().get('id') +call('CMS-DEPT-3', '编辑科室', 'PATCH', f'/api/cms/departments/{NEW_DEPT}/', T_SUP, json_body={'category': '儿童医学'}) +dfile = make_xlsx_bytes(['科室名称', '分类'], [['Swagger放射科', '医技']]) +with open(os.path.join(EXCEL_DIR, '科室导入_样例.xlsx'), 'wb') as f: + f.write(dfile) +call('CMS-DEPT-5', '导入科室', 'POST', '/api/cms/departments/import/', T_SUP, file_bytes=dfile, file_name='科室导入_样例.xlsx') +call('CMS-DEPT-7', '导入模板', 'GET', '/api/cms/departments/import-template/', T_SUP, save_as='科室导入_模板.xlsx') +call('CMS-DEPT-6', '导出科室', 'GET', '/api/cms/departments/export/', T_SUP, save_as='科室导出_结果.xlsx') +call('CMS-DEPT-4', '停用科室', 'DELETE', f'/api/cms/departments/{NEW_DEPT}/', T_SUP, expect=204) + +# ═══ 医院管理员 - 人员管理(本院 doctor/student/content_admin)═══ +w('\n' + '#' * 70 + '\n# 医院管理员 - 人员管理\n' + '#' * 70) +call('CMS-HUSER-1', '用户列表(本院)', 'GET', '/api/cms/users/', T_HOS, params={'role_type': 'student'}) +r = call('CMS-HUSER-2', '新增用户(本院)', 'POST', '/api/cms/users/', T_HOS, + json_body={'phone': '13800009020', 'real_name': '院管建的内容员', 'role_type': 'content_admin'}, expect=201) +HU_UID = r.json().get('id') +call('CMS-HUSER-3', '编辑用户(本院)', 'PATCH', f'/api/cms/users/{HU_UID}/', T_HOS, json_body={'title_name': '内容主管'}) +call('CMS-HUSER-5', '重置密码(本院)', 'POST', f'/api/cms/users/{HU_UID}/reset-password/', T_HOS, json_body={}) +hufile = make_xlsx_bytes(['手机号', '姓名', '角色', '机构编码'], [['13800009021', '院管导入生', '学生', '忽略']]) +with open(os.path.join(EXCEL_DIR, '本院用户导入_样例.xlsx'), 'wb') as f: + f.write(hufile) +call('CMS-HUSER-6', '导入用户(本院)', 'POST', '/api/cms/users/import/', T_HOS, file_bytes=hufile, file_name='本院用户导入_样例.xlsx') +call('CMS-HUSER-7', '导入模板', 'GET', '/api/cms/users/import-template/', T_HOS, save_as='本院用户导入_模板.xlsx') +call('CMS-HUSER-8', '导出用户(本院)', 'GET', '/api/cms/users/export/', T_HOS, save_as='本院用户导出_结果.xlsx') +call('CMS-HUSER-4', '停用用户(本院)', 'DELETE', f'/api/cms/users/{HU_UID}/', T_HOS, expect=204) + +# ═══ 医院管理员 - 师生关系 ═══ +w('\n' + '#' * 70 + '\n# 医院管理员 - 师生关系\n' + '#' * 70) +call('CMS-REL-1', '师生关系列表', 'GET', '/api/cms/teacher-student-relations/', T_HOS) +# 先把已存在的 doc×stu 关系拿来演示编辑/停用;新增用一对新的(再建一个本院学生) +new_stu = django_eval(r''' +from apps.user.models import User, Institution +instA = Institution.objects.get(code="SWG-CMS-A") +u,_ = User.objects.get_or_create(phone="13800009022", defaults=dict(username="13800009022", real_name="师生关系新学生", role_type="student", institution=instA, status=1)) +print(u.id) +''') +r = call('CMS-REL-2', '新增师生关系', 'POST', '/api/cms/teacher-student-relations/', T_HOS, + json_body={'teacher': int(DOC_ID), 'student': int(new_stu), 'relation_type': '指导'}, expect=201) +REL_ID = r.json().get('id') +call('CMS-REL-3', '编辑师生关系', 'PATCH', f'/api/cms/teacher-student-relations/{REL_ID}/', T_HOS, json_body={'status': 0}) +rfile = make_xlsx_bytes(['带教医生手机号', '学生手机号'], [['13800009002', '13800009003']]) +with open(os.path.join(EXCEL_DIR, '师生关系导入_样例.xlsx'), 'wb') as f: + f.write(rfile) +call('CMS-REL-5', '导入师生关系', 'POST', '/api/cms/teacher-student-relations/import/', T_HOS, file_bytes=rfile, file_name='师生关系导入_样例.xlsx') +call('CMS-REL-6', '导入模板', 'GET', '/api/cms/teacher-student-relations/import-template/', T_HOS, save_as='师生关系导入_模板.xlsx') +call('CMS-REL-7', '导出师生关系', 'GET', '/api/cms/teacher-student-relations/export/', T_HOS, save_as='师生关系导出_结果.xlsx') +call('CMS-REL-4', '停用师生关系', 'DELETE', f'/api/cms/teacher-student-relations/{REL_ID}/', T_HOS, expect=204) + +# ═══ 带教医生 - 我的学生 ═══ +w('\n' + '#' * 70 + '\n# 带教医生 - 我的学生\n' + '#' * 70) +call('CMS-TEA-1', '我的学生列表', 'GET', '/api/cms/students/', T_DOC) +call('CMS-TEA-2', '学生基础信息', 'GET', f'/api/cms/students/{STU_ID}/', T_DOC) + +# ─── 清理 ─── +w('\n[清理] 删除测试数据 ...') +django_eval(r''' +from apps.user.models import User, Institution, Department, TeacherStudentRelation +ph = ["13800009000","13800009001","13800009002","13800009003","13800009004","13800009010","13800009011","13800009012","13800009020","13800009021","13800009022"] +TeacherStudentRelation.all_objects.filter(teacher__phone__in=ph).hard_delete() +for u in User.all_objects.filter(phone__in=ph): u.hard_delete() +Institution.all_objects.filter(code__startswith="SWG-CMS").hard_delete() +Department.all_objects.filter(name__startswith="Swagger").hard_delete() +print("cleaned") +''') + +# ─── 落盘样例 JSON + 汇总 ─── +with open(EXAMPLES_FILE, 'w', encoding='utf-8') as f: + json.dump(examples, f, ensure_ascii=False, indent=2) + +w('\n' + '=' * 90) +total = len(results); passed = sum(1 for _, ok, _ in results if ok == 'PASS') +w(f' 总计 {total} 个接口 | 通过 {passed} | 失败 {total - passed}') +fails = [(c, s) for c, ok, s in results if ok == 'FAIL'] +if fails: + w(' 失败: ' + ', '.join(f'{c}({s})' for c, s in fails)) +w(f' 日志: {LOG_FILE}') +w(f' 样例 JSON: {EXAMPLES_FILE}') +w(f' Excel 文件目录: {EXCEL_DIR}') +_fh.close() +sys.exit(0 if not fails else 1) diff --git a/test/swagger_profile_config.py b/test/swagger_profile_config.py index 2134032..eba69cd 100644 --- a/test/swagger_profile_config.py +++ b/test/swagger_profile_config.py @@ -57,8 +57,8 @@ setup = django_eval( f'inst, _ = Institution.objects.get_or_create(code="{TRIAL_INST_CODE}", ' f' defaults={{"name":"北大医学部(实验室)试用","type":"hospital"}}); ' f'inst.banner_url = "institutions/default_hospital.png"; inst.save(update_fields=["banner_url"]); ' - f'd1, _ = Department.objects.get_or_create(institution=inst, name="内科", defaults={{"category":"临床"}}); ' - f'd2, _ = Department.objects.get_or_create(institution=inst, name="外科", defaults={{"category":"临床"}}); ' + f'd1, _ = Department.objects.get_or_create(name="内科", defaults={{"category":"临床"}}); ' + f'd2, _ = Department.objects.get_or_create(name="外科", defaults={{"category":"临床"}}); ' f'User.objects.filter(phone="{STUDENT_PHONE}").delete(); ' f'u = User.objects.create_user(username="{STUDENT_PHONE}", password=None, phone="{STUDENT_PHONE}", ' f' real_name="配置页测试学生", role_type="student", institution=inst, status=1); ' diff --git a/test/swagger_tryout.py b/test/swagger_tryout.py index 7293787..d2956f5 100644 --- a/test/swagger_tryout.py +++ b/test/swagger_tryout.py @@ -528,7 +528,7 @@ django_eval( f'from apps.user.models import Institution, Department; ' f'inst, _ = Institution.objects.get_or_create(code="{INST_CODE}", ' f' defaults={{"name":"{INST_NAME}","type":"hospital","province":"北京","city":"北京"}}); ' - f'Department.objects.get_or_create(name="{DEPT_NAME}", institution=inst, ' + f'Department.objects.get_or_create(name="{DEPT_NAME}", ' f' defaults={{"category":"临床"}}); ' f'print("OK")' ) diff --git a/test/test_cms_department.py b/test/test_cms_department.py new file mode 100644 index 0000000..fade44b --- /dev/null +++ b/test/test_cms_department.py @@ -0,0 +1,86 @@ +"""CMS 超级管理员 - 科室管理接口测试(CMS-DEPT-1~6,全局科室)。""" + +import io + +from openpyxl import Workbook +from django.core.files.uploadedfile import SimpleUploadedFile +from rest_framework.test import APIClient + +from apps.user.models import Department +from .conftest import CacheTestCase, create_test_user, get_auth_client + +CMS_DEPT_URL = '/api/cms/departments/' +XLSX_CT = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + + +def d_detail(pk): + return f'/api/cms/departments/{pk}/' + + +def make_xlsx(headers, rows): + wb = Workbook(); ws = wb.active + ws.append(headers) + for r in rows: + ws.append(r) + buf = io.BytesIO(); wb.save(buf); buf.seek(0) + return SimpleUploadedFile('dept.xlsx', buf.read(), content_type=XLSX_CT) + + +def super_client(phone='13933300001'): + admin = create_test_user(phone=phone, password='Admin123', role_type='super_admin') + return get_auth_client(admin) + + +class CmsDepartmentTest(CacheTestCase): + def setUp(self): + super().setUp() + self.client = super_client() + + def test_requires_super_admin(self): + self.assertEqual(APIClient().get(CMS_DEPT_URL).status_code, 401) + u = create_test_user(phone='13933300009', role_type='doctor') + self.assertEqual(get_auth_client(u).get(CMS_DEPT_URL).status_code, 403) + + def test_crud(self): + # 新增 + resp = self.client.post(CMS_DEPT_URL, {'name': '内科', 'category': '临床'}) + self.assertEqual(resp.status_code, 201, resp.content) + did = resp.json()['id'] + # 列表 + resp = self.client.get(CMS_DEPT_URL) + self.assertEqual(resp.status_code, 200) + self.assertIn('results', resp.json()) + # 编辑 + resp = self.client.patch(d_detail(did), {'category': '医技'}) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(resp.json()['category'], '医技') + + def test_duplicate_name(self): + Department.objects.create(name='外科', category='临床') + resp = self.client.post(CMS_DEPT_URL, {'name': '外科'}) + self.assertEqual(resp.status_code, 400, resp.content) + self.assertEqual(resp.json()['code'], 'CMS_DEPARTMENT_NAME_EXISTS') + + def test_soft_delete(self): + d = Department.objects.create(name='儿科', category='临床') + resp = self.client.delete(d_detail(d.id)) + self.assertEqual(resp.status_code, 204, resp.content) + self.assertFalse(Department.objects.filter(id=d.id).exists()) + self.assertTrue(Department.all_objects.get(id=d.id).is_deleted) + + def test_import_and_export(self): + f = make_xlsx(['科室名称', '分类'], [['心内科', '临床'], ['', 'x'], ['心内科', '临床']]) + resp = self.client.post('/api/cms/departments/import/', {'file': f}, format='multipart') + self.assertEqual(resp.status_code, 200, resp.content) + body = resp.json() + self.assertEqual(body['success'], 1) # 1 成功;空名 + 重复各 1 失败 + self.assertEqual(body['failed'], 2) + # 导出 + resp = self.client.get('/api/cms/departments/export/') + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp['Content-Type'], XLSX_CT) + + def test_import_template(self): + resp = self.client.get('/api/cms/departments/import-template/') + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp['Content-Type'], XLSX_CT) diff --git a/test/test_cms_huser.py b/test/test_cms_huser.py new file mode 100644 index 0000000..49d91c4 --- /dev/null +++ b/test/test_cms_huser.py @@ -0,0 +1,88 @@ +"""CMS 医院管理员 - 人员管理(用户范围)测试:CMS-HUSER-1~3。 + +医院管理员只能管理本院的医生/学生;机构强制本院、角色受限。 +""" +from rest_framework.test import APIClient + +from apps.user.models import User +from .conftest import CacheTestCase, create_test_user, get_auth_client, ensure_institution + +CMS_USER_URL = '/api/cms/users/' + + +def u_detail(pk): + return f'/api/cms/users/{pk}/' + + +class HospitalAdminUserScopeTest(CacheTestCase): + def setUp(self): + super().setUp() + self.inst = ensure_institution(name='本院', code='HU-A') + self.other = ensure_institution(name='他院', code='HU-B') + self.admin = create_test_user(phone='13931000001', role_type='hospital_admin', + institution=self.inst) + self.client = get_auth_client(self.admin) + # 本院医生、学生;他院学生 + self.doc = create_test_user(phone='13931000002', role_type='doctor', institution=self.inst) + self.stu = create_test_user(phone='13931000003', role_type='student', institution=self.inst) + self.other_stu = create_test_user(phone='13931000004', role_type='student', institution=self.other) + + def test_list_scoped_to_own_doctor_student(self): + resp = self.client.get(CMS_USER_URL) + self.assertEqual(resp.status_code, 200, resp.content) + ids = {u['id'] for u in resp.json()['results']} + self.assertIn(self.doc.id, ids) + self.assertIn(self.stu.id, ids) + self.assertNotIn(self.other_stu.id, ids) # 他院不可见 + self.assertNotIn(self.admin.id, ids) # 医院管理员自己(role=hospital_admin)不在 doctor/student 范围 + + def test_create_forces_own_institution(self): + # 传了他院机构,仍落本院 + resp = self.client.post(CMS_USER_URL, { + 'phone': '13931000010', 'real_name': '新医生', 'role_type': 'doctor', + 'institution': self.other.id, + }) + self.assertEqual(resp.status_code, 201, resp.content) + u = User.objects.get(phone='13931000010') + self.assertEqual(u.institution_id, self.inst.id) # 强制本院 + + def test_create_content_admin_allowed(self): + # 医院管理员可给本院授予内容管理员权限 + resp = self.client.post(CMS_USER_URL, { + 'phone': '13931000011', 'real_name': '内容员', 'role_type': 'content_admin', + }) + self.assertEqual(resp.status_code, 201, resp.content) + u = User.objects.get(phone='13931000011') + self.assertEqual(u.role_type, 'content_admin') + self.assertEqual(u.institution_id, self.inst.id) # 强制本院 + + def test_create_role_restricted(self): + # 医院管理员不能建 hospital_admin / super_admin + for role in ('hospital_admin', 'super_admin'): + resp = self.client.post(CMS_USER_URL, { + 'phone': '13931000012', 'real_name': 'x', 'role_type': role, + }) + self.assertEqual(resp.status_code, 403, f'{role}: {resp.content}') + self.assertEqual(resp.json()['code'], 'CMS_ROLE_NOT_ALLOWED') + + def test_cannot_touch_other_institution_user(self): + # 他院学生不在 queryset → 404 + self.assertEqual(self.client.get(u_detail(self.other_stu.id)).status_code, 404) + self.assertEqual(self.client.delete(u_detail(self.other_stu.id)).status_code, 404) + + def test_soft_delete_own_student(self): + resp = self.client.delete(u_detail(self.stu.id)) + self.assertEqual(resp.status_code, 204, resp.content) + self.assertFalse(User.objects.filter(id=self.stu.id).exists()) + + def test_reset_password_own(self): + resp = self.client.post(f'/api/cms/users/{self.doc.id}/reset-password/', {}) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(resp.json()['password'], 'Pass13931000002') + + def test_filter_by_role(self): + # 医生管理页 ?role_type=doctor + resp = self.client.get(CMS_USER_URL, {'role_type': 'doctor'}) + self.assertEqual(resp.status_code, 200) + roles = {u['role_type'] for u in resp.json()['results']} + self.assertEqual(roles, {'doctor'}) diff --git a/test/test_cms_institution.py b/test/test_cms_institution.py new file mode 100644 index 0000000..8220cea --- /dev/null +++ b/test/test_cms_institution.py @@ -0,0 +1,237 @@ +"""CMS 超级管理员 - 机构(医院)管理接口测试(CMS-INST-1~6)。""" + +import io +import tempfile + +from openpyxl import Workbook +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import override_settings +from rest_framework.test import APIClient + +from apps.user.models import Institution, Department +from .conftest import ( + CacheTestCase, + create_test_user, get_auth_client, ensure_institution, +) + +CMS_INST_URL = '/api/cms/institutions/' +XLSX_CT = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + + +def _xlsx(headers, rows): + wb = Workbook(); ws = wb.active + ws.append(headers) + for r in rows: + ws.append(r) + buf = io.BytesIO(); wb.save(buf); buf.seek(0) + return SimpleUploadedFile('inst.xlsx', buf.read(), content_type=XLSX_CT) + + +def inst_detail_url(pk): + return f'/api/cms/institutions/{pk}/' + + +def inst_banner_url(pk): + return f'/api/cms/institutions/{pk}/banner/' + + +def super_admin_client(phone='13911100001'): + admin = create_test_user(phone=phone, password='Admin123', role_type='super_admin') + return get_auth_client(admin), admin + + +# ── 权限 ────────────────────────────────────────────────────────────────────── + +class CmsInstitutionPermissionTest(CacheTestCase): + + def test_requires_auth(self): + """未登录 → 401。""" + resp = APIClient().get(CMS_INST_URL) + self.assertEqual(resp.status_code, 401, resp.content) + + def test_non_super_admin_forbidden(self): + """非超管(学生/医院管理员)→ 403 CMS_PERMISSION_DENIED。""" + for role in ('student', 'hospital_admin', 'content_admin', 'doctor'): + user = create_test_user(phone=f'1391110100{("student hospital_admin content_admin doctor".split().index(role))}', + role_type=role) + client = get_auth_client(user) + resp = client.get(CMS_INST_URL) + self.assertEqual(resp.status_code, 403, f'{role}: {resp.content}') + self.assertEqual(resp.json()['code'], 'CMS_PERMISSION_DENIED') + + +# ── CRUD ────────────────────────────────────────────────────────────────────── + +class CmsInstitutionCrudTest(CacheTestCase): + + def setUp(self): + super().setUp() + self.client, self.admin = super_admin_client() + + def test_list_paginated(self): + ensure_institution(name='协和医院', code='CMS-H001') + ensure_institution(name='同仁医院', code='CMS-H002') + resp = self.client.get(CMS_INST_URL) + self.assertEqual(resp.status_code, 200, resp.content) + data = resp.json() + self.assertIn('results', data) # DRF 分页 + codes = {i['code'] for i in data['results']} + self.assertTrue({'CMS-H001', 'CMS-H002'} <= codes) + + def test_list_search(self): + ensure_institution(name='北京协和医院', code='CMS-H010') + ensure_institution(name='上海瑞金医院', code='CMS-H011') + resp = self.client.get(CMS_INST_URL, {'search': '协和'}) + self.assertEqual(resp.status_code, 200, resp.content) + results = resp.json()['results'] + self.assertTrue(all('协和' in i['name'] for i in results)) + self.assertTrue(any(i['code'] == 'CMS-H010' for i in results)) + + def test_create_success(self): + payload = { + 'code': 'CMS-NEW-1', 'name': '新建示例医院', + 'type': 'hospital', 'level': '三甲', + 'province': '北京', 'city': '北京', + } + resp = self.client.post(CMS_INST_URL, payload) + self.assertEqual(resp.status_code, 201, resp.content) + body = resp.json() + self.assertEqual(body['code'], 'CMS-NEW-1') + self.assertEqual(body['name'], '新建示例医院') + self.assertEqual(body['banner_url'], '') # 未配图为空串 + self.assertTrue(Institution.objects.filter(code='CMS-NEW-1').exists()) + + def test_create_duplicate_code(self): + ensure_institution(name='已存在', code='CMS-DUP') + resp = self.client.post(CMS_INST_URL, {'code': 'CMS-DUP', 'name': '重复编码'}) + self.assertEqual(resp.status_code, 400, resp.content) + self.assertEqual(resp.json()['code'], 'CMS_INSTITUTION_CODE_EXISTS') + + def test_retrieve(self): + inst = ensure_institution(name='详情医院', code='CMS-DET') + resp = self.client.get(inst_detail_url(inst.id)) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(resp.json()['id'], inst.id) + + def test_update_patch(self): + inst = ensure_institution(name='旧名', code='CMS-UPD') + resp = self.client.patch(inst_detail_url(inst.id), {'name': '新名', 'level': '二甲'}) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(resp.json()['name'], '新名') + inst.refresh_from_db() + self.assertEqual(inst.name, '新名') + self.assertEqual(inst.level, '二甲') + + def test_update_duplicate_code(self): + ensure_institution(name='A', code='CMS-A') + inst_b = ensure_institution(name='B', code='CMS-B') + resp = self.client.patch(inst_detail_url(inst_b.id), {'code': 'CMS-A'}) + self.assertEqual(resp.status_code, 400, resp.content) + self.assertEqual(resp.json()['code'], 'CMS_INSTITUTION_CODE_EXISTS') + + def test_update_same_code_ok(self): + """编辑时传自己原 code 不算冲突。""" + inst = ensure_institution(name='自身', code='CMS-SELF') + resp = self.client.patch(inst_detail_url(inst.id), {'code': 'CMS-SELF', 'name': '改名'}) + self.assertEqual(resp.status_code, 200, resp.content) + + def test_delete_is_soft(self): + """停用 = 逻辑删除:默认管理器查不到,但库里仍在(all_objects 可见)。""" + inst = ensure_institution(name='可停用', code='CMS-DEL') + resp = self.client.delete(inst_detail_url(inst.id)) + self.assertEqual(resp.status_code, 204, resp.content) + # 默认管理器(已过滤 is_deleted)查不到 + self.assertFalse(Institution.objects.filter(id=inst.id).exists()) + # 实际未物理删除 + obj = Institution.all_objects.get(id=inst.id) + self.assertTrue(obj.is_deleted) + self.assertIsNotNone(obj.deleted_at) + + def test_deleted_not_in_list(self): + """软删后不出现在列表。""" + inst = ensure_institution(name='停用后隐藏', code='CMS-HIDE') + self.client.delete(inst_detail_url(inst.id)) + resp = self.client.get(CMS_INST_URL, {'search': 'CMS-HIDE'}) + codes = {i['code'] for i in resp.json()['results']} + self.assertNotIn('CMS-HIDE', codes) + + def test_put_not_allowed(self): + inst = ensure_institution(name='X', code='CMS-PUT') + resp = self.client.put(inst_detail_url(inst.id), {'code': 'CMS-PUT', 'name': 'Y'}) + self.assertEqual(resp.status_code, 405, resp.content) + + +# ── Banner 上传(写临时静态目录,避免污染仓库)───────────────────────────────── + +class CmsInstitutionBannerTest(CacheTestCase): + + def setUp(self): + super().setUp() + self.client, self.admin = super_admin_client(phone='13911100050') + self.inst = ensure_institution(name='传图医院', code='CMS-BANNER') + self._tmp = tempfile.mkdtemp() + + def _png(self, name='banner.png'): + # 最小合法 PNG 头 + 占位内容 + content = b'\x89PNG\r\n\x1a\n' + b'0' * 64 + return SimpleUploadedFile(name, content, content_type='image/png') + + def test_upload_success(self): + with override_settings(STATICFILES_DIRS=[self._tmp]): + resp = self.client.post(inst_banner_url(self.inst.id), + {'file': self._png()}, format='multipart') + self.assertEqual(resp.status_code, 200, resp.content) + body = resp.json() + self.assertEqual(body['message'], '上传成功') + self.assertTrue(body['banner_url'].endswith(f'/static/institutions/inst_{self.inst.id}_banner.png')) + self.inst.refresh_from_db() + self.assertEqual(self.inst.banner_url, f'institutions/inst_{self.inst.id}_banner.png') + + def test_upload_no_file(self): + with override_settings(STATICFILES_DIRS=[self._tmp]): + resp = self.client.post(inst_banner_url(self.inst.id), {}, format='multipart') + self.assertEqual(resp.status_code, 400, resp.content) + self.assertEqual(resp.json()['code'], 'CMS_BANNER_FILE_REQUIRED') + + def test_upload_bad_type(self): + bad = SimpleUploadedFile('x.txt', b'hello', content_type='text/plain') + with override_settings(STATICFILES_DIRS=[self._tmp]): + resp = self.client.post(inst_banner_url(self.inst.id), + {'file': bad}, format='multipart') + self.assertEqual(resp.status_code, 400, resp.content) + self.assertEqual(resp.json()['code'], 'CMS_BANNER_BAD_TYPE') + + +class CmsInstitutionImportExportTest(CacheTestCase): + + def setUp(self): + super().setUp() + self.client, self.admin = super_admin_client(phone='13911100090') + + def test_import_template(self): + resp = self.client.get('/api/cms/institutions/import-template/') + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(resp['Content-Type'], XLSX_CT) + + def test_export(self): + ensure_institution(name='导出医院', code='CMS-EXP-1') + resp = self.client.get('/api/cms/institutions/export/') + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp['Content-Type'], XLSX_CT) + + def test_import(self): + ensure_institution(name='已存在', code='CMS-IMP-DUP') + f = _xlsx( + ['机构编码', '名称', '类型', '等级', '省', '市'], + [ + ['CMS-IMP-1', '新医院A', 'hospital', '三甲', '北京', '北京'], + ['', '无编码', 'hospital', '', '', ''], # 编码空 → 失败 + ['CMS-IMP-DUP', '重复编码', 'hospital', '', '', ''], # 重复 → 失败 + ], + ) + resp = self.client.post('/api/cms/institutions/import/', {'file': f}, format='multipart') + self.assertEqual(resp.status_code, 200, resp.content) + body = resp.json() + self.assertEqual(body['success'], 1) + self.assertEqual(body['failed'], 2) + self.assertTrue(Institution.objects.filter(code='CMS-IMP-1').exists()) diff --git a/test/test_cms_relation.py b/test/test_cms_relation.py new file mode 100644 index 0000000..cb222ef --- /dev/null +++ b/test/test_cms_relation.py @@ -0,0 +1,99 @@ +"""CMS 师生关系管理测试:CMS-REL-1~4(医院管理员本院 / 超管全平台)。""" +import io + +from openpyxl import Workbook +from django.core.files.uploadedfile import SimpleUploadedFile +from rest_framework.test import APIClient + +from apps.user.models import TeacherStudentRelation +from .conftest import CacheTestCase, create_test_user, get_auth_client, ensure_institution + +REL_URL = '/api/cms/teacher-student-relations/' +XLSX_CT = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + + +def rel_detail(pk): + return f'/api/cms/teacher-student-relations/{pk}/' + + +def make_xlsx(headers, rows): + wb = Workbook(); ws = wb.active + ws.append(headers) + for r in rows: + ws.append(r) + buf = io.BytesIO(); wb.save(buf); buf.seek(0) + return SimpleUploadedFile('rel.xlsx', buf.read(), content_type=XLSX_CT) + + +class CmsRelationTest(CacheTestCase): + def setUp(self): + super().setUp() + self.inst = ensure_institution(name='本院', code='REL-A') + self.other = ensure_institution(name='他院', code='REL-B') + self.admin = create_test_user(phone='13932000001', role_type='hospital_admin', institution=self.inst) + self.client = get_auth_client(self.admin) + self.doc = create_test_user(phone='13932000002', role_type='doctor', institution=self.inst) + self.stu = create_test_user(phone='13932000003', role_type='student', institution=self.inst) + self.other_stu = create_test_user(phone='13932000004', role_type='student', institution=self.other) + + def test_permission(self): + self.assertEqual(APIClient().get(REL_URL).status_code, 401) + stu_client = get_auth_client(self.stu) + self.assertEqual(stu_client.get(REL_URL).status_code, 403) + + def test_create_and_list(self): + resp = self.client.post(REL_URL, {'teacher': self.doc.id, 'student': self.stu.id}) + self.assertEqual(resp.status_code, 201, resp.content) + self.assertEqual(resp.json()['teacher_phone'], '13932000002') + self.assertEqual(resp.json()['student_phone'], '13932000003') + # 列表 + resp = self.client.get(REL_URL) + self.assertEqual(resp.status_code, 200) + self.assertEqual(len(resp.json()['results']), 1) + + def test_create_duplicate(self): + self.client.post(REL_URL, {'teacher': self.doc.id, 'student': self.stu.id}) + resp = self.client.post(REL_URL, {'teacher': self.doc.id, 'student': self.stu.id}) + self.assertEqual(resp.status_code, 400, resp.content) + self.assertEqual(resp.json()['code'], 'CMS_REL_EXISTS') + + def test_teacher_must_be_doctor(self): + # 用学生当 teacher → 无效 pk + resp = self.client.post(REL_URL, {'teacher': self.stu.id, 'student': self.stu.id}) + self.assertEqual(resp.status_code, 400, resp.content) + + def test_scope_other_institution_student_rejected(self): + resp = self.client.post(REL_URL, {'teacher': self.doc.id, 'student': self.other_stu.id}) + self.assertEqual(resp.status_code, 403, resp.content) + self.assertEqual(resp.json()['code'], 'CMS_REL_SCOPE_FORBIDDEN') + + def test_soft_delete(self): + r = TeacherStudentRelation.objects.create(teacher=self.doc, student=self.stu, status=1) + resp = self.client.delete(rel_detail(r.id)) + self.assertEqual(resp.status_code, 204, resp.content) + self.assertFalse(TeacherStudentRelation.objects.filter(id=r.id).exists()) + self.assertTrue(TeacherStudentRelation.all_objects.get(id=r.id).is_deleted) + + def test_import_and_export(self): + f = make_xlsx(['带教医生手机号', '学生手机号'], [ + ['13932000002', '13932000003'], # ✅ + ['13932000002', '13932000004'], # 学生他院 → 失败 + ['00000000000', '13932000003'], # 医生不存在 → 失败 + ]) + resp = self.client.post('/api/cms/teacher-student-relations/import/', {'file': f}, format='multipart') + self.assertEqual(resp.status_code, 200, resp.content) + body = resp.json() + self.assertEqual(body['success'], 1) + self.assertEqual(body['failed'], 2) + # 导出 + resp = self.client.get('/api/cms/teacher-student-relations/export/') + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp['Content-Type'], XLSX_CT) + + def test_list_scoped_to_own(self): + # 他院的师生关系不可见 + other_doc = create_test_user(phone='13932000005', role_type='doctor', institution=self.other) + TeacherStudentRelation.objects.create(teacher=other_doc, student=self.other_stu, status=1) + TeacherStudentRelation.objects.create(teacher=self.doc, student=self.stu, status=1) + resp = self.client.get(REL_URL) + self.assertEqual(len(resp.json()['results']), 1) # 只看到本院那条 diff --git a/test/test_cms_students.py b/test/test_cms_students.py new file mode 100644 index 0000000..52e8531 --- /dev/null +++ b/test/test_cms_students.py @@ -0,0 +1,128 @@ +"""CMS 带教医生 - 我的学生测试:CMS-TEA-1~2。 + +带教医生(doctor)仅能看到 teacher_student_relation 中 +teacher=自己 且 status=1 的学生;只读,不能新增/编辑/删除。 +""" +from rest_framework.test import APIClient + +from apps.user.models import TeacherStudentRelation +from .conftest import ( + CacheTestCase, + create_test_user, + get_auth_client, + ensure_institution, + ensure_department, + create_teacher_student_relation, +) + +STUDENTS_URL = '/api/cms/students/' + + +def student_detail(pk): + return f'/api/cms/students/{pk}/' + + +class CmsStudentsTest(CacheTestCase): + def setUp(self): + super().setUp() + self.inst = ensure_institution(name='本院', code='TEA-A') + self.dept = ensure_department(name='内科') + self.doc = create_test_user( + phone='13940000001', password='Doc12345', + real_name='张医生', role_type='doctor', institution=self.inst, + ) + self.other_doc = create_test_user( + phone='13940000002', real_name='李医生', + role_type='doctor', institution=self.inst, + ) + # 名下进行中学生 + self.stu_own = create_test_user( + phone='13940000011', real_name='我的学生', + role_type='student', institution=self.inst, + ) + self.stu_own.department = self.dept + self.stu_own.save(update_fields=['department']) + # 名下已结束学生(status=0,列表应排除) + self.stu_ended = create_test_user( + phone='13940000012', real_name='已毕业学生', + role_type='student', institution=self.inst, + ) + # 其他医生的学生(当前医生不应看到) + self.stu_other = create_test_user( + phone='13940000013', real_name='其他学生', + role_type='student', institution=self.inst, + ) + create_teacher_student_relation(self.doc, self.stu_own, status=1) + create_teacher_student_relation(self.doc, self.stu_ended, status=0) + create_teacher_student_relation(self.other_doc, self.stu_other, status=1) + + self.client = get_auth_client(self.doc) + + # ── 权限 ────────────────────────────────────────────────────────────────── + def test_unauthenticated_401(self): + self.assertEqual(APIClient().get(STUDENTS_URL).status_code, 401) + + def test_non_doctor_403(self): + admin = create_test_user(phone='13940000091', role_type='hospital_admin', + institution=self.inst) + resp = get_auth_client(admin).get(STUDENTS_URL) + self.assertEqual(resp.status_code, 403, resp.content) + self.assertEqual(resp.json()['code'], 'CMS_PERMISSION_DENIED') + # 学生本人也无权访问 + resp2 = get_auth_client(self.stu_own).get(STUDENTS_URL) + self.assertEqual(resp2.status_code, 403, resp2.content) + + # ── CMS-TEA-1 列表 ───────────────────────────────────────────────────────── + def test_list_own_active_students_only(self): + resp = self.client.get(STUDENTS_URL) + self.assertEqual(resp.status_code, 200, resp.content) + results = resp.json()['results'] + ids = {u['id'] for u in results} + self.assertIn(self.stu_own.id, ids) + self.assertNotIn(self.stu_ended.id, ids) # status=0 + self.assertNotIn(self.stu_other.id, ids) # 他医生 + self.assertNotIn(self.doc.id, ids) # 不含自己 + self.assertEqual(len(results), 1) + + def test_list_search(self): + resp = self.client.get(STUDENTS_URL, {'search': '我的学生'}) + self.assertEqual(resp.status_code, 200, resp.content) + ids = {u['id'] for u in resp.json()['results']} + self.assertEqual(ids, {self.stu_own.id}) + + def test_list_student_fields(self): + resp = self.client.get(STUDENTS_URL) + item = resp.json()['results'][0] + self.assertEqual(item['real_name'], '我的学生') + self.assertEqual(item['role_type'], 'student') + self.assertEqual(item['department_name'], '内科') + self.assertIn('total_training_count', item) + + # ── CMS-TEA-2 详情 ───────────────────────────────────────────────────────── + def test_retrieve_own_student(self): + resp = self.client.get(student_detail(self.stu_own.id)) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(resp.json()['real_name'], '我的学生') + self.assertEqual(resp.json()['phone'], '13940000011') + + def test_retrieve_other_student_404(self): + resp = self.client.get(student_detail(self.stu_other.id)) + self.assertEqual(resp.status_code, 404, resp.content) + + def test_retrieve_ended_student_404(self): + resp = self.client.get(student_detail(self.stu_ended.id)) + self.assertEqual(resp.status_code, 404, resp.content) + + # ── 软删除师生关系 ───────────────────────────────────────────────────────── + def test_soft_deleted_relation_excludes_student(self): + rel = TeacherStudentRelation.objects.get(teacher=self.doc, student=self.stu_own) + rel.delete() # SoftDeleteModel 逻辑删除 + resp = self.client.get(STUDENTS_URL) + self.assertEqual(resp.status_code, 200, resp.content) + ids = {u['id'] for u in resp.json()['results']} + self.assertNotIn(self.stu_own.id, ids) + + # ── 只读 ────────────────────────────────────────────────────────────────── + def test_readonly_methods_not_allowed(self): + self.assertEqual(self.client.post(STUDENTS_URL, {}).status_code, 405) + self.assertEqual(self.client.delete(student_detail(self.stu_own.id)).status_code, 405) diff --git a/test/test_cms_user.py b/test/test_cms_user.py new file mode 100644 index 0000000..85478a9 --- /dev/null +++ b/test/test_cms_user.py @@ -0,0 +1,170 @@ +"""CMS 超级管理员 - 用户管理接口测试(CMS-USER-1~8)。""" + +import io + +from openpyxl import Workbook +from django.core.files.uploadedfile import SimpleUploadedFile +from rest_framework.test import APIClient + +from apps.user.models import User +from .conftest import CacheTestCase, create_test_user, get_auth_client, ensure_institution + +CMS_USER_URL = '/api/cms/users/' +XLSX_CT = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + + +def u_detail(pk): + return f'/api/cms/users/{pk}/' + + +def make_xlsx(headers, rows): + wb = Workbook(); ws = wb.active + ws.append(headers) + for r in rows: + ws.append(r) + buf = io.BytesIO(); wb.save(buf); buf.seek(0) + return SimpleUploadedFile('import.xlsx', buf.read(), content_type=XLSX_CT) + + +def super_client(phone='13922200001'): + admin = create_test_user(phone=phone, password='Admin123', role_type='super_admin') + return get_auth_client(admin), admin + + +class CmsUserPermissionTest(CacheTestCase): + def test_requires_auth(self): + self.assertEqual(APIClient().get(CMS_USER_URL).status_code, 401) + + def test_non_manager_forbidden(self): + # 用户管理仅超管 + 医院管理员;其它角色 403 + for role in ('student', 'doctor', 'content_admin'): + u = create_test_user(phone={'student': '13922200010', 'doctor': '13922200011', + 'content_admin': '13922200012'}[role], role_type=role) + resp = get_auth_client(u).get(CMS_USER_URL) + self.assertEqual(resp.status_code, 403, f'{role}: {resp.content}') + self.assertEqual(resp.json()['code'], 'CMS_PERMISSION_DENIED') + + +class CmsUserCrudTest(CacheTestCase): + def setUp(self): + super().setUp() + self.client, self.admin = super_client() + self.inst = ensure_institution(name='测试医院', code='CU-H001') + + def test_list(self): + create_test_user(phone='13922200021', real_name='学生甲', role_type='student') + resp = self.client.get(CMS_USER_URL) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertIn('results', resp.json()) + + def test_create_success(self): + resp = self.client.post(CMS_USER_URL, { + 'phone': '13922200030', 'real_name': '李医生', + 'role_type': 'doctor', 'institution': self.inst.id, + }) + self.assertEqual(resp.status_code, 201, resp.content) + self.assertEqual(resp.json()['phone'], '13922200030') + self.assertEqual(resp.json()['role_type'], 'doctor') + user = User.objects.get(phone='13922200030') + self.assertTrue(user.check_password('Pass13922200030')) # 默认密码 + + def test_create_role_required(self): + resp = self.client.post(CMS_USER_URL, { + 'phone': '13922200031', 'real_name': '无角色', 'institution': self.inst.id}) + self.assertEqual(resp.status_code, 400, resp.content) + + def test_create_institution_required(self): + resp = self.client.post(CMS_USER_URL, { + 'phone': '13922200032', 'real_name': '无机构', 'role_type': 'student'}) + self.assertEqual(resp.status_code, 400, resp.content) + self.assertEqual(resp.json()['code'], 'CMS_VALIDATION_ERROR') + + def test_create_bad_phone(self): + resp = self.client.post(CMS_USER_URL, { + 'phone': '123', 'real_name': 'x', 'role_type': 'student', 'institution': self.inst.id}) + self.assertEqual(resp.status_code, 400, resp.content) + + def test_create_duplicate_phone(self): + create_test_user(phone='13922200040', role_type='student') + resp = self.client.post(CMS_USER_URL, { + 'phone': '13922200040', 'real_name': 'dup', 'role_type': 'student', + 'institution': self.inst.id}) + self.assertEqual(resp.status_code, 400, resp.content) + self.assertEqual(resp.json()['code'], 'CMS_USER_PHONE_EXISTS') + + def test_edit_partial_keeps_role_institution(self): + """方案B:只改姓名、不带角色/机构 → 200,角色与机构保持原值。""" + u = create_test_user(phone='13922200050', real_name='原名', role_type='student', + institution=self.inst) + resp = self.client.patch(u_detail(u.id), {'real_name': '新名'}) + self.assertEqual(resp.status_code, 200, resp.content) + u.refresh_from_db() + self.assertEqual(u.real_name, '新名') + self.assertEqual(u.role_type, 'student') # 未传 → 保持 + self.assertEqual(u.institution_id, self.inst.id) # 未传 → 保持 + + def test_edit_cannot_blank_role(self): + """方案B:传了 role_type 但为空 → 400(不可清空角色)。""" + u = create_test_user(phone='13922200051', real_name='x', role_type='student', + institution=self.inst) + resp = self.client.patch(u_detail(u.id), {'role_type': ''}) + self.assertEqual(resp.status_code, 400, resp.content) + self.assertEqual(resp.json()['code'], 'CMS_VALIDATION_ERROR') + + def test_edit_cannot_blank_institution(self): + """方案B:传了 institution=null → 400(不可清空机构)。""" + u = create_test_user(phone='13922200052', real_name='x', role_type='student', + institution=self.inst) + resp = self.client.patch(u_detail(u.id), {'institution': None}, format='json') + self.assertEqual(resp.status_code, 400, resp.content) + self.assertEqual(resp.json()['code'], 'CMS_VALIDATION_ERROR') + + def test_soft_delete(self): + u = create_test_user(phone='13922200060', role_type='student') + resp = self.client.delete(u_detail(u.id)) + self.assertEqual(resp.status_code, 204, resp.content) + self.assertFalse(User.objects.filter(id=u.id).exists()) # 默认管理器过滤 + obj = User.all_objects.get(id=u.id) + self.assertTrue(obj.is_deleted) # 实际未物删 + + def test_reset_password(self): + u = create_test_user(phone='13922200070', password='OldPass1', role_type='student') + resp = self.client.post(f'/api/cms/users/{u.id}/reset-password/', {}) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(resp.json()['password'], 'Pass13922200070') + u.refresh_from_db() + self.assertTrue(u.check_password('Pass13922200070')) + + def test_import_template(self): + resp = self.client.get('/api/cms/users/import-template/') + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(resp['Content-Type'], XLSX_CT) + + def test_export(self): + create_test_user(phone='13922200080', role_type='student') + resp = self.client.get('/api/cms/users/export/') + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp['Content-Type'], XLSX_CT) + + def test_import_users(self): + f = make_xlsx( + ['手机号', '姓名', '角色', '机构编码'], + [ + ['13922200091', '导入甲', '医生', 'CU-H001'], # ✅ + ['13922200092', '导入乙', 'student', ''], # 机构编码空 → 失败 + ['bad', '格式错', '学生', 'CU-H001'], # 手机号格式错 + ['13922200093', '角色错', '不存在角色', 'CU-H001'], # 角色非法 + ], + ) + resp = self.client.post('/api/cms/users/import/', {'file': f}, format='multipart') + self.assertEqual(resp.status_code, 200, resp.content) + body = resp.json() + self.assertEqual(body['total'], 4) + self.assertEqual(body['success'], 1) + self.assertEqual(body['failed'], 3) + self.assertTrue(User.objects.filter(phone='13922200091', role_type='doctor').exists()) + + def test_import_no_file(self): + resp = self.client.post('/api/cms/users/import/', {}, format='multipart') + self.assertEqual(resp.status_code, 400, resp.content) + self.assertEqual(resp.json()['code'], 'CMS_IMPORT_FILE_REQUIRED') diff --git a/test/test_profile_config.py b/test/test_profile_config.py index e0994e2..3f2c94c 100644 --- a/test/test_profile_config.py +++ b/test/test_profile_config.py @@ -69,16 +69,13 @@ class InstitutionInfoTest(CacheTestCase): class MyDepartmentsTest(CacheTestCase): """所属机构科室列表接口(不分页)。""" - def test_returns_all_departments_no_pagination(self): - """返回本机构全部科室,且不分页(直接为列表)。""" - inst = ensure_institution(name='测试医院', code='TEST-HOSP-001') - Department.objects.create(institution=inst, name='内科', category='临床') - Department.objects.create(institution=inst, name='外科', category='临床') - # 另一机构的科室不应出现 - other = ensure_institution(name='其他医院', code='TEST-HOSP-OTHER') - Department.objects.create(institution=other, name='儿科', category='临床') + def test_returns_all_global_departments_no_pagination(self): + """返回全部全局科室(与机构无关),不分页(直接为列表)。""" + Department.objects.create(name='内科', category='临床') + Department.objects.create(name='外科', category='临床') + Department.objects.create(name='儿科', category='临床') - user = create_test_user(phone='13900200010', institution=inst) + user = create_test_user(phone='13900200010') client = get_auth_client(user) resp = client.get(USER_MY_DEPARTMENTS_URL) self.assertEqual(resp.status_code, 200, resp.content) @@ -86,13 +83,16 @@ class MyDepartmentsTest(CacheTestCase): data = resp.json() self.assertIsInstance(data, list) # 不分页:顶层是列表 names = {d['name'] for d in data} - self.assertEqual(names, {'内科', '外科'}) + self.assertEqual(names, {'内科', '外科', '儿科'}) - def test_user_without_institution_404(self): + def test_no_institution_still_returns_departments(self): + """科室全局,用户没机构也能拿到全部科室。""" + Department.objects.create(name='内科', category='临床') user = create_test_user(phone='13900200011', institution=None) client = get_auth_client(user) resp = client.get(USER_MY_DEPARTMENTS_URL) - self.assertEqual(resp.status_code, 404, resp.content) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual({d['name'] for d in resp.json()}, {'内科'}) class ProfileConfigTest(CacheTestCase): @@ -101,7 +101,7 @@ class ProfileConfigTest(CacheTestCase): def test_config_success(self): """录入 科室/职称/执业年限 → 落库成功。""" inst = ensure_institution(name='测试医院', code='TEST-HOSP-001') - dept = Department.objects.create(institution=inst, name='内科', category='临床') + dept = Department.objects.create(name='内科', category='临床') user = create_test_user(phone='13900200020', institution=inst) client = get_auth_client(user) @@ -118,20 +118,20 @@ class ProfileConfigTest(CacheTestCase): self.assertEqual(user.practice_years, '1-3年') self.assertEqual(resp.json()['user']['practice_years'], '1-3年') - def test_department_from_other_institution_rejected(self): - """所选科室不属于本机构 → 校验失败 400。""" - inst = ensure_institution(name='测试医院', code='TEST-HOSP-001') - other = ensure_institution(name='其他医院', code='TEST-HOSP-OTHER') - other_dept = Department.objects.create(institution=other, name='外科', category='临床') - user = create_test_user(phone='13900200021', institution=inst) + def test_any_global_department_accepted(self): + """科室全局:可选择任意科室(不再校验机构归属)。""" + dept = Department.objects.create(name='外科', category='临床') + user = create_test_user(phone='13900200021', institution=None) client = get_auth_client(user) resp = client.post(USER_PROFILE_CONFIG_URL, { - 'department': other_dept.id, + 'department': dept.id, 'title_name': '住院医师', 'practice_years': '1-3年', }) - self.assertEqual(resp.status_code, 400, resp.content) + self.assertEqual(resp.status_code, 200, resp.content) + user.refresh_from_db() + self.assertEqual(user.department_id, dept.id) def test_missing_fields_rejected(self): """缺少必填字段 → 400。""" diff --git a/test/test_profile_personal.py b/test/test_profile_personal.py new file mode 100644 index 0000000..4a20038 --- /dev/null +++ b/test/test_profile_personal.py @@ -0,0 +1,131 @@ +"""移动端个人中心:个人信息获取(GET) / 更新(PATCH) 接口测试。""" + +from rest_framework.test import APIClient + +from apps.user.models import Department +from .conftest import ( + CacheTestCase, + create_test_user, get_auth_client, ensure_institution, +) + +PROFILE_URL = '/api/user/profile/' + + +class ProfileGetTest(CacheTestCase): + + def test_get_full_info(self): + inst = ensure_institution(name='测试医院', code='PF-H001') + user = create_test_user(phone='13912300001', real_name='张三', institution=inst) + user.practice_years = '1-3年' + user.title_name = '住院医师' + user.save(update_fields=['practice_years', 'title_name']) + + client = get_auth_client(user) + resp = client.get(PROFILE_URL) + self.assertEqual(resp.status_code, 200, resp.content) + data = resp.json() + # 全量信息 + self.assertEqual(data['id'], user.id) + self.assertEqual(data['real_name'], '张三') + self.assertEqual(data['practice_years'], '1-3年') + self.assertEqual(data['title_name'], '住院医师') + # 机构/角色等只读信息也返回(供展示,但不可改) + self.assertEqual(data['institution'], inst.id) + self.assertEqual(data['role_type'], 'student') + + def test_get_requires_auth(self): + resp = APIClient().get(PROFILE_URL) + self.assertEqual(resp.status_code, 401, resp.content) + + +class ProfileUpdateTest(CacheTestCase): + + def setUp(self): + super().setUp() + self.inst = ensure_institution(name='测试医院', code='PF-H001') + self.user = create_test_user(phone='13912300010', real_name='原名', institution=self.inst) + self.client = get_auth_client(self.user) + + def test_update_allowed_fields(self): + dept = Department.objects.create(name='内科', category='临床') + payload = { + 'username': 'newuser001', + 'real_name': '李四', + 'avatar': 'https://cdn.x.com/a.png', + 'gender': 1, + 'department': dept.id, + 'title_name': '主治医师', + 'major': '心血管内科', + 'practice_years': '3-5年', + 'training_stage': '规培', + 'learning_target': '提升问诊能力', + } + resp = self.client.patch(PROFILE_URL, payload) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(resp.json()['message'], '更新成功') + + self.user.refresh_from_db() + self.assertEqual(self.user.username, 'newuser001') + self.assertEqual(self.user.real_name, '李四') + self.assertEqual(self.user.avatar, 'https://cdn.x.com/a.png') + self.assertEqual(self.user.gender, 1) + self.assertEqual(self.user.department_id, dept.id) + self.assertEqual(self.user.title_name, '主治医师') + self.assertEqual(self.user.major, '心血管内科') + self.assertEqual(self.user.practice_years, '3-5年') + self.assertEqual(self.user.training_stage, '规培') + self.assertEqual(self.user.learning_target, '提升问诊能力') + + def test_update_phone(self): + resp = self.client.patch(PROFILE_URL, {'phone': '13900008888'}) + self.assertEqual(resp.status_code, 200, resp.content) + self.user.refresh_from_db() + self.assertEqual(self.user.phone, '13900008888') + + def test_cannot_change_institution_role_superuser(self): + """机构 / 角色 / is_superuser 不在白名单,传了也被忽略。""" + other = ensure_institution(name='其他医院', code='PF-OTHER') + resp = self.client.patch(PROFILE_URL, { + 'real_name': '改个名', + 'institution': other.id, + 'role_type': 'super_admin', + 'is_superuser': True, + }) + self.assertEqual(resp.status_code, 200, resp.content) + self.user.refresh_from_db() + self.assertEqual(self.user.real_name, '改个名') # 白名单字段生效 + self.assertEqual(self.user.institution_id, self.inst.id) # 机构未变 + self.assertEqual(self.user.role_type, 'student') # 角色未变 + self.assertFalse(self.user.is_superuser) # 仍非超管 + + def test_username_taken(self): + create_test_user(phone='13912300099', real_name='别人') + # 把别人的 username 抢过来 + other_username = 'taken_name' + u = create_test_user(phone='13912300098', real_name='占名者') + u.username = other_username + u.save(update_fields=['username']) + resp = self.client.patch(PROFILE_URL, {'username': other_username}) + self.assertEqual(resp.status_code, 400, resp.content) + self.assertEqual(resp.json()['code'], 'VALIDATION_ERROR') + + def test_phone_invalid_format(self): + resp = self.client.patch(PROFILE_URL, {'phone': '12345'}) + self.assertEqual(resp.status_code, 400, resp.content) + + def test_phone_taken(self): + create_test_user(phone='13912300077', real_name='占号者') + resp = self.client.patch(PROFILE_URL, {'phone': '13912300077'}) + self.assertEqual(resp.status_code, 400, resp.content) + + def test_any_global_department_accepted(self): + """科室全局:可更新为任意科室(不再校验机构归属)。""" + dept = Department.objects.create(name='外科', category='临床') + resp = self.client.patch(PROFILE_URL, {'department': dept.id}) + self.assertEqual(resp.status_code, 200, resp.content) + self.user.refresh_from_db() + self.assertEqual(self.user.department_id, dept.id) + + def test_update_requires_auth(self): + resp = APIClient().patch(PROFILE_URL, {'real_name': 'x'}) + self.assertEqual(resp.status_code, 401, resp.content)