feat(api): let users read and set their preferred language - #582
Conversation
e64b2f9 to
25c83ce
Compare
160b507 to
b94564b
Compare
| router = APIRouter() | ||
|
|
||
|
|
||
| @router.get("", response_model=SupportedLanguages, dependencies=[Depends(get_current_user)]) |
There was a problem hiding this comment.
The auth gate blocks the second consumer named in this module docstring: if the static-UI translation layer reads this endpoint, the login, register and password-reset pages cannot fetch the list because there is no token yet. The payload is a constant, non-sensitive allowlist, so either drop the gate or the frontend needs a duplicated client-side list, which is what this endpoint exists to avoid.
There was a problem hiding this comment.
Gate dropped in 39d2c19. You were right that the docstring named a consumer the code blocked; the payload is a compile-time constant, so there was nothing to protect.
routes.py—dependencies=[Depends(get_current_user)]removed, and the docstring now states why it is public rather than leaving the next reader to wonder.test_requires_authentication→test_does_not_require_authentication, asserting200with no token. Written first and watched fail withassert 401 == 200before the gate came off.- docs: document the preferred language setting #583 is stacked on this branch and documented the
Bearerheader; updated there inf0004b3. The/user/meexamples keep theirs. - PR body's test step and Notes updated.
One thing worth flagging since it is a first for this codebase: every other endpoint under /api/v1 outside auth is gated, by get_current_user or a permission. This is now the only deliberate exception, so it is called out explicitly in the module docstring and the PR notes rather than left to be discovered.
generated.ts is unchanged — the security removal does not alter the typed surface, so the test.frontend.api staleness check stays green.
| from sparkth.schemas import UserBase | ||
|
|
||
|
|
||
| class User(UserBase): |
There was a problem hiding this comment.
This move contradicts the rule this same PR adds to architectural_patterns.md: models shared across domains stay in the root sparkth/schemas.py. User is shared, auth.py imports it for the register and login responses, so auth.py now imports the user package, which executes user/init.py and user/routes.py at import time. Any future import of auth.py from the user routes becomes a circular import. Either keep the model in sparkth/schemas.py or document why it is an exception.
There was a problem hiding this comment.
Moved back to sparkth/schemas.py in 39d2c19. The contradiction was real: User is shared by the rule's own definition — auth.py returns it from register and login, user/routes.py from /user/me — so it belongs in the root alongside UserBase and Token.
class Userback insparkth/schemas.py;auth.pyanduser/routes.pyboth import it from there.UserLanguageUpdatestays inuser/schemas.py— nothing outside those routes sends it, so it is genuinely owned by the package. That is the split the rule describes.- Verified the chain is gone:
auth.UserSchema.__module__is nowsparkth.schemas, so importingauthno longer executesuser/__init__.pyand thereforeuser/routes.py.
On the cycle: confirmed latent rather than live — user/routes.py imports fastapi, sqlmodel, core.models.user, lib.auth, lib.db and lib.permissions, but not api.v1.auth, so nothing was broken today. It would have failed the first time someone imported in that direction, which is the worst kind of landmine.
Also amended the rule itself, since "shared" was doing a lot of unstated work: it now names User, says ownership is decided by who imports the model rather than which routes feel closest to it, and spells out the __init__.py → routes.py execution that makes the pull-into-a-package version circular. Same class of mistake should not be inviting next time.
Full suite green: 1726 passed, 3 skipped; ruff, format and mypy strict clean.
b94564b to
be207de
Compare
be207de to
02a3db5
Compare
The frontend picker and the static-UI translation layer both need this list; serving it keeps the allowlist defined once instead of duplicated client-side. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PATCH /user/me validates the tag against the supported-language allowlist in the request model, so an unsupported value is a 422 before any domain logic runs. GET returns the raw column, null included, so the frontend can tell a user who never chose from one who chose English. PATCH loads the row it is about to write with session.get rather than mutating the injected principal. get_current_user resolves the same request-scoped session, so in production that is an identity-map hit; the guard exists because a principal that is not attached to the request session would otherwise make commit() a silent no-op while the endpoint still answered 200. Such a principal is now a 404. updated_at carries a default_factory but no onupdate, so the write bumps it explicitly. The test double for get_current_user takes the same session dependency the real one does, so a value one request writes is visible to a later request on the same client. That rewrote _override_current_user — the shared helper the three pre-existing is_admin tests also call — from a detached make_transient snapshot to a live select(User) on the request session; those three tests are themselves left unmodified and still pass. Also covers the PATCH auth gate (401 unauthenticated), that a PATCH only ever writes to the caller's own row, that a principal with no row is refused rather than handed a 200 for a write that never happened, and that a successful PATCH advances updated_at. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
openapi.json is a gitignored build artifact and is not committed; only the generated TypeScript client changes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Records when an api/v1 module is a single file and when it is a package with routes.py and schemas.py, now that user/ and language/ join permissions/ and whitelist/ in the second shape while five modules remain single files. Covers what belongs in each file, why __init__.py re-exporting router is a deliberate exception to the avoid-re-exports rule, that domain exception -> HTTP mappings register there, and that route paths come from the prefix in api.py so converting a file to a package never moves a URL. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
02a3db5 to
84eadd9
Compare
…ma shared Two review findings on #582. The languages endpoint was gated, but its own docstring names the static-UI translation layer as a consumer, and that layer renders the login, register and password-reset pages, which have no token yet. Gating it there would force the frontend to carry the duplicate allowlist this endpoint exists to remove. What it serves is a compile-time constant, so there is no user data, no database read, and nothing to enumerate. It is now the only endpoint outside auth that is unauthenticated, and says so. The User response model moved into sparkth/api/v1/user/schemas.py, contradicting the rule this same branch adds: models shared across domains stay in the root sparkth/schemas.py. User is shared — auth returns it from register and login — and the move made auth import the user package, which executes user/__init__.py and so user/routes.py, leaving any later import of auth from those routes a circular one. Moved back, with UserLanguageUpdate staying put since nothing outside those routes sends it. The rule now names User and explains what ownership is decided by, so the next reader does not repeat the move. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follows the endpoint dropping its auth gate on #582: documenting a Bearer header the endpoint no longer wants would send readers looking for a token before they can render a sign-in page in the right language. The /user/me examples keep theirs — those do need one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follows the endpoint dropping its auth gate on #582: documenting a Bearer header the endpoint no longer wants would send readers looking for a token before they can render a sign-in page in the right language. The /user/me examples keep theirs — those do need one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: document the preferred language setting Covers the supported list, how to read and set a user's choice, what null means, and what happens to a stored tag once its language leaves the allowlist. The guide is explicit that generated content does not consume the preference yet, so nothing here promises behaviour the platform does not have. The DEFAULT_LANGUAGE reference entry ships with the setting itself, so this change is guide-only. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * docs: the language list is fetched without a token Follows the endpoint dropping its auth gate on #582: documenting a Bearer header the endpoint no longer wants would send readers looking for a token before they can render a sign-in page in the right language. The /user/me examples keep theirs — those do need one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
#582 and #583 landed on main as squash commits while this branch carried their original commits, so git saw the same files added on both sides and flagged six conflicts where there was one real question: which copy is current. main's is. Its copies include the review fixes made after this branch was cut — the languages endpoint dropped its auth gate, and the User schema moved back to the root sparkth/schemas.py — so every file this branch does not own is resolved to main's version, and the branch keeps only what it exists to change: the api_router assembly moving from sparkth/api/v1/api.py into the package __init__. Two of those needed more than a side chosen: - api.py was modified in main (#582 registered the language router) and deleted here. Kept deleted; the nine include_router calls in __init__.py were verified identical to main's, prefixes and tags included, so the deletion drops no route. - architectural_patterns.md is a union: main's amended shared-models rule, which now names User, plus this branch's rename of api.py to the package __init__. The re-export bullet is reworded because the rename leaves two different __init__.py roles in one section. auth.py auto-merged without a conflict and silently kept this branch's pre-fix import, dropping main's. Restored from main. Verified by diffing the whole tree against main: no file outside the four this branch owns differs from it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
What
Exposes the preferred-language setting over the API so clients can list the supported languages and set or clear a user's choice.
Changes
GET /api/v1/languages, returning the supported languages and the platform default. Unauthenticated — the static-UI translation layer has to render the login, register and password-reset pages, which have no token yetPATCH /api/v1/user/meto set or clear the preference, and addlanguageto the user schemaHow to Test
uv run pytest tests/api/v1/test_language.py tests/api/v1/test_user.py -q— 18 tests pass.curl http://localhost:7727/api/v1/languagesreturns the three languages and"default": "en"with no token.curl -X PATCH .../api/v1/user/me -d '{"language":"es"}'returns 200;GET /api/v1/user/methen reports"language": "es".-d '{"language":"en-US"}'returns 422 and leaves the column untouched — matching is exact and case-sensitive.-d '{"language":null}'clears the preference back tonull.Notes
No migration.
GET /mereturns the raw stored value,nullincluded, so a client can tell "never chose" from "chose English"; resolving to the default isresolve_language's job.GET /api/v1/languagesis the only endpoint outsideauththat requires no token. It serves a compile-time constant — the supported-language table and the platform default — so there is no user data, no database read, and nothing to enumerate. Gating it would force the frontend to carry the duplicate allowlist the endpoint exists to remove.languageis a required field on the request body, so an explicitnullclears the preference while omitting the field is a 422 rather than a silent no-op.Generated content does not consult this preference yet — that arrives with prompt injection in a later change.
This description was written with the assistance of an LLM (Claude).