diff --git a/apps/user/migrations/0005_institution_banner_url_user_practice_years.py b/apps/user/migrations/0005_institution_banner_url_user_practice_years.py new file mode 100644 index 0000000..6ca4b82 --- /dev/null +++ b/apps/user/migrations/0005_institution_banner_url_user_practice_years.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.14 on 2026-06-08 08:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('user', '0004_institution_code_unique'), + ] + + operations = [ + migrations.AddField( + model_name='institution', + name='banner_url', + field=models.CharField(blank=True, help_text='机构专属图片:可为静态相对路径(如 institutions/xxx.png)或完整 http(s) URL', max_length=500, verbose_name='机构Banner图'), + ), + migrations.AddField( + model_name='user', + name='practice_years', + field=models.CharField(blank=True, max_length=20, verbose_name='执业年限'), + ), + ] diff --git a/apps/user/models.py b/apps/user/models.py index 89e7ae5..3153a43 100644 --- a/apps/user/models.py +++ b/apps/user/models.py @@ -51,6 +51,7 @@ class User(AbstractBaseUser, PermissionsMixin, BaseModel): null=True, blank=True, verbose_name='所属科室' ) title_name = models.CharField('职称', max_length=50, blank=True) + practice_years = models.CharField('执业年限', max_length=20, blank=True) major = models.CharField('专业', max_length=100, blank=True) training_stage = models.CharField('培训阶段', max_length=50, blank=True) learning_target = models.CharField('学习目标', max_length=255, blank=True) @@ -138,6 +139,10 @@ class Institution(BaseModel): level = models.CharField('等级', max_length=30, blank=True) province = models.CharField('省份', max_length=50, blank=True) city = models.CharField('城市', max_length=50, blank=True) + banner_url = models.CharField( + '机构Banner图', max_length=500, blank=True, + help_text='机构专属图片:可为静态相对路径(如 institutions/xxx.png)或完整 http(s) URL' + ) class Meta: db_table = 'institution' diff --git a/apps/user/serializers.py b/apps/user/serializers.py index 526da35..026b592 100644 --- a/apps/user/serializers.py +++ b/apps/user/serializers.py @@ -11,8 +11,8 @@ class UserSerializer(serializers.ModelSerializer): fields = [ 'id', 'username', 'real_name', 'phone', 'avatar', 'gender', 'role_type', 'institution', 'institution_name', - 'department', 'department_name', 'title_name', 'major', - 'training_stage', 'learning_target', 'competency_profile', + 'department', 'department_name', 'title_name', 'practice_years', + 'major', 'training_stage', 'learning_target', 'competency_profile', 'weak_dimensions', 'strong_dimensions', 'ai_preference', 'total_training_count', 'total_case_count', 'current_level', 'status', 'last_login_time', 'created_at', 'updated_at' @@ -44,11 +44,27 @@ class UserUpdateSerializer(serializers.ModelSerializer): model = User fields = [ 'real_name', 'phone', 'avatar', 'gender', 'role_type', - 'institution', 'department', 'title_name', 'major', - 'training_stage', 'learning_target', 'status' + 'institution', 'department', 'title_name', 'practice_years', + 'major', 'training_stage', 'learning_target', 'status' ] +class StudentProfileConfigSerializer(serializers.Serializer): + """医学生信息配置(首次进入系统)——科室、专业职称、执业年限""" + department = serializers.PrimaryKeyRelatedField( + 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): """密码修改序列化器""" old_password = serializers.CharField(required=True, write_only=True) diff --git a/apps/user/urls.py b/apps/user/urls.py index 663356d..3c736f2 100644 --- a/apps/user/urls.py +++ b/apps/user/urls.py @@ -19,6 +19,10 @@ urlpatterns = [ path('', include(router.urls)), # 移动端机构列表(不分页,登录前可调用) path('institution_list/', views.institution_list, name='institution-list'), + # 移动端配置页(登录后):机构信息 / 所属机构科室列表 / 学生信息配置 + 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'), # 认证相关 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 d2d6040..4fd96e8 100644 --- a/apps/user/views.py +++ b/apps/user/views.py @@ -1,3 +1,4 @@ +from django.conf import settings from rest_framework import viewsets, filters, status from rest_framework.decorators import action, api_view, permission_classes from rest_framework.permissions import IsAuthenticated, AllowAny @@ -10,6 +11,7 @@ from .auth import TRIAL_INSTITUTION_NAME from .models import User, Role, TeacherStudentRelation, Institution, Department from .serializers import ( UserSerializer, UserCreateSerializer, UserUpdateSerializer, + StudentProfileConfigSerializer, RoleSerializer, TeacherStudentRelationSerializer, InstitutionSerializer, DepartmentSerializer ) @@ -228,3 +230,101 @@ def institution_list(request): for inst in institutions ] return Response(data) + + +# ── 配置页相关接口(移动端,首次进入系统)───────────────────────────────────── + +def _build_banner_url(request, banner_value): + """把机构 banner 字段转成完整可访问 URL。 + + - 空值回退到 settings.DEFAULT_INSTITUTION_BANNER + - 已是 http(s) 完整 URL 时原样返回 + - 否则视为相对 STATIC_URL 的静态路径,拼成绝对 URL + """ + value = banner_value or settings.DEFAULT_INSTITUTION_BANNER + if value.startswith(('http://', 'https://')): + return value + path = '/' + settings.STATIC_URL.strip('/') + '/' + value.lstrip('/') + return request.build_absolute_uri(path) + + +@extend_schema( + summary='机构信息获取接口', + description='返回当前登录学生所属机构的信息,含机构专属 Banner 图 URL(用于配置页/首页顶部)。', + responses={200: None}, + tags=['机构'], +) +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def institution_info(request): + """机构信息获取接口 — 当前用户所属机构 + Banner 图 URL""" + inst = request.user.institution + if inst is None: + raise AppError('USER_INSTITUTION_NOT_FOUND', '当前账号未关联机构', status_code=404) + + return Response({ + 'id': inst.id, + 'code': inst.code, + 'name': inst.name, + 'type': inst.type, + 'level': inst.level, + 'province': inst.province, + 'city': inst.city, + 'banner_url': _build_banner_url(request, inst.banner_url), + }) + + +@extend_schema( + summary='所属机构科室列表接口(不分页)', + description='返回当前登录学生所属机构下的全部科室,不分页,供配置页选择执业科室。', + responses={200: None}, + tags=['机构'], +) +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def my_departments(request): + """所属机构科室列表接口 — 当前用户机构下全部科室、不分页""" + 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') + data = [ + { + 'id': dept.id, + 'name': dept.name, + 'category': dept.category, + } + for dept in departments + ] + return Response(data) + + +@extend_schema( + summary='医学生信息配置接口', + description='首次进入系统时录入学生信息:执业科室、专业职称、执业年限。' + '机构在登录时已选定,此处不再修改。', + request=StudentProfileConfigSerializer, + responses={200: UserSerializer}, + tags=['用户'], +) +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def student_profile_config(request): + """医学生信息配置接口 — 录入科室、职称、执业年限""" + serializer = StudentProfileConfigSerializer( + data=request.data, context={'request': request} + ) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + + user = request.user + user.department = data['department'] + user.title_name = data['title_name'] + user.practice_years = data['practice_years'] + user.save(update_fields=['department', 'title_name', 'practice_years', 'updated_at']) + + return Response({ + 'message': '配置成功', + 'user': UserSerializer(user).data, + }) diff --git a/config/settings.py b/config/settings.py index cec9014..e2eed15 100644 --- a/config/settings.py +++ b/config/settings.py @@ -107,6 +107,10 @@ USE_I18N = True USE_TZ = True STATIC_URL = 'static/' +STATICFILES_DIRS = [BASE_DIR / 'static'] + +# 机构 Banner 默认图(当 Institution.banner_url 为空时回退使用,相对 STATIC_URL) +DEFAULT_INSTITUTION_BANNER = 'institutions/default_hospital.png' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' diff --git a/test/conftest.py b/test/conftest.py index 3b398d8..eea2409 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -38,6 +38,9 @@ USER_CHANGE_PWD_URL = '/api/user/users/change-password/' USER_ME_URL = '/api/user/users/me/' USER_LIST_URL = '/api/user/users/' USER_INSTITUTION_LIST_URL = '/api/user/institution_list/' +USER_INSTITUTION_INFO_URL = '/api/user/institution_info/' +USER_MY_DEPARTMENTS_URL = '/api/user/my_departments/' +USER_PROFILE_CONFIG_URL = '/api/user/profile/config/' # 病例 CASE_PARSE_URL = '/api/case/cases/parse-pdf/' diff --git a/test/swagger_profile_config.py b/test/swagger_profile_config.py new file mode 100644 index 0000000..2134032 --- /dev/null +++ b/test/swagger_profile_config.py @@ -0,0 +1,147 @@ +""" +Swagger Try-it-out 等效脚本:仅测配置页三接口。 + 1) GET /api/user/institution_info/ 机构信息获取 + 2) GET /api/user/my_departments/ 所属机构科室列表(不分页) + 3) POST /api/user/profile/config/ 医学生信息配置 +运行方式:.venv\\Scripts\\python.exe test/swagger_profile_config.py +前提:Django dev server 已在 http://127.0.0.1:8000 运行,Redis 已启动。 +""" + +import sys +import json +import subprocess + +import requests + +sys.stdout.reconfigure(encoding='utf-8') +sys.stderr.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' +PASS, FAIL = 'PASS', 'FAIL' +results = [] + +TRIAL_INST_CODE = 'PKU_LAB_TRIAL' +STUDENT_PHONE = '13700000055' + + +def log(api_id, method, url, expected, actual, detail='', resp_body=None): + exp = expected if isinstance(expected, (list, tuple)) else [expected] + status = PASS if actual in exp else FAIL + results.append((api_id, status)) + print(f' {status} {api_id:<14} {method:<5} {url:<38} expect={str(expected):<10} got={actual} {detail}') + if resp_body is not None: + body = json.dumps(resp_body, ensure_ascii=False, indent=2) + if len(body) > 1200: + body = body[:1200] + f'... (truncated, {len(body)} chars)' + print(f' <<< {body}') + + +def django_eval(code): + preamble = ( + 'import django, os; ' + 'os.environ.setdefault("DJANGO_SETTINGS_MODULE","config.settings"); ' + 'django.setup(); ' + ) + proc = subprocess.run([PYTHON, '-c', preamble + code], capture_output=True, text=True, cwd=CWD) + return proc.stdout.strip() + + +# ─── 准备:试用机构 + banner + 两个科室 + 学生用户 + token ───────────────────── + +print('\n[准备] 配置试用机构 banner、科室、学生用户...') +setup = django_eval( + f'from apps.user.models import User, Institution, Department; ' + f'from rest_framework_simplejwt.tokens import RefreshToken; ' + 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'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); ' + f'print(str(RefreshToken.for_user(u).access_token) + "|" + str(d1.id) + "|" + str(d2.id) + "|" + str(inst.id))' +) +access, dept1_id, dept2_id, inst_id = setup.split('|') +auth = {'Authorization': f'Bearer {access}'} +print(f'[准备] 完成 inst_id={inst_id} dept1={dept1_id} dept2={dept2_id}\n') + +print('=' * 100) +print(' 配置页三接口 Swagger Try-it-out') +print('=' * 100) + +# ── 1. 机构信息获取 ─────────────────────────────────────────────────────────── + +r = s = requests.get(f'{BASE}/api/user/institution_info/', headers=auth) +body = r.json() if r.headers.get('content-type', '').startswith('application/json') else None +banner = (body or {}).get('banner_url', '') +detail = f'name={body.get("name","")}, banner_ok={banner.endswith("/static/institutions/default_hospital.png")}' if body else '' +log('institution_info', 'GET', '/api/user/institution_info/', 200, r.status_code, detail, resp_body=body) + +# 1b. 未登录 → 401 +r = requests.get(f'{BASE}/api/user/institution_info/') +log('inst_info-401', 'GET', '/api/user/institution_info/ (anon)', 401, r.status_code) + +# ── 2. 所属机构科室列表(不分页)───────────────────────────────────────────── + +r = requests.get(f'{BASE}/api/user/my_departments/', headers=auth) +body = r.json() if r.headers.get('content-type', '').startswith('application/json') else None +is_list = isinstance(body, list) +names = [d.get('name') for d in body] if is_list else [] +detail = f'not_paginated={is_list}, count={len(names)}, names={names}' +log('my_departments', 'GET', '/api/user/my_departments/', 200, r.status_code, detail, resp_body=body) + +# ── 3. 医学生信息配置 ───────────────────────────────────────────────────────── + +cfg_body = {'department': int(dept1_id), 'title_name': '住院医师', 'practice_years': '1-3年'} +r = requests.post(f'{BASE}/api/user/profile/config/', json=cfg_body, headers=auth) +body = r.json() if r.headers.get('content-type', '').startswith('application/json') else None +u = (body or {}).get('user', {}) +detail = (f'dept={u.get("department")}, title={u.get("title_name")}, ' + f'years={u.get("practice_years")}') if body else '' +log('profile_config', 'POST', '/api/user/profile/config/', 200, r.status_code, detail, resp_body=body) + +# 3b. 跨机构科室 → 400(用一个属于别的机构的科室) +other_dept = django_eval( + 'from apps.user.models import Institution, Department; ' + 'o, _ = Institution.objects.get_or_create(code="SWAG_OTHER_CFG", defaults={"name":"配置页其它医院","type":"hospital"}); ' + 'd, _ = Department.objects.get_or_create(institution=o, name="放射科", defaults={"category":"医技"}); ' + 'print(d.id)' +) +r = requests.post(f'{BASE}/api/user/profile/config/', + json={'department': int(other_dept), 'title_name': '住院医师', 'practice_years': '1-3年'}, + headers=auth) +body = r.json() if r.headers.get('content-type', '').startswith('application/json') else None +log('cfg-cross-inst', 'POST', '/api/user/profile/config/ (other inst dept)', 400, r.status_code, + f'code={(body or {}).get("code","")}', resp_body=body) + +# 3c. 缺字段 → 400 +r = requests.post(f'{BASE}/api/user/profile/config/', json={'title_name': '住院医师'}, headers=auth) +log('cfg-missing', 'POST', '/api/user/profile/config/ (missing fields)', 400, r.status_code) + +# 验证落库 +db = django_eval( + f'from apps.user.models import User; u=User.objects.get(phone="{STUDENT_PHONE}"); ' + f'print(f"{{u.department_id}}|{{u.title_name}}|{{u.practice_years}}")' +) +log('cfg-db-check', 'CHECK', 'user.department/title/years persisted', + f'{dept1_id}|住院医师|1-3年', db) + +# ─── 清理 ───────────────────────────────────────────────────────────────────── + +django_eval(f'from apps.user.models import User; User.objects.filter(phone="{STUDENT_PHONE}").delete(); print("cleaned")') + +# ─── 汇总 ───────────────────────────────────────────────────────────────────── + +print('=' * 100) +total = len(results) +passed = sum(1 for _, st in results if st == PASS) +failed = total - passed +print(f' 总计: {total} | 通过: {passed} | 失败: {failed}') +if failed: + print(' 失败:', [aid for aid, st in results if st == FAIL]) + sys.exit(1) +print(' ALL PASSED — 配置页三接口验证通过!') +sys.exit(0) diff --git a/test/test_profile_config.py b/test/test_profile_config.py new file mode 100644 index 0000000..e0994e2 --- /dev/null +++ b/test/test_profile_config.py @@ -0,0 +1,146 @@ +"""配置页三接口测试:机构信息获取 / 所属机构科室列表 / 医学生信息配置。""" + +from rest_framework.test import APIClient + +from apps.user.models import Department, Institution +from .conftest import ( + CacheTestCase, + USER_INSTITUTION_INFO_URL, USER_MY_DEPARTMENTS_URL, USER_PROFILE_CONFIG_URL, + create_test_user, get_auth_client, ensure_institution, +) + + +class InstitutionInfoTest(CacheTestCase): + """机构信息获取接口。""" + + def test_returns_institution_with_banner(self): + """配置了 banner_url 的机构 → 返回拼好的完整 URL。""" + inst = ensure_institution(name='测试医院', code='TEST-HOSP-001') + inst.banner_url = 'institutions/test.png' + inst.save(update_fields=['banner_url']) + user = create_test_user(phone='13900200001', institution=inst) + + client = get_auth_client(user) + resp = client.get(USER_INSTITUTION_INFO_URL) + self.assertEqual(resp.status_code, 200, resp.content) + + data = resp.json() + self.assertEqual(data['id'], inst.id) + self.assertEqual(data['name'], '测试医院') + self.assertTrue(data['banner_url'].startswith('http')) + self.assertTrue(data['banner_url'].endswith('/static/institutions/test.png')) + + def test_empty_banner_falls_back_to_default(self): + """机构未配 banner_url → 回退到默认医院图。""" + inst = ensure_institution(name='无图医院', code='TEST-HOSP-002') + user = create_test_user(phone='13900200002', institution=inst) + + client = get_auth_client(user) + resp = client.get(USER_INSTITUTION_INFO_URL) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertTrue( + resp.json()['banner_url'].endswith('/static/institutions/default_hospital.png') + ) + + def test_full_url_banner_passthrough(self): + """banner_url 已是完整 URL → 原样返回。""" + inst = ensure_institution(name='CDN医院', code='TEST-HOSP-003') + inst.banner_url = 'https://cdn.example.com/a.png' + inst.save(update_fields=['banner_url']) + user = create_test_user(phone='13900200003', institution=inst) + + client = get_auth_client(user) + resp = client.get(USER_INSTITUTION_INFO_URL) + self.assertEqual(resp.json()['banner_url'], 'https://cdn.example.com/a.png') + + def test_user_without_institution_404(self): + """账号未关联机构 → 404。""" + user = create_test_user(phone='13900200004', institution=None) + client = get_auth_client(user) + resp = client.get(USER_INSTITUTION_INFO_URL) + self.assertEqual(resp.status_code, 404, resp.content) + + def test_requires_auth(self): + """未登录 → 401。""" + resp = APIClient().get(USER_INSTITUTION_INFO_URL) + self.assertEqual(resp.status_code, 401, resp.content) + + +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='临床') + + user = create_test_user(phone='13900200010', institution=inst) + client = get_auth_client(user) + resp = client.get(USER_MY_DEPARTMENTS_URL) + self.assertEqual(resp.status_code, 200, resp.content) + + data = resp.json() + self.assertIsInstance(data, list) # 不分页:顶层是列表 + names = {d['name'] for d in data} + self.assertEqual(names, {'内科', '外科'}) + + def test_user_without_institution_404(self): + 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) + + +class ProfileConfigTest(CacheTestCase): + """医学生信息配置接口。""" + + def test_config_success(self): + """录入 科室/职称/执业年限 → 落库成功。""" + inst = ensure_institution(name='测试医院', code='TEST-HOSP-001') + dept = Department.objects.create(institution=inst, name='内科', category='临床') + user = create_test_user(phone='13900200020', institution=inst) + + client = get_auth_client(user) + resp = client.post(USER_PROFILE_CONFIG_URL, { + 'department': dept.id, + 'title_name': '住院医师', + 'practice_years': '1-3年', + }) + self.assertEqual(resp.status_code, 200, resp.content) + + user.refresh_from_db() + self.assertEqual(user.department_id, dept.id) + self.assertEqual(user.title_name, '住院医师') + 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) + + client = get_auth_client(user) + resp = client.post(USER_PROFILE_CONFIG_URL, { + 'department': other_dept.id, + 'title_name': '住院医师', + 'practice_years': '1-3年', + }) + self.assertEqual(resp.status_code, 400, resp.content) + + def test_missing_fields_rejected(self): + """缺少必填字段 → 400。""" + inst = ensure_institution(name='测试医院', code='TEST-HOSP-001') + user = create_test_user(phone='13900200022', institution=inst) + client = get_auth_client(user) + resp = client.post(USER_PROFILE_CONFIG_URL, {'title_name': '住院医师'}) + self.assertEqual(resp.status_code, 400, resp.content) + + def test_requires_auth(self): + resp = APIClient().post(USER_PROFILE_CONFIG_URL, {}) + self.assertEqual(resp.status_code, 401, resp.content)