Add test utilities and state machine diagrams for FSM models

- Introduced reusable test utilities in `backend/tests/utils` for FSM transitions, HTMX interactions, and common scenarios.
- Added factory functions for creating test submissions, parks, rides, and photo submissions.
- Implemented assertion helpers for verifying state changes, toast notifications, and transition logs.
- Created comprehensive state machine diagrams for all FSM-enabled models in `docs/STATE_DIAGRAMS.md`, detailing states, transitions, and guard conditions.
This commit is contained in:
pacnpal
2025-12-22 08:55:39 -05:00
parent b508434574
commit 45d97b6e68
71 changed files with 8608 additions and 633 deletions

View File

@@ -6,7 +6,10 @@
"Bash(find:*)",
"Bash(python:*)",
"Bash(DJANGO_SETTINGS_MODULE=config.django.local python:*)",
"Bash(DJANGO_SETTINGS_MODULE=config.django.local uv run python:*)"
"Bash(DJANGO_SETTINGS_MODULE=config.django.local uv run python:*)",
"Bash(ls:*)",
"Bash(grep:*)",
"Bash(mkdir:*)"
],
"deny": [],
"ask": []

View File

@@ -0,0 +1,37 @@
"""
Context processors for the core app.
This module provides context processors that add useful utilities
and data to template contexts across the application.
"""
from django_fsm import can_proceed
from .state_machine.exceptions import format_transition_error
from .state_machine.mixins import TRANSITION_METADATA
def fsm_context(request):
"""
Add FSM utilities to template context.
This context processor makes FSM-related utilities available in all
templates, enabling easier integration of state machine functionality.
Available context variables:
- can_proceed: Function to check if a transition can proceed
- format_transition_error: Function to format FSM exceptions
- TRANSITION_METADATA: Dictionary of default transition metadata
Usage in templates:
{% if can_proceed(submission.transition_to_approved, request.user) %}
<button>Approve</button>
{% endif %}
Returns:
Dictionary of FSM utilities
"""
return {
'can_proceed': can_proceed,
'format_transition_error': format_transition_error,
'TRANSITION_METADATA': TRANSITION_METADATA,
}

View File

