mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-22 13:11:12 -05:00
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:
@@ -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": []
|
||||
|
||||
37
backend/apps/core/context_processors.py
Normal file
37
backend/apps/core/context_processors.py
Normal 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,
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -22,7 +22,7 @@ from .registry import (
|
||||
register_transition_callbacks,
|
||||
discover_and_register_callbacks,
|
||||
)
|
||||
from .callbacks import (
|
||||
from .callback_base import (
|
||||
BaseTransitionCallback,
|
||||
PreTransitionCallback,
|
||||
PostTransitionCallback,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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__)
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
1
backend/apps/core/templatetags/__init__.py
Normal file
1
backend/apps/core/templatetags/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Template tags for the core app
|
||||
434
backend/apps/core/templatetags/fsm_tags.py
Normal file
434
backend/apps/core/templatetags/fsm_tags.py
Normal 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',
|
||||
]
|
||||
@@ -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)),
|
||||
]
|
||||
@@ -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)),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = [{
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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"""
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
305
backend/static/js/fsm-transitions.js
Normal file
305
backend/static/js/fsm-transitions.js
Normal 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);
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
58
backend/templates/htmx/state_actions.html
Normal file
58
backend/templates/htmx/state_actions.html
Normal 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 %}
|
||||
148
backend/templates/htmx/status_with_actions.html
Normal file
148
backend/templates/htmx/status_with_actions.html
Normal 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>
|
||||
80
backend/templates/htmx/updated_row.html
Normal file
80
backend/templates/htmx/updated_row.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
|
||||
188
backend/templates/moderation/history.html
Normal file
188
backend/templates/moderation/history.html
Normal 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 %}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
177
backend/templates/moderation/partials/editsubmission_row.html
Normal file
177
backend/templates/moderation/partials/editsubmission_row.html
Normal 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>
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
192
backend/templates/moderation/partials/history_filters.html
Normal file
192
backend/templates/moderation/partials/history_filters.html
Normal 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>
|
||||
158
backend/templates/moderation/partials/history_pagination.html
Normal file
158
backend/templates/moderation/partials/history_pagination.html
Normal 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 %}
|
||||
173
backend/templates/moderation/partials/history_table.html
Normal file
173
backend/templates/moderation/partials/history_table.html
Normal 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>
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
30
backend/templates/parks/partials/park_header_badge.html
Normal file
30
backend/templates/parks/partials/park_header_badge.html
Normal 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>
|
||||
23
backend/templates/parks/partials/park_history.html
Normal file
23
backend/templates/parks/partials/park_history.html
Normal 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>
|
||||
19
backend/templates/parks/partials/park_status_actions.html
Normal file
19
backend/templates/parks/partials/park_status_actions.html
Normal 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>
|
||||
@@ -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 %}
|
||||
|
||||
30
backend/templates/rides/partials/ride_header_badge.html
Normal file
30
backend/templates/rides/partials/ride_header_badge.html
Normal 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>
|
||||
23
backend/templates/rides/partials/ride_history.html
Normal file
23
backend/templates/rides/partials/ride_history.html
Normal 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>
|
||||
26
backend/templates/rides/partials/ride_status_actions.html
Normal file
26
backend/templates/rides/partials/ride_status_actions.html
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
229
backend/tests/e2e/BROWSER_TESTING_CHECKLIST.md
Normal file
229
backend/tests/e2e/BROWSER_TESTING_CHECKLIST.md
Normal 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
|
||||
```
|
||||
@@ -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
|
||||
|
||||
668
backend/tests/e2e/test_fsm_error_handling.py
Normal file
668
backend/tests/e2e/test_fsm_error_handling.py
Normal 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/)
|
||||
505
backend/tests/e2e/test_fsm_permissions.py
Normal file
505
backend/tests/e2e/test_fsm_permissions.py
Normal 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
|
||||
477
backend/tests/e2e/test_moderation_fsm.py
Normal file
477
backend/tests/e2e/test_moderation_fsm.py
Normal 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)
|
||||
591
backend/tests/e2e/test_park_ride_fsm.py
Normal file
591
backend/tests/e2e/test_park_ride_fsm.py
Normal 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/)
|
||||
6
backend/tests/integration/__init__.py
Normal file
6
backend/tests/integration/__init__.py
Normal 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).
|
||||
"""
|
||||
792
backend/tests/integration/test_fsm_transition_view.py
Normal file
792
backend/tests/integration/test_fsm_transition_view.py
Normal 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()
|
||||
28
backend/tests/utils/__init__.py
Normal file
28
backend/tests/utils/__init__.py
Normal 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",
|
||||
]
|
||||
571
backend/tests/utils/fsm_test_helpers.py
Normal file
571
backend/tests/utils/fsm_test_helpers.py
Normal 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
|
||||
}
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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
461
docs/STATE_DIAGRAMS.md
Normal 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"
|
||||
)
|
||||
```
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user