mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-22 10:31:09 -05:00
chore: fix pghistory migration deps and improve htmx utilities
- Update pghistory dependency from 0007 to 0006 in account migrations - Add docstrings and remove unused imports in htmx_forms.py - Add DJANGO_SETTINGS_MODULE bash commands to Claude settings - Add state transition definitions for ride statuses
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
"""
|
||||
Management command for analyzing state transition patterns.
|
||||
|
||||
This command provides insights into transition usage, patterns, and statistics
|
||||
across all models using django-fsm-log.
|
||||
"""
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db.models import Count, Avg, F
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
from django_fsm_log.models import StateLog
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Analyze state transition patterns and generate statistics'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--days',
|
||||
type=int,
|
||||
default=30,
|
||||
help='Number of days to analyze (default: 30)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--model',
|
||||
type=str,
|
||||
help='Specific model to analyze (e.g., editsubmission)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--output',
|
||||
type=str,
|
||||
choices=['console', 'json', 'csv'],
|
||||
default='console',
|
||||
help='Output format (default: console)'
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
days = options['days']
|
||||
model_filter = options['model']
|
||||
output_format = options['output']
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f'\n=== State Transition Analysis (Last {days} days) ===\n')
|
||||
)
|
||||
|
||||
# Filter by date range
|
||||
start_date = timezone.now() - timedelta(days=days)
|
||||
queryset = StateLog.objects.filter(timestamp__gte=start_date)
|
||||
|
||||
# Filter by specific model if provided
|
||||
if model_filter:
|
||||
try:
|
||||
content_type = ContentType.objects.get(model=model_filter.lower())
|
||||
queryset = queryset.filter(content_type=content_type)
|
||||
self.stdout.write(f'Filtering for model: {model_filter}\n')
|
||||
except ContentType.DoesNotExist:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(f'Model "{model_filter}" not found')
|
||||
)
|
||||
return
|
||||
|
||||
# Total transitions
|
||||
total_transitions = queryset.count()
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f'Total Transitions: {total_transitions}\n')
|
||||
)
|
||||
|
||||
if total_transitions == 0:
|
||||
self.stdout.write(
|
||||
self.style.WARNING('No transitions found in the specified period.')
|
||||
)
|
||||
return
|
||||
|
||||
# Most common transitions
|
||||
self.stdout.write(self.style.SUCCESS('\n--- Most Common Transitions ---'))
|
||||
common_transitions = (
|
||||
queryset.values('transition', 'content_type__model')
|
||||
.annotate(count=Count('id'))
|
||||
.order_by('-count')[:10]
|
||||
)
|
||||
|
||||
for t in common_transitions:
|
||||
model_name = t['content_type__model']
|
||||
transition_name = t['transition'] or 'N/A'
|
||||
count = t['count']
|
||||
percentage = (count / total_transitions) * 100
|
||||
self.stdout.write(
|
||||
f" {model_name}.{transition_name}: {count} ({percentage:.1f}%)"
|
||||
)
|
||||
|
||||
# Transitions by model
|
||||
self.stdout.write(self.style.SUCCESS('\n--- Transitions by Model ---'))
|
||||
by_model = (
|
||||
queryset.values('content_type__model')
|
||||
.annotate(count=Count('id'))
|
||||
.order_by('-count')
|
||||
)
|
||||
|
||||
for m in by_model:
|
||||
model_name = m['content_type__model']
|
||||
count = m['count']
|
||||
percentage = (count / total_transitions) * 100
|
||||
self.stdout.write(
|
||||
f" {model_name}: {count} ({percentage:.1f}%)"
|
||||
)
|
||||
|
||||
# Transitions by state
|
||||
self.stdout.write(self.style.SUCCESS('\n--- Final States Distribution ---'))
|
||||
by_state = (
|
||||
queryset.values('state')
|
||||
.annotate(count=Count('id'))
|
||||
.order_by('-count')
|
||||
)
|
||||
|
||||
for s in by_state:
|
||||
state_name = s['state']
|
||||
count = s['count']
|
||||
percentage = (count / total_transitions) * 100
|
||||
self.stdout.write(
|
||||
f" {state_name}: {count} ({percentage:.1f}%)"
|
||||
)
|
||||
|
||||
# Most active users
|
||||
self.stdout.write(self.style.SUCCESS('\n--- Most Active Users ---'))
|
||||
active_users = (
|
||||
queryset.exclude(by__isnull=True)
|
||||
.values('by__username', 'by__id')
|
||||
.annotate(count=Count('id'))
|
||||
.order_by('-count')[:10]
|
||||
)
|
||||
|
||||
for u in active_users:
|
||||
username = u['by__username']
|
||||
user_id = u['by__id']
|
||||
count = u['count']
|
||||
self.stdout.write(
|
||||
f" {username} (ID: {user_id}): {count} transitions"
|
||||
)
|
||||
|
||||
# System vs User transitions
|
||||
system_count = queryset.filter(by__isnull=True).count()
|
||||
user_count = queryset.exclude(by__isnull=True).count()
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('\n--- Transition Attribution ---'))
|
||||
self.stdout.write(f" User-initiated: {user_count} ({(user_count/total_transitions)*100:.1f}%)")
|
||||
self.stdout.write(f" System-initiated: {system_count} ({(system_count/total_transitions)*100:.1f}%)")
|
||||
|
||||
# Daily transition volume
|
||||
self.stdout.write(self.style.SUCCESS('\n--- Daily Transition Volume ---'))
|
||||
daily_stats = (
|
||||
queryset.extra(select={'day': 'date(timestamp)'})
|
||||
.values('day')
|
||||
.annotate(count=Count('id'))
|
||||
.order_by('-day')[:7]
|
||||
)
|
||||
|
||||
for day in daily_stats:
|
||||
date = day['day']
|
||||
count = day['count']
|
||||
self.stdout.write(f" {date}: {count} transitions")
|
||||
|
||||
# Busiest hours
|
||||
self.stdout.write(self.style.SUCCESS('\n--- Busiest Hours (UTC) ---'))
|
||||
hourly_stats = (
|
||||
queryset.extra(select={'hour': 'extract(hour from timestamp)'})
|
||||
.values('hour')
|
||||
.annotate(count=Count('id'))
|
||||
.order_by('-count')[:5]
|
||||
)
|
||||
|
||||
for hour in hourly_stats:
|
||||
hour_val = int(hour['hour'])
|
||||
count = hour['count']
|
||||
self.stdout.write(f" Hour {hour_val:02d}:00: {count} transitions")
|
||||
|
||||
# Transition patterns (common sequences)
|
||||
self.stdout.write(self.style.SUCCESS('\n--- Common Transition Patterns ---'))
|
||||
self.stdout.write(' Analyzing transition sequences...')
|
||||
|
||||
# Get recent objects and their transition sequences
|
||||
recent_objects = (
|
||||
queryset.values('content_type', 'object_id')
|
||||
.distinct()[:100]
|
||||
)
|
||||
|
||||
pattern_counts = {}
|
||||
for obj in recent_objects:
|
||||
transitions = list(
|
||||
StateLog.objects.filter(
|
||||
content_type=obj['content_type'],
|
||||
object_id=obj['object_id']
|
||||
)
|
||||
.order_by('timestamp')
|
||||
.values_list('transition', flat=True)
|
||||
)
|
||||
|
||||
# Create pattern from consecutive transitions
|
||||
if len(transitions) >= 2:
|
||||
pattern = ' → '.join([t or 'N/A' for t in transitions[:3]])
|
||||
pattern_counts[pattern] = pattern_counts.get(pattern, 0) + 1
|
||||
|
||||
# Display top patterns
|
||||
sorted_patterns = sorted(
|
||||
pattern_counts.items(),
|
||||
key=lambda x: x[1],
|
||||
reverse=True
|
||||
)[:5]
|
||||
|
||||
for pattern, count in sorted_patterns:
|
||||
self.stdout.write(f" {pattern}: {count} occurrences")
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f'\n=== Analysis Complete ===\n')
|
||||
)
|
||||
|
||||
# Export options
|
||||
if output_format == 'json':
|
||||
self._export_json(queryset, days)
|
||||
elif output_format == 'csv':
|
||||
self._export_csv(queryset, days)
|
||||
|
||||
def _export_json(self, queryset, days):
|
||||
"""Export analysis results as JSON."""
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
data = {
|
||||
'analysis_date': datetime.now().isoformat(),
|
||||
'period_days': days,
|
||||
'total_transitions': queryset.count(),
|
||||
'transitions': list(
|
||||
queryset.values(
|
||||
'id', 'timestamp', 'state', 'transition',
|
||||
'content_type__model', 'object_id', 'by__username'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
filename = f'transition_analysis_{datetime.now().strftime("%Y%m%d_%H%M%S")}.json'
|
||||
with open(filename, 'w') as f:
|
||||
json.dump(data, f, indent=2, default=str)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f'Exported to {filename}')
|
||||
)
|
||||
|
||||
def _export_csv(self, queryset, days):
|
||||
"""Export analysis results as CSV."""
|
||||
import csv
|
||||
from datetime import datetime
|
||||
|
||||
filename = f'transition_analysis_{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv'
|
||||
|
||||
with open(filename, 'w', newline='') as f:
|
||||
writer = csv.writer(f)
|
||||
writer.writerow([
|
||||
'ID', 'Timestamp', 'Model', 'Object ID',
|
||||
'State', 'Transition', 'User'
|
||||
])
|
||||
|
||||
for log in queryset.select_related('content_type', 'by'):
|
||||
writer.writerow([
|
||||
log.id,
|
||||
log.timestamp,
|
||||
log.content_type.model,
|
||||
log.object_id,
|
||||
log.state,
|
||||
log.transition or 'N/A',
|
||||
log.by.username if log.by else 'System'
|
||||
])
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f'Exported to {filename}')
|
||||
)
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Management command to validate state machine configurations for moderation models."""
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.core.management import CommandError
|
||||
|
||||
from apps.core.state_machine import MetadataValidator
|
||||
from apps.moderation.models import (
|
||||
EditSubmission,
|
||||
ModerationReport,
|
||||
ModerationQueue,
|
||||
BulkOperation,
|
||||
PhotoSubmission,
|
||||
)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Validate state machine configurations for all moderation models."""
|
||||
|
||||
help = (
|
||||
"Validates state machine configurations for all moderation models. "
|
||||
"Checks metadata, transitions, and FSM field setup."
|
||||
)
|
||||
|
||||
def add_arguments(self, parser):
|
||||
"""Add command arguments."""
|
||||
parser.add_argument(
|
||||
"--model",
|
||||
type=str,
|
||||
help=(
|
||||
"Validate only specific model "
|
||||
"(editsubmission, moderationreport, moderationqueue, "
|
||||
"bulkoperation, photosubmission)"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose",
|
||||
action="store_true",
|
||||
help="Show detailed validation information",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""Execute the command."""
|
||||
model_name = options.get("model")
|
||||
verbose = options.get("verbose", False)
|
||||
|
||||
# Define models to validate
|
||||
models_to_validate = {
|
||||
"editsubmission": (
|
||||
EditSubmission,
|
||||
"edit_submission_statuses",
|
||||
"moderation",
|
||||
),
|
||||
"moderationreport": (
|
||||
ModerationReport,
|
||||
"moderation_report_statuses",
|
||||
"moderation",
|
||||
),
|
||||
"moderationqueue": (
|
||||
ModerationQueue,
|
||||
"moderation_queue_statuses",
|
||||
"moderation",
|
||||
),
|
||||
"bulkoperation": (
|
||||
BulkOperation,
|
||||
"bulk_operation_statuses",
|
||||
"moderation",
|
||||
),
|
||||
"photosubmission": (
|
||||
PhotoSubmission,
|
||||
"photo_submission_statuses",
|
||||
"moderation",
|
||||
),
|
||||
}
|
||||
|
||||
# Filter by model name if specified
|
||||
if model_name:
|
||||
model_key = model_name.lower()
|
||||
if model_key not in models_to_validate:
|
||||
raise CommandError(
|
||||
f"Unknown model: {model_name}. "
|
||||
f"Valid options: {', '.join(models_to_validate.keys())}"
|
||||
)
|
||||
models_to_validate = {model_key: models_to_validate[model_key]}
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS("\nValidating State Machine Configurations\n")
|
||||
)
|
||||
self.stdout.write("=" * 60 + "\n")
|
||||
|
||||
all_valid = True
|
||||
for model_key, (
|
||||
model_class,
|
||||
choice_group,
|
||||
domain,
|
||||
) in models_to_validate.items():
|
||||
self.stdout.write(f"\nValidating {model_class.__name__}...")
|
||||
self.stdout.write(f" Choice Group: {choice_group}")
|
||||
self.stdout.write(f" Domain: {domain}\n")
|
||||
|
||||
# Validate metadata
|
||||
validator = MetadataValidator(choice_group, domain)
|
||||
result = validator.validate_choice_group()
|
||||
|
||||
if result.is_valid:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f" ✓ {model_class.__name__} validation passed"
|
||||
)
|
||||
)
|
||||
|
||||
if verbose:
|
||||
self._show_transition_graph(choice_group, domain)
|
||||
else:
|
||||
all_valid = False
|
||||
self.stdout.write(
|
||||
self.style.ERROR(
|
||||
f" ✗ {model_class.__name__} validation failed"
|
||||
)
|
||||
)
|
||||
|
||||
for error in result.errors:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(f" - {error.message}")
|
||||
)
|
||||
|
||||
# Check FSM field
|
||||
if not self._check_fsm_field(model_class):
|
||||
all_valid = False
|
||||
self.stdout.write(
|
||||
self.style.ERROR(
|
||||
f" - FSM field 'status' not found on "
|
||||
f"{model_class.__name__}"
|
||||
)
|
||||
)
|
||||
|
||||
# Check mixin
|
||||
if not self._check_state_machine_mixin(model_class):
|
||||
all_valid = False
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
f" - StateMachineMixin not found on "
|
||||
f"{model_class.__name__}"
|
||||
)
|
||||
)
|
||||
|
||||
self.stdout.write("\n" + "=" * 60)
|
||||
if all_valid:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
"\n✓ All validations passed successfully!\n"
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(
|
||||
"\n✗ Some validations failed. "
|
||||
"Please review the errors above.\n"
|
||||
)
|
||||
)
|
||||
raise CommandError("State machine validation failed")
|
||||
|
||||
def _check_fsm_field(self, model_class):
|
||||
"""Check if model has FSM field."""
|
||||
from apps.core.state_machine import RichFSMField
|
||||
|
||||
status_field = model_class._meta.get_field("status")
|
||||
return isinstance(status_field, RichFSMField)
|
||||
|
||||
def _check_state_machine_mixin(self, model_class):
|
||||
"""Check if model uses StateMachineMixin."""
|
||||
from apps.core.state_machine import StateMachineMixin
|
||||
|
||||
return issubclass(model_class, StateMachineMixin)
|
||||
|
||||
def _show_transition_graph(self, choice_group, domain):
|
||||
"""Show transition graph for choice group."""
|
||||
from apps.core.state_machine import registry_instance
|
||||
|
||||
self.stdout.write("\n Transition Graph:")
|
||||
|
||||
graph = registry_instance.export_transition_graph(
|
||||
choice_group, domain
|
||||
)
|
||||
|
||||
for source, targets in sorted(graph.items()):
|
||||
if targets:
|
||||
for target in sorted(targets):
|
||||
self.stdout.write(f" {source} -> {target}")
|
||||
else:
|
||||
self.stdout.write(f" {source} (no transitions)")
|
||||
|
||||
self.stdout.write("")
|
||||
Reference in New Issue
Block a user