969 strings, an instance default and a per-person choice, and no half-done corner: the admin prose is translated too. Design and reasoning: LLeMbas.wiki/Translations. KEYED BY THE ENGLISH SENTENCE A missing entry renders the key, which is the English -- so an untranslated string looks as it always did, an English instance is byte-for-byte 1.6.0, and a half-finished catalogue is a half-translated page rather than a page of dotted key names. The cost is that editing an English sentence orphans its translation silently, which is what tests/test_translations.py asserts in both directions. No gettext: .po -> .mo is a build step and this project does not have one. A JINJA GLOBAL, AND THEREFORE A CONTEXTVAR `t()` is a global for the reason `brand` already documents -- render() is bypassed by 25 TemplateResponse calls and 8 get_template().render() calls, the latter being the SSE frames, which have no Request at all. A global is bound once at import and the language is per person, so the active language is a ContextVar set per request. 🚨 `get_current_user` had to become `async def`. FastAPI runs a sync dependency in a threadpool, and anyio copies the context in and discards it on the way out -- so the language was set where nothing could see it and every page rendered in the instance's language whatever anybody had chosen, with no error anywhere. NOT TRANSLATED, ON PURPOSE Everything a model reads: the 60 prompt fragments, and the dates in harness.py, schedule/runner.py and schedule/compile.py. Only `i18n.stamp` is localised, and only where a person reads it -- with the *format* translatable as well as the words, because "26. septembra 2026" is a different pattern rather than the same one with different words in it. A process locale is not an option: global, not thread-safe, two people's pages at once. A second fragment telling models to answer in the reader's language was written during this work and removed: `core.style` has said it since long before, and test_an_empty_override_turns_a_fragment_off caught the duplicate. THE BULK PASS 1213 sites wrapped by a one-off script that only touched patterns it could not misread. It got three wrong in a way that mattered -- `t('…')` inside `attr="…"` where the sentence held an apostrophe, closing the Jinja string and 500ing two pages whose partials no test renders. tests/test_translations.py now compiles all 110 templates. It also wrapped the product's own name, an SSH key header, a keystroke hint and an example URL, all taken back out: a string is not translatable just because it is a string. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
138 lines
4.7 KiB
Python
138 lines
4.7 KiB
Python
"""Shared FastAPI dependencies: database sessions and the current user."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Iterator
|
|
from typing import Annotated
|
|
|
|
from fastapi import Depends, HTTPException, Request, status
|
|
from fastapi.responses import RedirectResponse
|
|
from sqlalchemy.orm import Session as DBSession
|
|
from starlette.requests import HTTPConnection
|
|
|
|
from lembas.db.models import User
|
|
from lembas.db.session import get_session_factory
|
|
from lembas.security.sessions import COOKIE_NAME, resolve_session
|
|
from lembas.web import i18n
|
|
|
|
|
|
def get_db() -> Iterator[DBSession]:
|
|
"""One database session per request, always closed."""
|
|
session = get_session_factory()()
|
|
try:
|
|
yield session
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
Db = Annotated[DBSession, Depends(get_db)]
|
|
|
|
|
|
async def get_current_user(conn: HTTPConnection, db: Db) -> User | None:
|
|
"""Resolve the session cookie to a user, or None when signed out.
|
|
|
|
⚠ `async def`, and that is load-bearing rather than tidy. FastAPI runs a
|
|
*sync* dependency in a threadpool, and `i18n.activate` below sets a
|
|
`ContextVar` -- which anyio copies **into** the thread and discards on the way
|
|
out, so the language was set in a context nothing else could see and every
|
|
page rendered in English however anybody's preference was stored. An async
|
|
dependency is awaited in the request's own task, where the value survives to
|
|
the render.
|
|
|
|
What it costs is one indexed SELECT on the event loop rather than in a
|
|
thread, which is what every route in this application already does with its
|
|
session.
|
|
|
|
Cached on the connection's state so several dependencies in one request do
|
|
not each hit the sessions table.
|
|
|
|
`HTTPConnection` rather than `Request` because the terminal panel is a
|
|
WebSocket, and FastAPI injects a `WebSocket` there -- annotating this
|
|
`Request` fails at *connect* time rather than at import, so it would pass
|
|
every smoke test and break in a browser. `HTTPConnection` is the base of
|
|
both and carries the cookies and the state either way.
|
|
"""
|
|
cached = getattr(conn.state, "user", None)
|
|
if cached is not None:
|
|
return cached
|
|
user = resolve_session(db, conn.cookies.get(COOKIE_NAME))
|
|
conn.state.user = user
|
|
# The language this request renders in, set here because this is where the
|
|
# person is already known -- no second session and no second cookie read. A
|
|
# request that never resolves a user keeps whatever `LanguageMiddleware` set,
|
|
# which is the instance default.
|
|
i18n.activate(i18n.for_user(user))
|
|
return user
|
|
|
|
|
|
CurrentUser = Annotated[User | None, Depends(get_current_user)]
|
|
|
|
|
|
class RedirectToLogin(HTTPException):
|
|
"""Signals "not signed in" so the exception handler can redirect a browser.
|
|
|
|
Raised instead of returning a response because dependencies cannot return
|
|
one. lembas.main turns this into a 303 for page loads and an HX-Redirect
|
|
header for HTMX requests, so a partial swap never renders a login form
|
|
inside the chat pane.
|
|
"""
|
|
|
|
def __init__(self, next_url: str = "/") -> None:
|
|
super().__init__(status_code=status.HTTP_401_UNAUTHORIZED, detail="Sign in required")
|
|
self.next_url = next_url
|
|
|
|
|
|
def require_user(request: Request, user: CurrentUser) -> User:
|
|
if user is None:
|
|
raise RedirectToLogin(next_url=request.url.path)
|
|
return user
|
|
|
|
|
|
RequiredUser = Annotated[User, Depends(require_user)]
|
|
|
|
|
|
def require_admin(user: RequiredUser) -> User:
|
|
if not user.is_admin:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="This area is restricted to administrators.",
|
|
)
|
|
return user
|
|
|
|
|
|
AdminUser = Annotated[User, Depends(require_admin)]
|
|
|
|
|
|
def require_permission(key: str):
|
|
"""Dependency factory guarding a route behind a named permission.
|
|
|
|
@router.post("", dependencies=[Depends(require_permission("chat.create"))])
|
|
|
|
Administrators always pass; see lembas.security.permissions for why.
|
|
"""
|
|
|
|
def guard(db: Db, user: RequiredUser) -> User:
|
|
from lembas.security import permissions
|
|
|
|
if not permissions.has(db, user, key):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="You do not have permission to do that.",
|
|
)
|
|
return user
|
|
|
|
return guard
|
|
|
|
|
|
def is_htmx(request: Request) -> bool:
|
|
return request.headers.get("HX-Request") == "true"
|
|
|
|
|
|
def login_redirect(next_url: str = "/") -> RedirectResponse:
|
|
target = "/auth/login"
|
|
if next_url and next_url not in ("/", "/auth/login"):
|
|
from urllib.parse import quote
|
|
|
|
target = f"{target}?next={quote(next_url, safe='')}"
|
|
return RedirectResponse(target, status_code=status.HTTP_303_SEE_OTHER)
|