Make user-facing strings translatable

Statistics labels, shop help text, and the confirm/form modal defaults
were plain strings; wrap them per CLAUDE.md's i18n convention so the
app stays translation-ready as it's written.
This commit is contained in:
2026-08-03 17:03:49 +02:00
parent 8598fd2b46
commit d5f45c9404
5 changed files with 40 additions and 33 deletions

View File

@@ -53,3 +53,7 @@ Domain notes (drive modeling decisions):
- Ruff config anticipates a Wagtail-style codebase (`DJ` Django rules; `RUF012`/`RUF005` ignored for framework idioms; `line-length = 250`). Migrations are excluded from linting — don't hand-edit them to satisfy ruff. - Ruff config anticipates a Wagtail-style codebase (`DJ` Django rules; `RUF012`/`RUF005` ignored for framework idioms; `line-length = 250`). Migrations are excluded from linting — don't hand-edit them to satisfy ruff.
- Settings files are exempt from `F403/F405/E501` (star imports allowed) under `rosterchief/settings/*` — note the config expects a settings *package*, though the current code is a single `settings.py`. If you split settings, match that path. - Settings files are exempt from `F403/F405/E501` (star imports allowed) under `rosterchief/settings/*` — note the config expects a settings *package*, though the current code is a single `settings.py`. If you split settings, match that path.
- **Every user-facing string must be translatable** (`USE_I18N = True`; no `.po` files exist yet, but the codebase is kept translation-ready as it's written, not audited later). This applies to templates, models, forms, and views alike:
- Templates: `{% load i18n %}`, then `{% trans "..." %}` for literal text and `{% blocktrans %}...{% endblocktrans %}` for text containing a variable (bind filter chains to a plain name first via `{% blocktrans with x=some.filtered|value %}`, or `{% blocktrans count counter=n %}...{% plural %}...{% endblocktrans %}` for pluralized counts — never hand-roll pluralization with `|pluralize`, real languages have more than two plural forms). Covers headings, buttons, table headers, empty-state text, `placeholder=`/`aria-label=` attributes — not URL names, CSS classes, icon names, `dom_id` arguments, or raw data interpolated on its own.
- Python (models, forms, views, services): `from django.utils.translation import gettext_lazy as _`, wrapping `verbose_name`, `help_text`, `Meta.verbose_name(_plural)`, form field `label`/`help_text`, `TextChoices`/`IntegerChoices` labels (never the choice *value*), and any user-facing string built in a method/view (`notify()` messages, raised `ValidationError`/`ValueError` text, dict labels rendered directly in a template).
- Never bake an interpolated value directly into a translatable string (an f-string with `{variable}` *inside* the translated text) — word order isn't guaranteed to survive translation. Use `%(name)s` placeholders instead: `_("“%(name)s” updated.") % {"name": obj}`. This matters most for `controlpanel.messages.notify(request, spec)`, whose `"<level>|<title>|<body>"` spec is assembled via f-string — translate the title and body as separate `_()`/`%()` expressions, then drop the already-resolved strings into the f-string skeleton.

View File

@@ -15,6 +15,7 @@ from django.contrib.auth import get_user_model
from django.db.models import Count, DateField, DecimalField, Exists, F, IntegerField, OuterRef, Q, Subquery, Sum, Value from django.db.models import Count, DateField, DecimalField, Exists, F, IntegerField, OuterRef, Q, Subquery, Sum, Value
from django.db.models.functions import Coalesce, TruncMonth from django.db.models.functions import Coalesce, TruncMonth
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from waffle import get_waffle_flag_model from waffle import get_waffle_flag_model
from authentication.middleware import ELEVATED_ROLES from authentication.middleware import ELEVATED_ROLES
@@ -149,10 +150,10 @@ def onboarding_funnel():
total = len(clubs) total = len(clubs)
return [ return [
{"label": "Clubs", "count": total, "icon": "building-2"}, {"label": _("Clubs"), "count": total, "icon": "building-2"},
{"label": "With members", "count": sum(1 for club in clubs if club.member_count), "icon": "users"}, {"label": _("With members"), "count": sum(1 for club in clubs if club.member_count), "icon": "users"},
{"label": "With a team", "count": sum(1 for club in clubs if club.team_count), "icon": "trophy"}, {"label": _("With a team"), "count": sum(1 for club in clubs if club.team_count), "icon": "trophy"},
{"label": "With events", "count": sum(1 for club in clubs if club.event_count), "icon": "calendar-days"}, {"label": _("With events"), "count": sum(1 for club in clubs if club.event_count), "icon": "calendar-days"},
] ]
@@ -351,7 +352,7 @@ def fee_aging(club):
owed = Order.objects.filter(club=club, status__in=OWED_STATUSES) owed = Order.objects.filter(club=club, status__in=OWED_STATUSES)
buckets = [] buckets = []
for label, older_than, newer_than in (("0-30 days", 0, 30), ("30-60 days", 30, 60), ("60+ days", 60, None)): for label, older_than, newer_than in ((_("0-30 days"), 0, 30), (_("30-60 days"), 30, 60), (_("60+ days"), 60, None)):
rows = owed.filter(created__lte=now - timedelta(days=older_than)) rows = owed.filter(created__lte=now - timedelta(days=older_than))
if newer_than is not None: if newer_than is not None:
rows = rows.filter(created__gt=now - timedelta(days=newer_than)) rows = rows.filter(created__gt=now - timedelta(days=newer_than))
@@ -415,10 +416,10 @@ def club_charts(club):
"fees": [ "fees": [
{"label": label, "value": memberships.filter(fee_status=status).count()} {"label": label, "value": memberships.filter(fee_status=status).count()}
for status, label in ( for status, label in (
(ClubMembership.FeeStatus.PAID, "Paid"), (ClubMembership.FeeStatus.PAID, _("Paid")),
(ClubMembership.FeeStatus.PARTIALLY_PAID, "Partial"), (ClubMembership.FeeStatus.PARTIALLY_PAID, _("Partial")),
(ClubMembership.FeeStatus.UNPAID, "Unpaid"), (ClubMembership.FeeStatus.UNPAID, _("Unpaid")),
(ClubMembership.FeeStatus.WAIVED, "Waived"), (ClubMembership.FeeStatus.WAIVED, _("Waived")),
) )
], ],
} }
@@ -435,40 +436,40 @@ def club_statistics(club):
return [ return [
{ {
"title": "Members", "title": _("Members"),
"icon": "users", "icon": "users",
"stats": [ "stats": [
("Members", memberships.values("member").distinct().count()), (_("Members"), memberships.values("member").distinct().count()),
("Active this season", memberships.filter(season=season, status=ClubMembership.StatusChoices.ACTIVE).count() if season else 0), (_("Active this season"), memberships.filter(season=season, status=ClubMembership.StatusChoices.ACTIVE).count() if season else 0),
("Pending", memberships.filter(status=ClubMembership.StatusChoices.PENDING).count()), (_("Pending"), memberships.filter(status=ClubMembership.StatusChoices.PENDING).count()),
("Lapsed", memberships.filter(status=ClubMembership.StatusChoices.LAPSED).count()), (_("Lapsed"), memberships.filter(status=ClubMembership.StatusChoices.LAPSED).count()),
], ],
}, },
{ {
"title": "Teams & staff", "title": _("Teams & staff"),
"icon": "shield", "icon": "shield",
"stats": [ "stats": [
("Teams", Team.objects.filter(club=club).count()), (_("Teams"), Team.objects.filter(club=club).count()),
("Players this season", TeamMembership.objects.filter(team__club=club, season=season).count() if season else 0), (_("Players this season"), TeamMembership.objects.filter(team__club=club, season=season).count() if season else 0),
("Staff this season", StaffAssignment.objects.filter(team__club=club, season=season).count() if season else 0), (_("Staff this season"), StaffAssignment.objects.filter(team__club=club, season=season).count() if season else 0),
], ],
}, },
{ {
"title": "Events", "title": _("Events"),
"icon": "calendar-days", "icon": "calendar-days",
"stats": [ "stats": [
("Upcoming", events.filter(start__gte=now).count()), (_("Upcoming"), events.filter(start__gte=now).count()),
("This season", events.filter(season=season).count() if season else 0), (_("This season"), events.filter(season=season).count() if season else 0),
], ],
}, },
{ {
"title": "Shop", "title": _("Shop"),
"icon": "shopping-cart", "icon": "shopping-cart",
"stats": [ "stats": [
("Orders", orders.count()), (_("Orders"), orders.count()),
("Revenue", _money(orders.filter(status__in=PAID_STATUSES))), (_("Revenue"), _money(orders.filter(status__in=PAID_STATUSES))),
("Outstanding", _money(orders.filter(status__in=OWED_STATUSES))), (_("Outstanding"), _money(orders.filter(status__in=OWED_STATUSES))),
("Open carts", Cart.objects.filter(club=club, status=Cart.CartStatus.OPEN).count()), (_("Open carts"), Cart.objects.filter(club=club, status=Cart.CartStatus.OPEN).count()),
], ],
}, },
] ]

