mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-04 17:07:01 +01:00
Merge branch 'main' into narl.io
This commit is contained in:
commit
79bc1f0251
8 files changed
+168
-18
No files matched your search
@@ -0,0 +1,29 @@
|
||||
# Generated by Django 6.0.2 on 2026-02-18 16:11
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('api', '0025_banner_schema'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='banner',
|
||||
name='is_enabled',
|
||||
field=models.BooleanField(default=True, help_text='Allows users to select this banner.'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='selected_banner',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='users', to='api.banner'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='banner',
|
||||
name='is_active',
|
||||
field=models.BooleanField(default=False, help_text='Designates this as the global default banner.'),
|
||||
),
|
||||
]
|
||||
@@ -32,6 +32,7 @@ class User(AbstractUser):
|
||||
pending_email = models.EmailField(max_length=254, blank=True, null=True)
|
||||
email_confirmation_code = models.CharField(max_length=6, blank=True, null=True)
|
||||
following = models.ManyToManyField('self', symmetrical=False, related_name='followers', blank=True)
|
||||
selected_banner = models.ForeignKey('Banner', on_delete=models.SET_NULL, null=True, blank=True, related_name='users')
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
super().save(*args, **kwargs)
|
||||
@@ -195,7 +196,8 @@ class Banner(models.Model):
|
||||
name = models.CharField(max_length=100)
|
||||
slug = models.SlugField(unique=True)
|
||||
description = models.TextField(blank=True)
|
||||
is_active = models.BooleanField(default=False)
|
||||
is_active = models.BooleanField(default=False, help_text="Designates this as the global default banner.")
|
||||
is_enabled = models.BooleanField(default=True, help_text="Allows users to select this banner.")
|
||||
settings = models.JSONField(default=dict, blank=True)
|
||||
schema = models.JSONField(default=list, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
@@ -203,8 +205,12 @@ class Banner(models.Model):
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.is_active:
|
||||
# Deactivate other banners
|
||||
# Set all others to inactive
|
||||
Banner.objects.filter(is_active=True).exclude(pk=self.pk).update(is_active=False)
|
||||
elif not Banner.objects.filter(is_active=True).exclude(pk=self.pk).exists():
|
||||
# If this was the only active banner, don't allow deactivating it
|
||||
# unless there's another one to take its place.
|
||||
self.is_active = True
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
|
||||
@@ -9,7 +9,7 @@ class CategorySerializer(serializers.ModelSerializer):
|
||||
class BannerSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Banner
|
||||
fields = ['id', 'name', 'slug', 'description', 'is_active', 'settings', 'schema', 'created_at', 'updated_at']
|
||||
fields = ['id', 'name', 'slug', 'description', 'is_active', 'is_enabled', 'settings', 'schema', 'created_at', 'updated_at']
|
||||
|
||||
class ReplySerializer(serializers.ModelSerializer):
|
||||
user = serializers.StringRelatedField(read_only=True)
|
||||
@@ -87,10 +87,11 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
is_following = serializers.SerializerMethodField()
|
||||
unread_notifications_count = serializers.SerializerMethodField()
|
||||
is_superuser = serializers.BooleanField(read_only=True)
|
||||
selected_banner_slug = serializers.CharField(source='selected_banner.slug', read_only=True, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['id', 'username', 'email', 'theme', 'language', 'avatar', 'following_count', 'followers_count', 'is_following', 'unread_notifications_count', 'is_superuser']
|
||||
fields = ['id', 'username', 'email', 'theme', 'language', 'avatar', 'following_count', 'followers_count', 'is_following', 'unread_notifications_count', 'is_superuser', 'selected_banner', 'selected_banner_slug']
|
||||
|
||||
def get_avatar(self, obj):
|
||||
if obj.avatar:
|
||||
|
||||
+56
-2
@@ -418,6 +418,7 @@ class UserViewSet(viewsets.ModelViewSet):
|
||||
user = request.user
|
||||
theme = request.data.get('theme')
|
||||
language = request.data.get('language')
|
||||
banner_id = request.data.get('selected_banner')
|
||||
|
||||
updated = False
|
||||
if theme in dict(User.THEME_CHOICES):
|
||||
@@ -426,10 +427,27 @@ class UserViewSet(viewsets.ModelViewSet):
|
||||
if language in dict(User.LANGUAGE_CHOICES):
|
||||
user.language = language
|
||||
updated = True
|
||||
|
||||
if banner_id is not None:
|
||||
if banner_id == "":
|
||||
user.selected_banner = None
|
||||
updated = True
|
||||
else:
|
||||
try:
|
||||
banner = Banner.objects.get(pk=banner_id, is_enabled=True)
|
||||
user.selected_banner = banner
|
||||
updated = True
|
||||
except Banner.DoesNotExist:
|
||||
return Response({'error': 'Invalid or disabled banner'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if updated:
|
||||
user.save()
|
||||
return Response({'status': 'preferences updated', 'theme': user.theme, 'language': user.language})
|
||||
return Response({
|
||||
'status': 'preferences updated',
|
||||
'theme': user.theme,
|
||||
'language': user.language,
|
||||
'selected_banner': user.selected_banner.id if user.selected_banner else None
|
||||
})
|
||||
return Response({'error': 'No valid updates provided'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
@@ -691,6 +709,11 @@ class BannerViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
|
||||
pagination_class = None
|
||||
|
||||
def get_queryset(self):
|
||||
if self.request.user.is_authenticated and self.request.user.is_superuser:
|
||||
return Banner.objects.all()
|
||||
return Banner.objects.filter(is_enabled=True)
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action in ['list', 'retrieve', 'active']:
|
||||
return [permissions.AllowAny()]
|
||||
@@ -698,6 +721,27 @@ class BannerViewSet(viewsets.ModelViewSet):
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def active(self, request):
|
||||
# Determine which banner to show
|
||||
# Priority:
|
||||
# 1. Requested user's preference (if username provided in query)
|
||||
# 2. Logged in user's preference
|
||||
# 3. Global default
|
||||
|
||||
username = request.query_params.get('username')
|
||||
user = None
|
||||
if username:
|
||||
try:
|
||||
user = User.objects.get(username=username)
|
||||
except User.DoesNotExist:
|
||||
pass
|
||||
|
||||
if not user and request.user.is_authenticated:
|
||||
user = request.user
|
||||
|
||||
if user and user.selected_banner and user.selected_banner.is_enabled:
|
||||
serializer = self.get_serializer(user.selected_banner)
|
||||
return Response(serializer.data)
|
||||
|
||||
active_banner = Banner.objects.filter(is_active=True).first()
|
||||
if active_banner:
|
||||
serializer = self.get_serializer(active_banner)
|
||||
@@ -708,8 +752,18 @@ class BannerViewSet(viewsets.ModelViewSet):
|
||||
def activate(self, request, pk=None):
|
||||
banner = self.get_object()
|
||||
banner.is_active = True
|
||||
banner.is_enabled = True # Active banner must be enabled
|
||||
banner.save()
|
||||
return Response({'status': 'banner activated'})
|
||||
return Response({'status': 'banner set as global default'})
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def toggle_enabled(self, request, pk=None):
|
||||
banner = self.get_object()
|
||||
if banner.is_active:
|
||||
return Response({'error': 'Cannot disable the global default banner.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
banner.is_enabled = not banner.is_enabled
|
||||
banner.save()
|
||||
return Response({'status': 'enabled' if banner.is_enabled else 'disabled'})
|
||||
|
||||
class AdminViewSet(viewsets.ViewSet):
|
||||
permission_classes = [permissions.IsAdminUser]
|
||||
|
||||
@@ -60,7 +60,9 @@ const DynamicBanner: React.FC<DynamicBannerProps> = (props) => {
|
||||
useEffect(() => {
|
||||
const fetchActiveBanner = async () => {
|
||||
try {
|
||||
const res = await api.get('banners/active/');
|
||||
const res = await api.get('banners/active/', {
|
||||
params: { username: props.username }
|
||||
});
|
||||
setBanner(res.data);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch active banner:', err);
|
||||
@@ -70,7 +72,7 @@ const DynamicBanner: React.FC<DynamicBannerProps> = (props) => {
|
||||
};
|
||||
|
||||
fetchActiveBanner();
|
||||
}, []);
|
||||
}, [props.username]);
|
||||
|
||||
if (loading) return null;
|
||||
|
||||
|
||||
@@ -52,6 +52,8 @@ const resources = {
|
||||
"appearanceTitle": "Appearance & Language",
|
||||
"themeLabel": "Theme",
|
||||
"langLabel": "Language",
|
||||
"bannerLabel": "Interactive Banner",
|
||||
"bannerDefault": "Site Default",
|
||||
"avatarTitle": "Profile Picture",
|
||||
"avatarButton": "Upload New Avatar",
|
||||
"avatarHint": "Max size: 2MB. JPG, PNG or WEBP.",
|
||||
@@ -248,6 +250,8 @@ const resources = {
|
||||
"appearanceTitle": "Erscheinungsbild & Sprache",
|
||||
"themeLabel": "Design",
|
||||
"langLabel": "Sprache",
|
||||
"bannerLabel": "Interaktiver Banner",
|
||||
"bannerDefault": "Seiten-Standard",
|
||||
"avatarTitle": "Profilbild",
|
||||
"avatarButton": "Neues Bild hochladen",
|
||||
"avatarHint": "Max. Größe: 2MB. JPG, PNG oder WEBP.",
|
||||
|
||||
@@ -49,6 +49,7 @@ interface Banner {
|
||||
slug: string;
|
||||
description: string;
|
||||
is_active: boolean;
|
||||
is_enabled: boolean;
|
||||
settings: any;
|
||||
schema: any[];
|
||||
}
|
||||
@@ -103,6 +104,15 @@ const AdminDashboard: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleEnabled = async (id: number) => {
|
||||
try {
|
||||
await api.post(`banners/${id}/toggle_enabled/`);
|
||||
fetchData();
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.error || 'Failed to toggle visibility');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
@@ -293,7 +303,10 @@ const AdminDashboard: React.FC = () => {
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>{banner.name} (Slug: {banner.slug})</Typography>
|
||||
{banner.is_active && <Chip label="Active" color="success" size="small" />}
|
||||
<Stack direction="row" spacing={1}>
|
||||
{!banner.is_enabled && <Chip label="Hidden from Users" color="warning" size="small" variant="outlined" />}
|
||||
{banner.is_active && <Chip label="Global Default" color="success" size="small" />}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" sx={{ mb: 2 }}>{banner.description}</Typography>
|
||||
@@ -302,7 +315,7 @@ const AdminDashboard: React.FC = () => {
|
||||
<pre style={{ margin: 0, fontSize: '0.8rem' }}>{JSON.stringify(banner.settings, null, 2)}</pre>
|
||||
</Box>
|
||||
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Stack direction="row" spacing={2} flexWrap="wrap" useFlexGap>
|
||||
<Button
|
||||
variant="outlined" size="small"
|
||||
onClick={() => {
|
||||
@@ -312,12 +325,19 @@ const AdminDashboard: React.FC = () => {
|
||||
>
|
||||
Edit Settings
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined" size="small" color={banner.is_enabled ? "warning" : "success"}
|
||||
onClick={() => handleToggleEnabled(banner.id)}
|
||||
disabled={banner.is_active}
|
||||
>
|
||||
{banner.is_enabled ? "Disable for Users" : "Enable for Users"}
|
||||
</Button>
|
||||
{!banner.is_active && (
|
||||
<Button
|
||||
variant="contained" size="small"
|
||||
onClick={() => handleActivateBanner(banner.id)}
|
||||
>
|
||||
Set as Active
|
||||
Set as Default
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -44,6 +44,8 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
|
||||
const [deletionCode, setDeletionCode] = useState('');
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
|
||||
const [banners, setBanners] = useState<any[]>([]);
|
||||
const [selectedBannerId, setSelectedBannerId] = useState<number | string>("");
|
||||
|
||||
const handleGoBack = () => {
|
||||
if (window.history.length > 1) {
|
||||
@@ -56,14 +58,24 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
const res = await api.get('users/me/');
|
||||
setUsername(res.data.username);
|
||||
setEmail(res.data.email);
|
||||
setCurrentEmail(res.data.email);
|
||||
setAvatar(res.data.avatar);
|
||||
if (res.data.language) {
|
||||
setLanguage(res.data.language);
|
||||
const [userRes, bannersRes] = await Promise.all([
|
||||
api.get('users/me/'),
|
||||
api.get('banners/')
|
||||
]);
|
||||
|
||||
setUsername(userRes.data.username);
|
||||
setEmail(userRes.data.email);
|
||||
setCurrentEmail(userRes.data.email);
|
||||
setAvatar(userRes.data.avatar);
|
||||
setSelectedBannerId(userRes.data.selected_banner || "");
|
||||
|
||||
if (userRes.data.language) {
|
||||
setLanguage(userRes.data.language);
|
||||
}
|
||||
|
||||
// Handle both paginated and non-paginated
|
||||
const bannersData = Array.isArray(bannersRes.data) ? bannersRes.data : (bannersRes.data.results || []);
|
||||
setBanners(bannersData);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
@@ -245,6 +257,28 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth margin="normal">
|
||||
<InputLabel>{t('settings.bannerLabel')}</InputLabel>
|
||||
<Select
|
||||
value={selectedBannerId}
|
||||
label={t('settings.bannerLabel')}
|
||||
onChange={async (e) => {
|
||||
const newId = e.target.value;
|
||||
setSelectedBannerId(newId);
|
||||
try {
|
||||
await api.patch('users/update_preferences/', { selected_banner: newId });
|
||||
} catch (err) {
|
||||
console.error('Failed to update banner preference');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MenuItem value="">{t('settings.bannerDefault')}</MenuItem>
|
||||
{banners.map(b => (
|
||||
<MenuItem key={b.id} value={b.id}>{b.name}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth margin="normal">
|
||||
<InputLabel>{t('settings.langLabel')}</InputLabel>
|
||||
<Select
|
||||
|
||||
Reference in new issue
Block a user