From 95f1798908bb3e669b34bf207c06b9ec66d4cd1b Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 9 Mar 2026 23:04:14 +0000
Subject: [PATCH 001/108] Initial plan
From 7685b787f2893d7a0ddd65d0b39c87bd6116b089 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 9 Mar 2026 23:04:32 +0000
Subject: [PATCH 002/108] Initial plan
From 9ee3249146e4b78961ca07482926018f8aa84bdc Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 9 Mar 2026 23:06:22 +0000
Subject: [PATCH 003/108] Initial plan
From 341dad643fe18f06f784950cf560d3b80a040cd9 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 9 Mar 2026 23:06:40 +0000
Subject: [PATCH 004/108] Initial plan
From 3afd406c599a295d72c8af895641754ffebbf293 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 9 Mar 2026 23:13:25 +0000
Subject: [PATCH 005/108] Initial plan
From b4131e0d19dd7ab5b083a5b58a41ace36590f43f Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 9 Mar 2026 23:14:47 +0000
Subject: [PATCH 006/108] Initial plan
From 73295cad33ac87ee67585915d408fd4ecd9e6d66 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 9 Mar 2026 23:15:51 +0000
Subject: [PATCH 007/108] Initial plan
From 35db9f88de4b956b0a3c97c09f026bde4c55e667 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 9 Mar 2026 23:18:18 +0000
Subject: [PATCH 008/108] feat(audit): add comprehensive audit logging with
SIEM integration
- Add AuditLog model with append-only design (timestamp, user, action, resource, IP, details, severity)
- Add audit_service.py with record/query helpers and SIEM forwarding (Syslog RFC 5424, HTTP/webhook)
- Add /api/audit-logs REST endpoints with filtering and pagination
- Add /admin/audit-logs viewer UI with real-time filters
- Add SIEM config settings (syslog, HTTP for Splunk HEC/Logstash/Grafana Loki)
- Add Alembic migration 027_add_audit_logs
- Add navigation link in admin menu
- Add comprehensive tests (20 tests covering model, service, SIEM, API, view)
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
---
app/api/__init__.py | 2 +
app/api/audit_logs.py | 115 +++++++
app/config.py | 43 +++
app/models.py | 21 ++
app/utils/audit_service.py | 319 +++++++++++++++++++
app/utils/db_migrate.py | 1 +
app/views/__init__.py | 2 +
app/views/audit_logs.py | 46 +++
frontend/templates/audit_logs.html | 222 +++++++++++++
frontend/templates/base.html | 3 +
migrations/env.py | 1 +
migrations/versions/027_add_audit_logs.py | 47 +++
tests/conftest.py | 1 +
tests/test_audit_logs.py | 365 ++++++++++++++++++++++
14 files changed, 1188 insertions(+)
create mode 100644 app/api/audit_logs.py
create mode 100644 app/utils/audit_service.py
create mode 100644 app/views/audit_logs.py
create mode 100644 frontend/templates/audit_logs.html
create mode 100644 migrations/versions/027_add_audit_logs.py
create mode 100644 tests/test_audit_logs.py
diff --git a/app/api/__init__.py b/app/api/__init__.py
index ae98cbd7..bfc6d2d0 100644
--- a/app/api/__init__.py
+++ b/app/api/__init__.py
@@ -8,6 +8,7 @@ from fastapi import APIRouter
from app.api.admin_users import router as admin_users_router
from app.api.api_tokens import router as api_tokens_router
+from app.api.audit_logs import router as audit_logs_router
from app.api.azure import router as azure_router
from app.api.backup import router as backup_router
from app.api.billing import router as billing_router
@@ -82,3 +83,4 @@ router.include_router(imap_accounts_router)
router.include_router(integrations_router)
router.include_router(notifications_router)
router.include_router(scheduled_jobs_router)
+router.include_router(audit_logs_router)
diff --git a/app/api/audit_logs.py b/app/api/audit_logs.py
new file mode 100644
index 00000000..a4ed9d10
--- /dev/null
+++ b/app/api/audit_logs.py
@@ -0,0 +1,115 @@
+"""
+Audit log REST API endpoints.
+
+Provides read-only access to the comprehensive audit log for admin users.
+Events are append-only — there are no update or delete endpoints.
+"""
+
+import logging
+from datetime import datetime
+from typing import Any
+
+from fastapi import APIRouter, Depends, Query, Request
+from sqlalchemy.orm import Session
+
+from app.auth import require_login
+from app.database import get_db
+from app.utils.audit_service import count_events, query_events
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter()
+
+
+@router.get("/audit-logs")
+@require_login
+async def list_audit_logs(
+ request: Request,
+ db: Session = Depends(get_db),
+ action: str | None = Query(None, description="Filter by action (exact match)"),
+ user: str | None = Query(None, description="Filter by username"),
+ resource_type: str | None = Query(None, description="Filter by resource type"),
+ severity: str | None = Query(None, description="Filter by severity level"),
+ since: datetime | None = Query(None, description="Only events at or after this ISO-8601 timestamp"),
+ until: datetime | None = Query(None, description="Only events at or before this ISO-8601 timestamp"),
+ limit: int = Query(50, ge=1, le=500, description="Max rows to return"),
+ offset: int = Query(0, ge=0, description="Rows to skip for pagination"),
+) -> dict[str, Any]:
+ """Return audit log entries with optional filtering and pagination.
+
+ Requires authentication. Returns events in reverse chronological order.
+ """
+ entries = query_events(
+ db,
+ action=action,
+ user=user,
+ resource_type=resource_type,
+ severity=severity,
+ since=since,
+ until=until,
+ limit=limit,
+ offset=offset,
+ )
+ total = count_events(
+ db,
+ action=action,
+ user=user,
+ resource_type=resource_type,
+ severity=severity,
+ since=since,
+ until=until,
+ )
+ return {
+ "items": [_serialize(e) for e in entries],
+ "total": total,
+ "limit": limit,
+ "offset": offset,
+ }
+
+
+@router.get("/audit-logs/actions")
+@require_login
+async def list_distinct_actions(
+ request: Request,
+ db: Session = Depends(get_db),
+) -> list[str]:
+ """Return the distinct action values present in the audit log."""
+ from app.models import AuditLog
+
+ rows = db.query(AuditLog.action).distinct().order_by(AuditLog.action).all()
+ return [r[0] for r in rows]
+
+
+@router.get("/audit-logs/users")
+@require_login
+async def list_distinct_users(
+ request: Request,
+ db: Session = Depends(get_db),
+) -> list[str]:
+ """Return the distinct user values present in the audit log."""
+ from app.models import AuditLog
+
+ rows = db.query(AuditLog.user).distinct().order_by(AuditLog.user).all()
+ return [r[0] for r in rows]
+
+
+# ------------------------------------------------------------------
+# Helpers
+# ------------------------------------------------------------------
+
+
+def _serialize(entry) -> dict[str, Any]:
+ """Convert an AuditLog row to a JSON-safe dict."""
+ import json as _json
+
+ return {
+ "id": entry.id,
+ "timestamp": entry.timestamp.isoformat() if entry.timestamp else None,
+ "user": entry.user,
+ "action": entry.action,
+ "resource_type": entry.resource_type,
+ "resource_id": entry.resource_id,
+ "ip_address": entry.ip_address,
+ "details": _json.loads(entry.details) if entry.details else None,
+ "severity": entry.severity,
+ }
diff --git a/app/config.py b/app/config.py
index ba6eeb25..51df35e0 100644
--- a/app/config.py
+++ b/app/config.py
@@ -849,6 +849,49 @@ class Settings(BaseSettings):
),
)
+ # SIEM / External Audit Log Forwarding
+ # Forward audit events to external SIEM systems for centralised monitoring.
+ audit_siem_enabled: bool = Field(
+ default=False,
+ description="Enable forwarding of audit events to an external SIEM system.",
+ )
+ audit_siem_transport: str = Field(
+ default="syslog",
+ description=(
+ "Transport used to forward audit events. "
+ "Options: 'syslog' (RFC 5424 over UDP/TCP), 'http' (JSON POST to a webhook URL, "
+ "compatible with Splunk HEC, Logstash HTTP input, Grafana Loki, etc.)."
+ ),
+ )
+ audit_siem_syslog_host: str = Field(
+ default="localhost",
+ description="Hostname or IP of the syslog receiver.",
+ )
+ audit_siem_syslog_port: int = Field(
+ default=514,
+ description="Port of the syslog receiver.",
+ )
+ audit_siem_syslog_protocol: str = Field(
+ default="udp",
+ description="Protocol for syslog transport: 'udp' or 'tcp'.",
+ )
+ audit_siem_http_url: str = Field(
+ default="",
+ description=(
+ "HTTP endpoint URL for SIEM webhook delivery. "
+ "Supports Splunk HEC (https://splunk:8088/services/collector/event), "
+ "Logstash HTTP input, Grafana Loki push API, or any JSON-accepting endpoint."
+ ),
+ )
+ audit_siem_http_token: str = Field(
+ default="",
+ description="Bearer / HEC token included in the Authorization header of SIEM HTTP requests.",
+ )
+ audit_siem_http_custom_headers: str = Field(
+ default="",
+ description="Comma-separated 'Key:Value' pairs of extra headers for SIEM HTTP requests.",
+ )
+
# UI / Appearance
ui_default_color_scheme: str = Field(
default="system",
diff --git a/app/models.py b/app/models.py
index 0cc7b53a..8e0db27d 100644
--- a/app/models.py
+++ b/app/models.py
@@ -146,6 +146,27 @@ class SettingsAuditLog(Base):
action = Column(String, nullable=False) # "update" or "delete"
+class AuditLog(Base):
+ """Comprehensive audit log for compliance tracking.
+
+ Records all significant actions: login/logout, document CRUD, settings
+ changes, and administrative operations. Rows are append-only; the API
+ and service layer never update or delete entries.
+ """
+
+ __tablename__ = "audit_logs"
+
+ id = Column(Integer, primary_key=True, index=True)
+ timestamp = Column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True)
+ user = Column(String, nullable=False, index=True) # Username or "anonymous" / "system"
+ action = Column(String, nullable=False, index=True) # e.g. "login", "document.create", "settings.update"
+ resource_type = Column(String, nullable=True, index=True) # e.g. "document", "user", "settings"
+ resource_id = Column(String, nullable=True) # ID of the affected resource
+ ip_address = Column(String, nullable=True) # Client IP address
+ details = Column(Text, nullable=True) # JSON-encoded extra context
+ severity = Column(String(16), nullable=False, server_default="info") # info / warning / error / critical
+
+
class SavedSearch(Base):
"""User-defined saved search filters for quick access to frequently used filter combinations."""
diff --git a/app/utils/audit_service.py b/app/utils/audit_service.py
new file mode 100644
index 00000000..c2b0ecd5
--- /dev/null
+++ b/app/utils/audit_service.py
@@ -0,0 +1,319 @@
+"""
+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 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).
+ 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(":")
+ headers[k.strip()] = 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)
diff --git a/app/utils/db_migrate.py b/app/utils/db_migrate.py
index f95d97f0..5d11ee36 100644
--- a/app/utils/db_migrate.py
+++ b/app/utils/db_migrate.py
@@ -32,6 +32,7 @@ _TABLE_ORDER = [
"processing_logs",
"application_settings",
"settings_audit_log",
+ "audit_logs",
"saved_searches",
"webhook_configs",
]
diff --git a/app/views/__init__.py b/app/views/__init__.py
index 5c098527..1b2b0443 100644
--- a/app/views/__init__.py
+++ b/app/views/__init__.py
@@ -6,6 +6,7 @@ from fastapi import APIRouter
from app.views.admin_users import router as admin_users_router
from app.views.api_tokens import router as api_tokens_router
+from app.views.audit_logs import router as audit_logs_router
from app.views.backup import router as backup_router
from app.views.db_wizard import router as db_wizard_router
from app.views.dropbox import router as dropbox_router
@@ -60,4 +61,5 @@ router.include_router(imap_accounts_router) # Per-user IMAP ingestion accounts
router.include_router(integrations_router) # Unified integrations dashboard
router.include_router(notifications_router) # User notification dashboard
router.include_router(scheduled_jobs_router) # Admin scheduled batch jobs
+router.include_router(audit_logs_router) # Comprehensive audit log viewer
router.include_router(help_router) # Built-in help / How-To docs
diff --git a/app/views/audit_logs.py b/app/views/audit_logs.py
new file mode 100644
index 00000000..a787fece
--- /dev/null
+++ b/app/views/audit_logs.py
@@ -0,0 +1,46 @@
+"""
+Audit log viewer UI — admin-only page with filtering and SIEM status.
+"""
+
+import logging
+
+from fastapi import Depends, HTTPException, Request, status
+from sqlalchemy.orm import Session
+
+from app.views.base import APIRouter, get_db, require_login, settings, templates
+from app.views.settings import require_admin_access
+
+logger = logging.getLogger(__name__)
+router = APIRouter()
+
+
+@router.get("/admin/audit-logs")
+@require_login
+@require_admin_access
+async def audit_logs_page(request: Request, db: Session = Depends(get_db)):
+ """Comprehensive audit log viewer with filtering controls.
+
+ Displays a chronological log of all significant actions: logins,
+ document operations, settings changes, and admin actions. The
+ actual data is fetched client-side via the ``/api/audit-logs`` JSON
+ endpoint so that filters, pagination, and live refresh work without
+ full-page reloads.
+ """
+ try:
+ siem_enabled = settings.audit_siem_enabled
+ siem_transport = settings.audit_siem_transport if siem_enabled else None
+ return templates.TemplateResponse(
+ "audit_logs.html",
+ {
+ "request": request,
+ "app_version": settings.version,
+ "siem_enabled": siem_enabled,
+ "siem_transport": siem_transport,
+ },
+ )
+ except Exception as e:
+ logger.error("Error loading audit logs page: %s", e)
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail="Failed to load audit logs page",
+ )
diff --git a/frontend/templates/audit_logs.html b/frontend/templates/audit_logs.html
new file mode 100644
index 00000000..7a015511
--- /dev/null
+++ b/frontend/templates/audit_logs.html
@@ -0,0 +1,222 @@
+{% extends "base.html" %}
+
+{% block title %}Audit Logs - DocuElevate{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+ Audit Logs
+
+
+ Comprehensive, append-only record of all significant actions.
+
+
+
+ {% if siem_enabled %}
+
+ SIEM: {{ siem_transport|upper }}
+
+ {% else %}
+
+ SIEM: Off
+
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Loading…
+
+
+
+
+
+
+
+
+ | Timestamp |
+ Severity |
+ User |
+ Action |
+ Resource |
+ IP |
+ Details |
+
+
+
+
+
+ |
+
+
+ |
+ |
+ |
+
+
+
+ |
+ |
+ |
+
+
+
+ |
+
+ No audit events recorded yet.
+ Significant actions (logins, document operations, settings changes) will appear here.
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/frontend/templates/base.html b/frontend/templates/base.html
index e4bb200a..81609f23 100644
--- a/frontend/templates/base.html
+++ b/frontend/templates/base.html
@@ -171,6 +171,9 @@
Backup & Restore
+
+ Audit Logs
+
Status
diff --git a/migrations/env.py b/migrations/env.py
index 903382a5..d421d3c7 100644
--- a/migrations/env.py
+++ b/migrations/env.py
@@ -21,6 +21,7 @@ from app.database import Base
# Ensure all models are imported so Base.metadata is populated.
from app.models import ( # noqa: F401
ApplicationSettings,
+ AuditLog,
DocumentMetadata,
FileProcessingStep,
FileRecord,
diff --git a/migrations/versions/027_add_audit_logs.py b/migrations/versions/027_add_audit_logs.py
new file mode 100644
index 00000000..5fe0280c
--- /dev/null
+++ b/migrations/versions/027_add_audit_logs.py
@@ -0,0 +1,47 @@
+"""Add audit_logs table for comprehensive compliance audit logging.
+
+Revision ID: 027_add_audit_logs
+Revises: 026_add_scheduled_jobs
+Create Date: 2026-03-09
+"""
+
+from typing import Union
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "027_add_audit_logs"
+down_revision: Union[str, None] = "026_add_scheduled_jobs"
+depends_on: Union[str, None] = None
+
+
+def upgrade() -> None:
+ """Create audit_logs table."""
+ op.create_table(
+ "audit_logs",
+ sa.Column("id", sa.Integer(), nullable=False),
+ sa.Column("timestamp", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
+ sa.Column("user", sa.String(), nullable=False),
+ sa.Column("action", sa.String(), nullable=False),
+ sa.Column("resource_type", sa.String(), nullable=True),
+ sa.Column("resource_id", sa.String(), nullable=True),
+ sa.Column("ip_address", sa.String(), nullable=True),
+ sa.Column("details", sa.Text(), nullable=True),
+ sa.Column("severity", sa.String(16), nullable=False, server_default="info"),
+ sa.PrimaryKeyConstraint("id"),
+ )
+ op.create_index("ix_audit_logs_id", "audit_logs", ["id"])
+ op.create_index("ix_audit_logs_timestamp", "audit_logs", ["timestamp"])
+ op.create_index("ix_audit_logs_user", "audit_logs", ["user"])
+ op.create_index("ix_audit_logs_action", "audit_logs", ["action"])
+ op.create_index("ix_audit_logs_resource_type", "audit_logs", ["resource_type"])
+
+
+def downgrade() -> None:
+ """Drop audit_logs table."""
+ op.drop_index("ix_audit_logs_resource_type", "audit_logs")
+ op.drop_index("ix_audit_logs_action", "audit_logs")
+ op.drop_index("ix_audit_logs_user", "audit_logs")
+ op.drop_index("ix_audit_logs_timestamp", "audit_logs")
+ op.drop_index("ix_audit_logs_id", "audit_logs")
+ op.drop_table("audit_logs")
diff --git a/tests/conftest.py b/tests/conftest.py
index ae6db91e..cce347f7 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -61,6 +61,7 @@ from app.main import app as fastapi_app # noqa: E402
# Import models to register them with SQLAlchemy Base
from app.models import ( # noqa: F401, E402
ApiToken,
+ AuditLog,
DocumentMetadata,
FileRecord,
Pipeline,
diff --git a/tests/test_audit_logs.py b/tests/test_audit_logs.py
new file mode 100644
index 00000000..1671fd47
--- /dev/null
+++ b/tests/test_audit_logs.py
@@ -0,0 +1,365 @@
+"""
+Tests for the comprehensive audit logging feature.
+
+Covers the audit service (recording, querying, SIEM forwarding),
+the REST API endpoints, and the admin viewer page.
+"""
+
+import json
+import socket
+from datetime import datetime, timezone
+from unittest.mock import MagicMock, patch
+
+import pytest
+from sqlalchemy import create_engine
+from sqlalchemy.orm import sessionmaker
+from sqlalchemy.pool import StaticPool
+
+from app.database import Base
+from app.models import AuditLog
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture()
+def audit_db():
+ """Fresh in-memory database with all tables created."""
+ engine = create_engine(
+ "sqlite:///:memory:",
+ connect_args={"check_same_thread": False},
+ poolclass=StaticPool,
+ )
+ Base.metadata.create_all(bind=engine)
+ Session = sessionmaker(bind=engine)
+ session = Session()
+ yield session
+ session.close()
+ Base.metadata.drop_all(bind=engine)
+
+
+# ---------------------------------------------------------------------------
+# Model tests
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestAuditLogModel:
+ """Verify the AuditLog ORM model."""
+
+ def test_create_minimal_entry(self, audit_db):
+ """Minimal required fields can be persisted."""
+ entry = AuditLog(user="alice", action="login")
+ audit_db.add(entry)
+ audit_db.commit()
+ audit_db.refresh(entry)
+ assert entry.id is not None
+ assert entry.user == "alice"
+ assert entry.action == "login"
+ assert entry.severity == "info" # server default
+
+ def test_create_full_entry(self, audit_db):
+ """All columns persist correctly."""
+ entry = AuditLog(
+ user="bob",
+ action="document.create",
+ resource_type="document",
+ resource_id="42",
+ ip_address="10.0.0.1",
+ details='{"filename": "invoice.pdf"}',
+ severity="warning",
+ )
+ audit_db.add(entry)
+ audit_db.commit()
+ audit_db.refresh(entry)
+ assert entry.resource_type == "document"
+ assert entry.resource_id == "42"
+ assert entry.ip_address == "10.0.0.1"
+ assert json.loads(entry.details) == {"filename": "invoice.pdf"}
+ assert entry.severity == "warning"
+
+
+# ---------------------------------------------------------------------------
+# Service tests
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestAuditService:
+ """Verify the audit_service helper functions."""
+
+ @patch("app.utils.audit_service.settings")
+ def test_record_event(self, mock_settings, audit_db):
+ """record_event persists a row and returns the entry."""
+ mock_settings.audit_siem_enabled = False
+ from app.utils.audit_service import record_event
+
+ entry = record_event(
+ audit_db,
+ action="settings.update",
+ user="admin",
+ resource_type="settings",
+ resource_id="openai_model",
+ details={"old": "gpt-4", "new": "gpt-4o"},
+ )
+ assert entry.id is not None
+ assert entry.action == "settings.update"
+ assert entry.user == "admin"
+
+ @patch("app.utils.audit_service.settings")
+ def test_query_events_no_filter(self, mock_settings, audit_db):
+ """query_events returns all events when no filter is supplied."""
+ mock_settings.audit_siem_enabled = False
+ from app.utils.audit_service import query_events, record_event
+
+ for i in range(5):
+ record_event(audit_db, action=f"action_{i}", user="sys")
+ results = query_events(audit_db)
+ assert len(results) == 5
+
+ @patch("app.utils.audit_service.settings")
+ def test_query_events_filter_action(self, mock_settings, audit_db):
+ """query_events filters by action."""
+ mock_settings.audit_siem_enabled = False
+ from app.utils.audit_service import query_events, record_event
+
+ record_event(audit_db, action="login", user="alice")
+ record_event(audit_db, action="logout", user="alice")
+ results = query_events(audit_db, action="login")
+ assert len(results) == 1
+ assert results[0].action == "login"
+
+ @patch("app.utils.audit_service.settings")
+ def test_query_events_filter_user(self, mock_settings, audit_db):
+ """query_events filters by user."""
+ mock_settings.audit_siem_enabled = False
+ from app.utils.audit_service import query_events, record_event
+
+ record_event(audit_db, action="login", user="alice")
+ record_event(audit_db, action="login", user="bob")
+ results = query_events(audit_db, user="bob")
+ assert len(results) == 1
+
+ @patch("app.utils.audit_service.settings")
+ def test_query_events_filter_severity(self, mock_settings, audit_db):
+ """query_events filters by severity."""
+ mock_settings.audit_siem_enabled = False
+ from app.utils.audit_service import query_events, record_event
+
+ record_event(audit_db, action="fail", user="sys", severity="error")
+ record_event(audit_db, action="ok", user="sys", severity="info")
+ results = query_events(audit_db, severity="error")
+ assert len(results) == 1
+ assert results[0].severity == "error"
+
+ @patch("app.utils.audit_service.settings")
+ def test_count_events(self, mock_settings, audit_db):
+ """count_events returns the correct total."""
+ mock_settings.audit_siem_enabled = False
+ from app.utils.audit_service import count_events, record_event
+
+ for _ in range(3):
+ record_event(audit_db, action="ping", user="sys")
+ assert count_events(audit_db) == 3
+ assert count_events(audit_db, action="ping") == 3
+ assert count_events(audit_db, action="pong") == 0
+
+ @patch("app.utils.audit_service.settings")
+ def test_query_events_pagination(self, mock_settings, audit_db):
+ """query_events respects limit and offset."""
+ mock_settings.audit_siem_enabled = False
+ from app.utils.audit_service import query_events, record_event
+
+ for i in range(10):
+ record_event(audit_db, action=f"a{i}", user="sys")
+ page1 = query_events(audit_db, limit=3, offset=0)
+ page2 = query_events(audit_db, limit=3, offset=3)
+ assert len(page1) == 3
+ assert len(page2) == 3
+ assert page1[0].id != page2[0].id
+
+
+# ---------------------------------------------------------------------------
+# SIEM forwarding tests
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestSIEMForwarding:
+ """Verify SIEM transport helpers."""
+
+ @patch("app.utils.audit_service.settings")
+ def test_build_siem_payload(self, mock_settings):
+ """_build_siem_payload returns a dict with all expected keys."""
+ from app.utils.audit_service import _build_siem_payload
+
+ entry = AuditLog(
+ id=1,
+ user="admin",
+ action="login",
+ resource_type="session",
+ timestamp=datetime(2026, 1, 1, tzinfo=timezone.utc),
+ severity="info",
+ )
+ payload = _build_siem_payload(entry)
+ assert payload["user"] == "admin"
+ assert payload["action"] == "login"
+ assert payload["source"] == "docuelevate"
+ assert "timestamp" in payload
+
+ @patch("app.utils.audit_service.settings")
+ @patch("app.utils.audit_service.socket")
+ def test_send_syslog_udp(self, mock_socket_mod, mock_settings):
+ """_send_syslog sends a UDP datagram to the configured host."""
+ mock_settings.audit_siem_syslog_protocol = "udp"
+ mock_settings.audit_siem_syslog_host = "127.0.0.1"
+ mock_settings.audit_siem_syslog_port = 5140
+
+ mock_sock = MagicMock()
+ mock_socket_mod.AF_INET = socket.AF_INET
+ mock_socket_mod.SOCK_DGRAM = socket.SOCK_DGRAM
+ mock_socket_mod.gethostname.return_value = "test-host"
+ mock_socket_mod.socket.return_value.__enter__ = MagicMock(return_value=mock_sock)
+ mock_socket_mod.socket.return_value.__exit__ = MagicMock(return_value=False)
+
+ from app.utils.audit_service import _send_syslog
+
+ _send_syslog({"user": "test", "action": "login", "severity": "info", "timestamp": "2026-01-01T00:00:00"})
+ mock_sock.sendto.assert_called_once()
+
+ @patch("app.utils.audit_service.settings")
+ @patch("app.utils.audit_service.httpx")
+ def test_send_http_generic(self, mock_httpx, mock_settings):
+ """_send_http POSTs JSON to a generic endpoint."""
+ mock_settings.audit_siem_http_url = "https://siem.example.com/ingest"
+ mock_settings.audit_siem_http_token = "my-token"
+ mock_settings.audit_siem_http_custom_headers = ""
+
+ mock_client = MagicMock()
+ mock_resp = MagicMock()
+ mock_resp.status_code = 200
+ mock_client.post.return_value = mock_resp
+ mock_httpx.Client.return_value.__enter__ = MagicMock(return_value=mock_client)
+ mock_httpx.Client.return_value.__exit__ = MagicMock(return_value=False)
+
+ from app.utils.audit_service import _send_http
+
+ _send_http({"user": "test", "action": "login"})
+ mock_client.post.assert_called_once()
+ call_kwargs = mock_client.post.call_args
+ assert call_kwargs.kwargs["headers"]["Authorization"] == "Bearer my-token"
+
+ @patch("app.utils.audit_service.settings")
+ @patch("app.utils.audit_service.httpx")
+ def test_send_http_splunk_hec(self, mock_httpx, mock_settings):
+ """_send_http wraps payload in Splunk HEC envelope when URL contains /services/collector."""
+ mock_settings.audit_siem_http_url = "https://splunk:8088/services/collector/event"
+ mock_settings.audit_siem_http_token = "hec-token"
+ mock_settings.audit_siem_http_custom_headers = ""
+
+ mock_client = MagicMock()
+ mock_resp = MagicMock()
+ mock_resp.status_code = 200
+ mock_client.post.return_value = mock_resp
+ mock_httpx.Client.return_value.__enter__ = MagicMock(return_value=mock_client)
+ mock_httpx.Client.return_value.__exit__ = MagicMock(return_value=False)
+
+ from app.utils.audit_service import _send_http
+
+ _send_http({"user": "test", "action": "login"})
+ call_kwargs = mock_client.post.call_args
+ body = call_kwargs.kwargs["json"]
+ assert "event" in body
+ assert body["sourcetype"] == "docuelevate:audit"
+
+
+# ---------------------------------------------------------------------------
+# API endpoint tests
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.integration
+class TestAuditLogAPI:
+ """Test /api/audit-logs REST endpoints."""
+
+ def test_list_audit_logs_empty(self, client):
+ """GET /api/audit-logs returns empty list when no events exist."""
+ resp = client.get("/api/audit-logs")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["items"] == []
+ assert data["total"] == 0
+
+ def test_list_audit_logs_with_data(self, client, db_session):
+ """GET /api/audit-logs returns recorded events."""
+ entry = AuditLog(user="tester", action="test.action", severity="info")
+ db_session.add(entry)
+ db_session.commit()
+
+ resp = client.get("/api/audit-logs")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["total"] == 1
+ assert data["items"][0]["action"] == "test.action"
+
+ def test_list_audit_logs_filter_by_action(self, client, db_session):
+ """GET /api/audit-logs?action=x filters correctly."""
+ db_session.add(AuditLog(user="a", action="login", severity="info"))
+ db_session.add(AuditLog(user="a", action="logout", severity="info"))
+ db_session.commit()
+
+ resp = client.get("/api/audit-logs?action=login")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["total"] == 1
+
+ def test_list_distinct_actions(self, client, db_session):
+ """GET /api/audit-logs/actions returns distinct action values."""
+ db_session.add(AuditLog(user="a", action="login", severity="info"))
+ db_session.add(AuditLog(user="b", action="login", severity="info"))
+ db_session.add(AuditLog(user="a", action="logout", severity="info"))
+ db_session.commit()
+
+ resp = client.get("/api/audit-logs/actions")
+ assert resp.status_code == 200
+ actions = resp.json()
+ assert set(actions) == {"login", "logout"}
+
+ def test_list_distinct_users(self, client, db_session):
+ """GET /api/audit-logs/users returns distinct user values."""
+ db_session.add(AuditLog(user="alice", action="x", severity="info"))
+ db_session.add(AuditLog(user="bob", action="x", severity="info"))
+ db_session.commit()
+
+ resp = client.get("/api/audit-logs/users")
+ assert resp.status_code == 200
+ users = resp.json()
+ assert set(users) == {"alice", "bob"}
+
+ def test_list_audit_logs_pagination(self, client, db_session):
+ """GET /api/audit-logs supports limit/offset pagination."""
+ for i in range(5):
+ db_session.add(AuditLog(user="u", action=f"a{i}", severity="info"))
+ db_session.commit()
+
+ resp = client.get("/api/audit-logs?limit=2&offset=0")
+ data = resp.json()
+ assert len(data["items"]) == 2
+ assert data["total"] == 5
+
+
+# ---------------------------------------------------------------------------
+# View tests
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.integration
+class TestAuditLogView:
+ """Test the admin audit-log viewer page."""
+
+ def test_audit_logs_page_loads(self, client):
+ """GET /admin/audit-logs returns 200 and renders the template."""
+ resp = client.get("/admin/audit-logs")
+ assert resp.status_code == 200
+ assert "Audit Logs" in resp.text
From ff76855f29343dae3eefe402a2130144c1a4291e Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 9 Mar 2026 23:28:02 +0000
Subject: [PATCH 009/108] feat(i18n): add internationalization framework with
10 languages
- Create i18n utility module (app/utils/i18n.py) with translation loading,
browser language detection, AI fallback, and l10n helpers
- Add JSON translation files for EN, DE, FR, ES, IT, PT, NL, PL, ZH, RU
- Add preferred_language column to UserProfile model with migration
- Register _() translation function as Jinja2 global
- Update base.html with translated navigation, footer, cookie notice
- Add language selector dropdown in nav bar (desktop + mobile)
- Create API endpoints for language preference (POST/GET /api/i18n/)
- Support language detection: user profile > cookie > Accept-Language > default
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
---
app/api/__init__.py | 2 +
app/api/i18n.py | 136 +++++++
app/models.py | 4 +
app/utils/i18n.py | 367 ++++++++++++++++++
app/views/base.py | 41 ++
frontend/templates/base.html | 216 +++++++----
frontend/translations/de.json | 190 +++++++++
frontend/translations/en.json | 190 +++++++++
frontend/translations/es.json | 190 +++++++++
frontend/translations/fr.json | 190 +++++++++
frontend/translations/it.json | 190 +++++++++
frontend/translations/nl.json | 190 +++++++++
frontend/translations/pl.json | 190 +++++++++
frontend/translations/pt.json | 190 +++++++++
frontend/translations/ru.json | 190 +++++++++
frontend/translations/zh.json | 190 +++++++++
.../027_add_user_language_preference.py | 28 ++
17 files changed, 2618 insertions(+), 76 deletions(-)
create mode 100644 app/api/i18n.py
create mode 100644 app/utils/i18n.py
create mode 100644 frontend/translations/de.json
create mode 100644 frontend/translations/en.json
create mode 100644 frontend/translations/es.json
create mode 100644 frontend/translations/fr.json
create mode 100644 frontend/translations/it.json
create mode 100644 frontend/translations/nl.json
create mode 100644 frontend/translations/pl.json
create mode 100644 frontend/translations/pt.json
create mode 100644 frontend/translations/ru.json
create mode 100644 frontend/translations/zh.json
create mode 100644 migrations/versions/027_add_user_language_preference.py
diff --git a/app/api/__init__.py b/app/api/__init__.py
index ae98cbd7..aec13465 100644
--- a/app/api/__init__.py
+++ b/app/api/__init__.py
@@ -17,6 +17,7 @@ from app.api.dropbox import router as dropbox_router
from app.api.duplicates import router as duplicates_router
from app.api.files import router as files_router
from app.api.google_drive import router as google_drive_router
+from app.api.i18n import router as i18n_router
from app.api.imap_accounts import router as imap_accounts_router
from app.api.integrations import router as integrations_router
from app.api.logs import router as logs_router
@@ -82,3 +83,4 @@ router.include_router(imap_accounts_router)
router.include_router(integrations_router)
router.include_router(notifications_router)
router.include_router(scheduled_jobs_router)
+router.include_router(i18n_router)
diff --git a/app/api/i18n.py b/app/api/i18n.py
new file mode 100644
index 00000000..8c59ad60
--- /dev/null
+++ b/app/api/i18n.py
@@ -0,0 +1,136 @@
+"""API endpoints for internationalization (i18n).
+
+Provides endpoints for:
+* Listing available languages
+* Getting/setting user language preference (persisted in session + cookie + DB)
+"""
+
+from __future__ import annotations
+
+import logging
+
+from fastapi import APIRouter, Depends, Request, Response
+from pydantic import BaseModel
+from sqlalchemy.orm import Session
+
+from app.database import get_db
+from app.models import UserProfile
+from app.utils.i18n import (
+ DEFAULT_LANGUAGE,
+ SUPPORTED_LANGUAGE_CODES,
+ SUPPORTED_LANGUAGES,
+ detect_language,
+)
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/api/i18n", tags=["i18n"])
+
+
+class LanguageInfo(BaseModel):
+ """Schema for a supported language."""
+
+ code: str
+ name: str
+ native: str
+ flag: str
+
+
+class LanguageListResponse(BaseModel):
+ """Response for the list-languages endpoint."""
+
+ languages: list[LanguageInfo]
+ current: str
+ default: str
+
+
+class SetLanguageRequest(BaseModel):
+ """Request body for setting the preferred language."""
+
+ language: str
+
+
+class SetLanguageResponse(BaseModel):
+ """Response after changing the language."""
+
+ language: str
+ message: str
+
+
+@router.get("/languages", response_model=LanguageListResponse)
+async def list_languages(request: Request) -> LanguageListResponse:
+ """Return all supported UI languages and the current active language."""
+ current = detect_language(request)
+ return LanguageListResponse(
+ languages=[LanguageInfo(**lang) for lang in SUPPORTED_LANGUAGES],
+ current=current,
+ default=DEFAULT_LANGUAGE,
+ )
+
+
+@router.post("/language", response_model=SetLanguageResponse)
+async def set_language(
+ body: SetLanguageRequest,
+ request: Request,
+ response: Response,
+ db: Session = Depends(get_db),
+) -> SetLanguageResponse:
+ """Set the preferred UI language.
+
+ Persists the choice in:
+ 1. The server-side session
+ 2. A ``docuelevate_lang`` cookie (30-day expiry)
+ 3. The ``UserProfile.preferred_language`` column (if authenticated)
+ """
+ lang = body.language.lower().strip()
+ if lang not in SUPPORTED_LANGUAGE_CODES:
+ lang = DEFAULT_LANGUAGE
+
+ # 1. Session
+ if hasattr(request, "session"):
+ request.session["preferred_language"] = lang
+
+ # 2. Cookie (30 days)
+ response.set_cookie(
+ key="docuelevate_lang",
+ value=lang,
+ max_age=30 * 24 * 60 * 60,
+ httponly=False,
+ samesite="lax",
+ )
+
+ # 3. Database (if user is authenticated)
+ _persist_language_to_profile(request, db, lang)
+
+ language_name = next(
+ (l["native"] for l in SUPPORTED_LANGUAGES if l["code"] == lang),
+ lang,
+ )
+ logger.info("Language preference set to '%s'", lang)
+ return SetLanguageResponse(
+ language=lang,
+ message=f"Language changed to {language_name}",
+ )
+
+
+def _persist_language_to_profile(request: Request, db: Session, lang: str) -> None:
+ """Write language preference to the UserProfile row, if the user is logged in."""
+ user_id: str | None = None
+ if hasattr(request, "session"):
+ user = request.session.get("user")
+ if isinstance(user, dict):
+ user_id = user.get("preferred_username") or user.get("email") or user.get("id")
+ elif isinstance(user, str):
+ user_id = user
+
+ if not user_id:
+ return
+
+ try:
+ profile = db.query(UserProfile).filter(UserProfile.user_id == user_id).first()
+ if profile:
+ profile.preferred_language = lang # type: ignore[attr-defined]
+ db.commit()
+ except Exception:
+ db.rollback()
+ logger.debug("Could not persist language preference for user_id=%s", user_id)
diff --git a/app/models.py b/app/models.py
index 0cc7b53a..3a1703b5 100644
--- a/app/models.py
+++ b/app/models.py
@@ -256,6 +256,10 @@ class UserProfile(Base):
preferred_destination = Column(String(50), nullable=True)
stripe_customer_id = Column(String(64), nullable=True)
+ # UI language preference for i18n (ISO 639-1 code, e.g. "en", "de", "fr")
+ # NULL means "auto-detect from browser Accept-Language header"
+ preferred_language = Column(String(10), nullable=True)
+
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
diff --git a/app/utils/i18n.py b/app/utils/i18n.py
new file mode 100644
index 00000000..c6cd9c27
--- /dev/null
+++ b/app/utils/i18n.py
@@ -0,0 +1,367 @@
+"""Internationalization (i18n) and localization (l10n) utilities.
+
+Provides a JSON-based translation system for the DocuElevate UI with:
+
+* **10 supported languages** (EN, DE, FR, ES, IT, PT, NL, PL, ZH, RU)
+* 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]] = [
+ {"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": "🇵🇹"},
+ {"code": "nl", "name": "Dutch", "native": "Nederlands", "flag": "🇳🇱"},
+ {"code": "pl", "name": "Polish", "native": "Polski", "flag": "🇵🇱"},
+ {"code": "zh", "name": "Chinese", "native": "中文", "flag": "🇨🇳"},
+ {"code": "ru", "name": "Russian", "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 part in header.split(","):
+ part = 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": ",",
+ },
+ "pl": {
+ "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
diff --git a/app/views/base.py b/app/views/base.py
index 010d4f21..fc9d5e0d 100644
--- a/app/views/base.py
+++ b/app/views/base.py
@@ -12,6 +12,14 @@ from sqlalchemy.orm import Session # noqa: F401
from app.auth import require_login # noqa: F401
from app.config import settings
from app.database import get_db # noqa: F401
+from app.utils.i18n import (
+ SUPPORTED_LANGUAGES,
+ detect_language,
+ format_date,
+ format_datetime,
+ format_number,
+ translate,
+)
# Set up Jinja2 templates
templates_dir = Path(__file__).parent.parent.parent / "frontend" / "templates"
@@ -21,6 +29,16 @@ templates = Jinja2Templates(directory=str(templates_dir))
templates.env.globals["min"] = min
templates.env.globals["max"] = max
+# ---------------------------------------------------------------------------
+# i18n Jinja2 integration
+# ---------------------------------------------------------------------------
+# The _() function is available in every template to translate UI strings.
+# Usage: {{ _("nav.dashboard") }} or {{ _("upload.max_size", size="10 MB") }}
+# The locale is automatically resolved from the request context.
+# ---------------------------------------------------------------------------
+
+templates.env.globals["supported_languages"] = SUPPORTED_LANGUAGES
+
# Customize Jinja2Templates to include app_version in all templates
original_template_response = templates.TemplateResponse
@@ -48,8 +66,31 @@ def _inject_global_context(ctx: dict) -> None:
session_user = req.session.get("user")
# When auth is disabled every visitor is effectively "logged in"
ctx.setdefault("is_logged_in", not getattr(settings, "auth_enabled", True) or session_user is not None)
+
+ # --- i18n: detect language and register template helpers ---
+ current_locale = detect_language(req)
+ ctx.setdefault("current_locale", current_locale)
+
+ def _translate(key: str, **kwargs: object) -> str:
+ return translate(key, current_locale, **kwargs)
+
+ def _format_date(value: object, short: bool = False) -> str:
+ return format_date(value, current_locale, short=short) # type: ignore[arg-type]
+
+ def _format_datetime(value: object) -> str:
+ return format_datetime(value, current_locale) # type: ignore[arg-type]
+
+ def _format_number(value: object) -> str:
+ return format_number(value, current_locale) # type: ignore[arg-type]
+
+ ctx.setdefault("_", _translate)
+ ctx.setdefault("format_date_l10n", _format_date)
+ ctx.setdefault("format_datetime_l10n", _format_datetime)
+ ctx.setdefault("format_number_l10n", _format_number)
else:
ctx.setdefault("is_logged_in", not getattr(settings, "auth_enabled", True))
+ ctx.setdefault("current_locale", "en")
+ ctx.setdefault("_", lambda key, **kw: translate(key, "en", **kw))
def template_response_with_version(*args, **kwargs):
diff --git a/frontend/templates/base.html b/frontend/templates/base.html
index e4bb200a..1ccdff9f 100644
--- a/frontend/templates/base.html
+++ b/frontend/templates/base.html
@@ -1,5 +1,5 @@
-
+
{% block title %}DocuElevate{% endblock %}
@@ -37,10 +37,10 @@
data-multi-user="{{ 'true' if multi_user_enabled else 'false' }}"
data-allow-signup="{{ 'true' if allow_signup else 'false' }}">
- Skip to main content
+ {{ _("nav.skip_to_content") }}
-