feat: cms overview and mobile case query
This commit is contained in:
@@ -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)
|
||||
@@ -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'])
|
||||
@@ -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)
|
||||
@@ -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])
|
||||
Reference in New Issue
Block a user