mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-05 11:05:17 -05:00
feat: add passkey authentication and enhance user preferences - Add passkey login security event type with fingerprint icon - Include request and site context in email confirmation for backend - Add user_id exact match filter to prevent incorrect user lookups - Enable PATCH method for updating user preferences via API - Add moderation_preferences support to user settings - Optimize ticket queries with select_related and prefetch_related This commit introduces passkey authentication tracking, improves user profile filtering accuracy, and extends the preferences API to support updates. Query optimizations reduce database hits for ticket listings.
1141 lines
43 KiB
Python
1141 lines
43 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 == "CREATE":
|
|
# Create new object
|
|
obj = model_class(**resolved_changes)
|
|
obj.full_clean()
|
|
obj.save()
|
|
else:
|
|
# Update existing object
|
|
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)
|
|
|
|
|
|
@pghistory.track() # Track all changes by default
|
|
class PhotoSubmission(StateMachineMixin, TrackedModel):
|
|
"""Photo submission model with FSM-managed status transitions."""
|
|
|
|
state_field_name = "status"
|
|
|
|
# Who submitted the photo
|
|
user = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.CASCADE,
|
|
related_name="photo_submissions",
|
|
help_text="User who submitted this photo",
|
|
)
|
|
|
|
# What the photo is for (Park or Ride)
|
|
content_type = models.ForeignKey(
|
|
ContentType,
|
|
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")
|
|
content_object = GenericForeignKey("content_type", "object_id")
|
|
|
|
# The photo itself
|
|
photo = models.ForeignKey(
|
|
"django_cloudflareimages_toolkit.CloudflareImage",
|
|
on_delete=models.CASCADE,
|
|
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")
|
|
|
|
# Metadata
|
|
status = RichFSMField(
|
|
choice_group="photo_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_photos",
|
|
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 photo 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_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")
|
|
|
|
class Meta(TrackedModel.Meta):
|
|
verbose_name = "Photo Submission"
|
|
verbose_name_plural = "Photo Submissions"
|
|
ordering = ["-created_at"]
|
|
indexes = [
|
|
models.Index(fields=["content_type", "object_id"]),
|
|
models.Index(fields=["status"]),
|
|
]
|
|
|
|
def __str__(self) -> str:
|
|
return f"Photo submission by {self.user.username} for {self.content_object}"
|
|
|
|
def claim(self, user: UserType) -> None:
|
|
"""
|
|
Claim this photo 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 photo 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 = None, notes: str = "", user=None) -> None:
|
|
"""
|
|
Approve the photo submission.
|
|
Wrapper method that preserves business logic while using FSM.
|
|
|
|
Args:
|
|
moderator: The user approving the submission
|
|
notes: Optional approval notes
|
|
user: Alternative parameter for FSM compatibility
|
|
"""
|
|
from django.core.exceptions import ValidationError
|
|
|
|
from apps.parks.models.media import ParkPhoto
|
|
from apps.rides.models.media import RidePhoto
|
|
|
|
# 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 photo submission: must be CLAIMED first (current status: {self.status})"
|
|
)
|
|
|
|
# Determine the correct photo model based on the content type
|
|
model_class = self.content_type.model_class()
|
|
if model_class.__name__ == "Park":
|
|
PhotoModel = ParkPhoto
|
|
elif model_class.__name__ == "Ride":
|
|
PhotoModel = RidePhoto
|
|
else:
|
|
raise ValueError(f"Unsupported content type: {model_class.__name__}")
|
|
|
|
# Create the approved photo
|
|
PhotoModel.objects.create(
|
|
uploaded_by=self.user,
|
|
content_object=self.content_object,
|
|
image=self.photo,
|
|
caption=self.caption,
|
|
is_approved=True,
|
|
)
|
|
|
|
# Use FSM transition to update status
|
|
self.transition_to_approved(user=approver)
|
|
self.handled_by = approver # type: ignore
|
|
self.handled_at = timezone.now()
|
|
self.notes = notes
|
|
self.save()
|
|
|
|
def reject(self, moderator: UserType = None, notes: str = "", user=None) -> None:
|
|
"""
|
|
Reject the photo submission.
|
|
Wrapper method that preserves business logic while using FSM.
|
|
|
|
Args:
|
|
moderator: The user rejecting the submission
|
|
notes: Rejection reason
|
|
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 photo 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 # type: ignore
|
|
self.handled_at = timezone.now()
|
|
self.notes = notes
|
|
self.save()
|
|
|
|
def auto_approve(self) -> None:
|
|
"""Auto-approve submissions from moderators."""
|
|
# Get user role safely
|
|
user_role = getattr(self.user, "role", None)
|
|
|
|
# If user is moderator or above, claim then approve
|
|
if user_role in ["MODERATOR", "ADMIN", "SUPERUSER"]:
|
|
self.claim(user=self.user)
|
|
self.approve(self.user)
|
|
|
|
def escalate(self, moderator: UserType = None, notes: str = "", user=None) -> None:
|
|
"""
|
|
Escalate the photo submission to admin.
|
|
Wrapper method that preserves business logic while using FSM.
|
|
|
|
Args:
|
|
moderator: The user escalating the submission
|
|
notes: Escalation reason
|
|
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 photo 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 # type: ignore
|
|
self.handled_at = timezone.now()
|
|
self.notes = notes
|
|
self.save()
|
|
|
|
|
|
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}"
|