View File

@@ -1,4 +1,4 @@
{% load lucide %} {% load i18n lucide %}
{% comment %} {% comment %}
A daisyUI native <dialog> confirmation modal for a destructive POST action with no A daisyUI native <dialog> confirmation modal for a destructive POST action with no
@@ -8,6 +8,7 @@
`_modal_form.html`, so it can share the `modal-action` row with the dialog-closing `_modal_form.html`, so it can share the `modal-action` row with the dialog-closing
Cancel button without nesting one <form> inside another. Cancel button without nesting one <form> inside another.
{% endcomment %} {% endcomment %}
{% trans "Confirm" as default_submit_label %}
<dialog id="{{ modal_id }}" class="modal"> <dialog id="{{ modal_id }}" class="modal">
<div class="modal-box"> <div class="modal-box">
<h3 class="text-lg font-bold">{{ title }}</h3> <h3 class="text-lg font-bold">{{ title }}</h3>
@@ -17,9 +18,9 @@
</form> </form>
<div class="modal-action"> <div class="modal-action">
<form method="dialog"> <form method="dialog">
<button class="btn btn-outline gap-2">{% lucide "x" size=16 %} Cancel</button> <button class="btn btn-outline btn-neutral gap-2">{% lucide "x" size=16 %} {% trans "Cancel" %}</button>
</form> </form>
<button class="btn btn-error gap-2" type="submit" form="{{ modal_id }}-form">{% lucide submit_icon|default:"trash-2" size=16 %} {{ submit_label|default:"Confirm" }}</button> <button class="btn btn-error gap-2" type="submit" form="{{ modal_id }}-form">{% lucide submit_icon|default:"trash-2" size=16 %} {{ submit_label|default:default_submit_label }}</button>
</div> </div>
</div> </div>
<form method="dialog" class="modal-backdrop"> <form method="dialog" class="modal-backdrop">

