Skip to content

Activated user-specific browser language#293

Open
Lucius1274 wants to merge 3 commits intomainfrom
locale_selective_transfer
Open

Activated user-specific browser language#293
Lucius1274 wants to merge 3 commits intomainfrom
locale_selective_transfer

Conversation

@Lucius1274
Copy link
Collaborator

@Lucius1274 Lucius1274 commented Jan 30, 2026

Custom activation of the user-specific browser locale in the websocket. Fixes wrong date format in selective transfer

Summary by CodeRabbit

  • New Features
    • Improved multi-language support: automatic detection from browser preferences and consistent application of the chosen language across forms, error messages, and responses for the duration of the session.
  • Tests
    • Added tests validating language detection, fallback behavior, and that translations are applied consistently during interactions.

@gemini-code-assist
Copy link
Contributor

Summary of Changes

Hello @Lucius1274, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request introduces crucial internationalization support for WebSocket consumers, specifically within the selective transfer feature. By dynamically detecting and activating the user's browser language based on the Accept-Language header, the system can now correctly format dates and other locale-dependent content, resolving previous issues and providing a more accurate and localized user experience.

Highlights

  • User-Specific Language Activation: Implemented a mechanism to detect and activate the user's preferred browser language within WebSocket consumers, ensuring that language-sensitive operations respect the user's locale.
  • New Language Detection Method: Added a private method _activate_user_language to parse the Accept-Language HTTP header from the request scope and set the active language using Django's activate function.
  • Consistent Language Context: Ensured that the user's language context is consistently applied before rendering forms and responses in key selective transfer methods (_build_form_error_response, send_query_response, build_transfer_response) to prevent locale-related display issues.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 30, 2026

📝 Walkthrough

Walkthrough

Adds per-user language activation to the SelectiveTransferConsumer by parsing the HTTP Accept-Language header on connect, storing the resolved language, and re-applying that language via translation override during form rendering, error handling, and response sending.

Changes

Cohort / File(s) Summary
Consumer: language activation & usage
adit/selective_transfer/consumers.py
Adds _activate_user_language() to parse Accept-Language, resolve a supported variant, and store user_language. Extends imports with get_supported_language_variant and override. Calls _activate_user_language() on connect and wraps rendering/error paths with override(user_language) to ensure translations are applied consistently. Minor control-flow adjustments to ensure language is set before rendering.
Tests: consumer language behavior
adit/selective_transfer/tests/test_consumer.py
Adds pytest coverage for language activation: verifies selection from Accept-Language, fallback when missing or malformed, storage on connect, and that rendering uses the selected language via override context.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client (browser)
    participant Consumer as SelectiveTransferConsumer
    participant DjangoI18n as django.utils.translation
    participant Renderer as Template/Form Renderer

    Client->>Consumer: WebSocket connect (includes Accept-Language header)
    Consumer->>DjangoI18n: _activate_user_language(header) -> get_supported_language_variant
    DjangoI18n-->>Consumer: resolved user_language
    Consumer-->>Consumer: store user_language on instance
    Consumer->>Renderer: request to render form/response
    Consumer->>DjangoI18n: override(user_language) context
    DjangoI18n-->>Renderer: rendering in user_language
    Renderer-->>Consumer: rendered payload
    Consumer-->>Client: send rendered response
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 I sniff the header, find the tongue to share,
I tuck it in my pocket, keep translations fair,
When forms and errors come to chat and sing,
I whisper their words in the language they bring. 🥕✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately reflects the main change: activating user-specific browser language in the websocket consumer to fix date formatting issues.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch locale_selective_transfer

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request correctly activates user-specific language settings in the websocket consumer to address date formatting issues, using the Accept-Language header. However, it introduces a Path Traversal vulnerability due to unvalidated language codes being passed to Django's translation system. Furthermore, the Accept-Language header parsing needs to be more robust to handle quality factors, and the direct use of django.utils.translation.activate in thread pools is not thread-safe, potentially causing language setting leaks. It is recommended to validate language codes against supported variants and use django.utils.translation.override for thread-safe language activation.

