236 lines
12 KiB
Python
236 lines
12 KiB
Python
|
|
"""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)
|