mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
main image selections
This commit is contained in:
1 parent
caf3830d72
commit
466d0a0f9f
7 files changed
+151
-78
No files matched your search
+29
-1
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|||||||
Reference in new issue
Block a user