"""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()))