diff --git a/CHANGELOG.md b/CHANGELOG.md
index 21fce45..df38eb4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,40 @@
# Changelog - FlowDeck
+## v7.31.0 (2026-10-01) — Audit : A28 lot 3 (collections → package 13 fichiers)
+
+### Changed
+
+- **A28 lot 3** : `app/routers/collections.py` (**2 622 lignes, 53
+ endpoints / 52 fonctions**) devient le package `app/routers/collections/` :
+ · 10 modules de routes : `crud` 337 L (6), `properties` 322 (8),
+ `linked` 286 (7), `structure` 267 (8), `dashboard_views` 214 (3),
+ `meta` 197 (5), `views` 187 (6), `pages` 184 (4), `data_api` 122 (2),
+ `boards` 61 (3)
+ · `_common.py` (220 L) : 8 helpers auth/permissions/validation
+ · `_renderers.py` (667 L) : **15 rendus HTML des vues** (_render_chart,
+ _render_map, …) + `CHART_MAX_GROUPS`
+ · `__init__.py` : ré-exports connus — `_validate_page_properties`
+ (automations), `_chart_values`/`_chart_aggregate`/`_fmt_number`/
+ `_render_chart` (tests)
+- Preuve contractuelle : **`docs/openapi-v2.json` régénéré = identique
+ byte-à-byte**
+
+### Fixed (pièges de la découpe)
+
+- docstring d'origine gardée dans le header copié → **F404** (`from
+ __future__` après un statement) : slice `[1:30]`
+- décorateurs **empilés** (`view_collection` ×2) : segment sans `def` →
+ skip du 2ᵉ décorateur (53 endpoints = 52 unités)
+- `CHART_MAX_GROUPS` hors détection des helpers (F821 dans
+ `dashboard_views`) → import `._renderers` ajouté
+- `test_csp_no_cdn_and_vendor` lisait `collections.py` → balayage du
+ package
+
+### Notes
+
+- Reste A28 : `board.py` 2 101 L (lot 4)
+- Suite complète : **1091/1091** · ruff OK
+
## v7.30.0 (2026-10-01) — Audit : A28 lot 2 (dashboard → package 10 fichiers)
### Changed
diff --git a/ROADMAP.md b/ROADMAP.md
index c5e9393..7939f15 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -1148,7 +1148,7 @@ Quality DB views, Agent IA Palette → Realtime + E
- [x] **A26 — Config piège** : (a) `FLOWDECK_STANDALONE` documenté dans `config.py:26` mais **jamais lu** (le champ réel est `STANDALONE`, pas de `env_prefix`, `extra="ignore"` avale la coquille) ; (b) `.env.example` documente `postgresql://…` mais `db_path` (`config.py:133`) retombe silencieusement sur SQLite ; (c) `db_path` fait `Path("/" + p)` (`config.py:132`) → pour `sqlite:////data/flowdeck.db` le résultat est le chemin UNC `\\data\flowdeck.db` (vérifié : inexistant) ; (d) `app_secret_key="change-me-to-random"` (`config.py:36`) + `gitea_oauth_client_secret="test-secret"` sans aucun garde au boot alors qu'il signe `flowdeck_session` (`session.py:14`). *Fix : 1 normalisation de `db_path` + `raise` au boot si secret par défaut + corriger le commentaire/`env_prefix`. Effort : **S**.*
- [x] **A27 — 13 900 lignes de JS inline — TERMINÉ 2026-10-01 (extraction 11 874 L / -85 % + eslint 0/0) — reste : structurel seul (base 1 523 L)**, ~3 800 livrées sur **chaque** page (`base.html` 1520 + `agent_panel` 1805 + `_icon_picker` 297 + `_header` 124 + `_notification_bell` 69), et **0 linté** : `eslint.config.mjs:50` ne couvre que `static/js/**/*.js` (soit `app.js` + `offline.js`), 2 blocs se neutralisent avec `/* eslint-disable */`. Grosseurs : `_page_editor_scripts` 2517, `local_workspace` 2030, `agent_panel` 1805, `base` 1520, `_database_table_scripts` 1323, `settings` 1093, `library` 1039. *Fix : extraire les gros partials vers `/static/js/*.js` (ils ne sont pas Jinja-interpolés) + ajouter les templates à eslint.* — **phase 1 faite 2026-10-01** : **7 templates sans Jinja → 9 fichiers `static/js/*.js` (4 243 lignes, -30 % du JS inline)** : agent_panel_1/2 (4 243… dont le 1 788 L livré sur CHAQUE page), library 1039, gitea_workspace 626, _icon_picker_1/2, _ctx_menu, import, workspaces — **un fichier par bloc** (ordre/timing identique, pas de defer, attributs conservés, `?v={{ asset_version }}`), `node --check` vert sur les 9, 0 script inline restant dans les cibles, suite 1089/1089. **Lint** : eslint **installé globalement** (`npm i -g eslint`, la config était déjà flat v9 sans dépendances) → `eslint static/js` = **0 erreur / 120 warnings** (no-unused-vars 69, no-empty 36, no-undef 15 — baseline à nettoyer opportunistiquement). **Phase 2a faite 2026-10-01 (l'éditeur, le plus gros bloc interpolé)** : `_page_editor_scripts` **2 516 L extraites** vers `static/js/page_editor_scripts.js` — recette « config JSON » : les 8 interpolations Jinja lisent `PD` = `JSON.parse(#page-data)` (le bloc JSON **existait déjà** juste avant le script) ; la route enrichit `page_data` de `updated_at`, `created_at`, `user_id`, `is_shared` (**dérivé hoisté : une seule expression pour le ctx ET le JSON**) et `clip_icon` (macro `fd_icon` rendue côté serveur) ; `workspace_key` reste vide comme avant (jamais défini dans ce ctx — parité). **Cumul A27 : 6 759 L extraites** (13 904 → 7 145 inline). **8 tests adaptés** (ils lisaient le template source → lisent maintenant `static/js/page_editor_scripts.js` ; `pageIsShared:true` → parsing du JSON `#page-data` → `is_shared is True`). **Phase 2b faite 2026-10-01** : **+3 801 L** extraites avec la même recette config JSON : `local_workspace` (2031 — `lw-config` : current_folder_id/workspace_id), `settings` (1093 — `st-config` : avatar/user/is_admin/auth_method, **2 routes rendent ce template**, expressions `or ""` préservées pour Undefined), `_page_editor_realtime` (531 — `rt-config` : SELF id/login/full_name/color), `board` (146 — `bd-config` : owner/repo/initial_view). BONUS sécurité : les valeurs passent par `|tojson` (échappement JSON) au lieu d'être interpolées dans des strings JS. **Cumul A27 : 10 560 L extraites** (13 904 → **3 344 restantes**), `node --check` vert ×4, suite 1089/1089, eslint **0 erreur / 279 warnings** (12 fichiers). **Phase 2c faite 2026-10-01 (BILAN : extraction TERMINÉE)** : `database_table` **1 314 L extraites** — le Jinja du bloc était confiné à la construction de l'objet de config (4 clés + `{% if collection_data %}`) → config JSON `null`-vs-objet (`#db-config`), le JS appelle `new DBInstance(container, PAGE_COLLECTION_ID, DB_CONFIG)` : les 2 branches Jinja disparaissent ; 2 tests adaptés (source → `database_table.js`) + `FlowDeckDB` dans src. **BILAN A27 : 11 874 L extraites en 4 phases** (4 243 + 2 516 + 3 801 + 1 314), **inline 13 904 → 2 022 L (-85 %)**, 22 fichiers `static/js/*.js`, `node --check` vert partout, suite 1089/1089. **Lint A27 TERMINÉ 2026-10-01 : `eslint static/js` = 0 erreur / 0 warning** (285 → 0, 22 fichiers). 3 familles traitées : (1) **no-empty ×70** = tous des `catch (x) {}` vides → `catch { /* volontaire */ }` (binding optionnel ES2019 + commentaire : passe no-empty ET no-unused-vars, zéro changement de comportement) ; (2) **no-unused-vars ×171** = bindings de catch retirés + 24 lignes mortes déterministes (suppressions avec assert sur le texte exact : `var self = this` ×8, compteurs jamais lus `restored++`/`resolved++`/`acc`/`today`, `uid()`/`propName()` sans 1 appel, etc.) + `/* exported */` sur les **10 fonctions appelées depuis les attributs HTML** (vérifiées par grep, 1 template chacune) ; (3) **no-undef ×44** = globaux réels déclarés dans `eslint.config.mjs` (`getSvgIcon` = script inline de `base.html`, `TextDecoder` = API navigateur, `Prism` = CDN) + **2 vrais correctifs** : `settings.js` utilisait `typeof toast === 'function'` (guard toujours faux → les toasts timezone/SAML ne s'affichaient JAMAIS) → `window.showToast`, et `_wsInitData = window._wsInitData` (auto-affectation sans effet, global implicite) supprimé. **Reste A27** : `base` 1 523 L structurel (inline par nature, décision assumée) + ~500 L de petits blocs hors cibles. Effort : **L** (fait).
-- [ ] **A28 — Dette de découpe (god files)** : `api_v2.py` 115 routes / 131 Ko, `dashboard.py` 63 / 116 Ko (27 pages HTMLResponse + 50 JSON + I/O fichiers, 16 `Environment(...)` locaux), `collections.py` 53 / 112 Ko, `board.py` 53 / 93 Ko (page CRUD + `zipfile` + sync Gitea). *Fix : scinder par **concern** (`pages_html`, `files`, sous-modules `api_v2/*`) — mécanique, 0 changement d'URL. Effort : **L**.* **Lot 1 fait 2026-10-01 (api_v2)** : le module `api_v2.py` (2 110 lignes, 115 routes) devient le **package `app/routers/api_v2/`** = 12 modules par concern (identity 7, workspaces 9, collections 23, properties 7, views 8, engagement 21, sharing 8, planning 7, templates_io 9, projects 4, admin 4, webhooks 8) + `_common.py` (`_hash`, `_v2_rate_check`) + `__init__.py` (aggrégat `APIRouter(prefix="/api/v2")` + `include_router` sans prefix). Preuve contractuelle : **`docs/openapi-v2.json` régénéré = IDENTIQUE byte-à-byte** (0 changement de chemin/tag/operation_id), seul importateur = `main.py` (`from app.routers.api_v2 import router` → le package l'expose) ; en-tête d'imports copié puis émondé par `ruff --fix`. **Lot 2 fait 2026-10-01 (dashboard)** : `dashboard.py` (2 735 lignes, 63 routes) → **package `app/routers/dashboard/`** = 8 modules par concern (pages_html 488 L/6 r., local_workspace 551/15, settings→`account_settings` 442/16, workspace 323/9, pages_api 243/6, workspaces 135/6, account_api 82/4, public 82/1) + `_common.py` (les **15 helpers intercalés** + état `logger`/`_VERSION`/`WORKSPACE_COOKIE`) + `__init__.py` (re-export complet : 7 importateurs — main, board ×3, my_tasks, web_clipper, wiki, sites `_dash._render_blocks_public`, tests). Pièges rencontrés : segment décorateur sans le `def` (corps perdus, assert `def in seg` ajouté) ; collision `settings` (section vs `from app.config import settings` dans le header → **`hasattr` du fromlist** : la section renommée `account_settings`) ; `WORKSPACE_COOKIE` utilisé sans import dans `workspaces.py` (F821 → ajout automatique) ; script `__all__` qui mangeait la queue du fichier (réécrit à la main). Preuve contractuelle : **`docs/openapi-v2.json` IDENTIQUE byte-à-byte** (ordre d'enregistrement préservé). **Reste A28** : `collections.py` 2 622 L, `board.py` 2 101 L → suite 1091/1091, version 7.30.0.
+- [ ] **A28 — Dette de découpe (god files)** : `api_v2.py` 115 routes / 131 Ko, `dashboard.py` 63 / 116 Ko (27 pages HTMLResponse + 50 JSON + I/O fichiers, 16 `Environment(...)` locaux), `collections.py` 53 / 112 Ko, `board.py` 53 / 93 Ko (page CRUD + `zipfile` + sync Gitea). *Fix : scinder par **concern** (`pages_html`, `files`, sous-modules `api_v2/*`) — mécanique, 0 changement d'URL. Effort : **L**.* **Lot 1 fait 2026-10-01 (api_v2)** : le module `api_v2.py` (2 110 lignes, 115 routes) devient le **package `app/routers/api_v2/`** = 12 modules par concern (identity 7, workspaces 9, collections 23, properties 7, views 8, engagement 21, sharing 8, planning 7, templates_io 9, projects 4, admin 4, webhooks 8) + `_common.py` (`_hash`, `_v2_rate_check`) + `__init__.py` (aggrégat `APIRouter(prefix="/api/v2")` + `include_router` sans prefix). Preuve contractuelle : **`docs/openapi-v2.json` régénéré = IDENTIQUE byte-à-byte** (0 changement de chemin/tag/operation_id), seul importateur = `main.py` (`from app.routers.api_v2 import router` → le package l'expose) ; en-tête d'imports copié puis émondé par `ruff --fix`. **Lot 2 fait 2026-10-01 (dashboard)** : `dashboard.py` (2 735 lignes, 63 routes) → **package `app/routers/dashboard/`** = 8 modules par concern (pages_html 488 L/6 r., local_workspace 551/15, settings→`account_settings` 442/16, workspace 323/9, pages_api 243/6, workspaces 135/6, account_api 82/4, public 82/1) + `_common.py` (les **15 helpers intercalés** + état `logger`/`_VERSION`/`WORKSPACE_COOKIE`) + `__init__.py` (re-export complet : 7 importateurs — main, board ×3, my_tasks, web_clipper, wiki, sites `_dash._render_blocks_public`, tests). Pièges rencontrés : segment décorateur sans le `def` (corps perdus, assert `def in seg` ajouté) ; collision `settings` (section vs `from app.config import settings` dans le header → **`hasattr` du fromlist** : la section renommée `account_settings`) ; `WORKSPACE_COOKIE` utilisé sans import dans `workspaces.py` (F821 → ajout automatique) ; script `__all__` qui mangeait la queue du fichier (réécrit à la main). Preuve contractuelle : **`docs/openapi-v2.json` IDENTIQUE byte-à-byte** (ordre d'enregistrement préservé). **Lot 3 fait 2026-10-01 (collections)** : `collections.py` (2 622 lignes, 53 endpoints / 52 fonctions — 1 paire de décorateurs empilés) → **package `app/routers/collections/`** = 10 modules par concern (crud 337, properties 322, linked 286, structure 267, dashboard_views 214, meta 197, views 187, pages 184, data_api 122, boards 61) + `_common.py` (8 helpers auth/permissions/validation, 220 L) + `_renderers.py` (**15 rendus de vues** + `CHART_MAX_GROUPS`, 667 L) + `__init__.py` (ré-exports : `_validate_page_properties` pour automations, 4 helpers de graphes pour les tests). Pièges : docstring d'origine dans le header → **F404 `from __future__` après un statement** (slice `[1:30]`), décorateurs empilés (`view_collection` ×2 → segments sans `def` → skip du 2ᵉ décorateur), `CHART_MAX_GROUPS` hors détection des helpers (F821 → import auto), test CDN lisant `collections.py` (balayage du package). Preuve : **`docs/openapi-v2.json` IDENTIQUE byte-à-byte**. **Reste A28** : `board.py` 2 101 L → suite 1091/1091, version 7.31.0.
- [x] **A29 — Endpoints dupliqués 2-3×** : publish/unpublish existe en 3 endroits (`sharing.py:304/345`, `board.py:1020/1039`, `api_v2.py:1743/1761`) avec slug et auth **différents** ; listing collections ×3 (`/api/v1/collections`, `/db/api`, `/api/v2/collections`) ; `/api/users/me` ×2. *Fix : un `services/publish.py` partagé, les routers déléguent.* — **fait 2026-10-01** : `services/publish.py` (slugify unique, 404 partout, événements) ; les 3 paires publish/unpublish déléguent (sharing + board + v2), board gagne `_require_auth`, les bonus divergents (`share_mode='anyone'` / `is_shared=1`) supprimés — le share dialog reste propriétaire de ces drapeaux ; **byproduct sécurité** : `GET /api/users/me` (v1) et le contexte de `/accounts` faisaient `SELECT *` → `password_hash` exposé → colonnes whitelistées. **Décision** : `/api/users/me` ×2 et listing collections ×3 **restent** — contrats versionnés distincts (session+guest vs Bearer+scope, formes différentes). Effort : **M**.
- [x] **A30 — 16 fonctions top-level jamais référencées**, dont `require_scope` (`api_v2_helpers.py:213`, la factory FastAPI qui doit faire les scopes — les handlers font `has_scope(...)` à la main), `validate_upload`, `_get_user_or_redirect`, `_require_user_gitea`, `unsync_block`, `find_referring`… *Fix : câbler `validate_upload` (A22) + `require_scope`, supprimer le reste. Effort : **S**.*
- [x] **A31 — Dette migrations** : `migrations.py` 1 522 lignes / 66 Ko, 28 migrations (versions 2-29, contiguës, bien version-gated), **25 copies du motif `PRAGMA table_info`** sans helper (`table_exists`/`column_exists` inexistants), 30 `ALTER TABLE`, et `fn(conn)` tourne **hors transaction** → un échec au milieu laisse du DDL partiel commité. *Fix : 3 helpers + transaction par migration.* — **fait 2026-10-01** : `_apply_one()` — BEGIN explicite par migration, rollback complet à l'échec (avant : DDL en autocommit → schéma partiel commité sans ligne `schema_version`, la reprise rejouait un DDL déjà appliqué) ; **1 helper au lieu de 3** : `columns(conn, table)` (valide l'identifiant) remplace les **25 copies** de `PRAGMA table_info` — `table_exists`/`column_exists` non livrés : aucune migration n'interroge `sqlite_master` et un contrôle unitaire se lit dans le set (YAGNI). Tests : rollback DDL + validation d'identifiant. Effort : **M**.
@@ -1181,4 +1181,4 @@ Quality DB views, Agent IA Palette → Realtime + E
→ Puis **A3–A8** (le bloc « fallback admin ») d'un seul tenant, puis **A10** (autoescape) qui débloque A18/A20.
*Audit produit le 2026-09-30 · 43 items · aucun code modifié ( ROADMAP seul ).*
-→ **A1–A9 corrigés le 2026-09-30** : deps réinstallées (`pyotp`/`webauthn`/`cbor2`), rebinding de `settings` supprimé dans `test_v54.py` → **suite 1016/1016 verts**, cycle committé (`1706ad1`) + tag `v7.3.0` poussé, `.db`/fichiers de test désindexés, `APP_SECRET_KEY` roté dans `.env` (sessions révoquées) · **A3–A8 : 401 sans session sur les routes de compte (mdp actuel exigé), tokens `/api/v1` + `/api/user` sans session → 401, CRUD membres d'espace sous session+role admin, `_require_view`/`_require_edit` sans session → 404/401, création/lecture de page sous session, `/board/api/pages` + `/api/user` sortis du CSRF exempt, seed admin sans mdp en dur (aléatoire ou `FLOWDECK_ADMIN_PASSWORD`). Tests : client connecte par defaut (`_TestSessionAuth`), helper `anon()` sur les 40 tests d'anonymat → suite 1016/1016 + ruff OK, commit `d125eb3` · **A10 : `app/templating.py` (ENV partagé + autoescape `select_autoescape(["html"])`) remplace les 29 instantiations, `|safe` retriés (corps d'issue/commentaires echappes, `sidebar_config` en `|tojson`) → suite 1016/1016, version 7.3.1 · **A11 (traversal avatar) + A18 (vue publique : 404 restricted/private + html.escape)** : `tests/test_audit_p0_fixes.py`, suite 1019/1019, version 7.3.2 · **A12/A13/A15/A17/A22/A23/A24** : garde SSRF par hop, routers automations + /api sous session/Bearer, webhooks admin+URL publique, uploads validés, N+1 en GROUP BY/executemany, 2 doublons de routes supprimés → suite 1025/1025, version 7.3.3 · **A16** : exports + pièces jointes sous session + `can_view_page` → suite 1026/1026, version 7.3.4 · **A19 (partiel)** : 12 préfixes CSRF retirés après scan front (12 préfixes = tous ceux dont les appels portent déjà le header), reste 5 préfixes / 49 fetchs → suite 1026/1026, version 7.3.5 · **A19 terminé** : 46 call sites front équipés, plus aucun préfixe cookie-auth exempté → suite 1026/1026, version 7.3.6 · **A14** : fallback `admin` supprimé de `_current_user_id`/`_current_admin` (401 sans session), `api_base` validé scheme http(s) sans identifiants (hôtes privés acceptés = Ollama localhost, commentaire `ponytail:`) → suite 1027/1027, version 7.3.7 · **A25** : 84 `except Exception: pass` remplacés par `logger.exception(fn)` (19 fichiers, +`logger` manquants), try supprimé sur `materialize_properties` dans `create_collection_v2` et `apply_db_template_v2` (rollback au lieu d'un commit sans schéma), test de rollback · **A21 (partiel)** : `busy_timeout=5000` dans `get_conn()` → suite 1028/1028, version 7.3.8 · **A26/A33/A34/A35/A36/A43** : secret par défaut refusé au boot, rate limit (préfixes + settings + XFF + épurage), `_spawn()` pour les 10 schedulers, OpenAPI 511 chemins + README, 4 deps mortes purgées, 15 `utcnow()` → `now(UTC)` naïf → suite 1028/1028, version 7.3.9. · **A30/A37/A39/A40/A41** : `require_scope` câblé sur 69 sites + 12 fonctions mortes supprimées, CORS sans `*` (origines de `app_base_url` + regex dev/extensions), assets versionnés depuis `VERSION` (source unique), `app.css` -10,2 Ko de règles mortes, htmx = décision « rien » documentée → suite 1031/1031, version 7.4.0. · **A29/A42** : `services/publish.py` partagé (3 routers déléguent, 404 partout, board sous session), fuite `password_hash` corrigée sur `GET /api/users/me` v1 + contexte `/accounts`, `settings.data_dir` remplace les 9 copies d'env, cache Gitea évacue les expirés ; `/users/me` ×2 + collections ×3 = contrats versionnés, on garde ; reste A42 = client httpx partagé → suite 1034/1034, version 7.5.0. · **A31** : transaction par migration (`_apply_one`, rollback tout-ou-rien du DDL) + helper `columns()` remplaçant 25 copies de `PRAGMA table_info` (1 helper au lieu de 3 — les 2 autres seraient mort-nés) → suite 1036/1036, version 7.6.0. · **A20 (partiel)** : CSP nonce par requête — `unsafe-inline` retiré de `script-src`, 38 scripts templates + login constant + 3 scripts Python noncés, meta `htmx-config` pour htmx, `script-src-attr` pour les 74 `onclick=`, CDN chart/leaflet débloqués (déjà cassés avant) → suite 1037/1037, version 7.7.0. · **A21 phase 1** : 352 routes `async def` sans `await` → `def` (threadpool FastAPI, SQLite hors loop, zéro changement de logique — scan corps par corps) ; reste phase 2 = 311 routes avec `await` → `anyio.to_thread.run_sync` par bloc DB → suite 1037/1037, version 7.8.0. **Phase 2c faite 2026-10-01** : **+190 routes hors loop** (283 → 93 async, **86 % des 667 routes**) en 4 passes : (A) **racine auth** — `get_current_user` (session.py) était `async def` SANS aucun await (cookie decode = synchrone) + ses clones async (`agent._current_user_id/_workspace_id/_current_admin`, `sso._require_admin`) → `def`, **47 `await` supprimés** (dont 3 via l'alias `gcu`) ; (B) re-scan → 19 routes sans await flipées ; (C/D) **155 routes** json/événements → `Body(default={})` (formes : try/except `body = {}`, try/except `raise HTTPException(400)` → `Body(...)` requis (422 FastAPI, aucun test ne couvrait le 400), forme conditionnelle content-type → défaut `{}`) + `run_event_sync`. **Reste async (93, justifié)** : `request.form`/`upload.read`/`file.read` (14+5+3, corps de requête réellement asynchrone), gitea/llm/oidc (réseau), `_json_body` 9 (wrapper de validation), 2 JSON inline en argument, 1 fallback à logique (capture_frontend_error), 1 lecture conditionnelle dans web_clipper. Échecs : 3 seeds d'aliases `gcu` attrapés par la suite → corrigés → suite 1089/1089, version 7.26.0. · **A21 phase 2a** : `body` JSON → paramètre `Body(default={})` sur les 36 routes api_v2 dont c'était le seul `await` → conversion en `def` → api_v2 à 96/115 hors loop (19 async restantes : fire_event/form/gitea) → suite 1037/1037, version 7.9.0. · **A21 phase 2b** : `run_event_sync()` (asyncio.run dans le worker, événement attendu = déterministe) + les 15 routes json/événements en `def` → api_v2 bouclé à 111/115 (4 async = vrais awaits réseau) ; repo-wide 403 sync / 260 async → suite 1037/1037, version 7.10.0. · **A32 phase 1** : les 4 routers à 0 test couverts (webhooks/notes/sidebar_config/github_routes = 10 smokes, 0 réseau réel, échappement notes vérifié) ; reste quasi nuls library/api/dashboard/api_v2 → suite 1047/1047, version 7.11.0. · **A32 phase 2a** : library 1/10 → 8 routes couvertes ; découverte = 2 routes lisant la table fantôme `local_workspace_items` (500 systématique, 0 ref front) supprimées + `_format_size` mort → suite 1053/1053, version 7.12.0. · **A32 phase 2b** : api.py 3 → 16/22 routes couvertes (board-config, col-mapping, card, collaborators stubbé, frontend-error dédup, mutations checklist vérifiées en base) ; reste 6 routes gitea + dashboard/api_v2 → suite 1059/1059, version 7.13.0. · **A32 phase 2c** : api_v2 scan strict → 5 routes à 0 ref couvertes (formula, rollup, audit-logs avec portail vérifié, webhooks/events, verify-signature signé) → suite 1064/1064, version 7.14.0. · **A32 phase 2d** : dashboard scan strict = 44 routes à 0 ref, 10 couvertes (tags CRUD, page content→rename→trash vérifiés en base, tree HTML, avatar-color restauré, members) → suite 1069/1069, version 7.15.0. · **A32 phase 2e** : +9 routes dashboard (pages comptes sans hash, profile/password A3 403, token fd_+64hex, forge, settings/account 400, select cookie, breadcrumb) → suite 1075/1075, version 7.16.0. · **A32 phase 2f** : +7 routes dashboard centrées A16 (files traversal 403, download/file-content sans fuite, avatar 302 sans réseau, table-data + création de ligne) → suite 1079/1079, version 7.17.0. · **A32 phase 2g** : +13 routes dashboard (gitea-workspace HTML, projects CRUD, cycle items 5 routes, cycle tags d'item 5 routes avec ws dédié) → suite 1083/1083, version 7.18.0. · **A32 phase 2h → dashboard bloqué** : members (cycle complet + quirk tuple), upload-folder (validations seules), convert-to-database (vérifié en base, ordre FK) ; les 44 routes à 0 ref sont toutes exercées (faux positifs f-string rapprochés) → suite 1086/1086, version 7.19.0. · **A32 TERMINÉ** : 6 routes Gitea stubbées (canevas mutable, carte board, HTML `?format=html`, 404) + **bug prod `card_detail.html`/`fd_icon` corrigé** (500 garanti avant) → suite 1089/1089, version 7.20.0. · **A27 phase 1** : 7 templates sans Jinja → 9 fichiers static/js (4 243 L, -30 % du inline, node --check vert), eslint installé globalement → `eslint static/js` 0 erreur/120 warnings ; reste = blocs interpolés Jinja (~9 661 L) → suite 1089/1089, version 7.21.0. · **A27 phase 2a** : éditeur 2 516 L extrait via `#page-data` (PD.*), dérivé `is_shared` hoisté, 8 tests adaptés (source → static js / parsing JSON) → suite 1089/1089, version 7.22.0. · **A27 phase 2b** : local_workspace 2031 + settings 1093 + realtime 531 + board 146 extraits (recette config JSON, tojson au lieu d'interpolation JS) → suite 1089/1089, version 7.23.0. · **A27 phase 2c → extraction TERMINÉE** : database_table 1314 L (config JSON null-vs-objet, 2 branches Jinja remplacées par `DB_CONFIG`), bilan **11 874 L extraites, inline -85 % (13 904 → 2 022)** → suite 1089/1089, version 7.24.0. · **A27 lint TERMINÉ** : eslint 285 warnings → **0/0** (catch vides → binding optionnel, 24 lignes mortes, 10 fonctions `/* exported */` appelées par le HTML, 3 globaux réels en config) + 2 vrais correctifs (toasts settings jamais affichés → `window.showToast`, `_wsInitData` sans effet supprimé) → suite 1089/1089, version 7.25.0. · **A21 phase 2c → A21 TERMINÉ** : auth racine sync (47 await retirés, alias gcu rattrapé) + 155 routes json/événements → Body/run_event_sync + 19 flips → **283→93 async (86 % hors loop)** → suite 1089/1089, version 7.26.0. · **A20 phase 2** : chart/leaflet vendorisés (static/js/vendor, 8 fichiers), CSP sans aucun hôte CDN, connect-src `'self' ws://{host} wss://{host}` (exfil fermé), Google Fonts morts retirés, img-src https: gardé (unfurls) → suite 1090/1090, version 7.27.0 — reste A20 = unsafe-eval (Alpine+htmx, E2E d'abord). · **A42 TERMINÉ** : `shared_client` (cache par boucle+kwargs, WeakKeyDictionary) remplace 49 créations httpx dans 14 fichiers, 2 sites laissés (transport injecté / own_client), stub webhooks étendu à la fabrique → suite 1091/1091, version 7.28.0. · **A28 lot 1** : api_v2.py (2 110 L, 115 routes) → package de 14 fichiers par concern, openapi JSON IDENTIQUE byte-à-byte (0 changement d'URL) → suite 1091/1091, version 7.29.0 · **A28 lot 2** : dashboard.py (2 735 L, 63 routes) → package de 10 fichiers (15 helpers dans _common, re-exports intacts, collision `settings`→`account_settings`), openapi IDENTIQUE byte-à-byte → suite 1091/1091, version 7.30.0 — reste : collections/board.
+→ **A1–A9 corrigés le 2026-09-30** : deps réinstallées (`pyotp`/`webauthn`/`cbor2`), rebinding de `settings` supprimé dans `test_v54.py` → **suite 1016/1016 verts**, cycle committé (`1706ad1`) + tag `v7.3.0` poussé, `.db`/fichiers de test désindexés, `APP_SECRET_KEY` roté dans `.env` (sessions révoquées) · **A3–A8 : 401 sans session sur les routes de compte (mdp actuel exigé), tokens `/api/v1` + `/api/user` sans session → 401, CRUD membres d'espace sous session+role admin, `_require_view`/`_require_edit` sans session → 404/401, création/lecture de page sous session, `/board/api/pages` + `/api/user` sortis du CSRF exempt, seed admin sans mdp en dur (aléatoire ou `FLOWDECK_ADMIN_PASSWORD`). Tests : client connecte par defaut (`_TestSessionAuth`), helper `anon()` sur les 40 tests d'anonymat → suite 1016/1016 + ruff OK, commit `d125eb3` · **A10 : `app/templating.py` (ENV partagé + autoescape `select_autoescape(["html"])`) remplace les 29 instantiations, `|safe` retriés (corps d'issue/commentaires echappes, `sidebar_config` en `|tojson`) → suite 1016/1016, version 7.3.1 · **A11 (traversal avatar) + A18 (vue publique : 404 restricted/private + html.escape)** : `tests/test_audit_p0_fixes.py`, suite 1019/1019, version 7.3.2 · **A12/A13/A15/A17/A22/A23/A24** : garde SSRF par hop, routers automations + /api sous session/Bearer, webhooks admin+URL publique, uploads validés, N+1 en GROUP BY/executemany, 2 doublons de routes supprimés → suite 1025/1025, version 7.3.3 · **A16** : exports + pièces jointes sous session + `can_view_page` → suite 1026/1026, version 7.3.4 · **A19 (partiel)** : 12 préfixes CSRF retirés après scan front (12 préfixes = tous ceux dont les appels portent déjà le header), reste 5 préfixes / 49 fetchs → suite 1026/1026, version 7.3.5 · **A19 terminé** : 46 call sites front équipés, plus aucun préfixe cookie-auth exempté → suite 1026/1026, version 7.3.6 · **A14** : fallback `admin` supprimé de `_current_user_id`/`_current_admin` (401 sans session), `api_base` validé scheme http(s) sans identifiants (hôtes privés acceptés = Ollama localhost, commentaire `ponytail:`) → suite 1027/1027, version 7.3.7 · **A25** : 84 `except Exception: pass` remplacés par `logger.exception(fn)` (19 fichiers, +`logger` manquants), try supprimé sur `materialize_properties` dans `create_collection_v2` et `apply_db_template_v2` (rollback au lieu d'un commit sans schéma), test de rollback · **A21 (partiel)** : `busy_timeout=5000` dans `get_conn()` → suite 1028/1028, version 7.3.8 · **A26/A33/A34/A35/A36/A43** : secret par défaut refusé au boot, rate limit (préfixes + settings + XFF + épurage), `_spawn()` pour les 10 schedulers, OpenAPI 511 chemins + README, 4 deps mortes purgées, 15 `utcnow()` → `now(UTC)` naïf → suite 1028/1028, version 7.3.9. · **A30/A37/A39/A40/A41** : `require_scope` câblé sur 69 sites + 12 fonctions mortes supprimées, CORS sans `*` (origines de `app_base_url` + regex dev/extensions), assets versionnés depuis `VERSION` (source unique), `app.css` -10,2 Ko de règles mortes, htmx = décision « rien » documentée → suite 1031/1031, version 7.4.0. · **A29/A42** : `services/publish.py` partagé (3 routers déléguent, 404 partout, board sous session), fuite `password_hash` corrigée sur `GET /api/users/me` v1 + contexte `/accounts`, `settings.data_dir` remplace les 9 copies d'env, cache Gitea évacue les expirés ; `/users/me` ×2 + collections ×3 = contrats versionnés, on garde ; reste A42 = client httpx partagé → suite 1034/1034, version 7.5.0. · **A31** : transaction par migration (`_apply_one`, rollback tout-ou-rien du DDL) + helper `columns()` remplaçant 25 copies de `PRAGMA table_info` (1 helper au lieu de 3 — les 2 autres seraient mort-nés) → suite 1036/1036, version 7.6.0. · **A20 (partiel)** : CSP nonce par requête — `unsafe-inline` retiré de `script-src`, 38 scripts templates + login constant + 3 scripts Python noncés, meta `htmx-config` pour htmx, `script-src-attr` pour les 74 `onclick=`, CDN chart/leaflet débloqués (déjà cassés avant) → suite 1037/1037, version 7.7.0. · **A21 phase 1** : 352 routes `async def` sans `await` → `def` (threadpool FastAPI, SQLite hors loop, zéro changement de logique — scan corps par corps) ; reste phase 2 = 311 routes avec `await` → `anyio.to_thread.run_sync` par bloc DB → suite 1037/1037, version 7.8.0. **Phase 2c faite 2026-10-01** : **+190 routes hors loop** (283 → 93 async, **86 % des 667 routes**) en 4 passes : (A) **racine auth** — `get_current_user` (session.py) était `async def` SANS aucun await (cookie decode = synchrone) + ses clones async (`agent._current_user_id/_workspace_id/_current_admin`, `sso._require_admin`) → `def`, **47 `await` supprimés** (dont 3 via l'alias `gcu`) ; (B) re-scan → 19 routes sans await flipées ; (C/D) **155 routes** json/événements → `Body(default={})` (formes : try/except `body = {}`, try/except `raise HTTPException(400)` → `Body(...)` requis (422 FastAPI, aucun test ne couvrait le 400), forme conditionnelle content-type → défaut `{}`) + `run_event_sync`. **Reste async (93, justifié)** : `request.form`/`upload.read`/`file.read` (14+5+3, corps de requête réellement asynchrone), gitea/llm/oidc (réseau), `_json_body` 9 (wrapper de validation), 2 JSON inline en argument, 1 fallback à logique (capture_frontend_error), 1 lecture conditionnelle dans web_clipper. Échecs : 3 seeds d'aliases `gcu` attrapés par la suite → corrigés → suite 1089/1089, version 7.26.0. · **A21 phase 2a** : `body` JSON → paramètre `Body(default={})` sur les 36 routes api_v2 dont c'était le seul `await` → conversion en `def` → api_v2 à 96/115 hors loop (19 async restantes : fire_event/form/gitea) → suite 1037/1037, version 7.9.0. · **A21 phase 2b** : `run_event_sync()` (asyncio.run dans le worker, événement attendu = déterministe) + les 15 routes json/événements en `def` → api_v2 bouclé à 111/115 (4 async = vrais awaits réseau) ; repo-wide 403 sync / 260 async → suite 1037/1037, version 7.10.0. · **A32 phase 1** : les 4 routers à 0 test couverts (webhooks/notes/sidebar_config/github_routes = 10 smokes, 0 réseau réel, échappement notes vérifié) ; reste quasi nuls library/api/dashboard/api_v2 → suite 1047/1047, version 7.11.0. · **A32 phase 2a** : library 1/10 → 8 routes couvertes ; découverte = 2 routes lisant la table fantôme `local_workspace_items` (500 systématique, 0 ref front) supprimées + `_format_size` mort → suite 1053/1053, version 7.12.0. · **A32 phase 2b** : api.py 3 → 16/22 routes couvertes (board-config, col-mapping, card, collaborators stubbé, frontend-error dédup, mutations checklist vérifiées en base) ; reste 6 routes gitea + dashboard/api_v2 → suite 1059/1059, version 7.13.0. · **A32 phase 2c** : api_v2 scan strict → 5 routes à 0 ref couvertes (formula, rollup, audit-logs avec portail vérifié, webhooks/events, verify-signature signé) → suite 1064/1064, version 7.14.0. · **A32 phase 2d** : dashboard scan strict = 44 routes à 0 ref, 10 couvertes (tags CRUD, page content→rename→trash vérifiés en base, tree HTML, avatar-color restauré, members) → suite 1069/1069, version 7.15.0. · **A32 phase 2e** : +9 routes dashboard (pages comptes sans hash, profile/password A3 403, token fd_+64hex, forge, settings/account 400, select cookie, breadcrumb) → suite 1075/1075, version 7.16.0. · **A32 phase 2f** : +7 routes dashboard centrées A16 (files traversal 403, download/file-content sans fuite, avatar 302 sans réseau, table-data + création de ligne) → suite 1079/1079, version 7.17.0. · **A32 phase 2g** : +13 routes dashboard (gitea-workspace HTML, projects CRUD, cycle items 5 routes, cycle tags d'item 5 routes avec ws dédié) → suite 1083/1083, version 7.18.0. · **A32 phase 2h → dashboard bloqué** : members (cycle complet + quirk tuple), upload-folder (validations seules), convert-to-database (vérifié en base, ordre FK) ; les 44 routes à 0 ref sont toutes exercées (faux positifs f-string rapprochés) → suite 1086/1086, version 7.19.0. · **A32 TERMINÉ** : 6 routes Gitea stubbées (canevas mutable, carte board, HTML `?format=html`, 404) + **bug prod `card_detail.html`/`fd_icon` corrigé** (500 garanti avant) → suite 1089/1089, version 7.20.0. · **A27 phase 1** : 7 templates sans Jinja → 9 fichiers static/js (4 243 L, -30 % du inline, node --check vert), eslint installé globalement → `eslint static/js` 0 erreur/120 warnings ; reste = blocs interpolés Jinja (~9 661 L) → suite 1089/1089, version 7.21.0. · **A27 phase 2a** : éditeur 2 516 L extrait via `#page-data` (PD.*), dérivé `is_shared` hoisté, 8 tests adaptés (source → static js / parsing JSON) → suite 1089/1089, version 7.22.0. · **A27 phase 2b** : local_workspace 2031 + settings 1093 + realtime 531 + board 146 extraits (recette config JSON, tojson au lieu d'interpolation JS) → suite 1089/1089, version 7.23.0. · **A27 phase 2c → extraction TERMINÉE** : database_table 1314 L (config JSON null-vs-objet, 2 branches Jinja remplacées par `DB_CONFIG`), bilan **11 874 L extraites, inline -85 % (13 904 → 2 022)** → suite 1089/1089, version 7.24.0. · **A27 lint TERMINÉ** : eslint 285 warnings → **0/0** (catch vides → binding optionnel, 24 lignes mortes, 10 fonctions `/* exported */` appelées par le HTML, 3 globaux réels en config) + 2 vrais correctifs (toasts settings jamais affichés → `window.showToast`, `_wsInitData` sans effet supprimé) → suite 1089/1089, version 7.25.0. · **A21 phase 2c → A21 TERMINÉ** : auth racine sync (47 await retirés, alias gcu rattrapé) + 155 routes json/événements → Body/run_event_sync + 19 flips → **283→93 async (86 % hors loop)** → suite 1089/1089, version 7.26.0. · **A20 phase 2** : chart/leaflet vendorisés (static/js/vendor, 8 fichiers), CSP sans aucun hôte CDN, connect-src `'self' ws://{host} wss://{host}` (exfil fermé), Google Fonts morts retirés, img-src https: gardé (unfurls) → suite 1090/1090, version 7.27.0 — reste A20 = unsafe-eval (Alpine+htmx, E2E d'abord). · **A42 TERMINÉ** : `shared_client` (cache par boucle+kwargs, WeakKeyDictionary) remplace 49 créations httpx dans 14 fichiers, 2 sites laissés (transport injecté / own_client), stub webhooks étendu à la fabrique → suite 1091/1091, version 7.28.0. · **A28 lot 1** : api_v2.py (2 110 L, 115 routes) → package de 14 fichiers par concern, openapi JSON IDENTIQUE byte-à-byte (0 changement d'URL) → suite 1091/1091, version 7.29.0 · **A28 lot 2** : dashboard.py (2 735 L, 63 routes) → package de 10 fichiers (15 helpers dans _common, re-exports intacts, collision `settings`→`account_settings`), openapi IDENTIQUE byte-à-byte → suite 1091/1091, version 7.30.0 · **A28 lot 3** : collections.py (2 622 L, 53 endpoints) → package de 13 fichiers (_renderers = 15 rendus, décorateurs empilés gérés, F404 docstring corrigé), openapi IDENTIQUE byte-à-byte → suite 1091/1091, version 7.31.0 — reste : board.
diff --git a/VERSION b/VERSION
index ea5c418..0ebb11e 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-7.30.0
+7.31.0
diff --git a/WORKLOAD.md b/WORKLOAD.md
index d4398c9..90e64e0 100644
--- a/WORKLOAD.md
+++ b/WORKLOAD.md
@@ -1,6 +1,6 @@
# WORKLOAD — FlowDeck Notion Clone
-> **Début**: 2026-07-08 | **Version**: v7.30.0 (audit — A28 lot 2 : dashboard.py 2 735 L → package 10 fichiers) | **Statut**: EN COURS 🔄
+> **Début**: 2026-07-08 | **Version**: v7.31.0 (audit — A28 lot 3 : collections.py 2 622 L → package 13 fichiers) | **Statut**: EN COURS 🔄
> **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0`
## Avancement Global
diff --git a/app/main.py b/app/main.py
index 7478594..c115349 100644
--- a/app/main.py
+++ b/app/main.py
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
- version="7.30.0",
+ version="7.31.0",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
diff --git a/app/routers/collections.py b/app/routers/collections.py
deleted file mode 100644
index 8d1b3d5..0000000
--- a/app/routers/collections.py
+++ /dev/null
@@ -1,2622 +0,0 @@
-"""FlowDeck — Collections router: Notion-style databases (v1.3.0)."""
-from __future__ import annotations
-
-import html as _htmlmod
-import json
-import logging
-import sqlite3
-
-from fastapi import APIRouter, Body, HTTPException, Request
-from fastapi.responses import HTMLResponse
-
-from app.auth.session import SessionManager
-from app.db import get_conn
-from app.services.automations import fire_event, run_event_sync
-from app.services.db_templates import materialize_properties
-from app.services.permission_manager import PermissionManager
-from app.services.property_types import (
- AUTO_TYPES,
- apply_auto_properties,
- validate_property_rule,
-)
-from app.services.recurrence import (
- RECURRENCE_KEY,
- expand_rule,
- is_valid_timezone,
- parse_date,
- validate_rule,
-)
-from app.services.reminders import REMINDER_KEY, parse_lead
-from app.templating import CSP_NONCE
-
-
-def _current_user(request: Request) -> dict:
- """Resolve the session user, falling back to the local admin (single-user)."""
- s = request.cookies.get("flowdeck_session", "")
- return SessionManager.decode_session(s) or {"login": "admin", "id": 1}
-
-
-def _session_user(request: Request) -> dict | None:
- """Resolve the session user WITHOUT the admin fallback (for ACL checks)."""
- s = request.cookies.get("flowdeck_session", "")
- user = SessionManager.decode_session(s)
- return user if user and user.get("id") else None
-
-
-def _require_view(collection_id: int, user: dict | None) -> None:
- """Raise 404 when the user may not view the collection (404 hides it).
-
- A6 : plus de session = accès refusé — l'absence de user ne vaut plus
- « legacy single-user » ( lecture anonyme de n'importe quelle collection ).
- """
- if not user:
- raise HTTPException(status_code=404, detail="Collection not found")
- pm = PermissionManager(user["id"])
- if not pm.can_view_collection(collection_id):
- raise HTTPException(status_code=404, detail="Collection not found")
-
-
-def _require_edit(collection_id: int, user: dict | None) -> None:
- """Raise 401/403 when the user may not edit pages in the collection."""
- if not user:
- raise HTTPException(status_code=401, detail="Authentication required")
- pm = PermissionManager(user["id"])
- if not pm.can_edit_collection(collection_id):
- raise HTTPException(status_code=403, detail="You don't have edit access to this collection")
-
-
-def _collection_properties(conn, collection_id: int) -> list[dict]:
- return [
- dict(r) for r in conn.execute(
- "SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
- (collection_id,),
- ).fetchall()
- ]
-
-logger = logging.getLogger(__name__)
-router = APIRouter(tags=["collections"], prefix="/db")
-
-
-def _apply_template(conn, template_name: str) -> dict | None:
- """Resolve a database template by name (from the seeded/built-in set)."""
- if not template_name:
- return None
- row = conn.execute(
- "SELECT id, name, icon, description, schema_json FROM database_templates WHERE name=?",
- (template_name,),
- ).fetchone()
- if row:
- return dict(row)
- return None
-
-
-def _validate_page_properties(conn, collection_id: int, properties: dict, exclude_page_id: int | None = None) -> None:
- """Validate submitted property values against the collection's schema.
-
- Raises ``HTTPException(400)`` with a user-friendly message on the first
- failure (type, required, unique, min/max).
- """
- props = conn.execute(
- "SELECT * FROM collection_properties WHERE collection_id=?", (collection_id,)
- ).fetchall()
-
- for prop in props:
- ptype = prop["prop_type"]
- if ptype == "title" or ptype in AUTO_TYPES:
- continue
- pid = prop["id"]
- # Values may be keyed by property id (FlowDeckDB UI) or by name (agent).
- value = properties.get(str(pid))
- if value is None:
- value = properties.get(prop["name"])
- validation = prop["validation_json"] if "validation_json" in prop.keys() else "{}"
-
- existing_values = None
- try:
- import json as _json
- vcfg = _json.loads(validation) if validation else {}
- except Exception:
- vcfg = {}
- if vcfg.get("unique"):
- rows = conn.execute(
- "SELECT id, property_values_json FROM collection_pages WHERE collection_id=?",
- (collection_id,),
- ).fetchall()
- existing_values = []
- for r in rows:
- if exclude_page_id is not None and r["id"] == exclude_page_id:
- continue
- try:
- pv = _json.loads(r["property_values_json"] or "{}")
- except Exception:
- pv = {}
- existing_values.append(pv.get(str(pid)) or pv.get(prop["name"]))
-
- ok, msg = validate_property_rule(ptype, value, validation, existing_values=existing_values)
- if not ok:
- raise HTTPException(status_code=400, detail=f"Property '{prop['name']}': {msg}")
-
-
-def _validate_meta_keys(conn, collection_id: int, properties: dict) -> None:
- """Validate the ``__recurrence__`` / ``__reminder__`` meta keys stored
- alongside real property values. Raises HTTPException(400) on bad shape.
-
- Each meta key maps a date-property id to a rule/reminder object. We verify
- the target is actually a date property and the payload parses.
- """
- date_ids = {
- str(r["id"]) for r in conn.execute(
- "SELECT id FROM collection_properties WHERE collection_id=? AND prop_type='date'",
- (collection_id,),
- ).fetchall()
- }
-
- rec = properties.get(RECURRENCE_KEY)
- if rec not in (None, {}):
- if not isinstance(rec, dict):
- raise HTTPException(status_code=400, detail="Recurrence must be an object")
- for prop_id, rule in rec.items():
- if rule is None:
- continue
- if str(prop_id) not in date_ids:
- raise HTTPException(status_code=400, detail="Recurrence target must be a date property")
- ok, msg = validate_rule(rule)
- if not ok:
- raise HTTPException(status_code=400, detail=f"Recurrence: {msg}")
-
- rem = properties.get(REMINDER_KEY)
- if rem not in (None, {}):
- if not isinstance(rem, dict):
- raise HTTPException(status_code=400, detail="Reminder must be an object")
- for prop_id, reminder in rem.items():
- if reminder is None:
- continue
- if str(prop_id) not in date_ids:
- raise HTTPException(status_code=400, detail="Reminder target must be a date property")
- if not isinstance(reminder, dict):
- raise HTTPException(status_code=400, detail="Reminder must be an object")
- if reminder.get("unit") not in (None, "none", "minutes", "hours", "days"):
- raise HTTPException(status_code=400, detail="Reminder unit must be minutes/hours/days/none")
- if parse_lead(reminder) is None and reminder.get("unit") != "none":
- raise HTTPException(status_code=400, detail="Reminder value must be a positive integer")
-
- from app.services.recurrence import TIMEZONE_KEY
- tzmap = properties.get(TIMEZONE_KEY)
- if tzmap not in (None, {}):
- if not isinstance(tzmap, dict):
- raise HTTPException(status_code=400, detail="Timezone map must be an object")
- for prop_id, value in tzmap.items():
- if str(prop_id) not in date_ids:
- raise HTTPException(status_code=400, detail="Timezone target must be a date property")
- if value and not is_valid_timezone(str(value)):
- raise HTTPException(status_code=400, detail=f"Unknown timezone '{value}'")
-
-
-# ── API: List & Create (no path params) ──
-
-
-@router.get("", response_class=HTMLResponse)
-def list_collections(request: Request):
- """Page listing all collections in the workspace."""
- with get_conn() as conn:
- rows = conn.execute(
- "SELECT * FROM collections ORDER BY name"
- ).fetchall()
- collections = [dict(r) for r in rows]
- return HTMLResponse(
- f"
"
- f"
Collections ({len(collections)}) "
- f"
{json.dumps(collections, indent=2, default=str)} "
- f"
"
- )
-
-
-@router.get("/api")
-def list_collections_api(request: Request):
- """API: list all collections."""
- with get_conn() as conn:
- rows = conn.execute(
- "SELECT * FROM collections ORDER BY name"
- ).fetchall()
- return {"collections": [dict(r) for r in rows]}
-
-
-@router.post("/api")
-def create_collection_api(request: Request, body: dict = Body(default={})):
- """API: create a new collection, optionally from a database template."""
-
- name = body.get("name", "").strip()
- if not name:
- raise HTTPException(status_code=400, detail="name is required")
-
- description = body.get("description", "")
- icon = body.get("icon", "📋")
- gitea_owner = body.get("gitea_owner")
- gitea_repo = body.get("gitea_repo")
- schema = body.get("schema", [])
- is_locked = body.get("is_locked", False)
-
- with get_conn() as conn:
- # Apply a template if requested (provides schema + icon).
- tpl = _apply_template(conn, body.get("template"))
- if tpl:
- if body.get("name"):
- name = body["name"].strip()
- description = tpl["description"]
- icon = tpl.get("icon") or icon
- try:
- schema = json.loads(tpl["schema_json"])
- except (json.JSONDecodeError, TypeError):
- schema = []
-
- schema_json = json.dumps(schema)
-
- cur = conn.execute(
- """INSERT INTO collections
- (name, description, icon, schema_json, gitea_owner, gitea_repo, is_locked)
- VALUES (?, ?, ?, ?, ?, ?, ?)""",
- (name, description, icon, schema_json, gitea_owner, gitea_repo, int(is_locked)),
- )
- collection_id = cur.lastrowid
-
- materialize_properties(conn, collection_id, schema)
-
- conn.execute(
- """INSERT INTO collection_views
- (collection_id, name, view_type, config_json)
- VALUES (?, ?, ?, ?)""",
- (collection_id, "Default View", "table", json.dumps({
- "visible_properties": ["Title"],
- "sorts": [],
- "filters": [],
- })),
- )
- conn.commit()
-
- run_event_sync(fire_event("collection.created", {"collection_id": collection_id, "name": name, "icon": icon}))
- return {"id": collection_id, "name": name, "status": "created"}
-
-
-# ── API: Update & Delete (no path-param conflicts) ──
-
-
-@router.put("/api/{collection_id}")
-def update_collection_api(request: Request, collection_id: int, body: dict = Body(default={})):
- """API: update a collection."""
-
- with get_conn() as conn:
- existing = conn.execute(
- "SELECT * FROM collections WHERE id=?", (collection_id,)
- ).fetchone()
- if not existing:
- raise HTTPException(status_code=404, detail="Collection not found")
-
- name = body.get("name", existing["name"])
- description = body.get("description", existing["description"])
- icon = body.get("icon", existing["icon"])
- is_locked = body.get("is_locked", existing["is_locked"])
- schema_json = json.dumps(body.get("schema", json.loads(existing["schema_json"])))
- gitea_owner = body.get("gitea_owner", existing["gitea_owner"])
- gitea_repo = body.get("gitea_repo", existing["gitea_repo"])
-
- conn.execute(
- """UPDATE collections SET name=?, description=?, icon=?, schema_json=?,
- is_locked=?, gitea_owner=?, gitea_repo=?, updated_at=CURRENT_TIMESTAMP
- WHERE id=?""",
- (name, description, icon, schema_json, int(is_locked),
- gitea_owner, gitea_repo, collection_id),
- )
- conn.commit()
-
- run_event_sync(fire_event("collection.updated", {"collection_id": collection_id, "name": name}))
- return {"id": collection_id, "status": "updated"}
-
-
-@router.delete("/api/{collection_id}")
-def delete_collection_api(request: Request, collection_id: int):
- """API: delete a collection and its pages (CASCADE)."""
- # v6.0.0: granular collection permissions — owner/admin only.
- user = _session_user(request)
- _require_view(collection_id, user)
- if user:
- pm = PermissionManager(user["id"])
- if not pm.can_manage_collection_permissions(collection_id):
- raise HTTPException(status_code=403, detail="Only a collection owner can delete it")
- with get_conn() as conn:
- existing = conn.execute(
- "SELECT * FROM collections WHERE id=?", (collection_id,)
- ).fetchone()
- if not existing:
- raise HTTPException(status_code=404, detail="Collection not found")
-
- conn.execute("DELETE FROM collections WHERE id=?", (collection_id,))
- conn.commit()
-
- run_event_sync(fire_event("collection.deleted", {"collection_id": collection_id,
- "name": existing["name"] if existing else ""}))
- return {"id": collection_id, "status": "deleted"}
-
-
-@router.post("/{collection_id}/duplicate")
-def duplicate_collection_api(request: Request, collection_id: int):
- """v5.4.0: deep-duplicate a database (views + properties + pages + data
- sources) into a new collection named ' (copy)'."""
- with get_conn() as conn:
- src = conn.execute(
- "SELECT * FROM collections WHERE id=?", (collection_id,)
- ).fetchone()
- if not src:
- raise HTTPException(status_code=404, detail="Collection not found")
-
- new_name = (src["name"] or "Database") + " copy"
- cur = conn.execute(
- """INSERT INTO collections
- (name, description, icon, schema_json, gitea_owner, gitea_repo, is_locked,
- is_inline, parent_page_id, workspace_id, created_by, is_task, updated_at)
- SELECT ?, description, icon, schema_json, gitea_owner, gitea_repo, is_locked,
- is_inline, parent_page_id, workspace_id, created_by, is_task, updated_at
- FROM collections WHERE id=?""",
- (new_name, collection_id),
- )
- new_id = cur.lastrowid
-
- # ── Properties (remap ids so relation/rollup refs stay valid) ──
- prop_map: dict[int, int] = {}
- rows = conn.execute(
- "SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
- (collection_id,),
- ).fetchall()
- # A23 : un seul executemany ; les rowid sont contigus (même transaction,
- # insertion dans l'ordre de `rows`), donc le mappeur se fait par index.
- tuples = [
- (new_id, p["name"], p["prop_type"], p["options_json"], p["number_format"],
- None, p["reverse_name"], None, None, p["rollup_function"],
- p["formula_expression"], p["position"], p["required"],
- p["visible_in_views"])
- for p in rows
- ]
- if tuples:
- ncur = conn.executemany(
- """INSERT INTO collection_properties
- (collection_id, name, prop_type, options_json, number_format,
- related_collection_id, reverse_name, relation_property_id,
- target_property_id, rollup_function, formula_expression,
- position, required, visible_in_views)
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
- tuples,
- )
- new_ids = [
- r["id"]
- for r in conn.execute(
- "SELECT id FROM collection_properties WHERE collection_id=? ORDER BY id",
- (new_id,),
- ).fetchall()
- ]
- assert len(new_ids) == len(tuples), "remap des propriétés : effectif inattendu"
- for p, new_pid in zip(rows, new_ids, strict=True):
- prop_map[p["id"]] = new_pid
-
- # Fix cross-property references after all rows exist (creates may target
- # columns not inserted yet). Related collection remapped to the copy.
- for p in rows:
- p = dict(p) # convert sqlite3.Row to dict
- new_pid = prop_map[p["id"]]
- related = p["related_collection_id"]
- related_new = new_id if related == collection_id else related
- if p["prop_type"] == "relation":
- conn.execute(
- "UPDATE collection_properties SET related_collection_id=? WHERE id=?",
- (related_new, new_pid),
- )
- if p.get("relation_property_id") and p["relation_property_id"] in prop_map:
- conn.execute(
- "UPDATE collection_properties SET relation_property_id=? WHERE id=?",
- (prop_map[p["relation_property_id"]], new_pid),
- )
- if p.get("target_property_id") and p["target_property_id"] in prop_map:
- conn.execute(
- "UPDATE collection_properties SET target_property_id=? WHERE id=?",
- (prop_map[p["target_property_id"]], new_pid),
- )
-
- # ── Views ──
- vrows = conn.execute(
- "SELECT * FROM collection_views WHERE collection_id=? ORDER BY position",
- (collection_id,),
- ).fetchall()
- for v in vrows:
- conn.execute(
- """INSERT INTO collection_views
- (collection_id, name, view_type, config_json, position)
- VALUES (?,?,?,?,?)""",
- (new_id, v["name"], v["view_type"], v["config_json"], v["position"]),
- )
-
- # ── Pages (rows) with property ids remapped to the copy's properties ──
- prows = conn.execute(
- "SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position",
- (collection_id,),
- ).fetchall()
- page_map: dict[int, int] = {}
- for p in prows:
- try:
- pv = json.loads(p["property_values_json"]) if p["property_values_json"] else {}
- except (json.JSONDecodeError, TypeError):
- pv = {}
- pv_new = {}
- for k, val in pv.items():
- try:
- prop_id = int(k)
- except (ValueError, TypeError):
- prop_id = None
- new_key = str(prop_map.get(prop_id, prop_id)) if prop_id is not None else k
- pv_new[new_key] = val
- ncur = conn.execute(
- """INSERT INTO collection_pages
- (collection_id, title, icon, position, parent_id, gitea_issue_id,
- gitea_issue_number, property_values_json, created_at, updated_at)
- SELECT ?, title, icon, position, NULL, NULL, NULL, ?, created_at, updated_at
- FROM collection_pages WHERE id=?""",
- (new_id, json.dumps(pv_new), p["id"]),
- )
- page_map[p["id"]] = ncur.lastrowid
-
- # Re-parent sub-items to the copied rows.
- for p in prows:
- if p["parent_id"] and p["parent_id"] in page_map:
- conn.execute(
- "UPDATE collection_pages SET parent_id=? WHERE id=?",
- (page_map[p["parent_id"]], page_map[p["id"]]),
- )
-
- # ── Data sources (linked DBs) ──
- drows = conn.execute(
- "SELECT * FROM collection_data_sources WHERE collection_id=?",
- (collection_id,),
- ).fetchall()
- for d in drows:
- src_coll = d["source_collection_id"]
- src_now = new_id if src_coll == collection_id else src_coll
- conn.execute(
- """INSERT INTO collection_data_sources
- (collection_id, source_collection_id, source_name, is_linked, position)
- VALUES (?,?,?,?,?)""",
- (new_id, src_now, d["source_name"], d["is_linked"], d["position"]),
- )
-
- conn.commit()
-
- run_event_sync(fire_event("collection.created", {"collection_id": new_id, "name": new_name}))
- return {"id": new_id, "name": new_name, "status": "duplicated"}
-
-
-# ── Page CRUD (standalone, BEFORE collection wildcards) ──
-
-
-@router.get("/pages/{page_id}/api")
-def get_page_api(request: Request, page_id: int):
- """API: get a single page."""
- with get_conn() as conn:
- page = conn.execute(
- "SELECT * FROM collection_pages WHERE id=?", (page_id,)
- ).fetchone()
- if not page:
- raise HTTPException(status_code=404, detail="Page not found")
- # v6.0.0: granular collection/page permissions.
- _require_view(page["collection_id"], _session_user(request))
- return dict(page)
-
-
-@router.get("/pages/{page_id}/open/api")
-def open_row_page_api(request: Request, page_id: int):
- """v6.5.0 — content page of a database row (lazy-created).
-
- Any DB view (table/board/gallery/list/calendar) opens a row through
- this endpoint: it returns the shadow ``pages`` id whose full page
- editor carries the row's block content (synced blocks included).
- """
- with get_conn() as conn:
- row = conn.execute(
- "SELECT collection_id FROM collection_pages WHERE id=?",
- (page_id,),
- ).fetchone()
- if not row:
- raise HTTPException(status_code=404, detail="Page not found")
- coll_id = row["collection_id"]
- # v6.0.0: granular collection permissions (same gate as the row itself).
- _require_view(coll_id, _session_user(request))
- from app.services.row_pages import ensure_row_page
- try:
- content_page_id = ensure_row_page(page_id)
- except KeyError:
- raise HTTPException(status_code=404, detail="Page not found") from None
- return {"page_id": content_page_id, "row_id": page_id}
-
-
-@router.put("/pages/{page_id}/api")
-def update_page_api(request: Request, page_id: int, body: dict = Body(default={})):
- """API: update a page's properties."""
-
- with get_conn() as conn:
- existing = conn.execute(
- "SELECT * FROM collection_pages WHERE id=?", (page_id,)
- ).fetchone()
- if not existing:
- raise HTTPException(status_code=404, detail="Page not found")
- # v6.0.0: granular collection/page permissions.
- _require_edit(existing["collection_id"], _session_user(request))
-
- title = body.get("title", existing["title"])
- icon = body.get("icon", existing["icon"])
- cover_url = body.get("cover_url", existing["cover_url"] if "cover_url" in existing.keys() else "")
- position = body.get("position", existing["position"])
- parent_id = body.get("parent_id", existing["parent_id"])
-
- try:
- stored = json.loads(existing["property_values_json"])
- except (json.JSONDecodeError, TypeError):
- stored = {}
-
- if "properties" in body:
- # Partial PATCH semantics: merge submitted values over stored ones.
- props = dict(stored)
- props.update(body["properties"])
- else:
- props = stored
-
- _validate_page_properties(conn, existing["collection_id"], props, exclude_page_id=page_id)
- _validate_meta_keys(conn, existing["collection_id"], props)
- apply_auto_properties(
- _collection_properties(conn, existing["collection_id"]),
- props,
- _current_user(request),
- is_create=False,
- )
-
- property_values = json.dumps(props)
-
- conn.execute(
- """UPDATE collection_pages
- SET title=?, icon=?, cover_url=?, position=?, parent_id=?, property_values_json=?,
- updated_at=CURRENT_TIMESTAMP
- WHERE id=?""",
- (title, icon, cover_url, position, parent_id, property_values, page_id),
- )
- # v6.5.0: keep the row's content page title in sync (row → page).
- from app.services.row_pages import sync_row_title_to_page
- sync_row_title_to_page(conn, page_id)
- conn.commit()
-
- run_event_sync(fire_event("page.updated", {
- "page_id": page_id,
- "collection_id": existing["collection_id"],
- "title": title,
- "icon": icon,
- "properties": props,
- }))
- run_event_sync(fire_event("collection.page.updated", {
- "page_id": page_id,
- "collection_id": existing["collection_id"],
- "title": title,
- }))
- # Notify newly assigned people (person properties) — v5.8.0.
- from app.services.notifications import notify_assignment
- user = _current_user(request)
- notify_assignment(existing["collection_id"], page_id, title,
- stored, props, user.get("id"))
- return {"id": page_id, "status": "updated"}
-
-
-@router.delete("/pages/{page_id}/api")
-def delete_page_api(request: Request, page_id: int):
- """API: delete a page from its collection."""
- with get_conn() as conn:
- existing = conn.execute(
- "SELECT * FROM collection_pages WHERE id=?", (page_id,)
- ).fetchone()
- if not existing:
- raise HTTPException(status_code=404, detail="Page not found")
- # v6.0.0: granular collection/page permissions.
- _require_edit(existing["collection_id"], _session_user(request))
-
- conn.execute("DELETE FROM collection_pages WHERE id=?", (page_id,))
- conn.commit()
-
- run_event_sync(fire_event("page.deleted", {
- "page_id": page_id,
- "collection_id": existing["collection_id"],
- "title": existing["title"],
- }))
- run_event_sync(fire_event("collection.page.deleted", {
- "page_id": page_id,
- "collection_id": existing["collection_id"],
- }))
- return {"id": page_id, "status": "deleted"}
-
-
-# ── Gitea Board Compatibility (BEFORE {collection_id} wildcards) ──
-
-
-@router.get("/boards/api")
-def list_boards_as_collections(request: Request):
- """API: list all Gitea boards as pseudo-collections."""
- from app.services.collection_adapter import GiteaBoardCompat
- boards = GiteaBoardCompat.list_boards_as_collections()
- return {"boards": boards}
-
-
-@router.get("/board/{owner}/{repo}/api")
-async def get_board_as_collection(request: Request, owner: str, repo: str):
- """API: get a specific Gitea board as a pseudo-collection."""
- from app.services.collection_adapter import GiteaBoardCompat
- coll = GiteaBoardCompat.get_board_as_collection(owner, repo)
- if not coll:
- raise HTTPException(status_code=404, detail="Board not found")
-
- from app.services.gitea_client import gitea
- issues = await gitea.get_issues(owner, repo, state="all")
- cards = GiteaBoardCompat.get_board_cards(owner, repo, issues)
-
- return {"collection": coll, "pages": cards}
-
-
-@router.post("/board/{owner}/{repo}/sync")
-async def sync_board_to_collection(request: Request, owner: str, repo: str):
- """Sync a Gitea board to a real collection."""
- from app.services.collection_adapter import GiteaBoardCompat
- from app.services.gitea_client import gitea
-
- issues = await gitea.get_issues(owner, repo, state="all")
- coll_id = GiteaBoardCompat.sync_to_collection(owner, repo, issues)
- if coll_id is None:
- raise HTTPException(status_code=404, detail="Board not found")
-
- return {"collection_id": coll_id, "status": "synced"}
-
-
-# ── Collection Properties (v1.4.0) ──
-
-
-@router.get("/property-types/api")
-def list_property_types_api(request: Request):
- """API: list all available property types."""
- from app.services.property_types import PROPERTY_TYPES
- return {"types": PROPERTY_TYPES}
-
-
-@router.get("/{collection_id}/properties/api")
-def list_properties_api(request: Request, collection_id: int):
- """API: list all properties visible to the current user."""
- user = _session_user(request)
- _require_view(collection_id, user)
- with get_conn() as conn:
- coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
- if not coll:
- raise HTTPException(status_code=404, detail="Collection not found")
- rows = conn.execute(
- "SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
- (collection_id,),
- ).fetchall()
- props = [dict(r) for r in rows]
- # v6.0.0: property-level visibility — owners/editors see everything, other
- # users only the properties explicitly granted or left open.
- if user:
- pm = PermissionManager(user["id"])
- visible = pm.get_visible_properties(collection_id)
- props = [p for p in props if p["id"] in visible]
- return {"properties": props}
-
-
-@router.get("/{collection_id}/members/api")
-def list_collection_members_api(request: Request, collection_id: int):
- """API: list workspace members available for a ``person`` property.
-
- Resolves the collection's workspace and returns its members (falling back to
- every active user for standalone databases without a workspace).
- """
- with get_conn() as conn:
- coll = conn.execute(
- "SELECT workspace_id FROM collections WHERE id=?", (collection_id,)
- ).fetchone()
- if not coll:
- raise HTTPException(status_code=404, detail="Collection not found")
-
- ws_id = coll["workspace_id"] if "workspace_id" in coll.keys() else None
- if ws_id:
- rows = conn.execute(
- """SELECT u.id, u.login, u.full_name, u.avatar_url, u.avatar_color, wm.role
- FROM workspace_members wm JOIN users u ON wm.user_id=u.id
- WHERE wm.workspace_id=? AND u.is_active=1 ORDER BY u.full_name, u.login""",
- (ws_id,),
- ).fetchall()
- else:
- rows = []
- if not rows:
- rows = conn.execute(
- """SELECT id, login, full_name, avatar_url, avatar_color, '' AS role
- FROM users WHERE is_active=1 ORDER BY full_name, login"""
- ).fetchall()
-
- return {"members": [dict(r) for r in rows]}
-
-
-@router.get("/{collection_id}/calendar/api")
-def collection_calendar_api(request: Request, collection_id: int,
- start: str = "", end: str = "",
- date_property: str = ""):
- """API (v5.8.0): expanded calendar events for a window [start, end].
-
- Returns every occurrence (recurrence-aware, virtual — never persisted)
- of the rows in the collection whose ``date_property`` falls inside the
- inclusive window. Rows without a rule yield their base date.
- """
- user = _current_user(request)
- s = parse_date(start)
- e = parse_date(end)
- if s is None or e is None or s > e:
- raise HTTPException(status_code=400, detail="start/end must be YYYY-MM-DD")
- if (e - s).days > 370:
- raise HTTPException(status_code=400, detail="window too large (max 370 days)")
-
- with get_conn() as conn:
- coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
- if not coll:
- raise HTTPException(status_code=404, detail="Collection not found")
-
- props = _collection_properties(conn, collection_id)
- date_props = [p for p in props if p["prop_type"] == "date"]
- target = None
- if date_property:
- target = next((p for p in date_props
- if str(p["id"]) == str(date_property) or p["name"] == date_property), None)
- if target is None:
- raise HTTPException(status_code=400, detail="Unknown date property")
- elif date_props:
- target = date_props[0]
- if target is None:
- return {"events": [], "timezone": "", "property": None}
-
- urow = conn.execute("SELECT timezone FROM users WHERE id=?", (user.get("id") or 1,)).fetchone()
- user_tz = (urow["timezone"] if urow and "timezone" in urow.keys() else "") or ""
-
- rows = conn.execute(
- "SELECT id, title, icon, property_values_json FROM collection_pages WHERE collection_id=?",
- (collection_id,),
- ).fetchall()
-
- pid = str(target["id"])
- events: list[dict] = []
- for r in rows:
- try:
- pv = json.loads(r["property_values_json"] or "{}")
- except (json.JSONDecodeError, TypeError):
- continue
- base_value = pv.get(pid)
- if base_value is None:
- base_value = pv.get(target["name"])
- if parse_date(base_value) is None:
- continue
- rec_all = pv.get(RECURRENCE_KEY) if isinstance(pv.get(RECURRENCE_KEY), dict) else {}
- rule = rec_all.get(pid) or rec_all.get(target["name"])
- row_tz = user_tz
- if isinstance(rule, dict) and rule.get("timezone"):
- row_tz = rule["timezone"]
- tzmap = pv.get("__timezone__")
- if isinstance(tzmap, dict):
- ev_tz = tzmap.get(pid) or tzmap.get(target["name"])
- if ev_tz:
- row_tz = str(ev_tz)
- if rule:
- dates = expand_rule(base_value, rule, s, e, max_occurrences=500)
- else:
- d = parse_date(base_value)
- dates = [d.isoformat()] if d and s <= d <= e else []
- for iso in dates:
- events.append({
- "date": iso,
- "page_id": r["id"],
- "title": r["title"],
- "icon": r["icon"],
- "recurring": bool(rule),
- "time": str(base_value)[11:16] if len(str(base_value)) >= 16 else "",
- "timezone": row_tz,
- })
- events.sort(key=lambda ev: (ev["date"], ev["page_id"]))
- return {"events": events, "timezone": user_tz, "property": {"id": target["id"], "name": target["name"]}}
-
-
-@router.get("/timezones/api")
-def timezones_api(request: Request):
- """API (v5.8.0): the user's timezone plus a picker-friendly zone list."""
- user = _current_user(request)
- with get_conn() as conn:
- row = conn.execute("SELECT timezone FROM users WHERE id=?", (user.get("id") or 1,)).fetchone()
- from app.services.recurrence import common_timezones
- return {
- "timezone": (row["timezone"] if row and "timezone" in row.keys() else "") or "",
- "zones": common_timezones(),
- }
-
-
-@router.post("/{collection_id}/property-groups/api")
-def set_property_groups_api(request: Request, collection_id: int, body: dict = Body(default={})):
- """API: (re)assign properties to collapsible groups in the table header.
-
- Body: ``{"groups": [{"name": "Basics", "property_ids": [1, 2]}]}``. Properties
- omitted from any group have their group cleared. Empty group names clear.
- """
- groups = body.get("groups", [])
-
- with get_conn() as conn:
- coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
- if not coll:
- raise HTTPException(status_code=404, detail="Collection not found")
-
- conn.execute(
- "UPDATE collection_properties SET group_name='' WHERE collection_id=?",
- (collection_id,),
- )
- for grp in groups:
- gname = (grp.get("name") or "").strip()
- if not gname:
- continue
- for pid in grp.get("property_ids", []) or []:
- conn.execute(
- "UPDATE collection_properties SET group_name=? WHERE id=? AND collection_id=?",
- (gname, pid, collection_id),
- )
- conn.commit()
-
- return {"status": "updated"}
-
-
-@router.post("/{collection_id}/properties/api")
-def create_property_api(request: Request, collection_id: int, body: dict = Body(default={})):
- """API: create a new property on a collection."""
-
- name = body.get("name", "").strip()
- if not name:
- raise HTTPException(status_code=400, detail="name is required")
-
- prop_type = body.get("prop_type", "text")
- options_json = json.dumps(body.get("options", []))
- number_format = body.get("number_format", "number")
- required = int(body.get("required", False))
- visible = int(body.get("visible_in_views", True))
- validation_json = json.dumps(body.get("validation", {}))
- group_name = (body.get("group_name") or "").strip()
-
- with get_conn() as conn:
- coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
- if not coll:
- raise HTTPException(status_code=404, detail="Collection not found")
-
- max_pos = conn.execute(
- "SELECT COALESCE(MAX(position), -1) + 1 FROM collection_properties WHERE collection_id=?",
- (collection_id,),
- ).fetchone()[0]
-
- try:
- cur = conn.execute(
- """INSERT INTO collection_properties
- (collection_id, name, prop_type, options_json, number_format,
- position, required, visible_in_views, validation_json, group_name)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
- (collection_id, name, prop_type, options_json, number_format, max_pos,
- required, visible, validation_json, group_name),
- )
- conn.commit()
- except Exception:
- raise HTTPException(status_code=409, detail=f"Property '{name}' already exists") from None
-
- return {"id": cur.lastrowid, "name": name, "prop_type": prop_type,
- "group_name": group_name, "status": "created"}
-
-
-@router.put("/properties/{prop_id}/api")
-def update_property_api(request: Request, prop_id: int, body: dict = Body(default={})):
- """API: update a property."""
-
- with get_conn() as conn:
- existing = conn.execute(
- "SELECT * FROM collection_properties WHERE id=?", (prop_id,)
- ).fetchone()
- if not existing:
- raise HTTPException(status_code=404, detail="Property not found")
-
- name = body.get("name", existing["name"])
- options_json = json.dumps(body.get("options", json.loads(existing["options_json"])))
- number_format = body.get("number_format", existing["number_format"])
- required = int(body.get("required", existing["required"]))
- visible = int(body.get("visible_in_views", existing["visible_in_views"]))
- if "validation" in body:
- validation_json = json.dumps(body.get("validation", {}))
- else:
- validation_json = existing["validation_json"] if "validation_json" in existing.keys() else "{}"
- group_name = body.get("group_name", existing["group_name"] if "group_name" in existing.keys() else "")
-
- conn.execute(
- """UPDATE collection_properties
- SET name=?, options_json=?, number_format=?, required=?, visible_in_views=?,
- validation_json=?, group_name=?
- WHERE id=?""",
- (name, options_json, number_format, required, visible, validation_json,
- group_name, prop_id),
- )
- conn.commit()
-
- return {"id": prop_id, "status": "updated"}
-
-
-@router.delete("/properties/{prop_id}/api")
-def delete_property_api(request: Request, prop_id: int):
- """API: delete a property."""
- with get_conn() as conn:
- existing = conn.execute(
- "SELECT * FROM collection_properties WHERE id=?", (prop_id,)
- ).fetchone()
- if not existing:
- raise HTTPException(status_code=404, detail="Property not found")
- conn.execute("DELETE FROM collection_properties WHERE id=?", (prop_id,))
- conn.commit()
-
- return {"id": prop_id, "status": "deleted"}
-
-
-# ── Relations, Rollups, Formulas (v1.5.0) ──
-
-
-@router.post("/{collection_id}/properties/relation")
-def create_relation_property(request: Request, collection_id: int, body: dict = Body(default={})):
- """Create a relation property between two collections."""
-
- name = body.get("name", "").strip()
- related_collection_id = body.get("related_collection_id")
- reverse_name = body.get("reverse_name", "").strip()
-
- if not name or not related_collection_id:
- raise HTTPException(status_code=400, detail="name and related_collection_id are required")
-
- with get_conn() as conn:
- # Verify both collections exist
- for cid in (collection_id, related_collection_id):
- if not conn.execute("SELECT id FROM collections WHERE id=?", (cid,)).fetchone():
- raise HTTPException(status_code=404, detail=f"Collection {cid} not found")
-
- max_pos = conn.execute(
- "SELECT COALESCE(MAX(position), -1) + 1 FROM collection_properties WHERE collection_id=?",
- (collection_id,),
- ).fetchone()[0]
-
- cur = conn.execute(
- """INSERT INTO collection_properties
- (collection_id, name, prop_type, related_collection_id, reverse_name, position)
- VALUES (?, ?, 'relation', ?, ?, ?)""",
- (collection_id, name, related_collection_id, reverse_name, max_pos),
- )
- prop_id = cur.lastrowid
-
- # Create reverse relation on the related collection
- if reverse_name:
- max_pos2 = conn.execute(
- "SELECT COALESCE(MAX(position), -1) + 1 FROM collection_properties WHERE collection_id=?",
- (related_collection_id,),
- ).fetchone()[0]
- conn.execute(
- """INSERT INTO collection_properties
- (collection_id, name, prop_type, related_collection_id, reverse_name, position)
- VALUES (?, ?, 'relation', ?, ?, ?)""",
- (related_collection_id, reverse_name, collection_id, name, max_pos2),
- )
-
- conn.commit()
-
- return {"id": prop_id, "name": name, "prop_type": "relation", "status": "created"}
-
-
-@router.post("/{collection_id}/properties/relation/link")
-def link_pages(request: Request, collection_id: int, body: dict = Body(default={})):
- """Link two pages via a relation property."""
-
- property_id = body.get("property_id")
- source_page_id = body.get("source_page_id")
- target_page_id = body.get("target_page_id")
-
- if not all([property_id, source_page_id, target_page_id]):
- raise HTTPException(status_code=400, detail="property_id, source_page_id, target_page_id required")
-
- with get_conn() as conn:
- # Get the relation property
- prop = conn.execute(
- "SELECT * FROM collection_properties WHERE id=? AND prop_type='relation'",
- (property_id,),
- ).fetchone()
- if not prop:
- raise HTTPException(status_code=404, detail="Relation property not found")
-
- # Update source page's property_values_json
- source = conn.execute(
- "SELECT property_values_json FROM collection_pages WHERE id=?",
- (source_page_id,),
- ).fetchone()
- if not source:
- raise HTTPException(status_code=404, detail="Source page not found")
-
- props = json.loads(source["property_values_json"])
- current = props.get(str(property_id), [])
- if not isinstance(current, list):
- current = []
- if target_page_id not in current:
- current.append(target_page_id)
- props[str(property_id)] = current
-
- conn.execute(
- "UPDATE collection_pages SET property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
- (json.dumps(props), source_page_id),
- )
-
- # Update reverse relation if exists
- if prop["reverse_name"]:
- reverse_prop = conn.execute(
- "SELECT id FROM collection_properties WHERE collection_id=? AND name=? AND prop_type='relation'",
- (prop["related_collection_id"], prop["reverse_name"]),
- ).fetchone()
- if reverse_prop:
- target = conn.execute(
- "SELECT property_values_json FROM collection_pages WHERE id=?",
- (target_page_id,),
- ).fetchone()
- if target:
- tprops = json.loads(target["property_values_json"])
- tcurrent = tprops.get(str(reverse_prop["id"]), [])
- if not isinstance(tcurrent, list):
- tcurrent = []
- if source_page_id not in tcurrent:
- tcurrent.append(source_page_id)
- tprops[str(reverse_prop["id"])] = tcurrent
- conn.execute(
- "UPDATE collection_pages SET property_values_json=? WHERE id=?",
- (json.dumps(tprops), target_page_id),
- )
-
- conn.commit()
-
- return {"status": "linked", "source": source_page_id, "target": target_page_id}
-
-
-@router.post("/rollup/compute")
-def compute_rollup(request: Request, body: dict = Body(default={})):
- """Compute a rollup aggregation."""
-
- collection_id = body.get("collection_id")
- relation_property_id = body.get("relation_property_id")
- target_property_id = body.get("target_property_id")
- page_id = body.get("page_id")
- rollup_function = body.get("function", "count")
-
- if not all([collection_id, relation_property_id, target_property_id, page_id]):
- raise HTTPException(status_code=400, detail="collection_id, relation_property_id, target_property_id, page_id required")
-
- from app.services.rollup_engine import RollupEngine
- engine = RollupEngine()
- result = engine.compute(
- collection_id, relation_property_id, target_property_id, page_id, rollup_function,
- )
-
- return {"result": result, "function": rollup_function}
-
-
-@router.post("/formula/evaluate")
-def evaluate_formula(request: Request, body: dict = Body(default={})):
- """Evaluate a formula expression."""
-
- expression = body.get("expression", "")
- context = body.get("context", {})
-
- if not expression:
- raise HTTPException(status_code=400, detail="expression is required")
-
- from app.services.formula_engine import FormulaEngine
- engine = FormulaEngine()
- result = engine.evaluate(expression, context)
-
- return {"result": result, "expression": expression}
-
-
-# ── v1.7.0 View Management ──
-
-
-@router.get("/views/{view_id}/api")
-def get_view_api(request: Request, view_id: int):
- """API: get a single view config."""
- with get_conn() as conn:
- row = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
- if not row:
- raise HTTPException(status_code=404, detail="View not found")
- return dict(row)
-
-
-@router.put("/views/{view_id}/config")
-def update_view_config(request: Request, view_id: int, body: dict = Body(default={})):
- """API: update view configuration (group_by, card_size, visible_properties, etc.)."""
-
- with get_conn() as conn:
- existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
- if not existing:
- raise HTTPException(status_code=404, detail="View not found")
-
- config = json.loads(existing["config_json"])
- for key in ("group_by", "sub_group_by", "wip_limits", "card_size", "cover_property",
- "cover_mode", "card_properties", "visible_properties", "filters", "sorts",
- "filter_conjunction", "date_property", "date_range_property",
- "property_groups", "view_type"):
- if key in body:
- config[key] = body[key]
-
- new_type = body.get("view_type") or existing["view_type"]
- conn.execute(
- "UPDATE collection_views SET config_json=?, name=COALESCE(?, name), view_type=?, "
- "updated_at=CURRENT_TIMESTAMP WHERE id=?",
- (json.dumps(config), body.get("name"), new_type, view_id),
- )
- conn.commit()
-
- return {"id": view_id, "status": "updated", "config": config, "view_type": new_type}
-
-
-@router.post("/{collection_id}/views/save-as")
-def save_view_as(request: Request, collection_id: int, body: dict = Body(default={})):
- """API: save current view state as a new named view."""
-
- name = body.get("name", "New View")
- config = body.get("config", {})
- user = _current_user(request)
- user_id = user.get("id") if user else None
-
- with get_conn() as conn:
- coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
- if not coll:
- raise HTTPException(status_code=404, detail="Collection not found")
-
- max_pos = conn.execute(
- "SELECT COALESCE(MAX(position), -1) + 1 FROM collection_views WHERE collection_id=?",
- (collection_id,),
- ).fetchone()[0]
-
- view_type = body.get("view_type", "table")
- cur = conn.execute(
- """INSERT INTO collection_views
- (collection_id, name, view_type, config_json, position, created_by)
- VALUES (?, ?, ?, ?, ?, ?)""",
- (collection_id, name, view_type, json.dumps(config), max_pos, user_id),
- )
- conn.commit()
- new_view_id = cur.lastrowid
-
- run_event_sync(fire_event("collection.view.created", {
- "view_id": new_view_id,
- "collection_id": collection_id,
- "name": name,
- "view_type": view_type,
- }))
- return {"id": new_view_id, "name": name, "view_type": view_type,
- "config_json": json.dumps(config), "created_by": user_id, "status": "saved"}
-
-
-@router.get("/{collection_id}/views/api")
-def list_views_api(request: Request, collection_id: int):
- """API: list views for a collection visible to the current user.
-
- Shared/legacy views (``created_by IS NULL``) are visible to everyone;
- personal views (``created_by = user``) only to their owner.
- """
- user = _current_user(request)
- user_id = user.get("id") if user else None
- with get_conn() as conn:
- if user_id is not None:
- rows = conn.execute(
- """SELECT * FROM collection_views
- WHERE collection_id=? AND (created_by IS NULL OR created_by=?)
- ORDER BY position""",
- (collection_id, user_id),
- ).fetchall()
- else:
- rows = conn.execute(
- "SELECT * FROM collection_views WHERE collection_id=? AND created_by IS NULL ORDER BY position",
- (collection_id,),
- ).fetchall()
- return {"views": [dict(r) for r in rows]}
-
-
-@router.delete("/views/{view_id}/api")
-def delete_view_api(request: Request, view_id: int):
- """API: delete a saved view."""
- with get_conn() as conn:
- existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
- if not existing:
- raise HTTPException(status_code=404, detail="View not found")
- conn.execute("DELETE FROM collection_views WHERE id=?", (view_id,))
- conn.commit()
- return {"id": view_id, "status": "deleted"}
-
-
-@router.post("/views/{view_id}/duplicate")
-def duplicate_view_api(request: Request, view_id: int, body: dict = Body(default={})):
- """API: duplicate a view (config + type), owned by the current user."""
-
- with get_conn() as conn:
- existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
- if not existing:
- raise HTTPException(status_code=404, detail="View not found")
-
- max_pos = conn.execute(
- "SELECT COALESCE(MAX(position), -1) + 1 FROM collection_views WHERE collection_id=?",
- (existing["collection_id"],),
- ).fetchone()[0]
-
- user = _current_user(request)
- user_id = user.get("id") if user else None
- name = body.get("name") or (existing["name"] + " copy")
- cur = conn.execute(
- """INSERT INTO collection_views
- (collection_id, name, view_type, config_json, position, created_by)
- VALUES (?, ?, ?, ?, ?, ?)""",
- (existing["collection_id"], name, existing["view_type"],
- existing["config_json"], max_pos, user_id),
- )
- conn.commit()
- dup_view_id = cur.lastrowid
-
- run_event_sync(fire_event("collection.view.created", {
- "view_id": dup_view_id,
- "collection_id": existing["collection_id"],
- "name": name,
- "view_type": existing["view_type"],
- }))
- return {"id": dup_view_id, "name": name, "view_type": existing["view_type"],
- "status": "duplicated"}
-
-
-# ── v1.8.0 Sub-items & Dependencies ──
-
-
-@router.get("/{collection_id}/pages/{page_id}/sub-items")
-def list_sub_items(request: Request, collection_id: int, page_id: int):
- """API: list sub-items of a page."""
- with get_conn() as conn:
- rows = conn.execute(
- "SELECT * FROM collection_pages WHERE parent_id=? ORDER BY position",
- (page_id,),
- ).fetchall()
- return {"sub_items": [dict(r) for r in rows]}
-
-
-@router.post("/{collection_id}/pages/{page_id}/sub-items")
-def create_sub_item(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})):
- """API: create a sub-item under a page."""
-
- title = body.get("title", "New sub-item").strip()
- if not title:
- raise HTTPException(status_code=400, detail="title is required")
-
- with get_conn() as conn:
- parent = conn.execute("SELECT id FROM collection_pages WHERE id=? AND collection_id=?", (page_id, collection_id)).fetchone()
- if not parent:
- raise HTTPException(status_code=404, detail="Parent page not found")
-
- max_pos = conn.execute(
- "SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE parent_id=?",
- (page_id,),
- ).fetchone()[0]
-
- sub_props = body.get("properties", {}) or {}
- apply_auto_properties(
- _collection_properties(conn, collection_id),
- sub_props,
- _current_user(request),
- is_create=True,
- )
-
- cur = conn.execute(
- "INSERT INTO collection_pages (collection_id, title, parent_id, position, property_values_json) VALUES (?, ?, ?, ?, ?)",
- (collection_id, title, page_id, max_pos, json.dumps(sub_props)),
- )
- conn.commit()
- new_id = cur.lastrowid
-
- run_event_sync(fire_event("page.created", {
- "page_id": new_id,
- "collection_id": collection_id,
- "parent_id": page_id,
- "title": title,
- "properties": body.get("properties", {}),
- }))
- run_event_sync(fire_event("collection.page.created", {
- "page_id": new_id,
- "collection_id": collection_id,
- "title": title,
- }))
- return {"id": new_id, "title": title, "parent_id": page_id, "status": "created"}
-
-
-@router.get("/{collection_id}/pages/{page_id}/status-aggregate")
-def aggregate_child_status(request: Request, collection_id: int, page_id: int):
- """API: compute aggregate status from children."""
- with get_conn() as conn:
- children = conn.execute(
- "SELECT property_values_json FROM collection_pages WHERE parent_id=?",
- (page_id,),
- ).fetchall()
-
- statuses = []
- for c in children:
- props = json.loads(c["property_values_json"])
- for v in props.values():
- if isinstance(v, str) and v:
- statuses.append(v)
-
- total = len(statuses)
- if total == 0:
- return {"total": 0, "done": 0, "all_done": False}
-
- done = sum(1 for s in statuses if s.lower() in ("done", "complete", "completed", "terminé"))
- return {"total": total, "done": done, "all_done": done == total}
-
-
-@router.post("/{collection_id}/pages/{page_id}/dependencies")
-def set_dependencies(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})):
- """API: set blocking dependencies for a page (stored as 'blocks' property)."""
-
- blocks_ids = body.get("blocks", [])
-
- with get_conn() as conn:
- page = conn.execute("SELECT property_values_json FROM collection_pages WHERE id=?", (page_id,)).fetchone()
- if not page:
- raise HTTPException(status_code=404, detail="Page not found")
-
- props = json.loads(page["property_values_json"])
- props["blocks"] = blocks_ids
-
- conn.execute(
- "UPDATE collection_pages SET property_values_json=? WHERE id=?",
- (json.dumps(props), page_id),
- )
- conn.commit()
-
- return {"page_id": page_id, "blocks": blocks_ids, "status": "updated"}
-
-
-@router.post("/{collection_id}/pages/{page_id}/check-deps")
-def check_dependencies(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})):
- """API: check if a page can transition to a new status."""
-
- body.get("new_status", "Done")
-
- with get_conn() as conn:
- page = conn.execute("SELECT property_values_json FROM collection_pages WHERE id=?", (page_id,)).fetchone()
- if not page:
- raise HTTPException(status_code=404, detail="Page not found")
-
- props = json.loads(page["property_values_json"])
- blocks_ids = props.get("blocks", [])
-
- if not blocks_ids:
- return {"can_transition": True, "blocked_by": []}
-
- # Check blocked pages status
- placeholders = ",".join("?" for _ in blocks_ids)
- blocked = conn.execute(
- f"SELECT id, title, property_values_json FROM collection_pages WHERE id IN ({placeholders})",
- blocks_ids,
- ).fetchall()
-
- blockers = []
- for b in blocked:
- bprops = json.loads(b["property_values_json"])
- bstatus = None
- for v in bprops.values():
- if isinstance(v, str) and v:
- bstatus = v
- break
- if bstatus and bstatus.lower() not in ("done", "complete", "completed", "terminé"):
- blockers.append({"id": b["id"], "title": b["title"], "status": bstatus})
-
- return {
- "can_transition": len(blockers) == 0,
- "blocked_by": blockers,
- }
-
-
-# ── v4.1.0: Data Sources & Linked Databases ──
-
-
-@router.get("/{collection_id}/sources/api")
-def list_data_sources(request: Request, collection_id: int):
- """API: list all data sources for a collection."""
- with get_conn() as conn:
- coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
- if not coll:
- raise HTTPException(status_code=404, detail="Collection not found")
-
- rows = conn.execute(
- "SELECT * FROM collection_data_sources WHERE collection_id=? ORDER BY position",
- (collection_id,),
- ).fetchall()
- return {"sources": [dict(r) for r in rows]}
-
-
-@router.post("/{collection_id}/sources/api")
-def add_data_source(request: Request, collection_id: int, body: dict = Body(default={})):
- """API: add a data source to a collection."""
-
- source_collection_id = body.get("source_collection_id")
- if not source_collection_id:
- raise HTTPException(status_code=400, detail="source_collection_id is required")
-
- source_name = body.get("source_name", "").strip()
- is_linked = body.get("is_linked", False)
-
- with get_conn() as conn:
- # Verify both collections exist
- for cid in (collection_id, source_collection_id):
- if not conn.execute("SELECT id FROM collections WHERE id=?", (cid,)).fetchone():
- raise HTTPException(status_code=404, detail=f"Collection {cid} not found")
-
- max_pos = conn.execute(
- "SELECT COALESCE(MAX(position), -1) + 1 FROM collection_data_sources WHERE collection_id=?",
- (collection_id,),
- ).fetchone()[0]
-
- try:
- cur = conn.execute(
- """INSERT INTO collection_data_sources
- (collection_id, source_collection_id, source_name, is_linked, position)
- VALUES (?, ?, ?, ?, ?)""",
- (collection_id, source_collection_id, source_name, int(is_linked), max_pos),
- )
- conn.commit()
- except sqlite3.IntegrityError:
- raise HTTPException(status_code=409, detail="This data source already exists in this collection") from None
-
- return {
- "id": cur.lastrowid,
- "collection_id": collection_id,
- "source_collection_id": source_collection_id,
- "status": "added",
- }
-
-
-@router.delete("/{collection_id}/sources/{source_id}/api")
-def remove_data_source(request: Request, collection_id: int, source_id: int):
- """API: remove a data source from a collection."""
- with get_conn() as conn:
- existing = conn.execute(
- "SELECT * FROM collection_data_sources WHERE id=? AND collection_id=?",
- (source_id, collection_id),
- ).fetchone()
- if not existing:
- raise HTTPException(status_code=404, detail="Data source not found")
-
- conn.execute("DELETE FROM collection_data_sources WHERE id=?", (source_id,))
- conn.commit()
-
- return {"id": source_id, "status": "removed"}
-
-
-@router.post("/{collection_id}/linked/api")
-def create_linked_database(request: Request, collection_id: int, body: dict = Body(default={})):
- """API: create a linked database view from a source collection.
- A linked database copies the structure (views, filters, sorts) of a source
- but shares the same pages — edits to pages propagate to the source.
- """
-
- name = body.get("name", "").strip()
- body.get("workspace_id")
-
- with get_conn() as conn:
- source = conn.execute(
- "SELECT * FROM collections WHERE id=?", (collection_id,)
- ).fetchone()
- if not source:
- raise HTTPException(status_code=404, detail="Source collection not found")
-
- if not name:
- name = f"{source['name']} (linked)"
-
- # Create the linked collection (shallow copy of structure)
- # Inherit workspace_id from source for permission inheritance
- src_dict = dict(source)
- source_workspace_id = src_dict.get("workspace_id")
- cur = conn.execute(
- """INSERT INTO collections
- (name, description, icon, schema_json, is_locked, is_inline, parent_page_id, workspace_id)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
- (
- name,
- src_dict["description"],
- src_dict["icon"],
- src_dict["schema_json"],
- 0, # linked DB is never locked
- 1, # linked DB starts as inline
- src_dict.get("parent_page_id"),
- source_workspace_id, # linked DB inherits source workspace permissions
- ),
- )
- linked_id = cur.lastrowid
-
- # Copy views from source
- views = conn.execute(
- "SELECT * FROM collection_views WHERE collection_id=? ORDER BY position",
- (collection_id,),
- ).fetchall()
- for v in views:
- conn.execute(
- "INSERT INTO collection_views (collection_id, name, view_type, config_json, position) VALUES (?, ?, ?, ?, ?)",
- (linked_id, v["name"], v["view_type"], v["config_json"], v["position"]),
- )
-
- # Add the source as a data source with is_linked=1
- conn.execute(
- """INSERT INTO collection_data_sources
- (collection_id, source_collection_id, source_name, is_linked, position)
- VALUES (?, ?, ?, 1, 0)""",
- (linked_id, collection_id, source["name"]),
- )
-
- # Copy properties from source
- props = conn.execute(
- "SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
- (collection_id,),
- ).fetchall()
- for p in props:
- conn.execute(
- """INSERT INTO collection_properties
- (collection_id, name, prop_type, options_json, number_format,
- related_collection_id, reverse_name, relation_property_id,
- target_property_id, rollup_function, formula_expression,
- position, required, visible_in_views)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
- (
- linked_id, p["name"], p["prop_type"], p["options_json"],
- p["number_format"], p["related_collection_id"], p["reverse_name"],
- p["relation_property_id"], p["target_property_id"],
- p["rollup_function"], p["formula_expression"],
- p["position"], p["required"], p["visible_in_views"],
- ),
- )
-
- conn.commit()
-
- return {
- "linked_id": linked_id,
- "name": name,
- "source_collection_id": collection_id,
- "status": "created",
- }
-
-
-@router.post("/{collection_id}/toggle-inline/api")
-def toggle_inline(request: Request, collection_id: int):
- """API: toggle a collection between full-page and inline mode."""
- with get_conn() as conn:
- coll = conn.execute(
- "SELECT id, is_inline FROM collections WHERE id=?",
- (collection_id,),
- ).fetchone()
- if not coll:
- raise HTTPException(status_code=404, detail="Collection not found")
-
- new_inline = 0 if coll["is_inline"] else 1
- conn.execute(
- "UPDATE collections SET is_inline=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
- (new_inline, collection_id),
- )
- conn.commit()
-
- return {
- "collection_id": collection_id,
- "is_inline": bool(new_inline),
- "mode": "inline" if new_inline else "full-page",
- }
-
-
-@router.post("/inline/api")
-def create_inline_database(request: Request, body: dict = Body(default={})):
- """API: create an inline database within a parent page (optionally from a template)."""
-
- name = body.get("name", "").strip()
- if not name:
- raise HTTPException(status_code=400, detail="name is required")
-
- description = body.get("description", "")
- icon = body.get("icon", "📋")
- parent_page_id = body.get("parent_page_id")
- workspace_id = body.get("workspace_id")
- schema = body.get("schema", [])
-
- with get_conn() as conn:
- tpl = _apply_template(conn, body.get("template"))
- if tpl:
- if body.get("name"):
- name = body["name"].strip()
- description = tpl["description"]
- icon = tpl.get("icon") or icon
- try:
- schema = json.loads(tpl["schema_json"])
- except (json.JSONDecodeError, TypeError):
- schema = []
-
- cur = conn.execute(
- """INSERT INTO collections
- (name, description, icon, schema_json, is_inline, parent_page_id, workspace_id)
- VALUES (?, ?, ?, ?, 1, ?, ?)""",
- (name, description, icon, json.dumps(schema), parent_page_id, workspace_id),
- )
- collection_id = cur.lastrowid
-
- materialize_properties(conn, collection_id, schema)
-
- # Create default view
- conn.execute(
- """INSERT INTO collection_views
- (collection_id, name, view_type, config_json)
- VALUES (?, ?, ?, ?)""",
- (collection_id, "Default View", "table", json.dumps({
- "visible_properties": ["Title"],
- "sorts": [],
- "filters": [],
- })),
- )
- conn.commit()
-
- return {
- "id": collection_id,
- "name": name,
- "icon": icon,
- "is_inline": True,
- "parent_page_id": parent_page_id,
- "status": "created",
- }
-
-
-# ── v4.4.0: Tasks & Dependencies ──
-
-
-@router.put("/{collection_id}/toggle-task/api")
-def toggle_task(request: Request, collection_id: int):
- """API: toggle is_task flag on a collection (Turn into Tasks)."""
- with get_conn() as conn:
- coll = conn.execute("SELECT id, is_task FROM collections WHERE id=?", (collection_id,)).fetchone()
- if not coll:
- raise HTTPException(status_code=404, detail="Collection not found")
- new_val = 0 if coll["is_task"] else 1
- conn.execute("UPDATE collections SET is_task=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (new_val, collection_id))
- conn.commit()
- return {"collection_id": collection_id, "is_task": bool(new_val), "mode": "tasks" if new_val else "standard"}
-
-
-@router.get("/{collection_id}/pages/{page_id}/dependencies/api")
-def list_page_dependencies(request: Request, collection_id: int, page_id: int):
- """API: list dependencies for a page (blocks, blocked_by, related)."""
- with get_conn() as conn:
- rows = conn.execute(
- "SELECT * FROM page_dependencies WHERE page_id=? ORDER BY created_at",
- (page_id,),
- ).fetchall()
- deps = []
- for r in rows:
- d = dict(r)
- dep_page = conn.execute(
- "SELECT id, title FROM collection_pages WHERE id=?", (r["dependency_id"],)
- ).fetchone()
- if dep_page:
- d["dependency_title"] = dep_page["title"]
- deps.append(d)
- return {"dependencies": deps}
-
-
-@router.post("/{collection_id}/pages/{page_id}/dependencies/api")
-def add_page_dependency(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})):
- """API: add a dependency (blocks/blocked_by/related) between two pages."""
- dependency_id = body.get("dependency_id")
- if not dependency_id:
- raise HTTPException(status_code=400, detail="dependency_id is required")
- dep_type = body.get("dependency_type", "blocks")
- auto_shift = body.get("auto_shift", "overlap")
-
- with get_conn() as conn:
- for pid in (page_id, dependency_id):
- if not conn.execute("SELECT id FROM collection_pages WHERE id=?", (pid,)).fetchone():
- raise HTTPException(status_code=404, detail=f"Page {pid} not found")
- try:
- cur = conn.execute(
- "INSERT INTO page_dependencies (page_id, dependency_id, dependency_type, auto_shift) VALUES (?,?,?,?)",
- (page_id, dependency_id, dep_type, auto_shift),
- )
- conn.commit()
- except sqlite3.IntegrityError:
- raise HTTPException(status_code=409, detail="This dependency already exists") from None
- return {"id": cur.lastrowid, "page_id": page_id, "dependency_id": dependency_id, "status": "added"}
-
-
-@router.delete("/{collection_id}/pages/{page_id}/dependencies/{dep_id}/api")
-def remove_page_dependency(request: Request, collection_id: int, page_id: int, dep_id: int):
- """API: remove a dependency."""
- with get_conn() as conn:
- existing = conn.execute(
- "SELECT * FROM page_dependencies WHERE id=? AND page_id=?", (dep_id, page_id)
- ).fetchone()
- if not existing:
- raise HTTPException(status_code=404, detail="Dependency not found")
- conn.execute("DELETE FROM page_dependencies WHERE id=?", (dep_id,))
- conn.commit()
- return {"id": dep_id, "status": "removed"}
-
-
-@router.post("/{collection_id}/pages/{page_id}/auto-shift/api")
-def auto_shift_dates(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})):
- """API: auto-shift dates based on blocking dependencies."""
- from datetime import date as dt_date
- from datetime import timedelta
-
- skip_weekends = body.get("skip_weekends", False)
-
- with get_conn() as conn:
- page = conn.execute("SELECT property_values_json FROM collection_pages WHERE id=?", (page_id,)).fetchone()
- if not page:
- raise HTTPException(status_code=404, detail="Page not found")
-
- # Get all blocking dependencies
- deps = conn.execute(
- "SELECT * FROM page_dependencies WHERE page_id=? AND dependency_type='blocks'",
- (page_id,),
- ).fetchall()
-
- shifted = False
- new_start = None
- for dep in deps:
- dep_page = conn.execute(
- "SELECT title, property_values_json FROM collection_pages WHERE id=?",
- (dep["dependency_id"],),
- ).fetchone()
- if not dep_page:
- continue
- dep_props = json.loads(dep_page["property_values_json"])
- # Find the latest end date among blockers
- for v in dep_props.values():
- if isinstance(v, str) and v.startswith("20"):
- end_date = v.split("...")[-1].split("→")[-1].strip()[:10]
- try:
- ed = dt_date.fromisoformat(end_date)
- if new_start is None or ed >= new_start:
- new_start = ed + timedelta(days=1)
- shifted = True
- except ValueError:
- continue
-
- if not shifted:
- return {"page_id": page_id, "shifted": False, "message": "No blocking dependencies with dates found"}
-
- # Skip weekends if requested
- if skip_weekends and new_start:
- while new_start.weekday() >= 5: # 5=Sat, 6=Sun
- new_start = new_start + timedelta(days=1)
-
- # Update the page's date properties
- props = json.loads(page["property_values_json"])
- for k, v in list(props.items()):
- if isinstance(v, str) and v.startswith("20"):
- old_parts = v.split("...")
- old_end = old_parts[-1] if len(old_parts) > 1 else old_parts[0]
- try:
- old_start_d = dt_date.fromisoformat(old_parts[0][:10])
- old_end_d = dt_date.fromisoformat(old_end[:10])
- duration = (old_end_d - old_start_d).days
- new_end = new_start + timedelta(days=max(duration, 0))
- props[k] = f"{new_start.isoformat()}...{new_end.isoformat()}"
- except ValueError:
- props[k] = new_start.isoformat()
- break
-
- conn.execute(
- "UPDATE collection_pages SET property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
- (json.dumps(props), page_id),
- )
- conn.commit()
-
- return {"page_id": page_id, "shifted": True, "new_start": new_start.isoformat(), "skip_weekends": skip_weekends}
-
-
-# ── {collection_id} wildcards (LAST — catches everything else) ──
-
-
-@router.get("/{collection_id}/dashboards/{dashboard_id}", response_class=HTMLResponse)
-def view_dashboard(request: Request, collection_id: int, dashboard_id: int):
- """v7.3.0: render a collection dashboard grid (multi-DB widgets).
-
- Widgets live in ``collection_dashboards.layout_json`` as
- ``{"columns": N, "widgets": [{collection_id?, view_type?, chart_type?,
- chart_property?, aggregate?, title?, width?, height?}]}``. Each widget may
- point at *any* database (the dashboard's own collection is the default),
- which is what "dashboards multi-DB" means.
- """
- uid = _session_user(request)
- _require_view(collection_id, uid)
- with get_conn() as conn:
- dash = conn.execute(
- "SELECT * FROM collection_dashboards WHERE id=? AND collection_id=?",
- (dashboard_id, collection_id)).fetchone()
- if not dash:
- raise HTTPException(404, "Dashboard not found")
- layout = json.loads(dash["layout_json"] or "{}")
- columns = max(1, int(layout.get("columns", 1) or 1))
- widgets = layout.get("widgets", []) or []
- if not isinstance(widgets, list):
- widgets = []
-
- rendered = []
- for w in widgets[:40]:
- if not isinstance(w, dict):
- continue
- wc = int(w.get("collection_id") or 0) or collection_id
- with get_conn() as conn:
- coll = conn.execute("SELECT * FROM collections WHERE id=?", (wc,)).fetchone()
- if not coll:
- continue
- try:
- _require_view(wc, uid)
- except HTTPException:
- continue # restricted database → widget skipped, not rendered
- with get_conn() as conn:
- wpages = conn.execute(
- "SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position LIMIT ?",
- (wc, CHART_MAX_GROUPS)).fetchall()
- wconfig = {k: v for k, v in w.items()
- if k in ("chart_type", "chart_property", "aggregate", "title")}
- view_type = w.get("view_type") or "chart"
- if view_type == "chart":
- body = _render_chart(view_type, dict(coll), [dict(p) for p in wpages], wconfig)
- else:
- body = _render_view(view_type, dict(coll), [dict(p) for p in wpages], wconfig)
- width = int(w.get("width") or 0)
- span = f"grid-column: span {width};" if width and width > 0 else ""
- rendered.append(f'{body}
')
-
- grid_css = f"grid-template-columns: repeat({columns}, minmax(0, 1fr));"
- body = f"""
-
-{_htmlmod.escape(dash['name'])}
-{''.join(rendered) if rendered else '
Empty dashboard — add widgets to layout_json.
'}
-"""
- return HTMLResponse(_base_html(dash["name"], "📊", "dashboard", body))
-
-
-@router.get("/{collection_id}", response_class=HTMLResponse)
-@router.get("/{collection_id}/view/{view_type}", response_class=HTMLResponse)
-def view_collection(request: Request, collection_id: int, view_type: str = "table"):
- """Main view — renders collection in the requested view type."""
- # v6.0.0: granular collection permissions — hide restricted collections.
- _require_view(collection_id, _session_user(request))
- with get_conn() as conn:
- collection = conn.execute(
- "SELECT * FROM collections WHERE id=?", (collection_id,)
- ).fetchone()
- if not collection:
- raise HTTPException(status_code=404, detail="Collection not found")
-
- view = conn.execute(
- "SELECT * FROM collection_views WHERE collection_id=? AND view_type=? ORDER BY position LIMIT 1",
- (collection_id, view_type),
- ).fetchone()
- if not view:
- view = conn.execute(
- "SELECT * FROM collection_views WHERE collection_id=? ORDER BY position LIMIT 1",
- (collection_id,),
- ).fetchone()
-
- pages = conn.execute(
- "SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position",
- (collection_id,),
- ).fetchall()
-
- collection_dict = dict(collection)
- pages_list = [dict(p) for p in pages]
- config = json.loads(view["config_json"]) if view else {}
-
- if "year" in request.query_params:
- config["year"] = int(request.query_params["year"])
- if "month" in request.query_params:
- config["month"] = int(request.query_params["month"])
-
- return HTMLResponse(_render_view(view_type, collection_dict, pages_list, config))
-
-
-# ── View renderers (v1.6.0) ──
-
-def _render_view(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
- if view_type == "calendar":
- return _render_calendar(view_type, collection, pages, config)
- elif view_type == "gallery":
- return _render_gallery(view_type, collection, pages, config)
- elif view_type == "list":
- return _render_list(view_type, collection, pages, config)
- elif view_type == "timeline":
- return _render_timeline(view_type, collection, pages, config)
- elif view_type == "chart":
- return _render_chart(view_type, collection, pages, config)
- elif view_type == "form":
- return _render_form(view_type, collection, pages, config)
- elif view_type == "map":
- return _render_map(view_type, collection, pages, config)
- elif view_type == "feed":
- return _render_feed(view_type, collection, pages, config)
- elif view_type == "gantt":
- return _render_gantt(view_type, collection, pages, config)
- else:
- return _render_table(view_type, collection, pages, config)
-
-
-def _base_html(title: str, icon: str, view_type: str, body: str) -> str:
- return f"""
-{title} — FlowDeck
-
-{icon} {title}
-
-{body}
-"""
-
-
-def _render_calendar(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
- from datetime import date as dt_date
- from datetime import timedelta
- today = dt_date.today()
- # Determine month/year from config or current
- year = config.get("year", today.year)
- month = config.get("month", today.month)
- first = dt_date(year, month, 1)
- # Start from Monday of first week
- start = first - timedelta(days=first.weekday())
- days_in_month = []
- for i in range(42): # 6 weeks
- d = start + timedelta(days=i)
- days_in_month.append(d)
-
- # Map pages to dates
- date_pages: dict[str, list[dict]] = {}
- for p in pages:
- props = json.loads(p.get("property_values_json", "{}"))
- for v in props.values():
- if isinstance(v, str) and v.startswith("20"):
- d = v[:10]
- date_pages.setdefault(d, []).append(p)
- break
-
- cells = ""
- for d in days_in_month:
- iso = d.isoformat()
- items = date_pages.get(iso, [])
- other_month = " other-month" if d.month != month else ""
- today_class = " today" if d == today else ""
- items_html = "".join(
- f"{p.get('icon','📄')} {p['title'][:20]}
"
- for p in items
- )
- cells += f"{d.day} {items_html}
"
-
- prev = first - timedelta(days=1)
- next_month = first + timedelta(days=32)
- next_month = next_month.replace(day=1)
-
- return _base_html(collection["name"], collection.get("icon", "📅"), view_type, f"""
-
-
-
-
-
-{cells}
-
-{len(pages)} pages in collection
-""")
-
-
-def _render_gallery(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
- card_size = config.get("card_size", "medium")
- size_css = {"small": "160px", "medium": "220px", "large": "300px"}.get(card_size, "220px")
-
- cards = ""
- for p in pages:
- props = json.loads(p.get("property_values_json", "{}"))
- cover_url = config.get("cover_property")
- cover_html = ""
- if cover_url:
- for _k, v in props.items():
- if isinstance(v, list) and len(v) > 0:
- url = v[0].get("url", "") if isinstance(v[0], dict) else str(v[0])
- if url.startswith("http"):
- cover_html = f"
"
- break
-
- prop_tags = "".join(
- f"{str(v)[:30]} "
- for v in list(props.values())[:3] if v
- )
-
- cards += f"""
-{cover_html}
-
-
{p.get('icon','📄')} {p['title']}
-
{prop_tags}
-
-
"""
-
- return _base_html(collection["name"], collection.get("icon", "🖼️"), view_type, f"""
-
-{cards}
-{len(pages)} cards
-""")
-
-
-def _render_list(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
- items = ""
- for p in pages:
- props = json.loads(p.get("property_values_json", "{}"))
- preview = " · ".join(str(v)[:60] for v in list(props.values())[:3] if v)
- items += f"""
-
{p.get('icon','📄')}
-
-
{p['title']}
-
{preview}
-
-
"""
-
- return _base_html(collection["name"], collection.get("icon", "📝"), view_type, f"""
-
-{items}
-{len(pages)} items
-""")
-
-
-def _render_timeline(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
- # Find date range
- dates = []
- page_dates = []
- for p in pages:
- props = json.loads(p.get("property_values_json", "{}"))
- start_val = end_val = None
- for _k, v in props.items():
- if isinstance(v, str) and v.startswith("20"):
- if "..." in v:
- parts = v.split("...")
- start_val, end_val = parts[0][:10], parts[1][:10] if len(parts) > 1 else parts[0][:10]
- else:
- start_val = end_val = v[:10]
- break
- if start_val:
- dates.append(start_val)
- if end_val:
- dates.append(end_val)
- page_dates.append((p, start_val, end_val or start_val))
-
- if not dates:
- return _base_html(collection["name"], collection.get("icon", "📈"), view_type,
- "No date data to display timeline.
")
-
- from datetime import date as dt_date
- min_date = min(dt_date.fromisoformat(d) for d in dates)
- max_date = max(dt_date.fromisoformat(d) for d in dates)
- total = (max_date - min_date).days or 1
-
- bars = ""
- for p, start, end in page_dates:
- sd = dt_date.fromisoformat(start)
- ed = dt_date.fromisoformat(end)
- left = (sd - min_date).days / total * 100
- width = max((ed - sd).days / total * 100, 1)
- bars += f"""
-
{p.get('icon','📄')} {p['title']}
-
-
"""
-
- return _base_html(collection["name"], collection.get("icon", "📈"), view_type, f"""
-
-
-{bars}
-{len(pages)} items · {min_date} → {max_date}
-""")
-
-
-# ── v4.3.0: New view types ──
-
-# Multi-collection dashboards and chart aggregations cap the number of
-# input rows/groups at 200 (keeps the rendered HTML and export reasonable).
-CHART_MAX_GROUPS = 200
-
-
-def _chart_values(pages: list[dict], chart_property: str) -> list[float]:
- """Numeric values of ``chart_property`` across ``pages`` (cap 200)."""
- values: list[float] = []
- for p in pages[:CHART_MAX_GROUPS]:
- props = json.loads(p.get("property_values_json", "{}") or "{}")
- v = props.get(chart_property)
- if v is None or v == "":
- continue
- try:
- values.append(float(v))
- except (ValueError, TypeError):
- continue
- return values
-
-
-def _chart_aggregate(pages: list[dict], chart_property: str, aggregate: str) -> float:
- """Compute count|sum|avg|min|max over a property (or row count)."""
- values = _chart_values(pages, chart_property)
- if aggregate == "count":
- return float(len(pages[:CHART_MAX_GROUPS]))
- if not values:
- return 0.0
- if aggregate == "sum":
- return float(sum(values))
- if aggregate == "avg":
- return float(sum(values) / len(values))
- if aggregate == "min":
- return float(min(values))
- if aggregate == "max":
- return float(max(values))
- return 0.0
-
-
-def _fmt_number(value: float) -> str:
- if abs(value) >= 1e9:
- return f"{value / 1e9:.2f}B"
- if abs(value) >= 1e6:
- return f"{value / 1e6:.2f}M"
- if abs(value) >= 1e3:
- return f"{value / 1e3:.1f}K"
- if value == int(value):
- return str(int(value))
- return f"{value:.2f}"
-
-
-def _render_chart(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
- """Chart view — bar, line, pie, doughnut, scatter via Chart.js CDN, plus
- the v7.3.0 ``number`` KPI widget (count|sum|avg|min|max aggregate)."""
- chart_type = config.get("chart_type", "bar")
- chart_property = config.get("chart_property", "")
-
- if chart_type == "number":
- aggregate = config.get("aggregate", "sum" if chart_property else "count")
- if aggregate not in ("count", "sum", "avg", "min", "max"):
- aggregate = "sum" if chart_property else "count"
- num = _chart_aggregate(pages, chart_property, aggregate)
- label = config.get("title") or chart_property or collection["name"]
- return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""
-
-
-
{label}
-
{_fmt_number(num)}
-
{aggregate} · {len(pages[:CHART_MAX_GROUPS])} row(s)
- {' of ' + chart_property if chart_property else ''}
-
-""")
-
- labels = []
- values = []
- for p in pages[:CHART_MAX_GROUPS]:
- labels.append(str(p.get("title") or "")[:30])
- props = json.loads(p.get("property_values_json", "{}") or "{}")
- val = 0.0
- if chart_property:
- v_raw = props.get(chart_property, 0)
- try:
- val = float(v_raw) if v_raw else 0.0
- except (ValueError, TypeError):
- val = 0.0
- values.append(val)
-
- labels_json = json.dumps(labels)
- values_json = json.dumps(values)
- subtitle = (f"{len(pages[:CHART_MAX_GROUPS])} entries"
- + (f" (truncated at {CHART_MAX_GROUPS})" if len(pages) > CHART_MAX_GROUPS else ""))
-
- return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""
-
-
-
-
-{subtitle}
-""")
-
-
-def _render_form(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
- """Form view — generates an HTML form that creates new pages in the collection."""
- from app.db import get_conn
- properties = []
- with get_conn() as conn:
- props = conn.execute(
- "SELECT name, prop_type, options_json FROM collection_properties WHERE collection_id=? AND prop_type!='formula' ORDER BY position",
- (collection["id"],),
- ).fetchall()
- for p in props:
- prop_dict = dict(p)
- prop_dict["options"] = json.loads(prop_dict.get("options_json", "[]"))
- properties.append(prop_dict)
-
- fields = ""
- for prop in properties:
- name = prop["name"]
- ptype = prop["prop_type"]
- if ptype in ("text", "email", "url", "phone", "number"):
- fields += f"""{name}
"""
- elif ptype in ("select", "status"):
- options = "".join(f"{o} " for o in prop.get("options", []))
- fields += f"""{name} {options}
"""
- elif ptype == "checkbox":
- fields += f""" {name}
"""
- elif ptype == "date":
- fields += f"""{name}
"""
-
- return _base_html(collection["name"], collection.get("icon", "📝"), view_type, f"""
-
-
-
-""")
-
-
-def _render_map(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
- """Map view — displays pages with location data on Leaflet map."""
- markers = []
- for p in pages:
- props = json.loads(p.get("property_values_json", "{}"))
- lat, lng = None, None
- for _k, v in props.items():
- if isinstance(v, str) and "," in v:
- parts = v.split(",")
- try:
- lat, lng = float(parts[0].strip()), float(parts[1].strip())
- except ValueError:
- continue
- elif isinstance(v, dict):
- lat = v.get("lat")
- lng = v.get("lng")
- if lat and lng:
- markers.append({"title": p["title"], "lat": lat, "lng": lng})
-
- markers_json = json.dumps(markers)
- center_lat = markers[0]["lat"] if markers else 45.5
- center_lng = markers[0]["lng"] if markers else -73.5
-
- return _base_html(collection["name"], collection.get("icon", "🗺️"), view_type, f"""
-
-
-
-
-
-{len(markers)} location(s) mapped
-""")
-
-
-def _render_feed(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
- """Feed view — chronological feed of pages, newest first."""
- sorted_pages = sorted(pages, key=lambda p: p.get("created_at", ""), reverse=True)
- items = ""
- for p in sorted_pages:
- created = p.get("created_at", "")[:10] if p.get("created_at") else ""
- items += f"""
-
{created}
-
{p.get('icon','📄')} {p['title']}
-
"""
-
- return _base_html(collection["name"], collection.get("icon", "📰"), view_type, f"""
-
-{items}
-{len(sorted_pages)} entries
-""")
-
-
-def _render_gantt(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
- """Gantt view — timeline with dependencies and group_by support."""
- group_by = config.get("group_by", "")
-
- gantt_data = []
- for p in pages:
- props = json.loads(p.get("property_values_json", "{}"))
- group = None
- start_val = end_val = None
- for _k, v in props.items():
- if isinstance(v, str) and v.startswith("20"):
- if "→" in v:
- parts = v.split("→")
- start_val, end_val = parts[0].strip()[:10], parts[1].strip()[:10] if len(parts) > 1 else parts[0].strip()[:10]
- elif "..." in v:
- parts = v.split("...")
- start_val, end_val = parts[0][:10], parts[1][:10] if len(parts) > 1 else parts[0][:10]
- else:
- start_val = end_val = v[:10]
- break
- if group_by:
- group = str(props.get(group_by, props.get("Status", "")))[:20]
- if start_val:
- gantt_data.append({"title": p["title"], "start": start_val, "end": end_val or start_val, "group": group or ""})
-
- if not gantt_data:
- return _base_html(collection["name"], collection.get("icon", "📊"), view_type, "No date data for Gantt chart.
")
-
- from datetime import date as dt_date_2
- all_dates = [d["start"] for d in gantt_data] + [d["end"] for d in gantt_data]
- min_date = min(dt_date_2.fromisoformat(d) for d in all_dates)
- max_date = max(dt_date_2.fromisoformat(d) for d in all_dates)
- total_days = max((max_date - min_date).days, 1)
-
- groups = {}
- for d in gantt_data:
- groups.setdefault(d["group"], []).append(d)
- if not groups or all(k == "" for k in groups):
- groups = {"": gantt_data}
-
- rows = ""
- for group_name, items in sorted(groups.items()):
- if group_name:
- rows += f"{group_name} ({len(items)})
"
- for item in items:
- sd = dt_date_2.fromisoformat(item["start"])
- ed = dt_date_2.fromisoformat(item["end"])
- left = max((sd - min_date).days / total_days * 100, 0)
- width = max((ed - sd).days / total_days * 100, 1)
- rows += f"""
-
{item['title'][:30]}
-
-
{item['start']} → {item['end']}
-
"""
-
- return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""
-
-
-{rows}
-{len(gantt_data)} items · {min_date} → {max_date}
-""")
-
-
-def _render_table(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
- rows = ""
- for p in pages:
- props = json.loads(p.get("property_values_json", "{}"))
- prop_cells = "".join(f"{str(v)[:80]} " for v in list(props.values())[:4])
- rows += f"{p.get('icon','📄')} {p['title']} {prop_cells} "
-
- return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""
-
-
-{len(pages)} rows
-""")
-
-
-@router.get("/{collection_id}/api")
-def get_collection_api(request: Request, collection_id: int):
- """API: get a single collection with its pages."""
- # v6.0.0: granular collection permissions — hide restricted collections.
- _require_view(collection_id, _session_user(request))
- with get_conn() as conn:
- collection = conn.execute(
- "SELECT * FROM collections WHERE id=?", (collection_id,)
- ).fetchone()
- if not collection:
- raise HTTPException(status_code=404, detail="Collection not found")
-
- pages = conn.execute(
- "SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position",
- (collection_id,),
- ).fetchall()
- views = conn.execute(
- "SELECT * FROM collection_views WHERE collection_id=? ORDER BY position",
- (collection_id,),
- ).fetchall()
-
- return {
- "collection": dict(collection),
- "pages": [dict(p) for p in pages],
- "views": [dict(v) for v in views],
- }
-
-
-@router.post("/{collection_id}/pages/api")
-def create_page_api(request: Request, collection_id: int, body: dict = Body(default={})):
- """API: create a page in a collection."""
- # v6.0.0: granular collection permissions — viewer/commenter cannot create.
- _require_view(collection_id, _session_user(request))
- _require_edit(collection_id, _session_user(request))
-
- title = body.get("title", "").strip()
- if not title:
- raise HTTPException(status_code=400, detail="title is required")
-
- icon = body.get("icon", "📄")
- property_values = body.get("properties", {})
- cover_url = body.get("cover_url", "")
- gitea_issue_id = body.get("gitea_issue_id")
- gitea_issue_number = body.get("gitea_issue_number")
-
- with get_conn() as conn:
- coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
- if not coll:
- raise HTTPException(status_code=404, detail="Collection not found")
-
- _validate_page_properties(conn, collection_id, property_values)
- _validate_meta_keys(conn, collection_id, property_values)
- apply_auto_properties(
- _collection_properties(conn, collection_id),
- property_values,
- _current_user(request),
- is_create=True,
- )
-
- max_pos = conn.execute(
- "SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?",
- (collection_id,),
- ).fetchone()[0]
-
- cur = conn.execute(
- """INSERT INTO collection_pages
- (collection_id, title, icon, cover_url, position, gitea_issue_id, gitea_issue_number, property_values_json)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
- (collection_id, title, icon, cover_url, max_pos, gitea_issue_id, gitea_issue_number,
- json.dumps(property_values)),
- )
- conn.commit()
- page_id = cur.lastrowid
-
- run_event_sync(fire_event("page.created", {
- "page_id": page_id,
- "collection_id": collection_id,
- "title": title,
- "icon": icon,
- "properties": property_values,
- }))
- run_event_sync(fire_event("collection.page.created", {
- "page_id": page_id,
- "collection_id": collection_id,
- "title": title,
- }))
- return {"id": page_id, "title": title, "status": "created"}
diff --git a/app/routers/collections/__init__.py b/app/routers/collections/__init__.py
new file mode 100644
index 0000000..22eec95
--- /dev/null
+++ b/app/routers/collections/__init__.py
@@ -0,0 +1,58 @@
+"""FlowDeck — Collections : bases de données façon Notion.
+
+Découpe A28 : l'ancien `collections.py` (2 622 lignes, 53 routes) est
+devenu ce package — un module par concern, helpers dans `_common`
+(auth/permissions/validation) et `_renderers` (rendus HTML des vues).
+Ré-exports : automations importe `_validate_page_properties`, les
+tests importent les helpers de graphes.
+"""
+from __future__ import annotations
+
+import logging
+
+from fastapi import APIRouter
+
+from . import ( # ordre = ordre d'enregistrement d'origine
+ boards,
+ crud,
+ dashboard_views,
+ data_api,
+ linked,
+ meta,
+ pages,
+ properties,
+ structure,
+ views,
+)
+from ._common import _validate_page_properties # noqa: F401
+from ._renderers import ( # noqa: F401 — ré-exports tests
+ _chart_aggregate,
+ _chart_values,
+ _fmt_number,
+ _render_chart,
+)
+
+logger = logging.getLogger(__name__)
+router = APIRouter()
+for _mod in (
+ crud,
+ pages,
+ boards,
+ meta,
+ properties,
+ views,
+ structure,
+ linked,
+ dashboard_views,
+ data_api,
+):
+ router.include_router(_mod.router)
+
+__all__ = [
+ "router",
+ "_chart_aggregate",
+ "_chart_values",
+ "_fmt_number",
+ "_render_chart",
+ "_validate_page_properties",
+]
diff --git a/app/routers/collections/_common.py b/app/routers/collections/_common.py
new file mode 100644
index 0000000..2a52294
--- /dev/null
+++ b/app/routers/collections/_common.py
@@ -0,0 +1,220 @@
+"""FlowDeck — Collections : helpers partagés (A28).
+
+Les 8 helpers de tête de l'ancien collections.py (auth, permissions,
+validation) — ré-exportés par le package.
+"""
+from __future__ import annotations
+
+import logging
+
+from fastapi import APIRouter, HTTPException, Request
+
+from app.auth.session import SessionManager
+from app.services.permission_manager import PermissionManager
+from app.services.property_types import (
+ AUTO_TYPES,
+ validate_property_rule,
+)
+from app.services.recurrence import (
+ RECURRENCE_KEY,
+ is_valid_timezone,
+ validate_rule,
+)
+from app.services.reminders import REMINDER_KEY, parse_lead
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(tags=["collections"], prefix="/db")
+
+
+
+def _current_user(request: Request) -> dict:
+ """Resolve the session user, falling back to the local admin (single-user)."""
+ s = request.cookies.get("flowdeck_session", "")
+ return SessionManager.decode_session(s) or {"login": "admin", "id": 1}
+
+
+
+
+
+
+def _session_user(request: Request) -> dict | None:
+ """Resolve the session user WITHOUT the admin fallback (for ACL checks)."""
+ s = request.cookies.get("flowdeck_session", "")
+ user = SessionManager.decode_session(s)
+ return user if user and user.get("id") else None
+
+
+
+
+
+
+def _require_view(collection_id: int, user: dict | None) -> None:
+ """Raise 404 when the user may not view the collection (404 hides it).
+
+ A6 : plus de session = accès refusé — l'absence de user ne vaut plus
+ « legacy single-user » ( lecture anonyme de n'importe quelle collection ).
+ """
+ if not user:
+ raise HTTPException(status_code=404, detail="Collection not found")
+ pm = PermissionManager(user["id"])
+ if not pm.can_view_collection(collection_id):
+ raise HTTPException(status_code=404, detail="Collection not found")
+
+
+
+
+
+
+def _require_edit(collection_id: int, user: dict | None) -> None:
+ """Raise 401/403 when the user may not edit pages in the collection."""
+ if not user:
+ raise HTTPException(status_code=401, detail="Authentication required")
+ pm = PermissionManager(user["id"])
+ if not pm.can_edit_collection(collection_id):
+ raise HTTPException(status_code=403, detail="You don't have edit access to this collection")
+
+
+
+
+
+
+def _collection_properties(conn, collection_id: int) -> list[dict]:
+ return [
+ dict(r) for r in conn.execute(
+ "SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
+ (collection_id,),
+ ).fetchall()
+ ]
+
+
+
+
+
+def _apply_template(conn, template_name: str) -> dict | None:
+ """Resolve a database template by name (from the seeded/built-in set)."""
+ if not template_name:
+ return None
+ row = conn.execute(
+ "SELECT id, name, icon, description, schema_json FROM database_templates WHERE name=?",
+ (template_name,),
+ ).fetchone()
+ if row:
+ return dict(row)
+ return None
+
+
+
+
+
+
+def _validate_page_properties(conn, collection_id: int, properties: dict, exclude_page_id: int | None = None) -> None:
+ """Validate submitted property values against the collection's schema.
+
+ Raises ``HTTPException(400)`` with a user-friendly message on the first
+ failure (type, required, unique, min/max).
+ """
+ props = conn.execute(
+ "SELECT * FROM collection_properties WHERE collection_id=?", (collection_id,)
+ ).fetchall()
+
+ for prop in props:
+ ptype = prop["prop_type"]
+ if ptype == "title" or ptype in AUTO_TYPES:
+ continue
+ pid = prop["id"]
+ # Values may be keyed by property id (FlowDeckDB UI) or by name (agent).
+ value = properties.get(str(pid))
+ if value is None:
+ value = properties.get(prop["name"])
+ validation = prop["validation_json"] if "validation_json" in prop.keys() else "{}"
+
+ existing_values = None
+ try:
+ import json as _json
+ vcfg = _json.loads(validation) if validation else {}
+ except Exception:
+ vcfg = {}
+ if vcfg.get("unique"):
+ rows = conn.execute(
+ "SELECT id, property_values_json FROM collection_pages WHERE collection_id=?",
+ (collection_id,),
+ ).fetchall()
+ existing_values = []
+ for r in rows:
+ if exclude_page_id is not None and r["id"] == exclude_page_id:
+ continue
+ try:
+ pv = _json.loads(r["property_values_json"] or "{}")
+ except Exception:
+ pv = {}
+ existing_values.append(pv.get(str(pid)) or pv.get(prop["name"]))
+
+ ok, msg = validate_property_rule(ptype, value, validation, existing_values=existing_values)
+ if not ok:
+ raise HTTPException(status_code=400, detail=f"Property '{prop['name']}': {msg}")
+
+
+
+
+
+
+def _validate_meta_keys(conn, collection_id: int, properties: dict) -> None:
+ """Validate the ``__recurrence__`` / ``__reminder__`` meta keys stored
+ alongside real property values. Raises HTTPException(400) on bad shape.
+
+ Each meta key maps a date-property id to a rule/reminder object. We verify
+ the target is actually a date property and the payload parses.
+ """
+ date_ids = {
+ str(r["id"]) for r in conn.execute(
+ "SELECT id FROM collection_properties WHERE collection_id=? AND prop_type='date'",
+ (collection_id,),
+ ).fetchall()
+ }
+
+ rec = properties.get(RECURRENCE_KEY)
+ if rec not in (None, {}):
+ if not isinstance(rec, dict):
+ raise HTTPException(status_code=400, detail="Recurrence must be an object")
+ for prop_id, rule in rec.items():
+ if rule is None:
+ continue
+ if str(prop_id) not in date_ids:
+ raise HTTPException(status_code=400, detail="Recurrence target must be a date property")
+ ok, msg = validate_rule(rule)
+ if not ok:
+ raise HTTPException(status_code=400, detail=f"Recurrence: {msg}")
+
+ rem = properties.get(REMINDER_KEY)
+ if rem not in (None, {}):
+ if not isinstance(rem, dict):
+ raise HTTPException(status_code=400, detail="Reminder must be an object")
+ for prop_id, reminder in rem.items():
+ if reminder is None:
+ continue
+ if str(prop_id) not in date_ids:
+ raise HTTPException(status_code=400, detail="Reminder target must be a date property")
+ if not isinstance(reminder, dict):
+ raise HTTPException(status_code=400, detail="Reminder must be an object")
+ if reminder.get("unit") not in (None, "none", "minutes", "hours", "days"):
+ raise HTTPException(status_code=400, detail="Reminder unit must be minutes/hours/days/none")
+ if parse_lead(reminder) is None and reminder.get("unit") != "none":
+ raise HTTPException(status_code=400, detail="Reminder value must be a positive integer")
+
+ from app.services.recurrence import TIMEZONE_KEY
+ tzmap = properties.get(TIMEZONE_KEY)
+ if tzmap not in (None, {}):
+ if not isinstance(tzmap, dict):
+ raise HTTPException(status_code=400, detail="Timezone map must be an object")
+ for prop_id, value in tzmap.items():
+ if str(prop_id) not in date_ids:
+ raise HTTPException(status_code=400, detail="Timezone target must be a date property")
+ if value and not is_valid_timezone(str(value)):
+ raise HTTPException(status_code=400, detail=f"Unknown timezone '{value}'")
+
+
+# ── API: List & Create (no path params) ──
+
+
+
diff --git a/app/routers/collections/_renderers.py b/app/routers/collections/_renderers.py
new file mode 100644
index 0000000..b38d1c5
--- /dev/null
+++ b/app/routers/collections/_renderers.py
@@ -0,0 +1,667 @@
+"""FlowDeck — Collections : rendus HTML des vues (A28).
+
+Les 15 helpers de rendu de l'ancien collections.py (_render_view,
+_render_chart, …) + CHART_MAX_GROUPS — ré-exportés pour les tests.
+"""
+from __future__ import annotations
+
+import json
+
+from app.db import get_conn
+from app.templating import CSP_NONCE
+
+CHART_MAX_GROUPS = 200
+
+
+
+# ── View renderers (v1.6.0) ──
+
+def _render_view(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
+ if view_type == "calendar":
+ return _render_calendar(view_type, collection, pages, config)
+ elif view_type == "gallery":
+ return _render_gallery(view_type, collection, pages, config)
+ elif view_type == "list":
+ return _render_list(view_type, collection, pages, config)
+ elif view_type == "timeline":
+ return _render_timeline(view_type, collection, pages, config)
+ elif view_type == "chart":
+ return _render_chart(view_type, collection, pages, config)
+ elif view_type == "form":
+ return _render_form(view_type, collection, pages, config)
+ elif view_type == "map":
+ return _render_map(view_type, collection, pages, config)
+ elif view_type == "feed":
+ return _render_feed(view_type, collection, pages, config)
+ elif view_type == "gantt":
+ return _render_gantt(view_type, collection, pages, config)
+ else:
+ return _render_table(view_type, collection, pages, config)
+
+
+
+
+
+
+def _base_html(title: str, icon: str, view_type: str, body: str) -> str:
+ return f"""
+{title} — FlowDeck
+
+{icon} {title}
+
+{body}
+"""
+
+
+
+
+
+
+def _render_calendar(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
+ from datetime import date as dt_date
+ from datetime import timedelta
+ today = dt_date.today()
+ # Determine month/year from config or current
+ year = config.get("year", today.year)
+ month = config.get("month", today.month)
+ first = dt_date(year, month, 1)
+ # Start from Monday of first week
+ start = first - timedelta(days=first.weekday())
+ days_in_month = []
+ for i in range(42): # 6 weeks
+ d = start + timedelta(days=i)
+ days_in_month.append(d)
+
+ # Map pages to dates
+ date_pages: dict[str, list[dict]] = {}
+ for p in pages:
+ props = json.loads(p.get("property_values_json", "{}"))
+ for v in props.values():
+ if isinstance(v, str) and v.startswith("20"):
+ d = v[:10]
+ date_pages.setdefault(d, []).append(p)
+ break
+
+ cells = ""
+ for d in days_in_month:
+ iso = d.isoformat()
+ items = date_pages.get(iso, [])
+ other_month = " other-month" if d.month != month else ""
+ today_class = " today" if d == today else ""
+ items_html = "".join(
+ f"{p.get('icon','📄')} {p['title'][:20]}
"
+ for p in items
+ )
+ cells += f"{d.day} {items_html}
"
+
+ prev = first - timedelta(days=1)
+ next_month = first + timedelta(days=32)
+ next_month = next_month.replace(day=1)
+
+ return _base_html(collection["name"], collection.get("icon", "📅"), view_type, f"""
+
+
+
+
+
+{cells}
+
+{len(pages)} pages in collection
+""")
+
+
+
+
+
+
+def _render_gallery(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
+ card_size = config.get("card_size", "medium")
+ size_css = {"small": "160px", "medium": "220px", "large": "300px"}.get(card_size, "220px")
+
+ cards = ""
+ for p in pages:
+ props = json.loads(p.get("property_values_json", "{}"))
+ cover_url = config.get("cover_property")
+ cover_html = ""
+ if cover_url:
+ for _k, v in props.items():
+ if isinstance(v, list) and len(v) > 0:
+ url = v[0].get("url", "") if isinstance(v[0], dict) else str(v[0])
+ if url.startswith("http"):
+ cover_html = f"
"
+ break
+
+ prop_tags = "".join(
+ f"{str(v)[:30]} "
+ for v in list(props.values())[:3] if v
+ )
+
+ cards += f"""
+{cover_html}
+
+
{p.get('icon','📄')} {p['title']}
+
{prop_tags}
+
+
"""
+
+ return _base_html(collection["name"], collection.get("icon", "🖼️"), view_type, f"""
+
+{cards}
+{len(pages)} cards
+""")
+
+
+
+
+
+
+def _render_list(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
+ items = ""
+ for p in pages:
+ props = json.loads(p.get("property_values_json", "{}"))
+ preview = " · ".join(str(v)[:60] for v in list(props.values())[:3] if v)
+ items += f"""
+
{p.get('icon','📄')}
+
+
{p['title']}
+
{preview}
+
+
"""
+
+ return _base_html(collection["name"], collection.get("icon", "📝"), view_type, f"""
+
+{items}
+{len(pages)} items
+""")
+
+
+
+
+
+
+def _render_timeline(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
+ # Find date range
+ dates = []
+ page_dates = []
+ for p in pages:
+ props = json.loads(p.get("property_values_json", "{}"))
+ start_val = end_val = None
+ for _k, v in props.items():
+ if isinstance(v, str) and v.startswith("20"):
+ if "..." in v:
+ parts = v.split("...")
+ start_val, end_val = parts[0][:10], parts[1][:10] if len(parts) > 1 else parts[0][:10]
+ else:
+ start_val = end_val = v[:10]
+ break
+ if start_val:
+ dates.append(start_val)
+ if end_val:
+ dates.append(end_val)
+ page_dates.append((p, start_val, end_val or start_val))
+
+ if not dates:
+ return _base_html(collection["name"], collection.get("icon", "📈"), view_type,
+ "No date data to display timeline.
")
+
+ from datetime import date as dt_date
+ min_date = min(dt_date.fromisoformat(d) for d in dates)
+ max_date = max(dt_date.fromisoformat(d) for d in dates)
+ total = (max_date - min_date).days or 1
+
+ bars = ""
+ for p, start, end in page_dates:
+ sd = dt_date.fromisoformat(start)
+ ed = dt_date.fromisoformat(end)
+ left = (sd - min_date).days / total * 100
+ width = max((ed - sd).days / total * 100, 1)
+ bars += f"""
+
{p.get('icon','📄')} {p['title']}
+
+
"""
+
+ return _base_html(collection["name"], collection.get("icon", "📈"), view_type, f"""
+
+
+{bars}
+{len(pages)} items · {min_date} → {max_date}
+""")
+
+
+# ── v4.3.0: New view types ──
+
+# Multi-collection dashboards and chart aggregations cap the number of
+# input rows/groups at 200 (keeps the rendered HTML and export reasonable).
+
+
+
+
+def _chart_values(pages: list[dict], chart_property: str) -> list[float]:
+ """Numeric values of ``chart_property`` across ``pages`` (cap 200)."""
+ values: list[float] = []
+ for p in pages[:CHART_MAX_GROUPS]:
+ props = json.loads(p.get("property_values_json", "{}") or "{}")
+ v = props.get(chart_property)
+ if v is None or v == "":
+ continue
+ try:
+ values.append(float(v))
+ except (ValueError, TypeError):
+ continue
+ return values
+
+
+
+
+
+
+def _chart_aggregate(pages: list[dict], chart_property: str, aggregate: str) -> float:
+ """Compute count|sum|avg|min|max over a property (or row count)."""
+ values = _chart_values(pages, chart_property)
+ if aggregate == "count":
+ return float(len(pages[:CHART_MAX_GROUPS]))
+ if not values:
+ return 0.0
+ if aggregate == "sum":
+ return float(sum(values))
+ if aggregate == "avg":
+ return float(sum(values) / len(values))
+ if aggregate == "min":
+ return float(min(values))
+ if aggregate == "max":
+ return float(max(values))
+ return 0.0
+
+
+
+
+
+
+def _fmt_number(value: float) -> str:
+ if abs(value) >= 1e9:
+ return f"{value / 1e9:.2f}B"
+ if abs(value) >= 1e6:
+ return f"{value / 1e6:.2f}M"
+ if abs(value) >= 1e3:
+ return f"{value / 1e3:.1f}K"
+ if value == int(value):
+ return str(int(value))
+ return f"{value:.2f}"
+
+
+
+
+
+
+def _render_chart(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
+ """Chart view — bar, line, pie, doughnut, scatter via Chart.js CDN, plus
+ the v7.3.0 ``number`` KPI widget (count|sum|avg|min|max aggregate)."""
+ chart_type = config.get("chart_type", "bar")
+ chart_property = config.get("chart_property", "")
+
+ if chart_type == "number":
+ aggregate = config.get("aggregate", "sum" if chart_property else "count")
+ if aggregate not in ("count", "sum", "avg", "min", "max"):
+ aggregate = "sum" if chart_property else "count"
+ num = _chart_aggregate(pages, chart_property, aggregate)
+ label = config.get("title") or chart_property or collection["name"]
+ return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""
+
+
+
{label}
+
{_fmt_number(num)}
+
{aggregate} · {len(pages[:CHART_MAX_GROUPS])} row(s)
+ {' of ' + chart_property if chart_property else ''}
+
+""")
+
+ labels = []
+ values = []
+ for p in pages[:CHART_MAX_GROUPS]:
+ labels.append(str(p.get("title") or "")[:30])
+ props = json.loads(p.get("property_values_json", "{}") or "{}")
+ val = 0.0
+ if chart_property:
+ v_raw = props.get(chart_property, 0)
+ try:
+ val = float(v_raw) if v_raw else 0.0
+ except (ValueError, TypeError):
+ val = 0.0
+ values.append(val)
+
+ labels_json = json.dumps(labels)
+ values_json = json.dumps(values)
+ subtitle = (f"{len(pages[:CHART_MAX_GROUPS])} entries"
+ + (f" (truncated at {CHART_MAX_GROUPS})" if len(pages) > CHART_MAX_GROUPS else ""))
+
+ return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""
+
+
+
+
+{subtitle}
+""")
+
+
+
+
+
+
+def _render_form(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
+ """Form view — generates an HTML form that creates new pages in the collection."""
+ properties = []
+ with get_conn() as conn:
+ props = conn.execute(
+ "SELECT name, prop_type, options_json FROM collection_properties WHERE collection_id=? AND prop_type!='formula' ORDER BY position",
+ (collection["id"],),
+ ).fetchall()
+ for p in props:
+ prop_dict = dict(p)
+ prop_dict["options"] = json.loads(prop_dict.get("options_json", "[]"))
+ properties.append(prop_dict)
+
+ fields = ""
+ for prop in properties:
+ name = prop["name"]
+ ptype = prop["prop_type"]
+ if ptype in ("text", "email", "url", "phone", "number"):
+ fields += f"""{name}
"""
+ elif ptype in ("select", "status"):
+ options = "".join(f"{o} " for o in prop.get("options", []))
+ fields += f"""{name} {options}
"""
+ elif ptype == "checkbox":
+ fields += f""" {name}
"""
+ elif ptype == "date":
+ fields += f"""{name}
"""
+
+ return _base_html(collection["name"], collection.get("icon", "📝"), view_type, f"""
+
+
+
+""")
+
+
+
+
+
+
+def _render_map(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
+ """Map view — displays pages with location data on Leaflet map."""
+ markers = []
+ for p in pages:
+ props = json.loads(p.get("property_values_json", "{}"))
+ lat, lng = None, None
+ for _k, v in props.items():
+ if isinstance(v, str) and "," in v:
+ parts = v.split(",")
+ try:
+ lat, lng = float(parts[0].strip()), float(parts[1].strip())
+ except ValueError:
+ continue
+ elif isinstance(v, dict):
+ lat = v.get("lat")
+ lng = v.get("lng")
+ if lat and lng:
+ markers.append({"title": p["title"], "lat": lat, "lng": lng})
+
+ markers_json = json.dumps(markers)
+ center_lat = markers[0]["lat"] if markers else 45.5
+ center_lng = markers[0]["lng"] if markers else -73.5
+
+ return _base_html(collection["name"], collection.get("icon", "🗺️"), view_type, f"""
+
+
+
+
+
+{len(markers)} location(s) mapped
+""")
+
+
+
+
+
+
+def _render_feed(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
+ """Feed view — chronological feed of pages, newest first."""
+ sorted_pages = sorted(pages, key=lambda p: p.get("created_at", ""), reverse=True)
+ items = ""
+ for p in sorted_pages:
+ created = p.get("created_at", "")[:10] if p.get("created_at") else ""
+ items += f"""
+
{created}
+
{p.get('icon','📄')} {p['title']}
+
"""
+
+ return _base_html(collection["name"], collection.get("icon", "📰"), view_type, f"""
+
+{items}
+{len(sorted_pages)} entries
+""")
+
+
+
+
+
+
+def _render_gantt(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
+ """Gantt view — timeline with dependencies and group_by support."""
+ group_by = config.get("group_by", "")
+
+ gantt_data = []
+ for p in pages:
+ props = json.loads(p.get("property_values_json", "{}"))
+ group = None
+ start_val = end_val = None
+ for _k, v in props.items():
+ if isinstance(v, str) and v.startswith("20"):
+ if "→" in v:
+ parts = v.split("→")
+ start_val, end_val = parts[0].strip()[:10], parts[1].strip()[:10] if len(parts) > 1 else parts[0].strip()[:10]
+ elif "..." in v:
+ parts = v.split("...")
+ start_val, end_val = parts[0][:10], parts[1][:10] if len(parts) > 1 else parts[0][:10]
+ else:
+ start_val = end_val = v[:10]
+ break
+ if group_by:
+ group = str(props.get(group_by, props.get("Status", "")))[:20]
+ if start_val:
+ gantt_data.append({"title": p["title"], "start": start_val, "end": end_val or start_val, "group": group or ""})
+
+ if not gantt_data:
+ return _base_html(collection["name"], collection.get("icon", "📊"), view_type, "No date data for Gantt chart.
")
+
+ from datetime import date as dt_date_2
+ all_dates = [d["start"] for d in gantt_data] + [d["end"] for d in gantt_data]
+ min_date = min(dt_date_2.fromisoformat(d) for d in all_dates)
+ max_date = max(dt_date_2.fromisoformat(d) for d in all_dates)
+ total_days = max((max_date - min_date).days, 1)
+
+ groups = {}
+ for d in gantt_data:
+ groups.setdefault(d["group"], []).append(d)
+ if not groups or all(k == "" for k in groups):
+ groups = {"": gantt_data}
+
+ rows = ""
+ for group_name, items in sorted(groups.items()):
+ if group_name:
+ rows += f"{group_name} ({len(items)})
"
+ for item in items:
+ sd = dt_date_2.fromisoformat(item["start"])
+ ed = dt_date_2.fromisoformat(item["end"])
+ left = max((sd - min_date).days / total_days * 100, 0)
+ width = max((ed - sd).days / total_days * 100, 1)
+ rows += f"""
+
{item['title'][:30]}
+
+
{item['start']} → {item['end']}
+
"""
+
+ return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""
+
+
+{rows}
+{len(gantt_data)} items · {min_date} → {max_date}
+""")
+
+
+
+
+
+
+def _render_table(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
+ rows = ""
+ for p in pages:
+ props = json.loads(p.get("property_values_json", "{}"))
+ prop_cells = "".join(f"{str(v)[:80]} " for v in list(props.values())[:4])
+ rows += f"{p.get('icon','📄')} {p['title']} {prop_cells} "
+
+ return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""
+
+
+{len(pages)} rows
+""")
+
+
+
diff --git a/app/routers/collections/boards.py b/app/routers/collections/boards.py
new file mode 100644
index 0000000..24ad0ab
--- /dev/null
+++ b/app/routers/collections/boards.py
@@ -0,0 +1,61 @@
+"""FlowDeck — Collections : boards.
+
+Découpe A28 de l'ancien app/routers/collections.py (2 622 lignes, 53 routes) — un module par concern, contrat inchangé.
+"""
+from __future__ import annotations
+
+import logging
+
+from fastapi import APIRouter, HTTPException, Request
+
+logger = logging.getLogger(__name__)
+router = APIRouter(tags=["collections"], prefix="/db")
+
+
+
+
+# ── Gitea Board Compatibility (BEFORE {collection_id} wildcards) ──
+
+
+@router.get("/boards/api")
+def list_boards_as_collections(request: Request):
+ """API: list all Gitea boards as pseudo-collections."""
+ from app.services.collection_adapter import GiteaBoardCompat
+ boards = GiteaBoardCompat.list_boards_as_collections()
+ return {"boards": boards}
+
+
+
+
+@router.get("/board/{owner}/{repo}/api")
+async def get_board_as_collection(request: Request, owner: str, repo: str):
+ """API: get a specific Gitea board as a pseudo-collection."""
+ from app.services.collection_adapter import GiteaBoardCompat
+ coll = GiteaBoardCompat.get_board_as_collection(owner, repo)
+ if not coll:
+ raise HTTPException(status_code=404, detail="Board not found")
+
+ from app.services.gitea_client import gitea
+ issues = await gitea.get_issues(owner, repo, state="all")
+ cards = GiteaBoardCompat.get_board_cards(owner, repo, issues)
+
+ return {"collection": coll, "pages": cards}
+
+
+
+
+@router.post("/board/{owner}/{repo}/sync")
+async def sync_board_to_collection(request: Request, owner: str, repo: str):
+ """Sync a Gitea board to a real collection."""
+ from app.services.collection_adapter import GiteaBoardCompat
+ from app.services.gitea_client import gitea
+
+ issues = await gitea.get_issues(owner, repo, state="all")
+ coll_id = GiteaBoardCompat.sync_to_collection(owner, repo, issues)
+ if coll_id is None:
+ raise HTTPException(status_code=404, detail="Board not found")
+
+ return {"collection_id": coll_id, "status": "synced"}
+
+
+# ── Collection Properties (v1.4.0) ──
diff --git a/app/routers/collections/crud.py b/app/routers/collections/crud.py
new file mode 100644
index 0000000..09aa2e6
--- /dev/null
+++ b/app/routers/collections/crud.py
@@ -0,0 +1,337 @@
+"""FlowDeck — Collections : crud.
+
+Découpe A28 de l'ancien app/routers/collections.py (2 622 lignes, 53 routes) — un module par concern, contrat inchangé.
+"""
+from __future__ import annotations
+
+import json
+import logging
+
+from fastapi import APIRouter, Body, HTTPException, Request
+from fastapi.responses import HTMLResponse
+
+from app.db import get_conn
+from app.services.automations import fire_event, run_event_sync
+from app.services.db_templates import materialize_properties
+from app.services.permission_manager import PermissionManager
+
+from ._common import _apply_template, _require_view, _session_user
+
+logger = logging.getLogger(__name__)
+router = APIRouter(tags=["collections"], prefix="/db")
+
+
+
+
+# ── API: List & Create (no path params) ──
+
+
+@router.get("", response_class=HTMLResponse)
+def list_collections(request: Request):
+ """Page listing all collections in the workspace."""
+ with get_conn() as conn:
+ rows = conn.execute(
+ "SELECT * FROM collections ORDER BY name"
+ ).fetchall()
+ collections = [dict(r) for r in rows]
+ return HTMLResponse(
+ f""
+ f"
Collections ({len(collections)}) "
+ f"
{json.dumps(collections, indent=2, default=str)} "
+ f"
"
+ )
+
+
+
+
+@router.get("/api")
+def list_collections_api(request: Request):
+ """API: list all collections."""
+ with get_conn() as conn:
+ rows = conn.execute(
+ "SELECT * FROM collections ORDER BY name"
+ ).fetchall()
+ return {"collections": [dict(r) for r in rows]}
+
+
+
+
+@router.post("/api")
+def create_collection_api(request: Request, body: dict = Body(default={})):
+ """API: create a new collection, optionally from a database template."""
+
+ name = body.get("name", "").strip()
+ if not name:
+ raise HTTPException(status_code=400, detail="name is required")
+
+ description = body.get("description", "")
+ icon = body.get("icon", "📋")
+ gitea_owner = body.get("gitea_owner")
+ gitea_repo = body.get("gitea_repo")
+ schema = body.get("schema", [])
+ is_locked = body.get("is_locked", False)
+
+ with get_conn() as conn:
+ # Apply a template if requested (provides schema + icon).
+ tpl = _apply_template(conn, body.get("template"))
+ if tpl:
+ if body.get("name"):
+ name = body["name"].strip()
+ description = tpl["description"]
+ icon = tpl.get("icon") or icon
+ try:
+ schema = json.loads(tpl["schema_json"])
+ except (json.JSONDecodeError, TypeError):
+ schema = []
+
+ schema_json = json.dumps(schema)
+
+ cur = conn.execute(
+ """INSERT INTO collections
+ (name, description, icon, schema_json, gitea_owner, gitea_repo, is_locked)
+ VALUES (?, ?, ?, ?, ?, ?, ?)""",
+ (name, description, icon, schema_json, gitea_owner, gitea_repo, int(is_locked)),
+ )
+ collection_id = cur.lastrowid
+
+ materialize_properties(conn, collection_id, schema)
+
+ conn.execute(
+ """INSERT INTO collection_views
+ (collection_id, name, view_type, config_json)
+ VALUES (?, ?, ?, ?)""",
+ (collection_id, "Default View", "table", json.dumps({
+ "visible_properties": ["Title"],
+ "sorts": [],
+ "filters": [],
+ })),
+ )
+ conn.commit()
+
+ run_event_sync(fire_event("collection.created", {"collection_id": collection_id, "name": name, "icon": icon}))
+ return {"id": collection_id, "name": name, "status": "created"}
+
+
+# ── API: Update & Delete (no path-param conflicts) ──
+
+
+
+
+# ── API: Update & Delete (no path-param conflicts) ──
+
+
+@router.put("/api/{collection_id}")
+def update_collection_api(request: Request, collection_id: int, body: dict = Body(default={})):
+ """API: update a collection."""
+
+ with get_conn() as conn:
+ existing = conn.execute(
+ "SELECT * FROM collections WHERE id=?", (collection_id,)
+ ).fetchone()
+ if not existing:
+ raise HTTPException(status_code=404, detail="Collection not found")
+
+ name = body.get("name", existing["name"])
+ description = body.get("description", existing["description"])
+ icon = body.get("icon", existing["icon"])
+ is_locked = body.get("is_locked", existing["is_locked"])
+ schema_json = json.dumps(body.get("schema", json.loads(existing["schema_json"])))
+ gitea_owner = body.get("gitea_owner", existing["gitea_owner"])
+ gitea_repo = body.get("gitea_repo", existing["gitea_repo"])
+
+ conn.execute(
+ """UPDATE collections SET name=?, description=?, icon=?, schema_json=?,
+ is_locked=?, gitea_owner=?, gitea_repo=?, updated_at=CURRENT_TIMESTAMP
+ WHERE id=?""",
+ (name, description, icon, schema_json, int(is_locked),
+ gitea_owner, gitea_repo, collection_id),
+ )
+ conn.commit()
+
+ run_event_sync(fire_event("collection.updated", {"collection_id": collection_id, "name": name}))
+ return {"id": collection_id, "status": "updated"}
+
+
+
+
+@router.delete("/api/{collection_id}")
+def delete_collection_api(request: Request, collection_id: int):
+ """API: delete a collection and its pages (CASCADE)."""
+ # v6.0.0: granular collection permissions — owner/admin only.
+ user = _session_user(request)
+ _require_view(collection_id, user)
+ if user:
+ pm = PermissionManager(user["id"])
+ if not pm.can_manage_collection_permissions(collection_id):
+ raise HTTPException(status_code=403, detail="Only a collection owner can delete it")
+ with get_conn() as conn:
+ existing = conn.execute(
+ "SELECT * FROM collections WHERE id=?", (collection_id,)
+ ).fetchone()
+ if not existing:
+ raise HTTPException(status_code=404, detail="Collection not found")
+
+ conn.execute("DELETE FROM collections WHERE id=?", (collection_id,))
+ conn.commit()
+
+ run_event_sync(fire_event("collection.deleted", {"collection_id": collection_id,
+ "name": existing["name"] if existing else ""}))
+ return {"id": collection_id, "status": "deleted"}
+
+
+
+
+@router.post("/{collection_id}/duplicate")
+def duplicate_collection_api(request: Request, collection_id: int):
+ """v5.4.0: deep-duplicate a database (views + properties + pages + data
+ sources) into a new collection named ' (copy)'."""
+ with get_conn() as conn:
+ src = conn.execute(
+ "SELECT * FROM collections WHERE id=?", (collection_id,)
+ ).fetchone()
+ if not src:
+ raise HTTPException(status_code=404, detail="Collection not found")
+
+ new_name = (src["name"] or "Database") + " copy"
+ cur = conn.execute(
+ """INSERT INTO collections
+ (name, description, icon, schema_json, gitea_owner, gitea_repo, is_locked,
+ is_inline, parent_page_id, workspace_id, created_by, is_task, updated_at)
+ SELECT ?, description, icon, schema_json, gitea_owner, gitea_repo, is_locked,
+ is_inline, parent_page_id, workspace_id, created_by, is_task, updated_at
+ FROM collections WHERE id=?""",
+ (new_name, collection_id),
+ )
+ new_id = cur.lastrowid
+
+ # ── Properties (remap ids so relation/rollup refs stay valid) ──
+ prop_map: dict[int, int] = {}
+ rows = conn.execute(
+ "SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
+ (collection_id,),
+ ).fetchall()
+ # A23 : un seul executemany ; les rowid sont contigus (même transaction,
+ # insertion dans l'ordre de `rows`), donc le mappeur se fait par index.
+ tuples = [
+ (new_id, p["name"], p["prop_type"], p["options_json"], p["number_format"],
+ None, p["reverse_name"], None, None, p["rollup_function"],
+ p["formula_expression"], p["position"], p["required"],
+ p["visible_in_views"])
+ for p in rows
+ ]
+ if tuples:
+ ncur = conn.executemany(
+ """INSERT INTO collection_properties
+ (collection_id, name, prop_type, options_json, number_format,
+ related_collection_id, reverse_name, relation_property_id,
+ target_property_id, rollup_function, formula_expression,
+ position, required, visible_in_views)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
+ tuples,
+ )
+ new_ids = [
+ r["id"]
+ for r in conn.execute(
+ "SELECT id FROM collection_properties WHERE collection_id=? ORDER BY id",
+ (new_id,),
+ ).fetchall()
+ ]
+ assert len(new_ids) == len(tuples), "remap des propriétés : effectif inattendu"
+ for p, new_pid in zip(rows, new_ids, strict=True):
+ prop_map[p["id"]] = new_pid
+
+ # Fix cross-property references after all rows exist (creates may target
+ # columns not inserted yet). Related collection remapped to the copy.
+ for p in rows:
+ p = dict(p) # convert sqlite3.Row to dict
+ new_pid = prop_map[p["id"]]
+ related = p["related_collection_id"]
+ related_new = new_id if related == collection_id else related
+ if p["prop_type"] == "relation":
+ conn.execute(
+ "UPDATE collection_properties SET related_collection_id=? WHERE id=?",
+ (related_new, new_pid),
+ )
+ if p.get("relation_property_id") and p["relation_property_id"] in prop_map:
+ conn.execute(
+ "UPDATE collection_properties SET relation_property_id=? WHERE id=?",
+ (prop_map[p["relation_property_id"]], new_pid),
+ )
+ if p.get("target_property_id") and p["target_property_id"] in prop_map:
+ conn.execute(
+ "UPDATE collection_properties SET target_property_id=? WHERE id=?",
+ (prop_map[p["target_property_id"]], new_pid),
+ )
+
+ # ── Views ──
+ vrows = conn.execute(
+ "SELECT * FROM collection_views WHERE collection_id=? ORDER BY position",
+ (collection_id,),
+ ).fetchall()
+ for v in vrows:
+ conn.execute(
+ """INSERT INTO collection_views
+ (collection_id, name, view_type, config_json, position)
+ VALUES (?,?,?,?,?)""",
+ (new_id, v["name"], v["view_type"], v["config_json"], v["position"]),
+ )
+
+ # ── Pages (rows) with property ids remapped to the copy's properties ──
+ prows = conn.execute(
+ "SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position",
+ (collection_id,),
+ ).fetchall()
+ page_map: dict[int, int] = {}
+ for p in prows:
+ try:
+ pv = json.loads(p["property_values_json"]) if p["property_values_json"] else {}
+ except (json.JSONDecodeError, TypeError):
+ pv = {}
+ pv_new = {}
+ for k, val in pv.items():
+ try:
+ prop_id = int(k)
+ except (ValueError, TypeError):
+ prop_id = None
+ new_key = str(prop_map.get(prop_id, prop_id)) if prop_id is not None else k
+ pv_new[new_key] = val
+ ncur = conn.execute(
+ """INSERT INTO collection_pages
+ (collection_id, title, icon, position, parent_id, gitea_issue_id,
+ gitea_issue_number, property_values_json, created_at, updated_at)
+ SELECT ?, title, icon, position, NULL, NULL, NULL, ?, created_at, updated_at
+ FROM collection_pages WHERE id=?""",
+ (new_id, json.dumps(pv_new), p["id"]),
+ )
+ page_map[p["id"]] = ncur.lastrowid
+
+ # Re-parent sub-items to the copied rows.
+ for p in prows:
+ if p["parent_id"] and p["parent_id"] in page_map:
+ conn.execute(
+ "UPDATE collection_pages SET parent_id=? WHERE id=?",
+ (page_map[p["parent_id"]], page_map[p["id"]]),
+ )
+
+ # ── Data sources (linked DBs) ──
+ drows = conn.execute(
+ "SELECT * FROM collection_data_sources WHERE collection_id=?",
+ (collection_id,),
+ ).fetchall()
+ for d in drows:
+ src_coll = d["source_collection_id"]
+ src_now = new_id if src_coll == collection_id else src_coll
+ conn.execute(
+ """INSERT INTO collection_data_sources
+ (collection_id, source_collection_id, source_name, is_linked, position)
+ VALUES (?,?,?,?,?)""",
+ (new_id, src_now, d["source_name"], d["is_linked"], d["position"]),
+ )
+
+ conn.commit()
+
+ run_event_sync(fire_event("collection.created", {"collection_id": new_id, "name": new_name}))
+ return {"id": new_id, "name": new_name, "status": "duplicated"}
+
+
+# ── Page CRUD (standalone, BEFORE collection wildcards) ──
diff --git a/app/routers/collections/dashboard_views.py b/app/routers/collections/dashboard_views.py
new file mode 100644
index 0000000..874a5f7
--- /dev/null
+++ b/app/routers/collections/dashboard_views.py
@@ -0,0 +1,214 @@
+"""FlowDeck — Collections : dashboard_views.
+
+Découpe A28 de l'ancien app/routers/collections.py (2 622 lignes, 53 routes) — un module par concern, contrat inchangé.
+"""
+from __future__ import annotations
+
+import html as _htmlmod
+import json
+import logging
+
+from fastapi import APIRouter, Body, HTTPException, Request
+from fastapi.responses import HTMLResponse
+
+from app.db import get_conn
+
+from ._common import _require_view, _session_user
+from ._renderers import CHART_MAX_GROUPS, _base_html, _render_chart, _render_view
+
+logger = logging.getLogger(__name__)
+router = APIRouter(tags=["collections"], prefix="/db")
+
+
+
+
+@router.post("/{collection_id}/pages/{page_id}/auto-shift/api")
+def auto_shift_dates(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})):
+ """API: auto-shift dates based on blocking dependencies."""
+ from datetime import date as dt_date
+ from datetime import timedelta
+
+ skip_weekends = body.get("skip_weekends", False)
+
+ with get_conn() as conn:
+ page = conn.execute("SELECT property_values_json FROM collection_pages WHERE id=?", (page_id,)).fetchone()
+ if not page:
+ raise HTTPException(status_code=404, detail="Page not found")
+
+ # Get all blocking dependencies
+ deps = conn.execute(
+ "SELECT * FROM page_dependencies WHERE page_id=? AND dependency_type='blocks'",
+ (page_id,),
+ ).fetchall()
+
+ shifted = False
+ new_start = None
+ for dep in deps:
+ dep_page = conn.execute(
+ "SELECT title, property_values_json FROM collection_pages WHERE id=?",
+ (dep["dependency_id"],),
+ ).fetchone()
+ if not dep_page:
+ continue
+ dep_props = json.loads(dep_page["property_values_json"])
+ # Find the latest end date among blockers
+ for v in dep_props.values():
+ if isinstance(v, str) and v.startswith("20"):
+ end_date = v.split("...")[-1].split("→")[-1].strip()[:10]
+ try:
+ ed = dt_date.fromisoformat(end_date)
+ if new_start is None or ed >= new_start:
+ new_start = ed + timedelta(days=1)
+ shifted = True
+ except ValueError:
+ continue
+
+ if not shifted:
+ return {"page_id": page_id, "shifted": False, "message": "No blocking dependencies with dates found"}
+
+ # Skip weekends if requested
+ if skip_weekends and new_start:
+ while new_start.weekday() >= 5: # 5=Sat, 6=Sun
+ new_start = new_start + timedelta(days=1)
+
+ # Update the page's date properties
+ props = json.loads(page["property_values_json"])
+ for k, v in list(props.items()):
+ if isinstance(v, str) and v.startswith("20"):
+ old_parts = v.split("...")
+ old_end = old_parts[-1] if len(old_parts) > 1 else old_parts[0]
+ try:
+ old_start_d = dt_date.fromisoformat(old_parts[0][:10])
+ old_end_d = dt_date.fromisoformat(old_end[:10])
+ duration = (old_end_d - old_start_d).days
+ new_end = new_start + timedelta(days=max(duration, 0))
+ props[k] = f"{new_start.isoformat()}...{new_end.isoformat()}"
+ except ValueError:
+ props[k] = new_start.isoformat()
+ break
+
+ conn.execute(
+ "UPDATE collection_pages SET property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
+ (json.dumps(props), page_id),
+ )
+ conn.commit()
+
+ return {"page_id": page_id, "shifted": True, "new_start": new_start.isoformat(), "skip_weekends": skip_weekends}
+
+
+# ── {collection_id} wildcards (LAST — catches everything else) ──
+
+
+
+
+# ── {collection_id} wildcards (LAST — catches everything else) ──
+
+
+@router.get("/{collection_id}/dashboards/{dashboard_id}", response_class=HTMLResponse)
+def view_dashboard(request: Request, collection_id: int, dashboard_id: int):
+ """v7.3.0: render a collection dashboard grid (multi-DB widgets).
+
+ Widgets live in ``collection_dashboards.layout_json`` as
+ ``{"columns": N, "widgets": [{collection_id?, view_type?, chart_type?,
+ chart_property?, aggregate?, title?, width?, height?}]}``. Each widget may
+ point at *any* database (the dashboard's own collection is the default),
+ which is what "dashboards multi-DB" means.
+ """
+ uid = _session_user(request)
+ _require_view(collection_id, uid)
+ with get_conn() as conn:
+ dash = conn.execute(
+ "SELECT * FROM collection_dashboards WHERE id=? AND collection_id=?",
+ (dashboard_id, collection_id)).fetchone()
+ if not dash:
+ raise HTTPException(404, "Dashboard not found")
+ layout = json.loads(dash["layout_json"] or "{}")
+ columns = max(1, int(layout.get("columns", 1) or 1))
+ widgets = layout.get("widgets", []) or []
+ if not isinstance(widgets, list):
+ widgets = []
+
+ rendered = []
+ for w in widgets[:40]:
+ if not isinstance(w, dict):
+ continue
+ wc = int(w.get("collection_id") or 0) or collection_id
+ with get_conn() as conn:
+ coll = conn.execute("SELECT * FROM collections WHERE id=?", (wc,)).fetchone()
+ if not coll:
+ continue
+ try:
+ _require_view(wc, uid)
+ except HTTPException:
+ continue # restricted database → widget skipped, not rendered
+ with get_conn() as conn:
+ wpages = conn.execute(
+ "SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position LIMIT ?",
+ (wc, CHART_MAX_GROUPS)).fetchall()
+ wconfig = {k: v for k, v in w.items()
+ if k in ("chart_type", "chart_property", "aggregate", "title")}
+ view_type = w.get("view_type") or "chart"
+ if view_type == "chart":
+ body = _render_chart(view_type, dict(coll), [dict(p) for p in wpages], wconfig)
+ else:
+ body = _render_view(view_type, dict(coll), [dict(p) for p in wpages], wconfig)
+ width = int(w.get("width") or 0)
+ span = f"grid-column: span {width};" if width and width > 0 else ""
+ rendered.append(f'{body}
')
+
+ grid_css = f"grid-template-columns: repeat({columns}, minmax(0, 1fr));"
+ body = f"""
+
+{_htmlmod.escape(dash['name'])}
+{''.join(rendered) if rendered else '
Empty dashboard — add widgets to layout_json.
'}
+"""
+ return HTMLResponse(_base_html(dash["name"], "📊", "dashboard", body))
+
+
+
+
+@router.get("/{collection_id}", response_class=HTMLResponse)
+@router.get("/{collection_id}/view/{view_type}", response_class=HTMLResponse)
+def view_collection(request: Request, collection_id: int, view_type: str = "table"):
+ """Main view — renders collection in the requested view type."""
+ # v6.0.0: granular collection permissions — hide restricted collections.
+ _require_view(collection_id, _session_user(request))
+ with get_conn() as conn:
+ collection = conn.execute(
+ "SELECT * FROM collections WHERE id=?", (collection_id,)
+ ).fetchone()
+ if not collection:
+ raise HTTPException(status_code=404, detail="Collection not found")
+
+ view = conn.execute(
+ "SELECT * FROM collection_views WHERE collection_id=? AND view_type=? ORDER BY position LIMIT 1",
+ (collection_id, view_type),
+ ).fetchone()
+ if not view:
+ view = conn.execute(
+ "SELECT * FROM collection_views WHERE collection_id=? ORDER BY position LIMIT 1",
+ (collection_id,),
+ ).fetchone()
+
+ pages = conn.execute(
+ "SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position",
+ (collection_id,),
+ ).fetchall()
+
+ collection_dict = dict(collection)
+ pages_list = [dict(p) for p in pages]
+ config = json.loads(view["config_json"]) if view else {}
+
+ if "year" in request.query_params:
+ config["year"] = int(request.query_params["year"])
+ if "month" in request.query_params:
+ config["month"] = int(request.query_params["month"])
+
+ return HTMLResponse(_render_view(view_type, collection_dict, pages_list, config))
+
+
+# ── View renderers (v1.6.0) ──
diff --git a/app/routers/collections/data_api.py b/app/routers/collections/data_api.py
new file mode 100644
index 0000000..93b1b2c
--- /dev/null
+++ b/app/routers/collections/data_api.py
@@ -0,0 +1,122 @@
+"""FlowDeck — Collections : data_api.
+
+Découpe A28 de l'ancien app/routers/collections.py (2 622 lignes, 53 routes) — un module par concern, contrat inchangé.
+"""
+from __future__ import annotations
+
+import json
+import logging
+
+from fastapi import APIRouter, Body, HTTPException, Request
+
+from app.db import get_conn
+from app.services.automations import fire_event, run_event_sync
+from app.services.property_types import (
+ apply_auto_properties,
+)
+
+from ._common import (
+ _collection_properties,
+ _current_user,
+ _require_edit,
+ _require_view,
+ _session_user,
+ _validate_meta_keys,
+ _validate_page_properties,
+)
+
+logger = logging.getLogger(__name__)
+router = APIRouter(tags=["collections"], prefix="/db")
+
+
+
+
+@router.get("/{collection_id}/api")
+def get_collection_api(request: Request, collection_id: int):
+ """API: get a single collection with its pages."""
+ # v6.0.0: granular collection permissions — hide restricted collections.
+ _require_view(collection_id, _session_user(request))
+ with get_conn() as conn:
+ collection = conn.execute(
+ "SELECT * FROM collections WHERE id=?", (collection_id,)
+ ).fetchone()
+ if not collection:
+ raise HTTPException(status_code=404, detail="Collection not found")
+
+ pages = conn.execute(
+ "SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position",
+ (collection_id,),
+ ).fetchall()
+ views = conn.execute(
+ "SELECT * FROM collection_views WHERE collection_id=? ORDER BY position",
+ (collection_id,),
+ ).fetchall()
+
+ return {
+ "collection": dict(collection),
+ "pages": [dict(p) for p in pages],
+ "views": [dict(v) for v in views],
+ }
+
+
+
+
+@router.post("/{collection_id}/pages/api")
+def create_page_api(request: Request, collection_id: int, body: dict = Body(default={})):
+ """API: create a page in a collection."""
+ # v6.0.0: granular collection permissions — viewer/commenter cannot create.
+ _require_view(collection_id, _session_user(request))
+ _require_edit(collection_id, _session_user(request))
+
+ title = body.get("title", "").strip()
+ if not title:
+ raise HTTPException(status_code=400, detail="title is required")
+
+ icon = body.get("icon", "📄")
+ property_values = body.get("properties", {})
+ cover_url = body.get("cover_url", "")
+ gitea_issue_id = body.get("gitea_issue_id")
+ gitea_issue_number = body.get("gitea_issue_number")
+
+ with get_conn() as conn:
+ coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
+ if not coll:
+ raise HTTPException(status_code=404, detail="Collection not found")
+
+ _validate_page_properties(conn, collection_id, property_values)
+ _validate_meta_keys(conn, collection_id, property_values)
+ apply_auto_properties(
+ _collection_properties(conn, collection_id),
+ property_values,
+ _current_user(request),
+ is_create=True,
+ )
+
+ max_pos = conn.execute(
+ "SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?",
+ (collection_id,),
+ ).fetchone()[0]
+
+ cur = conn.execute(
+ """INSERT INTO collection_pages
+ (collection_id, title, icon, cover_url, position, gitea_issue_id, gitea_issue_number, property_values_json)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
+ (collection_id, title, icon, cover_url, max_pos, gitea_issue_id, gitea_issue_number,
+ json.dumps(property_values)),
+ )
+ conn.commit()
+ page_id = cur.lastrowid
+
+ run_event_sync(fire_event("page.created", {
+ "page_id": page_id,
+ "collection_id": collection_id,
+ "title": title,
+ "icon": icon,
+ "properties": property_values,
+ }))
+ run_event_sync(fire_event("collection.page.created", {
+ "page_id": page_id,
+ "collection_id": collection_id,
+ "title": title,
+ }))
+ return {"id": page_id, "title": title, "status": "created"}
diff --git a/app/routers/collections/linked.py b/app/routers/collections/linked.py
new file mode 100644
index 0000000..125fdb0
--- /dev/null
+++ b/app/routers/collections/linked.py
@@ -0,0 +1,286 @@
+"""FlowDeck — Collections : linked.
+
+Découpe A28 de l'ancien app/routers/collections.py (2 622 lignes, 53 routes) — un module par concern, contrat inchangé.
+"""
+from __future__ import annotations
+
+import json
+import logging
+import sqlite3
+
+from fastapi import APIRouter, Body, HTTPException, Request
+
+from app.db import get_conn
+from app.services.db_templates import materialize_properties
+
+from ._common import _apply_template
+
+logger = logging.getLogger(__name__)
+router = APIRouter(tags=["collections"], prefix="/db")
+
+
+
+
+@router.post("/{collection_id}/linked/api")
+def create_linked_database(request: Request, collection_id: int, body: dict = Body(default={})):
+ """API: create a linked database view from a source collection.
+ A linked database copies the structure (views, filters, sorts) of a source
+ but shares the same pages — edits to pages propagate to the source.
+ """
+
+ name = body.get("name", "").strip()
+ body.get("workspace_id")
+
+ with get_conn() as conn:
+ source = conn.execute(
+ "SELECT * FROM collections WHERE id=?", (collection_id,)
+ ).fetchone()
+ if not source:
+ raise HTTPException(status_code=404, detail="Source collection not found")
+
+ if not name:
+ name = f"{source['name']} (linked)"
+
+ # Create the linked collection (shallow copy of structure)
+ # Inherit workspace_id from source for permission inheritance
+ src_dict = dict(source)
+ source_workspace_id = src_dict.get("workspace_id")
+ cur = conn.execute(
+ """INSERT INTO collections
+ (name, description, icon, schema_json, is_locked, is_inline, parent_page_id, workspace_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
+ (
+ name,
+ src_dict["description"],
+ src_dict["icon"],
+ src_dict["schema_json"],
+ 0, # linked DB is never locked
+ 1, # linked DB starts as inline
+ src_dict.get("parent_page_id"),
+ source_workspace_id, # linked DB inherits source workspace permissions
+ ),
+ )
+ linked_id = cur.lastrowid
+
+ # Copy views from source
+ views = conn.execute(
+ "SELECT * FROM collection_views WHERE collection_id=? ORDER BY position",
+ (collection_id,),
+ ).fetchall()
+ for v in views:
+ conn.execute(
+ "INSERT INTO collection_views (collection_id, name, view_type, config_json, position) VALUES (?, ?, ?, ?, ?)",
+ (linked_id, v["name"], v["view_type"], v["config_json"], v["position"]),
+ )
+
+ # Add the source as a data source with is_linked=1
+ conn.execute(
+ """INSERT INTO collection_data_sources
+ (collection_id, source_collection_id, source_name, is_linked, position)
+ VALUES (?, ?, ?, 1, 0)""",
+ (linked_id, collection_id, source["name"]),
+ )
+
+ # Copy properties from source
+ props = conn.execute(
+ "SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
+ (collection_id,),
+ ).fetchall()
+ for p in props:
+ conn.execute(
+ """INSERT INTO collection_properties
+ (collection_id, name, prop_type, options_json, number_format,
+ related_collection_id, reverse_name, relation_property_id,
+ target_property_id, rollup_function, formula_expression,
+ position, required, visible_in_views)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
+ (
+ linked_id, p["name"], p["prop_type"], p["options_json"],
+ p["number_format"], p["related_collection_id"], p["reverse_name"],
+ p["relation_property_id"], p["target_property_id"],
+ p["rollup_function"], p["formula_expression"],
+ p["position"], p["required"], p["visible_in_views"],
+ ),
+ )
+
+ conn.commit()
+
+ return {
+ "linked_id": linked_id,
+ "name": name,
+ "source_collection_id": collection_id,
+ "status": "created",
+ }
+
+
+
+
+@router.post("/{collection_id}/toggle-inline/api")
+def toggle_inline(request: Request, collection_id: int):
+ """API: toggle a collection between full-page and inline mode."""
+ with get_conn() as conn:
+ coll = conn.execute(
+ "SELECT id, is_inline FROM collections WHERE id=?",
+ (collection_id,),
+ ).fetchone()
+ if not coll:
+ raise HTTPException(status_code=404, detail="Collection not found")
+
+ new_inline = 0 if coll["is_inline"] else 1
+ conn.execute(
+ "UPDATE collections SET is_inline=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
+ (new_inline, collection_id),
+ )
+ conn.commit()
+
+ return {
+ "collection_id": collection_id,
+ "is_inline": bool(new_inline),
+ "mode": "inline" if new_inline else "full-page",
+ }
+
+
+
+
+@router.post("/inline/api")
+def create_inline_database(request: Request, body: dict = Body(default={})):
+ """API: create an inline database within a parent page (optionally from a template)."""
+
+ name = body.get("name", "").strip()
+ if not name:
+ raise HTTPException(status_code=400, detail="name is required")
+
+ description = body.get("description", "")
+ icon = body.get("icon", "📋")
+ parent_page_id = body.get("parent_page_id")
+ workspace_id = body.get("workspace_id")
+ schema = body.get("schema", [])
+
+ with get_conn() as conn:
+ tpl = _apply_template(conn, body.get("template"))
+ if tpl:
+ if body.get("name"):
+ name = body["name"].strip()
+ description = tpl["description"]
+ icon = tpl.get("icon") or icon
+ try:
+ schema = json.loads(tpl["schema_json"])
+ except (json.JSONDecodeError, TypeError):
+ schema = []
+
+ cur = conn.execute(
+ """INSERT INTO collections
+ (name, description, icon, schema_json, is_inline, parent_page_id, workspace_id)
+ VALUES (?, ?, ?, ?, 1, ?, ?)""",
+ (name, description, icon, json.dumps(schema), parent_page_id, workspace_id),
+ )
+ collection_id = cur.lastrowid
+
+ materialize_properties(conn, collection_id, schema)
+
+ # Create default view
+ conn.execute(
+ """INSERT INTO collection_views
+ (collection_id, name, view_type, config_json)
+ VALUES (?, ?, ?, ?)""",
+ (collection_id, "Default View", "table", json.dumps({
+ "visible_properties": ["Title"],
+ "sorts": [],
+ "filters": [],
+ })),
+ )
+ conn.commit()
+
+ return {
+ "id": collection_id,
+ "name": name,
+ "icon": icon,
+ "is_inline": True,
+ "parent_page_id": parent_page_id,
+ "status": "created",
+ }
+
+
+# ── v4.4.0: Tasks & Dependencies ──
+
+
+
+
+# ── v4.4.0: Tasks & Dependencies ──
+
+
+@router.put("/{collection_id}/toggle-task/api")
+def toggle_task(request: Request, collection_id: int):
+ """API: toggle is_task flag on a collection (Turn into Tasks)."""
+ with get_conn() as conn:
+ coll = conn.execute("SELECT id, is_task FROM collections WHERE id=?", (collection_id,)).fetchone()
+ if not coll:
+ raise HTTPException(status_code=404, detail="Collection not found")
+ new_val = 0 if coll["is_task"] else 1
+ conn.execute("UPDATE collections SET is_task=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (new_val, collection_id))
+ conn.commit()
+ return {"collection_id": collection_id, "is_task": bool(new_val), "mode": "tasks" if new_val else "standard"}
+
+
+
+
+@router.get("/{collection_id}/pages/{page_id}/dependencies/api")
+def list_page_dependencies(request: Request, collection_id: int, page_id: int):
+ """API: list dependencies for a page (blocks, blocked_by, related)."""
+ with get_conn() as conn:
+ rows = conn.execute(
+ "SELECT * FROM page_dependencies WHERE page_id=? ORDER BY created_at",
+ (page_id,),
+ ).fetchall()
+ deps = []
+ for r in rows:
+ d = dict(r)
+ dep_page = conn.execute(
+ "SELECT id, title FROM collection_pages WHERE id=?", (r["dependency_id"],)
+ ).fetchone()
+ if dep_page:
+ d["dependency_title"] = dep_page["title"]
+ deps.append(d)
+ return {"dependencies": deps}
+
+
+
+
+@router.post("/{collection_id}/pages/{page_id}/dependencies/api")
+def add_page_dependency(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})):
+ """API: add a dependency (blocks/blocked_by/related) between two pages."""
+ dependency_id = body.get("dependency_id")
+ if not dependency_id:
+ raise HTTPException(status_code=400, detail="dependency_id is required")
+ dep_type = body.get("dependency_type", "blocks")
+ auto_shift = body.get("auto_shift", "overlap")
+
+ with get_conn() as conn:
+ for pid in (page_id, dependency_id):
+ if not conn.execute("SELECT id FROM collection_pages WHERE id=?", (pid,)).fetchone():
+ raise HTTPException(status_code=404, detail=f"Page {pid} not found")
+ try:
+ cur = conn.execute(
+ "INSERT INTO page_dependencies (page_id, dependency_id, dependency_type, auto_shift) VALUES (?,?,?,?)",
+ (page_id, dependency_id, dep_type, auto_shift),
+ )
+ conn.commit()
+ except sqlite3.IntegrityError:
+ raise HTTPException(status_code=409, detail="This dependency already exists") from None
+ return {"id": cur.lastrowid, "page_id": page_id, "dependency_id": dependency_id, "status": "added"}
+
+
+
+
+@router.delete("/{collection_id}/pages/{page_id}/dependencies/{dep_id}/api")
+def remove_page_dependency(request: Request, collection_id: int, page_id: int, dep_id: int):
+ """API: remove a dependency."""
+ with get_conn() as conn:
+ existing = conn.execute(
+ "SELECT * FROM page_dependencies WHERE id=? AND page_id=?", (dep_id, page_id)
+ ).fetchone()
+ if not existing:
+ raise HTTPException(status_code=404, detail="Dependency not found")
+ conn.execute("DELETE FROM page_dependencies WHERE id=?", (dep_id,))
+ conn.commit()
+ return {"id": dep_id, "status": "removed"}
diff --git a/app/routers/collections/meta.py b/app/routers/collections/meta.py
new file mode 100644
index 0000000..076e58a
--- /dev/null
+++ b/app/routers/collections/meta.py
@@ -0,0 +1,197 @@
+"""FlowDeck — Collections : meta.
+
+Découpe A28 de l'ancien app/routers/collections.py (2 622 lignes, 53 routes) — un module par concern, contrat inchangé.
+"""
+from __future__ import annotations
+
+import json
+import logging
+
+from fastapi import APIRouter, HTTPException, Request
+
+from app.db import get_conn
+from app.services.permission_manager import PermissionManager
+from app.services.recurrence import (
+ RECURRENCE_KEY,
+ expand_rule,
+ parse_date,
+)
+
+from ._common import _collection_properties, _current_user, _require_view, _session_user
+
+logger = logging.getLogger(__name__)
+router = APIRouter(tags=["collections"], prefix="/db")
+
+
+
+
+# ── Collection Properties (v1.4.0) ──
+
+
+@router.get("/property-types/api")
+def list_property_types_api(request: Request):
+ """API: list all available property types."""
+ from app.services.property_types import PROPERTY_TYPES
+ return {"types": PROPERTY_TYPES}
+
+
+
+
+@router.get("/{collection_id}/properties/api")
+def list_properties_api(request: Request, collection_id: int):
+ """API: list all properties visible to the current user."""
+ user = _session_user(request)
+ _require_view(collection_id, user)
+ with get_conn() as conn:
+ coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
+ if not coll:
+ raise HTTPException(status_code=404, detail="Collection not found")
+ rows = conn.execute(
+ "SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
+ (collection_id,),
+ ).fetchall()
+ props = [dict(r) for r in rows]
+ # v6.0.0: property-level visibility — owners/editors see everything, other
+ # users only the properties explicitly granted or left open.
+ if user:
+ pm = PermissionManager(user["id"])
+ visible = pm.get_visible_properties(collection_id)
+ props = [p for p in props if p["id"] in visible]
+ return {"properties": props}
+
+
+
+
+@router.get("/{collection_id}/members/api")
+def list_collection_members_api(request: Request, collection_id: int):
+ """API: list workspace members available for a ``person`` property.
+
+ Resolves the collection's workspace and returns its members (falling back to
+ every active user for standalone databases without a workspace).
+ """
+ with get_conn() as conn:
+ coll = conn.execute(
+ "SELECT workspace_id FROM collections WHERE id=?", (collection_id,)
+ ).fetchone()
+ if not coll:
+ raise HTTPException(status_code=404, detail="Collection not found")
+
+ ws_id = coll["workspace_id"] if "workspace_id" in coll.keys() else None
+ if ws_id:
+ rows = conn.execute(
+ """SELECT u.id, u.login, u.full_name, u.avatar_url, u.avatar_color, wm.role
+ FROM workspace_members wm JOIN users u ON wm.user_id=u.id
+ WHERE wm.workspace_id=? AND u.is_active=1 ORDER BY u.full_name, u.login""",
+ (ws_id,),
+ ).fetchall()
+ else:
+ rows = []
+ if not rows:
+ rows = conn.execute(
+ """SELECT id, login, full_name, avatar_url, avatar_color, '' AS role
+ FROM users WHERE is_active=1 ORDER BY full_name, login"""
+ ).fetchall()
+
+ return {"members": [dict(r) for r in rows]}
+
+
+
+
+@router.get("/{collection_id}/calendar/api")
+def collection_calendar_api(request: Request, collection_id: int,
+ start: str = "", end: str = "",
+ date_property: str = ""):
+ """API (v5.8.0): expanded calendar events for a window [start, end].
+
+ Returns every occurrence (recurrence-aware, virtual — never persisted)
+ of the rows in the collection whose ``date_property`` falls inside the
+ inclusive window. Rows without a rule yield their base date.
+ """
+ user = _current_user(request)
+ s = parse_date(start)
+ e = parse_date(end)
+ if s is None or e is None or s > e:
+ raise HTTPException(status_code=400, detail="start/end must be YYYY-MM-DD")
+ if (e - s).days > 370:
+ raise HTTPException(status_code=400, detail="window too large (max 370 days)")
+
+ with get_conn() as conn:
+ coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
+ if not coll:
+ raise HTTPException(status_code=404, detail="Collection not found")
+
+ props = _collection_properties(conn, collection_id)
+ date_props = [p for p in props if p["prop_type"] == "date"]
+ target = None
+ if date_property:
+ target = next((p for p in date_props
+ if str(p["id"]) == str(date_property) or p["name"] == date_property), None)
+ if target is None:
+ raise HTTPException(status_code=400, detail="Unknown date property")
+ elif date_props:
+ target = date_props[0]
+ if target is None:
+ return {"events": [], "timezone": "", "property": None}
+
+ urow = conn.execute("SELECT timezone FROM users WHERE id=?", (user.get("id") or 1,)).fetchone()
+ user_tz = (urow["timezone"] if urow and "timezone" in urow.keys() else "") or ""
+
+ rows = conn.execute(
+ "SELECT id, title, icon, property_values_json FROM collection_pages WHERE collection_id=?",
+ (collection_id,),
+ ).fetchall()
+
+ pid = str(target["id"])
+ events: list[dict] = []
+ for r in rows:
+ try:
+ pv = json.loads(r["property_values_json"] or "{}")
+ except (json.JSONDecodeError, TypeError):
+ continue
+ base_value = pv.get(pid)
+ if base_value is None:
+ base_value = pv.get(target["name"])
+ if parse_date(base_value) is None:
+ continue
+ rec_all = pv.get(RECURRENCE_KEY) if isinstance(pv.get(RECURRENCE_KEY), dict) else {}
+ rule = rec_all.get(pid) or rec_all.get(target["name"])
+ row_tz = user_tz
+ if isinstance(rule, dict) and rule.get("timezone"):
+ row_tz = rule["timezone"]
+ tzmap = pv.get("__timezone__")
+ if isinstance(tzmap, dict):
+ ev_tz = tzmap.get(pid) or tzmap.get(target["name"])
+ if ev_tz:
+ row_tz = str(ev_tz)
+ if rule:
+ dates = expand_rule(base_value, rule, s, e, max_occurrences=500)
+ else:
+ d = parse_date(base_value)
+ dates = [d.isoformat()] if d and s <= d <= e else []
+ for iso in dates:
+ events.append({
+ "date": iso,
+ "page_id": r["id"],
+ "title": r["title"],
+ "icon": r["icon"],
+ "recurring": bool(rule),
+ "time": str(base_value)[11:16] if len(str(base_value)) >= 16 else "",
+ "timezone": row_tz,
+ })
+ events.sort(key=lambda ev: (ev["date"], ev["page_id"]))
+ return {"events": events, "timezone": user_tz, "property": {"id": target["id"], "name": target["name"]}}
+
+
+
+
+@router.get("/timezones/api")
+def timezones_api(request: Request):
+ """API (v5.8.0): the user's timezone plus a picker-friendly zone list."""
+ user = _current_user(request)
+ with get_conn() as conn:
+ row = conn.execute("SELECT timezone FROM users WHERE id=?", (user.get("id") or 1,)).fetchone()
+ from app.services.recurrence import common_timezones
+ return {
+ "timezone": (row["timezone"] if row and "timezone" in row.keys() else "") or "",
+ "zones": common_timezones(),
+ }
diff --git a/app/routers/collections/pages.py b/app/routers/collections/pages.py
new file mode 100644
index 0000000..e8d8a66
--- /dev/null
+++ b/app/routers/collections/pages.py
@@ -0,0 +1,184 @@
+"""FlowDeck — Collections : pages.
+
+Découpe A28 de l'ancien app/routers/collections.py (2 622 lignes, 53 routes) — un module par concern, contrat inchangé.
+"""
+from __future__ import annotations
+
+import json
+import logging
+
+from fastapi import APIRouter, Body, HTTPException, Request
+
+from app.db import get_conn
+from app.services.automations import fire_event, run_event_sync
+from app.services.property_types import (
+ apply_auto_properties,
+)
+
+from ._common import (
+ _collection_properties,
+ _current_user,
+ _require_edit,
+ _require_view,
+ _session_user,
+ _validate_meta_keys,
+ _validate_page_properties,
+)
+
+logger = logging.getLogger(__name__)
+router = APIRouter(tags=["collections"], prefix="/db")
+
+
+
+
+# ── Page CRUD (standalone, BEFORE collection wildcards) ──
+
+
+@router.get("/pages/{page_id}/api")
+def get_page_api(request: Request, page_id: int):
+ """API: get a single page."""
+ with get_conn() as conn:
+ page = conn.execute(
+ "SELECT * FROM collection_pages WHERE id=?", (page_id,)
+ ).fetchone()
+ if not page:
+ raise HTTPException(status_code=404, detail="Page not found")
+ # v6.0.0: granular collection/page permissions.
+ _require_view(page["collection_id"], _session_user(request))
+ return dict(page)
+
+
+
+
+@router.get("/pages/{page_id}/open/api")
+def open_row_page_api(request: Request, page_id: int):
+ """v6.5.0 — content page of a database row (lazy-created).
+
+ Any DB view (table/board/gallery/list/calendar) opens a row through
+ this endpoint: it returns the shadow ``pages`` id whose full page
+ editor carries the row's block content (synced blocks included).
+ """
+ with get_conn() as conn:
+ row = conn.execute(
+ "SELECT collection_id FROM collection_pages WHERE id=?",
+ (page_id,),
+ ).fetchone()
+ if not row:
+ raise HTTPException(status_code=404, detail="Page not found")
+ coll_id = row["collection_id"]
+ # v6.0.0: granular collection permissions (same gate as the row itself).
+ _require_view(coll_id, _session_user(request))
+ from app.services.row_pages import ensure_row_page
+ try:
+ content_page_id = ensure_row_page(page_id)
+ except KeyError:
+ raise HTTPException(status_code=404, detail="Page not found") from None
+ return {"page_id": content_page_id, "row_id": page_id}
+
+
+
+
+@router.put("/pages/{page_id}/api")
+def update_page_api(request: Request, page_id: int, body: dict = Body(default={})):
+ """API: update a page's properties."""
+
+ with get_conn() as conn:
+ existing = conn.execute(
+ "SELECT * FROM collection_pages WHERE id=?", (page_id,)
+ ).fetchone()
+ if not existing:
+ raise HTTPException(status_code=404, detail="Page not found")
+ # v6.0.0: granular collection/page permissions.
+ _require_edit(existing["collection_id"], _session_user(request))
+
+ title = body.get("title", existing["title"])
+ icon = body.get("icon", existing["icon"])
+ cover_url = body.get("cover_url", existing["cover_url"] if "cover_url" in existing.keys() else "")
+ position = body.get("position", existing["position"])
+ parent_id = body.get("parent_id", existing["parent_id"])
+
+ try:
+ stored = json.loads(existing["property_values_json"])
+ except (json.JSONDecodeError, TypeError):
+ stored = {}
+
+ if "properties" in body:
+ # Partial PATCH semantics: merge submitted values over stored ones.
+ props = dict(stored)
+ props.update(body["properties"])
+ else:
+ props = stored
+
+ _validate_page_properties(conn, existing["collection_id"], props, exclude_page_id=page_id)
+ _validate_meta_keys(conn, existing["collection_id"], props)
+ apply_auto_properties(
+ _collection_properties(conn, existing["collection_id"]),
+ props,
+ _current_user(request),
+ is_create=False,
+ )
+
+ property_values = json.dumps(props)
+
+ conn.execute(
+ """UPDATE collection_pages
+ SET title=?, icon=?, cover_url=?, position=?, parent_id=?, property_values_json=?,
+ updated_at=CURRENT_TIMESTAMP
+ WHERE id=?""",
+ (title, icon, cover_url, position, parent_id, property_values, page_id),
+ )
+ # v6.5.0: keep the row's content page title in sync (row → page).
+ from app.services.row_pages import sync_row_title_to_page
+ sync_row_title_to_page(conn, page_id)
+ conn.commit()
+
+ run_event_sync(fire_event("page.updated", {
+ "page_id": page_id,
+ "collection_id": existing["collection_id"],
+ "title": title,
+ "icon": icon,
+ "properties": props,
+ }))
+ run_event_sync(fire_event("collection.page.updated", {
+ "page_id": page_id,
+ "collection_id": existing["collection_id"],
+ "title": title,
+ }))
+ # Notify newly assigned people (person properties) — v5.8.0.
+ from app.services.notifications import notify_assignment
+ user = _current_user(request)
+ notify_assignment(existing["collection_id"], page_id, title,
+ stored, props, user.get("id"))
+ return {"id": page_id, "status": "updated"}
+
+
+
+
+@router.delete("/pages/{page_id}/api")
+def delete_page_api(request: Request, page_id: int):
+ """API: delete a page from its collection."""
+ with get_conn() as conn:
+ existing = conn.execute(
+ "SELECT * FROM collection_pages WHERE id=?", (page_id,)
+ ).fetchone()
+ if not existing:
+ raise HTTPException(status_code=404, detail="Page not found")
+ # v6.0.0: granular collection/page permissions.
+ _require_edit(existing["collection_id"], _session_user(request))
+
+ conn.execute("DELETE FROM collection_pages WHERE id=?", (page_id,))
+ conn.commit()
+
+ run_event_sync(fire_event("page.deleted", {
+ "page_id": page_id,
+ "collection_id": existing["collection_id"],
+ "title": existing["title"],
+ }))
+ run_event_sync(fire_event("collection.page.deleted", {
+ "page_id": page_id,
+ "collection_id": existing["collection_id"],
+ }))
+ return {"id": page_id, "status": "deleted"}
+
+
+# ── Gitea Board Compatibility (BEFORE {collection_id} wildcards) ──
diff --git a/app/routers/collections/properties.py b/app/routers/collections/properties.py
new file mode 100644
index 0000000..e416bbb
--- /dev/null
+++ b/app/routers/collections/properties.py
@@ -0,0 +1,322 @@
+"""FlowDeck — Collections : properties.
+
+Découpe A28 de l'ancien app/routers/collections.py (2 622 lignes, 53 routes) — un module par concern, contrat inchangé.
+"""
+from __future__ import annotations
+
+import json
+import logging
+
+from fastapi import APIRouter, Body, HTTPException, Request
+
+from app.db import get_conn
+
+logger = logging.getLogger(__name__)
+router = APIRouter(tags=["collections"], prefix="/db")
+
+
+
+
+@router.post("/{collection_id}/property-groups/api")
+def set_property_groups_api(request: Request, collection_id: int, body: dict = Body(default={})):
+ """API: (re)assign properties to collapsible groups in the table header.
+
+ Body: ``{"groups": [{"name": "Basics", "property_ids": [1, 2]}]}``. Properties
+ omitted from any group have their group cleared. Empty group names clear.
+ """
+ groups = body.get("groups", [])
+
+ with get_conn() as conn:
+ coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
+ if not coll:
+ raise HTTPException(status_code=404, detail="Collection not found")
+
+ conn.execute(
+ "UPDATE collection_properties SET group_name='' WHERE collection_id=?",
+ (collection_id,),
+ )
+ for grp in groups:
+ gname = (grp.get("name") or "").strip()
+ if not gname:
+ continue
+ for pid in grp.get("property_ids", []) or []:
+ conn.execute(
+ "UPDATE collection_properties SET group_name=? WHERE id=? AND collection_id=?",
+ (gname, pid, collection_id),
+ )
+ conn.commit()
+
+ return {"status": "updated"}
+
+
+
+
+@router.post("/{collection_id}/properties/api")
+def create_property_api(request: Request, collection_id: int, body: dict = Body(default={})):
+ """API: create a new property on a collection."""
+
+ name = body.get("name", "").strip()
+ if not name:
+ raise HTTPException(status_code=400, detail="name is required")
+
+ prop_type = body.get("prop_type", "text")
+ options_json = json.dumps(body.get("options", []))
+ number_format = body.get("number_format", "number")
+ required = int(body.get("required", False))
+ visible = int(body.get("visible_in_views", True))
+ validation_json = json.dumps(body.get("validation", {}))
+ group_name = (body.get("group_name") or "").strip()
+
+ with get_conn() as conn:
+ coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
+ if not coll:
+ raise HTTPException(status_code=404, detail="Collection not found")
+
+ max_pos = conn.execute(
+ "SELECT COALESCE(MAX(position), -1) + 1 FROM collection_properties WHERE collection_id=?",
+ (collection_id,),
+ ).fetchone()[0]
+
+ try:
+ cur = conn.execute(
+ """INSERT INTO collection_properties
+ (collection_id, name, prop_type, options_json, number_format,
+ position, required, visible_in_views, validation_json, group_name)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
+ (collection_id, name, prop_type, options_json, number_format, max_pos,
+ required, visible, validation_json, group_name),
+ )
+ conn.commit()
+ except Exception:
+ raise HTTPException(status_code=409, detail=f"Property '{name}' already exists") from None
+
+ return {"id": cur.lastrowid, "name": name, "prop_type": prop_type,
+ "group_name": group_name, "status": "created"}
+
+
+
+
+@router.put("/properties/{prop_id}/api")
+def update_property_api(request: Request, prop_id: int, body: dict = Body(default={})):
+ """API: update a property."""
+
+ with get_conn() as conn:
+ existing = conn.execute(
+ "SELECT * FROM collection_properties WHERE id=?", (prop_id,)
+ ).fetchone()
+ if not existing:
+ raise HTTPException(status_code=404, detail="Property not found")
+
+ name = body.get("name", existing["name"])
+ options_json = json.dumps(body.get("options", json.loads(existing["options_json"])))
+ number_format = body.get("number_format", existing["number_format"])
+ required = int(body.get("required", existing["required"]))
+ visible = int(body.get("visible_in_views", existing["visible_in_views"]))
+ if "validation" in body:
+ validation_json = json.dumps(body.get("validation", {}))
+ else:
+ validation_json = existing["validation_json"] if "validation_json" in existing.keys() else "{}"
+ group_name = body.get("group_name", existing["group_name"] if "group_name" in existing.keys() else "")
+
+ conn.execute(
+ """UPDATE collection_properties
+ SET name=?, options_json=?, number_format=?, required=?, visible_in_views=?,
+ validation_json=?, group_name=?
+ WHERE id=?""",
+ (name, options_json, number_format, required, visible, validation_json,
+ group_name, prop_id),
+ )
+ conn.commit()
+
+ return {"id": prop_id, "status": "updated"}
+
+
+
+
+@router.delete("/properties/{prop_id}/api")
+def delete_property_api(request: Request, prop_id: int):
+ """API: delete a property."""
+ with get_conn() as conn:
+ existing = conn.execute(
+ "SELECT * FROM collection_properties WHERE id=?", (prop_id,)
+ ).fetchone()
+ if not existing:
+ raise HTTPException(status_code=404, detail="Property not found")
+ conn.execute("DELETE FROM collection_properties WHERE id=?", (prop_id,))
+ conn.commit()
+
+ return {"id": prop_id, "status": "deleted"}
+
+
+# ── Relations, Rollups, Formulas (v1.5.0) ──
+
+
+
+
+# ── Relations, Rollups, Formulas (v1.5.0) ──
+
+
+@router.post("/{collection_id}/properties/relation")
+def create_relation_property(request: Request, collection_id: int, body: dict = Body(default={})):
+ """Create a relation property between two collections."""
+
+ name = body.get("name", "").strip()
+ related_collection_id = body.get("related_collection_id")
+ reverse_name = body.get("reverse_name", "").strip()
+
+ if not name or not related_collection_id:
+ raise HTTPException(status_code=400, detail="name and related_collection_id are required")
+
+ with get_conn() as conn:
+ # Verify both collections exist
+ for cid in (collection_id, related_collection_id):
+ if not conn.execute("SELECT id FROM collections WHERE id=?", (cid,)).fetchone():
+ raise HTTPException(status_code=404, detail=f"Collection {cid} not found")
+
+ max_pos = conn.execute(
+ "SELECT COALESCE(MAX(position), -1) + 1 FROM collection_properties WHERE collection_id=?",
+ (collection_id,),
+ ).fetchone()[0]
+
+ cur = conn.execute(
+ """INSERT INTO collection_properties
+ (collection_id, name, prop_type, related_collection_id, reverse_name, position)
+ VALUES (?, ?, 'relation', ?, ?, ?)""",
+ (collection_id, name, related_collection_id, reverse_name, max_pos),
+ )
+ prop_id = cur.lastrowid
+
+ # Create reverse relation on the related collection
+ if reverse_name:
+ max_pos2 = conn.execute(
+ "SELECT COALESCE(MAX(position), -1) + 1 FROM collection_properties WHERE collection_id=?",
+ (related_collection_id,),
+ ).fetchone()[0]
+ conn.execute(
+ """INSERT INTO collection_properties
+ (collection_id, name, prop_type, related_collection_id, reverse_name, position)
+ VALUES (?, ?, 'relation', ?, ?, ?)""",
+ (related_collection_id, reverse_name, collection_id, name, max_pos2),
+ )
+
+ conn.commit()
+
+ return {"id": prop_id, "name": name, "prop_type": "relation", "status": "created"}
+
+
+
+
+@router.post("/{collection_id}/properties/relation/link")
+def link_pages(request: Request, collection_id: int, body: dict = Body(default={})):
+ """Link two pages via a relation property."""
+
+ property_id = body.get("property_id")
+ source_page_id = body.get("source_page_id")
+ target_page_id = body.get("target_page_id")
+
+ if not all([property_id, source_page_id, target_page_id]):
+ raise HTTPException(status_code=400, detail="property_id, source_page_id, target_page_id required")
+
+ with get_conn() as conn:
+ # Get the relation property
+ prop = conn.execute(
+ "SELECT * FROM collection_properties WHERE id=? AND prop_type='relation'",
+ (property_id,),
+ ).fetchone()
+ if not prop:
+ raise HTTPException(status_code=404, detail="Relation property not found")
+
+ # Update source page's property_values_json
+ source = conn.execute(
+ "SELECT property_values_json FROM collection_pages WHERE id=?",
+ (source_page_id,),
+ ).fetchone()
+ if not source:
+ raise HTTPException(status_code=404, detail="Source page not found")
+
+ props = json.loads(source["property_values_json"])
+ current = props.get(str(property_id), [])
+ if not isinstance(current, list):
+ current = []
+ if target_page_id not in current:
+ current.append(target_page_id)
+ props[str(property_id)] = current
+
+ conn.execute(
+ "UPDATE collection_pages SET property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
+ (json.dumps(props), source_page_id),
+ )
+
+ # Update reverse relation if exists
+ if prop["reverse_name"]:
+ reverse_prop = conn.execute(
+ "SELECT id FROM collection_properties WHERE collection_id=? AND name=? AND prop_type='relation'",
+ (prop["related_collection_id"], prop["reverse_name"]),
+ ).fetchone()
+ if reverse_prop:
+ target = conn.execute(
+ "SELECT property_values_json FROM collection_pages WHERE id=?",
+ (target_page_id,),
+ ).fetchone()
+ if target:
+ tprops = json.loads(target["property_values_json"])
+ tcurrent = tprops.get(str(reverse_prop["id"]), [])
+ if not isinstance(tcurrent, list):
+ tcurrent = []
+ if source_page_id not in tcurrent:
+ tcurrent.append(source_page_id)
+ tprops[str(reverse_prop["id"])] = tcurrent
+ conn.execute(
+ "UPDATE collection_pages SET property_values_json=? WHERE id=?",
+ (json.dumps(tprops), target_page_id),
+ )
+
+ conn.commit()
+
+ return {"status": "linked", "source": source_page_id, "target": target_page_id}
+
+
+
+
+@router.post("/rollup/compute")
+def compute_rollup(request: Request, body: dict = Body(default={})):
+ """Compute a rollup aggregation."""
+
+ collection_id = body.get("collection_id")
+ relation_property_id = body.get("relation_property_id")
+ target_property_id = body.get("target_property_id")
+ page_id = body.get("page_id")
+ rollup_function = body.get("function", "count")
+
+ if not all([collection_id, relation_property_id, target_property_id, page_id]):
+ raise HTTPException(status_code=400, detail="collection_id, relation_property_id, target_property_id, page_id required")
+
+ from app.services.rollup_engine import RollupEngine
+ engine = RollupEngine()
+ result = engine.compute(
+ collection_id, relation_property_id, target_property_id, page_id, rollup_function,
+ )
+
+ return {"result": result, "function": rollup_function}
+
+
+
+
+@router.post("/formula/evaluate")
+def evaluate_formula(request: Request, body: dict = Body(default={})):
+ """Evaluate a formula expression."""
+
+ expression = body.get("expression", "")
+ context = body.get("context", {})
+
+ if not expression:
+ raise HTTPException(status_code=400, detail="expression is required")
+
+ from app.services.formula_engine import FormulaEngine
+ engine = FormulaEngine()
+ result = engine.evaluate(expression, context)
+
+ return {"result": result, "expression": expression}
+
+
+# ── v1.7.0 View Management ──
diff --git a/app/routers/collections/structure.py b/app/routers/collections/structure.py
new file mode 100644
index 0000000..e184721
--- /dev/null
+++ b/app/routers/collections/structure.py
@@ -0,0 +1,267 @@
+"""FlowDeck — Collections : structure.
+
+Découpe A28 de l'ancien app/routers/collections.py (2 622 lignes, 53 routes) — un module par concern, contrat inchangé.
+"""
+from __future__ import annotations
+
+import json
+import logging
+import sqlite3
+
+from fastapi import APIRouter, Body, HTTPException, Request
+
+from app.db import get_conn
+from app.services.automations import fire_event, run_event_sync
+from app.services.property_types import (
+ apply_auto_properties,
+)
+
+from ._common import _collection_properties, _current_user
+
+logger = logging.getLogger(__name__)
+router = APIRouter(tags=["collections"], prefix="/db")
+
+
+
+
+# ── v1.8.0 Sub-items & Dependencies ──
+
+
+@router.get("/{collection_id}/pages/{page_id}/sub-items")
+def list_sub_items(request: Request, collection_id: int, page_id: int):
+ """API: list sub-items of a page."""
+ with get_conn() as conn:
+ rows = conn.execute(
+ "SELECT * FROM collection_pages WHERE parent_id=? ORDER BY position",
+ (page_id,),
+ ).fetchall()
+ return {"sub_items": [dict(r) for r in rows]}
+
+
+
+
+@router.post("/{collection_id}/pages/{page_id}/sub-items")
+def create_sub_item(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})):
+ """API: create a sub-item under a page."""
+
+ title = body.get("title", "New sub-item").strip()
+ if not title:
+ raise HTTPException(status_code=400, detail="title is required")
+
+ with get_conn() as conn:
+ parent = conn.execute("SELECT id FROM collection_pages WHERE id=? AND collection_id=?", (page_id, collection_id)).fetchone()
+ if not parent:
+ raise HTTPException(status_code=404, detail="Parent page not found")
+
+ max_pos = conn.execute(
+ "SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE parent_id=?",
+ (page_id,),
+ ).fetchone()[0]
+
+ sub_props = body.get("properties", {}) or {}
+ apply_auto_properties(
+ _collection_properties(conn, collection_id),
+ sub_props,
+ _current_user(request),
+ is_create=True,
+ )
+
+ cur = conn.execute(
+ "INSERT INTO collection_pages (collection_id, title, parent_id, position, property_values_json) VALUES (?, ?, ?, ?, ?)",
+ (collection_id, title, page_id, max_pos, json.dumps(sub_props)),
+ )
+ conn.commit()
+ new_id = cur.lastrowid
+
+ run_event_sync(fire_event("page.created", {
+ "page_id": new_id,
+ "collection_id": collection_id,
+ "parent_id": page_id,
+ "title": title,
+ "properties": body.get("properties", {}),
+ }))
+ run_event_sync(fire_event("collection.page.created", {
+ "page_id": new_id,
+ "collection_id": collection_id,
+ "title": title,
+ }))
+ return {"id": new_id, "title": title, "parent_id": page_id, "status": "created"}
+
+
+
+
+@router.get("/{collection_id}/pages/{page_id}/status-aggregate")
+def aggregate_child_status(request: Request, collection_id: int, page_id: int):
+ """API: compute aggregate status from children."""
+ with get_conn() as conn:
+ children = conn.execute(
+ "SELECT property_values_json FROM collection_pages WHERE parent_id=?",
+ (page_id,),
+ ).fetchall()
+
+ statuses = []
+ for c in children:
+ props = json.loads(c["property_values_json"])
+ for v in props.values():
+ if isinstance(v, str) and v:
+ statuses.append(v)
+
+ total = len(statuses)
+ if total == 0:
+ return {"total": 0, "done": 0, "all_done": False}
+
+ done = sum(1 for s in statuses if s.lower() in ("done", "complete", "completed", "terminé"))
+ return {"total": total, "done": done, "all_done": done == total}
+
+
+
+
+@router.post("/{collection_id}/pages/{page_id}/dependencies")
+def set_dependencies(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})):
+ """API: set blocking dependencies for a page (stored as 'blocks' property)."""
+
+ blocks_ids = body.get("blocks", [])
+
+ with get_conn() as conn:
+ page = conn.execute("SELECT property_values_json FROM collection_pages WHERE id=?", (page_id,)).fetchone()
+ if not page:
+ raise HTTPException(status_code=404, detail="Page not found")
+
+ props = json.loads(page["property_values_json"])
+ props["blocks"] = blocks_ids
+
+ conn.execute(
+ "UPDATE collection_pages SET property_values_json=? WHERE id=?",
+ (json.dumps(props), page_id),
+ )
+ conn.commit()
+
+ return {"page_id": page_id, "blocks": blocks_ids, "status": "updated"}
+
+
+
+
+@router.post("/{collection_id}/pages/{page_id}/check-deps")
+def check_dependencies(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})):
+ """API: check if a page can transition to a new status."""
+
+ body.get("new_status", "Done")
+
+ with get_conn() as conn:
+ page = conn.execute("SELECT property_values_json FROM collection_pages WHERE id=?", (page_id,)).fetchone()
+ if not page:
+ raise HTTPException(status_code=404, detail="Page not found")
+
+ props = json.loads(page["property_values_json"])
+ blocks_ids = props.get("blocks", [])
+
+ if not blocks_ids:
+ return {"can_transition": True, "blocked_by": []}
+
+ # Check blocked pages status
+ placeholders = ",".join("?" for _ in blocks_ids)
+ blocked = conn.execute(
+ f"SELECT id, title, property_values_json FROM collection_pages WHERE id IN ({placeholders})",
+ blocks_ids,
+ ).fetchall()
+
+ blockers = []
+ for b in blocked:
+ bprops = json.loads(b["property_values_json"])
+ bstatus = None
+ for v in bprops.values():
+ if isinstance(v, str) and v:
+ bstatus = v
+ break
+ if bstatus and bstatus.lower() not in ("done", "complete", "completed", "terminé"):
+ blockers.append({"id": b["id"], "title": b["title"], "status": bstatus})
+
+ return {
+ "can_transition": len(blockers) == 0,
+ "blocked_by": blockers,
+ }
+
+
+# ── v4.1.0: Data Sources & Linked Databases ──
+
+
+
+
+# ── v4.1.0: Data Sources & Linked Databases ──
+
+
+@router.get("/{collection_id}/sources/api")
+def list_data_sources(request: Request, collection_id: int):
+ """API: list all data sources for a collection."""
+ with get_conn() as conn:
+ coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
+ if not coll:
+ raise HTTPException(status_code=404, detail="Collection not found")
+
+ rows = conn.execute(
+ "SELECT * FROM collection_data_sources WHERE collection_id=? ORDER BY position",
+ (collection_id,),
+ ).fetchall()
+ return {"sources": [dict(r) for r in rows]}
+
+
+
+
+@router.post("/{collection_id}/sources/api")
+def add_data_source(request: Request, collection_id: int, body: dict = Body(default={})):
+ """API: add a data source to a collection."""
+
+ source_collection_id = body.get("source_collection_id")
+ if not source_collection_id:
+ raise HTTPException(status_code=400, detail="source_collection_id is required")
+
+ source_name = body.get("source_name", "").strip()
+ is_linked = body.get("is_linked", False)
+
+ with get_conn() as conn:
+ # Verify both collections exist
+ for cid in (collection_id, source_collection_id):
+ if not conn.execute("SELECT id FROM collections WHERE id=?", (cid,)).fetchone():
+ raise HTTPException(status_code=404, detail=f"Collection {cid} not found")
+
+ max_pos = conn.execute(
+ "SELECT COALESCE(MAX(position), -1) + 1 FROM collection_data_sources WHERE collection_id=?",
+ (collection_id,),
+ ).fetchone()[0]
+
+ try:
+ cur = conn.execute(
+ """INSERT INTO collection_data_sources
+ (collection_id, source_collection_id, source_name, is_linked, position)
+ VALUES (?, ?, ?, ?, ?)""",
+ (collection_id, source_collection_id, source_name, int(is_linked), max_pos),
+ )
+ conn.commit()
+ except sqlite3.IntegrityError:
+ raise HTTPException(status_code=409, detail="This data source already exists in this collection") from None
+
+ return {
+ "id": cur.lastrowid,
+ "collection_id": collection_id,
+ "source_collection_id": source_collection_id,
+ "status": "added",
+ }
+
+
+
+
+@router.delete("/{collection_id}/sources/{source_id}/api")
+def remove_data_source(request: Request, collection_id: int, source_id: int):
+ """API: remove a data source from a collection."""
+ with get_conn() as conn:
+ existing = conn.execute(
+ "SELECT * FROM collection_data_sources WHERE id=? AND collection_id=?",
+ (source_id, collection_id),
+ ).fetchone()
+ if not existing:
+ raise HTTPException(status_code=404, detail="Data source not found")
+
+ conn.execute("DELETE FROM collection_data_sources WHERE id=?", (source_id,))
+ conn.commit()
+
+ return {"id": source_id, "status": "removed"}
diff --git a/app/routers/collections/views.py b/app/routers/collections/views.py
new file mode 100644
index 0000000..c7ffdc1
--- /dev/null
+++ b/app/routers/collections/views.py
@@ -0,0 +1,187 @@
+"""FlowDeck — Collections : views.
+
+Découpe A28 de l'ancien app/routers/collections.py (2 622 lignes, 53 routes) — un module par concern, contrat inchangé.
+"""
+from __future__ import annotations
+
+import json
+import logging
+
+from fastapi import APIRouter, Body, HTTPException, Request
+
+from app.db import get_conn
+from app.services.automations import fire_event, run_event_sync
+
+from ._common import _current_user
+
+logger = logging.getLogger(__name__)
+router = APIRouter(tags=["collections"], prefix="/db")
+
+
+
+
+# ── v1.7.0 View Management ──
+
+
+@router.get("/views/{view_id}/api")
+def get_view_api(request: Request, view_id: int):
+ """API: get a single view config."""
+ with get_conn() as conn:
+ row = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
+ if not row:
+ raise HTTPException(status_code=404, detail="View not found")
+ return dict(row)
+
+
+
+
+@router.put("/views/{view_id}/config")
+def update_view_config(request: Request, view_id: int, body: dict = Body(default={})):
+ """API: update view configuration (group_by, card_size, visible_properties, etc.)."""
+
+ with get_conn() as conn:
+ existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
+ if not existing:
+ raise HTTPException(status_code=404, detail="View not found")
+
+ config = json.loads(existing["config_json"])
+ for key in ("group_by", "sub_group_by", "wip_limits", "card_size", "cover_property",
+ "cover_mode", "card_properties", "visible_properties", "filters", "sorts",
+ "filter_conjunction", "date_property", "date_range_property",
+ "property_groups", "view_type"):
+ if key in body:
+ config[key] = body[key]
+
+ new_type = body.get("view_type") or existing["view_type"]
+ conn.execute(
+ "UPDATE collection_views SET config_json=?, name=COALESCE(?, name), view_type=?, "
+ "updated_at=CURRENT_TIMESTAMP WHERE id=?",
+ (json.dumps(config), body.get("name"), new_type, view_id),
+ )
+ conn.commit()
+
+ return {"id": view_id, "status": "updated", "config": config, "view_type": new_type}
+
+
+
+
+@router.post("/{collection_id}/views/save-as")
+def save_view_as(request: Request, collection_id: int, body: dict = Body(default={})):
+ """API: save current view state as a new named view."""
+
+ name = body.get("name", "New View")
+ config = body.get("config", {})
+ user = _current_user(request)
+ user_id = user.get("id") if user else None
+
+ with get_conn() as conn:
+ coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
+ if not coll:
+ raise HTTPException(status_code=404, detail="Collection not found")
+
+ max_pos = conn.execute(
+ "SELECT COALESCE(MAX(position), -1) + 1 FROM collection_views WHERE collection_id=?",
+ (collection_id,),
+ ).fetchone()[0]
+
+ view_type = body.get("view_type", "table")
+ cur = conn.execute(
+ """INSERT INTO collection_views
+ (collection_id, name, view_type, config_json, position, created_by)
+ VALUES (?, ?, ?, ?, ?, ?)""",
+ (collection_id, name, view_type, json.dumps(config), max_pos, user_id),
+ )
+ conn.commit()
+ new_view_id = cur.lastrowid
+
+ run_event_sync(fire_event("collection.view.created", {
+ "view_id": new_view_id,
+ "collection_id": collection_id,
+ "name": name,
+ "view_type": view_type,
+ }))
+ return {"id": new_view_id, "name": name, "view_type": view_type,
+ "config_json": json.dumps(config), "created_by": user_id, "status": "saved"}
+
+
+
+
+@router.get("/{collection_id}/views/api")
+def list_views_api(request: Request, collection_id: int):
+ """API: list views for a collection visible to the current user.
+
+ Shared/legacy views (``created_by IS NULL``) are visible to everyone;
+ personal views (``created_by = user``) only to their owner.
+ """
+ user = _current_user(request)
+ user_id = user.get("id") if user else None
+ with get_conn() as conn:
+ if user_id is not None:
+ rows = conn.execute(
+ """SELECT * FROM collection_views
+ WHERE collection_id=? AND (created_by IS NULL OR created_by=?)
+ ORDER BY position""",
+ (collection_id, user_id),
+ ).fetchall()
+ else:
+ rows = conn.execute(
+ "SELECT * FROM collection_views WHERE collection_id=? AND created_by IS NULL ORDER BY position",
+ (collection_id,),
+ ).fetchall()
+ return {"views": [dict(r) for r in rows]}
+
+
+
+
+@router.delete("/views/{view_id}/api")
+def delete_view_api(request: Request, view_id: int):
+ """API: delete a saved view."""
+ with get_conn() as conn:
+ existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
+ if not existing:
+ raise HTTPException(status_code=404, detail="View not found")
+ conn.execute("DELETE FROM collection_views WHERE id=?", (view_id,))
+ conn.commit()
+ return {"id": view_id, "status": "deleted"}
+
+
+
+
+@router.post("/views/{view_id}/duplicate")
+def duplicate_view_api(request: Request, view_id: int, body: dict = Body(default={})):
+ """API: duplicate a view (config + type), owned by the current user."""
+
+ with get_conn() as conn:
+ existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
+ if not existing:
+ raise HTTPException(status_code=404, detail="View not found")
+
+ max_pos = conn.execute(
+ "SELECT COALESCE(MAX(position), -1) + 1 FROM collection_views WHERE collection_id=?",
+ (existing["collection_id"],),
+ ).fetchone()[0]
+
+ user = _current_user(request)
+ user_id = user.get("id") if user else None
+ name = body.get("name") or (existing["name"] + " copy")
+ cur = conn.execute(
+ """INSERT INTO collection_views
+ (collection_id, name, view_type, config_json, position, created_by)
+ VALUES (?, ?, ?, ?, ?, ?)""",
+ (existing["collection_id"], name, existing["view_type"],
+ existing["config_json"], max_pos, user_id),
+ )
+ conn.commit()
+ dup_view_id = cur.lastrowid
+
+ run_event_sync(fire_event("collection.view.created", {
+ "view_id": dup_view_id,
+ "collection_id": existing["collection_id"],
+ "name": name,
+ "view_type": existing["view_type"],
+ }))
+ return {"id": dup_view_id, "name": name, "view_type": existing["view_type"],
+ "status": "duplicated"}
+
+
+# ── v1.8.0 Sub-items & Dependencies ──
diff --git a/docs/openapi-v2.json b/docs/openapi-v2.json
index 813cf0a..2766cf3 100644
--- a/docs/openapi-v2.json
+++ b/docs/openapi-v2.json
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "FlowDeck",
- "version": "7.30.0"
+ "version": "7.31.0"
},
"paths": {
"/auth/register": {
diff --git a/tests/test_audit_p0_fixes.py b/tests/test_audit_p0_fixes.py
index a602a4d..6ed3e5d 100644
--- a/tests/test_audit_p0_fixes.py
+++ b/tests/test_audit_p0_fixes.py
@@ -293,9 +293,11 @@ def test_csp_no_cdn_and_vendor(client):
assert conn == "'self' ws://testserver wss://testserver", conn
assert " https:" not in conn and not conn.startswith("https:"), conn
- # vues chart/map : références locales (aucun CDN dans collections.py)
- src = _pathlib.Path("app/routers/collections.py").read_text(encoding="utf-8")
- assert "cdn.jsdelivr" not in src and "unpkg.com" not in src
+ # vues chart/map : références locales (aucun CDN — A28 : le fichier
+ # collections.py est devenu un package, on balaie tous ses modules)
+ for src in _pathlib.Path("app/routers/collections").glob("*.py"):
+ text = src.read_text(encoding="utf-8")
+ assert "cdn.jsdelivr" not in text and "unpkg.com" not in text, src
# assets vendor servis
for path in (