Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion apps/nominations/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,23 @@
from django.contrib import admin
from django.db.models.functions import Lower

from apps.nominations.models import Election, Nomination, Nominee
from apps.nominations.models import Election, ElectionKind, Nomination, Nominee


@admin.register(ElectionKind)
class ElectionKindAdmin(admin.ModelAdmin):
"""Admin interface for managing election kinds and their accent colors."""

list_display = ("name", "accent_color", "slug")
readonly_fields = ("slug",)


@admin.register(Election)
class ElectionAdmin(admin.ModelAdmin):
"""Admin interface for managing elections."""

list_display = ("name", "kind", "date", "slug")
list_filter = ("kind",)
readonly_fields = ("slug",)


Expand Down
46 changes: 46 additions & 0 deletions apps/nominations/migrations/0003_election_kind.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Generated by Django 5.2.13 on 2026-06-24 15:33

import colorfield.fields
import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("nominations", "0002_auto_20190514_1435"),
]

operations = [
migrations.CreateModel(
name="ElectionKind",
fields=[
("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("name", models.CharField(max_length=100, unique=True)),
("slug", models.SlugField(blank=True, max_length=120, null=True, unique=True)),
(
"accent_color",
colorfield.fields.ColorField(
default="#0073b7",
help_text="Accent color used to theme this kind's pages.",
image_field=None,
max_length=25,
samples=None,
),
),
],
options={
"ordering": ["name"],
},
),
migrations.AddField(
model_name="election",
name="kind",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="elections",
to="nominations.electionkind",
),
),
]
42 changes: 42 additions & 0 deletions apps/nominations/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import datetime

from colorfield.fields import ColorField
from django.db import models
from django.db.models.signals import post_save
from django.dispatch import receiver
Expand All @@ -13,12 +14,48 @@
from apps.users.models import User
from fastly.utils import purge_url

DEFAULT_ACCENT_COLOR = "#0073b7"


class ElectionKind(models.Model):
"""An admin-managed category of election (Board, Packaging Council, ...).

Each kind carries an accent color used to visually distinguish its
election pages. New kinds can be added in the Django admin without
code changes.
"""

name = models.CharField(max_length=100, unique=True)
slug = models.SlugField(max_length=120, unique=True, blank=True, null=True)
accent_color = ColorField(default=DEFAULT_ACCENT_COLOR, help_text="Accent color used to theme this kind's pages.")

class Meta:
"""Meta configuration for ElectionKind."""

ordering = ["name"]

def __str__(self):
"""Return the kind name."""
return self.name

def save(self, *args, **kwargs):
"""Generate slug from name before saving."""
self.slug = slugify(self.name)
super().save(*args, **kwargs)


class Election(models.Model):
"""A PSF board election with nomination open/close dates."""

name = models.CharField(max_length=100)
date = models.DateField()
kind = models.ForeignKey(
ElectionKind,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="elections",
)
nominations_open_at = models.DateTimeField(blank=True, null=True)
nominations_close_at = models.DateTimeField(blank=True, null=True)
description = MarkupField(escape_html=False, markup_type="markdown", blank=False, null=True)
Expand All @@ -39,6 +76,11 @@ def save(self, *args, **kwargs):
self.slug = slugify(self.name)
super().save(*args, **kwargs)

@property
def accent_color(self):
"""Return the CSS accent color for this election's kind."""
return self.kind.accent_color if self.kind else DEFAULT_ACCENT_COLOR

@property
def nominations_open(self):
"""Return True if the current time is within the nomination window."""
Expand Down
20 changes: 20 additions & 0 deletions apps/nominations/templates/nominations/_theme.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{% comment %}
Per-election theming. Expects `election` in context (pass via
`{% include "nominations/_theme.html" with election=object.election %}`
where only the nomination object is available). Colors key headings
with the election's accent so Board vs. Packaging Council pages are
visually distinct. See Election.accent_color / the ElectionKind model.
{% endcomment %}
{% if election %}
<style>
:root {
--election-accent: {{ election.accent_color }};
}
/* Scoped to #content; this <style> only renders on themed election
pages, so these selectors never leak to the rest of the site. */
#content h1,
#content .page-title {
color: var(--election-accent);
}
</style>
{% endif %}
2 changes: 2 additions & 0 deletions apps/nominations/templates/nominations/election_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

{% block page_title %}Elections | {{ SITE_INFO.site_name }}{% endblock %}

{% block head %}{% include "nominations/_theme.html" %}{% endblock %}

