mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-05 00:55:19 -05:00
feat: Introduce a CLAIMED state for moderation submissions, requiring claims before approval or rejection, and add a scheduled task to expire stale claims.
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
Management command to expire stale claims on submissions.
|
||||
|
||||
This command can be run manually or via cron as an alternative to the Celery
|
||||
scheduled task when Celery is not available.
|
||||
|
||||
Usage:
|
||||
python manage.py expire_stale_claims
|
||||
python manage.py expire_stale_claims --minutes=10 # Custom timeout
|
||||
"""
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from apps.moderation.tasks import expire_stale_claims, DEFAULT_LOCK_DURATION_MINUTES
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Release stale claims on submissions that have exceeded the lock timeout"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--minutes",
|
||||
type=int,
|
||||
default=DEFAULT_LOCK_DURATION_MINUTES,
|
||||
help=f"Minutes after which a claim is considered stale (default: {DEFAULT_LOCK_DURATION_MINUTES})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Show what would be released without actually releasing",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
from datetime import timedelta
|
||||
from django.utils import timezone
|
||||
from apps.moderation.models import EditSubmission, PhotoSubmission
|
||||
|
||||
minutes = options["minutes"]
|
||||
dry_run = options["dry_run"]
|
||||
cutoff_time = timezone.now() - timedelta(minutes=minutes)
|
||||
|
||||
self.stdout.write(f"Looking for claims older than {minutes} minutes...")
|
||||
self.stdout.write(f"Cutoff time: {cutoff_time.isoformat()}")
|
||||
|
||||
# Find stale claims
|
||||
stale_edit = EditSubmission.objects.filter(
|
||||
status="CLAIMED",
|
||||
claimed_at__lt=cutoff_time,
|
||||
).select_related("claimed_by")
|
||||
|
||||
stale_photo = PhotoSubmission.objects.filter(
|
||||
status="CLAIMED",
|
||||
claimed_at__lt=cutoff_time,
|
||||
).select_related("claimed_by")
|
||||
|
||||
stale_edit_count = stale_edit.count()
|
||||
stale_photo_count = stale_photo.count()
|
||||
|
||||
if stale_edit_count == 0 and stale_photo_count == 0:
|
||||
self.stdout.write(self.style.SUCCESS("No stale claims found."))
|
||||
return
|
||||
|
||||
self.stdout.write(f"Found {stale_edit_count} stale EditSubmission claims:")
|
||||
for sub in stale_edit:
|
||||
self.stdout.write(
|
||||
f" - ID {sub.id}: claimed by {sub.claimed_by} at {sub.claimed_at}"
|
||||
)
|
||||
|
||||
self.stdout.write(f"Found {stale_photo_count} stale PhotoSubmission claims:")
|
||||
for sub in stale_photo:
|
||||
self.stdout.write(
|
||||
f" - ID {sub.id}: claimed by {sub.claimed_by} at {sub.claimed_at}"
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write(self.style.WARNING("\n--dry-run: No changes made."))
|
||||
return
|
||||
|
||||
# Run the actual expiration task
|
||||
result = expire_stale_claims(lock_duration_minutes=minutes)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("\nExpiration complete:"))
|
||||
self.stdout.write(
|
||||
f" EditSubmissions: {result['edit_submissions']['released']} released, "
|
||||
f"{result['edit_submissions']['failed']} failed"
|
||||
)
|
||||
self.stdout.write(
|
||||
f" PhotoSubmissions: {result['photo_submissions']['released']} released, "
|
||||
f"{result['photo_submissions']['failed']} failed"
|
||||
)
|
||||
|
||||
if result["failures"]:
|
||||
self.stdout.write(self.style.ERROR("\nFailures:"))
|
||||
for failure in result["failures"]:
|
||||
self.stdout.write(f" - {failure}")
|
||||
@@ -39,8 +39,8 @@ class ModerationService:
|
||||
with transaction.atomic():
|
||||
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,
|
||||
|
||||
170
backend/apps/moderation/tasks.py
Normal file
170
backend/apps/moderation/tasks.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""
|
||||
Celery tasks for moderation app.
|
||||
|
||||
This module contains background tasks for moderation management including:
|
||||
- Automatic expiration of stale claim locks
|
||||
- Cleanup of orphaned submissions
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from celery import shared_task
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.core.utils import capture_and_log
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
User = get_user_model()
|
||||
|
||||
# Default lock duration in minutes (matching views.py)
|
||||
DEFAULT_LOCK_DURATION_MINUTES = 15
|
||||
|
||||
|
||||
@shared_task(name="moderation.expire_stale_claims")
|
||||
def expire_stale_claims(lock_duration_minutes: int = None) -> dict:
|
||||
"""
|
||||
Expire claims on submissions that have been locked for too long without action.
|
||||
|
||||
This task finds submissions in CLAIMED status where claimed_at is older than
|
||||
the lock duration (default 15 minutes) and releases them back to PENDING
|
||||
so other moderators can claim them.
|
||||
|
||||
This task should be run every 5 minutes via Celery Beat.
|
||||
|
||||
Args:
|
||||
lock_duration_minutes: Override the default lock duration (15 minutes)
|
||||
|
||||
Returns:
|
||||
dict: Summary with counts of processed, succeeded, and failed releases
|
||||
"""
|
||||
from apps.moderation.models import EditSubmission, PhotoSubmission
|
||||
|
||||
if lock_duration_minutes is None:
|
||||
lock_duration_minutes = DEFAULT_LOCK_DURATION_MINUTES
|
||||
|
||||
logger.info("Starting stale claims expiration check (timeout: %d minutes)", lock_duration_minutes)
|
||||
|
||||
# Calculate cutoff time (claims older than this should be released)
|
||||
cutoff_time = timezone.now() - timedelta(minutes=lock_duration_minutes)
|
||||
|
||||
result = {
|
||||
"edit_submissions": {"processed": 0, "released": 0, "failed": 0},
|
||||
"photo_submissions": {"processed": 0, "released": 0, "failed": 0},
|
||||
"failures": [],
|
||||
"cutoff_time": cutoff_time.isoformat(),
|
||||
}
|
||||
|
||||
# Process EditSubmissions with stale claims
|
||||
# Query without lock first, then lock each row individually in transaction
|
||||
stale_edit_ids = list(
|
||||
EditSubmission.objects.filter(
|
||||
status="CLAIMED",
|
||||
claimed_at__lt=cutoff_time,
|
||||
).values_list("id", flat=True)
|
||||
)
|
||||
|
||||
for submission_id in stale_edit_ids:
|
||||
result["edit_submissions"]["processed"] += 1
|
||||
try:
|
||||
with transaction.atomic():
|
||||
# Lock and fetch the specific row
|
||||
submission = EditSubmission.objects.select_for_update(skip_locked=True).filter(
|
||||
id=submission_id,
|
||||
status="CLAIMED", # Re-verify status in case it changed
|
||||
).first()
|
||||
|
||||
if submission:
|
||||
_release_claim(submission)
|
||||
result["edit_submissions"]["released"] += 1
|
||||
logger.info(
|
||||
"Released stale claim on EditSubmission %s (claimed by %s at %s)",
|
||||
submission_id,
|
||||
submission.claimed_by,
|
||||
submission.claimed_at,
|
||||
)
|
||||
except Exception as e:
|
||||
result["edit_submissions"]["failed"] += 1
|
||||
error_msg = f"EditSubmission {submission_id}: {str(e)}"
|
||||
result["failures"].append(error_msg)
|
||||
capture_and_log(
|
||||
e,
|
||||
f"Release stale claim on EditSubmission {submission_id}",
|
||||
source="task",
|
||||
)
|
||||
|
||||
# Process PhotoSubmissions with stale claims
|
||||
stale_photo_ids = list(
|
||||
PhotoSubmission.objects.filter(
|
||||
status="CLAIMED",
|
||||
claimed_at__lt=cutoff_time,
|
||||
).values_list("id", flat=True)
|
||||
)
|
||||
|
||||
for submission_id in stale_photo_ids:
|
||||
result["photo_submissions"]["processed"] += 1
|
||||
try:
|
||||
with transaction.atomic():
|
||||
# Lock and fetch the specific row
|
||||
submission = PhotoSubmission.objects.select_for_update(skip_locked=True).filter(
|
||||
id=submission_id,
|
||||
status="CLAIMED", # Re-verify status in case it changed
|
||||
).first()
|
||||
|
||||
if submission:
|
||||
_release_claim(submission)
|
||||
result["photo_submissions"]["released"] += 1
|
||||
logger.info(
|
||||
"Released stale claim on PhotoSubmission %s (claimed by %s at %s)",
|
||||
submission_id,
|
||||
submission.claimed_by,
|
||||
submission.claimed_at,
|
||||
)
|
||||
except Exception as e:
|
||||
result["photo_submissions"]["failed"] += 1
|
||||
error_msg = f"PhotoSubmission {submission_id}: {str(e)}"
|
||||
result["failures"].append(error_msg)
|
||||
capture_and_log(
|
||||
e,
|
||||
f"Release stale claim on PhotoSubmission {submission_id}",
|
||||
source="task",
|
||||
)
|
||||
|
||||
total_released = result["edit_submissions"]["released"] + result["photo_submissions"]["released"]
|
||||
total_failed = result["edit_submissions"]["failed"] + result["photo_submissions"]["failed"]
|
||||
|
||||
logger.info(
|
||||
"Completed stale claims expiration: %s released, %s failed",
|
||||
total_released,
|
||||
total_failed,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _release_claim(submission):
|
||||
"""
|
||||
Release a stale claim on a submission.
|
||||
|
||||
Uses the unclaim() FSM method to properly transition from CLAIMED to PENDING
|
||||
and clear the claimed_by and claimed_at fields.
|
||||
|
||||
Args:
|
||||
submission: EditSubmission or PhotoSubmission instance
|
||||
"""
|
||||
# Store info for logging before clearing
|
||||
claimed_by = submission.claimed_by
|
||||
claimed_at = submission.claimed_at
|
||||
|
||||
# Use the FSM unclaim method - pass None for system-initiated unclaim
|
||||
submission.unclaim(user=None)
|
||||
|
||||
# Log the automatic release
|
||||
logger.debug(
|
||||
"Auto-released claim: submission=%s, was_claimed_by=%s, claimed_at=%s",
|
||||
submission.id,
|
||||
claimed_by,
|
||||
claimed_at,
|
||||
)
|
||||
@@ -399,11 +399,17 @@ class EditSubmissionTransitionTests(TestCase):
|
||||
reason="Test reason",
|
||||
)
|
||||
|
||||
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"
|
||||
)
|
||||
@@ -1083,42 +1160,59 @@ class PhotoSubmissionTransitionTests(TestCase):
|
||||
)
|
||||
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
|
||||
)
|
||||
|
||||
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
|
||||
# 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()
|
||||
|
||||
def test_pending_to_approved_transition(self):
|
||||
"""Test transition from PENDING to APPROVED."""
|
||||
return submission
|
||||
|
||||
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)
|
||||
|
||||
|
||||
BIN
backend/celerybeat-schedule
Normal file
BIN
backend/celerybeat-schedule
Normal file
Binary file not shown.
BIN
backend/celerybeat-schedule-shm
Normal file
BIN
backend/celerybeat-schedule-shm
Normal file
Binary file not shown.
BIN
backend/celerybeat-schedule-wal
Normal file
BIN
backend/celerybeat-schedule-wal
Normal file
Binary file not shown.
@@ -91,6 +91,10 @@ app.conf.update(
|
||||
"task": "core.data_retention_cleanup",
|
||||
"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
|
||||
|
||||
Reference in New Issue
Block a user