Files
MathApp/content/management/commands/mark_abandoned_node_progress.py
2026-08-19 17:15:55 +03:30

77 lines
4.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
تشخیصِ «نصفه‌رها» (abandoned) با سیگنال قابل انجام نیست چون به گذر زمانِ
بدون هیچ رویدادی نیاز داره، نه یک event مشخص؛ برای همین management
commandه، نه receiver. باید periodic اجرا بشه (cron / celery beat)،
مثلاً یک‌بار در روز:
python manage.py mark_abandoned_node_progress
آستانه از settings.NODE_PROGRESS_ABANDON_DAYS خونده می‌شه (پیش‌فرض ۱۴ روز).
"""
from django.conf import settings
from django.core.management.base import BaseCommand
from django.db import transaction
from django.utils import timezone
from content.models import NodeProgress
from statuslog.signals import status_changed
class Command(BaseCommand):
help = 'رکوردهای NodeProgress در حال انجام که مدت‌ها فعالیتی نداشته‌اند را نصفه‌رها (abandoned) علامت می‌زند.'
def handle(self, *args, **options):
abandon_days = getattr(settings, 'NODE_PROGRESS_ABANDON_DAYS', 14)
threshold = timezone.now() - timezone.timedelta(days=abandon_days)
stale_qs = NodeProgress.objects.filter(
status='in_progress', last_activity_at__lt=threshold,
)
count = 0
# ⚛️ atomic (به‌ازای هر رکورد، نه کل حلقه): این یک job دوره‌ای رو
# روی هزاران رکورد می‌تونه اجرا کنه. اگه کل حلقه یک atomic واحد
# باشه، یک خطا روی رکورد هزارم، ابطالِ ۹۹۹ رکورد درست‌شده‌ی قبلی رو
# هم rollback می‌کنه (و کل job باید از اول اجرا بشه). به‌جاش هر
# رکورد (save + signal که StatusChangeLog می‌سازه) جدا اتمیکه: یک
# خطای مقطعی فقط همون رکورد رو نصفه‌کاره نگه نمی‌داره، رکوردهای
# قبلی/بعدی مستقل می‌مونن.
#
# ⚛️🔒 اصلاح race condition (lost update): قبلاً همین آبجکتِ
# progressِ خونده‌شده از stale_qs (بدونِ قفل) مستقیماً save می‌شد.
# اگه هم‌زمان یک درخواستِ کاربرِ واقعی (touch_node_progress_on_answer
# یا update_node_progress_on_lesson_completion در content/services.py،
# هر دو با select_for_update) دقیقاً همون ردیف رو به‌روزرسانی و
# last_activity_at رو تازه می‌کرد، این کامند می‌تونست بدونِ اطلاع از
# اون تغییرِ تازه، status رو با مقدارِ قدیمیِ در-حافظه به 'abandoned'
# برگردونه — یعنی فعالیتِ تازه‌ی کاربر توسط این جاب rollback/overwrite
# می‌شد. حالا برای هر رکورد، فقط id از stale_qs گرفته می‌شه؛ ردیفِ
# واقعی داخل transaction.atomic با select_for_update دوباره خونده و
# قفل می‌شه (دقیقاً همون الگویی که مسیرِ کاربر استفاده می‌کنه)، و
# شرطِ نصفه‌رها بودن (status='in_progress' و last_activity_at قدیمی‌تر
# از threshold) بعد از گرفتنِ قفل دوباره چک می‌شه؛ اگه بین queryset
# اولیه و گرفتنِ قفل، یک فعالیتِ کاربر همین ردیف رو تازه کرده باشه،
# دیگه شرط برقرار نیست و این ردیف نادیده گرفته می‌شه (نه abandoned).
stale_ids = list(stale_qs.values_list('id', flat=True))
for progress_id in stale_ids:
with transaction.atomic():
progress = NodeProgress.objects.select_for_update().get(pk=progress_id)
if progress.status != 'in_progress' or progress.last_activity_at is None \
or progress.last_activity_at >= threshold:
continue
old_status = progress.status
progress.status = 'abandoned'
progress.save(update_fields=['status'])
status_changed.send(
sender='content.NodeProgress', object_id=progress.id,
old_status=old_status, new_status='abandoned',
event='inactivity_timeout', user_id=progress.user_id,
)
count += 1
self.stdout.write(self.style.SUCCESS(
f'{count} رکورد NodeProgress به abandoned تغییر کرد (آستانه: {abandon_days} روز).'
))