Merge branch 'main' into narl.io

This commit is contained in:
nvrl committed 2026-02-19 17:00:27 +01:00
commit e05e1b272c
20 files changed
+578 -179

No files matched your search

+1
View File
@@ -10,6 +10,7 @@ WORKDIR /app
# Install dependencies
COPY requirements.txt .
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir -r requirements.txt
# Copy the rest of the backend code
@@ -0,0 +1,58 @@
import sqlite3
import os
import shutil
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.'
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')
def handle(self, *args, **options):
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')
os.makedirs(backup_dir, exist_ok=True)
# 1. Database Backup
if options['output']:
backup_path = options['output']
else:
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
backup_path = os.path.join(backup_dir, f'db_backup_{timestamp}.sqlite3')
self.stdout.write(f"Starting backup of {db_path}...")
try:
# Use SQLite's online backup API for a consistent copy
src = sqlite3.connect(db_path)
dst = sqlite3.connect(backup_path)
with dst:
src.backup(dst)
dst.close()
src.close()
self.stdout.write(self.style.SUCCESS(f"Successfully backed up database to {backup_path}"))
except Exception as e:
self.stdout.write(self.style.ERROR(f"Database backup failed: {e}"))
return
# 2. Cleanup Old Backups
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)
if os.path.isfile(file_path):
file_time = datetime.fromtimestamp(os.path.getmtime(file_path))
if file_time < cutoff:
os.remove(file_path)
self.stdout.write(f" -> Deleted: {filename}")
@@ -0,0 +1,44 @@
from django.core.management.base import BaseCommand
from api.models import Flavor, Rating
from django.db.models import Count
class Command(BaseCommand):
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)
self.stdout.write(f"Found {duplicates.count()} sets of duplicates.")
for dup in duplicates:
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')
# 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})")
for other in others:
# Re-assign ratings
Rating.objects.filter(flavor=other).update(flavor=keep)
# Note: This might trigger unique constraint errors on Rating (user, flavor)
# if the user rated both duplicates. We'll handle that if needed.
# Re-assign other related models if any (e.g. notifications, etc.)
# In our case, Rating is the main one.
# Delete the duplicate
other.delete()
self.stdout.write(self.style.SUCCESS("Finished cleaning up duplicates."))
@@ -78,7 +78,7 @@ class Command(BaseCommand):
synced_external_ids = []
for p in products:
title = p.get('title')
title = p.get('title', '').strip()
tags = p.get('tags', [])
if isinstance(tags, str):
tags = [t.strip() for t in tags.split(',')]
@@ -119,23 +119,26 @@ class Command(BaseCommand):
handle = p.get('handle')
shop_url = f"https://weareholy.com/products/{handle}" if handle else None
# Find or create
# Find or create - be very specific to avoid duplicates
# 1. Try external_id
flavor = Flavor.objects.filter(external_id=p['id']).first()
if not flavor:
# Try to find by name AND category to avoid Milkshake/Iced Tea name collisions
# 2. Try exact name match in category
flavor = Flavor.objects.filter(name=title, category=category, is_legacy=False).first()
if not flavor:
# 3. Try case-insensitive name match in category (safety for whitespace/case)
flavor = Flavor.objects.filter(name__iexact=title, category=category, is_legacy=False).first()
if not flavor:
flavor = Flavor(external_id=p['id'], name=title, category=category, is_legacy=False)
created_count += 1
else:
updated_count += 1
flavor.category = category
flavor.description = description
flavor.shop_url = shop_url
flavor.is_available = is_available
flavor.external_id = p['id'] # Ensure ID is set
# Ensure external_id is updated if it was missing
if not flavor.external_id:
flavor.external_id = p['id']
if image_url:
# Check if local file exists
+47 -9
View File
@@ -106,24 +106,62 @@ class FlavorViewSet(viewsets.ModelViewSet):
# Filter by remaining query words in name or description
if remaining_query:
words = remaining_query.split()
# Construct a combined OR query for all words to be more permissive (fuzzy-ish)
word_query = Q()
for word in words:
flavors = flavors.filter(
Q(name__icontains=word) | Q(description__icontains=word)
)
if len(word) > 2: # Only filter by words longer than 2 chars to avoid noise
word_query |= Q(name__icontains=word) | Q(description__icontains=word)
if word_query:
flavors = flavors.filter(word_query)
# Limit results for performance
results = []
for f in flavors.order_by('name')[:15]:
results.append({
# Limit results and deduplicate in Python to be safe against DB inconsistencies
results_list = []
seen_keys = set()
# Priority sorting in Python
search_lower = remaining_query.lower()
# Order by name, but we'll also try to prioritize exact matches in Python
for f in flavors.order_by('name'):
# Create a unique key for this flavor (name + category)
key = f"{f.name.strip().lower()}|{f.category.slug}"
if key in seen_keys:
continue
seen_keys.add(key)
name_lower = f.name.lower()
relevance = 0
if name_lower == search_lower:
relevance = 3
elif name_lower.startswith(search_lower):
relevance = 2
elif search_lower in name_lower:
relevance = 1
results_list.append({
'id': f.id,
'name': f.name,
'type': 'flavor',
'subtitle': f.category.name,
'image_url': request.build_absolute_uri(f.image.url) if f.image else f.image_url,
'slug': None
'slug': None,
'relevance': relevance
})
return Response(results)
# Sort by relevance desc, then name
results_list.sort(key=lambda x: (-x['relevance'], x['name']))
# Remove relevance helper and limit to 15
final_results = []
for r in results_list:
del r['relevance']
final_results.append(r)
if len(final_results) >= 15:
break
return Response(final_results)
@action(detail=False, methods=['get'], permission_classes=[permissions.AllowAny])
def top(self, request):
+4
View File
@@ -25,6 +25,10 @@ chmod -R 777 django_cache
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
+7
View File
@@ -6,12 +6,19 @@ from django.conf import settings
from django.conf.urls.static import static
from django.views.static import serve
from django.views.generic.base import TemplateView
from django.http import HttpResponse
import os
def health_check(request):
return HttpResponse("OK", content_type="text/plain")
# Clean the admin path
admin_url = os.environ.get('ADMIN_URL', 'admin').strip('/')
urlpatterns = [
# Health check
path('health/', health_check, name='health_check'),
# Robots.txt
path('robots.txt', TemplateView.as_view(template_name="robots.txt", content_type="text/plain")),