Files
flowdeck/app/routers/my_tasks.py
T
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

424 lines
17 KiB
Python

"""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 `<link>` 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 %}"
'<script src="/static/js/my_tasks.js?v={{ asset_version }}" defer></script>'
"{% 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 ('<div id="my-tasks-app" class="my-tasks-app" '
f'data-config="{_json_attr(config)}"></div>')
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"""<div class="my-tasks-empty">
<div class="my-tasks-empty-icon">📋</div>
<h2>My Tasks</h2>
<p>Aucune base n'alimente encore ce tableau de bord. Pour en ajouter une :</p>
<ol class="my-tasks-empty-steps">
<li><span class="n">1</span><span>Ouvrez une base de données depuis
<a href="/db">la liste de vos bases</a>.</span></li>
<li><span class="n">2</span><span>Dans la barre de la base, cliquez sur
<b>« Convertir en base de tâches »</b> — il se trouve à gauche du bouton
<b>New</b>.</span></li>
<li><span class="n">3</span><span>Reliez les colonnes <b>Assigné à</b>,
<b>Statut</b> et <b>Échéance</b>, puis validez.</span></li>
</ol>
<p class="my-tasks-empty-hint">Jusqu'à {data["max_sources"]} bases
peuvent alimenter ce tableau de bord.</p>
<a href="/db" class="my-tasks-empty-btn">Ouvrir mes bases</a>
</div>"""
def _render_no_workspace() -> str:
return """<div class="my-tasks-empty">
<div class="my-tasks-empty-icon">📋</div>
<h2>My Tasks</h2>
<p>Aucun workspace. Créez un workspace, puis une base de tâches : elle
alimentera ce tableau de bord.</p>
<a href="/workspaces" class="my-tasks-empty-btn">Créer un workspace</a>
</div>"""
# ── 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}