mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-04 00:47:02 +01:00
expanded admin features: direct flavor editing, live configuration, and background job management
This commit is contained in:
1 parent
60eadae803
commit
be61b6bbcd
9 files changed
+567
-76
No files matched your search
@@ -54,5 +54,8 @@ class Command(BaseCommand):
|
||||
if os.path.isfile(file_path):
|
||||
file_time = datetime.fromtimestamp(os.path.getmtime(file_path))
|
||||
if file_time < cutoff:
|
||||
os.remove(file_path)
|
||||
self.stdout.write(f" -> Deleted: {filename}")
|
||||
try:
|
||||
os.remove(file_path)
|
||||
self.stdout.write(self.style.SUCCESS(f" -> Deleted old backup: {filename}"))
|
||||
except Exception as e:
|
||||
self.stdout.write(self.style.WARNING(f" -> Failed to delete {filename}: {e}"))
|
||||
@@ -31,14 +31,16 @@ class Command(BaseCommand):
|
||||
|
||||
for other in others:
|
||||
# Re-assign ratings
|
||||
Rating.objects.filter(flavor=other).update(flavor=keep)
|
||||
# Note: This might trigger unique constraint errors on Rating (user, flavor)
|
||||
# if the user rated both duplicates. We'll handle that if needed.
|
||||
for rating in Rating.objects.filter(flavor=other):
|
||||
try:
|
||||
rating.flavor = keep
|
||||
rating.save()
|
||||
except Exception:
|
||||
# User already has a rating for the 'keep' flavor
|
||||
# Delete the duplicate rating instead
|
||||
rating.delete()
|
||||
|
||||
# Re-assign other related models if any (e.g. notifications, etc.)
|
||||
# In our case, Rating is the main one.
|
||||
|
||||
# Delete the duplicate
|
||||
# Delete the duplicate flavor
|
||||
other.delete()
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("Finished cleaning up duplicates."))
|
||||
@@ -0,0 +1,74 @@
|
||||
import time
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.core.management import call_command
|
||||
from django.utils import timezone
|
||||
from api.models import Job
|
||||
from io import StringIO
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Background worker to process scheduled and on-demand jobs.'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
self.stdout.write(self.style.SUCCESS("Starting Holy Archive Worker..."))
|
||||
|
||||
# Ensure all core jobs exist in the DB
|
||||
for job_type, label in Job.JOB_TYPES:
|
||||
Job.objects.get_or_create(name=job_type)
|
||||
|
||||
while True:
|
||||
# 1. Find jobs that are either explicitly set to 'pending' (manual trigger)
|
||||
# or those where next_run has passed.
|
||||
now = timezone.now()
|
||||
jobs_to_run = Job.objects.filter(
|
||||
status__in=['pending', 'failed']
|
||||
).filter(
|
||||
# Run if pending OR (next_run is set AND has passed)
|
||||
models.Q(status='pending') |
|
||||
models.Q(next_run__lte=now, interval_hours__gt=0)
|
||||
)
|
||||
|
||||
for job in jobs_to_run:
|
||||
self.run_job(job)
|
||||
|
||||
time.sleep(30) # Check every 30 seconds
|
||||
|
||||
def run_job(self, job):
|
||||
self.stdout.write(f"Executing Job: {job.get_name_display()}...")
|
||||
job.status = 'running'
|
||||
job.last_run = timezone.now()
|
||||
job.save()
|
||||
|
||||
# Capture management command output
|
||||
out = StringIO()
|
||||
error = ""
|
||||
|
||||
try:
|
||||
if job.name == 'sync_flavors':
|
||||
call_command('sync_flavors', stdout=out)
|
||||
elif job.name == 'cleanup_duplicates':
|
||||
call_command('cleanup_duplicates', stdout=out)
|
||||
elif job.name == 'backup_db':
|
||||
call_command('backup_db', stdout=out)
|
||||
elif job.name == 'seed_legacy':
|
||||
call_command('seed_legacy_flavors', stdout=out)
|
||||
|
||||
job.status = 'completed'
|
||||
except Exception as e:
|
||||
job.status = 'failed'
|
||||
error = str(e)
|
||||
self.stdout.write(self.style.ERROR(f"Job failed: {error}"))
|
||||
|
||||
# Update scheduling for next run if interval is set
|
||||
if job.interval_hours > 0:
|
||||
job.next_run = timezone.now() + timedelta(hours=job.interval_hours)
|
||||
else:
|
||||
job.next_run = None
|
||||
|
||||
job.last_output = out.getvalue()
|
||||
job.error_message = error
|
||||
job.save()
|
||||
self.stdout.write(self.style.SUCCESS(f"Job finished: {job.status}"))
|
||||
|
||||
from django.db import models # Needed for the Q object inside handle
|
||||
@@ -216,3 +216,51 @@ class Banner(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class SystemConfig(models.Model):
|
||||
"""Singleton model for application-wide settings editable via UI."""
|
||||
site_name = models.CharField(max_length=100, default="Holy Flavors Archive")
|
||||
maintenance_mode = models.BooleanField(default=False)
|
||||
allow_new_signups = models.BooleanField(default=True)
|
||||
require_email_verification = models.BooleanField(default=True)
|
||||
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.pk = 1 # Force singleton
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def get_solo(cls):
|
||||
obj, _ = cls.objects.get_or_create(pk=1)
|
||||
return obj
|
||||
|
||||
class Meta:
|
||||
verbose_name = "System Configuration"
|
||||
|
||||
class Job(models.Model):
|
||||
"""Tracks background jobs, their status and scheduling."""
|
||||
JOB_TYPES = [
|
||||
('sync_flavors', 'Sync Shopify Flavors'),
|
||||
('cleanup_duplicates', 'Cleanup Duplicate Flavors'),
|
||||
('backup_db', 'Database Backup'),
|
||||
('seed_legacy', 'Seed Legacy Data'),
|
||||
]
|
||||
STATUS_CHOICES = [
|
||||
('pending', 'Pending'),
|
||||
('running', 'Running'),
|
||||
('completed', 'Completed'),
|
||||
('failed', 'Failed'),
|
||||
]
|
||||
|
||||
name = models.CharField(max_length=100, choices=JOB_TYPES)
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending')
|
||||
last_run = models.DateTimeField(null=True, blank=True)
|
||||
next_run = models.DateTimeField(null=True, blank=True)
|
||||
interval_hours = models.IntegerField(default=0, help_text="Set to 0 to disable periodic run.")
|
||||
|
||||
last_output = models.TextField(blank=True)
|
||||
error_message = models.TextField(blank=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.get_name_display()} ({self.status})"
|
||||
@@ -1,11 +1,24 @@
|
||||
from rest_framework import serializers
|
||||
from .models import User, Flavor, Category, Rating, Reply, Notification, Ticket, TicketMessage, UserIP, Banner
|
||||
from .models import User, Flavor, Category, Rating, Reply, Notification, Ticket, TicketMessage, UserIP, Banner, SystemConfig, Job
|
||||
|
||||
class CategorySerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Category
|
||||
fields = ['id', 'name', 'slug']
|
||||
|
||||
class SystemConfigSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = SystemConfig
|
||||
fields = ['site_name', 'maintenance_mode', 'allow_new_signups', 'require_email_verification', 'updated_at']
|
||||
|
||||
class JobSerializer(serializers.ModelSerializer):
|
||||
name_display = serializers.CharField(source='get_name_display', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Job
|
||||
fields = ['id', 'name', 'name_display', 'status', 'last_run', 'next_run', 'interval_hours', 'last_output', 'error_message']
|
||||
read_only_fields = ['status', 'last_run', 'next_run', 'last_output', 'error_message']
|
||||
|
||||
class BannerSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Banner
|
||||
|
||||
@@ -865,9 +865,64 @@ class AdminViewSet(viewsets.ViewSet):
|
||||
'use_tls': getattr(settings, 'EMAIL_USE_TLS', False),
|
||||
'use_ssl': getattr(settings, 'EMAIL_USE_SSL', False),
|
||||
'skip_verify': getattr(settings, 'EMAIL_SKIP_CERT_VERIFICATION', False),
|
||||
},
|
||||
'server_info': {
|
||||
'debug': settings.DEBUG,
|
||||
'allowed_hosts': settings.ALLOWED_HOSTS,
|
||||
'frontend_url': getattr(settings, 'FRONTEND_URL', 'Not set'),
|
||||
'media_root': settings.MEDIA_ROOT,
|
||||
'static_root': settings.STATIC_ROOT,
|
||||
}
|
||||
})
|
||||
|
||||
@action(detail=False, methods=['get', 'patch'])
|
||||
def config(self, request):
|
||||
config = SystemConfig.get_solo()
|
||||
if request.method == 'PATCH':
|
||||
serializer = SystemConfigSerializer(config, data=request.data, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save()
|
||||
return Response(serializer.data)
|
||||
|
||||
serializer = SystemConfigSerializer(config)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def jobs(self, request):
|
||||
# Ensure all jobs exist
|
||||
for job_type, label in Job.JOB_TYPES:
|
||||
Job.objects.get_or_create(name=job_type)
|
||||
|
||||
jobs = Job.objects.all().order_by('name')
|
||||
serializer = JobSerializer(jobs, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def trigger_job(self, request, pk=None):
|
||||
try:
|
||||
job = Job.objects.get(pk=pk)
|
||||
job.status = 'pending'
|
||||
job.save()
|
||||
return Response({'status': 'Job queued'})
|
||||
except Job.DoesNotExist:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
@action(detail=True, methods=['patch'])
|
||||
def update_job_schedule(self, request, pk=None):
|
||||
try:
|
||||
job = Job.objects.get(pk=pk)
|
||||
interval = request.data.get('interval_hours')
|
||||
if interval is not None:
|
||||
job.interval_hours = int(interval)
|
||||
# If we just enabled it, set next_run
|
||||
if job.interval_hours > 0 and not job.next_run:
|
||||
from django.utils import timezone
|
||||
job.next_run = timezone.now()
|
||||
job.save()
|
||||
return Response(JobSerializer(job).data)
|
||||
except Job.DoesNotExist:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def send_test_email(self, request):
|
||||
try:
|
||||
|
||||
Reference in new issue
Block a user