- 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).
424 lines
17 KiB
Python
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}
|