Comment on lines 74 to 89
def _activate_user_language(self) -> str:
headers = dict(self.scope.get("headers", []))
accept_language = headers.get(b"accept-language", b"").decode("utf-8")

# Parse Accept-Language header to get preferred language
preferred_lang = None
if accept_language:
langs = [lang.split(";")[0].strip().lower() for lang in accept_language.split(",")]
# Default to first language
preferred_lang = langs[0].split("-")[0] if "-" in langs[0] else langs[0]

if preferred_lang:
activate(preferred_lang)
return preferred_lang
else:
return get_language()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

The _activate_user_language method is vulnerable to a Path Traversal attack. It extracts a language code from the Accept-Language header and passes it directly to django.utils.translation.activate() without validation. This allows an attacker to potentially force the application to load translation files from arbitrary file system locations. To mitigate this, the extracted language code must be validated against supported languages using django.utils.translation.get_supported_language_variant before activation. Additionally, while the provided suggestion addresses the security vulnerability, the parsing of the Accept-Language header could be made more robust to correctly handle quality factors (e.g., en-US,en;q=0.9), which is not fully covered by the current implementation and could lead to incorrect language selection.

    def _activate_user_language(self) -> str:
        headers = dict(self.scope.get("headers", []))
        accept_language = headers.get(b"accept-language", b"").decode("utf-8")

        # Parse Accept-Language header to get preferred language
        preferred_lang = None
        if accept_language:
            langs = [lang.split(";")[0].strip().lower() for lang in accept_language.split(",")]
            # Default to first language
            preferred_lang = langs[0].split("-")[0] if "-" in langs[0] else langs[0]

        if preferred_lang:
            from django.utils.translation import get_supported_language_variant
            try:
                supported_lang = get_supported_language_variant(preferred_lang)
                activate(supported_lang)
                return supported_lang
            except LookupError:
                pass

        return get_language()

Comment on lines 184 to 185
if hasattr(self, "user_language"):
activate(self.user_language)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using activate() modifies the language for the current thread. Since this code runs in a thread from a thread pool (database_sync_to_async), and threads are reused, this can lead to subtle bugs where a subsequent operation on the same thread unexpectedly runs with the wrong language setting.

It's safer to use django.utils.translation.override as a context manager. It ensures the language is changed only within its scope and is automatically restored. You could wrap the function's body in a with statement:

from django.utils.translation import override

with override(getattr(self, "user_language", None)):
    rendered_form: str = render_crispy_form(form)
    rendered_error_message: str = render_error_message(message)
    return rendered_form + rendered_error_message

Comment on lines 293 to 294
if hasattr(self, "user_language"):
activate(self.user_language)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using activate() here can have side effects in a threaded environment because threads from the pool are reused. To avoid language settings leaking between different operations, it's better to scope the language change.

Please use django.utils.translation.override as a context manager, which handles restoring the original language automatically.

from django.utils.translation import override

with override(getattr(self, "user_language", None)):
    # ... rest of the function body

Comment on lines 344 to 345
if hasattr(self, "user_language"):
activate(self.user_language)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

As with the other rendering methods, using activate() here is not thread-safe without restoring the original language. To prevent potential issues with thread reuse, please use the django.utils.translation.override context manager.

from django.utils.translation import override

with override(getattr(self, "user_language", None)):
    # ... rest of the function body

@claude
Copy link

claude bot commented Jan 30, 2026

Code Review: Activated user-specific browser language

Thank you for this fix to address the date format localization issue in selective transfer!

✅ Strengths

  1. Correct problem identification: WebSocket connections bypass Django's LocaleMiddleware, so manual language activation is necessary
  2. Sensible implementation: Extracting language from Accept-Language header is the right approach for WebSocket contexts
  3. Strategic placement: Language activation in connect() and re-activation before rendering ensures consistency

🔍 Code Quality Issues

1. Parsing Logic Could Be More Robust (lines 74-89)

