"""FlowDeck — Collections : la page « /db » (liste des bases). `GET /db` était un **dump JSON de debug** (le HTML renvoyé était un `
` 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 = 'Base de tâches'
        hint = "Alimente My Tasks."
    elif r["is_task"]:
        badge = 'À configurer'
        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"""
{_esc(r["icon"] or "📋")} {_esc(r["name"])} {badge}
{_esc(meta)}
{_esc(hint)}
""" @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"""
{''.join(blocks)}
""" if other_blocks: sections += ('

Autres workspaces

' '

Ces bases appartiennent à ' "d'autres workspaces — elles alimentent tout de même " f'My Tasks.

' f"{''.join(other_blocks)}
") body = f"""

📋 Bases de données

{_esc(ws_name)}

{summary} — les bases de tâches alimentent My Tasks.

{sections}
""" elif has_ws: body = """
📋

Aucune base dans ce workspace

Créez une page, puis convertissez-la en base de données — ou partez d'un modèle depuis le menu d'ajout.

Une base reliée à My Tasks apparaît ici avec son état de configuration.

""" else: body = """
📋

Aucun workspace sélectionné

Créez ou ouvrez d'abord un workspace : vos bases y seront rattachées.

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