{% 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..9352813 100644
--- a/bsmain/views.py
+++ b/bsmain/views.py
@@ -20,7 +20,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..0680c9c 100644
--- a/bugsink/middleware.py
+++ b/bugsink/middleware.py
@@ -128,3 +128,32 @@ class SetRemoteAddrMiddleware:
request.META["REMOTE_ADDR"] = self.parse_x_forwarded_for(request.META.get("HTTP_X_FORWARDED_FOR", None))
return self.get_response(request)
+
+
+class UserLanguageMiddleware:
+ def __init__(self, get_response):
+ self.get_response = get_response
+
+ def __call__(self, request):
+ response = self.get_response(request)
+
+ if (request.user.is_authenticated and
+ hasattr(request.user, 'language')):
+
+ user_language = request.user.language
+ current_cookie = request.COOKIES.get('django_language')
+
+ if user_language == 'auto':
+ if current_cookie is not None:
+ response.delete_cookie('django_language')
+ else:
+ if current_cookie != user_language:
+ response.set_cookie(
+ 'django_language',
+ user_language,
+ max_age=365 * 24 * 60 * 60,
+ httponly=False,
+ samesite='Lax'
+ )
+
+ return response
diff --git a/bugsink/settings/default.py b/bugsink/settings/default.py
index 2c9f734..6fb76b7 100644
--- a/bugsink/settings/default.py
+++ b/bugsink/settings/default.py
@@ -97,11 +97,13 @@ MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
+ 'django.middleware.locale.LocaleMiddleware',
'django.middleware.common.CommonMiddleware',
'verbose_csrf_middleware.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'bugsink.middleware.LoginRequiredMiddleware',
+ 'bugsink.middleware.UserLanguageMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
@@ -241,6 +243,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..3e00e78 100644
--- a/teams/forms.py
+++ b/teams/forms.py
@@ -1,6 +1,8 @@
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 django.utils.translation import pgettext_lazy
from bugsink.utils import assert_
from .models import TeamRole, TeamMembership, Team
@@ -9,9 +11,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,14 +39,25 @@ 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")
+ self.fields['role'].label = _("Role")
+
if not edit_role:
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 = self.instance.user.send_email_alerts
+
+ if global_send_email_alerts:
+ global_send_email_alerts_text = _("Yes")
+ else:
+ global_send_email_alerts_text = _("No")
+
+ empty_label = _("User-default (%s)") % global_send_email_alerts
+ self.fields['send_email_alerts'].label = _("Send email alerts")
self.fields['send_email_alerts'].empty_label = empty_label
self.fields['send_email_alerts'].widget.choices[0] = ("unknown", empty_label)
@@ -61,3 +74,8 @@ class TeamForm(forms.ModelForm):
class Meta:
model = Team
fields = ["name", "visibility"]
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.fields['name'].label = pgettext_lazy("Team", "Name")
+ self.fields['visibility'].label = _("Visibility")
diff --git a/teams/models.py b/teams/models.py
index c4a670b..1cf98ec 100644
--- a/teams/models.py
+++ b/teams/models.py
@@ -3,24 +3,24 @@ import uuid
from django.db import models
from django.conf import settings
-
+from django.utils.translation import gettext_lazy as _
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):
@@ -30,7 +30,7 @@ class Team(models.Model):
visibility = models.IntegerField(
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
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 %}
- 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).