feat: add profile api

This commit is contained in:
2026-06-08 17:36:03 +08:00
parent ba9fb33062
commit 2b86c91edd
9 changed files with 452 additions and 4 deletions
@@ -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='执业年限'),
),
]
+5
View File
@@ -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'
+20 -4
View File
@@ -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)
+4
View File
@@ -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'),
+100
View File
@@ -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,
})
+4
View File
@@ -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'
+3
View File
@@ -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/'
+147
View File
@@ -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)
+146
View File
@@ -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)