Files
thrillwiki_django_no_react/backend/apps/moderation/models.py
pacnpal 4140a0d8e7 Add @extend_schema decorators to moderation ViewSet actions
- Add drf_spectacular imports (extend_schema, OpenApiResponse, inline_serializer)
- Annotate claim action with response schemas for 200/404/409/400
- Annotate unclaim action with response schemas for 200/403/400
- Annotate approve action with request=None and response schemas
- Annotate reject action with reason request body schema
- Annotate escalate action with reason request body schema
- All actions tagged with 'Moderation' for API docs grouping
2026-01-13 19:34:41 -05:00

929 lines
35 KiB
Python

"""
Moderation Models
This module contains models for the ThrillWiki moderation system, including:
- EditSubmission: Original content submission and approval workflow
- ModerationReport: User reports for content moderation
- ModerationQueue: Workflow management for moderation tasks
- ModerationAction: Actions taken against users/content
- BulkOperation: Administrative bulk operations
All models use pghistory for change tracking and TrackedModel base class.
Callback System Integration:
All FSM-enabled models in this module support the callback system.
Callbacks for notifications, cache invalidation, and related updates
are registered via the callback configuration defined in each model's Meta class.
"""
from datetime import timedelta
from typing import Any
import uuid
import pghistory
from django.conf import settings
from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.auth.models import AnonymousUser
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist
from django.db import models
from django.utils import timezone
from apps.core.choices.fields import RichChoiceField
from apps.core.history import TrackedModel
from apps.core.state_machine import RichFSMField, StateMachineMixin
UserType = AbstractBaseUser | AnonymousUser
# Lazy callback imports to avoid circular dependencies
def _get_notification_callbacks():
"""Lazy import of notification callbacks."""
from apps.core.state_machine.callbacks.notifications import (
ModerationNotificationCallback,
SubmissionApprovedNotification,
SubmissionEscalatedNotification,
SubmissionRejectedNotification,
)
return {
"approved": SubmissionApprovedNotification,
"rejected": SubmissionRejectedNotification,
"escalated": SubmissionEscalatedNotification,
"moderation": ModerationNotificationCallback,
}
def _get_cache_callbacks():
"""Lazy import of cache callbacks."""
from apps.core.state_machine.callbacks.cache import (
CacheInvalidationCallback,
ModerationCacheInvalidation,
)
return {
"generic": CacheInvalidationCallback,
"moderation": ModerationCacheInvalidation,
}
# ============================================================================
# Original EditSubmission Model (Preserved)
# ============================================================================
@pghistory.track() # Track all changes by default
class EditSubmission(StateMachineMixin, TrackedModel):
"""Edit submission model with FSM-managed status transitions."""
state_field_name = "status"
# Who submitted the edit
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="edit_submissions",
help_text="User who submitted this edit",
)
# What is being edited (Park or Ride)
content_type = models.ForeignKey(
ContentType,
on_delete=models.CASCADE,
help_text="Type of object being edited",
)
object_id = models.PositiveIntegerField(
null=True,
blank=True,
help_text="ID of object being edited (null for new objects)",
)
content_object = GenericForeignKey("content_type", "object_id")
# Type of submission
submission_type = RichChoiceField(
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")
# Moderator's edited version of changes before approval
moderator_changes = models.JSONField(
null=True,
blank=True,
help_text="Moderator's edited version of the changes before approval",
)
# Photo submission fields (only used when submission_type="PHOTO")
photo = models.ForeignKey(
"django_cloudflareimages_toolkit.CloudflareImage",
on_delete=models.SET_NULL,
null=True,
blank=True,
help_text="Photo for photo submissions",
)
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",
)
# Metadata
reason = models.TextField(help_text="Why this edit/addition is needed")
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"
)
created_at = models.DateTimeField(auto_now_add=True)
# Review details
handled_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
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")
# Claim tracking for concurrency control
claimed_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
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")
class Meta(TrackedModel.Meta):
verbose_name = "Edit Submission"
verbose_name_plural = "Edit Submissions"
ordering = ["-created_at"]
indexes = [
models.Index(fields=["content_type", "object_id"]),
models.Index(fields=["status"]),
]
def __str__(self) -> str:
action = "creation" if self.submission_type == "CREATE" else "edit"
if model_class := self.content_type.model_class():
target = self.content_object or model_class.__name__
else:
target = "Unknown"
return f"{action} by {self.user.username} on {target}"
def _resolve_foreign_keys(self, data: dict[str, Any]) -> dict[str, Any]:
"""Convert foreign key IDs to model instances"""
if not (model_class := self.content_type.model_class()):
raise ValueError("Could not resolve model class")
resolved_data = data.copy()
for field_name, value in data.items():
try:
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
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
) from None
except FieldDoesNotExist:
# Field doesn't exist on model, skip it
continue
return resolved_data
def _get_final_changes(self) -> dict[str, Any]:
"""Get the final changes to apply (moderator changes if available, otherwise original changes)"""
return self.moderator_changes or self.changes
def _get_model_class_for_item_type(self, item_type: str):
"""
Map item_type string to the corresponding Django model class.
Args:
item_type: Type string from frontend (e.g., 'manufacturer', 'park', 'ride_model')
Returns:
Model class for the item type
"""
# Lazy imports to avoid circular dependencies
from apps.parks.models import Company, Park
from apps.rides.models import Ride, RideModel
type_map = {
# Company types (all map to Company model)
'manufacturer': Company,
'designer': Company,
'operator': Company,
'property_owner': Company,
'company': Company,
# Entity types
'park': Park,
'ride': Ride,
'ride_model': RideModel,
}
model_class = type_map.get(item_type.lower())
if not model_class:
raise ValueError(f"Unknown item_type: {item_type}")
return model_class
def _process_composite_items(self, composite_items: list[dict[str, Any]]) -> dict[int, Any]:
"""
Process composite submission items (dependencies) before the primary entity.
Args:
composite_items: List of dependency items from frontend's submissionItems array
Each item has: item_type, action_type, item_data, order_index, depends_on
Returns:
Dictionary mapping order_index -> created entity ID for resolving temp references
"""
from django.db import transaction
# Sort by order_index to ensure proper dependency order
sorted_items = sorted(composite_items, key=lambda x: x.get('order_index', 0))
# Map of order_index -> created entity ID
created_entities: dict[int, Any] = {}
with transaction.atomic():
for item in sorted_items:
item_type = item.get('item_type', '')
item_data = item.get('item_data', {})
order_index = item.get('order_index', 0)
if not item_type or not item_data:
continue
# Get the model class for this item type
model_class = self._get_model_class_for_item_type(item_type)
# Clean up internal fields not needed for model creation
clean_data = {}
for key, value in item_data.items():
# Skip internal/temp fields
if key.startswith('_temp_') or key == 'images' or key == '_composite_items':
continue
# Skip fields with None or 'temp-' values
if value is None or (isinstance(value, str) and value.startswith('temp-')):
continue
clean_data[key] = value
# Resolve _temp_*_ref fields to actual entity IDs from previously created entities
for key, value in item_data.items():
if key.startswith('_temp_') and key.endswith('_ref'):
# Extract the field name: _temp_manufacturer_ref -> manufacturer_id
field_name = key[6:-4] + '_id' # Remove '_temp_' prefix and '_ref' suffix
ref_order_index = value
if isinstance(ref_order_index, int) and ref_order_index in created_entities:
clean_data[field_name] = created_entities[ref_order_index]
# Resolve foreign keys to model instances
resolved_data = {}
for field_name, value in clean_data.items():
try:
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)
resolved_data[field_name] = related_obj
except ObjectDoesNotExist:
# Skip invalid FK references
continue
else:
resolved_data[field_name] = value
except:
# Field doesn't exist on model, still try to include it
resolved_data[field_name] = value
# Create the entity
try:
obj = model_class(**resolved_data)
obj.full_clean()
obj.save()
created_entities[order_index] = obj.pk
except Exception as e:
# Log but continue - don't fail the whole submission for one dependency
import logging
logger = logging.getLogger(__name__)
logger.error(f"Failed to create composite item {item_type}: {e}")
continue
return created_entities
def claim(self, user: UserType) -> None:
"""
Claim this submission for review.
Transition: PENDING -> CLAIMED
Args:
user: The moderator claiming this submission
Raises:
ValidationError: If submission is not in PENDING state
"""
from django.core.exceptions import ValidationError
if self.status != "PENDING":
raise ValidationError(f"Cannot claim submission: current status is {self.status}, expected PENDING")
# Set status directly (similar to unclaim method)
# The transition_to_claimed FSM method was never defined
self.status = "CLAIMED"
self.claimed_by = user
self.claimed_at = timezone.now()
self.save()
def unclaim(self, user: UserType = None) -> None:
"""
Release claim on this submission.
Transition: CLAIMED -> PENDING
Args:
user: The user initiating the unclaim (for audit)
Raises:
ValidationError: If submission is not in CLAIMED state
"""
from django.core.exceptions import ValidationError
if self.status != "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
self.status = "PENDING"
self.claimed_by = None
self.claimed_at = None
self.save()
def approve(self, moderator: UserType, user=None) -> models.Model | None:
"""
Approve this submission and apply the changes.
Wrapper method that preserves business logic while using FSM.
Args:
moderator: The user approving the submission
user: Alternative parameter for FSM compatibility
Returns:
The created or updated model instance
Raises:
ValueError: If submission cannot be approved
ValidationError: If the data is invalid
"""
from django.core.exceptions import ValidationError
# Use user parameter if provided (FSM convention)
approver = user or moderator
# 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})")
model_class = self.content_type.model_class()
if not model_class:
raise ValueError("Could not resolve model class")
final_changes = self._get_final_changes()
# Process composite items (dependencies) first if present
created_entity_ids: dict[int, Any] = {}
if '_composite_items' in final_changes:
composite_items = final_changes.pop('_composite_items')
if composite_items and isinstance(composite_items, list):
created_entity_ids = self._process_composite_items(composite_items)
# Resolve _temp_*_ref fields in the primary entity using created dependency IDs
for key in list(final_changes.keys()):
if key.startswith('_temp_') and key.endswith('_ref'):
# Extract field name: _temp_manufacturer_ref -> manufacturer_id
field_name = key[6:-4] + '_id' # Remove '_temp_' and '_ref'
ref_order_index = final_changes.pop(key)
if isinstance(ref_order_index, int) and ref_order_index in created_entity_ids:
final_changes[field_name] = created_entity_ids[ref_order_index]
# Remove any remaining internal fields
keys_to_remove = [k for k in final_changes.keys() if k.startswith('_')]
for key in keys_to_remove:
final_changes.pop(key, None)
resolved_changes = self._resolve_foreign_keys(final_changes)
try:
if self.submission_type == "PHOTO":
# Handle photo submissions - create ParkPhoto or RidePhoto
from apps.parks.models.media import ParkPhoto
from apps.rides.models.media import RidePhoto
# Determine the correct photo model based on content type
model_name = model_class.__name__
if model_name == "Park":
PhotoModel = ParkPhoto
elif model_name == "Ride":
PhotoModel = RidePhoto
else:
raise ValueError(f"Unsupported content type for photo: {model_name}")
# Create the approved photo
obj = PhotoModel.objects.create(
uploaded_by=self.user,
content_object=self.content_object,
image=self.photo,
caption=self.caption or "",
is_approved=True,
)
elif self.submission_type == "CREATE":
# Create new object
obj = model_class(**resolved_changes)
obj.full_clean()
obj.save()
else:
# Update existing object (EDIT type)
if not self.content_object:
raise ValueError("Cannot update: content object not found")
obj = self.content_object
for field_name, value in resolved_changes.items():
if hasattr(obj, field_name):
setattr(obj, field_name, value)
obj.full_clean()
obj.save()
# Use FSM transition to update status
self.transition_to_approved(user=approver)
self.handled_by = approver
self.handled_at = timezone.now()
self.save()
return obj
except Exception as e:
# On error, record the issue and attempt rejection transition
self.notes = f"Approval failed: {str(e)}"
try:
self.transition_to_rejected(user=approver)
self.handled_by = approver
self.handled_at = timezone.now()
self.save()
except Exception:
pass
raise
def reject(self, moderator: UserType = None, reason: str = "", user=None) -> None:
"""
Reject this submission.
Wrapper method that preserves business logic while using FSM.
Args:
moderator: The user rejecting the submission
reason: Reason for rejection
user: Alternative parameter for FSM compatibility
"""
from django.core.exceptions import ValidationError
# Use user parameter if provided (FSM convention)
rejecter = user or moderator
# 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})")
# Use FSM transition to update status
self.transition_to_rejected(user=rejecter)
self.handled_by = rejecter
self.handled_at = timezone.now()
self.notes = f"Rejected: {reason}" if reason else "Rejected"
self.save()
def escalate(self, moderator: UserType = None, reason: str = "", user=None) -> None:
"""
Escalate this submission for higher-level review.
Wrapper method that preserves business logic while using FSM.
Args:
moderator: The user escalating the submission
reason: Reason for escalation
user: Alternative parameter for FSM compatibility
"""
from django.core.exceptions import ValidationError
# Use user parameter if provided (FSM convention)
escalator = user or moderator
# 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})")
# Use FSM transition to update status
self.transition_to_escalated(user=escalator)
self.handled_by = escalator
self.handled_at = timezone.now()
self.notes = f"Escalated: {reason}" if reason else "Escalated"
self.save()
@property
def submitted_by(self):
"""Alias for user field to maintain compatibility"""
return self.user
@property
def submitted_at(self):
"""Alias for created_at field to maintain compatibility"""
return self.created_at
# ============================================================================
# New Moderation System Models
# ============================================================================
@pghistory.track()
class ModerationReport(StateMachineMixin, TrackedModel):
"""
Model for tracking user reports about content, users, or behavior.
This handles the initial reporting phase where users flag content
or behavior that needs moderator attention.
"""
state_field_name = "status"
# Report details
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")
# 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)
# 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.)")
# Users involved
reported_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="moderation_reports_made",
help_text="User who made this report",
)
assigned_moderator = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
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")
# 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")
class Meta(TrackedModel.Meta):
verbose_name = "Moderation Report"
verbose_name_plural = "Moderation Reports"
ordering = ["-created_at"]
indexes = [
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
@pghistory.track()
class ModerationQueue(StateMachineMixin, TrackedModel):
"""
Model for managing moderation workflow and task assignment.
This represents items in the moderation queue that need attention,
separate from the initial reports.
"""
state_field_name = "status"
# Queue item details
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")
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")
# 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)
# Assignment and timing
assigned_to = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
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")
# Metadata
flagged_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="flagged_queue_items",
help_text="User who flagged this item",
)
tags = models.JSONField(default=list, blank=True, help_text="Tags for categorization")
# Related objects
related_report = models.ForeignKey(
ModerationReport,
on_delete=models.CASCADE,
null=True,
blank=True,
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")
class Meta(TrackedModel.Meta):
verbose_name = "Moderation Queue Item"
verbose_name_plural = "Moderation Queue Items"
ordering = ["priority", "created_at"]
indexes = [
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
@pghistory.track()
class ModerationAction(TrackedModel):
"""
Model for tracking actions taken against users or content.
This records what actions moderators have taken, including
warnings, suspensions, content removal, etc.
"""
# Action details
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"
)
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",
help_text="Moderator who took this action",
)
target_user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="moderation_actions_received",
help_text="User this action was taken against",
)
# Related objects
related_report = models.ForeignKey(
ModerationReport,
on_delete=models.SET_NULL,
null=True,
blank=True,
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")
class Meta(TrackedModel.Meta):
verbose_name = "Moderation Action"
verbose_name_plural = "Moderation Actions"
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"]),
]
def __str__(self):
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
if self.duration_hours and not self.expires_at:
self.expires_at = timezone.now() + timedelta(hours=self.duration_hours)
super().save(*args, **kwargs)
@pghistory.track()
class BulkOperation(StateMachineMixin, TrackedModel):
"""
Model for tracking bulk administrative operations.
This handles large-scale operations like bulk updates,
imports, exports, or mass moderation actions.
"""
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")
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")
# 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")
# Timing
estimated_duration_minutes = models.PositiveIntegerField(
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")
# Control
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",
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")
class Meta(TrackedModel.Meta):
verbose_name = "Bulk Operation"
verbose_name_plural = "Bulk Operations"
ordering = ["-created_at"]
indexes = [
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
@property
def progress_percentage(self):
"""Calculate progress percentage."""
if self.total_items == 0:
return 0.0
return round((self.processed_items / self.total_items) * 100, 2)
# NOTE: PhotoSubmission model removed - photos are now handled via
# EditSubmission with submission_type="PHOTO". See migration for details.
class ModerationAuditLog(models.Model):
"""
Audit log for moderation actions.
Records all moderation activities including approvals, rejections,
claims, escalations, and conversions for accountability and analytics.
"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
submission = models.ForeignKey(
EditSubmission,
on_delete=models.CASCADE,
related_name="audit_logs",
help_text="The submission this audit log entry is for",
)
moderator = models.ForeignKey(
settings.AUTH_USER_MODEL,
blank=True,
null=True,
on_delete=models.SET_NULL,
related_name="moderation_audit_logs",
help_text="The moderator who performed the action (null for system actions)",
)
action = RichChoiceField(
choice_group="moderation_audit_actions",
domain="moderation",
max_length=50,
db_index=True,
help_text="The action that was performed",
)
previous_status = models.CharField(
max_length=50,
blank=True,
null=True,
help_text="Status before the action",
)
new_status = models.CharField(
max_length=50,
blank=True,
null=True,
help_text="Status after the action",
)
notes = models.TextField(
blank=True,
null=True,
help_text="Notes or comments about the action",
)
is_system_action = models.BooleanField(
default=False,
db_index=True,
help_text="Whether this was an automated system action",
)
is_test_data = models.BooleanField(
default=False,
help_text="Whether this is test data",
)
# Timestamps
created_at = models.DateTimeField(
auto_now_add=True,
db_index=True,
help_text="When this action was performed",
)
class Meta:
ordering = ["-created_at"]
verbose_name = "Moderation Audit Log"
verbose_name_plural = "Moderation Audit Logs"
indexes = [
models.Index(fields=["submission", "created_at"]),
models.Index(fields=["moderator", "created_at"]),
models.Index(fields=["action", "created_at"]),
]
def __str__(self) -> str:
actor = self.moderator.username if self.moderator else "System"
return f"{self.get_action_display()} by {actor} on {self.submission_id}"