fix: side peek des bases repasse en vanilla JS + largeur 1100px standard (v7.49.0)
FlowDeck CI / test (push) Failing after 3h13m58s
FlowDeck CI / lint (push) Successful in 2m12s
FlowDeck CI / docker (push) Skipped

- Panneau peek: les bindings Alpine (x-data absent du conteneur) rendaient
  loovverture et le redimensionnement inoperants -> cblage direct sur le document.
- Helper unique window.fdWirePeekResize (app.js): pointer capture, 300px-90vw,
  clic=fermer, largeur persiste fd_peek_width partagee entre les 4 peeks.
- database-table-container margin:0 (tableau colle a gauche, marge Library).
- .lib-container remonte dans app.css (trash etait pleine largeur), .db-index 1100px.
- ObsiGate verifie sans code: creation .xlsx OK (openpyxl, #186).
This commit is contained in:
2026-10-05 22:47:36 -04:00
parent 894004a1f5
commit 1d1cdbd618
63 changed files with 9473 additions and 393 deletions
+7
View File
@@ -104,6 +104,10 @@ def update_user(user_id: int, request: Request, _admin=Depends(admin_required),
def delete_user(user_id: int, _admin=Depends(admin_required)):
"""Delete a user and cascade their data."""
from app.db import get_conn
from app.services.collection_lifecycle import (
delete_collections,
workspace_collection_ids,
)
with get_conn() as conn:
user = conn.execute("SELECT id FROM users WHERE id=?", (user_id,)).fetchone()
if not user:
@@ -121,6 +125,9 @@ def delete_user(user_id: int, _admin=Depends(admin_required)):
# Delete workspaces owned by this user
ws_rows = conn.execute("SELECT id FROM workspaces WHERE owner_id=?", (user_id,)).fetchall()
for ws in ws_rows:
# Les bases du workspace tombent avec lui (sinon : collections
# orphelines listées par /db et My Tasks).
delete_collections(conn, workspace_collection_ids(conn, ws["id"]))
conn.execute("DELETE FROM pages WHERE workspace_id=?", (ws["id"],))
conn.execute("DELETE FROM workspace_members WHERE workspace_id=?", (ws["id"],))
conn.execute("DELETE FROM workspaces WHERE owner_id=?", (user_id,))
+2 -1
View File
@@ -27,6 +27,7 @@ from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est ut
)
from app.services.automations import fire_event as _fire_event
from app.services.automations import run_event_sync
from app.services.collection_lifecycle import delete_collections
from ._common import _v2_rate_check
@@ -152,7 +153,7 @@ def delete_collection_v2(collection_id: int, request: Request, authorization: st
row = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
if not row:
raise HTTPException(404, "Collection not found")
conn.execute("DELETE FROM collections WHERE id=?", (collection_id,))
delete_collections(conn, [collection_id])
conn.commit()
audit_log(user, "collection.delete", "collection", collection_id, "", request)
return {"id": collection_id, "status": "deleted"}
+6 -3
View File
@@ -156,7 +156,10 @@ async def create_agent_v2(request: Request, authorization: str | None = Header(d
return JSONResponse(content=data, status_code=201)
@router.get("/agents/{agent_id}")
# v7.46.0 : `{agent_id:int}` — sans le contrainte de type, la routeparametrée
# enregistree AVANT `/agents/conversations` capturait le segment « conversations »
# et renvoyait 422 (int_parsing) au lieu de la liste des conversations.
@router.get("/agents/{agent_id:int}")
def get_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)):
_guard(request, authorization)
with get_conn() as conn:
@@ -166,7 +169,7 @@ def get_agent_v2(agent_id: int, request: Request, authorization: str | None = He
return row_to_dict(row)
@router.put("/agents/{agent_id}")
@router.put("/agents/{agent_id:int}")
async def update_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)):
user = _guard(request, authorization, write=True)
body = await _json_body(request)
@@ -194,7 +197,7 @@ async def update_agent_v2(agent_id: int, request: Request, authorization: str |
return {"id": agent_id, "status": "updated"}
@router.delete("/agents/{agent_id}")
@router.delete("/agents/{agent_id:int}")
def delete_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)):
user = _guard(request, authorization, write=True)
with get_conn() as conn:
+2 -1
View File
@@ -475,10 +475,11 @@ def logout(request: Request):
def current_user(request: Request):
"""Return current user info as JSON."""
from app.auth.session import get_current_user as gcu
from app.auth.session import public_user
user = gcu(request)
if not user:
return {"authenticated": False}
return {"authenticated": True, "user": user}
return {"authenticated": True, "user": public_user(user)}
# ── v7.2.0 — TOTP 2FA ─────────────────────────────────────────────────────
+4
View File
@@ -485,6 +485,10 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
active_ws_name = row["name"]
workspace_pages = _load_workspace_pages(ws_cookie)
has_active_workspace = True
# v7.46.0 : comme pour le sidebar dashboard, exposer l'ID de
# l'espace ACTIF (le bouton Home de base.html pointait sinon
# sur ``local_workspaces[0]``, premier espace trie par nom).
local_ws_id = wsi
except (ValueError, Exception):
pass
recent = []
+22 -2
View File
@@ -12,6 +12,11 @@ from fastapi.responses import HTMLResponse
from app.auth.session import SessionManager
from app.db import get_conn
from app.services.automations import fire_event, run_event_sync
from app.services.collection_lifecycle import (
collections_hosted_by_page,
collections_hosted_by_pages,
delete_collections,
)
from app.services.publish import fire_published, fire_unpublished, publish, unpublish
from ._common import _sidebar_data
@@ -176,10 +181,15 @@ def restore_page(request: Request, page_id: int):
def permanent_delete(request: Request, page_id: int):
_trash_session(request)
with get_conn() as conn:
# La page hôte d'une base disparaît définitivement → sa base et ses
# lignes aussi, sinon My Tasks (et /db) continuaient de lister des
# tâches sans document.
collections = collections_hosted_by_page(conn, page_id)
conn.execute("UPDATE pages SET parent_id=NULL WHERE parent_id=?", (page_id,))
conn.execute("DELETE FROM pages WHERE id=? AND deleted_at IS NOT NULL", (page_id,))
delete_collections(conn, collections)
conn.commit()
return {"status": "ok", "deleted": page_id}
return {"status": "ok", "deleted": page_id, "collections": len(collections)}
@@ -193,13 +203,23 @@ def empty_trash(request: Request):
n = conn.execute(
"SELECT COUNT(*) FROM pages WHERE deleted_at IS NOT NULL"
).fetchone()[0]
# Bases portées par les pages purgées (les pages contenu de ligne
# n'hébergent aucune base et ne remontent rien).
trashed = [
r["id"]
for r in conn.execute(
"SELECT id FROM pages WHERE deleted_at IS NOT NULL"
).fetchall()
]
collections = collections_hosted_by_pages(conn, trashed)
conn.execute(
"UPDATE pages SET parent_id=NULL WHERE parent_id IN "
"(SELECT id FROM pages WHERE deleted_at IS NOT NULL)"
)
conn.execute("DELETE FROM pages WHERE deleted_at IS NOT NULL")
delete_collections(conn, collections)
conn.commit()
return {"status": "ok", "deleted": n}
return {"status": "ok", "deleted": n, "collections": len(collections)}
+27 -6
View File
@@ -16,23 +16,41 @@ router = APIRouter(tags=["board"], prefix="/board")
# ═══════════════ v5.14.0: Synced Blocks ─═══════════════════
# These routes MUST be registered before the catch-all /{owner}/{repo} below.
# ═══════════════ v5.14.0: Synced Blocks ─═══════════════════# These routes MUST be registered before the catch-all /{owner}/{repo} below.
def _session_user(request: Request) -> dict:
"""Session obligatoire — sans garde, un appel anonyme levait
``AttributeError: 'NoneType' object has no attribute 'get'`` (HTTP 500)
au lieu d'un 401."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
raise HTTPException(401, "Authentication required")
return user
def _assert_can_edit_block(request: Request, sb: dict) -> None:
"""Seul l'auteur du bloc (ou un admin global) peut le modifier/supprimer."""
user = _session_user(request)
if sb.get("created_by") in (None, user.get("id")):
return
if user.get("is_admin"):
return
raise HTTPException(403, "Not the author of this synced block")
@router.get("/api/synced-blocks")
def list_synced_blocks_api(request: Request, workspace: str = Query(default="")):
"""List synced blocks for a workspace."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
user = _session_user(request)
from app.services.synced_blocks import list_synced_blocks
return {"synced_blocks": list_synced_blocks(workspace or user.get("login", ""))}
@router.post("/api/synced-blocks")
def create_synced_block_api(request: Request, body: dict = Body(...)):
"""Create a new synced block."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
user = _session_user(request)
from app.services.synced_blocks import create_synced_block
sid = create_synced_block(
workspace=body.get("workspace", ""),
@@ -61,6 +79,7 @@ async def update_synced_block_api(request: Request, sid: int):
sb = get_synced_block(sid)
if not sb:
raise HTTPException(404, "Synced block not found")
_assert_can_edit_block(request, sb)
update_synced_block(sid, body.get("title", sb["title"]), body.get("content", []))
# v6.5.0: rewrite every referencing page's stored content first (DB row
# content pages included), THEN push the realtime update so open rooms
@@ -86,6 +105,7 @@ async def delete_synced_block_api(request: Request, sid: int):
sb = get_synced_block(sid)
if not sb:
raise HTTPException(404, "Synced block not found")
_assert_can_edit_block(request, sb)
# v6.5.0: collect referencing pages BEFORE the FK cascade wipes the
# refs, rewrite their stored content (deleted state), then broadcast.
pids = page_ids_for_synced(sid)
@@ -99,8 +119,9 @@ async def delete_synced_block_api(request: Request, sid: int):
@router.get("/api/synced-blocks/{sid}")
def get_synced_block_api(sid: int):
def get_synced_block_api(request: Request, sid: int):
"""Get a synced block by id."""
_session_user(request)
from app.services.synced_blocks import get_synced_block
sb = get_synced_block(sid)
if not sb:
+9
View File
@@ -17,11 +17,13 @@ from . import ( # ordre = ordre d'enregistrement d'origine
crud,
dashboard_views,
data_api,
index_page,
linked,
meta,
pages,
properties,
structure,
task_db,
views,
)
from ._common import _validate_page_properties # noqa: F401
@@ -34,7 +36,14 @@ from ._renderers import ( # noqa: F401 — ré-exports tests
logger = logging.getLogger(__name__)
router = APIRouter()
# `task_db` est enregistré **en premier** : sa route statique
# `GET /db/task-dbs/api` serait sinon capturée par `data_api`'s
# `GET /db/{collection_id}/api` (`collection_id="task-dbs"` → 422).
# `index_page` (la page HTML `/db`) vient ensuite, avant tout module
# déclarant `/db/{collection_id}` — sinon la racine serait capturée en 422.
for _mod in (
task_db,
index_page,
crud,
pages,
boards,
+34 -21
View File
@@ -8,10 +8,11 @@ import json
import logging
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import HTMLResponse
from app.db import get_conn
from app.routers.dashboard._common import _get_active_workspace
from app.services.automations import fire_event, run_event_sync
from app.services.collection_lifecycle import delete_collections
from app.services.db_templates import materialize_properties
from app.services.permission_manager import PermissionManager
@@ -24,22 +25,10 @@ router = APIRouter(tags=["collections"], prefix="/db")
# ── API: List & Create (no path params) ──
@router.get("", response_class=HTMLResponse)
def list_collections(request: Request):
"""Page listing all collections in the workspace."""
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM collections ORDER BY name"
).fetchall()
collections = [dict(r) for r in rows]
return HTMLResponse(
f"<div class='collections-list'>"
f"<h2>Collections ({len(collections)})</h2>"
f"<pre>{json.dumps(collections, indent=2, default=str)}</pre>"
f"</div>"
)
#
# `GET /db` (la page HTML) vit dans `index_page.py` : elle était auparavant
# un dump JSON de debug, sans layout — alors qu'elle sert de destination au
# bouton « Ouvrir mes bases » de My Tasks.
@@ -71,6 +60,24 @@ def create_collection_api(request: Request, body: dict = Body(default={})):
schema = body.get("schema", [])
is_locked = body.get("is_locked", False)
# Workspace d'appartenance : celui du body si fourni et autorisé, sinon le
# workspace actif de l'utilisateur. Sans cela la collection reste orpheline
# et n'apparaît dans aucune vue scopée (ex. /my-tasks).
user = _session_user(request)
workspace_id = body.get("workspace_id")
if workspace_id is not None:
try:
workspace_id = int(workspace_id)
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="workspace_id must be an integer") from None
with get_conn() as conn:
exists = conn.execute("SELECT 1 FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
if not exists:
raise HTTPException(status_code=400, detail="workspace_id does not exist")
else:
active = _get_active_workspace(request, (user or {}).get("id"))
workspace_id = active["id"] if active else None
with get_conn() as conn:
# Apply a template if requested (provides schema + icon).
tpl = _apply_template(conn, body.get("template"))
@@ -88,9 +95,11 @@ def create_collection_api(request: Request, body: dict = Body(default={})):
cur = conn.execute(
"""INSERT INTO collections
(name, description, icon, schema_json, gitea_owner, gitea_repo, is_locked)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(name, description, icon, schema_json, gitea_owner, gitea_repo, int(is_locked)),
(name, description, icon, schema_json, gitea_owner, gitea_repo, is_locked,
workspace_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(name, description, icon, schema_json, gitea_owner, gitea_repo, int(is_locked),
workspace_id),
)
collection_id = cur.lastrowid
@@ -171,7 +180,11 @@ def delete_collection_api(request: Request, collection_id: int):
if not existing:
raise HTTPException(status_code=404, detail="Collection not found")
conn.execute("DELETE FROM collections WHERE id=?", (collection_id,))
# `delete_collections` détache la page hôte et les vues liées avant la
# suppression : `pages.collection_id` et
# `collection_data_sources.source_collection_id` sont en NO ACTION, la
# suppression brute levait un IntegrityError.
delete_collections(conn, [collection_id])
conn.commit()
run_event_sync(fire_event("collection.deleted", {"collection_id": collection_id,
+259
View File
@@ -0,0 +1,259 @@
"""FlowDeck — Collections : la page « /db » (liste des bases).
`GET /db` était un **dump JSON de debug** (le HTML renvoyé était un
`<pre>` dans le vide, sans layout) : c'est pourtant la destination du bouton
« Ouvrir mes bases » de My Tasks, et le point d'entrée pour convertir une base
en base de tâches. On le remplace par une vraie page :
* bases **du workspace courant** d'abord (ce que voit l'utilisateur dans la
sidebar), puis celles de ses **autres workspaces** dans une section séparée :
sans cela, un utilisateur dont le workspace actif ne contient aucune base
tombe sur une page vide alors qu'il en possède ailleurs ;
* indicateur « Base de tâches » / « À configurer » par base ;
* accès direct à la page de la base, où se trouve le bouton de conversion.
**Rattachement d'une base à un workspace.** `collections.workspace_id` n'est
renseigné que pour les bases créées via `/db/api`. Une base née d'un document
(page convertie en base, base inline) a `workspace_id` **NULL** et n'appartient
qu'à travers sa page hôte : la résout par `COALESCE(workspace_id, page hôte)`
(`app/services.collection_lifecycle.effective_workspace_sql`). Filtrer sur
`workspace_id`, ou faire un `JOIN workspaces`, faisait disparaître ces bases de
la page alors qu'elles sont listées dans la sidebar du workspace — l'utilisateur
les voyait sans pouvoir les ouvrir.
Le filtre de vivacité (`live_collection_ids`) est appliqué : une base dont la
page hôte est à la corbeille n'est pas proposée, puisqu'elle est inaccessible.
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from app.db import get_conn
from app.routers.dashboard._common import _get_active_workspace
from app.services.collection_lifecycle import (
effective_workspace_sql,
live_collection_ids,
user_workspace_ids,
)
from app.templating import ENV
from ._common import _session_user
logger = logging.getLogger(__name__)
router = APIRouter(tags=["collections"], prefix="/db")
#: Colonnes communes aux deux listes (carte de base). `c.` qualification
#: obligatoire : le nom de la collection d'icônes homonyme rendrait `rows` et
#: `id` ambigus.
_CARD_COLUMNS = """
c.id, c.name, c.icon, c.parent_page_id, c.workspace_id, c.is_task,
c.task_assignee_prop, c.task_status_prop, c.task_due_prop,
(SELECT COUNT(*) FROM collection_pages cp
WHERE cp.collection_id = c.id AND cp.parent_id IS NULL) AS rows
"""
def _esc(value) -> str:
return (
str(value if value is not None else "")
.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
.replace('"', "&quot;").replace("'", "&#39;")
)
def _card(r: dict) -> str:
"""Une carte de base : état My Tasks + ce qu'il reste à faire."""
target = (f"/pages/{r['parent_page_id']}" if r["parent_page_id"]
else f"/db/{r['id']}")
labels = {"assignee": "Assigné à", "status": "Statut", "due": "Échéance"}
missing = [role for role in ("assignee", "status", "due")
if not r.get(f"task_{role}_prop")]
if r["is_task"] and not missing:
badge = '<span class="db-card-badge ok">Base de tâches</span>'
hint = "Alimente My Tasks."
elif r["is_task"]:
badge = '<span class="db-card-badge warn">À configurer</span>'
hint = ("Il manque : " + ", ".join(labels[m] for m in missing)
+ ". Ouvrez la base puis « Configurer » dans sa barre.")
else:
badge = ""
hint = "Ouvrez-la puis cliquez sur « Convertir en base de tâches »."
# `workspace_id` est NULL pour une base portée par une page : on affiche le
# nom du workspace effectif plutôt que celui de la colonne brute.
ws_label = r.get("ws_label") or ""
meta = f'{r["rows"]} ligne(s)'
if ws_label:
meta += f" · {ws_label}"
return f"""<a class="db-card" href="{_esc(target)}">
<div class="db-card-head">
<span class="db-card-icon">{_esc(r["icon"] or "📋")}</span>
<span class="db-card-name">{_esc(r["name"])}</span>
{badge}
</div>
<div class="db-card-meta">{_esc(meta)}</div>
<div class="db-card-hint">{_esc(hint)}</div>
</a>"""
@router.get("", response_class=HTMLResponse)
def list_collections(request: Request):
"""Liste des bases du workspace, dans le layout de l'application."""
user = _session_user(request)
if not user:
from fastapi.responses import RedirectResponse
return RedirectResponse("/auth/login?provider=local", status_code=302)
with get_conn() as conn:
workspace = _get_active_workspace(request, user.get("id")) or {}
ws_id = workspace.get("id")
ws_name = workspace.get("name") or "Workspace"
rows = _collections_of(conn, ws_id) if ws_id else []
# Bases des autres workspaces : la page ne doit jamais laisser
# l'utilisateur sans piste s'il en possède ailleurs.
current_ids = {r["id"] for r in rows}
others = [r for r in _all_collections(conn, user.get("id"), ws_id)
if r["id"] not in current_ids]
connected = sum(1 for r in rows if r["is_task"])
return HTMLResponse(_render(request, ws_name, rows, connected, bool(ws_id),
others))
def _collections_of(conn, ws_id) -> list[dict]:
"""Bases d'un workspace, vivantes seulement.
`live_collection_ids` écarte les bases dont la page hôte a disparu (corbeille
ou suppression) : elles sont inaccessibles, les proposer serait trompeur.
Le rattachement passe par le workspace **effectif** : une base créée depuis
un document a `workspace_id` NULL et n'est repérée que via sa page hôte.
"""
live = set(live_collection_ids(conn, ws_id))
rows = conn.execute(
f"""SELECT {_CARD_COLUMNS}
FROM collections c
WHERE {effective_workspace_sql("c")} = ?
ORDER BY c.name""",
(ws_id,),
).fetchall()
return [dict(r) for r in rows if r["id"] in live]
def _all_collections(conn, user_id, current_ws_id=None) -> list[dict]:
"""Toutes les bases accessibles à l'utilisateur, tous workspaces confondus.
Sert à la section « Autres workspaces » : l'utilisateur doit pouvoir
retrouver une base même si elle n'est pas dans le workspace actif (My Tasks
étant transverse, ces bases l'alimentent aussi).
Le périmètre est celui de `user_workspace_ids` (propriétaire **ou** membre) :
sans ce filtre, la page exposait les bases de tous les utilisateurs de
l'instance. Les bases sans workspace rattachable sont exclues — elles
n'appartiennent à personne.
"""
ws_ids = user_workspace_ids(conn, user_id)
if not ws_ids:
return []
qs = ",".join("?" * len(ws_ids))
names = {
r["id"]: r["name"]
for r in conn.execute(
f"SELECT id, name FROM workspaces WHERE id IN ({qs})", ws_ids
).fetchall()
}
rows = conn.execute(
f"""SELECT {_CARD_COLUMNS},
{effective_workspace_sql("c")} AS ws_effective
FROM collections c
WHERE {effective_workspace_sql("c")} IN ({qs})
ORDER BY c.name""",
ws_ids,
).fetchall()
live: dict[int, set[int]] = {}
out = []
for r in rows:
d = dict(r)
ws = d["ws_effective"]
if ws not in live:
live[ws] = set(live_collection_ids(conn, ws))
if d["id"] not in live[ws]:
continue
# Le workspace courant est déjà indiqué dans l'en-tête de la page.
d["ws_label"] = "" if ws == current_ws_id else names.get(ws, "")
out.append(d)
return out
def _render(request: Request, ws_name: str, rows: list[dict], connected: int,
has_ws: bool, others: list[dict] | None = None) -> str:
"""Rendu de la page, dans le layout commun (sidebar + base.html)."""
from app.routers.dashboard import _sidebar_data
others = others or []
blocks = [_card(r) for r in rows]
other_blocks = [_card(r) for r in others]
if rows or others:
total = len(rows) + len(others)
summary = (f"{connected} base(s) de tâches connectée(s) sur {total}"
if connected else
f"Aucune base de tâches connectée sur {total}")
sections = f"""<div class="db-grid">{''.join(blocks)}</div>"""
if other_blocks:
sections += ('<h2 class="db-section-title">Autres workspaces</h2>'
'<p class="db-section-sub">Ces bases appartiennent à '
"d'autres workspaces — elles alimentent tout de même "
f'My Tasks.</p><div class="db-grid">'
f"{''.join(other_blocks)}</div>")
body = f"""<div class="db-index">
<div class="db-index-head">
<h1>📋 Bases de données</h1>
<span class="db-index-ws">{_esc(ws_name)}</span>
</div>
<p class="db-index-sub">{summary} — les bases de tâches alimentent
<a href="/my-tasks">My Tasks</a>.</p>
{sections}
</div>"""
elif has_ws:
body = """<div class="db-empty">
<div class="db-empty-icon">📋</div>
<h1>Aucune base dans ce workspace</h1>
<p>Créez une page, puis convertissez-la en base de données — ou partez
d'un modèle depuis le menu d'ajout.</p>
<p class="db-empty-hint">Une base reliée à My Tasks apparaît ici avec son
état de configuration.</p>
</div>"""
else:
body = """<div class="db-empty">
<div class="db-empty-icon">📋</div>
<h1>Aucun workspace sélectionné</h1>
<p>Créez ou ouvrez d'abord un workspace : vos bases y seront rattachées.</p>
</div>"""
# `my_tasks.css` porte les styles `.db-*` de cette page (cartes, sections,
# états vides) **et** ceux de My Tasks. Elle est liée par `base.html`
# (le shell, jamais swappé) : la lier ici exposait la page à un <link>
# retiré par htmx lors d'une navigation partielle.
block_tpl = ENV.from_string(
'{% extends "base.html" %}'
"{% block content %}{{ content_html|safe }}{% endblock %}"
)
return block_tpl.render(
**_sidebar_data(request, []),
request=request,
content_html=body,
page_title="Bases de données",
title_prefix="Bases de données",
page_icon="grid",
)
+15 -4
View File
@@ -211,14 +211,25 @@ def create_inline_database(request: Request, body: dict = Body(default={})):
@router.put("/{collection_id}/toggle-task/api")
def toggle_task(request: Request, collection_id: int):
"""API: toggle is_task flag on a collection (Turn into Tasks)."""
"""API: toggle is_task flag on a collection (Turn into Tasks).
Rétrocompatible : l'activation passe désormais par la même conversion que
``POST /db/{id}/task-db/api``, donc les trois colonnes requises sont liées
(créées si besoin). Sans cela, cette route laissait une base « connectée »
mais muette — elle n'émettait aucune tâche faute de mapping.
"""
from .task_db import disable_task_db_in_conn, enable_task_db_in_conn
with get_conn() as conn:
coll = conn.execute("SELECT id, is_task FROM collections WHERE id=?", (collection_id,)).fetchone()
if not coll:
raise HTTPException(status_code=404, detail="Collection not found")
new_val = 0 if coll["is_task"] else 1
conn.execute("UPDATE collections SET is_task=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (new_val, collection_id))
conn.commit()
if coll["is_task"]:
disable_task_db_in_conn(conn, collection_id)
new_val = 0
else:
enable_task_db_in_conn(conn, collection_id)
new_val = 1
return {"collection_id": collection_id, "is_task": bool(new_val), "mode": "tasks" if new_val else "standard"}
+270
View File
@@ -0,0 +1,270 @@
"""FlowDeck — Collections : « base de tâches » (My Tasks façon Notion).
Unlike Notion — where any base can be added to the dashboard widget from
scratch — a base doit **explicitement** alimenter My Tasks :
``POST /db/{id}/task-db`` bascule ``collections.is_task`` et enregistre le
mapping des trois propriétés requises (Assigné à / Statut / Échéance).
Tant que le mapping est incomplet, la base est connectée mais **n'émet
aucune tâche** (`task_databases.collect_tasks` la saute) — l'UI le signale
plutôt que de deviner une colonne.
Limite : 10 bases connectées au tableau de bord (`MAX_TASK_SOURCES`).
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Body, HTTPException, Request
from app.db import get_conn
from app.services.task_databases import (
MAX_TASK_SOURCES,
ROLE_LABELS,
ROLE_TYPES,
TASK_ROLES,
list_task_sources,
source_state,
)
from ._common import _require_edit, _session_user
logger = logging.getLogger(__name__)
router = APIRouter(tags=["collections"], prefix="/db")
#: Trio de statuts amorcé quand une colonne Statut est créée à la conversion.
DEFAULT_STATUS_OPTIONS = [
{"name": "À faire", "color": "gray"},
{"name": "En cours", "color": "blue"},
{"name": "Terminée", "color": "green"},
]
def _user_id(request: Request) -> int:
"""ID de l'appelant — session obligatoire.
`_current_user` retombe sur l'admin (id 1) en cas d'absence de session :
wrong pour une route qui liste *les bases de l'utilisateur* (un anonyme
verrait le tableau de bord de l'admin). On passe donc par `_session_user`.
"""
user = _session_user(request)
if not user or not user.get("id"):
raise HTTPException(status_code=401, detail="Authentication required")
return user["id"]
@router.get("/task-dbs/api")
def list_connected_task_databases(request: Request):
"""Bases de tâches connectées au tableau de bord My Tasks."""
uid = _user_id(request)
with get_conn() as conn:
sources = list_task_sources(conn, uid)
return {
"sources": [
{k: s[k] for k in (
"id", "name", "icon", "workspace_id", "workspace_name",
"configured", "missing_roles", "status_options",
)}
for s in sources
],
"total": len(sources),
"max_sources": MAX_TASK_SOURCES,
"limit_reached": len(sources) >= MAX_TASK_SOURCES,
}
@router.get("/{collection_id}/task-db/api")
def get_task_db_state(request: Request, collection_id: int):
"""État de conversion + colonnes candidates pour la modale."""
_require_edit(collection_id, _session_user(request))
with get_conn() as conn:
state = source_state(conn, collection_id)
if not state["exists"]:
raise HTTPException(status_code=404, detail="Collection not found")
return state
@router.post("/{collection_id}/task-db/api")
def enable_task_db(request: Request, collection_id: int, body: dict = Body(default={})):
"""Convertit la base en base de tâches (mapping explicite des 3 rôles).
``mapping`` : ``{"assignee": <prop_id>, "status": …, "due": …}``.
``create_missing`` : ``{"status": {"name": "Statut", "options": [...]}}``
pour qu'une colonne absente soit créée dans la transaction.
"""
_require_edit(collection_id, _session_user(request))
with get_conn() as conn:
state = enable_task_db_in_conn(
conn,
collection_id,
mapping=body.get("mapping") or {},
create_missing=body.get("create_missing") or {},
)
return {"status": "enabled", "collection_id": collection_id,
"mapping": state["mapping"], "configured": state["configured"]}
def enable_task_db_in_conn(conn, collection_id: int, *, mapping: dict | None = None,
create_missing: dict | None = None) -> dict:
"""Active ``is_task`` et enregistre le mapping — dans la transaction
appelante.
Point d'entrée unique de la conversion : la route `/task-db/api` comme
l'ancien `PUT /db/{id}/toggle-task/api` (qui aurait sinon laissé une base
« connectée » sans aucune colonne liée, donc muette).
"""
state = source_state(conn, collection_id)
if not state["exists"]:
raise HTTPException(status_code=404, detail="Collection not found")
# ── Limite de sources (on recompte : la base elle-même peut déjà être
# connectée, auquel cas on ne la compte pas deux fois).
if not state["is_task"] and state["sources_count"] >= MAX_TASK_SOURCES:
raise HTTPException(
status_code=409,
detail=(
f"Limite de {MAX_TASK_SOURCES} bases de tâches atteinte. "
"Déconnectez une base avant d'en ajouter une autre."
),
)
# ── Création des colonnes manquantes ──
create_missing = create_missing or {}
mapping = dict(mapping or {})
by_name = {p["name"]: p for p in state["all_properties"]}
for role in TASK_ROLES:
if mapping.get(role):
continue
spec = create_missing.get(role) or {}
name = (spec.get("name") or ROLE_LABELS[role]).strip()
prop_type = spec.get("prop_type") or ROLE_TYPES[role][0]
if prop_type not in ROLE_TYPES[role]:
raise HTTPException(
status_code=400,
detail=f"Type '{prop_type}' incompatible avec le rôle '{role}'",
)
if name in by_name:
# La colonne existe déjà (nom saisi) → on la lie.
mapping[role] = by_name[name]["id"]
continue
created = _create_property(conn, collection_id, name, prop_type,
spec.get("options"))
mapping[role] = created["id"]
# ── Validation : les 3 rôles pointent une colonne de la bonne base ──
props = {
p["id"]: p
for p in conn.execute(
"SELECT id, name, prop_type FROM collection_properties WHERE collection_id=?",
(collection_id,),
).fetchall()
}
seen: dict[int, str] = {}
for role in TASK_ROLES:
prop_id = mapping.get(role)
if not prop_id:
raise HTTPException(
status_code=400,
detail=f"Colonne « {ROLE_LABELS[role]} » manquante",
)
try:
prop_id = int(prop_id)
except (TypeError, ValueError):
raise HTTPException(status_code=400,
detail="Identifiant de propriété invalide") from None
prop = props.get(prop_id)
if prop is None:
raise HTTPException(
status_code=400,
detail=f"« {ROLE_LABELS[role]} » n'appartient pas à cette base",
)
if prop["prop_type"] not in ROLE_TYPES[role]:
raise HTTPException(
status_code=400,
detail=(
f"« {prop['name']} » est de type {prop['prop_type']}, "
f"incompatible avec « {ROLE_LABELS[role]} »"
),
)
if prop_id in seen:
raise HTTPException(
status_code=400,
detail=f"« {prop['name']} » est déjà utilisé pour « {seen[prop_id]} »",
)
seen[prop_id] = ROLE_LABELS[role]
mapping[role] = prop_id
conn.execute(
"""UPDATE collections
SET is_task = 1,
task_assignee_prop = ?, task_status_prop = ?, task_due_prop = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?""",
(mapping["assignee"], mapping["status"], mapping["due"], collection_id),
)
conn.commit()
return source_state(conn, collection_id)
@router.delete("/{collection_id}/task-db/api")
def disable_task_db(request: Request, collection_id: int):
"""Déconnecte la base du tableau de bord (les tâches sont conservées)."""
_require_edit(collection_id, _session_user(request))
with get_conn() as conn:
disable_task_db_in_conn(conn, collection_id)
return {"status": "disabled", "collection_id": collection_id}
def disable_task_db_in_conn(conn, collection_id: int) -> None:
"""Déconnexion dans la transaction appelante : efface aussi le mapping,
pour ne pas laisser des identifiants de colonnes mortes."""
row = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
if row is None:
raise HTTPException(status_code=404, detail="Collection not found")
conn.execute(
"""UPDATE collections
SET is_task = 0, task_assignee_prop = NULL, task_status_prop = NULL,
task_due_prop = NULL, updated_at = CURRENT_TIMESTAMP
WHERE id = ?""",
(collection_id,),
)
conn.commit()
def _create_property(conn, collection_id: int, name: str, prop_type: str,
options=None) -> dict:
"""Crée une propriété (à la position suivante) et la renvoie.
Une colonne Statut amorcée sans options reçoit le trio classique
« À faire / En cours / Terminée » : sans options, le Kanban n'aurait
aucune colonne à afficher.
"""
import json
row = conn.execute(
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_properties "
"WHERE collection_id = ?",
(collection_id,),
).fetchone()
position = row[0] if row else 0
opts = []
for opt in options or []:
if isinstance(opt, str):
opts.append({"name": opt, "color": "gray"})
elif isinstance(opt, dict) and opt.get("name"):
opts.append({"name": opt["name"], "color": opt.get("color") or "gray"})
if not opts and prop_type in ("status", "select"):
opts = list(DEFAULT_STATUS_OPTIONS)
cur = conn.execute(
"""INSERT INTO collection_properties
(collection_id, name, prop_type, options_json, position, required,
visible_in_views)
VALUES (?, ?, ?, ?, ?, 0, 1)""",
(collection_id, name, prop_type, json.dumps(opts), position),
)
prop_id = cur.lastrowid
conn.commit()
return {"id": prop_id, "name": name, "prop_type": prop_type}
+11 -5
View File
@@ -174,6 +174,11 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
active_ws_name = row["name"]
workspace_pages = _load_workspace_pages(ws_cookie)
has_active_workspace = True
# v7.46.0 : exposer l'ID de l'espace ACTIF. Avant, le sidebar
# ne fournissait que son nom et le bouton Home de la sidebar
# retombait sur ``local_workspaces[0]`` (premier espace TRIE
# PAR NOM) → clic sur Home = changement d'espace surprise.
local_ws_id = wsi
# else: stale cookie from another user — ignore
except (ValueError, Exception):
pass
@@ -199,8 +204,9 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
except Exception:
logger.exception("_sidebar_data")
# Get local workspace ID for Gitea workspace mirror
local_ws_id = 0
# Get local workspace ID for Gitea workspace mirror (le miroir Gitea est un
# espace DISTINCT : on ne doit pas écraser ``local_ws_id`` (espace actif).
gitea_mirror_ws_id = 0
if gitea_workspace and gitea_owner and gitea_repo:
try:
with get_conn() as conn:
@@ -209,18 +215,18 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
(user["id"], f"{gitea_owner}/{gitea_repo}", "%gitea_repo%")
).fetchone()
if row:
local_ws_id = row["id"]
gitea_mirror_ws_id = row["id"]
except Exception:
logger.exception("_sidebar_data")
# Private pages for mirror workspace (when Gitea remote active)
private_pages = []
if gitea_workspace and local_ws_id:
if gitea_workspace and gitea_mirror_ws_id:
try:
with get_conn() as conn:
pp_rows = conn.execute(
"SELECT id, title FROM pages WHERE parent_section='Private' AND workspace_id=? AND deleted_at IS NULL ORDER BY updated_at DESC LIMIT 20",
(local_ws_id,)
(gitea_mirror_ws_id,)
).fetchall()
private_pages = [{"id": r[0], "title": r[1] or "Untitled"} for r in pp_rows]
except Exception:
+51 -4
View File
@@ -15,6 +15,7 @@ from app.config import settings
from app.db import get_conn
from ._common import (
WORKSPACE_COOKIE,
_build_breadcrumb,
_build_tree_children,
_file_page_disk_path,
@@ -34,10 +35,16 @@ router = APIRouter(tags=["dashboard"])
# ═══════════ Local Workspace (file/folder CRUD) ═══════════
@router.get("/local-workspace", response_class=HTMLResponse)
def local_workspace_page(request: Request, folder: int = None):
def local_workspace_page(request: Request, folder: int = None, ws: int = None):
"""Local workspace page with file/folder tree.
If ?folder=ID is provided, shows that folder's contents with breadcrumb.
If ?ws=ID is provided, shows that workspace — c'est ce que fait le bouton
« Home » de la sidebar (``/local-workspace?ws=<id>``). Le paramètre était
produit par le sidebar mais **ignoré** par la route : le contenu affiché
restait celui du workspace *actif* (cookie), donc Home pouvait montrer un
autre workspace que celui annoncé dans l'URL. Un ``ws`` qui n'appartient
pas à l'utilisateur est ignoré (et le workspace actif conservé).
"""
from app.templating import ENV
env = ENV
@@ -46,13 +53,46 @@ def local_workspace_page(request: Request, folder: int = None):
if not user:
return RedirectResponse("/auth/login?provider=local", status_code=302)
ws = _get_active_workspace(request, user_id=user["id"])
ws_id = ws["id"] if ws else None
ws_row = _get_active_workspace(request, user_id=user["id"])
if ws is not None:
# Workspace demandé par l'URL : on ne l'accepte que s'il appartient à
# l'utilisateur (même règle de propriété que _get_active_workspace).
with get_conn() as conn:
requested = conn.execute(
"SELECT id, name FROM workspaces WHERE id=? AND owner_id=?",
(ws, user["id"]),
).fetchone()
if requested is not None:
ws_row = dict(requested)
# L'URL fait foi : on aligne le cookie pour que le reste de
# l'application (sidebar, API tree, breadcrumbs) suive le même
# workspace que celui affiché.
response = _render_local_workspace(env, sidebar, user, ws_row, folder)
response.set_cookie(WORKSPACE_COOKIE, str(ws_row["id"]),
max_age=86400 * 30, httponly=True, path="/")
return response
ws_id = ws_row["id"] if ws_row else None
# If no workspace exists for this user, redirect to workspaces page
if not ws_id:
return RedirectResponse("/workspaces", status_code=302)
return _render_local_workspace(env, sidebar, user, ws_row, folder)
def _render_local_workspace(env, sidebar: dict, user: dict, ws_row: dict,
folder: int | None) -> HTMLResponse:
"""Render the local-workspace page for ``ws_row`` (shared by both paths).
``sidebar`` a été calculé **avant** le éventuel changement de workspace
(``?ws=``) : on force donc le nom affiché et la sidebar sur ``ws_row``,
sinon la page afficherait le contenu d'un workspace sous le titre d'un
autre.
"""
ws_id = ws_row["id"]
ws_name = (ws_row.get("name") or sidebar.get("active_ws_name") or "My Workspace")
# Build breadcrumb if navigating into a folder
breadcrumb = []
current_folder_id = folder
@@ -63,7 +103,9 @@ def local_workspace_page(request: Request, folder: int = None):
ctx = {
**sidebar,
"user": user,
"workspace_name": sidebar.get("active_ws_name", "My Workspace"),
"workspace_name": ws_name,
"active_ws_name": ws_name,
"active_workspace_id": ws_id,
"current_folder_id": current_folder_id or 0,
"workspace_id": ws_id or 0,
"nav_workspace_id": ws_id or 0,
@@ -255,6 +297,7 @@ def nav_menu(request: Request, workspace_id: int = None, parent_id: int = None):
@router.post("/api/local-workspace/items")
def create_local_workspace_item(request: Request, body: dict = Body(default={})):
"""Create a new file in the active workspace."""
_require_user_id(request) # A3/A4 : pas de creation de page anonyme
name = (body.get("name") or "").strip() or "Untitled"
item_type = body.get("type", "page")
parent_id = body.get("parent_id")
@@ -279,6 +322,7 @@ def create_local_workspace_item(request: Request, body: dict = Body(default={}))
@router.put("/api/local-workspace/items/{item_id:int}")
def rename_local_workspace_item(request: Request, item_id: int, body: dict = Body(default={})):
"""Rename a file."""
_require_user_id(request)
name = body.get("name", "Untitled").strip()
with get_conn() as conn:
conn.execute("UPDATE pages SET title=? WHERE id=?", (name, item_id))
@@ -291,6 +335,7 @@ def rename_local_workspace_item(request: Request, item_id: int, body: dict = Bod
@router.delete("/api/local-workspace/items/{item_id:int}")
def delete_local_workspace_item(request: Request, item_id: int):
"""Soft-delete a file/folder (sets deleted_at)."""
_require_user_id(request)
from datetime import datetime
with get_conn() as conn:
conn.execute(
@@ -306,6 +351,7 @@ def delete_local_workspace_item(request: Request, item_id: int):
@router.post("/api/local-workspace/items/{item_id:int}/restore")
def restore_local_workspace_item(request: Request, item_id: int):
"""Restore a soft-deleted file/folder."""
_require_user_id(request)
with get_conn() as conn:
conn.execute(
"UPDATE pages SET deleted_at=NULL WHERE id=?",
@@ -342,6 +388,7 @@ def serve_uploaded_file(ws_id: int, filename: str):
@router.put("/api/local-workspace/items/{item_id:int}/move")
def move_local_workspace_item(request: Request, item_id: int, body: dict = Body(default={})):
"""Move an item to a new parent (drag & drop)."""
_require_user_id(request)
new_parent_id = body.get("parent_id") # None = move to root
with get_conn() as conn:
conn.execute(
+43 -7
View File
@@ -6,18 +6,47 @@ from __future__ import annotations
import logging
from fastapi import APIRouter, Body, Request
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from app.auth.session import SessionManager
from app.db import get_conn
from app.services.collection_lifecycle import (
delete_collections,
workspace_collection_ids,
)
from ._common import WORKSPACE_COOKIE, _get_active_workspace, _get_user_id, _sidebar_data
from ._common import (
WORKSPACE_COOKIE,
_get_active_workspace,
_get_user_id,
_require_user_id,
_sidebar_data,
)
logger = logging.getLogger(__name__)
router = APIRouter(tags=["dashboard"])
def _require_ws_owner(request: Request, ws_id: int) -> int:
"""A3/A4 — session obligatoire + l'appelant doit posseder l'espace
(ou être admin global). Sans ce garde, un POST/DELETE anonyme créait ou
supprimait des espaces au nom de l'utilisateur 1."""
uid = _require_user_id(request)
with get_conn() as conn:
row = conn.execute(
"SELECT owner_id FROM workspaces WHERE id=?", (ws_id,)
).fetchone()
if not row:
raise HTTPException(404, "Workspace not found")
is_admin = conn.execute(
"SELECT is_admin FROM users WHERE id=?", (uid,)
).fetchone()
if row["owner_id"] != uid and not (is_admin and is_admin["is_admin"]):
raise HTTPException(403, "Not your workspace")
return uid
@router.get("/workspaces", response_class=HTMLResponse)
@@ -42,8 +71,7 @@ def workspaces_page(request: Request):
@router.get("/api/workspaces")
def list_workspaces(request: Request):
"""List all workspaces for the current user."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
uid = user["id"] if user and user.get("id") else 1
uid = _require_user_id(request)
with get_conn() as conn:
rows = conn.execute(
"SELECT w.*, (SELECT COUNT(*) FROM pages WHERE workspace_id=w.id AND collection_row_id IS NULL) as page_count "
@@ -67,7 +95,7 @@ def create_workspace(request: Request, body: dict = Body(default={})):
if not name:
return {"error": "Name required"}
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
uid = user["id"] if user and user.get("id") else 1
uid = _require_user_id(request)
with get_conn() as conn:
# Ensure user exists (FK constraint)
uid_ok = conn.execute("SELECT id FROM users WHERE id=?", (uid,)).fetchone()
@@ -99,6 +127,7 @@ def rename_workspace(request: Request, ws_id: int, body: dict = Body(default={})
name = body.get("name", "").strip()
if not name:
return {"error": "Name required"}
_require_ws_owner(request, ws_id)
with get_conn() as conn:
conn.execute("UPDATE workspaces SET name=? WHERE id=?", (name, ws_id))
conn.commit()
@@ -109,13 +138,19 @@ def rename_workspace(request: Request, ws_id: int, body: dict = Body(default={})
@router.delete("/api/workspaces/{ws_id:int}")
def delete_workspace(request: Request, ws_id: int):
"""Delete a workspace and all its pages."""
"""Delete a workspace, its pages and its databases."""
_require_ws_owner(request, ws_id)
with get_conn() as conn:
# Les bases du workspace partaient avec ses pages : les laisser créait
# des collections orphelines (workspace_id pointant sur rien) que
# /db et My Tasks continuaient de lister.
collections = workspace_collection_ids(conn, ws_id)
delete_collections(conn, collections)
conn.execute("DELETE FROM pages WHERE workspace_id=?", (ws_id,))
conn.execute("DELETE FROM workspace_members WHERE workspace_id=?", (ws_id,))
conn.execute("DELETE FROM workspaces WHERE id=?", (ws_id,))
conn.commit()
return {"status": "ok"}
return {"status": "ok", "collections": len(collections)}
@@ -123,6 +158,7 @@ def delete_workspace(request: Request, ws_id: int):
@router.post("/api/workspaces/{ws_id:int}/select")
def select_workspace(request: Request, ws_id: int):
"""Set the active workspace via cookie."""
_require_ws_owner(request, ws_id)
response = JSONResponse({"status": "ok", "workspace_id": ws_id})
response.set_cookie(WORKSPACE_COOKIE, str(ws_id), max_age=86400 * 30, httponly=True, path="/")
return response
+68
View File
@@ -9,7 +9,9 @@ See ``docs/V71_Calendar_Meetings.md``.
"""
from __future__ import annotations
import json
import secrets
from datetime import UTC, date, datetime, timedelta
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import JSONResponse
@@ -115,6 +117,72 @@ def freebusy(collection_id: int, request: Request):
return out
# ── upcoming (sidebar "Meeting" tab) ────────────────────────────────────────
@router.get("/api/v2/meetings/upcoming")
def upcoming_meetings(request: Request, days: int = 30):
"""Upcoming calendar events for the sidebar Meeting tab.
Reads every calendar collection linked to the current user, extracts the
date property of each linked row and returns the ones falling in the next
``days`` days (today included), sorted chronologically. Rows imported from
Google/CalDAV carry an ``external_event_id`` and are flagged ``synced``.
"""
user = _auth_user(request)
horizon = max(1, min(days, 365))
today = datetime.now(UTC).date()
end = today + timedelta(days=horizon)
events: list[dict] = []
with get_conn() as conn:
links = conn.execute(
"SELECT id, collection_id, date_property, calendar_id, provider "
"FROM calendar_links WHERE user_id=? ORDER BY id",
(user["id"],),
).fetchall()
for link in links:
collection_id = link["collection_id"]
if not collection_id:
continue
date_prop = cal.date_prop_id(conn, collection_id,
link["date_property"] or "")
if not date_prop:
continue
prop_id, prop_name = date_prop
rows = conn.execute(
"SELECT id, title, property_values_json, external_event_id "
"FROM collection_pages WHERE collection_id=?",
(collection_id,),
).fetchall()
for row in rows:
try:
values = json.loads(row["property_values_json"] or "{}")
except (TypeError, ValueError):
continue
raw = cal.row_date(values, prop_id, prop_name)[:10]
if len(raw) != 10:
continue
try:
day = date.fromisoformat(raw)
except ValueError:
continue
if not (today <= day <= end):
continue
events.append({
"id": row["id"],
"title": row["title"] or "Untitled",
"date": raw,
"today": day == today,
"collection_id": collection_id,
"calendar_id": link["calendar_id"] or "primary",
"provider": link["provider"],
"synced": bool(row["external_event_id"]),
"url": f"/db/{collection_id}",
})
events.sort(key=lambda e: (e["date"], e["title"].lower()))
return {"today": today.isoformat(), "days": horizon,
"events": events[:200], "count": len(events)}
# ── meetings ───────────────────────────────────────────────────────────────
@router.post("/api/v2/meetings/transcribe")
+374 -98
View File
@@ -1,147 +1,423 @@
"""FlowDeck — My Tasks router (v1.9.0)."""
"""FlowDeck — My Tasks (tableau de bord de tâches façon Notion).
Contrairement à Notion, où *My Tasks* vit dans l'onglet **Home** sans être
rattaché à une page, cette page est un **tableau de bord transverse** : elle
réunit toutes les tâches qui vous sont assignées, **tous workspaces
confondus**. Le cookie `flowdeck_workspace` ne cadre donc plus cette vue (il
reste utilisé par la sidebar).
Les sources sont opt-in : une base n'alimente My Tasks qu'après conversion
explicite (`collections.is_task` + mapping des trois propriétés requises —
`app/services/task_databases.py`).
Trois vues (tableau / kanban / calendrier), filtres globaux (échéance
aujourd'hui, masquer les terminées), tri (échéance, statut, source), édition
en direct du statut et de l'échéance, et création rapide dans n'importe
quelle base connectée.
"""
from __future__ import annotations
import html
import json
import logging
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from app.auth.session import SessionManager
from app.db import get_conn
from app.services.property_types import apply_auto_properties
from app.services.task_databases import (
MAX_TASK_SOURCES,
collect_tasks,
filter_and_sort,
list_task_sources,
user_today,
)
from app.templating import ENV
logger = logging.getLogger(__name__)
router = APIRouter(tags=["my-tasks"], prefix="/my-tasks")
VIEWS = ("table", "board", "calendar")
DUE_FILTERS = ("all", "today", "overdue", "week")
SORTS = ("due", "status", "source", "created")
# ── Session ──────────────────────────────────────────────────────────────
def _get_current_user(request: Request) -> dict | None:
session = request.cookies.get("flowdeck_session", "")
return SessionManager.decode_session(session)
@router.get("", response_class=HTMLResponse)
def my_tasks_dashboard(request: Request, view: str = "all", days: int = 7):
"""My Tasks — aggregates all pages assigned to the current user across all collections."""
def _require_user(request: Request) -> dict:
"""Session obligatoire, sans repli « admin » (id 1) : ce tableau de bord
est strictement celui de l'appelant."""
user = _get_current_user(request)
user_login = user.get("login", "admin") if user else "admin"
if not user or not user.get("id"):
raise HTTPException(status_code=401, detail="Authentication required")
return user
with get_conn() as conn:
collections = conn.execute("SELECT * FROM collections ORDER BY name").fetchall()
grouped: dict[str, list[dict]] = {}
for col in collections:
col_id = col["id"]
pages = conn.execute(
"SELECT * FROM collection_pages WHERE collection_id=? AND parent_id IS NULL ORDER BY position",
(col_id,),
).fetchall()
# ── Données ──────────────────────────────────────────────────────────────
collection_tasks = []
for p in pages:
props = {}
try:
props = json.loads(p["property_values_json"] or "{}")
except (json.JSONDecodeError, TypeError):
pass
due_date = props.get("due_date", "")
status = props.get("status", "—")
assignee = props.get("assignee", "")
if view != "all" and assignee and assignee != user_login:
continue
collection_tasks.append({
"id": p["id"],
"title": p["title"] or "Untitled",
"icon": p["icon"] or "📋",
"status": status,
"due_date": due_date,
})
if collection_tasks:
grouped[col["name"]] = collection_tasks
def _workspace_names(conn, user_id: int) -> dict[int, str]:
rows = conn.execute(
"SELECT id, name FROM workspaces WHERE owner_id = ?", (user_id,)
).fetchall()
return {r["id"]: r["name"] for r in rows}
total = sum(len(t) for t in grouped.values())
# Build content HTML
sections = ""
for col_name, tasks in grouped.items():
items = ""
for t in tasks:
due_badge = f"<span class='mt-due'>{t['due_date']}</span>" if t.get("due_date") else ""
status_cls = t['status'].lower().replace(' ', '-') if t.get('status') else 'none'
status_badge = f"<span class='mt-status mt-{status_cls}'>{t.get('status', '—')}</span>"
items += f"<div class='mt-item'><span class='mt-icon'>{t['icon']}</span><span class='mt-title'>{t['title']}</span>{status_badge}{due_badge}</div>"
def _validated(view: str, due: str, sort: str) -> tuple[str, str, str]:
"""Paramètres d'URL bornés aux valeurs réellement implémentées."""
return (
view if view in VIEWS else "table",
due if due in DUE_FILTERS else "all",
sort if sort in SORTS else "due",
)
sections += f"<div class='mt-section'><div class='mt-section-header'>{col_name} <span class='mt-count'>{len(tasks)}</span></div>{items}</div>"
content_html = f"""<div style="max-width:800px;margin:0 auto;padding:40px 24px;">
<h1 style="font-size:24px;margin:0 0 8px;">📋 My Tasks</h1>
<p style="color:var(--text-dim);font-size:14px;margin-bottom:24px;">{total} tasks across {len(grouped)} collections</p>
<div class="view-tabs" style="display:flex;gap:4px;margin-bottom:24px;border-bottom:1px solid var(--border);padding-bottom:12px;">
<a class="tab{' active' if view=='all' else ''}" href="/my-tasks?view=all" style="padding:6px 14px;border-radius:6px;cursor:pointer;color:var(--text-dim);font-size:13px;text-decoration:none;">All</a>
<a class="tab{' active' if view=='today' else ''}" href="/my-tasks?view=today" style="padding:6px 14px;border-radius:6px;cursor:pointer;color:var(--text-dim);font-size:13px;text-decoration:none;">Today</a>
<a class="tab{' active' if view=='overdue' else ''}" href="/my-tasks?view=overdue" style="padding:6px 14px;border-radius:6px;cursor:pointer;color:var(--text-dim);font-size:13px;text-decoration:none;">Overdue</a>
<a class="tab{' active' if view=='upcoming' else ''}" href="/my-tasks?view=upcoming&days=7" style="padding:6px 14px;border-radius:6px;cursor:pointer;color:var(--text-dim);font-size:13px;text-decoration:none;">Next 7 days</a>
</div>
<style>
.tab.active{{background:var(--accent);color:#fff!important;}}
.tab:hover{{background:var(--bg-hover);color:var(--text);}}
.mt-section{{margin-bottom:24px;}}
.mt-section-header{{font-size:13px;font-weight:600;color:var(--text-dim);margin-bottom:8px;text-transform:uppercase;letter-spacing:.5px;}}
.mt-count{{color:var(--text-dim);font-weight:400;opacity:.5;}}
.mt-item{{display:flex;align-items:center;gap:10px;padding:10px 12px;background:var(--bg-card);border-radius:8px;margin-bottom:3px;border:1px solid var(--border);}}
.mt-item:hover{{border-color:var(--accent);}}
.mt-icon{{font-size:16px;}}
.mt-title{{flex:1;font-size:14px;}}
.mt-status{{font-size:11px;padding:2px 8px;border-radius:4px;}}
.mt-due{{font-size:11px;color:var(--text-dim);}}
.mt-todo,.mt-none{{background:rgba(255,255,255,.05);color:var(--text-dim);}}
.mt-in-progress{{background:rgba(35,131,226,.15);color:#2C8CEB;}}
.mt-done,.mt-completed,.mt-complete{{background:rgba(0,200,100,.15);color:#00CC66;}}
</style>
{sections}
</div>"""
def _public_source(s: dict) -> dict:
return {
"id": s["id"],
"name": s["name"],
"icon": s["icon"],
"workspace_id": s["workspace_id"],
"workspace_name": s.get("workspace_name") or "",
"configured": s["configured"],
"missing_roles": s["missing_roles"],
"status_options": s["status_options"],
}
def _payload(conn, request: Request, user_id: int, *, due: str, sort: str,
hide_done: bool) -> dict:
"""Charge et normalise tout ce dont les trois vues ont besoin."""
user = _get_current_user(request) or {}
tasks = collect_tasks(conn, user_id, mine_only=True,
login=user.get("login", ""))
# Même jour de référence que `due_state` (fuseau de l'utilisateur).
tasks = filter_and_sort(tasks, due=due, hide_done=hide_done, sort=sort,
today=user_today(conn, user_id))
sources = list_task_sources(conn, user_id)
return {
"tasks": tasks,
"total": len(tasks),
"sources": [_public_source(s) for s in sources],
"sources_total": len(sources),
"sources_configured": sum(1 for s in sources if s["configured"]),
"max_sources": MAX_TASK_SOURCES,
"filters": {"due": due, "sort": sort, "hide_done": hide_done},
}
# ── Rendu HTML ───────────────────────────────────────────────────────────
def _render_shell(request: Request, content_html: str, *, app_js: bool = True):
"""Enveloppe le contenu dans le layout commun (sidebar + base.html).
`static/css/my_tasks.css` est lié par `base.html` (le shell, jamais swappé)
: un `<link>` placé dans la zone swappée pouvait être retiré par htmx lors
d'une navigation partielle, et la page revenait sans aucun style. Seul
`static/js/my_tasks.js` reste conditionné à ``app_js`` : sans base
connectée, il n'y a rien à monter côté client.
"""
from app.routers.dashboard import _sidebar_data
env = ENV
sidebar = _sidebar_data(request, [])
block_tpl = env.from_string(
'{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}'
# `assets` est rendu par un mini-template : `asset_version` (cache-busting)
# n'est pas substitutionné si on le concatène à la main.
assets = ENV.from_string(
"{% if app_js %}"
'<script src="/static/js/my_tasks.js?v={{ asset_version }}" defer></script>'
"{% endif %}"
).render(app_js=app_js)
block_tpl = ENV.from_string(
'{% extends "base.html" %}'
"{% block content %}{{ assets|safe }}{{ content_html|safe }}{% endblock %}"
)
return block_tpl.render(
**sidebar,
request=request,
content_html=content_html,
assets=assets,
page_title="My Tasks",
title_prefix="My Tasks",
page_icon="📋",
)
@router.get("/api")
def my_tasks_api(request: Request, view: str = "all", days: int = 7):
"""API: return my tasks as JSON."""
def _json_attr(obj) -> str:
"""JSON échappé pour un attribut HTML."""
return html.escape(json.dumps(obj, ensure_ascii=False), quote=True)
def _render_app(data: dict, view: str) -> str:
"""Coquille du tableau de bord : barre d'outils, filtres et zone de vue
sont rendus côté client à partir de la configuration ci-dessous."""
config = {
"view": view,
"filters": data["filters"],
"sources": data["sources"],
"maxSources": data["max_sources"],
"views": list(VIEWS),
}
return ('<div id="my-tasks-app" class="my-tasks-app" '
f'data-config="{_json_attr(config)}"></div>')
def _render_empty(data: dict) -> str:
"""État vide « aucune source » : explique la conversion en base de tâches.
Gabarit centré (`my_tasks-empty*` dans `static/css/my_tasks.css`), sans
style inline : la mise en page suit le thème clair/sombre et reste
modifiable en un seul endroit. Les autres états vides (filtres actifs,
rien à faire) sont rendus par le client, qui connaît l'état des filtres.
"""
return f"""<div class="my-tasks-empty">
<div class="my-tasks-empty-icon">📋</div>
<h2>My Tasks</h2>
<p>Aucune base n'alimente encore ce tableau de bord. Pour en ajouter une :</p>
<ol class="my-tasks-empty-steps">
<li><span class="n">1</span><span>Ouvrez une base de données depuis
<a href="/db">la liste de vos bases</a>.</span></li>
<li><span class="n">2</span><span>Dans la barre de la base, cliquez sur
<b>« Convertir en base de tâches »</b> — il se trouve à gauche du bouton
<b>New</b>.</span></li>
<li><span class="n">3</span><span>Reliez les colonnes <b>Assigné à</b>,
<b>Statut</b> et <b>Échéance</b>, puis validez.</span></li>
</ol>
<p class="my-tasks-empty-hint">Jusqu'à {data["max_sources"]} bases
peuvent alimenter ce tableau de bord.</p>
<a href="/db" class="my-tasks-empty-btn">Ouvrir mes bases</a>
</div>"""
def _render_no_workspace() -> str:
return """<div class="my-tasks-empty">
<div class="my-tasks-empty-icon">📋</div>
<h2>My Tasks</h2>
<p>Aucun workspace. Créez un workspace, puis une base de tâches : elle
alimentera ce tableau de bord.</p>
<a href="/workspaces" class="my-tasks-empty-btn">Créer un workspace</a>
</div>"""
# ── Routes ───────────────────────────────────────────────────────────────
@router.get("", response_class=HTMLResponse)
def my_tasks_dashboard(request: Request, view: str = "table", due: str = "all",
sort: str = "due", hide_done: bool = False):
"""My Tasks — toutes vos tâches, tous workspaces confondus."""
user = _get_current_user(request)
user.get("login", "admin") if user else "admin"
if not user or not user.get("id"):
return RedirectResponse("/auth/login?provider=local", status_code=302)
view, due, sort = _validated(view, due, sort)
with get_conn() as conn:
if not _workspace_names(conn, user["id"]):
return _render_shell(request, _render_no_workspace(), app_js=False)
data = _payload(conn, request, user["id"], due=due, sort=sort,
hide_done=hide_done)
# Dès qu'au moins une base alimente le tableau de bord, le rendu est pris
# en charge par le client (3 vues + états vides contextualisés). Sans
# aucune source, le serveur affiche l'invite à convertir une base — le
# fichier JS n'est alors pas chargé.
if data["total"] or data["sources_total"]:
return _render_shell(request, _render_app(data, view))
return _render_shell(request, _render_empty(data), app_js=False)
@router.get("/api")
def my_tasks_api(request: Request, view: str = "table", due: str = "all",
sort: str = "due", hide_done: bool = False):
"""API: tâches normalisées + sources connectées (alimente les 3 vues)."""
user = _require_user(request)
view, due, sort = _validated(view, due, sort)
with get_conn() as conn:
return _payload(conn, request, user["id"], due=due, sort=sort,
hide_done=hide_done)
@router.post("/api/task")
def my_tasks_create(request: Request, body: dict = Body(default={})):
"""Création rapide multi-destinations : la tâche part dans la base choisie.
Elle est **auto-assignée** : sans colonne « Assigné à » renseignée, une
tâche nouvelle n'apparaîtrait pas dans « mes tâches », ce qui serait
illisible pour l'utilisateur qui vient de la créer.
"""
user = _require_user(request)
collection_id = body.get("collection_id")
title = str(body.get("title") or "").strip()
if not collection_id:
raise HTTPException(status_code=400, detail="collection_id is required")
if not title:
raise HTTPException(status_code=400, detail="title is required")
with get_conn() as conn:
collections = conn.execute("SELECT * FROM collections ORDER BY name").fetchall()
result = {}
coll = conn.execute(
"SELECT id, task_assignee_prop, task_due_prop FROM collections "
"WHERE id=? AND is_task=1",
(collection_id,),
).fetchone()
if coll is None:
raise HTTPException(status_code=400,
detail="This database does not feed My Tasks")
for col in collections:
pages = conn.execute(
"SELECT * FROM collection_pages WHERE collection_id=? AND parent_id IS NULL ORDER BY position",
(col["id"],),
props_meta = [
dict(p) for p in conn.execute(
"SELECT id, name, prop_type FROM collection_properties "
"WHERE collection_id=?",
(collection_id,),
).fetchall()
]
tasks = []
for p in pages:
props = json.loads(p["property_values_json"])
tasks.append({
"id": p["id"], "title": p["title"], "icon": p["icon"],
"properties": props,
})
properties: dict = {}
title_prop = next((p for p in props_meta if p["prop_type"] == "title"), None)
if title_prop:
properties[str(title_prop["id"])] = title
if tasks:
result[col["name"]] = tasks
if body.get("due") and coll["task_due_prop"]:
properties[str(coll["task_due_prop"])] = str(body["due"])[:10]
return {"tasks": result, "total": sum(len(t) for t in result.values())}
if coll["task_assignee_prop"]:
member = conn.execute(
"SELECT id, login, full_name, avatar_url, avatar_color "
"FROM users WHERE id=?",
(user["id"],),
).fetchone()
if member:
properties[str(coll["task_assignee_prop"])] = [{
"id": member["id"],
"login": member["login"],
"full_name": member["full_name"],
"avatar_url": member["avatar_url"] or "",
"avatar_color": member["avatar_color"] or "",
}]
apply_auto_properties(props_meta, properties, user, is_create=True)
nxt = conn.execute(
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages "
"WHERE collection_id=?",
(collection_id,),
).fetchone()[0]
cur = conn.execute(
"""INSERT INTO collection_pages
(collection_id, title, icon, position, property_values_json)
VALUES (?, ?, '📄', ?, ?)""",
(collection_id, title, nxt, json.dumps(properties)),
)
page_id = cur.lastrowid
conn.commit()
if title_prop:
from app.services.row_pages import sync_row_title_to_page
sync_row_title_to_page(conn, page_id)
conn.commit()
logger.info("my-tasks: task %s created in collection %s", page_id, collection_id)
return {"status": "created", "id": page_id, "collection_id": collection_id,
"title": title}
@router.put("/api/task/{page_id}")
def my_tasks_update(request: Request, page_id: int, body: dict = Body(default={})):
"""Édition en direct du statut / de l'échéance / du titre.
L'écriture passe par le **mapping enregistré de la base source** (et non
par une devinette « première colonne de type X »), puis la tâche est relue
et renvoyée normalisée : le front n'a rien à recalculer.
"""
user = _require_user(request)
field = body.get("field")
if field not in ("status", "due", "title", "assignee"):
raise HTTPException(status_code=400, detail="Unsupported field")
with get_conn() as conn:
row = conn.execute(
"SELECT id, collection_id, title, property_values_json "
"FROM collection_pages WHERE id=?",
(page_id,),
).fetchone()
if row is None:
raise HTTPException(status_code=404, detail="Task not found")
coll = conn.execute(
"SELECT id, is_task, task_assignee_prop, task_status_prop, task_due_prop "
"FROM collections WHERE id=?",
(row["collection_id"],),
).fetchone()
if coll is None:
raise HTTPException(status_code=404, detail="Source not found")
if not coll["is_task"]:
raise HTTPException(status_code=400,
detail="This database no longer feeds My Tasks")
column = {"status": coll["task_status_prop"],
"due": coll["task_due_prop"],
"assignee": coll["task_assignee_prop"]}.get(field)
if field != "title" and not column:
raise HTTPException(status_code=400,
detail=f"This database has no {field} column")
try:
pv = json.loads(row["property_values_json"] or "{}")
except (json.JSONDecodeError, TypeError):
pv = {}
if not isinstance(pv, dict):
pv = {}
new_title = None
if field == "title":
new_title = str(body.get("value") or "").strip()
title_prop = conn.execute(
"SELECT id FROM collection_properties "
"WHERE collection_id=? AND prop_type='title' ORDER BY position LIMIT 1",
(coll["id"],),
).fetchone()
if title_prop:
pv[str(title_prop["id"])] = new_title
elif field == "assignee":
# La valeur est une liste de personnes : elle est écrite telle
# quelle (booleenner écraserait la colonne).
pv[str(column)] = body.get("value") or []
elif field == "due":
pv[str(column)] = str(body.get("value") or "")[:10]
else:
pv[str(column)] = str(body.get("value") or "")
if new_title is not None:
conn.execute(
"UPDATE collection_pages SET title=?, property_values_json=?, "
"updated_at=CURRENT_TIMESTAMP WHERE id=?",
(new_title, json.dumps(pv), page_id),
)
from app.services.row_pages import sync_row_title_to_page
sync_row_title_to_page(conn, page_id)
else:
conn.execute(
"UPDATE collection_pages SET property_values_json=?, "
"updated_at=CURRENT_TIMESTAMP WHERE id=?",
(json.dumps(pv), page_id),
)
conn.commit()
tasks = collect_tasks(conn, user["id"], mine_only=True,
login=user.get("login", ""))
updated = next((t for t in tasks if t["id"] == page_id), None)
if updated is None:
# La tâche vient de cesser d'être « mienne » (désassignée) : le front
# doit la retirer, pas la re-rendre à l'identique.
return {"status": "updated", "task": None, "left_mytasks": True}
return {"status": "updated", "task": updated, "left_mytasks": False}
+50 -19
View File
@@ -8,7 +8,7 @@ import json
import logging
import sqlite3
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi import APIRouter, Body, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse, StreamingResponse
from app.auth.session import SessionManager
@@ -16,14 +16,32 @@ from app.db import get_conn
from app.services.automations import fire_event, run_event_sync
logger = logging.getLogger(__name__)
router = APIRouter(tags=["workspace"], prefix="/workspace")
ROLES = ["admin", "editor", "commenter", "viewer"]
def _require_session(request: Request) -> dict:
"""A3/A4 — session obligatoire sur TOUT le router `/workspace`.
Avant, ces routes s'exécutaient sans session : `GET /workspace/collections/
{id}/export/csv` renvoyait le contenu réel d'une collection à un visiteur
anonyme, et les POST créaient des données au nom de l'utilisateur 1.
La seule route publique (`/workspace/public/...`) vit désormais dans
``public_router``, sans dépendance.
"""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
raise HTTPException(401, "Authentication required")
return user
router = APIRouter(tags=["workspace"], prefix="/workspace", dependencies=[Depends(_require_session)])
# Route de partage public : AUCUNE dépendance d'authentification.
public_router = APIRouter(tags=["workspace-public"], prefix="/workspace")
def _current_user(request: Request) -> dict:
s = request.cookies.get("flowdeck_session", "")
return SessionManager.decode_session(s) or {"login": "admin", "id": 1}
"""Session obligatoire — plus de repli « admin implicite » (A3/A4)."""
return _require_session(request)
def _require_admin(request: Request) -> dict:
@@ -209,45 +227,58 @@ def record_history(request: Request, page_id: int, body: dict = Body(default={})
@router.get("/favorites")
def list_favorites(request: Request):
user = _current_user(request)
uid = user.get("id", 1)
"""List the caller's favorites.
v7.46.0 — aligne sur le schéma v2.2.0 (``app/db.py``) : la table
``favorites`` n'a plus de colonne ``collection_id`` et ``page_id``
référence ``pages(id)``. L'ancienne requête (jointure ``collection_pages``)
levait ``sqlite3.OperationalError: no such column: f.collection_id`` → 500.
"""
user = _require_session(request)
uid = user["id"]
with get_conn() as conn:
rows = conn.execute(
"""SELECT f.*, cp.title as page_title, c.name as collection_name
"""SELECT f.*, p.title AS page_title
FROM favorites f
LEFT JOIN collection_pages cp ON f.page_id=cp.id
LEFT JOIN collections c ON f.collection_id=c.id
LEFT JOIN pages p ON f.page_id=p.id
WHERE f.user_id=? ORDER BY f.position""",
(uid,),
).fetchall()
return {"favorites": [dict(r) for r in rows]}
@router.post("/favorites")
def add_favorite(request: Request, body: dict = Body(default={})):
user = _current_user(request)
uid = user.get("id", 1)
user = _require_session(request)
uid = user["id"]
page_id = body.get("page_id")
collection_id = body.get("collection_id")
if not page_id:
raise HTTPException(400, "page_id required")
with get_conn() as conn:
conn.execute("INSERT OR IGNORE INTO users (id, login, is_admin) VALUES (?,?,1)", (uid, user.get("login", "admin")))
conn.execute(
"INSERT OR IGNORE INTO favorites (user_id, page_id, collection_id) VALUES (?,?,?)",
(uid, page_id, collection_id),
"INSERT OR IGNORE INTO favorites (user_id, page_id) VALUES (?,?)",
(uid, page_id),
)
conn.commit()
try:
run_event_sync(fire_event("favorite.added", {"page_id": page_id, "collection_id": collection_id, "user_id": uid}))
run_event_sync(fire_event("favorite.added", {"page_id": page_id, "user_id": uid}))
except Exception:
logger.exception("add_favorite")
return {"status": "favorited"}
@router.delete("/favorites/{fav_id}")
def remove_favorite(request: Request, fav_id: int):
user = _require_session(request)
with get_conn() as conn:
conn.execute("DELETE FROM favorites WHERE id=?", (fav_id,))
cur = conn.execute(
"DELETE FROM favorites WHERE id=? AND user_id=?", (fav_id, user["id"])
)
conn.commit()
if cur.rowcount == 0:
raise HTTPException(404, "Favorite not found")
return {"status": "removed"}
@@ -714,7 +745,7 @@ def delete_webhook(request: Request, wh_id: int):
# ── Public Sharing ──
@router.get("/public/{collection_id}")
@public_router.get("/public/{collection_id}")
def public_view(request: Request, collection_id: int):
"""Simple public read-only view — no auth required.