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
+386
View File
@@ -0,0 +1,386 @@
"""Templates unifiés façon Notion — tests de la refonte (§17.2 du cahier des charges).
Couvre : migrations 48/49, backfill idempotent, presets système,
résolution des variables (aucun {{token}} survivant), dégradation sur
propriété renommée, défaut exclusif, idempotence, récurrence dédupliquée,
API interne et annulation (undo).
"""
import json
import pytest
from app.db import get_conn
from app.services.fd_templates import TemplateService, registry_ready
@pytest.fixture
def actor():
return {"id": 1, "login": "tester", "full_name": "Tester", "is_admin": 1}
def _ensure_ws(conn, actor):
"""Workspace valide (FK) pour les inserts directs des tests."""
from app.services.fd_templates import _ensure_workspace
return _ensure_workspace(conn, actor)
def _db_version():
with get_conn() as conn:
return conn.execute("SELECT COALESCE(MAX(version),0) FROM schema_version").fetchone()[0]
def test_migrations_48_49_applied(client):
assert registry_ready(get_conn().__enter__()) or True
with get_conn() as conn:
assert registry_ready(conn)
tables = {r[0] for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'").fetchall()}
assert {"templates", "template_runs", "template_recurrences"} <= tables
assert _db_version() >= 49
def test_backfill_idempotent_and_presets(client, actor):
with get_conn() as conn:
first = TemplateService.backfill_registry(conn)
n1 = conn.execute("SELECT COUNT(*) FROM templates WHERE deleted_at IS NULL").fetchone()[0]
second = TemplateService.backfill_registry(conn)
n2 = conn.execute("SELECT COUNT(*) FROM templates WHERE deleted_at IS NULL").fetchone()[0]
assert n1 == n2
assert sum(second.values()) == 0
# Silos legacy catalogués, jamais détruits.
legacy_db = conn.execute("SELECT COUNT(*) FROM database_templates").fetchone()[0]
reg_db = conn.execute(
"SELECT COUNT(*) FROM templates WHERE source_kind='legacy_database'").fetchone()[0]
assert reg_db == legacy_db and legacy_db > 0
# Galerie système : au moins 5 presets utilisables.
n_presets = TemplateService.ensure_system_presets(conn)
sys_rows = conn.execute(
"SELECT COUNT(*) FROM templates WHERE scope='system' AND deleted_at IS NULL").fetchone()[0]
assert sys_rows >= 5
assert TemplateService.ensure_system_presets(conn) == 0
assert first["legacy_database"] >= 0
def test_instantiate_page_resolves_variables(client, actor):
with get_conn() as conn:
dto = TemplateService.create_template(conn, actor, {
"kind": "page", "name": "Revue", "scope": "personal",
"title_template": "Revue du {{today}} — {{me}}",
"blocks": [{"id": "b1", "type": "paragraph", "content": "vu le {{today}}"}],
})
conn.commit()
result = TemplateService.instantiate(conn, actor, dto["id"], {}, {})
assert result["created_type"] == "page"
assert "{{" not in result["url"] and result["url"].startswith("/pages/")
page = conn.execute("SELECT * FROM pages WHERE id=?",
(int(result["created_id"]),)).fetchone()
assert "{{" not in (page["title"] or "")
assert "{{" not in (page["content"] or "")
assert "Revue du 20" in page["title"] # date du jour résolue
runs = conn.execute("SELECT * FROM template_runs WHERE template_id=?",
(dto["id"],)).fetchall()
assert len(runs) == 1 and runs[0]["status"] == "ok"
def test_required_variable_blocks_write(client, actor):
with get_conn() as conn:
dto = TemplateService.create_template(conn, actor, {
"kind": "page", "name": "Spec", "scope": "personal",
"title_template": "Spec — {{sujet}}",
"variables": [{"name": "sujet", "type": "text", "required": True}],
"blocks": [],
})
conn.commit()
before = conn.execute("SELECT COUNT(*) FROM pages").fetchone()[0]
with pytest.raises(ValueError, match="sujet"):
TemplateService.instantiate(conn, actor, dto["id"], {}, {})
after = conn.execute("SELECT COUNT(*) FROM pages").fetchone()[0]
assert before == after # erreur AVANT toute écriture
ok = TemplateService.instantiate(conn, actor, dto["id"], {"sujet": "X"}, {})
assert ok["created_type"] == "page"
def test_row_template_degraded_property_warning(client, actor):
with get_conn() as conn:
ws = _ensure_ws(conn, actor)
cid = conn.execute(
"INSERT INTO collections (name, description, icon, schema_json, workspace_id) VALUES (?,?,?,?,?)",
("Taches", "", "✅", "[]", ws)).lastrowid
conn.execute(
"INSERT INTO collection_properties (collection_id, name, prop_type, position) VALUES (?,?,?,?)",
(cid, "Status", "status", 0))
conn.commit()
dto = TemplateService.create_template(conn, actor, {
"kind": "row", "name": "Bug", "scope": "personal",
"target_collection_id": cid,
"manifest": {"property_defaults": {"Status": "À trier", "Priorite": "P1"}},
"blocks": [],
})
conn.commit()
result = TemplateService.instantiate(conn, actor, dto["id"], {},
{"collection_id": cid})
assert result["created_type"] == "row"
# Propriété renommée → avertissement, pas d'échec total.
assert any("Priorite" in w for w in result["warnings"])
row = conn.execute("SELECT * FROM collection_pages WHERE id=?",
(int(result["created_id"]),)).fetchone()
assert row is not None
def test_default_exclusive(client, actor):
with get_conn() as conn:
ws = _ensure_ws(conn, actor)
cid = conn.execute(
"INSERT INTO collections (name, schema_json, workspace_id) VALUES (?,?,?)",
("B", "[]", ws)).lastrowid
conn.commit()
a = TemplateService.create_template(conn, actor, {
"kind": "row", "name": "A", "scope": "personal",
"target_collection_id": cid, "blocks": []})
b = TemplateService.create_template(conn, actor, {
"kind": "row", "name": "B", "scope": "personal",
"target_collection_id": cid, "blocks": []})
conn.commit()
TemplateService.set_default(conn, actor, a["id"])
TemplateService.set_default(conn, actor, b["id"])
conn.commit()
rows = conn.execute(
"SELECT id FROM templates WHERE target_collection_id=? AND is_default=1 AND deleted_at IS NULL",
(cid,)).fetchall()
assert [r["id"] for r in rows] == [b["id"]]
def test_idempotency_single_object(client, actor):
with get_conn() as conn:
dto = TemplateService.create_template(conn, actor, {
"kind": "page", "name": "Idem", "scope": "personal", "blocks": []})
conn.commit()
key = "test-key-123"
r1 = TemplateService.instantiate(conn, actor, dto["id"], {}, {},
idempotency_key=key)
r2 = TemplateService.instantiate(conn, actor, dto["id"], {}, {},
idempotency_key=key)
assert r1["created_id"] == r2["created_id"]
assert r2.get("replayed") is True
n = conn.execute(
"SELECT COUNT(*) FROM pages WHERE title=?", ("Idem",)).fetchone()[0]
assert n == 1
def test_recurrence_dedup(client, actor):
with get_conn() as conn:
ws = _ensure_ws(conn, actor)
cid = conn.execute(
"INSERT INTO collections (name, schema_json, workspace_id) VALUES (?,?,?)",
("Rec", "[]", ws)).lastrowid
conn.commit()
dto = TemplateService.create_template(conn, actor, {
"kind": "row", "name": "Quotidien", "scope": "personal",
"target_collection_id": cid, "blocks": []})
conn.commit()
TemplateService.set_recurrence(conn, actor, dto["id"],
{"rrule": "FREQ=DAILY", "timezone": ""})
# Prochain passage forcé dans le passé pour le test.
conn.execute("UPDATE template_recurrences SET next_run_at='2000-01-01T09:00:00' WHERE template_id=?",
(dto["id"],))
conn.commit()
fired1 = TemplateService.run_due_recurrences(conn, now_iso="2000-01-02T10:00:00")
assert len(fired1) == 1 and fired1[0]["status"] == "ok"
# Même occurrence rejouée (redémarrage scheduler) → pas de doublon.
conn.execute("UPDATE template_recurrences SET next_run_at='2000-01-01T09:00:00' WHERE template_id=?",
(dto["id"],))
conn.commit()
fired2 = TemplateService.run_due_recurrences(conn, now_iso="2000-01-02T10:00:00")
assert fired2 == []
n = conn.execute("SELECT COUNT(*) FROM collection_pages WHERE collection_id=?",
(cid,)).fetchone()[0]
assert n == 1
def test_api_list_instantiate_undo(client):
# Liste (le bouton « Templates » consomme cette route).
r = client.get("/api/templates?kind=page")
assert r.status_code == 200
assert isinstance(r.json()["templates"], list)
assert len(r.json()["templates"]) >= 5 # presets système
tid = r.json()["templates"][0]["id"]
# Détail + aperçu.
assert client.get(f"/api/templates/{tid}").status_code == 200
pv = client.post(f"/api/templates/{tid}/preview", json={})
assert pv.status_code == 200
# Instanciation puis annulation (titre fourni pour les variables requises).
ins = client.post(f"/api/templates/{tid}/instantiate",
json={"variables": {"title": "Test"}})
assert ins.status_code == 200, ins.text
body = ins.json()
assert body["url"]
run_id = body["run_id"]
if run_id:
undo = client.post(f"/api/templates/runs/{run_id}/undo")
assert undo.status_code == 200
# 404 explicite, jamais un no-op silencieux.
assert client.get("/api/templates/nope-123").status_code == 404
bad = client.post("/api/templates/nope-123/instantiate", json={})
assert bad.status_code == 404
def test_manager_page_renders(client):
r = client.get("/templates")
assert r.status_code == 200
assert "Templates" in r.text
_VALID_BLOCK_TYPES = {
"paragraph", "heading_1", "heading_2", "heading_3", "heading_4",
"bulleted_list", "numbered_list", "to_do", "toggle", "quote", "callout",
"divider", "code", "table_of_contents", "math", "columns", "table",
}
def test_system_presets_catalog_is_complete(client, actor):
"""Galerie : > 20 presets, tous les genres, types de blocs valides."""
from app.services.fd_templates import SYSTEM_PRESETS
names = {(p["kind"], p["name"]) for p in SYSTEM_PRESETS}
assert len(SYSTEM_PRESETS) >= 25
assert len(names) == len(SYSTEM_PRESETS) # pas de doublon
kinds = {p["kind"] for p in SYSTEM_PRESETS}
assert {"page", "database", "blocks", "row"} <= kinds
# Les blocs utilisent les types réels de l'éditeur (pas heading/todo).
for p in SYSTEM_PRESETS:
for b in p.get("blocks") or []:
assert b["type"] in _VALID_BLOCK_TYPES, (p["name"], b["type"])
with get_conn() as conn:
TemplateService.ensure_system_presets(conn)
rows = conn.execute(
"SELECT kind, COUNT(*) n FROM templates WHERE scope='system' AND deleted_at IS NULL GROUP BY kind"
).fetchall()
by_kind = {r["kind"]: r["n"] for r in rows}
assert sum(by_kind.values()) >= 25
assert by_kind.get("page", 0) >= 10
assert by_kind.get("database", 0) >= 8
assert by_kind.get("blocks", 0) >= 4
assert by_kind.get("row", 0) >= 4
def test_database_preset_creates_properties(client, actor):
with get_conn() as conn:
items = TemplateService.list_templates(conn, actor, kind="database")
tache = next(t for t in items if t["name"] == "Tâches")
res = TemplateService.instantiate(conn, actor, tache["id"], {}, {})
assert res["created_type"] == "collection"
props = conn.execute(
"SELECT name FROM collection_properties WHERE collection_id=?",
(int(res["created_id"]),)).fetchall()
names = {p["name"] for p in props}
assert {"Statut", "Priorité", "Échéance", "Assigné à"} <= names
def test_row_preset_resolves_base_by_name(client, actor):
with get_conn() as conn:
ws = _ensure_ws(conn, actor)
cid = conn.execute(
"INSERT INTO collections (name, schema_json, workspace_id) VALUES (?,?,?)",
("Tâches", "[]", ws)).lastrowid
conn.commit()
items = TemplateService.list_templates(conn, actor, kind="row")
tache = next(t for t in items if t["name"] == "Tâche")
# Sans collection_id : la base est résolue par nom (manifest).
res = TemplateService.instantiate(conn, actor, tache["id"], {}, {})
assert res["created_type"] == "row"
row = conn.execute(
"SELECT collection_id FROM collection_pages WHERE id=?",
(int(res["created_id"]),)).fetchone()
assert row["collection_id"] == cid
def test_row_preset_without_base_reports_clearly(client, actor):
with get_conn() as conn:
items = TemplateService.list_templates(conn, actor, kind="row")
tache = next(t for t in items if t["name"] == "Tâche")
# Aucune base « Tâches » → message explicite, pas d'objet créé.
with pytest.raises(ValueError, match="base"):
TemplateService.instantiate(conn, actor, tache["id"], {}, {})
def test_page_template_fills_current_page_and_sets_title(client, actor):
"""Appliquer un template de page SANS remplir de variable ne doit pas
échouer (title réservé), et le titre suit le template."""
with get_conn() as conn:
ws = _ensure_ws(conn, actor)
page_id = conn.execute(
"INSERT INTO pages (workspace, workspace_id, title, content, content_format) "
"VALUES (?,?,?,?,?)",
("T", ws, "Untitled", "[]", "blocks")).lastrowid
conn.commit()
items = TemplateService.list_templates(conn, actor, kind="page")
tpl = next(t for t in items if t["name"] == "Compte rendu client")
res = TemplateService.instantiate(
conn, actor, tpl["id"], {},
{"target": {"type": "current_page", "page_id": page_id},
"parent_id": page_id})
conn.commit()
assert res["created_type"] == "page"
assert res["title"].startswith("CR —")
page = conn.execute("SELECT title FROM pages WHERE id=?", (page_id,)).fetchone()
assert page["title"] == res["title"]
def test_page_template_instantiates_without_variables(client):
"""Un template de page s'instancie sans variables (title réservé)."""
r = client.get("/api/templates?kind=page")
for t in r.json()["templates"]:
resp = client.post(f"/api/templates/{t['id']}/instantiate", json={"variables": {}})
assert resp.status_code == 200, (t["name"], resp.text)
def test_preview_never_400_on_required_vars(client, actor):
"""L'aperçu ne bloque jamais (tokens montrés tels quels, §7.1)."""
with get_conn() as conn:
dto = TemplateService.create_template(conn, actor, {
"kind": "page", "name": "Spec", "scope": "personal",
"title_template": "Spec — {{sujet}}",
"variables": [{"name": "sujet", "type": "text", "required": True}],
"blocks": [],
})
conn.commit()
preview = TemplateService.preview(conn, actor, dto["id"], {})
assert "{{sujet}}" in preview["title"]
assert any("sujet" in w for w in preview["warnings"])
def test_preview_endpoint_ok_for_system_presets(client):
r = client.get("/api/templates?kind=page")
assert r.status_code == 200
for t in r.json()["templates"]:
pv = client.post(f"/api/templates/{t['id']}/preview", json={})
assert pv.status_code == 200, (t["name"], pv.text)
def test_database_template_inline_in_current_page(client, actor):
"""Un template de base appliqué depuis un document crée une base INLINE
rattachée à la page (pas de navigation, embed côté client)."""
with get_conn() as conn:
ws = _ensure_ws(conn, actor)
page_id = conn.execute(
"INSERT INTO pages (workspace, workspace_id, title, content, content_format) VALUES (?,?,?,?,?)",
("Test", ws, "Doc", "[]", "blocks")).lastrowid
conn.commit()
dto = TemplateService.create_template(conn, actor, {
"kind": "database", "name": "Projets", "scope": "personal",
"manifest": {"collection": {"name": "Projets", "schema": [
{"name": "Status", "type": "status"}]}},
})
conn.commit()
result = TemplateService.instantiate(
conn, actor, dto["id"], {}, {"parent_page_id": page_id})
assert result["created_type"] == "collection"
assert result["url"] == f"/db/{result['created_id']}"
col = conn.execute("SELECT * FROM collections WHERE id=?",
(int(result["created_id"]),)).fetchone()
assert col["parent_page_id"] == page_id
assert col["is_inline"] == 1
props = conn.execute(
"SELECT name FROM collection_properties WHERE collection_id=?",
(col["id"],)).fetchall()
assert [p["name"] for p in props] == ["Status"]
+103
View File
@@ -0,0 +1,103 @@
"""FlowDeck v7.70.0 — Ouverture d'un projet Gitea : présentation + miroir local.
Régression couverte :
- ``GET /gitea-workspace?owner=&repo=`` crée le **workspace miroir local**
AVANT de calculer la sidebar : le premier affichage du projet ne montre plus
« No workspace open » (ou un ``localWsId: 0``) — équivalent local ouvert en
parallèle du dépôt distant ;
- le contexte projet est porté par l'URL : la sidebar résout le miroir même
sans cookie ``flowdeck_workspace`` (navigation directe / favori) ;
- ``GET /api/sidebar/workspace-tree`` rend l'arbre du **miroir** (et non plus le
placeholder « Remote workspace ») quand le cookie est ``gitea:owner:repo`` ;
- ``loadGiteaTree`` (base.html) cible ``#sidebar-gitea-items`` et n'écrase plus
l'arbre du workspace local de ``#sidebar-workspace-items``.
"""
from __future__ import annotations
import re
from app.db import get_conn
def _mirror_id(ws_key: str) -> int | None:
with get_conn() as conn:
row = conn.execute(
"SELECT id FROM workspaces WHERE name=? AND settings_json LIKE ?",
(ws_key, "%gitea_repo%"),
).fetchone()
return row["id"] if row else None
def _mk_mirror_page(ws_id: int, title: str) -> int:
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section) "
"VALUES ('', ?, ?, '', 'blocks', 'Private')",
(ws_id, title),
)
conn.commit()
return cur.lastrowid
def test_gitea_workspace_creates_and_shows_local_mirror(client):
"""Le premier GET crée le miroir et le sidebar pointe dessus (localWsId>0)."""
assert _mirror_id("acme/widget") is None
r = client.get("/gitea-workspace?owner=acme&repo=widget")
assert r.status_code == 200, r.text
ws_id = _mirror_id("acme/widget")
assert ws_id is not None, "le workspace miroir doit être créé"
# La sidebar expose l'ID du miroir (et non 0) dès le premier rendu.
m = re.search(r"localWsId:\s*(\d+)", r.text)
assert m, "localWsId introuvable dans la page"
assert int(m.group(1)) == ws_id, "la sidebar doit ouvrir l'équivalent local en parallèle"
assert "Open a workspace to see your files" not in r.text
assert "acme/widget" in r.text
def test_gitea_workspace_context_from_url_without_cookie(client):
"""Navigation directe (sans cookie) : le projet reste identifié par l'URL."""
r = client.get("/gitea-workspace?owner=acme&repo=gadget")
assert r.status_code == 200, r.text
ws_id = _mirror_id("acme/gadget")
assert ws_id is not None
m = re.search(r"localWsId:\s*(\d+)", r.text)
assert m and int(m.group(1)) == ws_id
# Pas de bandeau « aucune workspace ouverte » alors que le miroir existe.
assert "Open a workspace to see your files" not in r.text
def test_sidebar_workspace_tree_renders_gitea_mirror(client):
"""Le refresh sidebar ne remplace plus le miroir par « Remote workspace »."""
client.get("/gitea-workspace?owner=acme&repo=repo1")
ws_id = _mirror_id("acme/repo1")
assert ws_id is not None
_mk_mirror_page(ws_id, "Note miroir")
r = client.get(
"/api/sidebar/workspace-tree",
cookies={"flowdeck_workspace": "gitea:acme:repo1"},
)
assert r.status_code == 200, r.text
assert "Note miroir" in r.text
assert "Remote workspace" not in r.text
def test_sidebar_workspace_tree_gitea_without_mirror_is_empty_not_error(client):
r = client.get(
"/api/sidebar/workspace-tree",
cookies={"flowdeck_workspace": "gitea:ghost/none"},
)
assert r.status_code == 200
assert "Remote workspace" not in r.text
assert "No pages yet" in r.text
def test_loadgitea_tree_targets_repository_section():
"""Scan statique : loadGiteaTree ne doit pas cibler #sidebar-workspace-items."""
from pathlib import Path
html = (Path(__file__).resolve().parent.parent / "app" / "templates" / "base.html").read_text(
encoding="utf-8"
)
fn = html.split("loadGiteaTree() {", 1)[1].split("newPageInWorkspace()", 1)[0]
assert "getElementById('sidebar-gitea-items')" in fn
assert "getElementById('sidebar-workspace-items')" not in fn
+137
View File
@@ -0,0 +1,137 @@
"""Vues Notion (doc architecture-vues-notion-flowdeck.md §2.4, §21).
Tests d'acceptation CU-Chart / CU-Timeline / CU-Map / CU-Feed /
CU-Dashboard / CU-Form + type place + migrations 44-47.
"""
from __future__ import annotations
def _make_base(c):
r = c.post('/db/api', json={'name': 'TodoNotion'})
assert r.status_code == 200, r.text
cid = r.json()['id']
c.post(f'/db/{cid}/properties/api', json={'name': 'Status', 'prop_type': 'select'})
c.post(f'/db/{cid}/properties/api', json={'name': 'Due', 'prop_type': 'date'})
c.post(f'/db/{cid}/properties/api', json={'name': 'Place', 'prop_type': 'place'})
# 4 Not started, 1 In progress, 1 Done (cf. CU-Chart §2.4)
for i, s in enumerate(['Not started'] * 4 + ['In progress', 'Done']):
c.post(f'/db/{cid}/pages/api', json={'title': f'tache {i}', 'properties': {'Status': s}})
# Une ligne datee 22 -> 27 octobre 2026 (CU-Timeline)
c.post(f'/db/{cid}/pages/api', json={'title': 'bruno', 'properties': {
'Status': 'In progress', 'Due': '2026-10-22...2026-10-27',
'Place': {'name': 'Beloeil', 'address': 'Beloeil, QC', 'lat': 45.56, 'lng': -73.20}}})
return cid
def test_migrations_44_47_applied(client):
from app.db import get_conn
with get_conn() as conn:
v = conn.execute("SELECT MAX(version) FROM schema_version").fetchone()[0]
assert v >= 47
for tbl in ('geocode_cache', 'dashboard_widgets', 'form_questions'):
assert conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name=?", (tbl,)).fetchone()
def test_place_type_validation(client):
c = client
cid = _make_base(c)
r = c.post(f'/db/{cid}/pages/api', json={'title': 'Bad', 'properties': {'Place': {'lat': 1, 'lng': 2}}})
assert r.status_code == 400
r = c.post(f'/db/{cid}/pages/api', json={'title': 'Bad2', 'properties': {
'Place': {'name': 'X', 'lat': 200, 'lng': 0}}})
assert r.status_code == 400
r = c.post(f'/db/{cid}/pages/api', json={'title': 'Addr', 'properties': {'Place': 'Montreal, QC, Canada'}})
assert r.status_code in (200, 201)
def test_cu_chart_donut_and_drilldown(client):
c = client
cid = _make_base(c)
r = c.post(f'/db/{cid}/views/save-as', json={'name': 'Chart', 'view_type': 'chart', 'config': {}})
vid = r.json()['id']
c.put(f"/db/views/{vid}/config", json={'chart_type': 'doughnut', 'group_by': 'Status',
'measure': {'kind': 'count'}, 'omit_zero': False})
r = c.post(f"/db/{cid}/views/{vid}/aggregate", json={})
assert r.status_code == 200
data = r.json()
assert data['total'] == 7
by_key = {g['key']: g for g in data['groups']}
assert by_key['Not started']['count'] == 4
assert abs(by_key['Not started']['percent'] - 4 / 7) < 0.01
r = c.get(f"/db/{cid}/views/{vid}/drilldown", params={'group': 'Not started'})
assert r.json()['count'] == 4
def test_cu_timeline_nodate_bucket(client):
c = client
cid = _make_base(c)
r = c.post(f'/db/{cid}/views/save-as', json={'name': 'TL', 'view_type': 'timeline', 'config': {}})
vid = r.json()['id']
c.put(f"/db/views/{vid}/config", json={'date_property': 'Due', 'scale': 'quarter'})
r = c.get(f"/db/{cid}/views/{vid}/rows", params={'limit': 50})
assert r.status_code == 200
assert r.json()['undated'] == 6 # 6 lignes sans date
def test_cu_map_ceiling_and_geocode_off(client):
c = client
cid = _make_base(c)
r = c.post(f'/db/{cid}/views/save-as', json={'name': 'Map', 'view_type': 'map', 'config': {'place_property': 'Place'}})
vid = r.json()['id']
r = c.get(f"/db/{cid}/views/{vid}/rows", params={'limit': 50})
assert r.status_code == 200
# Provider none par defaut : pas d'appel externe, recherche vide.
r = c.get('/db/geocode/search', params={'q': 'Beloeil, QC'})
assert r.status_code == 200
assert r.json()['suggestions'] == []
def test_cu_dashboard_global_filter(client):
c = client
cid = _make_base(c)
r = c.post(f'/db/{cid}/views/save-as', json={'name': 'Chart', 'view_type': 'chart',
'config': {'group_by': 'Status', 'measure': {'kind': 'count'}}})
cv = r.json()['id']
r = c.post(f'/db/{cid}/views/save-as', json={'name': 'Dash', 'view_type': 'dashboard', 'config': {}})
dv = r.json()['id']
r = c.put(f"/db/{cid}/views/{dv}/widgets", json={'widgets': [
{'row_index': 0, 'position': 0, 'source_collection_id': cid,
'source_view_id': cv, 'inline_config': {}}]})
assert r.status_code == 200
widgets = c.get(f"/db/{cid}/views/{dv}/widgets").json()['widgets']
assert len(widgets) == 1
r = c.get(f"/db/{cid}/views/{dv}/widgets/{widgets[0]['id']}/data")
assert r.status_code == 200
# 13 widgets refuses (plafond 12).
r = c.put(f"/db/{cid}/views/{dv}/widgets",
json={'widgets': [{'row_index': 0, 'position': i} for i in range(13)]})
assert r.status_code == 400
def test_cu_form_builder_projection(client):
c = client
cid = _make_base(c)
r = c.post(f'/db/{cid}/views/save-as', json={'name': 'Form', 'view_type': 'form', 'config': {}})
fv = r.json()['id']
r = c.put(f"/db/{cid}/views/{fv}/form", json={
'questions': [{'property_name': 'Status', 'position': 0, 'required': True}],
'config': {'title': 'Demande', 'sharing': {'audience': 'workspace', 'anonymous': False}}})
assert r.status_code == 200
assert r.json()['projected_fields'] == 1
r = c.get(f"/db/{cid}/views/{fv}/form")
assert len(r.json()['questions']) == 1
r = c.post(f"/db/{cid}/views/{fv}/form/preview-token")
assert r.json()['submittable'] is False
def test_ssr_compat_untouched(client):
"""Les rendus SSR restent servis (compat §7.6) : chart/timeline/feed/map."""
c = client
cid = _make_base(c)
for vt in ('chart', 'timeline', 'feed', 'map', 'form', 'gantt'):
r = c.get(f"/db/{cid}/view/{vt}")
assert r.status_code == 200, vt