- 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).
271 lines
10 KiB
Python
271 lines
10 KiB
Python
"""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}
|