Round out referee amounts/colors, and add bilingual (nl/en) news content

Referee display: drop the redundant "External" pill from the PDF form, round
the event/dashboard "due" summary to 2 decimals (a fine-grained km rate like
0.083 was pushing the raw total to 3+ decimals), and theme the PDF's accent
colors off the club's own primary/secondary colors instead of a hardcoded
default.

News: title/body stay the club's own-language (Dutch) text; new optional
title_en/body_en carry a translation, with News.effective_title_en/
effective_body_en resolving the fallback to the original on read rather than
copying it in at save time -- so editing the Dutch text later never leaves a
stale English copy behind, and existing rows get correct fallback behaviour
with no backfill. The news form lays both languages out side by side; the
detail page only shows an English section when one was actually added. The
public API returns both languages in one call (title_nl/body_nl/excerpt_nl
alongside title_en/body_en/excerpt_en, the latter never blank) -- a breaking
rename of the previously-unprefixed title/body/excerpt fields.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 20:47:58 +02:00
parent 0a9ac67b21
commit 5dcffefb28
13 changed files with 265 additions and 31 deletions

View File

@@ -622,6 +622,8 @@ team-tagged instead of categorised, with a two-step release flow rather than a b
``` ```
news.News(ClubScopedModel) # -> carries `club` news.News(ClubScopedModel) # -> carries `club`
title, slug (SlugField, auto from title), body (TextField) title, slug (SlugField, auto from title), body (TextField)
title_en CharField (blank) -- optional English translation of `title`
body_en TextField (blank) -- optional English translation of `body`
teams M2M teams.Team (blank -- empty means club-wide) teams M2M teams.Team (blank -- empty means club-wide)
visibility CharField (TextChoices: internal | external | both) visibility CharField (TextChoices: internal | external | both)
status CharField (TextChoices: draft | published) status CharField (TextChoices: draft | published)
@@ -665,9 +667,23 @@ home.HomeConfig(ClubScopedModel) # one row PER CLUB: featured articles/teams,
club person, not a raw login; `SET_NULL` so deleting a member doesn't erase their posts. club person, not a raw login; `SET_NULL` so deleting a member doesn't erase their posts.
- `slug`s back clean public URLs and feed `search`; they are **unique per club** (§2.4), so - `slug`s back clean public URLs and feed `search`; they are **unique per club** (§2.4), so
two clubs can both have `/news/season-kickoff`. Resolve within the request's club. two clubs can both have `/news/season-kickoff`. Resolve within the request's club.
- `visibility` (internal/external/both) is stored and enforced nowhere yet — no - `visibility` (internal/external/both) is enforced by the public read-only API
member-facing reading page or public API exists. Both are later work; the field is (`news/api.py`, mounted under `api/`) — only `external`/`both` items, published and
there so they don't need a backfill when they land. past their release date, are ever returned. No member-facing internal reading page
exists yet; that's later work.
- **`title`/`body` are Dutch (the club's own language, and the only one required);
`title_en`/`body_en` are an optional English translation**, both left blank by
default. Nothing computes or stores a fallback — `News.effective_title_en` /
`effective_body_en` resolve it on read (`title_en or title`), so translating a Dutch
edit later never leaves a stale English copy behind, and every existing row gets
correct fallback behaviour with no backfill. The public API always returns both
languages in one call (`title_nl`/`body_nl`/`excerpt_nl` alongside
`title_en`/`body_en`/`excerpt_en`, the latter three via the `effective_*` properties
so they're never blank) — no `?lang=` param, the consumer picks what it needs. The
control panel's news form lays the two languages out in side-by-side columns
(`management/templates/management/news_form.html`); the detail page only shows an
"English" section when a translation was actually added, not the fallback-filled
text under a second heading.
- `NewsPhoto.image` / hero images use `ImageField` → **media storage must be configured** - `NewsPhoto.image` / hero images use `ImageField` → **media storage must be configured**
(§8). If page/news trees grow, consider a tree library later — start flat. (§8). If page/news trees grow, consider a tree library later — start flat.

View File

@@ -103,7 +103,7 @@ class NewsApiTests(ApiTestBase):
def test_excerpt_is_a_truncated_prefix_of_the_body(self): def test_excerpt_is_a_truncated_prefix_of_the_body(self):
self.make_news(body=" ".join(f"word{i}" for i in range(80))) self.make_news(body=" ".join(f"word{i}" for i in range(80)))
excerpt = self.api_get("/news/").json()["results"][0]["excerpt"] excerpt = self.api_get("/news/").json()["results"][0]["excerpt_nl"]
self.assertTrue(excerpt.startswith("word0 word1")) self.assertTrue(excerpt.startswith("word0 word1"))
self.assertTrue(excerpt.endswith("")) self.assertTrue(excerpt.endswith(""))
@@ -112,7 +112,7 @@ class NewsApiTests(ApiTestBase):
def test_excerpt_is_unchanged_when_the_body_is_already_short(self): def test_excerpt_is_unchanged_when_the_body_is_already_short(self):
item = self.make_news(body="Short body.") item = self.make_news(body="Short body.")
excerpt = self.api_get("/news/").json()["results"][0]["excerpt"] excerpt = self.api_get("/news/").json()["results"][0]["excerpt_nl"]
self.assertEqual(excerpt, item.body) self.assertEqual(excerpt, item.body)
@@ -144,7 +144,7 @@ class NewsApiTests(ApiTestBase):
def test_body_markdown_is_rendered_to_html(self): def test_body_markdown_is_rendered_to_html(self):
self.make_news(body="## Big win\n\nWe beat **Rivals FC** 4-2. [Full report](https://example.com).") self.make_news(body="## Big win\n\nWe beat **Rivals FC** 4-2. [Full report](https://example.com).")
body = self.api_get("/news/").json()["results"][0]["body"] body = self.api_get("/news/").json()["results"][0]["body_nl"]
self.assertIn("<h2>Big win</h2>", body) self.assertIn("<h2>Big win</h2>", body)
self.assertIn("<strong>Rivals FC</strong>", body) self.assertIn("<strong>Rivals FC</strong>", body)
@@ -154,14 +154,14 @@ class NewsApiTests(ApiTestBase):
def test_body_markdown_a_single_newline_becomes_a_line_break(self): def test_body_markdown_a_single_newline_becomes_a_line_break(self):
self.make_news(body="Line one\nLine two") self.make_news(body="Line one\nLine two")
body = self.api_get("/news/").json()["results"][0]["body"] body = self.api_get("/news/").json()["results"][0]["body_nl"]
self.assertIn("Line one<br", body) self.assertIn("Line one<br", body)
def test_body_html_strips_a_script_tag(self): def test_body_html_strips_a_script_tag(self):
self.make_news(body="Hello<script>alert('xss')</script>world") self.make_news(body="Hello<script>alert('xss')</script>world")
body = self.api_get("/news/").json()["results"][0]["body"] body = self.api_get("/news/").json()["results"][0]["body_nl"]
self.assertNotIn("<script", body) self.assertNotIn("<script", body)
self.assertNotIn("alert(", body) self.assertNotIn("alert(", body)
@@ -169,26 +169,46 @@ class NewsApiTests(ApiTestBase):
def test_body_html_strips_an_event_handler_attribute(self): def test_body_html_strips_an_event_handler_attribute(self):
self.make_news(body='<img src="x" onerror="alert(1)">') self.make_news(body='<img src="x" onerror="alert(1)">')
body = self.api_get("/news/").json()["results"][0]["body"] body = self.api_get("/news/").json()["results"][0]["body_nl"]
self.assertNotIn("onerror", body) self.assertNotIn("onerror", body)
def test_body_html_strips_a_javascript_url(self): def test_body_html_strips_a_javascript_url(self):
self.make_news(body="[click me](javascript:alert(1))") self.make_news(body="[click me](javascript:alert(1))")
body = self.api_get("/news/").json()["results"][0]["body"] body = self.api_get("/news/").json()["results"][0]["body_nl"]
self.assertNotIn("javascript:", body) self.assertNotIn("javascript:", body)
def test_excerpt_strips_markdown_syntax(self): def test_excerpt_strips_markdown_syntax(self):
self.make_news(body="**Bold** and a [link](https://example.com) and # not a heading here") self.make_news(body="**Bold** and a [link](https://example.com) and # not a heading here")
excerpt = self.api_get("/news/").json()["results"][0]["excerpt"] excerpt = self.api_get("/news/").json()["results"][0]["excerpt_nl"]
self.assertNotIn("**", excerpt) self.assertNotIn("**", excerpt)
self.assertNotIn("[link]", excerpt) self.assertNotIn("[link]", excerpt)
self.assertNotIn("<", excerpt) self.assertNotIn("<", excerpt)
def test_english_falls_back_to_the_original_when_not_translated(self):
self.make_news(title="Seizoensstart", body="We beginnen het seizoen.")
result = self.api_get("/news/").json()["results"][0]
self.assertEqual(result["title_en"], "Seizoensstart")
self.assertIn("We beginnen het seizoen.", result["body_en"])
self.assertEqual(result["excerpt_en"], result["excerpt_nl"])
def test_english_is_used_when_translated(self):
self.make_news(title="Seizoensstart", title_en="Season kickoff", body="We beginnen het seizoen.", body_en="We're starting the season.")
result = self.api_get("/news/").json()["results"][0]
self.assertEqual(result["title_nl"], "Seizoensstart")
self.assertEqual(result["title_en"], "Season kickoff")
self.assertIn("We beginnen het seizoen.", result["body_nl"])
self.assertIn("We're starting the season.", result["body_en"])
self.assertNotEqual(result["excerpt_en"], result["excerpt_nl"])
class TeamsApiTests(ApiTestBase): class TeamsApiTests(ApiTestBase):
def setUp(self): def setUp(self):

View File

@@ -522,10 +522,11 @@ class NewsForm(forms.ModelForm):
class Meta: class Meta:
model = News model = News
fields = ["title", "teams", "visibility", "body"] fields = ["title", "title_en", "teams", "visibility", "body", "body_en"]
widgets = { widgets = {
"teams": forms.SelectMultiple(attrs={"data-searchable": "true", "data-search-placeholder": _("Type to filter teams...")}), "teams": forms.SelectMultiple(attrs={"data-searchable": "true", "data-search-placeholder": _("Type to filter teams...")}),
"body": forms.Textarea(attrs={"rows": 8}), "body": forms.Textarea(attrs={"rows": 8}),
"body_en": forms.Textarea(attrs={"rows": 8}),
} }
def __init__(self, *args, club=None, **kwargs): def __init__(self, *args, club=None, **kwargs):

View File

@@ -24,7 +24,7 @@
{{ referee.display_name }} {{ referee.display_name }}
{% if referee.is_external %}<span class="badge badge-neutral badge-xs">{% trans "External" %}</span>{% endif %} {% if referee.is_external %}<span class="badge badge-neutral badge-xs">{% trans "External" %}</span>{% endif %}
{% if referee.assigned_by %}<span class="text-xs opacity-60">— {% blocktrans with name=referee.assigned_by %}assigned by {{ name }}{% endblocktrans %}</span>{% endif %} {% if referee.assigned_by %}<span class="text-xs opacity-60">— {% blocktrans with name=referee.assigned_by %}assigned by {{ name }}{% endblocktrans %}</span>{% endif %}
{% if referee.total_payable %}<span class="text-xs opacity-60">— {% blocktrans with total=referee.total_payable %}€{{ total }} due{% endblocktrans %}</span>{% endif %} {% if referee.total_payable %}<span class="text-xs opacity-60">— {% blocktrans with total=referee.total_payable|floatformat:2 %}€{{ total }} due{% endblocktrans %}</span>{% endif %}
</span> </span>
{% if can_manage_referees %} {% if can_manage_referees %}
<div class="flex gap-1 shrink-0"> <div class="flex gap-1 shrink-0">

View File

@@ -24,7 +24,12 @@
color: #8a8a8a; color: #8a8a8a;
} }
} }
:root { --accent: #3730a3; --accent-soft: #eef0fc; --ink: #16181d; --muted: #6b7280; --line: #d8dae0; } :root {
--accent: {{ club.primary_color|default:"#3730a3" }};
--accent-secondary: {{ club.secondary_color|default:"#be185d" }};
--accent-soft: color-mix(in srgb, var(--accent-secondary) 14%, white);
--ink: #16181d; --muted: #6b7280; --line: #d8dae0;
}
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { font-family: sans-serif; font-size: 10pt; color: var(--ink); line-height: 1.45; } body { font-family: sans-serif; font-size: 10pt; color: var(--ink); line-height: 1.45; }
@@ -51,8 +56,7 @@
table.fees td { padding: 2.5mm 2mm 2.5mm 0; border-bottom: 0.75pt solid var(--line); vertical-align: top; } table.fees td { padding: 2.5mm 2mm 2.5mm 0; border-bottom: 0.75pt solid var(--line); vertical-align: top; }
table.fees td.amount, table.fees th.amount { text-align: right; } table.fees td.amount, table.fees th.amount { text-align: right; }
table.fees tr.empty td { color: var(--muted); font-style: italic; } table.fees tr.empty td { color: var(--muted); font-style: italic; }
.badge-external { display: inline-block; font-size: 7pt; color: var(--accent); background: var(--accent-soft); border-radius: 2mm; padding: 0.3mm 1.5mm; margin-left: 1.5mm; } table.fees tr.total td { border-bottom: none; border-top: 1.5pt solid var(--accent); font-weight: 700; padding-top: 3mm; }
table.fees tr.total td { border-bottom: none; border-top: 1.5pt solid var(--ink); font-weight: 700; padding-top: 3mm; }
.signatures { display: flex; gap: 8mm; margin-top: 4mm; } .signatures { display: flex; gap: 8mm; margin-top: 4mm; }
.sign-box { flex: 1; } .sign-box { flex: 1; }
@@ -120,10 +124,7 @@
<tbody> <tbody>
{% for referee in referees %} {% for referee in referees %}
<tr> <tr>
<td> <td>{{ referee.display_name }}</td>
{{ referee.display_name }}
{% if referee.is_external %}<span class="badge-external">{% trans "External" %}</span>{% endif %}
</td>
<td class="amount">€{{ referee.fee|floatformat:2 }}</td> <td class="amount">€{{ referee.fee|floatformat:2 }}</td>
<td class="amount">{% if referee.km %}{{ referee.km }} km{% else %}—{% endif %}</td> <td class="amount">{% if referee.km %}{{ referee.km }} km{% else %}—{% endif %}</td>
<td class="amount">{% if referee.km %}€{{ referee.km_rate }}{% else %}—{% endif %}</td> <td class="amount">{% if referee.km %}€{{ referee.km_rate }}{% else %}—{% endif %}</td>

