This commit is contained in:
nvrl committed 2026-02-19 17:27:33 +01:00
commit 18b01a041a
10 files changed
+607 -77

No files matched your search

+5 -2
View File
@@ -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."))
+74
View File
@@ -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',
},
),
]
+48
View File
@@ -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})"
+14 -1
View File
@@ -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
+55
View File
@@ -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
View File
@@ -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
+241 -46
View File
@@ -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;
+104 -20
View File
@@ -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