feat: unify language handling across the application
- Updated the language configuration to use a single source of truth from `DEFAULT_LANGUAGE` for the bot, API, and Mini App, eliminating auto-detection from user settings. - Refactored the `get_lang` function to always return `DEFAULT_LANGUAGE`, ensuring consistent language usage throughout the application. - Modified the handling of language in various components, including API responses and UI elements, to reflect the new language management approach. - Enhanced documentation and comments to clarify the changes in language handling. - Added unit tests to verify the new language handling behavior and ensure coverage for the updated functionality.
This commit is contained in:
@@ -5,7 +5,6 @@ import re
|
||||
from datetime import date, timedelta
|
||||
|
||||
import duty_teller.config as config
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from fastapi import Depends, FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
@@ -58,22 +57,67 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
|
||||
class NoCacheStaticMiddleware(BaseHTTPMiddleware):
|
||||
"""Set Cache-Control for /app/*.js and /app/*.html so WebView gets fresh JS (i18n, etc.)."""
|
||||
class NoCacheStaticMiddleware:
|
||||
"""
|
||||
Raw ASGI middleware: Cache-Control: no-store for all /app and /app/* static files;
|
||||
Vary: Accept-Language on all responses so reverse proxies do not serve one user's response to another.
|
||||
"""
|
||||
|
||||
async def dispatch(self, request, call_next):
|
||||
response = await call_next(request)
|
||||
path = request.url.path
|
||||
if path.startswith("/app/") and (
|
||||
path.endswith(".js") or path.endswith(".html")
|
||||
):
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
def __init__(self, app, **kwargs):
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope["type"] != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
path = scope.get("path", "")
|
||||
is_app_path = path == "/app" or path.startswith("/app/")
|
||||
|
||||
async def send_wrapper(message):
|
||||
if message["type"] == "http.response.start":
|
||||
headers = list(message.get("headers", []))
|
||||
header_names = {h[0].lower(): i for i, h in enumerate(headers)}
|
||||
if is_app_path:
|
||||
cache_control = (b"cache-control", b"no-store")
|
||||
if b"cache-control" in header_names:
|
||||
headers[header_names[b"cache-control"]] = cache_control
|
||||
else:
|
||||
headers.append(cache_control)
|
||||
vary_val = b"Accept-Language"
|
||||
if b"vary" in header_names:
|
||||
idx = header_names[b"vary"]
|
||||
existing = headers[idx][1]
|
||||
tokens = [p.strip() for p in existing.split(b",")]
|
||||
if vary_val not in tokens:
|
||||
headers[idx] = (b"vary", existing + b", " + vary_val)
|
||||
else:
|
||||
headers.append((b"vary", vary_val))
|
||||
message = {"type": "http.response.start", "status": message["status"], "headers": headers}
|
||||
await send(message)
|
||||
|
||||
await self.app(scope, receive, send_wrapper)
|
||||
|
||||
|
||||
app.add_middleware(NoCacheStaticMiddleware)
|
||||
|
||||
|
||||
@app.get(
|
||||
"/app/config.js",
|
||||
summary="Mini App config (language)",
|
||||
description="Returns JS that sets window.__DT_LANG from DEFAULT_LANGUAGE. Loaded before main.js.",
|
||||
)
|
||||
def app_config_js() -> Response:
|
||||
"""Return JS assigning window.__DT_LANG for the webapp. No caching."""
|
||||
lang = config.DEFAULT_LANGUAGE
|
||||
body = f'window.__DT_LANG = "{lang}";'
|
||||
return Response(
|
||||
content=body,
|
||||
media_type="application/javascript; charset=utf-8",
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
@app.get(
|
||||
"/api/duties",
|
||||
response_model=list[DutyWithUser],
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""FastAPI dependencies: DB session, Miniapp auth (initData/allowlist), date validation."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Annotated, Generator
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, Query, Request
|
||||
@@ -17,42 +16,17 @@ from duty_teller.db.repository import (
|
||||
from duty_teller.db.schemas import DUTY_EVENT_TYPES, DutyWithUser
|
||||
from duty_teller.db.session import session_scope
|
||||
from duty_teller.i18n import t
|
||||
from duty_teller.i18n.lang import normalize_lang
|
||||
from duty_teller.utils.dates import DateRangeValidationError, validate_date_range
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Extract primary language code from first Accept-Language tag (e.g. "ru-RU" -> "ru").
|
||||
_ACCEPT_LANG_CODE_RE = re.compile(r"^([a-zA-Z]{2,3})(?:-|;|,|\s|$)")
|
||||
|
||||
|
||||
def _parse_first_language_code(header: str | None) -> str | None:
|
||||
"""Extract the first language code from Accept-Language header.
|
||||
|
||||
Args:
|
||||
header: Raw Accept-Language value (e.g. "ru-RU,ru;q=0.9,en;q=0.8").
|
||||
|
||||
Returns:
|
||||
Two- or three-letter code (e.g. 'ru', 'en') or None if missing/invalid.
|
||||
"""
|
||||
if not header or not header.strip():
|
||||
return None
|
||||
first = header.strip().split(",")[0].strip()
|
||||
m = _ACCEPT_LANG_CODE_RE.match(first)
|
||||
return m.group(1).lower() if m else None
|
||||
|
||||
|
||||
def _lang_from_accept_language(header: str | None) -> str:
|
||||
"""Normalize Accept-Language header to 'ru' or 'en'; fallback to config.DEFAULT_LANGUAGE.
|
||||
"""Return the application language: always config.DEFAULT_LANGUAGE.
|
||||
|
||||
Args:
|
||||
header: Raw Accept-Language header value (e.g. "ru-RU,ru;q=0.9,en;q=0.8").
|
||||
|
||||
Returns:
|
||||
'ru' or 'en'.
|
||||
The header argument is kept for backward compatibility but is ignored.
|
||||
The whole deployment uses a single language from DEFAULT_LANGUAGE.
|
||||
"""
|
||||
code = _parse_first_language_code(header)
|
||||
return normalize_lang(code if code is not None else config.DEFAULT_LANGUAGE)
|
||||
return config.DEFAULT_LANGUAGE
|
||||
|
||||
|
||||
def _auth_error_detail(auth_reason: str, lang: str) -> str:
|
||||
|
||||
@@ -6,7 +6,7 @@ import json
|
||||
import time
|
||||
from urllib.parse import unquote
|
||||
|
||||
from duty_teller.i18n.lang import normalize_lang
|
||||
import duty_teller.config as config
|
||||
|
||||
# Telegram algorithm: https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app
|
||||
# Data-check string: sorted key=value with URL-decoded values, then HMAC-SHA256(WebAppData, token) as secret.
|
||||
@@ -48,12 +48,12 @@ def validate_init_data_with_reason(
|
||||
Returns:
|
||||
Tuple (telegram_user_id, username, reason, lang). reason is one of: "ok",
|
||||
"empty", "no_hash", "hash_mismatch", "auth_date_expired", "no_user",
|
||||
"user_invalid", "no_user_id". lang is from user.language_code normalized
|
||||
to 'ru' or 'en'; 'en' when no user. On success: (user.id, username or None,
|
||||
"ok", lang).
|
||||
"user_invalid", "no_user_id". lang is always config.DEFAULT_LANGUAGE.
|
||||
On success: (user.id, username or None, "ok", lang).
|
||||
"""
|
||||
lang = config.DEFAULT_LANGUAGE
|
||||
if not init_data or not bot_token:
|
||||
return (None, None, "empty", "en")
|
||||
return (None, None, "empty", lang)
|
||||
init_data = init_data.strip()
|
||||
params = {}
|
||||
for part in init_data.split("&"):
|
||||
@@ -65,7 +65,7 @@ def validate_init_data_with_reason(
|
||||
params[key] = value
|
||||
hash_val = params.pop("hash", None)
|
||||
if not hash_val:
|
||||
return (None, None, "no_hash", "en")
|
||||
return (None, None, "no_hash", lang)
|
||||
data_pairs = sorted(params.items())
|
||||
# Data-check string: key=value with URL-decoded values (per Telegram example)
|
||||
data_string = "\n".join(f"{k}={unquote(v)}" for k, v in data_pairs)
|
||||
@@ -81,27 +81,26 @@ def validate_init_data_with_reason(
|
||||
digestmod=hashlib.sha256,
|
||||
).hexdigest()
|
||||
if not hmac.compare_digest(computed.lower(), hash_val.lower()):
|
||||
return (None, None, "hash_mismatch", "en")
|
||||
return (None, None, "hash_mismatch", lang)
|
||||
if max_age_seconds is not None and max_age_seconds > 0:
|
||||
auth_date_raw = params.get("auth_date")
|
||||
if not auth_date_raw:
|
||||
return (None, None, "auth_date_expired", "en")
|
||||
return (None, None, "auth_date_expired", lang)
|
||||
try:
|
||||
auth_date = int(float(auth_date_raw))
|
||||
except (ValueError, TypeError):
|
||||
return (None, None, "auth_date_expired", "en")
|
||||
return (None, None, "auth_date_expired", lang)
|
||||
if time.time() - auth_date > max_age_seconds:
|
||||
return (None, None, "auth_date_expired", "en")
|
||||
return (None, None, "auth_date_expired", lang)
|
||||
user_raw = params.get("user")
|
||||
if not user_raw:
|
||||
return (None, None, "no_user", "en")
|
||||
return (None, None, "no_user", lang)
|
||||
try:
|
||||
user = json.loads(unquote(user_raw))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return (None, None, "user_invalid", "en")
|
||||
return (None, None, "user_invalid", lang)
|
||||
if not isinstance(user, dict):
|
||||
return (None, None, "user_invalid", "en")
|
||||
lang = normalize_lang(user.get("language_code"))
|
||||
return (None, None, "user_invalid", lang)
|
||||
raw_id = user.get("id")
|
||||
if raw_id is None:
|
||||
return (None, None, "no_user_id", lang)
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"""get_lang and t(): language from Telegram user, translate by key with fallback to en."""
|
||||
"""get_lang and t(): language from config (DEFAULT_LANGUAGE), translate by key with fallback to en."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import duty_teller.config as config
|
||||
from duty_teller.i18n.lang import normalize_lang
|
||||
from duty_teller.i18n.messages import MESSAGES
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -12,13 +11,12 @@ if TYPE_CHECKING:
|
||||
|
||||
def get_lang(user: "User | None") -> str:
|
||||
"""
|
||||
Normalize Telegram user language to 'ru' or 'en'.
|
||||
Uses normalize_lang for user.language_code; when user is None or has no
|
||||
language_code, returns config.DEFAULT_LANGUAGE.
|
||||
Return the application language: always config.DEFAULT_LANGUAGE.
|
||||
|
||||
The user argument is kept for backward compatibility but is ignored.
|
||||
The whole deployment uses a single language from DEFAULT_LANGUAGE.
|
||||
"""
|
||||
if user is None or not getattr(user, "language_code", None):
|
||||
return config.DEFAULT_LANGUAGE
|
||||
return normalize_lang(user.language_code)
|
||||
return config.DEFAULT_LANGUAGE
|
||||
|
||||
|
||||
def t(lang: str, key: str, **kwargs: str) -> str:
|
||||
|
||||
Reference in New Issue
Block a user