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.
- 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.functions import Coalesce, TruncMonth
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from waffle import get_waffle_flag_model
from authentication.middleware import ELEVATED_ROLES
@@ -149,10 +150,10 @@ def onboarding_funnel():
total = len(clubs)
return [
{"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 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": _("Clubs"), "count": total, "icon": "building-2"},
{"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 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)
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))
if newer_than is not None:
rows = rows.filter(created__gt=now - timedelta(days=newer_than))
@@ -415,10 +416,10 @@ def club_charts(club):
"fees": [
{"label": label, "value": memberships.filter(fee_status=status).count()}
for status, label in (
(ClubMembership.FeeStatus.PAID, "Paid"),
(ClubMembership.FeeStatus.PARTIALLY_PAID, "Partial"),
(ClubMembership.FeeStatus.UNPAID, "Unpaid"),
(ClubMembership.FeeStatus.WAIVED, "Waived"),
(ClubMembership.FeeStatus.PAID, _("Paid")),
(ClubMembership.FeeStatus.PARTIALLY_PAID, _("Partial")),
(ClubMembership.FeeStatus.UNPAID, _("Unpaid")),
(ClubMembership.FeeStatus.WAIVED, _("Waived")),
)
],
}
@@ -435,40 +436,40 @@ def club_statistics(club):
return [
{
"title": "Members",
"title": _("Members"),
"icon": "users",
"stats": [
("Members", memberships.values("member").distinct().count()),
("Active this season", memberships.filter(season=season, status=ClubMembership.StatusChoices.ACTIVE).count() if season else 0),
("Pending", memberships.filter(status=ClubMembership.StatusChoices.PENDING).count()),
("Lapsed", memberships.filter(status=ClubMembership.StatusChoices.LAPSED).count()),
(_("Members"), memberships.values("member").distinct().count()),
(_("Active this season"), memberships.filter(season=season, status=ClubMembership.StatusChoices.ACTIVE).count() if season else 0),
(_("Pending"), memberships.filter(status=ClubMembership.StatusChoices.PENDING).count()),
(_("Lapsed"), memberships.filter(status=ClubMembership.StatusChoices.LAPSED).count()),
],
},
{
"title": "Teams & staff",
"title": _("Teams & staff"),
"icon": "shield",
"stats": [
("Teams", Team.objects.filter(club=club).count()),
("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),
(_("Teams"), Team.objects.filter(club=club).count()),
(_("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),
],
},
{
"title": "Events",
"title": _("Events"),
"icon": "calendar-days",
"stats": [
("Upcoming", events.filter(start__gte=now).count()),
("This season", events.filter(season=season).count() if season else 0),
(_("Upcoming"), events.filter(start__gte=now).count()),
(_("This season"), events.filter(season=season).count() if season else 0),
],
},
{
"title": "Shop",
"title": _("Shop"),
"icon": "shopping-cart",
"stats": [
("Orders", orders.count()),
("Revenue", _money(orders.filter(status__in=PAID_STATUSES))),
("Outstanding", _money(orders.filter(status__in=OWED_STATUSES))),
("Open carts", Cart.objects.filter(club=club, status=Cart.CartStatus.OPEN).count()),
(_("Orders"), orders.count()),
(_("Revenue"), _money(orders.filter(status__in=PAID_STATUSES))),
(_("Outstanding"), _money(orders.filter(status__in=OWED_STATUSES))),
(_("Open carts"), Cart.objects.filter(club=club, status=Cart.CartStatus.OPEN).count()),
],
},
]

View File

@@ -1,4 +1,4 @@
{% load lucide %}
{% load i18n lucide %}
{% comment %}
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
Cancel button without nesting one <form> inside another.
{% endcomment %}
{% trans "Confirm" as default_submit_label %}
<dialog id="{{ modal_id }}" class="modal">
<div class="modal-box">
<h3 class="text-lg font-bold">{{ title }}</h3>
@@ -17,9 +18,9 @@
</form>
<div class="modal-action">
<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>
<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>
<form method="dialog" class="modal-backdrop">

View File

@@ -1,4 +1,4 @@
{% load lucide %}
{% load i18n lucide %}
{% comment %}
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
dialog-closing Cancel button without nesting one <form> inside another.
{% endcomment %}
{% trans "Save" as default_submit_label %}
<dialog id="{{ modal_id }}" class="modal">
<div class="modal-box">
<h3 class="text-lg font-bold">{{ title }}</h3>
@@ -17,9 +18,9 @@
</form>
<div class="modal-action">
<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>
<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>
<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)
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})
early_bird_discount_enabled = models.BooleanField(_("early bird discount enabled?"), default=False)