diff --git a/bsmain/management/commands/__init__.py b/bsmain/management/commands/__init__.py
index e69de29..f0cacf8 100644
--- a/bsmain/management/commands/__init__.py
+++ b/bsmain/management/commands/__init__.py
@@ -0,0 +1,20 @@
+from django.db import models
+
+IGNORED_ATTRS = ['verbose_name', 'help_text']
+
+original_deconstruct = models.Field.deconstruct
+
+
+def new_deconstruct(self):
+ # works around the non-fix of https://code.djangoproject.com/ticket/21498 (I don't agree with the reasoning that
+ # "in principle any field could influence the database schema"; you must be _insane_ if verbose_name or help_text
+ # actually do, and the cost of the migrations is real)
+ # solution from https://stackoverflow.com/a/39801321/339144
+ name, path, args, kwargs = original_deconstruct(self)
+ for attr in IGNORED_ATTRS:
+ kwargs.pop(attr, None)
+ return name, path, args, kwargs
+
+
+def monkey_patch_deconstruct():
+ models.Field.deconstruct = new_deconstruct
diff --git a/bsmain/management/commands/makemigrations.py b/bsmain/management/commands/makemigrations.py
new file mode 100644
index 0000000..2cd10e7
--- /dev/null
+++ b/bsmain/management/commands/makemigrations.py
@@ -0,0 +1,8 @@
+from django.core.management.commands.makemigrations import Command as OriginalCommand
+
+from . import monkey_patch_deconstruct
+monkey_patch_deconstruct()
+
+
+class Command(OriginalCommand):
+ pass # no changes, except the monkey patch above
diff --git a/bsmain/management/commands/migrate.py b/bsmain/management/commands/migrate.py
index 513b5c0..ff080de 100644
--- a/bsmain/management/commands/migrate.py
+++ b/bsmain/management/commands/migrate.py
@@ -1,6 +1,9 @@
import time
from django.core.management.commands.migrate import Command as DjangoMigrateCommand
+from . import monkey_patch_deconstruct
+monkey_patch_deconstruct() # needed for migrate.py to avoid the warning about non-reflected changes
+
class Command(DjangoMigrateCommand):
# We override the default Django migrate command to add the elapsed time for each migration. (This could in theory
@@ -10,8 +13,7 @@ class Command(DjangoMigrateCommand):
# We care more about the elapsed time for each migration than the average Django user because sqlite takes such a
# prominent role in our architecture, and because migrations are run out of our direct control ("self hosted").
#
- # AFAIU, "just dropping a file called migrate.py in one of our apps" is good enough to be the override (and if it
- # isn't, it's not critical, since all we do is add a bit more info to the output).
+ # AFAIU, "just dropping a file called migrate.py in one of our apps" is good enough to be the override.
def migration_progress_callback(self, action, migration=None, fake=False):
# Django 4.2's method, with a single change
diff --git a/bsmain/templates/bsmain/auth_token_list.html b/bsmain/templates/bsmain/auth_token_list.html
index 1c069a6..623a723 100644
--- a/bsmain/templates/bsmain/auth_token_list.html
+++ b/bsmain/templates/bsmain/auth_token_list.html
@@ -1,7 +1,8 @@
{% extends "base.html" %}
{% load static %}
+{% load i18n %}
-{% block title %}Auth Tokens · {{ site_title }}{% endblock %}
+{% block title %}{% translate "Auth Tokens" %} · {{ site_title }}{% endblock %}
{% block content %}
@@ -19,12 +20,12 @@
{% endif %}
-
Auth Tokens
+
{% translate "Auth Tokens" %}
@@ -37,7 +38,7 @@
-
Auth Tokens
+
{% translate "Auth Tokens" %}
{% for auth_token in auth_tokens %}
@@ -50,7 +51,7 @@
-
+
@@ -59,7 +60,7 @@
- No Auth Tokens.
+ {% translate "No Auth Tokens." %}
diff --git a/bsmain/views.py b/bsmain/views.py
index 27225ad..25be8d7 100644
--- a/bsmain/views.py
+++ b/bsmain/views.py
@@ -2,6 +2,7 @@ from django.shortcuts import render, redirect
from django.http import Http404
from django.contrib import messages
from django.contrib.auth.decorators import user_passes_test
+from django.utils.translation import gettext_lazy as _
from bugsink.decorators import atomic_for_request_method
@@ -20,7 +21,7 @@ def auth_token_list(request):
if action == "delete":
AuthToken.objects.get(pk=pk).delete()
- messages.success(request, 'Token deleted')
+ messages.success(request, _('Token deleted'))
return redirect('auth_token_list')
return render(request, 'bsmain/auth_token_list.html', {
diff --git a/bugsink/middleware.py b/bugsink/middleware.py
index 3578fd3..7ebe665 100644
--- a/bugsink/middleware.py
+++ b/bugsink/middleware.py
@@ -5,6 +5,10 @@ from django.contrib.auth.decorators import login_required
from django.db import connection
from django.conf import settings
from django.core.exceptions import SuspiciousOperation
+from django.utils.translation import get_supported_language_variant
+from django.utils.translation.trans_real import parse_accept_lang_header
+from django.utils import translation
+
performance_logger = logging.getLogger("bugsink.performance.views")
@@ -128,3 +132,35 @@ class SetRemoteAddrMiddleware:
request.META["REMOTE_ADDR"] = self.parse_x_forwarded_for(request.META.get("HTTP_X_FORWARDED_FOR", None))
return self.get_response(request)
+
+
+def language_from_accept_language(request):
+ """
+ Pick a language using ONLY the Accept-Language header. Ignores URL prefixes, session, and cookies. I prefer to have
+ as little "magic" in the language selection as possible, and I _know_ we don't do anything with paths, so I'd rather
+ not have such code invoked at all (at the cost of reimplementing some of Django's logic here).
+ """
+ header = request.META.get("HTTP_ACCEPT_LANGUAGE", "")
+ for lang_code, _q in parse_accept_lang_header(header):
+ try:
+ # strict=False lets country variants match (e.g. 'es-CO' for 'es')
+ return get_supported_language_variant(lang_code, strict=False)
+ except LookupError:
+ continue
+ return settings.LANGUAGE_CODE
+
+
+def get_chosen_language(request_user, request):
+ if request_user.is_authenticated and request_user.language != "auto":
+ return get_supported_language_variant(request_user.language, strict=False)
+ return language_from_accept_language(request)
+
+
+class UserLanguageMiddleware:
+ def __init__(self, get_response):
+ self.get_response = get_response
+
+ def __call__(self, request):
+ translation.activate(get_chosen_language(request.user, request))
+ response = self.get_response(request)
+ return response
diff --git a/bugsink/settings/default.py b/bugsink/settings/default.py
index 2c9f734..91b3905 100644
--- a/bugsink/settings/default.py
+++ b/bugsink/settings/default.py
@@ -103,6 +103,10 @@ MIDDLEWARE = [
'bugsink.middleware.LoginRequiredMiddleware',
+ # note on ordering: we need request.user, so after AuthenticationMiddleware; and we're not tied to "before
+ # CommonMiddleware" as django.middleware.locale.LocaleMiddleware is, because we don't do path-related stuff.
+ 'bugsink.middleware.UserLanguageMiddleware',
+
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
@@ -241,6 +245,14 @@ TIME_ZONE = 'Europe/Amsterdam'
USE_I18N = True
+USE_L10N = True
+
+LOCALE_PATHS = [BASE_DIR / "locale"]
+LANGUAGES = (
+ ("en", "English"),
+ ("zh-hans", "简体中文"),
+)
+
USE_TZ = True
diff --git a/issues/models.py b/issues/models.py
index 7637521..534b912 100644
--- a/issues/models.py
+++ b/issues/models.py
@@ -8,6 +8,7 @@ from django.db.models.functions import Concat
from django.template.defaultfilters import date as default_date_filter
from django.conf import settings
from django.utils.functional import cached_property
+from django.utils.translation import gettext_lazy as _
from bugsink.utils import assert_
from bugsink.volume_based_condition import VolumeBasedCondition
@@ -485,16 +486,16 @@ class IssueQuerysetStateManager(object):
class TurningPointKind(models.IntegerChoices):
# The language of the kinds reflects a historic view of the system, e.g. "first seen" as opposed to "new issue"; an
# alternative take (which is more consistent with the language used elsewhere" is a more "active" language.
- FIRST_SEEN = 1, "First seen"
- RESOLVED = 2, "Resolved"
- MUTED = 3, "Muted"
- REGRESSED = 4, "Marked as regressed"
- UNMUTED = 5, "Unmuted"
+ FIRST_SEEN = 1, _("First seen")
+ RESOLVED = 2, _("Resolved")
+ MUTED = 3, _("Muted")
+ REGRESSED = 4, _("Marked as regressed")
+ UNMUTED = 5, _("Unmuted")
- NEXT_MATERIALIZED = 10, "Release info added"
+ NEXT_MATERIALIZED = 10, _("Release info added")
# ASSGINED = 10, "Assigned to user" # perhaps later
- MANUAL_ANNOTATION = 100, "Manual annotation"
+ MANUAL_ANNOTATION = 100, _("Manual annotation")
class TurningPoint(models.Model):
diff --git a/issues/templates/issues/_event_nav.html b/issues/templates/issues/_event_nav.html
index 5d7e65d..a36381f 100644
--- a/issues/templates/issues/_event_nav.html
+++ b/issues/templates/issues/_event_nav.html
@@ -1,7 +1,8 @@
{% load add_to_qs %}
+{% load i18n %}
{% if has_prev %} {# no need for 'is_first': if you can go to the left, you can go all the way to the left too #}
diff --git a/issues/templates/issues/base.html b/issues/templates/issues/base.html
index 9beecc8..668f6ee 100644
--- a/issues/templates/issues/base.html
+++ b/issues/templates/issues/base.html
@@ -4,6 +4,8 @@
{% load humanize %}
{% load stricter_templates %}
{% load add_to_qs %}
+{% load i18n %}
+
{% block title %}{{ issue.title }} · {{ block.super }}{% endblock %}
{% block content %}
@@ -18,12 +20,12 @@
{% spaceless %}{# needed to avoid whitespace between the looks-like-one-buttons #}
{% if issue.project.has_releases %}
-
+
{# we just hide the whole dropdown; this is the easiest implementation of not-showing the dropdown #}
{% else %}
-
+
{% endif %}
{% endspaceless %}
@@ -32,7 +34,7 @@
{% if issue.project.has_releases %}
{# 'by next' is shown even if 'by current' is also shown: just because you haven't seen 'by current' doesn't mean it's actually already solved; and in fact we show this option first precisely because we can always show it #}
-
+
@@ -51,7 +53,7 @@
{% else %}
-
+
{% endif %}
{% endspaceless %}
@@ -59,14 +61,14 @@
{% spaceless %}{# needed to avoid whitespace between the looks-like-one-buttons #}
{% if not issue.is_muted and not issue.is_resolved %}
-
+
{% else %}
-
+
{% endif %}
{% if not issue.is_muted and not issue.is_resolved %}
-
+
{% for mute_option in mute_options %}
@@ -74,7 +76,7 @@
{% endfor %}
{% else %}
-
+
{# note that when the issue is muted, no further muting is allowed. this is a design decision, I figured this is the easiest-to-understand UI, #}
{# both at the point-of-clicking and when displaying the when-will-this-be-unmuted in some place #}
{# (the alternative would be to allow multiple simulteneous reasons for unmuting to exist next to each other #}
@@ -83,9 +85,9 @@
{% if issue.is_muted and not issue.is_resolved %}
-
+
{% else %}
-
+
{% endif %}
{% endspaceless %}
@@ -107,13 +109,13 @@
{# overflow-x-auto is needed at the level of the flex item such that it works at the level where we need it (the code listings)#}
{# 96rem is 1536px, which matches the 2xl class; this is no "must" but eyeballing revealed: good result #}
Event {{ event.digest_order|intcomma }} of {{ issue.digested_event_count|intcomma }} which occured at {{ event.ingested_at|date:"j M G:i T" }}
{% endif %}
+ {% if is_event_page %}
{% blocktranslate with digest_order=event.digest_order|intcomma total_events=issue.digested_event_count|intcomma ingested_at=event.ingested_at|date:"j M G:i T" %}Event {{ digest_order }} of {{ total_events }} which occured at {{ ingested_at }}{% endblocktranslate %}
- Deleting an Issue is a permanent action and cannot be undone. It's typically better to resolve or mute an issue instead of deleting it, as this allows you to keep track of past issues and their resolutions.
+ {% translate "Deleting an Issue is a permanent action and cannot be undone. It's typically better to resolve or mute an issue instead of deleting it, as this allows you to keep track of past issues and their resolutions." %}
-
-
+
+
@@ -27,25 +28,25 @@
-
{{ project.name }} - Issues
+
{{ project.name }} - {% translate "Issues" %}
{% if unapplied_issue_ids %}
- The chosen action is not applicable to all selected issues. Issues for which it has not been applied have been left with checkboxes checked so that you can try again with another action.
+ {% translate "The chosen action is not applicable to all selected issues. Issues for which it has not been applied have been left with checkboxes checked so that you can try again with another action." %}
@@ -75,12 +76,12 @@
{% spaceless %}{# needed to avoid whitespace between the looks-like-one-buttons #}
{% if project.has_releases %}
-
+
{# we just hide the whole dropdown; this is the easiest implementation of not-showing the dropdown #}
{% else %}
-
+
{% endif %}
{% endspaceless %}
@@ -105,7 +106,7 @@
{% else %}
-
+
{% endif %}
{% endspaceless %}
@@ -113,14 +114,14 @@
{% spaceless %}{# needed to avoid whitespace between the looks-like-one-buttons #}
{% if not disable_mute_buttons %}
-
+
{% else %}
-
+
{% endif %}
{% if not disable_mute_buttons %}
-
+
{% for mute_option in mute_options %}
@@ -128,22 +129,22 @@
{% endfor %}
{% else %}
-
+
{# we just hide the whole dropdown; this is the easiest implementation of not-showing the dropdown when the issue is already muted #}
{% endif %}
{% if not disable_unmute_buttons %}
-
+
{% else %}
-
+
{% endif %}
-
+
@@ -152,7 +153,7 @@
{# NOTE: "reopen" is not available in the UI as per the notes in issue_detail #}
- {# only for resolved/muted items #}
+ {# only for resolved/muted items #}
from {{ issue.first_seen|date:"j M G:i T" }} | last {{ issue.last_seen|date:"j M G:i T" }} | with {{ issue.digested_event_count|intcomma }} events
+
from {{ issue.first_seen|date:"j M G:i T" }} | last {{ issue.last_seen|date:"j M G:i T" }} | {% blocktranslate with event_count=issue.digested_event_count|intcomma %}with {{ event_count }} events{% endblocktranslate %}
{% if issue.digested_event_count != issue.stored_event_count %}
({{ issue.stored_event_count|intcomma }} av{#ilable#})
{% endif %}
@@ -195,12 +196,12 @@
No {{ state_filter }} issues found for "{{ q }}"
{% else %}
{% if state_filter == "open" %}
- Congratulations! You have no open issues.
+ {% translate "Congratulations! You have no open issues." %}
{% if project.digested_event_count == 0 %}
This might mean you have not yet set up your SDK.
{% endif %}
{% else %}
- No {{ state_filter }} issues found.
+ {% blocktranslate %}No {{ state_filter }} issues found.{% endblocktranslate %}
{% endif %}
{% endif %}
@@ -230,12 +231,12 @@
{% endif %}
{% if page_obj.object_list|length > 0 %}{# sounds expensive, but this list is cached #}
- Issues {{ page_obj.start_index|intcomma }} – {{ page_obj.end_index|intcomma }}
+ {% translate "Issues" %} {{ page_obj.start_index|intcomma }} – {{ page_obj.end_index|intcomma }}
{% else %}
{% if page_obj.number > 1 %}
Less than {{ page_obj.start_index }} Issues {# corresponds to the 1/250 case of having an exactly full page and navigating to an empty page after that #}
{% else %}
- 0 Issues
+ 0 {% translate "Issues" %}
{% endif %}
{% endif %}
diff --git a/issues/templates/issues/stacktrace.html b/issues/templates/issues/stacktrace.html
index 6036c72..248b4a8 100644
--- a/issues/templates/issues/stacktrace.html
+++ b/issues/templates/issues/stacktrace.html
@@ -3,6 +3,7 @@
{% load stricter_templates %}
{% load issues %}
{% load humanize %}
+{% load i18n %}
{% block tab_content %}
@@ -21,7 +22,7 @@
- No stacktrace available for this event.
+ {% translate "No stacktrace available for this event." %}
- Are you sure you want to delete this project? This action cannot be undone and will delete all associated data.
+ {% translate "Are you sure you want to delete this project? This action cannot be undone and will delete all associated data." %}
{% endif %}
{% else %}
{% if ownership_filter == "teams" or project.is_joinable or request.user.is_superuser %}{# ownership_filter check: you can always join your own team's projects, so if you're looking at a list of them... #}
{% if not member.accepted %}
-
+
{% endif %}
{% if request.user == member.user %}
-
+
{% else %} {# NOTE: in our setup request_user_is_admin is implied because only admins may view the membership page #}
-
+
{% endif %}
@@ -71,7 +72,7 @@
{# Note: this is already somewhat exceptional, because the usually you'll at least see yourself here (unless you're a superuser and a project has become memberless) #}
- No members yet. Invite someone.
+ {% translate "No members yet." %} {% translate "Invite someone." %}
- You have been invited to join the project "{{ project.name }}" in the role of "{{ membership.get_role_display }}". Please confirm by clicking the button below.
+ {% blocktranslate with project_name=project.name role=membership.get_role_display %}You have been invited to join the project "{{ project_name }}" in the role of "{{ role }}". Please confirm by clicking the button below.{% endblocktranslate %}
{% blocktranslate with project_name=project.name %}Invite members ({{ project_name }}){% endblocktranslate %}
- Invite a member to join the project "{{ project.name }}". They will receive an email with a link to join.
+ {% blocktranslate with project_name=project.name %}Invite a member to join the project "{{ project_name }}". They will receive an email with a link to join.{% endblocktranslate %}
{% tailwind_formfield form.team %}
@@ -22,8 +23,8 @@
{% tailwind_formfield form.visibility %}
{% tailwind_formfield form.retention_max_event_count %}
-
- Cancel
+
+ {% translate "Cancel" %}
diff --git a/projects/views.py b/projects/views.py
index dd55500..16d3d49 100644
--- a/projects/views.py
+++ b/projects/views.py
@@ -11,6 +11,7 @@ from django.contrib import messages
from django.contrib.auth import logout
from django.urls import reverse
from django.utils import timezone
+from django.utils.translation import gettext_lazy as _
from users.models import EmailVerification
from teams.models import TeamMembership, Team, TeamRole
@@ -78,7 +79,7 @@ def project_list(request, ownership_filter=None):
if not project.is_joinable(user=request.user) and not request.user.is_superuser:
raise PermissionDenied("This project is not joinable")
- messages.success(request, 'You have joined the project "%s"' % project.name)
+ messages.success(request, _('You have joined the project "%s"') % project.name)
ProjectMembership.objects.create(
project_id=project_pk, user_id=request.user.id, role=ProjectRole.MEMBER, accepted=True)
return redirect('project_member_settings', project_pk=project_pk, user_pk=request.user.id)
diff --git a/teams/forms.py b/teams/forms.py
index a4ce73c..dbf0bd3 100644
--- a/teams/forms.py
+++ b/teams/forms.py
@@ -1,6 +1,7 @@
from django import forms
from django.contrib.auth import get_user_model
from django.template.defaultfilters import yesno
+from django.utils.translation import gettext_lazy as _
from bugsink.utils import assert_
from .models import TeamRole, TeamMembership, Team
@@ -9,9 +10,9 @@ User = get_user_model()
class TeamMemberInviteForm(forms.Form):
- email = forms.EmailField(label='Email', required=True)
+ email = forms.EmailField(label=_('Email'), required=True)
role = forms.ChoiceField(
- label='Role', choices=TeamRole.choices, required=True, initial=TeamRole.MEMBER, widget=forms.RadioSelect)
+ label=_('Role'), choices=TeamRole.choices, required=True, initial=TeamRole.MEMBER, widget=forms.RadioSelect)
def __init__(self, user_must_exist, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -37,6 +38,7 @@ class MyTeamMembershipForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
edit_role = kwargs.pop("edit_role")
+
super().__init__(*args, **kwargs)
assert_(self.instance is not None, "This form is only implemented for editing")
@@ -44,7 +46,9 @@ class MyTeamMembershipForm(forms.ModelForm):
del self.fields['role']
global_send_email_alerts = self.instance.user.send_email_alerts
- empty_label = "User-default (%s)" % yesno(global_send_email_alerts).capitalize()
+ global_send_email_alerts_text = yesno(global_send_email_alerts).capitalize()
+
+ empty_label = _("User-default (%s)") % global_send_email_alerts_text
self.fields['send_email_alerts'].empty_label = empty_label
self.fields['send_email_alerts'].widget.choices[0] = ("unknown", empty_label)
diff --git a/teams/models.py b/teams/models.py
index c4a670b..5cc41ec 100644
--- a/teams/models.py
+++ b/teams/models.py
@@ -3,34 +3,36 @@ import uuid
from django.db import models
from django.conf import settings
+from django.utils.translation import gettext_lazy as _, pgettext_lazy
class TeamRole(models.IntegerChoices):
- MEMBER = 0
- ADMIN = 1
+ MEMBER = 0, _("Member")
+ ADMIN = 1, _("Admin")
class TeamVisibility(models.IntegerChoices):
# PUBLIC = 0 # anyone can see the team and its members not sure if I want this or always want to require click-in
- JOINABLE = 1 # anyone can join
+ JOINABLE = 1, _("Joinable") # anyone can join
# the team's existance is visible in lists, but there is no "Join" button. the idea would be that you can "request
# to join" (which is currently not implemented as a button, but you could do it 'out of bands' i.e. via email or
# chat).
- DISCOVERABLE = 10
+ DISCOVERABLE = 10, _("Discoverable")
# the team is not visible to non-members; you need to be invited
- HIDDEN = 99
+ HIDDEN = 99, _("Hidden")
class Team(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
- name = models.CharField(max_length=255, blank=False, null=False, unique=True)
+ name = models.CharField(pgettext_lazy("Team", "Name"), max_length=255, blank=False, null=False, unique=True)
visibility = models.IntegerField(
+ _("Visibility"),
choices=TeamVisibility.choices, default=TeamVisibility.DISCOVERABLE,
- help_text="Which users can see this team and its issues?")
+ help_text=_("Which users can see this team and its issues?"))
def __str__(self):
return self.name
@@ -48,8 +50,8 @@ class TeamMembership(models.Model):
team = models.ForeignKey(Team, on_delete=models.CASCADE)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
- send_email_alerts = models.BooleanField(default=None, null=True, blank=True)
- role = models.IntegerField(choices=TeamRole.choices, default=TeamRole.MEMBER)
+ send_email_alerts = models.BooleanField(_("Send email alerts"), default=None, null=True, blank=True)
+ role = models.IntegerField(_("Role"), choices=TeamRole.choices, default=TeamRole.MEMBER)
accepted = models.BooleanField(default=False)
def __str__(self):
diff --git a/teams/templates/teams/team_edit.html b/teams/templates/teams/team_edit.html
index 4a8037f..c3d33eb 100644
--- a/teams/templates/teams/team_edit.html
+++ b/teams/templates/teams/team_edit.html
@@ -1,8 +1,9 @@
{% extends "base.html" %}
{% load static %}
{% load tailwind_forms %}
+{% load i18n %}
-{% block title %}Edit {{ team.name }} · {{ site_title }}{% endblock %}
+{% block title %}{% translate "Edit" %} {{ team.name }} · {{ site_title }}{% endblock %}
{% block content %}
@@ -14,18 +15,20 @@
{% csrf_token %}
-
Settings ({{ team.name }})
+
{% blocktranslate with team_name=team.name %}Settings ({{ team_name }}){% endblocktranslate %}
- Team settings for "{{ team.name }}".
+ {% blocktranslate with team_name=team.name %}Team settings for "{{ team_name }}".{% endblocktranslate %}
{% if this_is_you %}
- Your membership settings for team "{{ team.name }}".
+ {% blocktrans with team_name=team.name %}Your membership settings for team "{{ team_name }}".{% endblocktrans %}
{% else %}
- Settings for team "{{ team.name }}" and user {{ user.username }}.
+ {% blocktrans with team_name=team.name username=user.username %}Settings for team "{{ team_name }}" and user {{ username }}.{% endblocktrans %}
{% endif %}
{% tailwind_formfield form.role %}
{% tailwind_formfield form.send_email_alerts %}
-
+
{% if this_is_you %}
- Cancel {# not quite perfect, because "you" can also click on yourself in the member list #}
+ {% translate "Cancel" %} {# not quite perfect, because "you" can also click on yourself in the member list #}
{% else %}
- Cancel
+ {% translate "Cancel" %}
{% endif %}
diff --git a/teams/templates/teams/team_members.html b/teams/templates/teams/team_members.html
index ac69725..3369d0b 100644
--- a/teams/templates/teams/team_members.html
+++ b/teams/templates/teams/team_members.html
@@ -1,7 +1,8 @@
{% extends "base.html" %}
{% load static %}
+{% load i18n %}
-{% block title %}Members · {{ team.name }} · {{ site_title }}{% endblock %}
+{% block title %}{% translate "Members" %} · {{ team.name }} · {{ site_title }}{% endblock %}
{% block content %}
@@ -21,10 +22,10 @@
{% endif %}
{% if not member.accepted %}
-
+
{% endif %}
{% if request.user == member.user %}
-
+
{% else %} {# NOTE: in our setup request_user_is_admin is implied because only admins may view the membership page #}
-
+
{% endif %}
@@ -71,7 +72,7 @@
{# Note: this is already somewhat exceptional, because the usually you'll at least see yourself here (unless you're a superuser and a team has become memberless) #}
- No members yet. Invite someone.
+ {% translate "No members yet." %} {% translate "Invite someone." %}
{% blocktranslate with team_name=team.name %}Invite members ({{ team_name }}){% endblocktranslate %}
- Invite a member to join the team "{{ team.name }}". They will receive an email with a link to join.
+ {% blocktranslate with team_name=team.name %}Invite a member to join the team "{{ team_name }}". They will receive an email with a link to join.{% endblocktranslate %}
{% tailwind_formfield form.name %}
{% tailwind_formfield form.visibility %}
-
- Cancel
+
+ {% translate "Cancel" %}
diff --git a/teams/views.py b/teams/views.py
index 19e5d4a..6a39141 100644
--- a/teams/views.py
+++ b/teams/views.py
@@ -9,6 +9,7 @@ from django.utils import timezone
from django.urls import reverse
from django.contrib import messages
from django.contrib.auth import logout
+from django.utils.translation import gettext_lazy as _
from users.models import EmailVerification
from bugsink.app_settings import get_settings, CB_ANYBODY, CB_ADMINS, CB_MEMBERS
@@ -54,7 +55,7 @@ def team_list(request, ownership_filter=None):
if not team.is_joinable() and not request.user.is_superuser:
raise PermissionDenied("This team is not joinable")
- messages.success(request, 'You have joined the team "%s"' % team.name)
+ messages.success(request, _('You have joined the team "%s"') % team.name)
TeamMembership.objects.create(team_id=team_pk, user_id=request.user.id, role=TeamRole.MEMBER, accepted=True)
return redirect('team_member_settings', team_pk=team_pk, user_pk=request.user.id)
@@ -126,7 +127,8 @@ def team_edit(request, team_pk):
if action == 'delete':
# Double-check that the user is an admin or superuser
- if not (TeamMembership.objects.filter(team=team, user=request.user, role=TeamRole.ADMIN, accepted=True).exists() or
+ if not (TeamMembership.objects.filter(
+ team=team, user=request.user, role=TeamRole.ADMIN, accepted=True).exists() or
request.user.is_superuser):
raise PermissionDenied("Only team admins can delete teams")
diff --git a/templates/bugsink/login.html b/templates/bugsink/login.html
index c31a4e0..d515fc6 100644
--- a/templates/bugsink/login.html
+++ b/templates/bugsink/login.html
@@ -1,5 +1,6 @@
{% extends "barest_base.html" %}
{% load static %}
+{% load i18n %}
{% block title %}Log in · {{ site_title }}{% endblock %}
@@ -14,13 +15,13 @@
{% if form.errors %}
-
Your username and password didn't match. Please try again.
- A verification email has been sent to your email address. Please verify your email address to complete the registration process.
+ {% translate "A verification email has been sent to your email address. Please verify your email address to complete the registration process." %}
- A password reset link has been sent to your email address. Please check your inbox and follow the instructions to reset your password.
+ {% translate "A password reset link has been sent to your email address. Please check your inbox and follow the instructions to reset your password." %}
- Are you sure you want to delete this user? This action cannot be undone.
+ {% translate "Are you sure you want to delete this user? This action cannot be undone." %}
@@ -41,7 +42,7 @@
{% endif %}
-
Users
+
{% translate "Users" %}
{% comment %}
Our current invite-system is tied to either a team or a project; no "global" invites (yet).
diff --git a/users/views.py b/users/views.py
index 78d26fb..ad66680 100644
--- a/users/views.py
+++ b/users/views.py
@@ -7,9 +7,12 @@ from django.http import Http404
from django.utils import timezone
from django.contrib import messages
from django.contrib.auth.decorators import login_required, user_passes_test
+from django.utils.translation import gettext as _
+from django.utils import translation
from bugsink.app_settings import get_settings, CB_ANYBODY
from bugsink.decorators import atomic_for_request_method
+from bugsink.middleware import get_chosen_language
from .forms import (
UserCreationForm, ResendConfirmationForm, RequestPasswordResetForm, SetPasswordForm, PreferencesForm, UserEditForm)
@@ -225,8 +228,13 @@ def preferences(request):
form = PreferencesForm(request.POST, instance=user)
if form.is_valid():
- form.save()
- messages.success(request, "Updated preferences")
+ user = form.save()
+
+ # activate the selected language immediately for the Success message; we've already passed the middleware
+ # stage (which looked at the pre-change language), so we need to do this ourselves with the fresh value.
+ translation.activate(get_chosen_language(user, request))
+
+ messages.success(request, _("Updated preferences"))
return redirect('preferences')
else: