Templates (v7.71.x) : - registre unifié \ emplates\ (migrations 48-49) + TemplateService.instantiate unique (UI, API v2, agent, scheduler) - sélecteur (pilule page vide, menu •••, commande /template), gestionnaire /templates, menu New ▾, From template, base inline dans un document - 141 presets système (59 pages, 42 bases, 15 blocs, 25 lignes), titre auto depuis le template, variables title réservée - récurrences RRULE + scheduler dédupliqué, agent apply_template/list_templates, API /api/templates + /api/v2/fd-templates - correctifs : bouton Templates, centrage fenêtre, filtres CSP, flux de création, variable title - tests : tests/test_fd_templates.py (19) et e2e/templates_picker.spec.js (8) Inclut le travail déjà présent dans le working tree (vues Notion : view_query/view_aggregate/form_projection/geocoding, property_types, database_table, docs agents-skills) et ignore .playwright-mcp/.
222 lines
7.9 KiB
Python
222 lines
7.9 KiB
Python
"""FlowDeck — Aggregation Service (vues Notion §8.3).
|
|
|
|
Un seul moteur d'agregation cote serveur pour Chart, KPI, widgets de
|
|
Dashboard et drilldown : grouper / sous-grouper / mesurer / cumuler /
|
|
limiter, avec contrat de sortie normalise.
|
|
|
|
Mesures : count | sum | average | median | min | max (definitions alignees
|
|
sur le Rollup Engine). Groupes : ordre du schema pour select/status/
|
|
multi_select (couleurs des options incluses), chronologique pour les dates.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any
|
|
|
|
from .view_query import (
|
|
CHART_MAX_GROUPS,
|
|
CHART_MAX_SUBGROUPS,
|
|
apply_filters,
|
|
apply_sorts,
|
|
prop_value,
|
|
text_of,
|
|
)
|
|
|
|
NON_GROUPABLE = {"rollup", "button", "unique_id", "files"}
|
|
NON_MEASURABLE_AXES = {"rollup", "button", "unique_id", "files"}
|
|
|
|
|
|
def _schema_options(prop: dict) -> list[dict]:
|
|
try:
|
|
opts = json.loads(prop.get("options_json") or "[]")
|
|
except (json.JSONDecodeError, TypeError):
|
|
opts = []
|
|
out = []
|
|
for o in opts or []:
|
|
if isinstance(o, str):
|
|
out.append({"name": o, "color": "gray"})
|
|
elif isinstance(o, dict):
|
|
out.append({"name": o.get("name", ""), "color": o.get("color", "gray")})
|
|
return out
|
|
|
|
|
|
def _measure_values(rows: list[dict], measure_prop: dict | None) -> list[float]:
|
|
vals: list[float] = []
|
|
for p in rows:
|
|
if measure_prop is None:
|
|
vals.append(1.0)
|
|
continue
|
|
v = prop_value(p, measure_prop)
|
|
if v is None or v == "":
|
|
continue
|
|
try:
|
|
vals.append(float(v) if not isinstance(v, bool) else float(v))
|
|
except (ValueError, TypeError):
|
|
continue
|
|
return vals
|
|
|
|
|
|
def _reduce(kind: str, vals: list[float], count: int) -> float:
|
|
if kind == "count":
|
|
return float(count)
|
|
if not vals:
|
|
return 0.0
|
|
if kind == "sum":
|
|
return float(sum(vals))
|
|
if kind in ("average", "avg"):
|
|
return float(sum(vals) / len(vals))
|
|
if kind == "median":
|
|
s = sorted(vals)
|
|
mid = len(s) // 2
|
|
return float(s[mid] if len(s) % 2 else (s[mid - 1] + s[mid]) / 2)
|
|
if kind == "min":
|
|
return float(min(vals))
|
|
if kind == "max":
|
|
return float(max(vals))
|
|
return float(count)
|
|
|
|
|
|
def _date_bucket(value: Any, span_days: int) -> str:
|
|
s = str(value or "")[:10]
|
|
if len(s) < 10:
|
|
return s or "(empty)"
|
|
if span_days <= 62:
|
|
return s
|
|
if span_days <= 371:
|
|
# week bucket: keep ISO week label
|
|
try:
|
|
from datetime import date as _d
|
|
d = _d.fromisoformat(s)
|
|
iso = d.isocalendar()
|
|
return f"{iso.year}-W{iso.week:02d}"
|
|
except ValueError:
|
|
return s[:7]
|
|
return s[:7]
|
|
|
|
|
|
def aggregate(pages: list[dict], properties: list[dict], spec: dict) -> dict:
|
|
"""Calcule l'agregat normalise ``{groups, total, truncated, scanned}``."""
|
|
by_name = {p["name"]: p for p in properties}
|
|
by_id = {str(p["id"]): p for p in properties}
|
|
group_prop = by_name.get(spec.get("group_by")) or by_id.get(str(spec.get("group_by", "")))
|
|
sub_prop = None
|
|
if spec.get("sub_group_by"):
|
|
sub_prop = by_name.get(spec["sub_group_by"]) or by_id.get(str(spec["sub_group_by"]))
|
|
measure = spec.get("measure") or {"kind": "count"}
|
|
kind = measure.get("kind", "count")
|
|
measure_prop = None
|
|
if kind != "count" and measure.get("property"):
|
|
measure_prop = by_name.get(measure["property"]) or by_id.get(str(measure["property"]))
|
|
|
|
filters = spec.get("filters") or []
|
|
rows = apply_filters(pages, properties, filters, spec.get("filter_conjunction", "and"))
|
|
rows = apply_sorts(rows, properties, spec.get("sorts"))
|
|
scanned = len(rows)
|
|
|
|
hidden = set(spec.get("hidden_groups") or [])
|
|
omit_zero = spec.get("omit_zero", True)
|
|
cumulative = bool(spec.get("cumulative"))
|
|
order_cfg = spec.get("order") or {"by": "group_order", "direction": "asc"}
|
|
if isinstance(order_cfg, str):
|
|
order_cfg = {"by": order_cfg, "direction": "asc"}
|
|
|
|
# Date grouping needs the span first.
|
|
is_date = group_prop is not None and group_prop.get("prop_type") == "date"
|
|
span_days = 0
|
|
if is_date:
|
|
dates = sorted(text_of(group_prop, prop_value(p, group_prop))[:10]
|
|
for p in rows if text_of(group_prop, prop_value(p, group_prop)))
|
|
if len(dates) >= 2:
|
|
try:
|
|
from datetime import date as _d
|
|
span_days = (_d.fromisoformat(dates[-1][:10]) - _d.fromisoformat(dates[0][:10])).days
|
|
except ValueError:
|
|
span_days = 0
|
|
|
|
buckets: dict[str, list[dict]] = {}
|
|
for p in rows:
|
|
if group_prop is None:
|
|
key = "All"
|
|
elif group_prop.get("prop_type") == "multi_select":
|
|
v = prop_value(p, group_prop)
|
|
keys = v if isinstance(v, list) and v else ["(empty)"]
|
|
for k in keys:
|
|
buckets.setdefault(str(k or "(empty)"), []).append(p)
|
|
continue
|
|
elif is_date:
|
|
key = _date_bucket(prop_value(p, group_prop), span_days)
|
|
else:
|
|
t = text_of(group_prop, prop_value(p, group_prop))
|
|
key = t if t else "(empty)"
|
|
buckets.setdefault(key, []).append(p)
|
|
|
|
# Empty schema options (omit_zero=False) for select/status.
|
|
if group_prop is not None and not omit_zero and group_prop.get("prop_type") in ("select", "status"):
|
|
for o in _schema_options(group_prop):
|
|
buckets.setdefault(o["name"], [])
|
|
|
|
schema_order = [o["name"] for o in _schema_options(group_prop)] if group_prop else []
|
|
opt_colors = {o["name"]: o.get("color", "gray") for o in _schema_options(group_prop)} if group_prop else {}
|
|
|
|
def sort_key(item: tuple[str, list]) -> Any:
|
|
k, members = item
|
|
by = order_cfg.get("by", "group_order")
|
|
if by == "value":
|
|
mvals = _measure_values(members, measure_prop)
|
|
return _reduce(kind, mvals, len(members))
|
|
if by == "label":
|
|
return k.lower()
|
|
if group_prop is not None and group_prop.get("prop_type") == "date":
|
|
return k
|
|
if k in schema_order:
|
|
return (0, schema_order.index(k))
|
|
return (1, k.lower())
|
|
|
|
items = sorted(buckets.items(), key=sort_key)
|
|
if str(order_cfg.get("direction", "asc")).lower() in ("desc", "descending"):
|
|
items = list(reversed(items))
|
|
|
|
truncated = False
|
|
if len(items) > CHART_MAX_GROUPS:
|
|
items = items[:CHART_MAX_GROUPS]
|
|
truncated = True
|
|
|
|
groups = []
|
|
running = 0.0
|
|
for key, members in items:
|
|
if key in hidden:
|
|
continue
|
|
mvals = _measure_values(members, measure_prop)
|
|
value = _reduce(kind, mvals, len(members))
|
|
if cumulative and kind in ("count", "sum"):
|
|
running += value
|
|
value = running
|
|
sub = []
|
|
if sub_prop is not None:
|
|
sub_buckets: dict[str, list] = {}
|
|
for p in members:
|
|
sk = text_of(sub_prop, prop_value(p, sub_prop)) or "(empty)"
|
|
sub_buckets.setdefault(sk, []).append(p)
|
|
for sk, sp in list(sub_buckets.items())[:CHART_MAX_SUBGROUPS]:
|
|
svals = _measure_values(sp, measure_prop)
|
|
sub.append({"key": sk, "label": sk, "value": _reduce(kind, svals, len(sp))})
|
|
sub.sort(key=lambda g: -g["value"])
|
|
groups.append({
|
|
"key": key,
|
|
"label": key,
|
|
"color": opt_colors.get(key, "gray"),
|
|
"value": value,
|
|
"count": len(members),
|
|
"row_ids": [p.get("id") for p in members[:500]],
|
|
"sub": sub,
|
|
})
|
|
|
|
total_vals = _measure_values(rows, measure_prop)
|
|
total = _reduce(kind, total_vals, len(rows))
|
|
grand = sum(g["value"] for g in groups) or 1
|
|
for g in groups:
|
|
g["percent"] = g["value"] / grand if grand else 0
|
|
|
|
return {"groups": groups, "total": total, "truncated": truncated,
|
|
"scanned": scanned, "measure": kind}
|