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(