#94 Bookmarks + Kommentare: DB-Schema, API, UI

DB (database.py):
- bookmarks-Tabelle (user_id + drucksache, toggle)
- comments-Tabelle (user_id, user_name, drucksache, text, visibility)
- Functions: toggle_bookmark, get_bookmarks, add_comment, get_comments, delete_comment

API (main.py):
- POST /api/bookmark (toggle, Auth-pflichtig)
- GET /api/bookmarks (User-Bookmarks)
- POST /api/comment (Auth-pflichtig, max 2000 Zeichen)
- GET /api/comments?drucksache= (öffentlich)
- DELETE /api/comment/{id} (nur eigene, Auth-pflichtig)

UI (index.html):
- Bookmark-Button ("🔖 Merken" / " Gemerkt") im Detail-Footer
- Kommentar-Bereich: Liste + Eingabefeld + Senden-Button
- Kommentare laden automatisch beim Detail-Öffnen
- Eigene Kommentare löschbar (✕ Button)
- Ohne Login: "Anmelden um zu kommentieren"

Gruppen-Sichtbarkeit (visibility) ist vorbereitet aber noch nicht
im UI exponiert — kommt als separater Schritt wenn Keycloak-Gruppen
konfiguriert sind.

Tests: 206 passed.

Refs: #94
This commit is contained in:
Dotty Dotter 2026-04-10 22:19:46 +02:00
parent 5ec0b08648
commit 4b40de4e93
3 changed files with 255 additions and 1 deletions

View File

