Request logging: - One line per request from Flask hooks instead of the LoggingMiddleware. It now also covers static files, unknown URLs, 500 errors and the responses of the CSRF and auth middlewares. In debug mode, where Flask skips after_request on exceptions, the status is logged as 500. - All fields are logged as key=value. - The session is logged as a short SHA-256 prefix instead of the raw id. - In debug mode, the request lines of the Werkzeug dev server are filtered out, since the app logs requests itself. Translations: - TranslationHandler keeps the language in flask.g instead of a class attribute, so concurrent requests cannot overwrite each other's language. - Translating or rendering a template without a language raises LanguageNotSet. Requests that do not pass the I18NMiddleware are logged with lang=None. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
94 lines
2.8 KiB
Python
94 lines
2.8 KiB
Python
import json
|
|
import os
|
|
import re
|
|
|
|
from flask import g, has_request_context
|
|
|
|
from max.exceptions import LanguageNotSet
|
|
|
|
ACCEPT_LANGUAGE_HEADER_REGEX = re.compile(r"([a-zA-Z]{2,3})(-[a-zA-Z\-]+)?")
|
|
|
|
|
|
def t(*args, **kwargs):
|
|
return TranslationHandler.translate(*args, **kwargs)
|
|
|
|
|
|
class TranslationHandler:
|
|
translations = {}
|
|
language_available = {"en": True}
|
|
always_reload_translations = False
|
|
|
|
@classmethod
|
|
def init(cls, app):
|
|
cls.app = app
|
|
cls.always_reload_translations = app.debug
|
|
app.context_processor(lambda: dict(t=t, language=cls.get_assured_language()))
|
|
|
|
@classmethod
|
|
def is_language_available(cls, language):
|
|
if language not in cls.language_available:
|
|
cls.language_available[language] = os.path.exists(
|
|
cls.get_language_filepath(language)
|
|
)
|
|
return cls.language_available[language]
|
|
|
|
@classmethod
|
|
def get_language(cls):
|
|
"""The language of the current request, None if it was not set"""
|
|
if not has_request_context():
|
|
return None
|
|
return g.get("language")
|
|
|
|
@classmethod
|
|
def get_assured_language(cls):
|
|
"""The language of the current request, which must be set"""
|
|
language = cls.get_language()
|
|
if language is None:
|
|
raise LanguageNotSet()
|
|
return language
|
|
|
|
@classmethod
|
|
def set_language(cls, language):
|
|
if not cls.is_language_available(language):
|
|
cls.app.logger.info(f"Cannot find language {language}. Default to en.")
|
|
language = "en"
|
|
|
|
g.language = language
|
|
if language != "en" and (
|
|
cls.always_reload_translations or language not in cls.translations
|
|
):
|
|
cls.translations[language] = json.load(
|
|
open(cls.get_language_filepath(language), "r")
|
|
)
|
|
|
|
@classmethod
|
|
def translate(cls, string, **interpolation):
|
|
language = cls.get_assured_language()
|
|
if language == "en":
|
|
return string.format(**interpolation)
|
|
|
|
if string not in cls.translations[language]:
|
|
cls.app.logger.warning(f'Unknown translation to {language} for "{string}"')
|
|
return string.format(**interpolation)
|
|
|
|
return cls.translations[language][string].format(**interpolation)
|
|
|
|
@classmethod
|
|
def parse_language_header(cls, header):
|
|
if not header:
|
|
return None
|
|
|
|
if "*" in header:
|
|
return None
|
|
|
|
languages = [match[0] for match in ACCEPT_LANGUAGE_HEADER_REGEX.findall(header)]
|
|
# take the first matching one
|
|
for language in languages:
|
|
if cls.is_language_available(language):
|
|
return language
|
|
return None
|
|
|
|
@classmethod
|
|
def get_language_filepath(cls, language):
|
|
return os.path.join(cls.app.root_path, f"private_static/i18n/{language}.json")
|