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
+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",