@@ -8,7 +8,7 @@ showing which callbacks are registered for each model and transition.
from django.core.management.base import BaseCommand, CommandParser
from django.apps import apps
from apps.core.state_machine.callbacks import (
from apps.core.state_machine.callback_base import (
callback_registry,
CallbackStage,
)

View File

@@ -9,7 +9,7 @@ from django.core.management.base import BaseCommand, CommandParser, CommandError
from django.apps import apps
from django.contrib.auth import get_user_model
from apps.core.state_machine.callbacks import (
from apps.core.state_machine.callback_base import (
callback_registry,
CallbackStage,
TransitionContext,

View File

@@ -22,7 +22,7 @@ from .registry import (
register_transition_callbacks,
discover_and_register_callbacks,
)
from .callbacks import (
from .callback_base import (
BaseTransitionCallback,
PreTransitionCallback,
PostTransitionCallback,

View File

@@ -20,7 +20,7 @@ Callback Lifecycle:
Example Usage:
Define a custom callback::
from apps.core.state_machine.callbacks import (
from apps.core.state_machine.callback_base import (
PostTransitionCallback,
TransitionContext,
register_post_callback
@@ -91,7 +91,7 @@ class CallbackStage(Enum):
Example:
Register callbacks at different stages::
from apps.core.state_machine.callbacks import (
from apps.core.state_machine.callback_base import (
CallbackStage,
callback_registry
)

View File

@@ -11,7 +11,7 @@ import logging
from django.conf import settings
from django.db import models
from ..callbacks import PostTransitionCallback, TransitionContext
from ..callback_base import PostTransitionCallback, TransitionContext
logger = logging.getLogger(__name__)

View File

@@ -11,7 +11,7 @@ import logging
from django.conf import settings
from django.db import models
from ..callbacks import PostTransitionCallback, TransitionContext
from ..callback_base import PostTransitionCallback, TransitionContext
logger = logging.getLogger(__name__)

View File

@@ -11,7 +11,7 @@ import logging
from django.conf import settings
from django.db import models, transaction
from ..callbacks import PostTransitionCallback, TransitionContext
from ..callback_base import PostTransitionCallback, TransitionContext
logger = logging.getLogger(__name__)

View File

@@ -7,7 +7,7 @@ from django.db import models
from django_fsm import transition
from django_fsm_log.decorators import fsm_log_by
from .callbacks import (
from .callback_base import (
BaseTransitionCallback,
CallbackStage,
TransitionContext,
@@ -275,7 +275,7 @@ def register_method_callbacks(
if not metadata or not metadata.get('callbacks'):
return
from .callbacks import CallbackStage, PostTransitionCallback, PreTransitionCallback
from .callback_base import CallbackStage, PostTransitionCallback, PreTransitionCallback
for callback in metadata['callbacks']:
# Determine stage from callback type

View File

@@ -38,12 +38,72 @@ See Also:
- apps.core.state_machine.fields.RichFSMField: The FSM field implementation
- django_fsm.can_proceed: FSM transition checking utility
"""
from typing import Any, Iterable, Optional
from typing import Any, Dict, Iterable, List, Optional
from django.db import models
from django_fsm import can_proceed
# Default transition metadata for styling
TRANSITION_METADATA = {
# Approval transitions
"approve": {"style": "green", "icon": "check", "requires_confirm": True, "confirm_message": "Are you sure you want to approve this?"},
"transition_to_approved": {"style": "green", "icon": "check", "requires_confirm": True, "confirm_message": "Are you sure you want to approve this?"},
# Rejection transitions
"reject": {"style": "red", "icon": "times", "requires_confirm": True, "confirm_message": "Are you sure you want to reject this?"},
"transition_to_rejected": {"style": "red", "icon": "times", "requires_confirm": True, "confirm_message": "Are you sure you want to reject this?"},
# Escalation transitions
"escalate": {"style": "yellow", "icon": "arrow-up", "requires_confirm": True, "confirm_message": "Are you sure you want to escalate this?"},
"transition_to_escalated": {"style": "yellow", "icon": "arrow-up", "requires_confirm": True, "confirm_message": "Are you sure you want to escalate this?"},
# Assignment transitions
"assign": {"style": "blue", "icon": "user-plus", "requires_confirm": False},
"unassign": {"style": "gray", "icon": "user-minus", "requires_confirm": False},
# Status transitions
"start": {"style": "blue", "icon": "play", "requires_confirm": False},
"complete": {"style": "green", "icon": "check-circle", "requires_confirm": True, "confirm_message": "Are you sure you want to complete this?"},
"cancel": {"style": "red", "icon": "ban", "requires_confirm": True, "confirm_message": "Are you sure you want to cancel this?"},
"reopen": {"style": "blue", "icon": "redo", "requires_confirm": False},
# Resolution transitions
"resolve": {"style": "green", "icon": "check-double", "requires_confirm": True, "confirm_message": "Are you sure you want to resolve this?"},
"dismiss": {"style": "gray", "icon": "times-circle", "requires_confirm": True, "confirm_message": "Are you sure you want to dismiss this?"},
# Default
"default": {"style": "gray", "icon": "arrow-right", "requires_confirm": False},
}
def _get_transition_metadata(transition_name: str) -> Dict[str, Any]:
"""Get metadata for a transition by name."""
if transition_name in TRANSITION_METADATA:
return TRANSITION_METADATA[transition_name].copy()
for key, metadata in TRANSITION_METADATA.items():
if key in transition_name.lower() or transition_name.lower() in key:
return metadata.copy()
return TRANSITION_METADATA["default"].copy()
def _format_transition_label(transition_name: str) -> str:
"""Format a transition method name into a human-readable label."""
label = transition_name
for prefix in ['transition_to_', 'transition_', 'do_']:
if label.startswith(prefix):
label = label[len(prefix):]
break
if label.endswith('ed') and len(label) > 3:
if label.endswith('ied'):
label = label[:-3] + 'y'
elif label[-3] == label[-4]:
label = label[:-3]
else:
label = label[:-1]
if not label.endswith('e'):
label = label[:-1]
return label.replace('_', ' ').title()
class StateMachineMixin(models.Model):
"""
Common helpers for models that use django-fsm.
@@ -148,5 +208,57 @@ class StateMachineMixin(models.Model):
current_state = self.get_state_value(field_name)
return current_state == state
def get_available_user_transitions(self, user) -> List[Dict[str, Any]]:
"""
Get transitions available to the given user.
__all__ = ["StateMachineMixin"]
This method returns a list of transition metadata dictionaries for
transitions that the given user can execute based on their permissions.
Args:
user: The user to check permissions for
Returns:
List of dictionaries containing:
- name: The transition method name
- label: Human-readable label
- icon: Font Awesome icon name
- style: Button style (green, red, yellow, blue, gray)
- requires_confirm: Whether confirmation is needed
- confirm_message: Message to show in confirmation dialog
Example:
transitions = submission.get_available_user_transitions(request.user)
for t in transitions:
print(f"{t['label']}: {t['name']}")
"""
transitions = []
if not user:
return transitions
# Get available transitions from the FSM field
available_transition_names = list(self.get_available_transitions())
for transition_name in available_transition_names:
method = getattr(self, transition_name, None)
if method and callable(method):
try:
if can_proceed(method, user):
metadata = _get_transition_metadata(transition_name)
transitions.append({
'name': transition_name,
'label': _format_transition_label(transition_name),
'icon': metadata.get('icon', 'arrow-right'),
'style': metadata.get('style', 'gray'),
'requires_confirm': metadata.get('requires_confirm', False),
'confirm_message': metadata.get('confirm_message', 'Are you sure?'),
})
except Exception:
# Skip transitions that raise errors
pass
return transitions
__all__ = ["StateMachineMixin", "TRANSITION_METADATA"]

View File

@@ -16,7 +16,7 @@ import threading
from django.conf import settings
from django.db import models
from .callbacks import TransitionContext
from .callback_base import TransitionContext
logger = logging.getLogger(__name__)
@@ -431,7 +431,7 @@ def get_callback_execution_order(
Returns:
List of (stage, callback_name, priority) tuples in execution order.
"""
from .callbacks import callback_registry, CallbackStage
from .callback_base import callback_registry, CallbackStage
order = []

View File

@@ -307,7 +307,7 @@ def register_callback(
callback: The callback instance.
stage: When to execute ('pre', 'post', 'error').
"""
from .callbacks import callback_registry, CallbackStage
from .callback_base import callback_registry, CallbackStage
callback_registry.register(
model_class=model_class,

View File

@@ -11,7 +11,7 @@ import logging
from django.db import models
from django.dispatch import Signal, receiver
from .callbacks import TransitionContext
from .callback_base import TransitionContext
logger = logging.getLogger(__name__)

View File

@@ -452,7 +452,7 @@ class NotificationCallbackTests(TestCase):
instance=None,
):
"""Helper to create a TransitionContext."""
from ..callbacks import TransitionContext
from ..callback_base import TransitionContext
from django.utils import timezone
if instance is None:
@@ -603,7 +603,7 @@ class CacheCallbackTests(TestCase):
target_state: str = 'CLOSED_TEMP',
):
"""Helper to create a TransitionContext."""
from ..callbacks import TransitionContext
from ..callback_base import TransitionContext
from django.utils import timezone
instance = Mock()
@@ -716,7 +716,7 @@ class ModelCacheInvalidationTests(TestCase):
model_name: str = 'Ride',
instance_id: int = 789,
):
from ..callbacks import TransitionContext
from ..callback_base import TransitionContext
from django.utils import timezone
instance = Mock()
@@ -783,7 +783,7 @@ class RelatedUpdateCallbackTests(TestCase):
instance=None,
target_state: str = 'OPERATING',
):
from ..callbacks import TransitionContext
from ..callback_base import TransitionContext
from django.utils import timezone
if instance is None:
@@ -920,7 +920,7 @@ class CallbackErrorHandlingTests(TestCase):
"""Tests for callback error handling paths."""
def _create_transition_context(self):
from ..callbacks import TransitionContext
from ..callback_base import TransitionContext
from django.utils import timezone
instance = Mock()
@@ -979,7 +979,7 @@ class CallbackErrorHandlingTests(TestCase):
def test_callback_with_none_user(self):
"""Test callbacks handle None user gracefully."""
from ..callbacks import TransitionContext
from ..callback_base import TransitionContext
from ..callbacks.notifications import NotificationCallback
from django.utils import timezone

View File

@@ -0,0 +1 @@
# Template tags for the core app

View File

@@ -0,0 +1,434 @@
"""
Template tags for FSM (Finite State Machine) operations.
This module provides template tags and filters for working with FSM-enabled
models in Django templates, including transition buttons, status displays,
and permission checks.
Usage:
{% load fsm_tags %}
{# Get available transitions for an object #}
{% get_available_transitions submission request.user as transitions %}
{# Check if a specific transition is allowed #}
{% can_transition submission 'approve' request.user as can_approve %}
{# Get the current state value #}
{{ submission|get_state_value }}
{# Get the current state display #}
{{ submission|get_state_display }}
{# Render a transition button #}
{% transition_button submission 'approve' request.user %}
"""
from typing import Any, Dict, List, Optional
from django import template
from django.urls import reverse, NoReverseMatch
from django_fsm import can_proceed
from apps.core.views.views import get_transition_metadata, TRANSITION_METADATA
register = template.Library()
# =============================================================================
# Filters for State Machine Properties
# =============================================================================
@register.filter
def get_state_value(obj) -> Optional[str]:
"""
Get the current state value of an FSM-enabled object.
Usage:
{{ object|get_state_value }}
Args:
obj: An FSM-enabled model instance
Returns:
The current state value or None
"""
if hasattr(obj, 'get_state_value'):
return obj.get_state_value()
if hasattr(obj, 'state_field_name'):
return getattr(obj, obj.state_field_name, None)
# Try common field names
for field in ['status', 'state']:
if hasattr(obj, field):
return getattr(obj, field, None)
return None
@register.filter
def get_state_display(obj) -> str:
"""
Get the display value for the current state.
Usage:
{{ object|get_state_display }}
Args:
obj: An FSM-enabled model instance
Returns:
The human-readable state display value
"""
if hasattr(obj, 'get_state_display_value'):
return obj.get_state_display_value()
if hasattr(obj, 'state_field_name'):
field_name = obj.state_field_name
getter = getattr(obj, f'get_{field_name}_display', None)
if callable(getter):
return getter()
# Try common field names
for field in ['status', 'state']:
getter = getattr(obj, f'get_{field}_display', None)
if callable(getter):
return getter()
return str(get_state_value(obj) or '')
@register.filter
def get_state_choice(obj):
"""
Get the RichChoice object for the current state.
Usage:
{% with choice=object|get_state_choice %}
{{ choice.metadata.icon }}
{% endwith %}
Args:
obj: An FSM-enabled model instance
Returns:
The RichChoice object or None
"""
if hasattr(obj, 'get_state_choice'):
return obj.get_state_choice()
return None
@register.filter
def app_label(obj) -> str:
"""
Get the app label of a model instance.
Usage:
{{ object|app_label }}
Args:
obj: A Django model instance
Returns:
The app label string
"""
return obj._meta.app_label
@register.filter
def model_name(obj) -> str:
"""
Get the model name (lowercase) of a model instance.
Usage:
{{ object|model_name }}
Args:
obj: A Django model instance
Returns:
The model name in lowercase
"""
return obj._meta.model_name
@register.filter
def default_target_id(obj) -> str:
"""
Get the default HTMX target ID for an object.
Usage:
{{ object|default_target_id }}
Args:
obj: A Django model instance
Returns:
The target ID string (e.g., "editsubmission-123")
"""
return f"{obj._meta.model_name}-{obj.pk}"
# =============================================================================
# Assignment Tags for Transition Operations
# =============================================================================
@register.simple_tag
def get_available_transitions(obj, user) -> List[Dict[str, Any]]:
"""
Get all available transitions for an object that the user can execute.
This tag checks each transition method on the object and returns metadata
for transitions that can proceed for the given user.
Usage:
{% get_available_transitions submission request.user as transitions %}
{% for transition in transitions %}
<button>{{ transition.label }}</button>
{% endfor %}
Args:
obj: An FSM-enabled model instance
user: The user to check permissions for
Returns:
List of transition metadata dictionaries with keys:
- name: The method name
- label: Human-readable label
- icon: Font Awesome icon name
- style: Button style (green, red, yellow, blue, gray)
- requires_confirm: Whether to show confirmation dialog
- confirm_message: Confirmation message to display
"""
transitions = []
if not obj or not user:
return transitions
# Get list of available transitions
available_transition_names = []
if hasattr(obj, 'get_available_user_transitions'):
# Use the helper method if available
return obj.get_available_user_transitions(user)
if hasattr(obj, 'get_available_transitions'):
available_transition_names = list(obj.get_available_transitions())
else:
# Fallback: look for transition methods by convention
for attr_name in dir(obj):
if attr_name.startswith('transition_to_') or attr_name in ['approve', 'reject', 'escalate', 'complete', 'cancel']:
method = getattr(obj, attr_name, None)
if callable(method) and hasattr(method, '_django_fsm'):
available_transition_names.append(attr_name)
# Filter transitions by user permission
for transition_name in available_transition_names:
method = getattr(obj, transition_name, None)
if method and callable(method):
try:
if can_proceed(method, user):
metadata = get_transition_metadata(transition_name)
transitions.append({
'name': transition_name,
'label': _format_transition_label(transition_name),
'icon': metadata.get('icon', 'arrow-right'),
'style': metadata.get('style', 'gray'),
'requires_confirm': metadata.get('requires_confirm', False),
'confirm_message': metadata.get('confirm_message', 'Are you sure?'),
})
except Exception:
# Skip transitions that raise errors during can_proceed check
pass
return transitions
@register.simple_tag
def can_transition(obj, transition_name: str, user) -> bool:
"""
Check if a specific transition can be executed by the user.
Usage:
{% can_transition submission 'approve' request.user as can_approve %}
{% if can_approve %}
<button>Approve</button>
{% endif %}
Args:
obj: An FSM-enabled model instance
transition_name: The name of the transition method
user: The user to check permissions for
Returns:
True if the transition can proceed, False otherwise
"""
if not obj or not user or not transition_name:
return False
method = getattr(obj, transition_name, None)
if not method or not callable(method):
return False
try:
return can_proceed(method, user)
except Exception:
return False
@register.simple_tag
def get_transition_url(obj, transition_name: str) -> str:
"""
Get the URL for executing a transition on an object.
Usage:
{% get_transition_url submission 'approve' as approve_url %}
Args:
obj: An FSM-enabled model instance
transition_name: The name of the transition method
Returns:
The URL string for the transition endpoint
"""
try:
return reverse('core:fsm_transition', kwargs={
'app_label': obj._meta.app_label,
'model_name': obj._meta.model_name,
'pk': obj.pk,
'transition_name': transition_name,
})
except NoReverseMatch:
return ''
# =============================================================================
# Inclusion Tags for Rendering Components
# =============================================================================
@register.inclusion_tag('htmx/state_actions.html', takes_context=True)
def render_state_actions(context, obj, user=None, **kwargs):
"""
Render the state action buttons for an FSM-enabled object.
Usage:
{% render_state_actions submission request.user %}
{% render_state_actions submission request.user button_size='sm' %}
Args:
context: Template context
obj: An FSM-enabled model instance
user: The user to check permissions for (defaults to request.user)
**kwargs: Additional template context variables
Returns:
Context for the state_actions.html template
"""
if user is None:
user = context.get('request', {}).user if 'request' in context else None
return {
'object': obj,
'user': user,
'request': context.get('request'),
**kwargs,
}
@register.inclusion_tag('htmx/status_with_actions.html', takes_context=True)
def render_status_with_actions(context, obj, user=None, **kwargs):
"""
Render the status badge with action buttons for an FSM-enabled object.
Usage:
{% render_status_with_actions submission request.user %}
{% render_status_with_actions submission request.user dropdown_actions=True %}
Args:
context: Template context
obj: An FSM-enabled model instance
user: The user to check permissions for (defaults to request.user)
**kwargs: Additional template context variables
Returns:
Context for the status_with_actions.html template
"""
if user is None:
user = context.get('request', {}).user if 'request' in context else None
return {
'object': obj,
'user': user,
'request': context.get('request'),
**kwargs,
}
# =============================================================================
# Helper Functions
# =============================================================================
def _format_transition_label(transition_name: str) -> str:
"""
Format a transition method name into a human-readable label.
Examples:
'transition_to_approved' -> 'Approve'
'approve' -> 'Approve'
'reject_submission' -> 'Reject Submission'
Args:
transition_name: The transition method name
Returns:
Human-readable label
"""
# Remove common prefixes
label = transition_name
for prefix in ['transition_to_', 'transition_', 'do_']:
if label.startswith(prefix):
label = label[len(prefix):]
break
# Remove past tense suffix and capitalize
# e.g., 'approved' -> 'Approve'
if label.endswith('ed') and len(label) > 3:
# Handle special cases
if label.endswith('ied'):
label = label[:-3] + 'y'
elif label[-3] == label[-4]: # doubled consonant (e.g., 'submitted')
label = label[:-3]
else:
label = label[:-1] # Remove 'd'
if label.endswith('e'):
pass # Keep the 'e' for words like 'approve'
else:
label = label[:-1] # Remove 'e' for words like 'rejected' -> 'reject'
# Replace underscores with spaces and title case
label = label.replace('_', ' ').title()
return label
# =============================================================================
# Registration
# =============================================================================
# Ensure all tags and filters are registered
__all__ = [
# Filters
'get_state_value',
'get_state_display',
'get_state_choice',
'app_label',
'model_name',
'default_target_id',
# Tags
'get_available_transitions',
'can_transition',
'get_transition_url',
# Inclusion tags
'render_state_actions',
'render_status_with_actions',
]

View File

@@ -1,26 +0,0 @@
"""
Core app URL configuration.
"""
from django.urls import path, include
from .views.entity_search import (
EntityFuzzySearchView,
EntityNotFoundView,
QuickEntitySuggestionView,
)
app_name = "core"
# Entity search endpoints
entity_patterns = [
path("search/", EntityFuzzySearchView.as_view(), name="entity_fuzzy_search"),
path("not-found/", EntityNotFoundView.as_view(), name="entity_not_found"),
path(
"suggestions/", QuickEntitySuggestionView.as_view(), name="entity_suggestions"
),
]
urlpatterns = [
# Entity fuzzy matching and search endpoints
path("entities/", include(entity_patterns)),
]

View File

@@ -1 +1,47 @@
# URLs package for core app
"""
Core app URL configuration.
"""
from django.urls import path, include
from ..views.entity_search import (
EntityFuzzySearchView,
EntityNotFoundView,
QuickEntitySuggestionView,
)
from ..views.views import FSMTransitionView
app_name = "core"
# Entity search endpoints
entity_patterns = [
path("search/", EntityFuzzySearchView.as_view(), name="entity_fuzzy_search"),
path("not-found/", EntityNotFoundView.as_view(), name="entity_not_found"),
path(
"suggestions/", QuickEntitySuggestionView.as_view(), name="entity_suggestions"
),
]
# FSM transition endpoints
fsm_patterns = [
# Generic FSM transition endpoint
# URL: /core/fsm/<app_label>/<model_name>/<pk>/transition/<transition_name>/
path(
"<str:app_label>/<str:model_name>/<int:pk>/transition/<str:transition_name>/",
FSMTransitionView.as_view(),
name="fsm_transition",
),
# Slug-based FSM transition endpoint for models that use slugs
# URL: /core/fsm/<app_label>/<model_name>/by-slug/<slug>/transition/<transition_name>/
path(
"<str:app_label>/<str:model_name>/by-slug/<slug:slug>/transition/<str:transition_name>/",
FSMTransitionView.as_view(),
name="fsm_transition_by_slug",
),
]
urlpatterns = [
# Entity fuzzy matching and search endpoints
path("entities/", include(entity_patterns)),
# FSM transition endpoints
path("fsm/", include(fsm_patterns)),
]

View File

@@ -1,14 +1,30 @@
"""
Core views for the application.
"""
import json
import logging
from typing import Any, Dict, Optional, Type
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from django.db.models import Model
from django.http import HttpRequest, HttpResponse
from django.shortcuts import redirect, render
from django.http import HttpRequest, HttpResponse, JsonResponse
from django.shortcuts import redirect, render, get_object_or_404
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.csrf import csrf_protect
from django.views.generic import DetailView, TemplateView
from django_fsm import can_proceed, TransitionNotAllowed
from apps.core.state_machine.exceptions import (
TransitionPermissionDenied,
TransitionValidationError,
TransitionNotAvailable,
format_transition_error,
)
logger = logging.getLogger(__name__)
class SlugRedirectMixin(View):
@@ -91,3 +107,409 @@ class GlobalSearchView(TemplateView):
)
return render(request, self.template_name, context)
# =============================================================================
# FSM Transition View Infrastructure
# =============================================================================
# Default transition metadata for styling
TRANSITION_METADATA = {
# Approval transitions
"approve": {"style": "green", "icon": "check", "requires_confirm": True, "confirm_message": "Are you sure you want to approve this?"},
"transition_to_approved": {"style": "green", "icon": "check", "requires_confirm": True, "confirm_message": "Are you sure you want to approve this?"},
# Rejection transitions
"reject": {"style": "red", "icon": "times", "requires_confirm": True, "confirm_message": "Are you sure you want to reject this?"},
"transition_to_rejected": {"style": "red", "icon": "times", "requires_confirm": True, "confirm_message": "Are you sure you want to reject this?"},
# Escalation transitions
"escalate": {"style": "yellow", "icon": "arrow-up", "requires_confirm": True, "confirm_message": "Are you sure you want to escalate this?"},
"transition_to_escalated": {"style": "yellow", "icon": "arrow-up", "requires_confirm": True, "confirm_message": "Are you sure you want to escalate this?"},
# Assignment transitions
"assign": {"style": "blue", "icon": "user-plus", "requires_confirm": False},
"unassign": {"style": "gray", "icon": "user-minus", "requires_confirm": False},
# Status transitions
"start": {"style": "blue", "icon": "play", "requires_confirm": False},
"complete": {"style": "green", "icon": "check-circle", "requires_confirm": True, "confirm_message": "Are you sure you want to complete this?"},
"cancel": {"style": "red", "icon": "ban", "requires_confirm": True, "confirm_message": "Are you sure you want to cancel this?"},
"reopen": {"style": "blue", "icon": "redo", "requires_confirm": False},
# Resolution transitions
"resolve": {"style": "green", "icon": "check-double", "requires_confirm": True, "confirm_message": "Are you sure you want to resolve this?"},
"dismiss": {"style": "gray", "icon": "times-circle", "requires_confirm": True, "confirm_message": "Are you sure you want to dismiss this?"},
# Default
"default": {"style": "gray", "icon": "arrow-right", "requires_confirm": False},
}
def get_transition_metadata(transition_name: str) -> Dict[str, Any]:
"""Get metadata for a transition by name."""
# Check for exact match first
if transition_name in TRANSITION_METADATA:
return TRANSITION_METADATA[transition_name].copy()
# Check for partial match (e.g., "transition_to_approved" contains "approve")
for key, metadata in TRANSITION_METADATA.items():
if key in transition_name.lower() or transition_name.lower() in key:
return metadata.copy()
return TRANSITION_METADATA["default"].copy()
def add_toast_trigger(response: HttpResponse, message: str, toast_type: str = "success") -> HttpResponse:
"""
Add HX-Trigger header to trigger Alpine.js toast.
Args:
response: The HTTP response to modify
message: Toast message to display
toast_type: Type of toast ('success', 'error', 'warning', 'info')
Returns:
Modified response with HX-Trigger header
"""
trigger_data = {
"showToast": {
"message": message,
"type": toast_type
}
}
response["HX-Trigger"] = json.dumps(trigger_data)
return response
@method_decorator(csrf_protect, name='dispatch')
class FSMTransitionView(View):
"""
Generic view for handling FSM state transitions via HTMX.
This view handles POST requests to execute FSM transitions on any model
that uses django-fsm. It validates permissions, executes the transition,
and returns either an updated HTML partial (for HTMX) or JSON response.
URL pattern should provide:
- app_label: The app containing the model
- model_name: The model name (lowercase)
- pk: The primary key of the object
- transition_name: The name of the transition method to execute
Example URL patterns:
path('fsm/<str:app_label>/<str:model_name>/<int:pk>/transition/<str:transition_name>/',
FSMTransitionView.as_view(), name='fsm_transition')
"""
# Override these in subclasses or pass via URL kwargs
partial_template = None # Template to render after successful transition
def get_model_class(self, app_label: str, model_name: str) -> Optional[Type[Model]]:
"""
Get the model class from app_label and model_name.
Args:
app_label: The Django app label (e.g., 'moderation')
model_name: The model name in lowercase (e.g., 'editsubmission')
Returns:
The model class or None if not found
"""
try:
content_type = ContentType.objects.get(app_label=app_label, model=model_name)
return content_type.model_class()
except ContentType.DoesNotExist:
return None
def get_object(self, model_class: Type[Model], pk: Any, slug: Optional[str] = None) -> Model:
"""
Get the model instance.
Args:
model_class: The model class
pk: Primary key of the object (can be int or slug)
slug: Optional slug if using slug-based lookup
Returns:
The model instance
Raises:
Http404: If object not found
"""
if slug:
return get_object_or_404(model_class, slug=slug)
return get_object_or_404(model_class, pk=pk)
def get_transition_method(self, obj: Model, transition_name: str):
"""
Get the transition method from the object.
Args:
obj: The model instance
transition_name: The name of the transition method
Returns:
The transition method or None
"""
return getattr(obj, transition_name, None)
def validate_transition(self, obj: Model, transition_name: str, user) -> tuple[bool, Optional[str]]:
"""
Validate that the transition can proceed.
Args:
obj: The model instance
transition_name: The name of the transition method
user: The user attempting the transition
Returns:
Tuple of (can_proceed, error_message)
"""
method = self.get_transition_method(obj, transition_name)
if method is None:
return False, f"Transition '{transition_name}' not found on {obj.__class__.__name__}"
if not callable(method):
return False, f"'{transition_name}' is not a callable method"
# Check if the transition can proceed
if not can_proceed(method, user):
return False, f"Transition '{transition_name}' is not allowed from current state"
return True, None
def execute_transition(self, obj: Model, transition_name: str, user, **kwargs) -> None:
"""
Execute the transition on the object.
Args:
obj: The model instance
transition_name: The name of the transition method
user: The user performing the transition
**kwargs: Additional arguments to pass to the transition method
Raises:
TransitionNotAllowed: If transition fails
"""
method = self.get_transition_method(obj, transition_name)
# Execute the transition with user parameter
method(user=user, **kwargs)
obj.save()
def get_success_message(self, obj: Model, transition_name: str) -> str:
"""Generate a success message for the transition."""
# Clean up transition name for display
display_name = transition_name.replace("transition_to_", "").replace("_", " ").title()
model_name = obj._meta.verbose_name.title()
return f"{model_name} has been {display_name.lower()}d successfully."
def get_error_message(self, error: Exception) -> str:
"""Generate an error message from an exception."""
if hasattr(error, "user_message"):
return error.user_message
return str(error) or "An error occurred during the transition."
def get_partial_template(self, obj: Model, request: HttpRequest) -> Optional[str]:
"""
Get the template to render after a successful transition.
Override this method to return a custom template based on the object.
Uses Django's template loader to find model-specific templates.
"""
if self.partial_template:
return self.partial_template
app_label = obj._meta.app_label
model_name = obj._meta.model_name
# Special handling for parks and rides - return status section
if app_label == 'parks' and model_name == 'park':
return "parks/partials/park_status_actions.html"
elif app_label == 'rides' and model_name == 'ride':
return "rides/partials/ride_status_actions.html"
# Check for model-specific templates in order of preference
possible_templates = [
f"{app_label}/partials/{model_name}_row.html",
f"{app_label}/partials/{model_name}_item.html",
f"{app_label}/partials/{model_name}.html",
"htmx/updated_row.html",
]
# Use template loader to check if template exists
from django.template.loader import select_template
from django.template import TemplateDoesNotExist
try:
template = select_template(possible_templates)
return template.template.name
except TemplateDoesNotExist:
return "htmx/updated_row.html"
def format_success_response(
self,
request: HttpRequest,
obj: Model,
transition_name: str
) -> HttpResponse:
"""
Format a successful transition response.
For HTMX requests: renders the partial template with toast trigger
For regular requests: returns JSON response
"""
message = self.get_success_message(obj, transition_name)
if request.headers.get("HX-Request"):
# HTMX request - render partial and add toast trigger
template = self.get_partial_template(obj, request)
if template:
# Build context with object and model-specific variable names
context = {
"object": obj,
"user": request.user,
"transition_success": True,
"success_message": message,
}
# Add model-specific variable (e.g., 'park' or 'ride') for template compatibility
model_name = obj._meta.model_name
context[model_name] = obj
response = render(request, template, context)
else:
# No template - return empty response with OOB swap for status
response = HttpResponse("")
return add_toast_trigger(response, message, "success")
# Regular request - return JSON
return JsonResponse({
"success": True,
"message": message,
"new_state": getattr(obj, obj.state_field_name, None) if hasattr(obj, "state_field_name") else None,
})
def format_error_response(
self,
request: HttpRequest,
error: Exception,
status_code: int = 400
) -> HttpResponse:
"""
Format an error response.
For HTMX requests: returns error with toast trigger
For regular requests: returns JSON response
"""
message = self.get_error_message(error)
error_data = format_transition_error(error)
if request.headers.get("HX-Request"):
# HTMX request - return error response with toast trigger
response = HttpResponse(status=status_code)
return add_toast_trigger(response, message, "error")
# Regular request - return JSON
return JsonResponse({
"success": False,
"error": error_data,
}, status=status_code)
def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
"""Handle POST request to execute a transition."""
app_label = kwargs.get("app_label")
model_name = kwargs.get("model_name")
pk = kwargs.get("pk")
slug = kwargs.get("slug")
transition_name = kwargs.get("transition_name")
# Validate required parameters
if not all([app_label, model_name, transition_name]):
return self.format_error_response(
request,
ValueError("Missing required parameters: app_label, model_name, and transition_name"),
400
)
if not pk and not slug:
return self.format_error_response(
request,
ValueError("Missing required parameter: pk or slug"),
400
)
# Get the model class
model_class = self.get_model_class(app_label, model_name)
if model_class is None:
return self.format_error_response(
request,
ValueError(f"Model '{app_label}.{model_name}' not found"),
404
)
# Get the object
try:
obj = self.get_object(model_class, pk, slug)
except ObjectDoesNotExist:
return self.format_error_response(
request,
ValueError(f"Object not found: {model_name} with pk={pk}"),
404
)
# Validate the transition
can_execute, error_msg = self.validate_transition(obj, transition_name, request.user)
if not can_execute:
return self.format_error_response(
request,
TransitionNotAvailable(
message=error_msg,
user_message=error_msg,
current_state=getattr(obj, "status", None),
requested_transition=transition_name,
),
400
)
# Execute the transition
try:
# Get any additional kwargs from POST data
extra_kwargs = {}
if request.POST.get("notes"):
extra_kwargs["notes"] = request.POST.get("notes")
if request.POST.get("reason"):
extra_kwargs["reason"] = request.POST.get("reason")
self.execute_transition(obj, transition_name, request.user, **extra_kwargs)
logger.info(
f"Transition '{transition_name}' executed on {model_class.__name__}(pk={obj.pk}) by user {request.user}"
)
return self.format_success_response(request, obj, transition_name)
except TransitionPermissionDenied as e:
logger.warning(
f"Permission denied for transition '{transition_name}' on {model_class.__name__}(pk={obj.pk}) by user {request.user}: {e}"
)
return self.format_error_response(request, e, 403)
except TransitionValidationError as e:
logger.warning(
f"Validation error for transition '{transition_name}' on {model_class.__name__}(pk={obj.pk}): {e}"
)
return self.format_error_response(request, e, 400)
except TransitionNotAllowed as e:
logger.warning(
f"Transition not allowed: '{transition_name}' on {model_class.__name__}(pk={obj.pk}): {e}"
)
return self.format_error_response(request, e, 400)
except Exception as e:
logger.exception(
f"Unexpected error during transition '{transition_name}' on {model_class.__name__}(pk={obj.pk})"
)
return self.format_error_response(
request,
ValueError(f"An unexpected error occurred: {str(e)}"),
500
)

View File

@@ -7,6 +7,7 @@ moderation functionality including reports, queue management, actions, and bulk
"""
from django.urls import path, include
from django.views.generic import TemplateView
from rest_framework.routers import DefaultRouter
from .views import (
@@ -16,6 +17,49 @@ from .views import (
BulkOperationViewSet,
UserModerationViewSet,
)
from apps.core.views.views import FSMTransitionView
class ModerationDashboardView(TemplateView):
"""Moderation dashboard view with HTMX integration."""
template_name = "moderation/dashboard.html"
def get_context_data(self, **kwargs):
from .models import EditSubmission, PhotoSubmission
from .selectors import pending_submissions_for_review
context = super().get_context_data(**kwargs)
context["submissions"] = pending_submissions_for_review()
return context
class SubmissionListView(TemplateView):
"""Submission list view with filtering."""
template_name = "moderation/partials/dashboard_content.html"
def get_context_data(self, **kwargs):
from .models import EditSubmission, PhotoSubmission
from itertools import chain
context = super().get_context_data(**kwargs)
status = self.request.GET.get("status", "PENDING")
# Get filtered submissions
edit_submissions = EditSubmission.objects.filter(status=status).select_related("user")
photo_submissions = PhotoSubmission.objects.filter(status=status).select_related("user")
# Combine and sort
context["submissions"] = sorted(
chain(edit_submissions, photo_submissions),
key=lambda x: x.created_at,
reverse=True,
)
return context
class HistoryPageView(TemplateView):
"""Main history page view."""
template_name = "moderation/history.html"
# Create router and register viewsets
router = DefaultRouter()
@@ -27,11 +71,104 @@ router.register(r"users", UserModerationViewSet, basename="user-moderation")
app_name = "moderation"
urlpatterns = [
# Include all router URLs
path("", include(router.urls)),
# FSM transition convenience URLs for moderation models
fsm_transition_patterns = [
# EditSubmission transitions
# URL: /api/moderation/submissions/<pk>/transition/<transition_name>/
path(
"submissions/<int:pk>/transition/<str:transition_name>/",
FSMTransitionView.as_view(),
{"app_label": "moderation", "model_name": "editsubmission"},
name="submission_transition",
),
# PhotoSubmission transitions
# URL: /api/moderation/photos/<pk>/transition/<transition_name>/
path(
"photos/<int:pk>/transition/<str:transition_name>/",
FSMTransitionView.as_view(),
{"app_label": "moderation", "model_name": "photosubmission"},
name="photo_transition",
),
# ModerationReport transitions
# URL: /api/moderation/reports/<pk>/transition/<transition_name>/
path(
"reports/<int:pk>/transition/<str:transition_name>/",
FSMTransitionView.as_view(),
{"app_label": "moderation", "model_name": "moderationreport"},
name="report_transition",
),
# ModerationQueue transitions
# URL: /api/moderation/queue/<pk>/transition/<transition_name>/
path(
"queue/<int:pk>/transition/<str:transition_name>/",
FSMTransitionView.as_view(),
{"app_label": "moderation", "model_name": "moderationqueue"},
name="queue_transition",
),
# BulkOperation transitions
# URL: /api/moderation/bulk/<pk>/transition/<transition_name>/
path(
"bulk/<int:pk>/transition/<str:transition_name>/",
FSMTransitionView.as_view(),
{"app_label": "moderation", "model_name": "bulkoperation"},
name="bulk_operation_transition",
),
# Backward compatibility aliases for EditSubmission actions
# These redirect the old URL patterns to the FSM transition view
path(
"submissions/<int:pk>/approve/",
FSMTransitionView.as_view(),
{"app_label": "moderation", "model_name": "editsubmission", "transition_name": "transition_to_approved"},
name="approve_submission",
),
path(
"submissions/<int:pk>/reject/",
FSMTransitionView.as_view(),
{"app_label": "moderation", "model_name": "editsubmission", "transition_name": "transition_to_rejected"},
name="reject_submission",
),
path(
"submissions/<int:pk>/escalate/",
FSMTransitionView.as_view(),
{"app_label": "moderation", "model_name": "editsubmission", "transition_name": "transition_to_escalated"},
name="escalate_submission",
),
# Backward compatibility aliases for PhotoSubmission actions
path(
"photos/<int:pk>/approve/",
FSMTransitionView.as_view(),
{"app_label": "moderation", "model_name": "photosubmission", "transition_name": "transition_to_approved"},
name="approve_photo",
),
path(
"photos/<int:pk>/reject/",
FSMTransitionView.as_view(),
{"app_label": "moderation", "model_name": "photosubmission", "transition_name": "transition_to_rejected"},
name="reject_photo",
),
path(
"photos/<int:pk>/escalate/",
FSMTransitionView.as_view(),
{"app_label": "moderation", "model_name": "photosubmission", "transition_name": "transition_to_escalated"},
name="escalate_photo",
),
]
# HTML page patterns (for moderation dashboard)
html_patterns = [
path("", ModerationDashboardView.as_view(), name="dashboard"),
path("submissions/", SubmissionListView.as_view(), name="submission_list"),
path("history/", HistoryPageView.as_view(), name="history"),
]
urlpatterns = [
# HTML page views
*html_patterns,
# Include all router URLs (API endpoints)
path("api/", include(router.urls)),
# FSM transition convenience endpoints
] + fsm_transition_patterns
# URL patterns generated by the router:
#
# Moderation Reports:

View File

@@ -18,6 +18,8 @@ from django_filters.rest_framework import DjangoFilterBackend
from django.contrib.auth import get_user_model
from django.utils import timezone
from django.db.models import Q, Count
from django.shortcuts import render
from django.core.paginator import Paginator
from datetime import timedelta
from django_fsm import can_proceed, TransitionNotAllowed
@@ -334,31 +336,85 @@ class ModerationReportViewSet(viewsets.ModelViewSet):
@action(detail=False, methods=['get'], permission_classes=[CanViewModerationData])
def all_history(self, request):
"""Get all transition history with filtering."""
"""Get all transition history with filtering.
Supports both HTMX (returns HTML partials) and API (returns JSON) requests.
"""
from django_fsm_log.models import StateLog
from django.contrib.contenttypes.models import ContentType
queryset = StateLog.objects.select_related('by', 'content_type').all()
# Filter by id (for detail view)
log_id = request.query_params.get('id')
if log_id:
queryset = queryset.filter(id=log_id)
try:
log = queryset.get(id=log_id)
# Check if HTMX request for detail view
if request.headers.get('HX-Request'):
return render(request, 'moderation/partials/history_detail_content.html', {
'log': log,
})
# Return JSON for API request
return Response({
'id': log.id,
'timestamp': log.timestamp,
'model': log.content_type.model,
'object_id': log.object_id,
'state': log.state,
'from_state': log.source_state,
'to_state': log.state,
'transition': log.transition,
'user': log.by.username if log.by else None,
'description': log.description,
'reason': log.description,
})
except StateLog.DoesNotExist:
if request.headers.get('HX-Request'):
return render(request, 'moderation/partials/history_detail_content.html', {
'log': None,
})
return Response({'error': 'Log not found'}, status=status.HTTP_404_NOT_FOUND)
# Filter by model type
# Filter by model type with app_label support for correct ContentType resolution
model_type = request.query_params.get('model_type')
app_label = request.query_params.get('app_label')
if model_type:
try:
content_type = ContentType.objects.get(model=model_type)
if app_label:
# Use both app_label and model for precise matching
content_type = ContentType.objects.get_by_natural_key(app_label, model_type)
else:
# Map common model names to their app_labels for correct resolution
model_app_mapping = {
'park': 'parks',
'ride': 'rides',
'editsubmission': 'submissions',
'photosubmission': 'submissions',
'moderationreport': 'moderation',
'moderationqueue': 'moderation',
'bulkoperation': 'moderation',
}
mapped_app_label = model_app_mapping.get(model_type.lower())
if mapped_app_label:
content_type = ContentType.objects.get_by_natural_key(mapped_app_label, model_type.lower())
else:
# Fallback to model-only lookup
content_type = ContentType.objects.get(model=model_type)
queryset = queryset.filter(content_type=content_type)
except ContentType.DoesNotExist:
pass
# Filter by object_id (for object-level history)
object_id = request.query_params.get('object_id')
if object_id:
queryset = queryset.filter(object_id=object_id)
# Filter by user
user_id = request.query_params.get('user_id')
if user_id:
queryset = queryset.filter(by_id=user_id)
# Filter by date range
start_date = request.query_params.get('start_date')
end_date = request.query_params.get('end_date')
@@ -366,16 +422,41 @@ class ModerationReportViewSet(viewsets.ModelViewSet):
queryset = queryset.filter(timestamp__gte=start_date)
if end_date:
queryset = queryset.filter(timestamp__lte=end_date)
# Filter by state
state = request.query_params.get('state')
if state:
queryset = queryset.filter(state=state)
# Search filter (case-insensitive across relevant fields)
search_query = request.query_params.get('q')
if search_query:
queryset = queryset.filter(
Q(transition__icontains=search_query) |
Q(description__icontains=search_query) |
Q(state__icontains=search_query) |
Q(source_state__icontains=search_query) |
Q(object_id__icontains=search_query) |
Q(by__username__icontains=search_query)
)
# Order queryset
queryset = queryset.order_by('-timestamp')
# Paginate
# Check if HTMX request
if request.headers.get('HX-Request'):
# Use Django's Paginator for HTMX responses
paginator = Paginator(queryset, 20)
page_number = request.query_params.get('page', 1)
page_obj = paginator.get_page(page_number)
return render(request, 'moderation/partials/history_table.html', {
'history_logs': page_obj,
'page_obj': page_obj,
'request': request,
})
# Paginate for API response
page = self.paginate_queryset(queryset)
if page is not None:
history_data = [{

View File

@@ -1,6 +1,7 @@
from django.urls import path, include
from . import views, views_search
from apps.rides.views import ParkSingleCategoryListView
from apps.core.views.views import FSMTransitionView
from .views_roadtrip import (
RoadTripPlannerView,
CreateTripView,
@@ -99,6 +100,8 @@ urlpatterns = [
path("<slug:slug>/", views.ParkDetailView.as_view(), name="park_detail"),
path("<slug:slug>/edit/", views.ParkUpdateView.as_view(), name="park_update"),
path("<slug:slug>/actions/", views.park_actions, name="park_actions"),
path("<slug:slug>/status-actions/", views.park_status_actions, name="park_status_actions"),
path("<slug:slug>/header-badge/", views.park_header_badge, name="park_header_badge"),
# Area views
path(
"<slug:park_slug>/areas/<slug:area_slug>/",
@@ -147,4 +150,12 @@ urlpatterns = [
"<slug:park_slug>/rides/",
include("apps.rides.park_urls", namespace="rides"),
),
# FSM transition endpoint for parks
# URL: /parks/<slug>/transition/<transition_name>/
path(
"<slug:slug>/transition/<str:transition_name>/",
FSMTransitionView.as_view(),
{"app_label": "parks", "model_name": "park"},
name="park_transition",
),
]

View File

@@ -132,6 +132,29 @@ def park_actions(request: HttpRequest, slug: str) -> HttpResponse:
return render(request, "parks/partials/park_actions.html", {"park": park})
def park_status_actions(request: HttpRequest, slug: str) -> HttpResponse:
"""Return FSM status actions for park moderators"""
park = get_object_or_404(Park, slug=slug)
# Only show to moderators
if not request.user.has_perm('parks.change_park'):
return HttpResponse("")
return render(request, "parks/partials/park_status_actions.html", {
"park": park,
"user": request.user
})
def park_header_badge(request: HttpRequest, slug: str) -> HttpResponse:
"""Return the header status badge partial for a park"""
park = get_object_or_404(Park, slug=slug)
return render(request, "parks/partials/park_header_badge.html", {
"park": park,
"user": request.user
})
def get_park_areas(request: HttpRequest) -> HttpResponse:
"""Return park areas as options for a select element"""
park_id = request.GET.get("park")

View File

@@ -9,6 +9,8 @@ urlpatterns = [
path("create/", views.RideCreateView.as_view(), name="ride_create"),
# Park-specific detail views
path("<slug:ride_slug>/", views.RideDetailView.as_view(), name="ride_detail"),
path("<slug:ride_slug>/status-actions/", views.ride_status_actions, name="ride_status_actions"),
path("<slug:ride_slug>/header-badge/", views.ride_header_badge, name="ride_header_badge"),
path(
"<slug:ride_slug>/update/",
views.RideUpdateView.as_view(),

View File

@@ -1,5 +1,6 @@
from django.urls import path
from . import views
from apps.core.views.views import FSMTransitionView
app_name = "rides"
@@ -83,4 +84,12 @@ urlpatterns = [
views.RideUpdateView.as_view(),
name="ride_update",
),
# FSM transition endpoint for rides
# URL: /rides/<ride_slug>/transition/<transition_name>/
path(
"<slug:slug>/transition/<str:transition_name>/",
FSMTransitionView.as_view(),
{"app_label": "rides", "model_name": "ride"},
name="ride_transition",
),
]

View File

@@ -35,6 +35,33 @@ def show_coaster_fields(request: HttpRequest) -> HttpResponse:
return render(request, "rides/partials/coaster_fields.html")
def ride_status_actions(request: HttpRequest, park_slug: str, ride_slug: str) -> HttpResponse:
"""Return FSM status actions for ride moderators"""
park = get_object_or_404(Park, slug=park_slug)
ride = get_object_or_404(Ride, park=park, slug=ride_slug)
# Only show to moderators
if not request.user.has_perm('rides.change_ride'):
return HttpResponse("")
return render(request, "rides/partials/ride_status_actions.html", {
"ride": ride,
"park": park,
"user": request.user
})
def ride_header_badge(request: HttpRequest, park_slug: str, ride_slug: str) -> HttpResponse:
"""Return the header status badge partial for a ride"""
park = get_object_or_404(Park, slug=park_slug)
ride = get_object_or_404(Ride, park=park, slug=ride_slug)
return render(request, "rides/partials/ride_header_badge.html", {
"ride": ride,
"park": park,
"user": request.user
})
class RideDetailView(HistoryMixin, DetailView):
"""View for displaying ride details"""

View File

@@ -148,6 +148,7 @@ if TEMPLATES_ENABLED:
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
"apps.moderation.context_processors.moderation_access",
"apps.core.context_processors.fsm_context",
]
},
}
@@ -166,6 +167,7 @@ else:
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
"apps.moderation.context_processors.moderation_access",
"apps.core.context_processors.fsm_context",
]
},
}

View File

@@ -0,0 +1,305 @@
/**
* FSM Transitions JavaScript Module
*
* Provides enhanced UX for FSM state transitions including:
* - Toast notifications via HTMX event triggers
* - Loading indicators during transitions
* - Optimistic UI updates
* - Error handling and retry logic
* - Keyboard shortcuts for common transitions
*/
// =============================================================================
// Toast Integration for HTMX
// =============================================================================
/**
* Listen for showToast events triggered by HX-Trigger headers
* and display them using the Alpine.js toast store.
*/
document.body.addEventListener('showToast', function(evt) {
const detail = evt.detail;
if (detail && Alpine && Alpine.store('toast')) {
const toastStore = Alpine.store('toast');
const type = detail.type || 'info';
const message = detail.message || 'Operation completed';
switch (type) {
case 'success':
toastStore.success(message);
break;
case 'error':
toastStore.error(message);
break;
case 'warning':
toastStore.warning(message);
break;
case 'info':
default:
toastStore.info(message);
break;
}
}
});
// =============================================================================
// Transition Success/Error Handlers
// =============================================================================
/**
* Handle successful transition animations
*/
document.body.addEventListener('transitionSuccess', function(evt) {
const targetId = evt.detail?.targetId;
if (targetId) {
const element = document.getElementById(targetId);
if (element) {
// Add success flash animation
element.classList.add('animate-flash-success');
setTimeout(() => {
element.classList.remove('animate-flash-success');
}, 1000);
}
}
});
/**
* Handle transition errors with visual feedback
*/
document.body.addEventListener('transitionError', function(evt) {
const targetId = evt.detail?.targetId;
if (targetId) {
const element = document.getElementById(targetId);
if (element) {
// Add error shake animation
element.classList.add('animate-shake');
setTimeout(() => {
element.classList.remove('animate-shake');
}, 500);
}
}
});
// =============================================================================
// Loading State Management
// =============================================================================
/**
* Track loading states for transition buttons
*/
const loadingStates = new Map();
/**
* Show loading state on a button
*/
function showButtonLoading(button) {
if (!button) return;
const buttonId = button.id || button.dataset.transitionName;
if (!buttonId) return;
// Store original content
loadingStates.set(buttonId, {
innerHTML: button.innerHTML,
disabled: button.disabled,
});
// Show loading spinner
button.disabled = true;
button.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i> Processing...';
button.classList.add('opacity-75', 'cursor-not-allowed');
}
/**
* Hide loading state and restore button
*/
function hideButtonLoading(button) {
if (!button) return;
const buttonId = button.id || button.dataset.transitionName;
const originalState = loadingStates.get(buttonId);
if (originalState) {
button.innerHTML = originalState.innerHTML;
button.disabled = originalState.disabled;
button.classList.remove('opacity-75', 'cursor-not-allowed');
loadingStates.delete(buttonId);
}
}
// Listen for HTMX before send to show loading
document.body.addEventListener('htmx:beforeSend', function(evt) {
const element = evt.detail.elt;
if (element && element.matches('[hx-post*="transition"]')) {
showButtonLoading(element);
}
});
// Listen for HTMX after settle to hide loading
document.body.addEventListener('htmx:afterSettle', function(evt) {
const element = evt.detail.elt;
if (element && element.matches('[hx-post*="transition"]')) {
hideButtonLoading(element);
}
});
// Also handle errors
document.body.addEventListener('htmx:responseError', function(evt) {
const element = evt.detail.elt;
if (element && element.matches('[hx-post*="transition"]')) {
hideButtonLoading(element);
}
});
// =============================================================================
// Keyboard Shortcuts
// =============================================================================
/**
* Keyboard shortcut configuration
* Ctrl/Cmd + key triggers the action
*/
const keyboardShortcuts = {
'a': 'approve', // Ctrl+A for approve
'r': 'reject', // Ctrl+R for reject
'e': 'escalate', // Ctrl+E for escalate
};
/**
* Handle keyboard shortcuts for transitions
* Only active when an item is selected/focused
*/
document.addEventListener('keydown', function(evt) {
// Only handle if Ctrl/Cmd is pressed
if (!(evt.ctrlKey || evt.metaKey)) return;
const key = evt.key.toLowerCase();
const action = keyboardShortcuts[key];
if (!action) return;
// Find the focused/selected item
const focusedItem = document.querySelector('.submission-item.selected, .queue-item.selected, [data-selected="true"]');
if (!focusedItem) return;
// Find the corresponding transition button
const transitionButton = focusedItem.querySelector(`[data-transition="${action}"], [hx-post*="${action}"]`);
if (transitionButton && !transitionButton.disabled) {
evt.preventDefault();
transitionButton.click();
}
});
// =============================================================================
// Batch Operations
// =============================================================================
/**
* Execute batch transitions on multiple selected items
*/
function executeBatchTransition(transitionName, itemIds) {
if (!transitionName || !itemIds || itemIds.length === 0) {
console.warn('Batch transition requires transitionName and itemIds');
return;
}
// Show confirmation for batch operations
const confirmed = confirm(`Are you sure you want to ${transitionName} ${itemIds.length} items?`);
if (!confirmed) return;
// Track progress
let completed = 0;
let failed = 0;
itemIds.forEach(itemId => {
const element = document.getElementById(itemId);
if (!element) return;
const transitionButton = element.querySelector(`[hx-post*="${transitionName}"]`);
if (transitionButton) {
// Use HTMX to trigger the transition
htmx.trigger(transitionButton, 'click');
completed++;
} else {
failed++;
}
});
// Show summary toast
if (Alpine && Alpine.store('toast')) {
if (failed === 0) {
Alpine.store('toast').success(`Batch ${transitionName} initiated for ${completed} items`);
} else {
Alpine.store('toast').warning(`Batch ${transitionName}: ${completed} succeeded, ${failed} failed`);
}
}
}
// Expose batch function globally
window.fsmBatchTransition = executeBatchTransition;
// =============================================================================
// Status Change Event Dispatchers for Parks and Rides
// =============================================================================
/**
* Listen for successful FSM transitions and dispatch custom events
* to refresh status sections on detail pages.
*/
document.body.addEventListener('htmx:afterOnLoad', function(evt) {
const triggerHeader = evt.detail.xhr.getResponseHeader('HX-Trigger');
if (triggerHeader) {
try {
const triggers = JSON.parse(triggerHeader);
// Check if this was a transition request
const requestPath = evt.detail.pathInfo?.requestPath || '';
// Dispatch status change events for parks and rides
if (requestPath.includes('/parks/') && requestPath.includes('/transition/')) {
document.body.dispatchEvent(new CustomEvent('park-status-changed'));
}
if (requestPath.includes('/rides/') && requestPath.includes('/transition/')) {
document.body.dispatchEvent(new CustomEvent('ride-status-changed'));
}
} catch (e) {
// Ignore parsing errors
}
}
});
// =============================================================================
// Initialization
// =============================================================================
document.addEventListener('DOMContentLoaded', function() {
console.log('FSM Transitions module initialized');
// Add CSS for animations if not present
if (!document.getElementById('fsm-animations-style')) {
const style = document.createElement('style');
style.id = 'fsm-animations-style';
style.textContent = `
@keyframes flash-success {
0%, 100% { background-color: transparent; }
50% { background-color: rgba(34, 197, 94, 0.2); }
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
20%, 60% { transform: translateX(-5px); }
40%, 80% { transform: translateX(5px); }
}
.animate-flash-success {
animation: flash-success 1s ease-in-out;
}
.animate-shake {
animation: shake 0.5s ease-in-out;
}
`;
document.head.appendChild(style);
}
});

View File

@@ -1,377 +1,27 @@
/**
* Moderation Transition History JavaScript
* Handles AJAX loading and display of FSM transition history
* DEPRECATED: This file is no longer used.
*
* History functionality has been migrated to HTMX-based implementation.
*
* See the following files for the new implementation:
* - backend/templates/moderation/history.html
* - backend/templates/moderation/partials/history_table.html
* - backend/templates/moderation/partials/history_filters.html
* - backend/templates/moderation/partials/history_pagination.html
* - backend/templates/moderation/partials/history_detail_modal.html
*
* The HTMX implementation provides:
* - Server-side rendered history table
* - HTMX-based filtering and pagination
* - Alpine.js modal for detail views
* - Integration with the moderation dashboard
*
* This file will be removed in a future version.
*/
let currentPage = 1;
let nextPageUrl = null;
let previousPageUrl = null;
console.warn(
'[DEPRECATED] history.js is deprecated and will be removed in a future version. ' +
'History functionality has been migrated to HTMX. See moderation/history.html for the new implementation.'
);
/**
* Format timestamp to human-readable format
*/
function formatTimestamp(timestamp) {
const date = new Date(timestamp);
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
/**
* Get CSRF token from cookie
*/
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
/**
* Fetch and display transition history
*/
function loadHistory(url = null, filters = {}) {
const tbody = document.getElementById('history-tbody');
tbody.innerHTML = '<tr class="loading-row"><td colspan="7" class="text-center"><div class="spinner"></div> Loading history...</td></tr>';
// Build URL
let fetchUrl = url || '/api/moderation/reports/all_history/';
// Add filters to URL if no custom URL provided
if (!url && Object.keys(filters).length > 0) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(filters)) {
if (value) {
params.append(key, value);
}
}
fetchUrl += '?' + params.toString();
}
fetch(fetchUrl, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCookie('csrftoken')
},
credentials: 'same-origin'
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
renderHistoryTable(data.results || data);
updatePagination(data);
})
.catch(error => {
console.error('Error loading history:', error);
tbody.innerHTML = '<tr><td colspan="7" class="text-center" style="color: red;">Error loading history. Please try again.</td></tr>';
});
}
/**
* Render history table rows
*/
function renderHistoryTable(logs) {
const tbody = document.getElementById('history-tbody');
if (!logs || logs.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-center">No transition history found.</td></tr>';
return;
}
tbody.innerHTML = logs.map(log => `
<tr>
<td>${formatTimestamp(log.timestamp)}</td>
<td><span class="badge badge-model">${log.model}</span></td>
<td><a href="/moderation/${log.model}/${log.object_id}" class="object-link">${log.object_id}</a></td>
<td><span class="badge badge-transition">${log.transition || '-'}</span></td>
<td><span class="badge badge-state badge-state-${log.state}">${log.state}</span></td>
<td>${log.user || '<em>System</em>'}</td>
<td><button onclick="viewDetails(${log.id})" class="btn btn-sm btn-view">View</button></td>
</tr>
`).join('');
}
/**
* Update pagination controls
*/
function updatePagination(data) {
nextPageUrl = data.next || null;
previousPageUrl = data.previous || null;
const prevBtn = document.getElementById('prev-page');
const nextBtn = document.getElementById('next-page');
const pageInfo = document.getElementById('page-info');
prevBtn.disabled = !previousPageUrl;
nextBtn.disabled = !nextPageUrl;
// Calculate page number from count
if (data.count) {
const resultsPerPage = data.results ? data.results.length : 0;
const totalPages = Math.ceil(data.count / (resultsPerPage || 1));
pageInfo.textContent = `Page ${currentPage} of ${totalPages}`;
} else {
pageInfo.textContent = `Page ${currentPage}`;
}
}
/**
* View details modal
*/
function viewDetails(logId) {
const modal = document.getElementById('details-modal');
const modalBody = document.getElementById('modal-body');
modalBody.innerHTML = '<div class="spinner"></div> Loading details...';
modal.style.display = 'flex';
// Fetch detailed information filtered by id
fetch(`/api/moderation/reports/all_history/?id=${logId}`, {
headers: {
'X-CSRFToken': getCookie('csrftoken')
},
credentials: 'same-origin'
})
.then(response => response.json())
.then(data => {
// Handle both paginated and non-paginated responses
let log = null;
if (data.results && data.results.length > 0) {
log = data.results[0];
} else if (Array.isArray(data) && data.length > 0) {
log = data[0];
} else if (data.id) {
// Single object response
log = data;
}
if (log) {
modalBody.innerHTML = `
<div class="detail-grid">
<div class="detail-item">
<strong>ID:</strong> ${log.id}
</div>
<div class="detail-item">
<strong>Timestamp:</strong> ${formatTimestamp(log.timestamp)}
</div>
<div class="detail-item">
<strong>Model:</strong> ${log.model}
</div>
<div class="detail-item">
<strong>Object ID:</strong> ${log.object_id}
</div>
<div class="detail-item">
<strong>Transition:</strong> ${log.transition || '-'}
</div>
<div class="detail-item">
<strong>From State:</strong> ${log.from_state || '-'}
</div>
<div class="detail-item">
<strong>To State:</strong> ${log.to_state || log.state || '-'}
</div>
<div class="detail-item">
<strong>User:</strong> ${log.user || 'System'}
</div>
${log.reason ? `<div class="detail-item full-width"><strong>Reason:</strong><br>${log.reason}</div>` : ''}
${log.description ? `<div class="detail-item full-width"><strong>Description:</strong><br>${log.description}</div>` : ''}
</div>
`;
} else {
modalBody.innerHTML = '<p>No log entry found with this ID.</p>';
}
})
.catch(error => {
console.error('Error loading details:', error);
modalBody.innerHTML = '<p style="color: red;">Error loading details.</p>';
});
}
/**
* Close modal
*/
function closeModal() {
const modal = document.getElementById('details-modal');
modal.style.display = 'none';
}
/**
* Get current filters
*/
function getCurrentFilters() {
return {
model_type: document.getElementById('model-filter').value,
state: document.getElementById('state-filter').value,
start_date: document.getElementById('start-date').value,
end_date: document.getElementById('end-date').value,
user_id: document.getElementById('user-filter').value,
};
}
/**
* Event listeners
*/
document.addEventListener('DOMContentLoaded', function() {
// Apply filters button
document.getElementById('apply-filters').addEventListener('click', () => {
currentPage = 1;
const filters = getCurrentFilters();
loadHistory(null, filters);
});
// Clear filters button
document.getElementById('clear-filters').addEventListener('click', () => {
document.getElementById('model-filter').value = '';
document.getElementById('state-filter').value = '';
document.getElementById('start-date').value = '';
document.getElementById('end-date').value = '';
document.getElementById('user-filter').value = '';
currentPage = 1;
loadHistory();
});
// Pagination buttons
document.getElementById('prev-page').addEventListener('click', () => {
if (previousPageUrl) {
currentPage--;
loadHistory(previousPageUrl);
}
});
document.getElementById('next-page').addEventListener('click', () => {
if (nextPageUrl) {
currentPage++;
loadHistory(nextPageUrl);
}
});
// Close modal on background click
document.getElementById('details-modal').addEventListener('click', (e) => {
if (e.target.id === 'details-modal') {
closeModal();
}
});
// Initial load
loadHistory();
});
// Additional CSS for badges (inline styles)
const style = document.createElement('style');
style.textContent = `
.badge {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.badge-model {
background-color: #e7f3ff;
color: #0066cc;
}
.badge-transition {
background-color: #fff3cd;
color: #856404;
}
.badge-state {
background-color: #d4edda;
color: #155724;
}
.badge-state-PENDING {
background-color: #fff3cd;
color: #856404;
}
.badge-state-APPROVED {
background-color: #d4edda;
color: #155724;
}
.badge-state-REJECTED {
background-color: #f8d7da;
color: #721c24;
}
.badge-state-IN_PROGRESS {
background-color: #d1ecf1;
color: #0c5460;
}
.badge-state-COMPLETED {
background-color: #d4edda;
color: #155724;
}
.badge-state-ESCALATED {
background-color: #f8d7da;
color: #721c24;
}
.object-link {
color: #007bff;
text-decoration: none;
}
.object-link:hover {
text-decoration: underline;
}
.btn-view {
background-color: #007bff;
color: white;
border: none;
padding: 4px 12px;
border-radius: 4px;
cursor: pointer;
}
.btn-view:hover {
background-color: #0056b3;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 15px;
}
.detail-item {
padding: 10px;
background-color: #f8f9fa;
border-radius: 4px;
}
.detail-item.full-width {
grid-column: 1 / -1;
}
.detail-item strong {
display: block;
margin-bottom: 5px;
color: #666;
font-size: 0.875rem;
}
`;
document.head.appendChild(style);
// No functionality provided - all history features now use HTMX

View File

@@ -136,6 +136,27 @@
<!-- Custom JavaScript -->
<script src="{% static 'js/main.js' %}"></script>
<script src="{% static 'js/alerts.js' %}"></script>
<script src="{% static 'js/fsm-transitions.js' %}"></script>
<!-- Handle HX-Trigger headers for toast notifications -->
<script>
document.body.addEventListener('htmx:afterOnLoad', function(evt) {
const triggerHeader = evt.detail.xhr.getResponseHeader('HX-Trigger');
if (triggerHeader) {
try {
const triggers = JSON.parse(triggerHeader);
if (triggers.showToast && Alpine && Alpine.store('toast')) {
Alpine.store('toast')[triggers.showToast.type || 'info'](
triggers.showToast.message,
triggers.showToast.duration
);
}
} catch (e) {
// Ignore parsing errors for non-JSON triggers
}
}
});
</script>
{% block extra_js %}{% endblock %}
</body>
</html>

View File

@@ -1,17 +1,22 @@
{% comment %}
Reusable status badge component with consistent styling.
Usage: {% include 'components/status_badge.html' with status="OPERATING" %}
Usage (clickable): {% include 'components/status_badge.html' with status="OPERATING" clickable=True %}
{% endcomment %}
{% load park_tags %}
{% with status_config=status|get_status_config %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ status_config.classes }}">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ status_config.classes }}
{% if clickable %}cursor-pointer transition-all hover:ring-2 hover:ring-blue-500{% endif %}">
{% if status_config.icon %}
<svg class="-ml-0.5 mr-1.5 h-2 w-2" fill="currentColor" viewBox="0 0 8 8">
<circle cx="4" cy="4" r="3" />
</svg>
{% endif %}
{{ status_config.label }}
{% if clickable %}
<i class="fas fa-chevron-down ml-1.5 text-xs"></i>
{% endif %}
</span>
{% endwith %}

View File

@@ -1,3 +1,33 @@
<div class="htmx-loading-indicator" aria-hidden="true">
<div class="spinner">Loading…</div>
{% comment %}
Loading Indicator Component
Displays a loading spinner for HTMX requests.
Optional context:
- size: 'sm', 'md', or 'lg' (defaults to 'md')
- inline: Whether to render inline (defaults to false)
- message: Loading message text (defaults to 'Loading...')
- id: Optional ID for the indicator element
{% endcomment %}
{% if inline %}
<!-- Inline Loading Indicator -->
<span class="htmx-indicator inline-flex items-center gap-2 {% if size == 'sm' %}text-sm{% elif size == 'lg' %}text-lg{% endif %}"
{% if id %}id="{{ id }}"{% endif %}
aria-hidden="true">
<i class="fas fa-spinner fa-spin text-blue-500"></i>
{% if message %}<span class="text-gray-500 dark:text-gray-400">{{ message }}</span>{% endif %}
</span>
{% else %}
<!-- Block Loading Indicator -->
<div class="htmx-indicator flex items-center justify-center p-4 {% if size == 'sm' %}p-2{% elif size == 'lg' %}p-6{% endif %}"
{% if id %}id="{{ id }}"{% endif %}
aria-hidden="true">
<div class="flex items-center gap-3">
<div class="{% if size == 'sm' %}w-5 h-5{% elif size == 'lg' %}w-10 h-10{% else %}w-8 h-8{% endif %} border-4 border-blue-500 rounded-full animate-spin border-t-transparent"></div>
<span class="{% if size == 'sm' %}text-sm{% elif size == 'lg' %}text-lg{% else %}text-base{% endif %} text-gray-600 dark:text-gray-300">
{{ message|default:"Loading..." }}
</span>
</div>
</div>
{% endif %}

View File

@@ -0,0 +1,58 @@
{% comment %}
FSM State Actions Partial Template
Renders available transition buttons for an FSM-enabled object.
Uses HTMX for seamless state transitions with toast notifications.
Required context:
- object: The FSM-enabled model instance
- user: The current user (usually request.user)
Optional context:
- target_id: The ID of the element to swap after transition (defaults to object-{{ object.id }})
- button_size: 'sm', 'md', or 'lg' (defaults to 'md')
- show_labels: Whether to show button labels (defaults to true)
- inline: Whether to render buttons inline (defaults to false)
{% endcomment %}
{% load fsm_tags %}
{% get_available_transitions object user as transitions %}
{% if transitions %}
<div class="fsm-actions flex {% if inline %}flex-row gap-2{% else %}flex-wrap gap-2{% endif %}"
id="actions-{{ object.id }}">
{% for transition in transitions %}
<button
type="button"
hx-post="{% url 'core:fsm_transition' app_label=object|app_label model_name=object|model_name pk=object.pk transition_name=transition.name %}"
hx-target="#{{ target_id|default:object|default_target_id }}"
hx-swap="outerHTML"
{% if transition.requires_confirm %}
hx-confirm="{{ transition.confirm_message|default:'Are you sure?' }}"
{% endif %}
hx-indicator="#loading-{{ object.id }}"
class="inline-flex items-center justify-center gap-1.5 px-{% if button_size == 'sm' %}2.5 py-1.5 text-xs{% elif button_size == 'lg' %}5 py-3 text-base{% else %}4 py-2.5 text-sm{% endif %} font-medium rounded-lg transition-all duration-200 shadow-xs hover:shadow-md
{% if transition.style == 'green' %}
bg-green-600 text-white hover:bg-green-500 dark:bg-green-700 dark:hover:bg-green-600
{% elif transition.style == 'red' %}
bg-red-600 text-white hover:bg-red-500 dark:bg-red-700 dark:hover:bg-red-600
{% elif transition.style == 'yellow' %}
bg-yellow-600 text-white hover:bg-yellow-500 dark:bg-yellow-700 dark:hover:bg-yellow-600
{% elif transition.style == 'blue' %}
bg-blue-600 text-white hover:bg-blue-500 dark:bg-blue-700 dark:hover:bg-blue-600
{% else %}
bg-gray-600 text-white hover:bg-gray-500 dark:bg-gray-700 dark:hover:bg-gray-600
{% endif %}">
<i class="fas fa-{{ transition.icon|default:'arrow-right' }}"></i>
{% if show_labels|default:True %}
<span>{{ transition.label }}</span>
{% endif %}
</button>
{% endfor %}
<!-- Loading indicator -->
<span id="loading-{{ object.id }}" class="htmx-indicator inline-flex items-center">
<i class="fas fa-spinner fa-spin text-blue-500"></i>
</span>
</div>
{% endif %}

View File

@@ -0,0 +1,148 @@
{% comment %}
FSM Status Badge with Actions Partial Template
Displays current status badge alongside available transition buttons.
Combines status display with action capabilities for a cohesive UX.
Required context:
- object: The FSM-enabled model instance
- user: The current user (usually request.user)
Optional context:
- target_id: The ID of the element to swap after transition
- show_badge: Whether to show the status badge (defaults to true)
- badge_only: Only show the badge, no actions (defaults to false)
- dropdown_actions: Show actions in a dropdown menu (defaults to false)
- compact: Use compact layout (defaults to false)
{% endcomment %}
{% load fsm_tags %}
{% get_available_transitions object user as transitions %}
<div class="status-with-actions flex items-center gap-3 {% if compact %}gap-2{% endif %}">
{% if show_badge|default:True %}
<!-- Status Badge -->
<span class="status-badge inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-full" data-status-badge
{% with status=object|get_state_value %}
{% if status == 'PENDING' %}
bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300
{% elif status == 'APPROVED' %}
bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300
{% elif status == 'REJECTED' %}
bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300
{% elif status == 'ESCALATED' %}
bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300
{% elif status == 'IN_PROGRESS' or status == 'PROCESSING' %}
bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300
{% elif status == 'COMPLETED' or status == 'RESOLVED' %}
bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300
{% elif status == 'CANCELLED' or status == 'DISMISSED' %}
bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300
{% else %}
bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300
{% endif %}
{% endwith %}">
{% with choice=object|get_state_choice %}
{% if choice and choice.metadata.icon %}
<i class="fas fa-{{ choice.metadata.icon }}"></i>
{% else %}
{% with status=object|get_state_value %}
<i class="fas fa-{% if status == 'PENDING' %}clock{% elif status == 'APPROVED' %}check{% elif status == 'REJECTED' %}times{% elif status == 'ESCALATED' %}exclamation{% elif status == 'IN_PROGRESS' or status == 'PROCESSING' %}spinner{% elif status == 'COMPLETED' or status == 'RESOLVED' %}check-circle{% else %}circle{% endif %}"></i>
{% endwith %}
{% endif %}
{% endwith %}
{{ object|get_state_display }}
</span>
{% endif %}
{% if not badge_only %}
{% if transitions %}
{% if dropdown_actions %}
<!-- Dropdown Actions -->
<div class="relative" x-data="{ open: false }">
<button
@click="open = !open"
@click.outside="open = false"
type="button"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-700">
<span>Actions</span>
<i class="fas fa-chevron-down text-xs transition-transform" :class="{ 'rotate-180': open }"></i>
</button>
<div
x-show="open"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="transform opacity-0 scale-95"
x-transition:enter-end="transform opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="transform opacity-100 scale-100"
x-transition:leave-end="transform opacity-0 scale-95"
class="absolute right-0 mt-2 w-48 bg-white border border-gray-200 rounded-lg shadow-lg dark:bg-gray-800 dark:border-gray-700 z-50"
x-cloak>
<div class="py-1">
{% for transition in transitions %}
<button
type="button"
hx-post="{% url 'core:fsm_transition' app_label=object|app_label model_name=object|model_name pk=object.pk transition_name=transition.name %}"
hx-target="#{{ target_id|default:object|default_target_id }}"
hx-swap="outerHTML"
hx-include="closest .review-notes textarea[name='notes']"
{% if transition.requires_confirm %}
hx-confirm="{{ transition.confirm_message|default:'Are you sure?' }}"
{% endif %}
@click="open = false"
class="w-full flex items-center gap-2 px-4 py-2 text-sm text-left
{% if transition.style == 'green' %}text-green-700 hover:bg-green-50 dark:text-green-400 dark:hover:bg-green-900/30
{% elif transition.style == 'red' %}text-red-700 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/30
{% elif transition.style == 'yellow' %}text-yellow-700 hover:bg-yellow-50 dark:text-yellow-400 dark:hover:bg-yellow-900/30
{% elif transition.style == 'blue' %}text-blue-700 hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-blue-900/30
{% else %}text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-700
{% endif %}">
<i class="fas fa-{{ transition.icon|default:'arrow-right' }} w-4"></i>
{{ transition.label }}
</button>
{% endfor %}
</div>
</div>
</div>
{% else %}
<!-- Inline Actions -->
<div class="inline-flex items-center gap-2">
{% for transition in transitions %}
<button
type="button"
hx-post="{% url 'core:fsm_transition' app_label=object|app_label model_name=object|model_name pk=object.pk transition_name=transition.name %}"
hx-target="#{{ target_id|default:object|default_target_id }}"
hx-swap="outerHTML"
hx-include="closest .review-notes textarea[name='notes']"
{% if transition.requires_confirm %}
hx-confirm="{{ transition.confirm_message|default:'Are you sure?' }}"
{% endif %}
hx-indicator="#loading-{{ object.id }}"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-lg transition-all duration-200
{% if transition.style == 'green' %}
bg-green-600 text-white hover:bg-green-500 dark:bg-green-700 dark:hover:bg-green-600
{% elif transition.style == 'red' %}
bg-red-600 text-white hover:bg-red-500 dark:bg-red-700 dark:hover:bg-red-600
{% elif transition.style == 'yellow' %}
bg-yellow-600 text-white hover:bg-yellow-500 dark:bg-yellow-700 dark:hover:bg-yellow-600
{% elif transition.style == 'blue' %}
bg-blue-600 text-white hover:bg-blue-500 dark:bg-blue-700 dark:hover:bg-blue-600
{% else %}
bg-gray-600 text-white hover:bg-gray-500 dark:bg-gray-700 dark:hover:bg-gray-600
{% endif %}">
<i class="fas fa-{{ transition.icon|default:'arrow-right' }}"></i>
<span>{{ transition.label }}</span>
</button>
{% endfor %}
<!-- Loading indicator -->
<span id="loading-{{ object.id }}" class="htmx-indicator">
<i class="fas fa-spinner fa-spin text-blue-500"></i>
</span>
</div>
{% endif %}
{% endif %}
{% endif %}
</div>

View File

@@ -0,0 +1,80 @@
{% comment %}
FSM Updated Row Partial Template
Generic template for rendering an updated table row after an FSM transition.
Used as the default response template for FSMTransitionView.
Required context:
- object: The FSM-enabled model instance
- user: The current user (usually request.user)
Optional context:
- transition_success: Whether a transition just succeeded
- success_message: Success message to display (handled by toast)
- show_actions: Whether to show action buttons (defaults to true)
- row_class: Additional CSS classes for the row
{% endcomment %}
{% load fsm_tags %}
<tr id="{{ object|default_target_id }}"
class="{% if transition_success %}animate-flash-success{% endif %} {{ row_class|default:'' }} border-b border-gray-200/50 dark:border-gray-700/50 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors">
<!-- Status Cell -->
<td class="px-4 py-3">
<span class="status-badge inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-full
{% with status=object|get_state_value %}
{% if status == 'PENDING' %}
bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300
{% elif status == 'APPROVED' %}
bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300
{% elif status == 'REJECTED' %}
bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300
{% elif status == 'ESCALATED' %}
bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300
{% elif status == 'IN_PROGRESS' or status == 'PROCESSING' %}
bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300
{% elif status == 'COMPLETED' or status == 'RESOLVED' %}
bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300
{% else %}
bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300
{% endif %}
{% endwith %}">
{% with status=object|get_state_value %}
<i class="fas fa-{% if status == 'PENDING' %}clock{% elif status == 'APPROVED' %}check{% elif status == 'REJECTED' %}times{% elif status == 'ESCALATED' %}exclamation{% elif status == 'IN_PROGRESS' or status == 'PROCESSING' %}spinner{% elif status == 'COMPLETED' or status == 'RESOLVED' %}check-circle{% else %}circle{% endif %}"></i>
{% endwith %}
{{ object|get_state_display }}
</span>
</td>
<!-- Object Info Cell -->
<td class="px-4 py-3">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ object }}
</div>
{% if object.created_at %}
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ object.created_at|date:"M d, Y H:i" }}
</div>
{% endif %}
</td>
<!-- Actions Cell -->
{% if show_actions|default:True %}
<td class="px-4 py-3 text-right">
{% include 'htmx/state_actions.html' with object=object user=user button_size='sm' inline=True %}
</td>
{% endif %}
</tr>
{% comment %}
CSS for flash animation - add to your CSS file or include inline:
@keyframes flash-success {
0%, 100% { background-color: transparent; }
50% { background-color: rgba(34, 197, 94, 0.2); }
}
.animate-flash-success {
animation: flash-success 1s ease-in-out;
}
{% endcomment %}

View File

@@ -141,6 +141,36 @@
30%, 50%, 70% { transform: translate3d(-4px, 0, 0); }
40%, 60% { transform: translate3d(4px, 0, 0); }
}
/* Success Flash Animation for FSM transitions */
@keyframes flash-success {
0%, 100% { background-color: transparent; }
50% { background-color: rgba(34, 197, 94, 0.2); }
}
.animate-flash-success {
animation: flash-success 1s ease-in-out;
}
/* Error Flash Animation */
@keyframes flash-error {
0%, 100% { background-color: transparent; }
50% { background-color: rgba(239, 68, 68, 0.2); }
}
.animate-flash-error {
animation: flash-error 1s ease-in-out;
}
/* Warning Flash Animation */
@keyframes flash-warning {
0%, 100% { background-color: transparent; }
50% { background-color: rgba(234, 179, 8, 0.2); }
}
.animate-flash-warning {
animation: flash-warning 1s ease-in-out;
}
</style>
{% endblock %}
@@ -293,5 +323,26 @@ document.addEventListener('keydown', function(e) {
});
}
});
// History-specific HTMX event handlers
document.body.addEventListener('htmx:afterSwap', function(evt) {
if (evt.detail.target.id === 'history-table-container' || evt.detail.target.id === 'dashboard-history-container') {
console.log('History table updated');
}
});
document.body.addEventListener('htmx:responseError', function(evt) {
if (evt.detail.target.id === 'history-table-container' || evt.detail.target.id === 'dashboard-history-container') {
console.error('Failed to load history:', evt.detail.error);
}
});
// History modal event handler
document.addEventListener('open-history-modal', function() {
const modal = document.querySelector('#history-detail-modal');
if (modal && modal.__x) {
modal.__x.$data.open = true;
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,188 @@
{% extends "base/base.html" %}
{% load static %}
{% block title %}Transition History - ThrillWiki Moderation{% endblock %}
{% block extra_css %}
<style>
/* HTMX Loading States */
.htmx-request .htmx-indicator {
opacity: 1;
}
.htmx-request.htmx-indicator {
opacity: 1;
}
.htmx-indicator {
opacity: 0;
transition: opacity 200ms ease-in-out;
}
/* State cloak for Alpine.js */
[x-cloak] {
display: none !important;
}
/* Custom scrollbar for history table */
.overflow-x-auto::-webkit-scrollbar {
height: 8px;
}
.overflow-x-auto::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.1);
border-radius: 4px;
}
.overflow-x-auto::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
}
.overflow-x-auto::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.3);
}
.dark .overflow-x-auto::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.1);
}
.dark .overflow-x-auto::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
}
.dark .overflow-x-auto::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
/* Animation for row hover */
tbody tr {
transition: background-color 150ms ease-in-out;
}
/* Skeleton loading animation */
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
</style>
{% endblock %}
{% block content %}
<div class="container max-w-7xl px-4 py-6 mx-auto">
<!-- Page Header -->
<div class="mb-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
<i class="mr-2 text-blue-500 fas fa-history"></i>
Transition History
</h1>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
View all state machine transitions across the system
</p>
</div>
<a href="{% url 'moderation:dashboard' %}"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-700">
<i class="mr-2 fas fa-arrow-left"></i>
Back to Dashboard
</a>
</div>
</div>
<!-- Filters -->
{% include 'moderation/partials/history_filters.html' %}
<!-- History Table Container -->
<div id="history-table-wrapper" class="relative">
<div id="history-table-container"
hx-get="{% url 'moderation:moderation-reports-all-history' %}"
hx-trigger="load"
hx-indicator="#page-loading"
hx-swap="outerHTML">
<!-- Initial Loading State -->
<div class="overflow-hidden bg-white border rounded-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50">
<div class="p-8">
<div class="flex flex-col items-center justify-center">
<div class="w-16 h-16 mb-4 bg-gray-200 rounded-full animate-pulse dark:bg-gray-700"></div>
<div class="w-48 h-4 mb-2 bg-gray-200 rounded animate-pulse dark:bg-gray-700"></div>
<div class="w-32 h-3 bg-gray-200 rounded animate-pulse dark:bg-gray-700"></div>
</div>
</div>
</div>
</div>
<!-- Page Loading Indicator -->
<div id="page-loading" class="absolute inset-0 flex items-center justify-center bg-white/75 dark:bg-gray-800/75 htmx-indicator">
<div class="flex flex-col items-center">
<i class="mb-2 text-3xl text-blue-500 fas fa-spinner fa-spin"></i>
<span class="text-sm text-gray-600 dark:text-gray-400">Loading history...</span>
</div>
</div>
</div>
<!-- History Detail Modal -->
{% include 'moderation/partials/history_detail_modal.html' %}
</div>
{% endblock %}
{% block extra_js %}
<script>
// HTMX Configuration
document.body.addEventListener('htmx:configRequest', function(evt) {
evt.detail.headers['X-CSRFToken'] = '{{ csrf_token }}';
});
// Handle successful history table loads
document.body.addEventListener('htmx:afterSwap', function(evt) {
if (evt.detail.target.id === 'history-table-container') {
console.log('History table updated');
}
});
// Handle errors
document.body.addEventListener('htmx:responseError', function(evt) {
if (evt.detail.target.id === 'history-table-container') {
console.error('Failed to load history:', evt.detail.error);
// Show error message in the container
evt.detail.target.innerHTML = `
<div class="overflow-hidden bg-white border rounded-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50">
<div class="px-4 py-12 text-center">
<div class="flex flex-col items-center justify-center">
<div class="flex items-center justify-center w-16 h-16 mb-4 text-red-500 bg-red-100 rounded-full dark:bg-red-900/30">
<i class="text-2xl fas fa-exclamation-triangle"></i>
</div>
<h3 class="mb-1 text-sm font-medium text-gray-900 dark:text-gray-300">Failed to load history</h3>
<p class="mb-4 text-sm text-gray-500 dark:text-gray-400">There was an error loading the transition history.</p>
<button class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-500"
hx-get="{% url 'moderation:moderation-reports-all-history' %}"
hx-target="#history-table-container"
hx-swap="outerHTML">
<i class="mr-2 fas fa-sync-alt"></i>
Try Again
</button>
</div>
</div>
</div>
`;
htmx.process(evt.detail.target);
}
});
// History modal event handler
document.addEventListener('open-history-modal', function() {
const modal = document.querySelector('#history-detail-modal');
if (modal && modal.__x) {
modal.__x.$data.open = true;
}
});
</script>
{% endblock %}

View File

@@ -2,60 +2,99 @@
<h1 class="mb-6 text-2xl font-bold text-gray-900 dark:text-gray-200">Moderation Dashboard</h1>
<div class="space-y-6">
<div class="space-y-6" x-data="{ activeTab: 'submissions' }">
<!-- Tab Navigation -->
<div class="flex items-center justify-between p-4 bg-white border rounded-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50">
<div class="flex items-center space-x-4">
<a href="{% url 'moderation:submission_list' %}?status=PENDING"
class="flex items-center px-4 py-2.5 rounded-lg font-medium transition-all duration-200 {% if request.GET.status == 'PENDING' or not request.GET.status %}bg-blue-100 text-blue-900 dark:bg-blue-900/40 dark:text-blue-400{% else %}text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-300{% endif %}"
hx-get="{% url 'moderation:submission_list' %}?status=PENDING"
hx-target="#dashboard-content"
hx-push-url="true"
hx-indicator="#loading-indicator">
<i class="mr-2.5 text-lg fas fa-clock"></i>
<span>Pending</span>
</a>
<a href="{% url 'moderation:submission_list' %}?status=APPROVED"
class="flex items-center px-4 py-2.5 rounded-lg font-medium transition-all duration-200 {% if request.GET.status == 'APPROVED' %}bg-blue-100 text-blue-900 dark:bg-blue-900/40 dark:text-blue-400{% else %}text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-300{% endif %}"
hx-get="{% url 'moderation:submission_list' %}?status=APPROVED"
hx-target="#dashboard-content"
hx-push-url="true"
hx-indicator="#loading-indicator">
<i class="mr-2.5 text-lg fas fa-check"></i>
<span>Approved</span>
</a>
<a href="{% url 'moderation:submission_list' %}?status=REJECTED"
class="flex items-center px-4 py-2.5 rounded-lg font-medium transition-all duration-200 {% if request.GET.status == 'REJECTED' %}bg-blue-100 text-blue-900 dark:bg-blue-900/40 dark:text-blue-400{% else %}text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-300{% endif %}"
hx-get="{% url 'moderation:submission_list' %}?status=REJECTED"
hx-target="#dashboard-content"
hx-push-url="true"
hx-indicator="#loading-indicator">
<i class="mr-2.5 text-lg fas fa-times"></i>
<span>Rejected</span>
</a>
<a href="{% url 'moderation:submission_list' %}?status=ESCALATED"
class="flex items-center px-4 py-2.5 rounded-lg font-medium transition-all duration-200 {% if request.GET.status == 'ESCALATED' %}bg-blue-100 text-blue-900 dark:bg-blue-900/40 dark:text-blue-400{% else %}text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-300{% endif %}"
hx-get="{% url 'moderation:submission_list' %}?status=ESCALATED"
hx-target="#dashboard-content"
hx-push-url="true"
hx-indicator="#loading-indicator">
<i class="mr-2.5 text-lg fas fa-exclamation-triangle"></i>
<span>Escalated</span>
</a>
<!-- Submissions Tab -->
<button type="button"
@click="activeTab = 'submissions'"
:class="activeTab === 'submissions' ? 'bg-blue-100 text-blue-900 dark:bg-blue-900/40 dark:text-blue-400' : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-300'"
class="flex items-center px-4 py-2.5 rounded-lg font-medium transition-all duration-200">
<i class="mr-2.5 text-lg fas fa-file-alt"></i>
<span>Submissions</span>
</button>
<!-- History Tab -->
<button type="button"
@click="activeTab = 'history'"
:class="activeTab === 'history' ? 'bg-blue-100 text-blue-900 dark:bg-blue-900/40 dark:text-blue-400' : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-300'"
class="flex items-center px-4 py-2.5 rounded-lg font-medium transition-all duration-200">
<i class="mr-2.5 text-lg fas fa-history"></i>
<span>History</span>
</button>
<!-- Separator -->
<div class="w-px h-8 bg-gray-200 dark:bg-gray-700" x-show="activeTab === 'submissions'"></div>
<!-- Status Filters (only visible in Submissions tab) -->
<template x-if="activeTab === 'submissions'">
<div class="flex items-center space-x-2">
<a href="{% url 'moderation:submission_list' %}?status=PENDING"
class="flex items-center px-4 py-2.5 rounded-lg font-medium transition-all duration-200 {% if request.GET.status == 'PENDING' or not request.GET.status %}bg-blue-100 text-blue-900 dark:bg-blue-900/40 dark:text-blue-400{% else %}text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-300{% endif %}"
hx-get="{% url 'moderation:submission_list' %}?status=PENDING"
hx-target="#dashboard-content"
hx-push-url="true"
hx-indicator="#loading-indicator">
<i class="mr-2.5 text-lg fas fa-clock"></i>
<span>Pending</span>
</a>
<a href="{% url 'moderation:submission_list' %}?status=APPROVED"
class="flex items-center px-4 py-2.5 rounded-lg font-medium transition-all duration-200 {% if request.GET.status == 'APPROVED' %}bg-blue-100 text-blue-900 dark:bg-blue-900/40 dark:text-blue-400{% else %}text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-300{% endif %}"
hx-get="{% url 'moderation:submission_list' %}?status=APPROVED"
hx-target="#dashboard-content"
hx-push-url="true"
hx-indicator="#loading-indicator">
<i class="mr-2.5 text-lg fas fa-check"></i>
<span>Approved</span>
</a>
<a href="{% url 'moderation:submission_list' %}?status=REJECTED"
class="flex items-center px-4 py-2.5 rounded-lg font-medium transition-all duration-200 {% if request.GET.status == 'REJECTED' %}bg-blue-100 text-blue-900 dark:bg-blue-900/40 dark:text-blue-400{% else %}text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-300{% endif %}"
hx-get="{% url 'moderation:submission_list' %}?status=REJECTED"
hx-target="#dashboard-content"
hx-push-url="true"
hx-indicator="#loading-indicator">
<i class="mr-2.5 text-lg fas fa-times"></i>
<span>Rejected</span>
</a>
<a href="{% url 'moderation:submission_list' %}?status=ESCALATED"
class="flex items-center px-4 py-2.5 rounded-lg font-medium transition-all duration-200 {% if request.GET.status == 'ESCALATED' %}bg-blue-100 text-blue-900 dark:bg-blue-900/40 dark:text-blue-400{% else %}text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-300{% endif %}"
hx-get="{% url 'moderation:submission_list' %}?status=ESCALATED"
hx-target="#dashboard-content"
hx-push-url="true"
hx-indicator="#loading-indicator">
<i class="mr-2.5 text-lg fas fa-exclamation-triangle"></i>
<span>Escalated</span>
</a>
</div>
</template>
</div>
<button class="flex items-center px-4 py-2.5 rounded-lg font-medium transition-all duration-200 text-gray-600 dark:text-gray-400 hover:text-blue-900 hover:bg-blue-100 dark:hover:text-blue-400 dark:hover:bg-blue-900/40"
hx-get="{{ request.get_full_path }}"
hx-target="#dashboard-content"
hx-indicator="#loading-indicator">
<i class="mr-2.5 text-lg fas fa-sync-alt"></i>
<span>Refresh</span>
</button>
<div class="flex items-center space-x-2">
<!-- View Full History Link (visible in History tab) -->
<a href="{% url 'moderation:history' %}"
x-show="activeTab === 'history'"
x-cloak
class="flex items-center px-4 py-2.5 rounded-lg font-medium transition-all duration-200 text-blue-600 dark:text-blue-400 hover:text-blue-900 hover:bg-blue-100 dark:hover:text-blue-300 dark:hover:bg-blue-900/40">
<i class="mr-2.5 text-lg fas fa-external-link-alt"></i>
<span>Full History</span>
</a>
<button class="flex items-center px-4 py-2.5 rounded-lg font-medium transition-all duration-200 text-gray-600 dark:text-gray-400 hover:text-blue-900 hover:bg-blue-100 dark:hover:text-blue-400 dark:hover:bg-blue-900/40"
hx-get="{{ request.get_full_path }}"
hx-target="#dashboard-content"
hx-indicator="#loading-indicator">
<i class="mr-2.5 text-lg fas fa-sync-alt"></i>
<span>Refresh</span>
</button>
</div>
</div>
<div class="p-6 bg-white border rounded-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50">
<!-- Submissions Tab Panel -->
<div x-show="activeTab === 'submissions'" class="p-6 bg-white border rounded-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50">
<form class="mb-6"
x-data="{ showFilters: false }"
hx-get="{% url 'moderation:submission_list' %}"
@@ -63,7 +102,7 @@
hx-trigger="change from:select"
hx-push-url="true"
aria-label="Submission filters">
<!-- Mobile Filter Toggle -->
<button type="button"
class="flex items-center w-full gap-2 p-3 mb-4 font-medium text-left text-gray-700 transition-colors duration-200 bg-gray-100 rounded-lg md:hidden dark:text-gray-300 dark:bg-gray-900"
@@ -83,7 +122,7 @@
:class="{'hidden md:grid': !showFilters, 'grid': showFilters}"
role="group"
aria-label="Filter controls">
<div class="relative">
<label id="submission-type-label"
class="block mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
@@ -150,28 +189,60 @@
{% include "moderation/partials/submission_list.html" with submissions=submissions user=user %}
</div>
</div>
<!-- History Tab Panel -->
<div x-show="activeTab === 'history'" x-cloak>
<div id="dashboard-history-container"
hx-get="{% url 'moderation:moderation-reports-all-history' %}"
hx-trigger="intersect once"
hx-target="this"
hx-swap="innerHTML"
hx-indicator="#dashboard-history-loading">
<!-- Initial Loading State -->
<div class="p-6 bg-white border rounded-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50">
<div id="dashboard-history-loading" class="flex flex-col items-center justify-center py-8">
<i class="mb-2 text-2xl text-blue-500 fas fa-spinner fa-spin"></i>
<span class="text-sm text-gray-600 dark:text-gray-400">Loading history...</span>
</div>
</div>
</div>
</div>
<!-- History Detail Modal -->
{% include 'moderation/partials/history_detail_modal.html' %}
</div>
<div id="toast-container"
x-data="{
show: false,
<div id="toast-container"
data-toast
x-data="{
show: false,
message: '',
type: 'success',
icon: 'check',
color: 'green',
showToast(msg, icn = 'check', clr = 'green') {
this.message = msg;
this.icon = icn;
this.color = clr;
showToast(data) {
if (typeof data === 'string') {
this.message = data;
this.type = 'success';
} else {
this.message = data.message || 'Action completed';
this.type = data.type || 'success';
}
// Set icon based on type
this.icon = this.type === 'success' ? 'check' :
this.type === 'error' ? 'times' :
this.type === 'warning' ? 'exclamation-triangle' :
this.type === 'info' ? 'info-circle' : 'check';
this.show = true;
setTimeout(() => this.show = false, 3000);
}
}"
@submission-approved.window="showToast('Submission approved successfully', 'check', 'green')"
@submission-rejected.window="showToast('Submission rejected', 'times', 'red')"
@submission-escalated.window="showToast('Submission escalated', 'exclamation-triangle', 'yellow')"
@submission-updated.window="showToast('Changes saved successfully', 'check', 'blue')"
@show-toast.window="showToast($event.detail)"
@submission-approved.window="showToast({message: 'Submission approved successfully', type: 'success'})"
@submission-rejected.window="showToast({message: 'Submission rejected', type: 'error'})"
@submission-escalated.window="showToast({message: 'Submission escalated', type: 'warning'})"
@submission-updated.window="showToast({message: 'Changes saved successfully', type: 'info'})"
class="fixed z-50 bottom-4 right-4">
<div x-show="show"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-full"
@@ -182,15 +253,15 @@
class="flex items-center w-full max-w-xs p-4 text-gray-400 bg-gray-800 rounded-lg shadow">
<div class="inline-flex items-center justify-center shrink-0 w-8 h-8 rounded-lg"
:class="{
'text-green-400 bg-green-900/40': color === 'green',
'text-red-400 bg-red-900/40': color === 'red',
'text-yellow-400 bg-yellow-900/40': color === 'yellow',
'text-blue-400 bg-blue-900/40': color === 'blue'
'text-green-400 bg-green-900/40': type === 'success',
'text-red-400 bg-red-900/40': type === 'error',
'text-yellow-400 bg-yellow-900/40': type === 'warning',
'text-blue-400 bg-blue-900/40': type === 'info'
}">
<i class="fas" :class="'fa-' + icon"></i>
</div>
<div class="ml-3 text-sm font-normal" x-text="message"></div>
<button type="button"
<button type="button"
class="ml-auto -mx-1.5 -my-1.5 text-gray-400 hover:text-gray-300 rounded-lg p-1.5 inline-flex h-8 w-8"
@click="show = false">
<i class="fas fa-times"></i>
@@ -199,23 +270,59 @@
</div>
<script>
// Handle HX-Trigger headers from FSMTransitionView for toast notifications
document.body.addEventListener('htmx:afterOnLoad', function(evt) {
const triggerHeader = evt.detail.xhr.getResponseHeader('HX-Trigger');
if (triggerHeader) {
try {
const triggers = JSON.parse(triggerHeader);
if (triggers.showToast) {
window.dispatchEvent(new CustomEvent('show-toast', { detail: triggers.showToast }));
}
} catch (e) {
// Handle non-JSON trigger headers (simple event names)
console.debug('Non-JSON HX-Trigger header:', triggerHeader);
}
}
});
// Handle HX-Trigger headers on error responses for toast notifications
document.body.addEventListener('htmx:responseError', function(evt) {
const triggerHeader = evt.detail.xhr.getResponseHeader('HX-Trigger');
if (triggerHeader) {
try {
const triggers = JSON.parse(triggerHeader);
if (triggers.showToast) {
window.dispatchEvent(new CustomEvent('show-toast', { detail: triggers.showToast }));
}
} catch (e) {
// Handle non-JSON trigger headers (simple event names)
console.debug('Non-JSON HX-Trigger header:', triggerHeader);
}
}
});
// Fallback: Handle legacy URL-based toast dispatching for backward compatibility
document.body.addEventListener('htmx:afterRequest', function(evt) {
if (evt.detail.successful) {
const path = evt.detail.requestConfig.path;
let event;
if (path.includes('approve')) {
event = new CustomEvent('submission-approved');
} else if (path.includes('reject')) {
event = new CustomEvent('submission-rejected');
} else if (path.includes('escalate')) {
event = new CustomEvent('submission-escalated');
} else if (path.includes('edit')) {
event = new CustomEvent('submission-updated');
}
if (event) {
window.dispatchEvent(event);
// Only dispatch if it's an old-style URL (not FSM transition URL)
if (!path.includes('/transition/')) {
if (path.includes('approve')) {
event = new CustomEvent('submission-approved');
} else if (path.includes('reject')) {
event = new CustomEvent('submission-rejected');
} else if (path.includes('escalate')) {
event = new CustomEvent('submission-escalated');
} else if (path.includes('edit')) {
event = new CustomEvent('submission-updated');
}
if (event) {
window.dispatchEvent(event);
}
}
}
});

View File

@@ -0,0 +1,177 @@
{% load moderation_tags %}
{% load fsm_tags %}
<div class="p-6 bg-white border rounded-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50 {% if transition_success %}animate-flash-success{% endif %}"
id="submission-{{ object.id }}"
data-submission-id="{{ object.id }}"
x-data="{
showSuccess: false,
isEditing: false,
status: '{{ object.changes.status|default:"" }}',
category: '{{ object.changes.category|default:"" }}',
showCoasterFields: {% if object.changes.category == 'RC' %}true{% else %}false{% endif %},
init() {
this.$watch('category', value => {
this.showCoasterFields = value === 'RC';
});
}
}">
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
<!-- Left Column: Header & Status -->
<div class="md:col-span-1">
<div class="submission-header">
<h3 class="flex items-center gap-3 text-lg font-semibold text-gray-900 dark:text-gray-300">
{% include 'htmx/status_with_actions.html' with object=object user=user show_badge=True badge_only=True %}
</h3>
<div class="mt-3 text-gray-600 dark:text-gray-400">
<div class="flex items-center mb-2">
<i class="w-5 mr-2 fas fa-file-alt"></i>
{{ object.get_content_type_display }} -
{% if object.submission_type == 'CREATE' %}New{% else %}Edit{% endif %}
</div>
<div class="flex items-center mb-2">
<i class="w-5 mr-2 fas fa-user"></i>
{{ object.user.username }}
</div>
<div class="flex items-center mb-2">
<i class="w-5 mr-2 fas fa-clock"></i>
{{ object.created_at|date:"M d, Y H:i" }}
</div>
{% if object.handled_by %}
<div class="flex items-center">
<i class="w-5 mr-2 fas fa-user-shield"></i>
{{ object.handled_by.username }}
</div>
{% endif %}
</div>
</div>
</div>
<!-- Middle Column: Content Details -->
<div class="md:col-span-2">
{% if object.content_object %}
<div class="p-4 mb-4 bg-gray-100 border rounded-lg dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50">
<div class="text-sm font-medium text-gray-900 dark:text-gray-300">Current Object:</div>
<div class="mt-1.5 text-gray-600 dark:text-gray-400">
{{ object.content_object }}
</div>
</div>
{% endif %}
{% if object.reason %}
<div class="p-4 mb-4 bg-gray-100 border rounded-lg dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50">
<div class="text-sm font-medium text-gray-900 dark:text-gray-300">Reason:</div>
<div class="mt-1.5 text-gray-600 dark:text-gray-400">{{ object.reason }}</div>
</div>
{% endif %}
<!-- View Mode: Show key changes -->
<div x-show="!isEditing">
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
{% for field, value in object.changes.items %}
{% if field != 'model_name' and field != 'latitude' and field != 'longitude' and field != 'street_address' and field != 'city' and field != 'state' and field != 'postal_code' and field != 'country' and field != 'location' %}
<div class="p-4 bg-gray-100 border rounded-lg dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50">
<div class="text-sm font-medium text-gray-900 dark:text-gray-300">
{{ field|title }}:
</div>
<div class="mt-1.5 text-gray-600 dark:text-gray-400">
{% if field == 'opening_date' or field == 'closing_date' or field == 'status_since' %}
{{ value|date:"Y-m-d" }}
{% elif field == 'size_acres' %}
{{ value }} acres
{% elif field == 'website' %}
<a href="{{ value }}" target="_blank" class="text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300">
{{ value }}
</a>
{% elif field == 'park' %}
{% with park_name=value|get_object_name:'parks.Park' %}
{{ park_name }}
{% endwith %}
{% elif field == 'designer' %}
{% with designer_name=value|get_object_name:'designers.Designer' %}
{{ designer_name|default:'None' }}
{% endwith %}
{% elif field == 'manufacturer' %}
{% with manufacturer_name=value|get_object_name:'companies.Manufacturer' %}
{{ manufacturer_name|default:'None' }}
{% endwith %}
{% elif field == 'ride_model' %}
{% with model_name=value|get_object_name:'rides.RideModel' %}
{{ model_name|default:'None' }}
{% endwith %}
{% elif field == 'category' %}
{{ value|get_category_display }}
{% elif field == 'stats' %}
<div class="space-y-2">
{% if value.height_ft %}<div>Height: {{ value.height_ft }} ft</div>{% endif %}
{% if value.length_ft %}<div>Length: {{ value.length_ft }} ft</div>{% endif %}
{% if value.speed_mph %}<div>Speed: {{ value.speed_mph }} mph</div>{% endif %}
{% if value.inversions %}<div>Inversions: {{ value.inversions }}</div>{% endif %}
</div>
{% else %}
{{ value }}
{% endif %}
</div>
</div>
{% endif %}
{% endfor %}
</div>
</div>
<!-- Review Notes -->
{% if object.notes %}
<div class="p-4 mt-4 border rounded-lg bg-blue-50 dark:bg-blue-900/30 border-blue-200/50 dark:border-blue-700/50">
<div class="text-sm font-medium text-blue-900 dark:text-blue-300">Review Notes:</div>
<div class="mt-1.5 text-blue-800 dark:text-blue-200">{{ object.notes }}</div>
</div>
{% endif %}
<!-- FSM Actions -->
{% if object.status == 'PENDING' or object.status == 'ESCALATED' and user.role in 'ADMIN','SUPERUSER' %}
<div class="mt-6 review-notes" x-data="{ showNotes: false }">
<div x-show="showNotes"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 transform -translate-y-2"
x-transition:enter-end="opacity-100 transform translate-y-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 transform translate-y-0"
x-transition:leave-end="opacity-0 transform -translate-y-2">
<textarea name="notes"
class="w-full px-4 py-3 text-gray-900 bg-white border rounded-lg resize-none dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500"
placeholder="Add review notes (optional)"
rows="3"></textarea>
</div>
<div class="flex items-center justify-end gap-3 mt-4 action-buttons">
<button class="inline-flex items-center px-4 py-2.5 font-medium text-gray-700 transition-all duration-200 bg-gray-100 rounded-lg hover:bg-gray-200 hover:text-gray-900 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 dark:hover:text-white shadow-xs hover:shadow-md"
@click="showNotes = !showNotes">
<i class="mr-2 fas fa-comment-alt"></i>
Add Notes
</button>
<button class="inline-flex items-center px-4 py-2.5 font-medium text-gray-700 transition-all duration-200 bg-gray-100 rounded-lg hover:bg-gray-200 hover:text-gray-900 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 dark:hover:text-white shadow-xs hover:shadow-md"
@click="isEditing = !isEditing">
<i class="mr-2 fas fa-edit"></i>
Edit
</button>
<!-- History Button -->
<button type="button"
class="inline-flex items-center px-4 py-2.5 font-medium text-gray-700 transition-all duration-200 bg-gray-100 rounded-lg hover:bg-gray-200 hover:text-gray-900 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 dark:hover:text-white shadow-xs hover:shadow-md"
hx-get="{% url 'moderation:moderation-reports-all-history' %}?model_type=editsubmission&object_id={{ object.id }}"
hx-target="#history-detail-body"
hx-swap="innerHTML"
@click="$dispatch('open-history-modal')">
<i class="mr-2 fas fa-history"></i>
History
</button>
<!-- FSM-based transition actions -->
{% include 'htmx/status_with_actions.html' with object=object user=user target_id="submission-"|add:object.id|stringformat:"s" show_badge=False %}
</div>
</div>
{% endif %}
</div>
</div>
</div>

View File

@@ -0,0 +1,129 @@
{% comment %}
FSM History Detail Content Partial Template
Content for the history detail modal, loaded via HTMX.
Required context:
- log: StateLog object with transition details
{% endcomment %}
{% if log %}
<div class="space-y-4">
<!-- Log ID and Timestamp -->
<div class="grid grid-cols-2 gap-4">
<div class="p-3 rounded-lg bg-gray-50 dark:bg-gray-900/50">
<dt class="text-xs font-medium text-gray-500 uppercase dark:text-gray-400">Log ID</dt>
<dd class="mt-1 text-sm font-semibold text-gray-900 dark:text-white">#{{ log.id }}</dd>
</div>
<div class="p-3 rounded-lg bg-gray-50 dark:bg-gray-900/50">
<dt class="text-xs font-medium text-gray-500 uppercase dark:text-gray-400">Timestamp</dt>
<dd class="mt-1 text-sm font-semibold text-gray-900 dark:text-white">{{ log.timestamp|date:"M d, Y H:i:s" }}</dd>
</div>
</div>
<!-- Model and Object -->
<div class="grid grid-cols-2 gap-4">
<div class="p-3 rounded-lg bg-gray-50 dark:bg-gray-900/50">
<dt class="text-xs font-medium text-gray-500 uppercase dark:text-gray-400">Model Type</dt>
<dd class="mt-1">
<span class="inline-flex items-center px-2.5 py-0.5 text-xs font-medium text-blue-800 bg-blue-100 rounded-full dark:bg-blue-900/30 dark:text-blue-300">
{{ log.content_type.model|title }}
</span>
</dd>
</div>
<div class="p-3 rounded-lg bg-gray-50 dark:bg-gray-900/50">
<dt class="text-xs font-medium text-gray-500 uppercase dark:text-gray-400">Object ID</dt>
<dd class="mt-1 text-sm font-semibold text-gray-900 dark:text-white">#{{ log.object_id }}</dd>
</div>
</div>
<!-- Transition Name -->
<div class="p-3 rounded-lg bg-gray-50 dark:bg-gray-900/50">
<dt class="text-xs font-medium text-gray-500 uppercase dark:text-gray-400">Transition</dt>
<dd class="mt-1">
<span class="inline-flex items-center px-2.5 py-0.5 text-xs font-medium text-purple-800 bg-purple-100 rounded-full dark:bg-purple-900/30 dark:text-purple-300">
<i class="mr-1 fas fa-exchange-alt"></i>
{{ log.transition|default:"—" }}
</span>
</dd>
</div>
<!-- State Transition -->
<div class="p-4 border rounded-lg border-gray-200/50 dark:border-gray-700/50">
<dt class="mb-3 text-xs font-medium text-gray-500 uppercase dark:text-gray-400">State Transition</dt>
<dd class="flex items-center justify-center gap-4">
{% with from_state=log.source_state %}
<span class="inline-flex items-center px-3 py-1.5 text-sm font-medium rounded-full
{% if from_state == 'PENDING' %}
bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300
{% elif from_state == 'APPROVED' %}
bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300
{% elif from_state == 'REJECTED' %}
bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300
{% elif from_state == 'ESCALATED' %}
bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300
{% elif from_state == 'IN_PROGRESS' or from_state == 'PROCESSING' or from_state == 'UNDER_REVIEW' %}
bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300
{% elif from_state == 'COMPLETED' or from_state == 'RESOLVED' %}
bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300
{% else %}
bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300
{% endif %}">
{{ from_state|default:"—" }}
</span>
{% endwith %}
<i class="text-gray-400 fas fa-arrow-right"></i>
{% with to_state=log.state %}
<span class="inline-flex items-center px-3 py-1.5 text-sm font-medium rounded-full
{% if to_state == 'PENDING' %}
bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300
{% elif to_state == 'APPROVED' %}
bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300
{% elif to_state == 'REJECTED' %}
bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300
{% elif to_state == 'ESCALATED' %}
bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300
{% elif to_state == 'IN_PROGRESS' or to_state == 'PROCESSING' or to_state == 'UNDER_REVIEW' %}
bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300
{% elif to_state == 'COMPLETED' or to_state == 'RESOLVED' %}
bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300
{% else %}
bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300
{% endif %}">
{{ to_state|default:"—" }}
</span>
{% endwith %}
</dd>
</div>
<!-- User -->
<div class="p-3 rounded-lg bg-gray-50 dark:bg-gray-900/50">
<dt class="text-xs font-medium text-gray-500 uppercase dark:text-gray-400">Performed By</dt>
<dd class="flex items-center mt-1">
<i class="mr-2 text-gray-400 fas fa-user"></i>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ log.by.username|default:"System" }}</span>
{% if log.by %}
<span class="ml-2 text-xs text-gray-500 dark:text-gray-400">(ID: {{ log.by.id }})</span>
{% endif %}
</dd>
</div>
<!-- Description/Reason -->
{% if log.description %}
<div class="p-3 rounded-lg bg-gray-50 dark:bg-gray-900/50">
<dt class="text-xs font-medium text-gray-500 uppercase dark:text-gray-400">Description/Reason</dt>
<dd class="mt-1 text-sm text-gray-700 dark:text-gray-300">{{ log.description }}</dd>
</div>
{% endif %}
</div>
{% else %}
<div class="flex flex-col items-center justify-center py-8">
<div class="flex items-center justify-center w-16 h-16 mb-4 text-gray-400 bg-gray-100 rounded-full dark:bg-gray-700">
<i class="text-2xl fas fa-exclamation-triangle"></i>
</div>
<h3 class="mb-1 text-sm font-medium text-gray-900 dark:text-gray-300">Details not found</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">Unable to load transition details.</p>
</div>
{% endif %}

View File

@@ -0,0 +1,80 @@
{% comment %}
FSM History Detail Modal Partial Template
Modal for displaying detailed transition information with Alpine.js visibility control.
Context:
- Loaded via HTMX when "View" button is clicked
- Modal content populated in #history-detail-body
{% endcomment %}
<div id="history-detail-modal"
x-data="{ open: false }"
x-show="open"
x-cloak
@open-history-modal.window="open = true"
@keydown.escape.window="open = false"
class="fixed inset-0 z-50 overflow-y-auto"
aria-labelledby="history-detail-title"
role="dialog"
aria-modal="true">
<!-- Backdrop -->
<div class="fixed inset-0 transition-opacity bg-gray-500/75 dark:bg-gray-900/75"
x-show="open"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@click="open = false"
aria-hidden="true"></div>
<!-- Modal Container -->
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:p-0">
<!-- Modal Panel -->
<div class="relative w-full max-w-2xl overflow-hidden text-left transition-all transform bg-white rounded-lg shadow-xl dark:bg-gray-800 sm:my-8"
x-show="open"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
@click.stop>
<!-- Modal Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 id="history-detail-title" class="text-lg font-semibold text-gray-900 dark:text-white">
<i class="mr-2 text-blue-500 fas fa-history"></i>
Transition Details
</h3>
<button type="button"
class="p-2 text-gray-400 transition-colors duration-150 rounded-lg hover:text-gray-500 hover:bg-gray-100 dark:hover:text-gray-300 dark:hover:bg-gray-700"
@click="open = false"
aria-label="Close modal">
<i class="fas fa-times"></i>
</button>
</div>
<!-- Modal Body -->
<div id="history-detail-body" class="px-6 py-4">
<!-- Content loaded via HTMX -->
<div class="flex items-center justify-center py-8">
<i class="text-2xl text-gray-400 fas fa-spinner fa-spin"></i>
</div>
</div>
<!-- Modal Footer -->
<div class="flex items-center justify-end px-6 py-4 space-x-3 border-t border-gray-200 dark:border-gray-700">
<button type="button"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 transition-colors duration-150 bg-gray-100 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
@click="open = false">
<i class="mr-2 fas fa-times"></i>
Close
</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,192 @@
{% comment %}
FSM History Filter Form Partial Template
Provides HTMX-driven filtering for history table with model type, state,
date range, and user filters.
Context:
- request: Django request object for accessing current filter values
{% endcomment %}
<form id="history-filters"
class="p-4 mb-4 bg-white border rounded-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50"
hx-get="{% url 'moderation:moderation-reports-all-history' %}"
hx-target="#history-table-container"
hx-swap="outerHTML"
hx-trigger="submit"
hx-indicator="#history-loading"
x-data="{ showFilters: true }">
<!-- Mobile Filter Toggle -->
<button type="button"
class="flex items-center w-full gap-2 mb-4 font-medium text-left text-gray-700 md:hidden dark:text-gray-300"
@click="showFilters = !showFilters"
:aria-expanded="showFilters"
aria-controls="history-filter-controls">
<i class="fas" :class="showFilters ? 'fa-chevron-up' : 'fa-chevron-down'"></i>
<span>Filter Options</span>
</button>
<!-- Filter Controls -->
<div id="history-filter-controls"
class="grid gap-4 transition-all duration-200 md:grid-cols-6"
:class="{'hidden md:grid': !showFilters, 'grid': showFilters}"
role="group"
aria-label="History filter controls">
<!-- Search Input -->
<div class="relative md:col-span-2">
<label for="filter-search"
class="block mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Search
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<i class="text-gray-400 fas fa-search"></i>
</div>
<input type="text"
id="filter-search"
name="q"
value="{{ request.GET.q|default:'' }}"
placeholder="Search transitions, users, states..."
class="w-full py-2 pl-10 pr-3 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:outline-none focus:ring-2 focus:ring-blue-500"
hx-get="{% url 'moderation:moderation-reports-all-history' %}"
hx-target="#history-table-container"
hx-swap="outerHTML"
hx-include="#history-filters"
hx-trigger="keyup changed delay:300ms, search"
hx-indicator="#history-loading">
</div>
</div>
<!-- Model Type Filter -->
<div class="relative">
<label for="filter-model-type"
class="block mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Model Type
</label>
<select id="filter-model-type"
name="model_type"
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:outline-none focus:ring-2 focus:ring-blue-500"
hx-get="{% url 'moderation:moderation-reports-all-history' %}"
hx-target="#history-table-container"
hx-swap="outerHTML"
hx-include="#history-filters"
hx-trigger="change"
hx-indicator="#history-loading">
<option value="">All Models</option>
<option value="editsubmission" {% if request.GET.model_type == 'editsubmission' %}selected{% endif %}>Edit Submission</option>
<option value="photosubmission" {% if request.GET.model_type == 'photosubmission' %}selected{% endif %}>Photo Submission</option>
<option value="moderationreport" {% if request.GET.model_type == 'moderationreport' %}selected{% endif %}>Moderation Report</option>
<option value="moderationqueue" {% if request.GET.model_type == 'moderationqueue' %}selected{% endif %}>Moderation Queue</option>
<option value="bulkoperation" {% if request.GET.model_type == 'bulkoperation' %}selected{% endif %}>Bulk Operation</option>
<option value="park" {% if request.GET.model_type == 'park' %}selected{% endif %}>Park</option>
<option value="ride" {% if request.GET.model_type == 'ride' %}selected{% endif %}>Ride</option>
</select>
</div>
<!-- State Filter -->
<div class="relative">
<label for="filter-state"
class="block mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
State
</label>
<select id="filter-state"
name="state"
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:outline-none focus:ring-2 focus:ring-blue-500"
hx-get="{% url 'moderation:moderation-reports-all-history' %}"
hx-target="#history-table-container"
hx-swap="outerHTML"
hx-include="#history-filters"
hx-trigger="change"
hx-indicator="#history-loading">
<option value="">All States</option>
<option value="PENDING" {% if request.GET.state == 'PENDING' %}selected{% endif %}>Pending</option>
<option value="APPROVED" {% if request.GET.state == 'APPROVED' %}selected{% endif %}>Approved</option>
<option value="REJECTED" {% if request.GET.state == 'REJECTED' %}selected{% endif %}>Rejected</option>
<option value="ESCALATED" {% if request.GET.state == 'ESCALATED' %}selected{% endif %}>Escalated</option>
<option value="UNDER_REVIEW" {% if request.GET.state == 'UNDER_REVIEW' %}selected{% endif %}>Under Review</option>
<option value="RESOLVED" {% if request.GET.state == 'RESOLVED' %}selected{% endif %}>Resolved</option>
<option value="IN_PROGRESS" {% if request.GET.state == 'IN_PROGRESS' %}selected{% endif %}>In Progress</option>
<option value="COMPLETED" {% if request.GET.state == 'COMPLETED' %}selected{% endif %}>Completed</option>
<option value="CANCELLED" {% if request.GET.state == 'CANCELLED' %}selected{% endif %}>Cancelled</option>
</select>
</div>
<!-- Start Date Filter -->
<div class="relative">
<label for="filter-start-date"
class="block mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Start Date
</label>
<input type="date"
id="filter-start-date"
name="start_date"
value="{{ request.GET.start_date|default:'' }}"
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:outline-none focus:ring-2 focus:ring-blue-500"
hx-get="{% url 'moderation:moderation-reports-all-history' %}"
hx-target="#history-table-container"
hx-swap="outerHTML"
hx-include="#history-filters"
hx-trigger="change"
hx-indicator="#history-loading">
</div>
<!-- End Date Filter -->
<div class="relative">
<label for="filter-end-date"
class="block mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
End Date
</label>
<input type="date"
id="filter-end-date"
name="end_date"
value="{{ request.GET.end_date|default:'' }}"
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:outline-none focus:ring-2 focus:ring-blue-500"
hx-get="{% url 'moderation:moderation-reports-all-history' %}"
hx-target="#history-table-container"
hx-swap="outerHTML"
hx-include="#history-filters"
hx-trigger="change"
hx-indicator="#history-loading">
</div>
<!-- User ID Filter -->
<div class="relative">
<label for="filter-user-id"
class="block mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
User ID
</label>
<input type="text"
id="filter-user-id"
name="user_id"
value="{{ request.GET.user_id|default:'' }}"
placeholder="Enter user ID"
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
</div>
<!-- Action Buttons -->
<div class="flex items-center justify-end gap-3 pt-4 mt-4 border-t border-gray-200/50 dark:border-gray-700/50"
:class="{'hidden md:flex': !showFilters, 'flex': showFilters}">
<!-- Clear Filters Button -->
<button type="button"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 transition-colors duration-150 bg-gray-100 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
hx-get="{% url 'moderation:moderation-reports-all-history' %}"
hx-target="#history-table-container"
hx-swap="outerHTML"
hx-indicator="#history-loading"
onclick="document.getElementById('history-filters').reset();">
<i class="mr-2 fas fa-times"></i>
Clear Filters
</button>
<!-- Apply Filters Button -->
<button type="submit"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white transition-colors duration-150 bg-blue-600 rounded-lg hover:bg-blue-500 dark:bg-blue-700 dark:hover:bg-blue-600">
<i class="mr-2 fas fa-filter"></i>
Apply Filters
</button>
</div>
</form>

View File

@@ -0,0 +1,158 @@
{% comment %}
FSM History Pagination Partial Template
HTMX-based pagination controls for history table with state preservation.
Required context:
- page_obj: Django Paginator page object
{% endcomment %}
{% if page_obj.has_other_pages %}
<nav class="flex items-center justify-between px-4 py-3 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200/50 dark:border-gray-700/50"
role="navigation"
aria-label="History pagination">
<!-- Mobile Pagination -->
<div class="flex justify-between flex-1 sm:hidden">
{% if page_obj.has_previous %}
<button type="button"
class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-700"
hx-get="{% url 'moderation:moderation-reports-all-history' %}?page={{ page_obj.previous_page_number }}"
hx-target="#history-table-container"
hx-swap="outerHTML"
hx-include="#history-filters"
hx-indicator="#history-loading">
<i class="mr-2 fas fa-chevron-left"></i>
Previous
</button>
{% else %}
<span class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-400 bg-gray-100 border border-gray-300 rounded-md cursor-not-allowed dark:bg-gray-700 dark:text-gray-500 dark:border-gray-600">
<i class="mr-2 fas fa-chevron-left"></i>
Previous
</span>
{% endif %}
{% if page_obj.has_next %}
<button type="button"
class="relative inline-flex items-center px-4 py-2 ml-3 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-700"
hx-get="{% url 'moderation:moderation-reports-all-history' %}?page={{ page_obj.next_page_number }}"
hx-target="#history-table-container"
hx-swap="outerHTML"
hx-include="#history-filters"
hx-indicator="#history-loading">
Next
<i class="ml-2 fas fa-chevron-right"></i>
</button>
{% else %}
<span class="relative inline-flex items-center px-4 py-2 ml-3 text-sm font-medium text-gray-400 bg-gray-100 border border-gray-300 rounded-md cursor-not-allowed dark:bg-gray-700 dark:text-gray-500 dark:border-gray-600">
Next
<i class="ml-2 fas fa-chevron-right"></i>
</span>
{% endif %}
</div>
<!-- Desktop Pagination -->
<div class="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
<div>
<p class="text-sm text-gray-700 dark:text-gray-400">
Showing
<span class="font-medium">{{ page_obj.start_index }}</span>
to
<span class="font-medium">{{ page_obj.end_index }}</span>
of
<span class="font-medium">{{ page_obj.paginator.count }}</span>
results
</p>
</div>
<div>
<nav class="inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
<!-- Previous Button -->
{% if page_obj.has_previous %}
<button type="button"
class="relative inline-flex items-center px-2 py-2 text-gray-400 bg-white border border-gray-300 rounded-l-md hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:hover:bg-gray-700"
hx-get="{% url 'moderation:moderation-reports-all-history' %}?page={{ page_obj.previous_page_number }}"
hx-target="#history-table-container"
hx-swap="outerHTML"
hx-include="#history-filters"
hx-indicator="#history-loading"
aria-label="Previous page">
<i class="fas fa-chevron-left"></i>
</button>
{% else %}
<span class="relative inline-flex items-center px-2 py-2 text-gray-300 bg-gray-100 border border-gray-300 rounded-l-md cursor-not-allowed dark:bg-gray-700 dark:text-gray-500 dark:border-gray-600">
<i class="fas fa-chevron-left"></i>
</span>
{% endif %}
<!-- Page Numbers -->
{% for num in page_obj.paginator.page_range %}
{% if page_obj.number == num %}
<span class="relative inline-flex items-center px-4 py-2 text-sm font-semibold text-white bg-blue-600 border border-blue-600 z-10 dark:bg-blue-700 dark:border-blue-700"
aria-current="page">
{{ num }}
</span>
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
<button type="button"
class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-700"
hx-get="{% url 'moderation:moderation-reports-all-history' %}?page={{ num }}"
hx-target="#history-table-container"
hx-swap="outerHTML"
hx-include="#history-filters"
hx-indicator="#history-loading">
{{ num }}
</button>
{% elif num == 1 %}
<button type="button"
class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-700"
hx-get="{% url 'moderation:moderation-reports-all-history' %}?page=1"
hx-target="#history-table-container"
hx-swap="outerHTML"
hx-include="#history-filters"
hx-indicator="#history-loading">
1
</button>
{% elif num == page_obj.paginator.num_pages %}
<button type="button"
class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-700"
hx-get="{% url 'moderation:moderation-reports-all-history' %}?page={{ num }}"
hx-target="#history-table-container"
hx-swap="outerHTML"
hx-include="#history-filters"
hx-indicator="#history-loading">
{{ num }}
</button>
{% elif num == 2 or num == page_obj.paginator.num_pages|add:'-1' %}
<span class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 dark:bg-gray-800 dark:text-gray-500 dark:border-gray-600">
...
</span>
{% endif %}
{% endfor %}
<!-- Next Button -->
{% if page_obj.has_next %}
<button type="button"
class="relative inline-flex items-center px-2 py-2 text-gray-400 bg-white border border-gray-300 rounded-r-md hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:hover:bg-gray-700"
hx-get="{% url 'moderation:moderation-reports-all-history' %}?page={{ page_obj.next_page_number }}"
hx-target="#history-table-container"
hx-swap="outerHTML"
hx-include="#history-filters"
hx-indicator="#history-loading"
aria-label="Next page">
<i class="fas fa-chevron-right"></i>
</button>
{% else %}
<span class="relative inline-flex items-center px-2 py-2 text-gray-300 bg-gray-100 border border-gray-300 rounded-r-md cursor-not-allowed dark:bg-gray-700 dark:text-gray-500 dark:border-gray-600">
<i class="fas fa-chevron-right"></i>
</span>
{% endif %}
</nav>
</div>
</div>
<!-- Loading Indicator for Pagination -->
<div id="pagination-loading" class="absolute inset-0 flex items-center justify-center bg-white/75 dark:bg-gray-800/75 htmx-indicator">
<i class="text-2xl text-blue-500 fas fa-spinner fa-spin"></i>
</div>
</nav>
{% endif %}

View File

@@ -0,0 +1,173 @@
{% comment %}
FSM History Table Partial Template
Displays FSM transition logs in a responsive table format with HTMX integration.
Required context:
- history_logs: QuerySet or list of StateLog objects
Optional context:
- show_model_column: Boolean to show/hide model type column (default: True)
- show_object_link: Boolean to make object IDs clickable (default: True)
- compact: Boolean for compact layout (default: False)
- page_obj: Paginator page object for pagination
{% endcomment %}
<div id="history-table-container" class="overflow-hidden bg-white border rounded-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50">
<!-- Loading Indicator -->
<div id="history-loading" class="flex items-center justify-center py-8 htmx-indicator">
<i class="mr-2 text-blue-500 fas fa-spinner fa-spin"></i>
<span class="text-gray-600 dark:text-gray-400">Loading history...</span>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-900/50">
<tr>
<th scope="col" class="px-4 py-3 text-xs font-semibold tracking-wider text-left text-gray-600 uppercase dark:text-gray-400 {% if compact %}px-2 py-2{% endif %}">
Timestamp
</th>
{% if show_model_column|default:True %}
<th scope="col" class="px-4 py-3 text-xs font-semibold tracking-wider text-left text-gray-600 uppercase dark:text-gray-400 {% if compact %}px-2 py-2{% endif %}">
Model
</th>
{% endif %}
<th scope="col" class="px-4 py-3 text-xs font-semibold tracking-wider text-left text-gray-600 uppercase dark:text-gray-400 {% if compact %}px-2 py-2{% endif %}">
Object ID
</th>
<th scope="col" class="px-4 py-3 text-xs font-semibold tracking-wider text-left text-gray-600 uppercase dark:text-gray-400 {% if compact %}px-2 py-2{% endif %}">
Transition
</th>
<th scope="col" class="px-4 py-3 text-xs font-semibold tracking-wider text-left text-gray-600 uppercase dark:text-gray-400 {% if compact %}px-2 py-2{% endif %}">
From
</th>
<th scope="col" class="px-4 py-3 text-xs font-semibold tracking-wider text-left text-gray-600 uppercase dark:text-gray-400 {% if compact %}px-2 py-2{% endif %}">
To
</th>
<th scope="col" class="px-4 py-3 text-xs font-semibold tracking-wider text-left text-gray-600 uppercase dark:text-gray-400 {% if compact %}px-2 py-2{% endif %}">
User
</th>
<th scope="col" class="px-4 py-3 text-xs font-semibold tracking-wider text-center text-gray-600 uppercase dark:text-gray-400 {% if compact %}px-2 py-2{% endif %}">
Actions
</th>
</tr>
</thead>
<tbody id="history-tbody" class="bg-white divide-y divide-gray-200 dark:bg-gray-800 dark:divide-gray-700">
{% for log in history_logs %}
<tr id="history-row-{{ log.id }}" class="transition-colors duration-150 hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td class="px-4 py-3 text-sm text-gray-700 whitespace-nowrap dark:text-gray-300 {% if compact %}px-2 py-2{% endif %}">
<div class="flex items-center">
<i class="mr-2 text-gray-400 fas fa-clock"></i>
{{ log.timestamp|date:"M d, Y H:i" }}
</div>
</td>
{% if show_model_column|default:True %}
<td class="px-4 py-3 whitespace-nowrap {% if compact %}px-2 py-2{% endif %}">
<span class="inline-flex items-center px-2.5 py-0.5 text-xs font-medium text-blue-800 bg-blue-100 rounded-full dark:bg-blue-900/30 dark:text-blue-300">
{{ log.content_type.model|title }}
</span>
</td>
{% endif %}
<td class="px-4 py-3 text-sm text-gray-700 whitespace-nowrap dark:text-gray-300 {% if compact %}px-2 py-2{% endif %}">
{% if show_object_link|default:True %}
<a href="#" class="text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300">
#{{ log.object_id }}
</a>
{% else %}
#{{ log.object_id }}
{% endif %}
</td>
<td class="px-4 py-3 whitespace-nowrap {% if compact %}px-2 py-2{% endif %}">
<span class="inline-flex items-center px-2.5 py-0.5 text-xs font-medium text-purple-800 bg-purple-100 rounded-full dark:bg-purple-900/30 dark:text-purple-300">
<i class="mr-1 fas fa-exchange-alt"></i>
{{ log.transition|default:"—" }}
</span>
</td>
<td class="px-4 py-3 whitespace-nowrap {% if compact %}px-2 py-2{% endif %}">
{% with from_state=log.source_state %}
<span class="inline-flex items-center px-2.5 py-0.5 text-xs font-medium rounded-full
{% if from_state == 'PENDING' %}
bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300
{% elif from_state == 'APPROVED' %}
bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300
{% elif from_state == 'REJECTED' %}
bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300
{% elif from_state == 'ESCALATED' %}
bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300
{% elif from_state == 'IN_PROGRESS' or from_state == 'PROCESSING' or from_state == 'UNDER_REVIEW' %}
bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300
{% elif from_state == 'COMPLETED' or from_state == 'RESOLVED' %}
bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300
{% elif from_state == 'CANCELLED' or from_state == 'DISMISSED' %}
bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300
{% else %}
bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300
{% endif %}">
{{ from_state|default:"—" }}
</span>
{% endwith %}
</td>
<td class="px-4 py-3 whitespace-nowrap {% if compact %}px-2 py-2{% endif %}">
{% with to_state=log.state %}
<span class="inline-flex items-center px-2.5 py-0.5 text-xs font-medium rounded-full
{% if to_state == 'PENDING' %}
bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300
{% elif to_state == 'APPROVED' %}
bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300
{% elif to_state == 'REJECTED' %}
bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300
{% elif to_state == 'ESCALATED' %}
bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300
{% elif to_state == 'IN_PROGRESS' or to_state == 'PROCESSING' or to_state == 'UNDER_REVIEW' %}
bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300
{% elif to_state == 'COMPLETED' or to_state == 'RESOLVED' %}
bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300
{% elif to_state == 'CANCELLED' or to_state == 'DISMISSED' %}
bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300
{% else %}
bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300
{% endif %}">
{{ to_state|default:"—" }}
</span>
{% endwith %}
</td>
<td class="px-4 py-3 text-sm text-gray-700 whitespace-nowrap dark:text-gray-300 {% if compact %}px-2 py-2{% endif %}">
<div class="flex items-center">
<i class="mr-2 text-gray-400 fas fa-user"></i>
{{ log.by.username|default:"System" }}
</div>
</td>
<td class="px-4 py-3 text-sm text-center whitespace-nowrap {% if compact %}px-2 py-2{% endif %}">
<button type="button"
class="inline-flex items-center px-2.5 py-1.5 text-xs font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 transition-colors duration-150"
hx-get="{% url 'moderation:moderation-reports-all-history' %}?id={{ log.id }}"
hx-target="#history-detail-body"
hx-swap="innerHTML"
@click="$dispatch('open-history-modal')">
<i class="mr-1 fas fa-eye"></i>
View
</button>
</td>
</tr>
{% empty %}
<tr>
<td colspan="{% if show_model_column|default:True %}8{% else %}7{% endif %}" class="px-4 py-12 text-center">
<div class="flex flex-col items-center justify-center">
<div class="flex items-center justify-center w-16 h-16 mb-4 text-gray-400 bg-gray-100 rounded-full dark:bg-gray-700">
<i class="text-2xl fas fa-history"></i>
</div>
<h3 class="mb-1 text-sm font-medium text-gray-900 dark:text-gray-300">No history found</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">No state transitions have been recorded yet.</p>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if page_obj %}
{% include "moderation/partials/history_pagination.html" %}
{% endif %}
</div>

View File

@@ -1,3 +1,4 @@
{% load fsm_tags %}
<div class="p-6 submission-card" id="submission-{{ submission.id }}">
<div class="mb-4 submission-header">
<div>
@@ -56,54 +57,23 @@
</div>
{% endif %}
{% if submission.status == 'PENDING' or submission.status == 'ESCALATED' and user.role in 'ADMIN,SUPERUSER' %}
{% if submission.status == 'PENDING' or submission.status == 'ESCALATED' and user.role in 'ADMIN','SUPERUSER' %}
<div class="mt-4 review-notes" x-data="{ showNotes: false }">
<textarea x-show="showNotes"
name="notes"
class="w-full p-3 border border-gray-300 rounded-lg resize-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Add review notes (optional)"
rows="3"></textarea>
<div class="flex items-center justify-end gap-3 mt-4 action-buttons">
<button class="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
@click="showNotes = !showNotes">
<i class="mr-2 fas fa-comment-alt"></i>
Add Notes
</button>
{% if submission.status != 'ESCALATED' or user.role in 'ADMIN,SUPERUSER' %}
<button class="btn-approve"
hx-post="{% url 'moderation:approve_photo' submission.id %}"
hx-target="#submission-{{ submission.id }}"
hx-include="closest .review-notes"
hx-confirm="Are you sure you want to approve this photo?"
hx-indicator="#loading-indicator">
<i class="mr-2 fas fa-check"></i>
Approve
</button>
<button class="btn-reject"
hx-post="{% url 'moderation:reject_photo' submission.id %}"
hx-target="#submission-{{ submission.id }}"
hx-include="closest .review-notes"
hx-confirm="Are you sure you want to reject this photo?"
hx-indicator="#loading-indicator">
<i class="mr-2 fas fa-times"></i>
Reject
</button>
{% endif %}
{% if user.role == 'MODERATOR' and submission.status != 'ESCALATED' %}
<button class="btn-escalate"
hx-post="{% url 'moderation:escalate_photo' submission.id %}"
hx-target="#submission-{{ submission.id }}"
hx-include="closest .review-notes"
hx-confirm="Are you sure you want to escalate this photo?"
hx-indicator="#loading-indicator">
<i class="mr-2 fas fa-arrow-up"></i>
Escalate
</button>
{% endif %}
<!-- FSM-based transition actions -->
{% include 'htmx/status_with_actions.html' with object=submission user=user target_id="submission-"|add:submission.id|stringformat:"s" show_badge=False %}
</div>
</div>
{% endif %}

View File

@@ -0,0 +1,86 @@
{% load fsm_tags %}
<div class="p-6 submission-card {% if transition_success %}animate-flash-success{% endif %}"
id="submission-{{ object.id }}"
data-photo-submission-id="{{ object.id }}">
<div class="mb-4 submission-header">
<div>
<h3 class="flex items-center gap-2 text-lg font-semibold text-gray-900 dark:text-white">
{% include 'htmx/status_with_actions.html' with object=object user=user show_badge=True badge_only=True %}
Photo for {{ object.content_object }}
</h3>
<div class="mt-1 submission-meta">
<span class="inline-flex items-center">
<i class="mr-1 fas fa-user"></i>
{{ object.user.username }}
</span>
<span class="mx-2">*</span>
<span class="inline-flex items-center">
<i class="mr-1 fas fa-clock"></i>
{{ object.created_at|date:"M d, Y H:i" }}
</span>
{% if object.date_taken %}
<span class="mx-2">*</span>
<span class="inline-flex items-center">
<i class="mr-1 fas fa-calendar"></i>
Taken: {{ object.date_taken|date:"M d, Y" }}
</span>
{% endif %}
</div>
</div>
</div>
<!-- Photo display -->
<div class="mt-4 overflow-hidden bg-gray-100 rounded-lg aspect-w-16 aspect-h-9 dark:bg-gray-800">
<img src="{{ object.photo.url }}"
alt="{{ object.caption|default:'Submitted photo' }}"
class="object-contain w-full h-full">
</div>
{% if object.caption %}
<div class="p-4 mt-2 rounded-lg bg-gray-50 dark:bg-gray-700/50">
<div class="text-sm font-medium text-gray-700 dark:text-gray-300">Caption:</div>
<div class="mt-1 text-gray-600 dark:text-gray-400">{{ object.caption }}</div>
</div>
{% endif %}
{% if object.notes %}
<div class="p-4 mt-4 border rounded-lg bg-blue-50 dark:bg-blue-900/30 border-blue-200/50 dark:border-blue-700/50">
<div class="text-sm font-medium text-blue-900 dark:text-blue-300">Review Notes:</div>
<div class="mt-1.5 text-blue-800 dark:text-blue-200">{{ object.notes }}</div>
</div>
{% endif %}
<!-- FSM Actions -->
{% if object.status == 'PENDING' or object.status == 'ESCALATED' and user.role in 'ADMIN','SUPERUSER' %}
<div class="mt-4 review-notes" x-data="{ showNotes: false }">
<textarea x-show="showNotes"
name="notes"
class="w-full p-3 border border-gray-300 rounded-lg resize-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Add review notes (optional)"
rows="3"></textarea>
<div class="flex items-center justify-end gap-3 mt-4 action-buttons">
<button class="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
@click="showNotes = !showNotes">
<i class="mr-2 fas fa-comment-alt"></i>
Add Notes
</button>
<!-- History Button -->
<button type="button"
class="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
hx-get="{% url 'moderation:moderation-reports-all-history' %}?model_type=photosubmission&object_id={{ object.id }}"
hx-target="#history-detail-body"
hx-swap="innerHTML"
@click="$dispatch('open-history-modal')">
<i class="mr-2 fas fa-history"></i>
History
</button>
<!-- FSM-based transition actions -->
{% include 'htmx/status_with_actions.html' with object=object user=user target_id="submission-"|add:object.id|stringformat:"s" show_badge=False %}
</div>
</div>
{% endif %}
</div>

View File

@@ -1,5 +1,6 @@
{% load moderation_tags %}
{% load static %}
{% load fsm_tags %}
{% block extra_head %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
@@ -409,9 +410,9 @@
</div>
{% endif %}
{% if submission.status == 'PENDING' or submission.status == 'ESCALATED' and user.role in 'ADMIN,SUPERUSER' %}
{% if submission.status == 'PENDING' or submission.status == 'ESCALATED' and user.role in 'ADMIN','SUPERUSER' %}
<div class="mt-6 review-notes" x-data="{ showNotes: false }">
<div x-show="showNotes"
<div x-show="showNotes"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 transform -translate-y-2"
x-transition:enter-end="opacity-100 transform translate-y-0"
@@ -424,7 +425,7 @@
rows="3"></textarea>
</div>
<div class="flex items-center justify-end gap-3 action-buttons">
<div class="flex items-center justify-end gap-3 mt-4 action-buttons">
<button class="inline-flex items-center px-4 py-2.5 font-medium text-gray-700 transition-all duration-200 bg-gray-100 rounded-lg hover:bg-gray-200 hover:text-gray-900 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 dark:hover:text-white shadow-xs hover:shadow-md"
@click="showNotes = !showNotes">
<i class="mr-2 fas fa-comment-alt"></i>
@@ -436,40 +437,9 @@
<i class="mr-2 fas fa-edit"></i>
Edit
</button>
{% if submission.status != 'ESCALATED' or user.role in 'ADMIN,SUPERUSER' %}
<button class="inline-flex items-center px-4 py-2.5 font-medium text-white transition-all duration-200 bg-green-600 rounded-lg hover:bg-green-500 dark:bg-green-700 dark:hover:bg-green-600 shadow-xs hover:shadow-md"
hx-post="{% url 'moderation:approve_submission' submission.id %}"
hx-target="#submissions-content"
hx-include="closest .review-notes"
hx-confirm="Are you sure you want to approve this submission?"
hx-indicator="#loading-indicator">
<i class="mr-2 fas fa-check"></i>
Approve
</button>
<button class="inline-flex items-center px-4 py-2.5 font-medium text-white transition-all duration-200 bg-red-600 rounded-lg hover:bg-red-500 dark:bg-red-700 dark:hover:bg-red-600 shadow-xs hover:shadow-md"
hx-post="{% url 'moderation:reject_submission' submission.id %}"
hx-target="#submissions-content"
hx-include="closest .review-notes"
hx-confirm="Are you sure you want to reject this submission?"
hx-indicator="#loading-indicator">
<i class="mr-2 fas fa-times"></i>
Reject
</button>
{% endif %}
{% if user.role == 'MODERATOR' and submission.status != 'ESCALATED' %}
<button class="inline-flex items-center px-4 py-2.5 font-medium text-white transition-all duration-200 bg-yellow-600 rounded-lg hover:bg-yellow-500 dark:bg-yellow-700 dark:hover:bg-yellow-600 shadow-xs hover:shadow-md"
hx-post="{% url 'moderation:escalate_submission' submission.id %}"
hx-target="#submissions-content"
hx-include="closest .review-notes"
hx-confirm="Are you sure you want to escalate this submission?"
hx-indicator="#loading-indicator">
<i class="mr-2 fas fa-arrow-up"></i>
Escalate
</button>
{% endif %}
<!-- FSM-based transition actions -->
{% include 'htmx/status_with_actions.html' with object=submission user=user target_id="submission-"|add:submission.id|stringformat:"s" show_badge=False %}
</div>
</div>
{% endif %}

View File

@@ -27,6 +27,13 @@
hx-swap="innerHTML">
</div>
<!-- Status Management Section (Moderators Only) -->
<div id="park-status-section"
hx-get="{% url 'parks:park_status_actions' park.slug %}"
hx-trigger="load"
hx-swap="outerHTML">
</div>
<!-- Park Header -->
<div class="p-compact mb-6 bg-white rounded-lg shadow-lg dark:bg-gray-800">
<div class="text-center">
@@ -38,13 +45,8 @@
</div>
{% endif %}
<div class="flex flex-wrap items-center justify-center gap-2 mt-3">
<span class="status-badge text-sm font-medium py-1 px-3 {% if park.status == 'OPERATING' %}status-operating
{% elif park.status == 'CLOSED_TEMP' or park.status == 'CLOSED_PERM' %}status-closed
{% elif park.status == 'UNDER_CONSTRUCTION' %}status-construction
{% elif park.status == 'DEMOLISHED' %}status-demolished
{% elif park.status == 'RELOCATED' %}status-relocated{% endif %}">
{{ park.get_status_display }}
</span>
{% include "parks/partials/park_header_badge.html" with park=park %}
{% if park.average_rating %}
<span class="flex items-center px-3 py-1 text-sm font-medium text-yellow-800 bg-yellow-100 status-badge dark:bg-yellow-600 dark:text-yellow-50">
<span class="mr-1 text-yellow-500 dark:text-yellow-200"></span>
@@ -204,8 +206,40 @@
{% endif %}
<!-- History Panel -->
<div class="p-optimized mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">History</h2>
<div class="p-optimized mb-6 bg-white rounded-lg shadow dark:bg-gray-800" x-data="{ showFsmHistory: false }">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">History</h2>
{% if perms.parks.change_park %}
<button type="button"
@click="showFsmHistory = !showFsmHistory"
class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 transition-colors duration-150">
<i class="mr-2 fas fa-history"></i>
<span x-text="showFsmHistory ? 'Hide Transitions' : 'Show Transitions'"></span>
</button>
{% endif %}
</div>
<!-- FSM Transition History (Moderators Only) -->
{% if perms.parks.change_park %}
<div x-show="showFsmHistory" x-cloak class="mb-4">
<div id="park-fsm-history-container"
x-show="showFsmHistory"
x-init="$watch('showFsmHistory', value => { if(value && !$el.dataset.loaded) { htmx.trigger($el, 'load-history'); $el.dataset.loaded = 'true'; } })"
hx-get="{% url 'moderation:moderation-reports-all-history' %}?model_type=park&object_id={{ park.id }}"
hx-trigger="load-history"
hx-target="this"
hx-swap="innerHTML"
hx-indicator="#park-fsm-loading">
<!-- Loading State -->
<div id="park-fsm-loading" class="flex items-center justify-center py-4">
<i class="mr-2 text-blue-500 fas fa-spinner fa-spin"></i>
<span class="text-sm text-gray-600 dark:text-gray-400">Loading transitions...</span>
</div>
</div>
</div>
{% endif %}
<!-- Regular History -->
<div class="space-y-4">
{% for record in history %}
<div class="p-4 rounded-lg bg-gray-50 dark:bg-gray-700/50">
@@ -221,7 +255,7 @@
<div class="text-sm">
<span class="font-medium">{{ field|title }}:</span>
<span class="text-red-600 dark:text-red-400">{{ changes.old }}</span>
<span class="mx-1"></span>
<span class="mx-1">-></span>
<span class="text-green-600 dark:text-green-400">{{ changes.new }}</span>
</div>
{% endif %}

View File

@@ -0,0 +1,30 @@
{# Park header status badge partial - refreshes via HTMX on park-status-changed #}
<span id="park-header-badge"
hx-get="{% url 'parks:park_header_badge' park.slug %}"
hx-trigger="park-status-changed from:body"
hx-swap="outerHTML">
{% if perms.parks.change_park %}
<!-- Clickable status badge for moderators -->
<button type="button"
onclick="document.getElementById('park-status-section').scrollIntoView({behavior: 'smooth'})"
class="status-badge text-sm font-medium py-1 px-3 transition-all hover:ring-2 hover:ring-blue-500 cursor-pointer
{% if park.status == 'OPERATING' %}status-operating
{% elif park.status == 'CLOSED_TEMP' or park.status == 'CLOSED_PERM' %}status-closed
{% elif park.status == 'UNDER_CONSTRUCTION' %}status-construction
{% elif park.status == 'DEMOLISHED' %}status-demolished
{% elif park.status == 'RELOCATED' %}status-relocated{% endif %}">
{{ park.get_status_display }}
<i class="fas fa-chevron-down ml-1 text-xs"></i>
</button>
{% else %}
<!-- Static status badge for non-moderators -->
<span class="status-badge text-sm font-medium py-1 px-3
{% if park.status == 'OPERATING' %}status-operating
{% elif park.status == 'CLOSED_TEMP' or park.status == 'CLOSED_PERM' %}status-closed
{% elif park.status == 'UNDER_CONSTRUCTION' %}status-construction
{% elif park.status == 'DEMOLISHED' %}status-demolished
{% elif park.status == 'RELOCATED' %}status-relocated{% endif %}">
{{ park.get_status_display }}
</span>
{% endif %}
</span>

View File

@@ -0,0 +1,23 @@
{% comment %}
Park FSM History Partial Template
Displays FSM transition history for a specific park.
Loaded via HTMX when the history section is expanded.
Required context:
- park: The Park model instance
{% endcomment %}
<div class="mt-4">
<div id="park-history-container"
hx-get="{% url 'moderation:moderation-reports-all-history' %}?model_type=park&object_id={{ park.id }}"
hx-trigger="load"
hx-swap="innerHTML"
hx-indicator="#park-history-loading">
<!-- Loading State -->
<div id="park-history-loading" class="flex items-center justify-center py-8">
<i class="mr-2 text-blue-500 fas fa-spinner fa-spin"></i>
<span class="text-gray-600 dark:text-gray-400">Loading history...</span>
</div>
</div>
</div>

View File

@@ -0,0 +1,19 @@
{% load fsm_tags %}
{# This partial is loaded via HTMX into #park-status-section. It must include the container #}
{# element with the same id and hx-* attributes to preserve targeting for subsequent transitions. #}
<div id="park-status-section"
data-park-status-actions
hx-get="{% url 'parks:park_status_actions' park.slug %}"
hx-trigger="park-status-changed from:body"
hx-swap="outerHTML">
{% if user.is_authenticated and perms.parks.change_park %}
<div class="mb-6 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<h3 class="text-sm font-semibold text-blue-900 dark:text-blue-100 mb-3">
<i class="fas fa-cog mr-2"></i>Status Management
</h3>
{% include "htmx/status_with_actions.html" with object=park user=user target_id="park-status-section" %}
</div>
{% endif %}
</div>

View File

@@ -1,5 +1,37 @@
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">History</h2>
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800" x-data="{ showFsmHistory: false }">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">History</h2>
{% if perms.rides.change_ride %}
<button type="button"
@click="showFsmHistory = !showFsmHistory"
class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 transition-colors duration-150">
<i class="mr-2 fas fa-history"></i>
<span x-text="showFsmHistory ? 'Hide Transitions' : 'Show Transitions'"></span>
</button>
{% endif %}
</div>
<!-- FSM Transition History (Moderators Only) -->
{% if perms.rides.change_ride %}
<div x-show="showFsmHistory" x-cloak class="mb-4">
<div id="ride-fsm-history-container"
x-show="showFsmHistory"
x-init="$watch('showFsmHistory', value => { if(value && !$el.dataset.loaded) { htmx.trigger($el, 'load-history'); $el.dataset.loaded = 'true'; } })"
hx-get="{% url 'moderation:moderation-reports-all-history' %}?model_type=ride&object_id={{ ride.id }}"
hx-trigger="load-history"
hx-target="this"
hx-swap="innerHTML"
hx-indicator="#ride-fsm-loading">
<!-- Loading State -->
<div id="ride-fsm-loading" class="flex items-center justify-center py-4">
<i class="mr-2 text-blue-500 fas fa-spinner fa-spin"></i>
<span class="text-sm text-gray-600 dark:text-gray-400">Loading transitions...</span>
</div>
</div>
</div>
{% endif %}
<!-- Regular History -->
<div class="space-y-4">
{% for record in history %}
<div class="p-4 rounded-lg bg-gray-50 dark:bg-gray-700/50">
@@ -8,7 +40,7 @@
{% if record.pgh_context.user %}
by {{ record.pgh_context.user }}
{% endif %}
{{ record.pgh_label }}
- {{ record.pgh_label }}
</div>
{% if record.diff_against_previous %}
<div class="mt-2 space-y-2">
@@ -16,7 +48,7 @@
<div class="text-sm">
<span class="font-medium">{{ field }}:</span>
<span class="text-red-600 dark:text-red-400">{{ change.old }}</span>
<span class="mx-1"></span>
<span class="mx-1">-></span>
<span class="text-green-600 dark:text-green-400">{{ change.new }}</span>
</div>
{% endfor %}

View File

@@ -0,0 +1,30 @@
{# Ride header status badge partial - refreshes via HTMX on ride-status-changed #}
<span id="ride-header-badge"
hx-get="{% url 'parks:rides:ride_header_badge' park_slug=ride.park.slug ride_slug=ride.slug %}"
hx-trigger="ride-status-changed from:body"
hx-swap="outerHTML">
{% if perms.rides.change_ride %}
<!-- Clickable status badge for moderators -->
<button type="button"
onclick="document.getElementById('ride-status-section').scrollIntoView({behavior: 'smooth'})"
class="px-3 py-1 text-sm font-medium status-badge transition-all hover:ring-2 hover:ring-blue-500 cursor-pointer
{% if ride.status == 'OPERATING' %}status-operating
{% elif ride.status == 'CLOSED_TEMP' or ride.status == 'CLOSED_PERM' %}status-closed
{% elif ride.status == 'UNDER_CONSTRUCTION' %}status-construction
{% elif ride.status == 'DEMOLISHED' %}status-demolished
{% elif ride.status == 'RELOCATED' %}status-relocated{% endif %}">
{{ ride.get_status_display }}
<i class="fas fa-chevron-down ml-1 text-xs"></i>
</button>
{% else %}
<!-- Static status badge for non-moderators -->
<span class="px-3 py-1 text-sm font-medium status-badge
{% if ride.status == 'OPERATING' %}status-operating
{% elif ride.status == 'CLOSED_TEMP' or ride.status == 'CLOSED_PERM' %}status-closed
{% elif ride.status == 'UNDER_CONSTRUCTION' %}status-construction
{% elif ride.status == 'DEMOLISHED' %}status-demolished
{% elif ride.status == 'RELOCATED' %}status-relocated{% endif %}">
{{ ride.get_status_display }}
</span>
{% endif %}
</span>

View File

@@ -0,0 +1,23 @@
{% comment %}
Ride FSM History Partial Template
Displays FSM transition history for a specific ride.
Loaded via HTMX when the history section is expanded.
Required context:
- ride: The Ride model instance
{% endcomment %}
<div class="mt-4">
<div id="ride-history-container"
hx-get="{% url 'moderation:moderation-reports-all-history' %}?model_type=ride&object_id={{ ride.id }}"
hx-trigger="load"
hx-swap="innerHTML"
hx-indicator="#ride-history-loading">
<!-- Loading State -->
<div id="ride-history-loading" class="flex items-center justify-center py-8">
<i class="mr-2 text-blue-500 fas fa-spinner fa-spin"></i>
<span class="text-gray-600 dark:text-gray-400">Loading history...</span>
</div>
</div>
</div>

View File

@@ -0,0 +1,26 @@
{% load fsm_tags %}
{# This partial is loaded via HTMX into #ride-status-section. It must include the container #}
{# element with the same id and hx-* attributes to preserve targeting for subsequent transitions. #}
<div id="ride-status-section"
data-ride-status-actions
hx-get="{% url 'parks:rides:ride_status_actions' park_slug=ride.park.slug ride_slug=ride.slug %}"
hx-trigger="ride-status-changed from:body"
hx-swap="outerHTML">
{% if user.is_authenticated and perms.rides.change_ride %}
<div class="mb-6 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<h3 class="text-sm font-semibold text-blue-900 dark:text-blue-100 mb-3">
<i class="fas fa-cog mr-2"></i>Status Management
</h3>
{% include "htmx/status_with_actions.html" with object=ride user=user target_id="ride-status-section" %}
{% if ride.status == 'CLOSING' and ride.post_closing_status %}
<div class="mt-3 p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded text-sm">
<i class="fas fa-info-circle mr-2 text-yellow-600"></i>
<strong>Scheduled:</strong> Will transition to {{ ride.get_post_closing_status_display }} on {{ ride.closing_date }}
</div>
{% endif %}
</div>
{% endif %}
</div>

View File

@@ -8,12 +8,12 @@
<!-- Action Buttons - Above header -->
{% if user.is_authenticated %}
<div class="flex justify-end gap-2 mb-2">
<a href="{% url 'parks:rides:ride_update' park_slug=ride.park.slug ride_slug=ride.slug %}"
<a href="{% url 'parks:rides:ride_update' park_slug=ride.park.slug ride_slug=ride.slug %}"
class="transition-transform btn-secondary hover:scale-105">
<i class="mr-1 fas fa-pencil-alt"></i>Edit
</a>
{% if perms.media.add_photo %}
<button class="transition-transform btn-secondary hover:scale-105"
<button class="transition-transform btn-secondary hover:scale-105"
@click="$dispatch('show-photo-upload')">
<i class="mr-1 fas fa-camera"></i>Upload Photo
</button>
@@ -21,6 +21,13 @@
</div>
{% endif %}
<!-- Status Management Section (Moderators Only) -->
<div id="ride-status-section"
hx-get="{% url 'parks:rides:ride_status_actions' park_slug=ride.park.slug ride_slug=ride.slug %}"
hx-trigger="load"
hx-swap="outerHTML">
</div>
<!-- Ride Header -->
<div class="p-compact mb-6 bg-white rounded-lg shadow-lg dark:bg-gray-800">
<div class="text-center">
@@ -34,13 +41,8 @@
{% endif %}
</div>
<div class="flex flex-wrap items-center justify-center gap-2 mt-3">
<span class="px-3 py-1 text-sm font-medium status-badge {% if ride.status == 'OPERATING' %}status-operating
{% elif ride.status == 'CLOSED_TEMP' or ride.status == 'CLOSED_PERM' %}status-closed
{% elif ride.status == 'UNDER_CONSTRUCTION' %}status-construction
{% elif ride.status == 'DEMOLISHED' %}status-demolished
{% elif ride.status == 'RELOCATED' %}status-relocated{% endif %}">
{{ ride.get_status_display }}
</span>
{% include "rides/partials/ride_header_badge.html" with ride=ride %}
<span class="px-3 py-1 text-sm font-medium text-blue-800 bg-blue-100 status-badge dark:bg-blue-700 dark:text-blue-50">
{{ ride.get_category_display }}
</span>

View File

@@ -0,0 +1,229 @@
# FSM HTMX Browser Testing Checklist
This checklist provides manual testing steps to verify the FSM+HTMX integration
works correctly across different browsers and scenarios.
## Quick Start
Before testing, ensure:
1. Development server is running: `uv run manage.py runserver`
2. Test users exist: `uv run manage.py create_test_users`
3. Test data exists: `uv run manage.py loaddata test_data`
## Moderation Dashboard
### Edit Submissions
- [ ] **Approve submission** shows green toast and updates status badge to "Approved"
- [ ] **Reject submission** shows red toast and updates status badge to "Rejected"
- [ ] **Escalate submission** shows yellow toast and updates status badge to "Escalated"
- [ ] Confirmation dialog appears for approve/reject/escalate actions
- [ ] Loading spinner appears during transition (visible briefly)
- [ ] Row updates without full page reload
- [ ] Multiple transitions in sequence work correctly
- [ ] Transition buttons disappear after final state reached (Approved/Rejected)
- [ ] Status badge color matches state (green=approved, red=rejected, yellow=escalated)
- [ ] Toast notification auto-dismisses after ~3-5 seconds
### Photo Submissions
- [ ] **Approve photo** shows success toast and creates approved photo
- [ ] **Reject photo** shows warning toast and updates status
- [ ] Photo preview remains visible during transition
- [ ] Loading state shown while processing image approval
### Moderation Queue
- [ ] **Start** button transitions item to "In Progress"
- [ ] **Complete** button transitions item to "Completed"
- [ ] **Cancel** button transitions item to "Cancelled"
- [ ] Queue items update in real-time without refresh
- [ ] Assignment updates correctly when claiming item
### Bulk Operations
- [ ] **Cancel** pending operation shows confirmation and cancels
- [ ] Progress indicator updates during operation
- [ ] Cannot cancel completed operations (button hidden)
## Park Detail Page
### Status Transitions (as Moderator)
- [ ] **Close Temporarily** button visible for operating parks
- [ ] Status badge updates to "Temporarily Closed" (yellow)
- [ ] **Reopen** button appears after temporary closure
- [ ] Status badge updates back to "Operating" (green)
- [ ] **Close Permanently** transitions to "Permanently Closed" (red)
- [ ] **Mark as Demolished** available only from permanently closed
- [ ] **Mark as Relocated** available only from permanently closed
- [ ] Status badge updates to "Demolished" (gray) or "Relocated" (gray)
### Status Actions Section
- [ ] Available transitions update based on current state
- [ ] Buttons hidden for invalid transitions (can't reopen operating park)
- [ ] Confirmation dialogs appear for destructive transitions
- [ ] Toast notifications appear for all transitions
- [ ] Section refreshes via HTMX swap without full page reload
### Unauthorized Users
- [ ] Regular users cannot see transition buttons
- [ ] Status badge visible but not clickable for regular users
- [ ] Moderator buttons visible only to moderators
## Ride Detail Page
### Status Transitions (as Moderator)
- [ ] **Close Temporarily** button works correctly
- [ ] **Mark SBNO** transitions to "Standing But Not Operating"
- [ ] **Set Closing** transitions to "Closing" status
- [ ] Closing status with future date shows countdown (if implemented)
- [ ] **Reopen** button appears for closed/SBNO rides
- [ ] **Close Permanently** available for operating/closed_temp rides
- [ ] **Mark as Demolished** available only from permanently closed
- [ ] **Mark as Relocated** available only from permanently closed
### SBNO State
- [ ] SBNO status badge shows distinct styling (orange/amber)
- [ ] Reopen available from SBNO state
- [ ] Close Permanently available from SBNO state
## Error Handling
### Invalid Transitions
- [ ] Attempting invalid transition shows error toast (red)
- [ ] Error message is user-friendly (not technical exception)
- [ ] Database state unchanged after failed transition
- [ ] UI remains in consistent state after error
### Permission Denied
- [ ] Unauthorized transition attempt shows 403 error toast
- [ ] Error message says "Permission denied" or similar
- [ ] No partial state change occurs
### Network Errors
- [ ] Offline/network failure shows error toast
- [ ] "Connection failed" or similar message displayed
- [ ] Retry option available (if applicable)
- [ ] UI recovers gracefully from network errors
### Server Errors
- [ ] 500 errors show user-friendly message
- [ ] No raw exception/traceback shown to user
- [ ] Error toast has red/danger styling
## Loading States
- [ ] Loading spinner/indicator appears immediately on button click
- [ ] Button becomes disabled during transition
- [ ] Spinner disappears when response received
- [ ] Multiple rapid clicks don't cause duplicate requests
## Confirmation Dialogs
- [ ] Dialog appears for dangerous transitions (reject, cancel, demolish)
- [ ] Dialog shows appropriate warning message
- [ ] Clicking "Cancel" prevents transition
- [ ] Clicking "OK/Confirm" executes transition
- [ ] ESC key dismisses dialog without action
- [ ] Dialog backdrop click dismisses without action
## Cross-Browser Testing
Test all scenarios above in each browser:
### Desktop
- [ ] Chrome/Chromium (latest)
- [ ] Firefox (latest)
- [ ] Safari/WebKit (latest)
- [ ] Edge (latest)
### Mobile
- [ ] Mobile Safari (iOS 15+)
- [ ] Chrome Mobile (Android)
- [ ] Samsung Internet (Android)
### Notes
- Test with different viewport sizes (mobile, tablet, desktop)
- Verify touch interactions work on mobile
- Check that modals/dialogs are properly positioned on mobile
## Accessibility
### Keyboard Navigation
- [ ] All transition buttons focusable via Tab key
- [ ] Enter key activates focused button
- [ ] Escape key closes confirmation dialogs
- [ ] Focus indicator visible on all interactive elements
- [ ] Focus returns to appropriate element after transition
### Screen Reader
- [ ] Status changes announced to screen readers
- [ ] Toast notifications announced (aria-live region)
- [ ] Button labels are descriptive
- [ ] Loading states communicated to screen readers
### Visual
- [ ] Color contrast meets WCAG AA standards
- [ ] Status colors distinguishable without relying solely on color
- [ ] Focus indicators have sufficient contrast
- [ ] Text remains readable at 200% zoom
## Performance
- [ ] Transitions complete in under 500ms (typical)
- [ ] No visible flickering during HTMX swap
- [ ] Memory usage stable after multiple transitions
- [ ] No console errors during normal operation
## Test Data Reset
After testing, reset test data:
```bash
uv run manage.py flush_test_data
uv run manage.py loaddata test_data
```
## Reporting Issues
When reporting issues:
1. Note browser and version
2. Include console errors (if any)
3. Describe expected vs actual behavior
4. Include network request/response details
5. Screenshot or video if possible
## Automated Testing
Run automated E2E tests to complement manual testing:
```bash
# Run all FSM E2E tests
pytest backend/tests/e2e/test_moderation_fsm.py
pytest backend/tests/e2e/test_park_ride_fsm.py
pytest backend/tests/e2e/test_fsm_permissions.py
pytest backend/tests/e2e/test_fsm_error_handling.py
# Run with specific browser
pytest --browser firefox backend/tests/e2e/
# Run with headed mode (visible browser)
pytest --headed backend/tests/e2e/
# Run integration tests (faster, no browser)
pytest backend/tests/integration/test_fsm_transition_view.py
```

View File

@@ -34,10 +34,10 @@ def setup_page(page: Page):
@pytest.fixture
def auth_page(page: Page):
def auth_page(page: Page, live_server):
"""Fixture for authenticated page"""
# Login
page.goto("http://localhost:8000/accounts/login/")
# Login using live_server URL
page.goto(f"{live_server.url}/accounts/login/")
page.get_by_label("Username").fill("testuser")
page.get_by_label("Password").fill("testpass123")
page.get_by_role("button", name="Sign In").click()
@@ -46,10 +46,10 @@ def auth_page(page: Page):
@pytest.fixture
def mod_page(page: Page):
def mod_page(page: Page, live_server):
"""Fixture for moderator page"""
# Login as moderator
page.goto("http://localhost:8000/accounts/login/")
# Login as moderator using live_server URL
page.goto(f"{live_server.url}/accounts/login/")
page.get_by_label("Username").fill("moderator")
page.get_by_label("Password").fill("modpass123")
page.get_by_role("button", name="Sign In").click()
@@ -58,10 +58,10 @@ def mod_page(page: Page):
@pytest.fixture
def test_park(auth_page: Page):
def test_park(auth_page: Page, live_server):
"""Fixture for test park"""
# Create test park
auth_page.goto("http://localhost:8000/parks/create/")
# Create test park using live_server URL
auth_page.goto(f"{live_server.url}/parks/create/")
auth_page.get_by_label("Name").fill("Test Park")
auth_page.get_by_label("Location").fill("Orlando, FL")
auth_page.get_by_label("Description").fill("A test theme park")
@@ -72,10 +72,10 @@ def test_park(auth_page: Page):
@pytest.fixture
def test_ride(test_park: Page):
def test_ride(test_park: Page, live_server):
"""Fixture for test ride"""
# Create test ride
test_park.goto("http://localhost:8000/rides/create/")
# Create test ride using live_server URL
test_park.goto(f"{live_server.url}/rides/create/")
test_park.get_by_label("Name").fill("Test Ride")
test_park.get_by_label("Park").select_option("Test Park")
test_park.get_by_label("Type").select_option("Roller Coaster")
@@ -87,10 +87,10 @@ def test_ride(test_park: Page):
@pytest.fixture
def test_review(test_park: Page):
def test_review(test_park: Page, live_server):
"""Fixture for test review"""
# Create test review
test_park.goto("http://localhost:8000/parks/test-park/")
# Create test review using live_server URL
test_park.goto(f"{live_server.url}/parks/test-park/")
test_park.get_by_role("tab", name="Reviews").click()
test_park.get_by_role("button", name="Write Review").click()
test_park.get_by_label("Rating").select_option("5")
@@ -99,3 +99,310 @@ def test_review(test_park: Page):
test_park.get_by_role("button", name="Submit Review").click()
yield test_park
# =============================================================================
# FSM Testing Fixtures
# =============================================================================
@pytest.fixture
def admin_page(page: Page, live_server):
"""Fixture for admin/superuser page"""
# Login as admin using live_server URL
page.goto(f"{live_server.url}/accounts/login/")
page.get_by_label("Username").fill("admin")
page.get_by_label("Password").fill("adminpass123")
page.get_by_role("button", name="Sign In").click()
yield page
@pytest.fixture
def submission_pending(db):
"""Create a pending EditSubmission for FSM testing."""
from django.contrib.auth import get_user_model
from apps.moderation.models import EditSubmission
from apps.parks.models import Park
from django.contrib.contenttypes.models import ContentType
User = get_user_model()
# Get or create test user
user, _ = User.objects.get_or_create(
username="fsm_test_submitter",
defaults={"email": "fsm_test@example.com"}
)
user.set_password("testpass123")
user.save()
# Get a park
park = Park.objects.first()
if not park:
pytest.skip("No parks available for testing")
content_type = ContentType.objects.get_for_model(Park)
submission = EditSubmission.objects.create(
user=user,
content_type=content_type,
object_id=park.pk,
submission_type="EDIT",
changes={"description": "FSM test submission"},
reason="FSM e2e test",
status="PENDING"
)
yield submission
# Cleanup
try:
submission.delete()
except Exception:
pass
@pytest.fixture
def submission_approved(db):
"""Create an approved EditSubmission for FSM testing."""
from django.contrib.auth import get_user_model
from apps.moderation.models import EditSubmission
from apps.parks.models import Park
from django.contrib.contenttypes.models import ContentType
User = get_user_model()
user, _ = User.objects.get_or_create(
username="fsm_test_submitter_approved",
defaults={"email": "fsm_approved@example.com"}
)
park = Park.objects.first()
if not park:
pytest.skip("No parks available for testing")
content_type = ContentType.objects.get_for_model(Park)
submission = EditSubmission.objects.create(
user=user,
content_type=content_type,
object_id=park.pk,
submission_type="EDIT",
changes={"description": "Already approved"},
reason="FSM approved test",
status="APPROVED"
)
yield submission
try:
submission.delete()
except Exception:
pass
@pytest.fixture
def park_operating(db):
"""Create an operating Park for FSM testing."""
from tests.factories import ParkFactory
park = ParkFactory(
name="FSM Test Park Operating",
slug="fsm-test-park-operating",
status="OPERATING"
)
yield park
@pytest.fixture
def park_closed_temp(db):
"""Create a temporarily closed Park for FSM testing."""
from tests.factories import ParkFactory
park = ParkFactory(
name="FSM Test Park Closed Temp",
slug="fsm-test-park-closed-temp",
status="CLOSED_TEMP"
)
yield park
@pytest.fixture
def park_closed_perm(db):
"""Create a permanently closed Park for FSM testing."""
from tests.factories import ParkFactory
from datetime import date, timedelta
park = ParkFactory(
name="FSM Test Park Closed Perm",
slug="fsm-test-park-closed-perm",
status="CLOSED_PERM",
closing_date=date.today() - timedelta(days=365)
)
yield park
@pytest.fixture
def ride_operating(db, park_operating):
"""Create an operating Ride for FSM testing."""
from tests.factories import RideFactory
ride = RideFactory(
name="FSM Test Ride Operating",
slug="fsm-test-ride-operating",
park=park_operating,
status="OPERATING"
)
yield ride
@pytest.fixture
def ride_sbno(db, park_operating):
"""Create an SBNO Ride for FSM testing."""
from tests.factories import RideFactory
ride = RideFactory(
name="FSM Test Ride SBNO",
slug="fsm-test-ride-sbno",
park=park_operating,
status="SBNO"
)
yield ride
@pytest.fixture
def ride_closed_perm(db, park_operating):
"""Create a permanently closed Ride for FSM testing."""
from tests.factories import RideFactory
from datetime import date, timedelta
ride = RideFactory(
name="FSM Test Ride Closed Perm",
slug="fsm-test-ride-closed-perm",
park=park_operating,
status="CLOSED_PERM",
closing_date=date.today() - timedelta(days=365)
)
yield ride
@pytest.fixture
def queue_item_pending(db):
"""Create a pending ModerationQueue item for FSM testing."""
from django.contrib.auth import get_user_model
from apps.moderation.models import ModerationQueue
User = get_user_model()
user, _ = User.objects.get_or_create(
username="fsm_queue_flagger",
defaults={"email": "fsm_queue@example.com"}
)
queue_item = ModerationQueue.objects.create(
item_type="CONTENT_REVIEW",
status="PENDING",
priority="MEDIUM",
title="FSM Test Queue Item",
description="Queue item for FSM e2e testing",
flagged_by=user
)
yield queue_item
try:
queue_item.delete()
except Exception:
pass
@pytest.fixture
def bulk_operation_pending(db):
"""Create a pending BulkOperation for FSM testing."""
from django.contrib.auth import get_user_model
from apps.moderation.models import BulkOperation
User = get_user_model()
user, _ = User.objects.get_or_create(
username="fsm_bulk_creator",
defaults={"email": "fsm_bulk@example.com", "is_staff": True}
)
operation = BulkOperation.objects.create(
operation_type="IMPORT",
status="PENDING",
priority="MEDIUM",
description="FSM Test Bulk Operation",
parameters={"test": True},
created_by=user,
total_items=10
)
yield operation
try:
operation.delete()
except Exception:
pass
# =============================================================================
# Helper Fixtures
# =============================================================================
@pytest.fixture
def live_server(live_server_url):
"""Provide the live server URL for tests.
Note: This fixture is provided by pytest-django. The live_server_url
fixture provides the URL as a string.
"""
class LiveServer:
url = live_server_url
return LiveServer()
@pytest.fixture
def moderator_user(db):
"""Get or create a moderator user for testing."""
from django.contrib.auth import get_user_model
User = get_user_model()
user, _ = User.objects.get_or_create(
username="moderator",
defaults={
"email": "moderator@example.com",
"is_staff": True
}
)
user.set_password("modpass123")
user.save()
return user
@pytest.fixture
def regular_user(db):
"""Get or create a regular user for testing."""
from django.contrib.auth import get_user_model
User = get_user_model()
user, _ = User.objects.get_or_create(
username="testuser",
defaults={"email": "testuser@example.com"}
)
user.set_password("testpass123")
user.save()
return user

View File

@@ -0,0 +1,668 @@
"""
E2E Tests for FSM Error Handling and Loading States
Tests error scenarios and loading indicators during FSM transitions:
- Invalid transitions show error toast
- Loading indicator appears during transition
- Network error shows error toast
- Validation error shows user-friendly message
- Confirm dialog appears for dangerous transitions
- Cancel confirm dialog prevents transition
These tests verify:
- Error toast appears with appropriate message
- Loading spinner appears during requests
- Network errors are handled gracefully
- Confirmation dialogs work correctly
- User-friendly error messages are displayed
"""
import pytest
from playwright.sync_api import Page, expect
class TestInvalidTransitionErrors:
"""Tests for error handling when attempting invalid transitions."""
def test_invalid_transition_shows_error_toast(
self, mod_page: Page, live_server, db
):
"""Test that attempting an invalid transition shows an error toast."""
from apps.parks.models import Park
# Get an operating park
park = Park.objects.filter(status="OPERATING").first()
if not park:
pytest.skip("No operating park available")
mod_page.goto(f"{live_server.url}/parks/{park.slug}/")
mod_page.wait_for_load_state("networkidle")
# Attempt an invalid transition via direct API call
# For example, trying to reopen an already operating park
response = mod_page.evaluate(f"""
async () => {{
const response = await fetch('/core/fsm/parks/park/{park.pk}/transition/transition_to_operating/', {{
method: 'POST',
headers: {{
'HX-Request': 'true',
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]')?.value || ''
}},
credentials: 'include'
}});
return {{
status: response.status,
hxTrigger: response.headers.get('HX-Trigger')
}};
}}
""")
# Should return error status (400)
if response:
assert response.get('status') in [400, 403]
# Check for error toast in HX-Trigger header
hx_trigger = response.get('hxTrigger')
if hx_trigger:
assert 'showToast' in hx_trigger
assert 'error' in hx_trigger.lower()
def test_already_transitioned_shows_error(
self, mod_page: Page, live_server, db
):
"""Test that trying to approve an already-approved submission shows error."""
from django.contrib.auth import get_user_model
from apps.moderation.models import EditSubmission
from apps.parks.models import Park
from django.contrib.contenttypes.models import ContentType
User = get_user_model()
# Create an already-approved submission
user, _ = User.objects.get_or_create(
username="testsubmitter2",
defaults={"email": "testsubmitter2@example.com"}
)
park = Park.objects.first()
if not park:
pytest.skip("No park available")
content_type = ContentType.objects.get_for_model(Park)
submission = EditSubmission.objects.create(
user=user,
content_type=content_type,
object_id=park.pk,
submission_type="EDIT",
changes={"description": "Already approved"},
reason="Already approved test",
status="APPROVED" # Already approved
)
try:
mod_page.goto(f"{live_server.url}/moderation/dashboard/")
mod_page.wait_for_load_state("networkidle")
# Try to approve again via direct API call
response = mod_page.evaluate(f"""
async () => {{
const response = await fetch('/core/fsm/moderation/editsubmission/{submission.pk}/transition/transition_to_approved/', {{
method: 'POST',
headers: {{
'HX-Request': 'true',
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]')?.value || ''
}},
credentials: 'include'
}});
return {{
status: response.status,
hxTrigger: response.headers.get('HX-Trigger')
}};
}}
""")
# Should return error status
if response:
assert response.get('status') in [400, 403]
finally:
submission.delete()
def test_nonexistent_transition_shows_error(
self, mod_page: Page, live_server, db
):
"""Test that requesting a non-existent transition shows error."""
from apps.parks.models import Park
park = Park.objects.first()
if not park:
pytest.skip("No park available")
mod_page.goto(f"{live_server.url}/parks/{park.slug}/")
mod_page.wait_for_load_state("networkidle")
# Try to call a non-existent transition
response = mod_page.evaluate(f"""
async () => {{
const response = await fetch('/core/fsm/parks/park/{park.pk}/transition/nonexistent_transition/', {{
method: 'POST',
headers: {{
'HX-Request': 'true',
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]')?.value || ''
}},
credentials: 'include'
}});
return {{
status: response.status,
hxTrigger: response.headers.get('HX-Trigger')
}};
}}
""")
# Should return error status (400 or 404)
if response:
assert response.get('status') in [400, 404]
class TestLoadingIndicators:
"""Tests for loading indicator visibility during transitions."""
def test_loading_indicator_appears_during_transition(
self, mod_page: Page, live_server, db
):
"""Verify loading spinner appears during HTMX transition."""
from apps.parks.models import Park
park = Park.objects.filter(status="OPERATING").first()
if not park:
pytest.skip("No operating park available")
mod_page.goto(f"{live_server.url}/parks/{park.slug}/")
mod_page.wait_for_load_state("networkidle")
status_actions = mod_page.locator('[data-park-status-actions]')
close_temp_btn = status_actions.get_by_role(
"button", name="Close Temporarily"
)
if not close_temp_btn.is_visible():
pytest.skip("Close Temporarily button not visible")
# Add a route to slow down the request so we can see loading state
mod_page.route("**/core/fsm/**", lambda route: (
mod_page.wait_for_timeout(500),
route.continue_()
))
# Handle confirmation dialog
mod_page.on("dialog", lambda dialog: dialog.accept())
# Click and immediately check for loading indicator
close_temp_btn.click()
# Check for htmx-indicator class or spinner
loading_indicator = mod_page.locator('.htmx-indicator, .htmx-request .spinner, [class*="loading"]')
# The loading indicator should appear (may be brief)
# We wait a short time for it to appear
try:
expect(loading_indicator.first).to_be_visible(timeout=1000)
except Exception:
# Loading indicator may have already disappeared if response was fast
pass
# Wait for transition to complete
toast = mod_page.locator('[data-toast]')
expect(toast).to_be_visible(timeout=5000)
def test_button_disabled_during_transition(
self, mod_page: Page, live_server, db
):
"""Test that transition button is disabled during request."""
from apps.parks.models import Park
park = Park.objects.filter(status="OPERATING").first()
if not park:
pytest.skip("No operating park available")
mod_page.goto(f"{live_server.url}/parks/{park.slug}/")
mod_page.wait_for_load_state("networkidle")
status_actions = mod_page.locator('[data-park-status-actions]')
close_temp_btn = status_actions.get_by_role(
"button", name="Close Temporarily"
)
if not close_temp_btn.is_visible():
pytest.skip("Close Temporarily button not visible")
# Add a route to slow down the request
mod_page.route("**/core/fsm/**", lambda route: (
mod_page.wait_for_timeout(1000),
route.continue_()
))
mod_page.on("dialog", lambda dialog: dialog.accept())
# Click button
close_temp_btn.click()
# Check if button becomes disabled or has htmx-request class
expect(close_temp_btn).to_have_attribute("disabled", "", timeout=500)
class TestNetworkErrorHandling:
"""Tests for handling network errors during transitions."""
def test_network_error_shows_error_toast(
self, mod_page: Page, live_server, db
):
"""Test that network errors show appropriate error toast."""
from apps.parks.models import Park
park = Park.objects.filter(status="OPERATING").first()
if not park:
pytest.skip("No operating park available")
mod_page.goto(f"{live_server.url}/parks/{park.slug}/")
mod_page.wait_for_load_state("networkidle")
# Abort network requests to simulate network error
mod_page.route("**/core/fsm/**", lambda route: route.abort("failed"))
status_actions = mod_page.locator('[data-park-status-actions]')
close_temp_btn = status_actions.get_by_role(
"button", name="Close Temporarily"
)
if not close_temp_btn.is_visible():
pytest.skip("Close Temporarily button not visible")
mod_page.on("dialog", lambda dialog: dialog.accept())
# Click button - should trigger network error
close_temp_btn.click()
# HTMX should show error indication
# Check for error toast or htmx error event
error_indicator = mod_page.locator('[data-toast].error, .htmx-error, [class*="error"]')
# May show as toast or inline error
try:
expect(error_indicator.first).to_be_visible(timeout=5000)
except Exception:
# Error may be handled differently
pass
# Verify database state was NOT changed
park.refresh_from_db()
assert park.status == "OPERATING"
def test_server_error_shows_user_friendly_message(
self, mod_page: Page, live_server, db
):
"""Test that server errors show user-friendly messages."""
from apps.parks.models import Park
park = Park.objects.filter(status="OPERATING").first()
if not park:
pytest.skip("No operating park available")
mod_page.goto(f"{live_server.url}/parks/{park.slug}/")
mod_page.wait_for_load_state("networkidle")
# Return 500 error to simulate server error
mod_page.route("**/core/fsm/**", lambda route: route.fulfill(
status=500,
headers={"HX-Trigger": '{"showToast": {"message": "An unexpected error occurred", "type": "error"}}'},
body=""
))
status_actions = mod_page.locator('[data-park-status-actions]')
close_temp_btn = status_actions.get_by_role(
"button", name="Close Temporarily"
)
if not close_temp_btn.is_visible():
pytest.skip("Close Temporarily button not visible")
mod_page.on("dialog", lambda dialog: dialog.accept())
close_temp_btn.click()
# Should show user-friendly error message
toast = mod_page.locator('[data-toast]')
expect(toast).to_be_visible(timeout=5000)
# Should not show technical error details to user
expect(toast).not_to_contain_text("Traceback")
expect(toast).not_to_contain_text("Exception")
class TestConfirmationDialogs:
"""Tests for confirmation dialogs on dangerous transitions."""
def test_confirm_dialog_appears_for_reject_transition(
self, mod_page: Page, live_server, db
):
"""Test that confirmation dialog appears for reject transition."""
from django.contrib.auth import get_user_model
from apps.moderation.models import EditSubmission
from apps.parks.models import Park
from django.contrib.contenttypes.models import ContentType
User = get_user_model()
user, _ = User.objects.get_or_create(
username="testsubmitter3",
defaults={"email": "testsubmitter3@example.com"}
)
park = Park.objects.first()
if not park:
pytest.skip("No park available")
content_type = ContentType.objects.get_for_model(Park)
submission = EditSubmission.objects.create(
user=user,
content_type=content_type,
object_id=park.pk,
submission_type="EDIT",
changes={"description": "Confirm dialog test"},
reason="Confirm dialog test",
status="PENDING"
)
dialog_shown = {"shown": False}
def handle_dialog(dialog):
dialog_shown["shown"] = True
assert "reject" in dialog.message.lower() or "sure" in dialog.message.lower()
dialog.dismiss() # Cancel the action
try:
mod_page.goto(f"{live_server.url}/moderation/dashboard/")
mod_page.wait_for_load_state("networkidle")
mod_page.on("dialog", handle_dialog)
submission_row = mod_page.locator(
f'[data-submission-id="{submission.pk}"]'
)
if submission_row.is_visible():
reject_btn = submission_row.get_by_role("button", name="Reject")
if reject_btn.is_visible():
reject_btn.click()
# Give time for dialog to appear
mod_page.wait_for_timeout(500)
# Verify dialog was shown
assert dialog_shown["shown"], "Confirmation dialog should have been shown"
finally:
submission.delete()
def test_cancel_confirm_dialog_prevents_transition(
self, mod_page: Page, live_server, db
):
"""Test that canceling the confirmation dialog prevents the transition."""
from django.contrib.auth import get_user_model
from apps.moderation.models import EditSubmission
from apps.parks.models import Park
from django.contrib.contenttypes.models import ContentType
User = get_user_model()
user, _ = User.objects.get_or_create(
username="testsubmitter4",
defaults={"email": "testsubmitter4@example.com"}
)
park = Park.objects.first()
if not park:
pytest.skip("No park available")
content_type = ContentType.objects.get_for_model(Park)
submission = EditSubmission.objects.create(
user=user,
content_type=content_type,
object_id=park.pk,
submission_type="EDIT",
changes={"description": "Cancel confirm test"},
reason="Cancel confirm test",
status="PENDING"
)
try:
mod_page.goto(f"{live_server.url}/moderation/dashboard/")
mod_page.wait_for_load_state("networkidle")
# Dismiss (cancel) the dialog
mod_page.on("dialog", lambda dialog: dialog.dismiss())
submission_row = mod_page.locator(
f'[data-submission-id="{submission.pk}"]'
)
if submission_row.is_visible():
reject_btn = submission_row.get_by_role("button", name="Reject")
if reject_btn.is_visible():
reject_btn.click()
# Wait a moment
mod_page.wait_for_timeout(500)
# Verify submission status was NOT changed
submission.refresh_from_db()
assert submission.status == "PENDING"
finally:
submission.delete()
def test_accept_confirm_dialog_executes_transition(
self, mod_page: Page, live_server, db
):
"""Test that accepting the confirmation dialog executes the transition."""
from django.contrib.auth import get_user_model
from apps.moderation.models import EditSubmission
from apps.parks.models import Park
from django.contrib.contenttypes.models import ContentType
User = get_user_model()
user, _ = User.objects.get_or_create(
username="testsubmitter5",
defaults={"email": "testsubmitter5@example.com"}
)
park = Park.objects.first()
if not park:
pytest.skip("No park available")
content_type = ContentType.objects.get_for_model(Park)
submission = EditSubmission.objects.create(
user=user,
content_type=content_type,
object_id=park.pk,
submission_type="EDIT",
changes={"description": "Accept confirm test"},
reason="Accept confirm test",
status="PENDING"
)
try:
mod_page.goto(f"{live_server.url}/moderation/dashboard/")
mod_page.wait_for_load_state("networkidle")
# Accept the dialog
mod_page.on("dialog", lambda dialog: dialog.accept())
submission_row = mod_page.locator(
f'[data-submission-id="{submission.pk}"]'
)
if submission_row.is_visible():
reject_btn = submission_row.get_by_role("button", name="Reject")
if reject_btn.is_visible():
reject_btn.click()
# Wait for transition to complete
toast = mod_page.locator('[data-toast]')
expect(toast).to_be_visible(timeout=5000)
# Verify submission status WAS changed
submission.refresh_from_db()
assert submission.status == "REJECTED"
finally:
submission.delete()
class TestValidationErrors:
"""Tests for validation error handling."""
def test_validation_error_shows_specific_message(
self, mod_page: Page, live_server, db
):
"""Test that validation errors show specific error messages."""
# This test depends on having transitions that require additional data
# For example, a transition that requires a reason field
from apps.parks.models import Park
park = Park.objects.filter(status="OPERATING").first()
if not park:
pytest.skip("No operating park available")
mod_page.goto(f"{live_server.url}/parks/{park.slug}/")
mod_page.wait_for_load_state("networkidle")
# Some transitions might require additional data (like closing_date for close_permanently)
# If validation fails, it should show a specific message
# This is a placeholder test - actual behavior depends on FSM configuration
# The test verifies that validation errors are handled gracefully
pass
class TestToastNotificationBehavior:
"""Tests for toast notification appearance and behavior."""
def test_success_toast_auto_dismisses(
self, mod_page: Page, live_server, db
):
"""Test that success toast auto-dismisses after timeout."""
from apps.parks.models import Park
park = Park.objects.filter(status="OPERATING").first()
if not park:
pytest.skip("No operating park available")
mod_page.goto(f"{live_server.url}/parks/{park.slug}/")
mod_page.wait_for_load_state("networkidle")
status_actions = mod_page.locator('[data-park-status-actions]')
close_temp_btn = status_actions.get_by_role(
"button", name="Close Temporarily"
)
if not close_temp_btn.is_visible():
pytest.skip("Close Temporarily button not visible")
mod_page.on("dialog", lambda dialog: dialog.accept())
close_temp_btn.click()
# Toast should appear
toast = mod_page.locator('[data-toast]')
expect(toast).to_be_visible(timeout=5000)
# Toast should auto-dismiss after timeout (typically 3-5 seconds)
# Wait for auto-dismiss
expect(toast).not_to_be_visible(timeout=10000)
def test_error_toast_has_correct_styling(
self, mod_page: Page, live_server, db
):
"""Test that error toast has correct red/danger styling."""
from apps.parks.models import Park
park = Park.objects.filter(status="OPERATING").first()
if not park:
pytest.skip("No operating park available")
mod_page.goto(f"{live_server.url}/parks/{park.slug}/")
mod_page.wait_for_load_state("networkidle")
# Simulate an error response
mod_page.route("**/core/fsm/**", lambda route: route.fulfill(
status=400,
headers={
"HX-Trigger": '{"showToast": {"message": "Test error message", "type": "error"}}'
},
body=""
))
status_actions = mod_page.locator('[data-park-status-actions]')
close_temp_btn = status_actions.get_by_role(
"button", name="Close Temporarily"
)
if not close_temp_btn.is_visible():
pytest.skip("Close Temporarily button not visible")
mod_page.on("dialog", lambda dialog: dialog.accept())
close_temp_btn.click()
# Error toast should appear with error styling
toast = mod_page.locator('[data-toast]')
expect(toast).to_be_visible(timeout=5000)
# Should have error/danger styling (red)
expect(toast).to_have_class(/error|danger|bg-red|text-red/)
def test_success_toast_has_correct_styling(
self, mod_page: Page, live_server, db
):
"""Test that success toast has correct green/success styling."""
from apps.parks.models import Park
park = Park.objects.filter(status="OPERATING").first()
if not park:
pytest.skip("No operating park available")
# Reset any previous state
park.status = "OPERATING"
park.save()
mod_page.goto(f"{live_server.url}/parks/{park.slug}/")
mod_page.wait_for_load_state("networkidle")
status_actions = mod_page.locator('[data-park-status-actions]')
close_temp_btn = status_actions.get_by_role(
"button", name="Close Temporarily"
)
if not close_temp_btn.is_visible():
pytest.skip("Close Temporarily button not visible")
mod_page.on("dialog", lambda dialog: dialog.accept())
close_temp_btn.click()
# Success toast should appear with success styling
toast = mod_page.locator('[data-toast]')
expect(toast).to_be_visible(timeout=5000)
# Should have success styling (green)
expect(toast).to_have_class(/success|bg-green|text-green/)

View File

@@ -0,0 +1,505 @@
"""
E2E Tests for FSM Permission Guards
Tests that unauthorized users cannot execute FSM transitions:
- Unauthenticated users cannot see transition buttons
- Regular users cannot approve submissions
- Regular users cannot change park/ride status
- Moderators can approve but not admin-only transitions
- Transition buttons hidden when not allowed
These tests verify:
- Transition buttons are NOT visible for unauthorized users
- Direct POST requests return 403 Forbidden
- Database state does NOT change after failed transition attempt
- Error toast displays "Permission denied" message
"""
import pytest
from playwright.sync_api import Page, expect
class TestUnauthenticatedUserPermissions:
"""Tests for unauthenticated user permission guards."""
def test_unauthenticated_user_cannot_see_moderation_dashboard(
self, page: Page, live_server
):
"""Test that unauthenticated users are redirected from moderation dashboard."""
# Navigate to moderation dashboard without logging in
response = page.goto(f"{live_server.url}/moderation/dashboard/")
# Should be redirected to login page or see access denied
# Check URL contains login or access denied
current_url = page.url
assert "login" in current_url or "denied" in current_url or response.status == 403
def test_unauthenticated_user_cannot_see_transition_buttons(
self, page: Page, live_server, db
):
"""Test that unauthenticated users cannot see transition buttons on park detail."""
from apps.parks.models import Park
park = Park.objects.filter(status="OPERATING").first()
if not park:
pytest.skip("No operating park available")
page.goto(f"{live_server.url}/parks/{park.slug}/")
page.wait_for_load_state("networkidle")
# Status action buttons should NOT be visible
status_actions = page.locator('[data-park-status-actions]')
# Either the section doesn't exist or the buttons are not there
if status_actions.is_visible():
close_temp_btn = status_actions.get_by_role(
"button", name="Close Temporarily"
)
expect(close_temp_btn).not_to_be_visible()
def test_unauthenticated_direct_post_returns_403(
self, page: Page, live_server, db
):
"""Test that direct POST to FSM endpoint returns 403 for unauthenticated user."""
from apps.parks.models import Park
park = Park.objects.filter(status="OPERATING").first()
if not park:
pytest.skip("No operating park available")
# Attempt to POST directly to FSM transition endpoint
response = page.request.post(
f"{live_server.url}/core/fsm/parks/park/{park.pk}/transition/transition_to_closed_temp/",
headers={"HX-Request": "true"}
)
# Should get 403 Forbidden
assert response.status == 403 or response.status == 302 # 302 redirect to login
# Verify database state did NOT change
park.refresh_from_db()
assert park.status == "OPERATING"
class TestRegularUserPermissions:
"""Tests for regular (non-moderator) user permission guards."""
def test_regular_user_cannot_approve_submission(
self, auth_page: Page, live_server, db
):
"""Test that regular users cannot approve submissions."""
from django.contrib.auth import get_user_model
from apps.moderation.models import EditSubmission
from apps.parks.models import Park
from django.contrib.contenttypes.models import ContentType
User = get_user_model()
# Create a pending submission
user = User.objects.filter(username="testuser").first()
if not user:
pytest.skip("Test user not found")
park = Park.objects.first()
if not park:
pytest.skip("No park available")
content_type = ContentType.objects.get_for_model(Park)
submission = EditSubmission.objects.create(
user=user,
content_type=content_type,
object_id=park.pk,
submission_type="EDIT",
changes={"description": "Test change"},
reason="Permission test",
status="PENDING"
)
try:
# Navigate to moderation dashboard as regular user
auth_page.goto(f"{live_server.url}/moderation/dashboard/")
# Regular user should be redirected or denied
current_url = auth_page.url
# If somehow on dashboard, verify no approve button
if "dashboard" in current_url:
submission_row = auth_page.locator(
f'[data-submission-id="{submission.pk}"]'
)
if submission_row.is_visible():
approve_btn = submission_row.get_by_role("button", name="Approve")
expect(approve_btn).not_to_be_visible()
# Try direct POST - should be denied
response = auth_page.request.post(
f"{live_server.url}/core/fsm/moderation/editsubmission/{submission.pk}/transition/transition_to_approved/",
headers={"HX-Request": "true"}
)
# Should be denied (403 or 302 redirect)
assert response.status in [302, 403]
# Verify database state did NOT change
submission.refresh_from_db()
assert submission.status == "PENDING"
finally:
submission.delete()
def test_regular_user_cannot_change_park_status(
self, auth_page: Page, live_server, db
):
"""Test that regular users cannot change park status."""
from apps.parks.models import Park
park = Park.objects.filter(status="OPERATING").first()
if not park:
pytest.skip("No operating park available")
auth_page.goto(f"{live_server.url}/parks/{park.slug}/")
auth_page.wait_for_load_state("networkidle")
# Status action buttons should NOT be visible to regular user
status_actions = auth_page.locator('[data-park-status-actions]')
if status_actions.is_visible():
close_temp_btn = status_actions.get_by_role(
"button", name="Close Temporarily"
)
expect(close_temp_btn).not_to_be_visible()
# Try direct POST - should be denied
response = auth_page.request.post(
f"{live_server.url}/core/fsm/parks/park/{park.pk}/transition/transition_to_closed_temp/",
headers={"HX-Request": "true"}
)
# Should be denied
assert response.status in [302, 400, 403]
# Verify database state did NOT change
park.refresh_from_db()
assert park.status == "OPERATING"
def test_regular_user_cannot_change_ride_status(
self, auth_page: Page, live_server, db
):
"""Test that regular users cannot change ride status."""
from apps.rides.models import Ride
ride = Ride.objects.filter(status="OPERATING").first()
if not ride:
pytest.skip("No operating ride available")
auth_page.goto(
f"{live_server.url}/parks/{ride.park.slug}/rides/{ride.slug}/"
)
auth_page.wait_for_load_state("networkidle")
# Status action buttons should NOT be visible to regular user
status_actions = auth_page.locator('[data-ride-status-actions]')
if status_actions.is_visible():
close_temp_btn = status_actions.get_by_role(
"button", name="Close Temporarily"
)
expect(close_temp_btn).not_to_be_visible()
# Try direct POST - should be denied
response = auth_page.request.post(
f"{live_server.url}/core/fsm/rides/ride/{ride.pk}/transition/transition_to_closed_temp/",
headers={"HX-Request": "true"}
)
# Should be denied
assert response.status in [302, 400, 403]
# Verify database state did NOT change
ride.refresh_from_db()
assert ride.status == "OPERATING"
class TestModeratorPermissions:
"""Tests for moderator-specific permission guards."""
def test_moderator_can_approve_submission(
self, mod_page: Page, live_server, db
):
"""Test that moderators CAN see and use approve button."""
from django.contrib.auth import get_user_model
from apps.moderation.models import EditSubmission
from apps.parks.models import Park
from django.contrib.contenttypes.models import ContentType
User = get_user_model()
# Create a pending submission
user = User.objects.filter(username="testuser").first()
if not user:
user = User.objects.create_user(
username="testuser",
email="testuser@example.com",
password="testpass123"
)
park = Park.objects.first()
if not park:
pytest.skip("No park available")
content_type = ContentType.objects.get_for_model(Park)
submission = EditSubmission.objects.create(
user=user,
content_type=content_type,
object_id=park.pk,
submission_type="EDIT",
changes={"description": "Test change for moderator"},
reason="Moderator permission test",
status="PENDING"
)
try:
mod_page.goto(f"{live_server.url}/moderation/dashboard/")
mod_page.wait_for_load_state("networkidle")
# Moderator should be able to see the submission
submission_row = mod_page.locator(
f'[data-submission-id="{submission.pk}"]'
)
if submission_row.is_visible():
# Should see approve button
approve_btn = submission_row.get_by_role("button", name="Approve")
expect(approve_btn).to_be_visible()
finally:
submission.delete()
def test_moderator_can_change_park_status(
self, mod_page: Page, live_server, db
):
"""Test that moderators CAN see and use park status change buttons."""
from apps.parks.models import Park
park = Park.objects.filter(status="OPERATING").first()
if not park:
pytest.skip("No operating park available")
mod_page.goto(f"{live_server.url}/parks/{park.slug}/")
mod_page.wait_for_load_state("networkidle")
# Status action buttons SHOULD be visible to moderator
status_actions = mod_page.locator('[data-park-status-actions]')
if status_actions.is_visible():
# Should see close temporarily button
close_temp_btn = status_actions.get_by_role(
"button", name="Close Temporarily"
)
expect(close_temp_btn).to_be_visible()
def test_moderator_cannot_access_admin_only_transitions(
self, mod_page: Page, live_server, db
):
"""Test that moderators CANNOT access admin-only transitions."""
# This test verifies that certain transitions require admin privileges
# Specific transitions depend on the FSM configuration
from apps.parks.models import Park
# Get a permanently closed park for testing admin-only demolish
park = Park.objects.filter(status="CLOSED_PERM").first()
if not park:
# Create one
park = Park.objects.filter(status="OPERATING").first()
if park:
park.status = "CLOSED_PERM"
park.save()
else:
pytest.skip("No park available for testing")
mod_page.goto(f"{live_server.url}/parks/{park.slug}/")
mod_page.wait_for_load_state("networkidle")
# Check for admin-only buttons (if any are configured)
# The specific buttons that should be hidden depend on the FSM configuration
status_actions = mod_page.locator('[data-park-status-actions]')
# If there are admin-only transitions, verify they're hidden
# This is a placeholder - actual admin-only transitions depend on configuration
admin_only_btn = status_actions.get_by_role(
"button", name="Force Delete" # Example admin-only action
)
expect(admin_only_btn).not_to_be_visible()
class TestPermissionDeniedErrorHandling:
"""Tests for error handling when permission is denied."""
def test_permission_denied_shows_error_toast(
self, auth_page: Page, live_server, db
):
"""Test that permission denied errors show appropriate toast."""
from apps.parks.models import Park
park = Park.objects.filter(status="OPERATING").first()
if not park:
pytest.skip("No operating park available")
# Navigate to the page first
auth_page.goto(f"{live_server.url}/parks/{park.slug}/")
auth_page.wait_for_load_state("networkidle")
# Make the request programmatically with HTMX header
response = auth_page.evaluate("""
async () => {
const response = await fetch('/core/fsm/parks/park/""" + str(park.pk) + """/transition/transition_to_closed_temp/', {
method: 'POST',
headers: {
'HX-Request': 'true',
'Content-Type': 'application/x-www-form-urlencoded'
},
credentials: 'include'
});
return {
status: response.status,
hxTrigger: response.headers.get('HX-Trigger')
};
}
""")
# Check if error toast was triggered
if response and response.get('status') in [400, 403]:
hx_trigger = response.get('hxTrigger')
if hx_trigger:
assert 'showToast' in hx_trigger
assert 'error' in hx_trigger.lower() or 'denied' in hx_trigger.lower()
def test_database_state_unchanged_on_permission_denied(
self, auth_page: Page, live_server, db
):
"""Test that database state is unchanged when permission is denied."""
from apps.parks.models import Park
park = Park.objects.filter(status="OPERATING").first()
if not park:
pytest.skip("No operating park available")
original_status = park.status
# Attempt unauthorized transition via direct fetch
auth_page.goto(f"{live_server.url}/parks/{park.slug}/")
auth_page.wait_for_load_state("networkidle")
auth_page.evaluate("""
async () => {
await fetch('/core/fsm/parks/park/""" + str(park.pk) + """/transition/transition_to_closed_temp/', {
method: 'POST',
headers: {
'HX-Request': 'true',
'Content-Type': 'application/x-www-form-urlencoded'
},
credentials: 'include'
});
}
""")
# Verify database state did NOT change
park.refresh_from_db()
assert park.status == original_status
class TestTransitionButtonVisibility:
"""Tests for correct transition button visibility based on permissions and state."""
def test_transition_button_hidden_when_state_invalid(
self, mod_page: Page, live_server, db
):
"""Test that transition buttons are hidden when the current state is invalid."""
from apps.parks.models import Park
# Get an operating park
park = Park.objects.filter(status="OPERATING").first()
if not park:
pytest.skip("No operating park available")
mod_page.goto(f"{live_server.url}/parks/{park.slug}/")
mod_page.wait_for_load_state("networkidle")
status_actions = mod_page.locator('[data-park-status-actions]')
# Reopen button should NOT be visible for operating park
# (can't reopen something that's already operating)
reopen_btn = status_actions.get_by_role("button", name="Reopen")
expect(reopen_btn).not_to_be_visible()
# Demolish should NOT be visible for operating park
# (can only demolish from CLOSED_PERM)
demolish_btn = status_actions.get_by_role("button", name="Mark as Demolished")
expect(demolish_btn).not_to_be_visible()
def test_correct_buttons_shown_for_closed_temp_state(
self, mod_page: Page, live_server, db
):
"""Test that correct buttons are shown for temporarily closed state."""
from apps.parks.models import Park
park = Park.objects.filter(status="CLOSED_TEMP").first()
if not park:
# Create one
park = Park.objects.filter(status="OPERATING").first()
if park:
park.status = "CLOSED_TEMP"
park.save()
else:
pytest.skip("No park available for testing")
mod_page.goto(f"{live_server.url}/parks/{park.slug}/")
mod_page.wait_for_load_state("networkidle")
status_actions = mod_page.locator('[data-park-status-actions]')
# Reopen button SHOULD be visible
reopen_btn = status_actions.get_by_role("button", name="Reopen")
expect(reopen_btn).to_be_visible()
# Close Temporarily should NOT be visible (already closed)
close_temp_btn = status_actions.get_by_role(
"button", name="Close Temporarily"
)
expect(close_temp_btn).not_to_be_visible()
def test_correct_buttons_shown_for_closed_perm_state(
self, mod_page: Page, live_server, db
):
"""Test that correct buttons are shown for permanently closed state."""
from apps.parks.models import Park
park = Park.objects.filter(status="CLOSED_PERM").first()
if not park:
# Create one
park = Park.objects.filter(status="OPERATING").first()
if park:
park.status = "CLOSED_PERM"
park.save()
else:
pytest.skip("No park available for testing")
mod_page.goto(f"{live_server.url}/parks/{park.slug}/")
mod_page.wait_for_load_state("networkidle")
status_actions = mod_page.locator('[data-park-status-actions]')
# Demolish/Relocate buttons SHOULD be visible
demolish_btn = status_actions.get_by_role("button", name="Mark as Demolished")
relocate_btn = status_actions.get_by_role("button", name="Mark as Relocated")
# At least one of these should be visible for CLOSED_PERM
visible = demolish_btn.is_visible() or relocate_btn.is_visible()
assert visible, "Expected demolish or relocate button for CLOSED_PERM state"
# Reopen should still be visible to restore to operating
reopen_btn = status_actions.get_by_role("button", name="Reopen")
# May or may not be visible depending on FSM configuration

View File

@@ -0,0 +1,477 @@
"""
E2E Tests for Moderation FSM Transitions
Tests the complete HTMX FSM transition flow for moderation-related models:
- EditSubmission: approve, reject, escalate
- PhotoSubmission: approve, reject
- ModerationQueue: complete, cancel
- BulkOperation: cancel
These tests verify:
- Status badge updates correctly after transition
- Toast notifications appear with correct message/type
- HTMX swap occurs without full page reload
- StateLog entry created in database
"""
import pytest
from playwright.sync_api import Page, expect
from django.contrib.contenttypes.models import ContentType
@pytest.fixture
def pending_submission(db):
"""Create a pending EditSubmission for testing."""
from django.contrib.auth import get_user_model
from apps.moderation.models import EditSubmission
from apps.parks.models import Park
User = get_user_model()
# Get or create test user
user, _ = User.objects.get_or_create(
username="testsubmitter",
defaults={"email": "testsubmitter@example.com"}
)
user.set_password("testpass123")
user.save()
# Get or create a park for the submission
park = Park.objects.first()
if not park:
pytest.skip("No parks available for testing")
content_type = ContentType.objects.get_for_model(Park)
submission = EditSubmission.objects.create(
user=user,
content_type=content_type,
object_id=park.pk,
submission_type="EDIT",
changes={"description": "Updated park description for testing"},
reason="E2E test submission",
status="PENDING"
)
yield submission
# Cleanup
submission.delete()
@pytest.fixture
def pending_photo_submission(db):
"""Create a pending PhotoSubmission for testing."""
from django.contrib.auth import get_user_model
from apps.moderation.models import PhotoSubmission
from apps.parks.models import Park
from django.contrib.contenttypes.models import ContentType
User = get_user_model()
# Get or create test user
user, _ = User.objects.get_or_create(
username="testphotosubmitter",
defaults={"email": "testphotosubmitter@example.com"}
)
user.set_password("testpass123")
user.save()
# Get or create a park
park = Park.objects.first()
if not park:
pytest.skip("No parks available for testing")
content_type = ContentType.objects.get_for_model(Park)
# Check if CloudflareImage model exists and has entries
try:
from django_cloudflareimages_toolkit.models import CloudflareImage
photo = CloudflareImage.objects.first()
if not photo:
pytest.skip("No CloudflareImage available for testing")
except ImportError:
pytest.skip("CloudflareImage not available")
submission = PhotoSubmission.objects.create(
user=user,
content_type=content_type,
object_id=park.pk,
photo=photo,
caption="E2E test photo",
status="PENDING"
)
yield submission
# Cleanup
submission.delete()
class TestEditSubmissionTransitions:
"""Tests for EditSubmission FSM transitions via HTMX."""
def test_submission_approve_transition_as_moderator(
self, mod_page: Page, pending_submission, live_server
):
"""Test approving an EditSubmission as a moderator."""
# Navigate to moderation dashboard
mod_page.goto(f"{live_server.url}/moderation/dashboard/")
# Wait for the page to load
mod_page.wait_for_load_state("networkidle")
# Find the submission row
submission_row = mod_page.locator(f'[data-submission-id="{pending_submission.pk}"]')
# Verify initial status is pending
status_badge = submission_row.locator('[data-status-badge]')
expect(status_badge).to_contain_text("Pending")
# Click the approve button
approve_btn = submission_row.get_by_role("button", name="Approve")
# Handle confirmation dialog if present
mod_page.on("dialog", lambda dialog: dialog.accept())
# Click and wait for HTMX swap
approve_btn.click()
# Wait for toast notification
toast = mod_page.locator('[data-toast]')
expect(toast).to_be_visible(timeout=5000)
expect(toast).to_contain_text("approved")
# Verify status badge updated
expect(status_badge).to_contain_text("Approved")
# Verify database state
from apps.moderation.models import EditSubmission
pending_submission.refresh_from_db()
assert pending_submission.status == "APPROVED"
def test_submission_reject_transition_as_moderator(
self, mod_page: Page, pending_submission, live_server
):
"""Test rejecting an EditSubmission as a moderator."""
mod_page.goto(f"{live_server.url}/moderation/dashboard/")
mod_page.wait_for_load_state("networkidle")
submission_row = mod_page.locator(f'[data-submission-id="{pending_submission.pk}"]')
# Verify initial status
status_badge = submission_row.locator('[data-status-badge]')
expect(status_badge).to_contain_text("Pending")
# Click reject button
reject_btn = submission_row.get_by_role("button", name="Reject")
# Handle confirmation dialog
mod_page.on("dialog", lambda dialog: dialog.accept())
reject_btn.click()
# Verify toast notification
toast = mod_page.locator('[data-toast]')
expect(toast).to_be_visible(timeout=5000)
expect(toast).to_contain_text("rejected")
# Verify status badge updated (should show red/danger styling)
expect(status_badge).to_contain_text("Rejected")
# Verify database state
from apps.moderation.models import EditSubmission
pending_submission.refresh_from_db()
assert pending_submission.status == "REJECTED"
def test_submission_escalate_transition_as_moderator(
self, mod_page: Page, pending_submission, live_server
):
"""Test escalating an EditSubmission as a moderator."""
mod_page.goto(f"{live_server.url}/moderation/dashboard/")
mod_page.wait_for_load_state("networkidle")
submission_row = mod_page.locator(f'[data-submission-id="{pending_submission.pk}"]')
# Verify initial status
status_badge = submission_row.locator('[data-status-badge]')
expect(status_badge).to_contain_text("Pending")
# Click escalate button
escalate_btn = submission_row.get_by_role("button", name="Escalate")
# Handle confirmation dialog
mod_page.on("dialog", lambda dialog: dialog.accept())
escalate_btn.click()
# Verify toast notification
toast = mod_page.locator('[data-toast]')
expect(toast).to_be_visible(timeout=5000)
expect(toast).to_contain_text("escalated")
# Verify status badge updated
expect(status_badge).to_contain_text("Escalated")
# Verify database state
from apps.moderation.models import EditSubmission
pending_submission.refresh_from_db()
assert pending_submission.status == "ESCALATED"
class TestPhotoSubmissionTransitions:
"""Tests for PhotoSubmission FSM transitions via HTMX."""
def test_photo_submission_approve_transition(
self, mod_page: Page, pending_photo_submission, live_server
):
"""Test approving a PhotoSubmission as a moderator."""
mod_page.goto(f"{live_server.url}/moderation/dashboard/")
mod_page.wait_for_load_state("networkidle")
# Click on Photos tab if it exists
photos_tab = mod_page.get_by_role("tab", name="Photos")
if photos_tab.is_visible():
photos_tab.click()
# Find the photo submission row
submission_row = mod_page.locator(
f'[data-photo-submission-id="{pending_photo_submission.pk}"]'
)
if not submission_row.is_visible():
pytest.skip("Photo submission not visible in dashboard")
# Click approve button
approve_btn = submission_row.get_by_role("button", name="Approve")
mod_page.on("dialog", lambda dialog: dialog.accept())
approve_btn.click()
# Verify toast notification
toast = mod_page.locator('[data-toast]')
expect(toast).to_be_visible(timeout=5000)
expect(toast).to_contain_text("approved")
# Verify database state
from apps.moderation.models import PhotoSubmission
pending_photo_submission.refresh_from_db()
assert pending_photo_submission.status == "APPROVED"
def test_photo_submission_reject_transition(
self, mod_page: Page, pending_photo_submission, live_server
):
"""Test rejecting a PhotoSubmission as a moderator."""
mod_page.goto(f"{live_server.url}/moderation/dashboard/")
mod_page.wait_for_load_state("networkidle")
# Click on Photos tab if it exists
photos_tab = mod_page.get_by_role("tab", name="Photos")
if photos_tab.is_visible():
photos_tab.click()
# Find the photo submission row
submission_row = mod_page.locator(
f'[data-photo-submission-id="{pending_photo_submission.pk}"]'
)
if not submission_row.is_visible():
pytest.skip("Photo submission not visible in dashboard")
# Click reject button
reject_btn = submission_row.get_by_role("button", name="Reject")
mod_page.on("dialog", lambda dialog: dialog.accept())
reject_btn.click()
# Verify toast notification
toast = mod_page.locator('[data-toast]')
expect(toast).to_be_visible(timeout=5000)
expect(toast).to_contain_text("rejected")
# Verify database state
from apps.moderation.models import PhotoSubmission
pending_photo_submission.refresh_from_db()
assert pending_photo_submission.status == "REJECTED"
class TestModerationQueueTransitions:
"""Tests for ModerationQueue FSM transitions via HTMX."""
@pytest.fixture
def pending_queue_item(self, db):
"""Create a pending ModerationQueue item for testing."""
from django.contrib.auth import get_user_model
from apps.moderation.models import ModerationQueue
User = get_user_model()
user, _ = User.objects.get_or_create(
username="testflagger",
defaults={"email": "testflagger@example.com"}
)
queue_item = ModerationQueue.objects.create(
item_type="CONTENT_REVIEW",
status="PENDING",
priority="MEDIUM",
title="E2E Test Queue Item",
description="Queue item for E2E testing",
flagged_by=user
)
yield queue_item
queue_item.delete()
def test_moderation_queue_start_transition(
self, mod_page: Page, pending_queue_item, live_server
):
"""Test starting work on a ModerationQueue item."""
mod_page.goto(f"{live_server.url}/moderation/queue/")
mod_page.wait_for_load_state("networkidle")
# Find the queue item row
queue_row = mod_page.locator(f'[data-queue-id="{pending_queue_item.pk}"]')
if not queue_row.is_visible():
pytest.skip("Queue item not visible")
# Click start button
start_btn = queue_row.get_by_role("button", name="Start")
start_btn.click()
# Verify status updated to IN_PROGRESS
status_badge = queue_row.locator('[data-status-badge]')
expect(status_badge).to_contain_text("In Progress", timeout=5000)
# Verify database state
from apps.moderation.models import ModerationQueue
pending_queue_item.refresh_from_db()
assert pending_queue_item.status == "IN_PROGRESS"
def test_moderation_queue_complete_transition(
self, mod_page: Page, pending_queue_item, live_server
):
"""Test completing a ModerationQueue item."""
# First set status to IN_PROGRESS
pending_queue_item.status = "IN_PROGRESS"
pending_queue_item.save()
mod_page.goto(f"{live_server.url}/moderation/queue/")
mod_page.wait_for_load_state("networkidle")
queue_row = mod_page.locator(f'[data-queue-id="{pending_queue_item.pk}"]')
if not queue_row.is_visible():
pytest.skip("Queue item not visible")
# Click complete button
complete_btn = queue_row.get_by_role("button", name="Complete")
mod_page.on("dialog", lambda dialog: dialog.accept())
complete_btn.click()
# Verify toast and status
toast = mod_page.locator('[data-toast]')
expect(toast).to_be_visible(timeout=5000)
from apps.moderation.models import ModerationQueue
pending_queue_item.refresh_from_db()
assert pending_queue_item.status == "COMPLETED"
class TestBulkOperationTransitions:
"""Tests for BulkOperation FSM transitions via HTMX."""
@pytest.fixture
def pending_bulk_operation(self, db):
"""Create a pending BulkOperation for testing."""
from django.contrib.auth import get_user_model
from apps.moderation.models import BulkOperation
User = get_user_model()
user, _ = User.objects.get_or_create(
username="testadmin",
defaults={"email": "testadmin@example.com", "is_staff": True}
)
operation = BulkOperation.objects.create(
operation_type="IMPORT",
status="PENDING",
priority="MEDIUM",
description="E2E Test Bulk Operation",
parameters={"test": True},
created_by=user,
total_items=10
)
yield operation
operation.delete()
def test_bulk_operation_cancel_transition(
self, mod_page: Page, pending_bulk_operation, live_server
):
"""Test canceling a BulkOperation."""
mod_page.goto(f"{live_server.url}/moderation/bulk-operations/")
mod_page.wait_for_load_state("networkidle")
# Find the operation row
operation_row = mod_page.locator(
f'[data-bulk-operation-id="{pending_bulk_operation.pk}"]'
)
if not operation_row.is_visible():
pytest.skip("Bulk operation not visible")
# Click cancel button
cancel_btn = operation_row.get_by_role("button", name="Cancel")
mod_page.on("dialog", lambda dialog: dialog.accept())
cancel_btn.click()
# Verify toast
toast = mod_page.locator('[data-toast]')
expect(toast).to_be_visible(timeout=5000)
expect(toast).to_contain_text("cancel")
# Verify database state
from apps.moderation.models import BulkOperation
pending_bulk_operation.refresh_from_db()
assert pending_bulk_operation.status == "CANCELLED"
class TestTransitionLoadingStates:
"""Tests for loading indicators during FSM transitions."""
def test_loading_indicator_appears_during_transition(
self, mod_page: Page, pending_submission, live_server
):
"""Verify loading spinner appears during HTMX transition."""
mod_page.goto(f"{live_server.url}/moderation/dashboard/")
mod_page.wait_for_load_state("networkidle")
submission_row = mod_page.locator(
f'[data-submission-id="{pending_submission.pk}"]'
)
# Get approve button and associated loading indicator
approve_btn = submission_row.get_by_role("button", name="Approve")
# Slow down network to see loading state
mod_page.route("**/*", lambda route: route.continue_())
mod_page.on("dialog", lambda dialog: dialog.accept())
# Start transition
approve_btn.click()
# Check for htmx-indicator visibility (may be brief)
# The indicator should become visible during the request
loading_indicator = submission_row.locator('.htmx-indicator')
# Wait for transition to complete
toast = mod_page.locator('[data-toast]')
expect(toast).to_be_visible(timeout=5000)

View File

@@ -0,0 +1,591 @@
"""
E2E Tests for Park and Ride FSM Status Transitions
Tests the complete HTMX FSM transition flow for park and ride status changes:
- Park: close_temporarily, reopen, close_permanently, demolish, relocate
- Ride: close_temporarily, mark_sbno, close_permanently, demolish, relocate
These tests verify:
- Status badge updates in detail page header
- Available transitions update based on current state
- Toast notifications appear with correct message/type
- HTMX swap occurs without full page reload
- StateLog entry created in database
"""
import pytest
from playwright.sync_api import Page, expect
from datetime import date, timedelta
@pytest.fixture
def operating_park(db):
"""Create an operating Park for testing status transitions."""
from apps.parks.models import Park
from tests.factories import ParkFactory
# Use factory to create a complete park
park = ParkFactory(
name="E2E Test Park",
slug="e2e-test-park",
status="OPERATING"
)
yield park
# Cleanup handled by factory
@pytest.fixture
def operating_ride(db, operating_park):
"""Create an operating Ride for testing status transitions."""
from tests.factories import RideFactory
ride = RideFactory(
name="E2E Test Ride",
slug="e2e-test-ride",
park=operating_park,
status="OPERATING"
)
yield ride
class TestParkStatusTransitions:
"""Tests for Park FSM status transitions via HTMX."""
def test_park_close_temporarily_as_moderator(
self, mod_page: Page, operating_park, live_server
):
"""Test closing a park temporarily as a moderator."""
mod_page.goto(f"{live_server.url}/parks/{operating_park.slug}/")
mod_page.wait_for_load_state("networkidle")
# Verify initial status badge shows Operating
status_section = mod_page.locator('[data-park-status-actions]')
status_badge = mod_page.locator('[data-status-badge]')
expect(status_badge).to_contain_text("Operating")
# Find and click "Close Temporarily" button
close_temp_btn = status_section.get_by_role(
"button", name="Close Temporarily"
)
if not close_temp_btn.is_visible():
# May be in a dropdown menu
actions_dropdown = status_section.locator('[data-actions-dropdown]')
if actions_dropdown.is_visible():
actions_dropdown.click()
close_temp_btn = mod_page.get_by_role(
"button", name="Close Temporarily"
)
# Handle confirmation dialog
mod_page.on("dialog", lambda dialog: dialog.accept())
close_temp_btn.click()
# Verify toast notification
toast = mod_page.locator('[data-toast]')
expect(toast).to_be_visible(timeout=5000)
# Verify status badge updated
expect(status_badge).to_contain_text("Temporarily Closed", timeout=5000)
# Verify database state
from apps.parks.models import Park
operating_park.refresh_from_db()
assert operating_park.status == "CLOSED_TEMP"
def test_park_reopen_from_closed_temp(
self, mod_page: Page, operating_park, live_server
):
"""Test reopening a temporarily closed park."""
# First close the park temporarily
operating_park.status = "CLOSED_TEMP"
operating_park.save()
mod_page.goto(f"{live_server.url}/parks/{operating_park.slug}/")
mod_page.wait_for_load_state("networkidle")
# Verify initial status badge shows Temporarily Closed
status_badge = mod_page.locator('[data-status-badge]')
expect(status_badge).to_contain_text("Temporarily Closed")
# Find and click "Reopen" button
status_section = mod_page.locator('[data-park-status-actions]')
reopen_btn = status_section.get_by_role("button", name="Reopen")
mod_page.on("dialog", lambda dialog: dialog.accept())
reopen_btn.click()
# Verify toast notification
toast = mod_page.locator('[data-toast]')
expect(toast).to_be_visible(timeout=5000)
# Verify status badge updated
expect(status_badge).to_contain_text("Operating", timeout=5000)
# Verify database state
from apps.parks.models import Park
operating_park.refresh_from_db()
assert operating_park.status == "OPERATING"
def test_park_close_permanently_as_moderator(
self, mod_page: Page, operating_park, live_server
):
"""Test closing a park permanently as a moderator."""
mod_page.goto(f"{live_server.url}/parks/{operating_park.slug}/")
mod_page.wait_for_load_state("networkidle")
status_section = mod_page.locator('[data-park-status-actions]')
status_badge = mod_page.locator('[data-status-badge]')
# Find and click "Close Permanently" button
close_perm_btn = status_section.get_by_role(
"button", name="Close Permanently"
)
if not close_perm_btn.is_visible():
actions_dropdown = status_section.locator('[data-actions-dropdown]')
if actions_dropdown.is_visible():
actions_dropdown.click()
close_perm_btn = mod_page.get_by_role(
"button", name="Close Permanently"
)
mod_page.on("dialog", lambda dialog: dialog.accept())
close_perm_btn.click()
# Verify toast notification
toast = mod_page.locator('[data-toast]')
expect(toast).to_be_visible(timeout=5000)
# Verify status badge updated
expect(status_badge).to_contain_text("Permanently Closed", timeout=5000)
# Verify database state
from apps.parks.models import Park
operating_park.refresh_from_db()
assert operating_park.status == "CLOSED_PERM"
def test_park_demolish_from_closed_perm(
self, mod_page: Page, operating_park, live_server
):
"""Test transitioning a permanently closed park to demolished."""
# Set park to permanently closed
operating_park.status = "CLOSED_PERM"
operating_park.closing_date = date.today() - timedelta(days=365)
operating_park.save()
mod_page.goto(f"{live_server.url}/parks/{operating_park.slug}/")
mod_page.wait_for_load_state("networkidle")
status_section = mod_page.locator('[data-park-status-actions]')
status_badge = mod_page.locator('[data-status-badge]')
# Find and click "Mark as Demolished" button
demolish_btn = status_section.get_by_role("button", name="Mark as Demolished")
if not demolish_btn.is_visible():
actions_dropdown = status_section.locator('[data-actions-dropdown]')
if actions_dropdown.is_visible():
actions_dropdown.click()
demolish_btn = mod_page.get_by_role(
"button", name="Mark as Demolished"
)
mod_page.on("dialog", lambda dialog: dialog.accept())
demolish_btn.click()
# Verify toast notification
toast = mod_page.locator('[data-toast]')
expect(toast).to_be_visible(timeout=5000)
# Verify status badge updated
expect(status_badge).to_contain_text("Demolished", timeout=5000)
# Verify database state
from apps.parks.models import Park
operating_park.refresh_from_db()
assert operating_park.status == "DEMOLISHED"
def test_park_available_transitions_update(
self, mod_page: Page, operating_park, live_server
):
"""Test that available transitions update based on current state."""
mod_page.goto(f"{live_server.url}/parks/{operating_park.slug}/")
mod_page.wait_for_load_state("networkidle")
status_section = mod_page.locator('[data-park-status-actions]')
# Operating park should have Close Temporarily and Close Permanently
expect(
status_section.get_by_role("button", name="Close Temporarily")
).to_be_visible()
# Should NOT have Reopen (not applicable for Operating state)
expect(
status_section.get_by_role("button", name="Reopen")
).not_to_be_visible()
# Now close temporarily and verify buttons change
operating_park.status = "CLOSED_TEMP"
operating_park.save()
mod_page.reload()
mod_page.wait_for_load_state("networkidle")
# Now should have Reopen button
expect(
status_section.get_by_role("button", name="Reopen")
).to_be_visible()
class TestRideStatusTransitions:
"""Tests for Ride FSM status transitions via HTMX."""
def test_ride_close_temporarily_as_moderator(
self, mod_page: Page, operating_ride, live_server
):
"""Test closing a ride temporarily as a moderator."""
mod_page.goto(
f"{live_server.url}/parks/{operating_ride.park.slug}/rides/{operating_ride.slug}/"
)
mod_page.wait_for_load_state("networkidle")
status_section = mod_page.locator('[data-ride-status-actions]')
status_badge = mod_page.locator('[data-status-badge]')
expect(status_badge).to_contain_text("Operating")
# Find and click "Close Temporarily" button
close_temp_btn = status_section.get_by_role(
"button", name="Close Temporarily"
)
mod_page.on("dialog", lambda dialog: dialog.accept())
close_temp_btn.click()
# Verify toast notification
toast = mod_page.locator('[data-toast]')
expect(toast).to_be_visible(timeout=5000)
# Verify status badge updated
expect(status_badge).to_contain_text("Temporarily Closed", timeout=5000)
# Verify database state
from apps.rides.models import Ride
operating_ride.refresh_from_db()
assert operating_ride.status == "CLOSED_TEMP"
def test_ride_mark_sbno_as_moderator(
self, mod_page: Page, operating_ride, live_server
):
"""Test marking a ride as Standing But Not Operating (SBNO)."""
mod_page.goto(
f"{live_server.url}/parks/{operating_ride.park.slug}/rides/{operating_ride.slug}/"
)
mod_page.wait_for_load_state("networkidle")
status_section = mod_page.locator('[data-ride-status-actions]')
status_badge = mod_page.locator('[data-status-badge]')
# Find and click "Mark SBNO" button
sbno_btn = status_section.get_by_role("button", name="Mark SBNO")
if not sbno_btn.is_visible():
actions_dropdown = status_section.locator('[data-actions-dropdown]')
if actions_dropdown.is_visible():
actions_dropdown.click()
sbno_btn = mod_page.get_by_role("button", name="Mark SBNO")
mod_page.on("dialog", lambda dialog: dialog.accept())
sbno_btn.click()
# Verify toast notification
toast = mod_page.locator('[data-toast]')
expect(toast).to_be_visible(timeout=5000)
# Verify status badge updated
expect(status_badge).to_contain_text("SBNO", timeout=5000)
# Verify database state
from apps.rides.models import Ride
operating_ride.refresh_from_db()
assert operating_ride.status == "SBNO"
def test_ride_reopen_from_closed_temp(
self, mod_page: Page, operating_ride, live_server
):
"""Test reopening a temporarily closed ride."""
# First close the ride temporarily
operating_ride.status = "CLOSED_TEMP"
operating_ride.save()
mod_page.goto(
f"{live_server.url}/parks/{operating_ride.park.slug}/rides/{operating_ride.slug}/"
)
mod_page.wait_for_load_state("networkidle")
status_section = mod_page.locator('[data-ride-status-actions]')
status_badge = mod_page.locator('[data-status-badge]')
# Find and click "Reopen" button
reopen_btn = status_section.get_by_role("button", name="Reopen")
mod_page.on("dialog", lambda dialog: dialog.accept())
reopen_btn.click()
# Verify toast notification
toast = mod_page.locator('[data-toast]')
expect(toast).to_be_visible(timeout=5000)
# Verify status badge updated
expect(status_badge).to_contain_text("Operating", timeout=5000)
# Verify database state
from apps.rides.models import Ride
operating_ride.refresh_from_db()
assert operating_ride.status == "OPERATING"
def test_ride_close_permanently_as_moderator(
self, mod_page: Page, operating_ride, live_server
):
"""Test closing a ride permanently as a moderator."""
mod_page.goto(
f"{live_server.url}/parks/{operating_ride.park.slug}/rides/{operating_ride.slug}/"
)
mod_page.wait_for_load_state("networkidle")
status_section = mod_page.locator('[data-ride-status-actions]')
status_badge = mod_page.locator('[data-status-badge]')
# Find and click "Close Permanently" button
close_perm_btn = status_section.get_by_role(
"button", name="Close Permanently"
)
if not close_perm_btn.is_visible():
actions_dropdown = status_section.locator('[data-actions-dropdown]')
if actions_dropdown.is_visible():
actions_dropdown.click()
close_perm_btn = mod_page.get_by_role(
"button", name="Close Permanently"
)
mod_page.on("dialog", lambda dialog: dialog.accept())
close_perm_btn.click()
# Verify toast notification
toast = mod_page.locator('[data-toast]')
expect(toast).to_be_visible(timeout=5000)
# Verify status badge updated
expect(status_badge).to_contain_text("Permanently Closed", timeout=5000)
# Verify database state
from apps.rides.models import Ride
operating_ride.refresh_from_db()
assert operating_ride.status == "CLOSED_PERM"
def test_ride_demolish_from_closed_perm(
self, mod_page: Page, operating_ride, live_server
):
"""Test transitioning a permanently closed ride to demolished."""
# Set ride to permanently closed
operating_ride.status = "CLOSED_PERM"
operating_ride.closing_date = date.today() - timedelta(days=365)
operating_ride.save()
mod_page.goto(
f"{live_server.url}/parks/{operating_ride.park.slug}/rides/{operating_ride.slug}/"
)
mod_page.wait_for_load_state("networkidle")
status_section = mod_page.locator('[data-ride-status-actions]')
status_badge = mod_page.locator('[data-status-badge]')
# Find and click "Mark as Demolished" button
demolish_btn = status_section.get_by_role(
"button", name="Mark as Demolished"
)
if not demolish_btn.is_visible():
actions_dropdown = status_section.locator('[data-actions-dropdown]')
if actions_dropdown.is_visible():
actions_dropdown.click()
demolish_btn = mod_page.get_by_role(
"button", name="Mark as Demolished"
)
mod_page.on("dialog", lambda dialog: dialog.accept())
demolish_btn.click()
# Verify toast notification
toast = mod_page.locator('[data-toast]')
expect(toast).to_be_visible(timeout=5000)
# Verify status badge updated
expect(status_badge).to_contain_text("Demolished", timeout=5000)
# Verify database state
from apps.rides.models import Ride
operating_ride.refresh_from_db()
assert operating_ride.status == "DEMOLISHED"
def test_ride_relocate_from_closed_perm(
self, mod_page: Page, operating_ride, live_server
):
"""Test transitioning a permanently closed ride to relocated."""
# Set ride to permanently closed
operating_ride.status = "CLOSED_PERM"
operating_ride.closing_date = date.today() - timedelta(days=365)
operating_ride.save()
mod_page.goto(
f"{live_server.url}/parks/{operating_ride.park.slug}/rides/{operating_ride.slug}/"
)
mod_page.wait_for_load_state("networkidle")
status_section = mod_page.locator('[data-ride-status-actions]')
status_badge = mod_page.locator('[data-status-badge]')
# Find and click "Mark as Relocated" button
relocate_btn = status_section.get_by_role(
"button", name="Mark as Relocated"
)
if not relocate_btn.is_visible():
actions_dropdown = status_section.locator('[data-actions-dropdown]')
if actions_dropdown.is_visible():
actions_dropdown.click()
relocate_btn = mod_page.get_by_role(
"button", name="Mark as Relocated"
)
mod_page.on("dialog", lambda dialog: dialog.accept())
relocate_btn.click()
# Verify toast notification
toast = mod_page.locator('[data-toast]')
expect(toast).to_be_visible(timeout=5000)
# Verify status badge updated
expect(status_badge).to_contain_text("Relocated", timeout=5000)
# Verify database state
from apps.rides.models import Ride
operating_ride.refresh_from_db()
assert operating_ride.status == "RELOCATED"
class TestRideClosingWorkflow:
"""Tests for the special CLOSING status workflow with automatic transitions."""
def test_ride_set_closing_with_future_date(
self, mod_page: Page, operating_ride, live_server
):
"""Test setting a ride to CLOSING status with a future closing date."""
mod_page.goto(
f"{live_server.url}/parks/{operating_ride.park.slug}/rides/{operating_ride.slug}/"
)
mod_page.wait_for_load_state("networkidle")
status_section = mod_page.locator('[data-ride-status-actions]')
# Find and click "Set Closing" button
set_closing_btn = status_section.get_by_role(
"button", name="Set Closing"
)
if set_closing_btn.is_visible():
mod_page.on("dialog", lambda dialog: dialog.accept())
set_closing_btn.click()
# Verify status badge updated
status_badge = mod_page.locator('[data-status-badge]')
expect(status_badge).to_contain_text("Closing", timeout=5000)
# Verify database state
from apps.rides.models import Ride
operating_ride.refresh_from_db()
assert operating_ride.status == "CLOSING"
else:
pytest.skip("Set Closing button not available")
def test_ride_closing_shows_countdown(
self, mod_page: Page, operating_ride, live_server
):
"""Test that a ride in CLOSING status shows a countdown to closing date."""
# Set ride to CLOSING with future date
future_date = date.today() + timedelta(days=30)
operating_ride.status = "CLOSING"
operating_ride.closing_date = future_date
operating_ride.save()
mod_page.goto(
f"{live_server.url}/parks/{operating_ride.park.slug}/rides/{operating_ride.slug}/"
)
mod_page.wait_for_load_state("networkidle")
# Verify closing countdown is displayed
closing_info = mod_page.locator('[data-closing-countdown]')
if closing_info.is_visible():
expect(closing_info).to_contain_text("30")
else:
# May just show the status badge
status_badge = mod_page.locator('[data-status-badge]')
expect(status_badge).to_contain_text("Closing")
class TestStatusBadgeStyling:
"""Tests for correct status badge styling based on state."""
def test_operating_status_badge_style(
self, mod_page: Page, operating_park, live_server
):
"""Test that Operating status has correct green styling."""
mod_page.goto(f"{live_server.url}/parks/{operating_park.slug}/")
mod_page.wait_for_load_state("networkidle")
status_badge = mod_page.locator('[data-status-badge]')
expect(status_badge).to_have_class(/bg-green|text-green|success/)
def test_closed_temp_status_badge_style(
self, mod_page: Page, operating_park, live_server
):
"""Test that Temporarily Closed status has correct yellow/warning styling."""
operating_park.status = "CLOSED_TEMP"
operating_park.save()
mod_page.goto(f"{live_server.url}/parks/{operating_park.slug}/")
mod_page.wait_for_load_state("networkidle")
status_badge = mod_page.locator('[data-status-badge]')
expect(status_badge).to_have_class(/bg-yellow|text-yellow|warning/)
def test_closed_perm_status_badge_style(
self, mod_page: Page, operating_park, live_server
):
"""Test that Permanently Closed status has correct red/danger styling."""
operating_park.status = "CLOSED_PERM"
operating_park.save()
mod_page.goto(f"{live_server.url}/parks/{operating_park.slug}/")
mod_page.wait_for_load_state("networkidle")
status_badge = mod_page.locator('[data-status-badge]')
expect(status_badge).to_have_class(/bg-red|text-red|danger/)
def test_demolished_status_badge_style(
self, mod_page: Page, operating_park, live_server
):
"""Test that Demolished status has correct gray styling."""
operating_park.status = "DEMOLISHED"
operating_park.save()
mod_page.goto(f"{live_server.url}/parks/{operating_park.slug}/")
mod_page.wait_for_load_state("networkidle")
status_badge = mod_page.locator('[data-status-badge]')
expect(status_badge).to_have_class(/bg-gray|text-gray|muted/)

View File

@@ -0,0 +1,6 @@
"""
Integration tests for ThrillWiki.
These tests verify that different components of the application work together correctly
without requiring a browser (unlike E2E tests).
"""

View File

@@ -0,0 +1,792 @@
"""
Integration Tests for FSMTransitionView
Tests the FSMTransitionView without a browser, using Django's test client.
These tests verify:
- FSMTransitionView handles HTMX requests correctly
- HX-Trigger headers contain proper toast data
- Correct partial templates rendered for each model
- Permission validation before transition execution
These are faster than E2E tests and don't require Playwright.
"""
import json
import pytest
from django.test import TestCase, Client
from django.urls import reverse
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
User = get_user_model()
class TestFSMTransitionViewHTMX(TestCase):
"""Tests for FSMTransitionView with HTMX requests."""
@classmethod
def setUpTestData(cls):
"""Set up test data for all tests in this class."""
# Create regular user
cls.user = User.objects.create_user(
username="testuser",
email="testuser@example.com",
password="testpass123"
)
# Create moderator user
cls.moderator = User.objects.create_user(
username="moderator",
email="moderator@example.com",
password="modpass123",
is_staff=True
)
# Create admin user
cls.admin = User.objects.create_user(
username="admin",
email="admin@example.com",
password="adminpass123",
is_staff=True,
is_superuser=True
)
def setUp(self):
"""Set up test client for each test."""
self.client = Client()
def test_fsm_transition_view_with_htmx_header(self):
"""Test that FSMTransitionView handles HTMX requests correctly."""
from apps.moderation.models import EditSubmission
from apps.parks.models import Park
self.client.login(username="moderator", password="modpass123")
# Create a pending submission
park = Park.objects.first()
if not park:
self.skipTest("No park available for testing")
content_type = ContentType.objects.get_for_model(Park)
submission = EditSubmission.objects.create(
user=self.user,
content_type=content_type,
object_id=park.pk,
submission_type="EDIT",
changes={"description": "Test change"},
reason="Integration test",
status="PENDING"
)
url = reverse(
"core:fsm_transition",
kwargs={
"app_label": "moderation",
"model_name": "editsubmission",
"pk": submission.pk,
"transition_name": "transition_to_approved"
}
)
# Make request with HTMX header
response = self.client.post(
url,
HTTP_HX_REQUEST="true"
)
# Should return 200 OK
self.assertEqual(response.status_code, 200)
# Should have HX-Trigger header
self.assertIn("HX-Trigger", response)
# Parse HX-Trigger header
trigger_data = json.loads(response["HX-Trigger"])
self.assertIn("showToast", trigger_data)
self.assertEqual(trigger_data["showToast"]["type"], "success")
# Cleanup
submission.delete()
def test_fsm_transition_view_without_htmx_header(self):
"""Test that FSMTransitionView handles non-HTMX requests correctly."""
from apps.moderation.models import EditSubmission
from apps.parks.models import Park
self.client.login(username="moderator", password="modpass123")
park = Park.objects.first()
if not park:
self.skipTest("No park available for testing")
content_type = ContentType.objects.get_for_model(Park)
submission = EditSubmission.objects.create(
user=self.user,
content_type=content_type,
object_id=park.pk,
submission_type="EDIT",
changes={"description": "Test change non-htmx"},
reason="Integration test non-htmx",
status="PENDING"
)
url = reverse(
"core:fsm_transition",
kwargs={
"app_label": "moderation",
"model_name": "editsubmission",
"pk": submission.pk,
"transition_name": "transition_to_approved"
}
)
# Make request WITHOUT HTMX header
response = self.client.post(url)
# Should return 200 OK with JSON response
self.assertEqual(response.status_code, 200)
self.assertEqual(response["Content-Type"], "application/json")
# Parse JSON response
data = response.json()
self.assertTrue(data["success"])
self.assertIn("message", data)
# Cleanup
submission.delete()
def test_fsm_transition_view_returns_correct_partial(self):
"""Test that FSMTransitionView returns correct partial template."""
from apps.moderation.models import EditSubmission
from apps.parks.models import Park
self.client.login(username="moderator", password="modpass123")
park = Park.objects.first()
if not park:
self.skipTest("No park available for testing")
content_type = ContentType.objects.get_for_model(Park)
submission = EditSubmission.objects.create(
user=self.user,
content_type=content_type,
object_id=park.pk,
submission_type="EDIT",
changes={"description": "Test partial"},
reason="Partial test",
status="PENDING"
)
url = reverse(
"core:fsm_transition",
kwargs={
"app_label": "moderation",
"model_name": "editsubmission",
"pk": submission.pk,
"transition_name": "transition_to_approved"
}
)
response = self.client.post(
url,
HTTP_HX_REQUEST="true"
)
# Response should contain HTML (partial template)
self.assertIn("text/html", response["Content-Type"])
# Cleanup
submission.delete()
def test_fsm_transition_view_adds_toast_trigger(self):
"""Test that FSMTransitionView adds correct HX-Trigger for toast."""
from apps.parks.models import Park
self.client.login(username="moderator", password="modpass123")
park = Park.objects.filter(status="OPERATING").first()
if not park:
self.skipTest("No operating park available for testing")
url = reverse(
"core:fsm_transition",
kwargs={
"app_label": "parks",
"model_name": "park",
"pk": park.pk,
"transition_name": "transition_to_closed_temp"
}
)
response = self.client.post(
url,
HTTP_HX_REQUEST="true"
)
# Parse HX-Trigger header
trigger_data = json.loads(response["HX-Trigger"])
# Verify toast structure
self.assertIn("showToast", trigger_data)
self.assertIn("message", trigger_data["showToast"])
self.assertIn("type", trigger_data["showToast"])
self.assertEqual(trigger_data["showToast"]["type"], "success")
# Reset park status for other tests
park.status = "OPERATING"
park.save()
def test_fsm_transition_view_handles_invalid_model(self):
"""Test that FSMTransitionView handles invalid model gracefully."""
self.client.login(username="moderator", password="modpass123")
url = reverse(
"core:fsm_transition",
kwargs={
"app_label": "nonexistent",
"model_name": "fakemodel",
"pk": 1,
"transition_name": "fake_transition"
}
)
response = self.client.post(
url,
HTTP_HX_REQUEST="true"
)
# Should return 404
self.assertEqual(response.status_code, 404)
# Should have error toast trigger
self.assertIn("HX-Trigger", response)
trigger_data = json.loads(response["HX-Trigger"])
self.assertEqual(trigger_data["showToast"]["type"], "error")
def test_fsm_transition_view_handles_invalid_transition(self):
"""Test that FSMTransitionView handles invalid transition name."""
from apps.parks.models import Park
self.client.login(username="moderator", password="modpass123")
park = Park.objects.first()
if not park:
self.skipTest("No park available for testing")
url = reverse(
"core:fsm_transition",
kwargs={
"app_label": "parks",
"model_name": "park",
"pk": park.pk,
"transition_name": "nonexistent_transition"
}
)
response = self.client.post(
url,
HTTP_HX_REQUEST="true"
)
# Should return 400 Bad Request
self.assertEqual(response.status_code, 400)
# Should have error toast trigger
self.assertIn("HX-Trigger", response)
trigger_data = json.loads(response["HX-Trigger"])
self.assertEqual(trigger_data["showToast"]["type"], "error")
def test_fsm_transition_view_validates_permissions(self):
"""Test that FSMTransitionView validates user permissions."""
from apps.moderation.models import EditSubmission
from apps.parks.models import Park
# Login as regular user (not moderator)
self.client.login(username="testuser", password="testpass123")
park = Park.objects.first()
if not park:
self.skipTest("No park available for testing")
content_type = ContentType.objects.get_for_model(Park)
submission = EditSubmission.objects.create(
user=self.user,
content_type=content_type,
object_id=park.pk,
submission_type="EDIT",
changes={"description": "Permission test"},
reason="Permission test",
status="PENDING"
)
url = reverse(
"core:fsm_transition",
kwargs={
"app_label": "moderation",
"model_name": "editsubmission",
"pk": submission.pk,
"transition_name": "transition_to_approved"
}
)
response = self.client.post(
url,
HTTP_HX_REQUEST="true"
)
# Should return 400 or 403 (permission denied)
self.assertIn(response.status_code, [400, 403])
# Verify submission was NOT changed
submission.refresh_from_db()
self.assertEqual(submission.status, "PENDING")
# Cleanup
submission.delete()
class TestFSMTransitionViewParkModel(TestCase):
"""Tests for FSMTransitionView with Park model."""
@classmethod
def setUpTestData(cls):
cls.moderator = User.objects.create_user(
username="mod_park",
email="mod_park@example.com",
password="modpass123",
is_staff=True
)
def setUp(self):
self.client = Client()
self.client.login(username="mod_park", password="modpass123")
def test_park_close_temporarily_transition(self):
"""Test park close temporarily transition via view."""
from apps.parks.models import Park
park = Park.objects.filter(status="OPERATING").first()
if not park:
self.skipTest("No operating park available")
url = reverse(
"core:fsm_transition",
kwargs={
"app_label": "parks",
"model_name": "park",
"pk": park.pk,
"transition_name": "transition_to_closed_temp"
}
)
response = self.client.post(
url,
HTTP_HX_REQUEST="true"
)
self.assertEqual(response.status_code, 200)
# Verify park status changed
park.refresh_from_db()
self.assertEqual(park.status, "CLOSED_TEMP")
# Reset for other tests
park.status = "OPERATING"
park.save()
def test_park_reopen_transition(self):
"""Test park reopen transition via view."""
from apps.parks.models import Park
park = Park.objects.filter(status="OPERATING").first()
if not park:
self.skipTest("No park available")
# First close the park
park.status = "CLOSED_TEMP"
park.save()
url = reverse(
"core:fsm_transition",
kwargs={
"app_label": "parks",
"model_name": "park",
"pk": park.pk,
"transition_name": "transition_to_operating"
}
)
response = self.client.post(
url,
HTTP_HX_REQUEST="true"
)
self.assertEqual(response.status_code, 200)
# Verify park status changed
park.refresh_from_db()
self.assertEqual(park.status, "OPERATING")
def test_park_slug_based_transition(self):
"""Test FSM transition using slug instead of pk."""
from apps.parks.models import Park
park = Park.objects.filter(status="OPERATING").first()
if not park:
self.skipTest("No operating park available")
url = reverse(
"core:fsm_transition_by_slug",
kwargs={
"app_label": "parks",
"model_name": "park",
"slug": park.slug,
"transition_name": "transition_to_closed_temp"
}
)
response = self.client.post(
url,
HTTP_HX_REQUEST="true"
)
self.assertEqual(response.status_code, 200)
# Verify park status changed
park.refresh_from_db()
self.assertEqual(park.status, "CLOSED_TEMP")
# Reset for other tests
park.status = "OPERATING"
park.save()
class TestFSMTransitionViewRideModel(TestCase):
"""Tests for FSMTransitionView with Ride model."""
@classmethod
def setUpTestData(cls):
cls.moderator = User.objects.create_user(
username="mod_ride",
email="mod_ride@example.com",
password="modpass123",
is_staff=True
)
def setUp(self):
self.client = Client()
self.client.login(username="mod_ride", password="modpass123")
def test_ride_close_temporarily_transition(self):
"""Test ride close temporarily transition via view."""
from apps.rides.models import Ride
ride = Ride.objects.filter(status="OPERATING").first()
if not ride:
self.skipTest("No operating ride available")
url = reverse(
"core:fsm_transition",
kwargs={
"app_label": "rides",
"model_name": "ride",
"pk": ride.pk,
"transition_name": "transition_to_closed_temp"
}
)
response = self.client.post(
url,
HTTP_HX_REQUEST="true"
)
self.assertEqual(response.status_code, 200)
# Verify ride status changed
ride.refresh_from_db()
self.assertEqual(ride.status, "CLOSED_TEMP")
# Reset for other tests
ride.status = "OPERATING"
ride.save()
def test_ride_mark_sbno_transition(self):
"""Test ride mark SBNO transition via view."""
from apps.rides.models import Ride
ride = Ride.objects.filter(status="OPERATING").first()
if not ride:
self.skipTest("No operating ride available")
url = reverse(
"core:fsm_transition",
kwargs={
"app_label": "rides",
"model_name": "ride",
"pk": ride.pk,
"transition_name": "transition_to_sbno"
}
)
response = self.client.post(
url,
HTTP_HX_REQUEST="true"
)
self.assertEqual(response.status_code, 200)
# Verify ride status changed
ride.refresh_from_db()
self.assertEqual(ride.status, "SBNO")
# Reset for other tests
ride.status = "OPERATING"
ride.save()
class TestFSMTransitionViewModerationModels(TestCase):
"""Tests for FSMTransitionView with moderation models."""
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_user(
username="submitter",
email="submitter@example.com",
password="testpass123"
)
cls.moderator = User.objects.create_user(
username="mod_moderation",
email="mod_moderation@example.com",
password="modpass123",
is_staff=True
)
def setUp(self):
self.client = Client()
self.client.login(username="mod_moderation", password="modpass123")
def test_edit_submission_approve_transition(self):
"""Test EditSubmission approve transition."""
from apps.moderation.models import EditSubmission
from apps.parks.models import Park
park = Park.objects.first()
if not park:
self.skipTest("No park available")
content_type = ContentType.objects.get_for_model(Park)
submission = EditSubmission.objects.create(
user=self.user,
content_type=content_type,
object_id=park.pk,
submission_type="EDIT",
changes={"description": "Approve test"},
reason="Approve test",
status="PENDING"
)
url = reverse(
"core:fsm_transition",
kwargs={
"app_label": "moderation",
"model_name": "editsubmission",
"pk": submission.pk,
"transition_name": "transition_to_approved"
}
)
response = self.client.post(
url,
HTTP_HX_REQUEST="true"
)
self.assertEqual(response.status_code, 200)
# Verify submission status changed
submission.refresh_from_db()
self.assertEqual(submission.status, "APPROVED")
# Cleanup
submission.delete()
def test_edit_submission_reject_transition(self):
"""Test EditSubmission reject transition."""
from apps.moderation.models import EditSubmission
from apps.parks.models import Park
park = Park.objects.first()
if not park:
self.skipTest("No park available")
content_type = ContentType.objects.get_for_model(Park)
submission = EditSubmission.objects.create(
user=self.user,
content_type=content_type,
object_id=park.pk,
submission_type="EDIT",
changes={"description": "Reject test"},
reason="Reject test",
status="PENDING"
)
url = reverse(
"core:fsm_transition",
kwargs={
"app_label": "moderation",
"model_name": "editsubmission",
"pk": submission.pk,
"transition_name": "transition_to_rejected"
}
)
response = self.client.post(
url,
HTTP_HX_REQUEST="true"
)
self.assertEqual(response.status_code, 200)
# Verify submission status changed
submission.refresh_from_db()
self.assertEqual(submission.status, "REJECTED")
# Cleanup
submission.delete()
def test_edit_submission_escalate_transition(self):
"""Test EditSubmission escalate transition."""
from apps.moderation.models import EditSubmission
from apps.parks.models import Park
park = Park.objects.first()
if not park:
self.skipTest("No park available")
content_type = ContentType.objects.get_for_model(Park)
submission = EditSubmission.objects.create(
user=self.user,
content_type=content_type,
object_id=park.pk,
submission_type="EDIT",
changes={"description": "Escalate test"},
reason="Escalate test",
status="PENDING"
)
url = reverse(
"core:fsm_transition",
kwargs={
"app_label": "moderation",
"model_name": "editsubmission",
"pk": submission.pk,
"transition_name": "transition_to_escalated"
}
)
response = self.client.post(
url,
HTTP_HX_REQUEST="true"
)
self.assertEqual(response.status_code, 200)
# Verify submission status changed
submission.refresh_from_db()
self.assertEqual(submission.status, "ESCALATED")
# Cleanup
submission.delete()
class TestFSMTransitionViewStateLog(TestCase):
"""Tests that FSM transitions create proper StateLog entries."""
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_user(
username="submitter_log",
email="submitter_log@example.com",
password="testpass123"
)
cls.moderator = User.objects.create_user(
username="mod_log",
email="mod_log@example.com",
password="modpass123",
is_staff=True
)
def setUp(self):
self.client = Client()
self.client.login(username="mod_log", password="modpass123")
def test_transition_creates_state_log(self):
"""Test that FSM transition creates a StateLog entry."""
from apps.moderation.models import EditSubmission
from apps.parks.models import Park
from django_fsm_log.models import StateLog
park = Park.objects.first()
if not park:
self.skipTest("No park available")
content_type = ContentType.objects.get_for_model(Park)
submission = EditSubmission.objects.create(
user=self.user,
content_type=content_type,
object_id=park.pk,
submission_type="EDIT",
changes={"description": "StateLog test"},
reason="StateLog test",
status="PENDING"
)
# Count existing StateLog entries
initial_log_count = StateLog.objects.filter(
content_type=ContentType.objects.get_for_model(EditSubmission),
object_id=submission.pk
).count()
url = reverse(
"core:fsm_transition",
kwargs={
"app_label": "moderation",
"model_name": "editsubmission",
"pk": submission.pk,
"transition_name": "transition_to_approved"
}
)
self.client.post(
url,
HTTP_HX_REQUEST="true"
)
# Check that a new StateLog entry was created
new_log_count = StateLog.objects.filter(
content_type=ContentType.objects.get_for_model(EditSubmission),
object_id=submission.pk
).count()
self.assertEqual(new_log_count, initial_log_count + 1)
# Verify the StateLog entry details
latest_log = StateLog.objects.filter(
content_type=ContentType.objects.get_for_model(EditSubmission),
object_id=submission.pk
).latest('timestamp')
self.assertEqual(latest_log.state, "APPROVED")
self.assertEqual(latest_log.by, self.moderator)
# Cleanup
submission.delete()

View File

@@ -0,0 +1,28 @@
"""
Test utilities for ThrillWiki.
This package provides reusable test utilities, helpers, and fixtures
for testing FSM transitions, HTMX interactions, and other common scenarios.
"""
from .fsm_test_helpers import (
create_test_submission,
create_test_park,
create_test_ride,
assert_status_changed,
assert_state_log_created,
assert_toast_triggered,
wait_for_htmx_swap,
verify_transition_buttons_visible,
)
__all__ = [
"create_test_submission",
"create_test_park",
"create_test_ride",
"assert_status_changed",
"assert_state_log_created",
"assert_toast_triggered",
"wait_for_htmx_swap",
"verify_transition_buttons_visible",
]

View File

@@ -0,0 +1,571 @@
"""
FSM Test Helpers
Reusable utility functions for testing FSM transitions:
- Factory functions for creating test objects in specific states
- Assertion helpers for common FSM test scenarios
- Playwright helpers for HTMX swap verification
- Toast notification verification utilities
"""
import json
from typing import Any, Dict, List, Optional, Type
from django.db.models import Model
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.http import HttpResponse
User = get_user_model()
# =============================================================================
# Factory Functions
# =============================================================================
def create_test_submission(
status: str = "PENDING",
user: Optional[User] = None,
park: Optional[Model] = None,
submission_type: str = "EDIT",
changes: Optional[Dict[str, Any]] = None,
reason: str = "Test submission",
**kwargs
) -> "EditSubmission":
"""
Create a test EditSubmission with the given status.
Args:
status: The initial status (PENDING, APPROVED, REJECTED, ESCALATED)
user: The submitting user (created if not provided)
park: The park to edit (first park used if not provided)
submission_type: EDIT or CREATE
changes: The changes JSON (default: {"description": "Test change"})
reason: The submission reason
**kwargs: Additional fields to set on the submission
Returns:
EditSubmission instance
"""
from apps.moderation.models import EditSubmission
from apps.parks.models import Park
# Get or create user
if user is None:
user, _ = User.objects.get_or_create(
username="test_submitter",
defaults={"email": "test_submitter@example.com"}
)
user.set_password("testpass123")
user.save()
# Get or create park
if park is None:
park = Park.objects.first()
if not park:
raise ValueError("No park available for testing")
# Default changes
if changes is None:
changes = {"description": "Test change"}
content_type = ContentType.objects.get_for_model(Park)
submission = EditSubmission.objects.create(
user=user,
content_type=content_type,
object_id=park.pk,
submission_type=submission_type,
changes=changes,
reason=reason,
status=status,
**kwargs
)
return submission
def create_test_park(
status: str = "OPERATING",
name: Optional[str] = None,
slug: Optional[str] = None,
**kwargs
) -> "Park":
"""
Create a test Park with the given status.
Args:
status: The initial status (OPERATING, CLOSED_TEMP, CLOSED_PERM, DEMOLISHED, RELOCATED)
name: Park name (auto-generated if not provided)
slug: Park slug (auto-generated if not provided)
**kwargs: Additional fields to set on the park
Returns:
Park instance
"""
from tests.factories import ParkFactory
if name is None:
import random
name = f"Test Park {random.randint(1000, 9999)}"
if slug is None:
from django.utils.text import slugify
slug = slugify(name)
park = ParkFactory(
name=name,
slug=slug,
status=status,
**kwargs
)
return park
def create_test_ride(
status: str = "OPERATING",
name: Optional[str] = None,
slug: Optional[str] = None,
park: Optional[Model] = None,
**kwargs
) -> "Ride":
"""
Create a test Ride with the given status.
Args:
status: The initial status (OPERATING, CLOSED_TEMP, SBNO, CLOSING, CLOSED_PERM, DEMOLISHED, RELOCATED)
name: Ride name (auto-generated if not provided)
slug: Ride slug (auto-generated if not provided)
park: The park this ride belongs to (created if not provided)
**kwargs: Additional fields to set on the ride
Returns:
Ride instance
"""
from tests.factories import RideFactory
if name is None:
import random
name = f"Test Ride {random.randint(1000, 9999)}"
if slug is None:
from django.utils.text import slugify
slug = slugify(name)
ride_kwargs = {
"name": name,
"slug": slug,
"status": status,
**kwargs
}
if park is not None:
ride_kwargs["park"] = park
ride = RideFactory(**ride_kwargs)
return ride
def create_test_photo_submission(
status: str = "PENDING",
user: Optional[User] = None,
park: Optional[Model] = None,
**kwargs
) -> "PhotoSubmission":
"""
Create a test PhotoSubmission with the given status.
Args:
status: The initial status (PENDING, APPROVED, REJECTED, ESCALATED)
user: The submitting user (created if not provided)
park: The park for the photo (first park used if not provided)
**kwargs: Additional fields to set on the submission
Returns:
PhotoSubmission instance
"""
from apps.moderation.models import PhotoSubmission
from apps.parks.models import Park
# Get or create user
if user is None:
user, _ = User.objects.get_or_create(
username="test_photo_submitter",
defaults={"email": "test_photo@example.com"}
)
user.set_password("testpass123")
user.save()
# Get or create park
if park is None:
park = Park.objects.first()
if not park:
raise ValueError("No park available for testing")
content_type = ContentType.objects.get_for_model(Park)
# Get a photo if available
try:
from django_cloudflareimages_toolkit.models import CloudflareImage
photo = CloudflareImage.objects.first()
if not photo:
raise ValueError("No CloudflareImage available for testing")
except ImportError:
raise ValueError("CloudflareImage not available")
submission = PhotoSubmission.objects.create(
user=user,
content_type=content_type,
object_id=park.pk,
photo=photo,
caption="Test photo submission",
status=status,
**kwargs
)
return submission
# =============================================================================
# Assertion Helpers
# =============================================================================
def assert_status_changed(obj: Model, expected_status: str) -> None:
"""
Assert that an object's status has changed to the expected value.
Args:
obj: The model instance to check
expected_status: The expected status value
Raises:
AssertionError: If status doesn't match expected
"""
obj.refresh_from_db()
actual_status = getattr(obj, "status", None)
assert actual_status == expected_status, (
f"Expected status '{expected_status}', got '{actual_status}'"
)
def assert_state_log_created(
obj: Model,
transition_name: str,
user: Optional[User] = None,
expected_state: Optional[str] = None
) -> None:
"""
Assert that a StateLog entry was created for a transition.
Args:
obj: The model instance that was transitioned
transition_name: The name of the transition (optional, for verification)
user: The user who performed the transition (optional)
expected_state: The expected final state in the log
Raises:
AssertionError: If no matching StateLog entry found
"""
from django_fsm_log.models import StateLog
content_type = ContentType.objects.get_for_model(obj)
logs = StateLog.objects.filter(
content_type=content_type,
object_id=obj.pk
).order_by('-timestamp')
assert logs.exists(), "No StateLog entries found for object"
latest_log = logs.first()
if expected_state is not None:
assert latest_log.state == expected_state, (
f"Expected state '{expected_state}' in log, got '{latest_log.state}'"
)
if user is not None:
assert latest_log.by == user, (
f"Expected log by user '{user}', got '{latest_log.by}'"
)
def assert_toast_triggered(
response: HttpResponse,
message: Optional[str] = None,
toast_type: str = "success"
) -> None:
"""
Assert that the response contains an HX-Trigger header with toast data.
Args:
response: The HTTP response to check
message: Expected message substring (optional)
toast_type: Expected toast type ('success', 'error', 'warning', 'info')
Raises:
AssertionError: If toast trigger not found or doesn't match
"""
assert "HX-Trigger" in response, "Response missing HX-Trigger header"
trigger_data = json.loads(response["HX-Trigger"])
assert "showToast" in trigger_data, "HX-Trigger missing showToast event"
toast_data = trigger_data["showToast"]
assert toast_data.get("type") == toast_type, (
f"Expected toast type '{toast_type}', got '{toast_data.get('type')}'"
)
if message is not None:
assert message in toast_data.get("message", ""), (
f"Expected '{message}' in toast message, got '{toast_data.get('message')}'"
)
def assert_no_status_change(obj: Model, original_status: str) -> None:
"""
Assert that an object's status has NOT changed from the original.
Args:
obj: The model instance to check
original_status: The original status value
Raises:
AssertionError: If status has changed
"""
obj.refresh_from_db()
actual_status = getattr(obj, "status", None)
assert actual_status == original_status, (
f"Status should not have changed from '{original_status}', but is now '{actual_status}'"
)
# =============================================================================
# Playwright Helpers
# =============================================================================
def wait_for_htmx_swap(
page,
target_selector: str,
timeout: int = 5000
) -> None:
"""
Wait for an HTMX swap to complete on a target element.
Args:
page: Playwright page object
target_selector: CSS selector for the target element
timeout: Maximum time to wait in milliseconds
"""
# Wait for the htmx:afterSwap event
page.wait_for_function(
f"""
() => {{
const el = document.querySelector('{target_selector}');
return el && !el.classList.contains('htmx-request');
}}
""",
timeout=timeout
)
def verify_transition_buttons_visible(
page,
transitions: List[str],
container_selector: str = "[data-status-actions]"
) -> Dict[str, bool]:
"""
Verify which transition buttons are visible on the page.
Args:
page: Playwright page object
transitions: List of transition names to check (e.g., ["Approve", "Reject"])
container_selector: CSS selector for the container holding the buttons
Returns:
Dict mapping transition name to visibility boolean
"""
container = page.locator(container_selector)
results = {}
for transition in transitions:
button = container.get_by_role("button", name=transition)
results[transition] = button.is_visible()
return results
def get_status_badge_text(page, badge_selector: str = "[data-status-badge]") -> str:
"""
Get the text content of the status badge.
Args:
page: Playwright page object
badge_selector: CSS selector for the status badge
Returns:
The text content of the status badge
"""
badge = page.locator(badge_selector)
return badge.text_content().strip() if badge.is_visible() else ""
def get_status_badge_class(page, badge_selector: str = "[data-status-badge]") -> str:
"""
Get the class attribute of the status badge.
Args:
page: Playwright page object
badge_selector: CSS selector for the status badge
Returns:
The class attribute of the status badge
"""
badge = page.locator(badge_selector)
return badge.get_attribute("class") or ""
def wait_for_toast(page, toast_selector: str = "[data-toast]", timeout: int = 5000):
"""
Wait for a toast notification to appear.
Args:
page: Playwright page object
toast_selector: CSS selector for the toast element
timeout: Maximum time to wait in milliseconds
Returns:
The toast element locator
"""
toast = page.locator(toast_selector)
toast.wait_for(state="visible", timeout=timeout)
return toast
def wait_for_toast_dismiss(
page,
toast_selector: str = "[data-toast]",
timeout: int = 10000
) -> None:
"""
Wait for a toast notification to be dismissed.
Args:
page: Playwright page object
toast_selector: CSS selector for the toast element
timeout: Maximum time to wait in milliseconds
"""
toast = page.locator(toast_selector)
toast.wait_for(state="hidden", timeout=timeout)
def click_and_confirm(page, button_locator, accept: bool = True) -> None:
"""
Click a button and handle the confirmation dialog.
Args:
page: Playwright page object
button_locator: The button locator to click
accept: Whether to accept (True) or dismiss (False) the dialog
"""
def handle_dialog(dialog):
if accept:
dialog.accept()
else:
dialog.dismiss()
page.on("dialog", handle_dialog)
button_locator.click()
# =============================================================================
# Test Client Helpers
# =============================================================================
def make_htmx_post(client, url: str, data: Optional[Dict] = None) -> HttpResponse:
"""
Make a POST request with HTMX headers.
Args:
client: Django test client
url: The URL to POST to
data: Optional POST data
Returns:
HttpResponse from the request
"""
return client.post(
url,
data=data or {},
HTTP_HX_REQUEST="true"
)
def make_htmx_get(client, url: str) -> HttpResponse:
"""
Make a GET request with HTMX headers.
Args:
client: Django test client
url: The URL to GET
Returns:
HttpResponse from the request
"""
return client.get(
url,
HTTP_HX_REQUEST="true"
)
def get_fsm_transition_url(
app_label: str,
model_name: str,
pk: int,
transition_name: str,
use_slug: bool = False,
slug: Optional[str] = None
) -> str:
"""
Generate the URL for an FSM transition.
Args:
app_label: The Django app label (e.g., 'moderation')
model_name: The model name in lowercase (e.g., 'editsubmission')
pk: The object's primary key
transition_name: The name of the transition method
use_slug: Whether to use slug-based URL
slug: The object's slug (required if use_slug is True)
Returns:
The transition URL string
"""
from django.urls import reverse
if use_slug:
if slug is None:
raise ValueError("slug is required when use_slug is True")
return reverse(
"core:fsm_transition_by_slug",
kwargs={
"app_label": app_label,
"model_name": model_name,
"slug": slug,
"transition_name": transition_name
}
)
else:
return reverse(
"core:fsm_transition",
kwargs={
"app_label": app_label,
"model_name": model_name,
"pk": pk,
"transition_name": transition_name
}
)

View File

@@ -96,6 +96,8 @@ urlpatterns = [
path("user/", accounts_views.user_redirect_view, name="user_redirect"),
# Moderation URLs - placed after other URLs but before static/media serving
path("moderation/", include("apps.moderation.urls", namespace="moderation")),
# Core app URLs (FSM transitions, entity search)
path("core/", include("apps.core.urls", namespace="core")),
path(
"env-settings/",
views.environment_and_settings_view,

View File

@@ -129,6 +129,225 @@
- Filtered results
- Sorted listings
## FSM State Machine Integration with HTMX
### Overview
The codebase implements a reusable FSM (Finite State Machine) infrastructure using django-fsm, integrated with HTMX for seamless state transitions without full page reloads.
### Core Components
1. **FSMTransitionView** (`apps/core/views/views.py`)
- Generic view for handling FSM state transitions via HTMX POST requests
- Validates permissions using `can_proceed` from django-fsm
- Executes transitions and returns updated HTML partials
- Sends HX-Trigger headers for toast notifications
2. **FSM Template Tags** (`apps/core/templatetags/fsm_tags.py`)
- `get_available_transitions`: Returns available transitions for an object/user
- `get_state_value`: Gets current state value
- `get_state_display`: Gets human-readable state display
- `default_target_id`: Generates HTMX target IDs
- `app_label`, `model_name`: Model metadata filters
3. **Reusable Partials** (`templates/htmx/`)
- `status_with_actions.html`: Combined status badge with action buttons
- `state_actions.html`: Standalone action buttons
- `updated_row.html`: Generic fallback for row updates
### Integration Pattern
```django
{% load fsm_tags %}
<!-- Include FSM status and actions -->
{% include 'htmx/status_with_actions.html' with object=submission user=user show_badge=True %}
```
### FSM Transition Flow with HTMX
```mermaid
sequenceDiagram
participant User
participant Browser
participant HTMX
participant FSMTransitionView
participant Model
participant Template
User->>Browser: Click "Approve" button
Browser->>HTMX: hx-post to FSM transition URL
HTMX->>FSMTransitionView: POST /moderation/submissions/<pk>/transition/transition_to_approved/
FSMTransitionView->>FSMTransitionView: Validate permissions (can_proceed)
FSMTransitionView->>Model: Execute transition
Model->>Model: Update status field + save
Model-->>FSMTransitionView: Return updated object
FSMTransitionView->>Template: Render model-specific partial
FSMTransitionView-->>HTMX: HTTP 200 + HX-Trigger: showToast
HTMX->>Browser: Swap target element with new HTML
HTMX->>Browser: Trigger showToast event
Browser->>User: Display updated row + success toast
```
### URL Pattern Configuration
FSM transitions use a consistent URL pattern:
```
/api/moderation/<model_plural>/<pk>/transition/<transition_name>/
```
Example URL patterns in `apps/moderation/urls.py`:
```python
path(
"submissions/<int:pk>/transition/<str:transition_name>/",
FSMTransitionView.as_view(),
{"app_label": "moderation", "model_name": "editsubmission"},
name="submission_transition",
)
```
### Template Resolution
FSMTransitionView automatically resolves model-specific templates:
1. `{app_label}/partials/{model_name}_row.html`
2. `{app_label}/partials/{model_name}_item.html`
3. `{app_label}/partials/{model_name}.html`
4. `htmx/updated_row.html` (fallback)
### Toast Notifications
The FSMTransitionView includes HX-Trigger headers for toast notifications:
```python
response["HX-Trigger"] = json.dumps({
"showToast": {
"message": "Submission approved successfully",
"type": "success"
}
})
```
Dashboard templates listen for these events with Alpine.js:
```javascript
@show-toast.window="showToast($event.detail)"
```
### Status Badge Styling
The `status_with_actions.html` partial includes automatic styling based on state:
- `PENDING` - Yellow (warning)
- `APPROVED` - Green (success)
- `REJECTED` - Red (danger)
- `ESCALATED` - Orange (caution)
- `IN_PROGRESS` - Blue (info)
### Flash Animations
Successful transitions include a flash animation class:
```css
.animate-flash-success {
animation: flash-success 1s ease-in-out;
}
```
### Error Handling Flow
```mermaid
sequenceDiagram
participant User
participant Browser
participant HTMX
participant FSMTransitionView
participant PermissionGuard
User->>Browser: Click "Approve" button
Browser->>HTMX: hx-post with HX-Request header
HTMX->>FSMTransitionView: POST /transition/approve/
FSMTransitionView->>PermissionGuard: Check can_proceed(user)
alt Permission Denied
PermissionGuard-->>FSMTransitionView: False
FSMTransitionView-->>HTMX: 403 + HX-Trigger: showToast (error)
HTMX->>Browser: Trigger error toast event
Browser->>User: Display red error toast
else Permission Granted
PermissionGuard-->>FSMTransitionView: True
FSMTransitionView->>FSMTransitionView: Execute transition
alt Transition Success
FSMTransitionView-->>HTMX: 200 + HTML + HX-Trigger: showToast (success)
HTMX->>Browser: Swap HTML + trigger success toast
Browser->>User: Display updated row + green toast
else Transition Failed
FSMTransitionView-->>HTMX: 400 + HX-Trigger: showToast (error)
HTMX->>Browser: Trigger error toast event
Browser->>User: Display red error toast
end
end
```
### Error Response Types
| Status | Scenario | Toast Type | Message |
|--------|----------|------------|---------|
| 200 | Success | success | "{Model} has been {action}d successfully" |
| 400 | Invalid transition | error | "Transition not allowed from current state" |
| 403 | Permission denied | error | "You don't have permission for this action" |
| 404 | Object not found | error | "Object not found" |
| 500 | Server error | error | "An unexpected error occurred" |
### Testing FSM HTMX Transitions
#### Unit Tests
Backend unit tests in `apps/moderation/tests.py`, `apps/parks/tests.py`, and `apps/rides/tests.py` cover:
- FSM transition logic and state changes
- Permission guards and role-based access
- Transition history logging with django-fsm-log
- Callback execution and side effects
#### Integration Tests
Integration tests in `backend/tests/integration/test_fsm_transition_view.py` verify:
- FSMTransitionView handles HTMX requests correctly
- HX-Trigger headers contain proper toast data
- Correct partial templates rendered for each model
- Permission validation before transition execution
- StateLog entries created for each transition
#### E2E Tests
End-to-end tests using Playwright in `backend/tests/e2e/` validate:
- Complete user interaction flow from button click to UI update
- Toast notifications appear and auto-dismiss
- Loading indicators show during transitions
- Error handling displays user-friendly messages
- Permission guards prevent unauthorized transitions
- Cross-browser compatibility (Chrome, Firefox, Safari)
Test files:
- `test_moderation_fsm.py` - EditSubmission, PhotoSubmission transitions
- `test_park_ride_fsm.py` - Park and Ride status changes
- `test_fsm_permissions.py` - Permission guard verification
- `test_fsm_error_handling.py` - Error scenarios and loading states
#### Running Tests
```bash
# Run all FSM tests
pytest -k fsm
# Run e2e FSM tests
pytest backend/tests/e2e/test_moderation_fsm.py
pytest backend/tests/e2e/test_park_ride_fsm.py
pytest backend/tests/e2e/test_fsm_permissions.py
# Run with specific browser
pytest --browser firefox backend/tests/e2e/test_moderation_fsm.py
# Run with headed mode (see browser)
pytest --headed backend/tests/e2e/test_moderation_fsm.py
# Run integration tests (faster, no browser)
pytest backend/tests/integration/test_fsm_transition_view.py
```
#### Browser Testing Checklist
For comprehensive manual testing, see `backend/tests/e2e/BROWSER_TESTING_CHECKLIST.md`.
## Error Handling
- Django middleware
- Custom error pages

461
docs/STATE_DIAGRAMS.md Normal file
View File

@@ -0,0 +1,461 @@
# State Machine Diagrams
This document provides comprehensive state diagrams for all FSM-enabled models in ThrillWiki.
These diagrams show all possible states, transitions, and guard conditions.
## Table of Contents
1. [EditSubmission State Machine](#editsubmission-state-machine)
2. [PhotoSubmission State Machine](#photosubmission-state-machine)
3. [Park Status State Machine](#park-status-state-machine)
4. [Ride Status State Machine](#ride-status-state-machine)
5. [ModerationReport State Machine](#moderationreport-state-machine)
6. [ModerationQueue State Machine](#moderationqueue-state-machine)
7. [BulkOperation State Machine](#bulkoperation-state-machine)
---
## EditSubmission State Machine
EditSubmission tracks user-submitted edits through the moderation workflow.
```mermaid
stateDiagram-v2
[*] --> PENDING: User submits edit
PENDING --> APPROVED: transition_to_approved()
PENDING --> REJECTED: transition_to_rejected()
PENDING --> ESCALATED: transition_to_escalated()
ESCALATED --> APPROVED: transition_to_approved()
ESCALATED --> REJECTED: transition_to_rejected()
APPROVED --> [*]
REJECTED --> [*]
note right of PENDING
Initial state
Yellow badge
end note
note right of APPROVED
Final state
Green badge
Changes applied
end note
note right of REJECTED
Final state
Red badge
end note
note right of ESCALATED
Orange badge
Needs admin review
end note
```
### States
| State | Color | Description |
|-------|-------|-------------|
| PENDING | Yellow | Initial state, awaiting moderator review |
| APPROVED | Green | Final state, changes have been applied |
| REJECTED | Red | Final state, changes were declined |
| ESCALATED | Orange | Needs higher-level review |
### Transitions
| Transition | From | To | Guard | Description |
|------------|------|-----|-------|-------------|
| `transition_to_approved` | PENDING, ESCALATED | APPROVED | `is_moderator` | Approve and apply changes |
| `transition_to_rejected` | PENDING, ESCALATED | REJECTED | `is_moderator` | Reject with reason |
| `transition_to_escalated` | PENDING | ESCALATED | `is_moderator` | Escalate to admin |
---
## PhotoSubmission State Machine
PhotoSubmission tracks user-submitted photos through moderation.
```mermaid
stateDiagram-v2
[*] --> PENDING: User uploads photo
PENDING --> APPROVED: transition_to_approved()
PENDING --> REJECTED: transition_to_rejected()
PENDING --> ESCALATED: transition_to_escalated()
ESCALATED --> APPROVED: transition_to_approved()
ESCALATED --> REJECTED: transition_to_rejected()
APPROVED --> [*]
REJECTED --> [*]
note right of APPROVED
Photo added to gallery
end note
```
### States
| State | Color | Description |
|-------|-------|-------------|
| PENDING | Yellow | Awaiting moderator review |
| APPROVED | Green | Photo approved and visible |
| REJECTED | Red | Photo rejected |
| ESCALATED | Orange | Needs admin review |
---
## Park Status State Machine
Park status tracks the operational status of theme parks.
```mermaid
stateDiagram-v2
[*] --> OPERATING: Park opens
[*] --> UNDER_CONSTRUCTION: Park announced
UNDER_CONSTRUCTION --> OPERATING: transition_to_operating()
OPERATING --> CLOSED_TEMP: transition_to_closed_temp()
OPERATING --> CLOSED_PERM: transition_to_closed_perm()
OPERATING --> UNDER_CONSTRUCTION: transition_to_under_construction()
CLOSED_TEMP --> OPERATING: transition_to_operating()
CLOSED_TEMP --> CLOSED_PERM: transition_to_closed_perm()
CLOSED_PERM --> DEMOLISHED: transition_to_demolished()
CLOSED_PERM --> RELOCATED: transition_to_relocated()
CLOSED_PERM --> OPERATING: transition_to_operating()
DEMOLISHED --> [*]
RELOCATED --> [*]
note right of OPERATING
Green badge
Normal operations
end note
note right of CLOSED_TEMP
Yellow badge
Seasonal or temporary
end note
note right of CLOSED_PERM
Red badge
Permanently closed
end note
note left of DEMOLISHED
Gray badge
Final state
end note
note left of RELOCATED
Gray badge
Final state
end note
```
### States
| State | Color | Description |
|-------|-------|-------------|
| OPERATING | Green | Park is open and operating |
| UNDER_CONSTRUCTION | Blue | Park is being built |
| CLOSED_TEMP | Yellow | Temporarily closed (seasonal, renovation) |
| CLOSED_PERM | Red | Permanently closed |
| DEMOLISHED | Gray | Park has been demolished |
| RELOCATED | Gray | Park has moved to new location |
### Transitions
| Transition | From | To | Guard | Description |
|------------|------|-----|-------|-------------|
| `transition_to_operating` | CLOSED_TEMP, CLOSED_PERM, UNDER_CONSTRUCTION | OPERATING | `is_moderator` | Reopen park |
| `transition_to_closed_temp` | OPERATING | CLOSED_TEMP | `is_moderator` | Close temporarily |
| `transition_to_closed_perm` | OPERATING, CLOSED_TEMP | CLOSED_PERM | `is_moderator` | Close permanently |
| `transition_to_demolished` | CLOSED_PERM | DEMOLISHED | `is_moderator` | Mark as demolished |
| `transition_to_relocated` | CLOSED_PERM | RELOCATED | `is_moderator` | Mark as relocated |
| `transition_to_under_construction` | OPERATING | UNDER_CONSTRUCTION | `is_moderator` | Mark under construction |
---
## Ride Status State Machine
Ride status tracks the operational status of individual rides within parks.
```mermaid
stateDiagram-v2
[*] --> OPERATING: Ride opens
[*] --> UNDER_CONSTRUCTION: Ride announced
UNDER_CONSTRUCTION --> OPERATING: transition_to_operating()
OPERATING --> CLOSED_TEMP: transition_to_closed_temp()
OPERATING --> SBNO: transition_to_sbno()
OPERATING --> CLOSING: transition_to_closing()
OPERATING --> CLOSED_PERM: transition_to_closed_perm()
CLOSED_TEMP --> OPERATING: transition_to_operating()
CLOSED_TEMP --> CLOSED_PERM: transition_to_closed_perm()
SBNO --> OPERATING: transition_to_operating()
SBNO --> CLOSED_PERM: transition_to_closed_perm()
SBNO --> DEMOLISHED: transition_to_demolished()
CLOSING --> CLOSED_PERM: apply_post_closing_status()
CLOSING --> OPERATING: transition_to_operating()
CLOSED_PERM --> DEMOLISHED: transition_to_demolished()
CLOSED_PERM --> RELOCATED: transition_to_relocated()
CLOSED_PERM --> OPERATING: transition_to_operating()
DEMOLISHED --> [*]
RELOCATED --> [*]
note right of SBNO
Amber badge
Standing But Not Operating
end note
note right of CLOSING
Orange badge
Closing date announced
Auto-transitions when date passes
end note
```
### States
| State | Color | Description |
|-------|-------|-------------|
| OPERATING | Green | Ride is open and operating |
| UNDER_CONSTRUCTION | Blue | Ride is being built |
| CLOSED_TEMP | Yellow | Temporarily closed (maintenance, seasonal) |
| SBNO | Amber | Standing But Not Operating |
| CLOSING | Orange | Closing date announced, countdown to close |
| CLOSED_PERM | Red | Permanently closed |
| DEMOLISHED | Gray | Ride has been removed |
| RELOCATED | Gray | Ride has moved to new park |
### Special: CLOSING Status
The CLOSING status has special behavior:
- When a ride enters CLOSING, a `closing_date` should be set
- When the closing date passes, `apply_post_closing_status()` is called
- The ride transitions to the `post_closing_status` (default: CLOSED_PERM)
- This allows announcing closures ahead of time
---
## ModerationReport State Machine
ModerationReport tracks user reports about content or behavior.
```mermaid
stateDiagram-v2
[*] --> PENDING: User submits report
PENDING --> INVESTIGATING: transition_to_investigating()
PENDING --> DISMISSED: transition_to_dismissed()
INVESTIGATING --> RESOLVED: transition_to_resolved()
INVESTIGATING --> DISMISSED: transition_to_dismissed()
INVESTIGATING --> ESCALATED: transition_to_escalated()
ESCALATED --> RESOLVED: transition_to_resolved()
ESCALATED --> DISMISSED: transition_to_dismissed()
RESOLVED --> [*]
DISMISSED --> [*]
note right of INVESTIGATING
Blue badge
Moderator reviewing
end note
note right of RESOLVED
Green badge
Action taken
end note
note right of DISMISSED
Gray badge
No action needed
end note
```
### States
| State | Color | Description |
|-------|-------|-------------|
| PENDING | Yellow | Report awaiting review |
| INVESTIGATING | Blue | Moderator is reviewing |
| ESCALATED | Orange | Needs admin attention |
| RESOLVED | Green | Action taken, issue addressed |
| DISMISSED | Gray | Report dismissed, no action needed |
---
## ModerationQueue State Machine
ModerationQueue tracks workflow items for moderators.
```mermaid
stateDiagram-v2
[*] --> PENDING: Item created
PENDING --> IN_PROGRESS: transition_to_in_progress()
PENDING --> CANCELLED: transition_to_cancelled()
IN_PROGRESS --> COMPLETED: transition_to_completed()
IN_PROGRESS --> CANCELLED: transition_to_cancelled()
IN_PROGRESS --> PENDING: transition_to_pending()
COMPLETED --> [*]
CANCELLED --> [*]
note right of IN_PROGRESS
Blue badge
Being worked on
end note
note right of COMPLETED
Green badge
Task finished
end note
```
### States
| State | Color | Description |
|-------|-------|-------------|
| PENDING | Yellow | Waiting to be picked up |
| IN_PROGRESS | Blue | Currently being worked on |
| COMPLETED | Green | Task finished successfully |
| CANCELLED | Gray | Task cancelled |
---
## BulkOperation State Machine
BulkOperation tracks large-scale administrative operations.
```mermaid
stateDiagram-v2
[*] --> PENDING: Operation created
PENDING --> RUNNING: transition_to_running()
PENDING --> CANCELLED: transition_to_cancelled()
RUNNING --> COMPLETED: transition_to_completed()
RUNNING --> FAILED: transition_to_failed()
RUNNING --> CANCELLED: transition_to_cancelled()
COMPLETED --> [*]
FAILED --> [*]
CANCELLED --> [*]
note right of RUNNING
Blue badge
Processing items
Shows progress %
end note
note right of FAILED
Red badge
Error occurred
end note
```
### States
| State | Color | Description |
|-------|-------|-------------|
| PENDING | Yellow | Scheduled, waiting to start |
| RUNNING | Blue | Currently processing |
| COMPLETED | Green | Finished successfully |
| FAILED | Red | Encountered error |
| CANCELLED | Gray | Manually cancelled |
---
## Common Patterns
### Permission Guards
All transitions include permission guards:
```python
@transition(
field=status,
source='PENDING',
target='APPROVED',
permission='apps.moderation.can_approve_submission'
)
def transition_to_approved(self, user=None):
pass
```
### Confirmation Requirements
Dangerous transitions require confirmation in the UI:
- Reject (any submission)
- Cancel (any operation)
- Close Permanently (park/ride)
- Demolish (park/ride)
### Toast Notifications
All transitions trigger toast notifications:
| Transition Type | Toast Color | Icon |
|-----------------|-------------|------|
| Approve | Green | check |
| Reject | Red | times |
| Escalate | Orange | arrow-up |
| Complete | Green | check-circle |
| Cancel | Red | ban |
---
## Testing Transitions
See the test documentation:
- **Unit tests**: `backend/apps/*/tests.py`
- **Integration tests**: `backend/tests/integration/test_fsm_transition_view.py`
- **E2E tests**: `backend/tests/e2e/test_*_fsm.py`
- **Manual testing**: `backend/tests/e2e/BROWSER_TESTING_CHECKLIST.md`
---
## Implementation Notes
### Adding New Transitions
1. Add the transition method to the model with `@transition` decorator
2. Define source states, target state, and permission guard
3. Update the template to show the new button
4. Add tests for the new transition
5. Update this documentation
### State Field Configuration
States are defined using `RichFSMField` which integrates with:
- django-fsm for transition logic
- django-fsm-log for transition history
- RichChoices for metadata (colors, icons, labels)
```python
status = RichFSMField(
choice_group="statuses",
domain="moderation",
max_length=20,
default="PENDING"
)
```

View File

@@ -149,7 +149,8 @@ ThrillWiki's technical architecture represents a paradigm shift in how theme par
3. **Intelligent Content Moderation**
- Queue-based processing with priority algorithms
- Role-based approval workflows
- Role-based approval workflows with FSM (Finite State Machine) transitions
- HTMX-powered real-time status updates without page reloads
- Automated spam and abuse detection
- Bulk operations for efficient management