diff --git a/backend/api/management/commands/backup_db.py b/backend/api/management/commands/backup_db.py index 20a8d67..087bda3 100644 --- a/backend/api/management/commands/backup_db.py +++ b/backend/api/management/commands/backup_db.py @@ -54,5 +54,8 @@ class Command(BaseCommand): 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}") + try: + os.remove(file_path) + self.stdout.write(self.style.SUCCESS(f" -> Deleted old backup: {filename}")) + except Exception as e: + self.stdout.write(self.style.WARNING(f" -> Failed to delete {filename}: {e}")) diff --git a/backend/api/management/commands/cleanup_duplicates.py b/backend/api/management/commands/cleanup_duplicates.py index 4dc7246..effb8e9 100644 --- a/backend/api/management/commands/cleanup_duplicates.py +++ b/backend/api/management/commands/cleanup_duplicates.py @@ -31,14 +31,16 @@ class Command(BaseCommand): 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. + for rating in Rating.objects.filter(flavor=other): + try: + rating.flavor = keep + rating.save() + except Exception: + # User already has a rating for the 'keep' flavor + # Delete the duplicate rating instead + rating.delete() - # Re-assign other related models if any (e.g. notifications, etc.) - # In our case, Rating is the main one. - - # Delete the duplicate + # Delete the duplicate flavor other.delete() self.stdout.write(self.style.SUCCESS("Finished cleaning up duplicates.")) diff --git a/backend/api/management/commands/worker.py b/backend/api/management/commands/worker.py new file mode 100644 index 0000000..8491c00 --- /dev/null +++ b/backend/api/management/commands/worker.py @@ -0,0 +1,74 @@ +import time +import sys +from datetime import datetime, timedelta +from django.core.management.base import BaseCommand +from django.core.management import call_command +from django.utils import timezone +from api.models import Job +from io import StringIO + +class Command(BaseCommand): + help = 'Background worker to process scheduled and on-demand jobs.' + + def handle(self, *args, **options): + self.stdout.write(self.style.SUCCESS("Starting Holy Archive Worker...")) + + # Ensure all core jobs exist in the DB + for job_type, label in Job.JOB_TYPES: + Job.objects.get_or_create(name=job_type) + + while True: + # 1. Find jobs that are either explicitly set to 'pending' (manual trigger) + # or those where next_run has passed. + now = timezone.now() + jobs_to_run = Job.objects.filter( + status__in=['pending', 'failed'] + ).filter( + # Run if pending OR (next_run is set AND has passed) + models.Q(status='pending') | + models.Q(next_run__lte=now, interval_hours__gt=0) + ) + + for job in jobs_to_run: + self.run_job(job) + + time.sleep(30) # Check every 30 seconds + + def run_job(self, job): + self.stdout.write(f"Executing Job: {job.get_name_display()}...") + job.status = 'running' + job.last_run = timezone.now() + job.save() + + # Capture management command output + out = StringIO() + error = "" + + try: + if job.name == 'sync_flavors': + call_command('sync_flavors', stdout=out) + elif job.name == 'cleanup_duplicates': + call_command('cleanup_duplicates', stdout=out) + elif job.name == 'backup_db': + call_command('backup_db', stdout=out) + elif job.name == 'seed_legacy': + call_command('seed_legacy_flavors', stdout=out) + + job.status = 'completed' + except Exception as e: + job.status = 'failed' + error = str(e) + self.stdout.write(self.style.ERROR(f"Job failed: {error}")) + + # Update scheduling for next run if interval is set + if job.interval_hours > 0: + job.next_run = timezone.now() + timedelta(hours=job.interval_hours) + else: + job.next_run = None + + job.last_output = out.getvalue() + job.error_message = error + job.save() + self.stdout.write(self.style.SUCCESS(f"Job finished: {job.status}")) + +from django.db import models # Needed for the Q object inside handle diff --git a/backend/api/migrations/0028_job_systemconfig.py b/backend/api/migrations/0028_job_systemconfig.py new file mode 100644 index 0000000..f4e6b57 --- /dev/null +++ b/backend/api/migrations/0028_job_systemconfig.py @@ -0,0 +1,40 @@ +# Generated by Django 6.0.2 on 2026-02-19 16:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0027_alter_notification_notification_type'), + ] + + operations = [ + migrations.CreateModel( + name='Job', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(choices=[('sync_flavors', 'Sync Shopify Flavors'), ('cleanup_duplicates', 'Cleanup Duplicate Flavors'), ('backup_db', 'Database Backup'), ('seed_legacy', 'Seed Legacy Data')], max_length=100)), + ('status', models.CharField(choices=[('pending', 'Pending'), ('running', 'Running'), ('completed', 'Completed'), ('failed', 'Failed')], default='pending', max_length=20)), + ('last_run', models.DateTimeField(blank=True, null=True)), + ('next_run', models.DateTimeField(blank=True, null=True)), + ('interval_hours', models.IntegerField(default=0, help_text='Set to 0 to disable periodic run.')), + ('last_output', models.TextField(blank=True)), + ('error_message', models.TextField(blank=True)), + ], + ), + migrations.CreateModel( + name='SystemConfig', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('site_name', models.CharField(default='Holy Flavors Archive', max_length=100)), + ('maintenance_mode', models.BooleanField(default=False)), + ('allow_new_signups', models.BooleanField(default=True)), + ('require_email_verification', models.BooleanField(default=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'System Configuration', + }, + ), + ] diff --git a/backend/api/models.py b/backend/api/models.py index 9c8b5ac..1c61c37 100644 --- a/backend/api/models.py +++ b/backend/api/models.py @@ -216,3 +216,51 @@ class Banner(models.Model): def __str__(self): return self.name + +class SystemConfig(models.Model): + """Singleton model for application-wide settings editable via UI.""" + site_name = models.CharField(max_length=100, default="Holy Flavors Archive") + maintenance_mode = models.BooleanField(default=False) + allow_new_signups = models.BooleanField(default=True) + require_email_verification = models.BooleanField(default=True) + + updated_at = models.DateTimeField(auto_now=True) + + def save(self, *args, **kwargs): + self.pk = 1 # Force singleton + super().save(*args, **kwargs) + + @classmethod + def get_solo(cls): + obj, _ = cls.objects.get_or_create(pk=1) + return obj + + class Meta: + verbose_name = "System Configuration" + +class Job(models.Model): + """Tracks background jobs, their status and scheduling.""" + JOB_TYPES = [ + ('sync_flavors', 'Sync Shopify Flavors'), + ('cleanup_duplicates', 'Cleanup Duplicate Flavors'), + ('backup_db', 'Database Backup'), + ('seed_legacy', 'Seed Legacy Data'), + ] + STATUS_CHOICES = [ + ('pending', 'Pending'), + ('running', 'Running'), + ('completed', 'Completed'), + ('failed', 'Failed'), + ] + + name = models.CharField(max_length=100, choices=JOB_TYPES) + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending') + last_run = models.DateTimeField(null=True, blank=True) + next_run = models.DateTimeField(null=True, blank=True) + interval_hours = models.IntegerField(default=0, help_text="Set to 0 to disable periodic run.") + + last_output = models.TextField(blank=True) + error_message = models.TextField(blank=True) + + def __str__(self): + return f"{self.get_name_display()} ({self.status})" diff --git a/backend/api/serializers.py b/backend/api/serializers.py index 93ea41f..91affe4 100644 --- a/backend/api/serializers.py +++ b/backend/api/serializers.py @@ -1,11 +1,24 @@ from rest_framework import serializers -from .models import User, Flavor, Category, Rating, Reply, Notification, Ticket, TicketMessage, UserIP, Banner +from .models import User, Flavor, Category, Rating, Reply, Notification, Ticket, TicketMessage, UserIP, Banner, SystemConfig, Job class CategorySerializer(serializers.ModelSerializer): class Meta: model = Category fields = ['id', 'name', 'slug'] +class SystemConfigSerializer(serializers.ModelSerializer): + class Meta: + model = SystemConfig + fields = ['site_name', 'maintenance_mode', 'allow_new_signups', 'require_email_verification', 'updated_at'] + +class JobSerializer(serializers.ModelSerializer): + name_display = serializers.CharField(source='get_name_display', read_only=True) + + class Meta: + model = Job + fields = ['id', 'name', 'name_display', 'status', 'last_run', 'next_run', 'interval_hours', 'last_output', 'error_message'] + read_only_fields = ['status', 'last_run', 'next_run', 'last_output', 'error_message'] + class BannerSerializer(serializers.ModelSerializer): class Meta: model = Banner diff --git a/backend/api/views.py b/backend/api/views.py index 92d1020..c5491e6 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -865,9 +865,64 @@ class AdminViewSet(viewsets.ViewSet): 'use_tls': getattr(settings, 'EMAIL_USE_TLS', False), 'use_ssl': getattr(settings, 'EMAIL_USE_SSL', False), 'skip_verify': getattr(settings, 'EMAIL_SKIP_CERT_VERIFICATION', False), + }, + 'server_info': { + 'debug': settings.DEBUG, + 'allowed_hosts': settings.ALLOWED_HOSTS, + 'frontend_url': getattr(settings, 'FRONTEND_URL', 'Not set'), + 'media_root': settings.MEDIA_ROOT, + 'static_root': settings.STATIC_ROOT, } }) + @action(detail=False, methods=['get', 'patch']) + def config(self, request): + config = SystemConfig.get_solo() + if request.method == 'PATCH': + serializer = SystemConfigSerializer(config, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data) + + serializer = SystemConfigSerializer(config) + return Response(serializer.data) + + @action(detail=False, methods=['get']) + def jobs(self, request): + # Ensure all jobs exist + for job_type, label in Job.JOB_TYPES: + Job.objects.get_or_create(name=job_type) + + jobs = Job.objects.all().order_by('name') + serializer = JobSerializer(jobs, many=True) + return Response(serializer.data) + + @action(detail=True, methods=['post']) + def trigger_job(self, request, pk=None): + try: + job = Job.objects.get(pk=pk) + job.status = 'pending' + job.save() + return Response({'status': 'Job queued'}) + except Job.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + + @action(detail=True, methods=['patch']) + def update_job_schedule(self, request, pk=None): + try: + job = Job.objects.get(pk=pk) + interval = request.data.get('interval_hours') + if interval is not None: + job.interval_hours = int(interval) + # If we just enabled it, set next_run + if job.interval_hours > 0 and not job.next_run: + from django.utils import timezone + job.next_run = timezone.now() + job.save() + return Response(JobSerializer(job).data) + except Job.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + @action(detail=False, methods=['post']) def send_test_email(self, request): try: diff --git a/docker-compose.yml b/docker-compose.yml index e6b66ee..ca49ac6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,7 +32,23 @@ services: retries: 5 start_period: 30s - # --- Frontend Service --- + worker: + build: + context: ./backend + container_name: holy-worker + command: python manage.py worker + restart: unless-stopped + networks: + - internal + env_file: + - .env + volumes: + - ./backend:/app + - ./legacy:/app/legacy + depends_on: + backend: + condition: service_healthy + frontend: build: context: ./frontend diff --git a/frontend/src/pages/AdminDashboard.tsx b/frontend/src/pages/AdminDashboard.tsx index 1a60d23..f71469a 100644 --- a/frontend/src/pages/AdminDashboard.tsx +++ b/frontend/src/pages/AdminDashboard.tsx @@ -3,7 +3,8 @@ import { Container, Typography, Box, Grid, Card, CardContent, Button, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Stack, alpha, CircularProgress, Chip, TextField, IconButton, - useMediaQuery, Tabs, Tab + useMediaQuery, Tabs, Tab, List, ListItem, ListItemText, Switch, Divider, + Alert } from '@mui/material'; import EditIcon from '@mui/icons-material/Edit'; import EmailIcon from '@mui/icons-material/Email'; @@ -11,6 +12,9 @@ import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import SettingsIcon from '@mui/icons-material/Settings'; import PeopleIcon from '@mui/icons-material/People'; import WallpaperIcon from '@mui/icons-material/Wallpaper'; +import TerminalIcon from '@mui/icons-material/Terminal'; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import InfoIcon from '@mui/icons-material/Info'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import api from '../api'; @@ -30,6 +34,13 @@ interface Stats { use_ssl: boolean; skip_verify: boolean; }; + server_info: { + debug: boolean; + allowed_hosts: string[]; + frontend_url: string; + media_root: string; + static_root: string; + }; } interface AdminUser { @@ -54,6 +65,25 @@ interface Banner { schema: any[]; } +interface Job { + id: number; + name: string; + name_display: string; + status: 'pending' | 'running' | 'completed' | 'failed'; + last_run: string | null; + next_run: string | null; + interval_hours: number; + last_output: string; + error_message: string; +} + +interface SystemConfig { + site_name: string; + maintenance_mode: boolean; + allow_new_signups: boolean; + require_email_verification: boolean; +} + const AdminDashboard: React.FC = () => { const { t } = useTranslation(); const navigate = useNavigate(); @@ -62,6 +92,8 @@ const AdminDashboard: React.FC = () => { const [stats, setStats] = useState(null); const [users, setUsers] = useState([]); const [banners, setBanners] = useState([]); + const [jobs, setJobs] = useState([]); + const [config, setConfig] = useState(null); const [loading, setLoading] = useState(true); const [search, setSearch] = useState(''); const [currentTab, setCurrentTab] = useState(0); @@ -71,30 +103,57 @@ const AdminDashboard: React.FC = () => { const fetchData = async () => { setLoading(true); try { - const statsRes = await api.get('admin-custom/stats/'); + const [statsRes, usersRes, bannersRes, jobsRes, configRes] = await Promise.all([ + api.get('admin-custom/stats/'), + api.get('admin-custom/users/'), + api.get('banners/'), + api.get('admin-custom/jobs/'), + api.get('admin-custom/config/') + ]); + setStats(statsRes.data); - } catch (err) { - console.error('Stats fetch error:', err); - } - - try { - const usersRes = await api.get('admin-custom/users/'); setUsers(usersRes.data); - } catch (err) { - console.error('Users fetch error:', err); - } - - try { - const bannersRes = await api.get('banners/'); + const bannersData = Array.isArray(bannersRes.data) ? bannersRes.data : (bannersRes.data.results || []); setBanners(bannersData); + + setJobs(jobsRes.data); + setConfig(configRes.data); } catch (err) { - console.error('Banners fetch error:', err); + console.error('Data fetch error:', err); } setLoading(false); }; + const handleUpdateConfig = async (key: string, value: any) => { + try { + const res = await api.patch('admin-custom/config/', { [key]: value }); + setConfig(res.data); + } catch (err) { + alert('Failed to update configuration'); + } + }; + + const handleTriggerJob = async (id: number) => { + try { + await api.post(`admin-custom/${id}/trigger_job/`); + alert('Job queued successfully'); + fetchData(); + } catch (err) { + alert('Failed to trigger job'); + } + }; + + const handleUpdateJobSchedule = async (id: number, interval: number) => { + try { + await api.patch(`admin-custom/${id}/update_job_schedule/`, { interval_hours: interval }); + fetchData(); + } catch (err) { + alert('Failed to update schedule'); + } + }; + const handleActivateBanner = async (id: number) => { try { await api.post(`banners/${id}/activate/`); @@ -154,46 +213,113 @@ const AdminDashboard: React.FC = () => { } label={t('admin.overview')} iconPosition="start" /> } label={t('admin.users')} iconPosition="start" /> } label={t('admin.banners')} iconPosition="start" /> + } label="Jobs & Workers" iconPosition="start" /> {currentTab === 0 && ( - - - alpha(theme.palette.primary.main, 0.1) }}> - - {t('admin.totalUsers')} - {stats?.total_users} - - + + + + alpha(theme.palette.primary.main, 0.1) }}> + + {t('admin.totalUsers')} + {stats?.total_users} + + + + + alpha(theme.palette.error.main, 0.1) }}> + + {t('admin.openTickets')} + {stats?.open_tickets} + + + + + + + + + {t('admin.config')}: SMTP + + {stats?.email_config.host}:{stats?.email_config.port} • + TLS: {String(stats?.email_config.use_tls)} • + Insecure: {String(stats?.email_config.skip_verify)} + + + + + + + - - alpha(theme.palette.error.main, 0.1) }}> - - {t('admin.openTickets')} - {stats?.open_tickets} - - - - - - - + + + {/* Live Config */} + + System Configuration (Live) + + + {[ + { key: 'site_name', label: 'Site Name', type: 'text' }, + { key: 'maintenance_mode', label: 'Maintenance Mode', type: 'switch' }, + { key: 'allow_new_signups', label: 'Allow Signups', type: 'switch' }, + { key: 'require_email_verification', label: 'Require Email Verify', type: 'switch' } + ].map((item, idx) => ( + + + {item.type === 'switch' ? ( + ) => handleUpdateConfig(item.key, e.target.checked)} + /> + ) : ( + + )} + + ))} + + + + + {/* Environment Info */} + + Environment Info (.env) + + - {t('admin.config')}: SMTP - - {stats?.email_config.host}:{stats?.email_config.port} • - TLS: {String(stats?.email_config.use_tls)} • - Insecure: {String(stats?.email_config.skip_verify)} + API Status + DEBUG={String(stats?.server_info.debug)} + + + Frontend URL + {stats?.server_info.frontend_url} + + + Allowed Hosts + {stats?.server_info.allowed_hosts.join(', ')} + + + + + + These values are read from your server environment or .env file. + To change them, you must update the file and restart the backend container. - - - + + - + )} {currentTab === 1 && ( @@ -349,6 +475,74 @@ const AdminDashboard: React.FC = () => { )} + {currentTab === 3 && ( + + Background Worker Jobs + + {jobs.map(job => ( + + + + {job.name_display} + + + + + + Last Run + {job.last_run ? formatDate(job.last_run) : 'Never'} + + + Next Run + {job.next_run ? formatDate(job.next_run) : (job.interval_hours > 0 ? 'Pending' : 'Disabled')} + + + Interval (Hours) + + {job.interval_hours}h + + + + + + {job.last_output && ( + + Last Run Output + +
{job.last_output}
+
+
+ )} + + {job.error_message && ( + + Last Error + {job.error_message} + + )} + + +
+
+ ))} +
+
+ )} + { @@ -363,3 +557,4 @@ const AdminDashboard: React.FC = () => { }; export default AdminDashboard; + diff --git a/frontend/src/pages/flavors/FlavorDetail.tsx b/frontend/src/pages/flavors/FlavorDetail.tsx index 0ed93c5..44b7160 100644 --- a/frontend/src/pages/flavors/FlavorDetail.tsx +++ b/frontend/src/pages/flavors/FlavorDetail.tsx @@ -16,7 +16,11 @@ import { Stack, alpha, useTheme, - Collapse + Collapse, + TextField, + FormControlLabel, + Switch, + Divider } from '@mui/material'; import api from '../../api'; import { useTitle } from '../../hooks/useTitle'; @@ -24,6 +28,9 @@ import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ShoppingCartIcon from '@mui/icons-material/ShoppingCart'; import VerifiedIcon from '@mui/icons-material/Verified'; import CommentIcon from '@mui/icons-material/Comment'; +import EditIcon from '@mui/icons-material/Edit'; +import SaveIcon from '@mui/icons-material/Save'; +import CancelIcon from '@mui/icons-material/Cancel'; import { formatDate } from '../../utils/date'; import MentionTextField from '../../components/MentionTextField'; import RichText from '../../components/RichText'; @@ -77,6 +84,16 @@ const FlavorDetail: React.FC = ({ adminMode }) => { const [expandedReplies, setExpandedReplies] = useState<{[key: number]: boolean}>({}); const [currentUser, setCurrentUser] = useState(null); + // Product Edit state (Admin) + const [isAdminEditing, setIsAdminEditing] = useState(false); + const [editFlavorData, setEditFlavorData] = useState({ + name: '', + description: '', + shop_url: '', + is_available: true, + is_legacy: false + }); + // Rating form state const [newScore, setNewScore] = useState(null); const [newComment, setNewComment] = useState(''); @@ -94,6 +111,13 @@ const FlavorDetail: React.FC = ({ adminMode }) => { try { const res = await api.get(`flavors/${id}/`); setFlavor(res.data); + setEditFlavorData({ + name: res.data.name, + description: res.data.description, + shop_url: res.data.shop_url || '', + is_available: res.data.is_available, + is_legacy: res.data.is_legacy + }); } catch (err) { console.error(err); } finally { @@ -114,6 +138,16 @@ const FlavorDetail: React.FC = ({ adminMode }) => { useTitle(flavor?.name || t('common.loading')); + const handleAdminUpdate = async () => { + try { + await api.patch(`flavors/${id}/`, editFlavorData); + setIsAdminEditing(false); + fetchFlavor(); + } catch (err) { + alert('Admin update failed'); + } + }; + const handleRatingSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!newScore) { @@ -267,27 +301,77 @@ const FlavorDetail: React.FC = ({ adminMode }) => { )} - - - {flavor.name} - - - - - - {flavor.ratings.length} {t('common.reviews')} - - + {adminMode && ( + + {!isAdminEditing ? ( + + ) : ( + + + + + )} + + )} - - - + {isAdminEditing ? ( + + setEditFlavorData({...editFlavorData, name: e.target.value})} + /> + setEditFlavorData({...editFlavorData, description: e.target.value})} + /> + setEditFlavorData({...editFlavorData, shop_url: e.target.value})} + /> + setEditFlavorData({...editFlavorData, is_available: e.target.checked})} />} + label="In Stock" + /> + setEditFlavorData({...editFlavorData, is_legacy: e.target.checked})} />} + label="Legacy / Limited" + /> + + + ) : ( + <> + + + {flavor.name} + + + + + + {flavor.ratings.length} {t('common.reviews')} + + - {flavor.shop_url && ( + + + + + )} + + {flavor.shop_url && !isAdminEditing && (