Add test utilities and state machine diagrams for FSM models

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

View File

@@ -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 = [{