diff --git a/backend/api/views/admin.py b/backend/api/views/admin.py index 399f933..8b217d8 100644 --- a/backend/api/views/admin.py +++ b/backend/api/views/admin.py @@ -1,3 +1,5 @@ +import json + from django.conf import settings from django.core.mail import send_mail from rest_framework import permissions, status, viewsets @@ -13,6 +15,46 @@ from api.serializers import ( SystemConfigSerializer, ) +JOB_TASK_MAP = { + "sync_flavors": "api.sync_flavors", + "cleanup_duplicates": "api.cleanup_duplicates", + "backup_db": "api.backup_db", + "seed_legacy": "api.seed_legacy", +} + + +def _sync_periodic_task(job: Job) -> None: + """Create/update a django_celery_beat PeriodicTask for this Job. + + Called when the admin changes interval_hours. interval_hours == 0 disables + the schedule (PeriodicTask.enabled=False). + """ + from django_celery_beat.models import IntervalSchedule, PeriodicTask + + task_name = JOB_TASK_MAP.get(job.name) + if not task_name: + return + + periodic_name = f"job:{job.name}" + + if job.interval_hours <= 0: + PeriodicTask.objects.filter(name=periodic_name).update(enabled=False) + return + + schedule, _ = IntervalSchedule.objects.get_or_create( + every=job.interval_hours, + period=IntervalSchedule.HOURS, + ) + PeriodicTask.objects.update_or_create( + name=periodic_name, + defaults={ + "interval": schedule, + "task": task_name, + "enabled": True, + "kwargs": json.dumps({}), + }, + ) + class AdminViewSet(viewsets.ViewSet): permission_classes = [permissions.IsAdminUser] @@ -63,14 +105,27 @@ class AdminViewSet(viewsets.ViewSet): @action(detail=True, methods=["post"]) def trigger_job(self, request: Request, pk=None) -> Response: + from celery import current_app + 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) + task_name = JOB_TASK_MAP.get(job.name) + if not task_name: + return Response( + {"error": f"No celery task registered for {job.name}"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + job.status = "pending" + job.error_message = "" + job.save(update_fields=["status", "error_message"]) + + current_app.send_task(task_name) + return Response({"status": "Job queued", "task": task_name}) + @action(detail=True, methods=["patch"]) def update_job_schedule(self, request: Request, pk=None) -> Response: try: @@ -99,6 +154,7 @@ class AdminViewSet(viewsets.ViewSet): job.next_run = timezone.now() job.save() + _sync_periodic_task(job) return Response(JobSerializer(job).data) except Job.DoesNotExist: return Response(status=status.HTTP_404_NOT_FOUND) diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index fc54fff..a36ee0b 100755 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -1,47 +1,53 @@ #!/bin/sh -# Exit on error set -e -# Apply database migrations +# Worker/beat share the backend image but must not re-run migrate/seed on every +# restart. Set SKIP_INIT=true on those services. Only the primary backend +# container runs the init block below. +if [ "${SKIP_INIT:-false}" = "true" ]; then + echo "SKIP_INIT=true — skipping migrations/seed." + if [ "$#" -gt 0 ]; then + echo "Executing: $@" + exec "$@" + fi + echo "No command given with SKIP_INIT=true; exiting." + exit 0 +fi + echo "Applying database migrations..." -python manage.py makemigrations -python manage.py migrate +python manage.py migrate --noinput -# Create cache table for ratelimiting echo "Creating cache table..." python manage.py createcachetable -# Collect static files echo "Collecting static files..." python manage.py collectstatic --no-input -# Ensure media and cache directories exist mkdir -p media/flavors mkdir -p django_cache chmod -R 777 django_cache -# Sync flavors from the API -echo "Syncing flavors..." -python manage.py sync_flavors - -# Merge any duplicates that might have been created -echo "Cleaning up duplicates..." -python manage.py cleanup_duplicates - -# Seed legacy flavors if needed -echo "Seeding legacy flavors..." -python manage.py seed_legacy_flavors - -# Auto-sync banner models and settings from JSON files -echo "Syncing banner configurations..." -python manage.py seed_banners +# One-shot seed jobs. These should run on image first boot, not on every +# restart. A marker file on the bind-mounted volume gates them. +SEED_MARKER="/app/.seeded" +if [ ! -f "$SEED_MARKER" ]; then + echo "First boot — running initial seed commands..." + python manage.py sync_flavors || echo "sync_flavors failed (non-fatal on first boot)" + python manage.py cleanup_duplicates || echo "cleanup_duplicates failed (non-fatal)" + python manage.py seed_legacy_flavors || echo "seed_legacy_flavors failed (non-fatal)" + python manage.py seed_banners || echo "seed_banners failed (non-fatal)" + touch "$SEED_MARKER" +else + echo "Seed marker present — skipping seed commands. Trigger them from /admin if needed." + # Banners are cheap + config-driven; always re-sync them. + python manage.py seed_banners || echo "seed_banners failed (non-fatal)" +fi if [ "$#" -gt 0 ]; then echo "Executing custom command: $@" exec "$@" fi -# Start the server using Gunicorn echo "Starting Gunicorn..." exec gunicorn holy_backend.wsgi:application --bind 0.0.0.0:8000 --workers 3 --timeout 120 diff --git a/docker-compose.yml b/docker-compose.yml index 8a954f6..63e2824 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -61,6 +61,7 @@ services: - DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-holy_backend.settings.prod} - CELERY_BROKER_URL=${CELERY_BROKER_URL:-redis://redis:6379/0} - CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND:-redis://redis:6379/0} + - SKIP_INIT=true volumes: - ./backend:/app - ./legacy:/app/legacy @@ -86,6 +87,7 @@ services: - DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-holy_backend.settings.prod} - CELERY_BROKER_URL=${CELERY_BROKER_URL:-redis://redis:6379/0} - CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND:-redis://redis:6379/0} + - SKIP_INIT=true volumes: - ./backend:/app - ./legacy:/app/legacy diff --git a/frontend/src/components/ui/FlavorCard.tsx b/frontend/src/components/ui/FlavorCard.tsx index 31340f0..9ad71b0 100644 --- a/frontend/src/components/ui/FlavorCard.tsx +++ b/frontend/src/components/ui/FlavorCard.tsx @@ -57,7 +57,7 @@ export const FlavorCard = ({ = ({ adminMode }) => { sx={{ display: 'grid', gridTemplateColumns: { - xs: 'repeat(2, 1fr)', - sm: 'repeat(3, 1fr)', - md: 'repeat(4, 1fr)', - lg: 'repeat(5, 1fr)', - xl: 'repeat(6, 1fr)', + xs: 'repeat(2, minmax(0, 1fr))', + sm: 'repeat(3, minmax(0, 1fr))', + md: 'repeat(4, minmax(0, 1fr))', + lg: 'repeat(5, minmax(0, 1fr))', + xl: 'repeat(6, minmax(0, 1fr))', }, gap: 2, + width: '100%', + minWidth: 0, }} > {newestFlavors.map((flavor) => (