- 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).
439 lines
17 KiB
Python
439 lines
17 KiB
Python
"""FlowDeck — My Tasks : sources de tâches et normalisation.
|
|
|
|
Une base ne diffuse ses tâches dans My Tasks qu'après **conversion
|
|
explicite** (``collections.is_task``) et le mapping explicite de ses trois
|
|
propriétés requises :
|
|
|
|
=========================== ==========================================
|
|
Rôle Colonne sur ``collections``
|
|
=========================== ==========================================
|
|
``assignee`` (Assigné à) ``task_assignee_prop``
|
|
``status`` (Statut) ``task_status_prop``
|
|
``due`` (Échéance) ``task_due_prop``
|
|
=========================== ==========================================
|
|
|
|
Ce module est la lecture unique de ce mapping : My Tasks, les filtres, les
|
|
vues Kanban/calendrier et l'ajout rapide s'appuient tous sur
|
|
``collect_tasks`` — donc une même tâche ne peut pas être rendue
|
|
différemment selon l'appelant.
|
|
|
|
Périmètre : **tous les workspaces de l'utilisateur** (mode Notion *Home*),
|
|
pas seulement le workspace actif — le tableau de bord est un poste de
|
|
travail transverse.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import unicodedata
|
|
from datetime import UTC, date, datetime, timedelta
|
|
|
|
from app.services.collection_lifecycle import (
|
|
effective_workspace_sql,
|
|
live_collection_ids,
|
|
live_row_ids,
|
|
user_workspace_ids,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
#: Notion refuse au-delà de 10 bases connectées au widget My Tasks.
|
|
MAX_TASK_SOURCES = 10
|
|
|
|
#: Rôles requis, dans l'ordre d'affichage de la modale de conversion.
|
|
TASK_ROLES = ("assignee", "status", "due")
|
|
|
|
#: Types acceptés pour chaque rôle (une base « bien formée » est refusée
|
|
#: sinon : une colonne Date n'a pas de sens comme Assigné à).
|
|
ROLE_TYPES = {
|
|
"assignee": ("person",),
|
|
"status": ("status", "select"),
|
|
"due": ("date",),
|
|
}
|
|
|
|
ROLE_LABELS = {
|
|
"assignee": "Assigné à",
|
|
"status": "Statut",
|
|
"due": "Date d'échéance",
|
|
}
|
|
|
|
#: Premier mot d'un statut valant « terminé », une fois normalisé (minuscules,
|
|
#: sans accents). On compare des formes normalisées et non des libellés exacts :
|
|
#: « Terminée », « Terminé », « Complete » ou « Closed » doivent tous être
|
|
#: reconnus, quelle que soit la casse et l'accentuation.
|
|
#:
|
|
#: On matche le **premier mot** et non un simple préfixe : « Clôture »
|
|
#: commence comme « clos » mais n'est pas un statut terminé.
|
|
DONE_WORDS = frozenset({
|
|
# anglais
|
|
"done", "complete", "completed", "closed", "closed?", "terminated", "finished",
|
|
# français (masculin / féminin, singulier / pluriel)
|
|
"clos", "close", "termine", "terminee", "termines", "terminees",
|
|
"annule", "annulee", "annules", "annulees", "cancelled", "canceled",
|
|
"resolu", "resolue", "resolus", "resolues", "acheve", "achevee",
|
|
})
|
|
|
|
|
|
def _normalize(value) -> str:
|
|
"""Minuscules, accents retirés, espaces compactés."""
|
|
text = unicodedata.normalize("NFKD", str(value or ""))
|
|
text = "".join(c for c in text if not unicodedata.combining(c))
|
|
return " ".join(text.lower().split())
|
|
|
|
|
|
def is_done(status: str) -> bool:
|
|
"""Le statut vaut-il « terminé » ?
|
|
|
|
Le premier mot suffit : « Terminée », « terminated », « Complete! » et
|
|
« done (closed) » sont reconnus ; « Clôture » ne l'est pas.
|
|
"""
|
|
norm = _normalize(status)
|
|
if not norm:
|
|
return False
|
|
first = norm.split()[0] if norm.split() else ""
|
|
return first.strip("!?.,:;()") in DONE_WORDS
|
|
|
|
|
|
def user_today(conn, user_id: int) -> date:
|
|
"""« Aujourd'hui » dans le fuseau de l'utilisateur.
|
|
|
|
Le projet stocke déjà ce fuseau (`users.timezone`, cf. migration v5.8.0) et
|
|
l'utilise déjà pour le calendrier des bases. My Tasks s'en sert aussi :
|
|
sans cela, une tâche due « aujourd'hui » bascule dans « demain » (ou
|
|
l'inverse) dès que l'utilisateur est loin d'UTC.
|
|
"""
|
|
row = conn.execute(
|
|
"SELECT timezone FROM users WHERE id=?", (user_id,)
|
|
).fetchone()
|
|
tz_name = (row["timezone"] if row and "timezone" in row.keys() else "") or ""
|
|
if tz_name:
|
|
try:
|
|
from zoneinfo import ZoneInfo
|
|
|
|
return datetime.now(ZoneInfo(tz_name)).date()
|
|
except Exception: # fuseau inconnu / base tz absente → UTC
|
|
logger.debug("timezone « %s » illisible, repli sur UTC", tz_name)
|
|
return datetime.now(UTC).date()
|
|
|
|
|
|
def task_source_columns() -> str:
|
|
"""Colonnes de ``collections`` lues par les requêtes de sources.
|
|
|
|
Les 3 colonnes de mapping n'existent que depuis la migration 30 : sur une
|
|
base antérieure elles vaudront NULL de toute façon.
|
|
|
|
Préfixées par ``c.`` car `list_task_sources` joint ``pages`` : sans
|
|
qualification, ``id`` est ambigu.
|
|
"""
|
|
return ("c.id, c.name, c.icon, c.workspace_id, c.is_task, "
|
|
"c.task_assignee_prop, c.task_status_prop, c.task_due_prop")
|
|
|
|
|
|
def list_task_sources(conn, user_id: int) -> list[dict]:
|
|
"""Bases de tâches configurées par l'utilisateur, tous workspaces
|
|
confondus. Renvoie aussi ``configured`` (les 3 rôles mappés) et
|
|
``missing_roles`` pour piloter l'invite de conversion.
|
|
|
|
Deux exclusions :
|
|
|
|
* une base dont la page hôte est à la corbeille (ou supprimée) est
|
|
**retirée** : sans ce filtre, une base portée par un document supprimé
|
|
continuerait d'alimenter My Tasks — le bug d'origine ;
|
|
* une base dont le workspace n'est pas accessible à l'utilisateur n'est
|
|
pas listée (une base sans workspace rattachable n'appartient à personne).
|
|
|
|
Le workspace retenu est l'**effectif** (`collections.workspace_id`, sinon
|
|
celui de la page hôte) : une base créée depuis un document a
|
|
``workspace_id`` NULL, et un ``JOIN workspaces`` la faisait disparaître de
|
|
My Tasks alors qu'elle venait d'être convertie.
|
|
"""
|
|
ws_ids = user_workspace_ids(conn, user_id)
|
|
if not ws_ids:
|
|
return []
|
|
qs = ",".join("?" * len(ws_ids))
|
|
ws_sql = effective_workspace_sql("c")
|
|
rows = conn.execute(
|
|
f"""SELECT {task_source_columns()}, {ws_sql} AS ws_effective,
|
|
(SELECT w.name FROM workspaces w WHERE w.id = {ws_sql})
|
|
AS workspace_name
|
|
FROM collections c
|
|
WHERE c.is_task = 1 AND {ws_sql} IN ({qs})
|
|
ORDER BY c.name""",
|
|
[*ws_ids],
|
|
).fetchall()
|
|
|
|
# Un workspace peut apparaître plusieurs fois : on ne recalcule le
|
|
# jeu de bases vivantes qu'une fois par workspace.
|
|
live_by_ws: dict[int, set[int]] = {}
|
|
sources = []
|
|
for r in rows:
|
|
d = dict(r)
|
|
ws = d.get("ws_effective")
|
|
if ws not in live_by_ws:
|
|
live_by_ws[ws] = set(live_collection_ids(conn, ws))
|
|
if d["id"] not in live_by_ws[ws]:
|
|
logger.debug("base %s exclue : sa page hôte n'est plus vivante", d["id"])
|
|
continue
|
|
|
|
missing = [role for role in TASK_ROLES if not d.get(f"task_{role}_prop")]
|
|
d["configured"] = not missing
|
|
d["missing_roles"] = missing
|
|
d["status_options"] = _status_options(conn, d.get("task_status_prop"))
|
|
sources.append(d)
|
|
return sources
|
|
|
|
|
|
def _status_options(conn, prop_id) -> list[str]:
|
|
"""Libellés du statut de la base source (colonnes du Kanban)."""
|
|
if not prop_id:
|
|
return []
|
|
row = conn.execute(
|
|
"SELECT options_json FROM collection_properties WHERE id=?", (prop_id,)
|
|
).fetchone()
|
|
if not row:
|
|
return []
|
|
try:
|
|
opts = json.loads(row["options_json"] or "[]")
|
|
except (json.JSONDecodeError, TypeError):
|
|
return []
|
|
return [o if isinstance(o, str) else (o or {}).get("name", "") for o in opts
|
|
if isinstance(o, str) or isinstance(o, dict)]
|
|
|
|
|
|
def source_state(conn, collection_id: int) -> dict:
|
|
"""État de conversion d'une base pour la modale « convertir en base de
|
|
tâches » : mapping courant + colonnes candidates pour chaque rôle."""
|
|
coll = conn.execute(
|
|
f"SELECT {task_source_columns()} FROM collections c WHERE c.id=?",
|
|
(collection_id,),
|
|
).fetchone()
|
|
if coll is None:
|
|
return {"exists": False}
|
|
|
|
d = dict(coll)
|
|
props = conn.execute(
|
|
"SELECT id, name, prop_type, options_json, position FROM collection_properties "
|
|
"WHERE collection_id=? ORDER BY position, id",
|
|
(collection_id,),
|
|
).fetchall()
|
|
|
|
missing = [role for role in TASK_ROLES if not d.get(f"task_{role}_prop")]
|
|
used = {d.get(f"task_{role}_prop") for role in TASK_ROLES if d.get(f"task_{role}_prop")}
|
|
|
|
return {
|
|
"exists": True,
|
|
"collection_id": collection_id,
|
|
"name": d.get("name"),
|
|
"is_task": bool(d.get("is_task")),
|
|
"mapping": {role: d.get(f"task_{role}_prop") for role in TASK_ROLES},
|
|
"missing_roles": missing,
|
|
"configured": not missing,
|
|
# Par rôle : les colonnes compatibles (une colonne déjà affectée à un
|
|
# autre rôle est exclue — Notion n'autorise pas le double emploi).
|
|
"candidates": {
|
|
role: [
|
|
{"id": p["id"], "name": p["name"], "prop_type": p["prop_type"]}
|
|
for p in props
|
|
if p["prop_type"] in ROLE_TYPES[role] and p["id"] not in used
|
|
]
|
|
for role in TASK_ROLES
|
|
},
|
|
"all_properties": [
|
|
{"id": p["id"], "name": p["name"], "prop_type": p["prop_type"]}
|
|
for p in props
|
|
],
|
|
"suggested_names": {role: ROLE_LABELS[role] for role in TASK_ROLES},
|
|
"max_sources": MAX_TASK_SOURCES,
|
|
"sources_count": _sources_count(conn, d.get("workspace_id"), collection_id),
|
|
}
|
|
|
|
|
|
def _sources_count(conn, workspace_id, exclude_id=None) -> int:
|
|
"""Nombre de bases déjà connectées (pour la limite de 10)."""
|
|
row = conn.execute(
|
|
"SELECT COUNT(*) FROM collections WHERE is_task = 1 AND workspace_id = ?",
|
|
(workspace_id,),
|
|
).fetchone()
|
|
n = row[0] if row else 0
|
|
if exclude_id is not None:
|
|
row = conn.execute(
|
|
"SELECT is_task FROM collections WHERE id=?", (exclude_id,)
|
|
).fetchone()
|
|
if row and row[0]:
|
|
n -= 1
|
|
return max(n, 0)
|
|
|
|
|
|
def _row_value(raw: dict, prop_id):
|
|
"""Valeur d'une propriété de ligne, indexée par ID (ou par nom, pour les
|
|
lignes historicisées écrites avant le passage aux clés indexées)."""
|
|
if prop_id is None:
|
|
return None
|
|
if prop_id in raw:
|
|
return raw[prop_id]
|
|
return raw.get(str(prop_id))
|
|
|
|
|
|
def _person_values(value) -> list:
|
|
"""Aplatit une valeur `person` en liste d'éléments (dicts `person` ou
|
|
chaînes), sans perdre l'id — c'est lui qui identifie l'assigné."""
|
|
if value is None:
|
|
return []
|
|
if isinstance(value, dict):
|
|
return [value]
|
|
if isinstance(value, list):
|
|
out: list = []
|
|
for v in value:
|
|
out.extend(_person_values(v))
|
|
return out
|
|
if isinstance(value, str) and value.strip():
|
|
return [value]
|
|
return []
|
|
|
|
|
|
def _person_label(v) -> str:
|
|
if isinstance(v, dict):
|
|
return str(v.get("login") or v.get("full_name") or "")
|
|
return str(v)
|
|
|
|
|
|
def _assigned_to_me(values: list, user_id: int, login: str) -> bool:
|
|
"""L'assigné est-il l'utilisateur courant ?
|
|
|
|
Une valeur `person` porte `{id, login, full_name}` : on compare l'id en
|
|
priorité (stable), puis le login en repli pour les lignes historicisées
|
|
qui ne stockent qu'un nom.
|
|
"""
|
|
if not login:
|
|
return False
|
|
for v in values:
|
|
if isinstance(v, dict):
|
|
if v.get("id") is not None and str(v["id"]) == str(user_id):
|
|
return True
|
|
if _person_label(v).strip().lower() == login.strip().lower():
|
|
return True
|
|
return False
|
|
|
|
|
|
def collect_tasks(conn, user_id: int, *, mine_only: bool = True,
|
|
login: str = "") -> list[dict]:
|
|
"""Tâches normalisées de toutes les bases connectées.
|
|
|
|
Chaque entrée porte sa source (``collection_id``/``collection_name``) et
|
|
les identifiants des propriétés à écrire pour une édition en direct
|
|
(``status_prop``/``due_prop``/``assignee_prop``) : le front n'a plus à
|
|
deviner quoi écrire.
|
|
"""
|
|
tasks: list[dict] = []
|
|
today = user_today(conn, user_id)
|
|
|
|
for src in list_task_sources(conn, user_id):
|
|
if not src["configured"]:
|
|
# Base connectée mais mapping incomplet : elle n'alimente pas
|
|
# My Tasks (l'UI le signale), plutôt que d'inventer des colonnes.
|
|
continue
|
|
status_prop = src["task_status_prop"]
|
|
due_prop = src["task_due_prop"]
|
|
assignee_prop = src["task_assignee_prop"]
|
|
|
|
for row_id in live_row_ids(conn, src["id"]):
|
|
row = conn.execute(
|
|
"SELECT id, title, icon, created_at, property_values_json "
|
|
"FROM collection_pages WHERE id=?",
|
|
(row_id,),
|
|
).fetchone()
|
|
if row is None:
|
|
continue
|
|
try:
|
|
raw = json.loads(row["property_values_json"] or "{}")
|
|
except (json.JSONDecodeError, TypeError):
|
|
raw = {}
|
|
if not isinstance(raw, dict):
|
|
raw = {}
|
|
|
|
persons = _person_values(_row_value(raw, assignee_prop))
|
|
assignees = [_person_label(p) for p in persons if _person_label(p)]
|
|
if mine_only and persons and not _assigned_to_me(persons, user_id, login):
|
|
continue
|
|
|
|
status = _row_value(raw, status_prop)
|
|
if isinstance(status, list):
|
|
status = status[0] if status else ""
|
|
status = str(status or "")
|
|
|
|
due_raw = _row_value(raw, due_prop)
|
|
due = str(due_raw or "")[:10]
|
|
|
|
due_state = ""
|
|
if due:
|
|
try:
|
|
d = date.fromisoformat(due)
|
|
except ValueError:
|
|
d = None
|
|
if d:
|
|
due_state = ("overdue" if d < today
|
|
else "today" if d == today
|
|
else "upcoming")
|
|
|
|
tasks.append({
|
|
"id": row["id"],
|
|
"title": row["title"] or "Untitled",
|
|
"icon": row["icon"] or "📄",
|
|
"created_at": row["created_at"],
|
|
"collection_id": src["id"],
|
|
"collection_name": src["name"],
|
|
"collection_icon": src["icon"] or "📋",
|
|
"workspace_id": src["workspace_id"],
|
|
"workspace_name": src["workspace_name"],
|
|
"assignees": assignees,
|
|
"status": status,
|
|
"status_options": src["status_options"],
|
|
"done": is_done(status),
|
|
"due": due,
|
|
"due_state": due_state,
|
|
"status_prop": status_prop,
|
|
"due_prop": due_prop,
|
|
"assignee_prop": assignee_prop,
|
|
})
|
|
|
|
return tasks
|
|
|
|
|
|
def filter_and_sort(tasks: list[dict], *, due: str = "all", hide_done: bool = False,
|
|
sort: str = "due", today: date | None = None) -> list[dict]:
|
|
"""Filtres et tri globaux de My Tasks (portés par l'URL).
|
|
|
|
``today`` est le jour de référence de l'utilisateur (fuseau compris) : il
|
|
doit être celui utilisé par `collect_tasks`, sinon « aujourd'hui » et
|
|
« cette semaine » ne cibleront pas le même jour que `due_state`.
|
|
"""
|
|
out = tasks
|
|
if hide_done:
|
|
out = [t for t in out if not t["done"]]
|
|
if due and due != "all":
|
|
if due == "today":
|
|
out = [t for t in out if t["due_state"] == "today"]
|
|
elif due == "overdue":
|
|
out = [t for t in out if t["due_state"] in ("today", "overdue")]
|
|
elif due == "week":
|
|
# « Cette semaine » est **borné** à J+7 : sans borne, une tâche
|
|
# datée dans six mois apparaîtrait sous « Cette semaine ».
|
|
ref = today or datetime.now(UTC).date()
|
|
limit = (ref + timedelta(days=7)).isoformat()
|
|
out = [t for t in out
|
|
if t["due"] and (t["due_state"] in ("today", "overdue")
|
|
or t["due"] <= limit)]
|
|
|
|
if sort == "status":
|
|
return sorted(out, key=lambda t: (t["done"], t["status"].lower(),
|
|
(t["due"] or "9999")))
|
|
if sort == "source":
|
|
return sorted(out, key=lambda t: (t["collection_name"].lower(),
|
|
(t["due"] or "9999"), t["title"].lower()))
|
|
if sort == "created":
|
|
return sorted(out, key=lambda t: str(t.get("created_at") or ""))
|
|
# "due" : les tâches datées d'abord (les plus urgentes en tête), puis
|
|
# les tâches sans date — comme une liste de rappels.
|
|
return sorted(out, key=lambda t: ((t["due"] or "9999-12-31"),
|
|
t["done"], t["title"].lower()))
|