Pablan, as it stands
Self-hosted knowledge management for SMEs: a split-screen Markdown editor whose sections an LLM refines while you write, and RAG question answering over the documents that result. FastAPI + Postgres/pgvector on the back, SvelteKit on the front, everything OpenAI-compatible and self-hostable. Squashed into a single commit; the development history stays local. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CA43ZJda8Rbp2hKXNy8f6b
This commit is contained in:
co-authored by
Claude Opus 5
parent
68d3a43191
commit
784b76baf7
@@ -0,0 +1,136 @@
|
||||
"""Self-service account actions.
|
||||
|
||||
Separate from `auth.py` (login/logout/me) and from `admin.py`: this is what
|
||||
a user may change about themselves.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.deps import get_current_auth_session, get_current_user
|
||||
from app.auth.passwords import hash_password, verify_password
|
||||
from app.auth.sessions import revoke_user_sessions
|
||||
from app.db import get_db
|
||||
from app.errors import ApiError
|
||||
from app.models import AuthSession, Document, DocumentStatus, Template, User
|
||||
|
||||
router = APIRouter(prefix="/account", tags=["account"])
|
||||
logger = logging.getLogger("pablan.account")
|
||||
|
||||
|
||||
class LocalePreference(BaseModel):
|
||||
"""The languages the interface ships in — the frontend bundles must
|
||||
cover exactly these."""
|
||||
|
||||
# null = follow the browser's Accept-Language again.
|
||||
locale: Literal["de", "en"] | None = None
|
||||
|
||||
|
||||
class PasswordChange(BaseModel):
|
||||
current_password: str = Field(min_length=1, max_length=200)
|
||||
new_password: str = Field(min_length=8, max_length=200)
|
||||
|
||||
|
||||
@router.post("/password", status_code=204)
|
||||
async def change_password(
|
||||
body: PasswordChange,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AuthSession, Depends(get_current_auth_session)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
) -> None:
|
||||
"""Change your own password, proving the current one first.
|
||||
|
||||
Every other session of this user is revoked — a
|
||||
password change is how someone reacts to a suspected compromise, so
|
||||
other devices must lose access. The session doing the change survives,
|
||||
otherwise the user is thrown out of the app they are standing in.
|
||||
"""
|
||||
if not verify_password(user.password_hash, body.current_password):
|
||||
raise ApiError(
|
||||
403, "Current password is incorrect.", "invalid_current_password"
|
||||
)
|
||||
|
||||
user.password_hash = hash_password(body.new_password)
|
||||
await revoke_user_sessions(db, user.id, keep_session_id=session.id)
|
||||
await db.commit()
|
||||
# Metadata only — never the password, not even its length.
|
||||
logger.info("password changed", extra={"event": "password_change"})
|
||||
|
||||
|
||||
@router.put("/locale", status_code=204)
|
||||
async def set_locale(
|
||||
body: LocalePreference,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
) -> None:
|
||||
"""Pin the interface language, or clear it to follow the browser again.
|
||||
|
||||
The backend only stores the choice — it never renders UI-language
|
||||
strings (see docs/architecture.md); the frontend does the translating.
|
||||
"""
|
||||
user.locale = body.locale
|
||||
await db.commit()
|
||||
|
||||
|
||||
# The starter catalog's blueprint about a PERSON (role, specialities, who to
|
||||
# ask) rather than a topic. What "the document about you" is made from, named
|
||||
# once here so the frontend does not have to know a blueprint id.
|
||||
PERSONAL_BLUEPRINT = "person"
|
||||
|
||||
|
||||
class PersonalDocument(BaseModel):
|
||||
"""The caller's own document about themselves.
|
||||
|
||||
Either they wrote one — then it is opened and edited like any other
|
||||
document — or they have not, and `template_id` says what to start it from.
|
||||
Both are null when the blueprint is not in this instance and nothing was
|
||||
written yet; the frontend falls back to the ordinary template picker.
|
||||
"""
|
||||
|
||||
document_id: uuid.UUID | None = None
|
||||
title: str | None = None
|
||||
status: DocumentStatus | None = None
|
||||
template_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
@router.get("/document")
|
||||
async def personal_document(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
) -> PersonalDocument:
|
||||
"""What the profile page needs to show: your document about yourself, or
|
||||
the way to start it. Self-scoped, and authorship is the whole rule — a
|
||||
document someone else wrote about you is not this."""
|
||||
document = (
|
||||
await db.execute(
|
||||
select(Document)
|
||||
.where(
|
||||
Document.author_id == user.id,
|
||||
Document.meta["template"].astext == PERSONAL_BLUEPRINT,
|
||||
)
|
||||
# The newest, if a second one was ever started.
|
||||
.order_by(Document.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
template_id = (
|
||||
await db.execute(
|
||||
select(Template.id).where(
|
||||
Template.config["id"].astext == PERSONAL_BLUEPRINT
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if document is None:
|
||||
return PersonalDocument(template_id=template_id)
|
||||
return PersonalDocument(
|
||||
document_id=document.id,
|
||||
title=document.title,
|
||||
status=document.status,
|
||||
template_id=template_id,
|
||||
)
|
||||
Reference in New Issue
Block a user