Skip to content

Adds automatic user quota management for problem submissions#186

Open
happylittle7 wants to merge 2 commits intodevfrom
fix/quota-not-work
Open

Adds automatic user quota management for problem submissions#186
happylittle7 wants to merge 2 commits intodevfrom
fix/quota-not-work

Conversation

@happylittle7
Copy link
Copy Markdown
Contributor

Overview

Implements a robust quota tracking and enforcement mechanism for problem submissions, ensuring user submission limits are consistently managed and automatically updated in response to changes in problem settings.

Key Improvements

  • Signal-based quota updates: Introduces model signals to automatically reset user quotas when a problem's total quota is changed, preventing manual inconsistencies.
  • Streamlined quota validation: Refactors submission logic to consistently check and update user quotas, supporting both global and per-user overrides, and handling unlimited quotas cleanly.
  • Enhanced admin display: Improves quota usage presentation in the admin interface for clearer monitoring.
  • Extensibility: Lays groundwork for future enhancements by centralizing quota logic and reducing risk of logic duplication or errors.

Motivation

Addresses the need for accurate and automated quota management, reducing manual intervention and potential errors when updating problem limits. Improves user experience by providing clear feedback on quota status during submissions.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements an automatic quota management system for problem submissions, introducing signal-based quota updates and refactored submission validation logic. The changes aim to ensure user submission limits are consistently managed and automatically updated when problem quota settings change.

Key changes include:

  • Signal-based automatic quota reset when a problem's total_quota is modified
  • Refactored quota validation in submission logic to support both global and per-user quotas
  • Improved admin interface display for quota usage with percentage formatting

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 10 comments.

File Description
submissions/views.py Refactors quota checking logic to occur after problem permission validation, adds support for automatic quota creation based on problem settings, and implements handling for both global and per-user quota overrides
submissions/admin.py Updates quota_usage display method to use integer percentage formatting instead of floating-point format
problems/signals.py Introduces new signal handlers to track total_quota changes and automatically reset UserProblemQuota records when problem quotas are modified
problems/apps.py Registers signal handlers by importing problems.signals in the ready() method

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread problems/signals.py
Comment on lines +51 to +57
updated_count = UserProblemQuota.objects.filter(
problem_id=instance.id,
assignment_id__isnull=True # 只重置全域配額,不影響作業配額
).update(
total_quota=new_quota,
remaining_attempts=new_quota
)
Copy link

Copilot AI Dec 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The signal handler resets user quotas without wrapping the update in a transaction. If the update fails partway through (e.g., database connection issues), some users might have their quotas reset while others don't, leading to inconsistent state. The update operation should be wrapped in a transaction.atomic() block to ensure atomicity.

Copilot uses AI. Check for mistakes.
Comment thread submissions/views.py
Comment on lines +928 to +930
if quota.remaining_attempts > 0:
quota.remaining_attempts -= 1
quota.save()
Copy link

Copilot AI Dec 28, 2025

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.

Suggested change
if quota.remaining_attempts > 0:
quota.remaining_attempts -= 1
quota.save()
quota.remaining_attempts -= 1
quota.save()

Copilot uses AI. Check for mistakes.
Comment thread problems/signals.py
Comment on lines +50 to +57
# 重置所有該題目的配額記錄
updated_count = UserProblemQuota.objects.filter(
problem_id=instance.id,
assignment_id__isnull=True # 只重置全域配額,不影響作業配額
).update(
total_quota=new_quota,
remaining_attempts=new_quota
)
Copy link

Copilot AI Dec 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The signal handler updates all UserProblemQuota records when the problem's total_quota changes, but it doesn't consider users who may have already used some of their quota. When the quota is reset, users who already consumed attempts get a fresh quota equal to the new total, potentially allowing them more submissions than intended. Consider whether remaining_attempts should be calculated based on the usage (e.g., remaining_attempts = new_quota - (old_quota - old_remaining_attempts)) rather than simply resetting to new_quota.

Copilot uses AI. Check for mistakes.
Comment thread problems/signals.py
Comment on lines +11 to +24
@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
Copy link

Copilot AI Dec 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The signal is triggered on every save, even when called from update_fields that doesn't include total_quota. Consider checking if 'total_quota' is in update_fields (if available) to avoid unnecessary processing. You can access this via kwargs.get('update_fields') in the pre_save signal.

