diff --git a/app/admin.py b/app/admin.py index 5e8cbe788..19ef1691e 100644 --- a/app/admin.py +++ b/app/admin.py @@ -77,6 +77,7 @@ def get_normal_fields(self, request, obj: NaturalPerson = None): f(_m.identity), f(_m.status), f(_m.wechat_receive_level), f(_m.accept_promote), f(_m.active_score), + f(_m.course_priority) ]) return fields diff --git a/app/course_utils.py b/app/course_utils.py index 9ac24dcfb..4a7963039 100644 --- a/app/course_utils.py +++ b/app/course_utils.py @@ -12,6 +12,9 @@ process_time: 把datetime对象转换成人类可读的时间表示 check_course_time_conflict: 检查当前选择的课是否与已选的课上课时间冲突 """ +from collections import Counter, defaultdict +from typing import Callable + from app.utils_dependency import * from app.models import ( User, @@ -46,7 +49,7 @@ import openpyxl import openpyxl.worksheet.worksheet -from random import sample +from numpy.random import choice from urllib.parse import quote from collections import Counter from datetime import datetime, timedelta @@ -608,8 +611,6 @@ def registration_status_change(course_id: int, user: NaturalPerson, and course_status != Course.Status.STAGE2): return wrong("在非选课阶段不能选课!") - need_to_create = False - if action == "select": if CourseParticipant.objects.filter(course_id=course_id, person=user).exists(): @@ -617,7 +618,6 @@ def registration_status_change(course_id: int, user: NaturalPerson, course_id=course_id, person=user) cur_status = participant_info.status else: - need_to_create = True cur_status = CourseParticipant.Status.UNSELECT if course_status == Course.Status.STAGE1: @@ -681,15 +681,11 @@ def registration_status_change(course_id: int, user: NaturalPerson, course.current_participants += 1 course.save() - # 由于不同用户之间的状态不共享,这个更新应该可以不加锁 - if need_to_create: - CourseParticipant.objects.create(course_id=course_id, - person=user, - status=to_status) - else: - CourseParticipant.objects.filter( - course_id=course_id, - person=user).update(status=to_status) + CourseParticipant.objects.update_or_create( + course_id = course_id, + person = user, + defaults = {"status": to_status} + ) succeed("选课成功!", context) except: return context @@ -816,7 +812,11 @@ def draw_lots(): if participants_num <= 0: continue - participants_id = list(participants.values_list("id", flat=True)) + participants_info = participants.values_list("id", "person__course_priority") + participants_id, priority = map(list, zip(*participants_info)) + # Turn priority into a probability distribution + sum_priority = sum(priority) + priority = [i / sum_priority for i in priority] capacity = course.capacity if participants_num <= capacity: @@ -828,7 +828,7 @@ def draw_lots(): current_participants=participants_num) else: # 抽签;可能实现得有一些麻烦 - lucky_ones = sample(participants_id, capacity) + lucky_ones = choice(participants_id, capacity, replace=False, p=priority) unlucky_ones = list( set(participants_id).difference(set(lucky_ones))) # 不确定是否要加悲观锁 @@ -1489,3 +1489,37 @@ def download_select_info(single_course: Course | None = None): file_name = f'{year}{semester}选课名单汇总-{ctime}' # 保存并返回 return _excel_response(wb, file_name) + + +def update_course_priority(year: int, semester: str, priority_func: Callable[[int], float]): + """ + 根据指定学期的学时表,计算新的书院课选课权重。 + + :param year: 用于查找学时表 table, 一个可能值示例为2024 + :param semester: 用于查找学时表 table。semester的值应为'Fall'或'Spring' + :param priority_func: 一个函数,它接受一个int,表示过去学期学时无效的书院课门数,返回新学期选课优先级(0~1) + :return: None + """ + if semester not in ('Fall', 'Spring'): + raise ValueError('semester must have value of "Fall" or "Spring"!') + with transaction.atomic(): + # First, all set to 1.0 + NaturalPerson.objects.update(course_priority=1.0) + # Invalid records in the given semester + invalid_records = CourseRecord.objects.filter( + year = year, invalid = True, semester = semester + ).values_list( + 'person__id', flat = True + ) + # Counts the number of occurrences of a person's ID in invalid list + invalid_counter: Counter[int] = Counter(invalid_records) + # a[k] is the list of id of people who have k invalid records + a = defaultdict(list) + # Length of the current continuous segment + for person_id in invalid_counter: + cnt = invalid_counter[person_id] + a[cnt].append(person_id) + for i in a: + # There are len(a[i]) people with i invalid records + p = priority_func(i) + NaturalPerson.objects.filter(id__in = a[i]).update(course_priority = p) diff --git a/app/management/commands/update_course_priority.py b/app/management/commands/update_course_priority.py new file mode 100644 index 000000000..f95a42cbd --- /dev/null +++ b/app/management/commands/update_course_priority.py @@ -0,0 +1,21 @@ +from django.core.management.base import BaseCommand + +from app.course_utils import update_course_priority + +class Command(BaseCommand): + # This command should be run before new semester starts + help = "Updates course_priority for all NaturalPerson" + + def add_arguments(self, parser): + parser.add_argument('year', type=int, help='year of the course records to check (required)') + parser.add_argument('semester', type=str, help='semester of the course records to check. Valid values are: Fall, Spring (required)') + + def handle(self, *args, **options): + try: + # 修改这里的这个lambda,可以实现不同的权重策略 + update_course_priority(options['year'], options['semester'], + lambda x: 1 - 0.05 * x) + except Exception as e: + print('Error:', e.args) + else: + print('Success!') diff --git a/app/migrations/0009_courseparticipant_unique_course_selection_record.py b/app/migrations/0009_courseparticipant_unique_course_selection_record.py new file mode 100644 index 000000000..2d9661d6d --- /dev/null +++ b/app/migrations/0009_courseparticipant_unique_course_selection_record.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.9 on 2025-01-17 20:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("app", "0008_pool_activity_alter_poolitem_exchange_attributes_and_more"), + ] + + operations = [ + migrations.AddConstraint( + model_name="courseparticipant", + constraint=models.UniqueConstraint( + fields=("course", "person"), name="Unique course selection record" + ), + ), + ] diff --git a/app/migrations/0010_naturalperson_course_priority.py b/app/migrations/0010_naturalperson_course_priority.py new file mode 100644 index 000000000..8fd10e8d6 --- /dev/null +++ b/app/migrations/0010_naturalperson_course_priority.py @@ -0,0 +1,17 @@ +# Generated by Django 5.0.9 on 2025-01-17 21:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("app", "0009_courseparticipant_unique_course_selection_record"), + ] + + operations = [ + migrations.AddField( + model_name="naturalperson", + name="course_priority", + field=models.FloatField(default=1.0, verbose_name="书院课选课权重"), + ), + ] diff --git a/app/models.py b/app/models.py index 54efa978b..5587cadd4 100644 --- a/app/models.py +++ b/app/models.py @@ -362,6 +362,7 @@ class ReceiveLevel(models.IntegerChoices): accept_promote = models.BooleanField(default=True) # 是否接受推广消息 active_score = models.FloatField("活跃度", default=0) # 用户活跃度 + course_priority = models.FloatField("书院课选课权重", default=1.0) # 书院课预选抽签权重 def __str__(self): return str(self.name) @@ -1580,6 +1581,9 @@ class CourseParticipant(models.Model): class Meta: verbose_name = "4.课程报名情况" verbose_name_plural = verbose_name + constraints = [ + models.UniqueConstraint(fields = ['course', 'person'], name='Unique course selection record') + ] course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name="participant_set")