From 42e6905db490498106d8c8d4e15778ebff0ecd21 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Thu, 19 Feb 2026 16:10:04 +0100 Subject: [PATCH 1/8] improved docker startup: frontend now waits for healthy backend --- backend/Dockerfile | 1 + backend/holy_backend/urls.py | 7 +++++++ docker-compose.yml | 9 ++++++++- frontend/Dockerfile | 6 +++++- frontend/entrypoint.sh | 21 +++++++++++++++++++++ 5 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 frontend/entrypoint.sh diff --git a/backend/Dockerfile b/backend/Dockerfile index 636a820..c50cde3 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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 diff --git a/backend/holy_backend/urls.py b/backend/holy_backend/urls.py index aec8d25..f114fad 100644 --- a/backend/holy_backend/urls.py +++ b/backend/holy_backend/urls.py @@ -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")), diff --git a/docker-compose.yml b/docker-compose.yml index 127c032..f168e34 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,6 +23,12 @@ services: - "traefik.http.routers.holy-energy-backend.entrypoints=websecure" - "traefik.http.routers.holy-energy-backend.tls.certresolver=https_resolver" - "traefik.http.services.holy-energy-backend.loadbalancer.server.port=8000" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health/"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s frontend: build: @@ -41,7 +47,8 @@ services: env_file: - .env.narl.io depends_on: - - backend + backend: + condition: service_healthy labels: - "traefik.enable=true" - "traefik.docker.network=narl" diff --git a/frontend/Dockerfile b/frontend/Dockerfile index e1f9a98..2850b58 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -27,6 +27,10 @@ FROM nginx:stable-alpine # Copy built files COPY --from=build-stage /app/dist /usr/share/nginx/html +# Copy entrypoint script +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + # Custom Nginx config to handle React Router (SPA) RUN printf 'server {\n\ listen 80;\n\ @@ -38,4 +42,4 @@ RUN printf 'server {\n\ }' > /etc/nginx/conf.d/default.conf EXPOSE 80 -CMD ["nginx", "-g", "daemon off;"] +ENTRYPOINT ["/entrypoint.sh"] diff --git a/frontend/entrypoint.sh b/frontend/entrypoint.sh new file mode 100644 index 0000000..32b583a --- /dev/null +++ b/frontend/entrypoint.sh @@ -0,0 +1,21 @@ +#!/bin/sh + +# The backend hostname is 'holy-backend' based on docker-compose.yml +# We wait for port 8000 to be open +echo "Waiting for backend (holy-backend:8000) to be available..." + +MAX_RETRIES=30 +COUNT=0 + +while ! nc -z holy-backend 8000; do + COUNT=$((COUNT + 1)) + if [ $COUNT -ge $MAX_RETRIES ]; then + echo "Backend timed out after $MAX_RETRIES attempts - starting Nginx anyway" + break + fi + echo "Backend is unavailable (attempt $COUNT/$MAX_RETRIES) - sleeping" + sleep 2 +done + +echo "Starting Nginx..." +exec nginx -g "daemon off;" From c74524be170aa53c26eb6762433f21784e1381a0 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Thu, 19 Feb 2026 16:28:29 +0100 Subject: [PATCH 2/8] fixed duplicate flavors and improved search bar relevance/fuzzy matching --- .../management/commands/cleanup_duplicates.py | 44 +++++++++++++++ .../api/management/commands/sync_flavors.py | 21 ++++--- backend/api/views.py | 56 ++++++++++++++++--- backend/entrypoint.sh | 4 ++ 4 files changed, 107 insertions(+), 18 deletions(-) create mode 100644 backend/api/management/commands/cleanup_duplicates.py diff --git a/backend/api/management/commands/cleanup_duplicates.py b/backend/api/management/commands/cleanup_duplicates.py new file mode 100644 index 0000000..4dc7246 --- /dev/null +++ b/backend/api/management/commands/cleanup_duplicates.py @@ -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.")) diff --git a/backend/api/management/commands/sync_flavors.py b/backend/api/management/commands/sync_flavors.py index 4a77fa5..c78e580 100644 --- a/backend/api/management/commands/sync_flavors.py +++ b/backend/api/management/commands/sync_flavors.py @@ -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 diff --git a/backend/api/views.py b/backend/api/views.py index b3598e6..92d1020 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -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): diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index 5c15797..aa67c5b 100755 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -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 From 25f33e14f9f332e339c2bfe27e3674d74342ae6e Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Thu, 19 Feb 2026 16:33:39 +0100 Subject: [PATCH 3/8] added pre-push integrity hook for automated frontend and backend checks --- scripts/install-hooks.sh | 18 +++++++++++++ scripts/pre-push.sh | 58 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 scripts/install-hooks.sh create mode 100755 scripts/pre-push.sh diff --git a/scripts/install-hooks.sh b/scripts/install-hooks.sh new file mode 100644 index 0000000..2aeaccf --- /dev/null +++ b/scripts/install-hooks.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +# Get the root directory +ROOT_DIR=$(git rev-parse --show-toplevel) + +# Path to the hook script +HOOK_SRC="$ROOT_DIR/scripts/pre-push.sh" +HOOK_DEST="$ROOT_DIR/.git/hooks/pre-push" + +# Make the source script executable +chmod +x "$HOOK_SRC" + +# Copy/Link to git hooks +cp "$HOOK_SRC" "$HOOK_DEST" +chmod +x "$HOOK_DEST" + +echo "✅ Pre-push hook installed successfully!" +echo "It will now run frontend builds and backend checks automatically before every 'git push'." diff --git a/scripts/pre-push.sh b/scripts/pre-push.sh new file mode 100755 index 0000000..0afd962 --- /dev/null +++ b/scripts/pre-push.sh @@ -0,0 +1,58 @@ +#!/bin/bash + +# Get the root directory of the project +ROOT_DIR=$(git rev-parse --show-toplevel) + +# Set environment variables for non-interactive backend checks +export SECRET_KEY="pre-push-check-key" +export DEBUG="false" +export ALLOWED_HOSTS="localhost" + +echo "========================================" +echo " Running Pre-Push Integrity Checks " +echo "========================================" + +# 1. Frontend Build +echo "" +echo "Step 1: Checking Frontend Build..." +cd "$ROOT_DIR/frontend" +if ! npm run build; then + echo "" + echo "❌ ERROR: Frontend build failed." + echo " Please fix build errors before pushing." + exit 1 +fi + +# 2. Backend Check +echo "" +echo "Step 2: Checking Backend Integrity..." +cd "$ROOT_DIR/backend" + +# Detect python executable (prefer venv if exists) +if [ -f "venv/bin/python" ]; then + PYTHON_EXEC="venv/bin/python" +elif command -v python3 &>/dev/null; then + PYTHON_EXEC="python3" +else + PYTHON_EXEC="python" +fi + +# Check for missing migrations +if ! $PYTHON_EXEC manage.py makemigrations --check --dry-run &>/dev/null; then + echo "" + echo "❌ ERROR: Missing migrations detected." + echo " Please run 'python manage.py makemigrations' locally." + exit 1 +fi + +# Run Django system checks +if ! $PYTHON_EXEC manage.py check; then + echo "" + echo "❌ ERROR: Django system check failed." + exit 1 +fi + +echo "" +echo "✅ All checks passed! Proceeding with push..." +echo "========================================" +exit 0 From b0d3f4b35bf238997a22a4e7d4ec759a563c25a0 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Thu, 19 Feb 2026 16:35:21 +0100 Subject: [PATCH 4/8] updated readme with setup instructions, architecture, and dev workflow --- README.md | 142 +++++++++++++++++++++++++++++++++--------------------- 1 file changed, 88 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index fc088c0..c625d49 100644 --- a/README.md +++ b/README.md @@ -1,85 +1,119 @@ -# Holy Energy Archive & Community Hub +# Holy Flavor Rater -A premium, responsive full-stack platform for "Holy Energy" fans to rate flavors, explore a categorized archive, and share personalized rating profiles. +A high-density, community-driven platform for "Holy Energy" enthusiasts. Browse every flavor ever released—from current bestsellers to retired legacy editions—rate them, and build your own personal tiered leaderboard to share with the world. -## Features +## 🚀 Key Features -- **Dynamic Hall of Fame:** An auto-rotating carousel featuring the top-rated flavors and community reviews. -- **Flavor Gallery:** A space-efficient, grid-based view of all rated and missing flavors. -- **Personalized Taste Profiles:** Share your ratings with the community using tiered (S-D) leaderboards. -- **Responsive Design:** Optimized for all devices, from ultra-wide monitors to vertical smartphones. -- **Secure Authentication:** SMTP-verified email signup, password resets, and session management. -- **Catalog Automation:** Automatic flavor syncing from official Shopify sources with local image caching. +- **Personalized Taste Profiles:** Interactive, tiered (S-D) leaderboards based on your personal ratings. +- **Dynamic Hall of Fame:** Automatic carousel featuring community-favorite flavors and highlighted reviews. +- **Advanced Search & Discovery:** Typo-friendly search with category-aware filtering and relevance sorting. +- **Interactive Banners:** 10+ procedurally generated Canvas banners (Fireflies, Hextech, Nebula, etc.) that react to your mouse. +- **Pro Themes:** Over 15 built-in themes including Catppuccin, Nord, and T0P (Twenty One Pilots) inspired aesthetics. +- **Social Interaction:** Follow other collectors, receive follow notifications, and leave messages in profile guestbooks. +- **Enterprise Verification:** Secure one-click email verification and password recovery. +- **Automated Catalog:** Real-time syncing with Shopify APIs and historical legacy flavor data. --- -## Prerequisites +## 🛠️ Setup & Installation -- [Docker](https://docs.docker.com/get-docker/) -- [Docker Compose](https://docs.docker.com/compose/install/) -- [SMTP Server](https://support.google.com/mail/answer/185833?hl=en) (e.g., Gmail App Password) for email verification. - ---- - -## Quick Setup +### Option 1: Docker (Recommended) +The easiest way to get the full stack (Backend + Frontend + DB) running behind a production-ready proxy. 1. **Clone the repository:** ```bash - git clone https://github.com/your-repo/holy.git - cd holy + git clone https://github.com/nvrl/Holy-Flavor-Rater.git + cd Holy-Flavor-Rater ``` - -2. **Configure your environment:** - Copy the example environment file and fill in your specific details (especially SMTP and your domain name): +2. **Environment Configuration:** ```bash - cp .env.example .env + cp .env.example .env.narl.io + # Edit .env.narl.io with your SMTP settings and Domain ``` - -3. **Start the containers:** +3. **Start Services:** ```bash docker-compose up -d --build ``` + *Note: On first startup, the backend will automatically migrate, sync the flavor catalog, and clean up any duplicates.* -4. **Wait for initial setup:** - The first time you start the backend, it will automatically: - - Run database migrations. - - Collect static files. - - **Sync flavors** from the Holy Energy Shopify API (this might take a minute). +### Option 2: Local Manual Setup (Development) -5. **Access the application:** - - **Frontend:** [http://localhost:5173](http://localhost:5173) (or your configured domain) - - **API Docs:** [http://localhost:8000/api/schema/swagger-ui/](http://localhost:8000/api/schema/swagger-ui/) - - **Admin Panel:** [http://localhost:8000/admin/](http://localhost:8000/admin/) (or your configured `ADMIN_URL`) +#### Backend +1. **Navigate and Install:** + ```bash + cd backend + python -m venv venv + source venv/bin/activate # Linux/Mac + pip install -r requirements.txt + ``` +2. **Initialize DB:** + ```bash + python manage.py migrate + python manage.py sync_flavors + python manage.py seed_legacy_flavors + ``` +3. **Run Server:** + ```bash + python manage.py runserver + ``` + +#### Frontend +1. **Navigate and Install:** + ```bash + cd frontend + npm install + ``` +2. **Run Dev Server:** + ```bash + npm run dev + ``` --- -## Production Hosting +## 🏗️ Architecture -### Security Best Practices +The Holy Flavor Rater uses a modern **decoupled architecture**: -When hosting this application publicly, ensure you update your `.env` with: +### Backend (Django REST Framework) +- **Models:** Custom User model, Flavors (with category constraints), Ratings (with mention parsing), and Dynamic Banners. +- **Automation:** Management commands for periodic Shopify API syncing and database deduplication. +- **Security:** Token-based authentication, CSRF protection, and email-verified account activation. +- **Monitoring:** Integrated `/health/` endpoint for Docker container status. -- `DEBUG=false`: Essential for disabling internal Django stack traces. -- `SECRET_KEY`: Use a long, random string. -- `ADMIN_URL`: Change this to something unique (e.g., `super-secret-gate/`) to hide your admin panel from scanners. -- `ALLOWED_HOSTS`, `CSRF_TRUSTED_ORIGINS`, `CORS_ALLOWED_ORIGINS`: Set these to your actual production domain (e.g., `https://holy.narl.io`). - -### Performance - -- The application uses **WhiteNoise** for efficient static file serving. -- **Gunicorn** is configured as the production WSGI server. -- **Compression Middleware** (Gzip/Brotli) is active for API responses. -- **Lazy Loading** is enabled for all flavor and carousel images. +### Frontend (React 19 + TypeScript) +- **State Management:** React Hooks and local state for high performance. +- **UI Framework:** Material UI (MUI) with heavy customization for "Holy" branding. +- **Visualization:** Procedural generative art system using HTML5 Canvas for profile backgrounds. +- **Internationalization:** Full i18next integration (English & German). --- -## Project Structure +## 💻 Development Workflow -- `backend/`: Django REST Framework API. -- `frontend/`: React 19 application with Material UI and Catppuccin themes. -- `media/`: Locally cached product images and user avatars. -- `legacy/`: Static JSON data for historical flavors. +### Integrity Protection +This project includes a **Pre-Push Git Hook** to ensure code quality. It automatically: +1. Attempts a full Frontend build (`tsc` + `vite build`). +2. Runs Backend system checks and validates missing migrations. -## License +**To install the hooks:** +```bash +bash scripts/install-hooks.sh +``` +### Management Commands +- `python manage.py sync_flavors`: Syncs the latest products from the official Holy Energy API. +- `python manage.py cleanup_duplicates`: Merges duplicate entries and maintains rating integrity. +- `python manage.py seed_banners`: Updates procedurally generated banner configurations from JSON. + +--- + +## 🌍 Production Configuration + +Ensure your `.env` contains: +- `DEBUG=false` +- `SECRET_KEY`: A long random string. +- `ALLOWED_HOSTS`: Your domain (e.g., `holy.narl.io`). +- `FRONTEND_URL`: For one-click email verification links. + +## 📄 License MIT From e3947e933c4c5f8cd56f9ce73d28456b1cd05075 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Thu, 19 Feb 2026 16:38:26 +0100 Subject: [PATCH 5/8] updated .env format --- README.md | 4 ++-- docker-compose.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c625d49..91d0027 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,8 @@ The easiest way to get the full stack (Backend + Frontend + DB) running behind a ``` 2. **Environment Configuration:** ```bash - cp .env.example .env.narl.io - # Edit .env.narl.io with your SMTP settings and Domain + cp .env.example .env + # Edit .env with your SMTP settings and Domain ``` 3. **Start Services:** ```bash diff --git a/docker-compose.yml b/docker-compose.yml index f168e34..a30fbbe 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,7 @@ services: - narl - internal env_file: - - .env.narl.io + - .env volumes: - ./backend:/app - ./legacy:/app/legacy @@ -45,7 +45,7 @@ services: networks: - narl env_file: - - .env.narl.io + - .env depends_on: backend: condition: service_healthy From dc4b5e45126b27a80be72b12844a943f0c5f2f6c Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Thu, 19 Feb 2026 16:38:59 +0100 Subject: [PATCH 6/8] implemented automated SQLite and media backup system with rotation --- README.md | 28 ++++++++++ backend/api/management/commands/backup_db.py | 58 ++++++++++++++++++++ scripts/backup.sh | 37 +++++++++++++ 3 files changed, 123 insertions(+) create mode 100644 backend/api/management/commands/backup_db.py create mode 100755 scripts/backup.sh diff --git a/README.md b/README.md index 91d0027..a48134f 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,34 @@ bash scripts/install-hooks.sh - `python manage.py sync_flavors`: Syncs the latest products from the official Holy Energy API. - `python manage.py cleanup_duplicates`: Merges duplicate entries and maintains rating integrity. - `python manage.py seed_banners`: Updates procedurally generated banner configurations from JSON. +- `python manage.py backup_db`: Creates a consistent SQLite snapshot. + +--- + +## 💾 Backups & Restoration + +### Create a Backup +To create a full snapshot of your database and user-uploaded media: +```bash +bash scripts/backup.sh +``` +This will create a `.tar.gz` file in `backend/backups/`. + +**Automating with Cron:** +Add this to your `crontab -e` to backup every night at 2:00 AM: +```bash +0 2 * * * /path/to/Holy-Flavor-Rater/scripts/backup.sh +``` + +### Restore from Backup +1. Extract the archive: `tar -xzf backup_filename.tar.gz` +2. Stop the containers: `docker-compose down` +3. Replace the current files: + ```bash + cp extracted_folder/db.sqlite3 backend/db.sqlite3 + cp -r extracted_folder/media/* backend/media/ + ``` +4. Restart: `docker-compose up -d` --- diff --git a/backend/api/management/commands/backup_db.py b/backend/api/management/commands/backup_db.py new file mode 100644 index 0000000..20a8d67 --- /dev/null +++ b/backend/api/management/commands/backup_db.py @@ -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}") diff --git a/scripts/backup.sh b/scripts/backup.sh new file mode 100755 index 0000000..7f249c2 --- /dev/null +++ b/scripts/backup.sh @@ -0,0 +1,37 @@ +#!/bin/bash + +# This script creates a full snapshot of the database and user media. +# Best run as a daily cron job. + +ROOT_DIR=$(git rev-parse --show-toplevel) +cd "$ROOT_DIR" + +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +BACKUP_NAME="full_snapshot_$TIMESTAMP" +TEMP_DIR="backend/backups/$BACKUP_NAME" +FINAL_DEST="backend/backups" + +echo "========================================" +echo " Starting Full Project Backup... " +echo "========================================" + +mkdir -p "$TEMP_DIR" + +# 1. Run the database backup command +echo "Backing up database..." +docker exec holy-backend python manage.py backup_db --output "/app/backups/$BACKUP_NAME/db.sqlite3" + +# 2. Copy the media folder +echo "Backing up media..." +cp -r backend/media "$TEMP_DIR/media" + +# 3. Create compressed archive +echo "Compressing..." +tar -czf "$FINAL_DEST/$BACKUP_NAME.tar.gz" -C "backend/backups" "$BACKUP_NAME" + +# 4. Cleanup temp folder +rm -rf "$TEMP_DIR" + +echo "" +echo "✅ Backup complete: $FINAL_DEST/$BACKUP_NAME.tar.gz" +echo "========================================" From 469ca3eb47393767b3f2b16fafdb4c12520dfdfb Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Thu, 19 Feb 2026 16:56:59 +0100 Subject: [PATCH 7/8] renamed dashboard to my collection, added limited status badges, and improved navigation discoverability --- frontend/src/components/StatusBadge.tsx | 56 ++++++++++++++++ frontend/src/i18n.ts | 18 +++--- frontend/src/pages/Dashboard.tsx | 64 +++++++++---------- frontend/src/pages/MainPage.tsx | 21 +++--- .../src/pages/categories/CategoryFlavors.tsx | 13 ++-- frontend/src/pages/flavors/FlavorDetail.tsx | 39 ++++------- 6 files changed, 126 insertions(+), 85 deletions(-) create mode 100644 frontend/src/components/StatusBadge.tsx diff --git a/frontend/src/components/StatusBadge.tsx b/frontend/src/components/StatusBadge.tsx new file mode 100644 index 0000000..e0ebc8e --- /dev/null +++ b/frontend/src/components/StatusBadge.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { Box, Typography, alpha, useTheme } from '@mui/material'; +import HistoryIcon from '@mui/icons-material/History'; +import BlockIcon from '@mui/icons-material/Block'; + +interface StatusBadgeProps { + isLegacy?: boolean; + isAvailable?: boolean; + size?: 'small' | 'medium'; + sx?: any; +} + +const StatusBadge: React.FC = ({ isLegacy, isAvailable, size = 'medium', sx }) => { + const theme = useTheme(); + const isSmall = size === 'small'; + + if (isAvailable && !isLegacy) return null; + + const label = isLegacy ? 'Limited' : 'Out of Stock'; + const color = isLegacy ? theme.palette.warning.main : theme.palette.error.main; + const Icon = isLegacy ? HistoryIcon : BlockIcon; + + return ( + + + + {label} + + + ); +}; + +export default StatusBadge; diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 3e9f089..45b46c4 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -9,7 +9,7 @@ const resources = { "home": "Home", "community": "Community", "about": "About", - "dashboard": "Dashboard", + "dashboard": "My Collection", "profile": "My Profile", "settings": "Settings", "logout": "Logout", @@ -25,6 +25,7 @@ const resources = { "packs-and-other": "Packs & Other" }, "dashboard": { + "title": "My Collection", "welcome": "Welcome back, {{username}}!", "shareProfile": "Share Profile", "copySuccess": "Link copied to clipboard!", @@ -32,14 +33,14 @@ const resources = { "missing": "Missing", "noRatings": "You haven't rated any flavors yet.", "noComment": "No comment provided.", - "exploreFlavors": "Go explore flavors", + "exploreFlavors": "Explore New Flavors", "allRated": "Amazing! You've rated everything! 🏆", - "myReviews": "My Reviews", + "myReviews": "My Rated Flavors", "exploreNew": "Explore New", "editingReview": "Editing review for {{flavor}}", "newScore": "New Score:", "searchMissing": "Search flavors you haven't rated...", - "searchRated": "Search your reviews...", + "searchRated": "Search your collection...", "circleAvg": "Average rating from your friends", "sortBy": "Sort by", "date": "Date", @@ -250,7 +251,7 @@ const resources = { "home": "Startseite", "community": "Community", "about": "Über uns", - "dashboard": "Dashboard", + "dashboard": "Mein Archiv", "profile": "Mein Profil", "settings": "Einstellungen", "logout": "Abmelden", @@ -266,6 +267,7 @@ const resources = { "packs-and-other": "Bundles & Mehr" }, "dashboard": { + "title": "Mein Archiv", "welcome": "Willkommen zurück, {{username}}!", "shareProfile": "Profil teilen", "copySuccess": "Link in die Zwischenablage kopiert!", @@ -273,14 +275,14 @@ const resources = { "missing": "Fehlend", "noRatings": "Du hast noch keine Sorten bewertet.", "noComment": "Kein Kommentar abgegeben.", - "exploreFlavors": "Sorten entdecken", + "exploreFlavors": "Neue Sorten entdecken", "allRated": "Wahnsinn! Du hast alles bewertet! 🏆", - "myReviews": "Meine Rezensionen", + "myReviews": "Meine Sammlungen", "exploreNew": "Neu entdecken", "editingReview": "Rezension bearbeiten für {{flavor}}", "newScore": "Neue Bewertung:", "searchMissing": "Suche nach Sorten, die du noch nicht bewertet hast...", - "searchRated": "Suche in deinen Rezensionen...", + "searchRated": "Suche in deinem Archiv...", "circleAvg": "Durchschnittsbewertung deiner Freunde", "sortBy": "Sortieren nach", "date": "Datum", diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 4a29c14..793610c 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -19,6 +19,7 @@ import { Chip, Collapse, TextField, + Rating as MuiRating, InputAdornment, Tooltip, useMediaQuery, @@ -36,9 +37,9 @@ import { useTranslation } from 'react-i18next'; import api from '../api'; import { useTitle } from '../hooks/useTitle'; import RatingBadge from '../components/RatingBadge'; +import StatusBadge from '../components/StatusBadge'; import MentionTextField from '../components/MentionTextField'; import RichText from '../components/RichText'; -import DynamicBanner from '../components/DynamicBanner'; import { formatDate } from '../utils/date'; interface DashboardData { @@ -54,7 +55,7 @@ const Dashboard: React.FC = () => { const navigate = useNavigate(); const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('lg')); - useTitle(t('nav.dashboard')); + useTitle(t('dashboard.title')); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [activeTab, setActiveTab] = useState(0); @@ -232,14 +233,17 @@ const Dashboard: React.FC = () => { {/* Action Bar */} - + + + {t('dashboard.title')} + + ) : ( filteredAndSortedRated.map(rating => ( @@ -440,11 +436,9 @@ const Dashboard: React.FC = () => { {t('dashboard.editingReview', { flavor: rating.flavor_name })} {t('dashboard.newScore')} - setEditScore(Number(e.target.value))} - sx={{ width: 80 }} + setEditScore(val || 0)} /> { {rating.flavor_name} - - {t(`categories.${rating.category_slug}`, { defaultValue: rating.category_name })} • {formatDate(rating.created_at)} - + + + {t(`categories.${rating.category_slug}`, { defaultValue: rating.category_name })} • {formatDate(rating.created_at)} + + + @@ -630,8 +627,9 @@ const Dashboard: React.FC = () => { '&:hover': { transform: 'scale(1.1)' } }} /> - + + {flavor.followed_average_rating && ( diff --git a/frontend/src/pages/MainPage.tsx b/frontend/src/pages/MainPage.tsx index a3e9291..d4417a6 100644 --- a/frontend/src/pages/MainPage.tsx +++ b/frontend/src/pages/MainPage.tsx @@ -22,6 +22,7 @@ import api from '../api'; import { useTitle } from '../hooks/useTitle'; import { formatDate } from '../utils/date'; import RatingBadge from '../components/RatingBadge'; +import StatusBadge from '../components/StatusBadge'; import { useNavigate } from 'react-router-dom'; interface Flavor { @@ -165,15 +166,9 @@ const MainPage: React.FC = ({ adminMode }) => { - {!flavor.is_available && ( - - {flavor.is_legacy ? 'Unavailable' : 'Out of Stock'} - - )} + + + {flavor.image_url && ( = ({ adminMode }) => { textDecoration: 'none' }} > + + + = ({ adminMode }) => { gap: 2 }}> {newestFlavors.map(flavor => ( - + + + + { - {!flavor.is_available && ( - - {flavor.is_legacy ? 'Unavailable' : 'Out of Stock'} - - )} + + + {flavor.image_url && ( = ({ adminMode }) => { backdropFilter: 'blur(12px)', overflow: 'hidden' }}> - - {!flavor.is_available && ( - - )} - {flavor.image_url ? ( - + + + + {flavor.image_url ? ( Date: Thu, 19 Feb 2026 16:58:38 +0100 Subject: [PATCH 8/8] simplified dashboard badges: merged community and friends ratings into a single dynamic badge --- frontend/src/pages/Dashboard.tsx | 38 ++++++++++++++------------------ 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 793610c..f7d5348 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -628,29 +628,25 @@ const Dashboard: React.FC = () => { }} /> - + + + + + - {flavor.followed_average_rating && ( - - - - - {flavor.followed_average_rating.toFixed(1)} - - - - )}