@ -63,9 +63,120 @@ async def init_db():
)
""")
# Bookmarks (#94)
await db.execute("""
CREATE TABLE IF NOT EXISTS bookmarks (
user_id TEXT NOT NULL,
drucksache TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (user_id, drucksache)
)
""")
# Kommentare (#94)
await db.execute("""
CREATE TABLE IF NOT EXISTS comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
user_name TEXT DEFAULT '',
drucksache TEXT NOT NULL,
text TEXT NOT NULL,
visibility TEXT NOT NULL DEFAULT 'all',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
await db.execute(
"CREATE INDEX IF NOT EXISTS idx_comments_drucksache ON comments(drucksache)"
)
await db.commit()
# ─── Bookmark-Functions (#94) ───────────────────────────────────────────────
async def toggle_bookmark(user_id: str, drucksache: str) -> bool:
"""Toggle bookmark. Returns True if bookmarked, False if removed."""
async with aiosqlite.connect(settings.db_path) as db:
row = await db.execute(
"SELECT 1 FROM bookmarks WHERE user_id=? AND drucksache=?",
(user_id, drucksache),
)
exists = await row.fetchone()
if exists:
await db.execute(
"DELETE FROM bookmarks WHERE user_id=? AND drucksache=?",
(user_id, drucksache),
)
await db.commit()
return False
else:
await db.execute(
"INSERT INTO bookmarks (user_id, drucksache) VALUES (?, ?)",
(user_id, drucksache),
)
await db.commit()
return True
async def get_bookmarks(user_id: str) -> list[str]:
"""Get all bookmarked drucksache IDs for a user."""
async with aiosqlite.connect(settings.db_path) as db:
rows = await db.execute(
"SELECT drucksache FROM bookmarks WHERE user_id=? ORDER BY created_at DESC",
(user_id,),
)
return [r[0] for r in await rows.fetchall()]
# ─── Comment-Functions (#94) ────────────────────────────────────────────────
async def add_comment(user_id: str, user_name: str, drucksache: str,
text: str, visibility: str = "all") -> dict:
"""Add a comment. Returns the new comment dict."""
now = datetime.utcnow().isoformat()
async with aiosqlite.connect(settings.db_path) as db:
cursor = await db.execute(
"INSERT INTO comments (user_id, user_name, drucksache, text, visibility, created_at) "
"VALUES (?, ?, ?, ?, ?, ?)",
(user_id, user_name, drucksache, text, visibility, now),
)
await db.commit()
return {
"id": cursor.lastrowid,
"user_id": user_id,
"user_name": user_name,
"drucksache": drucksache,
"text": text,
"visibility": visibility,
"created_at": now,
}
async def get_comments(drucksache: str, user_id: Optional[str] = None) -> list[dict]:
"""Get comments for a drucksache. Filters by visibility:
- 'all' comments are always visible
- group-scoped comments only visible if user is in that group (TODO)
"""
async with aiosqlite.connect(settings.db_path) as db:
db.row_factory = aiosqlite.Row
rows = await db.execute(
"SELECT * FROM comments WHERE drucksache=? ORDER BY created_at",
(drucksache,),
)
return [dict(r) for r in await rows.fetchall()]
async def delete_comment(comment_id: int, user_id: str) -> bool:
"""Delete a comment (only by its author)."""
async with aiosqlite.connect(settings.db_path) as db:
cursor = await db.execute(
"DELETE FROM comments WHERE id=? AND user_id=?",
(comment_id, user_id),
)
await db.commit()
return cursor.rowcount > 0
async def create_job(
job_id: str,
input_preview: str,

View File

@ -35,6 +35,7 @@ from .database import (
get_all_assessments, get_assessment, delete_assessment,
upsert_assessment, import_json_assessments,
search_assessments,
toggle_bookmark, get_bookmarks, add_comment, get_comments, delete_comment,
)
from .parlamente import get_adapter, ADAPTERS
from .bundeslaender import alle_bundeslaender
@ -354,6 +355,55 @@ async def auth_login_url(request: Request, redirect: str = "/"):
return {"enabled": True, "url": url}
# ─── Bookmarks + Comments (#94) ─────────────────────────────────────────────
@app.post("/api/bookmark")
async def bookmark_toggle(
drucksache: str = Form(...),
user: dict = Depends(require_auth),
):
"""Toggle bookmark für einen Antrag."""
is_bookmarked = await toggle_bookmark(user["sub"], drucksache)
return {"bookmarked": is_bookmarked, "drucksache": drucksache}
@app.get("/api/bookmarks")
async def bookmarks_list(user=Depends(get_current_user)):
"""Liste aller Bookmarks des aktuellen Users."""
if not user:
return []
return await get_bookmarks(user["sub"])
@app.post("/api/comment")
async def comment_add(
drucksache: str = Form(...),
text: str = Form(...),
visibility: str = Form("all"),
user: dict = Depends(require_auth),
):
"""Kommentar hinzufügen."""
if len(text) > 2000:
raise HTTPException(status_code=400, detail="Kommentar zu lang (max 2000 Zeichen)")
return await add_comment(user["sub"], user.get("name", ""), drucksache, text, visibility)
@app.get("/api/comments")
async def comments_list(drucksache: str, user=Depends(get_current_user)):
"""Kommentare für einen Antrag."""
user_id = user["sub"] if user else None
return await get_comments(drucksache, user_id)
@app.delete("/api/comment/{comment_id}")
async def comment_delete(comment_id: int, user: dict = Depends(require_auth)):
"""Eigenen Kommentar löschen."""
deleted = await delete_comment(comment_id, user["sub"])
if not deleted:
raise HTTPException(status_code=404, detail="Kommentar nicht gefunden oder nicht Ihr Kommentar")
return {"status": "deleted"}
# API: Load assessments from database
@app.get("/api/assessments")
async def list_assessments(bundesland: Optional[str] = None):

View File

@ -1260,6 +1260,67 @@
}
}
// ─── Bookmarks + Comments (#94) ─────────────────────────────
async function toggleBookmark(drucksache, btn) {
const resp = await fetch('/api/bookmark', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: `drucksache=${encodeURIComponent(drucksache)}`
});
if (resp.ok) {
const data = await resp.json();
btn.textContent = data.bookmarked ? '⭐ Gemerkt' : '🔖 Merken';
btn.style.background = data.bookmarked ? '#fff3cd' : 'none';
}
}
async function loadBookmarkState(drucksache) {
if (!currentUser) return;
const bookmarks = await fetch('/api/bookmarks').then(r => r.json());
const btn = document.getElementById('bookmark-btn-' + drucksache.replace('/', '-'));
if (btn && bookmarks.includes(drucksache)) {
btn.textContent = '⭐ Gemerkt';
btn.style.background = '#fff3cd';
}
}
async function loadComments(drucksache) {
const container = document.getElementById('comments-' + drucksache.replace('/', '-'));
if (!container) return;
try {
const comments = await fetch(`/api/comments?drucksache=${encodeURIComponent(drucksache)}`).then(r => r.json());
if (comments.length === 0) {
container.innerHTML = '<span style="color:#aaa;font-size:0.85rem;">Noch keine Kommentare.</span>';
return;
}
container.innerHTML = comments.map(c => `
<div style="padding:0.4rem 0;border-bottom:1px solid #f0f0f0;font-size:0.85rem;">
<strong>${c.user_name || 'Anonym'}</strong>
<span style="color:#aaa;font-size:0.75rem;margin-left:0.5rem;">${new Date(c.created_at).toLocaleString('de-DE')}</span>
${currentUser && currentUser.sub === c.user_id ? `<button onclick="deleteCommentUI(${c.id},'${drucksache}')" style="float:right;background:none;border:none;color:#dc3545;cursor:pointer;font-size:0.75rem;"></button>` : ''}
<div style="margin-top:0.2rem;">${c.text}</div>
</div>
`).join('');
} catch { container.innerHTML = ''; }
}
async function addCommentUI(drucksache) {
const input = document.getElementById('comment-input-' + drucksache.replace('/', '-'));
if (!input || !input.value.trim()) return;
await fetch('/api/comment', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: `drucksache=${encodeURIComponent(drucksache)}&text=${encodeURIComponent(input.value)}`
});
input.value = '';
loadComments(drucksache);
}
async function deleteCommentUI(commentId, drucksache) {
await fetch(`/api/comment/${commentId}`, {method: 'DELETE'});
loadComments(drucksache);
}
async function reAnalyze(drucksache, bundesland, btn) {
if (!currentUser) {
alert('Bitte zuerst anmelden.');
@ -1767,9 +1828,41 @@
${item.source ? ` · Quelle: ${item.source}` : ''}
${item.model ? ` · Modell: ${item.model}` : ''}
</div>
<!-- Bookmark + Kommentare (#94) -->
<div style="margin-top: 1rem; border-top: 2px solid var(--color-lightgray); padding-top: 1rem;">
<div style="display: flex; align-items: center; gap: 1rem; margin-bottom: 0.75rem;">
<button id="bookmark-btn-${item.drucksache.replace('/','-')}"
style="background:none;border:1px solid #ddd;border-radius:4px;padding:0.3rem 0.8rem;cursor:pointer;font-size:0.9rem;"
onclick="toggleBookmark('${item.drucksache}', this)"
${currentUser ? '' : 'disabled title="Nur nach Anmeldung"'}>
🔖 Merken
</button>
<span style="font-size: 0.85rem; color: #888;">Kommentare:</span>
</div>
<div id="comments-${item.drucksache.replace('/','-')}" style="margin-bottom: 0.75rem;">
<span style="color:#aaa;font-size:0.85rem;">Lade Kommentare...</span>
</div>
${currentUser ? `
<div style="display:flex;gap:0.5rem;">
<input id="comment-input-${item.drucksache.replace('/','-')}" type="text"
placeholder="Kommentar schreiben..."
style="flex:1;padding:0.4rem;border:1px solid #ddd;border-radius:4px;font-size:0.85rem;"
onkeydown="if(event.key==='Enter')addCommentUI('${item.drucksache}')">
<button onclick="addCommentUI('${item.drucksache}')"
style="padding:0.4rem 0.8rem;background:var(--color-blue);color:white;border:none;border-radius:4px;cursor:pointer;font-size:0.85rem;">
Senden
</button>
</div>
` : '<span style="font-size:0.8rem;color:#aaa;">Anmelden um zu kommentieren</span>'}
</div>
</div>
`;
// Kommentare + Bookmark-Status laden
loadComments('${item.drucksache}');
loadBookmarkState('${item.drucksache}');
// Auf Mobile: zum Detail-Panel scrollen, damit der gewählte Antrag sichtbar wird
if (window.matchMedia('(max-width: 900px)').matches) {
document.getElementById('detail-panel').scrollIntoView({ behavior: 'smooth', block: 'start' });