View File

@@ -41,6 +41,16 @@
</div> </div>
</div> </div>
{% if news_item.title_en or news_item.body_en %}
<div class="card bg-base-100 shadow mt-4">
<div class="card-body">
<h2 class="card-title text-base">{% trans "English" %}</h2>
{% if news_item.title_en %}<p class="font-semibold">{{ news_item.title_en }}</p>{% endif %}
{% if news_item.body_en %}<p class="whitespace-pre-line">{{ news_item.body_en }}</p>{% endif %}
</div>
</div>
{% endif %}
<div class="card bg-base-100 shadow mt-4"> <div class="card bg-base-100 shadow mt-4">
<div class="card-body"> <div class="card-body">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">

View File

@@ -19,10 +19,19 @@
<p class="opacity-70 text-sm mb-2">{% trans "Photos can be added once the news item is created." %}</p> <p class="opacity-70 text-sm mb-2">{% trans "Photos can be added once the news item is created." %}</p>
{% endif %} {% endif %}
<div class="grid grid-cols-1 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{% for field in form %} {% form_field form.title %}
{% form_field field %} {% form_field form.title_en %}
{% endfor %} </div>
<div class="grid grid-cols-1 gap-4 mt-4">
{% form_field form.teams %}
{% form_field form.visibility %}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
{% form_field form.body %}
{% form_field form.body_en %}
</div> </div>
<div class="card-actions justify-start pt-2 mt-2"> <div class="card-actions justify-start pt-2 mt-2">

