#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:
parent
5ec0b08648
commit
4b40de4e93
111
app/database.py
111
app/database.py
@ -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()
|
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(
|
async def create_job(
|
||||||
job_id: str,
|
job_id: str,
|
||||||
input_preview: str,
|
input_preview: str,
|
||||||
|
|||||||
50
app/main.py
50
app/main.py
@ -35,6 +35,7 @@ from .database import (
|
|||||||
get_all_assessments, get_assessment, delete_assessment,
|
get_all_assessments, get_assessment, delete_assessment,
|
||||||
upsert_assessment, import_json_assessments,
|
upsert_assessment, import_json_assessments,
|
||||||
search_assessments,
|
search_assessments,
|
||||||
|
toggle_bookmark, get_bookmarks, add_comment, get_comments, delete_comment,
|
||||||
)
|
)
|
||||||
from .parlamente import get_adapter, ADAPTERS
|
from .parlamente import get_adapter, ADAPTERS
|
||||||
from .bundeslaender import alle_bundeslaender
|
from .bundeslaender import alle_bundeslaender
|
||||||
@ -354,6 +355,55 @@ async def auth_login_url(request: Request, redirect: str = "/"):
|
|||||||
return {"enabled": True, "url": url}
|
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
|
# API: Load assessments from database
|
||||||
@app.get("/api/assessments")
|
@app.get("/api/assessments")
|
||||||
async def list_assessments(bundesland: Optional[str] = None):
|
async def list_assessments(bundesland: Optional[str] = None):
|
||||||
|
|||||||
@ -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) {
|
async function reAnalyze(drucksache, bundesland, btn) {
|
||||||
if (!currentUser) {
|
if (!currentUser) {
|
||||||
alert('Bitte zuerst anmelden.');
|
alert('Bitte zuerst anmelden.');
|
||||||
@ -1767,9 +1828,41 @@
|
|||||||
${item.source ? ` · Quelle: ${item.source}` : ''}
|
${item.source ? ` · Quelle: ${item.source}` : ''}
|
||||||
${item.model ? ` · Modell: ${item.model}` : ''}
|
${item.model ? ` · Modell: ${item.model}` : ''}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Kommentare + Bookmark-Status laden
|
||||||
|
loadComments('${item.drucksache}');
|
||||||
|
loadBookmarkState('${item.drucksache}');
|
||||||
|
|
||||||
// Auf Mobile: zum Detail-Panel scrollen, damit der gewählte Antrag sichtbar wird
|
// Auf Mobile: zum Detail-Panel scrollen, damit der gewählte Antrag sichtbar wird
|
||||||
if (window.matchMedia('(max-width: 900px)').matches) {
|
if (window.matchMedia('(max-width: 900px)').matches) {
|
||||||
document.getElementById('detail-panel').scrollIntoView({ behavior: 'smooth', block: 'start' });
|
document.getElementById('detail-panel').scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user