feat: Implement initial schema and add various API, service, and management command enhancements across the application.

This commit is contained in:
pacnpal
2026-01-01 15:13:01 -05:00
parent c95f99ca10
commit b243b17af7
413 changed files with 11164 additions and 17433 deletions

View File

@@ -17,7 +17,7 @@ are registered via the callback configuration defined in each model's Meta class
"""
from datetime import timedelta
from typing import Any, Union
from typing import Any
import pghistory
from django.conf import settings
@@ -33,7 +33,7 @@ from apps.core.choices.fields import RichChoiceField
from apps.core.history import TrackedModel
from apps.core.state_machine import RichFSMField, StateMachineMixin
UserType = Union[AbstractBaseUser, AnonymousUser]
UserType = AbstractBaseUser | AnonymousUser
# Lazy callback imports to avoid circular dependencies
@@ -45,11 +45,12 @@ def _get_notification_callbacks():
SubmissionEscalatedNotification,
SubmissionRejectedNotification,
)
return {
'approved': SubmissionApprovedNotification,
'rejected': SubmissionRejectedNotification,
'escalated': SubmissionEscalatedNotification,
'moderation': ModerationNotificationCallback,
"approved": SubmissionApprovedNotification,
"rejected": SubmissionRejectedNotification,
"escalated": SubmissionEscalatedNotification,
"moderation": ModerationNotificationCallback,
}
@@ -59,9 +60,10 @@ def _get_cache_callbacks():
CacheInvalidationCallback,
ModerationCacheInvalidation,
)
return {
'generic': CacheInvalidationCallback,
'moderation': ModerationCacheInvalidation,
"generic": CacheInvalidationCallback,
"moderation": ModerationCacheInvalidation,
}
@@ -69,6 +71,7 @@ def _get_cache_callbacks():
# Original EditSubmission Model (Preserved)
# ============================================================================
@pghistory.track() # Track all changes by default
class EditSubmission(StateMachineMixin, TrackedModel):
"""Edit submission model with FSM-managed status transitions."""
@@ -98,16 +101,11 @@ class EditSubmission(StateMachineMixin, TrackedModel):
# Type of submission
submission_type = RichChoiceField(
choice_group="submission_types",
domain="moderation",
max_length=10,
default="EDIT"
choice_group="submission_types", domain="moderation", max_length=10, default="EDIT"
)
# The actual changes/data
changes = models.JSONField(
help_text="JSON representation of the changes or new object data"
)
changes = models.JSONField(help_text="JSON representation of the changes or new object data")
# Moderator's edited version of changes before approval
moderator_changes = models.JSONField(
@@ -118,14 +116,9 @@ class EditSubmission(StateMachineMixin, TrackedModel):
# Metadata
reason = models.TextField(help_text="Why this edit/addition is needed")
source = models.TextField(
blank=True, help_text="Source of information (if applicable)"
)
source = models.TextField(blank=True, help_text="Source of information (if applicable)")
status = RichFSMField(
choice_group="edit_submission_statuses",
domain="moderation",
max_length=20,
default="PENDING"
choice_group="edit_submission_statuses", domain="moderation", max_length=20, default="PENDING"
)
created_at = models.DateTimeField(auto_now_add=True)
@@ -138,12 +131,8 @@ class EditSubmission(StateMachineMixin, TrackedModel):
related_name="handled_submissions",
help_text="Moderator who handled this submission",
)
handled_at = models.DateTimeField(
null=True, blank=True, help_text="When this submission was handled"
)
notes = models.TextField(
blank=True, help_text="Notes from the moderator about this submission"
)
handled_at = models.DateTimeField(null=True, blank=True, help_text="When this submission was handled")
notes = models.TextField(blank=True, help_text="Notes from the moderator about this submission")
# Claim tracking for concurrency control
claimed_by = models.ForeignKey(
@@ -154,9 +143,7 @@ class EditSubmission(StateMachineMixin, TrackedModel):
related_name="claimed_edit_submissions",
help_text="Moderator who has claimed this submission for review",
)
claimed_at = models.DateTimeField(
null=True, blank=True, help_text="When this submission was claimed"
)
claimed_at = models.DateTimeField(null=True, blank=True, help_text="When this submission was claimed")
class Meta(TrackedModel.Meta):
verbose_name = "Edit Submission"
@@ -187,12 +174,12 @@ class EditSubmission(StateMachineMixin, TrackedModel):
field = model_class._meta.get_field(field_name)
if isinstance(field, models.ForeignKey) and value is not None:
try:
related_obj = field.related_model.objects.get(pk=value) # type: ignore
related_obj = field.related_model.objects.get(pk=value) # type: ignore
resolved_data[field_name] = related_obj
except ObjectDoesNotExist:
raise ValueError(
f"Related object {field.related_model.__name__} with pk={value} does not exist" # type: ignore
)
f"Related object {field.related_model.__name__} with pk={value} does not exist" # type: ignore
) from None
except FieldDoesNotExist:
# Field doesn't exist on model, skip it
continue
@@ -217,9 +204,7 @@ class EditSubmission(StateMachineMixin, TrackedModel):
from django.core.exceptions import ValidationError
if self.status != "PENDING":
raise ValidationError(
f"Cannot claim submission: current status is {self.status}, expected PENDING"
)
raise ValidationError(f"Cannot claim submission: current status is {self.status}, expected PENDING")
self.transition_to_claimed(user=user)
self.claimed_by = user
@@ -240,9 +225,7 @@ class EditSubmission(StateMachineMixin, TrackedModel):
from django.core.exceptions import ValidationError
if self.status != "CLAIMED":
raise ValidationError(
f"Cannot unclaim submission: current status is {self.status}, expected CLAIMED"
)
raise ValidationError(f"Cannot unclaim submission: current status is {self.status}, expected CLAIMED")
# Set status directly (not via FSM transition to avoid cycle)
# This is intentional - the unclaim action is a special "rollback" operation
@@ -274,9 +257,7 @@ class EditSubmission(StateMachineMixin, TrackedModel):
# Validate state - must be CLAIMED before approval
if self.status != "CLAIMED":
raise ValidationError(
f"Cannot approve submission: must be CLAIMED first (current status: {self.status})"
)
raise ValidationError(f"Cannot approve submission: must be CLAIMED first (current status: {self.status})")
model_class = self.content_type.model_class()
if not model_class:
@@ -341,9 +322,7 @@ class EditSubmission(StateMachineMixin, TrackedModel):
# Validate state - must be CLAIMED before rejection
if self.status != "CLAIMED":
raise ValidationError(
f"Cannot reject submission: must be CLAIMED first (current status: {self.status})"
)
raise ValidationError(f"Cannot reject submission: must be CLAIMED first (current status: {self.status})")
# Use FSM transition to update status
self.transition_to_rejected(user=rejecter)
@@ -369,9 +348,7 @@ class EditSubmission(StateMachineMixin, TrackedModel):
# Validate state - must be CLAIMED before escalation
if self.status != "CLAIMED":
raise ValidationError(
f"Cannot escalate submission: must be CLAIMED first (current status: {self.status})"
)
raise ValidationError(f"Cannot escalate submission: must be CLAIMED first (current status: {self.status})")
# Use FSM transition to update status
self.transition_to_escalated(user=escalator)
@@ -395,6 +372,7 @@ class EditSubmission(StateMachineMixin, TrackedModel):
# New Moderation System Models
# ============================================================================
@pghistory.track()
class ModerationReport(StateMachineMixin, TrackedModel):
"""
@@ -407,43 +385,29 @@ class ModerationReport(StateMachineMixin, TrackedModel):
state_field_name = "status"
# Report details
report_type = RichChoiceField(
choice_group="report_types",
domain="moderation",
max_length=50
)
report_type = RichChoiceField(choice_group="report_types", domain="moderation", max_length=50)
status = RichFSMField(
choice_group="moderation_report_statuses",
domain="moderation",
max_length=20,
default='PENDING'
)
priority = RichChoiceField(
choice_group="priority_levels",
domain="moderation",
max_length=10,
default='MEDIUM'
choice_group="moderation_report_statuses", domain="moderation", max_length=20, default="PENDING"
)
priority = RichChoiceField(choice_group="priority_levels", domain="moderation", max_length=10, default="MEDIUM")
# What is being reported
reported_entity_type = models.CharField(
max_length=50, help_text="Type of entity being reported (park, ride, user, etc.)")
reported_entity_id = models.PositiveIntegerField(
help_text="ID of the entity being reported")
content_type = models.ForeignKey(
ContentType, on_delete=models.CASCADE, null=True, blank=True)
max_length=50, help_text="Type of entity being reported (park, ride, user, etc.)"
)
reported_entity_id = models.PositiveIntegerField(help_text="ID of the entity being reported")
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, null=True, blank=True)
# Report content
reason = models.CharField(max_length=200, help_text="Brief reason for the report")
description = models.TextField(help_text="Detailed description of the issue")
evidence_urls = models.JSONField(
default=list, blank=True, help_text="URLs to evidence (screenshots, etc.)")
evidence_urls = models.JSONField(default=list, blank=True, help_text="URLs to evidence (screenshots, etc.)")
# Users involved
reported_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='moderation_reports_made',
related_name="moderation_reports_made",
help_text="User who made this report",
)
assigned_moderator = models.ForeignKey(
@@ -451,40 +415,32 @@ class ModerationReport(StateMachineMixin, TrackedModel):
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='assigned_moderation_reports',
related_name="assigned_moderation_reports",
help_text="Moderator assigned to handle this report",
)
# Resolution
resolution_action = models.CharField(
max_length=100, blank=True, help_text="Action taken to resolve")
resolution_notes = models.TextField(
blank=True, help_text="Notes about the resolution")
resolved_at = models.DateTimeField(
null=True, blank=True, help_text="When this report was resolved"
)
resolution_action = models.CharField(max_length=100, blank=True, help_text="Action taken to resolve")
resolution_notes = models.TextField(blank=True, help_text="Notes about the resolution")
resolved_at = models.DateTimeField(null=True, blank=True, help_text="When this report was resolved")
# Timestamps
created_at = models.DateTimeField(
auto_now_add=True, help_text="When this report was created"
)
updated_at = models.DateTimeField(
auto_now=True, help_text="When this report was last updated"
)
created_at = models.DateTimeField(auto_now_add=True, help_text="When this report was created")
updated_at = models.DateTimeField(auto_now=True, help_text="When this report was last updated")
class Meta(TrackedModel.Meta):
verbose_name = "Moderation Report"
verbose_name_plural = "Moderation Reports"
ordering = ['-created_at']
ordering = ["-created_at"]
indexes = [
models.Index(fields=['status', 'priority']),
models.Index(fields=['reported_by']),
models.Index(fields=['assigned_moderator']),
models.Index(fields=['created_at']),
models.Index(fields=["status", "priority"]),
models.Index(fields=["reported_by"]),
models.Index(fields=["assigned_moderator"]),
models.Index(fields=["created_at"]),
]
def __str__(self):
return f"{self.get_report_type_display()} report by {self.reported_by.username}" # type: ignore
return f"{self.get_report_type_display()} report by {self.reported_by.username}" # type: ignore
@pghistory.track()
@@ -499,37 +455,20 @@ class ModerationQueue(StateMachineMixin, TrackedModel):
state_field_name = "status"
# Queue item details
item_type = RichChoiceField(
choice_group="queue_item_types",
domain="moderation",
max_length=50
)
item_type = RichChoiceField(choice_group="queue_item_types", domain="moderation", max_length=50)
status = RichFSMField(
choice_group="moderation_queue_statuses",
domain="moderation",
max_length=20,
default='PENDING'
)
priority = RichChoiceField(
choice_group="priority_levels",
domain="moderation",
max_length=10,
default='MEDIUM'
choice_group="moderation_queue_statuses", domain="moderation", max_length=20, default="PENDING"
)
priority = RichChoiceField(choice_group="priority_levels", domain="moderation", max_length=10, default="MEDIUM")
title = models.CharField(max_length=200, help_text="Brief title for the queue item")
description = models.TextField(
help_text="Detailed description of what needs to be done")
description = models.TextField(help_text="Detailed description of what needs to be done")
# What entity this relates to
entity_type = models.CharField(
max_length=50, blank=True, help_text="Type of entity (park, ride, user, etc.)")
entity_id = models.PositiveIntegerField(
null=True, blank=True, help_text="ID of the related entity")
entity_preview = models.JSONField(
default=dict, blank=True, help_text="Preview data for the entity")
content_type = models.ForeignKey(
ContentType, on_delete=models.CASCADE, null=True, blank=True)
entity_type = models.CharField(max_length=50, blank=True, help_text="Type of entity (park, ride, user, etc.)")
entity_id = models.PositiveIntegerField(null=True, blank=True, help_text="ID of the related entity")
entity_preview = models.JSONField(default=dict, blank=True, help_text="Preview data for the entity")
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, null=True, blank=True)
# Assignment and timing
assigned_to = models.ForeignKey(
@@ -537,14 +476,11 @@ class ModerationQueue(StateMachineMixin, TrackedModel):
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='assigned_queue_items',
related_name="assigned_queue_items",
help_text="Moderator assigned to this item",
)
assigned_at = models.DateTimeField(
null=True, blank=True, help_text="When this item was assigned"
)
estimated_review_time = models.PositiveIntegerField(
default=30, help_text="Estimated time in minutes")
assigned_at = models.DateTimeField(null=True, blank=True, help_text="When this item was assigned")
estimated_review_time = models.PositiveIntegerField(default=30, help_text="Estimated time in minutes")
# Metadata
flagged_by = models.ForeignKey(
@@ -552,11 +488,10 @@ class ModerationQueue(StateMachineMixin, TrackedModel):
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='flagged_queue_items',
related_name="flagged_queue_items",
help_text="User who flagged this item",
)
tags = models.JSONField(default=list, blank=True,
help_text="Tags for categorization")
tags = models.JSONField(default=list, blank=True, help_text="Tags for categorization")
# Related objects
related_report = models.ForeignKey(
@@ -564,30 +499,26 @@ class ModerationQueue(StateMachineMixin, TrackedModel):
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='queue_items',
related_name="queue_items",
help_text="Related moderation report",
)
# Timestamps
created_at = models.DateTimeField(
auto_now_add=True, help_text="When this item was created"
)
updated_at = models.DateTimeField(
auto_now=True, help_text="When this item was last updated"
)
created_at = models.DateTimeField(auto_now_add=True, help_text="When this item was created")
updated_at = models.DateTimeField(auto_now=True, help_text="When this item was last updated")
class Meta(TrackedModel.Meta):
verbose_name = "Moderation Queue Item"
verbose_name_plural = "Moderation Queue Items"
ordering = ['priority', 'created_at']
ordering = ["priority", "created_at"]
indexes = [
models.Index(fields=['status', 'priority']),
models.Index(fields=['assigned_to']),
models.Index(fields=['created_at']),
models.Index(fields=["status", "priority"]),
models.Index(fields=["assigned_to"]),
models.Index(fields=["created_at"]),
]
def __str__(self):
return f"{self.get_item_type_display()}: {self.title}" # type: ignore
return f"{self.get_item_type_display()}: {self.title}" # type: ignore
@pghistory.track()
@@ -600,36 +531,28 @@ class ModerationAction(TrackedModel):
"""
# Action details
action_type = RichChoiceField(
choice_group="moderation_action_types",
domain="moderation",
max_length=50
)
action_type = RichChoiceField(choice_group="moderation_action_types", domain="moderation", max_length=50)
reason = models.CharField(max_length=200, help_text="Brief reason for the action")
details = models.TextField(help_text="Detailed explanation of the action")
# Duration (for temporary actions)
duration_hours = models.PositiveIntegerField(
null=True,
blank=True,
help_text="Duration in hours for temporary actions"
null=True, blank=True, help_text="Duration in hours for temporary actions"
)
expires_at = models.DateTimeField(
null=True, blank=True, help_text="When this action expires")
is_active = models.BooleanField(
default=True, help_text="Whether this action is currently active")
expires_at = models.DateTimeField(null=True, blank=True, help_text="When this action expires")
is_active = models.BooleanField(default=True, help_text="Whether this action is currently active")
# Users involved
moderator = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='moderation_actions_taken',
related_name="moderation_actions_taken",
help_text="Moderator who took this action",
)
target_user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='moderation_actions_received',
related_name="moderation_actions_received",
help_text="User this action was taken against",
)
@@ -639,31 +562,27 @@ class ModerationAction(TrackedModel):
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='actions_taken',
related_name="actions_taken",
help_text="Related moderation report",
)
# Timestamps
created_at = models.DateTimeField(
auto_now_add=True, help_text="When this action was created"
)
updated_at = models.DateTimeField(
auto_now=True, help_text="When this action was last updated"
)
created_at = models.DateTimeField(auto_now_add=True, help_text="When this action was created")
updated_at = models.DateTimeField(auto_now=True, help_text="When this action was last updated")
class Meta(TrackedModel.Meta):
verbose_name = "Moderation Action"
verbose_name_plural = "Moderation Actions"
ordering = ['-created_at']
ordering = ["-created_at"]
indexes = [
models.Index(fields=['target_user', 'is_active']),
models.Index(fields=['moderator']),
models.Index(fields=['expires_at']),
models.Index(fields=['created_at']),
models.Index(fields=["target_user", "is_active"]),
models.Index(fields=["moderator"]),
models.Index(fields=["expires_at"]),
models.Index(fields=["created_at"]),
]
def __str__(self):
return f"{self.get_action_type_display()} against {self.target_user.username} by {self.moderator.username}" # type: ignore
return f"{self.get_action_type_display()} against {self.target_user.username} by {self.moderator.username}" # type: ignore
def save(self, *args, **kwargs):
# Set expiration time if duration is provided
@@ -684,85 +603,56 @@ class BulkOperation(StateMachineMixin, TrackedModel):
state_field_name = "status"
# Operation details
operation_type = RichChoiceField(
choice_group="bulk_operation_types",
domain="moderation",
max_length=50
)
status = RichFSMField(
choice_group="bulk_operation_statuses",
domain="moderation",
max_length=20,
default='PENDING'
)
priority = RichChoiceField(
choice_group="priority_levels",
domain="moderation",
max_length=10,
default='MEDIUM'
)
operation_type = RichChoiceField(choice_group="bulk_operation_types", domain="moderation", max_length=50)
status = RichFSMField(choice_group="bulk_operation_statuses", domain="moderation", max_length=20, default="PENDING")
priority = RichChoiceField(choice_group="priority_levels", domain="moderation", max_length=10, default="MEDIUM")
description = models.TextField(help_text="Description of what this operation does")
# Operation parameters and results
parameters = models.JSONField(
default=dict, help_text="Parameters for the operation")
results = models.JSONField(default=dict, blank=True,
help_text="Results and output from the operation")
parameters = models.JSONField(default=dict, help_text="Parameters for the operation")
results = models.JSONField(default=dict, blank=True, help_text="Results and output from the operation")
# Progress tracking
total_items = models.PositiveIntegerField(
default=0, help_text="Total number of items to process")
processed_items = models.PositiveIntegerField(
default=0, help_text="Number of items processed")
failed_items = models.PositiveIntegerField(
default=0, help_text="Number of items that failed")
total_items = models.PositiveIntegerField(default=0, help_text="Total number of items to process")
processed_items = models.PositiveIntegerField(default=0, help_text="Number of items processed")
failed_items = models.PositiveIntegerField(default=0, help_text="Number of items that failed")
# Timing
estimated_duration_minutes = models.PositiveIntegerField(
null=True,
blank=True,
help_text="Estimated duration in minutes"
null=True, blank=True, help_text="Estimated duration in minutes"
)
schedule_for = models.DateTimeField(
null=True, blank=True, help_text="When to run this operation")
schedule_for = models.DateTimeField(null=True, blank=True, help_text="When to run this operation")
# Control
can_cancel = models.BooleanField(
default=True, help_text="Whether this operation can be cancelled")
can_cancel = models.BooleanField(default=True, help_text="Whether this operation can be cancelled")
# User who created the operation
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='bulk_operations_created',
related_name="bulk_operations_created",
help_text="User who created this operation",
)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
started_at = models.DateTimeField(
null=True, blank=True, help_text="When this operation started"
)
completed_at = models.DateTimeField(
null=True, blank=True, help_text="When this operation completed"
)
updated_at = models.DateTimeField(
auto_now=True, help_text="When this operation was last updated"
)
started_at = models.DateTimeField(null=True, blank=True, help_text="When this operation started")
completed_at = models.DateTimeField(null=True, blank=True, help_text="When this operation completed")
updated_at = models.DateTimeField(auto_now=True, help_text="When this operation was last updated")
class Meta(TrackedModel.Meta):
verbose_name = "Bulk Operation"
verbose_name_plural = "Bulk Operations"
ordering = ['-created_at']
ordering = ["-created_at"]
indexes = [
models.Index(fields=['status', 'priority']),
models.Index(fields=['created_by']),
models.Index(fields=['schedule_for']),
models.Index(fields=['created_at']),
models.Index(fields=["status", "priority"]),
models.Index(fields=["created_by"]),
models.Index(fields=["schedule_for"]),
models.Index(fields=["created_at"]),
]
def __str__(self):
return f"{self.get_operation_type_display()}: {self.description[:50]}" # type: ignore
return f"{self.get_operation_type_display()}: {self.description[:50]}" # type: ignore
@property
def progress_percentage(self):
@@ -792,28 +682,21 @@ class PhotoSubmission(StateMachineMixin, TrackedModel):
on_delete=models.CASCADE,
help_text="Type of object this photo is for",
)
object_id = models.PositiveIntegerField(
help_text="ID of object this photo is for"
)
object_id = models.PositiveIntegerField(help_text="ID of object this photo is for")
content_object = GenericForeignKey("content_type", "object_id")
# The photo itself
photo = models.ForeignKey(
'django_cloudflareimages_toolkit.CloudflareImage',
"django_cloudflareimages_toolkit.CloudflareImage",
on_delete=models.CASCADE,
help_text="Photo submission stored on Cloudflare Images"
help_text="Photo submission stored on Cloudflare Images",
)
caption = models.CharField(max_length=255, blank=True, help_text="Photo caption")
date_taken = models.DateField(
null=True, blank=True, help_text="Date the photo was taken"
)
date_taken = models.DateField(null=True, blank=True, help_text="Date the photo was taken")
# Metadata
status = RichFSMField(
choice_group="photo_submission_statuses",
domain="moderation",
max_length=20,
default="PENDING"
choice_group="photo_submission_statuses", domain="moderation", max_length=20, default="PENDING"
)
created_at = models.DateTimeField(auto_now_add=True)
@@ -826,9 +709,7 @@ class PhotoSubmission(StateMachineMixin, TrackedModel):
related_name="handled_photos",
help_text="Moderator who handled this submission",
)
handled_at = models.DateTimeField(
null=True, blank=True, help_text="When this submission was handled"
)
handled_at = models.DateTimeField(null=True, blank=True, help_text="When this submission was handled")
notes = models.TextField(
blank=True,
help_text="Notes from the moderator about this photo submission",
@@ -843,9 +724,7 @@ class PhotoSubmission(StateMachineMixin, TrackedModel):
related_name="claimed_photo_submissions",
help_text="Moderator who has claimed this submission for review",
)
claimed_at = models.DateTimeField(
null=True, blank=True, help_text="When this submission was claimed"
)
claimed_at = models.DateTimeField(null=True, blank=True, help_text="When this submission was claimed")
class Meta(TrackedModel.Meta):
verbose_name = "Photo Submission"
@@ -873,9 +752,7 @@ class PhotoSubmission(StateMachineMixin, TrackedModel):
from django.core.exceptions import ValidationError
if self.status != "PENDING":
raise ValidationError(
f"Cannot claim submission: current status is {self.status}, expected PENDING"
)
raise ValidationError(f"Cannot claim submission: current status is {self.status}, expected PENDING")
self.transition_to_claimed(user=user)
self.claimed_by = user
@@ -896,9 +773,7 @@ class PhotoSubmission(StateMachineMixin, TrackedModel):
from django.core.exceptions import ValidationError
if self.status != "CLAIMED":
raise ValidationError(
f"Cannot unclaim submission: current status is {self.status}, expected CLAIMED"
)
raise ValidationError(f"Cannot unclaim submission: current status is {self.status}, expected CLAIMED")
# Set status directly (not via FSM transition to avoid cycle)
# This is intentional - the unclaim action is a special "rollback" operation