Refactor backend and frontend cleanup paths

This commit is contained in:
curo1305
2026-06-16 11:50:17 +02:00
parent 6b56763689
commit e97ca164d7
29 changed files with 1106 additions and 2280 deletions
+41 -166
View File
@@ -1,27 +1,7 @@
"""
AI provider configuration service for DocuVault.
Provides HKDF/Fernet encryption helpers, a provider config loader that reads
from the system_settings DB table, and a startup seed function that populates
the default provider row from env vars on first boot.
Security design (D-05, T-07-02):
HKDF domain separation — the info bytes b"ai-provider-settings" differ from
b"cloud-credentials" used by storage/cloud_utils.py. Both use the same master
key (settings.cloud_creds_key) but produce DIFFERENT derived Fernet keys, so
a leaked cloud credential cannot decrypt an AI API key and vice versa.
AlreadyFinalized warning (RESEARCH.md Pitfall 2 / .continue-here.md anti-pattern):
The cryptography library raises AlreadyFinalized if .derive() is called twice
on the same HKDF instance. _derive_ai_settings_key() creates a FRESH HKDF(...)
object on every call — never cache or reuse the HKDF object between calls.
Pattern reference: storage/cloud_utils.py:_derive_fernet_key().
"""
"""AI provider configuration and API-key encryption helpers."""
from __future__ import annotations
import base64
import logging
from typing import Optional
import structlog
@@ -36,167 +16,78 @@ from config import settings
logger = structlog.get_logger(__name__)
AI_SETTINGS_KEY_INFO = b"ai-provider-settings"
# ── HKDF key derivation ───────────────────────────────────────────────────────
def _derive_ai_settings_key(master_key: bytes, provider_id: str) -> Fernet:
"""Derive a per-provider Fernet encryption key using HKDF-SHA256.
Security notes:
- A FRESH HKDF instance is created on every call. The cryptography library
raises AlreadyFinalized if .derive() is called twice on the same instance.
Never cache or reuse the HKDF object (RESEARCH.md Pitfall 2).
- salt = provider_id.encode("utf-8") provides per-provider derivation
(deterministic: same provider → same key for encrypt/decrypt consistency).
- info = b"ai-provider-settings" provides domain separation from
b"cloud-credentials" — same master key, different derived keys.
A leaked cloud credential cannot decrypt an AI API key (T-07-02 mitigated).
Args:
master_key: The CLOUD_CREDS_KEY env var as bytes.
provider_id: The provider slug, e.g. "openai", "anthropic" (used as HKDF salt).
Returns:
A Fernet instance ready for encrypt/decrypt operations.
"""
# Create a FRESH HKDF instance — never cache (AlreadyFinalized guard)
"""Derive a per-provider Fernet key with domain-separated HKDF-SHA256."""
hkdf = HKDF(
algorithm=hashes.SHA256(),
length=32,
salt=provider_id.encode("utf-8"),
info=b"ai-provider-settings", # domain-separated from b"cloud-credentials"
info=AI_SETTINGS_KEY_INFO,
)
raw_key: bytes = hkdf.derive(master_key)
fernet_key = base64.urlsafe_b64encode(raw_key)
return Fernet(fernet_key)
# ── Encryption helpers ────────────────────────────────────────────────────────
def encrypt_api_key(master_key: bytes, provider_id: str, api_key: str) -> str:
"""Encrypt a plaintext API key string to a Fernet token.
The returned string is safe to store in system_settings.api_key_enc.
No JSON wrapping — the raw API key string is encrypted directly.
Args:
master_key: The CLOUD_CREDS_KEY env var as bytes.
provider_id: The provider slug (used as HKDF salt for key derivation).
api_key: The plaintext API key, e.g. "sk-proj-...".
Returns:
A URL-safe base64 Fernet token (str).
"""
"""Encrypt a plaintext API key for storage in system_settings.api_key_enc."""
f = _derive_ai_settings_key(master_key, provider_id)
return f.encrypt(api_key.encode("utf-8")).decode("utf-8")
def decrypt_api_key(master_key: bytes, provider_id: str, api_key_enc: str) -> str:
"""Decrypt a Fernet token back to the original plaintext API key.
Args:
master_key: The CLOUD_CREDS_KEY env var as bytes.
provider_id: The provider slug (used as HKDF salt for key derivation).
api_key_enc: The Fernet token string from the database.
Returns:
The original plaintext API key string.
"""
"""Decrypt a stored API-key token."""
f = _derive_ai_settings_key(master_key, provider_id)
return f.decrypt(api_key_enc.encode("utf-8")).decode("utf-8")
# ── Provider config loader ────────────────────────────────────────────────────
def _config_from_settings_row(row) -> ProviderConfig:
api_key = ""
if row.api_key_enc:
master_key = settings.cloud_creds_key.encode("utf-8")
try:
api_key = decrypt_api_key(master_key, row.provider_id, row.api_key_enc)
except Exception:
logger.warning(
"ai_config: failed to decrypt api_key_enc",
provider_id=row.provider_id,
)
return ProviderConfig(
provider_id=row.provider_id,
api_key=api_key,
base_url=row.base_url,
model=row.model_name,
context_chars=row.context_chars,
)
async def _load_settings_row(session: AsyncSession, *criteria):
from db.models import SystemSettings # local import avoids circular deps
stmt = select(SystemSettings)
if criteria:
stmt = stmt.where(*criteria)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def load_provider_config(session: AsyncSession) -> Optional[ProviderConfig]:
"""Load the active AI provider config from the system_settings table.
Returns a ProviderConfig built from the row where is_active=True,
decrypting api_key_enc when present. Returns None when no active row exists.
Args:
session: An open AsyncSession.
Returns:
A ProviderConfig if an active row exists; None if the table is empty or
no row is marked active.
"""
from db.models import SystemSettings # local import to avoid circular deps
stmt = select(SystemSettings).where(SystemSettings.is_active.is_(True))
result = await session.execute(stmt)
row = result.scalar_one_or_none()
row = await _load_settings_row(session, SystemSettings.is_active.is_(True))
return _config_from_settings_row(row) if row else None
if row is None:
return None
# Decrypt API key if present
api_key = ""
if row.api_key_enc:
master_key = settings.cloud_creds_key.encode("utf-8")
try:
api_key = decrypt_api_key(master_key, row.provider_id, row.api_key_enc)
except Exception:
logger.warning(
"ai_config.load_provider_config: failed to decrypt api_key_enc",
provider_id=row.provider_id,
)
return ProviderConfig(
provider_id=row.provider_id,
api_key=api_key,
base_url=row.base_url,
model=row.model_name,
context_chars=row.context_chars,
)
# ── Provider config loader by ID ─────────────────────────────────────────────
async def load_provider_config_by_id(session: AsyncSession, provider_id: str) -> Optional[ProviderConfig]:
"""Load an AI provider config from system_settings by provider_id.
Unlike load_provider_config(), this function does NOT require is_active=True.
Used by the admin test-connection endpoint so admins can test inactive rows.
Args:
session: An open AsyncSession.
provider_id: The provider slug to load (e.g. "openai", "lmstudio").
Returns:
A ProviderConfig if a row with the given provider_id exists; None otherwise.
"""
from db.models import SystemSettings # local import to avoid circular deps
stmt = select(SystemSettings).where(SystemSettings.provider_id == provider_id)
result = await session.execute(stmt)
row = result.scalar_one_or_none()
row = await _load_settings_row(session, SystemSettings.provider_id == provider_id)
return _config_from_settings_row(row) if row else None
if row is None:
return None
# Decrypt API key if present
api_key = ""
if row.api_key_enc:
master_key = settings.cloud_creds_key.encode("utf-8")
try:
api_key = decrypt_api_key(master_key, row.provider_id, row.api_key_enc)
except Exception:
logger.warning(
"ai_config.load_provider_config_by_id: failed to decrypt api_key_enc",
provider_id=row.provider_id,
)
return ProviderConfig(
provider_id=row.provider_id,
api_key=api_key,
base_url=row.base_url,
model=row.model_name,
context_chars=row.context_chars,
)
# ── Provider ID validator (D-11 migration) ───────────────────────────────────
def validate_provider_id(v: str) -> str:
"""Service-layer provider_id validator; raises ValueError per CLAUDE.md service-vs-API rule."""
@@ -207,21 +98,8 @@ def validate_provider_id(v: str) -> str:
return v
# ── Startup seed ──────────────────────────────────────────────────────────────
async def seed_system_settings_from_env(session: AsyncSession) -> None:
"""Populate system_settings with a default provider row on first boot.
Reads settings.default_ai_provider and settings.default_ai_model from config.
If no row exists for that provider_id, inserts one with is_active=True.
Never overwrites an existing row — idempotent across restarts (D-04).
This function is called from the FastAPI lifespan in main.py after the
session factory is available. Caller is responsible for committing.
Args:
session: An open AsyncSession.
"""
"""Seed the default provider row from env settings if it does not exist."""
from db.models import SystemSettings # local import to avoid circular deps
provider_id = settings.default_ai_provider
@@ -232,13 +110,10 @@ async def seed_system_settings_from_env(session: AsyncSession) -> None:
existing = result.scalar_one_or_none()
if existing is not None:
# Row already exists — never overwrite (idempotent)
return
# Use PROVIDER_DEFAULTS context_chars for the provider, fallback to 8000
context_chars = PROVIDER_DEFAULTS.get(provider_id, {}).get("context_chars", 8000)
# Insert default row with no API key (local providers like Ollama don't need one)
row = SystemSettings(
provider_id=provider_id,
model_name=model_name,
+49 -73
View File
@@ -1,20 +1,4 @@
"""
Auth service — pure Python, no FastAPI coupling.
Handles:
- Password hashing (Argon2 via pwdlib) and constant-time verification (SEC-06)
- JWT access token creation/decode (PyJWT)
- Refresh token lifecycle with family revocation on reuse (AUTH-07, RFC 9700)
- TOTP provisioning and verification with replay prevention (AUTH-08)
- Backup code generation, storage, and constant-time verification (AUTH-02)
- HaveIBeenPwned k-anonymity check (SEC-03)
- Admin account bootstrap (D-04, D-05, D-06)
Security invariants:
- All token/code comparisons use hmac.compare_digest (constant-time, SEC-06)
- No function raises HTTPException — callers (api/) map ValueError to HTTP errors
- refresh token family revocation enqueues send_security_alert_email.delay (AUTH-07)
"""
"""Authentication, token, TOTP, and account bootstrap services."""
from __future__ import annotations
import base64
@@ -32,7 +16,7 @@ import jwt
import pyotp
from pwdlib import PasswordHash
from pwdlib.hashers.argon2 import Argon2Hasher
from sqlalchemy import select, update
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from config import settings
@@ -45,8 +29,6 @@ _PASSWORD_DETAIL = (
logger = logging.getLogger(__name__)
# ── Password hashing ────────────────────────────────────────────────────────────
# Single shared PasswordHash instance; Argon2 is the only enabled hasher.
_pwd = PasswordHash([Argon2Hasher()])
@@ -82,10 +64,8 @@ def validate_password_strength(password: str) -> None:
raise ValueError(_PASSWORD_DETAIL)
# ── JWT helpers ─────────────────────────────────────────────────────────────────
def _compute_fgp(user_agent: str, accept_lang: str) -> str:
"""Return 16-char hex fingerprint binding a token to its client context (D-04)."""
"""Return a short fingerprint binding a token to its client context."""
return hmac.new(
settings.secret_key.encode(),
(user_agent + "\x00" + accept_lang).encode(),
@@ -93,6 +73,36 @@ def _compute_fgp(user_agent: str, accept_lang: str) -> str:
).hexdigest()[:16]
def _jwt_private_key() -> str:
return base64.b64decode(settings.jwt_private_key).decode()
def _jwt_public_key() -> str:
return base64.b64decode(settings.jwt_public_key).decode()
def _encode_jwt(payload: dict) -> str:
return jwt.encode(payload, _jwt_private_key(), algorithm="ES256")
def _decode_jwt(
token: str,
expected_type: str,
expired_message: str,
invalid_prefix: str,
) -> dict:
try:
payload = jwt.decode(token, _jwt_public_key(), algorithms=["ES256"])
except jwt.ExpiredSignatureError as exc:
raise ValueError(expired_message) from exc
except jwt.PyJWTError as exc:
raise ValueError(f"{invalid_prefix}: {exc}") from exc
if payload.get("typ") != expected_type:
raise ValueError(f"Token type mismatch: expected '{expected_type}'")
return payload
def create_access_token(
user_id: str,
role: str,
@@ -114,8 +124,7 @@ def create_access_token(
"jti": str(uuid.uuid4()),
"fgp": _compute_fgp(user_agent, accept_lang),
}
private_pem = base64.b64decode(settings.jwt_private_key).decode()
return jwt.encode(payload, private_pem, algorithm="ES256")
return _encode_jwt(payload)
def decode_access_token(token: str) -> dict:
@@ -124,17 +133,7 @@ def decode_access_token(token: str) -> dict:
Verifies: signature, expiry, and typ='access' (T-02-01 — prevents password-reset
tokens from being used as access tokens).
"""
try:
public_pem = base64.b64decode(settings.jwt_public_key).decode()
payload = jwt.decode(token, public_pem, algorithms=["ES256"])
except jwt.ExpiredSignatureError as exc:
raise ValueError("Token has expired") from exc
except jwt.PyJWTError as exc:
raise ValueError(f"Invalid token: {exc}") from exc
if payload.get("typ") != "access":
raise ValueError("Token type mismatch: expected 'access'")
return payload
return _decode_jwt(token, "access", "Token has expired", "Invalid token")
def create_password_reset_token(user_id: str) -> str:
@@ -149,8 +148,7 @@ def create_password_reset_token(user_id: str) -> str:
"iat": now,
"exp": now + timedelta(seconds=3600),
}
private_pem = base64.b64decode(settings.jwt_private_key).decode()
return jwt.encode(payload, private_pem, algorithm="ES256")
return _encode_jwt(payload)
def decode_password_reset_token(token: str) -> str:
@@ -158,21 +156,15 @@ def decode_password_reset_token(token: str) -> str:
Returns the user_id string.
"""
try:
public_pem = base64.b64decode(settings.jwt_public_key).decode()
payload = jwt.decode(token, public_pem, algorithms=["ES256"])
except jwt.ExpiredSignatureError as exc:
raise ValueError("Reset token has expired") from exc
except jwt.PyJWTError as exc:
raise ValueError(f"Invalid reset token: {exc}") from exc
if payload.get("typ") != "password-reset":
raise ValueError("Token type mismatch: expected 'password-reset'")
payload = _decode_jwt(
token,
"password-reset",
"Reset token has expired",
"Invalid reset token",
)
return payload["sub"]
# ── Refresh token lifecycle ─────────────────────────────────────────────────────
async def create_refresh_token(
session: AsyncSession, user_id: uuid.UUID, remember_me: bool = False
) -> str:
@@ -181,8 +173,8 @@ async def create_refresh_token(
The raw token is returned to the caller and set as an httpOnly cookie.
Only the SHA-256 hash is stored in the database.
remember_me=False (default): TTL = refresh_token_expire_hours (16h short session, D-09, D-10)
remember_me=True: TTL = refresh_token_expire_days (30d extended session, D-11)
remember_me=False uses the short-session TTL; remember_me=True uses the
extended-session TTL.
"""
raw = secrets.token_urlsafe(32)
token_hash = hashlib.sha256(raw.encode()).hexdigest()
@@ -231,9 +223,7 @@ async def rotate_refresh_token(
raise ValueError("Refresh token has expired")
if row.revoked:
# T-02-02: reuse of revoked token — family revocation
await revoke_all_refresh_tokens(session, row.user_id)
# Enqueue security alert email (deferred import to avoid circular dependency)
from tasks.email_tasks import send_security_alert_email # noqa: PLC0415
send_security_alert_email.delay(str(row.user_id))
raise ValueError("token_family_revoked")
@@ -273,8 +263,6 @@ async def revoke_all_refresh_tokens(
return count
# ── TOTP provisioning ───────────────────────────────────────────────────────────
async def provision_totp(
session: AsyncSession, user_id: uuid.UUID
) -> tuple[str, str]:
@@ -329,13 +317,10 @@ async def verify_totp(
return True
# ── Backup codes ────────────────────────────────────────────────────────────────
def generate_backup_codes(n: int = 10) -> list[str]:
"""Return *n* random 8-character uppercase alphanumeric backup codes."""
codes = []
for _ in range(n):
# secrets.token_hex(4) returns 8 hex chars; uppercase for readability
code = secrets.token_hex(4).upper()
codes.append(code)
return codes
@@ -349,7 +334,6 @@ async def store_backup_codes(
Each code is stored as an Argon2 hash (never plaintext, T-02-03).
Existing unused codes are deleted first to prevent accumulation.
"""
# Delete existing unused codes
result = await session.execute(
select(BackupCode).where(
BackupCode.user_id == user_id,
@@ -360,7 +344,6 @@ async def store_backup_codes(
await session.delete(row)
await session.flush()
# Insert new hashed codes
for code in codes:
row = BackupCode(
id=uuid.uuid4(),
@@ -392,21 +375,17 @@ async def verify_backup_code(
matched_row: Optional[BackupCode] = None
for row in rows:
# Always call verify_password for ALL rows (constant-time: no early exit)
if verify_password(code, row.code_hash):
matched_row = row # record match but keep iterating
matched_row = row # keep iterating for constant-time behavior
if matched_row is None:
return False
# Mark the matched code as used
matched_row.used_at = datetime.now(timezone.utc)
await session.commit()
return True
# ── HaveIBeenPwned check ────────────────────────────────────────────────────────
async def check_hibp(password: str) -> bool:
"""Check if password appears in HaveIBeenPwned using the k-anonymity model.
@@ -442,19 +421,17 @@ async def check_hibp(password: str) -> bool:
return False
# ── Admin bootstrap ─────────────────────────────────────────────────────────────
async def bootstrap_admin(session: AsyncSession) -> None:
"""Idempotent admin account bootstrap (D-04, D-05, D-06).
"""Idempotent admin account bootstrap.
If the users table is empty AND settings.admin_email and settings.admin_password
are both non-empty, creates an admin User row with a Quota row.
Logs a WARNING if env vars are missing (D-05) but never raises.
Logs a warning if env vars are missing but never raises.
"""
if not settings.admin_email or not settings.admin_password:
logger.warning(
"Admin bootstrap skipped: ADMIN_EMAIL and/or ADMIN_PASSWORD not set (D-05). "
"Admin bootstrap skipped: ADMIN_EMAIL and/or ADMIN_PASSWORD not set. "
"Set both env vars to seed the first admin account on startup."
)
return
@@ -462,7 +439,6 @@ async def bootstrap_admin(session: AsyncSession) -> None:
# Check if any users exist
result = await session.execute(select(User).limit(1))
if result.scalar_one_or_none() is not None:
# Users already exist — idempotent, skip (D-04)
return
admin_id = uuid.uuid4()
@@ -477,7 +453,7 @@ async def bootstrap_admin(session: AsyncSession) -> None:
)
quota = Quota(
user_id=admin_id,
limit_bytes=104857600, # 100 MB default (D-06)
limit_bytes=104857600,
used_bytes=0,
)
session.add(admin_user)
+28 -100
View File
@@ -1,21 +1,4 @@
"""
Async document/topic/settings storage service for DocuVault.
This module replaces the legacy flat-file + filelock implementation with:
- Async SQLAlchemy ORM for document and topic persistence (PostgreSQL)
- MinIO SDK (via asyncio.to_thread) for binary object storage
Public function names are PRESERVED from the old flat-file implementation so
that api/documents.py and api/topics.py can be updated in Plan 05 with minimal
changes (async def + await + session parameter).
Phase 3 D-12: load_settings / save_settings / mask_api_key / settings_masked removed.
All AI config comes from DB (users.ai_provider / users.ai_model set by admin).
D-05: Storage service layer switched to PostgreSQL + MinIO.
D-06: Object key schema: {user_id}/{document_id}/{uuid4()}{ext} — human filename in DB only.
D-03: documents.user_id is None (nullable) in Phase 1 — no auth system yet.
"""
"""Async document and topic storage helpers."""
from __future__ import annotations
import sys
@@ -31,26 +14,17 @@ from db.models import Document, DocumentTopic, Topic
from storage import get_storage_backend
# ── Lazy singleton storage backend ────────────────────────────────────────────
_storage = None
def _backend():
"""Return the lazily-instantiated StorageBackend singleton.
Mirrors the module-level singleton behaviour of the old filelock objects so
the MinIO client is created once per process, not once per request.
"""
"""Return the lazily-instantiated StorageBackend singleton."""
global _storage
_storage = _storage or get_storage_backend()
return _storage
# ── Private helpers ────────────────────────────────────────────────────────────
def _doc_to_dict(doc: Document, topic_names: list) -> dict:
"""Convert a Document ORM row + resolved topic names to the legacy dict shape."""
return {
"id": str(doc.id),
"original_name": doc.filename,
@@ -65,8 +39,22 @@ def _doc_to_dict(doc: Document, topic_names: list) -> dict:
}
def _topic_to_dict(topic: Topic) -> dict:
return {
"id": str(topic.id),
"name": topic.name,
"description": topic.description,
"color": topic.color,
}
def _topic_namespace_filter(name: str, user_id: Optional[uuid.UUID]):
criteria = [sql_func.lower(Topic.name) == name.lower()]
criteria.append(Topic.user_id.is_(None) if user_id is None else Topic.user_id == user_id)
return criteria
async def _load_topic_names(session: AsyncSession, doc_id: uuid.UUID) -> list:
"""Return the list of topic names for a given document UUID."""
q = await session.execute(
select(Topic.name)
.join(DocumentTopic, DocumentTopic.topic_id == Topic.id)
@@ -75,9 +63,6 @@ async def _load_topic_names(session: AsyncSession, doc_id: uuid.UUID) -> list:
return [row[0] for row in q]
# ── Documents ─────────────────────────────────────────────────────────────────
async def save_metadata(session: AsyncSession, meta: dict) -> None:
"""Update a Document row from the legacy metadata dict shape.
@@ -120,10 +105,7 @@ async def get_metadata(session: AsyncSession, doc_id: str) -> Optional[dict]:
async def list_metadata(
session: AsyncSession, user_id: uuid.UUID, topic: Optional[str] = None
) -> list:
"""Return a list of metadata dicts for a specific user, optionally filtered by topic name.
D-16: always filters by user_id — a user can only see their own documents.
"""
"""Return metadata dicts for a user's documents, optionally filtered by topic."""
stmt = select(Document).where(Document.user_id == user_id).order_by(Document.created_at.desc())
if topic is not None:
stmt = (
@@ -176,10 +158,6 @@ async def delete_document(
print(f"[storage] WARNING: MinIO delete_object failed for {doc.object_key!r}: {exc}", file=sys.stderr)
if not skip_quota:
# Atomic quota decrement (STORE-06, D-07).
# user_id is always set post-migration (Plan 03-03+) — guard removed.
# Use CASE WHEN instead of GREATEST() for SQLite compatibility
# (PostgreSQL supports both; SQLite lacks the GREATEST scalar function).
await session.execute(
text(
"UPDATE quotas "
@@ -212,12 +190,10 @@ async def update_document_topics(
if doc is None:
return None
# Remove all existing associations
await session.execute(
delete(DocumentTopic).where(DocumentTopic.document_id == uid)
)
# Re-insert, deduplicating by name
seen: set = set()
for name in topics:
if name in seen:
@@ -257,15 +233,10 @@ async def remove_topic_from_all_documents(
return result.rowcount
# ── Topics ────────────────────────────────────────────────────────────────────
async def load_topics(session: AsyncSession) -> list:
"""Return all topics ordered by name."""
q = await session.execute(select(Topic).order_by(Topic.name))
return [
{"id": str(t.id), "name": t.name, "description": t.description, "color": t.color}
for t in q.scalars()
]
return [_topic_to_dict(t) for t in q.scalars()]
async def load_topics_for_user(session: AsyncSession, user_id: uuid.UUID) -> list:
@@ -280,17 +251,11 @@ async def load_topics_for_user(session: AsyncSession, user_id: uuid.UUID) -> lis
or_(Topic.user_id == user_id, Topic.user_id.is_(None))
).order_by(Topic.name)
)
return [
{"id": str(t.id), "name": t.name, "description": t.description, "color": t.color}
for t in q.scalars()
]
return [_topic_to_dict(t) for t in q.scalars()]
async def save_topics(session: AsyncSession, topics: list) -> None:
"""Idempotent bulk replace — delete all Topic rows then insert the list.
# legacy: not used by current endpoints; preserved for API compatibility.
"""
"""Idempotent bulk replace; kept for compatibility with older callers."""
await session.execute(delete(Topic))
for t in topics:
session.add(
@@ -313,7 +278,7 @@ async def get_topic(session: AsyncSession, topic_id: str) -> Optional[dict]:
t = await session.get(Topic, uid)
if t is None:
return None
return {"id": str(t.id), "name": t.name, "description": t.description, "color": t.color}
return _topic_to_dict(t)
async def create_topic(
@@ -323,51 +288,16 @@ async def create_topic(
color: str = "#6366f1",
user_id: Optional[uuid.UUID] = None,
) -> dict:
"""Create a topic, or return the existing one (case-insensitive, namespace-scoped dedup).
D-08: user_id=None creates a system topic (visible to all users).
D-08: user_id=<uuid> creates a per-user topic (visible only to that user).
Deduplication is scoped by user_id namespace:
- System topics (user_id=None) dedup against other system topics only
- Per-user topics dedup within that user's namespace only
This allows "Finance" to exist as both a system topic and a per-user topic.
SQLite note: Uses a branching approach instead of IS NOT DISTINCT FROM
(SQLite doesn't support that PostgreSQL construct for NULL comparison).
"""
if user_id is None:
q = await session.execute(
select(Topic).where(
sql_func.lower(Topic.name) == name.lower(),
Topic.user_id.is_(None),
)
)
else:
q = await session.execute(
select(Topic).where(
sql_func.lower(Topic.name) == name.lower(),
Topic.user_id == user_id,
)
)
"""Create a topic, or return an existing case-insensitive namespace match."""
q = await session.execute(select(Topic).where(*_topic_namespace_filter(name, user_id)))
existing = q.scalars().first()
if existing is not None:
return {
"id": str(existing.id),
"name": existing.name,
"description": existing.description,
"color": existing.color,
}
return _topic_to_dict(existing)
topic = Topic(name=name, description=description, color=color, user_id=user_id)
session.add(topic)
await session.commit()
return {
"id": str(topic.id),
"name": topic.name,
"description": topic.description,
"color": topic.color,
}
return _topic_to_dict(topic)
async def update_topic(
@@ -392,7 +322,7 @@ async def update_topic(
if color is not None:
t.color = color
await session.commit()
return {"id": str(t.id), "name": t.name, "description": t.description, "color": t.color}
return _topic_to_dict(t)
async def delete_topic(session: AsyncSession, topic_id: str) -> Optional[str]:
@@ -408,7 +338,7 @@ async def delete_topic(session: AsyncSession, topic_id: str) -> Optional[str]:
if t is None:
return None
name = t.name
await session.delete(t) # ondelete="CASCADE" removes DocumentTopic rows
await session.delete(t)
await session.commit()
return name
@@ -437,8 +367,6 @@ async def topic_doc_counts(
return {name: count for name, count in q}
# ── Public surface ─────────────────────────────────────────────────────────────
__all__ = [
"save_metadata",
"get_metadata",