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:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
36
api/tests.py
36
api/tests.py
@@ -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):
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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'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)
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|
||||||
|
|||||||
25
news/api.py
25
news/api.py
@@ -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()],
|
||||||
|
|||||||
23
news/migrations/0004_news_body_en_news_title_en.py
Normal file
23
news/migrations/0004_news_body_en_news_title_en.py
Normal 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)'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user