"""FlowDeck — My Tasks (tableau de bord de tâches façon Notion). Contrairement à Notion, où *My Tasks* vit dans l'onglet **Home** sans être rattaché à une page, cette page est un **tableau de bord transverse** : elle réunit toutes les tâches qui vous sont assignées, **tous workspaces confondus**. Le cookie `flowdeck_workspace` ne cadre donc plus cette vue (il reste utilisé par la sidebar). Les sources sont opt-in : une base n'alimente My Tasks qu'après conversion explicite (`collections.is_task` + mapping des trois propriétés requises — `app/services/task_databases.py`). Trois vues (tableau / kanban / calendrier), filtres globaux (échéance aujourd'hui, masquer les terminées), tri (échéance, statut, source), édition en direct du statut et de l'échéance, et création rapide dans n'importe quelle base connectée. """ from __future__ import annotations import html import json import logging from fastapi import APIRouter, Body, HTTPException, Request from fastapi.responses import HTMLResponse, RedirectResponse from app.auth.session import SessionManager from app.db import get_conn from app.services.property_types import apply_auto_properties from app.services.task_databases import ( MAX_TASK_SOURCES, collect_tasks, filter_and_sort, list_task_sources, user_today, ) from app.templating import ENV logger = logging.getLogger(__name__) router = APIRouter(tags=["my-tasks"], prefix="/my-tasks") VIEWS = ("table", "board", "calendar") DUE_FILTERS = ("all", "today", "overdue", "week") SORTS = ("due", "status", "source", "created") # ── Session ────────────────────────────────────────────────────────────── def _get_current_user(request: Request) -> dict | None: session = request.cookies.get("flowdeck_session", "") return SessionManager.decode_session(session) def _require_user(request: Request) -> dict: """Session obligatoire, sans repli « admin » (id 1) : ce tableau de bord est strictement celui de l'appelant.""" user = _get_current_user(request) if not user or not user.get("id"): raise HTTPException(status_code=401, detail="Authentication required") return user # ── Données ────────────────────────────────────────────────────────────── def _workspace_names(conn, user_id: int) -> dict[int, str]: rows = conn.execute( "SELECT id, name FROM workspaces WHERE owner_id = ?", (user_id,) ).fetchall() return {r["id"]: r["name"] for r in rows} def _validated(view: str, due: str, sort: str) -> tuple[str, str, str]: """Paramètres d'URL bornés aux valeurs réellement implémentées.""" return ( view if view in VIEWS else "table", due if due in DUE_FILTERS else "all", sort if sort in SORTS else "due", ) def _public_source(s: dict) -> dict: return { "id": s["id"], "name": s["name"], "icon": s["icon"], "workspace_id": s["workspace_id"], "workspace_name": s.get("workspace_name") or "", "configured": s["configured"], "missing_roles": s["missing_roles"], "status_options": s["status_options"], } def _payload(conn, request: Request, user_id: int, *, due: str, sort: str, hide_done: bool) -> dict: """Charge et normalise tout ce dont les trois vues ont besoin.""" user = _get_current_user(request) or {} tasks = collect_tasks(conn, user_id, mine_only=True, login=user.get("login", "")) # Même jour de référence que `due_state` (fuseau de l'utilisateur). tasks = filter_and_sort(tasks, due=due, hide_done=hide_done, sort=sort, today=user_today(conn, user_id)) sources = list_task_sources(conn, user_id) return { "tasks": tasks, "total": len(tasks), "sources": [_public_source(s) for s in sources], "sources_total": len(sources), "sources_configured": sum(1 for s in sources if s["configured"]), "max_sources": MAX_TASK_SOURCES, "filters": {"due": due, "sort": sort, "hide_done": hide_done}, } # ── Rendu HTML ─────────────────────────────────────────────────────────── def _render_shell(request: Request, content_html: str, *, app_js: bool = True): """Enveloppe le contenu dans le layout commun (sidebar + base.html). `static/css/my_tasks.css` est lié par `base.html` (le shell, jamais swappé) : un `` placé dans la zone swappée pouvait être retiré par htmx lors d'une navigation partielle, et la page revenait sans aucun style. Seul `static/js/my_tasks.js` reste conditionné à ``app_js`` : sans base connectée, il n'y a rien à monter côté client. """ from app.routers.dashboard import _sidebar_data sidebar = _sidebar_data(request, []) # `assets` est rendu par un mini-template : `asset_version` (cache-busting) # n'est pas substitutionné si on le concatène à la main. assets = ENV.from_string( "{% if app_js %}" '' "{% endif %}" ).render(app_js=app_js) block_tpl = ENV.from_string( '{% extends "base.html" %}' "{% block content %}{{ assets|safe }}{{ content_html|safe }}{% endblock %}" ) return block_tpl.render( **sidebar, request=request, content_html=content_html, assets=assets, page_title="My Tasks", title_prefix="My Tasks", page_icon="📋", ) def _json_attr(obj) -> str: """JSON échappé pour un attribut HTML.""" return html.escape(json.dumps(obj, ensure_ascii=False), quote=True) def _render_app(data: dict, view: str) -> str: """Coquille du tableau de bord : barre d'outils, filtres et zone de vue sont rendus côté client à partir de la configuration ci-dessous.""" config = { "view": view, "filters": data["filters"], "sources": data["sources"], "maxSources": data["max_sources"], "views": list(VIEWS), } return ('
') def _render_empty(data: dict) -> str: """État vide « aucune source » : explique la conversion en base de tâches. Gabarit centré (`my_tasks-empty*` dans `static/css/my_tasks.css`), sans style inline : la mise en page suit le thème clair/sombre et reste modifiable en un seul endroit. Les autres états vides (filtres actifs, rien à faire) sont rendus par le client, qui connaît l'état des filtres. """ return f"""
📋

