"""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": , "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}