From d5f45c940407f4ccb02501c083b0fc0111d79144 Mon Sep 17 00:00:00 2001 From: Bernard Siebens Date: Mon, 3 Aug 2026 17:03:49 +0200 Subject: [PATCH] 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. --- CLAUDE.md | 4 ++ controlpanel/services/statistics.py | 53 ++++++++++--------- .../controlpanel/_confirm_modal.html | 7 +-- .../templates/controlpanel/_modal_form.html | 7 +-- shop/models.py | 2 +- 5 files changed, 40 insertions(+), 33 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2c1a765..f63f0b4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `"||<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. diff --git a/controlpanel/services/statistics.py b/controlpanel/services/statistics.py index 77677de..8fc84db 100644 --- a/controlpanel/services/statistics.py +++ b/controlpanel/services/statistics.py @@ -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()), ], }, ] diff --git a/controlpanel/templates/controlpanel/_confirm_modal.html b/controlpanel/templates/controlpanel/_confirm_modal.html index dc55f65..544d12b 100644 --- a/controlpanel/templates/controlpanel/_confirm_modal.html +++ b/controlpanel/templates/controlpanel/_confirm_modal.html @@ -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"> diff --git a/controlpanel/templates/controlpanel/_modal_form.html b/controlpanel/templates/controlpanel/_modal_form.html index 1955fb3..12b879b 100644 --- a/controlpanel/templates/controlpanel/_modal_form.html +++ b/controlpanel/templates/controlpanel/_modal_form.html @@ -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"> diff --git a/shop/models.py b/shop/models.py index 28bf493..83bd5d6 100644 --- a/shop/models.py +++ b/shop/models.py @@ -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)