mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-22 23:11: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:
@@ -7,6 +7,7 @@ moderation functionality including reports, queue management, actions, and bulk
|
||||
"""
|
||||
|
||||
from django.urls import path, include
|
||||
from django.views.generic import TemplateView
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .views import (
|
||||
@@ -16,6 +17,49 @@ from .views import (
|
||||
BulkOperationViewSet,
|
||||
UserModerationViewSet,
|
||||
)
|
||||
from apps.core.views.views import FSMTransitionView
|
||||
|
||||
|
||||
class ModerationDashboardView(TemplateView):
|
||||
"""Moderation dashboard view with HTMX integration."""
|
||||
template_name = "moderation/dashboard.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
from .models import EditSubmission, PhotoSubmission
|
||||
from .selectors import pending_submissions_for_review
|
||||
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["submissions"] = pending_submissions_for_review()
|
||||
return context
|
||||
|
||||
|
||||
class SubmissionListView(TemplateView):
|
||||
"""Submission list view with filtering."""
|
||||
template_name = "moderation/partials/dashboard_content.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
from .models import EditSubmission, PhotoSubmission
|
||||
from itertools import chain
|
||||
|
||||
context = super().get_context_data(**kwargs)
|
||||
status = self.request.GET.get("status", "PENDING")
|
||||
|
||||
# Get filtered submissions
|
||||
edit_submissions = EditSubmission.objects.filter(status=status).select_related("user")
|
||||
photo_submissions = PhotoSubmission.objects.filter(status=status).select_related("user")
|
||||
|
||||
# Combine and sort
|
||||
context["submissions"] = sorted(
|
||||
chain(edit_submissions, photo_submissions),
|
||||
key=lambda x: x.created_at,
|
||||
reverse=True,
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
class HistoryPageView(TemplateView):
|
||||
"""Main history page view."""
|
||||
template_name = "moderation/history.html"
|
||||
|
||||
# Create router and register viewsets
|
||||
router = DefaultRouter()
|
||||
@@ -27,11 +71,104 @@ router.register(r"users", UserModerationViewSet, basename="user-moderation")
|
||||
|
||||
app_name = "moderation"
|
||||
|
||||
urlpatterns = [
|
||||
# Include all router URLs
|
||||
path("", include(router.urls)),
|
||||
# FSM transition convenience URLs for moderation models
|
||||
fsm_transition_patterns = [
|
||||
# EditSubmission transitions
|
||||
# URL: /api/moderation/submissions/<pk>/transition/<transition_name>/
|
||||
path(
|
||||
"submissions/<int:pk>/transition/<str:transition_name>/",
|
||||
FSMTransitionView.as_view(),
|
||||
{"app_label": "moderation", "model_name": "editsubmission"},
|
||||
name="submission_transition",
|
||||
),
|
||||
# PhotoSubmission transitions
|
||||
# URL: /api/moderation/photos/<pk>/transition/<transition_name>/
|
||||
path(
|
||||
"photos/<int:pk>/transition/<str:transition_name>/",
|
||||
FSMTransitionView.as_view(),
|
||||
{"app_label": "moderation", "model_name": "photosubmission"},
|
||||
name="photo_transition",
|
||||
),
|
||||
# ModerationReport transitions
|
||||
# URL: /api/moderation/reports/<pk>/transition/<transition_name>/
|
||||
path(
|
||||
"reports/<int:pk>/transition/<str:transition_name>/",
|
||||
FSMTransitionView.as_view(),
|
||||
{"app_label": "moderation", "model_name": "moderationreport"},
|
||||
name="report_transition",
|
||||
),
|
||||
# ModerationQueue transitions
|
||||
# URL: /api/moderation/queue/<pk>/transition/<transition_name>/
|
||||
path(
|
||||
"queue/<int:pk>/transition/<str:transition_name>/",
|
||||
FSMTransitionView.as_view(),
|
||||
{"app_label": "moderation", "model_name": "moderationqueue"},
|
||||
name="queue_transition",
|
||||
),
|
||||
# BulkOperation transitions
|
||||
# URL: /api/moderation/bulk/<pk>/transition/<transition_name>/
|
||||
path(
|
||||
"bulk/<int:pk>/transition/<str:transition_name>/",
|
||||
FSMTransitionView.as_view(),
|
||||
{"app_label": "moderation", "model_name": "bulkoperation"},
|
||||
name="bulk_operation_transition",
|
||||
),
|
||||
# Backward compatibility aliases for EditSubmission actions
|
||||
# These redirect the old URL patterns to the FSM transition view
|
||||
path(
|
||||
"submissions/<int:pk>/approve/",
|
||||
FSMTransitionView.as_view(),
|
||||
{"app_label": "moderation", "model_name": "editsubmission", "transition_name": "transition_to_approved"},
|
||||
name="approve_submission",
|
||||
),
|
||||
path(
|
||||
"submissions/<int:pk>/reject/",
|
||||
FSMTransitionView.as_view(),
|
||||
{"app_label": "moderation", "model_name": "editsubmission", "transition_name": "transition_to_rejected"},
|
||||
name="reject_submission",
|
||||
),
|
||||
path(
|
||||
"submissions/<int:pk>/escalate/",
|
||||
FSMTransitionView.as_view(),
|
||||
{"app_label": "moderation", "model_name": "editsubmission", "transition_name": "transition_to_escalated"},
|
||||
name="escalate_submission",
|
||||
),
|
||||
# Backward compatibility aliases for PhotoSubmission actions
|
||||
path(
|
||||
"photos/<int:pk>/approve/",
|
||||
FSMTransitionView.as_view(),
|
||||
{"app_label": "moderation", "model_name": "photosubmission", "transition_name": "transition_to_approved"},
|
||||
name="approve_photo",
|
||||
),
|
||||
path(
|
||||
"photos/<int:pk>/reject/",
|
||||
FSMTransitionView.as_view(),
|
||||
{"app_label": "moderation", "model_name": "photosubmission", "transition_name": "transition_to_rejected"},
|
||||
name="reject_photo",
|
||||
),
|
||||
path(
|
||||
"photos/<int:pk>/escalate/",
|
||||
FSMTransitionView.as_view(),
|
||||
{"app_label": "moderation", "model_name": "photosubmission", "transition_name": "transition_to_escalated"},
|
||||
name="escalate_photo",
|
||||
),
|
||||
]
|
||||
|
||||
# HTML page patterns (for moderation dashboard)
|
||||
html_patterns = [
|
||||
path("", ModerationDashboardView.as_view(), name="dashboard"),
|
||||
path("submissions/", SubmissionListView.as_view(), name="submission_list"),
|
||||
path("history/", HistoryPageView.as_view(), name="history"),
|
||||
]
|
||||
|
||||
urlpatterns = [
|
||||
# HTML page views
|
||||
*html_patterns,
|
||||
# Include all router URLs (API endpoints)
|
||||
path("api/", include(router.urls)),
|
||||
# FSM transition convenience endpoints
|
||||
] + fsm_transition_patterns
|
||||
|
||||
# URL patterns generated by the router:
|
||||
#
|
||||
# Moderation Reports:
|
||||
|
||||
@@ -18,6 +18,8 @@ from django_filters.rest_framework import DjangoFilterBackend
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
from django.db.models import Q, Count
|
||||
from django.shortcuts import render
|
||||
from django.core.paginator import Paginator
|
||||
from datetime import timedelta
|
||||
from django_fsm import can_proceed, TransitionNotAllowed
|
||||
|
||||
@@ -334,31 +336,85 @@ class ModerationReportViewSet(viewsets.ModelViewSet):
|
||||
|
||||
@action(detail=False, methods=['get'], permission_classes=[CanViewModerationData])
|
||||
def all_history(self, request):
|
||||
"""Get all transition history with filtering."""
|
||||
"""Get all transition history with filtering.
|
||||
|
||||
Supports both HTMX (returns HTML partials) and API (returns JSON) requests.
|
||||
"""
|
||||
from django_fsm_log.models import StateLog
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
|
||||
queryset = StateLog.objects.select_related('by', 'content_type').all()
|
||||
|
||||
# Filter by id (for detail view)
|
||||
log_id = request.query_params.get('id')
|
||||
if log_id:
|
||||
queryset = queryset.filter(id=log_id)
|
||||
try:
|
||||
log = queryset.get(id=log_id)
|
||||
# Check if HTMX request for detail view
|
||||
if request.headers.get('HX-Request'):
|
||||
return render(request, 'moderation/partials/history_detail_content.html', {
|
||||
'log': log,
|
||||
})
|
||||
# Return JSON for API request
|
||||
return Response({
|
||||
'id': log.id,
|
||||
'timestamp': log.timestamp,
|
||||
'model': log.content_type.model,
|
||||
'object_id': log.object_id,
|
||||
'state': log.state,
|
||||
'from_state': log.source_state,
|
||||
'to_state': log.state,
|
||||
'transition': log.transition,
|
||||
'user': log.by.username if log.by else None,
|
||||
'description': log.description,
|
||||
'reason': log.description,
|
||||
})
|
||||
except StateLog.DoesNotExist:
|
||||
if request.headers.get('HX-Request'):
|
||||
return render(request, 'moderation/partials/history_detail_content.html', {
|
||||
'log': None,
|
||||
})
|
||||
return Response({'error': 'Log not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# Filter by model type
|
||||
# Filter by model type with app_label support for correct ContentType resolution
|
||||
model_type = request.query_params.get('model_type')
|
||||
app_label = request.query_params.get('app_label')
|
||||
if model_type:
|
||||
try:
|
||||
content_type = ContentType.objects.get(model=model_type)
|
||||
if app_label:
|
||||
# Use both app_label and model for precise matching
|
||||
content_type = ContentType.objects.get_by_natural_key(app_label, model_type)
|
||||
else:
|
||||
# Map common model names to their app_labels for correct resolution
|
||||
model_app_mapping = {
|
||||
'park': 'parks',
|
||||
'ride': 'rides',
|
||||
'editsubmission': 'submissions',
|
||||
'photosubmission': 'submissions',
|
||||
'moderationreport': 'moderation',
|
||||
'moderationqueue': 'moderation',
|
||||
'bulkoperation': 'moderation',
|
||||
}
|
||||
mapped_app_label = model_app_mapping.get(model_type.lower())
|
||||
if mapped_app_label:
|
||||
content_type = ContentType.objects.get_by_natural_key(mapped_app_label, model_type.lower())
|
||||
else:
|
||||
# Fallback to model-only lookup
|
||||
content_type = ContentType.objects.get(model=model_type)
|
||||
queryset = queryset.filter(content_type=content_type)
|
||||
except ContentType.DoesNotExist:
|
||||
pass
|
||||
|
||||
|
||||
# Filter by object_id (for object-level history)
|
||||
object_id = request.query_params.get('object_id')
|
||||
if object_id:
|
||||
queryset = queryset.filter(object_id=object_id)
|
||||
|
||||
# Filter by user
|
||||
user_id = request.query_params.get('user_id')
|
||||
if user_id:
|
||||
queryset = queryset.filter(by_id=user_id)
|
||||
|
||||
|
||||
# Filter by date range
|
||||
start_date = request.query_params.get('start_date')
|
||||
end_date = request.query_params.get('end_date')
|
||||
@@ -366,16 +422,41 @@ class ModerationReportViewSet(viewsets.ModelViewSet):
|
||||
queryset = queryset.filter(timestamp__gte=start_date)
|
||||
if end_date:
|
||||
queryset = queryset.filter(timestamp__lte=end_date)
|
||||
|
||||
|
||||
# Filter by state
|
||||
state = request.query_params.get('state')
|
||||
if state:
|
||||
queryset = queryset.filter(state=state)
|
||||
|
||||
|
||||
# Search filter (case-insensitive across relevant fields)
|
||||
search_query = request.query_params.get('q')
|
||||
if search_query:
|
||||
queryset = queryset.filter(
|
||||
Q(transition__icontains=search_query) |
|
||||
Q(description__icontains=search_query) |
|
||||
Q(state__icontains=search_query) |
|
||||
Q(source_state__icontains=search_query) |
|
||||
Q(object_id__icontains=search_query) |
|
||||
Q(by__username__icontains=search_query)
|
||||
)
|
||||
|
||||
# Order queryset
|
||||
queryset = queryset.order_by('-timestamp')
|
||||
|
||||
# Paginate
|
||||
# Check if HTMX request
|
||||
if request.headers.get('HX-Request'):
|
||||
# Use Django's Paginator for HTMX responses
|
||||
paginator = Paginator(queryset, 20)
|
||||
page_number = request.query_params.get('page', 1)
|
||||
page_obj = paginator.get_page(page_number)
|
||||
|
||||
return render(request, 'moderation/partials/history_table.html', {
|
||||
'history_logs': page_obj,
|
||||
'page_obj': page_obj,
|
||||
'request': request,
|
||||
})
|
||||
|
||||
# Paginate for API response
|
||||
page = self.paginate_queryset(queryset)
|
||||
if page is not None:
|
||||
history_data = [{
|
||||
|
||||
Reference in New Issue
Block a user