feat: Implement MFA authentication, add ride statistics model, and update various services, APIs, and tests across the application.

This commit is contained in:
pacnpal
2025-12-28 17:32:53 -05:00
parent aa56c46c27
commit c95f99ca10
452 changed files with 7948 additions and 6073 deletions

View File

@@ -16,19 +16,21 @@ Callbacks for notifications, cache invalidation, and related updates
are registered via the callback configuration defined in each model's Meta 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
from datetime import timedelta
from typing import Any, Union
import pghistory
from django.conf import settings
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
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 = Union[AbstractBaseUser, AnonymousUser]
@@ -38,10 +40,10 @@ UserType = Union[AbstractBaseUser, AnonymousUser]
def _get_notification_callbacks():
"""Lazy import of notification callbacks."""
from apps.core.state_machine.callbacks.notifications import (
SubmissionApprovedNotification,
SubmissionRejectedNotification,
SubmissionEscalatedNotification,
ModerationNotificationCallback,
SubmissionApprovedNotification,
SubmissionEscalatedNotification,
SubmissionRejectedNotification,
)
return {
'approved': SubmissionApprovedNotification,
@@ -70,7 +72,7 @@ def _get_cache_callbacks():
@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
@@ -173,7 +175,7 @@ class EditSubmission(StateMachineMixin, TrackedModel):
target = "Unknown"
return f"{action} by {self.user.username} on {target}"
def _resolve_foreign_keys(self, data: Dict[str, Any]) -> Dict[str, Any]:
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")
@@ -197,7 +199,7 @@ class EditSubmission(StateMachineMixin, TrackedModel):
return resolved_data
def _get_final_changes(self) -> Dict[str, Any]:
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
@@ -213,12 +215,12 @@ class EditSubmission(StateMachineMixin, TrackedModel):
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"
)
self.transition_to_claimed(user=user)
self.claimed_by = user
self.claimed_at = timezone.now()
@@ -236,12 +238,12 @@ class EditSubmission(StateMachineMixin, TrackedModel):
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"
@@ -249,7 +251,7 @@ class EditSubmission(StateMachineMixin, TrackedModel):
self.claimed_at = None
self.save()
def approve(self, moderator: UserType, user=None) -> Optional[models.Model]:
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.
@@ -266,16 +268,16 @@ class EditSubmission(StateMachineMixin, TrackedModel):
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")
@@ -333,16 +335,16 @@ class EditSubmission(StateMachineMixin, TrackedModel):
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
@@ -361,16 +363,16 @@ class EditSubmission(StateMachineMixin, TrackedModel):
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
@@ -401,7 +403,7 @@ class ModerationReport(StateMachineMixin, TrackedModel):
This handles the initial reporting phase where users flag content
or behavior that needs moderator attention.
"""
state_field_name = "status"
# Report details
@@ -493,7 +495,7 @@ class ModerationQueue(StateMachineMixin, TrackedModel):
This represents items in the moderation queue that need attention,
separate from the initial reports.
"""
state_field_name = "status"
# Queue item details
@@ -678,7 +680,7 @@ class BulkOperation(StateMachineMixin, TrackedModel):
This handles large-scale operations like bulk updates,
imports, exports, or mass moderation actions.
"""
state_field_name = "status"
# Operation details
@@ -773,7 +775,7 @@ class BulkOperation(StateMachineMixin, TrackedModel):
@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
@@ -869,12 +871,12 @@ class PhotoSubmission(StateMachineMixin, TrackedModel):
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"
)
self.transition_to_claimed(user=user)
self.claimed_by = user
self.claimed_at = timezone.now()
@@ -892,12 +894,12 @@ class PhotoSubmission(StateMachineMixin, TrackedModel):
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"
@@ -909,25 +911,26 @@ class PhotoSubmission(StateMachineMixin, TrackedModel):
"""
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
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 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":
@@ -945,7 +948,7 @@ class PhotoSubmission(StateMachineMixin, TrackedModel):
caption=self.caption,
is_approved=True,
)
# Use FSM transition to update status
self.transition_to_approved(user=approver)
self.handled_by = approver # type: ignore
@@ -957,23 +960,23 @@ class PhotoSubmission(StateMachineMixin, TrackedModel):
"""
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
@@ -994,23 +997,23 @@ class PhotoSubmission(StateMachineMixin, TrackedModel):
"""
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