The current Accept-Language parsing is simplistic and doesn't handle quality values (q-values). The header Accept-Language: en-US,en;q=0.9,de;q=0.8 should prefer en-US over de, but current code just takes first lang.

Suggestion: Use Django's built-in parse_accept_lang_header from django.utils.translation which respects quality values and matches exactly how Django's LocaleMiddleware works.

2. Defensive Checks Unnecessary (lines 184, 293, 344)

Since user_language is set in connect() which always runs before these methods, the hasattr check is redundant. Either remove the check (cleaner) or add a comment explaining why it's needed.

🐛 Potential Issues

3. Language Code Validation Missing

The code doesn't validate if the extracted language code is actually supported by Django. If a user sends Accept-Language: xx-ZZ (unsupported language), activate may silently fail or fall back to default.

4. Inconsistent Re-activation

You're re-activating in three rendering methods but not in check_permission() (line 173) which renders error messages. Worth verifying the execution order.

🧪 Testing Concerns

Critical: No tests were added for this functionality. You should add:

  1. Unit tests for _activate_user_language()
  2. Integration tests for date formatting with different browser language settings

🎯 Security & Performance

  • ✅ No security issues identified
  • ✅ Performance impact is minimal (header parsing happens once per connection)
  • ✅ No SQL injection or XSS risks

📝 Recommendations Summary

Must fix:

  1. Add comprehensive tests
  2. Use parse_accept_lang_header for proper Accept-Language parsing

Should fix:
3. Add docstring explaining why manual activation is needed
4. Verify check_permission doesn't need language activation

Nice to have:
5. Remove redundant hasattr checks or document edge cases
6. Add language code validation

Overall: The fix addresses the reported issue correctly, but needs better parsing logic and test coverage before merging. Happy to review again after updates!


