This commit is contained in:
nvrl committed 2026-04-11 10:37:49 +02:00
1 parent 5e3474dd59
commit e4d59a13ba
119 files changed
+15490 -9975

No files matched your search

+36 -26
View File
@@ -1,38 +1,43 @@
import sqlite3
import os
import shutil
import sqlite3
from datetime import datetime, timedelta
from django.conf import settings
from django.core.management.base import BaseCommand
class Command(BaseCommand):
help = 'Creates a safe, consistent backup of the SQLite database and manages rotation.'
help = "Creates a safe, consistent backup of the SQLite database and manages rotation."
def add_arguments(self, parser):
parser.add_argument('--output', type=str, help='Specific output path for the backup file')
parser.add_argument('--retention', type=int, default=7, help='Number of days to keep old backups')
parser.add_argument('--full', action='store_true', help='Create a full archive including media')
parser.add_argument("--output", type=str, help="Specific output path for the backup file")
parser.add_argument(
"--retention", type=int, default=7, help="Number of days to keep old backups"
)
parser.add_argument(
"--full", action="store_true", help="Create a full archive including media"
)
def handle(self, *args, **options):
db_conf = settings.DATABASES['default']
if db_conf['ENGINE'] != 'django.db.backends.sqlite3':
db_conf = settings.DATABASES["default"]
if db_conf["ENGINE"] != "django.db.backends.sqlite3":
self.stdout.write(self.style.ERROR("This command only supports SQLite."))
return
db_path = str(db_conf['NAME'])
backup_dir = os.path.join(settings.BASE_DIR, 'backups')
db_path = str(db_conf["NAME"])
backup_dir = os.path.join(settings.BASE_DIR, "backups")
os.makedirs(backup_dir, exist_ok=True)
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
# 1. Database Backup
if options['output']:
db_backup_path = options['output']
if options["output"]:
db_backup_path = options["output"]
else:
db_backup_path = os.path.join(backup_dir, f'db_backup_{timestamp}.sqlite3')
db_backup_path = os.path.join(backup_dir, f"db_backup_{timestamp}.sqlite3")
self.stdout.write(f"Cloning database {db_path}...")
try:
src = sqlite3.connect(db_path)
dst = sqlite3.connect(db_backup_path)
@@ -46,12 +51,13 @@ class Command(BaseCommand):
return
# 2. Full Archive (Optional)
if options['full']:
if options["full"]:
import tarfile
archive_name = f"full_backup_{timestamp}.tar.gz"
archive_path = os.path.join(backup_dir, archive_name)
self.stdout.write(f"Creating full archive at {archive_path}...")
try:
with tarfile.open(archive_path, "w:gz") as tar:
# Add database
@@ -60,19 +66,19 @@ class Command(BaseCommand):
media_dir = str(settings.MEDIA_ROOT)
if os.path.exists(media_dir):
tar.add(media_dir, arcname="media")
# Remove the raw sqlite backup after bundling it into the archive
if not options['output']:
if not options["output"]:
os.remove(db_backup_path)
self.stdout.write(self.style.SUCCESS(f"Full archive created successfully."))
self.stdout.write(self.style.SUCCESS("Full archive created successfully."))
except Exception as e:
self.stdout.write(self.style.ERROR(f"Archive creation failed: {e}"))
# 3. Cleanup Old Backups
retention_days = options['retention']
retention_days = options["retention"]
cutoff = datetime.now() - timedelta(days=retention_days)
self.stdout.write(f"Cleaning up backups older than {retention_days} days...")
for filename in os.listdir(backup_dir):
file_path = os.path.join(backup_dir, filename)
@@ -81,6 +87,10 @@ class Command(BaseCommand):
if file_time < cutoff:
try:
os.remove(file_path)
self.stdout.write(self.style.SUCCESS(f" -> Deleted old backup: {filename}"))
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}"))
self.stdout.write(
self.style.WARNING(f" -> Failed to delete {filename}: {e}")
)
@@ -1,34 +1,39 @@
from django.core.management.base import BaseCommand
from api.models import Flavor, Rating
from django.db.models import Count
from api.models import Flavor, Rating
class Command(BaseCommand):
help = 'Identifies and merges duplicate flavors (same name and category)'
help = "Identifies and merges duplicate flavors (same name and category)"
def handle(self, *args, **options):
# Find duplicates based on name and category
duplicates = Flavor.objects.values('name', 'category').annotate(
name_count=Count('id')
).filter(name_count__gt=1)
duplicates = (
Flavor.objects.values("name", "category")
.annotate(name_count=Count("id"))
.filter(name_count__gt=1)
)
self.stdout.write(f"Found {duplicates.count()} sets of duplicates.")
for dup in duplicates:
name = dup['name']
category_id = dup['category']
name = dup["name"]
category_id = dup["category"]
# Get all flavors in this set, ordered by having an external_id first, then by date
flavor_set = Flavor.objects.filter(
name=name,
category_id=category_id
).order_by('-external_id', 'created_at')
flavor_set = Flavor.objects.filter(name=name, category_id=category_id).order_by(
"-external_id", "created_at"
)
# Keep the first one, merge others into it
keep = flavor_set[0]
others = flavor_set[1:]
self.stdout.write(f"Merging {len(others)} duplicates into '{keep.name}' (ID: {keep.id}, Ext: {keep.external_id})")
self.stdout.write(
f"Merging {len(others)} duplicates into '{keep.name}' (ID: {keep.id}, Ext: {keep.external_id})"
)
for other in others:
# Re-assign ratings
for rating in Rating.objects.filter(flavor=other):
@@ -39,7 +44,7 @@ class Command(BaseCommand):
# User already has a rating for the 'keep' flavor
# Delete the duplicate rating instead
rating.delete()
# Delete the duplicate flavor
other.delete()
+33 -22
View File
@@ -1,64 +1,75 @@
from django.core.management.base import BaseCommand
from api.models import Banner
import os
import json
import os
from django.core.management.base import BaseCommand
from api.models import Banner
class Command(BaseCommand):
help = 'Seeds banner models and missing settings from JSON files in backend/banners/'
help = "Seeds banner models and missing settings from JSON files in backend/banners/"
def handle(self, *args, **options):
# Determine the banners directory relative to the project root
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
banners_dir = os.path.join(project_root, 'banners')
banners_dir = os.path.join(project_root, "banners")
if not os.path.exists(banners_dir):
self.stdout.write(self.style.WARNING(f"Banners directory not found at {banners_dir}"))
return
for filename in os.listdir(banners_dir):
if filename.endswith('.json'):
if filename.endswith(".json"):
filepath = os.path.join(banners_dir, filename)
try:
with open(filepath, 'r') as f:
with open(filepath) as f:
data = json.load(f)
slug = data.get('slug')
slug = data.get("slug")
if not slug:
continue
banner, created = Banner.objects.get_or_create(
slug=slug,
defaults={
'name': data.get('name', slug),
'description': data.get('description', ''),
'settings': data.get('settings', {})
}
"name": data.get("name", slug),
"description": data.get("description", ""),
"settings": data.get("settings", {}),
},
)
if not created:
# Sync missing settings from JSON to DB without overwriting existing keys
db_settings = banner.settings or {}
json_settings = data.get('settings', {})
json_schema = data.get('schema', [])
json_settings = data.get("settings", {})
json_schema = data.get("schema", [])
updated = False
# Sync missing settings
for key, value in json_settings.items():
if key not in db_settings:
db_settings[key] = value
updated = True
# Always sync schema as it represents the UI structure
if banner.schema != json_schema:
banner.schema = json_schema
updated = True
if updated:
banner.settings = db_settings
banner.save()
self.stdout.write(self.style.SUCCESS(f"Synced new settings for banner '{slug}' from {filename}"))
self.stdout.write(
self.style.SUCCESS(
f"Synced new settings for banner '{slug}' from {filename}"
)
)
else:
self.stdout.write(self.style.SUCCESS(f"Created new banner '{slug}' from {filename}"))
self.stdout.write(
self.style.SUCCESS(f"Created new banner '{slug}' from {filename}")
)
except Exception as e:
self.stdout.write(self.style.ERROR(f"Failed to sync banner from {filename}: {e}"))
self.stdout.write(
self.style.ERROR(f"Failed to sync banner from {filename}: {e}")
)
@@ -1,41 +1,45 @@
import json
import os
import requests
from django.core.management.base import BaseCommand
from django.core.files import File
from django.conf import settings
from django.core.management.base import BaseCommand
from django.utils.text import slugify
from api.models import Category, Flavor
class Command(BaseCommand):
help = 'Adds legacy flavors from JSON files in the legacy/ folder'
help = "Adds legacy flavors from JSON files in the legacy/ folder"
def download_image(self, url, flavor_name):
if not url:
return None
try:
safe_name = slugify(flavor_name)
ext = url.split('.')[-1].split('?')[0].lower()
if ext not in ['jpg', 'jpeg', 'png', 'webp', 'gif']:
ext = 'png'
ext = url.split(".")[-1].split("?")[0].lower()
if ext not in ["jpg", "jpeg", "png", "webp", "gif"]:
ext = "png"
filename = f"legacy_{safe_name}.{ext}"
filepath = os.path.join(settings.MEDIA_ROOT, 'flavors', filename)
filepath = os.path.join(settings.MEDIA_ROOT, "flavors", filename)
os.makedirs(os.path.dirname(filepath), exist_ok=True)
response = requests.get(url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=10)
response = requests.get(url, headers={"User-Agent": "Mozilla/5.0"}, timeout=10)
response.raise_for_status()
if os.path.exists(filepath):
os.remove(filepath)
with open(filepath, 'wb') as f:
with open(filepath, "wb") as f:
f.write(response.content)
return f"flavors/{filename}"
except Exception as e:
self.stdout.write(self.style.WARNING(f"Failed to download image for {flavor_name}: {e}"))
self.stdout.write(
self.style.WARNING(f"Failed to download image for {flavor_name}: {e}")
)
return None
def handle(self, *args, **options):
@@ -43,88 +47,103 @@ class Command(BaseCommand):
# Instead, we update existing entries.
cat_map = {
'energy': Category.objects.get_or_create(name='Energy', defaults={'slug': 'energy'})[0],
'hydration': Category.objects.get_or_create(name='Hydration', defaults={'slug': 'hydration'})[0],
'iced-tea': Category.objects.get_or_create(name='Iced Tea', defaults={'slug': 'iced-tea'})[0],
'milkshake': Category.objects.get_or_create(name='Milkshake', defaults={'slug': 'milkshake'})[0],
'packs': Category.objects.get_or_create(name='Packs and other', defaults={'slug': 'packs-and-other'})[0],
"energy": Category.objects.get_or_create(name="Energy", defaults={"slug": "energy"})[0],
"hydration": Category.objects.get_or_create(
name="Hydration", defaults={"slug": "hydration"}
)[0],
"iced-tea": Category.objects.get_or_create(
name="Iced Tea", defaults={"slug": "iced-tea"}
)[0],
"milkshake": Category.objects.get_or_create(
name="Milkshake", defaults={"slug": "milkshake"}
)[0],
"packs": Category.objects.get_or_create(
name="Packs and other", defaults={"slug": "packs-and-other"}
)[0],
}
legacy_dir = os.path.join(settings.BASE_DIR, 'legacy')
legacy_dir = os.path.join(settings.BASE_DIR, "legacy")
if not os.path.exists(legacy_dir):
legacy_dir = os.path.join(settings.BASE_DIR, '..', 'legacy')
legacy_dir = os.path.join(settings.BASE_DIR, "..", "legacy")
if not os.path.exists(legacy_dir):
self.stdout.write(self.style.ERROR(f"Legacy directory not found."))
self.stdout.write(self.style.ERROR("Legacy directory not found."))
return
json_files = [f for f in os.listdir(legacy_dir) if f.endswith('.json')]
json_files = [f for f in os.listdir(legacy_dir) if f.endswith(".json")]
processed_count = 0
for json_file in json_files:
file_path = os.path.join(legacy_dir, json_file)
file_lower = json_file.lower()
# Determine category from filename
if 'energy' in file_lower:
file_cat = cat_map['energy']
elif 'iced_tea' in file_lower or 'iced-tea' in file_lower:
file_cat = cat_map['iced-tea']
elif 'hydration' in file_lower:
file_cat = cat_map['hydration']
elif 'milkshake' in file_lower:
file_cat = cat_map['milkshake']
if "energy" in file_lower:
file_cat = cat_map["energy"]
elif "iced_tea" in file_lower or "iced-tea" in file_lower:
file_cat = cat_map["iced-tea"]
elif "hydration" in file_lower:
file_cat = cat_map["hydration"]
elif "milkshake" in file_lower:
file_cat = cat_map["milkshake"]
else:
file_cat = None
with open(file_path, 'r', encoding='utf-8') as f:
with open(file_path, encoding="utf-8") as f:
data_list = json.load(f)
for data in data_list:
name = data.get('Name')
desc = data.get('Beschreibung')
taste = data.get('Geschmack')
status_text = data.get('Status')
image_url = data.get('Bild_URL')
name = data.get("Name")
desc = data.get("Beschreibung")
taste = data.get("Geschmack")
status_text = data.get("Status")
image_url = data.get("Bild_URL")
full_desc = f"**Geschmack:** {taste}\n\n{desc}\n\n**Status:** {status_text}"
# Determine final category
cat = file_cat
if not cat:
# Fallback to name/desc detection if filename was ambiguous
if "hydration" in name.lower() or "hydration" in desc.lower():
cat = cat_map['hydration']
cat = cat_map["hydration"]
elif "iced tea" in name.lower() or "eistee" in name.lower():
cat = cat_map['iced-tea']
cat = cat_map["iced-tea"]
elif "milkshake" in name.lower():
cat = cat_map['milkshake']
elif any(k in name.lower() for k in ['bundle', 'set', 'box', 'probe', 'sample', 'taster', 'shaker', 'starter']):
cat = cat_map['packs']
cat = cat_map["milkshake"]
elif any(
k in name.lower()
for k in [
"bundle",
"set",
"box",
"probe",
"sample",
"taster",
"shaker",
"starter",
]
):
cat = cat_map["packs"]
else:
cat = cat_map['energy'] # Ultimate fallback
cat = cat_map["energy"] # Ultimate fallback
# Try to find existing flavor by name and category regardless of legacy status
flavor = Flavor.objects.filter(name__iexact=name, category=cat).first()
# If it exists and is an active Shopify product, skip legacy processing
if flavor and not flavor.is_legacy:
self.stdout.write(f"Skipping active flavor: {name}")
continue
if not flavor:
flavor = Flavor(
name=name,
category=cat,
is_legacy=True,
external_id=None
)
flavor = Flavor(name=name, category=cat, is_legacy=True, external_id=None)
self.stdout.write(f"Adding new legacy flavor: {name}")
else:
self.stdout.write(f"Updating legacy flavor: {name}")
flavor.description = full_desc
flavor.is_available = False # Legacy is always unavailable via API
flavor.is_available = False # Legacy is always unavailable via API
if image_url:
# Check if local file exists
@@ -140,9 +159,11 @@ class Command(BaseCommand):
if rel_path:
flavor.image = rel_path
self.stdout.write(f" -> Updated image for: {name}")
flavor.image_url = image_url
flavor.save()
processed_count += 1
self.stdout.write(self.style.SUCCESS(f"Finished! Processed {processed_count} legacy flavors."))
self.stdout.write(
self.style.SUCCESS(f"Finished! Processed {processed_count} legacy flavors.")
)
+103 -67
View File
@@ -1,21 +1,22 @@
import json
import requests
import re
import os
from django.core.management.base import BaseCommand
from django.core.files import File
import re
import requests
from django.conf import settings
from django.core.management.base import BaseCommand
from django.utils.text import slugify
from api.models import Category, Flavor
class Command(BaseCommand):
help = 'Syncs flavors from Holy Energy Shopify API'
help = "Syncs flavors from Holy Energy Shopify API"
def clean_html(self, raw_html):
if not raw_html:
return ""
cleanr = re.compile('<.*?>')
cleantext = re.sub(cleanr, '', raw_html)
cleanr = re.compile("<.*?>")
cleantext = re.sub(cleanr, "", raw_html)
return cleantext.strip()
def download_image(self, url, flavor_name):
@@ -23,50 +24,60 @@ class Command(BaseCommand):
return None
try:
safe_name = slugify(flavor_name)
ext = url.split('.')[-1].split('?')[0].lower()
if ext not in ['jpg', 'jpeg', 'png', 'webp', 'gif']:
ext = 'png'
ext = url.split(".")[-1].split("?")[0].lower()
if ext not in ["jpg", "jpeg", "png", "webp", "gif"]:
ext = "png"
filename = f"{safe_name}.{ext}"
filepath = os.path.join(settings.MEDIA_ROOT, 'flavors', filename)
filepath = os.path.join(settings.MEDIA_ROOT, "flavors", filename)
os.makedirs(os.path.dirname(filepath), exist_ok=True)
response = requests.get(url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=10)
response = requests.get(url, headers={"User-Agent": "Mozilla/5.0"}, timeout=10)
response.raise_for_status()
if os.path.exists(filepath):
os.remove(filepath)
with open(filepath, 'wb') as f:
with open(filepath, "wb") as f:
f.write(response.content)
return f"flavors/{filename}"
except Exception as e:
self.stdout.write(self.style.WARNING(f"Failed to download image for {flavor_name}: {e}"))
self.stdout.write(
self.style.WARNING(f"Failed to download image for {flavor_name}: {e}")
)
return None
def handle(self, *args, **options):
url = "https://weareholy.com/products.json?limit=250"
self.stdout.write(f"Fetching from {url}...")
try:
response = requests.get(url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=15)
response = requests.get(url, headers={"User-Agent": "Mozilla/5.0"}, timeout=15)
response.raise_for_status()
data = response.json()
except Exception as e:
self.stdout.write(self.style.ERROR(f"Failed to fetch data: {e}"))
return
products = data.get('products', [])
products = data.get("products", [])
self.stdout.write(f"Found {len(products)} products.")
cat_map = {
'energy': Category.objects.get_or_create(name='Energy', defaults={'slug': 'energy'})[0],
'hydration': Category.objects.get_or_create(name='Hydration', defaults={'slug': 'hydration'})[0],
'iced-tea': Category.objects.get_or_create(name='Iced Tea', defaults={'slug': 'iced-tea'})[0],
'milkshake': Category.objects.get_or_create(name='Milkshake', defaults={'slug': 'milkshake'})[0],
'packs': Category.objects.get_or_create(name='Packs and other', defaults={'slug': 'packs-and-other'})[0],
"energy": Category.objects.get_or_create(name="Energy", defaults={"slug": "energy"})[0],
"hydration": Category.objects.get_or_create(
name="Hydration", defaults={"slug": "hydration"}
)[0],
"iced-tea": Category.objects.get_or_create(
name="Iced Tea", defaults={"slug": "iced-tea"}
)[0],
"milkshake": Category.objects.get_or_create(
name="Milkshake", defaults={"slug": "milkshake"}
)[0],
"packs": Category.objects.get_or_create(
name="Packs and other", defaults={"slug": "packs-and-other"}
)[0],
}
created_count = 0
@@ -74,82 +85,97 @@ class Command(BaseCommand):
synced_external_ids = []
for p in products:
title = p.get('title', '').strip()
tags = p.get('tags', [])
title = p.get("title", "").strip()
tags = p.get("tags", [])
if isinstance(tags, str):
tags = [t.strip() for t in tags.split(',')]
tags = [t.strip() for t in tags.split(",")]
tags_lower = [t.lower() for t in tags]
title_lower = title.lower()
is_pack = False
pack_keywords = ['bundle', 'set', 'box', 'probe', 'sample', 'taster', 'starter', 'collection', 'probier']
pack_keywords = [
"bundle",
"set",
"box",
"probe",
"sample",
"taster",
"starter",
"collection",
"probier",
]
if any(k in title_lower for k in pack_keywords):
is_pack = True
if 'shaker' in title_lower or 'merch' in tags_lower:
if "shaker" in title_lower or "merch" in tags_lower:
is_pack = True
category = None
if is_pack:
category = cat_map['packs']
elif 'holy energy' in tags_lower or 'energy' in tags_lower:
category = cat_map['energy']
elif 'holy hydration' in tags_lower or 'hydration' in tags_lower:
category = cat_map['hydration']
elif 'holy iced tea' in tags_lower or 'iced tea' in tags_lower:
category = cat_map['iced-tea']
elif 'milkshake' in tags_lower:
category = cat_map['milkshake']
category = cat_map["packs"]
elif "holy energy" in tags_lower or "energy" in tags_lower:
category = cat_map["energy"]
elif "holy hydration" in tags_lower or "hydration" in tags_lower:
category = cat_map["hydration"]
elif "holy iced tea" in tags_lower or "iced tea" in tags_lower:
category = cat_map["iced-tea"]
elif "milkshake" in tags_lower:
category = cat_map["milkshake"]
if not category:
if 'shaker' in tags_lower:
category = cat_map['packs']
if "shaker" in tags_lower:
category = cat_map["packs"]
else:
continue
variants = p.get('variants', [])
is_available = any(v.get('available') for v in variants)
images = p.get('images', [])
image_url = images[0].get('src') if images else None
description = self.clean_html(p.get('body_html', ''))
handle = p.get('handle')
variants = p.get("variants", [])
is_available = any(v.get("available") for v in variants)
images = p.get("images", [])
image_url = images[0].get("src") if images else None
description = self.clean_html(p.get("body_html", ""))
handle = p.get("handle")
shop_url = f"https://weareholy.com/products/{handle}" if handle else None
# Find or create - be very specific to avoid duplicates
# 1. Try external_id
flavor = Flavor.objects.filter(external_id=p['id']).first()
flavor = Flavor.objects.filter(external_id=p["id"]).first()
if not flavor:
# 2. Try exact name match in category
# REMOVED: is_legacy=False
flavor = Flavor.objects.filter(name=title, category=category).first()
if not flavor:
# 3. Try case-insensitive name match in category (safety for whitespace/case)
# REMOVED: is_legacy=False
flavor = Flavor.objects.filter(name__iexact=title, category=category).first()
if not flavor:
flavor = Flavor(external_id=p['id'], name=title, category=category, is_legacy=False)
flavor = Flavor(external_id=p["id"], name=title, category=category, is_legacy=False)
created_count += 1
else:
updated_count += 1
# Ensure external_id is updated if it was missing
if not flavor.external_id:
flavor.external_id = p['id']
flavor.external_id = p["id"]
# Reactivate the flavor if it was previously marked as legacy
if flavor.is_legacy:
flavor.is_legacy = False
# Ensure all synced fields are accurately reflected
if flavor.name != title and not Flavor.objects.filter(name=title, category=category).exclude(pk=flavor.pk).exists():
if (
flavor.name != title
and not Flavor.objects.filter(name=title, category=category)
.exclude(pk=flavor.pk)
.exists()
):
flavor.name = title
flavor.description = description
flavor.shop_url = shop_url
flavor.is_available = is_available
if image_url:
# Check if local file exists
file_exists = False
@@ -165,11 +191,21 @@ class Command(BaseCommand):
flavor.image = rel_path
self.stdout.write(f"Downloaded image for: {title}")
else:
self.stdout.write(self.style.WARNING(f"Failed to ensure image for: {title}"))
self.stdout.write(
self.style.WARNING(f"Failed to ensure image for: {title}")
)
flavor.image_url = image_url
flavor.save()
synced_external_ids.append(p['id'])
synced_external_ids.append(p["id"])
discontinued_count = Flavor.objects.filter(external_id__isnull=False).exclude(external_id__in=synced_external_ids).update(is_available=False)
self.stdout.write(self.style.SUCCESS(f"Finished! Created: {created_count}, Updated: {updated_count}, Marked Unavailable: {discontinued_count}"))
discontinued_count = (
Flavor.objects.filter(external_id__isnull=False)
.exclude(external_id__in=synced_external_ids)
.update(is_available=False)
)
self.stdout.write(
self.style.SUCCESS(
f"Finished! Created: {created_count}, Updated: {updated_count}, Marked Unavailable: {discontinued_count}"
)
)
-81
View File
@@ -1,81 +0,0 @@
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 django.db import models
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(
~models.Q(status='running')
).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.last_output = "" # Reset output for new run
job.save()
# Capture management command output
class LiveStream(StringIO):
def write(self, s):
super().write(s)
# Periodically update the DB with current output
job.last_output = self.getvalue()
job.save(update_fields=['last_output'])
out = LiveStream()
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', '--full', 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}"))