diff options
| author | Natalia <124304+nessita@users.noreply.github.com> | 2026-04-08 12:07:49 -0300 |
|---|---|---|
| committer | nessita <124304+nessita@users.noreply.github.com> | 2026-04-14 22:40:39 -0300 |
| commit | ab5429a66410966cebbce2616e216abf02f50b32 (patch) | |
| tree | d5154931e216047fb616204b378505d6aa8d5aaa /foundation | |
| parent | 858167b78fad97ec2aa3ef5a84de730a41135e5c (diff) | |
Allowed creation of site-wide banners via the admin interface.
Fixes #1550.
A banner requires a title, and optionally HTML body and CTA label/URL.
A banner can be active or inactive, and only one can be active at a time.
Banners can be previewed from the admin via the "View on site" options
on a Banner's detail page.
This initial implementation is intentionally minimal to provide a robust
but flexible MVP, with the goal to gather feedback for future iterations.
Future improvements could include:
- Active since and until dates.
- Flexible CTA URL handling, such as URL names or local URLs.
Thanks to Sarahs for the reviews.
Diffstat (limited to 'foundation')
| -rw-r--r-- | foundation/admin.py | 29 | ||||
| -rw-r--r-- | foundation/migrations/0008_banner.py | 92 | ||||
| -rw-r--r-- | foundation/models.py | 82 | ||||
| -rw-r--r-- | foundation/templatetags/banner.py | 10 | ||||
| -rw-r--r-- | foundation/tests.py | 138 | ||||
| -rw-r--r-- | foundation/views.py | 8 |
6 files changed, 355 insertions, 4 deletions
diff --git a/foundation/admin.py b/foundation/admin.py index 1c905040..13a604b4 100644 --- a/foundation/admin.py +++ b/foundation/admin.py @@ -52,6 +52,35 @@ class ActionItemInline(admin.StackedInline): model = models.ActionItem +@admin.register(models.Banner) +class BannerAdmin(admin.ModelAdmin): + list_display = ["title", "is_active", "created_at", "updated_at"] + list_editable = ["is_active"] + readonly_fields = ["created_by", "created_at", "updated_by", "updated_at"] + fieldsets = [ + (None, {"fields": ["title", "body", "is_active"]}), + ( + "Call to action", + { + "description": ( + "Both fields should be defined for the CTA button to be displayed." + ), + "fields": ["cta_label", "cta_url"], + }, + ), + ( + "Metadata", + {"fields": ["created_by", "created_at", "updated_by", "updated_at"]}, + ), + ] + + def save_model(self, request, obj, form, change): + if not change: + obj.created_by = request.user + obj.updated_by = request.user + super().save_model(request, obj, form, change) + + @admin.register(models.Meeting) class MeetingAdmin(admin.ModelAdmin): fieldsets = ( diff --git a/foundation/migrations/0008_banner.py b/foundation/migrations/0008_banner.py new file mode 100644 index 00000000..16d29dbd --- /dev/null +++ b/foundation/migrations/0008_banner.py @@ -0,0 +1,92 @@ +# Generated by Django 6.0.3 on 2026-04-13 09:20 + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("foundation", "0007_boardmember_account_protect"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Banner", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=512)), + ( + "body", + models.TextField( + blank=True, + help_text="Optional banner body text. HTML is supported and will be rendered as given.", + ), + ), + ( + "cta_label", + models.CharField( + blank=True, + help_text="Label for the call-to-action button. Both label and URL must be provided for the button to appear.", + max_length=100, + verbose_name="Call-to-action button label", + ), + ), + ( + "cta_url", + models.URLField( + blank=True, + help_text="URL for the call-to-action button. Both label and URL must be provided for the button to appear.", + verbose_name="Call-to-action button URL", + ), + ), + ( + "is_active", + models.BooleanField( + default=False, + help_text="Only one banner can be active at a time. Activating this banner will deactivate all others.", + ), + ), + ( + "created_at", + models.DateTimeField( + default=django.utils.timezone.now, editable=False + ), + ), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "created_by", + models.ForeignKey( + blank=True, + editable=False, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + editable=False, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/foundation/models.py b/foundation/models.py index 7ae1fa4a..86826e76 100644 --- a/foundation/models.py +++ b/foundation/models.py @@ -1,10 +1,12 @@ from decimal import Decimal from django.conf import settings +from django.core.exceptions import ValidationError from django.db import models -from django.urls import reverse +from django.utils import timezone from django.utils.dateformat import format as date_format from django.utils.translation import gettext_lazy as _ +from django_hosts.resolvers import reverse from djmoney.models.fields import MoneyField from djmoney.settings import CURRENCIES from docutils.core import publish_parts @@ -186,6 +188,84 @@ class ApprovedCorporateMember(models.Model): return self.name +class Banner(models.Model): + """A site-wide campaign banner displayed in the billboard area. + + Only one banner can be active at a time; activating a banner automatically + deactivates all others. + + Users with the view_banner permission can preview inactive banners on the + live site. + """ + + title = models.CharField(max_length=512) + body = models.TextField( + blank=True, + help_text=( + "Optional banner body text. HTML is supported and will be rendered as given." + ), + ) + cta_label = models.CharField( + "Call-to-action button label", + max_length=100, + blank=True, + help_text=( + "Label for the call-to-action button. Both label and URL must be provided " + "for the button to appear." + ), + ) + cta_url = models.URLField( + "Call-to-action button URL", + blank=True, + help_text=( + "URL for the call-to-action button. Both label and URL must be provided " + "for the button to appear." + ), + ) + is_active = models.BooleanField( + default=False, + help_text=( + "Only one banner can be active at a time. Activating this banner will " + "deactivate all others." + ), + ) + created_at = models.DateTimeField(default=timezone.now, editable=False) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + editable=False, + on_delete=models.SET_NULL, + related_name="+", + ) + updated_at = models.DateTimeField(auto_now=True) + updated_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + editable=False, + on_delete=models.SET_NULL, + related_name="+", + ) + + def __str__(self): + return self.title + + def get_absolute_url(self): + return reverse("foundation_banner_preview", kwargs={"pk": self.pk}) + + def clean(self): + if bool(self.cta_label) != bool(self.cta_url): + raise ValidationError( + "Both a call-to-action label and URL must be provided, or neither." + ) + + def save(self, *args, **kwargs): + if self.is_active: + Banner.objects.exclude(pk=self.pk).update(is_active=False) + super().save(*args, **kwargs) + + class Business(models.Model): """ Business of the DSF Board. diff --git a/foundation/templatetags/banner.py b/foundation/templatetags/banner.py new file mode 100644 index 00000000..bc3fac45 --- /dev/null +++ b/foundation/templatetags/banner.py @@ -0,0 +1,10 @@ +from django import template + +from foundation.models import Banner + +register = template.Library() + + +@register.inclusion_tag("foundation/banner.html") +def active_banner(): + return {"banner": Banner.objects.filter(is_active=True).first()} diff --git a/foundation/tests.py b/foundation/tests.py index 3b1a88ac..802fd789 100644 --- a/foundation/tests.py +++ b/foundation/tests.py @@ -1,13 +1,145 @@ from datetime import date -from django.contrib.auth.models import User +from django.contrib.auth.models import Permission, User +from django.core.exceptions import ValidationError from django.test import TestCase -from django.urls import reverse +from django_hosts.resolvers import reverse from djmoney.money import Money from djangoproject.tests import ReleaseMixin -from .models import ApprovedGrant, BoardMember, Business, Meeting, Office, Term +from .models import ApprovedGrant, Banner, BoardMember, Business, Meeting, Office, Term + + +class BannerTestCase(ReleaseMixin, TestCase): + def test_activating_banner_deactivates_others(self): + b1 = Banner.objects.create(title="First", is_active=True) + b2 = Banner.objects.create(title="Second", is_active=True) + b1.refresh_from_db() + self.assertIs(b1.is_active, False) + self.assertIs(b2.is_active, True) + + def test_deactivating_banner_leaves_others_unchanged(self): + b1 = Banner.objects.create(title="First", is_active=True) + b2 = Banner.objects.create(title="Second") + self.assertIs(b2.is_active, False) + + b2.title = "New Title" + b2.save() + b1.refresh_from_db() + self.assertIs(b1.is_active, True) + b2.refresh_from_db() + self.assertIs(b2.is_active, False) + + def test_active_banner_tag_no_active_banner(self): + Banner.objects.create(title="Inactive", is_active=False) + response = self.client.get("/") + self.assertNotContains(response, '<div class="banner">') + + def test_active_banner_tag_renders_banner(self): + Banner.objects.create( + title="Support Django!", + body="Please donate.", + cta_label="Donate", + cta_url="https://djangoproject.com/donate/", + is_active=True, + ) + response = self.client.get("/") + self.assertContains(response, "<h2>Support Django!</h2>", html=True) + self.assertContains(response, "Please donate.") + self.assertContains( + response, + '<a id="banner-cta" class="cta" href="https://djangoproject.com/donate/">' + "Donate</a>", + html=True, + ) + + def test_cta_label_without_url_raises_validation_error(self): + banner = Banner(title="Incomplete CTA", cta_label="Click me", cta_url="") + with self.assertRaisesMessage( + ValidationError, + "Both a call-to-action label and URL must be provided, or neither.", + ): + banner.full_clean() + + def test_cta_url_without_label_raises_validation_error(self): + banner = Banner( + title="Incomplete CTA", cta_label="", cta_url="https://example.com/" + ) + with self.assertRaisesMessage( + ValidationError, + "Both a call-to-action label and URL must be provided, or neither.", + ): + banner.full_clean() + + def test_cta_both_fields_set_is_valid(self): + banner = Banner( + title="Full CTA", + cta_label="Click me", + cta_url="https://example.com/", + ) + banner.full_clean() + + def test_cta_both_fields_blank_is_valid(self): + banner = Banner(title="No CTA", cta_label="", cta_url="") + banner.full_clean() + + def test_active_banner_tag_no_cta_when_fields_blank(self): + Banner.objects.create(title="No CTA", cta_label="", cta_url="", is_active=True) + response = self.client.get("/") + self.assertContains(response, "No CTA") + self.assertNotContains(response, '<a id="banner-cta" class="cta"') + + def test_inactive_banner_not_shown_on_main_site(self): + Banner.objects.create(title="Inactive banner", is_active=False) + response = self.client.get("/") + self.assertNotContains(response, "Inactive banner") + + def test_banner_get_absolute_url(self): + banner = Banner.objects.create(title="My Banner") + self.assertEqual( + banner.get_absolute_url(), + reverse("foundation_banner_preview", (banner.pk,)), + ) + + def test_preview_view_requires_login(self): + banner = Banner.objects.create(title="Draft banner") + response = self.client.get(banner.get_absolute_url()) + self.assertRedirects( + response, f"/accounts/login/?next=/foundation/banners/{banner.pk}/preview/" + ) + + def test_preview_view_requires_permission(self): + banner = Banner.objects.create(title="Draft banner") + user = User.objects.create_user("viewer", "v@example.com", "password") + self.client.force_login(user) + response = self.client.get(banner.get_absolute_url()) + self.assertEqual(response.status_code, 403) + + def test_preview_view_renders_banner(self): + banner = Banner.objects.create( + title="Draft banner", + body="Some body text.", + cta_label="Click here", + cta_url="https://example.com/", + ) + user = User.objects.create_user("previewer", "p@example.com", "password") + user.user_permissions.add(Permission.objects.get(codename="view_banner")) + self.client.force_login(user) + response = self.client.get(banner.get_absolute_url()) + self.assertContains(response, "<h2>Draft banner</h2>", html=True) + self.assertContains(response, "Some body text.") + self.assertContains( + response, + '<a id="banner-cta" class="cta" href="https://example.com/">Click here</a>', + html=True, + ) + self.assertContains(response, "Preview!") + + def test_fundraising_page_suppresses_banner(self): + Banner.objects.create(title="Donate now!", is_active=True) + response = self.client.get("/fundraising/") + self.assertNotContains(response, "Donate now!") class MeetingTestCase(ReleaseMixin, TestCase): diff --git a/foundation/views.py b/foundation/views.py index 0cef304d..8128f687 100644 --- a/foundation/views.py +++ b/foundation/views.py @@ -1,3 +1,4 @@ +from django.contrib.auth.mixins import PermissionRequiredMixin from django.views import generic from . import models @@ -55,6 +56,13 @@ class MeetingDetail(MeetingMixin, generic.DateDetailView): return context_data +class BannerPreview(PermissionRequiredMixin, generic.DetailView): + model = models.Banner + permission_required = "foundation.view_banner" + template_name = "foundation/banner_preview.html" + context_object_name = "banner" + + class CoreDevelopers(generic.ListView): queryset = models.CoreAwardCohort.objects.prefetch_related("recipients").order_by( "-cohort_date" |