Generated by Claude Code

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@adit/selective_transfer/consumers.py`:
- Around line 183-186: The method _build_form_error_response currently calls
activate(self.user_language) which mutates thread-local state; instead import
override from django.utils.translation and wrap the language activation in a
context manager so the language is restored after rendering (use
override(self.user_language) when self.user_language is set or
contextlib.nullcontext() when not). Apply the same pattern to the other two
methods that call activate (referenced in the diff at the other call sites) so
each of _build_form_error_response, and the other two methods use a with
override(...) / nullcontext(): block around render_crispy_form(form) to prevent
ThreadPoolExecutor worker threads from leaking language state.
- Around line 74-90: The _activate_user_language method currently drops region
subtags and ignores q-values; update it to parse the Accept-Language header with
q-value weights, sort candidates by q descending, and for each candidate use
django.utils.translation.get_supported_language_variant() (checking against
settings.LANGUAGES) to pick the best matching language while preserving region
variants (e.g., en-GB); then activate that language but ensure restoration to
avoid leakage by using django.utils.translation.override(...) as a context
manager around the consumer work that relies on the language or, if not
possible, capture the previous language with
django.utils.translation.get_language(), call activate(...) and always restore
it in a finally block.

Comment on lines 74 to 90
def _activate_user_language(self) -> str:
headers = dict(self.scope.get("headers", []))
accept_language = headers.get(b"accept-language", b"").decode("utf-8")

# Parse Accept-Language header to get preferred language
preferred_lang = None
if accept_language:
langs = [lang.split(";")[0].strip().lower() for lang in accept_language.split(",")]
# Default to first language
preferred_lang = langs[0].split("-")[0] if "-" in langs[0] else langs[0]

if preferred_lang:
activate(preferred_lang)
return preferred_lang
else:
return get_language()

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's examine the consumers.py file to understand the full context
cd /repo && wc -l adit/selective_transfer/consumers.py

Repository: openradx/adit

Length of output: 112


🏁 Script executed:

# Read the relevant sections of the consumers.py file
cd /repo && sed -n '1,120p' adit/selective_transfer/consumers.py | cat -n

Repository: openradx/adit

Length of output: 112


🏁 Script executed:

# Check if there are any threadpool or async patterns in this consumer
cd /repo && grep -n "thread\|async\|sync_to_async\|ThreadPool" adit/selective_transfer/consumers.py | head -20

Repository: openradx/adit

Length of output: 112


🏁 Script executed:

# Check Django version and available utilities
cd /repo && grep -r "get_supported_language_variant\|django.utils.translation" adit/selective_transfer/ --include="*.py"

Repository: openradx/adit

Length of output: 112


🏁 Script executed:

# Look for how activate() is currently used in the codebase
cd /repo && grep -n "activate(" adit/selective_transfer/consumers.py

Repository: openradx/adit

Length of output: 112


🏁 Script executed:

# Check if override() is already used elsewhere in the codebase for language handling
cd /repo && grep -r "override(" . --include="*.py" | grep -i language | head -10

Repository: openradx/adit

Length of output: 112


🌐 Web query:

Django get_supported_language_variant function availability and Accept-Language header parsing with q-values best practices

💡 Result:

get_supported_language_variant() availability (Django)

  • Public API: django.utils.translation.get_supported_language_variant(lang_code, strict=False).
  • Introduced in Django 2.1 (“New in Django 2.1”). [1]
  • Current behavior (Django 4.2/5.x): returns the best supported match from settings.LANGUAGES, including fallback to generic (es-ares) and (when strict=False) sometimes to a more specific variant if that’s all you support. [2]
  • Security-related length limit: language code parsing is capped (500 chars); docs note a behavior change in Django 4.2.15. [2] (This was released as part of security/bugfix updates; see also the 5.0.7 release notes for the DoS mitigation context.) [3]

Accept-Language parsing with q-values: best practices in Django

Prefer Django’s built-ins (don’t parse manually)

  • Use LocaleMiddleware (normal Django i18n stack) or call translation.get_language_from_request(request) directly. It:
    • Negotiates against settings.LANGUAGES only. [4]
    • Parses Accept-Language into (language, q) pairs ordered by descending q. [5]
    • Treats * as a wildcard and stops considering less-specific matches once encountered. [5]

Normalize language tags the “Django way”

  • Django’s Accept-Language parser lowercases the header value before processing. So ensure your supported language codes in LANGUAGES are in Django’s typical lowercase form (e.g., pt-br, en-us). [5]

Don’t treat Accept-Language as the user’s actual preference

  • Accept-Language indicates what the client accepts, often shaped by browser defaults, device UI language, travel, and privacy protections—not necessarily the user’s explicit site preference. (So store an explicit user choice via cookie/session/profile when available, and use Accept-Language as a fallback.) [6]

Avoid using Django’s private parser directly (unless you accept the risk)

  • django.utils.translation.trans_real.parse_accept_lang_header() is not a documented public API; Django core discussions explicitly flag it as “undocumented, private”. [7]
  • If you truly need a standalone parser (outside Django’s negotiation logic), prefer a dedicated library rather than reaching into Django internals (e.g. acceptlang, which is derived from Django’s logic). [8]

Sources:
[1] Django 2.1 utils docs (get_supported_language_variant “New in Django 2.1”) (docs.djangoproject.com)
[2] Django 5.1 utils docs (current behavior + “Changed in Django 4.2.15”) (docs.djangoproject.com)
[3] Django 5.0.7 release notes (DoS mitigation context for get_supported_language_variant) (fossies.org)
[4] Django utils docs (get_language_from_request behavior) (docs.djangoproject.com)
[5] Django trans_real.py (lowercasing, q-value sorting, length truncation) (fossies.org)
[6] MDN Accept-Language reference (q-values, wildcard, privacy notes) (developer.mozilla.org)
[7] Django ticket discussion noting parse_accept_lang_header() is private/undocumented (groups.google.com)
[8] acceptlang PyPI (standalone parser based on Django logic) (pypi.org)

Citations:


🌐 Web query:

Django Channels WebSocket consumer language activation threadpool async_to_sync language leakage

💡 Result:

In Django/Channels, language selection is effectively “context-local”, and it can leak in WebSocket consumers when you activate a language but don’t reliably restore it—especially when your code runs in reused threads (threadpools).

Why leakage happens

  • django.utils.translation.activate() is documented as per-thread, and Django explicitly warns you to switch back (or use translation.override()) because the change affects other code running in the same thread. [1]
  • Channels runs SyncConsumers in a threadpool (so threads get reused across connections/events). Channels even recommends SyncConsumers when you touch the ORM/sync Django APIs—i.e., the common case—meaning thread reuse is normal. [2]
  • asgiref’s sync/async adapters preserve threadlocals/contextvars across the boundary and sync_to_async(thread_sensitive=True) (the default) runs calls in a shared, thread-sensitive executor (a single-thread executor by default), increasing the chance that “thread-local” state (like active language) is observed by later work on the same thread if you didn’t restore it. [3][4]

What to do (practical fixes)

  1. Never leave a consumer/method with an activated language you set. Wrap language changes:
    • Prefer with translation.override(lang): ... so it restores automatically. [1]
  2. If you activate manually, restore in finally: (or call translation.deactivate() when appropriate), matching Django’s guidance. [1]
  3. If your leakage is happening inside sync work called from async (or vice-versa), be aware that thread_sensitive=True tends to reuse the same thread; isolating execution contexts can help:
    • Use ThreadSensitiveContext() to scope a separate thread-sensitive executor for a logical unit of work (e.g., per-connection / per-message handling), rather than sharing the process-wide one. [4]
    • Alternatively, for non-Django-sensitive sync code, sync_to_async(..., thread_sensitive=False) forces it off the shared thread-sensitive lane (but don’t do this for ORM access unless you know what you’re doing). [3]

If you paste a minimal consumer snippet showing where you call translation.activate() (and whether you’re using SyncConsumer, AsyncConsumer, database_sync_to_async, async_to_sync, etc.), I can point out the exact leak path and the smallest safe change.

Sources: Django i18n/translation docs (thread-based activation + override) [1]; Channels consumer guidance (SyncConsumer runs in a thread) [2]; Django async docs on async_to_sync / sync_to_async and preserved threadlocals/contextvars + thread_sensitive behavior [3]; asgiref.sync source for thread-sensitive executor/ThreadSensitiveContext [4].

[1] (docs.djangoproject.com)
[2] (dokk.org)
[3] (docs.djangoproject.com)
[4] (docs.djangoproject.com)

Citations:


🌐 Web query:

Django activate() vs override() context manager thread-local language storage scope

💡 Result:

  • translation.activate(lang) sets Django’s current language for the current execution context and leaves it set until something else changes it (or you call deactivate() / deactivate_all()). Django documents this as “for the current thread”. In practice, it’s stored in Django’s “active translations” holder. [1][2]

  • translation.override(lang, deactivate=False) is just a safer scoping wrapper around activate():

    • on enter: remembers the previous language and activates lang (or activates NullTranslations if lang is None)
    • on exit: restores the previous language (or optionally calls deactivate() if deactivate=True)
      This is exactly why it’s preferred for temporary switches (e.g., rendering an email in a specific locale). [1][2]
  • Where the language is stored (scope):

    • Django’s translation internals keep the “active” translation in _active, which is an asgiref.local.Local() instance (not a plain threading.local). [3]
    • asgiref.local.Local is designed to behave like thread-local in sync code and task-/context-local in async code, and it also proxies values across sync_to_async / async_to_sync boundaries (unless you opt into stricter behavior). [4]
      So the effective scope is “current thread / current async task context”, not a single global value.

Rule of thumb: use activate() when you’re intentionally setting the language for the rest of the current request/work unit; use override() when you need a temporary, exception-safe language switch with automatic restoration. [1][2]

Sources: Django utilities translation docs for activate/override and per-thread behavior [1][2]; Django translation internals showing _active = Local() [3]; asgiref docs describing Local semantics across threads/async contexts [4].

[1] https://docs.djangoproject.com/en/5.2/ref/utils/ (translation override, activate)
[2] https://docs.djangoproject.com/en/4.2/topics/i18n/translation/ (per-thread activation notes)
[3] https://fossies.org/linux/django/django/utils/translation/trans_real.py (_active = Local())
[4] https://pypi.org/project/asgiref/ (“Threadlocal replacement” / Local behavior)


🌐 Web query:

Django get_language_from_request WebSocket Channels consumer ASGI alternative

💡 Result:

django.utils.translation.get_language_from_request() wants a Django HttpRequest, but a Channels WebSocket consumer gets an ASGI scope (self.scope) instead, where headers/cookies/session live. In practice you do “language negotiation” yourself from scope, then translation.activate(...). Channels explicitly documents self.scope["headers"] for HTTP/WebSocket and that middleware can add more attributes to scope. [1]

Minimal “ASGI/Channels alternative” (no HttpRequest)

1) Ensure cookies/session are available in scope

Wrap your websocket app with SessionMiddlewareStack (or AuthMiddlewareStack, which includes sessions). Channels documents that SessionMiddlewareStack populates scope["session"] for WebSockets. [2]

2) Pick a language from (session/cookie/Accept-Language), then activate

Django’s locale selection order is: session (when enabled) → cookie (LANGUAGE_COOKIE_NAME, default django_language) → Accept-Language → fallback LANGUAGE_CODE. [3]

Example (Async consumer):

from channels.generic.websocket import AsyncWebsocketConsumer
from django.conf import settings
from django.utils import translation
from django.utils.translation import LANGUAGE_SESSION_KEY

def _get_header(scope, name: bytes) -> str | None:
    for k, v in scope.get("headers", []):
        if k.lower() == name:
            return v.decode("latin-1")
    return None

def _parse_cookie(header: str | None) -> dict[str, str]:
    if not header:
        return {}
    out = {}
    for part in header.split(";"):
        if "=" in part:
            k, v = part.split("=", 1)
            out[k.strip()] = v.strip()
    return out

class MyConsumer(AsyncWebsocketConsumer):
    async def connect(self):
        # 1) session (if you use SessionMiddlewareStack/AuthMiddlewareStack)
        lang = None
        session = self.scope.get("session")
        if session:
            lang = session.get(LANGUAGE_SESSION_KEY)

        # 2) cookie (django_language by default)
        if not lang:
            cookies = _parse_cookie(_get_header(self.scope, b"cookie"))
            lang = cookies.get(getattr(settings, "LANGUAGE_COOKIE_NAME", "django_language"))

        # 3) Accept-Language (basic: take first tag; you can implement q= weights if you want)
        if not lang:
            accept = _get_header(self.scope, b"accept-language")
            if accept:
                lang = accept.split(",")[0].strip()

        # 4) fallback
        if not lang:
            lang = settings.LANGUAGE_CODE

        translation.activate(lang)
        self.scope["LANGUAGE_CODE"] = lang  # optional: make it available like request.LANGUAGE_CODE

        await self.accept()

Notes:

  • WebSocket scopes include raw headers like accept-language. [4]
  • If you change language inside a WebSocket and want it persisted, Channels notes WebSockets won’t auto-save sessions; you must save explicitly (scope["session"].save() / asave()). [2]

If you really want to keep calling get_language_from_request()

Create a tiny adapter that builds a minimal HttpRequest-like object (META, COOKIES, optional session) from scope and pass it to get_language_from_request(). This is more work than just negotiating from scope, but it’s doable.


Sources: Channels “scope” in consumers [1], Channels sessions middleware / scope["session"] [2], Django LocaleMiddleware language selection order (cookie / Accept-Language / fallback) [3], example of accept-language present in WebSocket scope["headers"] [4].

[1] (channels.readthedocs.io)
[2] (channels.readthedocs.io)
[3] (docs.djangoproject.com)
[4] (coffeebytes.dev)

Citations:


Parse Accept-Language header according to q-values and preserve language region variants.

Current parsing strips region subtags (e.g., en-GBen) and ignores quality weights, which can still produce incorrect date formatting and defeats the header's negotiation semantics. Use get_supported_language_variant() with q-value sorting to normalize against settings.LANGUAGES while respecting browser preference weights.

Additionally, calling activate() without restoration in a Channels consumer risks language leakage across WebSocket messages and connections because Channels runs SyncConsumers in thread-reused threadpools and activate() is thread-local. Either:

  1. Wrap the language change in translation.override() context manager (auto-restores), or
  2. Restore the previous language in a finally block after calling activate().
🤖 Prompt for AI Agents
In `@adit/selective_transfer/consumers.py` around lines 74 - 90, The
_activate_user_language method currently drops region subtags and ignores
q-values; update it to parse the Accept-Language header with q-value weights,
sort candidates by q descending, and for each candidate use
django.utils.translation.get_supported_language_variant() (checking against
settings.LANGUAGES) to pick the best matching language while preserving region
variants (e.g., en-GB); then activate that language but ensure restoration to
avoid leakage by using django.utils.translation.override(...) as a context
manager around the consumer work that relies on the language or, if not
possible, capture the previous language with
django.utils.translation.get_language(), call activate(...) and always restore
it in a finally block.

@medihack
Copy link
Member

medihack commented Feb 2, 2026

/gemini review

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request correctly implements user-specific language activation for websockets by reading the Accept-Language header. The language is then activated before rendering templates, which fixes issues with date formatting. My feedback focuses on improving maintainability by reducing code duplication. The core logic of the feature is sound.

Comment on lines 184 to 185
if hasattr(self, "user_language"):
activate(self.user_language)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This language activation logic is repeated in send_query_response and build_transfer_response. This creates code duplication which can make future maintenance harder.

To improve this, you could use Django's built-in override context manager from django.utils.translation. It's designed for temporarily changing the active language.

Here's how you could refactor this method (and the others):

from django.utils.translation import override
...

@database_sync_to_async
def _build_form_error_response(self, form: SelectiveTransferJobForm, message: str) -> str:
    with override(getattr(self, "user_language", None)):
        rendered_form: str = render_crispy_form(form)
        rendered_error_message: str = render_error_message(message)
        return rendered_form + rendered_error_message

Applying this pattern to all three locations would make the code cleaner and more maintainable.

@claude
Copy link

claude bot commented Feb 2, 2026

Code Review

Thank you for this contribution! The PR addresses a legitimate issue with date formatting in WebSocket contexts. Here is my detailed feedback:

Strengths

  1. Problem Identification: Correctly identifies that Django i18n activation does not persist in WebSocket/async contexts
  2. Targeted Fix: Activates user language at key rendering points where translations occur
  3. Clean Implementation: Simple, readable approach without over-engineering

Issues and Concerns

1. Thread Safety Risk (CRITICAL)

Lines 74-89, 184-185, 293-294, 344-345

The current implementation has a critical thread safety issue. Django activate() function modifies thread-local state, but this code uses ThreadPoolExecutor (line 70) to run methods like send_query_response() in worker threads.

Problem: When activate(self.user_language) is called in send_query_response() (line 294), it sets the language for that worker thread, but the thread may be reused for other users requests, causing language bleed between users.

Recommended Fix: Use Django translation.override() context manager instead. This ensures the language is scoped to the specific operation and automatically restored afterward.

Apply the same pattern to _build_form_error_response() and build_transfer_response().

Reference: https://docs.djangoproject.com/en/5.1/topics/i18n/translation/#switching-translation-in-a-threaded-application

2. Fragile hasattr() Checks

Lines 184, 293, 344

The defensive hasattr() checks are unnecessary since user_language is always set in connect() (line 67). These checks make the code harder to reason about.

Recommendation: Remove the hasattr() checks and add a type annotation at class level instead.

3. Language Parsing Oversimplification

Lines 74-89

The Accept-Language parsing is overly simplistic and ignores quality values (q-factors) that indicate preference strength.

Issues:

  • Does not validate if the language is in LANGUAGES setting
  • Example: Accept-Language: de-CH,fr;q=0.9,en;q=0.8 selects de, but fr (q=0.9) might be better if de is not supported

Recommended Fix: Use Django built-in get_supported_language_variant() which leverages RFC 2616-compliant parsing.

4. Missing Test Coverage

Issue: No tests added for this functionality. Given that this fixes a user-facing bug affecting a critical user flow, tests are essential.

Recommendation: Add tests to verify:

  1. Language activation from Accept-Language header
  2. Correct fallback when header is missing
  3. Thread safety
  4. Date formatting in different locales

5. Code Style Compliance

Lines 81-83

Google Python Style Guide prefers clarity over brevity. Break down the complex list comprehension into multiple steps for readability.

Summary

Severity Issue Recommendation
Critical Thread safety issue with activate() Use translation.override() context manager
Medium Language parsing does not handle q-factors Use get_supported_language_variant()
Medium No test coverage Add unit tests for language activation
Low Unnecessary hasattr() checks Remove or add type annotation
Low Code readability Break down complex comprehensions

Actionable Next Steps

  1. Replace activate() with override() context manager (critical for thread safety)
  2. Use get_supported_language_variant() for proper Accept-Language parsing
  3. Add test coverage for language activation
  4. Remove hasattr() checks or add type annotation
  5. Update method name from _activate_user_language() to _get_user_language()

Please let me know if you have questions about any of these recommendations!

@claude
Copy link

claude bot commented Feb 3, 2026

Code Review: This PR adds language detection from browser Accept-Language headers to fix date formatting. Strengths: Good test coverage, clean implementation. Main issue: Language parsing ignores q-values (quality parameters). If user sends fr;q=0.5,en;q=0.9,de the code selects fr even though de has highest priority. Recommendation: Use django.utils.translation.get_language_from_request() for proper q-value handling. Also consider adding integration tests for actual date formatting and type hints for user_language attribute. Overall: Approve with minor changes required.

@claude
Copy link

claude bot commented Feb 3, 2026

Detailed Review Points:

  1. Language Priority Bug (consumers.py:84-86): The Accept-Language header supports q-values to indicate preference strength. Example: Accept-Language: fr;q=0.5, en;q=0.9, de means prefer de (q=1.0 implicit) over en (q=0.9) over fr (q=0.5). Current code takes first language regardless of q-value.

  2. Test Coverage Gaps: While unit tests are comprehensive for parsing logic, integration tests are missing. Specifically: Does the override() context actually change date formatting? What happens with concurrent WebSocket connections using different languages?

  3. Code Style: Line 84 exceeds 100 character limit per CLAUDE.md conventions. Consider splitting the list comprehension.

  4. Type Hints: Add user_language: str at class level for better type safety with pyright.

  5. Defensive Programming: getattr(self, user_language, settings.LANGUAGE_CODE) is used in 3 places but user_language is always set in connect(). Consider using self.user_language directly or document why fallback is needed.

@claude
Copy link

claude bot commented Feb 3, 2026

Suggested Fix for Language Priority:

Instead of manually parsing Accept-Language, consider using Django's built-in function:

from django.utils.translation import get_language_from_request

def _activate_user_language(self) -> str:
    class FakeRequest:
        def __init__(self, headers_dict):
            accept_lang = headers_dict.get(b'accept-language', b'').decode('utf-8')
            self.META = {'HTTP_ACCEPT_LANGUAGE': accept_lang}
    
    headers = dict(self.scope.get('headers', []))
    fake_req = FakeRequest(headers)
    return get_language_from_request(fake_req, check_path=False)

This properly handles q-values, language variants, and fallback to LANGUAGE_CODE automatically.

Security Note: No security concerns identified. Input sanitization is proper, Django's translation system handles exceptions well, and language selection is limited to supported variants.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants