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 00000000..b4f7039c Binary files /dev/null and b/backend/celerybeat-schedule differ diff --git a/backend/celerybeat-schedule-shm b/backend/celerybeat-schedule-shm new file mode 100644 index 00000000..56e00e05 Binary files /dev/null and b/backend/celerybeat-schedule-shm differ diff --git a/backend/celerybeat-schedule-wal b/backend/celerybeat-schedule-wal new file mode 100644 index 00000000..d18d5823 Binary files /dev/null and b/backend/celerybeat-schedule-wal differ 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