feat: cms overview and mobile case query

This commit is contained in:
2026-06-13 01:44:31 +08:00
parent 8fecaeeb54
commit 05ce7e987e
12 changed files with 1787 additions and 1 deletions
+148
View File
@@ -0,0 +1,148 @@
"""Swagger Try-it-out 等效脚本:CMS 各角色概览大屏(第八章 CMS-STATS-*)。
CMS-STATS-SUP-1 平台总览 / HOS-1 医院驾驶舱 / CNT-1 内容概览 / TEA-1 教学概览。
为产出**真实出参示例**,先在示例机构内播种一份连贯数据(机构/4 类管理员/2 学生/师生关系/
2 病例/3 条训练记录),调用后把完整 JSON 落到 logs/swagger-cms-stats-examples.json,最后清理。
运行方式:.venv\\Scripts\\python.exe test/swagger_cms_stats.py
前提:Django dev server 已在 http://127.0.0.1:8000 运行,Redis 已启动。
"""
import sys
import json
import subprocess
from pathlib import Path
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 = []
EXAMPLES = {}
OVERVIEW = '/api/cms/stats/overview/'
HOSPITAL = '/api/cms/stats/hospital/overview/'
CONTENT = '/api/cms/stats/content/overview/'
TEACHING = '/api/cms/stats/teaching/overview/'
PHONES = ['13700008001', '13700008002', '13700008003', '13700008004', '13700008005', '13700008006']
def log(api_id, method, url, expected, actual, detail=''):
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:<18} {method:<5} {url:<40} expect={str(expected):<9} got={actual} {detail}')
def django_eval(code):
pre = 'import django, os; os.environ.setdefault("DJANGO_SETTINGS_MODULE","config.settings"); django.setup()\n'
p = subprocess.run([PYTHON, '-c', pre + code], capture_output=True, text=True, cwd=CWD)
if p.returncode != 0:
print('[django_eval ERROR]', p.stderr[-1500:])
return p.stdout.strip()
def jb(r):
return r.json() if r.headers.get('content-type', '').startswith('application/json') else None
SEED = r'''
from apps.user.models import User, Institution, Department, TeacherStudentRelation
from apps.case.models import CaseBase
from rest_framework_simplejwt.tokens import RefreshToken
from django.db import connection
from django.utils import timezone
import json
PH = ["13700008001","13700008002","13700008003","13700008004","13700008005","13700008006"]
User.all_objects.filter(phone__in=PH).delete()
CaseBase.all_objects.filter(title__startswith="概览示例-").hard_delete()
with connection.cursor() as c:
c.execute("DELETE FROM training_record WHERE external_user_id='SWGSTAT'")
inst,_ = Institution.objects.get_or_create(code="SWG_STAT", defaults={"name":"概览示例院","type":"hospital","level":"三甲"})
dept,_ = Department.objects.get_or_create(name="心内科", defaults={"category":"临床"})
mk = lambda ph,nm,rt: User.objects.create_user(username=ph,password=None,phone=ph,real_name=nm,role_type=rt,institution=inst,status=1)
su=mk(PH[0],"示例超管","super_admin"); hu=mk(PH[1],"示例院管","hospital_admin")
cu=mk(PH[2],"示例内容","content_admin"); du=mk(PH[3],"示例带教","doctor")
s1=mk(PH[4],"学生甲","student"); s2=mk(PH[5],"学生乙","student")
s1.department=dept; s1.save(update_fields=["department"]); s2.department=dept; s2.save(update_fields=["department"])
TeacherStudentRelation.objects.create(teacher=du,student=s1,status=1)
TeacherStudentRelation.objects.create(teacher=du,student=s2,status=1)
c1=CaseBase.objects.create(title="概览示例-急性心梗",case_type="traditional",institution=inst,department=dept,publish_status=2,difficulty="medium",created_by=cu)
c2=CaseBase.objects.create(title="概览示例-稳定型心绞痛",case_type="traditional",institution=inst,department=dept,publish_status=1,difficulty="easy",created_by=cu)
DIMS=[{"dimension":"信息获取","score":18,"max_score":20},{"dimension":"分析推理","score":17,"max_score":20},{"dimension":"处置决策","score":8,"max_score":10},{"dimension":"沟通人文","score":7,"max_score":10},{"dimension":"临床整合","score":8,"max_score":10}]
now=timezone.now()
rows=[(s1.id,c1.id,88),(s2.id,c1.id,76),(s1.id,c2.id,92)]
with connection.cursor() as c:
for uid,cid,sc in rows:
c.execute("INSERT INTO training_record (user_id,case_id,training_mode,case_type,status,total_score,duration_seconds,end_time,start_time,score_type,evaluation_level,feedback,thinking_chain,diagnosis_path,wrong_points,missed_questions,recommendation_result,osce_station_score,emotion_analysis,ai_feedback_structured,external_user_id,created_at,updated_at) VALUES (%s,%s,'practice','traditional','completed',%s,1800,%s,%s,'percentage','good','','','','[]','[]','{}','{}','{}',%s,'SWGSTAT',%s,%s)",[uid,cid,sc,now,now,json.dumps({"dimension_scores":DIMS},ensure_ascii=False),now,now])
print("|".join(str(RefreshToken.for_user(x).access_token) for x in [su,hu,cu,du]))
'''
CLEANUP = r'''
from apps.user.models import User
from apps.case.models import CaseBase
from django.db import connection
PH = ["13700008001","13700008002","13700008003","13700008004","13700008005","13700008006"]
with connection.cursor() as c:
c.execute("DELETE FROM training_record WHERE external_user_id='SWGSTAT'")
CaseBase.all_objects.filter(title__startswith="概览示例-").hard_delete()
User.all_objects.filter(phone__in=PH).delete()
print("cleaned")
'''
print('\n[准备] 播种示例机构数据(机构/4 管理员/2 学生/师生关系/2 病例/3 训练记录)...')
out = django_eval(SEED)
su, hu, cu, du = out.split('|')
SU = {'Authorization': f'Bearer {su}'}
HU = {'Authorization': f'Bearer {hu}'}
CU = {'Authorization': f'Bearer {cu}'}
DU = {'Authorization': f'Bearer {du}'}
print('[准备] 完成\n')
print('=' * 100)
print(' CMS 各角色概览大屏 Swagger Try-it-out')
print('=' * 100)
# 权限
log('anon-401', 'GET', OVERVIEW, 401, requests.get(f'{BASE}{OVERVIEW}').status_code)
log('SUP-role403', 'GET', OVERVIEW, 403, requests.get(f'{BASE}{OVERVIEW}', headers=HU).status_code)
log('HOS-role403', 'GET', HOSPITAL, 403, requests.get(f'{BASE}{HOSPITAL}', headers=SU).status_code)
log('CNT-role403', 'GET', CONTENT, 403, requests.get(f'{BASE}{CONTENT}', headers=DU).status_code)
log('TEA-role403', 'GET', TEACHING, 403, requests.get(f'{BASE}{TEACHING}', headers=CU).status_code)
def call(api_id, url, headers):
r = requests.get(f'{BASE}{url}', headers=headers)
b = jb(r)
log(api_id, 'GET', url, 200, r.status_code)
if r.status_code == 200:
EXAMPLES[api_id] = {'request': {'method': 'GET', 'url': url,
'headers': {'Authorization': 'Bearer <access>'}},
'response': b}
return b
call('CMS-STATS-SUP-1', OVERVIEW, SU)
call('CMS-STATS-HOS-1', HOSPITAL, HU)
call('CMS-STATS-CNT-1', CONTENT, CU)
call('CMS-STATS-TEA-1', TEACHING, DU)
# 落盘完整示例
exf = Path(CWD) / 'logs' / 'swagger-cms-stats-examples.json'
exf.parent.mkdir(exist_ok=True)
exf.write_text(json.dumps(EXAMPLES, ensure_ascii=False, indent=2), encoding='utf-8')
print(f'\n[示例] 完整出参已写入 {exf}')
django_eval(CLEANUP)
print('=' * 100)
total = len(results); passed = sum(1 for _, s in results if s == PASS); failed = total - passed
print(f' 总计: {total} | 通过: {passed} | 失败: {failed}')
if failed:
print(' 失败:', [a for a, s in results if s == FAIL]); sys.exit(1)
print(' ALL PASSED — CMS 各角色概览大屏接口验证通过!')
sys.exit(0)
+180
View File
@@ -0,0 +1,180 @@
"""CMS 各角色概览大屏聚合接口测试(第八章 / CMS-STATS-*)。
`training_record` / `training_session` 是 managed=False 只读表,迁移无占位结构,
故在 setUpClass 按真实列建表(仅测试库),用 TransactionTestCase 允许 DDL。
权限类用例(401/403)在权限层即返回、不触表,放普通 TestCase。
"""
import json
from django.core.cache import cache
from django.db import connection
from django.test import TransactionTestCase
from django.utils import timezone
from rest_framework.test import APIClient
from apps.user.models import Department, TeacherStudentRelation
from apps.case.models import CaseBase
from .conftest import (
CacheTestCase, create_test_user, get_auth_client, ensure_institution,
)
OVERVIEW = '/api/cms/stats/overview/'
HOSPITAL = '/api/cms/stats/hospital/overview/'
CONTENT = '/api/cms/stats/content/overview/'
TEACHING = '/api/cms/stats/teaching/overview/'
CREATE_TR = """
CREATE TABLE training_record (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NULL, case_id BIGINT NULL, teacher_id BIGINT NULL,
training_mode VARCHAR(50) NULL, case_type VARCHAR(30) NULL,
start_time DATETIME NULL, end_time DATETIME NULL, duration_seconds INT NULL,
total_score DECIMAL(5,2) NULL, ai_score DECIMAL(5,2) NULL, teacher_score DECIMAL(5,2) NULL,
evaluation_level VARCHAR(20) NULL, status VARCHAR(30) NULL,
feedback TEXT NULL, thinking_chain TEXT NULL, diagnosis_path TEXT NULL,
wrong_points JSON NULL, missed_questions JSON NULL, recommendation_result JSON NULL,
ai_feedback_structured JSON NULL, osce_station_score JSON NULL,
interruption_count INT NULL, emotion_analysis JSON NULL,
prompt_version VARCHAR(50) NULL, rag_context_version VARCHAR(50) NULL,
external_user_id VARCHAR(128) NULL, session_id BIGINT NULL, evaluation_record_id BIGINT NULL,
score_type VARCHAR(20) NULL, pdf_file_path VARCHAR(512) NULL,
created_at DATETIME NULL, updated_at DATETIME NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
"""
CREATE_TS = """
CREATE TABLE training_session (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
case_id BIGINT NULL, case_type VARCHAR(30) NULL, training_mode VARCHAR(50) NULL,
status VARCHAR(30) NULL, external_user_id VARCHAR(128) NULL,
started_at DATETIME NULL, completed_at DATETIME NULL,
created_at DATETIME NULL, updated_at DATETIME NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
"""
DIMS = [{'dimension': '信息获取', 'score': 8, 'max_score': 10},
{'dimension': '分析推理', 'score': 9, 'max_score': 10},
{'dimension': '处置决策', 'score': 8, 'max_score': 10},
{'dimension': '沟通人文', 'score': 7, 'max_score': 10},
{'dimension': '临床整合', 'score': 8, 'max_score': 10}]
class StatsOverviewTest(TransactionTestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
with connection.cursor() as c:
c.execute('SET FOREIGN_KEY_CHECKS=0')
c.execute('DROP TABLE IF EXISTS training_record')
c.execute('DROP TABLE IF EXISTS training_session')
c.execute(CREATE_TR)
c.execute(CREATE_TS)
c.execute('SET FOREIGN_KEY_CHECKS=1')
def _record(self, user_id, case_id, total_score, status='completed'):
now = timezone.now()
with connection.cursor() as c:
c.execute(
"INSERT INTO training_record (user_id, case_id, training_mode, case_type, status, "
"total_score, duration_seconds, end_time, start_time, score_type, evaluation_level, "
"ai_feedback_structured, created_at, updated_at) "
"VALUES (%s,%s,'practice','traditional',%s,%s,1800,%s,%s,'percentage','good',%s,%s,%s)",
[user_id, case_id, status, total_score, now, now,
json.dumps({'dimension_scores': DIMS}, ensure_ascii=False), now, now],
)
def _session(self, case_id, completed=True):
now = timezone.now()
with connection.cursor() as c:
c.execute(
"INSERT INTO training_session (case_id, case_type, status, created_at, updated_at, "
"started_at, completed_at) VALUES (%s,'traditional',%s,%s,%s,%s,%s)",
[case_id, 'completed' if completed else 'inquiry', now, now, now,
now if completed else None],
)
def setUp(self):
cache.clear()
with connection.cursor() as c:
c.execute('DELETE FROM training_record')
c.execute('DELETE FROM training_session')
self.instA = ensure_institution(name='A院', code='STAT-A')
self.instB = ensure_institution(name='B院', code='STAT-B')
self.dept = Department.objects.create(name='心内科', category='临床')
self.caseA1 = CaseBase.objects.create(title='病例甲', case_type='traditional',
institution=self.instA, department=self.dept, publish_status=1)
self.caseA2 = CaseBase.objects.create(title='病例乙', case_type='traditional',
institution=self.instA, department=self.dept, publish_status=0)
self.superu = create_test_user(phone='13900000001', role_type='super_admin', institution=self.instA)
self.hosp = create_test_user(phone='13900000002', role_type='hospital_admin', institution=self.instA)
self.content = create_test_user(phone='13900000003', role_type='content_admin', institution=self.instA)
self.doctor = create_test_user(phone='13900000004', role_type='doctor', institution=self.instA)
self.s1 = create_test_user(phone='13900000005', role_type='student', institution=self.instA)
self.s2 = create_test_user(phone='13900000006', role_type='student', institution=self.instA)
TeacherStudentRelation.objects.create(teacher=self.doctor, student=self.s1, status=1)
TeacherStudentRelation.objects.create(teacher=self.doctor, student=self.s2, status=1)
# 4 个会话(3 完成)、2 条训练记录(s1=90、s2=80,均在 A 院病例甲)
self._session(self.caseA1.id, completed=True)
self._session(self.caseA1.id, completed=True)
self._session(self.caseA1.id, completed=True)
self._session(self.caseA1.id, completed=False)
self._record(self.s1.id, self.caseA1.id, 90)
self._record(self.s2.id, self.caseA1.id, 80)
# ── 权限 ─────────────────────────────────────────────────────────────
def test_permissions(self):
anon = APIClient()
for url in (OVERVIEW, HOSPITAL, CONTENT, TEACHING):
self.assertEqual(anon.get(url).status_code, 401, url)
# 角色错配 → 403
self.assertEqual(get_auth_client(self.hosp).get(OVERVIEW).status_code, 403) # 非超管
self.assertEqual(get_auth_client(self.superu).get(HOSPITAL).status_code, 403) # 非医院管理员
self.assertEqual(get_auth_client(self.doctor).get(CONTENT).status_code, 403) # 非内容管理员
self.assertEqual(get_auth_client(self.content).get(TEACHING).status_code, 403) # 非带教医生
# ── 8.1 平台总览 ─────────────────────────────────────────────────────
def test_platform_overview(self):
d = get_auth_client(self.superu).get(OVERVIEW).json()
self.assertEqual(d['kpi']['institution_count'], 2)
self.assertEqual(d['kpi']['user_total'], 6) # 6 个 status=1 用户
self.assertEqual(d['core']['train_total'], 4) # 4 个会话
self.assertEqual(d['core']['complete_rate'], 75.0) # 3/4
self.assertEqual(d['core']['avg_score'], 85.0) # avg(90,80)
self.assertEqual(d['case_asset']['total'], 2)
self.assertEqual(set(x['dimension'] for x in d['competency']['radar'])
if 'competency' in d else
{'信息获取', '分析推理', '处置决策', '沟通人文', '临床整合'},
{'信息获取', '分析推理', '处置决策', '沟通人文', '临床整合'})
# ── 8.2 医院驾驶舱 ───────────────────────────────────────────────────
def test_hospital_overview(self):
d = get_auth_client(self.hosp).get(HOSPITAL).json()
self.assertEqual(d['profile']['name'], 'A院')
self.assertEqual(d['summary']['student_count'], 2)
self.assertEqual(d['summary']['doctor_count'], 1)
self.assertEqual(d['summary']['train_total'], 2) # s1+s2 记录
self.assertEqual(d['summary']['avg_score'], 85.0)
self.assertEqual(d['case_asset']['total'], 2)
self.assertEqual(set(x['dimension'] for x in d['competency']['radar']),
{'信息获取', '分析推理', '处置决策', '沟通人文', '临床整合'})
# ── 8.3 内容概览 ─────────────────────────────────────────────────────
def test_content_overview(self):
d = get_auth_client(self.content).get(CONTENT).json()
self.assertEqual(d['summary']['case_total'], 2)
self.assertEqual(d['summary']['pending_publish'], 1) # caseA1 publish_status=1
self.assertEqual(d['summary']['train_total'], 2)
# ── 8.4 教学概览 ─────────────────────────────────────────────────────
def test_teaching_overview(self):
d = get_auth_client(self.doctor).get(TEACHING).json()
self.assertEqual(d['overview']['student_count'], 2)
self.assertEqual(len(d['students']), 2)
byid = {s['id']: s for s in d['students']}
self.assertEqual(byid[self.s1.id]['avg_score'], 90.0)
self.assertEqual(byid[self.s2.id]['avg_score'], 80.0)
self.assertEqual(byid[self.s1.id]['most_trained_type'], 'traditional')
# 任务相关无数据源 → None
self.assertIsNone(byid[self.s1.id]['pending_tasks'])
self.assertIsNone(d['overview']['task_summary'])
+235
View File
@@ -0,0 +1,235 @@
"""CMS 训练记录 + 带教能力相关接口测试。
- CMS-TRN-1 超管训练记录列表 GET /api/cms/training-records/
- CMS-TEA-3 教学工具-训练记录 GET /api/cms/students/training-records/
- CMS-TEA-4 学生能力画像 GET /api/cms/students/{id}/competency/
- CMS-TEA-5 学生排行榜 GET /api/cms/students/ranking/
training_record 是 managed=False 只读表,迁移占位结构缺新列,故在 setUpClass 里按真实列
重建该表(仅测试库),用 TransactionTestCase 允许 DDL(与 test_mobile_training_stats 一致)。
"""
import json
from django.core.cache import cache
from django.db import connection
from django.test import TransactionTestCase
from django.utils import timezone
from rest_framework.test import APIClient
from apps.user.models import Department, User
from apps.case.models import CaseBase
from .conftest import (
create_test_user, get_auth_client, ensure_institution,
create_teacher_student_relation,
)
CREATE_TR = """
CREATE TABLE training_record (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NULL, case_id BIGINT NULL, teacher_id BIGINT NULL,
training_mode VARCHAR(50) NULL, case_type VARCHAR(30) NULL,
start_time DATETIME NULL, end_time DATETIME NULL, duration_seconds INT NULL,
total_score DECIMAL(5,2) NULL, ai_score DECIMAL(5,2) NULL, teacher_score DECIMAL(5,2) NULL,
evaluation_level VARCHAR(20) NULL, status VARCHAR(30) NULL,
feedback TEXT NULL, thinking_chain TEXT NULL, diagnosis_path TEXT NULL,
wrong_points JSON NULL, missed_questions JSON NULL, recommendation_result JSON NULL,
ai_feedback_structured JSON NULL, osce_station_score JSON NULL,
interruption_count INT NULL, emotion_analysis JSON NULL,
prompt_version VARCHAR(50) NULL, rag_context_version VARCHAR(50) NULL,
external_user_id VARCHAR(128) NULL, session_id BIGINT NULL, evaluation_record_id BIGINT NULL,
score_type VARCHAR(20) NULL, pdf_file_path VARCHAR(512) NULL,
created_at DATETIME NULL, updated_at DATETIME NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
"""
SUP_URL = '/api/cms/training-records/'
TEA_TR_URL = '/api/cms/students/training-records/'
RANK_URL = '/api/cms/students/ranking/'
def _dim(name, score, mx):
return {'dimension': name, 'score': score, 'max_score': mx}
# 各学生维度评分(得分率见注释)
DIMS_A = [_dim('信息获取', 20, 25), _dim('分析推理', 18, 20), _dim('处置决策', 9, 10),
_dim('沟通人文', 7, 10), _dim('临床整合', 8, 10)] # 80/90/90/70/80
DIMS_B = [_dim('信息获取', 20, 25), _dim('分析推理', 16, 20), _dim('处置决策', 8, 10),
_dim('沟通人文', 8, 10), _dim('临床整合', 8, 10)] # 全 80
DIMS_C = [_dim('信息获取', 15, 25), _dim('分析推理', 12, 20), _dim('处置决策', 6, 10),
_dim('沟通人文', 6, 10), _dim('临床整合', 6, 10)] # 全 60
class CmsTrainingTest(TransactionTestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
with connection.cursor() as c:
c.execute('SET FOREIGN_KEY_CHECKS=0')
c.execute('DROP TABLE IF EXISTS training_record')
c.execute(CREATE_TR)
c.execute('SET FOREIGN_KEY_CHECKS=1')
def _insert(self, user_id, total_score, dims, status='completed',
score_type='percentage', case_type='diagnosis_treatment'):
now = timezone.now()
with connection.cursor() as c:
c.execute(
"INSERT INTO training_record (user_id, case_id, training_mode, case_type, status, "
"total_score, duration_seconds, end_time, start_time, score_type, evaluation_level, "
"ai_feedback_structured, created_at, updated_at) "
"VALUES (%s,%s,'practice',%s,%s,%s,%s,%s,%s,%s,'good',%s,%s,%s)",
[user_id, self.case.id, case_type, status, total_score, 1800, now, now, score_type,
json.dumps({'dimension_scores': dims}, ensure_ascii=False), now, now],
)
return c.lastrowid
def setUp(self):
cache.clear()
with connection.cursor() as c:
c.execute('DELETE FROM training_record')
self.inst = ensure_institution(name='测试医院', code='TR-H1')
self.dept = Department.objects.create(name='心内科', category='临床')
self.case = CaseBase.objects.create(title='急性心梗', case_type='traditional',
department=self.dept, institution=self.inst)
self.sup = create_test_user(phone='13970000001', role_type='super_admin', institution=self.inst)
self.doc = create_test_user(phone='13970000002', real_name='张医生',
role_type='doctor', institution=self.inst)
self.other_doc = create_test_user(phone='13970000003', role_type='doctor', institution=self.inst)
self.stu1 = create_test_user(phone='13970000011', real_name='学生甲',
role_type='student', institution=self.inst)
self.stu1.department = self.dept
self.stu1.save(update_fields=['department'])
self.stu2 = create_test_user(phone='13970000012', real_name='学生乙',
role_type='student', institution=self.inst)
self.stu_other = create_test_user(phone='13970000013', real_name='别家学生',
role_type='student', institution=self.inst)
create_teacher_student_relation(self.doc, self.stu1, status=1)
create_teacher_student_relation(self.doc, self.stu2, status=1)
create_teacher_student_relation(self.other_doc, self.stu_other, status=1)
# 训练记录:stu1 两条完成 + 一条进行中;stu2 一条完成;stu_other 一条完成
self._insert(self.stu1.id, 90, DIMS_A)
self._insert(self.stu1.id, 80, DIMS_B)
self._insert(self.stu1.id, None, [], status='in_progress')
self._insert(self.stu2.id, 60, DIMS_C)
self._insert(self.stu_other.id, 70, DIMS_B)
self.sup_client = get_auth_client(self.sup)
self.doc_client = get_auth_client(self.doc)
# ── CMS-TRN-1 超管训练记录 ──────────────────────────────────────────────────
def test_trn_unauthenticated_401(self):
self.assertEqual(APIClient().get(SUP_URL).status_code, 401)
def test_trn_non_super_403(self):
resp = self.doc_client.get(SUP_URL)
self.assertEqual(resp.status_code, 403, resp.content)
self.assertEqual(resp.json()['code'], 'CMS_PERMISSION_DENIED')
def test_trn_super_list_all(self):
resp = self.sup_client.get(SUP_URL)
self.assertEqual(resp.status_code, 200, resp.content)
data = resp.json()
self.assertEqual(data['count'], 5) # 全平台所有记录
self.assertEqual(data['summary']['total'], 5)
self.assertEqual(data['summary']['completed'], 4)
self.assertEqual(data['summary']['avg_score'], 75.0) # avg(90,80,60,70)
item = data['results'][0]
for k in ('record_id', 'user_id', 'user_name', 'case_title',
'score', 'score_normalized', 'status', 'trained_at'):
self.assertIn(k, item)
def test_trn_filter_by_user_and_status(self):
resp = self.sup_client.get(SUP_URL, {'user_id': self.stu1.id, 'status': 'completed'})
data = resp.json()
self.assertEqual(data['count'], 2)
self.assertTrue(all(r['user_id'] == self.stu1.id for r in data['results']))
def test_trn_filter_score_range(self):
resp = self.sup_client.get(SUP_URL, {'min_score': 75})
data = resp.json()
# 归一分≥75 的完成记录:90、80(stu1)、未含 60/70
self.assertEqual(data['count'], 2)
def test_trn_search_by_student_name(self):
resp = self.sup_client.get(SUP_URL, {'search': '学生甲'})
data = resp.json()
self.assertEqual(data['count'], 3) # stu1 全部记录(2 完成 + 1 进行中)
def test_trn_five_point_normalized(self):
self._insert(self.stu2.id, 4, DIMS_B, score_type='five_point') # 4×20=80
resp = self.sup_client.get(SUP_URL, {'user_id': self.stu2.id, 'min_score': 80})
data = resp.json()
self.assertEqual(data['count'], 1)
self.assertEqual(data['results'][0]['score'], 4.0)
self.assertEqual(data['results'][0]['score_normalized'], 80.0)
# ── CMS-TEA-3 教学工具-训练记录(名下学生)──────────────────────────────────
def test_tea_tr_scope_own_students(self):
resp = self.doc_client.get(TEA_TR_URL)
self.assertEqual(resp.status_code, 200, resp.content)
data = resp.json()
self.assertEqual(data['count'], 4) # stu1(3) + stu2(1),不含 stu_other
uids = {r['user_id'] for r in data['results']}
self.assertNotIn(self.stu_other.id, uids)
self.assertEqual(data['summary']['completed'], 3)
def test_tea_tr_non_teacher_403(self):
resp = self.sup_client.get(TEA_TR_URL)
self.assertEqual(resp.status_code, 403, resp.content)
def test_tea_tr_filter_by_student(self):
resp = self.doc_client.get(TEA_TR_URL, {'user_id': self.stu2.id})
self.assertEqual(resp.json()['count'], 1)
# ── CMS-TEA-4 能力画像 ──────────────────────────────────────────────────────
def test_competency_own_student(self):
resp = self.doc_client.get(f'/api/cms/students/{self.stu1.id}/competency/')
self.assertEqual(resp.status_code, 200, resp.content)
d = resp.json()
self.assertEqual(d['student_id'], self.stu1.id)
self.assertEqual(d['completed_cases'], 2)
self.assertEqual(d['avg_score'], 85.0) # avg(90,80)
self.assertEqual(d['diagnosis_accuracy'], 85) # 分析推理 avg(90,80)
radar = {x['dimension']: x['score'] for x in d['radar']}
self.assertEqual(set(radar), {'信息获取', '分析推理', '处置决策', '沟通人文', '临床整合'})
self.assertEqual(radar['信息获取'], 80)
self.assertEqual(radar['沟通人文'], 75)
self.assertEqual(d['weak_dimensions'], ['沟通人文'])
def test_competency_other_student_404(self):
resp = self.doc_client.get(f'/api/cms/students/{self.stu_other.id}/competency/')
self.assertEqual(resp.status_code, 404, resp.content)
# ── CMS-TEA-5 排行榜 ────────────────────────────────────────────────────────
def test_ranking_by_score_default(self):
resp = self.doc_client.get(RANK_URL)
self.assertEqual(resp.status_code, 200, resp.content)
d = resp.json()
self.assertEqual(d['dimension'], 'score')
self.assertEqual(d['count'], 2)
self.assertEqual(d['ranking'][0]['student_id'], self.stu1.id) # 85 > 60
self.assertEqual(d['ranking'][0]['rank'], 1)
self.assertEqual(d['ranking'][1]['student_id'], self.stu2.id)
def test_ranking_by_train_count(self):
resp = self.doc_client.get(RANK_URL, {'dimension': 'train_count'})
d = resp.json()
self.assertEqual(d['dimension'], 'train_count')
self.assertEqual(d['ranking'][0]['student_id'], self.stu1.id) # 3 > 1
self.assertEqual(d['ranking'][0]['value'], 3)
def test_ranking_by_competency_dimension(self):
resp = self.doc_client.get(RANK_URL, {'dimension': '信息获取'})
d = resp.json()
self.assertEqual(d['dimension'], '信息获取')
self.assertEqual(d['ranking'][0]['student_id'], self.stu1.id) # 80 > 60
def test_ranking_invalid_dimension_falls_back_to_score(self):
resp = self.doc_client.get(RANK_URL, {'dimension': '不存在'})
self.assertEqual(resp.json()['dimension'], 'score')
def test_ranking_non_teacher_403(self):
self.assertEqual(self.sup_client.get(RANK_URL).status_code, 403)
+179
View File
@@ -0,0 +1,179 @@
"""移动端病例列表 5 接口测试。
- 5.1 推荐病例 GET /api/case/mobile/recommended/
- 5.2 科室专项 GET /api/case/mobile/specialty/
- 5.3 薄弱环节 GET /api/case/mobile/weak/
- 5.4 教学互动 GET /api/case/mobile/teaching/
- 5.5 教师任务 GET /api/case/mobile/teacher-task/
均只取「已发布」病例(publish_status=2 & status=1 & is_deleted=0)。
training_record 是 managed=False 只读表,迁移占位结构缺新列,故在 setUpClass 里按真实列
重建该表(仅测试库),用 TransactionTestCase 允许 DDL(与 test_cms_training 一致)。
"""
import json
from django.core.cache import cache
from django.db import connection
from django.test import TransactionTestCase
from django.utils import timezone
from rest_framework.test import APIClient
from apps.user.models import Department
from apps.case.models import CaseBase
from .conftest import create_test_user, get_auth_client, ensure_institution
CREATE_TR = """
CREATE TABLE training_record (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NULL, case_id BIGINT NULL, teacher_id BIGINT NULL,
training_mode VARCHAR(50) NULL, case_type VARCHAR(30) NULL,
start_time DATETIME NULL, end_time DATETIME NULL, duration_seconds INT NULL,
total_score DECIMAL(5,2) NULL, ai_score DECIMAL(5,2) NULL, teacher_score DECIMAL(5,2) NULL,
evaluation_level VARCHAR(20) NULL, status VARCHAR(30) NULL,
feedback TEXT NULL, thinking_chain TEXT NULL, diagnosis_path TEXT NULL,
wrong_points JSON NULL, missed_questions JSON NULL, recommendation_result JSON NULL,
ai_feedback_structured JSON NULL, osce_station_score JSON NULL,
interruption_count INT NULL, emotion_analysis JSON NULL,
prompt_version VARCHAR(50) NULL, rag_context_version VARCHAR(50) NULL,
external_user_id VARCHAR(128) NULL, session_id BIGINT NULL, evaluation_record_id BIGINT NULL,
score_type VARCHAR(20) NULL, pdf_file_path VARCHAR(512) NULL,
created_at DATETIME NULL, updated_at DATETIME NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
"""
REC_URL = '/api/case/mobile/recommended/'
SPEC_URL = '/api/case/mobile/specialty/'
WEAK_URL = '/api/case/mobile/weak/'
TEACH_URL = '/api/case/mobile/teaching/'
TASK_URL = '/api/case/mobile/teacher-task/'
class MobileCaseListTest(TransactionTestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
with connection.cursor() as c:
c.execute('SET FOREIGN_KEY_CHECKS=0')
c.execute('DROP TABLE IF EXISTS training_record')
c.execute(CREATE_TR)
c.execute('SET FOREIGN_KEY_CHECKS=1')
def _insert(self, user_id, case_id, total_score, status='completed', score_type='percentage'):
now = timezone.now()
with connection.cursor() as c:
c.execute(
"INSERT INTO training_record (user_id, case_id, training_mode, status, "
"total_score, end_time, start_time, score_type, ai_feedback_structured, "
"created_at, updated_at) VALUES (%s,%s,'practice',%s,%s,%s,%s,%s,%s,%s,%s)",
[user_id, case_id, status, total_score, now, now, score_type,
json.dumps({}), now, now],
)
return c.lastrowid
def setUp(self):
cache.clear()
with connection.cursor() as c:
c.execute('DELETE FROM training_record')
self.inst = ensure_institution(name='测试医院', code='MCL-H1')
self.dept1 = Department.objects.create(name='心内科', category='临床')
self.dept2 = Department.objects.create(name='呼吸科', category='临床')
# 已发布病例
self.pub_trad1 = CaseBase.objects.create(
title='急性心梗', case_type='traditional', department=self.dept1,
institution=self.inst, chief_complaint='胸痛', tags='心内科,胸痛',
publish_status=2, status=1)
self.pub_teach = CaseBase.objects.create(
title='医患沟通教学', case_type='teaching', department=self.dept1,
institution=self.inst, competency_tags=['沟通人文', '医患沟通'],
publish_status=2, status=1)
self.pub_trad2 = CaseBase.objects.create(
title='肺炎诊治', case_type='traditional', department=self.dept2,
institution=self.inst, tags='呼吸科', publish_status=2, status=1)
# 不应出现:草稿 / 禁用 / 已下架(软删)
CaseBase.objects.create(title='草稿病例', case_type='traditional',
department=self.dept1, institution=self.inst, publish_status=0, status=1)
CaseBase.objects.create(title='禁用病例', case_type='traditional',
department=self.dept1, institution=self.inst, publish_status=2, status=0)
deleted = CaseBase.objects.create(title='已下架病例', case_type='teaching',
department=self.dept1, institution=self.inst, publish_status=2, status=1)
deleted.delete() # 软删除
self.stu = create_test_user(phone='13980000001', real_name='学生甲',
role_type='student', institution=self.inst)
self.stu.department = self.dept1
self.stu.weak_dimensions = ['沟通人文']
self.stu.save(update_fields=['department', 'weak_dimensions'])
# stu 已训练:pub_trad1 低分(60→薄弱)pub_trad2 高分(90)
self._insert(self.stu.id, self.pub_trad1.id, 60)
self._insert(self.stu.id, self.pub_trad2.id, 90)
self.client = get_auth_client(self.stu)
def _ids(self, resp):
return [r['id'] for r in resp.json()['results']]
# ── 鉴权 ────────────────────────────────────────────────────────────────
def test_unauthenticated_401(self):
for url in (REC_URL, SPEC_URL, WEAK_URL, TEACH_URL, TASK_URL):
self.assertEqual(APIClient().get(url).status_code, 401, url)
# ── 5.1 推荐 ───────────────────────────────────────────────────────────
def test_recommended_only_published(self):
resp = self.client.get(REC_URL)
self.assertEqual(resp.status_code, 200, resp.content)
ids = set(self._ids(resp))
self.assertEqual(resp.json()['count'], 3)
self.assertEqual(ids, {self.pub_trad1.id, self.pub_teach.id, self.pub_trad2.id})
def test_recommended_untrained_first(self):
# pub_teach 未训练且命中薄弱标签+同科室 → 应排在已训练病例之前
resp = self.client.get(REC_URL)
self.assertEqual(self._ids(resp)[0], self.pub_teach.id, resp.content)
# ── 5.2 科室专项 ──────────────────────────────────────────────────────────
def test_specialty_default_user_dept(self):
resp = self.client.get(SPEC_URL)
self.assertEqual(set(self._ids(resp)), {self.pub_trad1.id, self.pub_teach.id})
def test_specialty_explicit_department(self):
resp = self.client.get(SPEC_URL, {'department': self.dept2.id})
self.assertEqual(self._ids(resp), [self.pub_trad2.id])
# ── 5.3 薄弱环节 ──────────────────────────────────────────────────────────
def test_weak_low_score_cases(self):
resp = self.client.get(WEAK_URL)
self.assertEqual(resp.status_code, 200, resp.content)
results = resp.json()['results']
self.assertEqual([r['id'] for r in results], [self.pub_trad1.id])
self.assertEqual(results[0]['my_best_score'], 60.0)
self.assertEqual(results[0]['my_train_count'], 1)
def test_weak_cold_start_fallback(self):
# 新用户无训练记录,但 weak_dimensions 命中 pub_teach 能力标签 → 回退命中
fresh = create_test_user(phone='13980000009', role_type='student', institution=self.inst)
fresh.weak_dimensions = ['沟通人文']
fresh.save(update_fields=['weak_dimensions'])
resp = get_auth_client(fresh).get(WEAK_URL)
self.assertEqual(self._ids(resp), [self.pub_teach.id], resp.content)
# ── 5.4 教学互动 ──────────────────────────────────────────────────────────
def test_teaching_only_teaching_type(self):
resp = self.client.get(TEACH_URL)
self.assertEqual(self._ids(resp), [self.pub_teach.id])
# ── 5.5 教师任务(暂同教学互动)────────────────────────────────────────────
def test_teacher_task_same_as_teaching(self):
resp = self.client.get(TASK_URL)
self.assertEqual(self._ids(resp), [self.pub_teach.id])
# ── 通用过滤 ──────────────────────────────────────────────────────────────
def test_search_filter(self):
resp = self.client.get(REC_URL, {'search': '心梗'})
self.assertEqual(self._ids(resp), [self.pub_trad1.id])
def test_case_type_filter(self):
resp = self.client.get(REC_URL, {'case_type': 'teaching'})
self.assertEqual(self._ids(resp), [self.pub_teach.id])