View File

@@ -10,6 +10,7 @@ from allauth.mfa.models import Authenticator
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.core.cache import cache from django.core.cache import cache
from django.core.files.uploadedfile import SimpleUploadedFile from django.core.files.uploadedfile import SimpleUploadedFile
from django.template.loader import render_to_string
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from django.urls import NoReverseMatch, reverse from django.urls import NoReverseMatch, reverse
from django.utils import timezone from django.utils import timezone
@@ -2726,6 +2727,44 @@ class NewsManagementTests(ManagementTestBase):
self.assertRedirects(response, reverse("management:news_detail", args=[item.pk])) self.assertRedirects(response, reverse("management:news_detail", args=[item.pk]))
self.assertEqual(item.status, News.Status.DRAFT) self.assertEqual(item.status, News.Status.DRAFT)
def test_an_english_translation_can_be_added_alongside_the_original(self):
self.client.force_login(self.make_coach_manager())
self.club_post(
"news_create",
{"title": "Seizoensstart", "title_en": "Season kickoff", "body": "We beginnen het seizoen.", "body_en": "We're starting the season.", "visibility": News.Visibility.INTERNAL, "teams": [str(self.team.pk)]},
)
item = News.objects.get(club=self.club, title="Seizoensstart")
self.assertEqual(item.title_en, "Season kickoff")
self.assertEqual(item.body_en, "We're starting the season.")
def test_the_english_translation_is_optional(self):
self.client.force_login(self.make_coach_manager())
response = self.club_post("news_create", {"title": "Seizoensstart", "body": "We beginnen het seizoen.", "visibility": News.Visibility.INTERNAL, "teams": [str(self.team.pk)]})
item = News.objects.get(club=self.club, title="Seizoensstart")
self.assertRedirects(response, reverse("management:news_detail", args=[item.pk]))
self.assertEqual(item.title_en, "")
def test_detail_page_shows_the_english_translation_when_set(self):
item = News.objects.create(club=self.club, title="Seizoensstart", body="We beginnen het seizoen.", title_en="Season kickoff", body_en="We're starting the season.")
self.client.force_login(self.admin_user)
response = self.club_get("news_detail", item.pk)
self.assertContains(response, "Season kickoff")
self.assertContains(response, "We&#x27;re starting the season.")
def test_detail_page_hides_the_english_section_when_not_translated(self):
item = News.objects.create(club=self.club, title="Seizoensstart", body="We beginnen het seizoen.")
self.client.force_login(self.admin_user)
response = self.club_get("news_detail", item.pk)
self.assertNotContains(response, ">English<")
def test_plain_staff_cannot_create_news(self): def test_plain_staff_cannot_create_news(self):
self.client.force_login(self.make_plain_staff()) self.client.force_login(self.make_plain_staff())
@@ -4170,6 +4209,18 @@ class EventRefereeManagementTests(ManagementTestBase):
self.assertContains(response, "25.00") self.assertContains(response, "25.00")
def test_the_total_due_is_shown_with_at_most_two_decimals(self):
# A per-km rate like 0.083 pushes the raw total to 3+ decimals -- the
# "due" summary must still round to money-style 2.
game = self.make_game()
EventReferee.objects.create(event=game, member=self.referee, assigned_by=self.admin_member, fee=Decimal("25.00"), km=Decimal("40"), km_rate=Decimal("0.083"))
self.client.force_login(self.admin_user)
response = self.club_get("event_detail", game.pk)
self.assertContains(response, "28.32 due")
self.assertNotContains(response, "28.320")
def test_a_coach_gets_403_setting_a_fee(self): def test_a_coach_gets_403_setting_a_fee(self):
game = self.make_game() game = self.make_game()
assignment = EventReferee.objects.create(event=game, member=self.referee, assigned_by=self.admin_member) assignment = EventReferee.objects.create(event=game, member=self.referee, assigned_by=self.admin_member)
@@ -4234,6 +4285,56 @@ class EventRefereeFormPdfTests(ManagementTestBase):
self.assertEqual(context["home_location"], self.home_ground) self.assertEqual(context["home_location"], self.home_ground)
self.assertEqual(list(context["referees"]), [EventReferee.objects.get(event=game)]) self.assertEqual(list(context["referees"]), [EventReferee.objects.get(event=game)])
def test_the_grand_total_sums_every_referees_total_payable(self):
game = self.make_game()
other_referee = Member.objects.create(first_name="Other", last_name="Ref")
ClubMembership.objects.create(club=self.club, member=other_referee, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
EventReferee.objects.create(event=game, member=self.referee, assigned_by=self.admin_member, fee=Decimal("25.00"), km=Decimal("40"), km_rate=Decimal("0.083"))
EventReferee.objects.create(event=game, member=other_referee, assigned_by=self.admin_member, fee=Decimal("20.00"))
self.client.force_login(self.admin_user)
with mock.patch("management.views.event_referee_form_pdf", return_value=b"%PDF-fake") as renderer:
self.club_get("event_referee_form_pdf", game.pk)
self.assertEqual(renderer.call_args[0][0]["grand_total"], Decimal("48.320"))
def test_the_external_referee_pill_is_not_rendered(self):
game = self.make_game()
EventReferee.objects.create(event=game, external_name="Guest Referee", assigned_by=self.admin_member)
html = render_to_string("management/event_referee_form_pdf.html", {"club": self.club, "event": game, "referees": list(game.referees.all()), "home_location": self.home_ground, "grand_total": Decimal("0")})
self.assertIn("Guest Referee", html)
self.assertNotIn("External", html)
def test_amounts_render_with_at_most_two_decimals(self):
game = self.make_game()
EventReferee.objects.create(event=game, member=self.referee, assigned_by=self.admin_member, fee=Decimal("25.00"), km=Decimal("40"), km_rate=Decimal("0.083"))
html = render_to_string("management/event_referee_form_pdf.html", {"club": self.club, "event": game, "referees": list(game.referees.all()), "home_location": self.home_ground, "grand_total": Decimal("28.320")})
self.assertIn("€28.32<", html)
self.assertNotIn("28.320", html)
def test_the_pdf_uses_the_clubs_colours_when_set(self):
self.club.primary_color = "#0f766e"
self.club.secondary_color = "#f59e0b"
self.club.save(update_fields=["primary_color", "secondary_color"])
game = self.make_game()
html = render_to_string("management/event_referee_form_pdf.html", {"club": self.club, "event": game, "referees": [], "home_location": self.home_ground, "grand_total": Decimal("0")})
self.assertIn("--accent: #0f766e", html)
self.assertIn("--accent-secondary: #f59e0b", html)
def test_the_pdf_falls_back_to_default_colours_when_unset(self):
game = self.make_game()
html = render_to_string("management/event_referee_form_pdf.html", {"club": self.club, "event": game, "referees": [], "home_location": self.home_ground, "grand_total": Decimal("0")})
self.assertIn("--accent: #3730a3", html)
self.assertIn("--accent-secondary: #be185d", html)
def test_a_missing_pdf_library_is_reported_rather_than_a_500(self): def test_a_missing_pdf_library_is_reported_rather_than_a_500(self):
game = self.make_game() game = self.make_game()
self.client.force_login(self.admin_user) self.client.force_login(self.admin_user)

View File

@@ -12,7 +12,7 @@ class NewsPhotoInline(admin.TabularInline):
class NewsAdmin(admin.ModelAdmin): class NewsAdmin(admin.ModelAdmin):
list_display = ["title", "club", "status", "visibility", "created_by"] list_display = ["title", "club", "status", "visibility", "created_by"]
list_filter = ["club", "status", "visibility"] list_filter = ["club", "status", "visibility"]
search_fields = ["title"] search_fields = ["title", "title_en"]
raw_id_fields = ["created_by"] raw_id_fields = ["created_by"]
inlines = [NewsPhotoInline] inlines = [NewsPhotoInline]

View File

@@ -37,11 +37,21 @@ class NewsPhotoOut(Schema):
class NewsItemOut(Schema): class NewsItemOut(Schema):
"""Both languages come back in one object -- title_nl/body_nl/excerpt_nl are
the club's own-language text as authored; title_en/body_en/excerpt_en are the
English text, falling back to the nl content when no translation was added
(News.effective_title_en/effective_body_en) -- so these three are never blank,
even for a news item nobody has translated yet. The consumer picks whichever
it needs."""
id: uuid.UUID id: uuid.UUID
title: str title_nl: str
title_en: str
slug: str slug: str
excerpt: str excerpt_nl: str
body: str excerpt_en: str
body_nl: str
body_en: str
published_at: datetime published_at: datetime
teams: list[str] teams: list[str]
photos: list[NewsPhotoOut] photos: list[NewsPhotoOut]
@@ -66,10 +76,13 @@ def _visible_news(club):
def _to_news_item_out(item, request) -> NewsItemOut: def _to_news_item_out(item, request) -> NewsItemOut:
return NewsItemOut( return NewsItemOut(
id=item.pk, id=item.pk,
title=item.title, title_nl=item.title,
title_en=item.effective_title_en,
slug=item.slug, slug=item.slug,
excerpt=render_body_excerpt(item.body, words=EXCERPT_WORDS), excerpt_nl=render_body_excerpt(item.body, words=EXCERPT_WORDS),
body=render_body_html(item.body), excerpt_en=render_body_excerpt(item.effective_body_en, words=EXCERPT_WORDS),
body_nl=render_body_html(item.body),
body_en=render_body_html(item.effective_body_en),
published_at=item.published_at, published_at=item.published_at,
teams=[team.name for team in item.teams.all()], teams=[team.name for team in item.teams.all()],
photos=[NewsPhotoOut(url=request.build_absolute_uri(photo.image.url), is_main=photo.is_main, ordering=photo.ordering) for photo in item.photos.all()], photos=[NewsPhotoOut(url=request.build_absolute_uri(photo.image.url), is_main=photo.is_main, ordering=photo.ordering) for photo in item.photos.all()],

View File

@@ -0,0 +1,23 @@
# Generated by Django 6.0.6 on 2026-08-10 18:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('news', '0003_alter_news_body'),
]
operations = [
migrations.AddField(
model_name='news',
name='body_en',
field=models.TextField(blank=True, help_text='Optional. Supports the same Markdown as the body above. Falls back to the body above when left blank.', verbose_name='body (English)'),
),
migrations.AddField(
model_name='news',
name='title_en',
field=models.CharField(blank=True, help_text='Optional. Falls back to the title above when left blank.', max_length=255, verbose_name='title (English)'),
),
]

View File

@@ -34,6 +34,10 @@ class News(ClubScopedModel):
"shown as plain text here in the control panel." "shown as plain text here in the control panel."
), ),
) )
title_en = models.CharField(_("title (English)"), max_length=255, blank=True, help_text=_("Optional. Falls back to the title above when left blank."))
body_en = models.TextField(_("body (English)"), blank=True, help_text=_("Optional. Supports the same Markdown as the body above. Falls back to the body above when left blank."))
teams = models.ManyToManyField(Team, related_name="news_items", blank=True, verbose_name=_("teams"), help_text=_("Leave empty for club-wide news.")) teams = models.ManyToManyField(Team, related_name="news_items", blank=True, verbose_name=_("teams"), help_text=_("Leave empty for club-wide news."))
visibility = models.CharField(_("visibility"), max_length=10, choices=Visibility.choices, default=Visibility.INTERNAL) visibility = models.CharField(_("visibility"), max_length=10, choices=Visibility.choices, default=Visibility.INTERNAL)
@@ -61,6 +65,19 @@ class News(ClubScopedModel):
self.status, self.published_at = self.Status.DRAFT, None self.status, self.published_at = self.Status.DRAFT, None
self.save(update_fields=["status", "published_at"]) self.save(update_fields=["status", "published_at"])
@property
def effective_title_en(self) -> str:
"""The English title to show -- `title_en` when the editor set one,
else the Dutch `title`. Computed on read rather than copied into
`title_en` at save time, so editing the Dutch title later keeps this
current instead of leaving a stale English copy behind."""
return self.title_en or self.title
@property
def effective_body_en(self) -> str:
"""See `effective_title_en` -- same fallback, for the body."""
return self.body_en or self.body
@property @property
def is_scheduled(self): def is_scheduled(self):
"""PUBLISHED (past the editor's release gate) but its publish date hasn't """PUBLISHED (past the editor's release gate) but its publish date hasn't

View File

@@ -73,6 +73,29 @@ class NewsModelTests(TestCase):
self.assertEqual(item.status, News.Status.DRAFT) self.assertEqual(item.status, News.Status.DRAFT)
self.assertIsNone(item.published_at) self.assertIsNone(item.published_at)
def test_effective_english_falls_back_to_the_original_when_blank(self):
item = News.objects.create(club=self.club, title="Seizoensstart", body="We beginnen het seizoen.")
self.assertEqual(item.effective_title_en, "Seizoensstart")
self.assertEqual(item.effective_body_en, "We beginnen het seizoen.")
def test_effective_english_uses_its_own_text_when_set(self):
item = News.objects.create(club=self.club, title="Seizoensstart", body="We beginnen het seizoen.", title_en="Season kickoff", body_en="We're starting the season.")
self.assertEqual(item.effective_title_en, "Season kickoff")
self.assertEqual(item.effective_body_en, "We're starting the season.")
def test_effective_english_stays_current_after_the_original_changes(self):
# Read-time fallback, not copy-on-save: editing the Dutch text later must
# not leave a stale English "copy" behind.
item = News.objects.create(club=self.club, title="Seizoensstart", body="We beginnen het seizoen.")
item.title, item.body = "Nieuwe titel", "Nieuwe tekst."
item.save(update_fields=["title", "body"])
self.assertEqual(item.effective_title_en, "Nieuwe titel")
self.assertEqual(item.effective_body_en, "Nieuwe tekst.")
class NewsPhotoModelTests(TestCase): class NewsPhotoModelTests(TestCase):
def setUp(self): def setUp(self):