My Tasks

Aucune base n'alimente encore ce tableau de bord. Pour en ajouter une :

  1. 1Ouvrez une base de données depuis la liste de vos bases.
  2. 2Dans la barre de la base, cliquez sur « Convertir en base de tâches » — il se trouve à gauche du bouton New.
  3. 3Reliez les colonnes Assigné à, Statut et Échéance, puis validez.

Jusqu'à {data["max_sources"]} bases peuvent alimenter ce tableau de bord.

Ouvrir mes bases
""" def _render_no_workspace() -> str: return """
📋

My Tasks

Aucun workspace. Créez un workspace, puis une base de tâches : elle alimentera ce tableau de bord.

Créer un workspace
""" # ── Routes ─────────────────────────────────────────────────────────────── @router.get("", response_class=HTMLResponse) def my_tasks_dashboard(request: Request, view: str = "table", due: str = "all", sort: str = "due", hide_done: bool = False): """My Tasks — toutes vos tâches, tous workspaces confondus.""" user = _get_current_user(request) if not user or not user.get("id"): return RedirectResponse("/auth/login?provider=local", status_code=302) view, due, sort = _validated(view, due, sort) with get_conn() as conn: if not _workspace_names(conn, user["id"]): return _render_shell(request, _render_no_workspace(), app_js=False) data = _payload(conn, request, user["id"], due=due, sort=sort, hide_done=hide_done) # Dès qu'au moins une base alimente le tableau de bord, le rendu est pris # en charge par le client (3 vues + états vides contextualisés). Sans # aucune source, le serveur affiche l'invite à convertir une base — le # fichier JS n'est alors pas chargé. if data["total"] or data["sources_total"]: return _render_shell(request, _render_app(data, view)) return _render_shell(request, _render_empty(data), app_js=False) @router.get("/api") def my_tasks_api(request: Request, view: str = "table", due: str = "all", sort: str = "due", hide_done: bool = False): """API: tâches normalisées + sources connectées (alimente les 3 vues).""" user = _require_user(request) view, due, sort = _validated(view, due, sort) with get_conn() as conn: return _payload(conn, request, user["id"], due=due, sort=sort, hide_done=hide_done) @router.post("/api/task") def my_tasks_create(request: Request, body: dict = Body(default={})): """Création rapide multi-destinations : la tâche part dans la base choisie. Elle est **auto-assignée** : sans colonne « Assigné à » renseignée, une tâche nouvelle n'apparaîtrait pas dans « mes tâches », ce qui serait illisible pour l'utilisateur qui vient de la créer. """ user = _require_user(request) collection_id = body.get("collection_id") title = str(body.get("title") or "").strip() if not collection_id: raise HTTPException(status_code=400, detail="collection_id is required") if not title: raise HTTPException(status_code=400, detail="title is required") with get_conn() as conn: coll = conn.execute( "SELECT id, task_assignee_prop, task_due_prop FROM collections " "WHERE id=? AND is_task=1", (collection_id,), ).fetchone() if coll is None: raise HTTPException(status_code=400, detail="This database does not feed My Tasks") props_meta = [ dict(p) for p in conn.execute( "SELECT id, name, prop_type FROM collection_properties " "WHERE collection_id=?", (collection_id,), ).fetchall() ] properties: dict = {} title_prop = next((p for p in props_meta if p["prop_type"] == "title"), None) if title_prop: properties[str(title_prop["id"])] = title if body.get("due") and coll["task_due_prop"]: properties[str(coll["task_due_prop"])] = str(body["due"])[:10] if coll["task_assignee_prop"]: member = conn.execute( "SELECT id, login, full_name, avatar_url, avatar_color " "FROM users WHERE id=?", (user["id"],), ).fetchone() if member: properties[str(coll["task_assignee_prop"])] = [{ "id": member["id"], "login": member["login"], "full_name": member["full_name"], "avatar_url": member["avatar_url"] or "", "avatar_color": member["avatar_color"] or "", }] apply_auto_properties(props_meta, properties, user, is_create=True) nxt = conn.execute( "SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages " "WHERE collection_id=?", (collection_id,), ).fetchone()[0] cur = conn.execute( """INSERT INTO collection_pages (collection_id, title, icon, position, property_values_json) VALUES (?, ?, '📄', ?, ?)""", (collection_id, title, nxt, json.dumps(properties)), ) page_id = cur.lastrowid conn.commit() if title_prop: from app.services.row_pages import sync_row_title_to_page sync_row_title_to_page(conn, page_id) conn.commit() logger.info("my-tasks: task %s created in collection %s", page_id, collection_id) return {"status": "created", "id": page_id, "collection_id": collection_id, "title": title} @router.put("/api/task/{page_id}") def my_tasks_update(request: Request, page_id: int, body: dict = Body(default={})): """Édition en direct du statut / de l'échéance / du titre. L'écriture passe par le **mapping enregistré de la base source** (et non par une devinette « première colonne de type X »), puis la tâche est relue et renvoyée normalisée : le front n'a rien à recalculer. """ user = _require_user(request) field = body.get("field") if field not in ("status", "due", "title", "assignee"): raise HTTPException(status_code=400, detail="Unsupported field") with get_conn() as conn: row = conn.execute( "SELECT id, collection_id, title, property_values_json " "FROM collection_pages WHERE id=?", (page_id,), ).fetchone() if row is None: raise HTTPException(status_code=404, detail="Task not found") coll = conn.execute( "SELECT id, is_task, task_assignee_prop, task_status_prop, task_due_prop " "FROM collections WHERE id=?", (row["collection_id"],), ).fetchone() if coll is None: raise HTTPException(status_code=404, detail="Source not found") if not coll["is_task"]: raise HTTPException(status_code=400, detail="This database no longer feeds My Tasks") column = {"status": coll["task_status_prop"], "due": coll["task_due_prop"], "assignee": coll["task_assignee_prop"]}.get(field) if field != "title" and not column: raise HTTPException(status_code=400, detail=f"This database has no {field} column") try: pv = json.loads(row["property_values_json"] or "{}") except (json.JSONDecodeError, TypeError): pv = {} if not isinstance(pv, dict): pv = {} new_title = None if field == "title": new_title = str(body.get("value") or "").strip() title_prop = conn.execute( "SELECT id FROM collection_properties " "WHERE collection_id=? AND prop_type='title' ORDER BY position LIMIT 1", (coll["id"],), ).fetchone() if title_prop: pv[str(title_prop["id"])] = new_title elif field == "assignee": # La valeur est une liste de personnes : elle est écrite telle # quelle (booleenner écraserait la colonne). pv[str(column)] = body.get("value") or [] elif field == "due": pv[str(column)] = str(body.get("value") or "")[:10] else: pv[str(column)] = str(body.get("value") or "") if new_title is not None: conn.execute( "UPDATE collection_pages SET title=?, property_values_json=?, " "updated_at=CURRENT_TIMESTAMP WHERE id=?", (new_title, json.dumps(pv), page_id), ) from app.services.row_pages import sync_row_title_to_page sync_row_title_to_page(conn, page_id) else: conn.execute( "UPDATE collection_pages SET property_values_json=?, " "updated_at=CURRENT_TIMESTAMP WHERE id=?", (json.dumps(pv), page_id), ) conn.commit() tasks = collect_tasks(conn, user["id"], mine_only=True, login=user.get("login", "")) updated = next((t for t in tasks if t["id"] == page_id), None) if updated is None: # La tâche vient de cesser d'être « mienne » (désassignée) : le front # doit la retirer, pas la re-rendre à l'identique. return {"status": "updated", "task": None, "left_mytasks": True} return {"status": "updated", "task": updated, "left_mytasks": False}