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:
ProfessorNova
2026-09-04 08:36:17 +02:00
co-authored by Claude Opus 5
commit 97dbff309c
346 changed files with 43430 additions and 0 deletions
+136
View File
@@ -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,
)