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