Refactor API structure and add comprehensive user management features

- Restructure API v1 with improved serializers organization
- Add user deletion requests and moderation queue system
- Implement bulk moderation operations and permissions
- Add user profile enhancements with display names and avatars
- Expand ride and park API endpoints with better filtering
- Add manufacturer API with detailed ride relationships
- Improve authentication flows and error handling
- Update frontend documentation and API specifications
This commit is contained in:
pacnpal
2025-08-29 16:03:51 -04:00
parent 7b9f64be72
commit bb7da85516
92 changed files with 19690 additions and 9076 deletions

View File

@@ -1,4 +1,17 @@
from typing import Any, Dict, Optional, Type, Union
"""
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.
"""
from typing import Any, Dict, Optional, Union
from django.db import models
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
@@ -7,12 +20,17 @@ from django.utils import timezone
from django.core.exceptions import ObjectDoesNotExist, FieldDoesNotExist
from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.auth.models import AnonymousUser
from datetime import timedelta
import pghistory
from apps.core.history import TrackedModel
UserType = Union[AbstractBaseUser, AnonymousUser]
# ============================================================================
# Original EditSubmission Model (Preserved)
# ============================================================================
@pghistory.track() # Track all changes by default
class EditSubmission(TrackedModel):
STATUS_CHOICES = [
@@ -79,7 +97,7 @@ class EditSubmission(TrackedModel):
blank=True, help_text="Notes from the moderator about this submission"
)
class Meta:
class Meta(TrackedModel.Meta):
ordering = ["-created_at"]
indexes = [
models.Index(fields=["content_type", "object_id"]),
@@ -103,128 +121,508 @@ class EditSubmission(TrackedModel):
for field_name, value in data.items():
try:
if (
(field := model_class._meta.get_field(field_name))
and isinstance(field, models.ForeignKey)
and value is not None
):
if related_model := field.related_model:
resolved_data[field_name] = related_model.objects.get(pk=value)
except (FieldDoesNotExist, ObjectDoesNotExist):
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:
raise ValueError(
f"Related object {field.related_model.__name__} with pk={value} does not exist"
)
except FieldDoesNotExist:
# Field doesn't exist on model, skip it
continue
return resolved_data
def _prepare_model_data(
self, data: Dict[str, Any], model_class: Type[models.Model]
) -> Dict[str, Any]:
"""Prepare data for model creation/update by filtering out auto-generated fields"""
prepared_data = data.copy()
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
# Remove fields that are auto-generated or handled by the model's save
# method
auto_fields = {"created_at", "updated_at", "slug"}
for field in auto_fields:
prepared_data.pop(field, None)
def approve(self, moderator: UserType) -> Optional[models.Model]:
"""
Approve this submission and apply the changes.
# Set default values for required fields if not provided
for field in model_class._meta.fields:
if not field.auto_created and not field.blank and not field.null:
if field.name not in prepared_data and field.has_default():
prepared_data[field.name] = field.get_default()
Args:
moderator: The user approving the submission
return prepared_data
Returns:
The created or updated model instance
def _check_duplicate_name(
self, model_class: Type[models.Model], name: str
) -> Optional[models.Model]:
"""Check if an object with the same name already exists"""
try:
return model_class.objects.filter(name=name).first()
except BaseException as e:
print(f"Error checking for duplicate name '{name}': {e}")
raise e
return None
Raises:
ValueError: If submission cannot be approved
ValidationError: If the data is invalid
"""
if self.status != "PENDING":
raise ValueError(f"Cannot approve submission with status {self.status}")
def approve(self, user: UserType) -> Optional[models.Model]:
"""Approve the submission and apply the changes"""
if not (model_class := self.content_type.model_class()):
model_class = self.content_type.model_class()
if not model_class:
raise ValueError("Could not resolve model class")
final_changes = self._get_final_changes()
resolved_changes = self._resolve_foreign_keys(final_changes)
try:
# Use moderator_changes if available, otherwise use original
# changes
changes_to_apply = (
self.moderator_changes
if self.moderator_changes is not None
else self.changes
)
resolved_data = self._resolve_foreign_keys(changes_to_apply)
prepared_data = self._prepare_model_data(resolved_data, model_class)
# For CREATE submissions, check for duplicates by name
if self.submission_type == "CREATE" and "name" in prepared_data:
if existing_obj := self._check_duplicate_name(
model_class, prepared_data["name"]
):
self.status = "REJECTED"
self.handled_by = user # type: ignore
self.handled_at = timezone.now()
self.notes = f"A {model_class.__name__} with the name '{
prepared_data['name']
}' already exists (ID: {existing_obj.pk})"
self.save()
raise ValueError(self.notes)
self.status = "APPROVED"
self.handled_by = user # type: ignore
self.handled_at = timezone.now()
if self.submission_type == "CREATE":
# Create new object
obj = model_class(**prepared_data)
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
obj = model_class(**resolved_changes)
obj.full_clean()
obj.save()
# Update object_id after creation
self.object_id = getattr(obj, "id", None)
else:
# Apply changes to existing object
if not (obj := self.content_object):
raise ValueError("Content object not found")
for field, value in prepared_data.items():
setattr(obj, field, value)
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
# 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()
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
self.full_clean()
# Mark submission as approved
self.status = "APPROVED"
self.handled_by = moderator
self.handled_at = timezone.now()
self.save()
return obj
except Exception as e:
if (
self.status != "REJECTED"
): # Don't override if already rejected due to duplicate
self.status = "PENDING" # Reset status if approval failed
self.save()
raise ValueError(f"Error approving submission: {str(e)}") from e
# Mark as rejected on any error
self.status = "REJECTED"
self.handled_by = moderator
self.handled_at = timezone.now()
self.notes = f"Approval failed: {str(e)}"
self.save()
raise
def reject(self, moderator: UserType, reason: str) -> None:
"""
Reject this submission.
Args:
moderator: The user rejecting the submission
reason: Reason for rejection
"""
if self.status != "PENDING":
raise ValueError(f"Cannot reject submission with status {self.status}")
def reject(self, user: UserType) -> None:
"""Reject the submission"""
self.status = "REJECTED"
self.handled_by = user # type: ignore
self.handled_by = moderator
self.handled_at = timezone.now()
self.notes = f"Rejected: {reason}"
self.save()
def escalate(self, user: UserType) -> None:
"""Escalate the submission to admin"""
def escalate(self, moderator: UserType, reason: str) -> None:
"""
Escalate this submission for higher-level review.
Args:
moderator: The user escalating the submission
reason: Reason for escalation
"""
if self.status != "PENDING":
raise ValueError(f"Cannot escalate submission with status {self.status}")
self.status = "ESCALATED"
self.handled_by = user # type: ignore
self.handled_by = moderator
self.handled_at = timezone.now()
self.notes = f"Escalated: {reason}"
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(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.
"""
STATUS_CHOICES = [
('PENDING', 'Pending Review'),
('UNDER_REVIEW', 'Under Review'),
('RESOLVED', 'Resolved'),
('DISMISSED', 'Dismissed'),
]
PRIORITY_CHOICES = [
('LOW', 'Low'),
('MEDIUM', 'Medium'),
('HIGH', 'High'),
('URGENT', 'Urgent'),
]
REPORT_TYPE_CHOICES = [
('SPAM', 'Spam'),
('HARASSMENT', 'Harassment'),
('INAPPROPRIATE_CONTENT', 'Inappropriate Content'),
('MISINFORMATION', 'Misinformation'),
('COPYRIGHT', 'Copyright Violation'),
('PRIVACY', 'Privacy Violation'),
('HATE_SPEECH', 'Hate Speech'),
('VIOLENCE', 'Violence or Threats'),
('OTHER', 'Other'),
]
# Report details
report_type = models.CharField(max_length=50, choices=REPORT_TYPE_CHOICES)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='PENDING')
priority = models.CharField(
max_length=10, choices=PRIORITY_CHOICES, 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'
)
assigned_moderator = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='assigned_moderation_reports'
)
# 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)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta(TrackedModel.Meta):
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}"
@pghistory.track()
class ModerationQueue(TrackedModel):
"""
Model for managing moderation workflow and task assignment.
This represents items in the moderation queue that need attention,
separate from the initial reports.
"""
STATUS_CHOICES = [
('PENDING', 'Pending'),
('IN_PROGRESS', 'In Progress'),
('COMPLETED', 'Completed'),
('CANCELLED', 'Cancelled'),
]
PRIORITY_CHOICES = [
('LOW', 'Low'),
('MEDIUM', 'Medium'),
('HIGH', 'High'),
('URGENT', 'Urgent'),
]
ITEM_TYPE_CHOICES = [
('CONTENT_REVIEW', 'Content Review'),
('USER_REVIEW', 'User Review'),
('BULK_ACTION', 'Bulk Action'),
('POLICY_VIOLATION', 'Policy Violation'),
('APPEAL', 'Appeal'),
('OTHER', 'Other'),
]
# Queue item details
item_type = models.CharField(max_length=50, choices=ITEM_TYPE_CHOICES)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='PENDING')
priority = models.CharField(
max_length=10, choices=PRIORITY_CHOICES, 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'
)
assigned_at = models.DateTimeField(null=True, blank=True)
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'
)
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'
)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta(TrackedModel.Meta):
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}"
@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_TYPE_CHOICES = [
('WARNING', 'Warning'),
('USER_SUSPENSION', 'User Suspension'),
('USER_BAN', 'User Ban'),
('CONTENT_REMOVAL', 'Content Removal'),
('CONTENT_EDIT', 'Content Edit'),
('CONTENT_RESTRICTION', 'Content Restriction'),
('ACCOUNT_RESTRICTION', 'Account Restriction'),
('OTHER', 'Other'),
]
# Action details
action_type = models.CharField(max_length=50, choices=ACTION_TYPE_CHOICES)
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'
)
target_user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='moderation_actions_received'
)
# Related objects
related_report = models.ForeignKey(
ModerationReport,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='actions_taken'
)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta(TrackedModel.Meta):
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}"
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(TrackedModel):
"""
Model for tracking bulk administrative operations.
This handles large-scale operations like bulk updates,
imports, exports, or mass moderation actions.
"""
STATUS_CHOICES = [
('PENDING', 'Pending'),
('RUNNING', 'Running'),
('COMPLETED', 'Completed'),
('FAILED', 'Failed'),
('CANCELLED', 'Cancelled'),
]
PRIORITY_CHOICES = [
('LOW', 'Low'),
('MEDIUM', 'Medium'),
('HIGH', 'High'),
('URGENT', 'Urgent'),
]
OPERATION_TYPE_CHOICES = [
('UPDATE_PARKS', 'Update Parks'),
('UPDATE_RIDES', 'Update Rides'),
('IMPORT_DATA', 'Import Data'),
('EXPORT_DATA', 'Export Data'),
('MODERATE_CONTENT', 'Moderate Content'),
('USER_ACTIONS', 'User Actions'),
('CLEANUP', 'Cleanup'),
('OTHER', 'Other'),
]
# Operation details
operation_type = models.CharField(max_length=50, choices=OPERATION_TYPE_CHOICES)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='PENDING')
priority = models.CharField(
max_length=10, choices=PRIORITY_CHOICES, 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'
)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
started_at = models.DateTimeField(null=True, blank=True)
completed_at = models.DateTimeField(null=True, blank=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta(TrackedModel.Meta):
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]}"
@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(TrackedModel):
@@ -270,7 +668,7 @@ class PhotoSubmission(TrackedModel):
help_text="Notes from the moderator about this photo submission",
)
class Meta:
class Meta(TrackedModel.Meta):
ordering = ["-created_at"]
indexes = [
models.Index(fields=["content_type", "object_id"]),
@@ -319,7 +717,7 @@ class PhotoSubmission(TrackedModel):
self.save()
def auto_approve(self) -> None:
"""Auto-approve submissions from moderators"""
"""Auto - approve submissions from moderators"""
# Get user role safely
user_role = getattr(self.user, "role", None)