From 40cba5bdb2fac62a70d18c08403edbb8f550b679 Mon Sep 17 00:00:00 2001 From: pacnpal <183241239+pacnpal@users.noreply.github.com> Date: Wed, 7 Jan 2026 13:41:52 -0500 Subject: [PATCH] feat: Introduce a `CLAIMED` state for moderation submissions, requiring claims before approval or rejection, and add a scheduled task to expire stale claims. --- .../commands/expire_stale_claims.py | 95 +++++++ backend/apps/moderation/services.py | 15 +- backend/apps/moderation/tasks.py | 170 ++++++++++++ .../moderation/tests/test_comprehensive.py | 260 ++++++++++++------ backend/celerybeat-schedule | Bin 0 -> 4096 bytes backend/celerybeat-schedule-shm | Bin 0 -> 32768 bytes backend/celerybeat-schedule-wal | Bin 0 -> 243112 bytes backend/config/celery.py | 4 + 8 files changed, 450 insertions(+), 94 deletions(-) create mode 100644 backend/apps/moderation/management/commands/expire_stale_claims.py create mode 100644 backend/apps/moderation/tasks.py create mode 100644 backend/celerybeat-schedule create mode 100644 backend/celerybeat-schedule-shm create mode 100644 backend/celerybeat-schedule-wal diff --git a/backend/apps/moderation/management/commands/expire_stale_claims.py b/backend/apps/moderation/management/commands/expire_stale_claims.py new file mode 100644 index 00000000..50664145 --- /dev/null +++ b/backend/apps/moderation/management/commands/expire_stale_claims.py @@ -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}") diff --git a/backend/apps/moderation/services.py b/backend/apps/moderation/services.py index bead54d6..c0fcb738 100644 --- a/backend/apps/moderation/services.py +++ b/backend/apps/moderation/services.py @@ -39,8 +39,8 @@ class ModerationService: with transaction.atomic(): submission = EditSubmission.objects.select_for_update().get(id=submission_id) - if submission.status != "PENDING": - raise ValueError(f"Submission {submission_id} is not pending approval") + if submission.status != "CLAIMED": + raise ValueError(f"Submission {submission_id} must be claimed before approval (current status: {submission.status})") try: # Call the model's approve method which handles the business @@ -90,8 +90,8 @@ class ModerationService: with transaction.atomic(): submission = EditSubmission.objects.select_for_update().get(id=submission_id) - if submission.status != "PENDING": - raise ValueError(f"Submission {submission_id} is not pending review") + if submission.status != "CLAIMED": + raise ValueError(f"Submission {submission_id} must be claimed before rejection (current status: {submission.status})") # Use FSM transition method submission.transition_to_rejected(user=moderator) @@ -169,8 +169,8 @@ class ModerationService: with transaction.atomic(): submission = EditSubmission.objects.select_for_update().get(id=submission_id) - if submission.status != "PENDING": - raise ValueError(f"Submission {submission_id} is not pending review") + if submission.status not in ("PENDING", "CLAIMED"): + raise ValueError(f"Submission {submission_id} is not pending or claimed for review") submission.moderator_changes = moderator_changes @@ -281,8 +281,9 @@ class ModerationService: # Check if user is moderator or above if ModerationService._is_moderator_or_above(submitter): - # Auto-approve for moderators + # Auto-approve for moderators - must claim first then approve try: + submission.claim(user=submitter) created_object = submission.approve(submitter) return { "submission": submission, diff --git a/backend/apps/moderation/tasks.py b/backend/apps/moderation/tasks.py new file mode 100644 index 00000000..dccd32f3 --- /dev/null +++ b/backend/apps/moderation/tasks.py @@ -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, + ) diff --git a/backend/apps/moderation/tests/test_comprehensive.py b/backend/apps/moderation/tests/test_comprehensive.py index 0d548625..83c8e811 100644 --- a/backend/apps/moderation/tests/test_comprehensive.py +++ b/backend/apps/moderation/tests/test_comprehensive.py @@ -399,11 +399,17 @@ class EditSubmissionTransitionTests(TestCase): reason="Test reason", ) - def test_pending_to_approved_transition(self): - """Test transition from PENDING to APPROVED.""" + def test_pending_to_claimed_to_approved_transition(self): + """Test transition from PENDING to CLAIMED to APPROVED (mandatory flow).""" submission = self._create_submission() 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.handled_by = self.moderator submission.handled_at = timezone.now() @@ -414,11 +420,17 @@ class EditSubmissionTransitionTests(TestCase): self.assertEqual(submission.handled_by, self.moderator) self.assertIsNotNone(submission.handled_at) - def test_pending_to_rejected_transition(self): - """Test transition from PENDING to REJECTED.""" + def test_pending_to_claimed_to_rejected_transition(self): + """Test transition from PENDING to CLAIMED to REJECTED (mandatory flow).""" submission = self._create_submission() 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.handled_by = self.moderator submission.handled_at = timezone.now() @@ -430,11 +442,17 @@ class EditSubmissionTransitionTests(TestCase): self.assertEqual(submission.handled_by, self.moderator) self.assertIn("Rejected", submission.notes) - def test_pending_to_escalated_transition(self): - """Test transition from PENDING to ESCALATED.""" + def test_pending_to_claimed_to_escalated_transition(self): + """Test transition from PENDING to CLAIMED to ESCALATED (mandatory flow).""" submission = self._create_submission() 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.handled_by = self.moderator submission.handled_at = timezone.now() @@ -487,9 +505,15 @@ class EditSubmissionTransitionTests(TestCase): submission.transition_to_approved(user=self.moderator) def test_approve_wrapper_method(self): - """Test the approve() wrapper method.""" + """Test the approve() wrapper method (requires CLAIMED state first).""" 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.refresh_from_db() @@ -498,9 +522,15 @@ class EditSubmissionTransitionTests(TestCase): self.assertIsNotNone(submission.handled_at) def test_reject_wrapper_method(self): - """Test the reject() wrapper method.""" + """Test the reject() wrapper method (requires CLAIMED state first).""" 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.refresh_from_db() @@ -508,9 +538,15 @@ class EditSubmissionTransitionTests(TestCase): self.assertIn("Not enough evidence", submission.notes) def test_escalate_wrapper_method(self): - """Test the escalate() wrapper method.""" + """Test the escalate() wrapper method (requires CLAIMED state first).""" 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.refresh_from_db() @@ -846,18 +882,23 @@ class TransitionLoggingTestCase(TestCase): reason="Test reason", ) + # Must claim first (FSM requirement) + submission.claim(user=self.moderator) + submission.refresh_from_db() + # Perform transition submission.transition_to_approved(user=self.moderator) submission.save() # Check log was created 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.assertEqual(log.state, "APPROVED") self.assertEqual(log.by, self.moderator) - self.assertIn("approved", log.transition.lower()) def test_multiple_transitions_logged(self): """Test that multiple transitions are all logged.""" @@ -875,20 +916,28 @@ class TransitionLoggingTestCase(TestCase): 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.save() - # Second transition + # Second transition: ESCALATED -> APPROVED submission.transition_to_approved(user=self.moderator) submission.save() - # Check multiple logs created - logs = StateLog.objects.filter(content_type=submission_ct, object_id=submission.id).order_by("timestamp") + # Check logs created (excluding the claim transition log) + 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") - self.assertEqual(logs[0].state, "ESCALATED") - self.assertEqual(logs[1].state, "APPROVED") + # Should have at least 2 entries for ESCALATED and APPROVED + self.assertGreaterEqual(logs.count(), 2, "Should have at least 2 log entries") + states = [log.state for log in logs] + self.assertIn("ESCALATED", states) + self.assertIn("APPROVED", states) def test_history_endpoint_returns_logs(self): """Test history API endpoint returns transition logs.""" @@ -907,6 +956,10 @@ class TransitionLoggingTestCase(TestCase): reason="Test reason", ) + # Must claim first (FSM requirement) + submission.claim(user=self.moderator) + submission.refresh_from_db() + # Perform transition to create log submission.transition_to_approved(user=self.moderator) submission.save() @@ -918,7 +971,7 @@ class TransitionLoggingTestCase(TestCase): self.assertEqual(response.status_code, 200) 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 submission = EditSubmission.objects.create( @@ -931,13 +984,19 @@ class TransitionLoggingTestCase(TestCase): 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.save() # Check log was created even without user 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.assertEqual(log.state, "REJECTED") @@ -957,13 +1016,19 @@ class TransitionLoggingTestCase(TestCase): reason="Test reason", ) + # Must claim first (FSM requirement) + submission.claim(user=self.moderator) + submission.refresh_from_db() + # Perform transition submission.transition_to_approved(user=self.moderator) submission.save() # Check log 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) # 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) + # Must claim first (FSM requirement) + submission.claim(user=self.moderator) + submission.refresh_from_db() + # Create multiple transitions submission.transition_to_escalated(user=self.moderator) submission.save() @@ -996,9 +1065,11 @@ class TransitionLoggingTestCase(TestCase): # Get logs ordered by timestamp logs = list(StateLog.objects.filter(content_type=submission_ct, object_id=submission.id).order_by("timestamp")) - # Verify ordering - self.assertEqual(len(logs), 2) - self.assertTrue(logs[0].timestamp <= logs[1].timestamp) + # Verify ordering - should have at least 2 logs (escalated and approved) + self.assertGreaterEqual(len(logs), 2) + # 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): - """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): """Set up test fixtures.""" + from datetime import timedelta + from django_cloudflareimages_toolkit.models import CloudflareImage + self.user = User.objects.create_user( username="testuser", email="test@example.com", password="testpass123", role="USER" ) @@ -1082,43 +1159,60 @@ class PhotoSubmissionTransitionTests(TestCase): name="Test Operator", description="Test Description", roles=["OPERATOR"] ) self.content_type = ContentType.objects.get_for_model(Operator) - - def _create_mock_photo(self): - """Create a mock CloudflareImage for testing.""" - from unittest.mock import Mock - - mock_photo = Mock() - mock_photo.pk = 1 - mock_photo.id = 1 - return mock_photo + + # Create a real CloudflareImage for tests (required by FK constraint) + self.mock_image = CloudflareImage.objects.create( + cloudflare_id=f"test-cf-photo-{id(self)}", + user=self.user, + expires_at=timezone.now() + timedelta(days=365), + ) def _create_submission(self, status="PENDING"): - """Helper to create a PhotoSubmission.""" - # Create using direct database creation to bypass FK validation - from unittest.mock import Mock, patch + """Helper to create a PhotoSubmission with proper CloudflareImage.""" + submission = PhotoSubmission.objects.create( + 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 + ) + + # For non-PENDING states, we need to transition through CLAIMED + if status == "CLAIMED": + submission.claim(user=self.moderator) + submission.refresh_from_db() + elif status in ("APPROVED", "REJECTED", "ESCALATED"): + # First claim, then transition to target state + submission.claim(user=self.moderator) + if status == "APPROVED": + submission.transition_to_approved(user=self.moderator) + elif status == "REJECTED": + submission.transition_to_rejected(user=self.moderator) + elif status == "ESCALATED": + submission.transition_to_escalated(user=self.moderator) + submission.save() + submission.refresh_from_db() + + return submission - with patch.object(PhotoSubmission, "photo", Mock()): - submission = PhotoSubmission( - user=self.user, - content_type=self.content_type, - object_id=self.operator.id, - caption="Test Photo", - status=status, - ) - # Bypass model save to avoid FK constraint on photo - submission.photo_id = 1 - submission.save(update_fields=None) - # Force status after creation for non-PENDING states - if status != "PENDING": - PhotoSubmission.objects.filter(pk=submission.pk).update(status=status) - submission.refresh_from_db() - return submission - - def test_pending_to_approved_transition(self): - """Test transition from PENDING to APPROVED.""" + def test_pending_to_claimed_transition(self): + """Test transition from PENDING to CLAIMED.""" submission = self._create_submission() 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.handled_by = self.moderator submission.handled_at = timezone.now() @@ -1129,10 +1223,10 @@ class PhotoSubmissionTransitionTests(TestCase): self.assertEqual(submission.handled_by, self.moderator) self.assertIsNotNone(submission.handled_at) - def test_pending_to_rejected_transition(self): - """Test transition from PENDING to REJECTED.""" - submission = self._create_submission() - self.assertEqual(submission.status, "PENDING") + def test_claimed_to_rejected_transition(self): + """Test transition from CLAIMED to REJECTED (mandatory flow).""" + submission = self._create_submission(status="CLAIMED") + self.assertEqual(submission.status, "CLAIMED") submission.transition_to_rejected(user=self.moderator) submission.handled_by = self.moderator @@ -1145,10 +1239,10 @@ class PhotoSubmissionTransitionTests(TestCase): self.assertEqual(submission.handled_by, self.moderator) self.assertIn("Rejected", submission.notes) - def test_pending_to_escalated_transition(self): - """Test transition from PENDING to ESCALATED.""" - submission = self._create_submission() - self.assertEqual(submission.status, "PENDING") + def test_claimed_to_escalated_transition(self): + """Test transition from CLAIMED to ESCALATED (mandatory flow).""" + submission = self._create_submission(status="CLAIMED") + self.assertEqual(submission.status, "CLAIMED") submission.transition_to_escalated(user=self.moderator) submission.handled_by = self.moderator @@ -1199,28 +1293,22 @@ class PhotoSubmissionTransitionTests(TestCase): with self.assertRaises(TransitionNotAllowed): submission.transition_to_approved(user=self.moderator) + def test_reject_wrapper_method(self): - """Test the reject() wrapper method.""" - from unittest.mock import patch + """Test the reject() wrapper method (requires CLAIMED state first).""" + submission = self._create_submission(status="CLAIMED") - submission = self._create_submission() - - # 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.reject(self.moderator, notes="Not suitable") submission.refresh_from_db() self.assertEqual(submission.status, "REJECTED") self.assertIn("Not suitable", submission.notes) def test_escalate_wrapper_method(self): - """Test the escalate() wrapper method.""" - from unittest.mock import patch + """Test the escalate() wrapper method (requires CLAIMED state first).""" + submission = self._create_submission(status="CLAIMED") - submission = self._create_submission() - - with patch.object(submission, "transition_to_escalated"): - submission.escalate(self.moderator, notes="Needs admin review") + submission.escalate(self.moderator, notes="Needs admin review") submission.refresh_from_db() self.assertEqual(submission.status, "ESCALATED") @@ -1230,7 +1318,7 @@ class PhotoSubmissionTransitionTests(TestCase): """Test that transitions create StateLog entries.""" from django_fsm_log.models import StateLog - submission = self._create_submission() + submission = self._create_submission(status="CLAIMED") # Perform transition submission.transition_to_approved(user=self.moderator) @@ -1248,10 +1336,10 @@ class PhotoSubmissionTransitionTests(TestCase): """Test that multiple transitions are all logged.""" 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) - # First transition: PENDING -> ESCALATED + # First transition: CLAIMED -> ESCALATED submission.transition_to_escalated(user=self.moderator) submission.save() @@ -1268,10 +1356,7 @@ class PhotoSubmissionTransitionTests(TestCase): def test_handled_by_and_handled_at_updated(self): """Test that handled_by and handled_at are properly updated.""" - submission = self._create_submission() - - self.assertIsNone(submission.handled_by) - self.assertIsNone(submission.handled_at) + submission = self._create_submission(status="CLAIMED") before_time = timezone.now() submission.transition_to_approved(user=self.moderator) @@ -1287,7 +1372,7 @@ class PhotoSubmissionTransitionTests(TestCase): def test_notes_field_updated_on_rejection(self): """Test that notes field is updated with rejection reason.""" - submission = self._create_submission() + submission = self._create_submission(status="CLAIMED") rejection_reason = "Image contains watermarks" submission.transition_to_rejected(user=self.moderator) @@ -1299,7 +1384,7 @@ class PhotoSubmissionTransitionTests(TestCase): def test_notes_field_updated_on_escalation(self): """Test that notes field is updated with escalation reason.""" - submission = self._create_submission() + submission = self._create_submission(status="CLAIMED") escalation_reason = "Potentially copyrighted content" submission.transition_to_escalated(user=self.moderator) @@ -1308,3 +1393,4 @@ class PhotoSubmissionTransitionTests(TestCase): submission.refresh_from_db() self.assertEqual(submission.notes, escalation_reason) + diff --git a/backend/celerybeat-schedule b/backend/celerybeat-schedule new file mode 100644 index 0000000000000000000000000000000000000000..b4f7039cca4987c7c9ed4dfa0f4511818eb75ba1 GIT binary patch literal 4096 zcmWFz^vNtqRY=P(%1ta$FlG>7U}9o$P*7lCU|@t|AVoG{WYFvV!3)wZK(-m98b?E5 nGz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nC=3Argc%2x literal 0 HcmV?d00001 diff --git a/backend/celerybeat-schedule-shm b/backend/celerybeat-schedule-shm new file mode 100644 index 0000000000000000000000000000000000000000..56e00e05c8a0c97d189aff015eaed8196e375b5f GIT binary patch literal 32768 zcmeI)IZj+b6a~;rH?tXLxh!NUD_P4%w$jLM_VObKIm~g&Nk&3|009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t7x+;2%L& BE%*Qc literal 0 HcmV?d00001 diff --git a/backend/celerybeat-schedule-wal b/backend/celerybeat-schedule-wal new file mode 100644 index 0000000000000000000000000000000000000000..d18d58231e42970a17520d4c83599eeed664a559 GIT binary patch literal 243112 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 literal 0 HcmV?d00001 diff --git a/backend/config/celery.py b/backend/config/celery.py index a5a81cac..9c8d888a 100644 --- a/backend/config/celery.py +++ b/backend/config/celery.py @@ -91,6 +91,10 @@ app.conf.update( "task": "core.data_retention_cleanup", "schedule": 86400.0, # Daily }, + "moderation-expire-stale-claims": { + "task": "moderation.expire_stale_claims", + "schedule": 300.0, # Every 5 minutes + }, }, # Task result settings result_expires=3600, # 1 hour