Freunde, privates Teilen, Artikelrechte, Reiserouten
- Freunde (freunde.py, Tabelle friendships): suchen, anfragen, annehmen, ablehnen, entfernen; neue Seite "Freunde" im Menü - Vorratslisten und Kanban-Projekte immer privat: sichtbar für Ersteller und Mitglieder; teilen nur mit Freunden (Admins: mit allen); Kanban-Mitglieder (Tabelle kanban_project_members), Bearbeiter nur aus dem Projekt, ausgetretene Mitglieder verlieren ihre Zuteilung - Artikel: lesen alle; bearbeiten Autor, Mitarbeitende verknüpfter Kanban-Projekte und Admins; verknüpfen nur bearbeitbare Artikel (sonst ließen sich Rechte erschleichen); Board-Liste auf der Artikelseite zeigt nur sichtbare Boards - Administratoren sehen und bearbeiten alles - Reisen: Städte nur von Freunden (Admins: alle); "Reiserouten zeigen" zeichnet die Strecken der Kilometer-Rechnung als Bögen - Migration Schritt 6: bisher gemeinsame Listen und alle Projekte bekommen die vorhandenen Konten als Mitglieder, ohne Ersteller → erster Admin; erste Liste einer neuen Installation gehört dem ersten Konto - Tests: test_freunde.py, test_zugriff.py, bestehende auf Privat-Modell umgestellt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1d7497bead
commit
0fb3521c2f
34 files changed
+833
-245
No files matched your search
+133
-54
@@ -1,6 +1,8 @@
|
||||
"""Kanban: Projekte (je ein Board) → Aufgaben mit Status, Bearbeiter und Beschreibung.
|
||||
|
||||
Alles ist für alle Konten gemeinsam. Löschen dürfen nur, wer etwas angelegt hat, und Administratoren.
|
||||
Projekte sind privat: Sehen und bearbeiten dürfen sie der Ersteller und die Mitglieder (Freunde, mit denen er das
|
||||
Projekt umsetzt); Administratoren sehen alles. Mitglieder verwalten und das Projekt löschen dürfen der Ersteller
|
||||
und Administratoren, Aufgaben löschen deren Ersteller und Administratoren.
|
||||
Projekte lassen sich als abgeschlossen markieren und mit Artikeln aus "P42 Projekte" verknüpfen.
|
||||
`position` (Kommazahl) sortiert Projekte und Aufgaben innerhalb einer Spalte.
|
||||
Erledigte Aufgaben wandern nach 2 Tagen ins Archiv und werden nach 14 Tagen gelöscht (gezählt ab "erledigt").
|
||||
@@ -15,6 +17,7 @@ from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
from .auth import current_user
|
||||
from .common import ValidationError, clean_text, iso_after, iso_ago, now_iso, user_list
|
||||
from .db import transaction
|
||||
from .freunde import check_shareable, shareable
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -59,6 +62,64 @@ def _person(user_id, name) -> dict | None:
|
||||
return {"id": user_id, "name": name} if user_id is not None and name else None
|
||||
|
||||
|
||||
# Sichtbar: Ersteller, Mitglieder, Administratoren
|
||||
_VISIBLE = """(:admin OR k.created_by = :uid
|
||||
OR EXISTS (SELECT 1 FROM kanban_project_members m WHERE m.project_id = k.id AND m.user_id = :uid))"""
|
||||
|
||||
|
||||
def _who(user: dict) -> dict:
|
||||
return {"uid": user["id"], "admin": int(user["isAdmin"])}
|
||||
|
||||
|
||||
def _get_project(conn, project_id: int, user: dict):
|
||||
row = conn.execute(f"SELECT k.* FROM kanban_projects k WHERE k.id = :id AND {_VISIBLE}", {"id": project_id, **_who(user)}).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Kanban-Projekt nicht gefunden – vielleicht wurde es gelöscht oder nicht mit dir geteilt.")
|
||||
return row
|
||||
|
||||
|
||||
def _member_ids(conn, project_id: int) -> list:
|
||||
return [r["user_id"] for r in conn.execute("SELECT user_id FROM kanban_project_members WHERE project_id = ?", (project_id,))]
|
||||
|
||||
|
||||
def _people(conn, row) -> list:
|
||||
"""Wer am Projekt arbeitet (Ersteller + Mitglieder) – wählbar als Bearbeiter."""
|
||||
ids = {row["created_by"], *_member_ids(conn, row["id"])} - {None}
|
||||
return [u for u in user_list(conn) if u["id"] in ids]
|
||||
|
||||
|
||||
def _members(conn, value, user: dict, owner_id, current=()) -> list:
|
||||
if not isinstance(value, list) or any(isinstance(m, bool) or not isinstance(m, int) for m in value):
|
||||
raise ValidationError("Ungültige Mitglieder.")
|
||||
ids = sorted(set(value) - {owner_id})
|
||||
check_shareable(conn, user, ids, keep=current)
|
||||
return ids
|
||||
|
||||
|
||||
def _save_members(conn, project_id: int, ids: list) -> None:
|
||||
conn.execute("DELETE FROM kanban_project_members WHERE project_id = ?", (project_id,))
|
||||
conn.executemany("INSERT INTO kanban_project_members (project_id, user_id) VALUES (?, ?)", [(project_id, i) for i in ids])
|
||||
# Wer nicht mehr dabei ist, kann auch nicht mehr Bearbeiter sein
|
||||
people = {r["created_by"] for r in conn.execute("SELECT created_by FROM kanban_projects WHERE id = ?", (project_id,))} | set(ids)
|
||||
conn.execute(
|
||||
f"UPDATE kanban_tasks SET assignee = NULL WHERE project_id = ? AND assignee NOT IN ({','.join('?' * len(people))})",
|
||||
(project_id, *people),
|
||||
)
|
||||
|
||||
|
||||
def can_edit_article(conn, user: dict, article_id: str) -> bool:
|
||||
"""Artikel bearbeiten: Autor, wer an einem verknüpften Kanban-Projekt arbeitet, Administratoren."""
|
||||
if user["isAdmin"]:
|
||||
return True
|
||||
return conn.execute(
|
||||
"""SELECT 1 FROM projects p WHERE p.id = :a AND (p.created_by = :uid OR EXISTS (
|
||||
SELECT 1 FROM kanban_project_links l JOIN kanban_projects k ON k.id = l.project_id
|
||||
WHERE l.article_id = p.id AND (k.created_by = :uid
|
||||
OR EXISTS (SELECT 1 FROM kanban_project_members m WHERE m.project_id = k.id AND m.user_id = :uid))))""",
|
||||
{"a": article_id, "uid": user["id"]},
|
||||
).fetchone() is not None
|
||||
|
||||
|
||||
def _may_delete(user: dict, created_by) -> bool:
|
||||
return user["isAdmin"] or created_by == user["id"]
|
||||
|
||||
@@ -97,13 +158,12 @@ def _status(value) -> str:
|
||||
return value
|
||||
|
||||
|
||||
def _assignee(conn, value):
|
||||
def _assignee(conn, value, project):
|
||||
"""Bearbeiter muss am Projekt arbeiten (Ersteller oder Mitglied)."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool) or not isinstance(value, int) or not conn.execute(
|
||||
"SELECT 1 FROM users WHERE id = ?", (value,)
|
||||
).fetchone():
|
||||
raise ValidationError("Unbekannter Bearbeiter.")
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value not in {u["id"] for u in _people(conn, project)}:
|
||||
raise ValidationError("Bearbeiter muss an diesem Projekt mitarbeiten.")
|
||||
return value
|
||||
|
||||
|
||||
@@ -113,7 +173,9 @@ def _position(value) -> float:
|
||||
return float(value)
|
||||
|
||||
|
||||
def _links(conn, value) -> list:
|
||||
def _links(conn, value, user: dict, current=()) -> list:
|
||||
"""Verknüpfte Artikel: nur solche, die man selbst bearbeiten darf (sonst verschaffte die Verknüpfung
|
||||
Bearbeitungsrechte); schon verknüpfte dürfen bleiben."""
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, list) or len(value) > MAX_LINKS or not all(isinstance(v, str) for v in value):
|
||||
@@ -124,6 +186,8 @@ def _links(conn, value) -> list:
|
||||
)} if ids else set()
|
||||
if len(found) != len(ids):
|
||||
raise ValidationError("Ein verknüpfter Artikel existiert nicht mehr.")
|
||||
if any(a not in current and not can_edit_article(conn, user, a) for a in ids):
|
||||
raise ValidationError("Verknüpfen lassen sich nur Artikel, die du bearbeiten darfst.")
|
||||
return ids
|
||||
|
||||
|
||||
@@ -146,15 +210,14 @@ def _project_links(conn) -> dict:
|
||||
return links
|
||||
|
||||
|
||||
def _projects(conn, project_id=None) -> list:
|
||||
def _projects(conn, user: dict, project_id=None) -> list:
|
||||
rows = conn.execute(
|
||||
"""SELECT k.*, cu.display_name AS created_by_name, uu.display_name AS updated_by_name
|
||||
FROM kanban_projects k
|
||||
LEFT JOIN users cu ON cu.id = k.created_by
|
||||
LEFT JOIN users uu ON uu.id = k.updated_by"""
|
||||
+ (" WHERE k.id = ?" if project_id is not None else "")
|
||||
+ " ORDER BY k.position",
|
||||
(project_id,) if project_id is not None else (),
|
||||
f"""SELECT k.*, cu.display_name AS created_by_name, uu.display_name AS updated_by_name
|
||||
FROM kanban_projects k
|
||||
LEFT JOIN users cu ON cu.id = k.created_by
|
||||
LEFT JOIN users uu ON uu.id = k.updated_by
|
||||
WHERE {_VISIBLE}""" + (" AND k.id = :id" if project_id is not None else "") + " ORDER BY k.position",
|
||||
{"id": project_id, **_who(user)},
|
||||
).fetchall()
|
||||
counts = {}
|
||||
for r in conn.execute("SELECT project_id, status, COUNT(*) AS n FROM kanban_tasks GROUP BY project_id, status"):
|
||||
@@ -173,18 +236,28 @@ def _projects(conn, project_id=None) -> list:
|
||||
"createdBy": _person(r["created_by"], r["created_by_name"]),
|
||||
"updatedAt": r["updated_at"],
|
||||
"updatedBy": _person(r["updated_by"], r["updated_by_name"]),
|
||||
"members": _member_ids(conn, r["id"]),
|
||||
"canManage": _may_delete(user, r["created_by"]),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def _overview(conn) -> dict:
|
||||
def _articles(conn, user: dict) -> list:
|
||||
"""Artikel zum Verknüpfen; editable = darf man selbst verknüpfen."""
|
||||
return [
|
||||
{"id": a["id"], "title": a["title"], "editable": can_edit_article(conn, user, a["id"])}
|
||||
for a in conn.execute("SELECT id, title FROM projects ORDER BY title COLLATE NOCASE")
|
||||
]
|
||||
|
||||
|
||||
def _overview(conn, user: dict) -> dict:
|
||||
cleanup(conn)
|
||||
articles = conn.execute("SELECT id, title FROM projects ORDER BY title COLLATE NOCASE").fetchall()
|
||||
return {
|
||||
"projects": _projects(conn),
|
||||
"articles": [{"id": a["id"], "title": a["title"]} for a in articles],
|
||||
"projects": _projects(conn, user),
|
||||
"articles": _articles(conn, user),
|
||||
"users": user_list(conn),
|
||||
"shareable": shareable(conn, user),
|
||||
}
|
||||
|
||||
|
||||
@@ -298,8 +371,8 @@ def _ensure_unlocked(conn, task_id: int, status: str) -> None:
|
||||
raise ValidationError("Gesperrt – erst auf „Wartet“ oder „Erledigt“ setzen: " + ", ".join(f"„{t}“" for t in missing))
|
||||
|
||||
|
||||
def _board(conn, project_id: int) -> dict:
|
||||
projects = _projects(conn, project_id)
|
||||
def _board(conn, project_id: int, user: dict) -> dict:
|
||||
projects = _projects(conn, user, project_id)
|
||||
if not projects:
|
||||
raise HTTPException(404, "Kanban-Projekt nicht gefunden – vielleicht wurde es gerade gelöscht.")
|
||||
project = projects[0]
|
||||
@@ -312,7 +385,6 @@ def _board(conn, project_id: int) -> dict:
|
||||
WHERE t.project_id = ? ORDER BY t.status, t.priority, t.position""",
|
||||
(project_id,),
|
||||
).fetchall()
|
||||
articles = conn.execute("SELECT id, title FROM projects ORDER BY title COLLATE NOCASE").fetchall()
|
||||
archive_before = iso_ago(ARCHIVE_AFTER)
|
||||
archived = sorted(
|
||||
(r for r in tasks if r["status"] == "erledigt" and r["done_at"] and r["done_at"] <= archive_before),
|
||||
@@ -328,8 +400,10 @@ def _board(conn, project_id: int) -> dict:
|
||||
"archived": [
|
||||
{**_task(r), **extras.get(r["id"], {}), "deleteAt": iso_after(r["done_at"], DELETE_AFTER)} for r in archived
|
||||
],
|
||||
"users": user_list(conn),
|
||||
"articles": [{"id": a["id"], "title": a["title"]} for a in articles],
|
||||
"users": user_list(conn), # Namen (auch ehemaliger Mitglieder) für die Anzeige
|
||||
"people": _people(conn, conn.execute("SELECT * FROM kanban_projects WHERE id = ?", (project_id,)).fetchone()),
|
||||
"shareable": shareable(conn, user),
|
||||
"articles": _articles(conn, user),
|
||||
}
|
||||
|
||||
|
||||
@@ -344,9 +418,9 @@ def _save_links(conn, project_id: int, links: list) -> None:
|
||||
|
||||
|
||||
@router.get("/api/kanban")
|
||||
def get_overview(_: dict = Depends(current_user)):
|
||||
def get_overview(user: dict = Depends(current_user)):
|
||||
with transaction() as conn:
|
||||
return _overview(conn)
|
||||
return _overview(conn, user)
|
||||
|
||||
|
||||
# ---------- Projekte ----------
|
||||
@@ -357,7 +431,8 @@ def create_project(data: dict = Body(...), user: dict = Depends(current_user)):
|
||||
name = _name(data.get("name"))
|
||||
description = _description(data.get("description"))
|
||||
with transaction() as conn:
|
||||
links = _links(conn, data.get("links"))
|
||||
links = _links(conn, data.get("links"), user)
|
||||
members = _members(conn, data.get("members", []), user, user["id"])
|
||||
if conn.execute("SELECT COUNT(*) FROM kanban_projects").fetchone()[0] >= MAX_PROJECTS:
|
||||
raise ValidationError(f"Maximal {MAX_PROJECTS} Kanban-Projekte.")
|
||||
now = now_iso()
|
||||
@@ -367,20 +442,20 @@ def create_project(data: dict = Body(...), user: dict = Depends(current_user)):
|
||||
(name, description, _next(conn, "kanban_projects"), now, user["id"], now, user["id"]),
|
||||
).lastrowid
|
||||
_save_links(conn, project_id, links)
|
||||
return {"id": project_id, **_overview(conn)}
|
||||
_save_members(conn, project_id, members)
|
||||
return {"id": project_id, **_overview(conn, user)}
|
||||
|
||||
|
||||
@router.get("/api/kanban/projekte/{project_id}")
|
||||
def get_board(project_id: int, _: dict = Depends(current_user)):
|
||||
def get_board(project_id: int, user: dict = Depends(current_user)):
|
||||
with transaction() as conn:
|
||||
return _board(conn, project_id)
|
||||
return _board(conn, project_id, user)
|
||||
|
||||
|
||||
@router.patch("/api/kanban/projekte/{project_id}")
|
||||
def update_project(project_id: int, data: dict = Body(...), user: dict = Depends(current_user)):
|
||||
with transaction() as conn:
|
||||
if not conn.execute("SELECT 1 FROM kanban_projects WHERE id = ?", (project_id,)).fetchone():
|
||||
raise HTTPException(404, "Kanban-Projekt nicht gefunden.")
|
||||
project = _get_project(conn, project_id, user)
|
||||
values = {}
|
||||
if "name" in data:
|
||||
values["name"] = _name(data["name"])
|
||||
@@ -391,32 +466,36 @@ def update_project(project_id: int, data: dict = Body(...), user: dict = Depends
|
||||
raise ValidationError("Ungültiger Wert für abgeschlossen.")
|
||||
values["completed_at"] = now_iso() if data["completed"] else None
|
||||
if "links" in data:
|
||||
_save_links(conn, project_id, _links(conn, data["links"]))
|
||||
current = [r["article_id"] for r in conn.execute("SELECT article_id FROM kanban_project_links WHERE project_id = ?", (project_id,))]
|
||||
_save_links(conn, project_id, _links(conn, data["links"], user, current))
|
||||
if "members" in data:
|
||||
if not _may_delete(user, project["created_by"]):
|
||||
raise HTTPException(403, "Mitglieder verwalten dürfen nur wer das Projekt angelegt hat und Administratoren.")
|
||||
_save_members(conn, project_id, _members(conn, data["members"], user, project["created_by"], _member_ids(conn, project_id)))
|
||||
values.update(updated_at=now_iso(), updated_by=user["id"])
|
||||
columns = ", ".join(f"{k} = ?" for k in values) # Schlüssel stammen nur aus der Liste oben
|
||||
conn.execute(f"UPDATE kanban_projects SET {columns} WHERE id = ?", (*values.values(), project_id))
|
||||
return _board(conn, project_id)
|
||||
return _board(conn, project_id, user)
|
||||
|
||||
|
||||
@router.delete("/api/kanban/projekte/{project_id}")
|
||||
def delete_project(project_id: int, user: dict = Depends(current_user)):
|
||||
with transaction() as conn:
|
||||
row = conn.execute("SELECT * FROM kanban_projects WHERE id = ?", (project_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Kanban-Projekt nicht gefunden.")
|
||||
row = _get_project(conn, project_id, user)
|
||||
if not _may_delete(user, row["created_by"]):
|
||||
raise HTTPException(403, "Nur wer das Projekt angelegt hat oder ein Administrator darf es löschen.")
|
||||
conn.execute("DELETE FROM kanban_projects WHERE id = ?", (project_id,)) # Aufgaben & Links per CASCADE
|
||||
return _overview(conn)
|
||||
return _overview(conn, user)
|
||||
|
||||
|
||||
# ---------- Aufgaben ----------
|
||||
|
||||
|
||||
def _get_task(conn, task_id: int):
|
||||
def _get_task(conn, task_id: int, user: dict):
|
||||
row = conn.execute("SELECT * FROM kanban_tasks WHERE id = ?", (task_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Aufgabe nicht gefunden – vielleicht wurde sie gerade gelöscht.")
|
||||
_get_project(conn, row["project_id"], user)
|
||||
return row
|
||||
|
||||
|
||||
@@ -432,8 +511,7 @@ def create_task(project_id: int, data: dict = Body(...), user: dict = Depends(cu
|
||||
priority = _priority(data.get("priority", "mittel"))
|
||||
checklist = _checklist(data.get("checklist", []))
|
||||
with transaction() as conn:
|
||||
if not conn.execute("SELECT 1 FROM kanban_projects WHERE id = ?", (project_id,)).fetchone():
|
||||
raise HTTPException(404, "Kanban-Projekt nicht gefunden.")
|
||||
project = _get_project(conn, project_id, user)
|
||||
requires = _requires(conn, data.get("requires", []), project_id)
|
||||
if conn.execute("SELECT COUNT(*) FROM kanban_tasks WHERE project_id = ?", (project_id,)).fetchone()[0] >= MAX_TASKS:
|
||||
raise ValidationError(f"Maximal {MAX_TASKS} Aufgaben pro Projekt. Bitte erledigte Aufgaben löschen.")
|
||||
@@ -442,28 +520,28 @@ def create_task(project_id: int, data: dict = Body(...), user: dict = Depends(cu
|
||||
"""INSERT INTO kanban_tasks (project_id, title, description, status, assignee, priority, position,
|
||||
done_at, created_at, created_by, updated_at, updated_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(project_id, title, description, status, _assignee(conn, data.get("assignee")), priority,
|
||||
(project_id, title, description, status, _assignee(conn, data.get("assignee"), project), priority,
|
||||
_end_of(conn, project_id, status), now if status == "erledigt" else None, now, user["id"], now, user["id"]),
|
||||
)
|
||||
task_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
||||
_save_checklist(conn, task_id, checklist)
|
||||
_save_requires(conn, task_id, requires)
|
||||
_ensure_unlocked(conn, task_id, status)
|
||||
return _board(conn, project_id)
|
||||
return _board(conn, project_id, user)
|
||||
|
||||
|
||||
@router.patch("/api/kanban/aufgaben/{task_id}")
|
||||
def update_task(task_id: int, data: dict = Body(...), user: dict = Depends(current_user)):
|
||||
"""Felder einzeln ändern. Neuer Status oder neue Priorität ohne Position: ans Ende der Stufe."""
|
||||
with transaction() as conn:
|
||||
row = _get_task(conn, task_id)
|
||||
row = _get_task(conn, task_id, user)
|
||||
values = {}
|
||||
if "title" in data:
|
||||
values["title"] = _name(data["title"], "Titel", MAX_TITLE)
|
||||
if "description" in data:
|
||||
values["description"] = _description(data["description"])
|
||||
if "assignee" in data:
|
||||
values["assignee"] = _assignee(conn, data["assignee"])
|
||||
values["assignee"] = _assignee(conn, data["assignee"], _get_project(conn, row["project_id"], user))
|
||||
if "status" in data:
|
||||
values["status"] = _status(data["status"])
|
||||
if values["status"] != row["status"]:
|
||||
@@ -490,39 +568,40 @@ def update_task(task_id: int, data: dict = Body(...), user: dict = Depends(curre
|
||||
values.update(updated_at=now_iso(), updated_by=user["id"])
|
||||
columns = ", ".join(f"{k} = ?" for k in values) # Schlüssel stammen nur aus der Liste oben
|
||||
conn.execute(f"UPDATE kanban_tasks SET {columns} WHERE id = ?", (*values.values(), task_id))
|
||||
return _board(conn, row["project_id"])
|
||||
return _board(conn, row["project_id"], user)
|
||||
|
||||
|
||||
@router.post("/api/kanban/aufgaben/{task_id}/zurueckholen")
|
||||
def restore_task(task_id: int, user: dict = Depends(current_user)):
|
||||
"""Archivierte Aufgabe zurück in die Spalte "Erledigt"; Archiv- und Lösch-Frist beginnen neu."""
|
||||
with transaction() as conn:
|
||||
row = _get_task(conn, task_id)
|
||||
row = _get_task(conn, task_id, user)
|
||||
now = now_iso()
|
||||
conn.execute(
|
||||
"UPDATE kanban_tasks SET status = 'erledigt', done_at = ?, updated_at = ?, updated_by = ? WHERE id = ?",
|
||||
(now, now, user["id"], task_id),
|
||||
)
|
||||
return _board(conn, row["project_id"])
|
||||
return _board(conn, row["project_id"], user)
|
||||
|
||||
|
||||
@router.delete("/api/kanban/aufgaben/{task_id}")
|
||||
def delete_task(task_id: int, user: dict = Depends(current_user)):
|
||||
with transaction() as conn:
|
||||
row = _get_task(conn, task_id)
|
||||
row = _get_task(conn, task_id, user)
|
||||
if not _may_delete(user, row["created_by"]):
|
||||
raise HTTPException(403, "Nur wer die Aufgabe angelegt hat oder ein Administrator darf sie löschen.")
|
||||
conn.execute("DELETE FROM kanban_tasks WHERE id = ?", (task_id,))
|
||||
return _board(conn, row["project_id"])
|
||||
return _board(conn, row["project_id"], user)
|
||||
|
||||
|
||||
# ---------- Für die Artikelseite ----------
|
||||
|
||||
|
||||
def boards_for_article(conn, article_id: str) -> list:
|
||||
def boards_for_article(conn, article_id: str, user: dict) -> list:
|
||||
"""Verknüpfte Boards, die das Konto sehen darf."""
|
||||
rows = conn.execute(
|
||||
"""SELECT k.id, k.name FROM kanban_project_links l JOIN kanban_projects k ON k.id = l.project_id
|
||||
WHERE l.article_id = ? ORDER BY k.name COLLATE NOCASE""",
|
||||
(article_id,),
|
||||
f"""SELECT k.id, k.name FROM kanban_project_links l JOIN kanban_projects k ON k.id = l.project_id
|
||||
WHERE l.article_id = :a AND {_VISIBLE} ORDER BY k.name COLLATE NOCASE""",
|
||||
{"a": article_id, **_who(user)},
|
||||
).fetchall()
|
||||
return [{"id": r["id"], "name": r["name"]} for r in rows]
|
||||
Reference in new issue
Block a user