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" %}

{% csrf_token %} {# margins display slightly different from the Add Token +
@@ -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 %}
{# nav="last": when doing a new search on an event-page, you want the most recent matching event to show up #} - +
{% 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 #} - +