From 79302aa9f688766f63593e615c65d79ac6c19f02 Mon Sep 17 00:00:00 2001 From: Norbert Kwizera Date: Mon, 27 Jul 2026 13:33:47 +0200 Subject: [PATCH 1/2] Reset active language and timezone on requests without an org Previously SetOrgMiddleware activated an org's language and timezone in thread-local state but never reset them, so org-less requests handled by the same thread could render with a previous request's locale settings. --- dash/orgs/middleware.py | 6 ++++++ test_runner/tests.py | 27 ++++++++++++++++++++++++--- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/dash/orgs/middleware.py b/dash/orgs/middleware.py index d53c3a11..2883061c 100644 --- a/dash/orgs/middleware.py +++ b/dash/orgs/middleware.py @@ -91,11 +91,17 @@ def set_language(self, request, org): if org: lang = org.language or settings.DEFAULT_LANGUAGE translation.activate(lang) + else: + # reset any language activated by a previous request on this thread + translation.activate(settings.LANGUAGE_CODE) def set_timezone(self, request, org): """Set the current timezone from the org configuration.""" if org and org.timezone: timezone.activate(org.timezone) + else: + # reset any timezone activated by a previous request on this thread + timezone.deactivate() def process_view(self, request, view_func, view_args, view_kwargs): if not request.org: diff --git a/test_runner/tests.py b/test_runner/tests.py index 0dcfdf04..407d0c34 100644 --- a/test_runner/tests.py +++ b/test_runner/tests.py @@ -1,6 +1,5 @@ import zoneinfo -from dash.tags.models import Tag -from unittest.mock import Mock, patch, call +from unittest.mock import Mock, call, patch import valkey from smartmin.tests import SmartminTest @@ -13,6 +12,7 @@ from django.db.utils import IntegrityError from django.http import HttpRequest, HttpResponse from django.urls import ResolverMatch, reverse +from django.utils import timezone, translation from django.utils.encoding import force_str from dash.categories.fields import CategoryChoiceField @@ -25,8 +25,9 @@ from dash.orgs.tasks import org_task from dash.orgs.templatetags.dashorgs import display_time, national_phone from dash.stories.models import Story, StoryImage -from dash.utils import random_string +from dash.tags.models import Tag from dash.test import MockResponse +from dash.utils import random_string class UserTest(SmartminTest): @@ -347,6 +348,26 @@ def test_process(self): self.assertEqual(self.request.org, empty_subdomain_org) self.assertEqual(self.request.user.get_org(), empty_subdomain_org) + def test_language_and_timezone_reset(self): + ug_org = self.create_org("uganda", self.admin) + ug_org.language = "fr" + ug_org.timezone = zoneinfo.ZoneInfo("Africa/Kigali") + ug_org.save() + + # a request with an org activates its language and timezone + response = self.simulate_process("uganda.ureport.io", "dash.test_test") + self.assertIsNone(response) + self.assertEqual(self.request.org, ug_org) + self.assertEqual(translation.get_language(), "fr") + self.assertEqual(timezone.get_current_timezone_name(), "Africa/Kigali") + + # a subsequent org-less request on the same thread gets the defaults back + response = self.simulate_process("ureport.io", "orgs.org_create") + self.assertIsNone(response) + self.assertIsNone(self.request.org) + self.assertEqual(translation.get_language(), settings.LANGUAGE_CODE) + self.assertEqual(timezone.get_current_timezone_name(), settings.TIME_ZONE) + class OrgContextProcessorTestcase(DashTest): def test_group_perms_wrapper(self): From 09cccf28066cbe137303550b1e1154628ab5c53e Mon Sep 17 00:00:00 2001 From: Norbert Kwizera Date: Mon, 27 Jul 2026 16:29:27 +0200 Subject: [PATCH 2/2] Rework locale/timezone handling to save and restore around each request Instead of activating state in process_request and resetting to defaults on org-less requests, wrap request handling in translation.override and timezone.override when an org supplies a language/timezone. Prior state is snapshotted and restored, so nothing leaks between requests on the same thread and org-less requests keep whatever language was negotiated earlier in the middleware chain (e.g. by LocaleMiddleware). --- dash/orgs/middleware.py | 39 ++++++++++---------- test_runner/tests.py | 81 ++++++++++++++++++++++++++++++++++++----- 2 files changed, 90 insertions(+), 30 deletions(-) diff --git a/dash/orgs/middleware.py b/dash/orgs/middleware.py index 2883061c..ce13e2c3 100644 --- a/dash/orgs/middleware.py +++ b/dash/orgs/middleware.py @@ -1,5 +1,6 @@ import re import traceback +from contextlib import nullcontext from django.conf import settings from django.core.exceptions import DisallowedHost @@ -47,9 +48,27 @@ class SetOrgMiddleware(MiddlewareMixin): Sets the org on the request, based on the subdomain """ + async_capable = False + def __init__(self, get_response=None): super(SetOrgMiddleware, self).__init__(get_response) + def __call__(self, request): + response = self.process_request(request) + if response: + return response + + org = request.org + + # activate the org's language and timezone for the duration of the request only, restoring whatever was + # active before so that state never leaks into subsequent requests on the same thread, and org-less + # requests keep e.g. the language negotiated by LocaleMiddleware + lang_override = translation.override(org.language or settings.DEFAULT_LANGUAGE) if org else nullcontext() + tz_override = timezone.override(org.timezone) if org and org.timezone else nullcontext() + + with lang_override, tz_override: + return self.get_response(request) + def process_request(self, request): # try looking the domain level @@ -83,26 +102,6 @@ def process_request(self, request): request.org = org - self.set_language(request, org) - self.set_timezone(request, org) - - def set_language(self, request, org): - """Set the current language from the org configuration.""" - if org: - lang = org.language or settings.DEFAULT_LANGUAGE - translation.activate(lang) - else: - # reset any language activated by a previous request on this thread - translation.activate(settings.LANGUAGE_CODE) - - def set_timezone(self, request, org): - """Set the current timezone from the org configuration.""" - if org and org.timezone: - timezone.activate(org.timezone) - else: - # reset any timezone activated by a previous request on this thread - timezone.deactivate() - def process_view(self, request, view_func, view_args, view_kwargs): if not request.org: # serve static files diff --git a/test_runner/tests.py b/test_runner/tests.py index 407d0c34..a2ad1909 100644 --- a/test_runner/tests.py +++ b/test_runner/tests.py @@ -348,26 +348,87 @@ def test_process(self): self.assertEqual(self.request.org, empty_subdomain_org) self.assertEqual(self.request.user.get_org(), empty_subdomain_org) - def test_language_and_timezone_reset(self): + def simulate_call(self, host, url_name, path="/"): + """ + Simulates a full middleware call, capturing the language and timezone active while the view runs + """ + self.request = Mock(spec=HttpRequest) + self.request.get_host.return_value = host + self.request.user = self.admin + self.request.path = path + self.request.META = dict(HTTP_HOST=None) + + def get_response(request): + request.resolver_match = ResolverMatch(self.mock_view, [], {}, url_name) + response = self.middleware.process_view(request, self.mock_view, [], {}) + if response: + return response + + self.view_language = translation.get_language() + self.view_timezone = timezone.get_current_timezone_name() + return HttpResponse() + + return SetOrgMiddleware(get_response)(self.request) + + def test_language_and_timezone_restored(self): + self.addCleanup(translation.deactivate) + self.addCleanup(timezone.deactivate) + + # start from the default language and timezone + translation.deactivate() + timezone.deactivate() + ug_org = self.create_org("uganda", self.admin) ug_org.language = "fr" ug_org.timezone = zoneinfo.ZoneInfo("Africa/Kigali") ug_org.save() - # a request with an org activates its language and timezone - response = self.simulate_process("uganda.ureport.io", "dash.test_test") - self.assertIsNone(response) + # a request with an org activates its language and timezone for the duration of the view + response = self.simulate_call("uganda.ureport.io", "dash.test_test") + self.assertEqual(response.status_code, 200) self.assertEqual(self.request.org, ug_org) - self.assertEqual(translation.get_language(), "fr") - self.assertEqual(timezone.get_current_timezone_name(), "Africa/Kigali") + self.assertEqual(self.view_language, "fr") + self.assertEqual(self.view_timezone, "Africa/Kigali") - # a subsequent org-less request on the same thread gets the defaults back - response = self.simulate_process("ureport.io", "orgs.org_create") - self.assertIsNone(response) - self.assertIsNone(self.request.org) + # but restores the previous state once the response is returned self.assertEqual(translation.get_language(), settings.LANGUAGE_CODE) self.assertEqual(timezone.get_current_timezone_name(), settings.TIME_ZONE) + # so a subsequent org-less request on the same thread sees the defaults + response = self.simulate_call("ureport.io", "orgs.org_create") + self.assertEqual(response.status_code, 200) + self.assertIsNone(self.request.org) + self.assertEqual(self.view_language, settings.LANGUAGE_CODE) + self.assertEqual(self.view_timezone, settings.TIME_ZONE) + + def test_org_less_request_keeps_negotiated_language(self): + self.addCleanup(translation.deactivate) + self.addCleanup(timezone.deactivate) + + # simulate LocaleMiddleware having negotiated a language before this middleware runs + translation.activate("es") + + response = self.simulate_call("ureport.io", "orgs.org_create") + self.assertEqual(response.status_code, 200) + self.assertIsNone(self.request.org) + + # an org-less request doesn't touch the active language + self.assertEqual(self.view_language, "es") + self.assertEqual(translation.get_language(), "es") + + def test_org_without_language_gets_default(self): + self.addCleanup(translation.deactivate) + self.addCleanup(timezone.deactivate) + + ug_org = self.create_org("uganda", self.admin) + ug_org.language = None + ug_org.save() + + response = self.simulate_call("uganda.ureport.io", "dash.test_test") + self.assertEqual(response.status_code, 200) + self.assertEqual(self.request.org, ug_org) + self.assertEqual(self.view_language, settings.DEFAULT_LANGUAGE) + class OrgContextProcessorTestcase(DashTest): def test_group_perms_wrapper(self):