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:
@@ -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.
|
||||
|
||||
@@ -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()),
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user