diff --git a/problems/apps.py b/problems/apps.py index ac489e4..5928797 100644 --- a/problems/apps.py +++ b/problems/apps.py @@ -4,3 +4,7 @@ class ProblemsConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'problems' + + def ready(self): + # 導入 signals 以註冊 signal handlers + import problems.signals # noqa: F401 diff --git a/problems/signals.py b/problems/signals.py new file mode 100644 index 0000000..50e8031 --- /dev/null +++ b/problems/signals.py @@ -0,0 +1,62 @@ +""" +Problems app signals - 處理題目相關的 signal 事件 +""" +import logging +from django.db.models.signals import pre_save, post_save +from django.dispatch import receiver + +logger = logging.getLogger(__name__) + + +@receiver(pre_save, sender='problems.Problems') +def track_quota_change(sender, instance, **kwargs): + """ + 在保存前追蹤 total_quota 是否變更 + 將舊值存入 instance._old_total_quota + """ + if instance.pk: + try: + old_instance = sender.objects.get(pk=instance.pk) + instance._old_total_quota = old_instance.total_quota + except sender.DoesNotExist: + instance._old_total_quota = None + else: + instance._old_total_quota = None + + +@receiver(post_save, sender='problems.Problems') +def reset_user_quotas_on_change(sender, instance, created, **kwargs): + """ + 當題目的 total_quota 被修改時,重置所有該題目的 UserProblemQuota 記錄 + + 邏輯: + - 如果是新建題目,不需要處理 + - 如果 total_quota 沒有變更,不需要處理 + - 如果 total_quota 變更了,重置所有相關的 UserProblemQuota + """ + if created: + return + + old_quota = getattr(instance, '_old_total_quota', None) + new_quota = instance.total_quota + + # 如果 quota 沒有變更,不需要處理 + if old_quota == new_quota: + return + + # 延遲導入避免循環依賴 + from submissions.models import UserProblemQuota + + # 重置所有該題目的配額記錄 + updated_count = UserProblemQuota.objects.filter( + problem_id=instance.id, + assignment_id__isnull=True # 只重置全域配額,不影響作業配額 + ).update( + total_quota=new_quota, + remaining_attempts=new_quota + ) + + logger.info( + f"Problem {instance.id} total_quota changed from {old_quota} to {new_quota}. " + f"Reset {updated_count} user quota records." + ) diff --git a/submissions/admin.py b/submissions/admin.py index a5d8847..964c4cf 100644 --- a/submissions/admin.py +++ b/submissions/admin.py @@ -236,8 +236,8 @@ def quota_usage(self, obj): percentage = (used / obj.total_quota * 100) if obj.total_quota > 0 else 0 color = 'green' if percentage < 50 else 'orange' if percentage < 80 else 'red' return format_html( - '{}/{} ({:.0f}%)', - color, used, obj.total_quota, percentage + '{}/{} ({}%)', + color, used, obj.total_quota, int(percentage) ) quota_usage.short_description = '配額使用' diff --git a/submissions/views.py b/submissions/views.py index aa414b4..32c3069 100644 --- a/submissions/views.py +++ b/submissions/views.py @@ -880,44 +880,21 @@ def post(self, request, *args, **kwargs): status_code=status.HTTP_404_NOT_FOUND ) - # 4. Quota 檢查 - 檢查用戶是否還有提交配額 - # 先檢查題目層級的 total_quota 設定 + # 4. Quota 檢查 - 基於題目的 total_quota 設定 problem_quota = problem.total_quota # -1 = 無限制, >= 0 = 有限制 - # Wrap quota check and submission save in a single atomic transaction - # to ensure consistency (prevent quota decrement without submission save) - with transaction.atomic(): - if problem_quota >= 0: - # 題目有配額限制,需要檢查/建立 UserProblemQuota 記錄 - # Use explicit try-except pattern to avoid race condition with get_or_create - try: - # Try to get existing quota record with row-level lock - quota = UserProblemQuota.objects.select_for_update().get( - user=user, - problem_id=problem_id, - assignment_id=None # Global quota (no assignment) - ) - except UserProblemQuota.DoesNotExist: - # Record doesn't exist, create it - # The unique constraint will handle concurrent creates - try: - quota = UserProblemQuota.objects.create( - user=user, - problem_id=problem_id, - assignment_id=None, - total_quota=problem_quota, - remaining_attempts=problem_quota - ) - except IntegrityError: - # If another thread created it concurrently, get it with lock - quota = UserProblemQuota.objects.select_for_update().get( - user=user, - problem_id=problem_id, - assignment_id=None - ) - - # 如果記錄已存在但 total_quota 與題目設定不同,可能是題目設定更新了 - # 這裡我們不自動更新,保持原有配額(避免用戶突然獲得更多或更少配額) + if problem_quota >= 0: + # 題目有配額限制,需要檢查/建立該用戶的 UserProblemQuota 記錄 + with transaction.atomic(): + quota, created = UserProblemQuota.objects.select_for_update().get_or_create( + user=user, + problem_id=problem_id, + assignment_id=None, # 全域配額(非作業) + defaults={ + 'total_quota': problem_quota, + 'remaining_attempts': problem_quota + } + ) if quota.remaining_attempts == 0: return api_response( @@ -927,36 +904,35 @@ def post(self, request, *args, **kwargs): ) # 減少配額 - # Note: remaining_attempts can be -1 (unlimited) if manually set by admin - # In this case, we preserve the unlimited quota by not decrementing if quota.remaining_attempts > 0: quota.remaining_attempts -= 1 quota.save() - else: - # 題目無配額限制,但仍檢查是否有手動設定的 UserProblemQuota - try: + else: + # 題目無配額限制 (total_quota == -1) + # 但仍檢查是否有管理員手動設定的 UserProblemQuota(針對特定用戶的限制) + try: + with transaction.atomic(): quota = UserProblemQuota.objects.select_for_update().get( user=user, problem_id=problem_id, - assignment_id=None # Global quota (no assignment) + assignment_id__isnull=True ) - if quota.remaining_attempts == 0: - return api_response( - data=None, - message="you have used all your quotas", - status_code=status.HTTP_403_FORBIDDEN - ) - # Note: remaining_attempts can be -1 (unlimited) if manually set - # In this case, we preserve the unlimited quota by not decrementing - if quota.remaining_attempts > 0: - quota.remaining_attempts -= 1 - quota.save() - except UserProblemQuota.DoesNotExist: - # 沒有任何配額限制,允許提交 - pass - - # Save submission within the same transaction to ensure atomicity - submission = serializer.save() + # 只有當 total_quota >= 0 時才檢查(-1 表示此用戶也無限制) + if quota.total_quota >= 0: + if quota.remaining_attempts == 0: + return api_response( + data=None, + message="you have used all your quotas", + status_code=status.HTTP_403_FORBIDDEN + ) + if quota.remaining_attempts > 0: + quota.remaining_attempts -= 1 + quota.save() + except UserProblemQuota.DoesNotExist: + # 沒有任何配額限制,允許提交 + pass + + submission = serializer.save() # NOJ 格式響應 return api_response(