From 466d0a0f9fe88d2d2d75ec9d7717042e4e29ef53 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sun, 26 Apr 2026 22:38:07 +0200 Subject: [PATCH] main image selections --- backend/api/admin.py | 30 +++++- .../commands/seed_legacy_flavors.py | 52 +++++------ .../api/management/commands/sync_flavors.py | 92 +++++++++---------- ...ocal_image_paths_flavor_main_image_path.py | 23 +++++ backend/api/models/flavor.py | 2 + backend/api/serializers/_helpers.py | 13 +++ backend/api/serializers/flavor.py | 21 ++++- 7 files changed, 153 insertions(+), 80 deletions(-) create mode 100644 backend/api/migrations/0032_flavor_local_image_paths_flavor_main_image_path.py diff --git a/backend/api/admin.py b/backend/api/admin.py index 13c5a20..4cc6ce6 100644 --- a/backend/api/admin.py +++ b/backend/api/admin.py @@ -1,3 +1,4 @@ +from django import forms from django.contrib import admin from django.contrib.auth.admin import UserAdmin @@ -9,8 +10,35 @@ class CustomUserAdmin(UserAdmin): add_fieldsets = UserAdmin.add_fieldsets + ((None, {"fields": ("theme",)}),) +class FlavorAdminForm(forms.ModelForm): + class Meta: + model = Flavor + exclude: list[str] = [] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + instance = kwargs.get("instance") + paths = list(instance.local_image_paths or []) if instance else [] + if paths: + choices = [("", "— first available —")] + [(p, p.split("/")[-1]) for p in paths] + self.fields["main_image_path"] = forms.ChoiceField( + choices=choices, + required=False, + label="Main image", + help_text="Pick which downloaded image is used as the primary.", + ) + + +class FlavorAdmin(admin.ModelAdmin): + form = FlavorAdminForm + list_display = ("name", "category", "is_available", "is_legacy", "external_id") + list_filter = ("category", "is_available", "is_legacy") + search_fields = ("name",) + readonly_fields = ("local_image_paths",) + + admin.site.register(User, CustomUserAdmin) admin.site.register(Category) -admin.site.register(Flavor) +admin.site.register(Flavor, FlavorAdmin) admin.site.register(Rating) admin.site.register(Banner) diff --git a/backend/api/management/commands/seed_legacy_flavors.py b/backend/api/management/commands/seed_legacy_flavors.py index ee8f000..df224f4 100644 --- a/backend/api/management/commands/seed_legacy_flavors.py +++ b/backend/api/management/commands/seed_legacy_flavors.py @@ -1,5 +1,7 @@ +import hashlib import json import os +from urllib.parse import urlparse import requests from django.conf import settings @@ -13,29 +15,26 @@ class Command(BaseCommand): help = "Adds legacy flavors from JSON files in the legacy/ folder" def download_image(self, url, flavor_name): + """Download legacy image into flavors/legacy_/00_.. Returns rel path.""" 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"]: + path = urlparse(url).path + ext = path.rsplit(".", 1)[-1].lower() if "." in path else "" + 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) - - os.makedirs(os.path.dirname(filepath), exist_ok=True) - + url_hash = hashlib.sha1(path.encode("utf-8")).hexdigest()[:10] + rel_path = os.path.join("flavors", f"legacy_{safe_name}", f"00_{url_hash}.{ext}") + abs_path = os.path.join(settings.MEDIA_ROOT, rel_path) + if os.path.exists(abs_path) and os.path.getsize(abs_path) > 0: + return rel_path + os.makedirs(os.path.dirname(abs_path), exist_ok=True) 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(abs_path, "wb") as f: f.write(response.content) - - return f"flavors/{filename}" + return rel_path except Exception as e: self.stdout.write( self.style.WARNING(f"Failed to download image for {flavor_name}: {e}") @@ -146,21 +145,18 @@ class Command(BaseCommand): flavor.is_available = False # Legacy is always unavailable via API if image_url: - # Check if local file exists - file_exists = False - if flavor.image: - abs_path = os.path.join(settings.MEDIA_ROOT, flavor.image.name) - if os.path.exists(abs_path): - file_exists = True - - # Download if missing OR if image_url changed - if not file_exists or flavor.image_url != image_url: - rel_path = self.download_image(image_url, name) - if rel_path: - flavor.image = rel_path - self.stdout.write(f" -> Updated image for: {name}") + rel_path = self.download_image(image_url, name) + if rel_path: + flavor.local_image_paths = [rel_path] + if ( + flavor.main_image_path + and flavor.main_image_path not in flavor.local_image_paths + ): + flavor.main_image_path = None + self.stdout.write(f" -> Image for: {name}") flavor.image_url = image_url + flavor.image_urls = [image_url] if image_url else [] flavor.save() processed_count += 1 diff --git a/backend/api/management/commands/sync_flavors.py b/backend/api/management/commands/sync_flavors.py index 3040dae..b711895 100644 --- a/backend/api/management/commands/sync_flavors.py +++ b/backend/api/management/commands/sync_flavors.py @@ -1,10 +1,11 @@ +import hashlib import os import re +from urllib.parse import urlparse 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 @@ -19,36 +20,48 @@ class Command(BaseCommand): cleantext = re.sub(cleanr, "", raw_html) return cleantext.strip() - def download_image(self, url, flavor_name): + def download_image_to_path(self, url, rel_path): + """Download `url` to MEDIA_ROOT/rel_path. Skip if file already exists. Returns rel_path or None.""" if not url: return None + abs_path = os.path.join(settings.MEDIA_ROOT, rel_path) + if os.path.exists(abs_path) and os.path.getsize(abs_path) > 0: + return rel_path try: - safe_name = slugify(flavor_name) - 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) - - os.makedirs(os.path.dirname(filepath), exist_ok=True) - + os.makedirs(os.path.dirname(abs_path), exist_ok=True) 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(abs_path, "wb") as f: f.write(response.content) - - return f"flavors/{filename}" + return rel_path 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 {url}: {e}")) return None + @staticmethod + def _ext_for(url): + path = urlparse(url).path + ext = path.rsplit(".", 1)[-1].lower() if "." in path else "" + return ext if ext in ("jpg", "jpeg", "png", "webp", "gif") else "png" + + @staticmethod + def _hash_for(url): + path = urlparse(url).path + return hashlib.sha1(path.encode("utf-8")).hexdigest()[:10] + + def download_gallery(self, image_urls, dir_slug): + """Download every URL into flavors//_.. Returns list of rel paths.""" + paths = [] + for i, url in enumerate(image_urls): + if not url: + continue + filename = f"{i:02d}_{self._hash_for(url)}.{self._ext_for(url)}" + rel = os.path.join("flavors", dir_slug, filename) + saved = self.download_image_to_path(url, rel) + if saved: + paths.append(saved) + return paths + def handle(self, *args, **options): url = "https://weareholy.com/products.json?limit=250" self.stdout.write(f"Fetching from {url}...") @@ -143,12 +156,10 @@ class Command(BaseCommand): 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: @@ -156,15 +167,11 @@ class Command(BaseCommand): 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"] - - # 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) @@ -177,24 +184,17 @@ class Command(BaseCommand): flavor.shop_url = shop_url flavor.is_available = is_available - if image_url: - # Check if local file exists - file_exists = False - if flavor.image: - abs_path = os.path.join(settings.MEDIA_ROOT, flavor.image.name) - if os.path.exists(abs_path): - file_exists = True - - # Download if missing or URL changed - if not file_exists or flavor.image_url != image_url: - rel_path = self.download_image(image_url, title) - if rel_path: - 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}") - ) + # Download whole gallery into flavors// + dir_slug = str(p["id"]) + local_paths = self.download_gallery(image_urls_list, dir_slug) + if local_paths: + flavor.local_image_paths = local_paths + # Drop main_image_path if it points to a file no longer in the gallery + if flavor.main_image_path and flavor.main_image_path not in local_paths: + flavor.main_image_path = None + elif not flavor.local_image_paths: + # No new paths and nothing previously stored — leave both empty + flavor.main_image_path = None flavor.image_url = image_url flavor.image_urls = image_urls_list diff --git a/backend/api/migrations/0032_flavor_local_image_paths_flavor_main_image_path.py b/backend/api/migrations/0032_flavor_local_image_paths_flavor_main_image_path.py new file mode 100644 index 0000000..64af260 --- /dev/null +++ b/backend/api/migrations/0032_flavor_local_image_paths_flavor_main_image_path.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.4 on 2026-04-26 18:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0031_alter_job_name'), + ] + + operations = [ + migrations.AddField( + model_name='flavor', + name='local_image_paths', + field=models.JSONField(blank=True, default=list), + ), + migrations.AddField( + model_name='flavor', + name='main_image_path', + field=models.CharField(blank=True, max_length=500, null=True), + ), + ] diff --git a/backend/api/models/flavor.py b/backend/api/models/flavor.py index 18601b8..7d1673b 100644 --- a/backend/api/models/flavor.py +++ b/backend/api/models/flavor.py @@ -21,6 +21,8 @@ class Flavor(models.Model): image_url = models.URLField(max_length=1000, blank=True, null=True) image_urls = models.JSONField(blank=True, default=list) image = models.ImageField(upload_to="flavors/", blank=True, null=True) + local_image_paths = models.JSONField(blank=True, default=list) + main_image_path = models.CharField(max_length=500, blank=True, null=True) shop_url = models.URLField(max_length=500, blank=True, null=True) is_available = models.BooleanField(default=True) is_legacy = models.BooleanField(default=False) diff --git a/backend/api/serializers/_helpers.py b/backend/api/serializers/_helpers.py index 268bfdc..a23ac57 100644 --- a/backend/api/serializers/_helpers.py +++ b/backend/api/serializers/_helpers.py @@ -1,5 +1,7 @@ from typing import Any +from django.conf import settings + def absolute_image_url(serializer: Any, image_field: Any) -> str | None: if not image_field: @@ -8,3 +10,14 @@ def absolute_image_url(serializer: Any, image_field: Any) -> str | None: if request: return request.build_absolute_uri(image_field.url) return image_field.url + + +def absolute_media_url(serializer: Any, rel_path: str | None) -> str | None: + if not rel_path: + return None + media_url = settings.MEDIA_URL.rstrip("/") + "/" + url = media_url + rel_path.lstrip("/") + request = serializer.context.get("request") + if request: + return request.build_absolute_uri(url) + return url diff --git a/backend/api/serializers/flavor.py b/backend/api/serializers/flavor.py index 4f949d3..c7f231f 100644 --- a/backend/api/serializers/flavor.py +++ b/backend/api/serializers/flavor.py @@ -2,7 +2,7 @@ from django.db.models import Count from rest_framework import serializers from api.models import Category, Flavor, Rating -from api.serializers._helpers import absolute_image_url +from api.serializers._helpers import absolute_image_url, absolute_media_url from api.serializers.rating import RatingSerializer @@ -47,18 +47,29 @@ class FlavorSerializer(serializers.ModelSerializer): extra_kwargs = {"image": {"write_only": True}} def get_image_url(self, obj: Flavor) -> str | None: + if obj.main_image_path: + url = absolute_media_url(self, obj.main_image_path) + if url: + return url + if obj.local_image_paths: + url = absolute_media_url(self, obj.local_image_paths[0]) + if url: + return url if obj.image: return absolute_image_url(self, obj.image) return obj.image_url def get_image_urls(self, obj: Flavor) -> list[str]: - urls: list[str] = [] + if obj.local_image_paths: + urls = [absolute_media_url(self, p) for p in obj.local_image_paths] + return [u for u in urls if u] + legacy_urls: list[str] = [] if obj.image: absolute = absolute_image_url(self, obj.image) if absolute: - urls.append(absolute) - urls.extend(obj.image_urls or []) - return urls + legacy_urls.append(absolute) + legacy_urls.extend(obj.image_urls or []) + return legacy_urls def get_rating_distribution(self, obj: Flavor) -> dict[str, int]: counts = dict.fromkeys((str(i) for i in range(1, 11)), 0)