mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-05 14:35:17 -05:00
feat: add passkey authentication and enhance user preferences - Add passkey login security event type with fingerprint icon - Include request and site context in email confirmation for backend - Add user_id exact match filter to prevent incorrect user lookups - Enable PATCH method for updating user preferences via API - Add moderation_preferences support to user settings - Optimize ticket queries with select_related and prefetch_related This commit introduces passkey authentication tracking, improves user profile filtering accuracy, and extends the preferences API to support updates. Query optimizations reduce database hits for ticket listings.
208 lines
7.5 KiB
Python
208 lines
7.5 KiB
Python
"""
|
|
Celery tasks for moderation app.
|
|
|
|
This module contains background tasks for moderation management including:
|
|
- Automatic expiration of stale claim locks
|
|
- Cleanup of orphaned submissions
|
|
"""
|
|
|
|
import logging
|
|
from datetime import timedelta
|
|
|
|
from celery import shared_task
|
|
from django.contrib.auth import get_user_model
|
|
from django.db import transaction
|
|
from django.utils import timezone
|
|
|
|
from apps.core.utils import capture_and_log
|
|
|
|
logger = logging.getLogger(__name__)
|
|
User = get_user_model()
|
|
|
|
# Default lock duration in minutes (matching views.py)
|
|
DEFAULT_LOCK_DURATION_MINUTES = 15
|
|
|
|
|
|
@shared_task(name="moderation.expire_stale_claims")
|
|
def expire_stale_claims(lock_duration_minutes: int = None) -> dict:
|
|
"""
|
|
Expire claims on submissions that have been locked for too long without action.
|
|
|
|
This task finds submissions in CLAIMED status where claimed_at is older than
|
|
the lock duration (default 15 minutes) and releases them back to PENDING
|
|
so other moderators can claim them.
|
|
|
|
This task should be run every 5 minutes via Celery Beat.
|
|
|
|
Args:
|
|
lock_duration_minutes: Override the default lock duration (15 minutes)
|
|
|
|
Returns:
|
|
dict: Summary with counts of processed, succeeded, and failed releases
|
|
"""
|
|
from apps.moderation.models import EditSubmission, PhotoSubmission
|
|
|
|
if lock_duration_minutes is None:
|
|
lock_duration_minutes = DEFAULT_LOCK_DURATION_MINUTES
|
|
|
|
logger.info("Starting stale claims expiration check (timeout: %d minutes)", lock_duration_minutes)
|
|
|
|
# Calculate cutoff time (claims older than this should be released)
|
|
cutoff_time = timezone.now() - timedelta(minutes=lock_duration_minutes)
|
|
|
|
result = {
|
|
"edit_submissions": {"processed": 0, "released": 0, "failed": 0},
|
|
"photo_submissions": {"processed": 0, "released": 0, "failed": 0},
|
|
"failures": [],
|
|
"cutoff_time": cutoff_time.isoformat(),
|
|
}
|
|
|
|
# Process EditSubmissions with stale claims
|
|
# Query without lock first, then lock each row individually in transaction
|
|
stale_edit_ids = list(
|
|
EditSubmission.objects.filter(
|
|
status="CLAIMED",
|
|
claimed_at__lt=cutoff_time,
|
|
).values_list("id", flat=True)
|
|
)
|
|
|
|
for submission_id in stale_edit_ids:
|
|
result["edit_submissions"]["processed"] += 1
|
|
try:
|
|
with transaction.atomic():
|
|
# Lock and fetch the specific row
|
|
submission = EditSubmission.objects.select_for_update(skip_locked=True).filter(
|
|
id=submission_id,
|
|
status="CLAIMED", # Re-verify status in case it changed
|
|
).first()
|
|
|
|
if submission:
|
|
_release_claim(submission)
|
|
result["edit_submissions"]["released"] += 1
|
|
logger.info(
|
|
"Released stale claim on EditSubmission %s (claimed by %s at %s)",
|
|
submission_id,
|
|
submission.claimed_by,
|
|
submission.claimed_at,
|
|
)
|
|
except Exception as e:
|
|
result["edit_submissions"]["failed"] += 1
|
|
error_msg = f"EditSubmission {submission_id}: {str(e)}"
|
|
result["failures"].append(error_msg)
|
|
capture_and_log(
|
|
e,
|
|
f"Release stale claim on EditSubmission {submission_id}",
|
|
source="task",
|
|
)
|
|
|
|
# Process PhotoSubmissions with stale claims (legacy model - until removed)
|
|
stale_photo_ids = list(
|
|
PhotoSubmission.objects.filter(
|
|
status="CLAIMED",
|
|
claimed_at__lt=cutoff_time,
|
|
).values_list("id", flat=True)
|
|
)
|
|
|
|
for submission_id in stale_photo_ids:
|
|
result["photo_submissions"]["processed"] += 1
|
|
try:
|
|
with transaction.atomic():
|
|
# Lock and fetch the specific row
|
|
submission = PhotoSubmission.objects.select_for_update(skip_locked=True).filter(
|
|
id=submission_id,
|
|
status="CLAIMED", # Re-verify status in case it changed
|
|
).first()
|
|
|
|
if submission:
|
|
_release_claim(submission)
|
|
result["photo_submissions"]["released"] += 1
|
|
logger.info(
|
|
"Released stale claim on PhotoSubmission %s (claimed by %s at %s)",
|
|
submission_id,
|
|
submission.claimed_by,
|
|
submission.claimed_at,
|
|
)
|
|
except Exception as e:
|
|
result["photo_submissions"]["failed"] += 1
|
|
error_msg = f"PhotoSubmission {submission_id}: {str(e)}"
|
|
result["failures"].append(error_msg)
|
|
capture_and_log(
|
|
e,
|
|
f"Release stale claim on PhotoSubmission {submission_id}",
|
|
source="task",
|
|
)
|
|
|
|
# Also process EditSubmission with PHOTO type (new unified model)
|
|
stale_photo_edit_ids = list(
|
|
EditSubmission.objects.filter(
|
|
submission_type="PHOTO",
|
|
status="CLAIMED",
|
|
claimed_at__lt=cutoff_time,
|
|
).values_list("id", flat=True)
|
|
)
|
|
|
|
for submission_id in stale_photo_edit_ids:
|
|
result["edit_submissions"]["processed"] += 1 # Count with edit submissions
|
|
try:
|
|
with transaction.atomic():
|
|
submission = EditSubmission.objects.select_for_update(skip_locked=True).filter(
|
|
id=submission_id,
|
|
status="CLAIMED",
|
|
).first()
|
|
|
|
if submission:
|
|
_release_claim(submission)
|
|
result["edit_submissions"]["released"] += 1
|
|
logger.info(
|
|
"Released stale claim on PHOTO EditSubmission %s (claimed by %s at %s)",
|
|
submission_id,
|
|
submission.claimed_by,
|
|
submission.claimed_at,
|
|
)
|
|
except Exception as e:
|
|
result["edit_submissions"]["failed"] += 1
|
|
error_msg = f"PHOTO EditSubmission {submission_id}: {str(e)}"
|
|
result["failures"].append(error_msg)
|
|
capture_and_log(
|
|
e,
|
|
f"Release stale claim on PHOTO EditSubmission {submission_id}",
|
|
source="task",
|
|
)
|
|
|
|
total_released = result["edit_submissions"]["released"] + result["photo_submissions"]["released"]
|
|
total_failed = result["edit_submissions"]["failed"] + result["photo_submissions"]["failed"]
|
|
|
|
logger.info(
|
|
"Completed stale claims expiration: %s released, %s failed",
|
|
total_released,
|
|
total_failed,
|
|
)
|
|
|
|
return result
|
|
|
|
|
|
def _release_claim(submission):
|
|
"""
|
|
Release a stale claim on a submission.
|
|
|
|
Uses the unclaim() FSM method to properly transition from CLAIMED to PENDING
|
|
and clear the claimed_by and claimed_at fields.
|
|
|
|
Args:
|
|
submission: EditSubmission or PhotoSubmission instance
|
|
"""
|
|
# Store info for logging before clearing
|
|
claimed_by = submission.claimed_by
|
|
claimed_at = submission.claimed_at
|
|
|
|
# Use the FSM unclaim method - pass None for system-initiated unclaim
|
|
submission.unclaim(user=None)
|
|
|
|
# Log the automatic release
|
|
logger.debug(
|
|
"Auto-released claim: submission=%s, was_claimed_by=%s, claimed_at=%s",
|
|
submission.id,
|
|
claimed_by,
|
|
claimed_at,
|
|
)
|