diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 72ddccf..f7a1b36 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -622,6 +622,8 @@ team-tagged instead of categorised, with a two-step release flow rather than a b ``` news.News(ClubScopedModel) # -> carries `club` 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) visibility CharField (TextChoices: internal | external | both) 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. - `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. -- `visibility` (internal/external/both) is stored and enforced nowhere yet — no - member-facing reading page or public API exists. Both are later work; the field is - there so they don't need a backfill when they land. +- `visibility` (internal/external/both) is enforced by the public read-only API + (`news/api.py`, mounted under `api/`) — only `external`/`both` items, published and + 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** (§8). If page/news trees grow, consider a tree library later — start flat. diff --git a/api/tests.py b/api/tests.py index b24b9e4..c831caf 100644 --- a/api/tests.py +++ b/api/tests.py @@ -103,7 +103,7 @@ class NewsApiTests(ApiTestBase): def test_excerpt_is_a_truncated_prefix_of_the_body(self): 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.endswith("…")) @@ -112,7 +112,7 @@ class NewsApiTests(ApiTestBase): def test_excerpt_is_unchanged_when_the_body_is_already_short(self): 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) @@ -144,7 +144,7 @@ class NewsApiTests(ApiTestBase): 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).") - body = self.api_get("/news/").json()["results"][0]["body"] + body = self.api_get("/news/").json()["results"][0]["body_nl"] self.assertIn("

Big win

", body) self.assertIn("Rivals FC", body) @@ -154,14 +154,14 @@ class NewsApiTests(ApiTestBase): def test_body_markdown_a_single_newline_becomes_a_line_break(self): 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{% trans "External" %}{% endif %} {% if referee.assigned_by %}— {% blocktrans with name=referee.assigned_by %}assigned by {{ name }}{% endblocktrans %}{% endif %} - {% if referee.total_payable %}— {% blocktrans with total=referee.total_payable %}€{{ total }} due{% endblocktrans %}{% endif %} + {% if referee.total_payable %}— {% blocktrans with total=referee.total_payable|floatformat:2 %}€{{ total }} due{% endblocktrans %}{% endif %} {% if can_manage_referees %}
diff --git a/management/templates/management/event_referee_form_pdf.html b/management/templates/management/event_referee_form_pdf.html index f321138..c5bd089 100644 --- a/management/templates/management/event_referee_form_pdf.html +++ b/management/templates/management/event_referee_form_pdf.html @@ -24,7 +24,12 @@ 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; } 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.amount, table.fees th.amount { text-align: right; } 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(--ink); font-weight: 700; padding-top: 3mm; } + table.fees tr.total td { border-bottom: none; border-top: 1.5pt solid var(--accent); font-weight: 700; padding-top: 3mm; } .signatures { display: flex; gap: 8mm; margin-top: 4mm; } .sign-box { flex: 1; } @@ -120,10 +124,7 @@ {% for referee in referees %} - - {{ referee.display_name }} - {% if referee.is_external %}{% trans "External" %}{% endif %} - + {{ referee.display_name }} €{{ referee.fee|floatformat:2 }} {% if referee.km %}{{ referee.km }} km{% else %}—{% endif %} {% if referee.km %}€{{ referee.km_rate }}{% else %}—{% endif %} diff --git a/management/templates/management/news_detail.html b/management/templates/management/news_detail.html index 704b1e1..ac0c0be 100644 --- a/management/templates/management/news_detail.html +++ b/management/templates/management/news_detail.html @@ -41,6 +41,16 @@
+ {% if news_item.title_en or news_item.body_en %} +
+
+

{% trans "English" %}

+ {% if news_item.title_en %}

{{ news_item.title_en }}

{% endif %} + {% if news_item.body_en %}

{{ news_item.body_en }}

{% endif %} +
+
+ {% endif %} +
diff --git a/management/templates/management/news_form.html b/management/templates/management/news_form.html index 9065068..818e701 100644 --- a/management/templates/management/news_form.html +++ b/management/templates/management/news_form.html @@ -19,10 +19,19 @@

{% trans "Photos can be added once the news item is created." %}

{% endif %} -
- {% for field in form %} - {% form_field field %} - {% endfor %} +
+ {% form_field form.title %} + {% form_field form.title_en %} +
+ +
+ {% form_field form.teams %} + {% form_field form.visibility %} +
+ +
+ {% form_field form.body %} + {% form_field form.body_en %}
diff --git a/management/tests.py b/management/tests.py index 4f580f7..760b073 100644 --- a/management/tests.py +++ b/management/tests.py @@ -10,6 +10,7 @@ from allauth.mfa.models import Authenticator from django.contrib.auth import get_user_model from django.core.cache import cache from django.core.files.uploadedfile import SimpleUploadedFile +from django.template.loader import render_to_string from django.test import TestCase, override_settings from django.urls import NoReverseMatch, reverse from django.utils import timezone @@ -2726,6 +2727,44 @@ class NewsManagementTests(ManagementTestBase): self.assertRedirects(response, reverse("management:news_detail", args=[item.pk])) 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'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): self.client.force_login(self.make_plain_staff()) @@ -4170,6 +4209,18 @@ class EventRefereeManagementTests(ManagementTestBase): 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): game = self.make_game() 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(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): game = self.make_game() self.client.force_login(self.admin_user) diff --git a/news/admin.py b/news/admin.py index 59c6c48..f5219ef 100644 --- a/news/admin.py +++ b/news/admin.py @@ -12,7 +12,7 @@ class NewsPhotoInline(admin.TabularInline): class NewsAdmin(admin.ModelAdmin): list_display = ["title", "club", "status", "visibility", "created_by"] list_filter = ["club", "status", "visibility"] - search_fields = ["title"] + search_fields = ["title", "title_en"] raw_id_fields = ["created_by"] inlines = [NewsPhotoInline] diff --git a/news/api.py b/news/api.py index 1c19517..3569ec3 100644 --- a/news/api.py +++ b/news/api.py @@ -37,11 +37,21 @@ class NewsPhotoOut(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 - title: str + title_nl: str + title_en: str slug: str - excerpt: str - body: str + excerpt_nl: str + excerpt_en: str + body_nl: str + body_en: str published_at: datetime teams: list[str] photos: list[NewsPhotoOut] @@ -66,10 +76,13 @@ def _visible_news(club): def _to_news_item_out(item, request) -> NewsItemOut: return NewsItemOut( id=item.pk, - title=item.title, + title_nl=item.title, + title_en=item.effective_title_en, slug=item.slug, - excerpt=render_body_excerpt(item.body, words=EXCERPT_WORDS), - body=render_body_html(item.body), + excerpt_nl=render_body_excerpt(item.body, words=EXCERPT_WORDS), + 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, 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()], diff --git a/news/migrations/0004_news_body_en_news_title_en.py b/news/migrations/0004_news_body_en_news_title_en.py new file mode 100644 index 0000000..e4927c9 --- /dev/null +++ b/news/migrations/0004_news_body_en_news_title_en.py @@ -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)'), + ), + ] diff --git a/news/models.py b/news/models.py index 54c033a..ac78c49 100644 --- a/news/models.py +++ b/news/models.py @@ -34,6 +34,10 @@ class News(ClubScopedModel): "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.")) 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.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 def is_scheduled(self): """PUBLISHED (past the editor's release gate) but its publish date hasn't diff --git a/news/tests.py b/news/tests.py index ff9bb82..a2cd60c 100644 --- a/news/tests.py +++ b/news/tests.py @@ -73,6 +73,29 @@ class NewsModelTests(TestCase): self.assertEqual(item.status, News.Status.DRAFT) 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): def setUp(self):