- 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).
260 lines
10 KiB
Python
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("&", "&").replace("<", "<").replace(">", ">")
|
|
.replace('"', """).replace("'", "'")
|
|
)
|
|
|
|
|
|
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",
|
|
)
|