From 6a5fe0524ad11566909c9b2ca8bf9140428bae29 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Thu, 1 Oct 2026 23:26:45 -0400 Subject: [PATCH] =?UTF-8?q?refactor:=20A28=20lot=201=20=E2=80=94=20api=5Fv?= =?UTF-8?q?2.py=20(2=20110=20L)=20=E2=86=92=20package=2014=20fichiers=20(v?= =?UTF-8?q?7.29.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/api_v2.py (2 110 lignes, 115 routes) en package `app/routers/api_v2/` : - 12 modules de routes : collections 566 L (23 r.), engagement 338 (21), workspaces 230 (9), templates_io 205 (9), webhooks 195 (8), identity 195 (7), views 164 (8), sharing 160 (8), properties 151 (7), planning 148 (7), projects 93 (4), admin 91 (4) - `_common.py` : helpers partagés (_hash, _v2_rate_check) - `__init__.py` : router = APIRouter(prefix="/api/v2") + include_router sur les routers de sections (sans prefix, tags « api-v2 ») Preuve contractuelle : `docs/openapi-v2.json` régénéré = IDENTIQUE byte-à-byte (0 changement de chemin/tag/operation_id). Seul importateur (app/main.py : from app.routers.api_v2 import router) fonctionne via le package. En-tête d'imports copié par module puis émondé par ruff --fix (143 imports morts), I001 réordonnés. Reste A28 : dashboard.py 2 735 L, collections.py 2 622 L, board.py 2 101 L (même recette, lots suivants). suite **1091/1091** · ruff OK · OpenAPI 509 identique · docs à jour --- CHANGELOG.md | 25 + ROADMAP.md | 4 +- VERSION | 2 +- WORKLOAD.md | 2 +- app/main.py | 2 +- app/routers/api_v2.py | 2110 ---------------------------- app/routers/api_v2/__init__.py | 42 + app/routers/api_v2/_common.py | 36 + app/routers/api_v2/admin.py | 91 ++ app/routers/api_v2/collections.py | 566 ++++++++ app/routers/api_v2/engagement.py | 338 +++++ app/routers/api_v2/identity.py | 195 +++ app/routers/api_v2/planning.py | 148 ++ app/routers/api_v2/projects.py | 93 ++ app/routers/api_v2/properties.py | 151 ++ app/routers/api_v2/sharing.py | 160 +++ app/routers/api_v2/templates_io.py | 205 +++ app/routers/api_v2/views.py | 164 +++ app/routers/api_v2/webhooks.py | 195 +++ app/routers/api_v2/workspaces.py | 230 +++ docs/openapi-v2.json | 2 +- 21 files changed, 2645 insertions(+), 2116 deletions(-) delete mode 100644 app/routers/api_v2.py create mode 100644 app/routers/api_v2/__init__.py create mode 100644 app/routers/api_v2/_common.py create mode 100644 app/routers/api_v2/admin.py create mode 100644 app/routers/api_v2/collections.py create mode 100644 app/routers/api_v2/engagement.py create mode 100644 app/routers/api_v2/identity.py create mode 100644 app/routers/api_v2/planning.py create mode 100644 app/routers/api_v2/projects.py create mode 100644 app/routers/api_v2/properties.py create mode 100644 app/routers/api_v2/sharing.py create mode 100644 app/routers/api_v2/templates_io.py create mode 100644 app/routers/api_v2/views.py create mode 100644 app/routers/api_v2/webhooks.py create mode 100644 app/routers/api_v2/workspaces.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ca74d7..102e485 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog - FlowDeck +## v7.29.0 (2026-10-01) — Audit : A28 lot 1 (api_v2 → package 14 fichiers) + +### Changed + +- **A28 lot 1** : `app/routers/api_v2.py` (**2 110 lignes, 115 routes**) + devient le package `app/routers/api_v2/` — un module par concern : + · `collections` 566 L (23 routes), `engagement` 338 (21), `workspaces` + 230 (9), `templates_io` 205 (9), `webhooks` 195 (8), `identity` 195 (7), + `views` 164 (8), `sharing` 160 (8), `properties` 151 (7), + `planning` 148 (7), `projects` 93 (4), `admin` 91 (4) + · `_common.py` : helpers partagés (`_hash`, `_v2_rate_check`) + · `__init__.py` : `router = APIRouter(prefix="/api/v2")` + agrégat + `include_router` (section routers sans prefix, tags `api-v2`) +- Preuve contractuelle : **`docs/openapi-v2.json` régénéré = identique + byte-à-byte** (0 changement de chemin, tag ni operation_id) +- Seul importateur (`app/main.py` → `from app.routers.api_v2 import + router`) fonctionne via le package ; en-tête d'imports copié par module, + émondé par `ruff --fix` (143 imports morts supprimés automatiquement) + +### Notes + +- Reste A28 : `dashboard.py` 2 735 L, `collections.py` 2 622 L, + `board.py` 2 101 L (même recette, lots suivants) +- Suite complète : **1091/1091** · ruff OK + ## v7.28.0 (2026-10-01) — Audit : A42 TERMINÉ (client httpx partagé) ### Changed diff --git a/ROADMAP.md b/ROADMAP.md index 1704a4d..235ef63 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**.* +- [ ] **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`. **Reste A28** : `dashboard.py` 2 735 L, `collections.py` 2 622 L, `board.py` 2 101 L (même recette) → suite 1091/1091, version 7.29.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. +→ **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 — reste : dashboard/collections/board. diff --git a/VERSION b/VERSION index 757a860..d88eca0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7.28.0 +7.29.0 diff --git a/WORKLOAD.md b/WORKLOAD.md index 589ba04..e188dfd 100644 --- a/WORKLOAD.md +++ b/WORKLOAD.md @@ -1,6 +1,6 @@ # WORKLOAD — FlowDeck Notion Clone -> **Début**: 2026-07-08 | **Version**: v7.28.0 (audit — A42 TERMINÉ : client httpx partagé par boucle) | **Statut**: EN COURS 🔄 +> **Début**: 2026-07-08 | **Version**: v7.29.0 (audit — A28 lot 1 : api_v2.py 2 110 L → package 14 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 3b18d5c..10972e9 100644 --- a/app/main.py +++ b/app/main.py @@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI): app = FastAPI( title="FlowDeck", - version="7.28.0", + version="7.29.0", docs_url="/docs", redoc_url="/redoc", lifespan=lifespan, diff --git a/app/routers/api_v2.py b/app/routers/api_v2.py deleted file mode 100644 index dd0e7ec..0000000 --- a/app/routers/api_v2.py +++ /dev/null @@ -1,2110 +0,0 @@ -"""FlowDeck — Public API v2 (v6.3.0). - -Full Notion-clone REST wrapper around existing services. -Thin wrappers: one code path, JSON only, Bearer+scopes, pagination, -RFC7807 errors delegated to global handler, audit + idempotency. -""" -from __future__ import annotations - -import hashlib -import json -import logging -import secrets -from datetime import datetime - -from fastapi import APIRouter, Body, Header, HTTPException, Request -from fastapi.responses import JSONResponse, Response - -from app.db import get_conn -from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers - audit_log, - check_idempotency, - check_v2_rate_limit, - get_bearer_user, - has_scope, - paginate_headers, - parse_pagination, - require_scope, - row_to_dict, - store_idempotency, - to_iso8601, - validate_scopes_input, -) -from app.services.automations import fire_event as _fire_event -from app.services.automations import run_event_sync -from app.services.publish import fire_published, fire_unpublished, publish, unpublish - -logger = logging.getLogger(__name__) -router = APIRouter(prefix="/api/v2", tags=["api-v2"]) - -def _hash(token: str) -> str: - return hashlib.sha256(token.encode()).hexdigest() - -def _v2_rate_check(request: Request, user: dict) -> None: - ip = request.client.host if request.client else "unknown" - th = user.get("_token_hash") - if not check_v2_rate_limit(th, ip): - raise HTTPException(status_code=429, detail="Rate limit exceeded: 300 req/min per token") - -# ── Tokens ──────────────────────────────────────────────────────────────── - -@router.post("/tokens") -def create_token(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - name = (body.get("name") or "API token").strip()[:100] - scopes = validate_scopes_input(body.get("scopes") or "read,write") - expires_at = body.get("expires_at") - # Idempotency - idem = check_idempotency(request, user["id"]) - if idem: - return JSONResponse(content=idem["data"], status_code=idem["status"]) - token = f"fd_{secrets.token_urlsafe(32)}" - prefix = token[:12] - th = _hash(token) - exp_val = None - if expires_at: - try: - # accept ISO string - exp_val = str(expires_at) - # validate parse - datetime.fromisoformat(exp_val.replace("Z", "+00:00")) - except Exception as err: - raise HTTPException(400, "Invalid expires_at, use ISO-8601") from err - with get_conn() as conn: - try: - cur = conn.execute( - "INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, scopes, expires_at) VALUES (?, ?, ?, ?, ?, ?)", - (user["id"], name, th, prefix, scopes, exp_val), - ) - conn.commit() - tid = cur.lastrowid - except Exception as e: - raise HTTPException(409, f"Token creation failed: {e}") from None - row = conn.execute("SELECT id, name, token_prefix, scopes, expires_at, created_at FROM api_tokens WHERE id=?", (tid,)).fetchone() - audit_log(user, "token.create", "api_token", tid, f"scopes={scopes}", request) - data = {"id": tid, "name": row["name"], "token": token, "prefix": prefix, "scopes": scopes, "expires_at": to_iso8601(row["expires_at"]) if row["expires_at"] else None, "note": "Copy token now — shown once. Use as Authorization: Bearer "} - key = (request.headers.get("Idempotency-Key") or request.headers.get("idempotency-key") or "").strip() - if key: - store_idempotency(key, user["id"], data, 200) - return data - -@router.get("/tokens") -def list_tokens(request: Request, authorization: str | None = Header(default=None)): - user = get_bearer_user(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - rows = conn.execute("SELECT id, name, token_prefix, scopes, expires_at, last_used_at, created_at, revoked FROM api_tokens WHERE user_id=? ORDER BY created_at DESC", (user["id"],)).fetchall() - out = [] - for r in rows: - d = dict(r) - d["created_at"] = to_iso8601(d.get("created_at")) - d["expires_at"] = to_iso8601(d.get("expires_at")) if d.get("expires_at") else None - d["last_used_at"] = to_iso8601(d.get("last_used_at")) if d.get("last_used_at") else None - # never expose hash - out.append({k: v for k, v in d.items() if k != "token_hash"}) - return {"tokens": out} - -@router.delete("/tokens/{token_id}") -def revoke_token(token_id: int, request: Request, authorization: str | None = Header(default=None)): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - row = conn.execute("SELECT id, user_id FROM api_tokens WHERE id=?", (token_id,)).fetchone() - if not row: - raise HTTPException(404, "Token not found") - if row["user_id"] != user["id"] and not user.get("is_admin"): - raise HTTPException(403, "Not your token") - conn.execute("UPDATE api_tokens SET revoked=1 WHERE id=?", (token_id,)) - conn.commit() - audit_log(user, "token.revoke", "api_token", token_id, "", request) - return {"id": token_id, "status": "revoked"} - -@router.post("/tokens/{token_id}/rotate") -def rotate_token(token_id: int, request: Request, authorization: str | None = Header(default=None)): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - row = conn.execute("SELECT id, user_id, name, scopes FROM api_tokens WHERE id=?", (token_id,)).fetchone() - if not row: - raise HTTPException(404, "Token not found") - if row["user_id"] != user["id"] and not user.get("is_admin"): - raise HTTPException(403, "Not your token") - # revoke old - conn.execute("UPDATE api_tokens SET revoked=1 WHERE id=?", (token_id,)) - new_token = f"fd_{secrets.token_urlsafe(32)}" - th = _hash(new_token) - prefix = new_token[:12] - cur = conn.execute("INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, scopes) VALUES (?, ?, ?, ?, ?)", (row["user_id"], row["name"], th, prefix, row["scopes"] or "read,write")) - conn.commit() - nid = cur.lastrowid - audit_log(user, "token.rotate", "api_token", token_id, f"new_id={nid}", request) - return {"id": nid, "token": new_token, "prefix": prefix, "scopes": row["scopes"], "note": "Copy token now — shown once"} - -# ── Users ───────────────────────────────────────────────────────────────── - -@router.get("/users/me") -def get_me(request: Request, authorization: str | None = Header(default=None)): - user = get_bearer_user(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - row = conn.execute("SELECT id, login, full_name, email, avatar_url, avatar_color, is_admin, is_active, auth_method, sidebar_config, notification_prefs, timezone, created_at FROM users WHERE id=?", (user["id"],)).fetchone() - if not row: - raise HTTPException(404, "User not found") - d = row_to_dict(row) - # parse json prefs - for k in ("notification_prefs", "sidebar_config"): - if isinstance(d.get(k), str): - try: - d[k] = json.loads(d[k] or "{}") - except Exception: - logger.exception("get_me") - # never expose secrets - return d - -@router.patch("/users/me") -def patch_me(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - allowed = {"full_name", "email", "avatar_color", "notification_prefs", "sidebar_config", "timezone"} - updates = {} - for k in allowed: - if k in body: - updates[k] = body[k] - if not updates: - raise HTTPException(400, "No updatable fields") - # validation - if "email" in updates and updates["email"] and "@" not in str(updates["email"]): - raise HTTPException(400, "Invalid email") - with get_conn() as conn: - sets = [] - params = [] - for k, v in updates.items(): - if k in ("notification_prefs", "sidebar_config"): - v = json.dumps(v) if isinstance(v, (dict, list)) else str(v) - sets.append(f"{k}=?") - params.append(v) - params.append(user["id"]) - conn.execute(f"UPDATE users SET {', '.join(sets)} WHERE id=?", params) - conn.commit() - row = conn.execute("SELECT id, login, full_name, email, avatar_url, avatar_color, is_admin, timezone, notification_prefs FROM users WHERE id=?", (user["id"],)).fetchone() - audit_log(user, "user.update", "user", user["id"], "", request) - return row_to_dict(row) - -@router.get("/users/search") -def search_users(request: Request, q: str = "", authorization: str | None = Header(default=None)): - user = get_bearer_user(request, authorization) - _v2_rate_check(request, user) - q = (q or request.query_params.get("q") or "").strip() - if not q: - return {"users": []} - like = f"%{q}%" - with get_conn() as conn: - rows = conn.execute("SELECT id, login, full_name, email, avatar_url, avatar_color FROM users WHERE login LIKE ? OR email LIKE ? OR full_name LIKE ? LIMIT 20", (like, like, like)).fetchall() - return {"users": [dict(r) for r in rows]} - -# ── Workspaces ──────────────────────────────────────────────────────────── - -@router.get("/workspaces") -def list_workspaces(request: Request, authorization: str | None = Header(default=None)): - user = get_bearer_user(request, authorization) - _v2_rate_check(request, user) - limit, offset = parse_pagination(request) - with get_conn() as conn: - total = conn.execute("SELECT COUNT(*) FROM workspaces WHERE owner_id=? OR id IN (SELECT workspace_id FROM workspace_members WHERE user_id=?)", (user["id"], user["id"])).fetchone()[0] - rows = conn.execute("SELECT w.*, wm.role FROM workspaces w LEFT JOIN workspace_members wm ON wm.workspace_id=w.id AND wm.user_id=? WHERE w.owner_id=? OR w.id IN (SELECT workspace_id FROM workspace_members WHERE user_id=?) ORDER BY w.created_at DESC LIMIT ? OFFSET ?", (user["id"], user["id"], user["id"], limit, offset)).fetchall() - out = [] - for r in rows: - d = dict(r) - d["created_at"] = to_iso8601(d.get("created_at")) - try: - d["settings"] = json.loads(d.get("settings_json") or "{}") - except Exception: - d["settings"] = {} - out.append(d) - return {"workspaces": out, "total": total, "limit": limit, "offset": offset} - -@router.post("/workspaces") -def create_workspace(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - idem = check_idempotency(request, user["id"]) - if idem: - return JSONResponse(content=idem["data"], status_code=idem["status"]) - name = (body.get("name") or "").strip() - if not name: - raise HTTPException(400, "name is required") - settings_json = json.dumps(body.get("settings") or body.get("settings_json") or {}) - with get_conn() as conn: - cur = conn.execute("INSERT INTO workspaces (name, owner_id, settings_json) VALUES (?, ?, ?)", (name, user["id"], settings_json)) - wid = cur.lastrowid - # owner is implicitly admin member - try: - conn.execute("INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, 'owner')", (wid, user["id"])) - except Exception: - logger.exception("create_workspace") - conn.commit() - row = conn.execute("SELECT * FROM workspaces WHERE id=?", (wid,)).fetchone() - audit_log(user, "workspace.create", "workspace", wid, name, request) - data = {"id": wid, "name": name, "owner_id": user["id"], "status": "created", "workspace": row_to_dict(row)} - key = (request.headers.get("Idempotency-Key") or "").strip() - if key: - store_idempotency(key, user["id"], data, 200) - return JSONResponse(content=data, status_code=201) - -@router.get("/workspaces/{workspace_id}") -def get_workspace(workspace_id: int, request: Request, authorization: str | None = Header(default=None)): - user = get_bearer_user(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - row = conn.execute("SELECT * FROM workspaces WHERE id=?", (workspace_id,)).fetchone() - if not row: - raise HTTPException(404, "Workspace not found") - # ACL: must be member or owner - member = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?", (workspace_id, user["id"])).fetchone() - is_owner = row["owner_id"] == user["id"] - if not is_owner and not member and not user.get("is_admin"): - raise HTTPException(404, "Workspace not found") - members = conn.execute("SELECT u.id, u.login, u.full_name, u.avatar_url, wm.role FROM workspace_members wm JOIN users u ON u.id=wm.user_id WHERE wm.workspace_id=? ORDER BY wm.joined_at", (workspace_id,)).fetchall() - d = row_to_dict(row) - d["members"] = [dict(m) for m in members] - return d - -@router.patch("/workspaces/{workspace_id}") -def patch_workspace(workspace_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - row = conn.execute("SELECT * FROM workspaces WHERE id=?", (workspace_id,)).fetchone() - if not row: - raise HTTPException(404, "Workspace not found") - if row["owner_id"] != user["id"] and not user.get("is_admin"): - # check admin member - mem = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?", (workspace_id, user["id"])).fetchone() - if not mem or mem["role"] not in ("owner", "admin"): - raise HTTPException(403, "Only owner/admin can edit workspace") - name = body.get("name", row["name"]) - sj = body.get("settings_json") or body.get("settings") - if sj is not None: - sj = json.dumps(sj) if isinstance(sj, (dict, list)) else str(sj) - else: - sj = row["settings_json"] - conn.execute("UPDATE workspaces SET name=?, settings_json=? WHERE id=?", (name, sj, workspace_id)) - conn.commit() - audit_log(user, "workspace.update", "workspace", workspace_id, "", request) - return {"id": workspace_id, "status": "updated"} - -@router.delete("/workspaces/{workspace_id}") -def delete_workspace(workspace_id: int, request: Request, authorization: str | None = Header(default=None)): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - row = conn.execute("SELECT * FROM workspaces WHERE id=?", (workspace_id,)).fetchone() - if not row: - raise HTTPException(404, "Workspace not found") - if row["owner_id"] != user["id"] and not user.get("is_admin"): - raise HTTPException(403, "Only owner can delete workspace") - conn.execute("DELETE FROM workspaces WHERE id=?", (workspace_id,)) - conn.commit() - audit_log(user, "workspace.delete", "workspace", workspace_id, "", request) - return {"id": workspace_id, "status": "deleted"} - -@router.get("/workspaces/{workspace_id}/members") -def list_workspace_members(workspace_id: int, request: Request, authorization: str | None = Header(default=None)): - user = get_bearer_user(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - ws = conn.execute("SELECT id FROM workspaces WHERE id=?", (workspace_id,)).fetchone() - if not ws: - raise HTTPException(404, "Workspace not found") - rows = conn.execute("SELECT u.id, u.login, u.full_name, u.avatar_url, wm.role, wm.joined_at FROM workspace_members wm JOIN users u ON u.id=wm.user_id WHERE wm.workspace_id=? ORDER BY wm.joined_at", (workspace_id,)).fetchall() - return {"members": [row_to_dict(r) for r in rows]} - -@router.post("/workspaces/{workspace_id}/members") -def invite_member(workspace_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - target_id = body.get("user_id") or body.get("uid") - email = (body.get("email") or "").strip() - role = (body.get("role") or "editor").strip().lower() - if role not in ("owner", "admin", "editor", "viewer", "commenter"): - raise HTTPException(400, "Invalid role") - with get_conn() as conn: - ws = conn.execute("SELECT owner_id FROM workspaces WHERE id=?", (workspace_id,)).fetchone() - if not ws: - raise HTTPException(404, "Workspace not found") - # only owner/admin can invite - if ws["owner_id"] != user["id"] and not user.get("is_admin"): - mem = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?", (workspace_id, user["id"])).fetchone() - if not mem or mem["role"] not in ("owner", "admin"): - raise HTTPException(403, "Only owner/admin can invite") - uid = target_id - if not uid and email: - u = conn.execute("SELECT id FROM users WHERE email=?", (email,)).fetchone() - if not u: - raise HTTPException(404, f"User with email {email} not found") - uid = u["id"] - if not uid: - raise HTTPException(400, "user_id or email required") - try: - conn.execute("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, ?)", (workspace_id, uid, role)) - except Exception: - conn.execute("UPDATE workspace_members SET role=? WHERE workspace_id=? AND user_id=?", (role, workspace_id, uid)) - conn.commit() - audit_log(user, "workspace.invite", "workspace", workspace_id, f"uid={uid} role={role}", request) - return {"workspace_id": workspace_id, "user_id": uid, "role": role, "status": "added"} - -@router.patch("/workspaces/{workspace_id}/members/{uid}") -def update_member_role(workspace_id: int, uid: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - role = (body.get("role") or "").strip().lower() - if role not in ("owner", "admin", "editor", "viewer", "commenter"): - raise HTTPException(400, "Invalid role") - with get_conn() as conn: - ws = conn.execute("SELECT owner_id FROM workspaces WHERE id=?", (workspace_id,)).fetchone() - if not ws: - raise HTTPException(404, "Workspace not found") - if ws["owner_id"] != user["id"] and not user.get("is_admin"): - mem = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?", (workspace_id, user["id"])).fetchone() - if not mem or mem["role"] not in ("owner", "admin"): - raise HTTPException(403, "Only owner/admin can change roles") - conn.execute("UPDATE workspace_members SET role=? WHERE workspace_id=? AND user_id=?", (role, workspace_id, uid)) - if conn.total_changes == 0: - raise HTTPException(404, "Member not found") - conn.commit() - audit_log(user, "workspace.role_change", "workspace", workspace_id, f"uid={uid} role={role}", request) - return {"workspace_id": workspace_id, "user_id": uid, "role": role, "status": "updated"} - -@router.delete("/workspaces/{workspace_id}/members/{uid}") -def remove_member(workspace_id: int, uid: int, request: Request, authorization: str | None = Header(default=None)): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - ws = conn.execute("SELECT owner_id FROM workspaces WHERE id=?", (workspace_id,)).fetchone() - if not ws: - raise HTTPException(404, "Workspace not found") - if ws["owner_id"] != user["id"] and not user.get("is_admin"): - mem = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?", (workspace_id, user["id"])).fetchone() - if not mem or mem["role"] not in ("owner", "admin"): - raise HTTPException(403, "Only owner/admin can remove members") - conn.execute("DELETE FROM workspace_members WHERE workspace_id=? AND user_id=?", (workspace_id, uid)) - conn.commit() - audit_log(user, "workspace.remove_member", "workspace", workspace_id, f"uid={uid}", request) - return {"workspace_id": workspace_id, "user_id": uid, "status": "removed"} - -# ── Collections ─────────────────────────────────────────────────────────── - -@router.get("/collections") -def list_collections_v2(request: Request, authorization: str | None = Header(default=None)): - user = get_bearer_user(request, authorization) - _v2_rate_check(request, user) - limit, offset = parse_pagination(request) - ws_filter = request.query_params.get("workspace_id") - q = (request.query_params.get("query") or "").strip() - with get_conn() as conn: - where = [] - params: list = [] - if ws_filter: - try: - wid = int(ws_filter) - where.append("c.workspace_id=?") - params.append(wid) - except ValueError: - pass - if q: - where.append("(c.name LIKE ? OR c.description LIKE ?)") - like = f"%{q}%" - params.extend([like, like]) - clause = ("WHERE " + " AND ".join(where)) if where else "" - total = conn.execute(f"SELECT COUNT(*) FROM collections c {clause}", params).fetchone()[0] - rows = conn.execute(f"SELECT c.* FROM collections c {clause} ORDER BY c.name LIMIT ? OFFSET ?", (*params, limit, offset)).fetchall() - cols = [] - for r in rows: - d = row_to_dict(r) - # filter by visibility: skip private not visible (best-effort) - cols.append(d) - resp = {"collections": cols, "total": total, "limit": limit, "offset": offset} - return JSONResponse(content=resp, headers=paginate_headers(total)) - -@router.post("/collections") -def create_collection_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - idem = check_idempotency(request, user["id"]) - if idem: - return JSONResponse(content=idem["data"], status_code=idem["status"]) - name = (body.get("name") or "").strip() - if not name: - raise HTTPException(400, "name is required") - description = body.get("description", "") - icon = body.get("icon", "📋") - workspace_id = body.get("workspace_id") - schema = body.get("schema") or body.get("schema_json") or [] - if isinstance(schema, str): - try: - schema = json.loads(schema) - except Exception: - schema = [] - schema_json = json.dumps(schema) - with get_conn() as conn: - cur = conn.execute("INSERT INTO collections (name, description, icon, schema_json, workspace_id, created_by) VALUES (?, ?, ?, ?, ?, ?)", (name, description, icon, schema_json, workspace_id, user["id"])) - cid = cur.lastrowid - # materialize properties if schema provided — A25 : PAS de try ici, - # une exception doit interrompre la transaction (sinon la collection est - # commitée sans son schéma et l'erreur disparaît). - from app.services.db_templates import materialize_properties - materialize_properties(conn, cid, schema) - # default view - conn.execute("INSERT INTO collection_views (collection_id, name, view_type, config_json) VALUES (?,?,?,?)", (cid, "Default View", "table", json.dumps({"visible_properties": ["Title"]}))) - conn.commit() - row = conn.execute("SELECT * FROM collections WHERE id=?", (cid,)).fetchone() - audit_log(user, "collection.create", "collection", cid, name, request) - data = {"id": cid, "name": name, "status": "created", "collection": row_to_dict(row)} - key = (request.headers.get("Idempotency-Key") or "").strip() - if key: - store_idempotency(key, user["id"], data, 201) - return JSONResponse(content=data, status_code=201) - -@router.get("/collections/{collection_id}") -def get_collection_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)): - user = get_bearer_user(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - row = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone() - if not row: - raise HTTPException(404, "Collection not found") - pages = conn.execute("SELECT id, title, icon, position, property_values_json, created_at FROM collection_pages WHERE collection_id=? ORDER BY position LIMIT 50", (collection_id,)).fetchall() - d = row_to_dict(row) - d["pages"] = [row_to_dict(p) for p in pages] - return d - -@router.patch("/collections/{collection_id}") -def patch_collection_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - row = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone() - if not row: - raise HTTPException(404, "Collection not found") - name = body.get("name", row["name"]) - description = body.get("description", row["description"]) - icon = body.get("icon", row["icon"]) - schema = body.get("schema") or body.get("schema_json") - if schema is not None: - sj = json.dumps(schema) if isinstance(schema, (list, dict)) else str(schema) - else: - sj = row["schema_json"] - conn.execute("UPDATE collections SET name=?, description=?, icon=?, schema_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (name, description, icon, sj, collection_id)) - conn.commit() - audit_log(user, "collection.update", "collection", collection_id, "", request) - return {"id": collection_id, "status": "updated"} - -@router.delete("/collections/{collection_id}") -def delete_collection_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - row = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone() - if not row: - raise HTTPException(404, "Collection not found") - conn.execute("DELETE FROM collections WHERE id=?", (collection_id,)) - conn.commit() - audit_log(user, "collection.delete", "collection", collection_id, "", request) - return {"id": collection_id, "status": "deleted"} - -@router.post("/collections/{collection_id}/linked") -def create_linked_db(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - name = (body.get("name") or "").strip() or f"Linked DB {collection_id}" - with get_conn() as conn: - src = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone() - if not src: - raise HTTPException(404, "Collection not found") - cur = conn.execute("INSERT INTO collections (name, description, icon, schema_json, workspace_id, created_by) VALUES (?, ?, ?, ?, ?, ?)", (name, src["description"], src["icon"], src["schema_json"], src["workspace_id"] if "workspace_id" in src.keys() else None, user["id"])) - nid = cur.lastrowid - # copy data source as linked - try: - conn.execute("INSERT INTO collection_data_sources (collection_id, source_collection_id, is_linked) VALUES (?, ?, 1)", (nid, collection_id)) - except Exception: - logger.exception("create_linked_db") - # copy views + properties (light) - rows = conn.execute("SELECT * FROM collection_properties WHERE collection_id=?", (collection_id,)).fetchall() - for p in rows: - try: - conn.execute("INSERT INTO collection_properties (collection_id, name, prop_type, options_json, position) VALUES (?, ?, ?, ?, ?)", (nid, p["name"], p["prop_type"], p["options_json"], p["position"])) - except Exception: - logger.exception("create_linked_db") - vrows = conn.execute("SELECT * FROM collection_views WHERE collection_id=?", (collection_id,)).fetchall() - for v in vrows: - try: - conn.execute("INSERT INTO collection_views (collection_id, name, view_type, config_json, position) VALUES (?, ?, ?, ?, ?)", (nid, v["name"], v["view_type"], v["config_json"], v["position"])) - except Exception: - logger.exception("create_linked_db") - conn.commit() - audit_log(user, "collection.linked", "collection", nid, f"src={collection_id}", request) - return {"id": nid, "name": name, "status": "created"} - -@router.post("/collections/{collection_id}/task") -def toggle_task(collection_id: int, request: Request, authorization: str | None = Header(default=None)): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - row = conn.execute("SELECT is_task FROM collections WHERE id=?", (collection_id,)).fetchone() - if not row: - raise HTTPException(404, "Collection not found") - cur_val = row["is_task"] if "is_task" in row.keys() else 0 - new_val = 0 if cur_val else 1 - conn.execute("UPDATE collections SET is_task=? WHERE id=?", (new_val, collection_id)) - conn.commit() - audit_log(user, "collection.toggle_task", "collection", collection_id, str(new_val), request) - return {"id": collection_id, "is_task": bool(new_val)} - -@router.get("/collections/{collection_id}/sources") -def list_sources(collection_id: int, request: Request, authorization: str | None = Header(default=None)): - user = get_bearer_user(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - if not conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone(): - raise HTTPException(404, "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("/collections/{collection_id}/sources") -def add_source(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - src_id = body.get("source_collection_id") or body.get("source_id") - if not src_id: - raise HTTPException(400, "source_collection_id required") - with get_conn() as conn: - if not conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone(): - raise HTTPException(404, "Collection not found") - if not conn.execute("SELECT id FROM collections WHERE id=?", (src_id,)).fetchone(): - raise HTTPException(404, "Source collection not found") - try: - conn.execute("INSERT INTO collection_data_sources (collection_id, source_collection_id) VALUES (?, ?)", (collection_id, src_id)) - conn.commit() - except Exception as e: - raise HTTPException(409, str(e)) from None - audit_log(user, "collection.add_source", "collection", collection_id, str(src_id), request) - return {"collection_id": collection_id, "source_collection_id": src_id, "status": "added"} - -@router.delete("/collections/{collection_id}/sources/{source_id}") -def remove_source(collection_id: int, source_id: int, request: Request, authorization: str | None = Header(default=None)): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - conn.execute("DELETE FROM collection_data_sources WHERE collection_id=? AND (id=? OR source_collection_id=?)", (collection_id, source_id, source_id)) - conn.commit() - audit_log(user, "collection.remove_source", "collection", collection_id, str(source_id), request) - return {"status": "removed"} - -# ── Collection pages ────────────────────────────────────────────────────── - -@router.get("/collections/{collection_id}/pages") -def list_collection_pages_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)): - user = get_bearer_user(request, authorization) - _v2_rate_check(request, user) - limit, offset = parse_pagination(request) - with get_conn() as conn: - if not conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone(): - raise HTTPException(404, "Collection not found") - total = conn.execute("SELECT COUNT(*) FROM collection_pages WHERE collection_id=?", (collection_id,)).fetchone()[0] - # filters: filter[status]=Done etc., sort, fields - # Simple: filter by property name via property_values_json LIKE (best-effort), sort by position or title - sort = request.query_params.get("sort") or "" - order = "position" - desc = False - if sort: - if sort.startswith("-"): - desc = True - sort = sort[1:] - # allow sorting by title/position/created_at - if sort in ("title", "position", "created_at", "updated_at"): - order = sort - direction = "DESC" if desc else "ASC" - rows = conn.execute(f"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY {order} {direction} LIMIT ? OFFSET ?", (collection_id, limit, offset)).fetchall() - # apply filter[xxx] in-memory (small) - filters = {k[7:-1]: v for k, v in request.query_params.items() if k.startswith("filter[") and k.endswith("]")} - fields = request.query_params.get("fields") - fields_set = set(fields.split(",")) if fields else None - out = [] - for r in rows: - d = row_to_dict(r) - # property filter (AND) - if filters: - try: - pv = json.loads(r["property_values_json"] or "{}") if isinstance(r["property_values_json"], str) else r["property_values_json"] - except Exception: - pv = {} - ok = True - for fk, fv in filters.items(): - # lookup by prop id or name - found = False - for kk, vv in (pv or {}).items(): - if str(kk) == str(fk) or str(kk).lower() == fk.lower(): - if str(vv) == str(fv): - found = True - break - # also check title if filter field is title - if fk == "title" and d.get("title") == fv: - found = True - if not found: - ok = False - break - if not ok: - continue - if fields_set: - d = {k: v for k, v in d.items() if k in fields_set or k in ("id", "collection_id")} - out.append(d) - return JSONResponse(content={"pages": out, "total": total, "limit": limit, "offset": offset}, headers=paginate_headers(total)) - -@router.post("/collections/{collection_id}/pages") -def create_collection_page_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - idem = check_idempotency(request, user["id"]) - if idem: - return JSONResponse(content=idem["data"], status_code=idem["status"]) - title = (body.get("title") or body.get("name") or "Untitled").strip() or "Untitled" - icon = body.get("icon", "file") - parent_id = body.get("parent_id") - prop_vals = body.get("property_values") or body.get("properties") or body.get("property_values_json") or {} - if isinstance(prop_vals, str): - try: - prop_vals = json.loads(prop_vals) - except Exception: - prop_vals = {} - with get_conn() as conn: - if not conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone(): - raise HTTPException(404, "Collection not found") - # validate properties if helper exists - try: - pass - # light validation: we rely on existing validators - except Exception: - logger.exception("create_collection_page_v2") - max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_pages WHERE collection_id=?", (collection_id,)).fetchone()[0] - # apply auto props - try: - props_list = [dict(r) for r in conn.execute("SELECT * FROM collection_properties WHERE collection_id=?", (collection_id,)).fetchall()] - from app.services.property_types import apply_auto_properties as _aap - _aap(props_list, prop_vals, user, is_create=True) - except Exception: - logger.exception("create_collection_page_v2") - cur = conn.execute("INSERT INTO collection_pages (collection_id, title, icon, position, parent_id, property_values_json) VALUES (?, ?, ?, ?, ?, ?)", (collection_id, title, icon, max_pos, parent_id, json.dumps(prop_vals))) - pid = cur.lastrowid - conn.commit() - row = conn.execute("SELECT * FROM collection_pages WHERE id=?", (pid,)).fetchone() - audit_log(user, "page.create", "collection_page", pid, title, request) - try: - run_event_sync(_fire_event("collection.page.created", {"page_id": pid, "collection_id": collection_id, "title": title})) - except Exception: - logger.exception("create_collection_page_v2") - data = {"id": pid, "title": title, "status": "created", "page": row_to_dict(row)} - key = (request.headers.get("Idempotency-Key") or "").strip() - if key: - store_idempotency(key, user["id"], data, 201) - return JSONResponse(content=data, status_code=201) - -@router.get("/pages/{page_id}") -def get_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)): - user = get_bearer_user(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - row = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone() - if not row: - # also try pages table (block pages) - row2 = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone() - if not row2: - raise HTTPException(404, "Page not found") - d = row_to_dict(row2) - # v6.5.0: resolve synced blocks server-side (fresh content). - if (d.get("content_format") or "blocks") == "blocks" and d.get("content"): - from app.services.synced_blocks import resolve_content_json - d["content"] = resolve_content_json(d["content"], d["content_format"]) - return d - d = row_to_dict(row) - # property_values_json already parsed by row_to_dict - # v6.5.0: expose the row's content page when it exists (no lazy - # creation on a read-only endpoint). - content_page_id = conn.execute( - "SELECT id FROM pages WHERE collection_row_id=?", - (page_id,), - ).fetchone() - d["content_page_id"] = content_page_id["id"] if content_page_id else None - return d - -@router.patch("/pages/{page_id}") -def patch_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - row = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone() - if not row: - raise HTTPException(404, "Page not found") - title = body.get("title", row["title"]) - icon = body.get("icon", row["icon"]) - pos = body.get("position", row["position"]) - parent_id = body.get("parent_id", row["parent_id"]) - pv_raw = row["property_values_json"] or "{}" - try: - stored = json.loads(pv_raw) if isinstance(pv_raw, str) else dict(pv_raw) - except Exception: - stored = {} - incoming = body.get("property_values") or body.get("properties") - if incoming is not None: - if isinstance(incoming, str): - try: - incoming = json.loads(incoming) - except Exception: - incoming = {} - # merge - for k, v in (incoming or {}).items(): - stored[str(k)] = v - # apply auto props - try: - props_list = [dict(r) for r in conn.execute("SELECT * FROM collection_properties WHERE collection_id=?", (row["collection_id"],)).fetchall()] - from app.services.property_types import apply_auto_properties as _aap - _aap(props_list, stored, user, is_create=False) - except Exception: - logger.exception("patch_page_v2") - conn.execute("UPDATE collection_pages SET title=?, icon=?, position=?, parent_id=?, property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, icon, pos, parent_id, json.dumps(stored), page_id)) - conn.commit() - audit_log(user, "page.update", "collection_page", page_id, "", request) - try: - run_event_sync(_fire_event("collection.page.updated", {"page_id": page_id, "collection_id": row["collection_id"], "title": title})) - except Exception: - logger.exception("patch_page_v2") - return {"id": page_id, "status": "updated"} - -@router.delete("/pages/{page_id}") -def delete_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - row = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone() - if not row: - raise HTTPException(404, "Page not found") - conn.execute("DELETE FROM collection_pages WHERE id=?", (page_id,)) - conn.commit() - audit_log(user, "page.delete", "collection_page", page_id, "", request) - try: - run_event_sync(_fire_event("collection.page.deleted", {"page_id": page_id, "collection_id": row["collection_id"]})) - except Exception: - logger.exception("delete_page_v2") - return {"id": page_id, "status": "deleted"} - -@router.post("/pages/{page_id}/restore") -def restore_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - # For soft-deleted pages (deleted_at) - but collection_pages has no deleted_at; handle pages table - with get_conn() as conn: - row = conn.execute("SELECT deleted_at FROM pages WHERE id=?", (page_id,)).fetchone() - if row and row["deleted_at"]: - conn.execute("UPDATE pages SET deleted_at=NULL WHERE id=?", (page_id,)) - conn.commit() - try: - run_event_sync(_fire_event("page.restored", {"page_id": page_id})) - except Exception: - logger.exception("restore_page_v2") - return {"id": page_id, "status": "restored"} - raise HTTPException(404, "Page not found or not deleted") - -@router.post("/pages/{page_id}/move") -def move_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - row = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone() - if not row: - raise HTTPException(404, "Page not found") - parent_id = body.get("parent_id", row["parent_id"]) - position = body.get("position", row["position"]) - conn.execute("UPDATE collection_pages SET parent_id=?, position=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (parent_id, position, page_id)) - conn.commit() - audit_log(user, "page.move", "collection_page", page_id, f"parent={parent_id} pos={position}", request) - return {"id": page_id, "status": "moved"} - -@router.get("/pages/{page_id}/sub-items") -def list_sub_items_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)): - user = get_bearer_user(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - if not conn.execute("SELECT id FROM collection_pages WHERE id=?", (page_id,)).fetchone(): - raise HTTPException(404, "Page not found") - rows = conn.execute("SELECT * FROM collection_pages WHERE parent_id=? ORDER BY position", (page_id,)).fetchall() - return {"sub_items": [row_to_dict(r) for r in rows]} - -@router.post("/pages/{page_id}/sub-items") -def create_sub_item_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - parent = conn.execute("SELECT collection_id FROM collection_pages WHERE id=?", (page_id,)).fetchone() - if not parent: - raise HTTPException(404, "Page not found") - title = (body.get("title") or "Untitled").strip() - max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_pages WHERE parent_id=?", (page_id,)).fetchone()[0] - pv = json.dumps(body.get("property_values") or {}) - cur = conn.execute("INSERT INTO collection_pages (collection_id, title, parent_id, position, property_values_json) VALUES (?, ?, ?, ?, ?)", (parent["collection_id"], title, page_id, max_pos, pv)) - nid = cur.lastrowid - conn.commit() - audit_log(user, "page.create_subitem", "collection_page", nid, title, request) - return {"id": nid, "status": "created"} - -@router.get("/pages/{page_id}/dependencies") -def list_dependencies_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)): - user = get_bearer_user(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - rows = conn.execute("SELECT * FROM page_dependencies WHERE page_id=?", (page_id,)).fetchall() - return {"dependencies": [dict(r) for r in rows]} - -@router.post("/pages/{page_id}/dependencies") -def add_dependency_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - dep_id = body.get("dependency_id") or body.get("depends_on") - dtype = body.get("dependency_type") or "blocks" - if not dep_id: - raise HTTPException(400, "dependency_id required") - with get_conn() as conn: - if not conn.execute("SELECT id FROM collection_pages WHERE id=?", (page_id,)).fetchone(): - raise HTTPException(404, "Page not found") - if not conn.execute("SELECT id FROM collection_pages WHERE id=?", (dep_id,)).fetchone(): - raise HTTPException(404, "Dependency page not found") - try: - conn.execute("INSERT INTO page_dependencies (page_id, dependency_id, dependency_type) VALUES (?, ?, ?)", (page_id, dep_id, dtype)) - conn.commit() - except Exception as e: - raise HTTPException(409, str(e)) from None - audit_log(user, "page.add_dependency", "collection_page", page_id, str(dep_id), request) - return {"status": "added"} - -@router.delete("/pages/{page_id}/dependencies/{dep_id}") -def remove_dependency_v2(page_id: int, dep_id: int, request: Request, authorization: str | None = Header(default=None)): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - conn.execute("DELETE FROM page_dependencies WHERE page_id=? AND dependency_id=?", (page_id, dep_id)) - conn.commit() - audit_log(user, "page.remove_dependency", "collection_page", page_id, str(dep_id), request) - return {"status": "removed"} - -# ── Properties ──────────────────────────────────────────────────────────── - -@router.get("/collections/{collection_id}/properties") -def list_properties_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)): - user = get_bearer_user(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - if not conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone(): - raise HTTPException(404, "Collection not found") - rows = conn.execute("SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position", (collection_id,)).fetchall() - return {"properties": [row_to_dict(r) for r in rows]} - -@router.post("/collections/{collection_id}/properties") -def create_property_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - name = (body.get("name") or "").strip() - if not name: - raise HTTPException(400, "name is required") - ptype = body.get("prop_type") or body.get("type") or "text" - with get_conn() as conn: - if not conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone(): - raise HTTPException(404, "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, ptype, json.dumps(body.get("options") or []), body.get("number_format") or "number", max_pos, int(bool(body.get("required"))), int(bool(body.get("visible_in_views", True))), json.dumps(body.get("validation") or {}), (body.get("group_name") or "").strip())) - conn.commit() - pid = cur.lastrowid - except Exception as e: - raise HTTPException(409, f"Property exists: {e}") from None - audit_log(user, "property.create", "property", pid, name, request) - return {"id": pid, "name": name, "prop_type": ptype, "status": "created"} - -@router.patch("/properties/{prop_id}") -def patch_property_v2(prop_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - row = conn.execute("SELECT * FROM collection_properties WHERE id=?", (prop_id,)).fetchone() - if not row: - raise HTTPException(404, "Property not found") - name = body.get("name", row["name"]) - opts = json.dumps(body.get("options", json.loads(row["options_json"] or "[]"))) - nf = body.get("number_format", row["number_format"]) - req = int(bool(body.get("required", row["required"]))) - vis = int(bool(body.get("visible_in_views", row["visible_in_views"]))) - vj = json.dumps(body.get("validation", json.loads(row["validation_json"] or "{}"))) if "validation" in body else (row["validation_json"] if "validation_json" in row.keys() else "{}") - grp = body.get("group_name", row["group_name"] if "group_name" in row.keys() else "") - conn.execute("UPDATE collection_properties SET name=?, options_json=?, number_format=?, required=?, visible_in_views=?, validation_json=?, group_name=? WHERE id=?", (name, opts, nf, req, vis, vj, grp, prop_id)) - conn.commit() - audit_log(user, "property.update", "property", prop_id, "", request) - return {"id": prop_id, "status": "updated"} - -@router.delete("/properties/{prop_id}") -def delete_property_v2(prop_id: int, request: Request, authorization: str | None = Header(default=None)): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - if not conn.execute("SELECT id FROM collection_properties WHERE id=?", (prop_id,)).fetchone(): - raise HTTPException(404, "Property not found") - conn.execute("DELETE FROM collection_properties WHERE id=?", (prop_id,)) - conn.commit() - audit_log(user, "property.delete", "property", prop_id, "", request) - return {"id": prop_id, "status": "deleted"} - -@router.post("/properties/{prop_id}/relation") -def create_relation_v2(prop_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - related_id = body.get("related_collection_id") - reverse = (body.get("reverse_name") or "").strip() - if not related_id: - raise HTTPException(400, "related_collection_id required") - with get_conn() as conn: - row = conn.execute("SELECT collection_id FROM collection_properties WHERE id=?", (prop_id,)).fetchone() - if not row: - raise HTTPException(404, "Property not found") - conn.execute("UPDATE collection_properties SET prop_type='relation', related_collection_id=?, reverse_name=? WHERE id=?", (related_id, reverse, prop_id)) - if reverse: - max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_properties WHERE collection_id=?", (related_id,)).fetchone()[0] - try: - conn.execute("INSERT INTO collection_properties (collection_id, name, prop_type, related_collection_id, reverse_name, position) VALUES (?, ?, 'relation', ?, ?, ?)", (related_id, reverse, row["collection_id"], "", max_pos)) - except Exception: - logger.exception("create_relation_v2") - conn.commit() - return {"id": prop_id, "status": "updated"} - -@router.post("/properties/evaluate-formula") -def evaluate_formula_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): - user = get_bearer_user(request, authorization) - _v2_rate_check(request, user) - expr = body.get("expression") or body.get("formula") - if not expr: - raise HTTPException(400, "expression required") - ctx = body.get("context") or {} - try: - from app.services.formula_engine import FormulaEngine - res = FormulaEngine().evaluate(expr, ctx) - except Exception as e: - raise HTTPException(400, f"Formula error: {e}") from None - return {"result": res, "expression": expr} - -@router.post("/properties/compute-rollup") -def compute_rollup_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): - user = get_bearer_user(request, authorization) - _v2_rate_check(request, user) - for k in ("collection_id", "relation_property_id", "target_property_id", "page_id"): - if k not in body: - raise HTTPException(400, f"{k} required") - try: - from app.services.rollup_engine import RollupEngine - res = RollupEngine().compute(body["collection_id"], body["relation_property_id"], body["target_property_id"], body["page_id"], body.get("function", "count")) - except Exception as e: - raise HTTPException(400, f"Rollup error: {e}") from None - return {"result": res} - -# ── Views & dashboards ──────────────────────────────────────────────────── - -@router.get("/collections/{collection_id}/views") -def list_views_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)): - user = get_bearer_user(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - rows = conn.execute("SELECT * FROM collection_views WHERE collection_id=? ORDER BY position", (collection_id,)).fetchall() - return {"views": [row_to_dict(r) for r in rows]} - -@router.post("/collections/{collection_id}/views") -def create_view_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - name = (body.get("name") or "New View").strip() - vtype = body.get("view_type") or body.get("type") or "table" - config = body.get("config") or body.get("config_json") or {} - with get_conn() as conn: - if not conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone(): - raise HTTPException(404, "Collection not found") - max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_views WHERE collection_id=?", (collection_id,)).fetchone()[0] - cur = conn.execute("INSERT INTO collection_views (collection_id, name, view_type, config_json, position, created_by) VALUES (?, ?, ?, ?, ?, ?)", (collection_id, name, vtype, json.dumps(config), max_pos, user["id"])) - vid = cur.lastrowid - conn.commit() - audit_log(user, "view.create", "view", vid, name, request) - try: - run_event_sync(_fire_event("collection.view.created", {"view_id": vid, "collection_id": collection_id, "name": name, "view_type": vtype})) - except Exception: - logger.exception("create_view_v2") - return {"id": vid, "name": name, "view_type": vtype, "status": "created"} - -@router.patch("/views/{view_id}") -def patch_view_v2(view_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - row = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone() - if not row: - raise HTTPException(404, "View not found") - cfg = json.loads(row["config_json"] or "{}") - if "config" in body: - cfg.update(body["config"]) - elif "config_json" in body: - try: - cfg.update(json.loads(body["config_json"]) if isinstance(body["config_json"], str) else body["config_json"]) - except Exception: - logger.exception("patch_view_v2") - # also flat keys - for k in ("group_by", "sub_group_by", "wip_limits", "card_size", "cover_property", "cover_mode", "card_properties", "visible_properties", "filters", "sorts", "date_property"): - if k in body: - cfg[k] = body[k] - name = body.get("name", row["name"]) - vtype = body.get("view_type") or body.get("type") or row["view_type"] - conn.execute("UPDATE collection_views SET name=?, view_type=?, config_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (name, vtype, json.dumps(cfg), view_id)) - conn.commit() - audit_log(user, "view.update", "view", view_id, "", request) - return {"id": view_id, "status": "updated"} - -@router.delete("/views/{view_id}") -def delete_view_v2(view_id: int, request: Request, authorization: str | None = Header(default=None)): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - if not conn.execute("SELECT id FROM collection_views WHERE id=?", (view_id,)).fetchone(): - raise HTTPException(404, "View not found") - conn.execute("DELETE FROM collection_views WHERE id=?", (view_id,)) - conn.commit() - audit_log(user, "view.delete", "view", view_id, "", request) - return {"id": view_id, "status": "deleted"} - -@router.post("/views/{view_id}/save-as") -def save_as_view_v2(view_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - name = (body.get("name") or "").strip() or "Copy" - with get_conn() as conn: - row = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone() - if not row: - raise HTTPException(404, "View not found") - max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_views WHERE collection_id=?", (row["collection_id"],)).fetchone()[0] - cur = conn.execute("INSERT INTO collection_views (collection_id, name, view_type, config_json, position, created_by) VALUES (?, ?, ?, ?, ?, ?)", (row["collection_id"], name, row["view_type"], row["config_json"], max_pos, user["id"])) - nid = cur.lastrowid - conn.commit() - return {"id": nid, "name": name, "status": "created"} - -@router.get("/collections/{collection_id}/dashboards") -def list_dashboards_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)): - user = get_bearer_user(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - rows = conn.execute("SELECT * FROM collection_dashboards WHERE collection_id=? ORDER BY created_at", (collection_id,)).fetchall() - return {"dashboards": [row_to_dict(r) for r in rows]} - -@router.post("/collections/{collection_id}/dashboards") -def create_dashboard_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - name = (body.get("name") or "Dashboard").strip() - layout = body.get("layout") or body.get("layout_json") or {"columns": 1, "widgets": []} - with get_conn() as conn: - cur = conn.execute("INSERT INTO collection_dashboards (collection_id, name, layout_json) VALUES (?, ?, ?)", (collection_id, name, json.dumps(layout))) - did = cur.lastrowid - conn.commit() - return {"id": did, "name": name, "status": "created"} - -@router.patch("/dashboards/{dashboard_id}") -def patch_dashboard_v2(dashboard_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - row = conn.execute("SELECT * FROM collection_dashboards WHERE id=?", (dashboard_id,)).fetchone() - if not row: - raise HTTPException(404, "Dashboard not found") - name = body.get("name", row["name"]) - layout = body.get("layout") or body.get("layout_json") - if layout is not None: - layout_json = json.dumps(layout) - else: - layout_json = row["layout_json"] - conn.execute("UPDATE collection_dashboards SET name=?, layout_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (name, layout_json, dashboard_id)) - conn.commit() - return {"id": dashboard_id, "status": "updated"} - -@router.delete("/dashboards/{dashboard_id}") -def delete_dashboard_v2(dashboard_id: int, request: Request, authorization: str | None = Header(default=None)): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - conn.execute("DELETE FROM collection_dashboards WHERE id=?", (dashboard_id,)) - conn.commit() - return {"id": dashboard_id, "status": "deleted"} - -# ── Comments & mentions ─────────────────────────────────────────────────── - -@router.get("/pages/{page_id}/comments") -def list_comments_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)): - user = get_bearer_user(request, authorization) - _v2_rate_check(request, user) - limit, offset = parse_pagination(request) - with get_conn() as conn: - total = conn.execute("SELECT COUNT(*) FROM comments WHERE target_id=? OR page_id=?", (page_id, page_id)).fetchone()[0] - rows = conn.execute("SELECT c.*, u.login, u.full_name FROM comments c LEFT JOIN users u ON u.id=c.user_id WHERE c.target_id=? OR c.page_id=? ORDER BY c.created_at LIMIT ? OFFSET ?", (page_id, page_id, limit, offset)).fetchall() - return {"comments": [row_to_dict(r) for r in rows], "total": total, "limit": limit, "offset": offset} - -@router.post("/pages/{page_id}/comments") -def create_comment_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - text = (body.get("body") or body.get("content") or "").strip() - if not text: - raise HTTPException(400, "body is required") - with get_conn() as conn: - cur = conn.execute("INSERT INTO comments (page_id, user_id, body, target_type, target_id, anchor_block_id, anchor_start, anchor_end) VALUES (?, ?, ?, 'page', ?, ?, ?, ?)", (page_id, user["id"], text, page_id, body.get("anchor_block_id"), body.get("anchor_start"), body.get("anchor_end"))) - nid = cur.lastrowid - conn.commit() - row = conn.execute("SELECT * FROM comments WHERE id=?", (nid,)).fetchone() - audit_log(user, "comment.create", "comment", nid, text[:80], request) - try: - run_event_sync(_fire_event("comment.added", {"comment_id": nid, "page_id": page_id, "user_id": user["id"]})) - except Exception: - logger.exception("create_comment_v2") - return {"id": nid, "status": "created", "comment": row_to_dict(row)} - -@router.patch("/comments/{comment_id}") -def patch_comment_v2(comment_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - row = conn.execute("SELECT * FROM comments WHERE id=?", (comment_id,)).fetchone() - if not row: - raise HTTPException(404, "Comment not found") - if row["user_id"] != user["id"] and not user.get("is_admin"): - raise HTTPException(403, "Not your comment") - body_text = body.get("body", row["body"]) - resolved = body.get("resolved", row["resolved"]) - was_resolved = int(row["resolved"] or 0) - conn.execute("UPDATE comments SET body=?, resolved=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (body_text, int(bool(resolved)), comment_id)) - conn.commit() - if int(bool(resolved)) and not was_resolved: - try: - run_event_sync(_fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]})) - except Exception: - logger.exception("patch_comment_v2") - return {"id": comment_id, "status": "updated"} - -@router.delete("/comments/{comment_id}") -def delete_comment_v2(comment_id: int, request: Request, authorization: str | None = Header(default=None)): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - row = conn.execute("SELECT * FROM comments WHERE id=?", (comment_id,)).fetchone() - if not row: - raise HTTPException(404, "Comment not found") - if row["user_id"] != user["id"] and not user.get("is_admin"): - raise HTTPException(403, "Not your comment") - conn.execute("DELETE FROM comments WHERE id=?", (comment_id,)) - conn.commit() - return {"id": comment_id, "status": "deleted"} - -@router.post("/pages/{page_id}/mentions") -def create_mention_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - targets = body.get("user_ids") or body.get("mentions") or [] - if isinstance(targets, int): - targets = [targets] - if not targets: - raise HTTPException(400, "user_ids required") - created = 0 - with get_conn() as conn: - for uid in targets: - try: - conn.execute("INSERT INTO notifications (user_id, actor_id, ntype, title, message, resource_type, resource_id, url) VALUES (?, ?, 'mention', 'You were mentioned', ?, 'page', ?, ?)", (uid, user["id"], body.get("message") or f"Mentioned in page {page_id}", page_id, f"/pages/{page_id}")) - created += 1 - except Exception: - logger.exception("create_mention_v2") - conn.commit() - if created: - try: - run_event_sync(_fire_event("mention.added", {"page_id": page_id, "user_ids": [u for u in targets if isinstance(u, int)], "count": created})) - except Exception: - logger.exception("create_mention_v2") - return {"mentions": created, "status": "created"} - -# ── Notifications ───────────────────────────────────────────────────────── - -@router.get("/notifications") -def list_notifications_v2(request: Request, authorization: str | None = Header(default=None)): - user = get_bearer_user(request, authorization) - _v2_rate_check(request, user) - limit, offset = parse_pagination(request) - unread = request.query_params.get("unread") - with get_conn() as conn: - where = "user_id=?" - params: list = [user["id"]] - if unread == "1": - where += " AND is_read=0" - total = conn.execute(f"SELECT COUNT(*) FROM notifications WHERE {where}", params).fetchone()[0] - rows = conn.execute(f"SELECT * FROM notifications WHERE {where} ORDER BY created_at DESC LIMIT ? OFFSET ?", (*params, limit, offset)).fetchall() - return {"notifications": [row_to_dict(r) for r in rows], "total": total, "limit": limit, "offset": offset} - -@router.get("/notifications/unread-count") -def unread_count(request: Request, authorization: str | None = Header(default=None)): - user = get_bearer_user(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - cnt = conn.execute("SELECT COUNT(*) FROM notifications WHERE user_id=? AND is_read=0", (user["id"],)).fetchone()[0] - return {"unread": cnt} - -@router.post("/notifications/{notif_id}/read") -def mark_read(notif_id: int, request: Request, authorization: str | None = Header(default=None)): - user = get_bearer_user(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - conn.execute("UPDATE notifications SET is_read=1 WHERE id=? AND user_id=?", (notif_id, user["id"])) - conn.commit() - return {"id": notif_id, "status": "read"} - -@router.post("/notifications/read-all") -def mark_all_read(request: Request, authorization: str | None = Header(default=None)): - user = get_bearer_user(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - conn.execute("UPDATE notifications SET is_read=1 WHERE user_id=?", (user["id"],)) - conn.commit() - return {"status": "all read"} - -@router.patch("/users/me/preferences") -def patch_prefs(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): - user = get_bearer_user(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - row = conn.execute("SELECT notification_prefs FROM users WHERE id=?", (user["id"],)).fetchone() - try: - cur = json.loads(row["notification_prefs"] or "{}") - except Exception: - cur = {} - cur.update(body) - conn.execute("UPDATE users SET notification_prefs=? WHERE id=?", (json.dumps(cur), user["id"])) - conn.commit() - return {"preferences": cur} - -# ── Favorites / Tags / Recents ──────────────────────────────────────────── - -@router.get("/favorites") -def list_favorites_v2(request: Request, authorization: str | None = Header(default=None)): - user = get_bearer_user(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - rows = conn.execute("SELECT f.*, p.title, p.workspace_id FROM favorites f JOIN pages p ON p.id=f.page_id WHERE f.user_id=? ORDER BY f.position", (user["id"],)).fetchall() - return {"favorites": [row_to_dict(r) for r in rows]} - -@router.post("/favorites") -def add_favorite_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - pid = body.get("page_id") - if not pid: - raise HTTPException(400, "page_id required") - with get_conn() as conn: - try: - conn.execute("INSERT INTO favorites (user_id, page_id) VALUES (?, ?)", (user["id"], pid)) - conn.commit() - except Exception as e: - raise HTTPException(409, str(e)) from None - try: - run_event_sync(_fire_event("favorite.added", {"page_id": pid, "user_id": user["id"]})) - except Exception: - logger.exception("add_favorite_v2") - return {"page_id": pid, "status": "added"} - -@router.delete("/favorites/{page_id}") -def remove_favorite_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - conn.execute("DELETE FROM favorites WHERE user_id=? AND page_id=?", (user["id"], page_id)) - conn.commit() - try: - run_event_sync(_fire_event("favorite.removed", {"page_id": page_id, "user_id": user["id"]})) - except Exception: - logger.exception("remove_favorite_v2") - return {"page_id": page_id, "status": "removed"} - -@router.get("/tags") -def list_tags_v2(request: Request, authorization: str | None = Header(default=None)): - user = get_bearer_user(request, authorization) - _v2_rate_check(request, user) - q = (request.query_params.get("q") or "").strip() - with get_conn() as conn: - if q: - rows = conn.execute("SELECT * FROM tags WHERE user_id=? AND name LIKE ? ORDER BY name", (user["id"], f"%{q}%")).fetchall() - else: - rows = conn.execute("SELECT * FROM tags WHERE user_id=? ORDER BY name", (user["id"],)).fetchall() - return {"tags": [dict(r) for r in rows]} - -@router.post("/tags") -def create_tag_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - name = (body.get("name") or "").strip() - if not name: - raise HTTPException(400, "name required") - color = body.get("color", "#787774") - with get_conn() as conn: - try: - cur = conn.execute("INSERT INTO tags (name, color, user_id) VALUES (?, ?, ?)", (name, color, user["id"])) - tid = cur.lastrowid - conn.commit() - except Exception as e: - raise HTTPException(409, str(e)) from None - return {"id": tid, "name": name, "color": color, "status": "created"} - -@router.patch("/tags/{tag_id}") -def patch_tag_v2(tag_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - row = conn.execute("SELECT * FROM tags WHERE id=? AND user_id=?", (tag_id, user["id"])).fetchone() - if not row: - raise HTTPException(404, "Tag not found") - name = body.get("name", row["name"]) - color = body.get("color", row["color"]) - conn.execute("UPDATE tags SET name=?, color=? WHERE id=?", (name, color, tag_id)) - conn.commit() - return {"id": tag_id, "status": "updated"} - -@router.delete("/tags/{tag_id}") -def delete_tag_v2(tag_id: int, request: Request, authorization: str | None = Header(default=None)): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - conn.execute("DELETE FROM tags WHERE id=? AND user_id=?", (tag_id, user["id"])) - conn.commit() - return {"id": tag_id, "status": "deleted"} - -@router.post("/pages/{page_id}/tags") -def attach_tag_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - tag_id = body.get("tag_id") - if not tag_id: - raise HTTPException(400, "tag_id required") - with get_conn() as conn: - try: - conn.execute("INSERT INTO page_tags (page_id, tag_id) VALUES (?, ?)", (page_id, tag_id)) - conn.commit() - except Exception as e: - raise HTTPException(409, str(e)) from None - return {"page_id": page_id, "tag_id": tag_id, "status": "attached"} - -@router.delete("/pages/{page_id}/tags/{tag_id}") -def detach_tag_v2(page_id: int, tag_id: int, request: Request, authorization: str | None = Header(default=None)): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - conn.execute("DELETE FROM page_tags WHERE page_id=? AND tag_id=?", (page_id, tag_id)) - conn.commit() - return {"status": "detached"} - -@router.get("/recents") -def list_recents_v2(request: Request, authorization: str | None = Header(default=None)): - user = get_bearer_user(request, authorization) - _v2_rate_check(request, user) - limit = int(request.query_params.get("limit", "20")) - st = request.query_params.get("source_type") - with get_conn() as conn: - if st: - rows = conn.execute("SELECT * FROM recents WHERE user_id=? AND source_type=? ORDER BY accessed_at DESC LIMIT ?", (user["id"], st, limit)).fetchall() - else: - rows = conn.execute("SELECT * FROM recents WHERE user_id=? ORDER BY accessed_at DESC LIMIT ?", (user["id"], limit)).fetchall() - return {"recents": [row_to_dict(r) for r in rows]} - -# ── Sharing / publish ───────────────────────────────────────────────────── - -@router.get("/pages/{page_id}/shares") -def list_shares_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)): - user = get_bearer_user(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - rows = conn.execute("SELECT * FROM page_shares WHERE page_id=?", (page_id,)).fetchall() - return {"shares": [dict(r) for r in rows]} - -@router.post("/pages/{page_id}/shares") -def create_share_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - perm = (body.get("permission") or "view").strip().lower() - if perm not in ("view", "comment", "edit"): - raise HTTPException(400, "Invalid permission. Use view, comment, or edit") - email = (body.get("email") or "").strip() - uid = body.get("user_id") - if not email and not uid: - raise HTTPException(400, "email or user_id required") - with get_conn() as conn: - cur = conn.execute("INSERT INTO page_shares (page_id, shared_with_user_id, shared_with_email, permission, created_by) VALUES (?, ?, ?, ?, ?)", (page_id, uid, email, perm, user["id"])) - conn.execute("UPDATE pages SET is_shared=1 WHERE id=?", (page_id,)) - conn.commit() - nid = cur.lastrowid - audit_log(user, "share.create", "share", nid, f"page={page_id}", request) - try: - run_event_sync(_fire_event("page.shared", {"page_id": page_id, "share_id": nid, "permission": perm})) - except Exception: - logger.exception("create_share_v2") - return {"id": nid, "page_id": page_id, "status": "shared"} - -@router.patch("/shares/{share_id}") -def patch_share_v2(share_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - perm = (body.get("permission") or "").strip().lower() - if perm not in ("view", "comment", "edit"): - raise HTTPException(400, "Invalid permission") - with get_conn() as conn: - if not conn.execute("SELECT id FROM page_shares WHERE id=?", (share_id,)).fetchone(): - raise HTTPException(404, "Share not found") - conn.execute("UPDATE page_shares SET permission=? WHERE id=?", (perm, share_id)) - conn.commit() - return {"id": share_id, "status": "updated"} - -@router.delete("/shares/{share_id}") -def delete_share_v2(share_id: int, request: Request, authorization: str | None = Header(default=None)): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - row = conn.execute("SELECT page_id FROM page_shares WHERE id=?", (share_id,)).fetchone() - if not row: - raise HTTPException(404, "Share not found") - conn.execute("DELETE FROM page_shares WHERE id=?", (share_id,)) - # unset is_shared if no shares left - cnt = conn.execute("SELECT COUNT(*) FROM page_shares WHERE page_id=?", (row["page_id"],)).fetchone()[0] - if cnt == 0: - conn.execute("UPDATE pages SET is_shared=0 WHERE id=?", (row["page_id"],)) - conn.commit() - return {"id": share_id, "status": "revoked"} - -@router.post("/pages/{page_id}/publish") -def publish_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - slug_in = (body.get("slug") or body.get("publish_slug") or "").strip() or None - slug, _title = publish(page_id, explicit_slug=slug_in) - audit_log(user, "page.publish", "page", page_id, slug, request) - run_event_sync(fire_published(page_id, slug)) - return {"page_id": page_id, "slug": slug, "url": f"/p/{slug}", "status": "published"} - -@router.delete("/pages/{page_id}/publish") -def unpublish_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - unpublish(page_id) - run_event_sync(fire_unpublished(page_id)) - return {"page_id": page_id, "status": "unpublished"} - -# ── History ─────────────────────────────────────────────────────────────── - -@router.get("/pages/{page_id}/history") -def list_history_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)): - user = get_bearer_user(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - rows = conn.execute("SELECT h.*, u.login FROM page_history h LEFT JOIN users u ON u.id=h.user_id WHERE h.page_id=? ORDER BY h.created_at DESC", (page_id,)).fetchall() - # also page_versions for block pages - vrows = conn.execute("SELECT * FROM page_versions WHERE page_id=? ORDER BY created_at DESC", (page_id,)).fetchall() - return {"history": [row_to_dict(r) for r in rows], "versions": [row_to_dict(r) for r in vrows]} - -@router.post("/pages/{page_id}/history/restore") -def restore_history_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - hid = body.get("history_id") or body.get("id") or body.get("version_id") - if not hid: - raise HTTPException(400, "history_id required") - with get_conn() as conn: - h = conn.execute("SELECT * FROM page_versions WHERE id=? AND page_id=?", (hid, page_id)).fetchone() - if h: - conn.execute("UPDATE pages SET content=?, title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (h["blocks_json"], h["title"], page_id)) - conn.commit() - return {"page_id": page_id, "restored_version": hid, "status": "restored"} - h2 = conn.execute("SELECT * FROM page_history WHERE id=? AND page_id=?", (hid, page_id)).fetchone() - if h2: - try: - snap = json.loads(h2["snapshot_json"] or "{}") - except Exception: - snap = {} - # best-effort restore content - if snap.get("content"): - conn.execute("UPDATE pages SET content=? WHERE id=?", (snap["content"], page_id)) - conn.commit() - return {"page_id": page_id, "restored_version": hid, "status": "restored"} - raise HTTPException(404, "History not found") - -# ── Sprints ─────────────────────────────────────────────────────────────── - -@router.get("/collections/{collection_id}/sprints") -def list_sprints_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)): - user = get_bearer_user(request, authorization) - _v2_rate_check(request, user) - limit, offset = parse_pagination(request) - with get_conn() as conn: - total = conn.execute("SELECT COUNT(*) FROM sprints WHERE collection_id=?", (collection_id,)).fetchone()[0] - rows = conn.execute("SELECT * FROM sprints WHERE collection_id=? ORDER BY created_at DESC LIMIT ? OFFSET ?", (collection_id, limit, offset)).fetchall() - return {"sprints": [row_to_dict(r) for r in rows], "total": total, "limit": limit, "offset": offset} - -@router.post("/collections/{collection_id}/sprints") -def create_sprint_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - name = (body.get("name") or "Sprint").strip() - start = body.get("start_date") or body.get("start") or "" - end = body.get("end_date") or body.get("end") or "" - if not start or not end: - raise HTTPException(400, "start_date and end_date required (YYYY-MM-DD)") - with get_conn() as conn: - cur = conn.execute("INSERT INTO sprints (collection_id, name, start_date, end_date, goal) VALUES (?, ?, ?, ?, ?)", (collection_id, name, start, end, body.get("goal") or "")) - sid = cur.lastrowid - conn.commit() - try: - run_event_sync(_fire_event("sprint.created", {"sprint_id": sid, "collection_id": collection_id, "name": name})) - except Exception: - logger.exception("create_sprint_v2") - return {"id": sid, "name": name, "status": "created"} - -@router.patch("/sprints/{sprint_id}") -def patch_sprint_v2(sprint_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - row = conn.execute("SELECT * FROM sprints WHERE id=?", (sprint_id,)).fetchone() - if not row: - raise HTTPException(404, "Sprint not found") - name = body.get("name", row["name"]) - start = body.get("start_date", row["start_date"]) - end = body.get("end_date", row["end_date"]) - goal = body.get("goal", row["goal"]) - status = body.get("status", row["status"]) - conn.execute("UPDATE sprints SET name=?, start_date=?, end_date=?, goal=?, status=? WHERE id=?", (name, start, end, goal, status, sprint_id)) - conn.commit() - try: - run_event_sync(_fire_event("sprint.updated", {"sprint_id": sprint_id, "collection_id": row["collection_id"], "name": name, "status": status})) - except Exception: - logger.exception("patch_sprint_v2") - return {"id": sprint_id, "status": "updated"} - -@router.delete("/sprints/{sprint_id}") -def delete_sprint_v2(sprint_id: int, request: Request, authorization: str | None = Header(default=None)): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - conn.execute("DELETE FROM sprints WHERE id=?", (sprint_id,)) - conn.commit() - return {"id": sprint_id, "status": "deleted"} - -@router.post("/sprints/{sprint_id}/assign") -def assign_sprint_v2(sprint_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - pid = body.get("page_id") - if not pid: - raise HTTPException(400, "page_id required") - with get_conn() as conn: - try: - conn.execute("INSERT INTO sprint_pages (sprint_id, page_id, velocity_points) VALUES (?, ?, ?)", (sprint_id, pid, body.get("velocity_points", 1))) - conn.commit() - except Exception as e: - raise HTTPException(409, str(e)) from None - return {"sprint_id": sprint_id, "page_id": pid, "status": "assigned"} - -@router.delete("/sprints/{sprint_id}/assign/{page_id}") -def unassign_sprint_v2(sprint_id: int, page_id: int, request: Request, authorization: str | None = Header(default=None)): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - conn.execute("DELETE FROM sprint_pages WHERE sprint_id=? AND page_id=?", (sprint_id, page_id)) - conn.commit() - return {"status": "removed"} - -@router.get("/sprints/{sprint_id}/burndown") -def burndown_v2(sprint_id: int, request: Request, authorization: str | None = Header(default=None)): - user = get_bearer_user(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - s = conn.execute("SELECT * FROM sprints WHERE id=?", (sprint_id,)).fetchone() - if not s: - raise HTTPException(404, "Sprint not found") - pages = conn.execute("SELECT sp.*, cp.property_values_json FROM sprint_pages sp JOIN collection_pages cp ON cp.id=sp.page_id WHERE sp.sprint_id=?", (sprint_id,)).fetchall() - total = len(pages) - # crude: completed where status property == Done (best-effort) - completed = 0 - for p in pages: - try: - pv = json.loads(p["property_values_json"] or "{}") - for v in pv.values(): - if str(v).lower() in ("done", "completed", "terminé"): - completed += 1 - break - except Exception: - logger.exception("burndown_v2") - remaining = total - completed - # ideal linear - ideal = [round(total * (1 - i / 10)) for i in range(11)] - return {"sprint_id": sprint_id, "total": total, "completed": completed, "remaining": remaining, "ideal": ideal} - -# ── Templates ───────────────────────────────────────────────────────────── - -@router.get("/collections/{collection_id}/templates") -def list_templates_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)): - user = get_bearer_user(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - rows = conn.execute("SELECT * FROM page_templates WHERE collection_id=? ORDER BY created_at", (collection_id,)).fetchall() - return {"templates": [row_to_dict(r) for r in rows]} - -@router.post("/collections/{collection_id}/templates") -def create_template_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - name = (body.get("name") or "Template").strip() - pv = json.dumps(body.get("property_values") or body.get("property_values_json") or {}) - cj = json.dumps(body.get("content") or body.get("content_json") or []) - with get_conn() as conn: - cur = conn.execute("INSERT INTO page_templates (collection_id, name, property_values_json, content_json) VALUES (?, ?, ?, ?)", (collection_id, name, pv, cj)) - tid = cur.lastrowid - conn.commit() - return {"id": tid, "name": name, "status": "created"} - -@router.patch("/templates/{template_id}") -def patch_template_v2(template_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - row = conn.execute("SELECT * FROM page_templates WHERE id=?", (template_id,)).fetchone() - if not row: - raise HTTPException(404, "Template not found") - name = body.get("name", row["name"]) - pv = json.dumps(body.get("property_values", json.loads(row["property_values_json"] or "{}"))) if "property_values" in body else row["property_values_json"] - cj = json.dumps(body.get("content", json.loads(row["content_json"] or "[]"))) if "content" in body else row["content_json"] - conn.execute("UPDATE page_templates SET name=?, property_values_json=?, content_json=? WHERE id=?", (name, pv, cj, template_id)) - conn.commit() - return {"id": template_id, "status": "updated"} - -@router.delete("/templates/{template_id}") -def delete_template_v2(template_id: int, request: Request, authorization: str | None = Header(default=None)): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - conn.execute("DELETE FROM page_templates WHERE id=?", (template_id,)) - conn.commit() - return {"id": template_id, "status": "deleted"} - -@router.post("/templates/{template_id}/apply") -def apply_template_v2(template_id: int, request: Request, authorization: str | None = Header(default=None)): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - tpl = conn.execute("SELECT * FROM page_templates WHERE id=?", (template_id,)).fetchone() - if not tpl: - raise HTTPException(404, "Template not found") - max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_pages WHERE collection_id=?", (tpl["collection_id"],)).fetchone()[0] - pv = tpl["property_values_json"] or "{}" - cur = conn.execute("INSERT INTO collection_pages (collection_id, title, position, property_values_json) VALUES (?, ?, ?, ?)", (tpl["collection_id"], tpl["name"], max_pos, pv)) - pid = cur.lastrowid - conn.commit() - return {"template_id": template_id, "page_id": pid, "status": "applied"} - -@router.get("/templates/database") -def list_db_templates_v2(request: Request, authorization: str | None = Header(default=None)): - user = get_bearer_user(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - rows = conn.execute("SELECT * FROM database_templates ORDER BY name").fetchall() - return {"templates": [row_to_dict(r) for r in rows]} - -@router.post("/templates/database/{template_id}/apply") -def apply_db_template_v2(template_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - tpl = conn.execute("SELECT * FROM database_templates WHERE id=?", (template_id,)).fetchone() - if not tpl: - raise HTTPException(404, "Template not found") - name = (body.get("name") or tpl["name"]).strip() - schema = json.loads(tpl["schema_json"] or "[]") - cur = conn.execute("INSERT INTO collections (name, description, icon, schema_json, workspace_id, created_by) VALUES (?, ?, ?, ?, ?, ?)", (name, tpl["description"], tpl["icon"] if "icon" in tpl.keys() else "📋", json.dumps(schema), body.get("workspace_id"), user["id"])) - cid = cur.lastrowid - # A25 : pas de try — un échec de matérialisation doit interrompre la - # transaction plutôt que de commiter une collection sans schéma. - from app.services.db_templates import materialize_properties - materialize_properties(conn, cid, schema) - conn.commit() - return {"collection_id": cid, "name": name, "status": "created"} - -# ── Export / Import ─────────────────────────────────────────────────────── - -@router.get("/pages/{page_id}/export") -def export_page_v2(page_id: int, request: Request, format: str = "markdown", authorization: str | None = Header(default=None)): - user = get_bearer_user(request, authorization) - _v2_rate_check(request, user) - fmt = (format or request.query_params.get("format") or "markdown").lower() - if fmt not in ("markdown", "html", "pdf"): - raise HTTPException(400, "format must be markdown, html or pdf") - with get_conn() as conn: - row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone() - if not row: - # try collection_pages - row2 = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone() - if not row2: - raise HTTPException(404, "Page not found") - # collection pages: return JSON - return {"page": row_to_dict(row2), "format": fmt} - # block pages: delegate to export service - from app.services.export import export_page as _export - try: - data, mime, fname = _export(row, fmt) # type: ignore - return Response(content=data, media_type=mime, headers={"Content-Disposition": f'attachment; filename="{fname}"'}) - except Exception as e: - raise HTTPException(500, f"Export failed: {e}") from None - -@router.get("/collections/{collection_id}/export/csv") -def export_csv_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)): - user = get_bearer_user(request, authorization) - _v2_rate_check(request, user) - import csv - import io - with get_conn() as conn: - if not conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone(): - raise HTTPException(404, "Collection not found") - props = [dict(r) for r in conn.execute("SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position", (collection_id,)).fetchall()] - rows = conn.execute("SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position", (collection_id,)).fetchall() - out = io.StringIO() - writer = csv.writer(out) - header = ["Title"] + [p["name"] for p in props] - writer.writerow(header) - for r in rows: - try: - pv = json.loads(r["property_values_json"] or "{}") - except Exception: - pv = {} - vals = [r["title"]] - for p in props: - vals.append(str(pv.get(str(p["id"])) or pv.get(p["name"]) or "")) - writer.writerow(vals) - return Response(content=out.getvalue().encode("utf-8"), media_type="text/csv", headers={"Content-Disposition": f'attachment; filename="collection-{collection_id}.csv"'}) - -@router.post("/collections/{collection_id}/import/csv") -async def import_csv_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - try: - form = await request.form() - file = form.get("file") - data = await file.read() if file else b"" - text = data.decode("utf-8", errors="ignore") - except Exception as err: - raise HTTPException(400, "file required (multipart)") from err - import csv - import io - reader = csv.DictReader(io.StringIO(text)) - created = 0 - with get_conn() as conn: - for row in reader: - title = row.get("Title") or row.get("title") or "Untitled" - # map remaining columns to property names - pv = {} - # resolve prop name -> id - props = {p["name"]: p["id"] for p in conn.execute("SELECT id, name FROM collection_properties WHERE collection_id=?", (collection_id,)).fetchall()} - for k, v in row.items(): - if k in ("Title", "title"): - continue - pid = props.get(k) - if pid: - pv[str(pid)] = v - max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_pages WHERE collection_id=?", (collection_id,)).fetchone()[0] - conn.execute("INSERT INTO collection_pages (collection_id, title, position, property_values_json) VALUES (?, ?, ?, ?)", (collection_id, title, max_pos, json.dumps(pv))) - created += 1 - conn.commit() - return {"imported": created, "status": "ok"} - -# ── Forges / Projects ───────────────────────────────────────────────────── - -@router.get("/projects") -def list_projects_v2(request: Request, authorization: str | None = Header(default=None)): - user = get_bearer_user(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - rows = conn.execute("SELECT * FROM projects ORDER BY proj_type, owner, name").fetchall() - return {"projects": [row_to_dict(r) for r in rows]} - -@router.get("/projects/{owner}/{repo}/tree") -async def project_tree_v2(owner: str, repo: str, request: Request, path: str = "", authorization: str | None = Header(default=None)): - get_bearer_user(request, authorization) - # proxy to gitea client? Return placeholder listing from projects table - with get_conn() as conn: - proj = conn.execute("SELECT * FROM projects WHERE owner=? AND name=?", (owner, repo)).fetchone() - if not proj: - raise HTTPException(404, "Project not found") - # delegate to gitea API if available (best-effort) - try: - from app.services.gitea_client import gitea - tree = await gitea.list_repo_files(owner, repo, path or "") - return {"owner": owner, "repo": repo, "path": path, "tree": tree} - except Exception: - return {"owner": owner, "repo": repo, "path": path, "tree": []} - -# ── Search (FTS5 + LIKE fallback) ───────────────────────────────────────── - -@router.get("/search") -def search_v2(request: Request, query: str = "", workspace_id: int | None = None, type: str = "all", authorization: str | None = Header(default=None)): - user = get_bearer_user(request, authorization) - _v2_rate_check(request, user) - q = (query or request.query_params.get("query") or "").strip() - if not q: - return {"results": [], "query": q} - like = f"%{q}%" - with get_conn() as conn: - pages = [] - # try FTS5 - try: - rows = conn.execute("SELECT p.id, p.title, p.content, p.workspace_id, snippet(pages_fts, -1, '', '', '...', 32) as snippet FROM pages_fts f JOIN pages p ON p.id=f.rowid WHERE pages_fts MATCH ? LIMIT 20", (q,)).fetchall() - pages = [{"type": "page", "id": r["id"], "title": r["title"], "snippet": r["snippet"]} for r in rows] - except Exception: - rows = conn.execute("SELECT id, title FROM pages WHERE title LIKE ? OR content LIKE ? LIMIT 20", (like, like)).fetchall() - pages = [{"type": "page", "id": r["id"], "title": r["title"]} for r in rows] - # collections - colls = conn.execute("SELECT id, name FROM collections WHERE name LIKE ? LIMIT 10", (like,)).fetchall() - results = pages + [{"type": "collection", "id": r["id"], "title": r["name"]} for r in colls] - return {"query": q, "results": results} - -# ── Admin ───────────────────────────────────────────────────────────────── - -@router.get("/admin/users") -def admin_list_users_v2(request: Request, limit: int = 30, offset: int = 0, authorization: str | None = Header(default=None)): - user = get_bearer_user(request, authorization) - _v2_rate_check(request, user) - if not user.get("is_admin") and not has_scope(user.get("_token_scopes"), "admin"): - raise HTTPException(403, "Admin scope required") - limit = max(1, min(limit, 100)) - with get_conn() as conn: - total = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] - rows = conn.execute("SELECT id, login, full_name, email, is_admin, is_active, created_at FROM users ORDER BY id LIMIT ? OFFSET ?", (limit, offset)).fetchall() - return {"users": [row_to_dict(r) for r in rows], "total": total, "limit": limit, "offset": offset} - -@router.patch("/admin/users/{uid}") -def admin_patch_user_v2(uid: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): - user = get_bearer_user(request, authorization) - _v2_rate_check(request, user) - if not user.get("is_admin") and not has_scope(user.get("_token_scopes"), "admin"): - raise HTTPException(403, "Admin scope required") - with get_conn() as conn: - if not conn.execute("SELECT id FROM users WHERE id=?", (uid,)).fetchone(): - raise HTTPException(404, "User not found") - sets = [] - params: list = [] - for k in ("is_active", "is_admin", "full_name", "email"): - if k in body: - sets.append(f"{k}=?") - params.append(int(body[k]) if k in ("is_active", "is_admin") else body[k]) - if not sets: - raise HTTPException(400, "No fields") - params.append(uid) - conn.execute(f"UPDATE users SET {', '.join(sets)} WHERE id=?", params) - conn.commit() - audit_log(user, "admin.user_update", "user", uid, "", request) - return {"id": uid, "status": "updated"} - -@router.delete("/admin/users/{uid}") -def admin_delete_user_v2(uid: int, request: Request, authorization: str | None = Header(default=None)): - user = get_bearer_user(request, authorization) - _v2_rate_check(request, user) - if not user.get("is_admin") and not has_scope(user.get("_token_scopes"), "admin"): - raise HTTPException(403, "Admin scope required") - if uid == user["id"]: - raise HTTPException(400, "Cannot delete yourself") - with get_conn() as conn: - conn.execute("DELETE FROM users WHERE id=?", (uid,)) - conn.commit() - audit_log(user, "admin.user_delete", "user", uid, "", request) - return {"id": uid, "status": "deleted"} - -@router.get("/admin/audit-logs") -def admin_audit_logs_v2(request: Request, limit: int = 50, authorization: str | None = Header(default=None)): - user = get_bearer_user(request, authorization) - _v2_rate_check(request, user) - if not user.get("is_admin") and not has_scope(user.get("_token_scopes"), "admin"): - raise HTTPException(403, "Admin scope required") - limit = max(1, min(limit, 200)) - with get_conn() as conn: - rows = conn.execute("SELECT * FROM api_audit_log ORDER BY created_at DESC LIMIT ?", (limit,)).fetchall() - return {"logs": [row_to_dict(r) for r in rows]} - -# ── Webhooks (v6.4.0 prod) ──────────────────────────────────────────────── - -@router.get("/webhooks/events") -def list_webhook_events_v2(request: Request, authorization: str | None = Header(default=None)): - """Catalogue of deliverable events (+ wildcard syntax).""" - user = get_bearer_user(request, authorization) - _v2_rate_check(request, user) - from app.services.webhook_outbound import EVENTS - return {"events": EVENTS, "wildcards": ["*", "page.*", "collection.*"]} - - -@router.get("/webhooks") -def list_webhooks_v2(request: Request, authorization: str | None = Header(default=None)): - user = get_bearer_user(request, authorization) - _v2_rate_check(request, user) - limit, offset = parse_pagination(request) - with get_conn() as conn: - total = conn.execute("SELECT COUNT(*) AS n FROM webhook_subscriptions").fetchone()["n"] - rows = conn.execute( - "SELECT * FROM webhook_subscriptions ORDER BY created_at DESC LIMIT ? OFFSET ?", - (limit, offset), - ).fetchall() - return JSONResponse( - content={"webhooks": [dict(r) for r in rows]}, - headers=paginate_headers(total), - ) - -@router.post("/webhooks") -def create_webhook_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - from app.services.webhook_outbound import EVENTS, _event_matches - url = (body.get("url") or "").strip() - event = (body.get("event") or "page.created").strip() - secret = (body.get("secret") or "").strip() - if not url or not url.startswith("http"): - raise HTTPException(400, "url must start with http") - if not event or (event not in EVENTS and not (event.endswith(".*") or event in ("*", "all"))): - raise HTTPException(400, f"Unknown event '{event}'. See GET /api/v2/webhooks/events") - # make sure the pattern matches at least one known event - if not any(_event_matches(event, e) for e in EVENTS): - raise HTTPException(400, f"Event pattern '{event}' matches no known event") - with get_conn() as conn: - cur = conn.execute("INSERT INTO webhook_subscriptions (url, event, secret) VALUES (?, ?, ?)", (url, event, secret)) - wid = cur.lastrowid - conn.commit() - row = conn.execute("SELECT * FROM webhook_subscriptions WHERE id=?", (wid,)).fetchone() - audit_log(user, "webhook.create", "webhook", wid, url, request) - return {"id": wid, "status": "created", "webhook": dict(row) if row else {}, - "signature_header": "X-FlowDeck-Signature (HMAC-SHA256, sha256=)" if secret else None} - -@router.patch("/webhooks/{webhook_id}") -def patch_webhook_v2(webhook_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - row = conn.execute("SELECT * FROM webhook_subscriptions WHERE id=?", (webhook_id,)).fetchone() - if not row: - raise HTTPException(404, "Webhook not found") - url = body.get("url", row["url"]) - event = body.get("event", row["event"]) - secret = body.get("secret", row["secret"]) - active = int(bool(body.get("active", row["active"]))) - conn.execute("UPDATE webhook_subscriptions SET url=?, event=?, secret=?, active=? WHERE id=?", (url, event, secret, active, webhook_id)) - conn.commit() - audit_log(user, "webhook.update", "webhook", webhook_id, "", request) - return {"id": webhook_id, "status": "updated"} - -@router.delete("/webhooks/{webhook_id}") -def delete_webhook_v2(webhook_id: int, request: Request, authorization: str | None = Header(default=None)): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - conn.execute("DELETE FROM webhook_subscriptions WHERE id=?", (webhook_id,)) - conn.commit() - audit_log(user, "webhook.delete", "webhook", webhook_id, "", request) - return {"id": webhook_id, "status": "deleted"} - -@router.post("/webhooks/{webhook_id}/test") -async def test_webhook_v2(webhook_id: int, request: Request, authorization: str | None = Header(default=None)): - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - with get_conn() as conn: - row = conn.execute("SELECT * FROM webhook_subscriptions WHERE id=?", (webhook_id,)).fetchone() - if not row: - raise HTTPException(404, "Webhook not found") - # live delivery via the prod dispatcher (HMAC + retry + journal), - # direct to this subscription only (no wildcard fan-out) - from app.services.webhook_outbound import deliver_to_sub - ok = await deliver_to_sub(webhook_id, row["url"], "ping", - {"webhook_id": webhook_id, "test": True}, - row["secret"] or "") - audit_log(user, "webhook.test", "webhook", webhook_id, f"ok={ok}", request) - return {"webhook_id": webhook_id, "status": "tested", "delivered": ok} - -@router.get("/webhooks/{webhook_id}/deliveries") -def list_deliveries_v2(webhook_id: int, request: Request, - status: str | None = None, - authorization: str | None = Header(default=None)): - user = get_bearer_user(request, authorization) - _v2_rate_check(request, user) - limit, offset = parse_pagination(request) - with get_conn() as conn: - if status: - total = conn.execute( - "SELECT COUNT(*) AS n FROM webhook_deliveries WHERE webhook_id=? AND status=?", - (webhook_id, status)).fetchone()["n"] - rows = conn.execute( - "SELECT * FROM webhook_deliveries WHERE webhook_id=? AND status=? " - "ORDER BY created_at DESC LIMIT ? OFFSET ?", - (webhook_id, status, limit, offset)).fetchall() - else: - total = conn.execute( - "SELECT COUNT(*) AS n FROM webhook_deliveries WHERE webhook_id=?", - (webhook_id,)).fetchone()["n"] - rows = conn.execute( - "SELECT * FROM webhook_deliveries WHERE webhook_id=? " - "ORDER BY created_at DESC LIMIT ? OFFSET ?", - (webhook_id, limit, offset)).fetchall() - return JSONResponse( - content={"deliveries": [row_to_dict(r) for r in rows]}, - headers=paginate_headers(total), - ) - - -@router.post("/webhooks/{webhook_id}/retry") -async def retry_webhook_deliveries(webhook_id: int, request: Request, - authorization: str | None = Header(default=None)): - """Manually retry failed deliveries for a webhook.""" - user = require_scope("write")(request, authorization) - _v2_rate_check(request, user) - - from app.services.webhook_outbound import retry_due_deliveries - - with get_conn() as conn: - # Force retry by setting next_retry_at to the past - conn.execute( - """UPDATE webhook_deliveries - SET next_retry_at = strftime('%s', 'now', '-1 second') - WHERE webhook_id = ? AND status = 'retrying'""", - (webhook_id,), - ) - conn.commit() - - retried = await retry_due_deliveries() - audit_log(user, "webhook.retry", "webhook", webhook_id, f"retried={retried}", request) - return {"webhook_id": webhook_id, "status": "retried", "retried_count": retried} - - -@router.post("/webhooks/verify-signature") -def verify_webhook_signature(request: Request, - authorization: str | None = Header(default=None), - body: dict = Body(default={})): - """Verify a webhook signature (for debugging/testing).""" - user = get_bearer_user(request, authorization) - _v2_rate_check(request, user) - - from app.services.webhook_outbound import verify_signature - - secret = body.get("secret", "") - payload = body.get("payload", "{}") - signature = body.get("signature", "") - - is_valid = verify_signature(secret, payload.encode(), signature) - - audit_log(user, "webhook.signature_verify", "webhook", 0, f"valid={is_valid}", request) - return {"valid": is_valid, "secret": secret[:10] + "..." if len(secret) > 10 else secret} diff --git a/app/routers/api_v2/__init__.py b/app/routers/api_v2/__init__.py new file mode 100644 index 0000000..d7f1c5b --- /dev/null +++ b/app/routers/api_v2/__init__.py @@ -0,0 +1,42 @@ +"""FlowDeck — Public API v2. + +Découpe A28 : l'ancien `api_v2.py` (2 110 lignes, 115 routes) est devenu +ce package — un module par concern (`_common` = helpers), `router` +agrégé ci-dessous avec le même prefix/tags qu'avant → 0 changement +d'URL, 0 changement d'operation_id. +""" +from __future__ import annotations + +from fastapi import APIRouter + +from . import ( + admin, + collections, + engagement, + identity, + planning, + projects, + properties, + sharing, + templates_io, + views, + webhooks, + workspaces, +) + +router = APIRouter(prefix="/api/v2") +for _mod in ( + identity, + workspaces, + collections, + properties, + views, + engagement, + sharing, + planning, + templates_io, + projects, + admin, + webhooks, +): + router.include_router(_mod.router) diff --git a/app/routers/api_v2/_common.py b/app/routers/api_v2/_common.py new file mode 100644 index 0000000..12dc54a --- /dev/null +++ b/app/routers/api_v2/_common.py @@ -0,0 +1,36 @@ +"""FlowDeck — API v2 : helpers partagés des modules de routes (A28).""" +from __future__ import annotations + +import hashlib +import logging + +from fastapi import HTTPException, Request + +from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers + audit_log, + check_idempotency, + check_v2_rate_limit, + get_bearer_user, + has_scope, + paginate_headers, + parse_pagination, + require_scope, + row_to_dict, + store_idempotency, + to_iso8601, + validate_scopes_input, +) + +logger = logging.getLogger(__name__) + +def _hash(token: str) -> str: + return hashlib.sha256(token.encode()).hexdigest() + +def _v2_rate_check(request: Request, user: dict) -> None: + ip = request.client.host if request.client else "unknown" + th = user.get("_token_hash") + if not check_v2_rate_limit(th, ip): + raise HTTPException(status_code=429, detail="Rate limit exceeded: 300 req/min per token") + +# ── Tokens ──────────────────────────────────────────────────────────────── + diff --git a/app/routers/api_v2/admin.py b/app/routers/api_v2/admin.py new file mode 100644 index 0000000..2730834 --- /dev/null +++ b/app/routers/api_v2/admin.py @@ -0,0 +1,91 @@ +"""FlowDeck — Public API v2 : admin. + +Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency). +""" +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Body, Header, HTTPException, Request + +from app.db import get_conn +from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers + audit_log, + check_idempotency, + check_v2_rate_limit, + get_bearer_user, + has_scope, + paginate_headers, + parse_pagination, + require_scope, + row_to_dict, + store_idempotency, + to_iso8601, + validate_scopes_input, +) + +from ._common import _v2_rate_check + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["api-v2"]) + + + +@router.patch("/admin/users/{uid}") +def admin_patch_user_v2(uid: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): + user = get_bearer_user(request, authorization) + _v2_rate_check(request, user) + if not user.get("is_admin") and not has_scope(user.get("_token_scopes"), "admin"): + raise HTTPException(403, "Admin scope required") + with get_conn() as conn: + if not conn.execute("SELECT id FROM users WHERE id=?", (uid,)).fetchone(): + raise HTTPException(404, "User not found") + sets = [] + params: list = [] + for k in ("is_active", "is_admin", "full_name", "email"): + if k in body: + sets.append(f"{k}=?") + params.append(int(body[k]) if k in ("is_active", "is_admin") else body[k]) + if not sets: + raise HTTPException(400, "No fields") + params.append(uid) + conn.execute(f"UPDATE users SET {', '.join(sets)} WHERE id=?", params) + conn.commit() + audit_log(user, "admin.user_update", "user", uid, "", request) + return {"id": uid, "status": "updated"} + + +@router.delete("/admin/users/{uid}") +def admin_delete_user_v2(uid: int, request: Request, authorization: str | None = Header(default=None)): + user = get_bearer_user(request, authorization) + _v2_rate_check(request, user) + if not user.get("is_admin") and not has_scope(user.get("_token_scopes"), "admin"): + raise HTTPException(403, "Admin scope required") + if uid == user["id"]: + raise HTTPException(400, "Cannot delete yourself") + with get_conn() as conn: + conn.execute("DELETE FROM users WHERE id=?", (uid,)) + conn.commit() + audit_log(user, "admin.user_delete", "user", uid, "", request) + return {"id": uid, "status": "deleted"} + + +@router.get("/admin/audit-logs") +def admin_audit_logs_v2(request: Request, limit: int = 50, authorization: str | None = Header(default=None)): + user = get_bearer_user(request, authorization) + _v2_rate_check(request, user) + if not user.get("is_admin") and not has_scope(user.get("_token_scopes"), "admin"): + raise HTTPException(403, "Admin scope required") + limit = max(1, min(limit, 200)) + with get_conn() as conn: + rows = conn.execute("SELECT * FROM api_audit_log ORDER BY created_at DESC LIMIT ?", (limit,)).fetchall() + return {"logs": [row_to_dict(r) for r in rows]} + + +@router.get("/webhooks/events") +def list_webhook_events_v2(request: Request, authorization: str | None = Header(default=None)): + """Catalogue of deliverable events (+ wildcard syntax).""" + user = get_bearer_user(request, authorization) + _v2_rate_check(request, user) + from app.services.webhook_outbound import EVENTS + return {"events": EVENTS, "wildcards": ["*", "page.*", "collection.*"]} diff --git a/app/routers/api_v2/collections.py b/app/routers/api_v2/collections.py new file mode 100644 index 0000000..a4ad45a --- /dev/null +++ b/app/routers/api_v2/collections.py @@ -0,0 +1,566 @@ +"""FlowDeck — Public API v2 : collections. + +Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency). +""" +from __future__ import annotations + +import json +import logging + +from fastapi import APIRouter, Body, Header, HTTPException, Request +from fastapi.responses import JSONResponse + +from app.db import get_conn +from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers + audit_log, + check_idempotency, + check_v2_rate_limit, + get_bearer_user, + has_scope, + paginate_headers, + parse_pagination, + require_scope, + row_to_dict, + store_idempotency, + to_iso8601, + validate_scopes_input, +) +from app.services.automations import fire_event as _fire_event +from app.services.automations import run_event_sync + +from ._common import _v2_rate_check + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["api-v2"]) + + + +@router.get("/collections") +def list_collections_v2(request: Request, authorization: str | None = Header(default=None)): + user = get_bearer_user(request, authorization) + _v2_rate_check(request, user) + limit, offset = parse_pagination(request) + ws_filter = request.query_params.get("workspace_id") + q = (request.query_params.get("query") or "").strip() + with get_conn() as conn: + where = [] + params: list = [] + if ws_filter: + try: + wid = int(ws_filter) + where.append("c.workspace_id=?") + params.append(wid) + except ValueError: + pass + if q: + where.append("(c.name LIKE ? OR c.description LIKE ?)") + like = f"%{q}%" + params.extend([like, like]) + clause = ("WHERE " + " AND ".join(where)) if where else "" + total = conn.execute(f"SELECT COUNT(*) FROM collections c {clause}", params).fetchone()[0] + rows = conn.execute(f"SELECT c.* FROM collections c {clause} ORDER BY c.name LIMIT ? OFFSET ?", (*params, limit, offset)).fetchall() + cols = [] + for r in rows: + d = row_to_dict(r) + # filter by visibility: skip private not visible (best-effort) + cols.append(d) + resp = {"collections": cols, "total": total, "limit": limit, "offset": offset} + return JSONResponse(content=resp, headers=paginate_headers(total)) + + +@router.post("/collections") +def create_collection_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + idem = check_idempotency(request, user["id"]) + if idem: + return JSONResponse(content=idem["data"], status_code=idem["status"]) + name = (body.get("name") or "").strip() + if not name: + raise HTTPException(400, "name is required") + description = body.get("description", "") + icon = body.get("icon", "📋") + workspace_id = body.get("workspace_id") + schema = body.get("schema") or body.get("schema_json") or [] + if isinstance(schema, str): + try: + schema = json.loads(schema) + except Exception: + schema = [] + schema_json = json.dumps(schema) + with get_conn() as conn: + cur = conn.execute("INSERT INTO collections (name, description, icon, schema_json, workspace_id, created_by) VALUES (?, ?, ?, ?, ?, ?)", (name, description, icon, schema_json, workspace_id, user["id"])) + cid = cur.lastrowid + # materialize properties if schema provided — A25 : PAS de try ici, + # une exception doit interrompre la transaction (sinon la collection est + # commitée sans son schéma et l'erreur disparaît). + from app.services.db_templates import materialize_properties + materialize_properties(conn, cid, schema) + # default view + conn.execute("INSERT INTO collection_views (collection_id, name, view_type, config_json) VALUES (?,?,?,?)", (cid, "Default View", "table", json.dumps({"visible_properties": ["Title"]}))) + conn.commit() + row = conn.execute("SELECT * FROM collections WHERE id=?", (cid,)).fetchone() + audit_log(user, "collection.create", "collection", cid, name, request) + data = {"id": cid, "name": name, "status": "created", "collection": row_to_dict(row)} + key = (request.headers.get("Idempotency-Key") or "").strip() + if key: + store_idempotency(key, user["id"], data, 201) + return JSONResponse(content=data, status_code=201) + + +@router.get("/collections/{collection_id}") +def get_collection_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)): + user = get_bearer_user(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + row = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone() + if not row: + raise HTTPException(404, "Collection not found") + pages = conn.execute("SELECT id, title, icon, position, property_values_json, created_at FROM collection_pages WHERE collection_id=? ORDER BY position LIMIT 50", (collection_id,)).fetchall() + d = row_to_dict(row) + d["pages"] = [row_to_dict(p) for p in pages] + return d + + +@router.patch("/collections/{collection_id}") +def patch_collection_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + row = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone() + if not row: + raise HTTPException(404, "Collection not found") + name = body.get("name", row["name"]) + description = body.get("description", row["description"]) + icon = body.get("icon", row["icon"]) + schema = body.get("schema") or body.get("schema_json") + if schema is not None: + sj = json.dumps(schema) if isinstance(schema, (list, dict)) else str(schema) + else: + sj = row["schema_json"] + conn.execute("UPDATE collections SET name=?, description=?, icon=?, schema_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (name, description, icon, sj, collection_id)) + conn.commit() + audit_log(user, "collection.update", "collection", collection_id, "", request) + return {"id": collection_id, "status": "updated"} + + +@router.delete("/collections/{collection_id}") +def delete_collection_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + row = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone() + if not row: + raise HTTPException(404, "Collection not found") + conn.execute("DELETE FROM collections WHERE id=?", (collection_id,)) + conn.commit() + audit_log(user, "collection.delete", "collection", collection_id, "", request) + return {"id": collection_id, "status": "deleted"} + + +@router.post("/collections/{collection_id}/linked") +def create_linked_db(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + name = (body.get("name") or "").strip() or f"Linked DB {collection_id}" + with get_conn() as conn: + src = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone() + if not src: + raise HTTPException(404, "Collection not found") + cur = conn.execute("INSERT INTO collections (name, description, icon, schema_json, workspace_id, created_by) VALUES (?, ?, ?, ?, ?, ?)", (name, src["description"], src["icon"], src["schema_json"], src["workspace_id"] if "workspace_id" in src.keys() else None, user["id"])) + nid = cur.lastrowid + # copy data source as linked + try: + conn.execute("INSERT INTO collection_data_sources (collection_id, source_collection_id, is_linked) VALUES (?, ?, 1)", (nid, collection_id)) + except Exception: + logger.exception("create_linked_db") + # copy views + properties (light) + rows = conn.execute("SELECT * FROM collection_properties WHERE collection_id=?", (collection_id,)).fetchall() + for p in rows: + try: + conn.execute("INSERT INTO collection_properties (collection_id, name, prop_type, options_json, position) VALUES (?, ?, ?, ?, ?)", (nid, p["name"], p["prop_type"], p["options_json"], p["position"])) + except Exception: + logger.exception("create_linked_db") + vrows = conn.execute("SELECT * FROM collection_views WHERE collection_id=?", (collection_id,)).fetchall() + for v in vrows: + try: + conn.execute("INSERT INTO collection_views (collection_id, name, view_type, config_json, position) VALUES (?, ?, ?, ?, ?)", (nid, v["name"], v["view_type"], v["config_json"], v["position"])) + except Exception: + logger.exception("create_linked_db") + conn.commit() + audit_log(user, "collection.linked", "collection", nid, f"src={collection_id}", request) + return {"id": nid, "name": name, "status": "created"} + + +@router.post("/collections/{collection_id}/task") +def toggle_task(collection_id: int, request: Request, authorization: str | None = Header(default=None)): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + row = conn.execute("SELECT is_task FROM collections WHERE id=?", (collection_id,)).fetchone() + if not row: + raise HTTPException(404, "Collection not found") + cur_val = row["is_task"] if "is_task" in row.keys() else 0 + new_val = 0 if cur_val else 1 + conn.execute("UPDATE collections SET is_task=? WHERE id=?", (new_val, collection_id)) + conn.commit() + audit_log(user, "collection.toggle_task", "collection", collection_id, str(new_val), request) + return {"id": collection_id, "is_task": bool(new_val)} + + +@router.get("/collections/{collection_id}/sources") +def list_sources(collection_id: int, request: Request, authorization: str | None = Header(default=None)): + user = get_bearer_user(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + if not conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone(): + raise HTTPException(404, "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("/collections/{collection_id}/sources") +def add_source(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + src_id = body.get("source_collection_id") or body.get("source_id") + if not src_id: + raise HTTPException(400, "source_collection_id required") + with get_conn() as conn: + if not conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone(): + raise HTTPException(404, "Collection not found") + if not conn.execute("SELECT id FROM collections WHERE id=?", (src_id,)).fetchone(): + raise HTTPException(404, "Source collection not found") + try: + conn.execute("INSERT INTO collection_data_sources (collection_id, source_collection_id) VALUES (?, ?)", (collection_id, src_id)) + conn.commit() + except Exception as e: + raise HTTPException(409, str(e)) from None + audit_log(user, "collection.add_source", "collection", collection_id, str(src_id), request) + return {"collection_id": collection_id, "source_collection_id": src_id, "status": "added"} + + +@router.delete("/collections/{collection_id}/sources/{source_id}") +def remove_source(collection_id: int, source_id: int, request: Request, authorization: str | None = Header(default=None)): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + conn.execute("DELETE FROM collection_data_sources WHERE collection_id=? AND (id=? OR source_collection_id=?)", (collection_id, source_id, source_id)) + conn.commit() + audit_log(user, "collection.remove_source", "collection", collection_id, str(source_id), request) + return {"status": "removed"} + + +@router.get("/collections/{collection_id}/pages") +def list_collection_pages_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)): + user = get_bearer_user(request, authorization) + _v2_rate_check(request, user) + limit, offset = parse_pagination(request) + with get_conn() as conn: + if not conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone(): + raise HTTPException(404, "Collection not found") + total = conn.execute("SELECT COUNT(*) FROM collection_pages WHERE collection_id=?", (collection_id,)).fetchone()[0] + # filters: filter[status]=Done etc., sort, fields + # Simple: filter by property name via property_values_json LIKE (best-effort), sort by position or title + sort = request.query_params.get("sort") or "" + order = "position" + desc = False + if sort: + if sort.startswith("-"): + desc = True + sort = sort[1:] + # allow sorting by title/position/created_at + if sort in ("title", "position", "created_at", "updated_at"): + order = sort + direction = "DESC" if desc else "ASC" + rows = conn.execute(f"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY {order} {direction} LIMIT ? OFFSET ?", (collection_id, limit, offset)).fetchall() + # apply filter[xxx] in-memory (small) + filters = {k[7:-1]: v for k, v in request.query_params.items() if k.startswith("filter[") and k.endswith("]")} + fields = request.query_params.get("fields") + fields_set = set(fields.split(",")) if fields else None + out = [] + for r in rows: + d = row_to_dict(r) + # property filter (AND) + if filters: + try: + pv = json.loads(r["property_values_json"] or "{}") if isinstance(r["property_values_json"], str) else r["property_values_json"] + except Exception: + pv = {} + ok = True + for fk, fv in filters.items(): + # lookup by prop id or name + found = False + for kk, vv in (pv or {}).items(): + if str(kk) == str(fk) or str(kk).lower() == fk.lower(): + if str(vv) == str(fv): + found = True + break + # also check title if filter field is title + if fk == "title" and d.get("title") == fv: + found = True + if not found: + ok = False + break + if not ok: + continue + if fields_set: + d = {k: v for k, v in d.items() if k in fields_set or k in ("id", "collection_id")} + out.append(d) + return JSONResponse(content={"pages": out, "total": total, "limit": limit, "offset": offset}, headers=paginate_headers(total)) + + +@router.post("/collections/{collection_id}/pages") +def create_collection_page_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + idem = check_idempotency(request, user["id"]) + if idem: + return JSONResponse(content=idem["data"], status_code=idem["status"]) + title = (body.get("title") or body.get("name") or "Untitled").strip() or "Untitled" + icon = body.get("icon", "file") + parent_id = body.get("parent_id") + prop_vals = body.get("property_values") or body.get("properties") or body.get("property_values_json") or {} + if isinstance(prop_vals, str): + try: + prop_vals = json.loads(prop_vals) + except Exception: + prop_vals = {} + with get_conn() as conn: + if not conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone(): + raise HTTPException(404, "Collection not found") + # validate properties if helper exists + try: + pass + # light validation: we rely on existing validators + except Exception: + logger.exception("create_collection_page_v2") + max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_pages WHERE collection_id=?", (collection_id,)).fetchone()[0] + # apply auto props + try: + props_list = [dict(r) for r in conn.execute("SELECT * FROM collection_properties WHERE collection_id=?", (collection_id,)).fetchall()] + from app.services.property_types import apply_auto_properties as _aap + _aap(props_list, prop_vals, user, is_create=True) + except Exception: + logger.exception("create_collection_page_v2") + cur = conn.execute("INSERT INTO collection_pages (collection_id, title, icon, position, parent_id, property_values_json) VALUES (?, ?, ?, ?, ?, ?)", (collection_id, title, icon, max_pos, parent_id, json.dumps(prop_vals))) + pid = cur.lastrowid + conn.commit() + row = conn.execute("SELECT * FROM collection_pages WHERE id=?", (pid,)).fetchone() + audit_log(user, "page.create", "collection_page", pid, title, request) + try: + run_event_sync(_fire_event("collection.page.created", {"page_id": pid, "collection_id": collection_id, "title": title})) + except Exception: + logger.exception("create_collection_page_v2") + data = {"id": pid, "title": title, "status": "created", "page": row_to_dict(row)} + key = (request.headers.get("Idempotency-Key") or "").strip() + if key: + store_idempotency(key, user["id"], data, 201) + return JSONResponse(content=data, status_code=201) + + +@router.get("/pages/{page_id}") +def get_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)): + user = get_bearer_user(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + row = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone() + if not row: + # also try pages table (block pages) + row2 = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone() + if not row2: + raise HTTPException(404, "Page not found") + d = row_to_dict(row2) + # v6.5.0: resolve synced blocks server-side (fresh content). + if (d.get("content_format") or "blocks") == "blocks" and d.get("content"): + from app.services.synced_blocks import resolve_content_json + d["content"] = resolve_content_json(d["content"], d["content_format"]) + return d + d = row_to_dict(row) + # property_values_json already parsed by row_to_dict + # v6.5.0: expose the row's content page when it exists (no lazy + # creation on a read-only endpoint). + content_page_id = conn.execute( + "SELECT id FROM pages WHERE collection_row_id=?", + (page_id,), + ).fetchone() + d["content_page_id"] = content_page_id["id"] if content_page_id else None + return d + + +@router.patch("/pages/{page_id}") +def patch_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + row = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone() + if not row: + raise HTTPException(404, "Page not found") + title = body.get("title", row["title"]) + icon = body.get("icon", row["icon"]) + pos = body.get("position", row["position"]) + parent_id = body.get("parent_id", row["parent_id"]) + pv_raw = row["property_values_json"] or "{}" + try: + stored = json.loads(pv_raw) if isinstance(pv_raw, str) else dict(pv_raw) + except Exception: + stored = {} + incoming = body.get("property_values") or body.get("properties") + if incoming is not None: + if isinstance(incoming, str): + try: + incoming = json.loads(incoming) + except Exception: + incoming = {} + # merge + for k, v in (incoming or {}).items(): + stored[str(k)] = v + # apply auto props + try: + props_list = [dict(r) for r in conn.execute("SELECT * FROM collection_properties WHERE collection_id=?", (row["collection_id"],)).fetchall()] + from app.services.property_types import apply_auto_properties as _aap + _aap(props_list, stored, user, is_create=False) + except Exception: + logger.exception("patch_page_v2") + conn.execute("UPDATE collection_pages SET title=?, icon=?, position=?, parent_id=?, property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, icon, pos, parent_id, json.dumps(stored), page_id)) + conn.commit() + audit_log(user, "page.update", "collection_page", page_id, "", request) + try: + run_event_sync(_fire_event("collection.page.updated", {"page_id": page_id, "collection_id": row["collection_id"], "title": title})) + except Exception: + logger.exception("patch_page_v2") + return {"id": page_id, "status": "updated"} + + +@router.delete("/pages/{page_id}") +def delete_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + row = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone() + if not row: + raise HTTPException(404, "Page not found") + conn.execute("DELETE FROM collection_pages WHERE id=?", (page_id,)) + conn.commit() + audit_log(user, "page.delete", "collection_page", page_id, "", request) + try: + run_event_sync(_fire_event("collection.page.deleted", {"page_id": page_id, "collection_id": row["collection_id"]})) + except Exception: + logger.exception("delete_page_v2") + return {"id": page_id, "status": "deleted"} + + +@router.post("/pages/{page_id}/restore") +def restore_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + # For soft-deleted pages (deleted_at) - but collection_pages has no deleted_at; handle pages table + with get_conn() as conn: + row = conn.execute("SELECT deleted_at FROM pages WHERE id=?", (page_id,)).fetchone() + if row and row["deleted_at"]: + conn.execute("UPDATE pages SET deleted_at=NULL WHERE id=?", (page_id,)) + conn.commit() + try: + run_event_sync(_fire_event("page.restored", {"page_id": page_id})) + except Exception: + logger.exception("restore_page_v2") + return {"id": page_id, "status": "restored"} + raise HTTPException(404, "Page not found or not deleted") + + +@router.post("/pages/{page_id}/move") +def move_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + row = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone() + if not row: + raise HTTPException(404, "Page not found") + parent_id = body.get("parent_id", row["parent_id"]) + position = body.get("position", row["position"]) + conn.execute("UPDATE collection_pages SET parent_id=?, position=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (parent_id, position, page_id)) + conn.commit() + audit_log(user, "page.move", "collection_page", page_id, f"parent={parent_id} pos={position}", request) + return {"id": page_id, "status": "moved"} + + +@router.get("/pages/{page_id}/sub-items") +def list_sub_items_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)): + user = get_bearer_user(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + if not conn.execute("SELECT id FROM collection_pages WHERE id=?", (page_id,)).fetchone(): + raise HTTPException(404, "Page not found") + rows = conn.execute("SELECT * FROM collection_pages WHERE parent_id=? ORDER BY position", (page_id,)).fetchall() + return {"sub_items": [row_to_dict(r) for r in rows]} + + +@router.post("/pages/{page_id}/sub-items") +def create_sub_item_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + parent = conn.execute("SELECT collection_id FROM collection_pages WHERE id=?", (page_id,)).fetchone() + if not parent: + raise HTTPException(404, "Page not found") + title = (body.get("title") or "Untitled").strip() + max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_pages WHERE parent_id=?", (page_id,)).fetchone()[0] + pv = json.dumps(body.get("property_values") or {}) + cur = conn.execute("INSERT INTO collection_pages (collection_id, title, parent_id, position, property_values_json) VALUES (?, ?, ?, ?, ?)", (parent["collection_id"], title, page_id, max_pos, pv)) + nid = cur.lastrowid + conn.commit() + audit_log(user, "page.create_subitem", "collection_page", nid, title, request) + return {"id": nid, "status": "created"} + + +@router.get("/pages/{page_id}/dependencies") +def list_dependencies_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)): + user = get_bearer_user(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + rows = conn.execute("SELECT * FROM page_dependencies WHERE page_id=?", (page_id,)).fetchall() + return {"dependencies": [dict(r) for r in rows]} + + +@router.post("/pages/{page_id}/dependencies") +def add_dependency_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + dep_id = body.get("dependency_id") or body.get("depends_on") + dtype = body.get("dependency_type") or "blocks" + if not dep_id: + raise HTTPException(400, "dependency_id required") + with get_conn() as conn: + if not conn.execute("SELECT id FROM collection_pages WHERE id=?", (page_id,)).fetchone(): + raise HTTPException(404, "Page not found") + if not conn.execute("SELECT id FROM collection_pages WHERE id=?", (dep_id,)).fetchone(): + raise HTTPException(404, "Dependency page not found") + try: + conn.execute("INSERT INTO page_dependencies (page_id, dependency_id, dependency_type) VALUES (?, ?, ?)", (page_id, dep_id, dtype)) + conn.commit() + except Exception as e: + raise HTTPException(409, str(e)) from None + audit_log(user, "page.add_dependency", "collection_page", page_id, str(dep_id), request) + return {"status": "added"} + + +@router.delete("/pages/{page_id}/dependencies/{dep_id}") +def remove_dependency_v2(page_id: int, dep_id: int, request: Request, authorization: str | None = Header(default=None)): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + conn.execute("DELETE FROM page_dependencies WHERE page_id=? AND dependency_id=?", (page_id, dep_id)) + conn.commit() + audit_log(user, "page.remove_dependency", "collection_page", page_id, str(dep_id), request) + return {"status": "removed"} + + +@router.get("/collections/{collection_id}/properties") +def list_properties_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)): + user = get_bearer_user(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + if not conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone(): + raise HTTPException(404, "Collection not found") + rows = conn.execute("SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position", (collection_id,)).fetchall() + return {"properties": [row_to_dict(r) for r in rows]} diff --git a/app/routers/api_v2/engagement.py b/app/routers/api_v2/engagement.py new file mode 100644 index 0000000..34e6b86 --- /dev/null +++ b/app/routers/api_v2/engagement.py @@ -0,0 +1,338 @@ +"""FlowDeck — Public API v2 : engagement. + +Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency). +""" +from __future__ import annotations + +import json +import logging + +from fastapi import APIRouter, Body, Header, HTTPException, Request + +from app.db import get_conn +from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers + audit_log, + check_idempotency, + check_v2_rate_limit, + get_bearer_user, + has_scope, + paginate_headers, + parse_pagination, + require_scope, + row_to_dict, + store_idempotency, + to_iso8601, + validate_scopes_input, +) +from app.services.automations import fire_event as _fire_event +from app.services.automations import run_event_sync + +from ._common import _v2_rate_check + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["api-v2"]) + + + +@router.get("/pages/{page_id}/comments") +def list_comments_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)): + user = get_bearer_user(request, authorization) + _v2_rate_check(request, user) + limit, offset = parse_pagination(request) + with get_conn() as conn: + total = conn.execute("SELECT COUNT(*) FROM comments WHERE target_id=? OR page_id=?", (page_id, page_id)).fetchone()[0] + rows = conn.execute("SELECT c.*, u.login, u.full_name FROM comments c LEFT JOIN users u ON u.id=c.user_id WHERE c.target_id=? OR c.page_id=? ORDER BY c.created_at LIMIT ? OFFSET ?", (page_id, page_id, limit, offset)).fetchall() + return {"comments": [row_to_dict(r) for r in rows], "total": total, "limit": limit, "offset": offset} + + +@router.post("/pages/{page_id}/comments") +def create_comment_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + text = (body.get("body") or body.get("content") or "").strip() + if not text: + raise HTTPException(400, "body is required") + with get_conn() as conn: + cur = conn.execute("INSERT INTO comments (page_id, user_id, body, target_type, target_id, anchor_block_id, anchor_start, anchor_end) VALUES (?, ?, ?, 'page', ?, ?, ?, ?)", (page_id, user["id"], text, page_id, body.get("anchor_block_id"), body.get("anchor_start"), body.get("anchor_end"))) + nid = cur.lastrowid + conn.commit() + row = conn.execute("SELECT * FROM comments WHERE id=?", (nid,)).fetchone() + audit_log(user, "comment.create", "comment", nid, text[:80], request) + try: + run_event_sync(_fire_event("comment.added", {"comment_id": nid, "page_id": page_id, "user_id": user["id"]})) + except Exception: + logger.exception("create_comment_v2") + return {"id": nid, "status": "created", "comment": row_to_dict(row)} + + +@router.patch("/comments/{comment_id}") +def patch_comment_v2(comment_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + row = conn.execute("SELECT * FROM comments WHERE id=?", (comment_id,)).fetchone() + if not row: + raise HTTPException(404, "Comment not found") + if row["user_id"] != user["id"] and not user.get("is_admin"): + raise HTTPException(403, "Not your comment") + body_text = body.get("body", row["body"]) + resolved = body.get("resolved", row["resolved"]) + was_resolved = int(row["resolved"] or 0) + conn.execute("UPDATE comments SET body=?, resolved=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (body_text, int(bool(resolved)), comment_id)) + conn.commit() + if int(bool(resolved)) and not was_resolved: + try: + run_event_sync(_fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]})) + except Exception: + logger.exception("patch_comment_v2") + return {"id": comment_id, "status": "updated"} + + +@router.delete("/comments/{comment_id}") +def delete_comment_v2(comment_id: int, request: Request, authorization: str | None = Header(default=None)): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + row = conn.execute("SELECT * FROM comments WHERE id=?", (comment_id,)).fetchone() + if not row: + raise HTTPException(404, "Comment not found") + if row["user_id"] != user["id"] and not user.get("is_admin"): + raise HTTPException(403, "Not your comment") + conn.execute("DELETE FROM comments WHERE id=?", (comment_id,)) + conn.commit() + return {"id": comment_id, "status": "deleted"} + + +@router.post("/pages/{page_id}/mentions") +def create_mention_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + targets = body.get("user_ids") or body.get("mentions") or [] + if isinstance(targets, int): + targets = [targets] + if not targets: + raise HTTPException(400, "user_ids required") + created = 0 + with get_conn() as conn: + for uid in targets: + try: + conn.execute("INSERT INTO notifications (user_id, actor_id, ntype, title, message, resource_type, resource_id, url) VALUES (?, ?, 'mention', 'You were mentioned', ?, 'page', ?, ?)", (uid, user["id"], body.get("message") or f"Mentioned in page {page_id}", page_id, f"/pages/{page_id}")) + created += 1 + except Exception: + logger.exception("create_mention_v2") + conn.commit() + if created: + try: + run_event_sync(_fire_event("mention.added", {"page_id": page_id, "user_ids": [u for u in targets if isinstance(u, int)], "count": created})) + except Exception: + logger.exception("create_mention_v2") + return {"mentions": created, "status": "created"} + + +@router.get("/notifications") +def list_notifications_v2(request: Request, authorization: str | None = Header(default=None)): + user = get_bearer_user(request, authorization) + _v2_rate_check(request, user) + limit, offset = parse_pagination(request) + unread = request.query_params.get("unread") + with get_conn() as conn: + where = "user_id=?" + params: list = [user["id"]] + if unread == "1": + where += " AND is_read=0" + total = conn.execute(f"SELECT COUNT(*) FROM notifications WHERE {where}", params).fetchone()[0] + rows = conn.execute(f"SELECT * FROM notifications WHERE {where} ORDER BY created_at DESC LIMIT ? OFFSET ?", (*params, limit, offset)).fetchall() + return {"notifications": [row_to_dict(r) for r in rows], "total": total, "limit": limit, "offset": offset} + + +@router.get("/notifications/unread-count") +def unread_count(request: Request, authorization: str | None = Header(default=None)): + user = get_bearer_user(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + cnt = conn.execute("SELECT COUNT(*) FROM notifications WHERE user_id=? AND is_read=0", (user["id"],)).fetchone()[0] + return {"unread": cnt} + + +@router.post("/notifications/{notif_id}/read") +def mark_read(notif_id: int, request: Request, authorization: str | None = Header(default=None)): + user = get_bearer_user(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + conn.execute("UPDATE notifications SET is_read=1 WHERE id=? AND user_id=?", (notif_id, user["id"])) + conn.commit() + return {"id": notif_id, "status": "read"} + + +@router.post("/notifications/read-all") +def mark_all_read(request: Request, authorization: str | None = Header(default=None)): + user = get_bearer_user(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + conn.execute("UPDATE notifications SET is_read=1 WHERE user_id=?", (user["id"],)) + conn.commit() + return {"status": "all read"} + + +@router.patch("/users/me/preferences") +def patch_prefs(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): + user = get_bearer_user(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + row = conn.execute("SELECT notification_prefs FROM users WHERE id=?", (user["id"],)).fetchone() + try: + cur = json.loads(row["notification_prefs"] or "{}") + except Exception: + cur = {} + cur.update(body) + conn.execute("UPDATE users SET notification_prefs=? WHERE id=?", (json.dumps(cur), user["id"])) + conn.commit() + return {"preferences": cur} + + +@router.get("/favorites") +def list_favorites_v2(request: Request, authorization: str | None = Header(default=None)): + user = get_bearer_user(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + rows = conn.execute("SELECT f.*, p.title, p.workspace_id FROM favorites f JOIN pages p ON p.id=f.page_id WHERE f.user_id=? ORDER BY f.position", (user["id"],)).fetchall() + return {"favorites": [row_to_dict(r) for r in rows]} + + +@router.post("/favorites") +def add_favorite_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + pid = body.get("page_id") + if not pid: + raise HTTPException(400, "page_id required") + with get_conn() as conn: + try: + conn.execute("INSERT INTO favorites (user_id, page_id) VALUES (?, ?)", (user["id"], pid)) + conn.commit() + except Exception as e: + raise HTTPException(409, str(e)) from None + try: + run_event_sync(_fire_event("favorite.added", {"page_id": pid, "user_id": user["id"]})) + except Exception: + logger.exception("add_favorite_v2") + return {"page_id": pid, "status": "added"} + + +@router.delete("/favorites/{page_id}") +def remove_favorite_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + conn.execute("DELETE FROM favorites WHERE user_id=? AND page_id=?", (user["id"], page_id)) + conn.commit() + try: + run_event_sync(_fire_event("favorite.removed", {"page_id": page_id, "user_id": user["id"]})) + except Exception: + logger.exception("remove_favorite_v2") + return {"page_id": page_id, "status": "removed"} + + +@router.get("/tags") +def list_tags_v2(request: Request, authorization: str | None = Header(default=None)): + user = get_bearer_user(request, authorization) + _v2_rate_check(request, user) + q = (request.query_params.get("q") or "").strip() + with get_conn() as conn: + if q: + rows = conn.execute("SELECT * FROM tags WHERE user_id=? AND name LIKE ? ORDER BY name", (user["id"], f"%{q}%")).fetchall() + else: + rows = conn.execute("SELECT * FROM tags WHERE user_id=? ORDER BY name", (user["id"],)).fetchall() + return {"tags": [dict(r) for r in rows]} + + +@router.post("/tags") +def create_tag_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + name = (body.get("name") or "").strip() + if not name: + raise HTTPException(400, "name required") + color = body.get("color", "#787774") + with get_conn() as conn: + try: + cur = conn.execute("INSERT INTO tags (name, color, user_id) VALUES (?, ?, ?)", (name, color, user["id"])) + tid = cur.lastrowid + conn.commit() + except Exception as e: + raise HTTPException(409, str(e)) from None + return {"id": tid, "name": name, "color": color, "status": "created"} + + +@router.patch("/tags/{tag_id}") +def patch_tag_v2(tag_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + row = conn.execute("SELECT * FROM tags WHERE id=? AND user_id=?", (tag_id, user["id"])).fetchone() + if not row: + raise HTTPException(404, "Tag not found") + name = body.get("name", row["name"]) + color = body.get("color", row["color"]) + conn.execute("UPDATE tags SET name=?, color=? WHERE id=?", (name, color, tag_id)) + conn.commit() + return {"id": tag_id, "status": "updated"} + + +@router.delete("/tags/{tag_id}") +def delete_tag_v2(tag_id: int, request: Request, authorization: str | None = Header(default=None)): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + conn.execute("DELETE FROM tags WHERE id=? AND user_id=?", (tag_id, user["id"])) + conn.commit() + return {"id": tag_id, "status": "deleted"} + + +@router.post("/pages/{page_id}/tags") +def attach_tag_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + tag_id = body.get("tag_id") + if not tag_id: + raise HTTPException(400, "tag_id required") + with get_conn() as conn: + try: + conn.execute("INSERT INTO page_tags (page_id, tag_id) VALUES (?, ?)", (page_id, tag_id)) + conn.commit() + except Exception as e: + raise HTTPException(409, str(e)) from None + return {"page_id": page_id, "tag_id": tag_id, "status": "attached"} + + +@router.delete("/pages/{page_id}/tags/{tag_id}") +def detach_tag_v2(page_id: int, tag_id: int, request: Request, authorization: str | None = Header(default=None)): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + conn.execute("DELETE FROM page_tags WHERE page_id=? AND tag_id=?", (page_id, tag_id)) + conn.commit() + return {"status": "detached"} + + +@router.get("/recents") +def list_recents_v2(request: Request, authorization: str | None = Header(default=None)): + user = get_bearer_user(request, authorization) + _v2_rate_check(request, user) + limit = int(request.query_params.get("limit", "20")) + st = request.query_params.get("source_type") + with get_conn() as conn: + if st: + rows = conn.execute("SELECT * FROM recents WHERE user_id=? AND source_type=? ORDER BY accessed_at DESC LIMIT ?", (user["id"], st, limit)).fetchall() + else: + rows = conn.execute("SELECT * FROM recents WHERE user_id=? ORDER BY accessed_at DESC LIMIT ?", (user["id"], limit)).fetchall() + return {"recents": [row_to_dict(r) for r in rows]} + + +@router.get("/pages/{page_id}/shares") +def list_shares_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)): + user = get_bearer_user(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + rows = conn.execute("SELECT * FROM page_shares WHERE page_id=?", (page_id,)).fetchall() + return {"shares": [dict(r) for r in rows]} diff --git a/app/routers/api_v2/identity.py b/app/routers/api_v2/identity.py new file mode 100644 index 0000000..5c3dde7 --- /dev/null +++ b/app/routers/api_v2/identity.py @@ -0,0 +1,195 @@ +"""FlowDeck — Public API v2 : identity. + +Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency). +""" +from __future__ import annotations + +import json +import logging +import secrets +from datetime import datetime + +from fastapi import APIRouter, Body, Header, HTTPException, Request +from fastapi.responses import JSONResponse + +from app.db import get_conn +from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers + audit_log, + check_idempotency, + check_v2_rate_limit, + get_bearer_user, + has_scope, + paginate_headers, + parse_pagination, + require_scope, + row_to_dict, + store_idempotency, + to_iso8601, + validate_scopes_input, +) + +from ._common import _hash, _v2_rate_check + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["api-v2"]) + + + +@router.post("/tokens") +def create_token(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + name = (body.get("name") or "API token").strip()[:100] + scopes = validate_scopes_input(body.get("scopes") or "read,write") + expires_at = body.get("expires_at") + # Idempotency + idem = check_idempotency(request, user["id"]) + if idem: + return JSONResponse(content=idem["data"], status_code=idem["status"]) + token = f"fd_{secrets.token_urlsafe(32)}" + prefix = token[:12] + th = _hash(token) + exp_val = None + if expires_at: + try: + # accept ISO string + exp_val = str(expires_at) + # validate parse + datetime.fromisoformat(exp_val.replace("Z", "+00:00")) + except Exception as err: + raise HTTPException(400, "Invalid expires_at, use ISO-8601") from err + with get_conn() as conn: + try: + cur = conn.execute( + "INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, scopes, expires_at) VALUES (?, ?, ?, ?, ?, ?)", + (user["id"], name, th, prefix, scopes, exp_val), + ) + conn.commit() + tid = cur.lastrowid + except Exception as e: + raise HTTPException(409, f"Token creation failed: {e}") from None + row = conn.execute("SELECT id, name, token_prefix, scopes, expires_at, created_at FROM api_tokens WHERE id=?", (tid,)).fetchone() + audit_log(user, "token.create", "api_token", tid, f"scopes={scopes}", request) + data = {"id": tid, "name": row["name"], "token": token, "prefix": prefix, "scopes": scopes, "expires_at": to_iso8601(row["expires_at"]) if row["expires_at"] else None, "note": "Copy token now — shown once. Use as Authorization: Bearer "} + key = (request.headers.get("Idempotency-Key") or request.headers.get("idempotency-key") or "").strip() + if key: + store_idempotency(key, user["id"], data, 200) + return data + + +@router.get("/tokens") +def list_tokens(request: Request, authorization: str | None = Header(default=None)): + user = get_bearer_user(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + rows = conn.execute("SELECT id, name, token_prefix, scopes, expires_at, last_used_at, created_at, revoked FROM api_tokens WHERE user_id=? ORDER BY created_at DESC", (user["id"],)).fetchall() + out = [] + for r in rows: + d = dict(r) + d["created_at"] = to_iso8601(d.get("created_at")) + d["expires_at"] = to_iso8601(d.get("expires_at")) if d.get("expires_at") else None + d["last_used_at"] = to_iso8601(d.get("last_used_at")) if d.get("last_used_at") else None + # never expose hash + out.append({k: v for k, v in d.items() if k != "token_hash"}) + return {"tokens": out} + + +@router.delete("/tokens/{token_id}") +def revoke_token(token_id: int, request: Request, authorization: str | None = Header(default=None)): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + row = conn.execute("SELECT id, user_id FROM api_tokens WHERE id=?", (token_id,)).fetchone() + if not row: + raise HTTPException(404, "Token not found") + if row["user_id"] != user["id"] and not user.get("is_admin"): + raise HTTPException(403, "Not your token") + conn.execute("UPDATE api_tokens SET revoked=1 WHERE id=?", (token_id,)) + conn.commit() + audit_log(user, "token.revoke", "api_token", token_id, "", request) + return {"id": token_id, "status": "revoked"} + + +@router.post("/tokens/{token_id}/rotate") +def rotate_token(token_id: int, request: Request, authorization: str | None = Header(default=None)): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + row = conn.execute("SELECT id, user_id, name, scopes FROM api_tokens WHERE id=?", (token_id,)).fetchone() + if not row: + raise HTTPException(404, "Token not found") + if row["user_id"] != user["id"] and not user.get("is_admin"): + raise HTTPException(403, "Not your token") + # revoke old + conn.execute("UPDATE api_tokens SET revoked=1 WHERE id=?", (token_id,)) + new_token = f"fd_{secrets.token_urlsafe(32)}" + th = _hash(new_token) + prefix = new_token[:12] + cur = conn.execute("INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, scopes) VALUES (?, ?, ?, ?, ?)", (row["user_id"], row["name"], th, prefix, row["scopes"] or "read,write")) + conn.commit() + nid = cur.lastrowid + audit_log(user, "token.rotate", "api_token", token_id, f"new_id={nid}", request) + return {"id": nid, "token": new_token, "prefix": prefix, "scopes": row["scopes"], "note": "Copy token now — shown once"} + + +@router.get("/users/me") +def get_me(request: Request, authorization: str | None = Header(default=None)): + user = get_bearer_user(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + row = conn.execute("SELECT id, login, full_name, email, avatar_url, avatar_color, is_admin, is_active, auth_method, sidebar_config, notification_prefs, timezone, created_at FROM users WHERE id=?", (user["id"],)).fetchone() + if not row: + raise HTTPException(404, "User not found") + d = row_to_dict(row) + # parse json prefs + for k in ("notification_prefs", "sidebar_config"): + if isinstance(d.get(k), str): + try: + d[k] = json.loads(d[k] or "{}") + except Exception: + logger.exception("get_me") + # never expose secrets + return d + + +@router.patch("/users/me") +def patch_me(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + allowed = {"full_name", "email", "avatar_color", "notification_prefs", "sidebar_config", "timezone"} + updates = {} + for k in allowed: + if k in body: + updates[k] = body[k] + if not updates: + raise HTTPException(400, "No updatable fields") + # validation + if "email" in updates and updates["email"] and "@" not in str(updates["email"]): + raise HTTPException(400, "Invalid email") + with get_conn() as conn: + sets = [] + params = [] + for k, v in updates.items(): + if k in ("notification_prefs", "sidebar_config"): + v = json.dumps(v) if isinstance(v, (dict, list)) else str(v) + sets.append(f"{k}=?") + params.append(v) + params.append(user["id"]) + conn.execute(f"UPDATE users SET {', '.join(sets)} WHERE id=?", params) + conn.commit() + row = conn.execute("SELECT id, login, full_name, email, avatar_url, avatar_color, is_admin, timezone, notification_prefs FROM users WHERE id=?", (user["id"],)).fetchone() + audit_log(user, "user.update", "user", user["id"], "", request) + return row_to_dict(row) + + +@router.get("/users/search") +def search_users(request: Request, q: str = "", authorization: str | None = Header(default=None)): + user = get_bearer_user(request, authorization) + _v2_rate_check(request, user) + q = (q or request.query_params.get("q") or "").strip() + if not q: + return {"users": []} + like = f"%{q}%" + with get_conn() as conn: + rows = conn.execute("SELECT id, login, full_name, email, avatar_url, avatar_color FROM users WHERE login LIKE ? OR email LIKE ? OR full_name LIKE ? LIMIT 20", (like, like, like)).fetchall() + return {"users": [dict(r) for r in rows]} diff --git a/app/routers/api_v2/planning.py b/app/routers/api_v2/planning.py new file mode 100644 index 0000000..d60e3f5 --- /dev/null +++ b/app/routers/api_v2/planning.py @@ -0,0 +1,148 @@ +"""FlowDeck — Public API v2 : planning. + +Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency). +""" +from __future__ import annotations + +import json +import logging + +from fastapi import APIRouter, Body, Header, HTTPException, Request + +from app.db import get_conn +from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers + audit_log, + check_idempotency, + check_v2_rate_limit, + get_bearer_user, + has_scope, + paginate_headers, + parse_pagination, + require_scope, + row_to_dict, + store_idempotency, + to_iso8601, + validate_scopes_input, +) +from app.services.automations import fire_event as _fire_event +from app.services.automations import run_event_sync + +from ._common import _v2_rate_check + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["api-v2"]) + + + +@router.post("/collections/{collection_id}/sprints") +def create_sprint_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + name = (body.get("name") or "Sprint").strip() + start = body.get("start_date") or body.get("start") or "" + end = body.get("end_date") or body.get("end") or "" + if not start or not end: + raise HTTPException(400, "start_date and end_date required (YYYY-MM-DD)") + with get_conn() as conn: + cur = conn.execute("INSERT INTO sprints (collection_id, name, start_date, end_date, goal) VALUES (?, ?, ?, ?, ?)", (collection_id, name, start, end, body.get("goal") or "")) + sid = cur.lastrowid + conn.commit() + try: + run_event_sync(_fire_event("sprint.created", {"sprint_id": sid, "collection_id": collection_id, "name": name})) + except Exception: + logger.exception("create_sprint_v2") + return {"id": sid, "name": name, "status": "created"} + + +@router.patch("/sprints/{sprint_id}") +def patch_sprint_v2(sprint_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + row = conn.execute("SELECT * FROM sprints WHERE id=?", (sprint_id,)).fetchone() + if not row: + raise HTTPException(404, "Sprint not found") + name = body.get("name", row["name"]) + start = body.get("start_date", row["start_date"]) + end = body.get("end_date", row["end_date"]) + goal = body.get("goal", row["goal"]) + status = body.get("status", row["status"]) + conn.execute("UPDATE sprints SET name=?, start_date=?, end_date=?, goal=?, status=? WHERE id=?", (name, start, end, goal, status, sprint_id)) + conn.commit() + try: + run_event_sync(_fire_event("sprint.updated", {"sprint_id": sprint_id, "collection_id": row["collection_id"], "name": name, "status": status})) + except Exception: + logger.exception("patch_sprint_v2") + return {"id": sprint_id, "status": "updated"} + + +@router.delete("/sprints/{sprint_id}") +def delete_sprint_v2(sprint_id: int, request: Request, authorization: str | None = Header(default=None)): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + conn.execute("DELETE FROM sprints WHERE id=?", (sprint_id,)) + conn.commit() + return {"id": sprint_id, "status": "deleted"} + + +@router.post("/sprints/{sprint_id}/assign") +def assign_sprint_v2(sprint_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + pid = body.get("page_id") + if not pid: + raise HTTPException(400, "page_id required") + with get_conn() as conn: + try: + conn.execute("INSERT INTO sprint_pages (sprint_id, page_id, velocity_points) VALUES (?, ?, ?)", (sprint_id, pid, body.get("velocity_points", 1))) + conn.commit() + except Exception as e: + raise HTTPException(409, str(e)) from None + return {"sprint_id": sprint_id, "page_id": pid, "status": "assigned"} + + +@router.delete("/sprints/{sprint_id}/assign/{page_id}") +def unassign_sprint_v2(sprint_id: int, page_id: int, request: Request, authorization: str | None = Header(default=None)): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + conn.execute("DELETE FROM sprint_pages WHERE sprint_id=? AND page_id=?", (sprint_id, page_id)) + conn.commit() + return {"status": "removed"} + + +@router.get("/sprints/{sprint_id}/burndown") +def burndown_v2(sprint_id: int, request: Request, authorization: str | None = Header(default=None)): + user = get_bearer_user(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + s = conn.execute("SELECT * FROM sprints WHERE id=?", (sprint_id,)).fetchone() + if not s: + raise HTTPException(404, "Sprint not found") + pages = conn.execute("SELECT sp.*, cp.property_values_json FROM sprint_pages sp JOIN collection_pages cp ON cp.id=sp.page_id WHERE sp.sprint_id=?", (sprint_id,)).fetchall() + total = len(pages) + # crude: completed where status property == Done (best-effort) + completed = 0 + for p in pages: + try: + pv = json.loads(p["property_values_json"] or "{}") + for v in pv.values(): + if str(v).lower() in ("done", "completed", "terminé"): + completed += 1 + break + except Exception: + logger.exception("burndown_v2") + remaining = total - completed + # ideal linear + ideal = [round(total * (1 - i / 10)) for i in range(11)] + return {"sprint_id": sprint_id, "total": total, "completed": completed, "remaining": remaining, "ideal": ideal} + + +@router.get("/collections/{collection_id}/templates") +def list_templates_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)): + user = get_bearer_user(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + rows = conn.execute("SELECT * FROM page_templates WHERE collection_id=? ORDER BY created_at", (collection_id,)).fetchall() + return {"templates": [row_to_dict(r) for r in rows]} diff --git a/app/routers/api_v2/projects.py b/app/routers/api_v2/projects.py new file mode 100644 index 0000000..845ec93 --- /dev/null +++ b/app/routers/api_v2/projects.py @@ -0,0 +1,93 @@ +"""FlowDeck — Public API v2 : projects. + +Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency). +""" +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Header, HTTPException, Request + +from app.db import get_conn +from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers + audit_log, + check_idempotency, + check_v2_rate_limit, + get_bearer_user, + has_scope, + paginate_headers, + parse_pagination, + require_scope, + row_to_dict, + store_idempotency, + to_iso8601, + validate_scopes_input, +) + +from ._common import _v2_rate_check + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["api-v2"]) + + + +@router.get("/projects") +def list_projects_v2(request: Request, authorization: str | None = Header(default=None)): + user = get_bearer_user(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + rows = conn.execute("SELECT * FROM projects ORDER BY proj_type, owner, name").fetchall() + return {"projects": [row_to_dict(r) for r in rows]} + + +@router.get("/projects/{owner}/{repo}/tree") +async def project_tree_v2(owner: str, repo: str, request: Request, path: str = "", authorization: str | None = Header(default=None)): + get_bearer_user(request, authorization) + # proxy to gitea client? Return placeholder listing from projects table + with get_conn() as conn: + proj = conn.execute("SELECT * FROM projects WHERE owner=? AND name=?", (owner, repo)).fetchone() + if not proj: + raise HTTPException(404, "Project not found") + # delegate to gitea API if available (best-effort) + try: + from app.services.gitea_client import gitea + tree = await gitea.list_repo_files(owner, repo, path or "") + return {"owner": owner, "repo": repo, "path": path, "tree": tree} + except Exception: + return {"owner": owner, "repo": repo, "path": path, "tree": []} + + +@router.get("/search") +def search_v2(request: Request, query: str = "", workspace_id: int | None = None, type: str = "all", authorization: str | None = Header(default=None)): + user = get_bearer_user(request, authorization) + _v2_rate_check(request, user) + q = (query or request.query_params.get("query") or "").strip() + if not q: + return {"results": [], "query": q} + like = f"%{q}%" + with get_conn() as conn: + pages = [] + # try FTS5 + try: + rows = conn.execute("SELECT p.id, p.title, p.content, p.workspace_id, snippet(pages_fts, -1, '', '', '...', 32) as snippet FROM pages_fts f JOIN pages p ON p.id=f.rowid WHERE pages_fts MATCH ? LIMIT 20", (q,)).fetchall() + pages = [{"type": "page", "id": r["id"], "title": r["title"], "snippet": r["snippet"]} for r in rows] + except Exception: + rows = conn.execute("SELECT id, title FROM pages WHERE title LIKE ? OR content LIKE ? LIMIT 20", (like, like)).fetchall() + pages = [{"type": "page", "id": r["id"], "title": r["title"]} for r in rows] + # collections + colls = conn.execute("SELECT id, name FROM collections WHERE name LIKE ? LIMIT 10", (like,)).fetchall() + results = pages + [{"type": "collection", "id": r["id"], "title": r["name"]} for r in colls] + return {"query": q, "results": results} + + +@router.get("/admin/users") +def admin_list_users_v2(request: Request, limit: int = 30, offset: int = 0, authorization: str | None = Header(default=None)): + user = get_bearer_user(request, authorization) + _v2_rate_check(request, user) + if not user.get("is_admin") and not has_scope(user.get("_token_scopes"), "admin"): + raise HTTPException(403, "Admin scope required") + limit = max(1, min(limit, 100)) + with get_conn() as conn: + total = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] + rows = conn.execute("SELECT id, login, full_name, email, is_admin, is_active, created_at FROM users ORDER BY id LIMIT ? OFFSET ?", (limit, offset)).fetchall() + return {"users": [row_to_dict(r) for r in rows], "total": total, "limit": limit, "offset": offset} diff --git a/app/routers/api_v2/properties.py b/app/routers/api_v2/properties.py new file mode 100644 index 0000000..e431706 --- /dev/null +++ b/app/routers/api_v2/properties.py @@ -0,0 +1,151 @@ +"""FlowDeck — Public API v2 : properties. + +Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency). +""" +from __future__ import annotations + +import json +import logging + +from fastapi import APIRouter, Body, Header, HTTPException, Request + +from app.db import get_conn +from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers + audit_log, + check_idempotency, + check_v2_rate_limit, + get_bearer_user, + has_scope, + paginate_headers, + parse_pagination, + require_scope, + row_to_dict, + store_idempotency, + to_iso8601, + validate_scopes_input, +) + +from ._common import _v2_rate_check + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["api-v2"]) + + + +@router.post("/collections/{collection_id}/properties") +def create_property_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + name = (body.get("name") or "").strip() + if not name: + raise HTTPException(400, "name is required") + ptype = body.get("prop_type") or body.get("type") or "text" + with get_conn() as conn: + if not conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone(): + raise HTTPException(404, "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, ptype, json.dumps(body.get("options") or []), body.get("number_format") or "number", max_pos, int(bool(body.get("required"))), int(bool(body.get("visible_in_views", True))), json.dumps(body.get("validation") or {}), (body.get("group_name") or "").strip())) + conn.commit() + pid = cur.lastrowid + except Exception as e: + raise HTTPException(409, f"Property exists: {e}") from None + audit_log(user, "property.create", "property", pid, name, request) + return {"id": pid, "name": name, "prop_type": ptype, "status": "created"} + + +@router.patch("/properties/{prop_id}") +def patch_property_v2(prop_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + row = conn.execute("SELECT * FROM collection_properties WHERE id=?", (prop_id,)).fetchone() + if not row: + raise HTTPException(404, "Property not found") + name = body.get("name", row["name"]) + opts = json.dumps(body.get("options", json.loads(row["options_json"] or "[]"))) + nf = body.get("number_format", row["number_format"]) + req = int(bool(body.get("required", row["required"]))) + vis = int(bool(body.get("visible_in_views", row["visible_in_views"]))) + vj = json.dumps(body.get("validation", json.loads(row["validation_json"] or "{}"))) if "validation" in body else (row["validation_json"] if "validation_json" in row.keys() else "{}") + grp = body.get("group_name", row["group_name"] if "group_name" in row.keys() else "") + conn.execute("UPDATE collection_properties SET name=?, options_json=?, number_format=?, required=?, visible_in_views=?, validation_json=?, group_name=? WHERE id=?", (name, opts, nf, req, vis, vj, grp, prop_id)) + conn.commit() + audit_log(user, "property.update", "property", prop_id, "", request) + return {"id": prop_id, "status": "updated"} + + +@router.delete("/properties/{prop_id}") +def delete_property_v2(prop_id: int, request: Request, authorization: str | None = Header(default=None)): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + if not conn.execute("SELECT id FROM collection_properties WHERE id=?", (prop_id,)).fetchone(): + raise HTTPException(404, "Property not found") + conn.execute("DELETE FROM collection_properties WHERE id=?", (prop_id,)) + conn.commit() + audit_log(user, "property.delete", "property", prop_id, "", request) + return {"id": prop_id, "status": "deleted"} + + +@router.post("/properties/{prop_id}/relation") +def create_relation_v2(prop_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + related_id = body.get("related_collection_id") + reverse = (body.get("reverse_name") or "").strip() + if not related_id: + raise HTTPException(400, "related_collection_id required") + with get_conn() as conn: + row = conn.execute("SELECT collection_id FROM collection_properties WHERE id=?", (prop_id,)).fetchone() + if not row: + raise HTTPException(404, "Property not found") + conn.execute("UPDATE collection_properties SET prop_type='relation', related_collection_id=?, reverse_name=? WHERE id=?", (related_id, reverse, prop_id)) + if reverse: + max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_properties WHERE collection_id=?", (related_id,)).fetchone()[0] + try: + conn.execute("INSERT INTO collection_properties (collection_id, name, prop_type, related_collection_id, reverse_name, position) VALUES (?, ?, 'relation', ?, ?, ?)", (related_id, reverse, row["collection_id"], "", max_pos)) + except Exception: + logger.exception("create_relation_v2") + conn.commit() + return {"id": prop_id, "status": "updated"} + + +@router.post("/properties/evaluate-formula") +def evaluate_formula_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): + user = get_bearer_user(request, authorization) + _v2_rate_check(request, user) + expr = body.get("expression") or body.get("formula") + if not expr: + raise HTTPException(400, "expression required") + ctx = body.get("context") or {} + try: + from app.services.formula_engine import FormulaEngine + res = FormulaEngine().evaluate(expr, ctx) + except Exception as e: + raise HTTPException(400, f"Formula error: {e}") from None + return {"result": res, "expression": expr} + + +@router.post("/properties/compute-rollup") +def compute_rollup_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): + user = get_bearer_user(request, authorization) + _v2_rate_check(request, user) + for k in ("collection_id", "relation_property_id", "target_property_id", "page_id"): + if k not in body: + raise HTTPException(400, f"{k} required") + try: + from app.services.rollup_engine import RollupEngine + res = RollupEngine().compute(body["collection_id"], body["relation_property_id"], body["target_property_id"], body["page_id"], body.get("function", "count")) + except Exception as e: + raise HTTPException(400, f"Rollup error: {e}") from None + return {"result": res} + + +@router.get("/collections/{collection_id}/views") +def list_views_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)): + user = get_bearer_user(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + rows = conn.execute("SELECT * FROM collection_views WHERE collection_id=? ORDER BY position", (collection_id,)).fetchall() + return {"views": [row_to_dict(r) for r in rows]} diff --git a/app/routers/api_v2/sharing.py b/app/routers/api_v2/sharing.py new file mode 100644 index 0000000..5ceea77 --- /dev/null +++ b/app/routers/api_v2/sharing.py @@ -0,0 +1,160 @@ +"""FlowDeck — Public API v2 : sharing. + +Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency). +""" +from __future__ import annotations + +import json +import logging + +from fastapi import APIRouter, Body, Header, HTTPException, Request + +from app.db import get_conn +from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers + audit_log, + check_idempotency, + check_v2_rate_limit, + get_bearer_user, + has_scope, + paginate_headers, + parse_pagination, + require_scope, + row_to_dict, + store_idempotency, + to_iso8601, + validate_scopes_input, +) +from app.services.automations import fire_event as _fire_event +from app.services.automations import run_event_sync +from app.services.publish import fire_published, fire_unpublished, publish, unpublish + +from ._common import _v2_rate_check + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["api-v2"]) + + + +@router.post("/pages/{page_id}/shares") +def create_share_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + perm = (body.get("permission") or "view").strip().lower() + if perm not in ("view", "comment", "edit"): + raise HTTPException(400, "Invalid permission. Use view, comment, or edit") + email = (body.get("email") or "").strip() + uid = body.get("user_id") + if not email and not uid: + raise HTTPException(400, "email or user_id required") + with get_conn() as conn: + cur = conn.execute("INSERT INTO page_shares (page_id, shared_with_user_id, shared_with_email, permission, created_by) VALUES (?, ?, ?, ?, ?)", (page_id, uid, email, perm, user["id"])) + conn.execute("UPDATE pages SET is_shared=1 WHERE id=?", (page_id,)) + conn.commit() + nid = cur.lastrowid + audit_log(user, "share.create", "share", nid, f"page={page_id}", request) + try: + run_event_sync(_fire_event("page.shared", {"page_id": page_id, "share_id": nid, "permission": perm})) + except Exception: + logger.exception("create_share_v2") + return {"id": nid, "page_id": page_id, "status": "shared"} + + +@router.patch("/shares/{share_id}") +def patch_share_v2(share_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + perm = (body.get("permission") or "").strip().lower() + if perm not in ("view", "comment", "edit"): + raise HTTPException(400, "Invalid permission") + with get_conn() as conn: + if not conn.execute("SELECT id FROM page_shares WHERE id=?", (share_id,)).fetchone(): + raise HTTPException(404, "Share not found") + conn.execute("UPDATE page_shares SET permission=? WHERE id=?", (perm, share_id)) + conn.commit() + return {"id": share_id, "status": "updated"} + + +@router.delete("/shares/{share_id}") +def delete_share_v2(share_id: int, request: Request, authorization: str | None = Header(default=None)): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + row = conn.execute("SELECT page_id FROM page_shares WHERE id=?", (share_id,)).fetchone() + if not row: + raise HTTPException(404, "Share not found") + conn.execute("DELETE FROM page_shares WHERE id=?", (share_id,)) + # unset is_shared if no shares left + cnt = conn.execute("SELECT COUNT(*) FROM page_shares WHERE page_id=?", (row["page_id"],)).fetchone()[0] + if cnt == 0: + conn.execute("UPDATE pages SET is_shared=0 WHERE id=?", (row["page_id"],)) + conn.commit() + return {"id": share_id, "status": "revoked"} + + +@router.post("/pages/{page_id}/publish") +def publish_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + slug_in = (body.get("slug") or body.get("publish_slug") or "").strip() or None + slug, _title = publish(page_id, explicit_slug=slug_in) + audit_log(user, "page.publish", "page", page_id, slug, request) + run_event_sync(fire_published(page_id, slug)) + return {"page_id": page_id, "slug": slug, "url": f"/p/{slug}", "status": "published"} + + +@router.delete("/pages/{page_id}/publish") +def unpublish_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + unpublish(page_id) + run_event_sync(fire_unpublished(page_id)) + return {"page_id": page_id, "status": "unpublished"} + + +@router.get("/pages/{page_id}/history") +def list_history_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)): + user = get_bearer_user(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + rows = conn.execute("SELECT h.*, u.login FROM page_history h LEFT JOIN users u ON u.id=h.user_id WHERE h.page_id=? ORDER BY h.created_at DESC", (page_id,)).fetchall() + # also page_versions for block pages + vrows = conn.execute("SELECT * FROM page_versions WHERE page_id=? ORDER BY created_at DESC", (page_id,)).fetchall() + return {"history": [row_to_dict(r) for r in rows], "versions": [row_to_dict(r) for r in vrows]} + + +@router.post("/pages/{page_id}/history/restore") +def restore_history_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + hid = body.get("history_id") or body.get("id") or body.get("version_id") + if not hid: + raise HTTPException(400, "history_id required") + with get_conn() as conn: + h = conn.execute("SELECT * FROM page_versions WHERE id=? AND page_id=?", (hid, page_id)).fetchone() + if h: + conn.execute("UPDATE pages SET content=?, title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (h["blocks_json"], h["title"], page_id)) + conn.commit() + return {"page_id": page_id, "restored_version": hid, "status": "restored"} + h2 = conn.execute("SELECT * FROM page_history WHERE id=? AND page_id=?", (hid, page_id)).fetchone() + if h2: + try: + snap = json.loads(h2["snapshot_json"] or "{}") + except Exception: + snap = {} + # best-effort restore content + if snap.get("content"): + conn.execute("UPDATE pages SET content=? WHERE id=?", (snap["content"], page_id)) + conn.commit() + return {"page_id": page_id, "restored_version": hid, "status": "restored"} + raise HTTPException(404, "History not found") + + +@router.get("/collections/{collection_id}/sprints") +def list_sprints_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)): + user = get_bearer_user(request, authorization) + _v2_rate_check(request, user) + limit, offset = parse_pagination(request) + with get_conn() as conn: + total = conn.execute("SELECT COUNT(*) FROM sprints WHERE collection_id=?", (collection_id,)).fetchone()[0] + rows = conn.execute("SELECT * FROM sprints WHERE collection_id=? ORDER BY created_at DESC LIMIT ? OFFSET ?", (collection_id, limit, offset)).fetchall() + return {"sprints": [row_to_dict(r) for r in rows], "total": total, "limit": limit, "offset": offset} diff --git a/app/routers/api_v2/templates_io.py b/app/routers/api_v2/templates_io.py new file mode 100644 index 0000000..3b01f38 --- /dev/null +++ b/app/routers/api_v2/templates_io.py @@ -0,0 +1,205 @@ +"""FlowDeck — Public API v2 : templates_io. + +Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency). +""" +from __future__ import annotations + +import json +import logging + +from fastapi import APIRouter, Body, Header, HTTPException, Request +from fastapi.responses import Response + +from app.db import get_conn +from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers + audit_log, + check_idempotency, + check_v2_rate_limit, + get_bearer_user, + has_scope, + paginate_headers, + parse_pagination, + require_scope, + row_to_dict, + store_idempotency, + to_iso8601, + validate_scopes_input, +) + +from ._common import _v2_rate_check + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["api-v2"]) + + + +@router.post("/collections/{collection_id}/templates") +def create_template_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + name = (body.get("name") or "Template").strip() + pv = json.dumps(body.get("property_values") or body.get("property_values_json") or {}) + cj = json.dumps(body.get("content") or body.get("content_json") or []) + with get_conn() as conn: + cur = conn.execute("INSERT INTO page_templates (collection_id, name, property_values_json, content_json) VALUES (?, ?, ?, ?)", (collection_id, name, pv, cj)) + tid = cur.lastrowid + conn.commit() + return {"id": tid, "name": name, "status": "created"} + + +@router.patch("/templates/{template_id}") +def patch_template_v2(template_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + row = conn.execute("SELECT * FROM page_templates WHERE id=?", (template_id,)).fetchone() + if not row: + raise HTTPException(404, "Template not found") + name = body.get("name", row["name"]) + pv = json.dumps(body.get("property_values", json.loads(row["property_values_json"] or "{}"))) if "property_values" in body else row["property_values_json"] + cj = json.dumps(body.get("content", json.loads(row["content_json"] or "[]"))) if "content" in body else row["content_json"] + conn.execute("UPDATE page_templates SET name=?, property_values_json=?, content_json=? WHERE id=?", (name, pv, cj, template_id)) + conn.commit() + return {"id": template_id, "status": "updated"} + + +@router.delete("/templates/{template_id}") +def delete_template_v2(template_id: int, request: Request, authorization: str | None = Header(default=None)): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + conn.execute("DELETE FROM page_templates WHERE id=?", (template_id,)) + conn.commit() + return {"id": template_id, "status": "deleted"} + + +@router.post("/templates/{template_id}/apply") +def apply_template_v2(template_id: int, request: Request, authorization: str | None = Header(default=None)): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + tpl = conn.execute("SELECT * FROM page_templates WHERE id=?", (template_id,)).fetchone() + if not tpl: + raise HTTPException(404, "Template not found") + max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_pages WHERE collection_id=?", (tpl["collection_id"],)).fetchone()[0] + pv = tpl["property_values_json"] or "{}" + cur = conn.execute("INSERT INTO collection_pages (collection_id, title, position, property_values_json) VALUES (?, ?, ?, ?)", (tpl["collection_id"], tpl["name"], max_pos, pv)) + pid = cur.lastrowid + conn.commit() + return {"template_id": template_id, "page_id": pid, "status": "applied"} + + +@router.get("/templates/database") +def list_db_templates_v2(request: Request, authorization: str | None = Header(default=None)): + user = get_bearer_user(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + rows = conn.execute("SELECT * FROM database_templates ORDER BY name").fetchall() + return {"templates": [row_to_dict(r) for r in rows]} + + +@router.post("/templates/database/{template_id}/apply") +def apply_db_template_v2(template_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + tpl = conn.execute("SELECT * FROM database_templates WHERE id=?", (template_id,)).fetchone() + if not tpl: + raise HTTPException(404, "Template not found") + name = (body.get("name") or tpl["name"]).strip() + schema = json.loads(tpl["schema_json"] or "[]") + cur = conn.execute("INSERT INTO collections (name, description, icon, schema_json, workspace_id, created_by) VALUES (?, ?, ?, ?, ?, ?)", (name, tpl["description"], tpl["icon"] if "icon" in tpl.keys() else "📋", json.dumps(schema), body.get("workspace_id"), user["id"])) + cid = cur.lastrowid + # A25 : pas de try — un échec de matérialisation doit interrompre la + # transaction plutôt que de commiter une collection sans schéma. + from app.services.db_templates import materialize_properties + materialize_properties(conn, cid, schema) + conn.commit() + return {"collection_id": cid, "name": name, "status": "created"} + + +@router.get("/pages/{page_id}/export") +def export_page_v2(page_id: int, request: Request, format: str = "markdown", authorization: str | None = Header(default=None)): + user = get_bearer_user(request, authorization) + _v2_rate_check(request, user) + fmt = (format or request.query_params.get("format") or "markdown").lower() + if fmt not in ("markdown", "html", "pdf"): + raise HTTPException(400, "format must be markdown, html or pdf") + with get_conn() as conn: + row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone() + if not row: + # try collection_pages + row2 = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone() + if not row2: + raise HTTPException(404, "Page not found") + # collection pages: return JSON + return {"page": row_to_dict(row2), "format": fmt} + # block pages: delegate to export service + from app.services.export import export_page as _export + try: + data, mime, fname = _export(row, fmt) # type: ignore + return Response(content=data, media_type=mime, headers={"Content-Disposition": f'attachment; filename="{fname}"'}) + except Exception as e: + raise HTTPException(500, f"Export failed: {e}") from None + + +@router.get("/collections/{collection_id}/export/csv") +def export_csv_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)): + user = get_bearer_user(request, authorization) + _v2_rate_check(request, user) + import csv + import io + with get_conn() as conn: + if not conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone(): + raise HTTPException(404, "Collection not found") + props = [dict(r) for r in conn.execute("SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position", (collection_id,)).fetchall()] + rows = conn.execute("SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position", (collection_id,)).fetchall() + out = io.StringIO() + writer = csv.writer(out) + header = ["Title"] + [p["name"] for p in props] + writer.writerow(header) + for r in rows: + try: + pv = json.loads(r["property_values_json"] or "{}") + except Exception: + pv = {} + vals = [r["title"]] + for p in props: + vals.append(str(pv.get(str(p["id"])) or pv.get(p["name"]) or "")) + writer.writerow(vals) + return Response(content=out.getvalue().encode("utf-8"), media_type="text/csv", headers={"Content-Disposition": f'attachment; filename="collection-{collection_id}.csv"'}) + + +@router.post("/collections/{collection_id}/import/csv") +async def import_csv_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + try: + form = await request.form() + file = form.get("file") + data = await file.read() if file else b"" + text = data.decode("utf-8", errors="ignore") + except Exception as err: + raise HTTPException(400, "file required (multipart)") from err + import csv + import io + reader = csv.DictReader(io.StringIO(text)) + created = 0 + with get_conn() as conn: + for row in reader: + title = row.get("Title") or row.get("title") or "Untitled" + # map remaining columns to property names + pv = {} + # resolve prop name -> id + props = {p["name"]: p["id"] for p in conn.execute("SELECT id, name FROM collection_properties WHERE collection_id=?", (collection_id,)).fetchall()} + for k, v in row.items(): + if k in ("Title", "title"): + continue + pid = props.get(k) + if pid: + pv[str(pid)] = v + max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_pages WHERE collection_id=?", (collection_id,)).fetchone()[0] + conn.execute("INSERT INTO collection_pages (collection_id, title, position, property_values_json) VALUES (?, ?, ?, ?)", (collection_id, title, max_pos, json.dumps(pv))) + created += 1 + conn.commit() + return {"imported": created, "status": "ok"} diff --git a/app/routers/api_v2/views.py b/app/routers/api_v2/views.py new file mode 100644 index 0000000..7651d59 --- /dev/null +++ b/app/routers/api_v2/views.py @@ -0,0 +1,164 @@ +"""FlowDeck — Public API v2 : views. + +Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency). +""" +from __future__ import annotations + +import json +import logging + +from fastapi import APIRouter, Body, Header, HTTPException, Request + +from app.db import get_conn +from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers + audit_log, + check_idempotency, + check_v2_rate_limit, + get_bearer_user, + has_scope, + paginate_headers, + parse_pagination, + require_scope, + row_to_dict, + store_idempotency, + to_iso8601, + validate_scopes_input, +) +from app.services.automations import fire_event as _fire_event +from app.services.automations import run_event_sync + +from ._common import _v2_rate_check + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["api-v2"]) + + + +@router.post("/collections/{collection_id}/views") +def create_view_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + name = (body.get("name") or "New View").strip() + vtype = body.get("view_type") or body.get("type") or "table" + config = body.get("config") or body.get("config_json") or {} + with get_conn() as conn: + if not conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone(): + raise HTTPException(404, "Collection not found") + max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_views WHERE collection_id=?", (collection_id,)).fetchone()[0] + cur = conn.execute("INSERT INTO collection_views (collection_id, name, view_type, config_json, position, created_by) VALUES (?, ?, ?, ?, ?, ?)", (collection_id, name, vtype, json.dumps(config), max_pos, user["id"])) + vid = cur.lastrowid + conn.commit() + audit_log(user, "view.create", "view", vid, name, request) + try: + run_event_sync(_fire_event("collection.view.created", {"view_id": vid, "collection_id": collection_id, "name": name, "view_type": vtype})) + except Exception: + logger.exception("create_view_v2") + return {"id": vid, "name": name, "view_type": vtype, "status": "created"} + + +@router.patch("/views/{view_id}") +def patch_view_v2(view_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + row = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone() + if not row: + raise HTTPException(404, "View not found") + cfg = json.loads(row["config_json"] or "{}") + if "config" in body: + cfg.update(body["config"]) + elif "config_json" in body: + try: + cfg.update(json.loads(body["config_json"]) if isinstance(body["config_json"], str) else body["config_json"]) + except Exception: + logger.exception("patch_view_v2") + # also flat keys + for k in ("group_by", "sub_group_by", "wip_limits", "card_size", "cover_property", "cover_mode", "card_properties", "visible_properties", "filters", "sorts", "date_property"): + if k in body: + cfg[k] = body[k] + name = body.get("name", row["name"]) + vtype = body.get("view_type") or body.get("type") or row["view_type"] + conn.execute("UPDATE collection_views SET name=?, view_type=?, config_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (name, vtype, json.dumps(cfg), view_id)) + conn.commit() + audit_log(user, "view.update", "view", view_id, "", request) + return {"id": view_id, "status": "updated"} + + +@router.delete("/views/{view_id}") +def delete_view_v2(view_id: int, request: Request, authorization: str | None = Header(default=None)): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + if not conn.execute("SELECT id FROM collection_views WHERE id=?", (view_id,)).fetchone(): + raise HTTPException(404, "View not found") + conn.execute("DELETE FROM collection_views WHERE id=?", (view_id,)) + conn.commit() + audit_log(user, "view.delete", "view", view_id, "", request) + return {"id": view_id, "status": "deleted"} + + +@router.post("/views/{view_id}/save-as") +def save_as_view_v2(view_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + name = (body.get("name") or "").strip() or "Copy" + with get_conn() as conn: + row = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone() + if not row: + raise HTTPException(404, "View not found") + max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_views WHERE collection_id=?", (row["collection_id"],)).fetchone()[0] + cur = conn.execute("INSERT INTO collection_views (collection_id, name, view_type, config_json, position, created_by) VALUES (?, ?, ?, ?, ?, ?)", (row["collection_id"], name, row["view_type"], row["config_json"], max_pos, user["id"])) + nid = cur.lastrowid + conn.commit() + return {"id": nid, "name": name, "status": "created"} + + +@router.get("/collections/{collection_id}/dashboards") +def list_dashboards_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)): + user = get_bearer_user(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + rows = conn.execute("SELECT * FROM collection_dashboards WHERE collection_id=? ORDER BY created_at", (collection_id,)).fetchall() + return {"dashboards": [row_to_dict(r) for r in rows]} + + +@router.post("/collections/{collection_id}/dashboards") +def create_dashboard_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + name = (body.get("name") or "Dashboard").strip() + layout = body.get("layout") or body.get("layout_json") or {"columns": 1, "widgets": []} + with get_conn() as conn: + cur = conn.execute("INSERT INTO collection_dashboards (collection_id, name, layout_json) VALUES (?, ?, ?)", (collection_id, name, json.dumps(layout))) + did = cur.lastrowid + conn.commit() + return {"id": did, "name": name, "status": "created"} + + +@router.patch("/dashboards/{dashboard_id}") +def patch_dashboard_v2(dashboard_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + row = conn.execute("SELECT * FROM collection_dashboards WHERE id=?", (dashboard_id,)).fetchone() + if not row: + raise HTTPException(404, "Dashboard not found") + name = body.get("name", row["name"]) + layout = body.get("layout") or body.get("layout_json") + if layout is not None: + layout_json = json.dumps(layout) + else: + layout_json = row["layout_json"] + conn.execute("UPDATE collection_dashboards SET name=?, layout_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (name, layout_json, dashboard_id)) + conn.commit() + return {"id": dashboard_id, "status": "updated"} + + +@router.delete("/dashboards/{dashboard_id}") +def delete_dashboard_v2(dashboard_id: int, request: Request, authorization: str | None = Header(default=None)): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + conn.execute("DELETE FROM collection_dashboards WHERE id=?", (dashboard_id,)) + conn.commit() + return {"id": dashboard_id, "status": "deleted"} diff --git a/app/routers/api_v2/webhooks.py b/app/routers/api_v2/webhooks.py new file mode 100644 index 0000000..54676c0 --- /dev/null +++ b/app/routers/api_v2/webhooks.py @@ -0,0 +1,195 @@ +"""FlowDeck — Public API v2 : webhooks. + +Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency). +""" +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Body, Header, HTTPException, Request +from fastapi.responses import JSONResponse + +from app.db import get_conn +from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers + audit_log, + check_idempotency, + check_v2_rate_limit, + get_bearer_user, + has_scope, + paginate_headers, + parse_pagination, + require_scope, + row_to_dict, + store_idempotency, + to_iso8601, + validate_scopes_input, +) + +from ._common import _v2_rate_check + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["api-v2"]) + + + +@router.get("/webhooks") +def list_webhooks_v2(request: Request, authorization: str | None = Header(default=None)): + user = get_bearer_user(request, authorization) + _v2_rate_check(request, user) + limit, offset = parse_pagination(request) + with get_conn() as conn: + total = conn.execute("SELECT COUNT(*) AS n FROM webhook_subscriptions").fetchone()["n"] + rows = conn.execute( + "SELECT * FROM webhook_subscriptions ORDER BY created_at DESC LIMIT ? OFFSET ?", + (limit, offset), + ).fetchall() + return JSONResponse( + content={"webhooks": [dict(r) for r in rows]}, + headers=paginate_headers(total), + ) + + +@router.post("/webhooks") +def create_webhook_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + from app.services.webhook_outbound import EVENTS, _event_matches + url = (body.get("url") or "").strip() + event = (body.get("event") or "page.created").strip() + secret = (body.get("secret") or "").strip() + if not url or not url.startswith("http"): + raise HTTPException(400, "url must start with http") + if not event or (event not in EVENTS and not (event.endswith(".*") or event in ("*", "all"))): + raise HTTPException(400, f"Unknown event '{event}'. See GET /api/v2/webhooks/events") + # make sure the pattern matches at least one known event + if not any(_event_matches(event, e) for e in EVENTS): + raise HTTPException(400, f"Event pattern '{event}' matches no known event") + with get_conn() as conn: + cur = conn.execute("INSERT INTO webhook_subscriptions (url, event, secret) VALUES (?, ?, ?)", (url, event, secret)) + wid = cur.lastrowid + conn.commit() + row = conn.execute("SELECT * FROM webhook_subscriptions WHERE id=?", (wid,)).fetchone() + audit_log(user, "webhook.create", "webhook", wid, url, request) + return {"id": wid, "status": "created", "webhook": dict(row) if row else {}, + "signature_header": "X-FlowDeck-Signature (HMAC-SHA256, sha256=)" if secret else None} + + +@router.patch("/webhooks/{webhook_id}") +def patch_webhook_v2(webhook_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + row = conn.execute("SELECT * FROM webhook_subscriptions WHERE id=?", (webhook_id,)).fetchone() + if not row: + raise HTTPException(404, "Webhook not found") + url = body.get("url", row["url"]) + event = body.get("event", row["event"]) + secret = body.get("secret", row["secret"]) + active = int(bool(body.get("active", row["active"]))) + conn.execute("UPDATE webhook_subscriptions SET url=?, event=?, secret=?, active=? WHERE id=?", (url, event, secret, active, webhook_id)) + conn.commit() + audit_log(user, "webhook.update", "webhook", webhook_id, "", request) + return {"id": webhook_id, "status": "updated"} + + +@router.delete("/webhooks/{webhook_id}") +def delete_webhook_v2(webhook_id: int, request: Request, authorization: str | None = Header(default=None)): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + conn.execute("DELETE FROM webhook_subscriptions WHERE id=?", (webhook_id,)) + conn.commit() + audit_log(user, "webhook.delete", "webhook", webhook_id, "", request) + return {"id": webhook_id, "status": "deleted"} + + +@router.post("/webhooks/{webhook_id}/test") +async def test_webhook_v2(webhook_id: int, request: Request, authorization: str | None = Header(default=None)): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + row = conn.execute("SELECT * FROM webhook_subscriptions WHERE id=?", (webhook_id,)).fetchone() + if not row: + raise HTTPException(404, "Webhook not found") + # live delivery via the prod dispatcher (HMAC + retry + journal), + # direct to this subscription only (no wildcard fan-out) + from app.services.webhook_outbound import deliver_to_sub + ok = await deliver_to_sub(webhook_id, row["url"], "ping", + {"webhook_id": webhook_id, "test": True}, + row["secret"] or "") + audit_log(user, "webhook.test", "webhook", webhook_id, f"ok={ok}", request) + return {"webhook_id": webhook_id, "status": "tested", "delivered": ok} + + +@router.get("/webhooks/{webhook_id}/deliveries") +def list_deliveries_v2(webhook_id: int, request: Request, + status: str | None = None, + authorization: str | None = Header(default=None)): + user = get_bearer_user(request, authorization) + _v2_rate_check(request, user) + limit, offset = parse_pagination(request) + with get_conn() as conn: + if status: + total = conn.execute( + "SELECT COUNT(*) AS n FROM webhook_deliveries WHERE webhook_id=? AND status=?", + (webhook_id, status)).fetchone()["n"] + rows = conn.execute( + "SELECT * FROM webhook_deliveries WHERE webhook_id=? AND status=? " + "ORDER BY created_at DESC LIMIT ? OFFSET ?", + (webhook_id, status, limit, offset)).fetchall() + else: + total = conn.execute( + "SELECT COUNT(*) AS n FROM webhook_deliveries WHERE webhook_id=?", + (webhook_id,)).fetchone()["n"] + rows = conn.execute( + "SELECT * FROM webhook_deliveries WHERE webhook_id=? " + "ORDER BY created_at DESC LIMIT ? OFFSET ?", + (webhook_id, limit, offset)).fetchall() + return JSONResponse( + content={"deliveries": [row_to_dict(r) for r in rows]}, + headers=paginate_headers(total), + ) + + +@router.post("/webhooks/{webhook_id}/retry") +async def retry_webhook_deliveries(webhook_id: int, request: Request, + authorization: str | None = Header(default=None)): + """Manually retry failed deliveries for a webhook.""" + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + + from app.services.webhook_outbound import retry_due_deliveries + + with get_conn() as conn: + # Force retry by setting next_retry_at to the past + conn.execute( + """UPDATE webhook_deliveries + SET next_retry_at = strftime('%s', 'now', '-1 second') + WHERE webhook_id = ? AND status = 'retrying'""", + (webhook_id,), + ) + conn.commit() + + retried = await retry_due_deliveries() + audit_log(user, "webhook.retry", "webhook", webhook_id, f"retried={retried}", request) + return {"webhook_id": webhook_id, "status": "retried", "retried_count": retried} + + +@router.post("/webhooks/verify-signature") +def verify_webhook_signature(request: Request, + authorization: str | None = Header(default=None), + body: dict = Body(default={})): + """Verify a webhook signature (for debugging/testing).""" + user = get_bearer_user(request, authorization) + _v2_rate_check(request, user) + + from app.services.webhook_outbound import verify_signature + + secret = body.get("secret", "") + payload = body.get("payload", "{}") + signature = body.get("signature", "") + + is_valid = verify_signature(secret, payload.encode(), signature) + + audit_log(user, "webhook.signature_verify", "webhook", 0, f"valid={is_valid}", request) + return {"valid": is_valid, "secret": secret[:10] + "..." if len(secret) > 10 else secret} diff --git a/app/routers/api_v2/workspaces.py b/app/routers/api_v2/workspaces.py new file mode 100644 index 0000000..ac55723 --- /dev/null +++ b/app/routers/api_v2/workspaces.py @@ -0,0 +1,230 @@ +"""FlowDeck — Public API v2 : workspaces. + +Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency). +""" +from __future__ import annotations + +import json +import logging + +from fastapi import APIRouter, Body, Header, HTTPException, Request +from fastapi.responses import JSONResponse + +from app.db import get_conn +from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers + audit_log, + check_idempotency, + check_v2_rate_limit, + get_bearer_user, + has_scope, + paginate_headers, + parse_pagination, + require_scope, + row_to_dict, + store_idempotency, + to_iso8601, + validate_scopes_input, +) + +from ._common import _v2_rate_check + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["api-v2"]) + + + +@router.get("/workspaces") +def list_workspaces(request: Request, authorization: str | None = Header(default=None)): + user = get_bearer_user(request, authorization) + _v2_rate_check(request, user) + limit, offset = parse_pagination(request) + with get_conn() as conn: + total = conn.execute("SELECT COUNT(*) FROM workspaces WHERE owner_id=? OR id IN (SELECT workspace_id FROM workspace_members WHERE user_id=?)", (user["id"], user["id"])).fetchone()[0] + rows = conn.execute("SELECT w.*, wm.role FROM workspaces w LEFT JOIN workspace_members wm ON wm.workspace_id=w.id AND wm.user_id=? WHERE w.owner_id=? OR w.id IN (SELECT workspace_id FROM workspace_members WHERE user_id=?) ORDER BY w.created_at DESC LIMIT ? OFFSET ?", (user["id"], user["id"], user["id"], limit, offset)).fetchall() + out = [] + for r in rows: + d = dict(r) + d["created_at"] = to_iso8601(d.get("created_at")) + try: + d["settings"] = json.loads(d.get("settings_json") or "{}") + except Exception: + d["settings"] = {} + out.append(d) + return {"workspaces": out, "total": total, "limit": limit, "offset": offset} + + +@router.post("/workspaces") +def create_workspace(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + idem = check_idempotency(request, user["id"]) + if idem: + return JSONResponse(content=idem["data"], status_code=idem["status"]) + name = (body.get("name") or "").strip() + if not name: + raise HTTPException(400, "name is required") + settings_json = json.dumps(body.get("settings") or body.get("settings_json") or {}) + with get_conn() as conn: + cur = conn.execute("INSERT INTO workspaces (name, owner_id, settings_json) VALUES (?, ?, ?)", (name, user["id"], settings_json)) + wid = cur.lastrowid + # owner is implicitly admin member + try: + conn.execute("INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, 'owner')", (wid, user["id"])) + except Exception: + logger.exception("create_workspace") + conn.commit() + row = conn.execute("SELECT * FROM workspaces WHERE id=?", (wid,)).fetchone() + audit_log(user, "workspace.create", "workspace", wid, name, request) + data = {"id": wid, "name": name, "owner_id": user["id"], "status": "created", "workspace": row_to_dict(row)} + key = (request.headers.get("Idempotency-Key") or "").strip() + if key: + store_idempotency(key, user["id"], data, 200) + return JSONResponse(content=data, status_code=201) + + +@router.get("/workspaces/{workspace_id}") +def get_workspace(workspace_id: int, request: Request, authorization: str | None = Header(default=None)): + user = get_bearer_user(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + row = conn.execute("SELECT * FROM workspaces WHERE id=?", (workspace_id,)).fetchone() + if not row: + raise HTTPException(404, "Workspace not found") + # ACL: must be member or owner + member = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?", (workspace_id, user["id"])).fetchone() + is_owner = row["owner_id"] == user["id"] + if not is_owner and not member and not user.get("is_admin"): + raise HTTPException(404, "Workspace not found") + members = conn.execute("SELECT u.id, u.login, u.full_name, u.avatar_url, wm.role FROM workspace_members wm JOIN users u ON u.id=wm.user_id WHERE wm.workspace_id=? ORDER BY wm.joined_at", (workspace_id,)).fetchall() + d = row_to_dict(row) + d["members"] = [dict(m) for m in members] + return d + + +@router.patch("/workspaces/{workspace_id}") +def patch_workspace(workspace_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + row = conn.execute("SELECT * FROM workspaces WHERE id=?", (workspace_id,)).fetchone() + if not row: + raise HTTPException(404, "Workspace not found") + if row["owner_id"] != user["id"] and not user.get("is_admin"): + # check admin member + mem = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?", (workspace_id, user["id"])).fetchone() + if not mem or mem["role"] not in ("owner", "admin"): + raise HTTPException(403, "Only owner/admin can edit workspace") + name = body.get("name", row["name"]) + sj = body.get("settings_json") or body.get("settings") + if sj is not None: + sj = json.dumps(sj) if isinstance(sj, (dict, list)) else str(sj) + else: + sj = row["settings_json"] + conn.execute("UPDATE workspaces SET name=?, settings_json=? WHERE id=?", (name, sj, workspace_id)) + conn.commit() + audit_log(user, "workspace.update", "workspace", workspace_id, "", request) + return {"id": workspace_id, "status": "updated"} + + +@router.delete("/workspaces/{workspace_id}") +def delete_workspace(workspace_id: int, request: Request, authorization: str | None = Header(default=None)): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + row = conn.execute("SELECT * FROM workspaces WHERE id=?", (workspace_id,)).fetchone() + if not row: + raise HTTPException(404, "Workspace not found") + if row["owner_id"] != user["id"] and not user.get("is_admin"): + raise HTTPException(403, "Only owner can delete workspace") + conn.execute("DELETE FROM workspaces WHERE id=?", (workspace_id,)) + conn.commit() + audit_log(user, "workspace.delete", "workspace", workspace_id, "", request) + return {"id": workspace_id, "status": "deleted"} + + +@router.get("/workspaces/{workspace_id}/members") +def list_workspace_members(workspace_id: int, request: Request, authorization: str | None = Header(default=None)): + user = get_bearer_user(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + ws = conn.execute("SELECT id FROM workspaces WHERE id=?", (workspace_id,)).fetchone() + if not ws: + raise HTTPException(404, "Workspace not found") + rows = conn.execute("SELECT u.id, u.login, u.full_name, u.avatar_url, wm.role, wm.joined_at FROM workspace_members wm JOIN users u ON u.id=wm.user_id WHERE wm.workspace_id=? ORDER BY wm.joined_at", (workspace_id,)).fetchall() + return {"members": [row_to_dict(r) for r in rows]} + + +@router.post("/workspaces/{workspace_id}/members") +def invite_member(workspace_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + target_id = body.get("user_id") or body.get("uid") + email = (body.get("email") or "").strip() + role = (body.get("role") or "editor").strip().lower() + if role not in ("owner", "admin", "editor", "viewer", "commenter"): + raise HTTPException(400, "Invalid role") + with get_conn() as conn: + ws = conn.execute("SELECT owner_id FROM workspaces WHERE id=?", (workspace_id,)).fetchone() + if not ws: + raise HTTPException(404, "Workspace not found") + # only owner/admin can invite + if ws["owner_id"] != user["id"] and not user.get("is_admin"): + mem = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?", (workspace_id, user["id"])).fetchone() + if not mem or mem["role"] not in ("owner", "admin"): + raise HTTPException(403, "Only owner/admin can invite") + uid = target_id + if not uid and email: + u = conn.execute("SELECT id FROM users WHERE email=?", (email,)).fetchone() + if not u: + raise HTTPException(404, f"User with email {email} not found") + uid = u["id"] + if not uid: + raise HTTPException(400, "user_id or email required") + try: + conn.execute("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, ?)", (workspace_id, uid, role)) + except Exception: + conn.execute("UPDATE workspace_members SET role=? WHERE workspace_id=? AND user_id=?", (role, workspace_id, uid)) + conn.commit() + audit_log(user, "workspace.invite", "workspace", workspace_id, f"uid={uid} role={role}", request) + return {"workspace_id": workspace_id, "user_id": uid, "role": role, "status": "added"} + + +@router.patch("/workspaces/{workspace_id}/members/{uid}") +def update_member_role(workspace_id: int, uid: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + role = (body.get("role") or "").strip().lower() + if role not in ("owner", "admin", "editor", "viewer", "commenter"): + raise HTTPException(400, "Invalid role") + with get_conn() as conn: + ws = conn.execute("SELECT owner_id FROM workspaces WHERE id=?", (workspace_id,)).fetchone() + if not ws: + raise HTTPException(404, "Workspace not found") + if ws["owner_id"] != user["id"] and not user.get("is_admin"): + mem = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?", (workspace_id, user["id"])).fetchone() + if not mem or mem["role"] not in ("owner", "admin"): + raise HTTPException(403, "Only owner/admin can change roles") + conn.execute("UPDATE workspace_members SET role=? WHERE workspace_id=? AND user_id=?", (role, workspace_id, uid)) + if conn.total_changes == 0: + raise HTTPException(404, "Member not found") + conn.commit() + audit_log(user, "workspace.role_change", "workspace", workspace_id, f"uid={uid} role={role}", request) + return {"workspace_id": workspace_id, "user_id": uid, "role": role, "status": "updated"} + + +@router.delete("/workspaces/{workspace_id}/members/{uid}") +def remove_member(workspace_id: int, uid: int, request: Request, authorization: str | None = Header(default=None)): + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + ws = conn.execute("SELECT owner_id FROM workspaces WHERE id=?", (workspace_id,)).fetchone() + if not ws: + raise HTTPException(404, "Workspace not found") + if ws["owner_id"] != user["id"] and not user.get("is_admin"): + mem = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?", (workspace_id, user["id"])).fetchone() + if not mem or mem["role"] not in ("owner", "admin"): + raise HTTPException(403, "Only owner/admin can remove members") + conn.execute("DELETE FROM workspace_members WHERE workspace_id=? AND user_id=?", (workspace_id, uid)) + conn.commit() + audit_log(user, "workspace.remove_member", "workspace", workspace_id, f"uid={uid}", request) + return {"workspace_id": workspace_id, "user_id": uid, "status": "removed"} diff --git a/docs/openapi-v2.json b/docs/openapi-v2.json index 21c9ba0..2fdaa49 100644 --- a/docs/openapi-v2.json +++ b/docs/openapi-v2.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "FlowDeck", - "version": "7.28.0" + "version": "7.29.0" }, "paths": { "/auth/register": {