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:
pacnpal
2025-12-21 17:33:24 -05:00
parent b9063ff4f8
commit 7ba0004c93
74 changed files with 11134 additions and 198 deletions

View File

@@ -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}')
)

View File

@@ -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("")