77 lines
4.7 KiB
Python
77 lines
4.7 KiB
Python
"""
|
||
تشخیصِ «نصفهرها» (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} روز).'
|
||
))
|