{% block content_attributes %}with-left-sidebar{% endblock %}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
{% endblock %}

{% block body_attributes %}class="nominations nominations_view"{% endblock %}
{% block head %}{% include "nominations/_theme.html" %}{% endblock %}
{% block left_sidebar %}{% endblock %}
{% block content_attributes %}{% endblock %}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
{% endblock %}

{% block body_attributes %}class="nominations nominations_view"{% endblock %}
{% block head %}{% include "nominations/_theme.html" with election=nomination.election %}{% endblock %}
{% block left_sidebar %}{% endblock %}
{% block content_attributes %}{% endblock %}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
{% endblock %}

{% block body_attributes %}class="nominations nominations_form"{% endblock %}
{% block head %}{% include "nominations/_theme.html" %}{% endblock %}
{% block left_sidebar %}{% endblock %}
{% block content_attributes %}{% endblock %}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
{% endblock %}

{% block body_attributes %}class="nominations nominations_view"{% endblock %}
{% block head %}{% include "nominations/_theme.html" %}{% endblock %}
{% block left_sidebar %}{% endblock %}
{% block content_attributes %}{% endblock %}

Expand Down
1 change: 1 addition & 0 deletions apps/nominations/templates/nominations/nominee_list.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
{% block content_attributes %}{% endblock %}

{% block head %}
{% include "nominations/_theme.html" %}
<style>
details
{
Expand Down
Empty file.
52 changes: 52 additions & 0 deletions apps/nominations/tests/test_models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import datetime

from django.test import TestCase

from apps.nominations.models import DEFAULT_ACCENT_COLOR, Election, ElectionKind


class ElectionKindModelTests(TestCase):
def test_slug_generated_from_name(self):
kind = ElectionKind.objects.create(name="Packaging Council", accent_color="#6f42c1")
self.assertEqual(kind.slug, "packaging-council")

def test_slug_regenerated_on_rename(self):
kind = ElectionKind.objects.create(name="Board")
kind.name = "Steering Council"
kind.save()
self.assertEqual(kind.slug, "steering-council")

def test_str_is_name(self):
self.assertEqual(str(ElectionKind.objects.create(name="Board")), "Board")

def test_default_accent_color(self):
kind = ElectionKind.objects.create(name="Board")
self.assertEqual(kind.accent_color, DEFAULT_ACCENT_COLOR)


class ElectionAccentColorTests(TestCase):
def setUp(self):
self.election = Election.objects.create(
name="2026 Board Election",
date=datetime.date(2026, 1, 1),
)

def test_accent_color_falls_back_when_no_kind(self):
self.assertIsNone(self.election.kind)
self.assertEqual(self.election.accent_color, DEFAULT_ACCENT_COLOR)

def test_accent_color_uses_kind(self):
self.election.kind = ElectionKind.objects.create(name="Packaging Council", accent_color="#6f42c1")
self.election.save()
self.assertEqual(self.election.accent_color, "#6f42c1")

def test_accent_color_falls_back_after_kind_deleted(self):
kind = ElectionKind.objects.create(name="Packaging Council", accent_color="#6f42c1")
self.election.kind = kind
self.election.save()

kind.delete()
self.election.refresh_from_db()

self.assertIsNone(self.election.kind)
self.assertEqual(self.election.accent_color, DEFAULT_ACCENT_COLOR)
30 changes: 30 additions & 0 deletions apps/nominations/tests/test_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import datetime

from django.test import TestCase
from django.urls import reverse

from apps.nominations.models import DEFAULT_ACCENT_COLOR, Election, ElectionKind


class ElectionDetailThemeTests(TestCase):
def _make_election(self, name, kind=None):
return Election.objects.create(name=name, date=datetime.date(2026, 1, 1), kind=kind)

def test_detail_includes_kind_accent_color(self):
kind = ElectionKind.objects.create(name="Packaging Council", accent_color="#6f42c1")
election = self._make_election("2026 Packaging Council Election", kind=kind)

url = reverse("nominations:election_detail", kwargs={"election": election.slug})
response = self.client.get(url)

self.assertEqual(response.status_code, 200)
self.assertContains(response, "--election-accent: #6f42c1")

def test_detail_falls_back_to_default_accent_without_kind(self):
election = self._make_election("2026 Board Election")

url = reverse("nominations:election_detail", kwargs={"election": election.slug})
response = self.client.get(url)

self.assertEqual(response.status_code, 200)
self.assertContains(response, f"--election-accent: {DEFAULT_ACCENT_COLOR}")