feat: update init users

This commit is contained in:
2026-06-13 13:21:53 +08:00
parent 05ce7e987e
commit 46761906fe
11 changed files with 502 additions and 430 deletions
+233 -233
View File
@@ -1,233 +1,233 @@
"""移动端病例列表(首页 / 病例页 5 个入口)。
均读 `case_base`,只取**已发布**病例(publish_status=2 & status=1 & is_deleted=0):
- 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/CMS 暂无指派,先同教学互动)
个性化/薄弱口径读只读表 training_recorduser_id=当前用户、status='completed'),
分数按 score_type 归一百分制后比较,与《个人中心》一致。
"""
from django.db.models import Q
from rest_framework import serializers
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.pagination import PageNumberPagination
from rest_framework.response import Response
from drf_spectacular.utils import extend_schema
from apps.training.models import TrainingRecord
from .models import CaseBase
# 薄弱阈值:归一百分制下,最好成绩低于该分数视为「薄弱病例」
WEAK_SCORE_THRESHOLD = 70
class MobilePagination(PageNumberPagination):
page_size = 20
page_size_query_param = 'page_size'
max_page_size = 100
class MobileCaseSerializer(serializers.ModelSerializer):
"""移动端病例列表元素(统一字段)。"""
case_type_display = serializers.CharField(source='get_case_type_display', read_only=True)
department_name = serializers.CharField(source='department.name', read_only=True)
# 仅「薄弱环节」会注入;其余接口为 None
my_best_score = serializers.SerializerMethodField()
my_train_count = serializers.SerializerMethodField()
class Meta:
model = CaseBase
fields = [
'id', 'title', 'case_type', 'case_type_display', 'difficulty',
'difficulty_score', 'department', 'department_name',
'chief_complaint', 'description', 'patient_age', 'patient_gender',
'tags', 'competency_tags', 'estimated_minutes', 'osce_enabled',
'created_at', 'my_best_score', 'my_train_count',
]
def _stat(self, obj):
return (self.context.get('case_stats') or {}).get(obj.id)
def get_my_best_score(self, obj):
s = self._stat(obj)
return s['best_score'] if s else None
def get_my_train_count(self, obj):
s = self._stat(obj)
return s['train_count'] if s else None
# ── 公共工具 ──────────────────────────────────────────────────────────────
def _published():
"""已发布病例基集(已过滤 is_deleted)。"""
return CaseBase.objects.filter(publish_status=2, status=1)
def _apply_common_filters(qs, request, *, allow_department=True):
"""通用查询参数:search / case_type / difficulty / department。"""
p = request.query_params
search = (p.get('search') or '').strip()
if search:
qs = qs.filter(
Q(title__icontains=search)
| Q(chief_complaint__icontains=search)
| Q(tags__icontains=search)
)
if p.get('case_type'):
qs = qs.filter(case_type=p['case_type'])
if p.get('difficulty'):
qs = qs.filter(difficulty=p['difficulty'])
if allow_department and p.get('department'):
qs = qs.filter(department_id=p['department'])
return qs
def _completed_records(user):
"""当前用户已完成训练记录(只读表,不 JOIN,仅取 case_id/分数)。"""
return TrainingRecord.objects.filter(user_id=user.id, status='completed')
def _norm_score(score, score_type):
if score is None:
return None
return float(score) * 20 if score_type == 'five_point' else float(score)
def _user_case_stats(user):
"""{case_id: {best_score, train_count}}(归一百分制)。"""
stats = {}
rows = _completed_records(user).values('case_id', 'total_score', 'score_type')
for r in rows:
cid = r['case_id']
if cid is None:
continue
s = _norm_score(r['total_score'], r['score_type'])
cur = stats.setdefault(cid, {'best_score': None, 'train_count': 0})
cur['train_count'] += 1
if s is not None and (cur['best_score'] is None or s > cur['best_score']):
cur['best_score'] = round(s, 1)
return stats
def _paginate(qs, request, *, case_stats=None):
paginator = MobilePagination()
page = paginator.paginate_queryset(qs, request)
ctx = {'request': request, 'case_stats': case_stats or {}}
data = MobileCaseSerializer(page, many=True, context=ctx).data
return paginator.get_paginated_response(data)
# ── 5.1 推荐病例(个性化)────────────────────────────────────────────────────
def _recommend_score(case, *, dept_id, trained_ids, weak_dims):
"""个性化推荐分:未训练 > 同科室 > 命中薄弱能力标签。"""
score = 0
if case.id not in trained_ids:
score += 2
if dept_id and case.department_id == dept_id:
score += 1
if weak_dims and case.competency_tags:
tags = ' '.join(str(t) for t in case.competency_tags)
if any(w and w in tags for w in weak_dims):
score += 1
return score
@extend_schema(summary='推荐病例(个性化)', tags=['病例列表'])
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def recommended(request):
user = request.user
qs = _apply_common_filters(_published(), request)
cases = list(qs.select_related('department'))
trained_ids = set(
_completed_records(user).values_list('case_id', flat=True)
)
weak_dims = [str(w) for w in (user.weak_dimensions or [])]
dept_id = user.department_id
cases.sort(
key=lambda c: (
_recommend_score(c, dept_id=dept_id, trained_ids=trained_ids, weak_dims=weak_dims),
c.created_at or c.id,
),
reverse=True,
)
return _paginate(cases, request)
# ── 5.2 科室专项 ────────────────────────────────────────────────────────────
@extend_schema(summary='科室专项病例', tags=['病例列表'])
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def specialty(request):
qs = _apply_common_filters(_published(), request, allow_department=False)
dept = request.query_params.get('department') or request.user.department_id
if dept:
qs = qs.filter(department_id=dept)
qs = qs.select_related('department').order_by('-created_at', '-id')
return _paginate(qs, request)
# ── 5.3 薄弱环节 ────────────────────────────────────────────────────────────
@extend_schema(summary='薄弱环节病例', tags=['病例列表'])
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def weak(request):
user = request.user
stats = _user_case_stats(user)
weak_ids = [cid for cid, s in stats.items()
if s['best_score'] is not None and s['best_score'] < WEAK_SCORE_THRESHOLD]
base = _apply_common_filters(_published(), request).select_related('department')
if weak_ids:
cases = list(base.filter(id__in=weak_ids))
# worst-first:最好成绩越低越靠前
cases.sort(key=lambda c: stats[c.id]['best_score'])
return _paginate(cases, request, case_stats=stats)
# 冷启动:无低分记录 → 回退到命中用户薄弱能力标签的已发布病例(competency_tags 为 JSON,按 Python 匹配)
weak_dims = [str(w) for w in (user.weak_dimensions or []) if w]
cases = []
if weak_dims:
for c in base.order_by('-created_at', '-id'):
tags = ' '.join(str(t) for t in (c.competency_tags or []))
if any(w in tags for w in weak_dims):
cases.append(c)
return _paginate(cases, request, case_stats=stats)
# ── 5.4 教学互动 ────────────────────────────────────────────────────────────
@extend_schema(summary='教学互动病例', tags=['病例列表'])
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def teaching(request):
qs = _apply_common_filters(
_published().filter(case_type='teaching'), request
).select_related('department').order_by('-created_at', '-id')
return _paginate(qs, request)
# ── 5.5 教师任务(暂同教学互动)──────────────────────────────────────────────
@extend_schema(
summary='教师任务病例', tags=['病例列表'],
description='CMS 暂无「指派病例」功能,先与教学互动一致(已发布 case_type=teaching)。',
)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def teacher_task(request):
qs = _apply_common_filters(
_published().filter(case_type='teaching'), request
).select_related('department').order_by('-created_at', '-id')
return _paginate(qs, request)
"""移动端病例列表(首页 / 病例页 5 个入口)。
均读 `case_base`,只取**已发布**病例(publish_status=2 & status=1 & is_deleted=0):
- 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/CMS 暂无指派,先同教学互动)
个性化/薄弱口径读只读表 training_recorduser_id=当前用户、status='completed'),
分数按 score_type 归一百分制后比较,与《个人中心》一致。
"""
from django.db.models import Q
from rest_framework import serializers
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.pagination import PageNumberPagination
from rest_framework.response import Response
from drf_spectacular.utils import extend_schema
from apps.training.models import TrainingRecord
from .models import CaseBase
# 薄弱阈值:归一百分制下,最好成绩低于该分数视为「薄弱病例」
WEAK_SCORE_THRESHOLD = 70
class MobilePagination(PageNumberPagination):
page_size = 20
page_size_query_param = 'page_size'
max_page_size = 100
class MobileCaseSerializer(serializers.ModelSerializer):
"""移动端病例列表元素(统一字段)。"""
case_type_display = serializers.CharField(source='get_case_type_display', read_only=True)
department_name = serializers.CharField(source='department.name', read_only=True)
# 仅「薄弱环节」会注入;其余接口为 None
my_best_score = serializers.SerializerMethodField()
my_train_count = serializers.SerializerMethodField()
class Meta:
model = CaseBase
fields = [
'id', 'title', 'case_type', 'case_type_display', 'difficulty',
'difficulty_score', 'department', 'department_name',
'chief_complaint', 'description', 'patient_age', 'patient_gender',
'tags', 'competency_tags', 'estimated_minutes', 'osce_enabled',
'created_at', 'my_best_score', 'my_train_count',
]
def _stat(self, obj):
return (self.context.get('case_stats') or {}).get(obj.id)
def get_my_best_score(self, obj):
s = self._stat(obj)
return s['best_score'] if s else None
def get_my_train_count(self, obj):
s = self._stat(obj)
return s['train_count'] if s else None
# ── 公共工具 ──────────────────────────────────────────────────────────────
def _published():
"""已发布病例基集(已过滤 is_deleted)。"""
return CaseBase.objects.filter(publish_status=2, status=1)
def _apply_common_filters(qs, request, *, allow_department=True):
"""通用查询参数:search / case_type / difficulty / department。"""
p = request.query_params
search = (p.get('search') or '').strip()
if search:
qs = qs.filter(
Q(title__icontains=search)
| Q(chief_complaint__icontains=search)
| Q(tags__icontains=search)
)
if p.get('case_type'):
qs = qs.filter(case_type=p['case_type'])
if p.get('difficulty'):
qs = qs.filter(difficulty=p['difficulty'])
if allow_department and p.get('department'):
qs = qs.filter(department_id=p['department'])
return qs
def _completed_records(user):
"""当前用户已完成训练记录(只读表,不 JOIN,仅取 case_id/分数)。"""
return TrainingRecord.objects.filter(user_id=user.id, status='completed')
def _norm_score(score, score_type):
if score is None:
return None
return float(score) * 20 if score_type == 'five_point' else float(score)
def _user_case_stats(user):
"""{case_id: {best_score, train_count}}(归一百分制)。"""
stats = {}
rows = _completed_records(user).values('case_id', 'total_score', 'score_type')
for r in rows:
cid = r['case_id']
if cid is None:
continue
s = _norm_score(r['total_score'], r['score_type'])
cur = stats.setdefault(cid, {'best_score': None, 'train_count': 0})
cur['train_count'] += 1
if s is not None and (cur['best_score'] is None or s > cur['best_score']):
cur['best_score'] = round(s, 1)
return stats
def _paginate(qs, request, *, case_stats=None):
paginator = MobilePagination()
page = paginator.paginate_queryset(qs, request)
ctx = {'request': request, 'case_stats': case_stats or {}}
data = MobileCaseSerializer(page, many=True, context=ctx).data
return paginator.get_paginated_response(data)
# ── 5.1 推荐病例(个性化)────────────────────────────────────────────────────
def _recommend_score(case, *, dept_id, trained_ids, weak_dims):
"""个性化推荐分:未训练 > 同科室 > 命中薄弱能力标签。"""
score = 0
if case.id not in trained_ids:
score += 2
if dept_id and case.department_id == dept_id:
score += 1
if weak_dims and case.competency_tags:
tags = ' '.join(str(t) for t in case.competency_tags)
if any(w and w in tags for w in weak_dims):
score += 1
return score
@extend_schema(summary='推荐病例(个性化)', tags=['病例列表'])
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def recommended(request):
user = request.user
qs = _apply_common_filters(_published(), request)
cases = list(qs.select_related('department'))
trained_ids = set(
_completed_records(user).values_list('case_id', flat=True)
)
weak_dims = [str(w) for w in (user.weak_dimensions or [])]
dept_id = user.department_id
cases.sort(
key=lambda c: (
_recommend_score(c, dept_id=dept_id, trained_ids=trained_ids, weak_dims=weak_dims),
c.created_at or c.id,
),
reverse=True,
)
return _paginate(cases, request)
# ── 5.2 科室专项 ────────────────────────────────────────────────────────────
@extend_schema(summary='科室专项病例', tags=['病例列表'])
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def specialty(request):
qs = _apply_common_filters(_published(), request, allow_department=False)
dept = request.query_params.get('department') or request.user.department_id
if dept:
qs = qs.filter(department_id=dept)
qs = qs.select_related('department').order_by('-created_at', '-id')
return _paginate(qs, request)
# ── 5.3 薄弱环节 ────────────────────────────────────────────────────────────
@extend_schema(summary='薄弱环节病例', tags=['病例列表'])
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def weak(request):
user = request.user
stats = _user_case_stats(user)
weak_ids = [cid for cid, s in stats.items()
if s['best_score'] is not None and s['best_score'] < WEAK_SCORE_THRESHOLD]
base = _apply_common_filters(_published(), request).select_related('department')
if weak_ids:
cases = list(base.filter(id__in=weak_ids))
# worst-first:最好成绩越低越靠前
cases.sort(key=lambda c: stats[c.id]['best_score'])
return _paginate(cases, request, case_stats=stats)
# 冷启动:无低分记录 → 回退到命中用户薄弱能力标签的已发布病例(competency_tags 为 JSON,按 Python 匹配)
weak_dims = [str(w) for w in (user.weak_dimensions or []) if w]
cases = []
if weak_dims:
for c in base.order_by('-created_at', '-id'):
tags = ' '.join(str(t) for t in (c.competency_tags or []))
if any(w in tags for w in weak_dims):
cases.append(c)
return _paginate(cases, request, case_stats=stats)
# ── 5.4 教学互动 ────────────────────────────────────────────────────────────
@extend_schema(summary='教学互动病例', tags=['病例列表'])
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def teaching(request):
qs = _apply_common_filters(
_published().filter(case_type='teaching'), request
).select_related('department').order_by('-created_at', '-id')
return _paginate(qs, request)
# ── 5.5 教师任务(暂同教学互动)──────────────────────────────────────────────
@extend_schema(
summary='教师任务病例', tags=['病例列表'],
description='CMS 暂无「指派病例」功能,先与教学互动一致(已发布 case_type=teaching)。',
)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def teacher_task(request):
qs = _apply_common_filters(
_published().filter(case_type='teaching'), request
).select_related('department').order_by('-created_at', '-id')
return _paginate(qs, request)
@@ -0,0 +1,34 @@
# Generated by Django 5.2.14 on 2026-06-13 05:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('training', '0003_alter_trainingrecord_options_and_more'),
]
operations = [
migrations.CreateModel(
name='TrainingSession',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('id', models.BigAutoField(primary_key=True, serialize=False)),
('case_id', models.BigIntegerField(blank=True, null=True, verbose_name='病例ID')),
('case_type', models.CharField(blank=True, max_length=30, verbose_name='病例类型')),
('training_mode', models.CharField(blank=True, max_length=50, verbose_name='训练模式')),
('status', models.CharField(blank=True, max_length=30, verbose_name='状态')),
('external_user_id', models.CharField(blank=True, max_length=128, verbose_name='宿主系统用户ID')),
('started_at', models.DateTimeField(blank=True, null=True, verbose_name='开始时间')),
('completed_at', models.DateTimeField(blank=True, null=True, verbose_name='完成时间')),
],
options={
'verbose_name': '训练会话',
'verbose_name_plural': '训练会话',
'db_table': 'training_session',
'managed': False,
},
),
]
+4 -1
View File
@@ -2,7 +2,10 @@ from rest_framework_simplejwt.tokens import RefreshToken
from config.exceptions import AppError
ALLOWED_ROLE_TYPES = ('student', 'doctor', 'teacher')
# 系统五类角色:super_admin / hospital_admin / content_admin / doctor(带教医生)/ student
ROLE_TYPES = ('super_admin', 'hospital_admin', 'content_admin', 'doctor', 'student')
# 移动端可自注册的角色(带教老师即 doctor,不单列 teacher
ALLOWED_ROLE_TYPES = ('student', 'doctor')
# CMS 端可登录的角色(U3 密码登录):超级管理员 / 医院管理员 / 内容管理员 / 医生(带教老师)
CMS_ROLE_TYPES = ('super_admin', 'hospital_admin', 'content_admin', 'doctor')
+26 -1
View File
@@ -2,6 +2,8 @@ import os
from django.core.management.base import BaseCommand
from django.contrib.auth import get_user_model
from apps.user.models import TeacherStudentRelation
User = get_user_model()
@@ -24,9 +26,12 @@ class Command(BaseCommand):
# 创建超级管理员
self._create_superadmin()
# 创建测试角色用户
# 创建测试角色用户(覆盖五类角色:医院管理员/内容管理员/带教医生/学生)
self._create_test_users()
# 师生关系(只在 doctor 与 student 之间)
self._create_relation()
self.stdout.write(self.style.SUCCESS('\n[完成] 用户初始化完成'))
def _create_superadmin(self):
@@ -55,6 +60,13 @@ class Command(BaseCommand):
def _create_test_users(self):
"""创建测试用户"""
test_users = [
{
'username': 'hospital_admin',
'password': 'hospital123',
'real_name': '医院管理员',
'role_type': 'hospital_admin',
'phone': '13800138003',
},
{
'username': 'doctor1',
'password': 'doctor123',
@@ -103,3 +115,16 @@ class Command(BaseCommand):
f'[已存在] 用户: {user_data["username"]} ({user_data["real_name"]})'
)
)
def _create_relation(self):
"""初始化一条师生关系(带教医生 doctor1 → 学生 student1)。"""
teacher = User.objects.filter(username='doctor1', role_type='doctor').first()
student = User.objects.filter(username='student1', role_type='student').first()
if not teacher or not student:
return
_, created = TeacherStudentRelation.objects.get_or_create(
teacher=teacher, student=student,
defaults={'relation_type': '指导', 'status': 1},
)
msg = '[创建] 师生关系: doctor1 → student1' if created else '[已存在] 师生关系: doctor1 → student1'
self.stdout.write((self.style.SUCCESS if created else self.style.WARNING)(msg))
+3 -3
View File
@@ -16,7 +16,7 @@ class IsUserListPermitted(BasePermission):
user = request.user
if _is_admin(user):
return True
if user.role_type == 'teacher':
if user.role_type == 'doctor': # 带教医生:可看名下学生
return True
raise AppError('USER_NO_LIST_PERMISSION', '您没有查看用户列表的权限', status_code=403)
@@ -32,8 +32,8 @@ class IsUserDetailPermitted(BasePermission):
# 本人:可查看自己
if user.id == obj.id:
return True
# 教师:可查看自己名下活跃学生
if user.role_type == 'teacher':
# 带教医生:可查看自己名下活跃学生
if user.role_type == 'doctor':
if TeacherStudentRelation.objects.filter(
teacher=user, student=obj, status=1
).exists():
+10
View File
@@ -120,6 +120,16 @@ class TeacherStudentRelationSerializer(serializers.ModelSerializer):
model = TeacherStudentRelation
fields = '__all__'
def validate(self, attrs):
# 师生关系只能在 带教医生(doctor) 与 学生(student) 之间建立
teacher = attrs.get('teacher') or getattr(self.instance, 'teacher', None)
student = attrs.get('student') or getattr(self.instance, 'student', None)
if teacher is not None and teacher.role_type != 'doctor':
raise serializers.ValidationError({'teacher': '带教方必须是带教医生(doctor)'})
if student is not None and student.role_type != 'student':
raise serializers.ValidationError({'student': '学生方必须是学生(student)'})
return attrs
class InstitutionSerializer(serializers.ModelSerializer):
class Meta:
+2 -2
View File
@@ -67,8 +67,8 @@ class UserViewSet(viewsets.ModelViewSet):
if self.action == 'list':
if user.role_type in ('super_admin', 'content_admin') or user.is_staff:
return qs # 管理员:全员
elif user.role_type == 'teacher':
# 教师:仅自己名下活跃学生
elif user.role_type == 'doctor':
# 带教医生:仅自己名下活跃学生
student_ids = TeacherStudentRelation.objects.filter(
teacher=user, status=1
).values_list('student_id', flat=True)