feat(templates): refonte complète des templates façon Notion + vues/agents-skills
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/.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,68 @@
|
||||
"""FlowDeck — Projection Form builder -> pipeline public /f/ (§16.5).
|
||||
|
||||
Le builder ecrit ``form_questions`` + reglages de la vue form ; le
|
||||
projecteur regenere ``collections.form_config_json`` (format v6.8 inchange)
|
||||
consomme par GET/POST /f/<token>. Le public ne lit jamais les tables du
|
||||
builder : zero regression possible sur les liens deja diffuses.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
|
||||
def project_form_config(view_config: dict, questions: list[dict]) -> dict:
|
||||
"""Construit le ``form_config_json`` v6.8 depuis le builder."""
|
||||
cfg = dict(view_config or {})
|
||||
sharing = cfg.get("sharing") or {}
|
||||
submit = cfg.get("submit_screen") or {}
|
||||
fields = []
|
||||
for q in sorted(questions, key=lambda x: x.get("position", 0)):
|
||||
fields.append({
|
||||
"property": q.get("property_name"),
|
||||
"label": q.get("label_override") or q.get("property_name"),
|
||||
"required": bool(q.get("required")),
|
||||
"help": q.get("help_text") or "",
|
||||
"widget": q.get("widget") or "auto",
|
||||
"max_selections": q.get("max_selections"),
|
||||
"conditional": json.loads(q["conditional_json"]) if q.get("conditional_json") else None,
|
||||
})
|
||||
return {
|
||||
"title": cfg.get("title") or "Form",
|
||||
"description": cfg.get("description") or "",
|
||||
"fields": fields,
|
||||
"sharing": {
|
||||
"audience": sharing.get("audience", "workspace"),
|
||||
"anonymous": bool(sharing.get("anonymous", False)),
|
||||
"respondent_access": sharing.get("respondent_access", "none"),
|
||||
"branding": sharing.get("branding", True),
|
||||
},
|
||||
"submit": {
|
||||
"button_text": submit.get("button_text", "Envoyer"),
|
||||
"button_color": submit.get("button_color", "blue"),
|
||||
"confirmation_title": submit.get("confirmation_title", "Merci !"),
|
||||
"confirmation_body": submit.get("confirmation_body", "Votre réponse a été enregistrée."),
|
||||
},
|
||||
"form_view_id": cfg.get("form_view_id"),
|
||||
}
|
||||
|
||||
|
||||
def default_questions_for(properties: list[dict]) -> list[dict]:
|
||||
"""Questions initiales depuis les proprietes exposables (hors formules)."""
|
||||
skip = {"formula", "rollup", "button", "unique_id",
|
||||
"created_time", "created_by", "last_edited_time", "last_edited_by"}
|
||||
out = []
|
||||
for i, p in enumerate(properties):
|
||||
if p.get("prop_type") in skip:
|
||||
continue
|
||||
out.append({
|
||||
"property_name": p.get("name"),
|
||||
"position": i,
|
||||
"label_override": None,
|
||||
"sync_label": 1,
|
||||
"help_text": "",
|
||||
"required": 0,
|
||||
"widget": "auto",
|
||||
"max_selections": None,
|
||||
"conditional_json": None,
|
||||
})
|
||||
return out
|
||||
@@ -0,0 +1,154 @@
|
||||
"""FlowDeck — Geocoding Service (vue Map §13.4).
|
||||
|
||||
Interface de fournisseur unique : search / resolve / reverse.
|
||||
Implementations : ``none`` (defaut — saisie manuelle uniquement, aucun
|
||||
appel externe), ``nominatim`` (OpenStreetMap, User-Agent identifie,
|
||||
<= 1 req/s, cache obligatoire), ``custom`` (endpoint compatible Nominatim).
|
||||
|
||||
Le cache SQLite (migration 45, table ``geocode_cache``) est consulte
|
||||
avant tout appel. Les journaux ne contiennent jamais d'adresses en clair
|
||||
au-dela de compteurs (le texte reste dans le cache, purgeable).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_last_call_ts = 0.0
|
||||
|
||||
|
||||
def _norm(query: str) -> str:
|
||||
return " ".join((query or "").lower().split())
|
||||
|
||||
|
||||
def query_hash(query: str, provider: str) -> str:
|
||||
return hashlib.sha256(f"{provider}:{_norm(query)}".encode()).hexdigest()
|
||||
|
||||
|
||||
def get_settings(conn=None) -> dict:
|
||||
"""Lit la config geocodeur (instance). Defauts surs : provider=none."""
|
||||
try:
|
||||
from app.config import settings as _s # type: ignore
|
||||
provider = getattr(_s, "GEOCODER_PROVIDER", None) or "none"
|
||||
endpoint = getattr(_s, "GEOCODER_ENDPOINT", None) or "https://nominatim.openstreetmap.org"
|
||||
return {"provider": provider, "endpoint": endpoint.rstrip("/")}
|
||||
except Exception:
|
||||
return {"provider": "none", "endpoint": "https://nominatim.openstreetmap.org"}
|
||||
|
||||
|
||||
def cache_get(conn, qhash: str) -> dict | None:
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT result_json, lat, lng, hit_count FROM geocode_cache WHERE query_hash=?",
|
||||
(qhash,),
|
||||
).fetchone()
|
||||
except Exception:
|
||||
return None
|
||||
if not row:
|
||||
return None
|
||||
try:
|
||||
conn.execute("UPDATE geocode_cache SET hit_count = hit_count + 1 WHERE query_hash=?", (qhash,))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
data = json.loads(row["result_json"]) if row["result_json"] else {}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
data = {}
|
||||
data.setdefault("lat", row["lat"])
|
||||
data.setdefault("lng", row["lng"])
|
||||
return data
|
||||
|
||||
|
||||
def cache_put(conn, qhash: str, provider: str, query_text: str, result: dict) -> None:
|
||||
try:
|
||||
conn.execute(
|
||||
"""INSERT OR REPLACE INTO geocode_cache
|
||||
(query_hash, provider, query_text, result_json, lat, lng, created_at, hit_count)
|
||||
VALUES (?, ?, ?, ?, ?, ?, datetime('now'), COALESCE(
|
||||
(SELECT hit_count FROM geocode_cache WHERE query_hash=?), 0))""",
|
||||
(qhash, provider, query_text[:300], json.dumps(result),
|
||||
result.get("lat"), result.get("lng"), qhash),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _http_json(url: str, params: dict, timeout: float = 3.0) -> Any:
|
||||
global _last_call_ts
|
||||
# Politesse Nominatim : <= 1 req/s.
|
||||
wait = 1.0 - (time.time() - _last_call_ts)
|
||||
if wait > 0:
|
||||
time.sleep(wait)
|
||||
full = url + "?" + urllib.parse.urlencode(params)
|
||||
req = urllib.request.Request(full, headers={
|
||||
"User-Agent": "FlowDeck/1.0 (self-hosted; contact: admin)",
|
||||
"Accept": "application/json",
|
||||
})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
_last_call_ts = time.time()
|
||||
return json.loads(resp.read().decode("utf-8", "replace"))
|
||||
|
||||
|
||||
def search(conn, query: str, limit: int = 5) -> list[dict]:
|
||||
"""Autocompletion d'adresses. Vide quand provider=none."""
|
||||
cfg = get_settings(conn)
|
||||
provider = cfg["provider"]
|
||||
if not query or len(query.strip()) < 3 or provider == "none":
|
||||
return []
|
||||
qhash = query_hash(f"search:{query}:{limit}", provider)
|
||||
cached = cache_get(conn, qhash)
|
||||
if cached and isinstance(cached.get("suggestions"), list):
|
||||
return cached["suggestions"][:limit]
|
||||
base = cfg["endpoint"]
|
||||
try:
|
||||
if provider in ("nominatim", "custom"):
|
||||
data = _http_json(f"{base}/search", {
|
||||
"q": query, "format": "jsonv2", "limit": limit,
|
||||
"addressdetails": 1,
|
||||
})
|
||||
suggestions = [{
|
||||
"name": (d.get("display_name") or "").split(",")[0].strip(),
|
||||
"address": d.get("display_name", ""),
|
||||
"lat": float(d["lat"]), "lng": float(d["lon"]),
|
||||
"provider": provider, "place_id": str(d.get("place_id", "")),
|
||||
} for d in (data or []) if d.get("lat") and d.get("lon")]
|
||||
else:
|
||||
suggestions = []
|
||||
except Exception as exc:
|
||||
logger.warning("geocode search failed: %s", type(exc).__name__)
|
||||
return []
|
||||
try:
|
||||
cache_put(conn, qhash, provider, query, {"suggestions": suggestions})
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
return suggestions
|
||||
|
||||
|
||||
def resolve(conn, query: str) -> dict | None:
|
||||
"""Adresse -> {lat, lng, ...}. Cache d'abord, None si echec."""
|
||||
cfg = get_settings(conn)
|
||||
provider = cfg["provider"]
|
||||
if not query or provider == "none":
|
||||
return None
|
||||
qhash = query_hash(f"resolve:{query}", provider)
|
||||
cached = cache_get(conn, qhash)
|
||||
if cached and cached.get("lat") is not None:
|
||||
return cached
|
||||
results = search(conn, query, limit=1)
|
||||
if not results:
|
||||
return None
|
||||
first = dict(results[0])
|
||||
try:
|
||||
cache_put(conn, qhash, provider, query, first)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
return first
|
||||
@@ -97,14 +97,20 @@ PROPERTY_TYPES: dict[str, dict] = {
|
||||
"default": None,
|
||||
},
|
||||
"button": {
|
||||
"storage": "none — runs linked automation (button_automation_id)",
|
||||
"storage": "none - runs linked automation (button_automation_id)",
|
||||
"default": None,
|
||||
},
|
||||
"place": {
|
||||
"storage": "json object {name, address, lat, lng, provider, place_id, resolved_at, manual}",
|
||||
"validation": "name or address required, lat in [-90,90], lng in [-180,180]",
|
||||
"default": None,
|
||||
},
|
||||
}
|
||||
|
||||
# CSV-friendly subset (no relation/rollup/formula)
|
||||
SIMPLE_TYPES = ["title", "text", "number", "select", "multi_select", "status",
|
||||
"date", "person", "checkbox", "url", "email", "phone", "files"]
|
||||
"date", "person", "checkbox", "url", "email", "phone", "files",
|
||||
"place"]
|
||||
|
||||
# Auto-property types (computed server-side)
|
||||
AUTO_TYPES = ["created_time", "created_by", "last_edited_time", "last_edited_by"]
|
||||
@@ -136,10 +142,19 @@ def validate_property_value(prop_type: str, value: Any, options: list | None = N
|
||||
return False, f"'{v}' is not a valid option"
|
||||
elif prop_type == "date":
|
||||
if isinstance(value, str) and value:
|
||||
try:
|
||||
datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except (ValueError, TypeError):
|
||||
# Les plages « start...end » (Timeline, auto-shift) sont valides :
|
||||
# chaque borne est controlee separement.
|
||||
parts = value.split("...") if "..." in value else [value]
|
||||
if len(parts) > 2 or not any(p.strip() for p in parts):
|
||||
return False, f"'{value}' is not a valid ISO 8601 date"
|
||||
for part in parts:
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
try:
|
||||
datetime.fromisoformat(part.replace("Z", "+00:00"))
|
||||
except (ValueError, TypeError):
|
||||
return False, f"'{value}' is not a valid ISO 8601 date"
|
||||
elif prop_type == "person":
|
||||
if not isinstance(value, list):
|
||||
return False, "Person must be a list of workspace members"
|
||||
@@ -152,6 +167,31 @@ def validate_property_value(prop_type: str, value: Any, options: list | None = N
|
||||
elif prop_type == "phone":
|
||||
if not isinstance(value, str):
|
||||
return False, "Phone must be a string"
|
||||
elif prop_type == "place":
|
||||
if value in ("", {}):
|
||||
return True, ""
|
||||
if isinstance(value, str):
|
||||
# Plain-text address (unresolved place, e.g. CSV import) is valid.
|
||||
return True, ""
|
||||
if not isinstance(value, dict):
|
||||
return False, "Place must be an object {name, address, lat, lng}"
|
||||
if not (value.get("name") or value.get("address")):
|
||||
return False, "Place requires a name or an address"
|
||||
lat, lng = value.get("lat"), value.get("lng")
|
||||
if lat is not None and lat != "":
|
||||
try:
|
||||
lat = float(lat)
|
||||
except (ValueError, TypeError):
|
||||
return False, "Place latitude must be a number"
|
||||
if not -90 <= lat <= 90:
|
||||
return False, "Place latitude must be between -90 and 90"
|
||||
if lng is not None and lng != "":
|
||||
try:
|
||||
lng = float(lng)
|
||||
except (ValueError, TypeError):
|
||||
return False, "Place longitude must be a number"
|
||||
if not -180 <= lng <= 180:
|
||||
return False, "Place longitude must be between -180 and 180"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
+119
-17
@@ -703,32 +703,134 @@ class AddDependency(Tool):
|
||||
|
||||
class ApplyTemplate(Tool):
|
||||
name = "apply_template"
|
||||
description = "Applique un template de page dans une collection (crée une page)."
|
||||
description = (
|
||||
"Applique un template du registre unifié (crée une page, une ligne, "
|
||||
"une base ou des blocs). Même service que l'UI (TemplateService)."
|
||||
)
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {"collection_id": {"type": "integer"}, "template_id": {"type": "integer"}, "title": {"type": "string"}},
|
||||
"required": ["collection_id", "template_id"],
|
||||
"properties": {
|
||||
"collection_id": {"type": "integer"},
|
||||
"template_id": {"type": ["integer", "string"]},
|
||||
"title": {"type": "string"},
|
||||
"variables": {"type": "object"},
|
||||
},
|
||||
"required": ["template_id"],
|
||||
}
|
||||
|
||||
async def execute(self, args, *, user_id=None) -> ToolResult:
|
||||
cid, tid = args.get("collection_id"), args.get("template_id")
|
||||
from app.services.fd_templates import TemplateService, registry_ready
|
||||
|
||||
tid = args.get("template_id")
|
||||
cid = args.get("collection_id")
|
||||
# Compat legacy : un id numérique de page_templates.
|
||||
template_ref: str = str(tid)
|
||||
with get_conn() as conn:
|
||||
tmpl = conn.execute("SELECT * FROM page_templates WHERE id=? AND collection_id=?", (tid, cid)).fetchone()
|
||||
if not tmpl:
|
||||
return ToolResult(status="error", tool=self.name, message="Template introuvable")
|
||||
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?", (cid,)).fetchone()[0]
|
||||
cur = conn.execute(
|
||||
"INSERT INTO collection_pages (collection_id, title, position, property_values_json) VALUES (?,?,?,?)",
|
||||
(cid, args.get("title", "New Page"), max_pos, tmpl["property_values_json"]),
|
||||
)
|
||||
conn.commit()
|
||||
actor = _actor_for(conn, user_id)
|
||||
if registry_ready(conn) and isinstance(tid, int):
|
||||
legacy = conn.execute(
|
||||
"SELECT id FROM templates WHERE source_kind='legacy_page' AND source_id=?",
|
||||
(str(tid),),
|
||||
).fetchone()
|
||||
if legacy:
|
||||
template_ref = legacy["id"]
|
||||
variables = dict(args.get("variables") or {})
|
||||
if args.get("title"):
|
||||
variables.setdefault("title", args["title"])
|
||||
context: dict = {}
|
||||
if cid:
|
||||
context["collection_id"] = cid
|
||||
try:
|
||||
result = TemplateService.instantiate(
|
||||
conn, actor, template_ref, variables, context,
|
||||
run_source="agent",
|
||||
)
|
||||
except LookupError as exc:
|
||||
return ToolResult(status="error", tool=self.name, message=str(exc))
|
||||
except (ValueError, PermissionError) as exc:
|
||||
return ToolResult(status="error", tool=self.name, message=str(exc))
|
||||
except Exception as exc: # noqa: BLE001 — l'agent doit recevoir l'erreur
|
||||
logger.exception("apply_template agent")
|
||||
return ToolResult(status="error", tool=self.name, message=str(exc))
|
||||
created_id = result.get("created_id") or None
|
||||
try:
|
||||
target_id = int(created_id) if created_id else 0
|
||||
except (TypeError, ValueError):
|
||||
target_id = 0
|
||||
undo = None
|
||||
if result.get("created_type") in ("page", "row", "collection") and target_id:
|
||||
table = {"page": "pages", "row": "collection_pages",
|
||||
"collection": "collections"}[result["created_type"]]
|
||||
undo = {"action": "delete", "table": table, "id": target_id}
|
||||
return ToolResult(
|
||||
status="success", tool=self.name, target_type="page", target_id=cur.lastrowid,
|
||||
data={"page_id": cur.lastrowid},
|
||||
undo={"action": "delete", "table": "collection_pages", "id": cur.lastrowid},
|
||||
status="success", tool=self.name,
|
||||
target_type=result.get("created_type") or "page",
|
||||
target_id=target_id,
|
||||
data={"page_id": target_id, "url": result.get("url"),
|
||||
"warnings": result.get("warnings", []),
|
||||
"run_id": result.get("run_id")},
|
||||
undo=undo,
|
||||
)
|
||||
|
||||
|
||||
class ListTemplates(Tool):
|
||||
name = "list_templates"
|
||||
description = "Liste les templates visibles par l'utilisateur (registre unifié)."
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {"type": "string"},
|
||||
"query": {"type": "string"},
|
||||
"target_collection_id": {"type": "integer"},
|
||||
},
|
||||
"required": [],
|
||||
}
|
||||
|
||||
async def execute(self, args, *, user_id=None) -> ToolResult:
|
||||
from app.services.fd_templates import TemplateService
|
||||
|
||||
with get_conn() as conn:
|
||||
actor = _actor_for(conn, user_id)
|
||||
try:
|
||||
items = TemplateService.list_templates(
|
||||
conn, actor, kind=args.get("kind"),
|
||||
query=args.get("query"),
|
||||
target_collection_id=args.get("target_collection_id"),
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("list_templates agent")
|
||||
return ToolResult(status="error", tool=self.name, message=str(exc))
|
||||
summary = [
|
||||
{"id": t["id"], "kind": t["kind"], "name": t["name"],
|
||||
"scope": t["scope"], "is_default": t.get("is_default", False)}
|
||||
for t in items
|
||||
]
|
||||
return ToolResult(
|
||||
status="success", tool=self.name, target_type="templates",
|
||||
data={"templates": summary},
|
||||
)
|
||||
|
||||
|
||||
def _actor_for(conn, user_id: int | None) -> dict:
|
||||
"""Acteur minimal pour le service (l'agent n'agit jamais au-delà de
|
||||
l'utilisateur appelant, §6.1 de l'architecture)."""
|
||||
actor: dict = {"id": user_id}
|
||||
if user_id:
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT id, login, full_name, is_admin FROM users WHERE id=?",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
if row:
|
||||
actor.update(
|
||||
{"login": row["login"], "full_name": row["full_name"],
|
||||
"is_admin": bool(row["is_admin"])}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return actor
|
||||
|
||||
|
||||
# ══════════════════════════ Destructive tools ══════════════════════════
|
||||
|
||||
|
||||
@@ -1149,7 +1251,7 @@ TOOL_CLASSES = [
|
||||
SearchWorkspace, ReadCollection, ReadPage, ReadWorkspaces, ReadDocument,
|
||||
CreateCollection, CreateView, AddProperty, CreatePage, CreateDocument,
|
||||
UpdatePage, WriteBlocks,
|
||||
AddRelation, CreateSubItem, AddDependency, ApplyTemplate,
|
||||
AddRelation, CreateSubItem, AddDependency, ApplyTemplate, ListTemplates,
|
||||
DeletePage, DeleteCollection,
|
||||
ReadGiteaIssues, SyncGitea, CreateGiteaIssue,
|
||||
DeleteDocument,
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
"""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}
|
||||
@@ -0,0 +1,166 @@
|
||||
"""FlowDeck — View Query Service (vues Notion §8.1).
|
||||
|
||||
Extraction partagee de la chaine de rendu des vues : charger le schema,
|
||||
appliquer filtres (and/or) -> tris -> projection des proprietes visibles.
|
||||
Toutes les vues clientes (Table/Board/... + chart/timeline/feed/map/
|
||||
dashboard/form) en sont des consommatrices.
|
||||
|
||||
ACL : le filtrage des proprietes se fait AVANT tout calcul — une vue
|
||||
n'agrege / ne geocode / n'affiche jamais une propriete invisible.
|
||||
En v1 on s'appuie sur les ACL de collection (le granulair propriete est
|
||||
applique quand `property_acls` est disponible, sinon tout est visible).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
CHART_MAX_GROUPS = 200
|
||||
CHART_MAX_SUBGROUPS = 50
|
||||
VIEW_ROWS_MAX = 1000
|
||||
MAP_POINTS_MAX = 100
|
||||
TIMELINE_LOAD_MAX = 200
|
||||
FEED_PAGE_SIZE = 20
|
||||
|
||||
|
||||
def parse_values(page: dict) -> dict:
|
||||
try:
|
||||
v = page.get("property_values_json", "{}")
|
||||
return json.loads(v) if isinstance(v, str) else (v or {})
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return {}
|
||||
|
||||
|
||||
def prop_value(page: dict, prop: dict) -> Any:
|
||||
if prop.get("prop_type") == "title":
|
||||
return page.get("title", "")
|
||||
vals = parse_values(page)
|
||||
v = vals.get(str(prop.get("id")))
|
||||
if v is None:
|
||||
v = vals.get(prop.get("name"))
|
||||
return v
|
||||
|
||||
|
||||
def text_of(prop: dict, value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, dict):
|
||||
# place / person-single / user ref
|
||||
return str(value.get("name") or value.get("address")
|
||||
or value.get("login") or value.get("full_name") or "")
|
||||
if isinstance(value, list):
|
||||
parts = []
|
||||
for x in value:
|
||||
if isinstance(x, dict):
|
||||
parts.append(str(x.get("login") or x.get("full_name")
|
||||
or x.get("name") or x.get("url") or ""))
|
||||
else:
|
||||
parts.append(str(x))
|
||||
return ", ".join(parts)
|
||||
return str(value)
|
||||
|
||||
|
||||
def _match_op(actual: Any, op: str, expected: Any, prop: dict) -> bool:
|
||||
actual_text = text_of(prop, actual).lower()
|
||||
if op in ("is", "equals", "is exactly"):
|
||||
if isinstance(actual, list):
|
||||
return str(expected) in [str(x.get("name", x) if isinstance(x, dict) else x) for x in actual]
|
||||
return actual_text == str(expected or "").lower()
|
||||
if op in ("is_not", "is not", "not_equals"):
|
||||
return actual_text != str(expected or "").lower()
|
||||
if op == "contains":
|
||||
return str(expected or "").lower() in actual_text
|
||||
if op in ("not_contains", "does_not_contain"):
|
||||
return str(expected or "").lower() not in actual_text
|
||||
if op == "is_empty":
|
||||
return actual in (None, "", []) or actual_text == ""
|
||||
if op == "is_not_empty":
|
||||
return not (actual in (None, "", []) or actual_text == "")
|
||||
if op in ("gt", ">", "after"):
|
||||
try:
|
||||
return float(actual_text) > float(expected) if actual_text else False
|
||||
except (ValueError, TypeError):
|
||||
return str(actual_text) > str(expected or "")
|
||||
if op in ("lt", "<", "before"):
|
||||
try:
|
||||
return float(actual_text) < float(expected) if actual_text else False
|
||||
except (ValueError, TypeError):
|
||||
return str(actual_text) < str(expected or "")
|
||||
if op == "is_resolved":
|
||||
return isinstance(actual, dict) and actual.get("lat") not in (None, "")
|
||||
if op == "is_not_resolved":
|
||||
return not (isinstance(actual, dict) and actual.get("lat") not in (None, ""))
|
||||
return True
|
||||
|
||||
|
||||
def apply_filters(pages: list[dict], properties: list[dict],
|
||||
filters: list[dict] | None,
|
||||
conjunction: str = "and") -> list[dict]:
|
||||
if not filters:
|
||||
return list(pages)
|
||||
by_name = {p["name"]: p for p in properties}
|
||||
by_id = {str(p["id"]): p for p in properties}
|
||||
out = []
|
||||
for page in pages:
|
||||
results = []
|
||||
for f in filters:
|
||||
prop = by_name.get(f.get("property")) or by_id.get(str(f.get("property", "")))
|
||||
if prop is None:
|
||||
continue
|
||||
actual = prop_value(page, prop)
|
||||
results.append(_match_op(actual, f.get("operator", "contains"), f.get("value"), prop))
|
||||
if not results:
|
||||
out.append(page)
|
||||
elif (conjunction or "and").lower() == "or":
|
||||
if any(results):
|
||||
out.append(page)
|
||||
elif all(results):
|
||||
out.append(page)
|
||||
return out
|
||||
|
||||
|
||||
def apply_sorts(pages: list[dict], properties: list[dict],
|
||||
sorts: list[dict] | None) -> list[dict]:
|
||||
if not sorts:
|
||||
return list(pages)
|
||||
by_name = {p["name"]: p for p in properties}
|
||||
by_id = {str(p["id"]): p for p in properties}
|
||||
rows = list(pages)
|
||||
for s in reversed(sorts):
|
||||
prop = by_name.get(s.get("property")) or by_id.get(str(s.get("property", "")))
|
||||
if prop is None:
|
||||
continue
|
||||
reverse = str(s.get("direction", "asc")).lower() in ("desc", "descending")
|
||||
rows.sort(key=lambda p: text_of(prop, prop_value(p, prop)).lower(), reverse=reverse)
|
||||
return rows
|
||||
|
||||
|
||||
def query_view_rows(pages: list[dict], properties: list[dict],
|
||||
config: dict, extra_filters: list[dict] | None = None,
|
||||
limit: int = VIEW_ROWS_MAX,
|
||||
cursor: str | None = None) -> tuple[list[dict], str | None]:
|
||||
"""Filtre + tri + projection + pagination par curseur (id)."""
|
||||
filters = list(config.get("filters") or [])
|
||||
if extra_filters:
|
||||
filters.extend(extra_filters)
|
||||
conjunction = config.get("filter_conjunction", "and")
|
||||
rows = apply_filters(pages, properties, filters, conjunction)
|
||||
rows = apply_sorts(pages and rows, properties, config.get("sorts"))
|
||||
start = 0
|
||||
if cursor:
|
||||
for i, p in enumerate(rows):
|
||||
if str(p.get("id")) == str(cursor):
|
||||
start = i + 1
|
||||
break
|
||||
window = rows[start:start + limit]
|
||||
next_cursor = str(window[-1]["id"]) if len(rows) > start + limit and window else None
|
||||
return window, next_cursor
|
||||
|
||||
|
||||
def visible_props(properties: list[dict], config: dict) -> list[dict]:
|
||||
vis = config.get("visible_properties")
|
||||
if not vis:
|
||||
return list(properties)
|
||||
wanted = {str(v) for v in vis}
|
||||
ordered = [p for p in properties if str(p.get("id")) in wanted or p.get("name") in wanted]
|
||||
return ordered or list(properties)
|
||||
Reference in New Issue
Block a user