diff --git a/backend/api/migrations/0026_banner_is_enabled_user_selected_banner_and_more.py b/backend/api/migrations/0026_banner_is_enabled_user_selected_banner_and_more.py new file mode 100644 index 0000000..bb8515c --- /dev/null +++ b/backend/api/migrations/0026_banner_is_enabled_user_selected_banner_and_more.py @@ -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.'), + ), + ] diff --git a/backend/api/models.py b/backend/api/models.py index b3c43fa..a8b5559 100644 --- a/backend/api/models.py +++ b/backend/api/models.py @@ -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): diff --git a/backend/api/serializers.py b/backend/api/serializers.py index 32f89eb..58e4238 100644 --- a/backend/api/serializers.py +++ b/backend/api/serializers.py @@ -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: diff --git a/backend/api/views.py b/backend/api/views.py index eed2204..23fd3d8 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -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] diff --git a/frontend/src/components/DynamicBanner.tsx b/frontend/src/components/DynamicBanner.tsx index 4f5ad31..9a4eaca 100644 --- a/frontend/src/components/DynamicBanner.tsx +++ b/frontend/src/components/DynamicBanner.tsx @@ -60,7 +60,9 @@ const DynamicBanner: React.FC = (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 = (props) => { }; fetchActiveBanner(); - }, []); + }, [props.username]); if (loading) return null; diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index afff887..12f8b72 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -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.", diff --git a/frontend/src/pages/AdminDashboard.tsx b/frontend/src/pages/AdminDashboard.tsx index ce80e60..1a60d23 100644 --- a/frontend/src/pages/AdminDashboard.tsx +++ b/frontend/src/pages/AdminDashboard.tsx @@ -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 = () => { {banner.name} (Slug: {banner.slug}) - {banner.is_active && } + + {!banner.is_enabled && } + {banner.is_active && } + {banner.description} @@ -302,7 +315,7 @@ const AdminDashboard: React.FC = () => {
{JSON.stringify(banner.settings, null, 2)}
- + + {!banner.is_active && ( )} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 7c037a9..3699744 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -44,6 +44,8 @@ const Settings: React.FC = ({ 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([]); + const [selectedBannerId, setSelectedBannerId] = useState(""); const handleGoBack = () => { if (window.history.length > 1) { @@ -56,14 +58,24 @@ const Settings: React.FC = ({ 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 = ({ themeName, onThemeChange }) => { + + {t('settings.bannerLabel')} + + + {t('settings.langLabel')}