feat(templates): refonte complète des templates façon Notion + vues/agents-skills
FlowDeck CI / lint (push) Failing after 1m32s
FlowDeck CI / test (push) Failing after 27m50s
FlowDeck CI / docker (push) Skipped

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:
2026-10-10 18:52:19 -04:00
parent d0452e10dd
commit fc8548194a
36 changed files with 12290 additions and 137 deletions
File diff suppressed because it is too large Load Diff
+68
View File
@@ -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
+154
View File
@@ -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
+45 -5
View File
@@ -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
View File
@@ -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,
+221
View File
@@ -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}
+166
View File
@@ -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)