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

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}