main image selections

This commit is contained in:
nvrl committed 2026-04-26 22:38:07 +02:00
1 parent caf3830d72
commit 466d0a0f9f
7 files changed
+151 -78

No files matched your search

+29 -1
View File
@@ -1,3 +1,4 @@
from django import forms
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.admin import UserAdmin from django.contrib.auth.admin import UserAdmin
@@ -9,8 +10,35 @@ class CustomUserAdmin(UserAdmin):
add_fieldsets = UserAdmin.add_fieldsets + ((None, {"fields": ("theme",)}),) 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(User, CustomUserAdmin)
admin.site.register(Category) admin.site.register(Category)
admin.site.register(Flavor) admin.site.register(Flavor, FlavorAdmin)
admin.site.register(Rating) admin.site.register(Rating)
admin.site.register(Banner) admin.site.register(Banner)
@@ -1,5 +1,7 @@
import hashlib
import json import json
import os import os
from urllib.parse import urlparse
import requests import requests
from django.conf import settings from django.conf import settings
@@ -13,29 +15,26 @@ 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): def download_image(self, url, flavor_name):
"""Download legacy image into flavors/legacy_<slug>/00_<hash>.<ext>. Returns rel path."""
if not url: if not url:
return None return None
try: try:
safe_name = slugify(flavor_name) safe_name = slugify(flavor_name)
ext = url.split(".")[-1].split("?")[0].lower() path = urlparse(url).path
if ext not in ["jpg", "jpeg", "png", "webp", "gif"]: ext = path.rsplit(".", 1)[-1].lower() if "." in path else ""
if ext not in ("jpg", "jpeg", "png", "webp", "gif"):
ext = "png" ext = "png"
url_hash = hashlib.sha1(path.encode("utf-8")).hexdigest()[:10]
filename = f"legacy_{safe_name}.{ext}" rel_path = os.path.join("flavors", f"legacy_{safe_name}", f"00_{url_hash}.{ext}")
filepath = os.path.join(settings.MEDIA_ROOT, "flavors", filename) abs_path = os.path.join(settings.MEDIA_ROOT, rel_path)
if os.path.exists(abs_path) and os.path.getsize(abs_path) > 0:
os.makedirs(os.path.dirname(filepath), exist_ok=True) 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 = requests.get(url, headers={"User-Agent": "Mozilla/5.0"}, timeout=10)
response.raise_for_status() response.raise_for_status()
with open(abs_path, "wb") as f:
if os.path.exists(filepath):
os.remove(filepath)
with open(filepath, "wb") as f:
f.write(response.content) f.write(response.content)
return rel_path
return f"flavors/{filename}"
except Exception as e: except Exception as e:
self.stdout.write( self.stdout.write(
self.style.WARNING(f"Failed to download image for {flavor_name}: {e}") 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 flavor.is_available = False # Legacy is always unavailable via API
if image_url: 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) rel_path = self.download_image(image_url, name)
if rel_path: if rel_path:
flavor.image = rel_path flavor.local_image_paths = [rel_path]
self.stdout.write(f" -> Updated image for: {name}") 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_url = image_url
flavor.image_urls = [image_url] if image_url else []
flavor.save() flavor.save()
processed_count += 1 processed_count += 1
+46 -46
View File
@@ -1,10 +1,11 @@
import hashlib
import os import os
import re import re
from urllib.parse import urlparse
import requests import requests
from django.conf import settings from django.conf import settings
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.utils.text import slugify
from api.models import Category, Flavor from api.models import Category, Flavor
@@ -19,36 +20,48 @@ class Command(BaseCommand):
cleantext = re.sub(cleanr, "", raw_html) cleantext = re.sub(cleanr, "", raw_html)
return cleantext.strip() 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: if not url:
return None 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: try:
safe_name = slugify(flavor_name) os.makedirs(os.path.dirname(abs_path), exist_ok=True)
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)
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() response.raise_for_status()
with open(abs_path, "wb") as f:
if os.path.exists(filepath):
os.remove(filepath)
with open(filepath, "wb") as f:
f.write(response.content) f.write(response.content)
return rel_path
return f"flavors/{filename}"
except Exception as e: except Exception as e:
self.stdout.write( self.stdout.write(self.style.WARNING(f"Failed to download {url}: {e}"))
self.style.WARNING(f"Failed to download image for {flavor_name}: {e}")
)
return None 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/<dir_slug>/<i>_<hash>.<ext>. 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): def handle(self, *args, **options):
url = "https://weareholy.com/products.json?limit=250" url = "https://weareholy.com/products.json?limit=250"
self.stdout.write(f"Fetching from {url}...") self.stdout.write(f"Fetching from {url}...")
@@ -143,12 +156,10 @@ class Command(BaseCommand):
if not flavor: if not flavor:
# 2. Try exact name match in category # 2. Try exact name match in category
# REMOVED: is_legacy=False
flavor = Flavor.objects.filter(name=title, category=category).first() flavor = Flavor.objects.filter(name=title, category=category).first()
if not flavor: if not flavor:
# 3. Try case-insensitive name match in category (safety for whitespace/case) # 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() flavor = Flavor.objects.filter(name__iexact=title, category=category).first()
if not flavor: if not flavor:
@@ -156,15 +167,11 @@ class Command(BaseCommand):
created_count += 1 created_count += 1
else: else:
updated_count += 1 updated_count += 1
# Ensure external_id is updated if it was missing
if not flavor.external_id: 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: if flavor.is_legacy:
flavor.is_legacy = False flavor.is_legacy = False
# Ensure all synced fields are accurately reflected
if ( if (
flavor.name != title flavor.name != title
and not Flavor.objects.filter(name=title, category=category) and not Flavor.objects.filter(name=title, category=category)
@@ -177,24 +184,17 @@ class Command(BaseCommand):
flavor.shop_url = shop_url flavor.shop_url = shop_url
flavor.is_available = is_available flavor.is_available = is_available
if image_url: # Download whole gallery into flavors/<external_id>/
# Check if local file exists dir_slug = str(p["id"])
file_exists = False local_paths = self.download_gallery(image_urls_list, dir_slug)
if flavor.image: if local_paths:
abs_path = os.path.join(settings.MEDIA_ROOT, flavor.image.name) flavor.local_image_paths = local_paths
if os.path.exists(abs_path): # Drop main_image_path if it points to a file no longer in the gallery
file_exists = True if flavor.main_image_path and flavor.main_image_path not in local_paths:
flavor.main_image_path = None
# Download if missing or URL changed elif not flavor.local_image_paths:
if not file_exists or flavor.image_url != image_url: # No new paths and nothing previously stored — leave both empty
rel_path = self.download_image(image_url, title) flavor.main_image_path = None
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}")
)
flavor.image_url = image_url flavor.image_url = image_url
flavor.image_urls = image_urls_list flavor.image_urls = image_urls_list
@@ -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),
),
]
+2
View File
@@ -21,6 +21,8 @@ class Flavor(models.Model):
image_url = models.URLField(max_length=1000, blank=True, null=True) image_url = models.URLField(max_length=1000, blank=True, null=True)
image_urls = models.JSONField(blank=True, default=list) image_urls = models.JSONField(blank=True, default=list)
image = models.ImageField(upload_to="flavors/", blank=True, null=True) 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) shop_url = models.URLField(max_length=500, blank=True, null=True)
is_available = models.BooleanField(default=True) is_available = models.BooleanField(default=True)
is_legacy = models.BooleanField(default=False) is_legacy = models.BooleanField(default=False)
+13
View File
@@ -1,5 +1,7 @@
from typing import Any from typing import Any
from django.conf import settings
def absolute_image_url(serializer: Any, image_field: Any) -> str | None: def absolute_image_url(serializer: Any, image_field: Any) -> str | None:
if not image_field: if not image_field:
@@ -8,3 +10,14 @@ def absolute_image_url(serializer: Any, image_field: Any) -> str | None:
if request: if request:
return request.build_absolute_uri(image_field.url) return request.build_absolute_uri(image_field.url)
return 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
+16 -5
View File
@@ -2,7 +2,7 @@ from django.db.models import Count
from rest_framework import serializers from rest_framework import serializers
from api.models import Category, Flavor, Rating 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 from api.serializers.rating import RatingSerializer
@@ -47,18 +47,29 @@ class FlavorSerializer(serializers.ModelSerializer):
extra_kwargs = {"image": {"write_only": True}} extra_kwargs = {"image": {"write_only": True}}
def get_image_url(self, obj: Flavor) -> str | None: 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: if obj.image:
return absolute_image_url(self, obj.image) return absolute_image_url(self, obj.image)
return obj.image_url return obj.image_url
def get_image_urls(self, obj: Flavor) -> list[str]: 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: if obj.image:
absolute = absolute_image_url(self, obj.image) absolute = absolute_image_url(self, obj.image)
if absolute: if absolute:
urls.append(absolute) legacy_urls.append(absolute)
urls.extend(obj.image_urls or []) legacy_urls.extend(obj.image_urls or [])
return urls return legacy_urls
def get_rating_distribution(self, obj: Flavor) -> dict[str, int]: def get_rating_distribution(self, obj: Flavor) -> dict[str, int]:
counts = dict.fromkeys((str(i) for i in range(1, 11)), 0) counts = dict.fromkeys((str(i) for i in range(1, 11)), 0)