mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-05 02:35:18 -05:00
feat: Introduce a CLAIMED state for moderation submissions, requiring claims before approval or rejection, and add a scheduled task to expire stale claims.
This commit is contained in:
@@ -0,0 +1,95 @@
|
|||||||
|
"""
|
||||||
|
Management command to expire stale claims on submissions.
|
||||||
|
|
||||||
|
This command can be run manually or via cron as an alternative to the Celery
|
||||||
|
scheduled task when Celery is not available.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python manage.py expire_stale_claims
|
||||||
|
python manage.py expire_stale_claims --minutes=10 # Custom timeout
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
from apps.moderation.tasks import expire_stale_claims, DEFAULT_LOCK_DURATION_MINUTES
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Release stale claims on submissions that have exceeded the lock timeout"
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
"--minutes",
|
||||||
|
type=int,
|
||||||
|
default=DEFAULT_LOCK_DURATION_MINUTES,
|
||||||
|
help=f"Minutes after which a claim is considered stale (default: {DEFAULT_LOCK_DURATION_MINUTES})",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run",
|
||||||
|
action="store_true",
|
||||||
|
help="Show what would be released without actually releasing",
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
from datetime import timedelta
|
||||||
|
from django.utils import timezone
|
||||||
|
from apps.moderation.models import EditSubmission, PhotoSubmission
|
||||||
|
|
||||||
|
minutes = options["minutes"]
|
||||||
|
dry_run = options["dry_run"]
|
||||||
|
cutoff_time = timezone.now() - timedelta(minutes=minutes)
|
||||||
|
|
||||||
|
self.stdout.write(f"Looking for claims older than {minutes} minutes...")
|
||||||
|
self.stdout.write(f"Cutoff time: {cutoff_time.isoformat()}")
|
||||||
|
|
||||||
|
# Find stale claims
|
||||||
|
stale_edit = EditSubmission.objects.filter(
|
||||||
|
status="CLAIMED",
|
||||||
|
claimed_at__lt=cutoff_time,
|
||||||
|
).select_related("claimed_by")
|
||||||
|
|
||||||
|
stale_photo = PhotoSubmission.objects.filter(
|
||||||
|
status="CLAIMED",
|
||||||
|
claimed_at__lt=cutoff_time,
|
||||||
|
).select_related("claimed_by")
|
||||||
|
|
||||||
|
stale_edit_count = stale_edit.count()
|
||||||
|
stale_photo_count = stale_photo.count()
|
||||||
|
|
||||||
|
if stale_edit_count == 0 and stale_photo_count == 0:
|
||||||
|
self.stdout.write(self.style.SUCCESS("No stale claims found."))
|
||||||
|
return
|
||||||
|
|
||||||
|
self.stdout.write(f"Found {stale_edit_count} stale EditSubmission claims:")
|
||||||
|
for sub in stale_edit:
|
||||||
|
self.stdout.write(
|
||||||
|
f" - ID {sub.id}: claimed by {sub.claimed_by} at {sub.claimed_at}"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stdout.write(f"Found {stale_photo_count} stale PhotoSubmission claims:")
|
||||||
|
for sub in stale_photo:
|
||||||
|
self.stdout.write(
|
||||||
|
f" - ID {sub.id}: claimed by {sub.claimed_by} at {sub.claimed_at}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
self.stdout.write(self.style.WARNING("\n--dry-run: No changes made."))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Run the actual expiration task
|
||||||
|
result = expire_stale_claims(lock_duration_minutes=minutes)
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS("\nExpiration complete:"))
|
||||||
|
self.stdout.write(
|
||||||
|
f" EditSubmissions: {result['edit_submissions']['released']} released, "
|
||||||
|
f"{result['edit_submissions']['failed']} failed"
|
||||||
|
)
|
||||||
|
self.stdout.write(
|
||||||
|
f" PhotoSubmissions: {result['photo_submissions']['released']} released, "
|
||||||
|
f"{result['photo_submissions']['failed']} failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
if result["failures"]:
|
||||||
|
self.stdout.write(self.style.ERROR("\nFailures:"))
|
||||||
|
for failure in result["failures"]:
|
||||||
|
self.stdout.write(f" - {failure}")
|
||||||
@@ -39,8 +39,8 @@ class ModerationService:
|
|||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
submission = EditSubmission.objects.select_for_update().get(id=submission_id)
|
submission = EditSubmission.objects.select_for_update().get(id=submission_id)
|
||||||
|
|
||||||
if submission.status != "PENDING":
|
if submission.status != "CLAIMED":
|
||||||
raise ValueError(f"Submission {submission_id} is not pending approval")
|
raise ValueError(f"Submission {submission_id} must be claimed before approval (current status: {submission.status})")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Call the model's approve method which handles the business
|
# Call the model's approve method which handles the business
|
||||||
@@ -90,8 +90,8 @@ class ModerationService:
|
|||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
submission = EditSubmission.objects.select_for_update().get(id=submission_id)
|
submission = EditSubmission.objects.select_for_update().get(id=submission_id)
|
||||||
|
|
||||||
if submission.status != "PENDING":
|
if submission.status != "CLAIMED":
|
||||||
raise ValueError(f"Submission {submission_id} is not pending review")
|
raise ValueError(f"Submission {submission_id} must be claimed before rejection (current status: {submission.status})")
|
||||||
|
|
||||||
# Use FSM transition method
|
# Use FSM transition method
|
||||||
submission.transition_to_rejected(user=moderator)
|
submission.transition_to_rejected(user=moderator)
|
||||||
@@ -169,8 +169,8 @@ class ModerationService:
|
|||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
submission = EditSubmission.objects.select_for_update().get(id=submission_id)
|
submission = EditSubmission.objects.select_for_update().get(id=submission_id)
|
||||||
|
|
||||||
if submission.status != "PENDING":
|
if submission.status not in ("PENDING", "CLAIMED"):
|
||||||
raise ValueError(f"Submission {submission_id} is not pending review")
|
raise ValueError(f"Submission {submission_id} is not pending or claimed for review")
|
||||||
|
|
||||||
submission.moderator_changes = moderator_changes
|
submission.moderator_changes = moderator_changes
|
||||||
|
|
||||||
@@ -281,8 +281,9 @@ class ModerationService:
|
|||||||
|
|
||||||
# Check if user is moderator or above
|
# Check if user is moderator or above
|
||||||
if ModerationService._is_moderator_or_above(submitter):
|
if ModerationService._is_moderator_or_above(submitter):
|
||||||
# Auto-approve for moderators
|
# Auto-approve for moderators - must claim first then approve
|
||||||
try:
|
try:
|
||||||
|
submission.claim(user=submitter)
|
||||||
created_object = submission.approve(submitter)
|
created_object = submission.approve(submitter)
|
||||||
return {
|
return {
|
||||||
"submission": submission,
|
"submission": submission,
|
||||||
|
|||||||
170
backend/apps/moderation/tasks.py
Normal file
170
backend/apps/moderation/tasks.py
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
@@ -399,11 +399,17 @@ class EditSubmissionTransitionTests(TestCase):
|
|||||||
reason="Test reason",
|
reason="Test reason",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_pending_to_approved_transition(self):
|
def test_pending_to_claimed_to_approved_transition(self):
|
||||||
"""Test transition from PENDING to APPROVED."""
|
"""Test transition from PENDING to CLAIMED to APPROVED (mandatory flow)."""
|
||||||
submission = self._create_submission()
|
submission = self._create_submission()
|
||||||
self.assertEqual(submission.status, "PENDING")
|
self.assertEqual(submission.status, "PENDING")
|
||||||
|
|
||||||
|
# Must claim first
|
||||||
|
submission.claim(user=self.moderator)
|
||||||
|
submission.refresh_from_db()
|
||||||
|
self.assertEqual(submission.status, "CLAIMED")
|
||||||
|
|
||||||
|
# Now can approve
|
||||||
submission.transition_to_approved(user=self.moderator)
|
submission.transition_to_approved(user=self.moderator)
|
||||||
submission.handled_by = self.moderator
|
submission.handled_by = self.moderator
|
||||||
submission.handled_at = timezone.now()
|
submission.handled_at = timezone.now()
|
||||||
@@ -414,11 +420,17 @@ class EditSubmissionTransitionTests(TestCase):
|
|||||||
self.assertEqual(submission.handled_by, self.moderator)
|
self.assertEqual(submission.handled_by, self.moderator)
|
||||||
self.assertIsNotNone(submission.handled_at)
|
self.assertIsNotNone(submission.handled_at)
|
||||||
|
|
||||||
def test_pending_to_rejected_transition(self):
|
def test_pending_to_claimed_to_rejected_transition(self):
|
||||||
"""Test transition from PENDING to REJECTED."""
|
"""Test transition from PENDING to CLAIMED to REJECTED (mandatory flow)."""
|
||||||
submission = self._create_submission()
|
submission = self._create_submission()
|
||||||
self.assertEqual(submission.status, "PENDING")
|
self.assertEqual(submission.status, "PENDING")
|
||||||
|
|
||||||
|
# Must claim first
|
||||||
|
submission.claim(user=self.moderator)
|
||||||
|
submission.refresh_from_db()
|
||||||
|
self.assertEqual(submission.status, "CLAIMED")
|
||||||
|
|
||||||
|
# Now can reject
|
||||||
submission.transition_to_rejected(user=self.moderator)
|
submission.transition_to_rejected(user=self.moderator)
|
||||||
submission.handled_by = self.moderator
|
submission.handled_by = self.moderator
|
||||||
submission.handled_at = timezone.now()
|
submission.handled_at = timezone.now()
|
||||||
@@ -430,11 +442,17 @@ class EditSubmissionTransitionTests(TestCase):
|
|||||||
self.assertEqual(submission.handled_by, self.moderator)
|
self.assertEqual(submission.handled_by, self.moderator)
|
||||||
self.assertIn("Rejected", submission.notes)
|
self.assertIn("Rejected", submission.notes)
|
||||||
|
|
||||||
def test_pending_to_escalated_transition(self):
|
def test_pending_to_claimed_to_escalated_transition(self):
|
||||||
"""Test transition from PENDING to ESCALATED."""
|
"""Test transition from PENDING to CLAIMED to ESCALATED (mandatory flow)."""
|
||||||
submission = self._create_submission()
|
submission = self._create_submission()
|
||||||
self.assertEqual(submission.status, "PENDING")
|
self.assertEqual(submission.status, "PENDING")
|
||||||
|
|
||||||
|
# Must claim first
|
||||||
|
submission.claim(user=self.moderator)
|
||||||
|
submission.refresh_from_db()
|
||||||
|
self.assertEqual(submission.status, "CLAIMED")
|
||||||
|
|
||||||
|
# Now can escalate
|
||||||
submission.transition_to_escalated(user=self.moderator)
|
submission.transition_to_escalated(user=self.moderator)
|
||||||
submission.handled_by = self.moderator
|
submission.handled_by = self.moderator
|
||||||
submission.handled_at = timezone.now()
|
submission.handled_at = timezone.now()
|
||||||
@@ -487,9 +505,15 @@ class EditSubmissionTransitionTests(TestCase):
|
|||||||
submission.transition_to_approved(user=self.moderator)
|
submission.transition_to_approved(user=self.moderator)
|
||||||
|
|
||||||
def test_approve_wrapper_method(self):
|
def test_approve_wrapper_method(self):
|
||||||
"""Test the approve() wrapper method."""
|
"""Test the approve() wrapper method (requires CLAIMED state first)."""
|
||||||
submission = self._create_submission()
|
submission = self._create_submission()
|
||||||
|
|
||||||
|
# Must claim first
|
||||||
|
submission.claim(user=self.moderator)
|
||||||
|
submission.refresh_from_db()
|
||||||
|
self.assertEqual(submission.status, "CLAIMED")
|
||||||
|
|
||||||
|
# Now can approve
|
||||||
submission.approve(self.moderator)
|
submission.approve(self.moderator)
|
||||||
|
|
||||||
submission.refresh_from_db()
|
submission.refresh_from_db()
|
||||||
@@ -498,9 +522,15 @@ class EditSubmissionTransitionTests(TestCase):
|
|||||||
self.assertIsNotNone(submission.handled_at)
|
self.assertIsNotNone(submission.handled_at)
|
||||||
|
|
||||||
def test_reject_wrapper_method(self):
|
def test_reject_wrapper_method(self):
|
||||||
"""Test the reject() wrapper method."""
|
"""Test the reject() wrapper method (requires CLAIMED state first)."""
|
||||||
submission = self._create_submission()
|
submission = self._create_submission()
|
||||||
|
|
||||||
|
# Must claim first
|
||||||
|
submission.claim(user=self.moderator)
|
||||||
|
submission.refresh_from_db()
|
||||||
|
self.assertEqual(submission.status, "CLAIMED")
|
||||||
|
|
||||||
|
# Now can reject
|
||||||
submission.reject(self.moderator, reason="Not enough evidence")
|
submission.reject(self.moderator, reason="Not enough evidence")
|
||||||
|
|
||||||
submission.refresh_from_db()
|
submission.refresh_from_db()
|
||||||
@@ -508,9 +538,15 @@ class EditSubmissionTransitionTests(TestCase):
|
|||||||
self.assertIn("Not enough evidence", submission.notes)
|
self.assertIn("Not enough evidence", submission.notes)
|
||||||
|
|
||||||
def test_escalate_wrapper_method(self):
|
def test_escalate_wrapper_method(self):
|
||||||
"""Test the escalate() wrapper method."""
|
"""Test the escalate() wrapper method (requires CLAIMED state first)."""
|
||||||
submission = self._create_submission()
|
submission = self._create_submission()
|
||||||
|
|
||||||
|
# Must claim first
|
||||||
|
submission.claim(user=self.moderator)
|
||||||
|
submission.refresh_from_db()
|
||||||
|
self.assertEqual(submission.status, "CLAIMED")
|
||||||
|
|
||||||
|
# Now can escalate
|
||||||
submission.escalate(self.moderator, reason="Needs admin approval")
|
submission.escalate(self.moderator, reason="Needs admin approval")
|
||||||
|
|
||||||
submission.refresh_from_db()
|
submission.refresh_from_db()
|
||||||
@@ -846,18 +882,23 @@ class TransitionLoggingTestCase(TestCase):
|
|||||||
reason="Test reason",
|
reason="Test reason",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Must claim first (FSM requirement)
|
||||||
|
submission.claim(user=self.moderator)
|
||||||
|
submission.refresh_from_db()
|
||||||
|
|
||||||
# Perform transition
|
# Perform transition
|
||||||
submission.transition_to_approved(user=self.moderator)
|
submission.transition_to_approved(user=self.moderator)
|
||||||
submission.save()
|
submission.save()
|
||||||
|
|
||||||
# Check log was created
|
# Check log was created
|
||||||
submission_ct = ContentType.objects.get_for_model(submission)
|
submission_ct = ContentType.objects.get_for_model(submission)
|
||||||
log = StateLog.objects.filter(content_type=submission_ct, object_id=submission.id).first()
|
log = StateLog.objects.filter(
|
||||||
|
content_type=submission_ct, object_id=submission.id, state="APPROVED"
|
||||||
|
).first()
|
||||||
|
|
||||||
self.assertIsNotNone(log, "StateLog entry should be created")
|
self.assertIsNotNone(log, "StateLog entry should be created")
|
||||||
self.assertEqual(log.state, "APPROVED")
|
self.assertEqual(log.state, "APPROVED")
|
||||||
self.assertEqual(log.by, self.moderator)
|
self.assertEqual(log.by, self.moderator)
|
||||||
self.assertIn("approved", log.transition.lower())
|
|
||||||
|
|
||||||
def test_multiple_transitions_logged(self):
|
def test_multiple_transitions_logged(self):
|
||||||
"""Test that multiple transitions are all logged."""
|
"""Test that multiple transitions are all logged."""
|
||||||
@@ -875,20 +916,28 @@ class TransitionLoggingTestCase(TestCase):
|
|||||||
|
|
||||||
submission_ct = ContentType.objects.get_for_model(submission)
|
submission_ct = ContentType.objects.get_for_model(submission)
|
||||||
|
|
||||||
# First transition
|
# First claim (FSM requirement)
|
||||||
|
submission.claim(user=self.moderator)
|
||||||
|
submission.refresh_from_db()
|
||||||
|
|
||||||
|
# First transition: CLAIMED -> ESCALATED
|
||||||
submission.transition_to_escalated(user=self.moderator)
|
submission.transition_to_escalated(user=self.moderator)
|
||||||
submission.save()
|
submission.save()
|
||||||
|
|
||||||
# Second transition
|
# Second transition: ESCALATED -> APPROVED
|
||||||
submission.transition_to_approved(user=self.moderator)
|
submission.transition_to_approved(user=self.moderator)
|
||||||
submission.save()
|
submission.save()
|
||||||
|
|
||||||
# Check multiple logs created
|
# Check logs created (excluding the claim transition log)
|
||||||
logs = StateLog.objects.filter(content_type=submission_ct, object_id=submission.id).order_by("timestamp")
|
logs = StateLog.objects.filter(
|
||||||
|
content_type=submission_ct, object_id=submission.id
|
||||||
|
).order_by("timestamp")
|
||||||
|
|
||||||
self.assertEqual(logs.count(), 2, "Should have 2 log entries")
|
# Should have at least 2 entries for ESCALATED and APPROVED
|
||||||
self.assertEqual(logs[0].state, "ESCALATED")
|
self.assertGreaterEqual(logs.count(), 2, "Should have at least 2 log entries")
|
||||||
self.assertEqual(logs[1].state, "APPROVED")
|
states = [log.state for log in logs]
|
||||||
|
self.assertIn("ESCALATED", states)
|
||||||
|
self.assertIn("APPROVED", states)
|
||||||
|
|
||||||
def test_history_endpoint_returns_logs(self):
|
def test_history_endpoint_returns_logs(self):
|
||||||
"""Test history API endpoint returns transition logs."""
|
"""Test history API endpoint returns transition logs."""
|
||||||
@@ -907,6 +956,10 @@ class TransitionLoggingTestCase(TestCase):
|
|||||||
reason="Test reason",
|
reason="Test reason",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Must claim first (FSM requirement)
|
||||||
|
submission.claim(user=self.moderator)
|
||||||
|
submission.refresh_from_db()
|
||||||
|
|
||||||
# Perform transition to create log
|
# Perform transition to create log
|
||||||
submission.transition_to_approved(user=self.moderator)
|
submission.transition_to_approved(user=self.moderator)
|
||||||
submission.save()
|
submission.save()
|
||||||
@@ -918,7 +971,7 @@ class TransitionLoggingTestCase(TestCase):
|
|||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
def test_system_transitions_without_user(self):
|
def test_system_transitions_without_user(self):
|
||||||
"""Test that system transitions work without a user."""
|
"""Test that system transitions work without a user (admin/cron operations)."""
|
||||||
from django_fsm_log.models import StateLog
|
from django_fsm_log.models import StateLog
|
||||||
|
|
||||||
submission = EditSubmission.objects.create(
|
submission = EditSubmission.objects.create(
|
||||||
@@ -931,13 +984,19 @@ class TransitionLoggingTestCase(TestCase):
|
|||||||
reason="Test reason",
|
reason="Test reason",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Perform transition without user
|
# Must claim first (FSM requirement)
|
||||||
|
submission.claim(user=self.moderator)
|
||||||
|
submission.refresh_from_db()
|
||||||
|
|
||||||
|
# Perform transition without user (simulating system/cron action)
|
||||||
submission.transition_to_rejected(user=None)
|
submission.transition_to_rejected(user=None)
|
||||||
submission.save()
|
submission.save()
|
||||||
|
|
||||||
# Check log was created even without user
|
# Check log was created even without user
|
||||||
submission_ct = ContentType.objects.get_for_model(submission)
|
submission_ct = ContentType.objects.get_for_model(submission)
|
||||||
log = StateLog.objects.filter(content_type=submission_ct, object_id=submission.id).first()
|
log = StateLog.objects.filter(
|
||||||
|
content_type=submission_ct, object_id=submission.id, state="REJECTED"
|
||||||
|
).first()
|
||||||
|
|
||||||
self.assertIsNotNone(log)
|
self.assertIsNotNone(log)
|
||||||
self.assertEqual(log.state, "REJECTED")
|
self.assertEqual(log.state, "REJECTED")
|
||||||
@@ -957,13 +1016,19 @@ class TransitionLoggingTestCase(TestCase):
|
|||||||
reason="Test reason",
|
reason="Test reason",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Must claim first (FSM requirement)
|
||||||
|
submission.claim(user=self.moderator)
|
||||||
|
submission.refresh_from_db()
|
||||||
|
|
||||||
# Perform transition
|
# Perform transition
|
||||||
submission.transition_to_approved(user=self.moderator)
|
submission.transition_to_approved(user=self.moderator)
|
||||||
submission.save()
|
submission.save()
|
||||||
|
|
||||||
# Check log
|
# Check log
|
||||||
submission_ct = ContentType.objects.get_for_model(submission)
|
submission_ct = ContentType.objects.get_for_model(submission)
|
||||||
log = StateLog.objects.filter(content_type=submission_ct, object_id=submission.id).first()
|
log = StateLog.objects.filter(
|
||||||
|
content_type=submission_ct, object_id=submission.id, state="APPROVED"
|
||||||
|
).first()
|
||||||
|
|
||||||
self.assertIsNotNone(log)
|
self.assertIsNotNone(log)
|
||||||
# Description field exists and can be used for audit trails
|
# Description field exists and can be used for audit trails
|
||||||
@@ -986,6 +1051,10 @@ class TransitionLoggingTestCase(TestCase):
|
|||||||
|
|
||||||
submission_ct = ContentType.objects.get_for_model(submission)
|
submission_ct = ContentType.objects.get_for_model(submission)
|
||||||
|
|
||||||
|
# Must claim first (FSM requirement)
|
||||||
|
submission.claim(user=self.moderator)
|
||||||
|
submission.refresh_from_db()
|
||||||
|
|
||||||
# Create multiple transitions
|
# Create multiple transitions
|
||||||
submission.transition_to_escalated(user=self.moderator)
|
submission.transition_to_escalated(user=self.moderator)
|
||||||
submission.save()
|
submission.save()
|
||||||
@@ -996,9 +1065,11 @@ class TransitionLoggingTestCase(TestCase):
|
|||||||
# Get logs ordered by timestamp
|
# Get logs ordered by timestamp
|
||||||
logs = list(StateLog.objects.filter(content_type=submission_ct, object_id=submission.id).order_by("timestamp"))
|
logs = list(StateLog.objects.filter(content_type=submission_ct, object_id=submission.id).order_by("timestamp"))
|
||||||
|
|
||||||
# Verify ordering
|
# Verify ordering - should have at least 2 logs (escalated and approved)
|
||||||
self.assertEqual(len(logs), 2)
|
self.assertGreaterEqual(len(logs), 2)
|
||||||
self.assertTrue(logs[0].timestamp <= logs[1].timestamp)
|
# Verify timestamps are ordered
|
||||||
|
for i in range(len(logs) - 1):
|
||||||
|
self.assertTrue(logs[i].timestamp <= logs[i + 1].timestamp)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -1065,10 +1136,16 @@ class ModerationActionTests(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class PhotoSubmissionTransitionTests(TestCase):
|
class PhotoSubmissionTransitionTests(TestCase):
|
||||||
"""Comprehensive tests for PhotoSubmission FSM transitions."""
|
"""Comprehensive tests for PhotoSubmission FSM transitions.
|
||||||
|
|
||||||
|
Note: All approve/reject/escalate transitions require CLAIMED state first.
|
||||||
|
"""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
"""Set up test fixtures."""
|
"""Set up test fixtures."""
|
||||||
|
from datetime import timedelta
|
||||||
|
from django_cloudflareimages_toolkit.models import CloudflareImage
|
||||||
|
|
||||||
self.user = User.objects.create_user(
|
self.user = User.objects.create_user(
|
||||||
username="testuser", email="test@example.com", password="testpass123", role="USER"
|
username="testuser", email="test@example.com", password="testpass123", role="USER"
|
||||||
)
|
)
|
||||||
@@ -1083,42 +1160,59 @@ class PhotoSubmissionTransitionTests(TestCase):
|
|||||||
)
|
)
|
||||||
self.content_type = ContentType.objects.get_for_model(Operator)
|
self.content_type = ContentType.objects.get_for_model(Operator)
|
||||||
|
|
||||||
def _create_mock_photo(self):
|
# Create a real CloudflareImage for tests (required by FK constraint)
|
||||||
"""Create a mock CloudflareImage for testing."""
|
self.mock_image = CloudflareImage.objects.create(
|
||||||
from unittest.mock import Mock
|
cloudflare_id=f"test-cf-photo-{id(self)}",
|
||||||
|
user=self.user,
|
||||||
mock_photo = Mock()
|
expires_at=timezone.now() + timedelta(days=365),
|
||||||
mock_photo.pk = 1
|
)
|
||||||
mock_photo.id = 1
|
|
||||||
return mock_photo
|
|
||||||
|
|
||||||
def _create_submission(self, status="PENDING"):
|
def _create_submission(self, status="PENDING"):
|
||||||
"""Helper to create a PhotoSubmission."""
|
"""Helper to create a PhotoSubmission with proper CloudflareImage."""
|
||||||
# Create using direct database creation to bypass FK validation
|
submission = PhotoSubmission.objects.create(
|
||||||
from unittest.mock import Mock, patch
|
user=self.user,
|
||||||
|
content_type=self.content_type,
|
||||||
|
object_id=self.operator.id,
|
||||||
|
photo=self.mock_image,
|
||||||
|
caption="Test Photo",
|
||||||
|
status="PENDING", # Always create as PENDING first
|
||||||
|
)
|
||||||
|
|
||||||
with patch.object(PhotoSubmission, "photo", Mock()):
|
# For non-PENDING states, we need to transition through CLAIMED
|
||||||
submission = PhotoSubmission(
|
if status == "CLAIMED":
|
||||||
user=self.user,
|
submission.claim(user=self.moderator)
|
||||||
content_type=self.content_type,
|
submission.refresh_from_db()
|
||||||
object_id=self.operator.id,
|
elif status in ("APPROVED", "REJECTED", "ESCALATED"):
|
||||||
caption="Test Photo",
|
# First claim, then transition to target state
|
||||||
status=status,
|
submission.claim(user=self.moderator)
|
||||||
)
|
if status == "APPROVED":
|
||||||
# Bypass model save to avoid FK constraint on photo
|
submission.transition_to_approved(user=self.moderator)
|
||||||
submission.photo_id = 1
|
elif status == "REJECTED":
|
||||||
submission.save(update_fields=None)
|
submission.transition_to_rejected(user=self.moderator)
|
||||||
# Force status after creation for non-PENDING states
|
elif status == "ESCALATED":
|
||||||
if status != "PENDING":
|
submission.transition_to_escalated(user=self.moderator)
|
||||||
PhotoSubmission.objects.filter(pk=submission.pk).update(status=status)
|
submission.save()
|
||||||
submission.refresh_from_db()
|
submission.refresh_from_db()
|
||||||
return submission
|
|
||||||
|
|
||||||
def test_pending_to_approved_transition(self):
|
return submission
|
||||||
"""Test transition from PENDING to APPROVED."""
|
|
||||||
|
def test_pending_to_claimed_transition(self):
|
||||||
|
"""Test transition from PENDING to CLAIMED."""
|
||||||
submission = self._create_submission()
|
submission = self._create_submission()
|
||||||
self.assertEqual(submission.status, "PENDING")
|
self.assertEqual(submission.status, "PENDING")
|
||||||
|
|
||||||
|
submission.claim(user=self.moderator)
|
||||||
|
submission.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertEqual(submission.status, "CLAIMED")
|
||||||
|
self.assertEqual(submission.claimed_by, self.moderator)
|
||||||
|
self.assertIsNotNone(submission.claimed_at)
|
||||||
|
|
||||||
|
def test_claimed_to_approved_transition(self):
|
||||||
|
"""Test transition from CLAIMED to APPROVED (mandatory flow)."""
|
||||||
|
submission = self._create_submission(status="CLAIMED")
|
||||||
|
self.assertEqual(submission.status, "CLAIMED")
|
||||||
|
|
||||||
submission.transition_to_approved(user=self.moderator)
|
submission.transition_to_approved(user=self.moderator)
|
||||||
submission.handled_by = self.moderator
|
submission.handled_by = self.moderator
|
||||||
submission.handled_at = timezone.now()
|
submission.handled_at = timezone.now()
|
||||||
@@ -1129,10 +1223,10 @@ class PhotoSubmissionTransitionTests(TestCase):
|
|||||||
self.assertEqual(submission.handled_by, self.moderator)
|
self.assertEqual(submission.handled_by, self.moderator)
|
||||||
self.assertIsNotNone(submission.handled_at)
|
self.assertIsNotNone(submission.handled_at)
|
||||||
|
|
||||||
def test_pending_to_rejected_transition(self):
|
def test_claimed_to_rejected_transition(self):
|
||||||
"""Test transition from PENDING to REJECTED."""
|
"""Test transition from CLAIMED to REJECTED (mandatory flow)."""
|
||||||
submission = self._create_submission()
|
submission = self._create_submission(status="CLAIMED")
|
||||||
self.assertEqual(submission.status, "PENDING")
|
self.assertEqual(submission.status, "CLAIMED")
|
||||||
|
|
||||||
submission.transition_to_rejected(user=self.moderator)
|
submission.transition_to_rejected(user=self.moderator)
|
||||||
submission.handled_by = self.moderator
|
submission.handled_by = self.moderator
|
||||||
@@ -1145,10 +1239,10 @@ class PhotoSubmissionTransitionTests(TestCase):
|
|||||||
self.assertEqual(submission.handled_by, self.moderator)
|
self.assertEqual(submission.handled_by, self.moderator)
|
||||||
self.assertIn("Rejected", submission.notes)
|
self.assertIn("Rejected", submission.notes)
|
||||||
|
|
||||||
def test_pending_to_escalated_transition(self):
|
def test_claimed_to_escalated_transition(self):
|
||||||
"""Test transition from PENDING to ESCALATED."""
|
"""Test transition from CLAIMED to ESCALATED (mandatory flow)."""
|
||||||
submission = self._create_submission()
|
submission = self._create_submission(status="CLAIMED")
|
||||||
self.assertEqual(submission.status, "PENDING")
|
self.assertEqual(submission.status, "CLAIMED")
|
||||||
|
|
||||||
submission.transition_to_escalated(user=self.moderator)
|
submission.transition_to_escalated(user=self.moderator)
|
||||||
submission.handled_by = self.moderator
|
submission.handled_by = self.moderator
|
||||||
@@ -1199,28 +1293,22 @@ class PhotoSubmissionTransitionTests(TestCase):
|
|||||||
with self.assertRaises(TransitionNotAllowed):
|
with self.assertRaises(TransitionNotAllowed):
|
||||||
submission.transition_to_approved(user=self.moderator)
|
submission.transition_to_approved(user=self.moderator)
|
||||||
|
|
||||||
|
|
||||||
def test_reject_wrapper_method(self):
|
def test_reject_wrapper_method(self):
|
||||||
"""Test the reject() wrapper method."""
|
"""Test the reject() wrapper method (requires CLAIMED state first)."""
|
||||||
from unittest.mock import patch
|
submission = self._create_submission(status="CLAIMED")
|
||||||
|
|
||||||
submission = self._create_submission()
|
submission.reject(self.moderator, notes="Not suitable")
|
||||||
|
|
||||||
# Mock the photo creation part since we don't have actual photos
|
|
||||||
with patch.object(submission, "transition_to_rejected"):
|
|
||||||
submission.reject(self.moderator, notes="Not suitable")
|
|
||||||
|
|
||||||
submission.refresh_from_db()
|
submission.refresh_from_db()
|
||||||
self.assertEqual(submission.status, "REJECTED")
|
self.assertEqual(submission.status, "REJECTED")
|
||||||
self.assertIn("Not suitable", submission.notes)
|
self.assertIn("Not suitable", submission.notes)
|
||||||
|
|
||||||
def test_escalate_wrapper_method(self):
|
def test_escalate_wrapper_method(self):
|
||||||
"""Test the escalate() wrapper method."""
|
"""Test the escalate() wrapper method (requires CLAIMED state first)."""
|
||||||
from unittest.mock import patch
|
submission = self._create_submission(status="CLAIMED")
|
||||||
|
|
||||||
submission = self._create_submission()
|
submission.escalate(self.moderator, notes="Needs admin review")
|
||||||
|
|
||||||
with patch.object(submission, "transition_to_escalated"):
|
|
||||||
submission.escalate(self.moderator, notes="Needs admin review")
|
|
||||||
|
|
||||||
submission.refresh_from_db()
|
submission.refresh_from_db()
|
||||||
self.assertEqual(submission.status, "ESCALATED")
|
self.assertEqual(submission.status, "ESCALATED")
|
||||||
@@ -1230,7 +1318,7 @@ class PhotoSubmissionTransitionTests(TestCase):
|
|||||||
"""Test that transitions create StateLog entries."""
|
"""Test that transitions create StateLog entries."""
|
||||||
from django_fsm_log.models import StateLog
|
from django_fsm_log.models import StateLog
|
||||||
|
|
||||||
submission = self._create_submission()
|
submission = self._create_submission(status="CLAIMED")
|
||||||
|
|
||||||
# Perform transition
|
# Perform transition
|
||||||
submission.transition_to_approved(user=self.moderator)
|
submission.transition_to_approved(user=self.moderator)
|
||||||
@@ -1248,10 +1336,10 @@ class PhotoSubmissionTransitionTests(TestCase):
|
|||||||
"""Test that multiple transitions are all logged."""
|
"""Test that multiple transitions are all logged."""
|
||||||
from django_fsm_log.models import StateLog
|
from django_fsm_log.models import StateLog
|
||||||
|
|
||||||
submission = self._create_submission()
|
submission = self._create_submission(status="CLAIMED")
|
||||||
submission_ct = ContentType.objects.get_for_model(submission)
|
submission_ct = ContentType.objects.get_for_model(submission)
|
||||||
|
|
||||||
# First transition: PENDING -> ESCALATED
|
# First transition: CLAIMED -> ESCALATED
|
||||||
submission.transition_to_escalated(user=self.moderator)
|
submission.transition_to_escalated(user=self.moderator)
|
||||||
submission.save()
|
submission.save()
|
||||||
|
|
||||||
@@ -1268,10 +1356,7 @@ class PhotoSubmissionTransitionTests(TestCase):
|
|||||||
|
|
||||||
def test_handled_by_and_handled_at_updated(self):
|
def test_handled_by_and_handled_at_updated(self):
|
||||||
"""Test that handled_by and handled_at are properly updated."""
|
"""Test that handled_by and handled_at are properly updated."""
|
||||||
submission = self._create_submission()
|
submission = self._create_submission(status="CLAIMED")
|
||||||
|
|
||||||
self.assertIsNone(submission.handled_by)
|
|
||||||
self.assertIsNone(submission.handled_at)
|
|
||||||
|
|
||||||
before_time = timezone.now()
|
before_time = timezone.now()
|
||||||
submission.transition_to_approved(user=self.moderator)
|
submission.transition_to_approved(user=self.moderator)
|
||||||
@@ -1287,7 +1372,7 @@ class PhotoSubmissionTransitionTests(TestCase):
|
|||||||
|
|
||||||
def test_notes_field_updated_on_rejection(self):
|
def test_notes_field_updated_on_rejection(self):
|
||||||
"""Test that notes field is updated with rejection reason."""
|
"""Test that notes field is updated with rejection reason."""
|
||||||
submission = self._create_submission()
|
submission = self._create_submission(status="CLAIMED")
|
||||||
rejection_reason = "Image contains watermarks"
|
rejection_reason = "Image contains watermarks"
|
||||||
|
|
||||||
submission.transition_to_rejected(user=self.moderator)
|
submission.transition_to_rejected(user=self.moderator)
|
||||||
@@ -1299,7 +1384,7 @@ class PhotoSubmissionTransitionTests(TestCase):
|
|||||||
|
|
||||||
def test_notes_field_updated_on_escalation(self):
|
def test_notes_field_updated_on_escalation(self):
|
||||||
"""Test that notes field is updated with escalation reason."""
|
"""Test that notes field is updated with escalation reason."""
|
||||||
submission = self._create_submission()
|
submission = self._create_submission(status="CLAIMED")
|
||||||
escalation_reason = "Potentially copyrighted content"
|
escalation_reason = "Potentially copyrighted content"
|
||||||
|
|
||||||
submission.transition_to_escalated(user=self.moderator)
|
submission.transition_to_escalated(user=self.moderator)
|
||||||
@@ -1308,3 +1393,4 @@ class PhotoSubmissionTransitionTests(TestCase):
|
|||||||
|
|
||||||
submission.refresh_from_db()
|
submission.refresh_from_db()
|
||||||
self.assertEqual(submission.notes, escalation_reason)
|
self.assertEqual(submission.notes, escalation_reason)
|
||||||
|
|
||||||
|
|||||||
BIN
backend/celerybeat-schedule
Normal file
BIN
backend/celerybeat-schedule
Normal file
Binary file not shown.
BIN
backend/celerybeat-schedule-shm
Normal file
BIN
backend/celerybeat-schedule-shm
Normal file
Binary file not shown.
BIN
backend/celerybeat-schedule-wal
Normal file
BIN
backend/celerybeat-schedule-wal
Normal file
Binary file not shown.
@@ -91,6 +91,10 @@ app.conf.update(
|
|||||||
"task": "core.data_retention_cleanup",
|
"task": "core.data_retention_cleanup",
|
||||||
"schedule": 86400.0, # Daily
|
"schedule": 86400.0, # Daily
|
||||||
},
|
},
|
||||||
|
"moderation-expire-stale-claims": {
|
||||||
|
"task": "moderation.expire_stale_claims",
|
||||||
|
"schedule": 300.0, # Every 5 minutes
|
||||||
|
},
|
||||||
},
|
},
|
||||||
# Task result settings
|
# Task result settings
|
||||||
result_expires=3600, # 1 hour
|
result_expires=3600, # 1 hour
|
||||||
|
|||||||
Reference in New Issue
Block a user