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:
pacnpal
2026-01-07 13:41:52 -05:00
parent 28c9ec56da
commit 40cba5bdb2
8 changed files with 450 additions and 94 deletions

View File

@@ -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}")

View File

@@ -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,

View 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,
)

View File

@@ -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
with patch.object(PhotoSubmission, "photo", Mock()):
submission = PhotoSubmission(
user=self.user, user=self.user,
content_type=self.content_type, content_type=self.content_type,
object_id=self.operator.id, object_id=self.operator.id,
photo=self.mock_image,
caption="Test Photo", caption="Test Photo",
status=status, status="PENDING", # Always create as PENDING first
) )
# Bypass model save to avoid FK constraint on photo
submission.photo_id = 1 # For non-PENDING states, we need to transition through CLAIMED
submission.save(update_fields=None) if status == "CLAIMED":
# Force status after creation for non-PENDING states submission.claim(user=self.moderator)
if status != "PENDING":
PhotoSubmission.objects.filter(pk=submission.pk).update(status=status)
submission.refresh_from_db() 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 return submission
def test_pending_to_approved_transition(self): def test_pending_to_claimed_transition(self):
"""Test transition from PENDING to APPROVED.""" """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,14 +1293,11 @@ 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()
# 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() submission.refresh_from_db()
@@ -1214,12 +1305,9 @@ class PhotoSubmissionTransitionTests(TestCase):
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()
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() submission.refresh_from_db()
@@ -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

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -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