Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions problems/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
62 changes: 62 additions & 0 deletions problems/signals.py
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
Comment on lines +17 to +22

Copilot AI Dec 28, 2025

Copy link

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.
else:
instance._old_total_quota = None
Comment on lines +11 to +24

Copilot AI Dec 28, 2025

Copy link

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.


@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

Copilot AI Dec 28, 2025

Copy link

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 on lines +50 to +57

Copilot AI Dec 28, 2025

Copy link

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 on lines +51 to +57

Copilot AI Dec 28, 2025

Copy link

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.

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

Copilot AI Dec 28, 2025

Copy link

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.
4 changes: 2 additions & 2 deletions submissions/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
'<span style="color: {};">{}/{} ({:.0f}%)</span>',
color, used, obj.total_quota, percentage
'<span style="color: {};">{}/{} ({}%)</span>',
color, used, obj.total_quota, int(percentage)
)
quota_usage.short_description = '配額使用'

Expand Down
94 changes: 35 additions & 59 deletions submissions/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copilot AI Dec 28, 2025

Copy link

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.
if quota.remaining_attempts == 0:
return api_response(
Expand All @@ -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

Copilot AI Dec 28, 2025

Copy link

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.

Copilot AI Dec 28, 2025

Copy link

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.
)
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()
Comment on lines +928 to +930

Copilot AI Dec 28, 2025

Copy link

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.
except UserProblemQuota.DoesNotExist:
# 沒有任何配額限制,允許提交
pass

submission = serializer.save()

Copilot AI Dec 28, 2025

Copy link

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.

# NOJ 格式響應
return api_response(
Expand Down