From adf56a2dd811f8abf7d5fe1bd9cec10547c034cb Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Fri, 2 Oct 2026 08:16:03 -0400 Subject: [PATCH] =?UTF-8?q?refactor:=20A28=20lot=203=20=E2=80=94=20collect?= =?UTF-8?q?ions.py=20(2=20622=20L)=20=E2=86=92=20package=2013=20fichiers?= =?UTF-8?q?=20(v7.31.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Découpe par concern de l'ancien app/routers/collections.py (2 622 lignes, 53 endpoints / 52 fonctions) en package `app/routers/collections/` : - 10 modules de routes : crud 337 L (6 r.), 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 + CHART_MAX_GROUPS - __init__.py : ré-exports connus (_validate_page_properties pour automations ; _chart_values/_chart_aggregate/_fmt_number/_render_chart pour les tests) + __all__ Preuve contractuelle : docs/openapi-v2.json régénéré = IDENTIQUE byte-à-byte (509 chemins, ordre préservé). Pièges rattrapés : - docstring d'origine conservé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 2e décorateur (53 endpoints = 52 unités) - CHART_MAX_GROUPS hors détection des helpers (F821) → import ._renderers - test_csp_no_cdn_and_vendor lisait collections.py → balayage du package Reste A28 : board.py 2 101 L (lot 4). suite **1091/1091** · ruff OK · OpenAPI 509 identique · docs à jour --- CHANGELOG.md | 35 + ROADMAP.md | 4 +- VERSION | 2 +- WORKLOAD.md | 2 +- app/main.py | 2 +- app/routers/collections.py | 2622 -------------------- app/routers/collections/__init__.py | 58 + app/routers/collections/_common.py | 220 ++ app/routers/collections/_renderers.py | 667 +++++ app/routers/collections/boards.py | 61 + app/routers/collections/crud.py | 337 +++ app/routers/collections/dashboard_views.py | 214 ++ app/routers/collections/data_api.py | 122 + app/routers/collections/linked.py | 286 +++ app/routers/collections/meta.py | 197 ++ app/routers/collections/pages.py | 184 ++ app/routers/collections/properties.py | 322 +++ app/routers/collections/structure.py | 267 ++ app/routers/collections/views.py | 187 ++ docs/openapi-v2.json | 2 +- tests/test_audit_p0_fixes.py | 8 +- 21 files changed, 3168 insertions(+), 2631 deletions(-) delete mode 100644 app/routers/collections.py create mode 100644 app/routers/collections/__init__.py create mode 100644 app/routers/collections/_common.py create mode 100644 app/routers/collections/_renderers.py create mode 100644 app/routers/collections/boards.py create mode 100644 app/routers/collections/crud.py create mode 100644 app/routers/collections/dashboard_views.py create mode 100644 app/routers/collections/data_api.py create mode 100644 app/routers/collections/linked.py create mode 100644 app/routers/collections/meta.py create mode 100644 app/routers/collections/pages.py create mode 100644 app/routers/collections/properties.py create mode 100644 app/routers/collections/structure.py create mode 100644 app/routers/collections/views.py 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""" - - -
-
Mon
Tue
Wed
-
Thu
Fri
Sat
Sun
-{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""" - - -

{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""" - -
-{min_date} -{max_date} -
-{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"""
""" - elif ptype in ("select", "status"): - options = "".join(f"" for o in prop.get("options", [])) - fields += f"""
""" - elif ptype == "checkbox": - fields += f"""
""" - elif ptype == "date": - fields += f"""
""" - - return _base_html(collection["name"], collection.get("icon", "📝"), view_type, f""" - -
-

New entry in {collection['name']}

-
-{fields} -
- -
- -
- -""") - - -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""" - -
-{min_date} -{max_date} -
-{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""" - -{rows}
TitleProperties
-

{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""" + + +
+
Mon
Tue
Wed
+
Thu
Fri
Sat
Sun
+{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""" + + +

{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""" + +
+{min_date} +{max_date} +
+{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"""
""" + elif ptype in ("select", "status"): + options = "".join(f"" for o in prop.get("options", [])) + fields += f"""
""" + elif ptype == "checkbox": + fields += f"""
""" + elif ptype == "date": + fields += f"""
""" + + return _base_html(collection["name"], collection.get("icon", "📝"), view_type, f""" + +
+

New entry in {collection['name']}

+
+{fields} +
+ +
+ +
+ +""") + + + + + + +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""" + +
+{min_date} +{max_date} +
+{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""" + +{rows}
TitleProperties
+

{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 (