-
Notifications
You must be signed in to change notification settings - Fork 0
Adds automatic user quota management for problem submissions #186
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
|
Comment on lines
+11
to
+24
|
||
|
|
||
|
|
||
| @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 | ||
| ) | ||
|
Comment on lines
+51
to
+57
|
||
|
|
||
| logger.info( | ||
| f"Problem {instance.id} total_quota changed from {old_quota} to {new_quota}. " | ||
| f"Reset {updated_count} user quota records." | ||
| ) | ||
|
Comment on lines
+11
to
+62
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+889
to
898
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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 | |
| } | |
| ) | |
| try: | |
| # 先嘗試在行鎖下取得現有配額紀錄 | |
| quota = UserProblemQuota.objects.select_for_update().get( | |
| user=user, | |
| problem_id=problem_id, | |
| assignment_id=None, # 全域配額(非作業) | |
| ) | |
| created = False | |
| except UserProblemQuota.DoesNotExist: | |
| # 如不存在則嘗試建立,若發生競態導致 IntegrityError,再回頭取得 | |
| try: | |
| quota = UserProblemQuota.objects.create( | |
| user=user, | |
| problem_id=problem_id, | |
| assignment_id=None, # 全域配額(非作業) | |
| total_quota=problem_quota, | |
| remaining_attempts=problem_quota, | |
| ) | |
| created = True | |
| except IntegrityError: | |
| quota = UserProblemQuota.objects.select_for_update().get( | |
| user=user, | |
| problem_id=problem_id, | |
| assignment_id=None, # 全域配額(非作業) | |
| ) | |
| created = False |
Copilot
AI
Dec 28, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using assignment_id=None versus assignment_id__isnull=True inconsistently could lead to issues. At line 892, the code uses assignment_id=None as a parameter for get_or_create, but at line 918 it uses assignment_id__isnull=True for the query. While both should work for NULL values, for consistency and clarity, use the same pattern throughout the quota checking logic.
| assignment_id__isnull=True | |
| assignment_id=None |
Copilot
AI
Dec 28, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There's an inconsistency in how assignment_id filtering is done: line 892 uses 'assignment_id=None' while line 918 uses 'assignment_id__isnull=True'. These have different behaviors in Django - the first filters for NULL explicitly, while the second includes both NULL and potentially other null-like values. For consistency and correctness with the get_or_create logic above, line 918 should use 'assignment_id=None'.
| assignment_id__isnull=True | |
| assignment_id=None # 全域配額(非作業),與上方 get_or_create 邏輯一致 |
Copilot
AI
Dec 28, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Similar to the issue in the first quota check block, this redundant condition should be removed. If remaining_attempts is 0, the error is already returned at line 922-927. The check at line 928 before decrementing is unnecessary because we know at that point remaining_attempts must be greater than 0. This check should be removed to simplify the logic.
| if quota.remaining_attempts > 0: | |
| quota.remaining_attempts -= 1 | |
| quota.save() | |
| quota.remaining_attempts -= 1 | |
| quota.save() |
Copilot
AI
Dec 28, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The submission save is outside the transaction block that handles quota decrement, breaking atomicity. If the submission save fails after the quota has been decremented, the user will lose a quota attempt without creating a submission. Move this line inside the transaction block to ensure that both quota updates and submission creation succeed or fail together.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The signal handler performs a database query within the pre_save signal, which can cause performance issues. This query executes for every save operation, even when the total_quota hasn't changed. Consider checking if total_quota is in the instance's changed fields using Django's update_fields or checking the instance's state to avoid unnecessary database hits.