Files
bruno 1d1cdbd618
FlowDeck CI / test (push) Failing after 3h13m58s
FlowDeck CI / lint (push) Successful in 2m12s
FlowDeck CI / docker (push) Skipped
fix: side peek des bases repasse en vanilla JS + largeur 1100px standard (v7.49.0)
- 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).
2026-10-05 22:47:36 -04:00

260 lines
10 KiB
Python

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