Copilot uses AI. Check for mistakes.
Comment thread submissions/views.py
quota = UserProblemQuota.objects.select_for_update().get(
user=user,
problem_id=problem_id,
assignment_id__isnull=True
Copy link

Copilot AI Dec 28, 2025

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.

Suggested change
assignment_id__isnull=True
assignment_id=None

Copilot uses AI. Check for mistakes.
Comment thread submissions/views.py
Comment on lines +883 to +933
# 4. Quota 檢查 - 基於題目的 total_quota 設定
problem_quota = problem.total_quota # -1 = 無限制, >= 0 = 有限制

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(
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()
else:
# 題目無配額限制 (total_quota == -1)
# 但仍檢查是否有管理員手動設定的 UserProblemQuota(針對特定用戶的限制)
try:
with transaction.atomic():
quota = UserProblemQuota.objects.select_for_update().get(
user=user,
problem_id=problem_id,
assignment_id__isnull=True
)
# 只有當 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
Copy link

Copilot AI Dec 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new quota management logic in the submission view and the signal-based quota reset functionality lack test coverage. Given that other submission functionality in this codebase has comprehensive test coverage (as seen in test_submission_views_api.py and other test files), tests should be added to verify: 1) quota checking and decrementing behavior for problems with limited quotas, 2) handling of unlimited quotas (-1), 3) per-user quota overrides, 4) the signal handler's quota reset behavior when total_quota changes, and 5) edge cases like concurrent submissions and quota exhaustion.

Copilot uses AI. Check for mistakes.
Comment thread problems/signals.py
Comment on lines +11 to +62
@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."
)
Copy link

Copilot AI Dec 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The signal handlers in this new file lack test coverage. Given that the problems app has existing test infrastructure (test_api.py), tests should be added to verify: 1) that total_quota changes trigger the signal correctly, 2) that UserProblemQuota records are updated as expected, 3) that only global quotas (assignment_id=None) are affected, 4) edge cases like changing from unlimited to limited quotas and vice versa, and 5) behavior when no UserProblemQuota records exist yet.

Copilot uses AI. Check for mistakes.
Comment thread submissions/views.py
Comment on lines +906 to +909
# 減少配額
if quota.remaining_attempts > 0:
quota.remaining_attempts -= 1
quota.save()
Copy link

Copilot AI Dec 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The quota checking and decrement logic has a potential race condition. After checking that remaining_attempts is 0 at line 899, but before the check at line 907, the value could theoretically have changed. Although select_for_update() is used, the logic structure allows remaining_attempts to be decremented even when it's 0 (if it becomes non-zero between checks). The condition at line 907 should be removed since the check at line 899 already handles the case when remaining_attempts is 0, and if we reach line 907, we know remaining_attempts must be positive.

Copilot uses AI. Check for mistakes.
Comment thread problems/signals.py
Comment on lines +17 to +22
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
Copy link

Copilot AI Dec 28, 2025

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread submissions/views.py
user=user,
problem_id=problem_id,
assignment_id=None # Global quota (no assignment)
assignment_id__isnull=True
Copy link

Copilot AI Dec 28, 2025

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'.

Suggested change
assignment_id__isnull=True
assignment_id=None # 全域配額(非作業),與上方 get_or_create 邏輯一致

Copilot uses AI. Check for mistakes.
Comment thread problems/signals.py
Comment on lines +51 to +57
updated_count = UserProblemQuota.objects.filter(
problem_id=instance.id,
assignment_id__isnull=True # 只重置全域配額,不影響作業配額
).update(
total_quota=new_quota,
remaining_attempts=new_quota
)
Copy link

Copilot AI Dec 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The signal update doesn't wrap the quota reset in a transaction, which could lead to partial updates if the operation fails midway. Wrap the UserProblemQuota.objects.filter().update() operation in a transaction.atomic() block to ensure all-or-nothing updates.

Copilot uses AI. Check for mistakes.
Comment thread submissions/views.py
# 沒有任何配額限制,允許提交
pass

submission = serializer.save()
Copy link

Copilot AI Dec 28, 2025

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.

Copilot uses AI. Check for mistakes.
Comment thread submissions/views.py
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
}
)

Copy link

Copilot AI Dec 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using select_for_update with get_or_create can lead to unexpected behavior. The select_for_update lock is only applied to the SELECT query, but if the record doesn't exist, the subsequent INSERT isn't protected. This means the lock doesn't prevent race conditions during creation. Consider using a try-except pattern with get and select_for_update, followed by create with IntegrityError handling (similar to the original implementation that was removed).

Suggested change
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 uses AI. Check for mistakes.
@happylittle7
Copy link
Copy Markdown
Contributor Author

@copilot open a new pull request to apply changes based on the comments in this thread

Copy link
Copy Markdown
Contributor

Copilot AI commented Dec 28, 2025

@happylittle7 I've opened a new pull request, #187, to work on those changes. Once the pull request is ready, I'll request review from you.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants