fix(migrations): re-chain mobile devices migration against main and restore merge conflicts

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-11 20:43:29 +00:00
parent 4eb04bd7f6
commit 1d5eee4281
17 changed files with 1513 additions and 4 deletions
+331
View File
@@ -0,0 +1,331 @@
"""
Comprehensive audit-event service for DocuElevate.
Provides helpers to **record** audit events (append-only database writes)
and to optionally **forward** them to external SIEM systems.
Supported SIEM transports:
* **Syslog** RFC 5424 structured-data messages over UDP or TCP.
* **HTTP** JSON POST payloads compatible with Splunk HEC, Logstash
HTTP input, Grafana Loki push API, and any generic webhook endpoint.
"""
import json
import logging
import re
import socket
import threading
from datetime import datetime, timezone
from typing import Any
import httpx
from fastapi import Request
from sqlalchemy.orm import Session
from app.config import settings
from app.middleware.audit_log import get_client_ip, get_username
from app.models import AuditLog
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Public helpers
# ---------------------------------------------------------------------------
def record_event(
db: Session,
*,
action: str,
user: str = "system",
resource_type: str | None = None,
resource_id: str | None = None,
ip_address: str | None = None,
details: dict[str, Any] | None = None,
severity: str = "info",
) -> AuditLog:
"""Persist an audit event and optionally forward it to SIEM.
Args:
db: Active SQLAlchemy session.
action: Short action identifier (e.g. ``"login"``, ``"document.create"``).
user: Username performing the action.
resource_type: Category of the affected resource (``"document"``, ``"user"`` …).
resource_id: Identifier of the affected resource.
ip_address: Client IP address (``None`` when not applicable).
details: Arbitrary key/value context serialised as JSON.
severity: One of ``info``, ``warning``, ``error``, ``critical``.
Returns:
The newly created :class:`AuditLog` row.
"""
details_json = json.dumps(details, default=str) if details else None
entry = AuditLog(
user=user,
action=action,
resource_type=resource_type,
resource_id=str(resource_id) if resource_id is not None else None,
ip_address=ip_address,
details=details_json,
severity=severity,
)
db.add(entry)
db.commit()
db.refresh(entry)
# Fire-and-forget SIEM forwarding in a background thread so we never
# block the request path.
if settings.audit_siem_enabled:
payload = _build_siem_payload(entry)
thread = threading.Thread(target=_forward_to_siem, args=(payload,), daemon=True)
thread.start()
return entry
def record_event_from_request(
db: Session,
request: Request,
*,
action: str,
resource_type: str | None = None,
resource_id: str | None = None,
details: dict[str, Any] | None = None,
severity: str = "info",
) -> AuditLog:
"""Convenience wrapper that extracts user and IP from a :class:`Request`.
Args:
db: Active SQLAlchemy session.
request: The current HTTP request.
action: Short action identifier.
resource_type: Category of the affected resource.
resource_id: Identifier of the affected resource.
details: Arbitrary key/value context serialised as JSON.
severity: One of ``info``, ``warning``, ``error``, ``critical``.
Returns:
The newly created :class:`AuditLog` row.
"""
return record_event(
db,
action=action,
user=get_username(request),
resource_type=resource_type,
resource_id=resource_id,
ip_address=get_client_ip(request),
details=details,
severity=severity,
)
def query_events(
db: Session,
*,
action: str | None = None,
user: str | None = None,
resource_type: str | None = None,
severity: str | None = None,
since: datetime | None = None,
until: datetime | None = None,
limit: int = 200,
offset: int = 0,
) -> list[AuditLog]:
"""Query audit log entries with optional filtering.
Args:
db: Active SQLAlchemy session.
action: Filter by action string (exact match).
user: Filter by username (exact match).
resource_type: Filter by resource type (exact match).
severity: Filter by severity level (exact match).
since: Only events at or after this timestamp.
until: Only events at or before this timestamp.
limit: Maximum number of rows to return.
offset: Number of rows to skip (for pagination).
Returns:
List of :class:`AuditLog` rows ordered by *timestamp descending*.
"""
q = db.query(AuditLog)
if action:
q = q.filter(AuditLog.action == action)
if user:
q = q.filter(AuditLog.user == user)
if resource_type:
q = q.filter(AuditLog.resource_type == resource_type)
if severity:
q = q.filter(AuditLog.severity == severity)
if since:
q = q.filter(AuditLog.timestamp >= since)
if until:
q = q.filter(AuditLog.timestamp <= until)
return q.order_by(AuditLog.timestamp.desc()).offset(offset).limit(limit).all()
def count_events(
db: Session,
*,
action: str | None = None,
user: str | None = None,
resource_type: str | None = None,
severity: str | None = None,
since: datetime | None = None,
until: datetime | None = None,
) -> int:
"""Return the total count of events matching the given filters.
Args:
db: Active SQLAlchemy session.
action: Filter by action string.
user: Filter by username.
resource_type: Filter by resource type.
severity: Filter by severity level.
since: Only events at or after this timestamp.
until: Only events at or before this timestamp.
Returns:
Integer count.
"""
q = db.query(AuditLog)
if action:
q = q.filter(AuditLog.action == action)
if user:
q = q.filter(AuditLog.user == user)
if resource_type:
q = q.filter(AuditLog.resource_type == resource_type)
if severity:
q = q.filter(AuditLog.severity == severity)
if since:
q = q.filter(AuditLog.timestamp >= since)
if until:
q = q.filter(AuditLog.timestamp <= until)
return q.count()
# ---------------------------------------------------------------------------
# SIEM forwarding internals
# ---------------------------------------------------------------------------
_SYSLOG_FACILITY_LOCAL0 = 16
_SYSLOG_SEVERITY_MAP = {
"info": 6,
"warning": 4,
"error": 3,
"critical": 2,
}
def _build_siem_payload(entry: AuditLog) -> dict[str, Any]:
"""Convert an :class:`AuditLog` row into a plain dict for SIEM delivery."""
ts = entry.timestamp if entry.timestamp else datetime.now(timezone.utc)
return {
"id": entry.id,
"timestamp": ts.isoformat(),
"user": entry.user,
"action": entry.action,
"resource_type": entry.resource_type,
"resource_id": entry.resource_id,
"ip_address": entry.ip_address,
"details": entry.details,
"severity": entry.severity,
"source": "docuelevate",
}
def _forward_to_siem(payload: dict[str, Any]) -> None:
"""Route a SIEM payload to the configured transport."""
transport = settings.audit_siem_transport.lower()
try:
if transport == "syslog":
_send_syslog(payload)
elif transport == "http":
_send_http(payload)
else:
logger.warning("Unknown SIEM transport %r; skipping forwarding", transport)
except Exception:
logger.exception("Failed to forward audit event to SIEM (%s)", transport)
def _send_syslog(payload: dict[str, Any]) -> None:
"""Send a RFC 5424 syslog message to the configured receiver."""
severity_num = _SYSLOG_SEVERITY_MAP.get(payload.get("severity", "info"), 6)
priority = _SYSLOG_FACILITY_LOCAL0 * 8 + severity_num
ts = payload.get("timestamp", datetime.now(timezone.utc).isoformat())
hostname = socket.gethostname()
app_name = "docuelevate"
msg_id = payload.get("action", "-")
# Structured data (SD) element with key event fields.
sd = (
f'[docuelevate@0 user="{payload.get("user", "-")}" '
f'action="{payload.get("action", "-")}" '
f'resource_type="{payload.get("resource_type", "-")}" '
f'resource_id="{payload.get("resource_id", "-")}" '
f'ip="{payload.get("ip_address", "-")}"]'
)
message = json.dumps(payload, default=str)
syslog_msg = f"<{priority}>1 {ts} {hostname} {app_name} - {msg_id} {sd} {message}"
proto = settings.audit_siem_syslog_protocol.lower()
host = settings.audit_siem_syslog_host
port = settings.audit_siem_syslog_port
if proto == "tcp":
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.settimeout(5)
sock.connect((host, port))
sock.sendall(syslog_msg.encode("utf-8"))
else:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
sock.settimeout(5)
sock.sendto(syslog_msg.encode("utf-8"), (host, port))
logger.debug("Syslog audit event sent to %s:%s (%s)", host, port, proto)
def _send_http(payload: dict[str, Any]) -> None:
"""POST a JSON audit event to the configured HTTP endpoint."""
url = settings.audit_siem_http_url
if not url:
logger.warning("SIEM HTTP URL not configured; skipping HTTP forwarding")
return
headers: dict[str, str] = {"Content-Type": "application/json"}
token = settings.audit_siem_http_token
if token:
headers["Authorization"] = f"Bearer {token}"
# Parse custom headers (comma-separated "Key:Value" pairs).
# Reject headers that could override security-critical ones already set,
# and validate that header names contain only RFC 7230 token characters.
_PROTECTED_HEADERS = {"authorization", "content-type", "host"}
_VALID_HEADER_NAME = re.compile(r"^[A-Za-z0-9!#$%&'*+\-.^_`|~]+$")
raw_custom = settings.audit_siem_http_custom_headers
if raw_custom:
for raw_pair in raw_custom.split(","):
pair = raw_pair.strip()
if ":" in pair:
k, _, v = pair.partition(":")
name = k.strip()
if not name or not _VALID_HEADER_NAME.match(name):
logger.warning("Skipping invalid SIEM custom header name: %r", name)
continue
if name.lower() in _PROTECTED_HEADERS:
logger.warning("Skipping protected SIEM custom header: %r", name)
continue
headers[name] = v.strip()
# Wrap in Splunk HEC-style envelope when URL contains ``/services/collector``.
body: dict[str, Any]
if "/services/collector" in url:
body = {"event": payload, "sourcetype": "docuelevate:audit", "source": "docuelevate"}
else:
body = payload
with httpx.Client(timeout=10) as client:
resp = client.post(url, json=body, headers=headers)
resp.raise_for_status()
logger.debug("HTTP audit event forwarded to %s (status %s)", url, resp.status_code)
+2
View File
@@ -32,8 +32,10 @@ _TABLE_ORDER = [
"processing_logs",
"application_settings",
"settings_audit_log",
"audit_logs",
"saved_searches",
"webhook_configs",
"shared_links",
]
+539
View File
@@ -0,0 +1,539 @@
"""Internationalization (i18n) and localization (l10n) utilities.
Provides a JSON-based translation system for the DocuElevate UI with:
* **31 supported languages** covering all major European languages plus ZH
* Browser ``Accept-Language`` detection with cookie & user-profile persistence
* AI-powered fallback translation via the configured LLM provider
* Locale-aware date, number, and file-size formatting helpers
* Jinja2 integration via a ``_()`` global function
Language resolution order:
1. User profile ``preferred_language`` (persisted in DB)
2. ``docuelevate_lang`` cookie
3. ``Accept-Language`` HTTP header
4. Default (``en``)
"""
from __future__ import annotations
import json
import logging
from datetime import date, datetime
from functools import lru_cache
from pathlib import Path
from typing import Any
from starlette.requests import Request
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Supported languages (ordered by priority)
# ---------------------------------------------------------------------------
SUPPORTED_LANGUAGES: list[dict[str, str]] = [
# --- Tier 1: Primary European languages ---
{"code": "en", "name": "English", "native": "English", "flag": "🇬🇧"},
{"code": "de", "name": "German", "native": "Deutsch", "flag": "🇩🇪"},
{"code": "fr", "name": "French", "native": "Français", "flag": "🇫🇷"},
{"code": "es", "name": "Spanish", "native": "Español", "flag": "🇪🇸"},
{"code": "it", "name": "Italian", "native": "Italiano", "flag": "🇮🇹"},
{"code": "pt", "name": "Portuguese", "native": "Português", "flag": "🇵🇹"},
# --- Tier 2: Western & Northern European ---
{"code": "nl", "name": "Dutch", "native": "Nederlands", "flag": "🇳🇱"},
{"code": "nb", "name": "Norwegian", "native": "Norsk", "flag": "🇳🇴"},
{"code": "da", "name": "Danish", "native": "Dansk", "flag": "🇩🇰"},
{"code": "sv", "name": "Swedish", "native": "Svenska", "flag": "🇸🇪"},
{"code": "fi", "name": "Finnish", "native": "Suomi", "flag": "🇫🇮"},
{"code": "is", "name": "Icelandic", "native": "Íslenska", "flag": "🇮🇸"},
{"code": "ga", "name": "Irish", "native": "Gaeilge", "flag": "🇮🇪"},
{"code": "lb", "name": "Luxembourgish", "native": "Lëtzebuergesch", "flag": "🇱🇺"},
{"code": "ca", "name": "Catalan", "native": "Català", "flag": "🏴"},
# --- Tier 3: Central & Eastern European ---
{"code": "pl", "name": "Polish", "native": "Polski", "flag": "🇵🇱"},
{"code": "cs", "name": "Czech", "native": "Čeština", "flag": "🇨🇿"},
{"code": "sk", "name": "Slovak", "native": "Slovenčina", "flag": "🇸🇰"},
{"code": "hu", "name": "Hungarian", "native": "Magyar", "flag": "🇭🇺"},
{"code": "sl", "name": "Slovenian", "native": "Slovenščina", "flag": "🇸🇮"},
{"code": "hr", "name": "Croatian", "native": "Hrvatski", "flag": "🇭🇷"},
{"code": "ro", "name": "Romanian", "native": "Română", "flag": "🇷🇴"},
{"code": "bg", "name": "Bulgarian", "native": "Български", "flag": "🇧🇬"},
{"code": "el", "name": "Greek", "native": "Ελληνικά", "flag": "🇬🇷"},
{"code": "et", "name": "Estonian", "native": "Eesti", "flag": "🇪🇪"},
{"code": "lv", "name": "Latvian", "native": "Latviešu", "flag": "🇱🇻"},
{"code": "lt", "name": "Lithuanian", "native": "Lietuvių", "flag": "🇱🇹"},
# --- Tier 4: Non-EU European & Other ---
{"code": "tr", "name": "Turkish", "native": "Türkçe", "flag": "🇹🇷"},
{"code": "uk", "name": "Ukrainian", "native": "Українська", "flag": "🇺🇦"},
{"code": "ru", "name": "Russian", "native": "Русский", "flag": "🇷🇺"},
{"code": "zh", "name": "Chinese", "native": "中文", "flag": "🇨🇳"},
]
SUPPORTED_LANGUAGE_CODES: set[str] = {lang["code"] for lang in SUPPORTED_LANGUAGES}
DEFAULT_LANGUAGE = "en"
# ---------------------------------------------------------------------------
# Translation file loading
# ---------------------------------------------------------------------------
_TRANSLATIONS_DIR = Path(__file__).resolve().parent.parent.parent / "frontend" / "translations"
_translation_cache: dict[str, dict[str, str]] = {}
def _load_translations(locale: str) -> dict[str, str]:
"""Load the translation JSON file for *locale*, with caching."""
if locale in _translation_cache:
return _translation_cache[locale]
filepath = _TRANSLATIONS_DIR / f"{locale}.json"
if not filepath.is_file():
logger.warning("Translation file not found for locale '%s'", locale)
_translation_cache[locale] = {}
return {}
try:
data: dict[str, str] = json.loads(filepath.read_text(encoding="utf-8"))
_translation_cache[locale] = data
return data
except (json.JSONDecodeError, OSError):
logger.exception("Failed to load translations for '%s'", locale)
_translation_cache[locale] = {}
return {}
def reload_translations() -> None:
"""Clear the translation cache so files are re-read on next access."""
_translation_cache.clear()
# ---------------------------------------------------------------------------
# Core translation function
# ---------------------------------------------------------------------------
def translate(key: str, locale: str | None = None, **kwargs: Any) -> str:
"""Return the translated string for *key* in *locale*.
Falls back through:
1. Requested *locale*
2. English (``en``)
3. The raw key itself (to keep the UI functional)
Positional placeholders ``{0}``, ``{1}`` or named placeholders
``{name}`` in the translated string are interpolated via *kwargs*.
"""
locale = locale if locale and locale in SUPPORTED_LANGUAGE_CODES else DEFAULT_LANGUAGE
translations = _load_translations(locale)
value = translations.get(key)
# Fallback to English
if value is None and locale != DEFAULT_LANGUAGE:
en_translations = _load_translations(DEFAULT_LANGUAGE)
value = en_translations.get(key)
# Fallback to key itself
if value is None:
value = key
if kwargs:
try:
value = value.format(**kwargs)
except (KeyError, IndexError):
pass # Return unformatted string rather than crash
return value
# ---------------------------------------------------------------------------
# AI fallback translation (best-effort, non-blocking)
# ---------------------------------------------------------------------------
_ai_translation_cache: dict[tuple[str, str], str] = {}
def translate_with_ai_fallback(text: str, target_locale: str) -> str:
"""Translate *text* using the configured AI provider as a fallback.
Returns the original *text* unchanged when:
* The target locale is English (source language)
* The AI provider is unavailable or returns an error
* The translation has already been cached
Results are cached in-memory for the lifetime of the process.
"""
if target_locale == DEFAULT_LANGUAGE or target_locale not in SUPPORTED_LANGUAGE_CODES:
return text
cache_key = (text, target_locale)
if cache_key in _ai_translation_cache:
return _ai_translation_cache[cache_key]
target_name = next(
(lang["name"] for lang in SUPPORTED_LANGUAGES if lang["code"] == target_locale),
target_locale,
)
try:
from litellm import completion # type: ignore[import-untyped]
from app.config import settings
model = getattr(settings, "ai_model", None) or getattr(settings, "openai_model", "gpt-4o-mini")
response = completion(
model=model,
messages=[
{
"role": "system",
"content": (
f"You are a professional translator. Translate the following UI text "
f"from English to {target_name}. Return ONLY the translated text, "
f"nothing else. Keep any HTML tags, placeholders like {{name}}, "
f"and special characters intact."
),
},
{"role": "user", "content": text},
],
max_tokens=256,
temperature=0.1,
)
translated = response.choices[0].message.content.strip()
_ai_translation_cache[cache_key] = translated
return translated
except Exception:
logger.debug("AI fallback translation failed for '%s'%s", text[:50], target_locale)
return text
# ---------------------------------------------------------------------------
# Language detection
# ---------------------------------------------------------------------------
def detect_language(request: Request) -> str:
"""Determine the preferred UI language from the request context.
Resolution order:
1. ``preferred_language`` stored in the user session
2. ``docuelevate_lang`` cookie
3. ``Accept-Language`` HTTP header (best match)
4. Default → ``en``
"""
# 1. User session preference
if hasattr(request, "session"):
session_lang = request.session.get("preferred_language")
if session_lang and session_lang in SUPPORTED_LANGUAGE_CODES:
return session_lang
# 2. Cookie
cookie_lang = request.cookies.get("docuelevate_lang")
if cookie_lang and cookie_lang in SUPPORTED_LANGUAGE_CODES:
return cookie_lang
# 3. Accept-Language header
accept = request.headers.get("accept-language", "")
lang = _parse_accept_language(accept)
if lang:
return lang
return DEFAULT_LANGUAGE
def _parse_accept_language(header: str) -> str | None:
"""Extract the best matching language from an ``Accept-Language`` header.
Parses quality values and returns the highest-priority match among
:data:`SUPPORTED_LANGUAGE_CODES`, or ``None`` if nothing matches.
"""
if not header:
return None
entries: list[tuple[float, str]] = []
for raw_part in header.split(","):
part = raw_part.strip()
if not part:
continue
if ";q=" in part:
lang_tag, _, q_str = part.partition(";q=")
try:
quality = float(q_str.strip())
except ValueError:
quality = 0.0
else:
lang_tag = part
quality = 1.0
entries.append((quality, lang_tag.strip().lower()))
# Sort by quality descending
entries.sort(key=lambda e: e[0], reverse=True)
for _quality, tag in entries:
# Try exact match first (e.g., "de", "zh")
code = tag.split("-")[0]
if code in SUPPORTED_LANGUAGE_CODES:
return code
return None
# ---------------------------------------------------------------------------
# Localization helpers (l10n)
# ---------------------------------------------------------------------------
# Locale-specific formatting rules for date/number display
_LOCALE_FORMATS: dict[str, dict[str, Any]] = {
"en": {
"date": "%B %d, %Y",
"date_short": "%m/%d/%Y",
"datetime": "%B %d, %Y %I:%M %p",
"thousands_sep": ",",
"decimal_sep": ".",
},
"de": {
"date": "%d. %B %Y",
"date_short": "%d.%m.%Y",
"datetime": "%d. %B %Y %H:%M",
"thousands_sep": ".",
"decimal_sep": ",",
},
"fr": {
"date": "%d %B %Y",
"date_short": "%d/%m/%Y",
"datetime": "%d %B %Y %H:%M",
"thousands_sep": "\u202f",
"decimal_sep": ",",
},
"es": {
"date": "%d de %B de %Y",
"date_short": "%d/%m/%Y",
"datetime": "%d de %B de %Y %H:%M",
"thousands_sep": ".",
"decimal_sep": ",",
},
"it": {
"date": "%d %B %Y",
"date_short": "%d/%m/%Y",
"datetime": "%d %B %Y %H:%M",
"thousands_sep": ".",
"decimal_sep": ",",
},
"pt": {
"date": "%d de %B de %Y",
"date_short": "%d/%m/%Y",
"datetime": "%d de %B de %Y %H:%M",
"thousands_sep": ".",
"decimal_sep": ",",
},
"nl": {
"date": "%d %B %Y",
"date_short": "%d-%m-%Y",
"datetime": "%d %B %Y %H:%M",
"thousands_sep": ".",
"decimal_sep": ",",
},
"nb": {
"date": "%d. %B %Y",
"date_short": "%d.%m.%Y",
"datetime": "%d. %B %Y %H:%M",
"thousands_sep": "\u00a0",
"decimal_sep": ",",
},
"da": {
"date": "%d. %B %Y",
"date_short": "%d.%m.%Y",
"datetime": "%d. %B %Y %H:%M",
"thousands_sep": ".",
"decimal_sep": ",",
},
"sv": {
"date": "%d %B %Y",
"date_short": "%Y-%m-%d",
"datetime": "%d %B %Y %H:%M",
"thousands_sep": "\u00a0",
"decimal_sep": ",",
},
"fi": {
"date": "%d. %B %Y",
"date_short": "%d.%m.%Y",
"datetime": "%d. %B %Y %H:%M",
"thousands_sep": "\u00a0",
"decimal_sep": ",",
},
"is": {
"date": "%d. %B %Y",
"date_short": "%d.%m.%Y",
"datetime": "%d. %B %Y %H:%M",
"thousands_sep": ".",
"decimal_sep": ",",
},
"ga": {
"date": "%d %B %Y",
"date_short": "%d/%m/%Y",
"datetime": "%d %B %Y %H:%M",
"thousands_sep": ",",
"decimal_sep": ".",
},
"lb": {
"date": "%d. %B %Y",
"date_short": "%d.%m.%Y",
"datetime": "%d. %B %Y %H:%M",
"thousands_sep": ".",
"decimal_sep": ",",
},
"ca": {
"date": "%d de %B de %Y",
"date_short": "%d/%m/%Y",
"datetime": "%d de %B de %Y %H:%M",
"thousands_sep": ".",
"decimal_sep": ",",
},
"pl": {
"date": "%d %B %Y",
"date_short": "%d.%m.%Y",
"datetime": "%d %B %Y %H:%M",
"thousands_sep": "\u00a0",
"decimal_sep": ",",
},
"cs": {
"date": "%d. %B %Y",
"date_short": "%d.%m.%Y",
"datetime": "%d. %B %Y %H:%M",
"thousands_sep": "\u00a0",
"decimal_sep": ",",
},
"sk": {
"date": "%d. %B %Y",
"date_short": "%d.%m.%Y",
"datetime": "%d. %B %Y %H:%M",
"thousands_sep": "\u00a0",
"decimal_sep": ",",
},
"hu": {
"date": "%Y. %B %d.",
"date_short": "%Y.%m.%d.",
"datetime": "%Y. %B %d. %H:%M",
"thousands_sep": "\u00a0",
"decimal_sep": ",",
},
"sl": {
"date": "%d. %B %Y",
"date_short": "%d.%m.%Y",
"datetime": "%d. %B %Y %H:%M",
"thousands_sep": ".",
"decimal_sep": ",",
},
"hr": {
"date": "%d. %B %Y.",
"date_short": "%d.%m.%Y.",
"datetime": "%d. %B %Y. %H:%M",
"thousands_sep": ".",
"decimal_sep": ",",
},
"ro": {
"date": "%d %B %Y",
"date_short": "%d.%m.%Y",
"datetime": "%d %B %Y %H:%M",
"thousands_sep": ".",
"decimal_sep": ",",
},
"bg": {
"date": "%d %B %Y",
"date_short": "%d.%m.%Y",
"datetime": "%d %B %Y %H:%M",
"thousands_sep": "\u00a0",
"decimal_sep": ",",
},
"el": {
"date": "%d %B %Y",
"date_short": "%d/%m/%Y",
"datetime": "%d %B %Y %H:%M",
"thousands_sep": ".",
"decimal_sep": ",",
},
"et": {
"date": "%d. %B %Y",
"date_short": "%d.%m.%Y",
"datetime": "%d. %B %Y %H:%M",
"thousands_sep": "\u00a0",
"decimal_sep": ",",
},
"lv": {
"date": "%Y. gada %d. %B",
"date_short": "%d.%m.%Y.",
"datetime": "%Y. gada %d. %B %H:%M",
"thousands_sep": "\u00a0",
"decimal_sep": ",",
},
"lt": {
"date": "%Y m. %B %d d.",
"date_short": "%Y-%m-%d",
"datetime": "%Y m. %B %d d. %H:%M",
"thousands_sep": "\u00a0",
"decimal_sep": ",",
},
"tr": {
"date": "%d %B %Y",
"date_short": "%d.%m.%Y",
"datetime": "%d %B %Y %H:%M",
"thousands_sep": ".",
"decimal_sep": ",",
},
"uk": {
"date": "%d %B %Y",
"date_short": "%d.%m.%Y",
"datetime": "%d %B %Y %H:%M",
"thousands_sep": "\u00a0",
"decimal_sep": ",",
},
"zh": {
"date": "%Y年%m月%d",
"date_short": "%Y/%m/%d",
"datetime": "%Y年%m月%d%H:%M",
"thousands_sep": ",",
"decimal_sep": ".",
},
"ru": {
"date": "%d %B %Y",
"date_short": "%d.%m.%Y",
"datetime": "%d %B %Y %H:%M",
"thousands_sep": "\u00a0",
"decimal_sep": ",",
},
}
def format_date(value: date | datetime | None, locale: str = DEFAULT_LANGUAGE, short: bool = False) -> str:
"""Format a date/datetime value according to the locale conventions."""
if value is None:
return ""
fmt_key = "date_short" if short else "date"
fmt = _LOCALE_FORMATS.get(locale, _LOCALE_FORMATS[DEFAULT_LANGUAGE])[fmt_key]
return value.strftime(fmt)
def format_datetime(value: datetime | None, locale: str = DEFAULT_LANGUAGE) -> str:
"""Format a datetime value according to the locale conventions."""
if value is None:
return ""
fmt = _LOCALE_FORMATS.get(locale, _LOCALE_FORMATS[DEFAULT_LANGUAGE])["datetime"]
return value.strftime(fmt)
def format_number(value: int | float, locale: str = DEFAULT_LANGUAGE) -> str:
"""Format a number with locale-appropriate thousand separators."""
lf = _LOCALE_FORMATS.get(locale, _LOCALE_FORMATS[DEFAULT_LANGUAGE])
if isinstance(value, float):
int_part, _, dec_part = f"{value:,.2f}".partition(".")
formatted_int = int_part.replace(",", lf["thousands_sep"])
return f"{formatted_int}{lf['decimal_sep']}{dec_part}"
return f"{value:,}".replace(",", lf["thousands_sep"])
@lru_cache(maxsize=32)
def get_language_info(code: str) -> dict[str, str] | None:
"""Return the metadata dict for a supported language code, or ``None``."""
for lang in SUPPORTED_LANGUAGES:
if lang["code"] == code:
return lang
return None
+72
View File
@@ -2065,6 +2065,78 @@ SETTING_METADATA = {
"required": False,
"restart_required": True,
},
"audit_siem_enabled": {
"category": "Security",
"description": "Enable forwarding of audit events to an external SIEM system (Syslog, Splunk, Logstash, etc.).",
"type": "boolean",
"sensitive": False,
"required": False,
"restart_required": False,
},
"audit_siem_transport": {
"category": "Security",
"description": (
"Transport used to forward audit events. 'syslog' sends RFC 5424 messages over UDP/TCP. "
"'http' sends JSON POST payloads to a webhook URL (Splunk HEC, Logstash, Grafana Loki, etc.)."
),
"type": "string",
"sensitive": False,
"required": False,
"restart_required": False,
"options": ["syslog", "http"],
},
"audit_siem_syslog_host": {
"category": "Security",
"description": "Hostname or IP of the syslog receiver.",
"type": "string",
"sensitive": False,
"required": False,
"restart_required": False,
},
"audit_siem_syslog_port": {
"category": "Security",
"description": "Port of the syslog receiver. Default: 514.",
"type": "integer",
"sensitive": False,
"required": False,
"restart_required": False,
},
"audit_siem_syslog_protocol": {
"category": "Security",
"description": "Protocol for syslog transport: 'udp' or 'tcp'. Default: udp.",
"type": "string",
"sensitive": False,
"required": False,
"restart_required": False,
"options": ["udp", "tcp"],
},
"audit_siem_http_url": {
"category": "Security",
"description": (
"HTTP endpoint URL for SIEM webhook delivery. Supports Splunk HEC, "
"Logstash HTTP input, Grafana Loki push API, or any JSON-accepting endpoint."
),
"type": "string",
"sensitive": False,
"required": False,
"restart_required": False,
},
"audit_siem_http_token": {
"category": "Security",
"description": "Bearer / HEC token included in the Authorization header of SIEM HTTP requests.",
"type": "string",
"sensitive": True,
"required": False,
"restart_required": False,
},
"audit_siem_http_custom_headers": {
"category": "Security",
"description": "Comma-separated 'Key:Value' pairs of extra headers for SIEM HTTP requests.",
"type": "string",
"sensitive": False,
"required": False,
"restart_required": False,
},
# Rate Limiting
"rate_limiting_enabled": {
"category": "Security",