mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
merged
This commit is contained in:
commit
18b01a041a
10 files changed
+607
-77
No files matched your search
@@ -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}"))
|
||||
@@ -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."))
|
||||
@@ -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
|
||||
@@ -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',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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})"
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
+17
-1
@@ -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
|
||||
|
||||
@@ -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<Stats | null>(null);
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [banners, setBanners] = useState<Banner[]>([]);
|
||||
const [jobs, setJobs] = useState<Job[]>([]);
|
||||
const [config, setConfig] = useState<SystemConfig | null>(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 = () => {
|
||||
<Tab icon={<SettingsIcon />} label={t('admin.overview')} iconPosition="start" />
|
||||
<Tab icon={<PeopleIcon />} label={t('admin.users')} iconPosition="start" />
|
||||
<Tab icon={<WallpaperIcon />} label={t('admin.banners')} iconPosition="start" />
|
||||
<Tab icon={<TerminalIcon />} label="Jobs & Workers" iconPosition="start" />
|
||||
</Tabs>
|
||||
|
||||
{currentTab === 0 && (
|
||||
<Grid container spacing={isMobile ? 2 : 3} sx={{ mb: 6 }}>
|
||||
<Grid size={{ xs: 6, md: 3 }}>
|
||||
<Card sx={{ bgcolor: (theme) => alpha(theme.palette.primary.main, 0.1) }}>
|
||||
<CardContent sx={{ p: isMobile ? 2 : 3 }}>
|
||||
<Typography variant="overline" sx={{ fontSize: '0.6rem' }}>{t('admin.totalUsers')}</Typography>
|
||||
<Typography variant={isMobile ? "h5" : "h4"} sx={{ fontWeight: 'bold' }}>{stats?.total_users}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Box>
|
||||
<Grid container spacing={isMobile ? 2 : 3} sx={{ mb: 6 }}>
|
||||
<Grid size={{ xs: 6, md: 3 }}>
|
||||
<Card sx={{ bgcolor: (theme) => alpha(theme.palette.primary.main, 0.1) }}>
|
||||
<CardContent sx={{ p: isMobile ? 2 : 3 }}>
|
||||
<Typography variant="overline" sx={{ fontSize: '0.6rem' }}>{t('admin.totalUsers')}</Typography>
|
||||
<Typography variant={isMobile ? "h5" : "h4"} sx={{ fontWeight: 'bold' }}>{stats?.total_users}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3 }}>
|
||||
<Card sx={{ bgcolor: (theme) => alpha(theme.palette.error.main, 0.1) }}>
|
||||
<CardContent sx={{ p: isMobile ? 2 : 3 }}>
|
||||
<Typography variant="overline" sx={{ fontSize: '0.6rem' }}>{t('admin.openTickets')}</Typography>
|
||||
<Typography variant={isMobile ? "h5" : "h4"} sx={{ fontWeight: 'bold', color: 'error.main' }}>{stats?.open_tickets}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Card variant="outlined">
|
||||
<CardContent sx={{ p: isMobile ? 2 : 3 }}>
|
||||
<Stack direction={isMobile ? "column" : "row"} justifyContent="space-between" alignItems={isMobile ? "flex-start" : "center"} spacing={2}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>{t('admin.config')}: SMTP</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
|
||||
{stats?.email_config.host}:{stats?.email_config.port} •
|
||||
TLS: {String(stats?.email_config.use_tls)} •
|
||||
Insecure: {String(stats?.email_config.skip_verify)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button variant="outlined" fullWidth={isMobile} startIcon={<EmailIcon />} onClick={handleSendTestEmail} size="small">
|
||||
{t('admin.sendTestEmail')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3 }}>
|
||||
<Card sx={{ bgcolor: (theme) => alpha(theme.palette.error.main, 0.1) }}>
|
||||
<CardContent sx={{ p: isMobile ? 2 : 3 }}>
|
||||
<Typography variant="overline" sx={{ fontSize: '0.6rem' }}>{t('admin.openTickets')}</Typography>
|
||||
<Typography variant={isMobile ? "h5" : "h4"} sx={{ fontWeight: 'bold', color: 'error.main' }}>{stats?.open_tickets}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Card variant="outlined">
|
||||
<CardContent sx={{ p: isMobile ? 2 : 3 }}>
|
||||
<Stack direction={isMobile ? "column" : "row"} justifyContent="space-between" alignItems={isMobile ? "flex-start" : "center"} spacing={2}>
|
||||
|
||||
<Grid container spacing={4}>
|
||||
{/* Live Config */}
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold', mb: 2 }}>System Configuration (Live)</Typography>
|
||||
<Paper variant="outlined" sx={{ p: 0, borderRadius: 3, overflow: 'hidden' }}>
|
||||
<List disablePadding>
|
||||
{[
|
||||
{ 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) => (
|
||||
<ListItem key={item.key} divider={idx < 3} sx={{ py: 2 }}>
|
||||
<ListItemText
|
||||
primary={item.label}
|
||||
secondary={item.type === 'text' ? (config as any)?.[item.key] : null}
|
||||
/>
|
||||
{item.type === 'switch' ? (
|
||||
<Switch
|
||||
checked={(config as any)?.[item.key] || false}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleUpdateConfig(item.key, e.target.checked)}
|
||||
/>
|
||||
) : (
|
||||
<Button size="small" onClick={() => {
|
||||
const val = prompt(`Enter new value for ${item.label}:`, (config as any)?.[item.key]);
|
||||
if (val !== null) handleUpdateConfig(item.key, val);
|
||||
}}>Edit</Button>
|
||||
)}
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Environment Info */}
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold', mb: 2 }}>Environment Info (.env)</Typography>
|
||||
<Paper variant="outlined" sx={{ p: 3, borderRadius: 3, bgcolor: alpha('#000', 0.02) }}>
|
||||
<Stack spacing={2.5}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>{t('admin.config')}: SMTP</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
|
||||
{stats?.email_config.host}:{stats?.email_config.port} •
|
||||
TLS: {String(stats?.email_config.use_tls)} •
|
||||
Insecure: {String(stats?.email_config.skip_verify)}
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 'bold', textTransform: 'uppercase' }}>API Status</Typography>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>DEBUG={String(stats?.server_info.debug)}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 'bold', textTransform: 'uppercase' }}>Frontend URL</Typography>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>{stats?.server_info.frontend_url}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 'bold', textTransform: 'uppercase' }}>Allowed Hosts</Typography>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>{stats?.server_info.allowed_hosts.join(', ')}</Typography>
|
||||
</Box>
|
||||
<Divider />
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
|
||||
<InfoIcon fontSize="small" color="primary" />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
These values are read from your server environment or .env file.
|
||||
To change them, you must update the file and restart the backend container.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button variant="outlined" fullWidth={isMobile} startIcon={<EmailIcon />} onClick={handleSendTestEmail} size="small">
|
||||
{t('admin.sendTestEmail')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{currentTab === 1 && (
|
||||
@@ -349,6 +475,74 @@ const AdminDashboard: React.FC = () => {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{currentTab === 3 && (
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 'bold', mb: 3 }}>Background Worker Jobs</Typography>
|
||||
<Stack spacing={3}>
|
||||
{jobs.map(job => (
|
||||
<Card key={job.id} sx={{ borderRadius: 3, border: '1px solid', borderColor: 'divider' }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>{job.name_display}</Typography>
|
||||
<Chip
|
||||
label={job.status.toUpperCase()}
|
||||
size="small"
|
||||
color={job.status === 'completed' ? 'success' : job.status === 'running' ? 'info' : job.status === 'failed' ? 'error' : 'default'}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={2} sx={{ mb: 2 }}>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Typography variant="caption" color="text.secondary" display="block">Last Run</Typography>
|
||||
<Typography variant="body2">{job.last_run ? formatDate(job.last_run) : 'Never'}</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Typography variant="caption" color="text.secondary" display="block">Next Run</Typography>
|
||||
<Typography variant="body2">{job.next_run ? formatDate(job.next_run) : (job.interval_hours > 0 ? 'Pending' : 'Disabled')}</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Typography variant="caption" color="text.secondary" display="block">Interval (Hours)</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>{job.interval_hours}h</Typography>
|
||||
<Button size="small" onClick={() => {
|
||||
const val = prompt('Set interval in hours (0 to disable):', String(job.interval_hours));
|
||||
if (val !== null) handleUpdateJobSchedule(job.id, parseInt(val));
|
||||
}}>Change</Button>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{job.last_output && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="caption" color="text.secondary" display="block" gutterBottom>Last Run Output</Typography>
|
||||
<Box sx={{ p: 1.5, bgcolor: '#1e1e1e', color: '#d4d4d4', borderRadius: 2, maxHeight: 150, overflowY: 'auto', border: '1px solid #333' }}>
|
||||
<pre style={{ margin: 0, fontSize: '0.75rem', fontFamily: 'monospace', whiteSpace: 'pre-wrap' }}>{job.last_output}</pre>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{job.error_message && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="caption" color="error" display="block" gutterBottom>Last Error</Typography>
|
||||
<Alert severity="error" sx={{ py: 0 }}>{job.error_message}</Alert>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<PlayArrowIcon />}
|
||||
disabled={job.status === 'running'}
|
||||
onClick={() => handleTriggerJob(job.id)}
|
||||
>
|
||||
Trigger Now
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<BannerSettingsDialog
|
||||
open={isSettingsDialogOpen}
|
||||
onClose={() => {
|
||||
@@ -363,3 +557,4 @@ const AdminDashboard: React.FC = () => {
|
||||
};
|
||||
|
||||
export default AdminDashboard;
|
||||
|
||||
@@ -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<FlavorDetailProps> = ({ adminMode }) => {
|
||||
const [expandedReplies, setExpandedReplies] = useState<{[key: number]: boolean}>({});
|
||||
const [currentUser, setCurrentUser] = useState<string | null>(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<number | null>(null);
|
||||
const [newComment, setNewComment] = useState('');
|
||||
@@ -94,6 +111,13 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ 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<FlavorDetailProps> = ({ 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<FlavorDetailProps> = ({ adminMode }) => {
|
||||
)}
|
||||
</Box>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
<Chip
|
||||
label={flavor.category_name}
|
||||
size="small"
|
||||
sx={{ mb: 2, fontWeight: 'bold', bgcolor: alpha(theme.palette.primary.main, 0.1), color: 'primary.main' }}
|
||||
/>
|
||||
<Typography variant="h4" sx={{ fontWeight: '800', mb: 2, lineHeight: 1.2 }}>
|
||||
{flavor.name}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<RatingBadge score={flavor.average_rating || 0} size="large" />
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 'bold' }}>
|
||||
{flavor.ratings.length} {t('common.reviews')}
|
||||
</Typography>
|
||||
</Box>
|
||||
{adminMode && (
|
||||
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'flex-end' }}>
|
||||
{!isAdminEditing ? (
|
||||
<Button startIcon={<EditIcon />} size="small" onClick={() => setIsAdminEditing(true)}>
|
||||
Edit Product (Admin)
|
||||
</Button>
|
||||
) : (
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button startIcon={<SaveIcon />} size="small" color="success" variant="contained" onClick={handleAdminUpdate}>
|
||||
Save
|
||||
</Button>
|
||||
<Button startIcon={<CancelIcon />} size="small" onClick={() => setIsAdminEditing(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Typography variant="body2" sx={{ lineHeight: 1.7, color: 'text.secondary', mb: 4 }}>
|
||||
<RichText text={flavor.description} />
|
||||
</Typography>
|
||||
{isAdminEditing ? (
|
||||
<Stack spacing={2} sx={{ mb: 2 }}>
|
||||
<TextField
|
||||
label="Name" fullWidth size="small"
|
||||
value={editFlavorData.name}
|
||||
onChange={(e) => setEditFlavorData({...editFlavorData, name: e.target.value})}
|
||||
/>
|
||||
<TextField
|
||||
label="Description" fullWidth size="small" multiline rows={4}
|
||||
value={editFlavorData.description}
|
||||
onChange={(e) => setEditFlavorData({...editFlavorData, description: e.target.value})}
|
||||
/>
|
||||
<TextField
|
||||
label="Shop URL" fullWidth size="small"
|
||||
value={editFlavorData.shop_url}
|
||||
onChange={(e) => setEditFlavorData({...editFlavorData, shop_url: e.target.value})}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={editFlavorData.is_available} onChange={(e) => setEditFlavorData({...editFlavorData, is_available: e.target.checked})} />}
|
||||
label="In Stock"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={editFlavorData.is_legacy} onChange={(e) => setEditFlavorData({...editFlavorData, is_legacy: e.target.checked})} />}
|
||||
label="Legacy / Limited"
|
||||
/>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
</Stack>
|
||||
) : (
|
||||
<>
|
||||
<Chip
|
||||
label={flavor.category_name}
|
||||
size="small"
|
||||
sx={{ mb: 2, fontWeight: 'bold', bgcolor: alpha(theme.palette.primary.main, 0.1), color: 'primary.main' }}
|
||||
/>
|
||||
<Typography variant="h4" sx={{ fontWeight: '800', mb: 2, lineHeight: 1.2 }}>
|
||||
{flavor.name}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<RatingBadge score={flavor.average_rating || 0} size="large" />
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 'bold' }}>
|
||||
{flavor.ratings.length} {t('common.reviews')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{flavor.shop_url && (
|
||||
<Typography variant="body2" sx={{ lineHeight: 1.7, color: 'text.secondary', mb: 4 }}>
|
||||
<RichText text={flavor.description} />
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
|
||||
{flavor.shop_url && !isAdminEditing && (
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
|
||||
Reference in new issue
Block a user