mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-22 19:51:08 -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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user