View File

@@ -1,4 +1,4 @@
{% load lucide %} {% load i18n lucide %}
{% comment %} {% comment %}
A daisyUI native <dialog> modal wrapping a Django form that posts straight to A daisyUI native <dialog> modal wrapping a Django form that posts straight to
@@ -7,6 +7,7 @@
tag (linked via the `form` attribute) so it can share the `modal-action` row with the tag (linked via the `form` attribute) so it can share the `modal-action` row with the
dialog-closing Cancel button without nesting one <form> inside another. dialog-closing Cancel button without nesting one <form> inside another.
{% endcomment %} {% endcomment %}
{% trans "Save" as default_submit_label %}
<dialog id="{{ modal_id }}" class="modal"> <dialog id="{{ modal_id }}" class="modal">
<div class="modal-box"> <div class="modal-box">
<h3 class="text-lg font-bold">{{ title }}</h3> <h3 class="text-lg font-bold">{{ title }}</h3>
@@ -17,9 +18,9 @@
</form> </form>
<div class="modal-action"> <div class="modal-action">
<form method="dialog"> <form method="dialog">
<button class="btn btn-outline gap-2">{% lucide "x" size=16 %} Cancel</button> <button class="btn btn-outline btn-neutral gap-2">{% lucide "x" size=16 %} {% trans "Cancel" %}</button>
</form> </form>
<button class="btn btn-primary gap-2" type="submit" form="{{ modal_id }}-form">{% lucide submit_icon|default:"check" size=16 %} {{ submit_label|default:"Save" }}</button> <button class="btn btn-primary gap-2" type="submit" form="{{ modal_id }}-form">{% lucide submit_icon|default:"check" size=16 %} {{ submit_label|default:default_submit_label }}</button>
</div> </div>
</div> </div>
<form method="dialog" class="modal-backdrop"> <form method="dialog" class="modal-backdrop">

View File

@@ -61,7 +61,7 @@ class Product(ClubScopedModel):
season = models.ForeignKey(Season, on_delete=models.PROTECT, related_name="products", verbose_name=_("season"), blank=True, null=True) season = models.ForeignKey(Season, on_delete=models.PROTECT, related_name="products", verbose_name=_("season"), blank=True, null=True)
is_active = models.BooleanField(_("is active?"), default=True) is_active = models.BooleanField(_("is active?"), default=True)
is_public = models.BooleanField(_("is public?"), default=True, help_text="Non-public products are only visible to staff members for adding on to an order later on.") is_public = models.BooleanField(_("is public?"), default=True, help_text=_("Non-public products are only visible to staff members for adding on to an order later on."))
staff_role = models.ForeignKey(Position, on_delete=models.PROTECT, related_name="staff_products", verbose_name=_("staff role"), blank=True, null=True, limit_choices_to={"staff_position": True}) staff_role = models.ForeignKey(Position, on_delete=models.PROTECT, related_name="staff_products", verbose_name=_("staff role"), blank=True, null=True, limit_choices_to={"staff_position": True})
early_bird_discount_enabled = models.BooleanField(_("early bird discount enabled?"), default=False) early_bird_discount_enabled = models.BooleanField(_("early bird discount enabled?"), default=False)