Files
medical_training/test/test_cms_training.py
T

236 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)