Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de751ffe35 | ||
|
|
48b5551b93 | ||
|
|
e4552c3763 | ||
|
|
6914780f24 | ||
|
|
d7d9966edf | ||
|
|
3cab76fed5 | ||
|
|
6ff88237fc | ||
|
|
840d2b2615 | ||
|
|
ab6ac1e84c | ||
|
|
3a74ea8bbd | ||
|
|
13dc8fdaad | ||
|
|
770fdc2b68 | ||
|
|
0bc74ad728 | ||
|
|
adf56a2dd8 | ||
|
|
c0925e511b | ||
|
|
6a5fe0524a |
@@ -13,7 +13,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
python-version: '3.13'
|
||||
- name: Install lint tools
|
||||
run: pip install -r requirements-dev.txt
|
||||
- name: Ruff (Python)
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
python-version: '3.13'
|
||||
- name: Install system dependencies (WeasyPrint / emoji fonts)
|
||||
run: |-
|
||||
SUDO=""
|
||||
|
||||
+480
@@ -1,5 +1,485 @@
|
||||
# Changelog - FlowDeck
|
||||
|
||||
## v7.43.0 (2026-10-01) — 🎉 A20 TERMINÉ : Alpine en build CSP, `unsafe-eval` retiré
|
||||
|
||||
### Changed (la bascule A20 phase 3)
|
||||
|
||||
- **`static/js/alpine.csp.min.js`** (build officiel `@alpinejs/csp`,
|
||||
0 `eval`/`new Function`, parseur d'expressions maison) servi partout :
|
||||
`base.html`, `import.html`, `welcome.html` + entrée `sw.js` (cache bump
|
||||
v8).
|
||||
- **CSP** : `script-src 'self' 'nonce-…'` — **`unsafe-eval` supprimé** (il
|
||||
ne servait plus qu'Alpine standard). htmx : `allowEval: false` déjà posé
|
||||
(v7.37).
|
||||
- Assertion test inversée : `assert "'unsafe-eval'" not in script_src`.
|
||||
- Scan statique final sur **tous** les templates : 0 expression incompatible
|
||||
(4 résidus = faux positifs dans des chaînes de texte).
|
||||
|
||||
### Notes
|
||||
|
||||
- 12 surfaces migrées + gateées en amont (lots 1-3a/3b) ; **E2E 7/7 sous
|
||||
CSP réel** (le route-swap du `csp_preview` sert désormais le même
|
||||
fichier — les gates restent utiles contre une régression du build).
|
||||
- board/table_view/teamload/card_detail : scan propre mais non gateés
|
||||
(gitea down) → à vérifier au premier usage avec gitea remonté.
|
||||
- Risque résiduel assumé : expressions n'ayant jamais tourné en runtime
|
||||
sur ces 4 surfaces (les gates + le filet 0-erreur les attraperont).
|
||||
|
||||
## v7.42.0 (2026-10-01) — 🐛 BUG TOPBAR CORRIGÉ (boutons du header en texte brut)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Les `right_actions` du topbar étaient servis ÉCHAPPÉS sur TOUTES les
|
||||
pages** (entities `"`/`<` → boutons Share/Star/Settings/Login en
|
||||
texte brut). **Cause racine** : `{% set right_actions = '…' ~ fd_icon(…) ~ '…' %}`
|
||||
— `fd_icon` est une **macro → `Markup`**, et `Markup.__radd__/__add__`
|
||||
**échappe ses arguments `str`** → tous les segments littéraux sortent
|
||||
entité-és ; le `|safe` de `_header:141` est then no-op sur un Markup déjà
|
||||
échappé. Découvert en cherchant l'échec du gate éditeur CSP (v7.41.0),
|
||||
reproductible partout (`curl /workspaces`).
|
||||
- **Fix racine (5 templates)** : conversion en **block-set**
|
||||
`{% set right_actions %}…{{ fd_icon(…) }}…{% endset %}` (source = brute,
|
||||
interpolation = Markup brut — la forme idiomatique Jinja) :
|
||||
`gitea_workspace`, `page_editor`, `page_editor_collection`, `workspace`,
|
||||
`workspaces`. Piège du script : regex greedy multi-lignes = set avalé →
|
||||
matcher **une ligne**.
|
||||
- **Test permanent** `tests/test_topbar_right_actions.py` : `/workspaces`
|
||||
doit contenir `class="topbar-btn"` parsé et ZÉRIE entité `"`.
|
||||
- Gate éditeur : l'assertion `.star-btn` **ré-ajoutée** (les boutons
|
||||
rendent à nouveau).
|
||||
|
||||
### Notes
|
||||
|
||||
- Probable régression depuis A10 (activation d'autoescape) : les `~`
|
||||
étaient des no-op avant, Markup.__radd__ échappait déjà… les
|
||||
`|safe` devenaient nécessaires et ne pouvaient plus réparer.
|
||||
|
||||
## v7.41.0 (2026-10-01) — A20 phase 3 LOT 3b : gitea + agent + éditeur verts
|
||||
|
||||
### Changed
|
||||
|
||||
- **gitea_workspace** : `x-data="giteaWorkspace"` → appel `giteaWorkspace()`,
|
||||
`new Date(…)` → `fmtGwDate(pp)`, x-html icône d'arbre → `bindGwIcon`
|
||||
(x-init + `Alpine.effect`).
|
||||
- **agent_panel** : x-html markdown → `bindMarkdown($el, m)` (effet réactif
|
||||
sur `m.content`).
|
||||
- **page_editor (les 12 sites `window.E` du topbar `right_actions`)** →
|
||||
délégués `appState` : `edCall('…')` (6 appels, arg littéral = seule forme
|
||||
parsable), `edTimeAgo`, `edCommentCount`, `edShared`, `bindStar` (les 2
|
||||
branches du ternaire favorited étaient identiques → rendu 1×) — les deux
|
||||
templates `page_editor.html` + `page_editor_collection.html` + garde
|
||||
Jinja : les quotes insérées doivent être `\'` (le `set` est délimité par
|
||||
`'`, une quote nue casse le template = 500).
|
||||
- **_page_editor_content** : +3 sites (`window.E.commentOnSelection` →
|
||||
`edCall`, `backlinksOpen…location.href` → `openBacklink(b)`,
|
||||
`Math.round(importFile…)` → `fmtImportSize(f)`) + x-html icône de page →
|
||||
`bindIconHtml` (effet sur `iconHtml()`).
|
||||
- **Gate éditeur** (`csp_preview`) : création collection → `/pages/{id}`,
|
||||
délégués `appState` liés + `editorState` lié + filet 0-erreur.
|
||||
|
||||
### Fixed
|
||||
|
||||
- x-html `iconHtml()` du contenu éditeur : directive **interdite** sous
|
||||
build CSP (le filet l'a attrapé) → `x-init` + `Alpine.effect`.
|
||||
|
||||
### ⚠️ Nouveau bug pré-existant identifié (PAS introdui par ce lot)
|
||||
|
||||
- **Les `right_actions` du topbar sont servi ÉCHAPPÉS sur TOUTES les
|
||||
pages** (entities `"`/`<` — les boutons Share/Star/Settings/…
|
||||
s'affichent en texte brut). Reproductible : `curl /workspaces` →
|
||||
`"topbar-btn"`. Le `{{ right_actions|safe … }}` de `_header:141`
|
||||
EST présent, l'ENV Jinja est standard, un rendu local du même motif sort
|
||||
PARSED — la cause exacte côté serveur reste à cerner (piste : valeur déjà
|
||||
échappée à la construction). Roadmap = suivi dédié ; le gate éditeur
|
||||
n'asserte donc pas la présence des boutons.
|
||||
|
||||
## v7.40.0 (2026-10-01) — A20 phase 3 LOT 3a : 5 surfaces de plus vertes
|
||||
|
||||
### Added
|
||||
|
||||
- **Gate `csp_preview` « surfaces simples »** : `/welcome`, `/trash`,
|
||||
`/accounts`, `/workspace`, `/import` — **0 modification de code
|
||||
nécessaire** sur les 4 premières (scan statique 0 expression/x-html +
|
||||
registres Alpine.data posés au lot 1). **8 surfaces couvertes** au total
|
||||
(base shell, library, settings, local workspace + celles-ci).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Bug pré-existant sur `/import` (visible sous les DEUX builds)** :
|
||||
`x-text="'🔗 '+report.relations…"` évalué alors que `report = null`
|
||||
(le `x-show` parent ne masque pas, il initialise quand même) → pageerror
|
||||
« Cannot read property … 'relations' » — garde `report && report.relations`.
|
||||
(Le probe montrait aussi un 401 console pré-existant sur la page — hors
|
||||
périmètre, non touché.)
|
||||
|
||||
### Notes
|
||||
|
||||
- Reste ph3 : page_editor (12 sites `window.E` dans `right_actions`),
|
||||
gitea_workspace (`new Date`), agent_panel (site `x-text` markdown +
|
||||
1 x-html), board + table_view/teamload/card_detail (gabarits liés au
|
||||
contexte Gitea, scan statique propre) → bascule réelle ensuite.
|
||||
|
||||
## v7.39.0 (2026-10-01) — A20 phase 3 LOT 2 : settings + local workspace verts
|
||||
|
||||
### Added
|
||||
|
||||
- **2 gates `csp_preview` de plus** : `settings` (composant lié, overlay
|
||||
visible, 0 erreur) et `local workspace` (recherche focalisée via
|
||||
`Alpine.nextTick`, chips filtre en SVG via `bindSvg`, 0 erreur).
|
||||
**3 surfaces vertes** sous build CSP : library, settings, local workspace.
|
||||
|
||||
### Changed
|
||||
|
||||
- **settings** : `window.history.back()`/`new Date(…)` → méthodes
|
||||
`historyBack`/`fmtLastLogin`/`fmtAuditDate` ; `?.` → ternaires.
|
||||
- **local workspace** : `x-data="_wsInitData"` → registre `wsInitData()`
|
||||
(le build CSP ne résout que le registre) ; 14 `x-html` → `x-init` +
|
||||
`Alpine.effect` (`bindSvg`/`bindFileIcon`/`bindNodeIcon`/`bindChildren`/
|
||||
`bindPreview`) ; `$nextTick`+`$refs` arrow → `toggleSearch()` ;
|
||||
`window.FlowDeck.*` → `createPageAt`/`createFolderAt` ; `?.` → ternaires ;
|
||||
`@contextmenu="_wsInitData.…"` → appel de méthode.
|
||||
- **Partage d'état JS↔Alpine (piège du build CSP)** : `ji` = snapshot des
|
||||
**valeurs** de toutes les propriétés `globalThis` au boot → l'objet mis
|
||||
sur `window` avant Alpine est **banni** (« Accessing global variables is
|
||||
prohibited »). Fix : objet porté par une **const lexicale** (non propriété
|
||||
`globalThis`) + factory Alpine.data qui le retourne → **même objet**
|
||||
partagé, réactivité intacte (une copie `Object.assign` aurait coupé les
|
||||
mises à jour JS : preview, uploads, isDragging).
|
||||
- **Bloc preview hors div racine** (structure pré-existante : le parseur
|
||||
referme la racine avant, masquée par le fallback window d'Alpine
|
||||
standard) → composant `wsPreview` **déléguant** vers `_wsInitData`
|
||||
(`Alpine.reactive` pour la réactivité ; wrapper = objet unique, les
|
||||
magics `$nextTick` ne sont redéfinissables qu'une fois — deuxième
|
||||
montage du même objet = « Cannot redefine property »).
|
||||
- **`.env` local : `RATE_LIMIT_REQUESTS=600`** — les rafales E2E
|
||||
Playwright (5 tests × ~25 requêtes) butaient sur le 60/min par IP ;
|
||||
défaut produit inchangé.
|
||||
|
||||
### Notes
|
||||
|
||||
- Reste ph3 : page_editor, board, agent_panel, import, gitea_workspace,
|
||||
welcome/accounts/trash/team_load/workspace/table_view/card_detail →
|
||||
puis bascule réelle (retrait `unsafe-eval`).
|
||||
|
||||
## v7.38.0 (2026-10-01) — A20 phase 3 LOT 1 : shell + library migres
|
||||
|
||||
### Added
|
||||
|
||||
- **`e2e/csp_preview.spec.js`** — aperçu CSP strict SANS déployer : le
|
||||
build `@alpinejs/csp` (fichier officiel, `e2e/fixtures/alpine.csp.js`)
|
||||
est servi **à la place** de `alpine.min.js` par interception Playwright ;
|
||||
tout échec du parseur maison = `pageerror` (filet). **Première surface
|
||||
verte : library** (composant lié, icônes SVG rendues via `Alpine.effect`,
|
||||
recherche ouverte + focalisée, 0 erreur console/page).
|
||||
|
||||
### Changed
|
||||
|
||||
- **Composants `x-data="fn()"` → registre `Alpine.data(...)`** (15 +
|
||||
`appState` + `libraryPage`) : le build CSP ne résout que le registre
|
||||
(probe : globale window → `Undefined variable`) — scripts/classiques
|
||||
chargés pendant le parsing = `alpine:init` toujours joint à temps.
|
||||
- **base.html (shell) migré** : `x-effect document.*` → `syncSidebarClass()`,
|
||||
`$nextTick(arrow)` → `initSidebarSort()`, `window.FlowDeck.*` →
|
||||
`fdCreatePage/fdCreateFolder/fdGwRefresh`, `Object.keys`/`Math.min`/
|
||||
`window.innerWidth` dans `x-for`/`:style` → `sidebarSections()`/
|
||||
`sectionMenuPos()` — toutes les formes = simple appel de méthode.
|
||||
- **x-html restants du shell** → `x-init` + `Alpine.effect` :
|
||||
icône agent (`bindAgentIcon`), carte projet (`bindProjectIcon`),
|
||||
library ×3 (`bindHtmlIcon`/`bindHtmlItem`), recherche library
|
||||
(`toggleSearch` avec `Alpine.nextTick`), `openMoveSelected` (library).
|
||||
- **eslint : 70 warnings → 0/0** : `getCsrf` (helper A38 ph1) déclaré dans
|
||||
les globals du config, `/* exported openCardDetail */` +
|
||||
`/* global owner, repo */` (app.js), 3 `;;` résiduels de la conversion
|
||||
A38 supprimés.
|
||||
|
||||
### Notes
|
||||
|
||||
- Portes A20 ph3 : surfaces restantes = settings, local_workspace,
|
||||
gitea_workspace, page_editor, board (partiels), agent_panel, import,
|
||||
welcome, accounts, trash, team_load, workspace, table_view, card_detail ;
|
||||
**bascule réelle** (retrait `unsafe-eval`) = quand csp_preview est vert
|
||||
sur toutes les pages principales.
|
||||
|
||||
## v7.37.0 (2026-10-01) — A20 : htmx allowEval off + plan Alpine CSP (phase 3)
|
||||
|
||||
### Changed
|
||||
|
||||
- **htmx `allowEval: false`** dans le `<meta name="htmx-config">` : htmx
|
||||
ne peut plus évaluer de JS (`hx-on`/`hx-vars`/`hx-vals`) — grep = **0
|
||||
usage** dans les templates, donc zéro régression possible. `unsafe-eval`
|
||||
reste **uniquement** pour Alpine standard.
|
||||
- **Gate E20 renforcé** : le smoke vérifie désormais que `Alpine.$data()`
|
||||
lie un vrai composant `[x-data]` de la page (le lien composant = le cœur
|
||||
de tout basculement CSP).
|
||||
|
||||
### Notes — A20 phase 3 (unsafe-eval, scopé par probes)
|
||||
|
||||
Le build `@alpinejs/csp` a été **testé empiriquement** (fichier 72 Ko,
|
||||
**0 `eval`/`new Function`**, parseur d'expressions maison) : il tourne sous
|
||||
CSP strict, mais **bloqué sur FlowDeck** par deux familles d'usages :
|
||||
- **13 expressions non parsables** par la grammaire restreinte :
|
||||
arrows (`$nextTick(() => …)` ×2), `typeof` ×1, `new Date(…)` ×4,
|
||||
optional-chaining `?.` ×6 (base, library, local_workspace, settings,
|
||||
gitea_workspace) ;
|
||||
- **24 `x-html` réactifs** (icônes SVG `getSvgIcon`/`_fileIcon`,
|
||||
markdown agent, preview) — **interdits par le build CSP** (innerHTML) :
|
||||
nécessitent de reposer les icônes sur des composants `Alpine.data`.
|
||||
- Portée : 0 variable globale/`document` accessible dans les expressions
|
||||
du build CSP (scope = données du composant + magics) → chaque site
|
||||
devient une méthode de composant enregistrée via `Alpine.data`.
|
||||
**Plan phase 3** : migrer composant par composant (library → settings →
|
||||
local_workspace → gitea → base) avec gate E2E dédiée, puis retirer
|
||||
`unsafe-eval`. En attendant : `unsafe-eval` conservé (Alpine standard).
|
||||
|
||||
## v7.36.0 (2026-10-01) — Fondations E2E + 2 bugs trouvés au passage
|
||||
|
||||
### Added
|
||||
|
||||
- **`e2e/smoke.spec.js`** — 2 gates vert contre l'instance de test :
|
||||
· **gate A39** : bascule de vues d'une collection (clic onglet Calendar →
|
||||
`?view_type=calendar`, grille `.calendar` + `.cal-header` rendue,
|
||||
collection créée puis **supprimée** = répétable)
|
||||
· **gate A20** : palette Ctrl+K (ouverture Alpine `.open`, recherche GET
|
||||
rend `.cmd-palette-item`, fermeture Échap)
|
||||
· **filet console** : 0 erreur JS/CSP (les violations atterrissent ici ;
|
||||
le bruit `Failed to load resource` 401/403 est filtré)
|
||||
· **Service Workers bloqués** dans le smoke : `/sw.js` sert sa page
|
||||
« hors ligne » sur les navigations redirigées (`redirect:'manual'`) —
|
||||
bruit PWA hors sujet, `pwa_offline.spec.js` couvre le SW
|
||||
· bootstrap autonome : login OU création du compte e2e documenté,
|
||||
workspace si absent — lecture seule sur les données existantes
|
||||
- Commande : `cd e2e && node node_modules/@playwright/test/cli.js test smoke.spec.js`
|
||||
|
||||
### Fixed (trouvés par les gates)
|
||||
|
||||
- **Bascule de vues standalone jamais fonctionnelle** : les onglets
|
||||
émettaient `?view=…` mais la route lit `view_type` (FastAPI) → l'onglet
|
||||
restait sur Table quel que soit le clic (bug pré-existant, non introduit
|
||||
par A28). Onglets → `?view_type=` + assertion comportementale ajoutée à
|
||||
`test_all_view_tabs_present`
|
||||
- **Inter bloqué par la CSP depuis v7.27** : `app.css` importait encore
|
||||
Google Fonts (`@import` raté par le grep de v7.27) → violation
|
||||
`style-src` sur chaque page + police tombée en fallback. Inter
|
||||
**auto-hébergé** : 2 faces variables (100-900, latin + latin-ext) dans
|
||||
`static/fonts/`, `@import` supprimé
|
||||
|
||||
### Notes
|
||||
|
||||
- Suite complète : **1093/1093** · ruff OK · E2E **2/2**
|
||||
- Portes : A20 (unsafe-eval) attaquable avec ce filet ; A39 couvre la
|
||||
bascule collection (pas la bascule htmx board → décision « rien »
|
||||
maintenue) ; A38 twins reste conditionné à une couverture élargie
|
||||
|
||||
## v7.35.0 (2026-10-01) — Audit : A35 TERMINÉ (Python 3.13 aligné + rebuild)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Drift Python résolu** (dernier reliquat A35) : tout le projet est en
|
||||
**3.13** — `Dockerfile` (`python:3.13-slim` builder + runtime), CI Gitea
|
||||
(`python-version: '3.13'` ×2), `README.md` ×2, ruff
|
||||
`target-version = "py313"` (0 nouvelle remarque ruff). Restait 3.12 aux
|
||||
6 endroits alors que `uv.lock` = `requires-python >=3.13` et le venv =
|
||||
3.13.14. Zéro référence 3.12 résiduelle.
|
||||
|
||||
### Validation (le point que l'audit laissait « à faire par rebuild »)
|
||||
|
||||
- `docker build` **vert** sur `python:3.13-slim` (image `flowdeck:a35-py313`)
|
||||
- dans le conteneur : `python -V` = **3.13.16**, `import app.main` OK
|
||||
(version 7.35.0) → wheels `requirements.txt` construits et importables
|
||||
sur 3.13
|
||||
- Suite locale complète : **1093/1093** · ruff OK (target py313)
|
||||
|
||||
### Notes
|
||||
|
||||
- A35 = **TERMINÉ** (OpenAPI/README/titre faits en 7.3.9 + drift aujourd'hui)
|
||||
|
||||
## v7.34.0 (2026-10-01) — Audit : A38 phase 2 (0 doublon de fonction globale)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Inventaire exhaustif de la duplication de fonctions** : scan de
|
||||
profondeur de brace sur les 13 noms définis 2+ fois (templates +
|
||||
static/js, strings/comments/backticks gérés) → **12 sont déjà scopés**
|
||||
dans des IIFEs depuis A27 (aucun conflit de page possible)
|
||||
- **Seul doublon global = `openCardDetail`** (corps byte-identiques ×2 dans
|
||||
`board_fragment` + `detailed_board`, fragments de vues mutuellement
|
||||
exclusifs) → dédupliquée vers `static/js/app.js`, les 2 copies
|
||||
supprimées (les onclick/@click des deux fragments appellent la même
|
||||
définition)
|
||||
|
||||
### Tests
|
||||
|
||||
- `test_no_duplicate_global_functions` : garde-fou — 0 `function NAME`
|
||||
globale définie 2+ fois entre templates et static/js (scanner naïf,
|
||||
plafond `ponytail:` commenté : un faux positif se lit au nom signalé)
|
||||
- Suite complète : **1092/1092** · ruff OK · node --check vert
|
||||
|
||||
### Notes
|
||||
|
||||
- Reste A38 : méthodes jumelles library/local_workspace (~9-21 noms
|
||||
communs, corps divergents) → fusion `workspace-tree.js` reportée
|
||||
(réconciliation sans E2E — même logique que A39/A20)
|
||||
|
||||
## v7.33.0 (2026-10-01) — Audit : A43 TERMINÉ + A38 phase 1 (CSRF unifié)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A43-1 — plus de `__CSRF_PLACEHOLDER__` servi** : ContextVar
|
||||
`CSRF_TOKEN` posée par `CSRFMiddleware` **avant** `call_next` (même
|
||||
mécanique que le nonce CSP), global `{{ csrf_token() }}` ajouté à
|
||||
templating ; `base.html` rend `{"X-CSRF-Token":{{ csrf_token()|tojson }}}`
|
||||
— vide si le cookie est absent sur cette 1ʳᵉ requête, mais
|
||||
`htmx:configRequest` re-lit le cookie à chaque appel → jamais de jeton
|
||||
factice. Test `test_csrf_server_rendered_no_placeholder` (pas de
|
||||
placeholder + token == cookie)
|
||||
- **A43-2 — palette : plus de re-parse par frappe** : `fetch('/api/search…')`
|
||||
sans header (GET ∈ `SAFE_METHODS` → le CSRF ne s'applique pas) — le
|
||||
`JSON.parse(document.body.getAttribute('hx-headers'))` par frappe disparaît
|
||||
|
||||
### Changed
|
||||
|
||||
- **A38 phase 1 — helper CSRF unique** : `window.getCsrf()` défini dans le
|
||||
`<head>` de `base.html` (le plus tôt possible) ; **76 lectures brutes du
|
||||
cookie → `getCsrf()`** dans 13 fichiers (47 formes `(…||[])[1]||''`,
|
||||
25 déclarations `const X = match(…)` + leurs usages `X?X[1]:''` → `X`,
|
||||
4 formes espacées) ; définitions dupliquées supprimées (`card_detail`,
|
||||
`database_table`) ; les 3 variantes de `base.html` (IIFE + 2
|
||||
`getCsrfToken`) unifiées sur `return getCsrf()` ; `welcome.html` garde sa
|
||||
lecture locale (page autonome documentée)
|
||||
|
||||
### Notes
|
||||
|
||||
- A43 = **TERMINÉ** (4/4 : utcnow déprécié = 0 dans app/**.py, health
|
||||
loggé, placeholder, palette)
|
||||
- A38 reste : 12 fonctions dupliquées entre templates (wrappeur) + 21
|
||||
méthodes jumelles library/local_workspace
|
||||
- Suite complète : **1092/1092** · ruff OK · node --check vert
|
||||
|
||||
## v7.32.0 (2026-10-01) — Audit : A28 TERMINÉ (board, dernier lot)
|
||||
|
||||
### Changed
|
||||
|
||||
- **A28 lot 4** : `app/routers/board.py` (**2 101 lignes, 53 routes**)
|
||||
devient le package `app/routers/board/` :
|
||||
· 12 modules : `pages` 271 L (7), `page_api` 229 (5), `board_views` 223 (8),
|
||||
`page_ops` 176 (3), `sharing` 175 (10), `synced` 144 (8),
|
||||
`page_media` 122 (4), `import_` 85 (2), `wiki` 76 (2), `library` 66 (1),
|
||||
`embed` 64 (2), `sync` 51 (1)
|
||||
· `_common.py` (878 L) : les **23 helpers dont 4 async** + les 4
|
||||
constantes (STATUS_COLORS, STATUS_LABELS, AI_KEYWORD_COLORS,
|
||||
_REPO_REF_RE)
|
||||
· `__init__.py` : `__all__` complet — importateurs **inchangés**
|
||||
(api.py ×4 top-level, webhooks top-level, dashboard ×5 lazy, tests ×4)
|
||||
- Preuve contractuelle : **`docs/openapi-v2.json` régénéré = identique
|
||||
byte-à-byte**
|
||||
|
||||
### Fixed
|
||||
|
||||
- Constantes d'état oubliées dans `_common` à la 1ʳᵉ passe (F821 +
|
||||
ImportError au chargement) → ré-insérées avec leurs valeurs exactes
|
||||
(git show)
|
||||
- Docstring du header copié → F404 (`from __future__` après un statement)
|
||||
→ slice `[1:21]` ; helpers **async** non détectés par `def ` seul
|
||||
|
||||
### Notes
|
||||
|
||||
- **A28 TERMINÉ en 4 lots** : api_v2 (7.29.0), dashboard (7.30.0),
|
||||
collections (7.31.0), board (7.32.0) — **0 changement d'URL** sur les 4
|
||||
- Suite complète : **1091/1091** · ruff OK
|
||||
|
||||
## v7.31.0 (2026-10-01) — Audit : A28 lot 3 (collections → package 13 fichiers)
|
||||
|
||||
### Changed
|
||||
|
||||
- **A28 lot 3** : `app/routers/collections.py` (**2 622 lignes, 53
|
||||
endpoints / 52 fonctions**) devient le package `app/routers/collections/` :
|
||||
· 10 modules de routes : `crud` 337 L (6), `properties` 322 (8),
|
||||
`linked` 286 (7), `structure` 267 (8), `dashboard_views` 214 (3),
|
||||
`meta` 197 (5), `views` 187 (6), `pages` 184 (4), `data_api` 122 (2),
|
||||
`boards` 61 (3)
|
||||
· `_common.py` (220 L) : 8 helpers auth/permissions/validation
|
||||
· `_renderers.py` (667 L) : **15 rendus HTML des vues** (_render_chart,
|
||||
_render_map, …) + `CHART_MAX_GROUPS`
|
||||
· `__init__.py` : ré-exports connus — `_validate_page_properties`
|
||||
(automations), `_chart_values`/`_chart_aggregate`/`_fmt_number`/
|
||||
`_render_chart` (tests)
|
||||
- Preuve contractuelle : **`docs/openapi-v2.json` régénéré = identique
|
||||
byte-à-byte**
|
||||
|
||||
### Fixed (pièges de la découpe)
|
||||
|
||||
- docstring d'origine gardée dans le header copié → **F404** (`from
|
||||
__future__` après un statement) : slice `[1:30]`
|
||||
- décorateurs **empilés** (`view_collection` ×2) : segment sans `def` →
|
||||
skip du 2ᵉ décorateur (53 endpoints = 52 unités)
|
||||
- `CHART_MAX_GROUPS` hors détection des helpers (F821 dans
|
||||
`dashboard_views`) → import `._renderers` ajouté
|
||||
- `test_csp_no_cdn_and_vendor` lisait `collections.py` → balayage du
|
||||
package
|
||||
|
||||
### Notes
|
||||
|
||||
- Reste A28 : `board.py` 2 101 L (lot 4)
|
||||
- Suite complète : **1091/1091** · ruff OK
|
||||
|
||||
## v7.30.0 (2026-10-01) — Audit : A28 lot 2 (dashboard → package 10 fichiers)
|
||||
|
||||
### Changed
|
||||
|
||||
- **A28 lot 2** : `app/routers/dashboard.py` (**2 735 lignes, 63 routes**)
|
||||
devient le package `app/routers/dashboard/` :
|
||||
· `local_workspace` 559 L (15 routes), `pages_html` 485 (6),
|
||||
`account_settings` 439 (16), `workspace` 321 (9), `pages_api` 240 (6),
|
||||
`workspaces` 131 (6), `public` 78 (1), `account_api` 77 (4)
|
||||
· `_common.py` (774 L) : les **15 helpers top-level intercalés** dans
|
||||
l'ancien fichier + état (`logger`, `_VERSION`, `WORKSPACE_COOKIE`)
|
||||
· `__init__.py` : re-export complet (les 7 importateurs existants —
|
||||
main, board ×3, my_tasks, web_clipper, wiki, sites, tests — n'ont
|
||||
rien changé) + `__all__`
|
||||
- Preuve contractuelle : **`docs/openapi-v2.json` régénéré = identique
|
||||
byte-à-byte** (ordre d'enregistrement des routes préservé)
|
||||
|
||||
### Fixed (pièges de la découpe, rattrapés)
|
||||
|
||||
- segment décorateur sans sa fonction (`def`) → corps perdus en silence :
|
||||
assert `def in seg` ajoutée + récupération depuis git
|
||||
- collision `settings` : la section `settings.py` entrait en conflit avec
|
||||
`from app.config import settings` du header (`hasattr` du fromlist de
|
||||
Python → la section était ignorée) → renommée `account_settings`
|
||||
- `WORKSPACE_COOKIE` utilisé dans `workspaces.py` sans import (F821) →
|
||||
import `._common` ajouté
|
||||
- script `__all__` tronquant la fin du fichier → `__init__.py` réécrit
|
||||
|
||||
### Notes
|
||||
|
||||
- Reste A28 : `collections.py` 2 622 L, `board.py` 2 101 L
|
||||
- Suite complète : **1091/1091** · ruff OK
|
||||
|
||||
## 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
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@
|
||||
# Stage 1 "builder": build Python wheels once.
|
||||
# Stage 2 "runtime": minimal image with WeasyPrint system libs.
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
FROM python:3.12-slim AS builder
|
||||
FROM python:3.13-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -11,7 +11,7 @@ COPY requirements.txt .
|
||||
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt
|
||||
|
||||
# ── runtime stage ───────────────────────────────────────────
|
||||
FROM python:3.12-slim AS runtime
|
||||
FROM python:3.13-slim AS runtime
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -73,9 +73,9 @@ docker compose up -d
|
||||
| Couche | Techno |
|
||||
|--------|--------|
|
||||
| Frontend | Jinja2 + HTMX + Alpine.js + SortableJS + CSS |
|
||||
| Backend | Python 3.12 + FastAPI + httpx |
|
||||
| Backend | Python 3.13 + FastAPI + httpx |
|
||||
| BDD | SQLite (WAL, 21 tables) — `/data/flowdeck.db` |
|
||||
| Déploiement | Docker (python:3.12-slim), docker-compose |
|
||||
| Déploiement | Docker (python:3.13-slim), docker-compose |
|
||||
|
||||
## Configuration
|
||||
|
||||
|
||||
+28
-6
File diff suppressed because one or more lines are too long
+1
-1
@@ -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.43.0 (**A20 TERMINÉ** : bascule Alpine CSP + unsafe-eval retiré de la CSP) | **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
|
||||
|
||||
+1
-1
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="7.28.0",
|
||||
version="7.43.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
|
||||
@@ -7,6 +7,8 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from app.templating import CSRF_TOKEN
|
||||
|
||||
|
||||
class CSRFMiddleware(BaseHTTPMiddleware):
|
||||
"""Lightweight CSRF protection for state-changing requests.
|
||||
@@ -35,6 +37,9 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
||||
}
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# A43 : jeton expose aux templates (base.html hx-headers) — posé AVANT
|
||||
# call_next, comme le nonce CSP (meme mecanisme ContextVar).
|
||||
CSRF_TOKEN.set(request.cookies.get("csrf_token", ""))
|
||||
# Webhook receiver, OAuth callback, and internal API are exempt
|
||||
if any(request.url.path.startswith(p) for p in self.EXCLUDED_PATHS):
|
||||
return await call_next(request)
|
||||
|
||||
@@ -71,15 +71,15 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
|
||||
# requête — un XSS injecté dans une page ne tourne plus). Les 74 handlers
|
||||
# inline `onclick=` restent couverts par `script-src-attr`, explicitement
|
||||
# détaché de script-src (sinon le nonce les désactiverait aussi).
|
||||
# `unsafe-eval` reste : Alpine (x-data en string) et htmx (hx-vars) en ont
|
||||
# besoin → à retirer avec le build CSP d'Alpine (voir reste d'A20).
|
||||
# A20 TERMINÉ : `unsafe-eval` retiré — Alpine tourne en build CSP
|
||||
# (static/js/alpine.csp.min.js, 0 eval) ; htmx allowEval=false.
|
||||
# 15 surfaces en csp_preview vert + scan statique 0 (board/gitea/cards
|
||||
# = props propres, gitea down empêche un gate dédié).
|
||||
CSP_VALUE = (
|
||||
"default-src 'self'; "
|
||||
# A20 phase 2 : chart.js/leaflet vendorisés dans /static/js/vendor
|
||||
# (test_csp_no_cdn_and_vendor), plus aucun hôte CDN tiers.
|
||||
# `unsafe-eval` reste : Alpine (x-data) + htmx (hx-on/hx-vars) en
|
||||
# ont besoin → retrait avec le build CSP d'Alpine (reste d'A20).
|
||||
"script-src 'self' 'unsafe-eval' 'nonce-{nonce}'; "
|
||||
"script-src 'self' 'nonce-{nonce}'; "
|
||||
"script-src-attr 'unsafe-inline'; "
|
||||
# ponytail: aucun @font-face Google (grep négatif) → les deux
|
||||
# hôtes fonts étaient morts, supprimés.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
@@ -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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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.*"]}
|
||||
@@ -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]}
|
||||
@@ -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]}
|
||||
@@ -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 <token>"}
|
||||
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]}
|
||||
@@ -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]}
|
||||
@@ -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, '<mark>', '</mark>', '...', 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}
|
||||
@@ -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]}
|
||||
@@ -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}
|
||||
@@ -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"}
|
||||
@@ -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"}
|
||||
@@ -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=<hex>)" 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}
|
||||
@@ -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"}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,108 @@
|
||||
"""FlowDeck — Board : Kanban Notion-style + multi-vues.
|
||||
|
||||
Découpe A28 : l'ancien `board.py` (2 101 lignes, 53 routes) est
|
||||
devenu ce package — un module par concern, helpers/constantes dans
|
||||
`_common`. Ré-exportés (importateurs inchangés) : api.py
|
||||
(STATUS_COLORS, STATUS_LABELS, _issue_column, _map_issue_to_card),
|
||||
webhooks (_issue_column), dashboard (_sidebar_data,
|
||||
_load_workspace_pages, _load_shared_sidebar_pages, _file_icon),
|
||||
tests (_build_page_tree, _REPO_REF_RE, _unfurl_repo).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from . import ( # ordre = ordre d'enregistrement d'origine
|
||||
board_views,
|
||||
embed,
|
||||
import_,
|
||||
library,
|
||||
page_api,
|
||||
page_media,
|
||||
page_ops,
|
||||
pages,
|
||||
sharing,
|
||||
sync,
|
||||
synced,
|
||||
wiki,
|
||||
)
|
||||
from ._common import ( # noqa: F401 — ré-exports
|
||||
_REPO_REF_RE,
|
||||
AI_KEYWORD_COLORS,
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
_apply_filters,
|
||||
_apply_sorts,
|
||||
_block_texts,
|
||||
_build_page_tree,
|
||||
_create_page_from_markdown,
|
||||
_ensure_block_ids,
|
||||
_ensure_page_editable,
|
||||
_extract_ai_keywords,
|
||||
_file_icon,
|
||||
_get_project_properties,
|
||||
_issue_column,
|
||||
_load_children,
|
||||
_load_shared_sidebar_pages,
|
||||
_load_workspace_pages,
|
||||
_local_workspaces_for_user,
|
||||
_map_issue_to_card,
|
||||
_record_version,
|
||||
_sidebar_data,
|
||||
_store_uploaded_file,
|
||||
_unfurl_repo,
|
||||
_upload_root,
|
||||
_ws_id_for,
|
||||
asyncio_get_labels,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
for _mod in (
|
||||
wiki,
|
||||
page_api,
|
||||
library,
|
||||
sharing,
|
||||
synced,
|
||||
board_views,
|
||||
pages,
|
||||
page_media,
|
||||
import_,
|
||||
embed,
|
||||
page_ops,
|
||||
sync,
|
||||
):
|
||||
router.include_router(_mod.router)
|
||||
|
||||
__all__ = [
|
||||
"router",
|
||||
"AI_KEYWORD_COLORS",
|
||||
"STATUS_COLORS",
|
||||
"STATUS_LABELS",
|
||||
"_REPO_REF_RE",
|
||||
"_apply_filters",
|
||||
"_apply_sorts",
|
||||
"_block_texts",
|
||||
"_build_page_tree",
|
||||
"_create_page_from_markdown",
|
||||
"_ensure_block_ids",
|
||||
"_ensure_page_editable",
|
||||
"_extract_ai_keywords",
|
||||
"_file_icon",
|
||||
"_get_project_properties",
|
||||
"_issue_column",
|
||||
"_load_children",
|
||||
"_load_shared_sidebar_pages",
|
||||
"_load_workspace_pages",
|
||||
"_local_workspaces_for_user",
|
||||
"_map_issue_to_card",
|
||||
"_record_version",
|
||||
"_sidebar_data",
|
||||
"_store_uploaded_file",
|
||||
"_unfurl_repo",
|
||||
"_upload_root",
|
||||
"_ws_id_for",
|
||||
"asyncio_get_labels",
|
||||
]
|
||||
@@ -0,0 +1,878 @@
|
||||
"""FlowDeck — Board : helpers et constantes partagés (A28).
|
||||
|
||||
Les 23 helpers de l'ancien board.py (dont 4 async) + constantes
|
||||
(STATUS_COLORS/STATUS_LABELS/AI_KEYWORD_COLORS/_REPO_REF_RE) —
|
||||
ré-exportés : api.py, webhooks, dashboard, tests.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
from app.routers.dashboard import _get_app_version
|
||||
from app.routers.sidebar_config import get_sidebar_config_sync
|
||||
from app.services.gitea_client import gitea
|
||||
from app.services.http_client import shared_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["board"], prefix="/board")
|
||||
|
||||
STATUS_COLORS = {"todo": "var(--gray)", "progress": "var(--blue)", "done": "var(--green)"}
|
||||
STATUS_LABELS = {"todo": "To-do", "progress": "In progress", "done": "Complete"}
|
||||
|
||||
AI_KEYWORD_COLORS = [
|
||||
"#E03E3E", "#D9730D", "#9B72F0", "#0F7B6C", "#3399CC",
|
||||
"#E255A1", "#787774", "#6B4E3D", "#6374C4", "#5A9E4B",
|
||||
]
|
||||
|
||||
_REPO_REF_RE = re.compile(r"^(gitea|github):([\w\-\.]+)/([\w\-\.]+)$")
|
||||
|
||||
|
||||
|
||||
def _ensure_page_editable(conn, page_id: int, user: dict | None) -> None:
|
||||
"""v5.12.0: raise 423 when the page is locked and the actor may not edit.
|
||||
|
||||
Allowed to edit a locked page: admins and the user who locked it
|
||||
(locked_by). Unauthenticated callers only pass when the page is unlocked.
|
||||
"""
|
||||
row = conn.execute("SELECT is_locked, locked_by FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row or not row["is_locked"]:
|
||||
return
|
||||
uid = (user or {}).get("id")
|
||||
is_admin = bool((user or {}).get("is_admin"))
|
||||
if is_admin or (uid and row["locked_by"] == uid):
|
||||
return
|
||||
raise HTTPException(423, "Page is locked — only the owner of the lock or an admin can edit")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _ensure_block_ids(blocks) -> None:
|
||||
"""Assign unique ids to blocks missing one, recursively.
|
||||
|
||||
Built-in page templates ship without ids (the editor used to assign them
|
||||
client-side only). Without persisted ids, the realtime layer and the editor
|
||||
disagree on block identity, which duplicated lines / shuffled blocks when
|
||||
editing a template-created page. We now materialize ids at creation time.
|
||||
"""
|
||||
import uuid
|
||||
if not isinstance(blocks, list):
|
||||
return
|
||||
for b in blocks:
|
||||
if isinstance(b, dict):
|
||||
if not b.get("id"):
|
||||
b["id"] = "b" + uuid.uuid4().hex[:12]
|
||||
if isinstance(b.get("children"), list):
|
||||
_ensure_block_ids(b["children"])
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ── Core helpers ──
|
||||
|
||||
def _issue_column(issue: dict, columns: list[str], board_id: int) -> str:
|
||||
if issue.get("state") == "closed":
|
||||
return "Terminé" if "Terminé" in columns else columns[-1]
|
||||
for lbl in issue.get("labels", []):
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT column_name FROM col_mapping WHERE board_id=? AND gitea_label=?",
|
||||
(board_id, lbl["name"]),
|
||||
).fetchone()
|
||||
if row and row["column_name"] in columns:
|
||||
return row["column_name"]
|
||||
return columns[0] if columns else "Backlog"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _map_issue_to_card(issue: dict, owner: str = "", repo: str = "") -> dict:
|
||||
title = issue.get("title", "Untitled")
|
||||
status = "todo"
|
||||
if issue.get("state") == "closed":
|
||||
status = "done"
|
||||
labels = issue.get("labels", [])
|
||||
for lbl in labels:
|
||||
name = lbl.get("name", "").lower()
|
||||
if "progress" in name or "doing" in name:
|
||||
status = "progress"
|
||||
elif "done" in name or "complete" in name or "terminé" in name:
|
||||
status = "done"
|
||||
|
||||
assignee = issue.get("assignee", {}) or {}
|
||||
assignee_name = assignee.get("login", "")
|
||||
tag = labels[0].get("name", "") if labels else ""
|
||||
tag_color = labels[0].get("color", "#787774") if labels else "#787774"
|
||||
if tag_color and not tag_color.startswith("#"):
|
||||
tag_color = f"#{tag_color}"
|
||||
|
||||
icon_map = {
|
||||
"bug": "🐛", "feature": "✨", "enhancement": "⚡", "documentation": "📄",
|
||||
"design": "🎨", "testing": "🧪", "refactor": "🔧", "security": "🔒",
|
||||
}
|
||||
icon = "file"
|
||||
for lbl in labels:
|
||||
for kw, emoji in icon_map.items():
|
||||
if kw in lbl.get("name", "").lower():
|
||||
icon = emoji
|
||||
break
|
||||
|
||||
# Load custom property values
|
||||
props = {}
|
||||
if owner and repo:
|
||||
with get_conn() as conn:
|
||||
pvs = conn.execute("""
|
||||
SELECT pp.name, pp.prop_type, pv.value
|
||||
FROM property_values pv
|
||||
JOIN project_properties pp ON pp.id = pv.property_id
|
||||
WHERE pp.project_owner=? AND pp.project_name=? AND pv.gitea_issue_id=?
|
||||
""", (owner, repo, issue.get("number", 0))).fetchall()
|
||||
for pv in pvs:
|
||||
props[pv["name"]] = {"type": pv["prop_type"], "value": pv["value"]}
|
||||
|
||||
# AI keywords from DB
|
||||
keywords = []
|
||||
if owner and repo:
|
||||
with get_conn() as conn:
|
||||
kw_rows = conn.execute(
|
||||
"SELECT keyword, color FROM ai_keywords WHERE project_owner=? AND project_name=? ORDER BY usage_count DESC",
|
||||
(owner, repo),
|
||||
).fetchall()
|
||||
# Filter: show keywords matching this issue's labels
|
||||
label_names = {lbl.get("name", "").lower() for lbl in labels}
|
||||
for kw in kw_rows:
|
||||
if kw["keyword"].lower() in label_names or any(
|
||||
kw["keyword"].lower() in lbl for lbl in label_names
|
||||
):
|
||||
keywords.append({"name": kw["keyword"], "color": kw["color"]})
|
||||
|
||||
return {
|
||||
"id": str(issue.get("number", 0)),
|
||||
"title": title,
|
||||
"status": status,
|
||||
"status_color": STATUS_COLORS.get(status, "var(--gray)"),
|
||||
"status_label": STATUS_LABELS.get(status, "To-do"),
|
||||
"icon": icon,
|
||||
"assignee": assignee_name,
|
||||
"tag": tag if tag else None,
|
||||
"tag_color": tag_color,
|
||||
"due_date": issue.get("due_date", ""),
|
||||
"url": issue.get("html_url", ""),
|
||||
"keywords": keywords,
|
||||
"custom_props": props,
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _build_page_tree(conn, parent_id: int | None, ws_key: str, depth: int = 0, max_depth: int = 3) -> list[dict]:
|
||||
"""Build nested page tree recursively. max_depth prevents infinite recursion."""
|
||||
if depth >= max_depth:
|
||||
return []
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, updated_at FROM pages WHERE workspace=? AND parent_id IS ? AND deleted_at IS NULL AND collection_row_id IS NULL ORDER BY sort_order ASC, updated_at DESC",
|
||||
(ws_key, parent_id),
|
||||
).fetchall()
|
||||
items = []
|
||||
for row in rows:
|
||||
children = _build_page_tree(conn, row["id"], ws_key, depth + 1, max_depth)
|
||||
items.append({
|
||||
"id": f"page/{row['id']}",
|
||||
"db_id": row["id"],
|
||||
"name": row["title"] or "New page",
|
||||
"icon": "📄",
|
||||
"url": f"/pages/{row['id']}",
|
||||
"active": False,
|
||||
"depth": depth,
|
||||
"has_children": len(children) > 0,
|
||||
"children": children,
|
||||
})
|
||||
return items
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _file_icon(name: str, content_format: str = "") -> str:
|
||||
"""Map file extension to icon name (SVG-safe)."""
|
||||
# FlowDeck internal pages (no extension)
|
||||
if content_format and content_format != 'file':
|
||||
return 'edit'
|
||||
n = name.lower()
|
||||
if re.search(r'\.(png|jpe?g|gif|webp|svg|bmp|ico)$', n):
|
||||
return 'image'
|
||||
if n.endswith('.pdf'):
|
||||
return 'file'
|
||||
if re.search(r'\.(md|markdown)$', n):
|
||||
return 'edit'
|
||||
if n.endswith('.py'):
|
||||
return 'file'
|
||||
if re.search(r'\.(js|jsx|ts|tsx)$', n):
|
||||
return 'file'
|
||||
if re.search(r'\.(html?|xml)$', n):
|
||||
return 'file'
|
||||
if n.endswith('.css'):
|
||||
return 'file'
|
||||
if n.endswith('.json'):
|
||||
return 'file'
|
||||
if n.endswith('.sql'):
|
||||
return 'file'
|
||||
if re.search(r'\.(sh|bash|zsh)$', n):
|
||||
return 'file'
|
||||
if n.endswith('.ps1'):
|
||||
return 'file'
|
||||
if re.search(r'\.(rs|go|java|rb|php|c|cpp|h|swift|kt|scala|r)$', n):
|
||||
return 'file'
|
||||
if re.search(r'\.(txt|log)$', n):
|
||||
return 'file'
|
||||
if re.search(r'\.(zip|tar|gz|rar|7z)$', n):
|
||||
return 'file'
|
||||
return 'file'
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _load_workspace_pages(ws_cookie: str) -> list:
|
||||
"""Load top-level pages with children for the active workspace."""
|
||||
if not ws_cookie:
|
||||
return []
|
||||
try:
|
||||
ws_id = int(ws_cookie)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, parent_section, content_format, "
|
||||
"is_shared, share_mode, COALESCE(published,0) AS published "
|
||||
"FROM pages WHERE workspace_id=? AND parent_id IS NULL AND deleted_at IS NULL AND collection_row_id IS NULL ORDER BY created_at DESC",
|
||||
(ws_id,),
|
||||
).fetchall()
|
||||
items = []
|
||||
for r in rows:
|
||||
is_folder = r["parent_section"] == "Workspace"
|
||||
title = r["title"] or "Untitled"
|
||||
sub_children = _load_children(r["id"])
|
||||
is_shared = bool(r["is_shared"] or r["share_mode"] != "private" or r["published"])
|
||||
items.append({
|
||||
"db_id": r["id"], "name": title,
|
||||
"id": f"page/{r['id']}",
|
||||
"icon": "📁" if is_folder else _file_icon(title, r["content_format"]),
|
||||
"is_folder": is_folder,
|
||||
"is_shared": is_shared,
|
||||
"child_count": len(sub_children),
|
||||
"children": sub_children,
|
||||
})
|
||||
return items
|
||||
except (ValueError, Exception):
|
||||
return []
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _load_children(parent_id: int) -> list:
|
||||
"""Recursively load children of a page."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, parent_section, content_format, "
|
||||
"is_shared, share_mode, COALESCE(published,0) AS published "
|
||||
"FROM pages WHERE parent_id=? AND deleted_at IS NULL ORDER BY created_at",
|
||||
(parent_id,),
|
||||
).fetchall()
|
||||
children = []
|
||||
for r in rows:
|
||||
is_folder = r["parent_section"] == "Workspace"
|
||||
title = r["title"] or "Untitled"
|
||||
sub_children = _load_children(r["id"])
|
||||
is_shared = bool(r["is_shared"] or r["share_mode"] != "private" or r["published"])
|
||||
children.append({
|
||||
"db_id": r["id"], "name": title,
|
||||
"id": f"page/{r['id']}",
|
||||
"icon": "📁" if is_folder else _file_icon(title, r["content_format"]),
|
||||
"is_folder": is_folder,
|
||||
"is_shared": is_shared,
|
||||
"child_count": len(sub_children),
|
||||
"children": sub_children,
|
||||
})
|
||||
return children
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _local_workspaces_for_user(user: dict | None, workspace_id: int = 0) -> list[dict]:
|
||||
"""Return list of local workspaces for a user."""
|
||||
if not user:
|
||||
return []
|
||||
try:
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, name FROM workspaces WHERE owner_id = ? ORDER BY name",
|
||||
(user["id"],)
|
||||
).fetchall()
|
||||
return [{"id": r["id"], "name": r["name"]} for r in rows]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _load_shared_sidebar_pages(user_id: int) -> tuple[list, list, list, list]:
|
||||
"""Shared / received / published pages for the sidebar (reused by dashboard)."""
|
||||
with get_conn() as conn:
|
||||
own_ws = (
|
||||
"SELECT w.id FROM workspaces w WHERE w.owner_id = ? "
|
||||
"UNION SELECT wm.workspace_id FROM workspace_members wm WHERE wm.user_id = ?"
|
||||
)
|
||||
own_ws_names = (
|
||||
"SELECT w.name FROM workspaces w WHERE w.owner_id = ? "
|
||||
"UNION SELECT w.name FROM workspaces w "
|
||||
"JOIN workspace_members wm ON wm.workspace_id = w.id WHERE wm.user_id = ?"
|
||||
)
|
||||
# Scope "shared by me"-style lists to pages in the user's own workspaces
|
||||
# (or legacy pages whose workspace_id is NULL but identify the workspace by text).
|
||||
scope_cond = (
|
||||
f"(workspace_id IN ({own_ws}) "
|
||||
f"OR (workspace_id IS NULL AND lower(workspace) IN "
|
||||
f"(SELECT lower(name) FROM ({own_ws_names}))) "
|
||||
f"OR (workspace_id IS NULL AND lower(workspace) = lower("
|
||||
f"(SELECT login FROM users WHERE id=?))))"
|
||||
)
|
||||
scope_params = (user_id, user_id, user_id, user_id, user_id)
|
||||
|
||||
made_nominal = conn.execute(
|
||||
"SELECT DISTINCT p.id, p.title, p.workspace, p.updated_at FROM page_shares s "
|
||||
"JOIN pages p ON p.id=s.page_id "
|
||||
"WHERE s.created_by=? AND p.deleted_at IS NULL",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
made_link = conn.execute(
|
||||
f"SELECT id, title, workspace, updated_at FROM pages "
|
||||
f"WHERE share_mode='anyone' AND published=0 AND deleted_at IS NULL "
|
||||
f"AND {scope_cond}",
|
||||
scope_params,
|
||||
).fetchall()
|
||||
made_flag = conn.execute(
|
||||
f"SELECT id, title, workspace, updated_at FROM pages "
|
||||
f"WHERE (is_shared=1 OR share_mode != 'private') AND COALESCE(published,0)=0 AND deleted_at IS NULL "
|
||||
f"AND {scope_cond}",
|
||||
scope_params,
|
||||
).fetchall()
|
||||
published_rows = conn.execute(
|
||||
f"SELECT id, title, workspace, updated_at FROM pages "
|
||||
f"WHERE published=1 AND deleted_at IS NULL AND {scope_cond} "
|
||||
f"ORDER BY updated_at DESC LIMIT 20",
|
||||
scope_params,
|
||||
).fetchall()
|
||||
try:
|
||||
received_rows = conn.execute(
|
||||
"SELECT DISTINCT p.id, p.title, p.workspace, p.updated_at FROM page_shares s "
|
||||
"JOIN pages p ON p.id=s.page_id "
|
||||
"LEFT JOIN group_members gm ON gm.group_id = s.shared_with_group_id AND gm.user_id=? "
|
||||
"WHERE (s.shared_with_user_id=? OR gm.user_id=?) AND p.deleted_at IS NULL",
|
||||
(user_id, user_id, user_id),
|
||||
).fetchall()
|
||||
except Exception:
|
||||
received_rows = conn.execute(
|
||||
"SELECT DISTINCT p.id, p.title, p.workspace, p.updated_at FROM page_shares s "
|
||||
"JOIN pages p ON p.id=s.page_id "
|
||||
"WHERE s.shared_with_user_id=? AND p.deleted_at IS NULL",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
|
||||
def _entry(r, icon):
|
||||
return {
|
||||
"id": f"page/{r['id']}",
|
||||
"db_id": r["id"],
|
||||
"name": r["title"] or "New page",
|
||||
"icon": icon,
|
||||
"url": f"/pages/{r['id']}",
|
||||
"active": False,
|
||||
"indent": 0,
|
||||
"depth": 0,
|
||||
"has_children": False,
|
||||
"children": [],
|
||||
}
|
||||
|
||||
made_map = {}
|
||||
for r in (*made_nominal, *made_link, *made_flag):
|
||||
made_map.setdefault(r["id"], r)
|
||||
made_sorted = sorted(made_map.values(), key=lambda r: r["updated_at"] or "", reverse=True)[:20]
|
||||
shared_made = [_entry(r, "link") for r in made_sorted]
|
||||
received_sorted = [r for r in received_rows if r["id"] not in made_map]
|
||||
received_sorted = sorted(received_sorted, key=lambda r: r["updated_at"] or "", reverse=True)[:20]
|
||||
shared_received = [_entry(r, "users") for r in received_sorted]
|
||||
published = [_entry(r, "globe") for r in published_rows]
|
||||
shared_all = [_entry(r, "link") for r in made_sorted]
|
||||
return shared_made, shared_received, published, shared_all
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
ws_name = user.get("login", "Bruno") if user else "Bruno"
|
||||
ws_key = f"{owner}/{repo}" if owner and repo else ws_name # workspace = project context
|
||||
|
||||
# Active workspace name from cookie (for local workspace display)
|
||||
from app.routers.dashboard import WORKSPACE_COOKIE
|
||||
ws_cookie = request.cookies.get(WORKSPACE_COOKIE, "")
|
||||
active_ws_name = "Workspace"
|
||||
workspace_pages = []
|
||||
gitea_workspace = False
|
||||
gitea_owner = ""
|
||||
gitea_repo = ""
|
||||
has_active_workspace = False
|
||||
local_ws_id = 0
|
||||
|
||||
if ws_cookie and ws_cookie.startswith("gitea:"):
|
||||
# Gitea workspace: preserve context across pages. Also load the local
|
||||
# mirror workspace so it appears in "My Workspaces" in the top section
|
||||
# of the sidebar, in parallel with the Gitea repository tree.
|
||||
parts = ws_cookie.split(":", 2)
|
||||
if len(parts) >= 3:
|
||||
gitea_owner = parts[1]
|
||||
gitea_repo = parts[2]
|
||||
active_ws_name = f"{gitea_owner}/{gitea_repo}"
|
||||
gitea_workspace = True
|
||||
has_active_workspace = True
|
||||
# Get local workspace ID for mirror and load its tree
|
||||
if user:
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?",
|
||||
(user["id"], f"{gitea_owner}/{gitea_repo}", "%gitea_repo%")
|
||||
).fetchone()
|
||||
if row:
|
||||
local_ws_id = row["id"]
|
||||
workspace_pages = _load_workspace_pages(str(local_ws_id))
|
||||
except Exception:
|
||||
logger.exception("_sidebar_data")
|
||||
elif ws_cookie and user:
|
||||
try:
|
||||
wsi = int(ws_cookie)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, name, owner_id FROM workspaces WHERE id=? AND owner_id=?",
|
||||
(wsi, user["id"])
|
||||
).fetchone()
|
||||
if row:
|
||||
active_ws_name = row["name"]
|
||||
workspace_pages = _load_workspace_pages(ws_cookie)
|
||||
has_active_workspace = True
|
||||
except (ValueError, Exception):
|
||||
pass
|
||||
recent = []
|
||||
if owner and repo:
|
||||
view_map = {
|
||||
"Kanban board": "kanban", "Detailed board": "detailed",
|
||||
"Table view": "table", "Status overview": "status", "Team Load": "teamload",
|
||||
}
|
||||
first = True
|
||||
for label, view in view_map.items():
|
||||
indent = 0 if first else 1
|
||||
active = first
|
||||
recent.append({
|
||||
"id": f"{owner}/{repo}/{view}",
|
||||
"name": label, "icon": "folder" if first else "",
|
||||
"url": f"/board/{owner}/{repo}?view={view}",
|
||||
"active": active, "indent": indent,
|
||||
"depth": indent, "has_children": False, "children": [],
|
||||
})
|
||||
first = False
|
||||
# Load pages as nested tree for this project workspace
|
||||
with get_conn() as conn:
|
||||
tree_pages = _build_page_tree(conn, None, ws_key)
|
||||
for p in tree_pages:
|
||||
recent.append(p)
|
||||
# Private pages: same as recent but filtered for page/ items (non-board views)
|
||||
private_items = [r for r in recent if r.get("active") or r["id"].startswith("page/")]
|
||||
|
||||
# Load favorite pages from DB
|
||||
uid = user["id"] if user and user.get("id") else 1
|
||||
with get_conn() as conn:
|
||||
fav_rows = conn.execute(
|
||||
"SELECT p.id, p.title, p.workspace, p.updated_at FROM favorites f "
|
||||
"JOIN pages p ON p.id = f.page_id "
|
||||
"WHERE f.user_id=? ORDER BY f.position", (uid,)
|
||||
).fetchall()
|
||||
favorites = []
|
||||
for r in fav_rows:
|
||||
favorites.append({
|
||||
"id": f"page/{r['id']}",
|
||||
"db_id": r["id"],
|
||||
"name": r["title"] or "New page",
|
||||
"icon": "📄",
|
||||
"url": f"/pages/{r['id']}",
|
||||
"active": False,
|
||||
"indent": 0,
|
||||
"depth": 0,
|
||||
"has_children": False,
|
||||
"children": [],
|
||||
})
|
||||
|
||||
# Load shared pages
|
||||
shared_made_pages, shared_received_pages, published_pages, shared_pages = _load_shared_sidebar_pages(uid)
|
||||
|
||||
# Auth method & OAuth badge data
|
||||
auth_method = "local"
|
||||
gitea_linked = False
|
||||
github_linked = False
|
||||
if user and user.get("id"):
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
am_row = conn.execute("SELECT auth_method FROM users WHERE id=?", (user["id"],)).fetchone()
|
||||
if am_row and am_row["auth_method"]:
|
||||
auth_method = am_row["auth_method"]
|
||||
tokens = conn.execute(
|
||||
"SELECT provider FROM user_oauth_tokens WHERE user_id=?", (user["id"],)
|
||||
).fetchall()
|
||||
for t in tokens:
|
||||
if t["provider"] == "gitea":
|
||||
gitea_linked = True
|
||||
elif t["provider"] == "github":
|
||||
github_linked = True
|
||||
except Exception:
|
||||
logger.exception("_sidebar_data")
|
||||
|
||||
return {"workspace_name": ws_name, "workspace_initial": ws_name[0].upper() if ws_name else "B",
|
||||
"active_ws_name": active_ws_name,
|
||||
"workspace_key": f"{gitea_owner}/{gitea_repo}" if gitea_workspace else ws_key,
|
||||
"workspace_pages": workspace_pages,
|
||||
"gitea_workspace": gitea_workspace,
|
||||
"gitea_owner": gitea_owner,
|
||||
"gitea_repo": gitea_repo,
|
||||
"local_ws_id": local_ws_id,
|
||||
"current_page": repo or "Dashboard", "last_edited": "now",
|
||||
"recent_pages": recent, "private_pages": private_items,
|
||||
"favorite_pages": favorites, "shared_pages": shared_pages,
|
||||
"shared_made_pages": shared_made_pages,
|
||||
"shared_received_pages": shared_received_pages,
|
||||
"published_pages": published_pages,
|
||||
"user": user,
|
||||
"auth_method": auth_method,
|
||||
"gitea_linked": gitea_linked,
|
||||
"github_linked": github_linked,
|
||||
"has_active_workspace": has_active_workspace,
|
||||
"app_version": _get_app_version(),
|
||||
"local_workspaces": _local_workspaces_for_user(user),
|
||||
"sidebar_config": get_sidebar_config_sync(uid)}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _extract_ai_keywords(owner: str, repo: str, labels: list[dict], body: str = ""):
|
||||
"""Extract and persist AI keywords from issue labels and body."""
|
||||
if not owner or not repo:
|
||||
return
|
||||
candidates = set()
|
||||
for lbl in labels:
|
||||
name = lbl.get("name", "").strip().lower()
|
||||
if name and len(name) > 1:
|
||||
candidates.add(name)
|
||||
# Simple extraction from body: single words > 3 chars
|
||||
for word in re.findall(r'\b[a-zA-Z]{4,}\b', body.lower()):
|
||||
if word not in ("this", "that", "with", "from", "have", "when", "will"):
|
||||
candidates.add(word)
|
||||
|
||||
with get_conn() as conn:
|
||||
for kw in candidates:
|
||||
kw = kw[:30]
|
||||
existing = conn.execute(
|
||||
"SELECT id, usage_count FROM ai_keywords WHERE project_owner=? AND project_name=? AND keyword=?",
|
||||
(owner, repo, kw),
|
||||
).fetchone()
|
||||
if existing:
|
||||
conn.execute("UPDATE ai_keywords SET usage_count=? WHERE id=?",
|
||||
(existing["usage_count"] + 1, existing["id"]))
|
||||
else:
|
||||
color_idx = len(candidates) % len(AI_KEYWORD_COLORS)
|
||||
conn.execute(
|
||||
"INSERT INTO ai_keywords (project_owner, project_name, keyword, color) VALUES (?,?,?,?)",
|
||||
(owner, repo, kw, AI_KEYWORD_COLORS[color_idx]),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _get_project_properties(owner: str, repo: str) -> list[dict]:
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM project_properties WHERE project_owner=? AND project_name=? ORDER BY position",
|
||||
(owner, repo),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
async def asyncio_get_labels(owner: str, repo: str):
|
||||
return await gitea.get_labels(owner, repo)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _apply_filters(cards: list[dict], status_filter: str, filters: str) -> list[dict]:
|
||||
if status_filter:
|
||||
allowed = set(status_filter.split(","))
|
||||
cards = [c for c in cards if c["status"] in allowed]
|
||||
if filters:
|
||||
for f in filters.split(","):
|
||||
if ":" in f:
|
||||
prop, val = f.split(":", 1)
|
||||
val_lower = val.lower()
|
||||
if prop == "assignee":
|
||||
cards = [c for c in cards if c.get("assignee", "").lower() == val_lower]
|
||||
elif prop == "tag":
|
||||
cards = [c for c in cards if (c.get("tag") or "").lower() == val_lower]
|
||||
elif prop == "keyword":
|
||||
cards = [c for c in cards if any(val_lower in kw.get("name", "").lower() for kw in c.get("keywords", []))]
|
||||
return cards
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _apply_sorts(cards: list[dict], sorts: str) -> list[dict]:
|
||||
if not sorts:
|
||||
return cards
|
||||
order = {"todo": 0, "progress": 1, "done": 2}
|
||||
for spec in reversed(sorts.split(",")):
|
||||
if ":" not in spec:
|
||||
continue
|
||||
field, direction = spec.split(":", 1)
|
||||
rev = direction == "desc"
|
||||
if field == "name":
|
||||
cards.sort(key=lambda c: c["title"].lower(), reverse=rev)
|
||||
elif field == "status":
|
||||
cards.sort(key=lambda c: order.get(c["status"], 0), reverse=rev)
|
||||
elif field == "assignee":
|
||||
cards.sort(key=lambda c: c.get("assignee", "").lower(), reverse=rev)
|
||||
elif field == "deadline":
|
||||
cards.sort(key=lambda c: c.get("due_date", ""), reverse=rev)
|
||||
return cards
|
||||
|
||||
|
||||
# ═══════════ Library page ═══════════
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _record_version(conn, page_id: int, user_id, title: str, blocks_json: str) -> None:
|
||||
"""Insert a version snapshot unless it is byte-identical to the latest one."""
|
||||
prev = conn.execute(
|
||||
"SELECT COALESCE(title, ''), blocks_json FROM page_versions "
|
||||
"WHERE page_id=? ORDER BY id DESC LIMIT 1",
|
||||
(page_id,),
|
||||
).fetchone()
|
||||
if prev is not None and prev["blocks_json"] == blocks_json:
|
||||
if prev["title"] != (title or ""):
|
||||
conn.execute(
|
||||
"UPDATE page_versions SET title=? WHERE id="
|
||||
"(SELECT id FROM page_versions WHERE page_id=? ORDER BY id DESC LIMIT 1)",
|
||||
(title or "", page_id),
|
||||
)
|
||||
return
|
||||
conn.execute(
|
||||
"INSERT INTO page_versions (page_id, user_id, title, blocks_json, note) VALUES (?,?,?,?,'edited')",
|
||||
(page_id, user_id, title or "", blocks_json),
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _block_texts(b: dict) -> list[str]:
|
||||
"""Flatten a block (including children) into searchable text chunks."""
|
||||
out = []
|
||||
raw = b.get("content")
|
||||
if isinstance(raw, str) and raw.strip():
|
||||
out.append(raw)
|
||||
for child in b.get("children") or []:
|
||||
out.extend(_block_texts(child))
|
||||
return out
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ v5.5.0: Cover & icon ═══════════
|
||||
|
||||
|
||||
def _upload_root() -> Path:
|
||||
return Path(settings.data_dir)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _ws_id_for(request: Request, page_id: int) -> int:
|
||||
"""The active workspace id for the page (cookie, then page, then fallback 1)."""
|
||||
cookie = request.cookies.get("flowdeck_workspace", "")
|
||||
try:
|
||||
ws_id = int(cookie)
|
||||
if ws_id > 0:
|
||||
return ws_id
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT workspace_id FROM pages WHERE id=?", (page_id,)
|
||||
).fetchone()
|
||||
if row and row["workspace_id"]:
|
||||
return int(row["workspace_id"])
|
||||
return 1
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
async def _store_uploaded_file(request: Request, ws_id: int) -> dict:
|
||||
"""Persist an uploaded file under uploads/workspace_{ws_id}/ and return
|
||||
{file_url, file_path, mime_type, size, file_name}."""
|
||||
import datetime
|
||||
import re as _re
|
||||
|
||||
form = await request.form()
|
||||
upload = form.get("file")
|
||||
if upload is None or not hasattr(upload, "filename"):
|
||||
raise HTTPException(400, "file field required")
|
||||
original = (upload.filename or "cover.png").replace("\\", "/").rsplit("/", 1)[-1]
|
||||
name = _re.sub(r"[^A-Za-z0-9._-]", "_", original)[:120]
|
||||
ext = name.rsplit(".", 1)[-1].lower() if "." in name else "bin"
|
||||
if ext not in {"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif"}:
|
||||
raise HTTPException(400, "Unsupported image format")
|
||||
stamp = datetime.datetime.now(datetime.UTC).replace(tzinfo=None).strftime("%Y%m%d%H%M%S")
|
||||
folder = _upload_root() / f"uploads/workspace_{ws_id}"
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
final = f"{stamp}_{name}"
|
||||
(folder / final).write_bytes(await upload.read())
|
||||
mime = f"image/{'svg+xml' if ext == 'svg' else 'jpeg' if ext == 'jpg' else ext}"
|
||||
return {
|
||||
"file_url": f"/api/files/{ws_id}/{final}",
|
||||
"file_path": f"uploads/workspace_{ws_id}/{final}",
|
||||
"mime_type": mime,
|
||||
"size": (folder / final).stat().st_size,
|
||||
"file_name": name,
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ v5.4.0: Import (Markdown / .md / Notion .zip) ═══════════
|
||||
|
||||
|
||||
async def _create_page_from_markdown(request: Request, markdown: str, title: str = "") -> int:
|
||||
"""Convert markdown → blocks (server-side, same mapping as the editor) and
|
||||
create a page in the caller's workspace."""
|
||||
from app.services.export import _md_to_blocks
|
||||
|
||||
blocks = _md_to_blocks(markdown or "")
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
ws_key = user.get("login", "Bruno") if user else "Bruno"
|
||||
file_title = title.strip() or "Import"
|
||||
fallback = (file_title or "Imported page").replace("/", "-").replace("\\", "-")[:120]
|
||||
if not blocks:
|
||||
blocks = [{"type": "paragraph", "content": markdown or ""}]
|
||||
with get_conn() as conn:
|
||||
next_order = conn.execute(
|
||||
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace=? AND parent_id IS NULL",
|
||||
(ws_key,),
|
||||
).fetchone()[0]
|
||||
cur = conn.execute(
|
||||
"INSERT INTO pages (workspace, title, content, content_format, parent_section, sort_order, workspace_id) "
|
||||
"VALUES (?,?,?,?,'Private',?,(SELECT id FROM workspaces WHERE name=? LIMIT 1))",
|
||||
(ws_key, fallback, json.dumps(blocks), "blocks", next_order, ws_key),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
async def _unfurl_repo(forge: str, owner: str, repo: str):
|
||||
"""Resolve a ``gitea:owner/repo`` / ``github:owner/repo`` ref."""
|
||||
try:
|
||||
if forge == "gitea":
|
||||
from app.services.gitea_client import GiteaClient
|
||||
info = await GiteaClient().get_repo_info(owner, repo)
|
||||
site = "Gitea"
|
||||
else:
|
||||
from app.config import settings
|
||||
from app.services.github_adapter import GitHubAdapter
|
||||
token = getattr(settings, "github_token", None) or ""
|
||||
if token:
|
||||
info = await GitHubAdapter(access_token=token).get_repo_info(owner, repo)
|
||||
else:
|
||||
async with shared_client(timeout=10) as client:
|
||||
r = await client.get(
|
||||
f"https://api.github.com/repos/{owner}/{repo}",
|
||||
headers={"Accept": "application/vnd.github+json"},
|
||||
)
|
||||
r.raise_for_status()
|
||||
info = r.json()
|
||||
site = "GitHub"
|
||||
except Exception as exc: # noqa: BLE001 — forge lookup is best-effort
|
||||
logging.getLogger(__name__).debug("unfurl %s failed: %s", forge, exc)
|
||||
return None
|
||||
branch = info.get("default_branch") or "main"
|
||||
return {
|
||||
"url": info.get("html_url") or f"https://{forge}.com/{owner}/{repo}",
|
||||
"title": info.get("full_name") or f"{owner}/{repo}",
|
||||
"description": (info.get("description") or f"{site} repository "
|
||||
f"{owner}/{repo} · default branch: {branch}"),
|
||||
"image": "",
|
||||
"site_name": site,
|
||||
"language": info.get("language") or "",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
"""FlowDeck — Board : board_views.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.gitea_client import gitea
|
||||
|
||||
from ._common import (
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
_apply_filters,
|
||||
_apply_sorts,
|
||||
_extract_ai_keywords,
|
||||
_get_project_properties,
|
||||
_map_issue_to_card,
|
||||
_sidebar_data,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["board"], prefix="/board")
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ Board page ═══════════
|
||||
|
||||
@router.get("/{owner}/{repo}", response_class=HTMLResponse)
|
||||
def board(request: Request, owner: str, repo: str, view: str = Query(default="")):
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, owner, repo)
|
||||
template = env.get_template("board.html")
|
||||
return template.render(request=request, owner=owner, repo=repo, groups=[],
|
||||
initial_view=view, **sidebar)
|
||||
|
||||
|
||||
# ═══════════ View fragments ═══════════
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ View fragments ═══════════
|
||||
|
||||
@router.get("/{owner}/{repo}/view/{view}", response_class=HTMLResponse)
|
||||
async def board_view(
|
||||
request: Request, owner: str, repo: str, view: str,
|
||||
status: str = Query(default=""),
|
||||
filter: str = Query(default=""),
|
||||
sort: str = Query(default=""),
|
||||
):
|
||||
try:
|
||||
issues = await gitea.get_issues(owner, repo, state="all")
|
||||
issues_only = [i for i in issues if not i.get("pull_request")]
|
||||
cards = [_map_issue_to_card(i, owner, repo) for i in issues_only]
|
||||
cards = _apply_filters(cards, status, filter)
|
||||
cards = _apply_sorts(cards, sort)
|
||||
except Exception as e:
|
||||
logger.error("Board view error: %s", e)
|
||||
cards = []
|
||||
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
|
||||
# Dynamic groups from Gitea labels (fallback to hardcoded)
|
||||
group_names = ["Design", "Engineering", "No Team"]
|
||||
groups = []
|
||||
for gname in group_names:
|
||||
gid = gname.lower().replace(" ", "-")
|
||||
gcards = cards
|
||||
groups.append({
|
||||
"id": gid, "name": gname,
|
||||
"counts": {
|
||||
"todo": len([c for c in gcards if c["status"] == "todo"]),
|
||||
"progress": len([c for c in gcards if c["status"] == "progress"]),
|
||||
"done": len([c for c in gcards if c["status"] == "done"]),
|
||||
},
|
||||
"cards": gcards,
|
||||
})
|
||||
|
||||
ctx = {"owner": owner, "repo": repo, "groups": groups, "cards": cards}
|
||||
|
||||
template_map = {
|
||||
"table": "table_view.html",
|
||||
"status": "status_overview.html",
|
||||
"teamload": "team_load.html",
|
||||
"detailed": "detailed_board.html",
|
||||
}
|
||||
|
||||
if view == "table":
|
||||
grouped = {g["name"]: g["cards"] for g in groups}
|
||||
ctx["grouped_cards"] = grouped
|
||||
elif view == "status":
|
||||
counts = {"todo": 0, "progress": 0, "done": 0}
|
||||
for c in cards:
|
||||
if c["status"] in counts:
|
||||
counts[c["status"]] += 1
|
||||
ctx.update(status_data=counts, status_colors=STATUS_COLORS, status_labels=STATUS_LABELS)
|
||||
elif view == "teamload":
|
||||
members = {}
|
||||
for c in cards:
|
||||
name = c.get("assignee") or "Unassigned"
|
||||
if name not in members:
|
||||
members[name] = {"name": name, "initial": name[0].upper(),
|
||||
"todo": 0, "progress": 0, "complete": 0, "total": 0}
|
||||
sk = c["status"] if c["status"] in ("todo", "progress") else "complete"
|
||||
members[name][sk] += 1
|
||||
members[name]["total"] += 1
|
||||
ctx["team_data"] = list(members.values())
|
||||
elif view == "detailed":
|
||||
pass
|
||||
else:
|
||||
template_map["kanban"] = "board_fragment.html"
|
||||
|
||||
template_name = template_map.get(view, "board_fragment.html")
|
||||
template = env.get_template(template_name)
|
||||
return template.render(**ctx)
|
||||
|
||||
|
||||
# ═══════════ v0.9.0: Custom Properties API ═══════════
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ v0.9.0: Custom Properties API ═══════════
|
||||
|
||||
@router.get("/api/properties/{owner}/{repo}")
|
||||
def get_properties(owner: str, repo: str):
|
||||
return {"properties": _get_project_properties(owner, repo)}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/properties/{owner}/{repo}")
|
||||
def create_property(owner: str, repo: str, name: str = Query(...),
|
||||
prop_type: str = Query(default="select"),
|
||||
options: str = Query(default="")):
|
||||
opts = json.dumps([o.strip() for o in options.split(",") if o.strip()])
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO project_properties (project_owner, project_name, name, prop_type, options_json) VALUES (?,?,?,?,?)",
|
||||
(owner, repo, name, prop_type, opts),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
raise HTTPException(409, f"Property already exists: {e}") from e
|
||||
return {"status": "ok", "name": name, "type": prop_type}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/api/properties/{owner}/{repo}")
|
||||
def delete_property(owner: str, repo: str, name: str = Query(...)):
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM project_properties WHERE project_owner=? AND project_name=? AND name=?",
|
||||
(owner, repo, name),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/properties/{owner}/{repo}/values")
|
||||
def set_property_value(owner: str, repo: str, issue_id: int = Query(...),
|
||||
name: str = Query(...), value: str = Query(default="")):
|
||||
with get_conn() as conn:
|
||||
prop = conn.execute(
|
||||
"SELECT id FROM project_properties WHERE project_owner=? AND project_name=? AND name=?",
|
||||
(owner, repo, name),
|
||||
).fetchone()
|
||||
if not prop:
|
||||
raise HTTPException(404, f"Property '{name}' not found")
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO property_values (property_id, gitea_issue_id, value) VALUES (?,?,?)",
|
||||
(prop["id"], issue_id, value),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ═══════════ v0.9.0: AI Keywords API ═══════════
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ v0.9.0: AI Keywords API ═══════════
|
||||
|
||||
@router.get("/api/ai-keywords/{owner}/{repo}")
|
||||
def get_ai_keywords(owner: str, repo: str):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT keyword, color, usage_count FROM ai_keywords WHERE project_owner=? AND project_name=? ORDER BY usage_count DESC LIMIT 30",
|
||||
(owner, repo),
|
||||
).fetchall()
|
||||
return {"keywords": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/ai-keywords/{owner}/{repo}/extract")
|
||||
async def extract_ai_keywords(owner: str, repo: str):
|
||||
"""Re-extract keywords from all issues in the repo."""
|
||||
try:
|
||||
issues = await gitea.get_issues(owner, repo, state="all")
|
||||
for issue in issues:
|
||||
if not issue.get("pull_request"):
|
||||
_extract_ai_keywords(owner, repo, issue.get("labels", []), issue.get("body", ""))
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e)) from e
|
||||
return {"status": "ok", "issues_scanned": len(issues)}
|
||||
|
||||
|
||||
# ═══════════ Pages Markdown ═══════════
|
||||
@@ -0,0 +1,64 @@
|
||||
"""FlowDeck — Board : embed.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.config import settings
|
||||
|
||||
from ._common import _REPO_REF_RE, _unfurl_repo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["board"], prefix="/board")
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/og/metadata")
|
||||
async def og_metadata(request: Request):
|
||||
"""v5.5.0: Open Graph metadata for a bookmark card.
|
||||
|
||||
v7.3.0: ``gitea:owner/repo`` and ``github:owner/repo`` schemes are
|
||||
unfurled straight from the forge API (no HTTP fetch of the HTML page).
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid JSON body") from None
|
||||
url = (body.get("url") or "").strip()
|
||||
if not url:
|
||||
raise HTTPException(400, "url required")
|
||||
m = _REPO_REF_RE.match(url)
|
||||
if m:
|
||||
forge, owner, repo = m.group(1).lower(), m.group(2), m.group(3)
|
||||
data = await _unfurl_repo(forge, owner, repo)
|
||||
if data:
|
||||
return {"ok": True, **data}
|
||||
from app.services.og_fetcher import fetch_og_metadata
|
||||
try:
|
||||
data = await fetch_og_metadata(url)
|
||||
except ValueError as exc:
|
||||
# A12 : SSRF — URL vers un hôte privé/loopback (ou redirection vers l'un).
|
||||
raise HTTPException(400, str(exc)) from None
|
||||
return {"ok": True, **data}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/embed/resolve")
|
||||
def resolve_embed(request: Request, body: dict = Body(...)):
|
||||
"""v5.5.0: rewrite a pasted URL to its provider embed src.
|
||||
|
||||
Powers the universal ``/embed`` block (YouTube, Vimeo, Figma, Maps,
|
||||
Docs, Loom, CodePen, Miro, Spotify, SoundCloud, Twitch, X/Twitter…).
|
||||
"""
|
||||
url = (body.get("url") or "").strip()
|
||||
if not url:
|
||||
raise HTTPException(400, "url required")
|
||||
from app.services.embeds import resolve_embed as _resolve
|
||||
data = _resolve(url, parent=settings.app_base_url)
|
||||
return {"ok": True, "url": url, **data}
|
||||
@@ -0,0 +1,85 @@
|
||||
"""FlowDeck — Board : import_.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.services.automations import fire_event
|
||||
|
||||
from ._common import _create_page_from_markdown
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["board"], prefix="/board")
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/pages/import")
|
||||
async def import_page(request: Request):
|
||||
"""v5.4.0: import markdown text as a new page (blocks) in the workspace."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid JSON body") from None
|
||||
markdown = body.get("markdown", "")
|
||||
title = body.get("title", "")
|
||||
if not markdown and not body.get("csv"):
|
||||
raise HTTPException(400, "markdown field required")
|
||||
if not markdown.strip():
|
||||
raise HTTPException(400, "markdown is empty")
|
||||
page_id = await _create_page_from_markdown(request, markdown, title)
|
||||
await fire_event("page.created", {"page_id": page_id, "title": title or "Import",
|
||||
"workspace": ""})
|
||||
return {"status": "ok", "id": page_id}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/pages/import/file")
|
||||
async def import_file(request: Request):
|
||||
"""v5.4.0: import an uploaded .md file (or a Notion export .zip containing
|
||||
markdown pages) into the workspace. Returns the created page ids."""
|
||||
import io as _io
|
||||
import zipfile
|
||||
|
||||
form = await request.form()
|
||||
upload = form.get("file")
|
||||
if upload is None or not hasattr(upload, "filename"):
|
||||
raise HTTPException(400, "file field required")
|
||||
filename = (upload.filename or "import.md").replace("\\", "/").rsplit("/", 1)[-1]
|
||||
data = await upload.read()
|
||||
created_ids = []
|
||||
|
||||
if filename.lower().endswith(".zip"):
|
||||
try:
|
||||
zf = zipfile.ZipFile(_io.BytesIO(data))
|
||||
except zipfile.BadZipFile:
|
||||
raise HTTPException(400, "Invalid zip archive") from None
|
||||
md_entries = sorted(
|
||||
(n for n in zf.namelist() if n.lower().endswith((".md", ".markdown"))),
|
||||
key=lambda n: (n.count("/"), n.lower()),
|
||||
)
|
||||
if not md_entries:
|
||||
raise HTTPException(400, "No .md files found in archive")
|
||||
for name in md_entries:
|
||||
raw = zf.read(name).decode("utf-8", errors="replace")
|
||||
title = name.replace("\\", "/").rsplit("/", 1)[-1][:-3]
|
||||
try:
|
||||
created_ids.append(await _create_page_from_markdown(request, raw, title))
|
||||
except Exception as exc: # noqa: BLE001 - keep importing the rest
|
||||
logger.warning("import failed for %s: %s", name, exc)
|
||||
else:
|
||||
try:
|
||||
raw = data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
raise HTTPException(400, "Only text/markdown files are supported") from None
|
||||
title = filename.replace(".md", "").replace(".markdown", "").replace(".txt", "")
|
||||
created_ids.append(await _create_page_from_markdown(request, raw, title))
|
||||
|
||||
if not created_ids:
|
||||
raise HTTPException(422, "No pages could be imported")
|
||||
return {"status": "ok", "ids": created_ids, "count": len(created_ids)}
|
||||
@@ -0,0 +1,66 @@
|
||||
"""FlowDeck — Board : library.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Query, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
from ._common import _sidebar_data
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["board"], prefix="/board")
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ Library page ═══════════
|
||||
|
||||
@router.get("/library", response_class=HTMLResponse)
|
||||
def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
|
||||
"""Library page avec onglets Recents, Favorites, Shared, Private — scopé au workspace."""
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, owner, repo)
|
||||
# Load all pages for the workspace from DB
|
||||
ws_key = f"{owner}/{repo}" if owner and repo else ""
|
||||
with get_conn() as conn:
|
||||
if ws_key:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, workspace, updated_at FROM pages "
|
||||
"WHERE workspace=? AND collection_row_id IS NULL ORDER BY updated_at DESC",
|
||||
(ws_key,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, workspace, updated_at FROM pages "
|
||||
"WHERE collection_row_id IS NULL ORDER BY updated_at DESC",
|
||||
).fetchall()
|
||||
all_pages = []
|
||||
for r in rows:
|
||||
page = dict(r)
|
||||
all_pages.append({
|
||||
"id": f"page/{page['id']}",
|
||||
"name": page["title"] or "Untitled",
|
||||
"icon": "📄",
|
||||
"url": f"/pages/{page['id']}",
|
||||
"created_by": "You",
|
||||
"source": page.get("workspace") or "Private",
|
||||
"last_edited": page.get("updated_at", "now"),
|
||||
"last_visited": page.get("updated_at", "now"),
|
||||
})
|
||||
sidebar["recent_pages"] = all_pages
|
||||
sidebar["favorite_pages"] = []
|
||||
sidebar["private_pages"] = [p for p in all_pages if p.get("source") == "🔒 Private"]
|
||||
sidebar["shared_pages"] = []
|
||||
sidebar["shared_made_pages"] = []
|
||||
sidebar["shared_received_pages"] = []
|
||||
template = env.get_template("library.html")
|
||||
return template.render(**sidebar)
|
||||
|
||||
# ═══════════ Favorites API ═══════════
|
||||
@@ -0,0 +1,229 @@
|
||||
"""FlowDeck — Board : page_api.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event, run_event_sync
|
||||
|
||||
from ._common import _ensure_block_ids
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["board"], prefix="/board")
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/lock")
|
||||
def set_page_lock(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
"""v5.12.0: lock/unlock a page (read-only for everyone except the locker,
|
||||
admins and the page creator)."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
locked = bool(body.get("locked"))
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id, is_locked, locked_by, workspace FROM pages WHERE id=?",
|
||||
(page_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Page not found")
|
||||
is_admin = 1 if user.get("is_admin") else 0
|
||||
if row["is_locked"] and not is_admin and row["locked_by"] != user["id"]:
|
||||
raise HTTPException(403, "Only the person who locked this page (or an admin) can unlock it")
|
||||
conn.execute("UPDATE pages SET is_locked=?, locked_by=? WHERE id=?",
|
||||
(1 if locked else 0, user["id"] if locked else None, page_id))
|
||||
conn.commit()
|
||||
run_event_sync(fire_event("page.locked" if locked else "page.unlocked",
|
||||
{"page_id": page_id, "by": user["id"]}))
|
||||
return {"status": "ok", "is_locked": int(locked)}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/options")
|
||||
def set_page_options(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
"""v5.12.0: page layout options — full-width and compact typography."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
updates = {}
|
||||
for key in ("full_width", "font_small"):
|
||||
if key in body:
|
||||
updates[key] = 1 if body[key] else 0
|
||||
if not updates:
|
||||
raise HTTPException(400, "nothing to update")
|
||||
sets = ", ".join(f"{k}=?" for k in updates)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Page not found")
|
||||
conn.execute(f"UPDATE pages SET {sets}, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(*updates.values(), page_id))
|
||||
conn.commit()
|
||||
return {"status": "ok", **{k: bool(v) for k, v in updates.items()}}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/page-templates")
|
||||
def list_page_templates_api(request: Request):
|
||||
"""v5.12.0: built-in + user global page templates for the picker."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = (user or {}).get("id")
|
||||
from app.services.block_templates import template_list
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT id, name, icon, description, created_by
|
||||
FROM page_global_templates
|
||||
WHERE created_by IS NULL OR created_by=?
|
||||
ORDER BY created_at""",
|
||||
(uid,),
|
||||
).fetchall()
|
||||
mine = [dict(r) for r in rows]
|
||||
for t in mine:
|
||||
t["builtin"] = False
|
||||
return {"templates": template_list() + mine}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/page-templates")
|
||||
def create_page_template(request: Request, body: dict = Body(default={})):
|
||||
"""v5.12.0: save the current page (or a raw block list) as a personal
|
||||
global template: {name, icon?, description?, page_id? | blocks?}."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "name is required")
|
||||
blocks = body.get("blocks")
|
||||
if body.get("page_id"):
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT content, content_format FROM pages WHERE id=?",
|
||||
(int(body["page_id"]),)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Page not found")
|
||||
if row["content_format"] == "blocks" and row["content"]:
|
||||
try:
|
||||
blocks = json.loads(row["content"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
raise HTTPException(400, "Page content is not block JSON") from None
|
||||
if not isinstance(blocks, list) or not blocks:
|
||||
raise HTTPException(400, "blocks (or page_id) required")
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO page_global_templates (name, icon, description, blocks_json, created_by)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
(name, body.get("icon") or "📄", body.get("description") or "",
|
||||
json.dumps(blocks), user["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
tid = cur.lastrowid
|
||||
return {"status": "ok", "id": tid}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/page-templates/{template_id}/use")
|
||||
def use_page_template(request: Request, template_id: int, body: dict = Body(default={})):
|
||||
"""v5.12.0: instantiate a page from a template (built-in or user).
|
||||
|
||||
Body: {key?} for built-ins OR uses the row id for user templates.
|
||||
Creates 'blocks'-format page in the caller's workspace and returns its id.
|
||||
"""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
title = (body.get("title") or "").strip()
|
||||
blocks_json = None
|
||||
if template_id == 0:
|
||||
from app.services.block_templates import blocks_json_for
|
||||
key = body.get("key") or "empty"
|
||||
blocks_json = blocks_json_for(key)
|
||||
name = key
|
||||
if blocks_json is None:
|
||||
raise HTTPException(404, "Unknown built-in template")
|
||||
else:
|
||||
with get_conn() as conn:
|
||||
uid = (user or {}).get("id")
|
||||
row = conn.execute(
|
||||
"SELECT * FROM page_global_templates WHERE id=? AND (created_by IS NULL OR created_by=?)",
|
||||
(template_id, uid),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Template not found")
|
||||
blocks_json = row["blocks_json"]
|
||||
name = row["name"]
|
||||
title = title or row["name"]
|
||||
# Resolve the target workspace so the new page actually shows up in the
|
||||
# active local workspace (bugfix: template pages previously got
|
||||
# workspace_id = NULL and never appeared in the sidebar/tree).
|
||||
uid = (user or {}).get("id")
|
||||
ws_id_raw = body.get("workspace_id")
|
||||
ws_id = None
|
||||
if ws_id_raw is not None:
|
||||
try:
|
||||
ws_id = int(ws_id_raw)
|
||||
except (TypeError, ValueError):
|
||||
ws_id = None
|
||||
ws_key = user.get("login", "Bruno") if user else "Bruno"
|
||||
if ws_id is not None:
|
||||
with get_conn() as conn:
|
||||
ws_row = conn.execute(
|
||||
"SELECT id, name, owner_id FROM workspaces WHERE id=?", (ws_id,)
|
||||
).fetchone()
|
||||
if ws_row and (uid is None or ws_row["owner_id"] == uid):
|
||||
ws_key = ws_row["name"] or ws_key
|
||||
else:
|
||||
ws_id = None
|
||||
else:
|
||||
body_ws = (body.get("workspace") or "").strip()
|
||||
if body_ws:
|
||||
ws_key = body_ws
|
||||
# Optional target folder: instantiate the template as a child of it.
|
||||
parent_id = body.get("parent_id")
|
||||
try:
|
||||
parent_id = int(parent_id) if parent_id not in (None, "", 0, "0") else None
|
||||
except (TypeError, ValueError):
|
||||
parent_id = None
|
||||
try:
|
||||
parsed_blocks = json.loads(blocks_json)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
raise HTTPException(500, "Template content corrupted") from None
|
||||
_ensure_block_ids(parsed_blocks)
|
||||
blocks_json = json.dumps(parsed_blocks)
|
||||
with get_conn() as conn:
|
||||
if parent_id is not None:
|
||||
next_order = conn.execute(
|
||||
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE parent_id=?",
|
||||
(parent_id,),
|
||||
).fetchone()[0]
|
||||
elif ws_id is not None:
|
||||
next_order = conn.execute(
|
||||
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace_id=? AND parent_id IS NULL",
|
||||
(ws_id,),
|
||||
).fetchone()[0]
|
||||
else:
|
||||
next_order = conn.execute(
|
||||
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace=? AND parent_id IS NULL",
|
||||
(ws_key,),
|
||||
).fetchone()[0]
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id, sort_order)
|
||||
VALUES (?,?,?,?,?, 'Private', ?, ?)""",
|
||||
(ws_key, ws_id, title or name, blocks_json, "blocks", parent_id, next_order),
|
||||
)
|
||||
conn.commit()
|
||||
page_id = cur.lastrowid
|
||||
run_event_sync(fire_event("page.created", {"page_id": page_id, "title": title or name,
|
||||
"workspace": ws_key, "from_template": name}))
|
||||
return {"status": "ok", "id": page_id, "title": title or name}
|
||||
|
||||
|
||||
# ── Core helpers ──
|
||||
@@ -0,0 +1,122 @@
|
||||
"""FlowDeck — Board : page_media.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event, run_event_sync
|
||||
|
||||
from ._common import _store_uploaded_file, _ws_id_for
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["board"], prefix="/board")
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ v5.4.0: Page & collection duplication ═══════════
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/duplicate")
|
||||
def duplicate_page(request: Request, page_id: int):
|
||||
"""Duplicate a page (block/markdown content included) as a sibling."""
|
||||
SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM pages WHERE id=? AND deleted_at IS NULL", (page_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Page not found")
|
||||
page = dict(row)
|
||||
|
||||
def copy_tree(src_id: int, parent_id) -> int:
|
||||
with get_conn() as conn:
|
||||
conn.execute("SELECT * FROM pages WHERE id=?", (src_id,)).fetchone()
|
||||
cur = conn.execute(
|
||||
"INSERT INTO pages (workspace, workspace_id, title, content, content_format, "
|
||||
"parent_section, parent_id, sort_order, share_mode, published, is_published, "
|
||||
"publish_slug, is_shared, cover_url, page_icon, created_at, updated_at) "
|
||||
"SELECT workspace, workspace_id, title || ' copy', content, content_format, "
|
||||
"parent_section, ?, sort_order, share_mode, 0, is_published, '', is_shared, "
|
||||
"cover_url, page_icon, created_at, updated_at FROM pages WHERE id=?",
|
||||
(parent_id, src_id),
|
||||
)
|
||||
new_id = cur.lastrowid
|
||||
conn.commit()
|
||||
for child in conn.execute(
|
||||
"SELECT id FROM pages WHERE parent_id=? ", (src_id,)
|
||||
).fetchall():
|
||||
copy_tree(child["id"], new_id)
|
||||
return new_id
|
||||
|
||||
new_id = copy_tree(page_id, page.get("parent_id"))
|
||||
title = (page.get("title") or "Untitled") + " copy"
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET title=? WHERE id=?", (title, new_id))
|
||||
conn.commit()
|
||||
run_event_sync(fire_event("page.created", {"page_id": new_id, "title": title,
|
||||
"workspace": page.get("workspace")}))
|
||||
return {"status": "ok", "id": new_id, "title": title}
|
||||
|
||||
|
||||
# ═══════════ v5.5.0: Cover & icon ═══════════
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/cover")
|
||||
async def set_page_cover(request: Request, page_id: int):
|
||||
"""v5.5.0: upload an image cover for a page.
|
||||
|
||||
JSON body {cover_url} accepts an external URL; multipart ``file`` uploads
|
||||
an image stored in the workspace's uploads directory.
|
||||
"""
|
||||
ctype = (request.headers.get("content-type") or "").lower()
|
||||
if ctype.startswith("application/json"):
|
||||
body = await request.json()
|
||||
cover_url = (body.get("cover_url") or "").strip()
|
||||
if not cover_url:
|
||||
raise HTTPException(400, "cover_url required")
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET cover_url=? WHERE id=?", (cover_url, page_id))
|
||||
conn.commit()
|
||||
return {"status": "ok", "page_id": page_id, "cover_url": cover_url}
|
||||
ws_id = _ws_id_for(request, page_id)
|
||||
meta = await _store_uploaded_file(request, ws_id)
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET cover_url=? WHERE id=?", (meta["file_url"], page_id))
|
||||
conn.commit()
|
||||
return {"status": "ok", "page_id": page_id, "cover_url": meta["file_url"]}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/api/pages/{page_id}/cover")
|
||||
def remove_page_cover(request: Request, page_id: int):
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET cover_url='' WHERE id=?", (page_id,))
|
||||
conn.commit()
|
||||
return {"status": "ok", "page_id": page_id}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/icon")
|
||||
def set_page_icon(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
"""v5.5.0: set a page emoji/icon label (or a custom-emoji image URL)."""
|
||||
icon = (body.get("icon") or "").strip()
|
||||
if len(icon) > 512:
|
||||
raise HTTPException(400, "icon too long")
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET page_icon=? WHERE id=?", (icon, page_id))
|
||||
conn.commit()
|
||||
return {"status": "ok", "page_id": page_id, "icon": icon}
|
||||
|
||||
|
||||
# ═══════════ v5.4.0: Import (Markdown / .md / Notion .zip) ═══════════
|
||||
@@ -0,0 +1,176 @@
|
||||
"""FlowDeck — Board : page_ops.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event, run_event_sync
|
||||
from app.services.permission_manager import PermissionManager
|
||||
|
||||
from ._common import _sidebar_data
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["board"], prefix="/board")
|
||||
|
||||
|
||||
|
||||
|
||||
@router.put("/api/pages/{page_id}/move")
|
||||
def move_page(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
"""Move a page to another workspace or reorder within tree.
|
||||
|
||||
Body (JSON): { workspace_id?: int, parent_id?: int, new_order?: int }
|
||||
- workspace_id: move page to a different workspace
|
||||
- parent_id: change parent (0 = root level)
|
||||
- new_order: position among siblings (0 = append)
|
||||
"""
|
||||
new_ws_id = body.get("workspace_id")
|
||||
new_parent_id = body.get("parent_id", 0)
|
||||
new_order = body.get("new_order", 0)
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id, workspace, workspace_id FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Page not found")
|
||||
|
||||
if new_ws_id:
|
||||
# Move to a different workspace: get the workspace name
|
||||
ws_row = conn.execute("SELECT name FROM workspaces WHERE id=?", (new_ws_id,)).fetchone()
|
||||
if not ws_row:
|
||||
return JSONResponse({"status": "error", "detail": "Workspace not found"}, status_code=404)
|
||||
conn.execute(
|
||||
"UPDATE pages SET workspace_id=?, workspace=?, parent_id=NULL, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(new_ws_id, ws_row["name"], page_id),
|
||||
)
|
||||
else:
|
||||
# Reorder within same workspace
|
||||
conn.execute(
|
||||
"UPDATE pages SET parent_id=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(new_parent_id if new_parent_id > 0 else None, page_id),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE pages SET sort_order=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(new_order, page_id),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
run_event_sync(fire_event("page.moved", {"page_id": page_id, "workspace_id": new_ws_id or 0,
|
||||
"parent_id": new_parent_id}))
|
||||
return {"status": "ok", "id": page_id}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/api/pages/{page_id}")
|
||||
def delete_page(request: Request, page_id: int):
|
||||
"""Move a page to trash (soft delete)."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = (user or {}).get("id")
|
||||
if not uid:
|
||||
raise HTTPException(403, "Authentication required")
|
||||
# v6.0.0: granular page permissions — need at least edit access to trash.
|
||||
if not PermissionManager(uid).can_edit_page(page_id):
|
||||
raise HTTPException(403, "You don't have edit access to this page")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id, title FROM pages WHERE id=? AND deleted_at IS NULL", (page_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Page not found")
|
||||
import datetime
|
||||
conn.execute("UPDATE pages SET deleted_at=? WHERE id=?", (datetime.datetime.now(datetime.UTC).replace(tzinfo=None).isoformat(), page_id,))
|
||||
conn.commit()
|
||||
run_event_sync(fire_event("page.deleted", {"page_id": page_id, "title": row["title"] or ""}))
|
||||
return {"status": "ok", "deleted": page_id, "title": row["title"]}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}", response_class=HTMLResponse)
|
||||
def view_page(request: Request, page_id: int):
|
||||
"""Render a page as HTML, or a file viewer for uploaded files.
|
||||
?embed=1 — minimal mode for side peek (editor only, no header)."""
|
||||
embed = request.query_params.get("embed") == "1"
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
# v6.0.0: granular page permissions — hide restricted pages (404).
|
||||
user_hdr = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if user_hdr and user_hdr.get("id") and not PermissionManager(user_hdr["id"]).can_view_page(page_id):
|
||||
return HTMLResponse("<h2>Page not found</h2>", status_code=404)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
return HTMLResponse("<h2>Page not found</h2>", status_code=404)
|
||||
page = dict(row)
|
||||
# v6.5.0: synced blocks resolve server-side at read time (fresh content
|
||||
# even when the stored cache is stale).
|
||||
from app.services.synced_blocks import resolve_content_json
|
||||
page["content"] = resolve_content_json(page.get("content", ""), page.get("content_format"))
|
||||
|
||||
ws = page.get("workspace", "")
|
||||
parts = ws.split("/") if "/" in ws else ["", ""]
|
||||
owner, repo = parts[0], parts[1] if len(parts) > 1 else ""
|
||||
sidebar = _sidebar_data(request, owner, repo)
|
||||
# Check if page is favorited
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = user["id"] if user and user.get("id") else 1
|
||||
with get_conn() as conn:
|
||||
fav = conn.execute(
|
||||
"SELECT id FROM favorites WHERE user_id=? AND page_id=?", (uid, page_id)
|
||||
).fetchone()
|
||||
|
||||
# Build page_data, including file metadata for uploaded files
|
||||
_locked = bool(page.get("is_locked", 0))
|
||||
_locked_by = page.get("locked_by") if "locked_by" in page else None
|
||||
_can_edit = (not _locked) or bool(user and user.get("is_admin")) or (uid and _locked_by == uid)
|
||||
page_data = {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": fav is not None, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("published", 0)), "cover_url": page.get("cover_url", "") or "", "page_icon": page.get("page_icon", "") or "",
|
||||
"is_locked": _locked,
|
||||
"locked_by": _locked_by,
|
||||
"can_edit": _can_edit,
|
||||
"full_width": bool(page.get("full_width", 0)) if "full_width" in page else False,
|
||||
"font_small": bool(page.get("font_small", 0)) if "font_small" in page else False}
|
||||
|
||||
# For file pages, extract file metadata and add to page_data
|
||||
if page.get("content_format") == "file":
|
||||
import json as _json
|
||||
try:
|
||||
meta = _json.loads(page.get("content", "{}"))
|
||||
except _json.JSONDecodeError:
|
||||
meta = {}
|
||||
file_path = meta.get("file_path", "").replace("\\", "/")
|
||||
mime_type = meta.get("mime_type", "application/octet-stream")
|
||||
file_size = meta.get("size", 0)
|
||||
# Build workspace_id from file_path
|
||||
fp_parts = file_path.split("/")
|
||||
ws_id = ""
|
||||
for p in fp_parts:
|
||||
if p.startswith("workspace_"):
|
||||
ws_id = p.replace("workspace_", "")
|
||||
break
|
||||
filename = fp_parts[-1] if fp_parts else page.get("title", "File")
|
||||
file_url = f"/api/files/{ws_id}/{filename}" if ws_id else ""
|
||||
page_data["file_url"] = file_url
|
||||
page_data["file_mime"] = mime_type
|
||||
page_data["file_size"] = file_size
|
||||
page_data["file_name"] = filename
|
||||
|
||||
from app.routers.dashboard import _nav_breadcrumb
|
||||
with get_conn() as conn:
|
||||
nav_crumbs = _nav_breadcrumb(conn, page_id)
|
||||
ctx = {**sidebar, "page": page, "page_favorited": fav is not None,
|
||||
"page_share_mode": page.get("share_mode", "private"),
|
||||
"page_published": bool(page.get("published", 0)),
|
||||
"page_is_shared": bool(page.get("is_shared", 0)) or page.get("share_mode", "private") != "private" or bool(page.get("published", 0)),
|
||||
"page_data": page_data,
|
||||
"breadcrumb_items": nav_crumbs,
|
||||
"nav_workspace_id": page.get("workspace_id") or 0,
|
||||
"nav_page_id": page_id,
|
||||
"embed_mode": embed}
|
||||
template = env.get_template("page_editor_embed.html" if embed else "page_editor.html")
|
||||
response = template.render(**ctx)
|
||||
return HTMLResponse(content=response, headers={"Cache-Control": "no-store, max-age=0"})
|
||||
@@ -0,0 +1,271 @@
|
||||
"""FlowDeck — Board : pages.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Query, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event, run_event_sync
|
||||
from app.services.permission_manager import PermissionManager
|
||||
|
||||
from ._common import _block_texts, _ensure_page_editable, _record_version
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["board"], prefix="/board")
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ Pages Markdown ═══════════
|
||||
|
||||
@router.post("/api/pages")
|
||||
def create_page(request: Request, title: str = Query(default=""),
|
||||
section: str = Query(default="Private"),
|
||||
project: str = Query(default=""),
|
||||
parent_id: int = Query(default=0)):
|
||||
"""Create a new Markdown page, optionally as a sub-page."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
# A7 : la création de page exige une session (route sortue de la liste CSRF).
|
||||
raise HTTPException(401, "Authentication required")
|
||||
ws_key = project if project else (user.get("login", "Bruno") if user else "Bruno")
|
||||
page_title = title.strip() if title else ""
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
# Compute next sort_order for this parent
|
||||
next_order = 0
|
||||
parent_val = parent_id if parent_id > 0 else None
|
||||
row = conn.execute(
|
||||
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace=? AND parent_id IS ?",
|
||||
(ws_key, parent_val),
|
||||
).fetchone()
|
||||
if row:
|
||||
next_order = row[0]
|
||||
cur = conn.execute(
|
||||
"INSERT INTO pages (workspace, title, parent_section, parent_id, sort_order) VALUES (?,?,?,?,?)",
|
||||
(ws_key, page_title, section, parent_val, next_order),
|
||||
)
|
||||
conn.commit()
|
||||
page_id = cur.lastrowid
|
||||
run_event_sync(fire_event("page.created", {"page_id": page_id, "title": page_title,
|
||||
"workspace": ws_key, "parent_id": parent_id}))
|
||||
return {"status": "ok", "id": page_id, "title": page_title, "workspace": ws_key, "parent_id": parent_id}
|
||||
except Exception as e:
|
||||
logger.error("create_page failed: %s", e)
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse({"error": "Failed to create page", "detail": str(e)}, status_code=500)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}")
|
||||
def get_page(request: Request, page_id: int):
|
||||
"""Get a Markdown page."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
# v6.0.0: granular page permissions — 404 (not 403) hides restricted pages.
|
||||
if not PermissionManager(user["id"], bool(user.get("is_admin"))).can_view_page(page_id):
|
||||
raise HTTPException(404, "Page not found")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Page not found")
|
||||
return dict(row)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.put("/api/pages/{page_id}")
|
||||
def update_page(request: Request, page_id: int, title: str = Query(default=""),
|
||||
content: str = Query(default=""),
|
||||
content_format: str = Query(default="")):
|
||||
"""Update a page's title and/or content. Accepts JSON body for blocks."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(403, "Authentication required")
|
||||
# v6.0.0: granular page permissions — hidden pages 404 (not 403); a visible
|
||||
# page the caller cannot edit yields 403.
|
||||
pm = PermissionManager(user["id"], bool(user.get("is_admin")))
|
||||
if not pm.can_view_page(page_id):
|
||||
raise HTTPException(404, "Page not found")
|
||||
if not pm.can_edit_page(page_id):
|
||||
raise HTTPException(403, "You don't have edit access to this page")
|
||||
with get_conn() as conn:
|
||||
_ensure_page_editable(conn, page_id, user)
|
||||
if title:
|
||||
conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, page_id))
|
||||
# v6.5.0: renaming a database row's content page updates the row.
|
||||
from app.services.row_pages import sync_page_title_to_row
|
||||
sync_page_title_to_row(conn, page_id)
|
||||
if content:
|
||||
conn.execute("UPDATE pages SET content=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content, page_id))
|
||||
if content_format:
|
||||
conn.execute("UPDATE pages SET content_format=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content_format, page_id))
|
||||
conn.commit()
|
||||
run_event_sync(fire_event("page.updated", {"page_id": page_id, "title": title,
|
||||
"content_format": content_format or "markdown",
|
||||
"actor_id": user.get("id")}))
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/blocks")
|
||||
def save_page_blocks(request: Request, page_id: int, body: dict = Body(...)):
|
||||
"""Save blocks JSON content (Notion-style block editor).
|
||||
|
||||
v5.4.0: a version snapshot is recorded (if the block content actually
|
||||
changed) so the UI can browse the version history and restore any of them.
|
||||
v5.14.0: synced block references are tracked in page_synced_blocks.
|
||||
"""
|
||||
blocks = body.get("blocks", [])
|
||||
blocks_json = json.dumps(blocks)
|
||||
title = body.get("title", "")
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = (user or {}).get("id")
|
||||
# No session → legacy single-user behaviour; otherwise enforce edit rights.
|
||||
if uid and not PermissionManager(uid).can_edit_page(page_id):
|
||||
raise HTTPException(403, "You don't have edit access to this page")
|
||||
|
||||
# Extract synced block ids from the blocks
|
||||
def _extract_synced(blocks: list[dict]) -> set[int]:
|
||||
ids: set[int] = set()
|
||||
for b in blocks:
|
||||
if b.get("type") == "synced" and b.get("synced_id"):
|
||||
ids.add(b["synced_id"])
|
||||
if isinstance(b.get("children"), list):
|
||||
ids |= _extract_synced(b["children"])
|
||||
return ids
|
||||
|
||||
synced_ids = _extract_synced(blocks)
|
||||
|
||||
with get_conn() as conn:
|
||||
_ensure_page_editable(conn, page_id, user)
|
||||
if title:
|
||||
conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, page_id))
|
||||
# v6.5.0: renaming a database row's content page updates the row.
|
||||
from app.services.row_pages import sync_page_title_to_row
|
||||
sync_page_title_to_row(conn, page_id)
|
||||
conn.execute(
|
||||
"UPDATE pages SET content=?, content_format='blocks', updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(blocks_json, page_id),
|
||||
)
|
||||
_record_version(conn, page_id, uid, title or "", blocks_json)
|
||||
# Update synced block references
|
||||
existing = {r["synced_block_id"] for r in conn.execute(
|
||||
"SELECT synced_block_id FROM page_synced_blocks WHERE page_id=?", (page_id,)
|
||||
).fetchall()}
|
||||
for sid in synced_ids:
|
||||
if sid not in existing:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO page_synced_blocks (page_id, synced_block_id, block_index) VALUES (?, ?, 0)",
|
||||
(page_id, sid),
|
||||
)
|
||||
for sid in existing - synced_ids:
|
||||
conn.execute(
|
||||
"DELETE FROM page_synced_blocks WHERE page_id=? AND synced_block_id=?",
|
||||
(page_id, sid),
|
||||
)
|
||||
conn.commit()
|
||||
run_event_sync(fire_event("page.updated", {"page_id": page_id, "title": title or "",
|
||||
"content_format": "blocks",
|
||||
"actor_id": uid}))
|
||||
return {"status": "ok", "id": page_id}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}/backlinks")
|
||||
def page_backlinks(request: Request, page_id: int):
|
||||
"""v5.4.0: pages that link to this one ("Lié depuis…").
|
||||
|
||||
Scans every non-deleted page's blocks (and raw markdown) for an internal
|
||||
reference to ``/pages/{page_id}`` or ``#fdblk-…`` inside ``/pages/{page_id}``.
|
||||
"""
|
||||
target = f"/pages/{page_id}" if page_id else None
|
||||
wiki_target = f"[[fdpage:{page_id}]]" if page_id else None
|
||||
backlinks = []
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, workspace, content, content_format, updated_at "
|
||||
"FROM pages WHERE deleted_at IS NULL AND id != ?",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
fmt = r["content_format"]
|
||||
hits = False
|
||||
if fmt == "blocks" and r["content"]:
|
||||
try:
|
||||
blocks = json.loads(r["content"])
|
||||
for b in blocks if isinstance(blocks, list) else []:
|
||||
for text in _block_texts(b):
|
||||
if target and (target in text or (wiki_target and wiki_target in text)):
|
||||
hits = True
|
||||
break
|
||||
if hits:
|
||||
break
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
hits = target and (target in (r["content"] or "") or (wiki_target and wiki_target in (r["content"] or "")))
|
||||
elif fmt == "markdown":
|
||||
hits = target and (target in (r["content"] or "") or (wiki_target and wiki_target in (r["content"] or "")))
|
||||
elif r["content"]:
|
||||
hits = target and (target in json.dumps(r["content"]) or (wiki_target and wiki_target in json.dumps(r["content"])))
|
||||
if not hits and target:
|
||||
hits = f"/pages/{page_id}" in (r["content"] or "")
|
||||
if hits:
|
||||
backlinks.append({
|
||||
"id": r["id"],
|
||||
"title": r["title"] or "Untitled",
|
||||
"workspace": r["workspace"] or "",
|
||||
"updated_at": r["updated_at"] or "",
|
||||
})
|
||||
backlinks.sort(key=lambda x: x.get("updated_at") or "", reverse=True)
|
||||
return {"backlinks": backlinks}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}/versions")
|
||||
def page_versions(request: Request, page_id: int):
|
||||
"""v5.4.0: version history for a block-editor page."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT pv.id, pv.title, pv.note, pv.created_at, "
|
||||
"COALESCE(u.login, '') AS author "
|
||||
"FROM page_versions pv LEFT JOIN users u ON u.id=pv.user_id "
|
||||
"WHERE pv.page_id=? ORDER BY pv.id DESC LIMIT 100",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
return {"versions": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/versions/{version_id}/restore")
|
||||
def restore_version(request: Request, page_id: int, version_id: int):
|
||||
"""v5.4.0: restore a page from a version snapshot."""
|
||||
with get_conn() as conn:
|
||||
ver = conn.execute(
|
||||
"SELECT * FROM page_versions WHERE id=? AND page_id=?",
|
||||
(version_id, page_id),
|
||||
).fetchone()
|
||||
if not ver:
|
||||
raise HTTPException(404, "Version not found")
|
||||
conn.execute(
|
||||
"UPDATE pages SET content=?, title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(ver["blocks_json"], ver["title"] or "", page_id),
|
||||
)
|
||||
conn.commit()
|
||||
run_event_sync(fire_event("page.updated", {"page_id": page_id, "title": ver["title"] or "",
|
||||
"content_format": "blocks"}))
|
||||
return {"status": "ok", "restored": version_id}
|
||||
|
||||
|
||||
# ═══════════ v5.4.0: Page & collection duplication ═══════════
|
||||
@@ -0,0 +1,175 @@
|
||||
"""FlowDeck — Board : sharing.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event, run_event_sync
|
||||
from app.services.publish import fire_published, fire_unpublished, publish, unpublish
|
||||
|
||||
from ._common import _sidebar_data
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["board"], prefix="/board")
|
||||
|
||||
|
||||
|
||||
# ═══════════ Favorites API ═══════════
|
||||
|
||||
@router.get("/api/favorites")
|
||||
def list_favorites(request: Request):
|
||||
"""List favorited page IDs for the current user."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = user["id"] if user and user.get("id") else 1
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT page_id FROM favorites WHERE user_id=? ORDER BY position", (uid,)
|
||||
).fetchall()
|
||||
return {"favorites": [r["page_id"] for r in rows]}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/favorites/{page_id:int}")
|
||||
def add_favorite(request: Request, page_id: int):
|
||||
"""Add a page to favorites."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = user["id"] if user and user.get("id") else 1
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM favorites WHERE user_id=? AND page_id=?", (uid, page_id)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM favorites WHERE user_id=?", (uid,)
|
||||
).fetchone()[0]
|
||||
conn.execute(
|
||||
"INSERT INTO favorites (user_id, page_id, position) VALUES (?,?,?)",
|
||||
(uid, page_id, pos),
|
||||
)
|
||||
conn.commit()
|
||||
try:
|
||||
run_event_sync(fire_event("favorite.added", {"page_id": page_id, "user_id": uid}))
|
||||
except Exception:
|
||||
logger.exception("add_favorite")
|
||||
return {"status": "added", "page_id": page_id}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/api/favorites/{page_id:int}")
|
||||
def remove_favorite(request: Request, page_id: int):
|
||||
"""Remove a page from favorites."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = user["id"] if user and user.get("id") else 1
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM favorites WHERE user_id=? AND page_id=?", (uid, page_id))
|
||||
conn.commit()
|
||||
try:
|
||||
run_event_sync(fire_event("favorite.removed", {"page_id": page_id, "user_id": uid}))
|
||||
except Exception:
|
||||
logger.exception("remove_favorite")
|
||||
return {"status": "removed", "page_id": page_id}
|
||||
|
||||
# ═══════════ Share API ═══════════
|
||||
|
||||
|
||||
|
||||
# ═══════════ Share API ═══════════
|
||||
|
||||
@router.post("/api/share/{page_id:int}")
|
||||
def update_share(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
"""Save share settings for a page."""
|
||||
mode = body.get("mode", "private")
|
||||
published = body.get("published", False)
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE pages SET share_mode=?, published=? WHERE id=?",
|
||||
(mode, 1 if published else 0, page_id),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok", "share_mode": mode, "published": published}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id:int}/publish")
|
||||
def publish_page(request: Request, page_id: int):
|
||||
"""Publish a page to the web (generates publish_slug)."""
|
||||
if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
slug, title = publish(page_id)
|
||||
run_event_sync(fire_published(page_id, slug))
|
||||
return {"is_published": True, "publish_slug": slug, "title": title}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/api/pages/{page_id:int}/publish")
|
||||
def unpublish_page(request: Request, page_id: int):
|
||||
"""Unpublish a page from the web."""
|
||||
if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
unpublish(page_id)
|
||||
run_event_sync(fire_unpublished(page_id))
|
||||
return {"is_published": False}
|
||||
|
||||
|
||||
# ═══════════ Trash (must be before /{owner}/{repo} catch-all) ═══════════
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ Trash (must be before /{owner}/{repo} catch-all) ═══════════
|
||||
|
||||
@router.get("/api/trash")
|
||||
def list_trash(request: Request):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT id, title, workspace, parent_id, deleted_at FROM pages WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC").fetchall()
|
||||
return [{"id": r["id"], "name": r["title"] or "Untitled", "icon": "📄", "path": r["workspace"] or "Private", "deleted_at": r["deleted_at"]} for r in rows]
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/trash/{page_id}/restore")
|
||||
def restore_page(request: Request, page_id: int):
|
||||
with get_conn() as conn:
|
||||
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")
|
||||
return {"status": "ok", "restored": page_id}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/api/trash/{page_id}")
|
||||
def permanent_delete(request: Request, page_id: int):
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET parent_id=NULL WHERE parent_id=?", (page_id,))
|
||||
conn.execute("DELETE FROM pages WHERE id=? AND deleted_at IS NOT NULL", (page_id,))
|
||||
conn.commit()
|
||||
return {"status": "ok", "deleted": page_id}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/trash", response_class=HTMLResponse)
|
||||
def trash_page(request: Request):
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
template = env.get_template("trash.html")
|
||||
return template.render(**_sidebar_data(request))
|
||||
|
||||
|
||||
# ═══════════════ v5.14.0: Synced Blocks ─═══════════════════
|
||||
# These routes MUST be registered before the catch-all /{owner}/{repo} below.
|
||||
@@ -0,0 +1,51 @@
|
||||
"""FlowDeck — Board : sync.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.gitea_client import gitea
|
||||
|
||||
from ._common import _extract_ai_keywords, _issue_column
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["board"], prefix="/board")
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/sync/{owner}/{repo}")
|
||||
async def sync_project(owner: str, repo: str):
|
||||
"""Full bidirectional sync: fetch Gitea issues → update local DB."""
|
||||
try:
|
||||
issues = await gitea.get_issues(owner, repo, state="all")
|
||||
issues_only = [i for i in issues if not i.get("pull_request")]
|
||||
with get_conn() as conn:
|
||||
board = conn.execute(
|
||||
"SELECT id, columns_json FROM boards WHERE project_owner=? AND project_name=?",
|
||||
(owner, repo),
|
||||
).fetchone()
|
||||
if board:
|
||||
board_id = board["id"]
|
||||
columns = json.loads(board["columns_json"])
|
||||
# A23 : un seul executemany pour toutes les cards.
|
||||
conn.executemany(
|
||||
"INSERT OR REPLACE INTO cards (board_id, gitea_issue_id, column_name) VALUES (?,?,?)",
|
||||
[
|
||||
(board_id, issue["number"], _issue_column(issue, columns, board_id))
|
||||
for issue in issues_only
|
||||
],
|
||||
)
|
||||
for issue in issues_only:
|
||||
# Extract AI keywords from each issue
|
||||
_extract_ai_keywords(owner, repo, issue.get("labels", []), issue.get("body", ""))
|
||||
conn.commit()
|
||||
return {"status": "ok", "issues_synced": len(issues_only)}
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e)) from e
|
||||
@@ -0,0 +1,144 @@
|
||||
"""FlowDeck — Board : synced.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Query, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["board"], prefix="/board")
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════════ v5.14.0: Synced Blocks ─═══════════════════
|
||||
# These routes MUST be registered before the catch-all /{owner}/{repo} below.
|
||||
|
||||
@router.get("/api/synced-blocks")
|
||||
def list_synced_blocks_api(request: Request, workspace: str = Query(default="")):
|
||||
"""List synced blocks for a workspace."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
from app.services.synced_blocks import list_synced_blocks
|
||||
return {"synced_blocks": list_synced_blocks(workspace or user.get("login", ""))}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/synced-blocks")
|
||||
def create_synced_block_api(request: Request, body: dict = Body(...)):
|
||||
"""Create a new synced block."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
from app.services.synced_blocks import create_synced_block
|
||||
sid = create_synced_block(
|
||||
workspace=body.get("workspace", ""),
|
||||
title=body.get("title", "Synced block"),
|
||||
content=body.get("content", []),
|
||||
created_by=user.get("id"),
|
||||
)
|
||||
return {"status": "ok", "synced_block_id": sid}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.put("/api/synced-blocks/{sid}")
|
||||
async def update_synced_block_api(request: Request, sid: int):
|
||||
"""Update a synced block's content (propagates to all pages)."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid JSON body") from None
|
||||
from app.services.synced_blocks import (
|
||||
get_synced_block,
|
||||
page_ids_for_synced,
|
||||
sync_synced_blocks_in_page,
|
||||
update_synced_block,
|
||||
)
|
||||
sb = get_synced_block(sid)
|
||||
if not sb:
|
||||
raise HTTPException(404, "Synced block not found")
|
||||
update_synced_block(sid, body.get("title", sb["title"]), body.get("content", []))
|
||||
# v6.5.0: rewrite every referencing page's stored content first (DB row
|
||||
# content pages included), THEN push the realtime update so open rooms
|
||||
# reload the fresh content from the DB.
|
||||
for pid in page_ids_for_synced(sid):
|
||||
sync_synced_blocks_in_page(pid)
|
||||
from app.services.realtime_server import manager
|
||||
await manager._propagate_synced(sid)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/api/synced-blocks/{sid}")
|
||||
async def delete_synced_block_api(request: Request, sid: int):
|
||||
"""Delete a synced block."""
|
||||
from app.services.synced_blocks import (
|
||||
delete_synced_block,
|
||||
get_synced_block,
|
||||
mark_synced_block_deleted,
|
||||
page_ids_for_synced,
|
||||
)
|
||||
sb = get_synced_block(sid)
|
||||
if not sb:
|
||||
raise HTTPException(404, "Synced block not found")
|
||||
# v6.5.0: collect referencing pages BEFORE the FK cascade wipes the
|
||||
# refs, rewrite their stored content (deleted state), then broadcast.
|
||||
pids = page_ids_for_synced(sid)
|
||||
delete_synced_block(sid)
|
||||
mark_synced_block_deleted(sid, pids)
|
||||
from app.services.realtime_server import manager
|
||||
await manager._broadcast_synced_to(pids, sid)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/synced-blocks/{sid}")
|
||||
def get_synced_block_api(sid: int):
|
||||
"""Get a synced block by id."""
|
||||
from app.services.synced_blocks import get_synced_block
|
||||
sb = get_synced_block(sid)
|
||||
if not sb:
|
||||
raise HTTPException(404, "Synced block not found")
|
||||
return dict(sb)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/synced")
|
||||
def add_synced_to_page(request: Request, page_id: int, body: dict = Body(...)):
|
||||
"""Add a synced block reference to a page."""
|
||||
from app.services.synced_blocks import add_page_synced, get_synced_block
|
||||
sid = body.get("synced_block_id")
|
||||
sb = get_synced_block(sid)
|
||||
if not sb:
|
||||
raise HTTPException(404, "Synced block not found")
|
||||
add_page_synced(page_id, sid, body.get("block_index", 0))
|
||||
return {"status": "ok", "synced_block_id": sid}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/api/pages/{page_id}/synced/{sid}")
|
||||
def remove_synced_from_page(request: Request, page_id: int, sid: int):
|
||||
"""Remove a synced block reference from a page (unsync)."""
|
||||
from app.services.synced_blocks import remove_page_synced
|
||||
remove_page_synced(page_id, sid)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}/synced")
|
||||
def get_page_synced_refs(request: Request, page_id: int):
|
||||
"""Get all synced block references for a page."""
|
||||
from app.services.synced_blocks import get_page_synced
|
||||
return {"synced_blocks": get_page_synced(page_id)}
|
||||
|
||||
|
||||
# ═══════════ Board page ═══════════
|
||||
@@ -0,0 +1,76 @@
|
||||
"""FlowDeck — Board : wiki.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Query, Request
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["board"], prefix="/board")
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/wiki/pages")
|
||||
def wiki_page_search(request: Request, q: str = Query(default="")):
|
||||
"""v5.11.0: page picker for [[ wiki-links. Fuzzy-ish title search across
|
||||
every non-deleted page the current user can see (single source: pages)."""
|
||||
q = (q or "").strip().lower()
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT id, title, page_icon, workspace FROM pages
|
||||
WHERE deleted_at IS NULL
|
||||
ORDER BY updated_at DESC LIMIT 500"""
|
||||
).fetchall()
|
||||
results = []
|
||||
for r in rows:
|
||||
title = r["title"] or "Untitled"
|
||||
if q:
|
||||
# subsequence match ("mtg" → "Meeting notes") or plain substring.
|
||||
hay = title.lower()
|
||||
it = iter(hay)
|
||||
subseq = all(ch in it for ch in q)
|
||||
if q not in hay and not subseq:
|
||||
continue
|
||||
results.append({
|
||||
"id": r["id"],
|
||||
"title": title,
|
||||
"icon": r["page_icon"] or "",
|
||||
"workspace": r["workspace"] or "",
|
||||
})
|
||||
if len(results) >= 20:
|
||||
break
|
||||
return {"pages": results}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/wiki/titles")
|
||||
def wiki_titles(request: Request, ids: str = Query(default="")):
|
||||
"""v5.11.0: resolve page-id lists to current labels (rename propagation)."""
|
||||
parsed: list[int] = []
|
||||
for part in (ids or "").split(","):
|
||||
part = part.strip()
|
||||
if part.isdigit():
|
||||
parsed.append(int(part))
|
||||
parsed = parsed[:200]
|
||||
out: dict[str, str] = {}
|
||||
if parsed:
|
||||
placeholders = ",".join("?" * len(parsed))
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT id, title, page_icon, deleted_at FROM pages WHERE id IN ({placeholders})",
|
||||
parsed,
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
if r["deleted_at"]:
|
||||
out[str(r["id"])] = "Deleted page"
|
||||
else:
|
||||
icon = (r["page_icon"] or "")
|
||||
out[str(r["id"])] = (icon + " " if icon else "") + (r["title"] or "Untitled")
|
||||
return {"titles": out}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,58 @@
|
||||
"""FlowDeck — Collections : bases de données façon Notion.
|
||||
|
||||
Découpe A28 : l'ancien `collections.py` (2 622 lignes, 53 routes) est
|
||||
devenu ce package — un module par concern, helpers dans `_common`
|
||||
(auth/permissions/validation) et `_renderers` (rendus HTML des vues).
|
||||
Ré-exports : automations importe `_validate_page_properties`, les
|
||||
tests importent les helpers de graphes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from . import ( # ordre = ordre d'enregistrement d'origine
|
||||
boards,
|
||||
crud,
|
||||
dashboard_views,
|
||||
data_api,
|
||||
linked,
|
||||
meta,
|
||||
pages,
|
||||
properties,
|
||||
structure,
|
||||
views,
|
||||
)
|
||||
from ._common import _validate_page_properties # noqa: F401
|
||||
from ._renderers import ( # noqa: F401 — ré-exports tests
|
||||
_chart_aggregate,
|
||||
_chart_values,
|
||||
_fmt_number,
|
||||
_render_chart,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
for _mod in (
|
||||
crud,
|
||||
pages,
|
||||
boards,
|
||||
meta,
|
||||
properties,
|
||||
views,
|
||||
structure,
|
||||
linked,
|
||||
dashboard_views,
|
||||
data_api,
|
||||
):
|
||||
router.include_router(_mod.router)
|
||||
|
||||
__all__ = [
|
||||
"router",
|
||||
"_chart_aggregate",
|
||||
"_chart_values",
|
||||
"_fmt_number",
|
||||
"_render_chart",
|
||||
"_validate_page_properties",
|
||||
]
|
||||
@@ -0,0 +1,220 @@
|
||||
"""FlowDeck — Collections : helpers partagés (A28).
|
||||
|
||||
Les 8 helpers de tête de l'ancien collections.py (auth, permissions,
|
||||
validation) — ré-exportés par le package.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.services.permission_manager import PermissionManager
|
||||
from app.services.property_types import (
|
||||
AUTO_TYPES,
|
||||
validate_property_rule,
|
||||
)
|
||||
from app.services.recurrence import (
|
||||
RECURRENCE_KEY,
|
||||
is_valid_timezone,
|
||||
validate_rule,
|
||||
)
|
||||
from app.services.reminders import REMINDER_KEY, parse_lead
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["collections"], prefix="/db")
|
||||
|
||||
|
||||
|
||||
def _current_user(request: Request) -> dict:
|
||||
"""Resolve the session user, falling back to the local admin (single-user)."""
|
||||
s = request.cookies.get("flowdeck_session", "")
|
||||
return SessionManager.decode_session(s) or {"login": "admin", "id": 1}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _session_user(request: Request) -> dict | None:
|
||||
"""Resolve the session user WITHOUT the admin fallback (for ACL checks)."""
|
||||
s = request.cookies.get("flowdeck_session", "")
|
||||
user = SessionManager.decode_session(s)
|
||||
return user if user and user.get("id") else None
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _require_view(collection_id: int, user: dict | None) -> None:
|
||||
"""Raise 404 when the user may not view the collection (404 hides it).
|
||||
|
||||
A6 : plus de session = accès refusé — l'absence de user ne vaut plus
|
||||
« legacy single-user » ( lecture anonyme de n'importe quelle collection ).
|
||||
"""
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
pm = PermissionManager(user["id"])
|
||||
if not pm.can_view_collection(collection_id):
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _require_edit(collection_id: int, user: dict | None) -> None:
|
||||
"""Raise 401/403 when the user may not edit pages in the collection."""
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
pm = PermissionManager(user["id"])
|
||||
if not pm.can_edit_collection(collection_id):
|
||||
raise HTTPException(status_code=403, detail="You don't have edit access to this collection")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _collection_properties(conn, collection_id: int) -> list[dict]:
|
||||
return [
|
||||
dict(r) for r in conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
]
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _apply_template(conn, template_name: str) -> dict | None:
|
||||
"""Resolve a database template by name (from the seeded/built-in set)."""
|
||||
if not template_name:
|
||||
return None
|
||||
row = conn.execute(
|
||||
"SELECT id, name, icon, description, schema_json FROM database_templates WHERE name=?",
|
||||
(template_name,),
|
||||
).fetchone()
|
||||
if row:
|
||||
return dict(row)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _validate_page_properties(conn, collection_id: int, properties: dict, exclude_page_id: int | None = None) -> None:
|
||||
"""Validate submitted property values against the collection's schema.
|
||||
|
||||
Raises ``HTTPException(400)`` with a user-friendly message on the first
|
||||
failure (type, required, unique, min/max).
|
||||
"""
|
||||
props = conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE collection_id=?", (collection_id,)
|
||||
).fetchall()
|
||||
|
||||
for prop in props:
|
||||
ptype = prop["prop_type"]
|
||||
if ptype == "title" or ptype in AUTO_TYPES:
|
||||
continue
|
||||
pid = prop["id"]
|
||||
# Values may be keyed by property id (FlowDeckDB UI) or by name (agent).
|
||||
value = properties.get(str(pid))
|
||||
if value is None:
|
||||
value = properties.get(prop["name"])
|
||||
validation = prop["validation_json"] if "validation_json" in prop.keys() else "{}"
|
||||
|
||||
existing_values = None
|
||||
try:
|
||||
import json as _json
|
||||
vcfg = _json.loads(validation) if validation else {}
|
||||
except Exception:
|
||||
vcfg = {}
|
||||
if vcfg.get("unique"):
|
||||
rows = conn.execute(
|
||||
"SELECT id, property_values_json FROM collection_pages WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
existing_values = []
|
||||
for r in rows:
|
||||
if exclude_page_id is not None and r["id"] == exclude_page_id:
|
||||
continue
|
||||
try:
|
||||
pv = _json.loads(r["property_values_json"] or "{}")
|
||||
except Exception:
|
||||
pv = {}
|
||||
existing_values.append(pv.get(str(pid)) or pv.get(prop["name"]))
|
||||
|
||||
ok, msg = validate_property_rule(ptype, value, validation, existing_values=existing_values)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=400, detail=f"Property '{prop['name']}': {msg}")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _validate_meta_keys(conn, collection_id: int, properties: dict) -> None:
|
||||
"""Validate the ``__recurrence__`` / ``__reminder__`` meta keys stored
|
||||
alongside real property values. Raises HTTPException(400) on bad shape.
|
||||
|
||||
Each meta key maps a date-property id to a rule/reminder object. We verify
|
||||
the target is actually a date property and the payload parses.
|
||||
"""
|
||||
date_ids = {
|
||||
str(r["id"]) for r in conn.execute(
|
||||
"SELECT id FROM collection_properties WHERE collection_id=? AND prop_type='date'",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
}
|
||||
|
||||
rec = properties.get(RECURRENCE_KEY)
|
||||
if rec not in (None, {}):
|
||||
if not isinstance(rec, dict):
|
||||
raise HTTPException(status_code=400, detail="Recurrence must be an object")
|
||||
for prop_id, rule in rec.items():
|
||||
if rule is None:
|
||||
continue
|
||||
if str(prop_id) not in date_ids:
|
||||
raise HTTPException(status_code=400, detail="Recurrence target must be a date property")
|
||||
ok, msg = validate_rule(rule)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=400, detail=f"Recurrence: {msg}")
|
||||
|
||||
rem = properties.get(REMINDER_KEY)
|
||||
if rem not in (None, {}):
|
||||
if not isinstance(rem, dict):
|
||||
raise HTTPException(status_code=400, detail="Reminder must be an object")
|
||||
for prop_id, reminder in rem.items():
|
||||
if reminder is None:
|
||||
continue
|
||||
if str(prop_id) not in date_ids:
|
||||
raise HTTPException(status_code=400, detail="Reminder target must be a date property")
|
||||
if not isinstance(reminder, dict):
|
||||
raise HTTPException(status_code=400, detail="Reminder must be an object")
|
||||
if reminder.get("unit") not in (None, "none", "minutes", "hours", "days"):
|
||||
raise HTTPException(status_code=400, detail="Reminder unit must be minutes/hours/days/none")
|
||||
if parse_lead(reminder) is None and reminder.get("unit") != "none":
|
||||
raise HTTPException(status_code=400, detail="Reminder value must be a positive integer")
|
||||
|
||||
from app.services.recurrence import TIMEZONE_KEY
|
||||
tzmap = properties.get(TIMEZONE_KEY)
|
||||
if tzmap not in (None, {}):
|
||||
if not isinstance(tzmap, dict):
|
||||
raise HTTPException(status_code=400, detail="Timezone map must be an object")
|
||||
for prop_id, value in tzmap.items():
|
||||
if str(prop_id) not in date_ids:
|
||||
raise HTTPException(status_code=400, detail="Timezone target must be a date property")
|
||||
if value and not is_valid_timezone(str(value)):
|
||||
raise HTTPException(status_code=400, detail=f"Unknown timezone '{value}'")
|
||||
|
||||
|
||||
# ── API: List & Create (no path params) ──
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,667 @@
|
||||
"""FlowDeck — Collections : rendus HTML des vues (A28).
|
||||
|
||||
Les 15 helpers de rendu de l'ancien collections.py (_render_view,
|
||||
_render_chart, …) + CHART_MAX_GROUPS — ré-exportés pour les tests.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from app.db import get_conn
|
||||
from app.templating import CSP_NONCE
|
||||
|
||||
CHART_MAX_GROUPS = 200
|
||||
|
||||
|
||||
|
||||
# ── View renderers (v1.6.0) ──
|
||||
|
||||
def _render_view(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
||||
if view_type == "calendar":
|
||||
return _render_calendar(view_type, collection, pages, config)
|
||||
elif view_type == "gallery":
|
||||
return _render_gallery(view_type, collection, pages, config)
|
||||
elif view_type == "list":
|
||||
return _render_list(view_type, collection, pages, config)
|
||||
elif view_type == "timeline":
|
||||
return _render_timeline(view_type, collection, pages, config)
|
||||
elif view_type == "chart":
|
||||
return _render_chart(view_type, collection, pages, config)
|
||||
elif view_type == "form":
|
||||
return _render_form(view_type, collection, pages, config)
|
||||
elif view_type == "map":
|
||||
return _render_map(view_type, collection, pages, config)
|
||||
elif view_type == "feed":
|
||||
return _render_feed(view_type, collection, pages, config)
|
||||
elif view_type == "gantt":
|
||||
return _render_gantt(view_type, collection, pages, config)
|
||||
else:
|
||||
return _render_table(view_type, collection, pages, config)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _base_html(title: str, icon: str, view_type: str, body: str) -> str:
|
||||
return f"""<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>{title} — FlowDeck</title>
|
||||
<style>
|
||||
body{{font-family:system-ui,sans-serif;background:#191919;color:#fff;margin:0;padding:20px}}
|
||||
h1{{font-size:24px;margin:0 0 8px}} p.desc{{color:#A0A0A0;margin:0 0 20px}}
|
||||
.view-tabs{{display:flex;gap:4px;margin-bottom:20px;border-bottom:1px solid #333;padding-bottom:8px}}
|
||||
.tab{{padding:6px 14px;border-radius:6px;cursor:pointer;color:#A0A0A0;font-size:13px;background:none;border:none}}
|
||||
.tab:hover,.tab.active{{background:#333;color:#fff}}
|
||||
</style></head><body>
|
||||
<h1>{icon} {title}</h1>
|
||||
<div class="view-tabs">
|
||||
<a class="tab{' active' if view_type=='table' else ''}" href="?view_type=table">📊 Table</a>
|
||||
<a class="tab{' active' if view_type=='board' else ''}" href="?view_type=board">📋 Board</a>
|
||||
<a class="tab{' active' if view_type=='calendar' else ''}" href="?view_type=calendar">📅 Calendar</a>
|
||||
<a class="tab{' active' if view_type=='gallery' else ''}" href="?view_type=gallery">🖼️ Gallery</a>
|
||||
<a class="tab{' active' if view_type=='list' else ''}" href="?view_type=list">📝 List</a>
|
||||
<a class="tab{' active' if view_type=='timeline' else ''}" href="?view_type=timeline">📈 Timeline</a>
|
||||
<a class="tab{' active' if view_type=='gantt' else ''}" href="?view_type=gantt">📊 Gantt</a>
|
||||
<a class="tab{' active' if view_type=='chart' else ''}" href="?view_type=chart">📉 Chart</a>
|
||||
<a class="tab{' active' if view_type=='form' else ''}" href="?view_type=form">📋 Form</a>
|
||||
<a class="tab{' active' if view_type=='map' else ''}" href="?view_type=map">🗺️ Map</a>
|
||||
<a class="tab{' active' if view_type=='feed' else ''}" href="?view_type=feed">📰 Feed</a>
|
||||
</div>
|
||||
{body}
|
||||
</body></html>"""
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _render_calendar(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
||||
from datetime import date as dt_date
|
||||
from datetime import timedelta
|
||||
today = dt_date.today()
|
||||
# Determine month/year from config or current
|
||||
year = config.get("year", today.year)
|
||||
month = config.get("month", today.month)
|
||||
first = dt_date(year, month, 1)
|
||||
# Start from Monday of first week
|
||||
start = first - timedelta(days=first.weekday())
|
||||
days_in_month = []
|
||||
for i in range(42): # 6 weeks
|
||||
d = start + timedelta(days=i)
|
||||
days_in_month.append(d)
|
||||
|
||||
# Map pages to dates
|
||||
date_pages: dict[str, list[dict]] = {}
|
||||
for p in pages:
|
||||
props = json.loads(p.get("property_values_json", "{}"))
|
||||
for v in props.values():
|
||||
if isinstance(v, str) and v.startswith("20"):
|
||||
d = v[:10]
|
||||
date_pages.setdefault(d, []).append(p)
|
||||
break
|
||||
|
||||
cells = ""
|
||||
for d in days_in_month:
|
||||
iso = d.isoformat()
|
||||
items = date_pages.get(iso, [])
|
||||
other_month = " other-month" if d.month != month else ""
|
||||
today_class = " today" if d == today else ""
|
||||
items_html = "".join(
|
||||
f"<div class='cal-item' title='{p['title']}'>{p.get('icon','📄')} {p['title'][:20]}</div>"
|
||||
for p in items
|
||||
)
|
||||
cells += f"<div class='cal-day{other_month}{today_class}'><span class='cal-num'>{d.day}</span>{items_html}</div>"
|
||||
|
||||
prev = first - timedelta(days=1)
|
||||
next_month = first + timedelta(days=32)
|
||||
next_month = next_month.replace(day=1)
|
||||
|
||||
return _base_html(collection["name"], collection.get("icon", "📅"), view_type, f"""
|
||||
<style>
|
||||
.calendar{{display:grid;grid-template-columns:repeat(7,1fr);gap:1px;background:#333;border-radius:8px;overflow:hidden}}
|
||||
.cal-header{{background:#222;padding:8px;text-align:center;font-size:11px;color:#A0A0A0;text-transform:uppercase}}
|
||||
.cal-day{{background:#1a1a1a;min-height:80px;padding:4px}}
|
||||
.cal-day.other-month{{opacity:.35}}
|
||||
.cal-day.today{{background:#1a2744}}
|
||||
.cal-num{{font-size:12px;color:#A0A0A0;display:block;margin-bottom:2px}}
|
||||
.cal-item{{font-size:11px;padding:2px 4px;margin:1px 0;background:#333;border-radius:3px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}}
|
||||
.cal-nav{{display:flex;gap:8px;align-items:center;margin-bottom:12px}}
|
||||
.cal-nav a{{color:#3366CC;text-decoration:none;font-size:14px}}
|
||||
.cal-nav span{{font-size:16px;font-weight:600}}
|
||||
</style>
|
||||
<div class="cal-nav">
|
||||
<a href="?view_type=calendar&year={prev.year}&month={prev.month}">← {prev.strftime('%B')}</a>
|
||||
<span>{first.strftime('%B %Y')}</span>
|
||||
<a href="?view_type=calendar&year={next_month.year}&month={next_month.month}">{next_month.strftime('%B')} →</a>
|
||||
</div>
|
||||
<div class="calendar">
|
||||
<div class="cal-header">Mon</div><div class="cal-header">Tue</div><div class="cal-header">Wed</div>
|
||||
<div class="cal-header">Thu</div><div class="cal-header">Fri</div><div class="cal-header">Sat</div><div class="cal-header">Sun</div>
|
||||
{cells}
|
||||
</div>
|
||||
<p class="desc">{len(pages)} pages in collection</p>
|
||||
""")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _render_gallery(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
||||
card_size = config.get("card_size", "medium")
|
||||
size_css = {"small": "160px", "medium": "220px", "large": "300px"}.get(card_size, "220px")
|
||||
|
||||
cards = ""
|
||||
for p in pages:
|
||||
props = json.loads(p.get("property_values_json", "{}"))
|
||||
cover_url = config.get("cover_property")
|
||||
cover_html = ""
|
||||
if cover_url:
|
||||
for _k, v in props.items():
|
||||
if isinstance(v, list) and len(v) > 0:
|
||||
url = v[0].get("url", "") if isinstance(v[0], dict) else str(v[0])
|
||||
if url.startswith("http"):
|
||||
cover_html = f"<div class='gal-cover' style='background-image:url({url})'></div>"
|
||||
break
|
||||
|
||||
prop_tags = "".join(
|
||||
f"<span class='gal-prop'>{str(v)[:30]}</span>"
|
||||
for v in list(props.values())[:3] if v
|
||||
)
|
||||
|
||||
cards += f"""<div class='gal-card'>
|
||||
{cover_html}
|
||||
<div class='gal-body'>
|
||||
<div class='gal-title'>{p.get('icon','📄')} {p['title']}</div>
|
||||
<div class='gal-props'>{prop_tags}</div>
|
||||
</div>
|
||||
</div>"""
|
||||
|
||||
return _base_html(collection["name"], collection.get("icon", "🖼️"), view_type, f"""
|
||||
<style>
|
||||
.gallery{{display:grid;grid-template-columns:repeat(auto-fill,minmax({size_css},1fr));gap:12px}}
|
||||
.gal-card{{background:#1a1a1a;border-radius:8px;overflow:hidden;border:1px solid #333}}
|
||||
.gal-card:hover{{border-color:#555}}
|
||||
.gal-cover{{height:120px;background:#222;background-size:cover;background-position:center}}
|
||||
.gal-body{{padding:12px}}
|
||||
.gal-title{{font-size:14px;font-weight:500;margin-bottom:6px}}
|
||||
.gal-props{{display:flex;flex-wrap:wrap;gap:4px}}
|
||||
.gal-prop{{font-size:11px;padding:2px 6px;background:#333;border-radius:4px;color:#A0A0A0}}
|
||||
</style>
|
||||
<div class="gallery">{cards}</div>
|
||||
<p class="desc">{len(pages)} cards</p>
|
||||
""")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _render_list(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
||||
items = ""
|
||||
for p in pages:
|
||||
props = json.loads(p.get("property_values_json", "{}"))
|
||||
preview = " · ".join(str(v)[:60] for v in list(props.values())[:3] if v)
|
||||
items += f"""<div class='list-item'>
|
||||
<span class='list-icon'>{p.get('icon','📄')}</span>
|
||||
<div class='list-content'>
|
||||
<div class='list-title'>{p['title']}</div>
|
||||
<div class='list-preview'>{preview}</div>
|
||||
</div>
|
||||
</div>"""
|
||||
|
||||
return _base_html(collection["name"], collection.get("icon", "📝"), view_type, f"""
|
||||
<style>
|
||||
.list-item{{display:flex;align-items:flex-start;gap:10px;padding:10px 12px;background:#1a1a1a;border-radius:6px;margin-bottom:4px;border:1px solid #222}}
|
||||
.list-item:hover{{border-color:#444}}
|
||||
.list-icon{{font-size:18px;margin-top:1px}}
|
||||
.list-content{{flex:1;min-width:0}}
|
||||
.list-title{{font-size:14px;font-weight:500}}
|
||||
.list-preview{{font-size:12px;color:#A0A0A0;margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}
|
||||
</style>
|
||||
{items}
|
||||
<p class="desc">{len(pages)} items</p>
|
||||
""")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _render_timeline(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
||||
# Find date range
|
||||
dates = []
|
||||
page_dates = []
|
||||
for p in pages:
|
||||
props = json.loads(p.get("property_values_json", "{}"))
|
||||
start_val = end_val = None
|
||||
for _k, v in props.items():
|
||||
if isinstance(v, str) and v.startswith("20"):
|
||||
if "..." in v:
|
||||
parts = v.split("...")
|
||||
start_val, end_val = parts[0][:10], parts[1][:10] if len(parts) > 1 else parts[0][:10]
|
||||
else:
|
||||
start_val = end_val = v[:10]
|
||||
break
|
||||
if start_val:
|
||||
dates.append(start_val)
|
||||
if end_val:
|
||||
dates.append(end_val)
|
||||
page_dates.append((p, start_val, end_val or start_val))
|
||||
|
||||
if not dates:
|
||||
return _base_html(collection["name"], collection.get("icon", "📈"), view_type,
|
||||
"<p class='desc'>No date data to display timeline.</p>")
|
||||
|
||||
from datetime import date as dt_date
|
||||
min_date = min(dt_date.fromisoformat(d) for d in dates)
|
||||
max_date = max(dt_date.fromisoformat(d) for d in dates)
|
||||
total = (max_date - min_date).days or 1
|
||||
|
||||
bars = ""
|
||||
for p, start, end in page_dates:
|
||||
sd = dt_date.fromisoformat(start)
|
||||
ed = dt_date.fromisoformat(end)
|
||||
left = (sd - min_date).days / total * 100
|
||||
width = max((ed - sd).days / total * 100, 1)
|
||||
bars += f"""<div class='tl-row'>
|
||||
<span class='tl-label'>{p.get('icon','📄')} {p['title']}</span>
|
||||
<div class='tl-track'>
|
||||
<div class='tl-bar' style='left:{left:.1f}%;width:{width:.1f}%' title='{start} → {end}'></div>
|
||||
</div>
|
||||
</div>"""
|
||||
|
||||
return _base_html(collection["name"], collection.get("icon", "📈"), view_type, f"""
|
||||
<style>
|
||||
.tl-row{{display:flex;align-items:center;margin-bottom:8px;gap:12px}}
|
||||
.tl-label{{width:160px;font-size:13px;text-align:right;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}
|
||||
.tl-track{{flex:1;height:28px;background:#222;border-radius:4px;position:relative}}
|
||||
.tl-bar{{position:absolute;top:4px;height:20px;background:#3366CC;border-radius:4px;min-width:4px}}
|
||||
</style>
|
||||
<div class="tl-header" style="display:flex;margin-bottom:16px;padding-left:172px">
|
||||
<span style="flex:1;font-size:11px;color:#A0A0A0">{min_date}</span>
|
||||
<span style="font-size:11px;color:#A0A0A0">{max_date}</span>
|
||||
</div>
|
||||
{bars}
|
||||
<p class="desc">{len(pages)} items · {min_date} → {max_date}</p>
|
||||
""")
|
||||
|
||||
|
||||
# ── v4.3.0: New view types ──
|
||||
|
||||
# Multi-collection dashboards and chart aggregations cap the number of
|
||||
# input rows/groups at 200 (keeps the rendered HTML and export reasonable).
|
||||
|
||||
|
||||
|
||||
|
||||
def _chart_values(pages: list[dict], chart_property: str) -> list[float]:
|
||||
"""Numeric values of ``chart_property`` across ``pages`` (cap 200)."""
|
||||
values: list[float] = []
|
||||
for p in pages[:CHART_MAX_GROUPS]:
|
||||
props = json.loads(p.get("property_values_json", "{}") or "{}")
|
||||
v = props.get(chart_property)
|
||||
if v is None or v == "":
|
||||
continue
|
||||
try:
|
||||
values.append(float(v))
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
return values
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _chart_aggregate(pages: list[dict], chart_property: str, aggregate: str) -> float:
|
||||
"""Compute count|sum|avg|min|max over a property (or row count)."""
|
||||
values = _chart_values(pages, chart_property)
|
||||
if aggregate == "count":
|
||||
return float(len(pages[:CHART_MAX_GROUPS]))
|
||||
if not values:
|
||||
return 0.0
|
||||
if aggregate == "sum":
|
||||
return float(sum(values))
|
||||
if aggregate == "avg":
|
||||
return float(sum(values) / len(values))
|
||||
if aggregate == "min":
|
||||
return float(min(values))
|
||||
if aggregate == "max":
|
||||
return float(max(values))
|
||||
return 0.0
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _fmt_number(value: float) -> str:
|
||||
if abs(value) >= 1e9:
|
||||
return f"{value / 1e9:.2f}B"
|
||||
if abs(value) >= 1e6:
|
||||
return f"{value / 1e6:.2f}M"
|
||||
if abs(value) >= 1e3:
|
||||
return f"{value / 1e3:.1f}K"
|
||||
if value == int(value):
|
||||
return str(int(value))
|
||||
return f"{value:.2f}"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _render_chart(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
||||
"""Chart view — bar, line, pie, doughnut, scatter via Chart.js CDN, plus
|
||||
the v7.3.0 ``number`` KPI widget (count|sum|avg|min|max aggregate)."""
|
||||
chart_type = config.get("chart_type", "bar")
|
||||
chart_property = config.get("chart_property", "")
|
||||
|
||||
if chart_type == "number":
|
||||
aggregate = config.get("aggregate", "sum" if chart_property else "count")
|
||||
if aggregate not in ("count", "sum", "avg", "min", "max"):
|
||||
aggregate = "sum" if chart_property else "count"
|
||||
num = _chart_aggregate(pages, chart_property, aggregate)
|
||||
label = config.get("title") or chart_property or collection["name"]
|
||||
return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""
|
||||
<style>
|
||||
.kpi{{max-width:420px;margin:60px auto;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.12);border-radius:14px;padding:36px;text-align:center}}
|
||||
.kpi-label{{font-size:13px;text-transform:uppercase;letter-spacing:1.2px;opacity:.6;margin-bottom:10px}}
|
||||
.kpi-value{{font-size:64px;font-weight:700;line-height:1;font-variant-numeric:tabular-nums}}
|
||||
.kpi-agg{{font-size:12px;color:var(--text-dim);margin-top:12px}}
|
||||
</style>
|
||||
<div class="kpi">
|
||||
<div class="kpi-label">{label}</div>
|
||||
<div class="kpi-value">{_fmt_number(num)}</div>
|
||||
<div class="kpi-agg">{aggregate} · {len(pages[:CHART_MAX_GROUPS])} row(s)
|
||||
{' of ' + chart_property if chart_property else ''}</div>
|
||||
</div>
|
||||
""")
|
||||
|
||||
labels = []
|
||||
values = []
|
||||
for p in pages[:CHART_MAX_GROUPS]:
|
||||
labels.append(str(p.get("title") or "")[:30])
|
||||
props = json.loads(p.get("property_values_json", "{}") or "{}")
|
||||
val = 0.0
|
||||
if chart_property:
|
||||
v_raw = props.get(chart_property, 0)
|
||||
try:
|
||||
val = float(v_raw) if v_raw else 0.0
|
||||
except (ValueError, TypeError):
|
||||
val = 0.0
|
||||
values.append(val)
|
||||
|
||||
labels_json = json.dumps(labels)
|
||||
values_json = json.dumps(values)
|
||||
subtitle = (f"{len(pages[:CHART_MAX_GROUPS])} entries"
|
||||
+ (f" (truncated at {CHART_MAX_GROUPS})" if len(pages) > CHART_MAX_GROUPS else ""))
|
||||
|
||||
return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""
|
||||
<style>
|
||||
.chart-container{{max-width:800px;margin:0 auto}}
|
||||
canvas{{max-height:400px}}
|
||||
</style>
|
||||
<div class="chart-container"><canvas id="chartCanvas"></canvas></div>
|
||||
<script src="/static/js/vendor/chart.umd.js"></script>
|
||||
<script nonce="{CSP_NONCE.get()}">
|
||||
new Chart(document.getElementById('chartCanvas'), {{
|
||||
type: '{chart_type}',
|
||||
data: {{
|
||||
labels: {labels_json},
|
||||
datasets: [{{
|
||||
label: '{config.get("title") or collection["name"]}',
|
||||
data: {values_json},
|
||||
backgroundColor: ['#3366CC','#DC3912','#FF9900','#109618','#990099','#0099C6','#DD4477','#66AA00'],
|
||||
}}]
|
||||
}},
|
||||
options: {{ responsive: true }}
|
||||
}});
|
||||
</script>
|
||||
<p class="desc">{subtitle}</p>
|
||||
""")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _render_form(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
||||
"""Form view — generates an HTML form that creates new pages in the collection."""
|
||||
properties = []
|
||||
with get_conn() as conn:
|
||||
props = conn.execute(
|
||||
"SELECT name, prop_type, options_json FROM collection_properties WHERE collection_id=? AND prop_type!='formula' ORDER BY position",
|
||||
(collection["id"],),
|
||||
).fetchall()
|
||||
for p in props:
|
||||
prop_dict = dict(p)
|
||||
prop_dict["options"] = json.loads(prop_dict.get("options_json", "[]"))
|
||||
properties.append(prop_dict)
|
||||
|
||||
fields = ""
|
||||
for prop in properties:
|
||||
name = prop["name"]
|
||||
ptype = prop["prop_type"]
|
||||
if ptype in ("text", "email", "url", "phone", "number"):
|
||||
fields += f"""<div class='form-field'><label>{name}</label><input type='{"number" if ptype=="number" else "text"}' name='prop_{name}' placeholder='{name}'></div>"""
|
||||
elif ptype in ("select", "status"):
|
||||
options = "".join(f"<option value='{o}'>{o}</option>" for o in prop.get("options", []))
|
||||
fields += f"""<div class='form-field'><label>{name}</label><select name='prop_{name}'>{options}</select></div>"""
|
||||
elif ptype == "checkbox":
|
||||
fields += f"""<div class='form-field'><label><input type='checkbox' name='prop_{name}' value='1'> {name}</label></div>"""
|
||||
elif ptype == "date":
|
||||
fields += f"""<div class='form-field'><label>{name}</label><input type='date' name='prop_{name}'></div>"""
|
||||
|
||||
return _base_html(collection["name"], collection.get("icon", "📝"), view_type, f"""
|
||||
<style>
|
||||
.form-field{{margin-bottom:12px}}
|
||||
.form-field label{{display:block;font-size:13px;color:#A0A0A0;margin-bottom:4px}}
|
||||
.form-field input,.form-field select{{width:100%;max-width:400px;padding:8px;background:#333;border:1px solid #555;border-radius:6px;color:#fff;font-size:14px}}
|
||||
.form-submit{{padding:8px 20px;background:#3366CC;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:14px;margin-top:8px}}
|
||||
.form-submit:hover{{background:#254E99}}
|
||||
</style>
|
||||
<div class="form-container">
|
||||
<h3>New entry in {collection['name']}</h3>
|
||||
<form id="collectionForm" onsubmit="submitForm(event)">
|
||||
{fields}
|
||||
<div class='form-field'><label>Title</label><input type='text' name='title' placeholder='Page title' required></div>
|
||||
<button type='submit' class='form-submit'>Submit</button>
|
||||
</form>
|
||||
<div id="formResult" style="margin-top:12px;color:#4CAF50;display:none">✓ Created successfully!</div>
|
||||
</div>
|
||||
<script nonce="{CSP_NONCE.get()}">
|
||||
async function submitForm(e) {{
|
||||
e.preventDefault();
|
||||
const form = document.getElementById('collectionForm');
|
||||
const fd = new FormData(form);
|
||||
const properties = {{}};
|
||||
const title = fd.get('title') || 'New entry';
|
||||
fd.forEach((v,k) => {{ if(k.startsWith('prop_')) properties[k.slice(5)] = v; }});
|
||||
const resp = await fetch('/db/{collection["id"]}/pages/api', {{
|
||||
method:'POST', headers:{{'Content-Type':'application/json'}},
|
||||
body: JSON.stringify({{title, properties}})
|
||||
}});
|
||||
if(resp.ok) {{
|
||||
document.getElementById('formResult').style.display='block';
|
||||
form.reset();
|
||||
}}
|
||||
}}
|
||||
</script>
|
||||
""")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _render_map(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
||||
"""Map view — displays pages with location data on Leaflet map."""
|
||||
markers = []
|
||||
for p in pages:
|
||||
props = json.loads(p.get("property_values_json", "{}"))
|
||||
lat, lng = None, None
|
||||
for _k, v in props.items():
|
||||
if isinstance(v, str) and "," in v:
|
||||
parts = v.split(",")
|
||||
try:
|
||||
lat, lng = float(parts[0].strip()), float(parts[1].strip())
|
||||
except ValueError:
|
||||
continue
|
||||
elif isinstance(v, dict):
|
||||
lat = v.get("lat")
|
||||
lng = v.get("lng")
|
||||
if lat and lng:
|
||||
markers.append({"title": p["title"], "lat": lat, "lng": lng})
|
||||
|
||||
markers_json = json.dumps(markers)
|
||||
center_lat = markers[0]["lat"] if markers else 45.5
|
||||
center_lng = markers[0]["lng"] if markers else -73.5
|
||||
|
||||
return _base_html(collection["name"], collection.get("icon", "🗺️"), view_type, f"""
|
||||
<style>
|
||||
#map{{height:400px;border-radius:8px}}
|
||||
</style>
|
||||
<link rel="stylesheet" href="/static/js/vendor/leaflet.css" />
|
||||
<div id="map"></div>
|
||||
<script src="/static/js/vendor/leaflet.js"></script>
|
||||
<script nonce="{CSP_NONCE.get()}">
|
||||
const map = L.map('map').setView([{center_lat}, {center_lng}], 6);
|
||||
L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png', {{attribution:'© OSM'}}).addTo(map);
|
||||
const markers = {markers_json};
|
||||
markers.forEach(m => L.marker([m.lat, m.lng]).addTo(map).bindPopup(m.title));
|
||||
if(markers.length===0) L.marker([{center_lat},{center_lng}]).addTo(map).bindPopup('Default');
|
||||
</script>
|
||||
<p class="desc">{len(markers)} location(s) mapped</p>
|
||||
""")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _render_feed(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
||||
"""Feed view — chronological feed of pages, newest first."""
|
||||
sorted_pages = sorted(pages, key=lambda p: p.get("created_at", ""), reverse=True)
|
||||
items = ""
|
||||
for p in sorted_pages:
|
||||
created = p.get("created_at", "")[:10] if p.get("created_at") else ""
|
||||
items += f"""<div class='feed-item'>
|
||||
<div class='feed-meta'>{created}</div>
|
||||
<div class='feed-title'>{p.get('icon','📄')} {p['title']}</div>
|
||||
</div>"""
|
||||
|
||||
return _base_html(collection["name"], collection.get("icon", "📰"), view_type, f"""
|
||||
<style>
|
||||
.feed-item{{padding:12px 16px;border-left:2px solid #333;margin-bottom:8px;background:#1a1a1a;border-radius:0 8px 8px 0}}
|
||||
.feed-item:hover{{border-left-color:#3366CC}}
|
||||
.feed-meta{{font-size:11px;color:#A0A0A0;margin-bottom:4px}}
|
||||
.feed-title{{font-size:14px;font-weight:500}}
|
||||
</style>
|
||||
{items}
|
||||
<p class="desc">{len(sorted_pages)} entries</p>
|
||||
""")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _render_gantt(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
||||
"""Gantt view — timeline with dependencies and group_by support."""
|
||||
group_by = config.get("group_by", "")
|
||||
|
||||
gantt_data = []
|
||||
for p in pages:
|
||||
props = json.loads(p.get("property_values_json", "{}"))
|
||||
group = None
|
||||
start_val = end_val = None
|
||||
for _k, v in props.items():
|
||||
if isinstance(v, str) and v.startswith("20"):
|
||||
if "→" in v:
|
||||
parts = v.split("→")
|
||||
start_val, end_val = parts[0].strip()[:10], parts[1].strip()[:10] if len(parts) > 1 else parts[0].strip()[:10]
|
||||
elif "..." in v:
|
||||
parts = v.split("...")
|
||||
start_val, end_val = parts[0][:10], parts[1][:10] if len(parts) > 1 else parts[0][:10]
|
||||
else:
|
||||
start_val = end_val = v[:10]
|
||||
break
|
||||
if group_by:
|
||||
group = str(props.get(group_by, props.get("Status", "")))[:20]
|
||||
if start_val:
|
||||
gantt_data.append({"title": p["title"], "start": start_val, "end": end_val or start_val, "group": group or ""})
|
||||
|
||||
if not gantt_data:
|
||||
return _base_html(collection["name"], collection.get("icon", "📊"), view_type, "<p class='desc'>No date data for Gantt chart.</p>")
|
||||
|
||||
from datetime import date as dt_date_2
|
||||
all_dates = [d["start"] for d in gantt_data] + [d["end"] for d in gantt_data]
|
||||
min_date = min(dt_date_2.fromisoformat(d) for d in all_dates)
|
||||
max_date = max(dt_date_2.fromisoformat(d) for d in all_dates)
|
||||
total_days = max((max_date - min_date).days, 1)
|
||||
|
||||
groups = {}
|
||||
for d in gantt_data:
|
||||
groups.setdefault(d["group"], []).append(d)
|
||||
if not groups or all(k == "" for k in groups):
|
||||
groups = {"": gantt_data}
|
||||
|
||||
rows = ""
|
||||
for group_name, items in sorted(groups.items()):
|
||||
if group_name:
|
||||
rows += f"<div class='gantt-group'>{group_name} ({len(items)})</div>"
|
||||
for item in items:
|
||||
sd = dt_date_2.fromisoformat(item["start"])
|
||||
ed = dt_date_2.fromisoformat(item["end"])
|
||||
left = max((sd - min_date).days / total_days * 100, 0)
|
||||
width = max((ed - sd).days / total_days * 100, 1)
|
||||
rows += f"""<div class='gantt-row'>
|
||||
<span class='gantt-label'>{item['title'][:30]}</span>
|
||||
<div class='gantt-track'><div class='gantt-bar' style='left:{left:.1f}%;width:{width:.1f}%' title='{item["start"]} → {item["end"]}'></div></div>
|
||||
<span class='gantt-dates'>{item['start']} → {item['end']}</span>
|
||||
</div>"""
|
||||
|
||||
return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""
|
||||
<style>
|
||||
.gantt-group{{padding:8px 12px;background:#222;font-size:13px;font-weight:600;margin:8px 0 4px;border-radius:4px}}
|
||||
.gantt-row{{display:flex;align-items:center;margin-bottom:6px;gap:8px}}
|
||||
.gantt-label{{width:180px;font-size:12px;text-align:right;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}
|
||||
.gantt-track{{flex:1;height:24px;background:#222;border-radius:4px;position:relative}}
|
||||
.gantt-bar{{position:absolute;top:3px;height:18px;background:linear-gradient(90deg,#3366CC,#5599EE);border-radius:4px;min-width:4px}}
|
||||
.gantt-dates{{font-size:10px;color:#A0A0A0;flex-shrink:0;min-width:140px}}
|
||||
</style>
|
||||
<div class="gantt-header" style="display:flex;margin-bottom:8px;padding-left:188px">
|
||||
<span style="flex:1;font-size:10px;color:#A0A0A0">{min_date}</span>
|
||||
<span style="font-size:10px;color:#A0A0A0">{max_date}</span>
|
||||
</div>
|
||||
{rows}
|
||||
<p class="desc">{len(gantt_data)} items · {min_date} → {max_date}</p>
|
||||
""")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _render_table(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
||||
rows = ""
|
||||
for p in pages:
|
||||
props = json.loads(p.get("property_values_json", "{}"))
|
||||
prop_cells = "".join(f"<td>{str(v)[:80]}</td>" for v in list(props.values())[:4])
|
||||
rows += f"<tr><td>{p.get('icon','📄')}</td><td>{p['title']}</td>{prop_cells}</tr>"
|
||||
|
||||
return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""
|
||||
<style>
|
||||
table{{width:100%;border-collapse:collapse}}
|
||||
th,td{{padding:8px 12px;text-align:left;font-size:13px;border-bottom:1px solid #333}}
|
||||
th{{color:#A0A0A0;font-weight:500;background:#1a1a1a;position:sticky;top:0}}
|
||||
tr:hover td{{background:#222}}
|
||||
</style>
|
||||
<table><thead><tr><th></th><th>Title</th><th>Properties</th></tr></thead><tbody>{rows}</tbody></table>
|
||||
<p class="desc">{len(pages)} rows</p>
|
||||
""")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""FlowDeck — Collections : boards.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/collections.py (2 622 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collections"], prefix="/db")
|
||||
|
||||
|
||||
|
||||
|
||||
# ── Gitea Board Compatibility (BEFORE {collection_id} wildcards) ──
|
||||
|
||||
|
||||
@router.get("/boards/api")
|
||||
def list_boards_as_collections(request: Request):
|
||||
"""API: list all Gitea boards as pseudo-collections."""
|
||||
from app.services.collection_adapter import GiteaBoardCompat
|
||||
boards = GiteaBoardCompat.list_boards_as_collections()
|
||||
return {"boards": boards}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/board/{owner}/{repo}/api")
|
||||
async def get_board_as_collection(request: Request, owner: str, repo: str):
|
||||
"""API: get a specific Gitea board as a pseudo-collection."""
|
||||
from app.services.collection_adapter import GiteaBoardCompat
|
||||
coll = GiteaBoardCompat.get_board_as_collection(owner, repo)
|
||||
if not coll:
|
||||
raise HTTPException(status_code=404, detail="Board not found")
|
||||
|
||||
from app.services.gitea_client import gitea
|
||||
issues = await gitea.get_issues(owner, repo, state="all")
|
||||
cards = GiteaBoardCompat.get_board_cards(owner, repo, issues)
|
||||
|
||||
return {"collection": coll, "pages": cards}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/board/{owner}/{repo}/sync")
|
||||
async def sync_board_to_collection(request: Request, owner: str, repo: str):
|
||||
"""Sync a Gitea board to a real collection."""
|
||||
from app.services.collection_adapter import GiteaBoardCompat
|
||||
from app.services.gitea_client import gitea
|
||||
|
||||
issues = await gitea.get_issues(owner, repo, state="all")
|
||||
coll_id = GiteaBoardCompat.sync_to_collection(owner, repo, issues)
|
||||
if coll_id is None:
|
||||
raise HTTPException(status_code=404, detail="Board not found")
|
||||
|
||||
return {"collection_id": coll_id, "status": "synced"}
|
||||
|
||||
|
||||
# ── Collection Properties (v1.4.0) ──
|
||||
@@ -0,0 +1,337 @@
|
||||
"""FlowDeck — Collections : crud.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/collections.py (2 622 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event, run_event_sync
|
||||
from app.services.db_templates import materialize_properties
|
||||
from app.services.permission_manager import PermissionManager
|
||||
|
||||
from ._common import _apply_template, _require_view, _session_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collections"], prefix="/db")
|
||||
|
||||
|
||||
|
||||
|
||||
# ── API: List & Create (no path params) ──
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
def list_collections(request: Request):
|
||||
"""Page listing all collections in the workspace."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM collections ORDER BY name"
|
||||
).fetchall()
|
||||
collections = [dict(r) for r in rows]
|
||||
return HTMLResponse(
|
||||
f"<div class='collections-list'>"
|
||||
f"<h2>Collections ({len(collections)})</h2>"
|
||||
f"<pre>{json.dumps(collections, indent=2, default=str)}</pre>"
|
||||
f"</div>"
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api")
|
||||
def list_collections_api(request: Request):
|
||||
"""API: list all collections."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM collections ORDER BY name"
|
||||
).fetchall()
|
||||
return {"collections": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api")
|
||||
def create_collection_api(request: Request, body: dict = Body(default={})):
|
||||
"""API: create a new collection, optionally from a database template."""
|
||||
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name is required")
|
||||
|
||||
description = body.get("description", "")
|
||||
icon = body.get("icon", "📋")
|
||||
gitea_owner = body.get("gitea_owner")
|
||||
gitea_repo = body.get("gitea_repo")
|
||||
schema = body.get("schema", [])
|
||||
is_locked = body.get("is_locked", False)
|
||||
|
||||
with get_conn() as conn:
|
||||
# Apply a template if requested (provides schema + icon).
|
||||
tpl = _apply_template(conn, body.get("template"))
|
||||
if tpl:
|
||||
if body.get("name"):
|
||||
name = body["name"].strip()
|
||||
description = tpl["description"]
|
||||
icon = tpl.get("icon") or icon
|
||||
try:
|
||||
schema = json.loads(tpl["schema_json"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
schema = []
|
||||
|
||||
schema_json = json.dumps(schema)
|
||||
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collections
|
||||
(name, description, icon, schema_json, gitea_owner, gitea_repo, is_locked)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(name, description, icon, schema_json, gitea_owner, gitea_repo, int(is_locked)),
|
||||
)
|
||||
collection_id = cur.lastrowid
|
||||
|
||||
materialize_properties(conn, collection_id, schema)
|
||||
|
||||
conn.execute(
|
||||
"""INSERT INTO collection_views
|
||||
(collection_id, name, view_type, config_json)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
(collection_id, "Default View", "table", json.dumps({
|
||||
"visible_properties": ["Title"],
|
||||
"sorts": [],
|
||||
"filters": [],
|
||||
})),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
run_event_sync(fire_event("collection.created", {"collection_id": collection_id, "name": name, "icon": icon}))
|
||||
return {"id": collection_id, "name": name, "status": "created"}
|
||||
|
||||
|
||||
# ── API: Update & Delete (no path-param conflicts) ──
|
||||
|
||||
|
||||
|
||||
|
||||
# ── API: Update & Delete (no path-param conflicts) ──
|
||||
|
||||
|
||||
@router.put("/api/{collection_id}")
|
||||
def update_collection_api(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""API: update a collection."""
|
||||
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT * FROM collections WHERE id=?", (collection_id,)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
name = body.get("name", existing["name"])
|
||||
description = body.get("description", existing["description"])
|
||||
icon = body.get("icon", existing["icon"])
|
||||
is_locked = body.get("is_locked", existing["is_locked"])
|
||||
schema_json = json.dumps(body.get("schema", json.loads(existing["schema_json"])))
|
||||
gitea_owner = body.get("gitea_owner", existing["gitea_owner"])
|
||||
gitea_repo = body.get("gitea_repo", existing["gitea_repo"])
|
||||
|
||||
conn.execute(
|
||||
"""UPDATE collections SET name=?, description=?, icon=?, schema_json=?,
|
||||
is_locked=?, gitea_owner=?, gitea_repo=?, updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=?""",
|
||||
(name, description, icon, schema_json, int(is_locked),
|
||||
gitea_owner, gitea_repo, collection_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
run_event_sync(fire_event("collection.updated", {"collection_id": collection_id, "name": name}))
|
||||
return {"id": collection_id, "status": "updated"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/api/{collection_id}")
|
||||
def delete_collection_api(request: Request, collection_id: int):
|
||||
"""API: delete a collection and its pages (CASCADE)."""
|
||||
# v6.0.0: granular collection permissions — owner/admin only.
|
||||
user = _session_user(request)
|
||||
_require_view(collection_id, user)
|
||||
if user:
|
||||
pm = PermissionManager(user["id"])
|
||||
if not pm.can_manage_collection_permissions(collection_id):
|
||||
raise HTTPException(status_code=403, detail="Only a collection owner can delete it")
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT * FROM collections WHERE id=?", (collection_id,)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
conn.execute("DELETE FROM collections WHERE id=?", (collection_id,))
|
||||
conn.commit()
|
||||
|
||||
run_event_sync(fire_event("collection.deleted", {"collection_id": collection_id,
|
||||
"name": existing["name"] if existing else ""}))
|
||||
return {"id": collection_id, "status": "deleted"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/{collection_id}/duplicate")
|
||||
def duplicate_collection_api(request: Request, collection_id: int):
|
||||
"""v5.4.0: deep-duplicate a database (views + properties + pages + data
|
||||
sources) into a new collection named '<original> (copy)'."""
|
||||
with get_conn() as conn:
|
||||
src = conn.execute(
|
||||
"SELECT * FROM collections WHERE id=?", (collection_id,)
|
||||
).fetchone()
|
||||
if not src:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
new_name = (src["name"] or "Database") + " copy"
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collections
|
||||
(name, description, icon, schema_json, gitea_owner, gitea_repo, is_locked,
|
||||
is_inline, parent_page_id, workspace_id, created_by, is_task, updated_at)
|
||||
SELECT ?, description, icon, schema_json, gitea_owner, gitea_repo, is_locked,
|
||||
is_inline, parent_page_id, workspace_id, created_by, is_task, updated_at
|
||||
FROM collections WHERE id=?""",
|
||||
(new_name, collection_id),
|
||||
)
|
||||
new_id = cur.lastrowid
|
||||
|
||||
# ── Properties (remap ids so relation/rollup refs stay valid) ──
|
||||
prop_map: dict[int, int] = {}
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
# A23 : un seul executemany ; les rowid sont contigus (même transaction,
|
||||
# insertion dans l'ordre de `rows`), donc le mappeur se fait par index.
|
||||
tuples = [
|
||||
(new_id, p["name"], p["prop_type"], p["options_json"], p["number_format"],
|
||||
None, p["reverse_name"], None, None, p["rollup_function"],
|
||||
p["formula_expression"], p["position"], p["required"],
|
||||
p["visible_in_views"])
|
||||
for p in rows
|
||||
]
|
||||
if tuples:
|
||||
ncur = conn.executemany(
|
||||
"""INSERT INTO collection_properties
|
||||
(collection_id, name, prop_type, options_json, number_format,
|
||||
related_collection_id, reverse_name, relation_property_id,
|
||||
target_property_id, rollup_function, formula_expression,
|
||||
position, required, visible_in_views)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
tuples,
|
||||
)
|
||||
new_ids = [
|
||||
r["id"]
|
||||
for r in conn.execute(
|
||||
"SELECT id FROM collection_properties WHERE collection_id=? ORDER BY id",
|
||||
(new_id,),
|
||||
).fetchall()
|
||||
]
|
||||
assert len(new_ids) == len(tuples), "remap des propriétés : effectif inattendu"
|
||||
for p, new_pid in zip(rows, new_ids, strict=True):
|
||||
prop_map[p["id"]] = new_pid
|
||||
|
||||
# Fix cross-property references after all rows exist (creates may target
|
||||
# columns not inserted yet). Related collection remapped to the copy.
|
||||
for p in rows:
|
||||
p = dict(p) # convert sqlite3.Row to dict
|
||||
new_pid = prop_map[p["id"]]
|
||||
related = p["related_collection_id"]
|
||||
related_new = new_id if related == collection_id else related
|
||||
if p["prop_type"] == "relation":
|
||||
conn.execute(
|
||||
"UPDATE collection_properties SET related_collection_id=? WHERE id=?",
|
||||
(related_new, new_pid),
|
||||
)
|
||||
if p.get("relation_property_id") and p["relation_property_id"] in prop_map:
|
||||
conn.execute(
|
||||
"UPDATE collection_properties SET relation_property_id=? WHERE id=?",
|
||||
(prop_map[p["relation_property_id"]], new_pid),
|
||||
)
|
||||
if p.get("target_property_id") and p["target_property_id"] in prop_map:
|
||||
conn.execute(
|
||||
"UPDATE collection_properties SET target_property_id=? WHERE id=?",
|
||||
(prop_map[p["target_property_id"]], new_pid),
|
||||
)
|
||||
|
||||
# ── Views ──
|
||||
vrows = conn.execute(
|
||||
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
for v in vrows:
|
||||
conn.execute(
|
||||
"""INSERT INTO collection_views
|
||||
(collection_id, name, view_type, config_json, position)
|
||||
VALUES (?,?,?,?,?)""",
|
||||
(new_id, v["name"], v["view_type"], v["config_json"], v["position"]),
|
||||
)
|
||||
|
||||
# ── Pages (rows) with property ids remapped to the copy's properties ──
|
||||
prows = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
page_map: dict[int, int] = {}
|
||||
for p in prows:
|
||||
try:
|
||||
pv = json.loads(p["property_values_json"]) if p["property_values_json"] else {}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pv = {}
|
||||
pv_new = {}
|
||||
for k, val in pv.items():
|
||||
try:
|
||||
prop_id = int(k)
|
||||
except (ValueError, TypeError):
|
||||
prop_id = None
|
||||
new_key = str(prop_map.get(prop_id, prop_id)) if prop_id is not None else k
|
||||
pv_new[new_key] = val
|
||||
ncur = conn.execute(
|
||||
"""INSERT INTO collection_pages
|
||||
(collection_id, title, icon, position, parent_id, gitea_issue_id,
|
||||
gitea_issue_number, property_values_json, created_at, updated_at)
|
||||
SELECT ?, title, icon, position, NULL, NULL, NULL, ?, created_at, updated_at
|
||||
FROM collection_pages WHERE id=?""",
|
||||
(new_id, json.dumps(pv_new), p["id"]),
|
||||
)
|
||||
page_map[p["id"]] = ncur.lastrowid
|
||||
|
||||
# Re-parent sub-items to the copied rows.
|
||||
for p in prows:
|
||||
if p["parent_id"] and p["parent_id"] in page_map:
|
||||
conn.execute(
|
||||
"UPDATE collection_pages SET parent_id=? WHERE id=?",
|
||||
(page_map[p["parent_id"]], page_map[p["id"]]),
|
||||
)
|
||||
|
||||
# ── Data sources (linked DBs) ──
|
||||
drows = conn.execute(
|
||||
"SELECT * FROM collection_data_sources WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
for d in drows:
|
||||
src_coll = d["source_collection_id"]
|
||||
src_now = new_id if src_coll == collection_id else src_coll
|
||||
conn.execute(
|
||||
"""INSERT INTO collection_data_sources
|
||||
(collection_id, source_collection_id, source_name, is_linked, position)
|
||||
VALUES (?,?,?,?,?)""",
|
||||
(new_id, src_now, d["source_name"], d["is_linked"], d["position"]),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
run_event_sync(fire_event("collection.created", {"collection_id": new_id, "name": new_name}))
|
||||
return {"id": new_id, "name": new_name, "status": "duplicated"}
|
||||
|
||||
|
||||
# ── Page CRUD (standalone, BEFORE collection wildcards) ──
|
||||
@@ -0,0 +1,214 @@
|
||||
"""FlowDeck — Collections : dashboard_views.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/collections.py (2 622 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import html as _htmlmod
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
from ._common import _require_view, _session_user
|
||||
from ._renderers import CHART_MAX_GROUPS, _base_html, _render_chart, _render_view
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collections"], prefix="/db")
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/{collection_id}/pages/{page_id}/auto-shift/api")
|
||||
def auto_shift_dates(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})):
|
||||
"""API: auto-shift dates based on blocking dependencies."""
|
||||
from datetime import date as dt_date
|
||||
from datetime import timedelta
|
||||
|
||||
skip_weekends = body.get("skip_weekends", False)
|
||||
|
||||
with get_conn() as conn:
|
||||
page = conn.execute("SELECT property_values_json FROM collection_pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not page:
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
|
||||
# Get all blocking dependencies
|
||||
deps = conn.execute(
|
||||
"SELECT * FROM page_dependencies WHERE page_id=? AND dependency_type='blocks'",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
|
||||
shifted = False
|
||||
new_start = None
|
||||
for dep in deps:
|
||||
dep_page = conn.execute(
|
||||
"SELECT title, property_values_json FROM collection_pages WHERE id=?",
|
||||
(dep["dependency_id"],),
|
||||
).fetchone()
|
||||
if not dep_page:
|
||||
continue
|
||||
dep_props = json.loads(dep_page["property_values_json"])
|
||||
# Find the latest end date among blockers
|
||||
for v in dep_props.values():
|
||||
if isinstance(v, str) and v.startswith("20"):
|
||||
end_date = v.split("...")[-1].split("→")[-1].strip()[:10]
|
||||
try:
|
||||
ed = dt_date.fromisoformat(end_date)
|
||||
if new_start is None or ed >= new_start:
|
||||
new_start = ed + timedelta(days=1)
|
||||
shifted = True
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if not shifted:
|
||||
return {"page_id": page_id, "shifted": False, "message": "No blocking dependencies with dates found"}
|
||||
|
||||
# Skip weekends if requested
|
||||
if skip_weekends and new_start:
|
||||
while new_start.weekday() >= 5: # 5=Sat, 6=Sun
|
||||
new_start = new_start + timedelta(days=1)
|
||||
|
||||
# Update the page's date properties
|
||||
props = json.loads(page["property_values_json"])
|
||||
for k, v in list(props.items()):
|
||||
if isinstance(v, str) and v.startswith("20"):
|
||||
old_parts = v.split("...")
|
||||
old_end = old_parts[-1] if len(old_parts) > 1 else old_parts[0]
|
||||
try:
|
||||
old_start_d = dt_date.fromisoformat(old_parts[0][:10])
|
||||
old_end_d = dt_date.fromisoformat(old_end[:10])
|
||||
duration = (old_end_d - old_start_d).days
|
||||
new_end = new_start + timedelta(days=max(duration, 0))
|
||||
props[k] = f"{new_start.isoformat()}...{new_end.isoformat()}"
|
||||
except ValueError:
|
||||
props[k] = new_start.isoformat()
|
||||
break
|
||||
|
||||
conn.execute(
|
||||
"UPDATE collection_pages SET property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(json.dumps(props), page_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {"page_id": page_id, "shifted": True, "new_start": new_start.isoformat(), "skip_weekends": skip_weekends}
|
||||
|
||||
|
||||
# ── {collection_id} wildcards (LAST — catches everything else) ──
|
||||
|
||||
|
||||
|
||||
|
||||
# ── {collection_id} wildcards (LAST — catches everything else) ──
|
||||
|
||||
|
||||
@router.get("/{collection_id}/dashboards/{dashboard_id}", response_class=HTMLResponse)
|
||||
def view_dashboard(request: Request, collection_id: int, dashboard_id: int):
|
||||
"""v7.3.0: render a collection dashboard grid (multi-DB widgets).
|
||||
|
||||
Widgets live in ``collection_dashboards.layout_json`` as
|
||||
``{"columns": N, "widgets": [{collection_id?, view_type?, chart_type?,
|
||||
chart_property?, aggregate?, title?, width?, height?}]}``. Each widget may
|
||||
point at *any* database (the dashboard's own collection is the default),
|
||||
which is what "dashboards multi-DB" means.
|
||||
"""
|
||||
uid = _session_user(request)
|
||||
_require_view(collection_id, uid)
|
||||
with get_conn() as conn:
|
||||
dash = conn.execute(
|
||||
"SELECT * FROM collection_dashboards WHERE id=? AND collection_id=?",
|
||||
(dashboard_id, collection_id)).fetchone()
|
||||
if not dash:
|
||||
raise HTTPException(404, "Dashboard not found")
|
||||
layout = json.loads(dash["layout_json"] or "{}")
|
||||
columns = max(1, int(layout.get("columns", 1) or 1))
|
||||
widgets = layout.get("widgets", []) or []
|
||||
if not isinstance(widgets, list):
|
||||
widgets = []
|
||||
|
||||
rendered = []
|
||||
for w in widgets[:40]:
|
||||
if not isinstance(w, dict):
|
||||
continue
|
||||
wc = int(w.get("collection_id") or 0) or collection_id
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT * FROM collections WHERE id=?", (wc,)).fetchone()
|
||||
if not coll:
|
||||
continue
|
||||
try:
|
||||
_require_view(wc, uid)
|
||||
except HTTPException:
|
||||
continue # restricted database → widget skipped, not rendered
|
||||
with get_conn() as conn:
|
||||
wpages = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position LIMIT ?",
|
||||
(wc, CHART_MAX_GROUPS)).fetchall()
|
||||
wconfig = {k: v for k, v in w.items()
|
||||
if k in ("chart_type", "chart_property", "aggregate", "title")}
|
||||
view_type = w.get("view_type") or "chart"
|
||||
if view_type == "chart":
|
||||
body = _render_chart(view_type, dict(coll), [dict(p) for p in wpages], wconfig)
|
||||
else:
|
||||
body = _render_view(view_type, dict(coll), [dict(p) for p in wpages], wconfig)
|
||||
width = int(w.get("width") or 0)
|
||||
span = f"grid-column: span {width};" if width and width > 0 else ""
|
||||
rendered.append(f'<div class="dash-widget" style="{span}">{body}</div>')
|
||||
|
||||
grid_css = f"grid-template-columns: repeat({columns}, minmax(0, 1fr));"
|
||||
body = f"""
|
||||
<style>
|
||||
.dash-grid{{display:grid;{grid_css} gap:16px;max-width:1200px;margin:0 auto;padding:24px}}
|
||||
.dash-widget{{background:rgba(255,255,255,.02);border:1px solid rgba(255,255,255,.08);border-radius:12px;overflow:hidden}}
|
||||
.dash-widget .desc{{color:var(--text-dim);font-size:12px;padding:8px 16px 16px}}
|
||||
</style>
|
||||
<h1 style="max-width:1200px;margin:24px auto 0;padding:0 24px;font-size:22px;">{_htmlmod.escape(dash['name'])}</h1>
|
||||
<div class="dash-grid">{''.join(rendered) if rendered else '<p style="color:var(--text-dim);padding:20px;">Empty dashboard — add widgets to <code>layout_json</code>.</p>'}</div>
|
||||
"""
|
||||
return HTMLResponse(_base_html(dash["name"], "📊", "dashboard", body))
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/{collection_id}", response_class=HTMLResponse)
|
||||
@router.get("/{collection_id}/view/{view_type}", response_class=HTMLResponse)
|
||||
def view_collection(request: Request, collection_id: int, view_type: str = "table"):
|
||||
"""Main view — renders collection in the requested view type."""
|
||||
# v6.0.0: granular collection permissions — hide restricted collections.
|
||||
_require_view(collection_id, _session_user(request))
|
||||
with get_conn() as conn:
|
||||
collection = conn.execute(
|
||||
"SELECT * FROM collections WHERE id=?", (collection_id,)
|
||||
).fetchone()
|
||||
if not collection:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
view = conn.execute(
|
||||
"SELECT * FROM collection_views WHERE collection_id=? AND view_type=? ORDER BY position LIMIT 1",
|
||||
(collection_id, view_type),
|
||||
).fetchone()
|
||||
if not view:
|
||||
view = conn.execute(
|
||||
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position LIMIT 1",
|
||||
(collection_id,),
|
||||
).fetchone()
|
||||
|
||||
pages = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
|
||||
collection_dict = dict(collection)
|
||||
pages_list = [dict(p) for p in pages]
|
||||
config = json.loads(view["config_json"]) if view else {}
|
||||
|
||||
if "year" in request.query_params:
|
||||
config["year"] = int(request.query_params["year"])
|
||||
if "month" in request.query_params:
|
||||
config["month"] = int(request.query_params["month"])
|
||||
|
||||
return HTMLResponse(_render_view(view_type, collection_dict, pages_list, config))
|
||||
|
||||
|
||||
# ── View renderers (v1.6.0) ──
|
||||
@@ -0,0 +1,122 @@
|
||||
"""FlowDeck — Collections : data_api.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/collections.py (2 622 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event, run_event_sync
|
||||
from app.services.property_types import (
|
||||
apply_auto_properties,
|
||||
)
|
||||
|
||||
from ._common import (
|
||||
_collection_properties,
|
||||
_current_user,
|
||||
_require_edit,
|
||||
_require_view,
|
||||
_session_user,
|
||||
_validate_meta_keys,
|
||||
_validate_page_properties,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collections"], prefix="/db")
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/{collection_id}/api")
|
||||
def get_collection_api(request: Request, collection_id: int):
|
||||
"""API: get a single collection with its pages."""
|
||||
# v6.0.0: granular collection permissions — hide restricted collections.
|
||||
_require_view(collection_id, _session_user(request))
|
||||
with get_conn() as conn:
|
||||
collection = conn.execute(
|
||||
"SELECT * FROM collections WHERE id=?", (collection_id,)
|
||||
).fetchone()
|
||||
if not collection:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
pages = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
views = conn.execute(
|
||||
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
|
||||
return {
|
||||
"collection": dict(collection),
|
||||
"pages": [dict(p) for p in pages],
|
||||
"views": [dict(v) for v in views],
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/{collection_id}/pages/api")
|
||||
def create_page_api(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""API: create a page in a collection."""
|
||||
# v6.0.0: granular collection permissions — viewer/commenter cannot create.
|
||||
_require_view(collection_id, _session_user(request))
|
||||
_require_edit(collection_id, _session_user(request))
|
||||
|
||||
title = body.get("title", "").strip()
|
||||
if not title:
|
||||
raise HTTPException(status_code=400, detail="title is required")
|
||||
|
||||
icon = body.get("icon", "📄")
|
||||
property_values = body.get("properties", {})
|
||||
cover_url = body.get("cover_url", "")
|
||||
gitea_issue_id = body.get("gitea_issue_id")
|
||||
gitea_issue_number = body.get("gitea_issue_number")
|
||||
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not coll:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
_validate_page_properties(conn, collection_id, property_values)
|
||||
_validate_meta_keys(conn, collection_id, property_values)
|
||||
apply_auto_properties(
|
||||
_collection_properties(conn, collection_id),
|
||||
property_values,
|
||||
_current_user(request),
|
||||
is_create=True,
|
||||
)
|
||||
|
||||
max_pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchone()[0]
|
||||
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collection_pages
|
||||
(collection_id, title, icon, cover_url, position, gitea_issue_id, gitea_issue_number, property_values_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(collection_id, title, icon, cover_url, max_pos, gitea_issue_id, gitea_issue_number,
|
||||
json.dumps(property_values)),
|
||||
)
|
||||
conn.commit()
|
||||
page_id = cur.lastrowid
|
||||
|
||||
run_event_sync(fire_event("page.created", {
|
||||
"page_id": page_id,
|
||||
"collection_id": collection_id,
|
||||
"title": title,
|
||||
"icon": icon,
|
||||
"properties": property_values,
|
||||
}))
|
||||
run_event_sync(fire_event("collection.page.created", {
|
||||
"page_id": page_id,
|
||||
"collection_id": collection_id,
|
||||
"title": title,
|
||||
}))
|
||||
return {"id": page_id, "title": title, "status": "created"}
|
||||
@@ -0,0 +1,286 @@
|
||||
"""FlowDeck — Collections : linked.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/collections.py (2 622 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.db_templates import materialize_properties
|
||||
|
||||
from ._common import _apply_template
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collections"], prefix="/db")
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/{collection_id}/linked/api")
|
||||
def create_linked_database(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""API: create a linked database view from a source collection.
|
||||
A linked database copies the structure (views, filters, sorts) of a source
|
||||
but shares the same pages — edits to pages propagate to the source.
|
||||
"""
|
||||
|
||||
name = body.get("name", "").strip()
|
||||
body.get("workspace_id")
|
||||
|
||||
with get_conn() as conn:
|
||||
source = conn.execute(
|
||||
"SELECT * FROM collections WHERE id=?", (collection_id,)
|
||||
).fetchone()
|
||||
if not source:
|
||||
raise HTTPException(status_code=404, detail="Source collection not found")
|
||||
|
||||
if not name:
|
||||
name = f"{source['name']} (linked)"
|
||||
|
||||
# Create the linked collection (shallow copy of structure)
|
||||
# Inherit workspace_id from source for permission inheritance
|
||||
src_dict = dict(source)
|
||||
source_workspace_id = src_dict.get("workspace_id")
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collections
|
||||
(name, description, icon, schema_json, is_locked, is_inline, parent_page_id, workspace_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
name,
|
||||
src_dict["description"],
|
||||
src_dict["icon"],
|
||||
src_dict["schema_json"],
|
||||
0, # linked DB is never locked
|
||||
1, # linked DB starts as inline
|
||||
src_dict.get("parent_page_id"),
|
||||
source_workspace_id, # linked DB inherits source workspace permissions
|
||||
),
|
||||
)
|
||||
linked_id = cur.lastrowid
|
||||
|
||||
# Copy views from source
|
||||
views = conn.execute(
|
||||
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
for v in views:
|
||||
conn.execute(
|
||||
"INSERT INTO collection_views (collection_id, name, view_type, config_json, position) VALUES (?, ?, ?, ?, ?)",
|
||||
(linked_id, v["name"], v["view_type"], v["config_json"], v["position"]),
|
||||
)
|
||||
|
||||
# Add the source as a data source with is_linked=1
|
||||
conn.execute(
|
||||
"""INSERT INTO collection_data_sources
|
||||
(collection_id, source_collection_id, source_name, is_linked, position)
|
||||
VALUES (?, ?, ?, 1, 0)""",
|
||||
(linked_id, collection_id, source["name"]),
|
||||
)
|
||||
|
||||
# Copy properties from source
|
||||
props = conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
for p in props:
|
||||
conn.execute(
|
||||
"""INSERT INTO collection_properties
|
||||
(collection_id, name, prop_type, options_json, number_format,
|
||||
related_collection_id, reverse_name, relation_property_id,
|
||||
target_property_id, rollup_function, formula_expression,
|
||||
position, required, visible_in_views)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
linked_id, p["name"], p["prop_type"], p["options_json"],
|
||||
p["number_format"], p["related_collection_id"], p["reverse_name"],
|
||||
p["relation_property_id"], p["target_property_id"],
|
||||
p["rollup_function"], p["formula_expression"],
|
||||
p["position"], p["required"], p["visible_in_views"],
|
||||
),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
return {
|
||||
"linked_id": linked_id,
|
||||
"name": name,
|
||||
"source_collection_id": collection_id,
|
||||
"status": "created",
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/{collection_id}/toggle-inline/api")
|
||||
def toggle_inline(request: Request, collection_id: int):
|
||||
"""API: toggle a collection between full-page and inline mode."""
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute(
|
||||
"SELECT id, is_inline FROM collections WHERE id=?",
|
||||
(collection_id,),
|
||||
).fetchone()
|
||||
if not coll:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
new_inline = 0 if coll["is_inline"] else 1
|
||||
conn.execute(
|
||||
"UPDATE collections SET is_inline=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(new_inline, collection_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {
|
||||
"collection_id": collection_id,
|
||||
"is_inline": bool(new_inline),
|
||||
"mode": "inline" if new_inline else "full-page",
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/inline/api")
|
||||
def create_inline_database(request: Request, body: dict = Body(default={})):
|
||||
"""API: create an inline database within a parent page (optionally from a template)."""
|
||||
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name is required")
|
||||
|
||||
description = body.get("description", "")
|
||||
icon = body.get("icon", "📋")
|
||||
parent_page_id = body.get("parent_page_id")
|
||||
workspace_id = body.get("workspace_id")
|
||||
schema = body.get("schema", [])
|
||||
|
||||
with get_conn() as conn:
|
||||
tpl = _apply_template(conn, body.get("template"))
|
||||
if tpl:
|
||||
if body.get("name"):
|
||||
name = body["name"].strip()
|
||||
description = tpl["description"]
|
||||
icon = tpl.get("icon") or icon
|
||||
try:
|
||||
schema = json.loads(tpl["schema_json"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
schema = []
|
||||
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collections
|
||||
(name, description, icon, schema_json, is_inline, parent_page_id, workspace_id)
|
||||
VALUES (?, ?, ?, ?, 1, ?, ?)""",
|
||||
(name, description, icon, json.dumps(schema), parent_page_id, workspace_id),
|
||||
)
|
||||
collection_id = cur.lastrowid
|
||||
|
||||
materialize_properties(conn, collection_id, schema)
|
||||
|
||||
# Create default view
|
||||
conn.execute(
|
||||
"""INSERT INTO collection_views
|
||||
(collection_id, name, view_type, config_json)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
(collection_id, "Default View", "table", json.dumps({
|
||||
"visible_properties": ["Title"],
|
||||
"sorts": [],
|
||||
"filters": [],
|
||||
})),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {
|
||||
"id": collection_id,
|
||||
"name": name,
|
||||
"icon": icon,
|
||||
"is_inline": True,
|
||||
"parent_page_id": parent_page_id,
|
||||
"status": "created",
|
||||
}
|
||||
|
||||
|
||||
# ── v4.4.0: Tasks & Dependencies ──
|
||||
|
||||
|
||||
|
||||
|
||||
# ── v4.4.0: Tasks & Dependencies ──
|
||||
|
||||
|
||||
@router.put("/{collection_id}/toggle-task/api")
|
||||
def toggle_task(request: Request, collection_id: int):
|
||||
"""API: toggle is_task flag on a collection (Turn into Tasks)."""
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT id, is_task FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not coll:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
new_val = 0 if coll["is_task"] else 1
|
||||
conn.execute("UPDATE collections SET is_task=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (new_val, collection_id))
|
||||
conn.commit()
|
||||
return {"collection_id": collection_id, "is_task": bool(new_val), "mode": "tasks" if new_val else "standard"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/{collection_id}/pages/{page_id}/dependencies/api")
|
||||
def list_page_dependencies(request: Request, collection_id: int, page_id: int):
|
||||
"""API: list dependencies for a page (blocks, blocked_by, related)."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM page_dependencies WHERE page_id=? ORDER BY created_at",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
deps = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
dep_page = conn.execute(
|
||||
"SELECT id, title FROM collection_pages WHERE id=?", (r["dependency_id"],)
|
||||
).fetchone()
|
||||
if dep_page:
|
||||
d["dependency_title"] = dep_page["title"]
|
||||
deps.append(d)
|
||||
return {"dependencies": deps}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/{collection_id}/pages/{page_id}/dependencies/api")
|
||||
def add_page_dependency(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})):
|
||||
"""API: add a dependency (blocks/blocked_by/related) between two pages."""
|
||||
dependency_id = body.get("dependency_id")
|
||||
if not dependency_id:
|
||||
raise HTTPException(status_code=400, detail="dependency_id is required")
|
||||
dep_type = body.get("dependency_type", "blocks")
|
||||
auto_shift = body.get("auto_shift", "overlap")
|
||||
|
||||
with get_conn() as conn:
|
||||
for pid in (page_id, dependency_id):
|
||||
if not conn.execute("SELECT id FROM collection_pages WHERE id=?", (pid,)).fetchone():
|
||||
raise HTTPException(status_code=404, detail=f"Page {pid} not found")
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO page_dependencies (page_id, dependency_id, dependency_type, auto_shift) VALUES (?,?,?,?)",
|
||||
(page_id, dependency_id, dep_type, auto_shift),
|
||||
)
|
||||
conn.commit()
|
||||
except sqlite3.IntegrityError:
|
||||
raise HTTPException(status_code=409, detail="This dependency already exists") from None
|
||||
return {"id": cur.lastrowid, "page_id": page_id, "dependency_id": dependency_id, "status": "added"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/{collection_id}/pages/{page_id}/dependencies/{dep_id}/api")
|
||||
def remove_page_dependency(request: Request, collection_id: int, page_id: int, dep_id: int):
|
||||
"""API: remove a dependency."""
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT * FROM page_dependencies WHERE id=? AND page_id=?", (dep_id, page_id)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Dependency not found")
|
||||
conn.execute("DELETE FROM page_dependencies WHERE id=?", (dep_id,))
|
||||
conn.commit()
|
||||
return {"id": dep_id, "status": "removed"}
|
||||
@@ -0,0 +1,197 @@
|
||||
"""FlowDeck — Collections : meta.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/collections.py (2 622 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.permission_manager import PermissionManager
|
||||
from app.services.recurrence import (
|
||||
RECURRENCE_KEY,
|
||||
expand_rule,
|
||||
parse_date,
|
||||
)
|
||||
|
||||
from ._common import _collection_properties, _current_user, _require_view, _session_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collections"], prefix="/db")
|
||||
|
||||
|
||||
|
||||
|
||||
# ── Collection Properties (v1.4.0) ──
|
||||
|
||||
|
||||
@router.get("/property-types/api")
|
||||
def list_property_types_api(request: Request):
|
||||
"""API: list all available property types."""
|
||||
from app.services.property_types import PROPERTY_TYPES
|
||||
return {"types": PROPERTY_TYPES}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/{collection_id}/properties/api")
|
||||
def list_properties_api(request: Request, collection_id: int):
|
||||
"""API: list all properties visible to the current user."""
|
||||
user = _session_user(request)
|
||||
_require_view(collection_id, user)
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not coll:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
props = [dict(r) for r in rows]
|
||||
# v6.0.0: property-level visibility — owners/editors see everything, other
|
||||
# users only the properties explicitly granted or left open.
|
||||
if user:
|
||||
pm = PermissionManager(user["id"])
|
||||
visible = pm.get_visible_properties(collection_id)
|
||||
props = [p for p in props if p["id"] in visible]
|
||||
return {"properties": props}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/{collection_id}/members/api")
|
||||
def list_collection_members_api(request: Request, collection_id: int):
|
||||
"""API: list workspace members available for a ``person`` property.
|
||||
|
||||
Resolves the collection's workspace and returns its members (falling back to
|
||||
every active user for standalone databases without a workspace).
|
||||
"""
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute(
|
||||
"SELECT workspace_id FROM collections WHERE id=?", (collection_id,)
|
||||
).fetchone()
|
||||
if not coll:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
ws_id = coll["workspace_id"] if "workspace_id" in coll.keys() else None
|
||||
if ws_id:
|
||||
rows = conn.execute(
|
||||
"""SELECT u.id, u.login, u.full_name, u.avatar_url, u.avatar_color, wm.role
|
||||
FROM workspace_members wm JOIN users u ON wm.user_id=u.id
|
||||
WHERE wm.workspace_id=? AND u.is_active=1 ORDER BY u.full_name, u.login""",
|
||||
(ws_id,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = []
|
||||
if not rows:
|
||||
rows = conn.execute(
|
||||
"""SELECT id, login, full_name, avatar_url, avatar_color, '' AS role
|
||||
FROM users WHERE is_active=1 ORDER BY full_name, login"""
|
||||
).fetchall()
|
||||
|
||||
return {"members": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/{collection_id}/calendar/api")
|
||||
def collection_calendar_api(request: Request, collection_id: int,
|
||||
start: str = "", end: str = "",
|
||||
date_property: str = ""):
|
||||
"""API (v5.8.0): expanded calendar events for a window [start, end].
|
||||
|
||||
Returns every occurrence (recurrence-aware, virtual — never persisted)
|
||||
of the rows in the collection whose ``date_property`` falls inside the
|
||||
inclusive window. Rows without a rule yield their base date.
|
||||
"""
|
||||
user = _current_user(request)
|
||||
s = parse_date(start)
|
||||
e = parse_date(end)
|
||||
if s is None or e is None or s > e:
|
||||
raise HTTPException(status_code=400, detail="start/end must be YYYY-MM-DD")
|
||||
if (e - s).days > 370:
|
||||
raise HTTPException(status_code=400, detail="window too large (max 370 days)")
|
||||
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not coll:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
props = _collection_properties(conn, collection_id)
|
||||
date_props = [p for p in props if p["prop_type"] == "date"]
|
||||
target = None
|
||||
if date_property:
|
||||
target = next((p for p in date_props
|
||||
if str(p["id"]) == str(date_property) or p["name"] == date_property), None)
|
||||
if target is None:
|
||||
raise HTTPException(status_code=400, detail="Unknown date property")
|
||||
elif date_props:
|
||||
target = date_props[0]
|
||||
if target is None:
|
||||
return {"events": [], "timezone": "", "property": None}
|
||||
|
||||
urow = conn.execute("SELECT timezone FROM users WHERE id=?", (user.get("id") or 1,)).fetchone()
|
||||
user_tz = (urow["timezone"] if urow and "timezone" in urow.keys() else "") or ""
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, icon, property_values_json FROM collection_pages WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
|
||||
pid = str(target["id"])
|
||||
events: list[dict] = []
|
||||
for r in rows:
|
||||
try:
|
||||
pv = json.loads(r["property_values_json"] or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
base_value = pv.get(pid)
|
||||
if base_value is None:
|
||||
base_value = pv.get(target["name"])
|
||||
if parse_date(base_value) is None:
|
||||
continue
|
||||
rec_all = pv.get(RECURRENCE_KEY) if isinstance(pv.get(RECURRENCE_KEY), dict) else {}
|
||||
rule = rec_all.get(pid) or rec_all.get(target["name"])
|
||||
row_tz = user_tz
|
||||
if isinstance(rule, dict) and rule.get("timezone"):
|
||||
row_tz = rule["timezone"]
|
||||
tzmap = pv.get("__timezone__")
|
||||
if isinstance(tzmap, dict):
|
||||
ev_tz = tzmap.get(pid) or tzmap.get(target["name"])
|
||||
if ev_tz:
|
||||
row_tz = str(ev_tz)
|
||||
if rule:
|
||||
dates = expand_rule(base_value, rule, s, e, max_occurrences=500)
|
||||
else:
|
||||
d = parse_date(base_value)
|
||||
dates = [d.isoformat()] if d and s <= d <= e else []
|
||||
for iso in dates:
|
||||
events.append({
|
||||
"date": iso,
|
||||
"page_id": r["id"],
|
||||
"title": r["title"],
|
||||
"icon": r["icon"],
|
||||
"recurring": bool(rule),
|
||||
"time": str(base_value)[11:16] if len(str(base_value)) >= 16 else "",
|
||||
"timezone": row_tz,
|
||||
})
|
||||
events.sort(key=lambda ev: (ev["date"], ev["page_id"]))
|
||||
return {"events": events, "timezone": user_tz, "property": {"id": target["id"], "name": target["name"]}}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/timezones/api")
|
||||
def timezones_api(request: Request):
|
||||
"""API (v5.8.0): the user's timezone plus a picker-friendly zone list."""
|
||||
user = _current_user(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT timezone FROM users WHERE id=?", (user.get("id") or 1,)).fetchone()
|
||||
from app.services.recurrence import common_timezones
|
||||
return {
|
||||
"timezone": (row["timezone"] if row and "timezone" in row.keys() else "") or "",
|
||||
"zones": common_timezones(),
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
"""FlowDeck — Collections : pages.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/collections.py (2 622 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event, run_event_sync
|
||||
from app.services.property_types import (
|
||||
apply_auto_properties,
|
||||
)
|
||||
|
||||
from ._common import (
|
||||
_collection_properties,
|
||||
_current_user,
|
||||
_require_edit,
|
||||
_require_view,
|
||||
_session_user,
|
||||
_validate_meta_keys,
|
||||
_validate_page_properties,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collections"], prefix="/db")
|
||||
|
||||
|
||||
|
||||
|
||||
# ── Page CRUD (standalone, BEFORE collection wildcards) ──
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/api")
|
||||
def get_page_api(request: Request, page_id: int):
|
||||
"""API: get a single page."""
|
||||
with get_conn() as conn:
|
||||
page = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE id=?", (page_id,)
|
||||
).fetchone()
|
||||
if not page:
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
# v6.0.0: granular collection/page permissions.
|
||||
_require_view(page["collection_id"], _session_user(request))
|
||||
return dict(page)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/open/api")
|
||||
def open_row_page_api(request: Request, page_id: int):
|
||||
"""v6.5.0 — content page of a database row (lazy-created).
|
||||
|
||||
Any DB view (table/board/gallery/list/calendar) opens a row through
|
||||
this endpoint: it returns the shadow ``pages`` id whose full page
|
||||
editor carries the row's block content (synced blocks included).
|
||||
"""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT collection_id FROM collection_pages WHERE id=?",
|
||||
(page_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
coll_id = row["collection_id"]
|
||||
# v6.0.0: granular collection permissions (same gate as the row itself).
|
||||
_require_view(coll_id, _session_user(request))
|
||||
from app.services.row_pages import ensure_row_page
|
||||
try:
|
||||
content_page_id = ensure_row_page(page_id)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="Page not found") from None
|
||||
return {"page_id": content_page_id, "row_id": page_id}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.put("/pages/{page_id}/api")
|
||||
def update_page_api(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
"""API: update a page's properties."""
|
||||
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE id=?", (page_id,)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
# v6.0.0: granular collection/page permissions.
|
||||
_require_edit(existing["collection_id"], _session_user(request))
|
||||
|
||||
title = body.get("title", existing["title"])
|
||||
icon = body.get("icon", existing["icon"])
|
||||
cover_url = body.get("cover_url", existing["cover_url"] if "cover_url" in existing.keys() else "")
|
||||
position = body.get("position", existing["position"])
|
||||
parent_id = body.get("parent_id", existing["parent_id"])
|
||||
|
||||
try:
|
||||
stored = json.loads(existing["property_values_json"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
stored = {}
|
||||
|
||||
if "properties" in body:
|
||||
# Partial PATCH semantics: merge submitted values over stored ones.
|
||||
props = dict(stored)
|
||||
props.update(body["properties"])
|
||||
else:
|
||||
props = stored
|
||||
|
||||
_validate_page_properties(conn, existing["collection_id"], props, exclude_page_id=page_id)
|
||||
_validate_meta_keys(conn, existing["collection_id"], props)
|
||||
apply_auto_properties(
|
||||
_collection_properties(conn, existing["collection_id"]),
|
||||
props,
|
||||
_current_user(request),
|
||||
is_create=False,
|
||||
)
|
||||
|
||||
property_values = json.dumps(props)
|
||||
|
||||
conn.execute(
|
||||
"""UPDATE collection_pages
|
||||
SET title=?, icon=?, cover_url=?, position=?, parent_id=?, property_values_json=?,
|
||||
updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=?""",
|
||||
(title, icon, cover_url, position, parent_id, property_values, page_id),
|
||||
)
|
||||
# v6.5.0: keep the row's content page title in sync (row → page).
|
||||
from app.services.row_pages import sync_row_title_to_page
|
||||
sync_row_title_to_page(conn, page_id)
|
||||
conn.commit()
|
||||
|
||||
run_event_sync(fire_event("page.updated", {
|
||||
"page_id": page_id,
|
||||
"collection_id": existing["collection_id"],
|
||||
"title": title,
|
||||
"icon": icon,
|
||||
"properties": props,
|
||||
}))
|
||||
run_event_sync(fire_event("collection.page.updated", {
|
||||
"page_id": page_id,
|
||||
"collection_id": existing["collection_id"],
|
||||
"title": title,
|
||||
}))
|
||||
# Notify newly assigned people (person properties) — v5.8.0.
|
||||
from app.services.notifications import notify_assignment
|
||||
user = _current_user(request)
|
||||
notify_assignment(existing["collection_id"], page_id, title,
|
||||
stored, props, user.get("id"))
|
||||
return {"id": page_id, "status": "updated"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/pages/{page_id}/api")
|
||||
def delete_page_api(request: Request, page_id: int):
|
||||
"""API: delete a page from its collection."""
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE id=?", (page_id,)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
# v6.0.0: granular collection/page permissions.
|
||||
_require_edit(existing["collection_id"], _session_user(request))
|
||||
|
||||
conn.execute("DELETE FROM collection_pages WHERE id=?", (page_id,))
|
||||
conn.commit()
|
||||
|
||||
run_event_sync(fire_event("page.deleted", {
|
||||
"page_id": page_id,
|
||||
"collection_id": existing["collection_id"],
|
||||
"title": existing["title"],
|
||||
}))
|
||||
run_event_sync(fire_event("collection.page.deleted", {
|
||||
"page_id": page_id,
|
||||
"collection_id": existing["collection_id"],
|
||||
}))
|
||||
return {"id": page_id, "status": "deleted"}
|
||||
|
||||
|
||||
# ── Gitea Board Compatibility (BEFORE {collection_id} wildcards) ──
|
||||
@@ -0,0 +1,322 @@
|
||||
"""FlowDeck — Collections : properties.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/collections.py (2 622 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collections"], prefix="/db")
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/{collection_id}/property-groups/api")
|
||||
def set_property_groups_api(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""API: (re)assign properties to collapsible groups in the table header.
|
||||
|
||||
Body: ``{"groups": [{"name": "Basics", "property_ids": [1, 2]}]}``. Properties
|
||||
omitted from any group have their group cleared. Empty group names clear.
|
||||
"""
|
||||
groups = body.get("groups", [])
|
||||
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not coll:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
conn.execute(
|
||||
"UPDATE collection_properties SET group_name='' WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
)
|
||||
for grp in groups:
|
||||
gname = (grp.get("name") or "").strip()
|
||||
if not gname:
|
||||
continue
|
||||
for pid in grp.get("property_ids", []) or []:
|
||||
conn.execute(
|
||||
"UPDATE collection_properties SET group_name=? WHERE id=? AND collection_id=?",
|
||||
(gname, pid, collection_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {"status": "updated"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/{collection_id}/properties/api")
|
||||
def create_property_api(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""API: create a new property on a collection."""
|
||||
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name is required")
|
||||
|
||||
prop_type = body.get("prop_type", "text")
|
||||
options_json = json.dumps(body.get("options", []))
|
||||
number_format = body.get("number_format", "number")
|
||||
required = int(body.get("required", False))
|
||||
visible = int(body.get("visible_in_views", True))
|
||||
validation_json = json.dumps(body.get("validation", {}))
|
||||
group_name = (body.get("group_name") or "").strip()
|
||||
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not coll:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
max_pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_properties WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchone()[0]
|
||||
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collection_properties
|
||||
(collection_id, name, prop_type, options_json, number_format,
|
||||
position, required, visible_in_views, validation_json, group_name)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(collection_id, name, prop_type, options_json, number_format, max_pos,
|
||||
required, visible, validation_json, group_name),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
raise HTTPException(status_code=409, detail=f"Property '{name}' already exists") from None
|
||||
|
||||
return {"id": cur.lastrowid, "name": name, "prop_type": prop_type,
|
||||
"group_name": group_name, "status": "created"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.put("/properties/{prop_id}/api")
|
||||
def update_property_api(request: Request, prop_id: int, body: dict = Body(default={})):
|
||||
"""API: update a property."""
|
||||
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE id=?", (prop_id,)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Property not found")
|
||||
|
||||
name = body.get("name", existing["name"])
|
||||
options_json = json.dumps(body.get("options", json.loads(existing["options_json"])))
|
||||
number_format = body.get("number_format", existing["number_format"])
|
||||
required = int(body.get("required", existing["required"]))
|
||||
visible = int(body.get("visible_in_views", existing["visible_in_views"]))
|
||||
if "validation" in body:
|
||||
validation_json = json.dumps(body.get("validation", {}))
|
||||
else:
|
||||
validation_json = existing["validation_json"] if "validation_json" in existing.keys() else "{}"
|
||||
group_name = body.get("group_name", existing["group_name"] if "group_name" in existing.keys() else "")
|
||||
|
||||
conn.execute(
|
||||
"""UPDATE collection_properties
|
||||
SET name=?, options_json=?, number_format=?, required=?, visible_in_views=?,
|
||||
validation_json=?, group_name=?
|
||||
WHERE id=?""",
|
||||
(name, options_json, number_format, required, visible, validation_json,
|
||||
group_name, prop_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {"id": prop_id, "status": "updated"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/properties/{prop_id}/api")
|
||||
def delete_property_api(request: Request, prop_id: int):
|
||||
"""API: delete a property."""
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE id=?", (prop_id,)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Property not found")
|
||||
conn.execute("DELETE FROM collection_properties WHERE id=?", (prop_id,))
|
||||
conn.commit()
|
||||
|
||||
return {"id": prop_id, "status": "deleted"}
|
||||
|
||||
|
||||
# ── Relations, Rollups, Formulas (v1.5.0) ──
|
||||
|
||||
|
||||
|
||||
|
||||
# ── Relations, Rollups, Formulas (v1.5.0) ──
|
||||
|
||||
|
||||
@router.post("/{collection_id}/properties/relation")
|
||||
def create_relation_property(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""Create a relation property between two collections."""
|
||||
|
||||
name = body.get("name", "").strip()
|
||||
related_collection_id = body.get("related_collection_id")
|
||||
reverse_name = body.get("reverse_name", "").strip()
|
||||
|
||||
if not name or not related_collection_id:
|
||||
raise HTTPException(status_code=400, detail="name and related_collection_id are required")
|
||||
|
||||
with get_conn() as conn:
|
||||
# Verify both collections exist
|
||||
for cid in (collection_id, related_collection_id):
|
||||
if not conn.execute("SELECT id FROM collections WHERE id=?", (cid,)).fetchone():
|
||||
raise HTTPException(status_code=404, detail=f"Collection {cid} not found")
|
||||
|
||||
max_pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_properties WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchone()[0]
|
||||
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collection_properties
|
||||
(collection_id, name, prop_type, related_collection_id, reverse_name, position)
|
||||
VALUES (?, ?, 'relation', ?, ?, ?)""",
|
||||
(collection_id, name, related_collection_id, reverse_name, max_pos),
|
||||
)
|
||||
prop_id = cur.lastrowid
|
||||
|
||||
# Create reverse relation on the related collection
|
||||
if reverse_name:
|
||||
max_pos2 = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_properties WHERE collection_id=?",
|
||||
(related_collection_id,),
|
||||
).fetchone()[0]
|
||||
conn.execute(
|
||||
"""INSERT INTO collection_properties
|
||||
(collection_id, name, prop_type, related_collection_id, reverse_name, position)
|
||||
VALUES (?, ?, 'relation', ?, ?, ?)""",
|
||||
(related_collection_id, reverse_name, collection_id, name, max_pos2),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
return {"id": prop_id, "name": name, "prop_type": "relation", "status": "created"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/{collection_id}/properties/relation/link")
|
||||
def link_pages(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""Link two pages via a relation property."""
|
||||
|
||||
property_id = body.get("property_id")
|
||||
source_page_id = body.get("source_page_id")
|
||||
target_page_id = body.get("target_page_id")
|
||||
|
||||
if not all([property_id, source_page_id, target_page_id]):
|
||||
raise HTTPException(status_code=400, detail="property_id, source_page_id, target_page_id required")
|
||||
|
||||
with get_conn() as conn:
|
||||
# Get the relation property
|
||||
prop = conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE id=? AND prop_type='relation'",
|
||||
(property_id,),
|
||||
).fetchone()
|
||||
if not prop:
|
||||
raise HTTPException(status_code=404, detail="Relation property not found")
|
||||
|
||||
# Update source page's property_values_json
|
||||
source = conn.execute(
|
||||
"SELECT property_values_json FROM collection_pages WHERE id=?",
|
||||
(source_page_id,),
|
||||
).fetchone()
|
||||
if not source:
|
||||
raise HTTPException(status_code=404, detail="Source page not found")
|
||||
|
||||
props = json.loads(source["property_values_json"])
|
||||
current = props.get(str(property_id), [])
|
||||
if not isinstance(current, list):
|
||||
current = []
|
||||
if target_page_id not in current:
|
||||
current.append(target_page_id)
|
||||
props[str(property_id)] = current
|
||||
|
||||
conn.execute(
|
||||
"UPDATE collection_pages SET property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(json.dumps(props), source_page_id),
|
||||
)
|
||||
|
||||
# Update reverse relation if exists
|
||||
if prop["reverse_name"]:
|
||||
reverse_prop = conn.execute(
|
||||
"SELECT id FROM collection_properties WHERE collection_id=? AND name=? AND prop_type='relation'",
|
||||
(prop["related_collection_id"], prop["reverse_name"]),
|
||||
).fetchone()
|
||||
if reverse_prop:
|
||||
target = conn.execute(
|
||||
"SELECT property_values_json FROM collection_pages WHERE id=?",
|
||||
(target_page_id,),
|
||||
).fetchone()
|
||||
if target:
|
||||
tprops = json.loads(target["property_values_json"])
|
||||
tcurrent = tprops.get(str(reverse_prop["id"]), [])
|
||||
if not isinstance(tcurrent, list):
|
||||
tcurrent = []
|
||||
if source_page_id not in tcurrent:
|
||||
tcurrent.append(source_page_id)
|
||||
tprops[str(reverse_prop["id"])] = tcurrent
|
||||
conn.execute(
|
||||
"UPDATE collection_pages SET property_values_json=? WHERE id=?",
|
||||
(json.dumps(tprops), target_page_id),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
return {"status": "linked", "source": source_page_id, "target": target_page_id}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/rollup/compute")
|
||||
def compute_rollup(request: Request, body: dict = Body(default={})):
|
||||
"""Compute a rollup aggregation."""
|
||||
|
||||
collection_id = body.get("collection_id")
|
||||
relation_property_id = body.get("relation_property_id")
|
||||
target_property_id = body.get("target_property_id")
|
||||
page_id = body.get("page_id")
|
||||
rollup_function = body.get("function", "count")
|
||||
|
||||
if not all([collection_id, relation_property_id, target_property_id, page_id]):
|
||||
raise HTTPException(status_code=400, detail="collection_id, relation_property_id, target_property_id, page_id required")
|
||||
|
||||
from app.services.rollup_engine import RollupEngine
|
||||
engine = RollupEngine()
|
||||
result = engine.compute(
|
||||
collection_id, relation_property_id, target_property_id, page_id, rollup_function,
|
||||
)
|
||||
|
||||
return {"result": result, "function": rollup_function}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/formula/evaluate")
|
||||
def evaluate_formula(request: Request, body: dict = Body(default={})):
|
||||
"""Evaluate a formula expression."""
|
||||
|
||||
expression = body.get("expression", "")
|
||||
context = body.get("context", {})
|
||||
|
||||
if not expression:
|
||||
raise HTTPException(status_code=400, detail="expression is required")
|
||||
|
||||
from app.services.formula_engine import FormulaEngine
|
||||
engine = FormulaEngine()
|
||||
result = engine.evaluate(expression, context)
|
||||
|
||||
return {"result": result, "expression": expression}
|
||||
|
||||
|
||||
# ── v1.7.0 View Management ──
|
||||
@@ -0,0 +1,267 @@
|
||||
"""FlowDeck — Collections : structure.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/collections.py (2 622 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event, run_event_sync
|
||||
from app.services.property_types import (
|
||||
apply_auto_properties,
|
||||
)
|
||||
|
||||
from ._common import _collection_properties, _current_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collections"], prefix="/db")
|
||||
|
||||
|
||||
|
||||
|
||||
# ── v1.8.0 Sub-items & Dependencies ──
|
||||
|
||||
|
||||
@router.get("/{collection_id}/pages/{page_id}/sub-items")
|
||||
def list_sub_items(request: Request, collection_id: int, page_id: int):
|
||||
"""API: list sub-items of a page."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE parent_id=? ORDER BY position",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
return {"sub_items": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/{collection_id}/pages/{page_id}/sub-items")
|
||||
def create_sub_item(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})):
|
||||
"""API: create a sub-item under a page."""
|
||||
|
||||
title = body.get("title", "New sub-item").strip()
|
||||
if not title:
|
||||
raise HTTPException(status_code=400, detail="title is required")
|
||||
|
||||
with get_conn() as conn:
|
||||
parent = conn.execute("SELECT id FROM collection_pages WHERE id=? AND collection_id=?", (page_id, collection_id)).fetchone()
|
||||
if not parent:
|
||||
raise HTTPException(status_code=404, detail="Parent page not found")
|
||||
|
||||
max_pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE parent_id=?",
|
||||
(page_id,),
|
||||
).fetchone()[0]
|
||||
|
||||
sub_props = body.get("properties", {}) or {}
|
||||
apply_auto_properties(
|
||||
_collection_properties(conn, collection_id),
|
||||
sub_props,
|
||||
_current_user(request),
|
||||
is_create=True,
|
||||
)
|
||||
|
||||
cur = conn.execute(
|
||||
"INSERT INTO collection_pages (collection_id, title, parent_id, position, property_values_json) VALUES (?, ?, ?, ?, ?)",
|
||||
(collection_id, title, page_id, max_pos, json.dumps(sub_props)),
|
||||
)
|
||||
conn.commit()
|
||||
new_id = cur.lastrowid
|
||||
|
||||
run_event_sync(fire_event("page.created", {
|
||||
"page_id": new_id,
|
||||
"collection_id": collection_id,
|
||||
"parent_id": page_id,
|
||||
"title": title,
|
||||
"properties": body.get("properties", {}),
|
||||
}))
|
||||
run_event_sync(fire_event("collection.page.created", {
|
||||
"page_id": new_id,
|
||||
"collection_id": collection_id,
|
||||
"title": title,
|
||||
}))
|
||||
return {"id": new_id, "title": title, "parent_id": page_id, "status": "created"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/{collection_id}/pages/{page_id}/status-aggregate")
|
||||
def aggregate_child_status(request: Request, collection_id: int, page_id: int):
|
||||
"""API: compute aggregate status from children."""
|
||||
with get_conn() as conn:
|
||||
children = conn.execute(
|
||||
"SELECT property_values_json FROM collection_pages WHERE parent_id=?",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
|
||||
statuses = []
|
||||
for c in children:
|
||||
props = json.loads(c["property_values_json"])
|
||||
for v in props.values():
|
||||
if isinstance(v, str) and v:
|
||||
statuses.append(v)
|
||||
|
||||
total = len(statuses)
|
||||
if total == 0:
|
||||
return {"total": 0, "done": 0, "all_done": False}
|
||||
|
||||
done = sum(1 for s in statuses if s.lower() in ("done", "complete", "completed", "terminé"))
|
||||
return {"total": total, "done": done, "all_done": done == total}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/{collection_id}/pages/{page_id}/dependencies")
|
||||
def set_dependencies(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})):
|
||||
"""API: set blocking dependencies for a page (stored as 'blocks' property)."""
|
||||
|
||||
blocks_ids = body.get("blocks", [])
|
||||
|
||||
with get_conn() as conn:
|
||||
page = conn.execute("SELECT property_values_json FROM collection_pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not page:
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
|
||||
props = json.loads(page["property_values_json"])
|
||||
props["blocks"] = blocks_ids
|
||||
|
||||
conn.execute(
|
||||
"UPDATE collection_pages SET property_values_json=? WHERE id=?",
|
||||
(json.dumps(props), page_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {"page_id": page_id, "blocks": blocks_ids, "status": "updated"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/{collection_id}/pages/{page_id}/check-deps")
|
||||
def check_dependencies(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})):
|
||||
"""API: check if a page can transition to a new status."""
|
||||
|
||||
body.get("new_status", "Done")
|
||||
|
||||
with get_conn() as conn:
|
||||
page = conn.execute("SELECT property_values_json FROM collection_pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not page:
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
|
||||
props = json.loads(page["property_values_json"])
|
||||
blocks_ids = props.get("blocks", [])
|
||||
|
||||
if not blocks_ids:
|
||||
return {"can_transition": True, "blocked_by": []}
|
||||
|
||||
# Check blocked pages status
|
||||
placeholders = ",".join("?" for _ in blocks_ids)
|
||||
blocked = conn.execute(
|
||||
f"SELECT id, title, property_values_json FROM collection_pages WHERE id IN ({placeholders})",
|
||||
blocks_ids,
|
||||
).fetchall()
|
||||
|
||||
blockers = []
|
||||
for b in blocked:
|
||||
bprops = json.loads(b["property_values_json"])
|
||||
bstatus = None
|
||||
for v in bprops.values():
|
||||
if isinstance(v, str) and v:
|
||||
bstatus = v
|
||||
break
|
||||
if bstatus and bstatus.lower() not in ("done", "complete", "completed", "terminé"):
|
||||
blockers.append({"id": b["id"], "title": b["title"], "status": bstatus})
|
||||
|
||||
return {
|
||||
"can_transition": len(blockers) == 0,
|
||||
"blocked_by": blockers,
|
||||
}
|
||||
|
||||
|
||||
# ── v4.1.0: Data Sources & Linked Databases ──
|
||||
|
||||
|
||||
|
||||
|
||||
# ── v4.1.0: Data Sources & Linked Databases ──
|
||||
|
||||
|
||||
@router.get("/{collection_id}/sources/api")
|
||||
def list_data_sources(request: Request, collection_id: int):
|
||||
"""API: list all data sources for a collection."""
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not coll:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM collection_data_sources WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
return {"sources": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/{collection_id}/sources/api")
|
||||
def add_data_source(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""API: add a data source to a collection."""
|
||||
|
||||
source_collection_id = body.get("source_collection_id")
|
||||
if not source_collection_id:
|
||||
raise HTTPException(status_code=400, detail="source_collection_id is required")
|
||||
|
||||
source_name = body.get("source_name", "").strip()
|
||||
is_linked = body.get("is_linked", False)
|
||||
|
||||
with get_conn() as conn:
|
||||
# Verify both collections exist
|
||||
for cid in (collection_id, source_collection_id):
|
||||
if not conn.execute("SELECT id FROM collections WHERE id=?", (cid,)).fetchone():
|
||||
raise HTTPException(status_code=404, detail=f"Collection {cid} not found")
|
||||
|
||||
max_pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_data_sources WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchone()[0]
|
||||
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collection_data_sources
|
||||
(collection_id, source_collection_id, source_name, is_linked, position)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
(collection_id, source_collection_id, source_name, int(is_linked), max_pos),
|
||||
)
|
||||
conn.commit()
|
||||
except sqlite3.IntegrityError:
|
||||
raise HTTPException(status_code=409, detail="This data source already exists in this collection") from None
|
||||
|
||||
return {
|
||||
"id": cur.lastrowid,
|
||||
"collection_id": collection_id,
|
||||
"source_collection_id": source_collection_id,
|
||||
"status": "added",
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/{collection_id}/sources/{source_id}/api")
|
||||
def remove_data_source(request: Request, collection_id: int, source_id: int):
|
||||
"""API: remove a data source from a collection."""
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT * FROM collection_data_sources WHERE id=? AND collection_id=?",
|
||||
(source_id, collection_id),
|
||||
).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Data source not found")
|
||||
|
||||
conn.execute("DELETE FROM collection_data_sources WHERE id=?", (source_id,))
|
||||
conn.commit()
|
||||
|
||||
return {"id": source_id, "status": "removed"}
|
||||
@@ -0,0 +1,187 @@
|
||||
"""FlowDeck — Collections : views.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/collections.py (2 622 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event, run_event_sync
|
||||
|
||||
from ._common import _current_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collections"], prefix="/db")
|
||||
|
||||
|
||||
|
||||
|
||||
# ── v1.7.0 View Management ──
|
||||
|
||||
|
||||
@router.get("/views/{view_id}/api")
|
||||
def get_view_api(request: Request, view_id: int):
|
||||
"""API: get a single view config."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="View not found")
|
||||
return dict(row)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.put("/views/{view_id}/config")
|
||||
def update_view_config(request: Request, view_id: int, body: dict = Body(default={})):
|
||||
"""API: update view configuration (group_by, card_size, visible_properties, etc.)."""
|
||||
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="View not found")
|
||||
|
||||
config = json.loads(existing["config_json"])
|
||||
for key in ("group_by", "sub_group_by", "wip_limits", "card_size", "cover_property",
|
||||
"cover_mode", "card_properties", "visible_properties", "filters", "sorts",
|
||||
"filter_conjunction", "date_property", "date_range_property",
|
||||
"property_groups", "view_type"):
|
||||
if key in body:
|
||||
config[key] = body[key]
|
||||
|
||||
new_type = body.get("view_type") or existing["view_type"]
|
||||
conn.execute(
|
||||
"UPDATE collection_views SET config_json=?, name=COALESCE(?, name), view_type=?, "
|
||||
"updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(json.dumps(config), body.get("name"), new_type, view_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {"id": view_id, "status": "updated", "config": config, "view_type": new_type}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/{collection_id}/views/save-as")
|
||||
def save_view_as(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""API: save current view state as a new named view."""
|
||||
|
||||
name = body.get("name", "New View")
|
||||
config = body.get("config", {})
|
||||
user = _current_user(request)
|
||||
user_id = user.get("id") if user else None
|
||||
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not coll:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
max_pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_views WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchone()[0]
|
||||
|
||||
view_type = body.get("view_type", "table")
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collection_views
|
||||
(collection_id, name, view_type, config_json, position, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(collection_id, name, view_type, json.dumps(config), max_pos, user_id),
|
||||
)
|
||||
conn.commit()
|
||||
new_view_id = cur.lastrowid
|
||||
|
||||
run_event_sync(fire_event("collection.view.created", {
|
||||
"view_id": new_view_id,
|
||||
"collection_id": collection_id,
|
||||
"name": name,
|
||||
"view_type": view_type,
|
||||
}))
|
||||
return {"id": new_view_id, "name": name, "view_type": view_type,
|
||||
"config_json": json.dumps(config), "created_by": user_id, "status": "saved"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/{collection_id}/views/api")
|
||||
def list_views_api(request: Request, collection_id: int):
|
||||
"""API: list views for a collection visible to the current user.
|
||||
|
||||
Shared/legacy views (``created_by IS NULL``) are visible to everyone;
|
||||
personal views (``created_by = user``) only to their owner.
|
||||
"""
|
||||
user = _current_user(request)
|
||||
user_id = user.get("id") if user else None
|
||||
with get_conn() as conn:
|
||||
if user_id is not None:
|
||||
rows = conn.execute(
|
||||
"""SELECT * FROM collection_views
|
||||
WHERE collection_id=? AND (created_by IS NULL OR created_by=?)
|
||||
ORDER BY position""",
|
||||
(collection_id, user_id),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM collection_views WHERE collection_id=? AND created_by IS NULL ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
return {"views": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/views/{view_id}/api")
|
||||
def delete_view_api(request: Request, view_id: int):
|
||||
"""API: delete a saved view."""
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="View not found")
|
||||
conn.execute("DELETE FROM collection_views WHERE id=?", (view_id,))
|
||||
conn.commit()
|
||||
return {"id": view_id, "status": "deleted"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/views/{view_id}/duplicate")
|
||||
def duplicate_view_api(request: Request, view_id: int, body: dict = Body(default={})):
|
||||
"""API: duplicate a view (config + type), owned by the current user."""
|
||||
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="View not found")
|
||||
|
||||
max_pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_views WHERE collection_id=?",
|
||||
(existing["collection_id"],),
|
||||
).fetchone()[0]
|
||||
|
||||
user = _current_user(request)
|
||||
user_id = user.get("id") if user else None
|
||||
name = body.get("name") or (existing["name"] + " copy")
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collection_views
|
||||
(collection_id, name, view_type, config_json, position, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(existing["collection_id"], name, existing["view_type"],
|
||||
existing["config_json"], max_pos, user_id),
|
||||
)
|
||||
conn.commit()
|
||||
dup_view_id = cur.lastrowid
|
||||
|
||||
run_event_sync(fire_event("collection.view.created", {
|
||||
"view_id": dup_view_id,
|
||||
"collection_id": existing["collection_id"],
|
||||
"name": name,
|
||||
"view_type": existing["view_type"],
|
||||
}))
|
||||
return {"id": dup_view_id, "name": name, "view_type": existing["view_type"],
|
||||
"status": "duplicated"}
|
||||
|
||||
|
||||
# ── v1.8.0 Sub-items & Dependencies ──
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,77 @@
|
||||
"""FlowDeck — Dashboard (pages HTML + API de l'app).
|
||||
|
||||
Découpe A28 : l'ancien `dashboard.py` (2 735 lignes, 63 routes) est
|
||||
devenu ce package — un module par concern, helpers dans `_common`,
|
||||
re-export de tout ce que les 7 importateurs existants utilisent
|
||||
(main, board, my_tasks, web_clipper, wiki, sites, tests).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from . import ( # ordre = ordre d'enregistrement d'origine (openapi identique)
|
||||
account_api,
|
||||
account_settings,
|
||||
local_workspace,
|
||||
pages_api,
|
||||
pages_html,
|
||||
public,
|
||||
workspace,
|
||||
workspaces,
|
||||
)
|
||||
from ._common import ( # noqa: F401 — re-export des helpers
|
||||
_VERSION,
|
||||
WORKSPACE_COOKIE,
|
||||
_build_breadcrumb,
|
||||
_build_tree_children,
|
||||
_file_page_disk_path,
|
||||
_format_size,
|
||||
_get_active_workspace,
|
||||
_get_app_version,
|
||||
_get_user_id,
|
||||
_get_user_or_redirect,
|
||||
_local_workspaces_for_user,
|
||||
_nav_breadcrumb,
|
||||
_render_blocks_public,
|
||||
_require_page_view,
|
||||
_require_user_id,
|
||||
_sanitize_id,
|
||||
_sidebar_data,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
for _mod in (
|
||||
pages_html,
|
||||
account_api,
|
||||
workspace,
|
||||
local_workspace,
|
||||
workspaces,
|
||||
account_settings,
|
||||
public,
|
||||
pages_api,
|
||||
):
|
||||
router.include_router(_mod.router)
|
||||
|
||||
__all__ = [
|
||||
"router",
|
||||
"WORKSPACE_COOKIE",
|
||||
"_VERSION",
|
||||
"_build_breadcrumb",
|
||||
"_build_tree_children",
|
||||
"_file_page_disk_path",
|
||||
"_format_size",
|
||||
"_get_active_workspace",
|
||||
"_get_app_version",
|
||||
"_get_user_id",
|
||||
"_get_user_or_redirect",
|
||||
"_local_workspaces_for_user",
|
||||
"_nav_breadcrumb",
|
||||
"_render_blocks_public",
|
||||
"_require_page_view",
|
||||
"_require_user_id",
|
||||
"_sanitize_id",
|
||||
"_sidebar_data",
|
||||
]
|
||||
@@ -0,0 +1,774 @@
|
||||
"""FlowDeck — Dashboard : helpers partagés des modules de routes (A28).
|
||||
|
||||
Les 15 helpers top-level de l'ancien dashboard.py vivent ici (état :
|
||||
_VERSION, WORKSPACE_COOKIE) — ré-exportés par le package.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_VERSION = None
|
||||
|
||||
WORKSPACE_COOKIE = "flowdeck_workspace"
|
||||
|
||||
|
||||
|
||||
def _get_app_version() -> str:
|
||||
"""Read version from VERSION file with caching."""
|
||||
global _VERSION
|
||||
if _VERSION is not None:
|
||||
return _VERSION
|
||||
try:
|
||||
import os
|
||||
version_path = os.path.join(os.path.dirname(__file__), "..", "..", "VERSION")
|
||||
if os.path.exists(version_path):
|
||||
with open(version_path) as f:
|
||||
_VERSION = f.read().strip()
|
||||
else:
|
||||
# Docker fallback
|
||||
version_path = "/app/VERSION"
|
||||
if os.path.exists(version_path):
|
||||
with open(version_path) as f:
|
||||
_VERSION = f.read().strip()
|
||||
else:
|
||||
_VERSION = "0.0.0"
|
||||
except Exception:
|
||||
_VERSION = "0.0.0"
|
||||
return _VERSION
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _get_user_or_redirect(request: Request):
|
||||
"""Return decoded user or a RedirectResponse to login page.
|
||||
Skips redirect when DB has no users (fresh install / test env)."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
# Allow through if no users exist yet (fresh install / tests)
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||
if count == 0:
|
||||
return {"id": 1, "login": "admin", "full_name": "Admin", "is_admin": True}
|
||||
except Exception:
|
||||
logger.exception("_get_user_or_redirect")
|
||||
return RedirectResponse("/auth/login?provider=local&expired=1", status_code=302)
|
||||
return user
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _local_workspaces_for_user(user: dict | None, workspace_id: int = 0) -> list[dict]:
|
||||
"""Return list of local workspaces for a user."""
|
||||
if not user:
|
||||
return []
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, name FROM workspaces WHERE owner_id = ? ORDER BY name",
|
||||
(user["id"],)
|
||||
).fetchall()
|
||||
return [{"id": r["id"], "name": r["name"]} for r in rows]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool = True) -> dict:
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
ws = user.get("login", "Bruno") if user else "Bruno"
|
||||
initial = ws[0].upper() if ws else "B"
|
||||
|
||||
# Get avatar info from DB
|
||||
avatar_url = ""
|
||||
avatar_color = "#3A3A3A"
|
||||
if user:
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT avatar_url, avatar_color FROM users WHERE id = ?", (user["id"],)).fetchone()
|
||||
if row:
|
||||
avatar_url = row["avatar_url"] or ""
|
||||
avatar_color = row["avatar_color"] or "#3A3A3A"
|
||||
except Exception:
|
||||
logger.exception("_sidebar_data")
|
||||
|
||||
recent_pages = []
|
||||
for repo in repos[:10]:
|
||||
full_name = repo.get("full_name", "")
|
||||
recent_pages.append({
|
||||
"id": full_name,
|
||||
"name": repo.get("name", full_name),
|
||||
"icon": "folder",
|
||||
"url": f"/board/{full_name}",
|
||||
"active": False,
|
||||
"indent": 0,
|
||||
"depth": 0,
|
||||
"has_children": False,
|
||||
"children": [],
|
||||
})
|
||||
|
||||
# Active workspace from cookie (skip on pages like /workspaces where no
|
||||
# workspace context should be shown)
|
||||
from app.routers.board import _load_workspace_pages
|
||||
ws_cookie = request.cookies.get("flowdeck_workspace", "")
|
||||
active_ws_name = "Workspace"
|
||||
workspace_pages = []
|
||||
gitea_workspace = False
|
||||
gitea_owner = ""
|
||||
gitea_repo = ""
|
||||
has_active_workspace = False
|
||||
local_ws_id = 0
|
||||
|
||||
if ws_cookie and ws_cookie.startswith("gitea:"):
|
||||
# Gitea workspace: set owner/repo for client-side tree loading
|
||||
# AND open the local workspace mirror of the same name in the
|
||||
# sidebar's top "My Workspaces" section, in parallel with the
|
||||
# Gitea repository tree.
|
||||
parts = ws_cookie.split(":", 2)
|
||||
if len(parts) >= 3:
|
||||
gitea_owner = parts[1]
|
||||
gitea_repo = parts[2]
|
||||
active_ws_name = f"{gitea_owner}/{gitea_repo}"
|
||||
gitea_workspace = True
|
||||
has_active_workspace = True
|
||||
# Load the local mirror workspace tree so it appears in "My
|
||||
# Workspaces" alongside the Gitea repository section.
|
||||
if user:
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?",
|
||||
(user["id"], f"{gitea_owner}/{gitea_repo}", "%gitea_repo%")
|
||||
).fetchone()
|
||||
if row:
|
||||
local_ws_id = row["id"]
|
||||
workspace_pages = _load_workspace_pages(str(local_ws_id))
|
||||
except (ValueError, Exception):
|
||||
pass
|
||||
elif include_workspace and ws_cookie and user:
|
||||
try:
|
||||
wsi = int(ws_cookie)
|
||||
with get_conn() as conn:
|
||||
# Verify this workspace belongs to the current user
|
||||
row = conn.execute(
|
||||
"SELECT id, name, owner_id FROM workspaces WHERE id=? AND owner_id=?",
|
||||
(wsi, user["id"])
|
||||
).fetchone()
|
||||
if row:
|
||||
active_ws_name = row["name"]
|
||||
workspace_pages = _load_workspace_pages(ws_cookie)
|
||||
has_active_workspace = True
|
||||
# else: stale cookie from another user — ignore
|
||||
except (ValueError, Exception):
|
||||
pass
|
||||
|
||||
# Auth method & OAuth badge data
|
||||
auth_method = "local"
|
||||
gitea_linked = False
|
||||
github_linked = False
|
||||
if user and user.get("id"):
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
am_row = conn.execute("SELECT auth_method FROM users WHERE id=?", (user["id"],)).fetchone()
|
||||
if am_row and am_row["auth_method"]:
|
||||
auth_method = am_row["auth_method"]
|
||||
tokens = conn.execute(
|
||||
"SELECT provider FROM user_oauth_tokens WHERE user_id=?", (user["id"],)
|
||||
).fetchall()
|
||||
for t in tokens:
|
||||
if t["provider"] == "gitea":
|
||||
gitea_linked = True
|
||||
elif t["provider"] == "github":
|
||||
github_linked = True
|
||||
except Exception:
|
||||
logger.exception("_sidebar_data")
|
||||
|
||||
# Get local workspace ID for Gitea workspace mirror
|
||||
local_ws_id = 0
|
||||
if gitea_workspace and gitea_owner and gitea_repo:
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?",
|
||||
(user["id"], f"{gitea_owner}/{gitea_repo}", "%gitea_repo%")
|
||||
).fetchone()
|
||||
if row:
|
||||
local_ws_id = row["id"]
|
||||
except Exception:
|
||||
logger.exception("_sidebar_data")
|
||||
|
||||
# Private pages for mirror workspace (when Gitea remote active)
|
||||
private_pages = []
|
||||
if gitea_workspace and local_ws_id:
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
pp_rows = conn.execute(
|
||||
"SELECT id, title FROM pages WHERE parent_section='Private' AND workspace_id=? AND deleted_at IS NULL ORDER BY updated_at DESC LIMIT 20",
|
||||
(local_ws_id,)
|
||||
).fetchall()
|
||||
private_pages = [{"id": r[0], "title": r[1] or "Untitled"} for r in pp_rows]
|
||||
except Exception:
|
||||
logger.exception("_sidebar_data")
|
||||
|
||||
# Shared / received / published pages for the sidebar (Par moi / Avec moi)
|
||||
shared_made_pages = []
|
||||
shared_received_pages = []
|
||||
published_pages = []
|
||||
shared_pages = []
|
||||
if user and user.get("id"):
|
||||
from app.routers.board import _load_shared_sidebar_pages
|
||||
shared_made_pages, shared_received_pages, published_pages, shared_pages = _load_shared_sidebar_pages(user["id"])
|
||||
|
||||
sidebar = {
|
||||
"workspace_name": ws, "workspace_initial": initial,
|
||||
"active_ws_name": active_ws_name,
|
||||
"workspace_key": f"{gitea_owner}/{gitea_repo}" if gitea_workspace else "",
|
||||
"gitea_workspace": gitea_workspace,
|
||||
"gitea_owner": gitea_owner,
|
||||
"gitea_repo": gitea_repo,
|
||||
"local_ws_id": local_ws_id,
|
||||
"workspace_pages": workspace_pages,
|
||||
"current_page": "Dashboard", "last_edited": "now",
|
||||
"recent_pages": recent_pages,
|
||||
"private_pages": private_pages,
|
||||
"favorite_pages": [],
|
||||
"shared_pages": shared_pages,
|
||||
"shared_made_pages": shared_made_pages,
|
||||
"shared_received_pages": shared_received_pages,
|
||||
"published_pages": published_pages,
|
||||
"user": user,
|
||||
"avatar_url": avatar_url,
|
||||
"avatar_color": avatar_color,
|
||||
"auth_method": auth_method,
|
||||
"gitea_linked": gitea_linked,
|
||||
"github_linked": github_linked,
|
||||
"has_active_workspace": has_active_workspace,
|
||||
"app_version": _get_app_version(),
|
||||
}
|
||||
|
||||
sidebar["local_workspaces"] = _local_workspaces_for_user(user)
|
||||
|
||||
return sidebar
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ User API endpoints ═══════════
|
||||
|
||||
def _get_user_id(request: Request) -> int:
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
return user["id"] if user and user.get("id") else 1
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _require_user_id(request: Request) -> int:
|
||||
"""A3/A4 — 401 sans session (les routes qui mutent un compte ne tolèrent
|
||||
plus le fallback « legacy single-user » → id 1 = l'admin seedé)."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
return user["id"]
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _file_page_disk_path(page: dict):
|
||||
"""Resolve the on-disk file behind a ``content_format == 'file'`` page.
|
||||
|
||||
Returns ``(abs_path: Path, filename: str, mime: str, size: int)`` or None
|
||||
when the row is not a file page, references a non-textual/missing file, or
|
||||
the path escapes the data root (path-traversal guard).
|
||||
"""
|
||||
if (page.get("content_format") or "") != "file":
|
||||
return None
|
||||
import json as _json
|
||||
try:
|
||||
meta = _json.loads(page.get("content", "{}"))
|
||||
except (_json.JSONDecodeError, TypeError):
|
||||
meta = {}
|
||||
if not isinstance(meta, dict):
|
||||
return None
|
||||
rel = (meta.get("file_path") or "").replace("\\", "/").strip()
|
||||
if not rel or not rel.startswith("uploads/"):
|
||||
return None
|
||||
parts = rel.split("/")
|
||||
if ".." in parts or "." in parts:
|
||||
return None
|
||||
from pathlib import Path
|
||||
root = Path(settings.data_dir).resolve()
|
||||
full = (root / rel).resolve()
|
||||
try:
|
||||
full.relative_to(root)
|
||||
except ValueError:
|
||||
return None
|
||||
if not full.exists() or not full.is_file():
|
||||
return None
|
||||
filename = parts[-1] or page.get("title", "file")
|
||||
mime = meta.get("mime_type") or "application/octet-stream"
|
||||
size = meta.get("size") or 0
|
||||
return (full, filename, mime, size)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _require_page_view(request: Request, page_id: int) -> None:
|
||||
"""A16 : lecture d'une pièce jointe = session + `can_view_page` (404 sinon)."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
from app.services.permission_manager import PermissionManager
|
||||
|
||||
if not PermissionManager(user["id"], bool(user.get("is_admin"))).can_view_page(page_id):
|
||||
raise HTTPException(404, "Page not found")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _build_tree_children(conn, parent_id: int | None, ws_id: int, uid: int | None = None) -> list:
|
||||
"""Recursively build the tree of children for a node."""
|
||||
if parent_id is None:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, parent_section, content_format, content, page_icon, "
|
||||
"(is_shared OR share_mode != 'private' OR COALESCE(published,0)) as is_shared, "
|
||||
"created_at, updated_at FROM pages "
|
||||
"WHERE parent_id IS NULL AND workspace_id=? AND deleted_at IS NULL AND collection_row_id IS NULL "
|
||||
"ORDER BY created_at DESC",
|
||||
(ws_id,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, parent_section, content_format, content, page_icon, "
|
||||
"(is_shared OR share_mode != 'private' OR COALESCE(published,0)) as is_shared, "
|
||||
"created_at, updated_at FROM pages "
|
||||
"WHERE parent_id=? AND workspace_id=? AND deleted_at IS NULL "
|
||||
"ORDER BY created_at DESC",
|
||||
(parent_id, ws_id),
|
||||
).fetchall()
|
||||
|
||||
# Get workspace owner name for author display
|
||||
ws_owner = conn.execute(
|
||||
"SELECT u.full_name, u.login FROM workspaces w JOIN users u ON u.id=w.owner_id WHERE w.id=?",
|
||||
(ws_id,),
|
||||
).fetchone()
|
||||
author = ws_owner["full_name"] or ws_owner["login"] if ws_owner else "—"
|
||||
|
||||
# Collect all page IDs to fetch tags in one query
|
||||
all_ids = [r["id"] for r in rows]
|
||||
tags_map = {}
|
||||
favorited_ids = set()
|
||||
if all_ids:
|
||||
placeholders = ",".join("?" for _ in all_ids)
|
||||
tag_rows = conn.execute(
|
||||
f"SELECT pt.page_id, t.id, t.name, t.color FROM page_tags pt "
|
||||
f"JOIN tags t ON t.id=pt.tag_id WHERE pt.page_id IN ({placeholders})",
|
||||
all_ids,
|
||||
).fetchall()
|
||||
for tr in tag_rows:
|
||||
tags_map.setdefault(tr["page_id"], []).append({
|
||||
"id": tr["id"], "name": tr["name"], "color": tr["color"],
|
||||
})
|
||||
if uid is not None:
|
||||
fav_rows = conn.execute(
|
||||
f"SELECT page_id FROM favorites WHERE user_id=? AND page_id IN ({placeholders})",
|
||||
[uid, *all_ids],
|
||||
).fetchall()
|
||||
favorited_ids = {fr["page_id"] for fr in fav_rows}
|
||||
|
||||
tree = []
|
||||
for r in rows:
|
||||
is_folder = r["parent_section"] == "Workspace"
|
||||
children = _build_tree_children(conn, r["id"], ws_id, uid)
|
||||
|
||||
# Compute size
|
||||
size = 0
|
||||
if r["content_format"] == "file":
|
||||
import json as _json
|
||||
try:
|
||||
meta = _json.loads(r["content"])
|
||||
size = meta.get("size", 0)
|
||||
except Exception:
|
||||
size = len(r["content"] or "")
|
||||
else:
|
||||
size = len(r["content"] or "")
|
||||
|
||||
tree.append({
|
||||
"id": r["id"],
|
||||
"name": r["title"] or "Untitled",
|
||||
"type": "folder" if is_folder else "page",
|
||||
"is_folder": is_folder,
|
||||
"content_format": r["content_format"] if not is_folder else None,
|
||||
"page_icon": r["page_icon"] or "",
|
||||
"children": children,
|
||||
"has_children": len(children) > 0,
|
||||
"child_count": len(children),
|
||||
"size": size,
|
||||
"size_display": _format_size(size),
|
||||
"created_at": r["created_at"],
|
||||
"updated_at": r["updated_at"],
|
||||
"author": author,
|
||||
"tags": tags_map.get(r["id"], []),
|
||||
"is_shared": bool(r["is_shared"]),
|
||||
"favorited": r["id"] in favorited_ids,
|
||||
})
|
||||
return tree
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _format_size(size_bytes: int) -> str:
|
||||
"""Human-readable file size."""
|
||||
if size_bytes < 1024:
|
||||
return f"{size_bytes} B"
|
||||
elif size_bytes < 1024 * 1024:
|
||||
return f"{size_bytes / 1024:.1f} KB"
|
||||
elif size_bytes < 1024 * 1024 * 1024:
|
||||
return f"{size_bytes / (1024 * 1024):.1f} MB"
|
||||
return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _build_breadcrumb(conn, folder_id: int) -> list:
|
||||
"""Build breadcrumb trail from root to folder_id."""
|
||||
breadcrumb = []
|
||||
current = folder_id
|
||||
seen = set()
|
||||
while current and current not in seen:
|
||||
seen.add(current)
|
||||
row = conn.execute(
|
||||
"SELECT id, title, parent_id, parent_section FROM pages WHERE id=?",
|
||||
(current,),
|
||||
).fetchone()
|
||||
if row:
|
||||
breadcrumb.insert(0, {
|
||||
"id": row["id"],
|
||||
"name": row["title"] or "Untitled",
|
||||
"is_folder": row["parent_section"] == "Workspace",
|
||||
})
|
||||
current = row["parent_id"]
|
||||
else:
|
||||
break
|
||||
return breadcrumb
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _nav_breadcrumb(conn, page_id: int) -> list:
|
||||
"""Build a Notion-style breadcrumb chain (root -> page) for the header.
|
||||
|
||||
Returns a list of dicts: {id, label, url, icon, menu}. The last item is the
|
||||
current page (url = None). Every item has ``menu: True`` so the header can
|
||||
open a sibling-navigation dropdown for it.
|
||||
"""
|
||||
from app.routers.board import _file_icon
|
||||
chain = []
|
||||
current = page_id
|
||||
seen = set()
|
||||
while current and current not in seen:
|
||||
seen.add(current)
|
||||
row = conn.execute(
|
||||
"SELECT id, title, parent_id, parent_section, content_format "
|
||||
"FROM pages WHERE id=? AND deleted_at IS NULL",
|
||||
(current,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
break
|
||||
is_folder = row["parent_section"] == "Workspace"
|
||||
title = row["title"] or "Untitled"
|
||||
chain.insert(0, {
|
||||
"id": row["id"],
|
||||
"label": title,
|
||||
"url": None,
|
||||
"icon": "folder" if is_folder else _file_icon(title, row["content_format"]),
|
||||
"menu": True,
|
||||
})
|
||||
current = row["parent_id"]
|
||||
# All items except the current page are navigable links.
|
||||
for i, item in enumerate(chain):
|
||||
if i < len(chain) - 1:
|
||||
item["url"] = f"/pages/{item['id']}"
|
||||
return chain
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _get_active_workspace(request: Request, user_id: int = None) -> dict | None:
|
||||
"""Get the active workspace ID from the cookie (verified for current user), or first user workspace, or None."""
|
||||
ws_id = request.cookies.get(WORKSPACE_COOKIE)
|
||||
if ws_id:
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
ws = conn.execute("SELECT * FROM workspaces WHERE id=?",
|
||||
(int(ws_id),)).fetchone()
|
||||
if ws:
|
||||
ws_dict = dict(ws)
|
||||
# Verify ownership — only return if it belongs to the current user
|
||||
if user_id is None or ws_dict.get("owner_id") == user_id:
|
||||
return ws_dict
|
||||
except (ValueError, Exception):
|
||||
pass
|
||||
# Fallback: first workspace owned by this user
|
||||
if user_id:
|
||||
with get_conn() as conn:
|
||||
ws = conn.execute(
|
||||
"SELECT * FROM workspaces WHERE owner_id=? ORDER BY id LIMIT 1",
|
||||
(user_id,)
|
||||
).fetchone()
|
||||
if ws:
|
||||
return dict(ws)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _sanitize_id(block_id: str) -> str:
|
||||
"""Sanitize a block id for use as an HTML anchor (only alnum kept)."""
|
||||
if not block_id:
|
||||
return ""
|
||||
return "".join(ch for ch in str(block_id) if ch.isalnum())
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _render_blocks_public(blocks: list, titles: dict | None = None) -> str:
|
||||
"""Render FlowDeck blocks as plain HTML for public pages.
|
||||
|
||||
v5.11.0: ``titles`` (token → label, see app.services.wiki_links) turns
|
||||
``[[fdpage:ID]]`` / ``[[fddate:...]]`` tokens into chips/links.
|
||||
"""
|
||||
html_parts = []
|
||||
|
||||
def _wiki(c: str) -> str:
|
||||
if titles and ("[[fdpage:" in c or "[[fddate:" in c):
|
||||
from app.services.wiki_links import resolve_tokens_html
|
||||
return resolve_tokens_html(c, titles)
|
||||
return c
|
||||
|
||||
for b in blocks:
|
||||
t = b.get("type", "paragraph")
|
||||
c = _wiki(b.get("content", "") or "")
|
||||
if t == "heading_1":
|
||||
html_parts.append(f'<h1 id="h-{_sanitize_id(b.get("id",""))}" style="font-size:2.5rem;font-weight:700;margin:32px 0 8px;">{c}</h1>')
|
||||
elif t == "heading_2":
|
||||
html_parts.append(f'<h2 id="h-{_sanitize_id(b.get("id",""))}" style="font-size:1.75rem;font-weight:600;margin:28px 0 6px;">{c}</h2>')
|
||||
elif t == "heading_3":
|
||||
html_parts.append(f'<h3 id="h-{_sanitize_id(b.get("id",""))}" style="font-size:1.35rem;font-weight:600;margin:24px 0 4px;">{c}</h3>')
|
||||
elif t == "heading_4":
|
||||
html_parts.append(f'<h4 id="h-{_sanitize_id(b.get("id",""))}" style="font-size:1.15rem;font-weight:600;margin:20px 0 4px;">{c}</h4>')
|
||||
elif t == "bulleted_list":
|
||||
html_parts.append(f'<li style="margin-left:24px;">{c}</li>')
|
||||
elif t == "numbered_list":
|
||||
html_parts.append(f'<li style="margin-left:24px;list-style:decimal;">{c}</li>')
|
||||
elif t == "to_do":
|
||||
checked = "checked" if b.get("checked") else ""
|
||||
todo_style = "text-decoration:line-through;opacity:.5" if b.get("checked") else ""
|
||||
html_parts.append(
|
||||
f'<div style="display:flex;align-items:center;gap:8px;margin:4px 0;">'
|
||||
f'<input type="checkbox" {checked} disabled>'
|
||||
f'<span style="{todo_style}">{c}</span>'
|
||||
f'</div>'
|
||||
)
|
||||
elif t == "toggle":
|
||||
children_html = ""
|
||||
if b.get("children"):
|
||||
children_html = '<div style="margin-left:22px;padding-left:12px;border-left:1px solid rgba(255,255,255,.1);margin-top:4px;">'
|
||||
children_html += _render_blocks_public(b["children"], titles)
|
||||
children_html += "</div>"
|
||||
html_parts.append(
|
||||
f'<details style="margin:8px 0;" open><summary style="cursor:pointer;font-weight:500;">{c}</summary>{children_html}</details>'
|
||||
)
|
||||
elif t == "quote":
|
||||
html_parts.append(
|
||||
f'<blockquote style="border-left:3px solid var(--accent,#4c9aff);margin:12px 0;padding:4px 16px;opacity:.85;">{c}</blockquote>'
|
||||
)
|
||||
elif t == "table_of_contents":
|
||||
toc = [
|
||||
x for x in blocks
|
||||
if x.get("type", "").startswith("heading_") and (x.get("content") or "").strip()
|
||||
]
|
||||
if toc:
|
||||
items = []
|
||||
for h in toc:
|
||||
lvl = int(h["type"].split("_")[-1])
|
||||
items.append(
|
||||
f'<div style="margin-left:{max(0, lvl - 1) * 14}px;padding:5px 8px;font-size:14px;">'
|
||||
f'<a href="#h-{_sanitize_id(h.get("id",""))}" style="color:inherit;text-decoration:none;display:block;">{h.get("content","")}</a></div>'
|
||||
)
|
||||
html_parts.append(
|
||||
'<div style="border:1px solid rgba(255,255,255,.1);border-radius:8px;padding:16px 20px;margin:4px 0;">'
|
||||
'<div style="font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;opacity:.5;margin-bottom:10px;">On this page</div>'
|
||||
+ "".join(items) + "</div>"
|
||||
)
|
||||
elif t == "math":
|
||||
tex = c.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
html_parts.append(
|
||||
f'<div data-katex="{tex}" style="margin:12px 0;padding:12px 16px;background:rgba(255,255,255,.04);border-radius:8px;overflow-x:auto;"></div>'
|
||||
)
|
||||
elif t == "columns":
|
||||
cols_html = ""
|
||||
for child in b.get("children") or []:
|
||||
cols_html += (
|
||||
'<div style="flex:1;min-width:0;padding:10px 12px;background:rgba(255,255,255,.05);'
|
||||
'border-radius:8px;box-sizing:border-box;">'
|
||||
+ _render_blocks_public([child], titles) + "</div>"
|
||||
)
|
||||
html_parts.append(
|
||||
f'<div style="display:flex;gap:12px;margin:8px 0 16px;align-items:stretch;">{cols_html}</div>'
|
||||
)
|
||||
elif t == "callout":
|
||||
icon = b.get("icon", "💡")
|
||||
bg = (b.get("style") or {}).get("bgColor", "rgba(76,154,255,.1)")
|
||||
html_parts.append(
|
||||
f'<div style="display:flex;gap:10px;padding:14px 18px;margin:12px 0;border-radius:8px;'
|
||||
f'background:{bg};align-items:flex-start;">'
|
||||
f'<span style="font-size:20px;flex-shrink:0;">{icon}</span>'
|
||||
f'<span>{c}</span></div>'
|
||||
)
|
||||
elif t == "code":
|
||||
lang = b.get("language", "")
|
||||
lang_label = f"<div style='font-size:11px;opacity:.4;margin-bottom:8px;'>{lang}</div>" if lang else ""
|
||||
html_parts.append(
|
||||
f'<pre style="background:rgba(255,255,255,.05);padding:16px 20px;border-radius:8px;'
|
||||
f'overflow-x:auto;font-size:14px;line-height:1.5;margin:12px 0;">'
|
||||
f'{lang_label}'
|
||||
f'<code>{c}</code></pre>'
|
||||
)
|
||||
elif t == "divider":
|
||||
html_parts.append('<hr style="border:none;border-top:1px solid rgba(255,255,255,.1);margin:16px 0;">')
|
||||
elif t == "image":
|
||||
src = b.get("src", "")
|
||||
alt = b.get("alt", "")
|
||||
html_parts.append(
|
||||
f'<figure style="margin:16px 0;text-align:center;">'
|
||||
f'<img src="{src}" alt="{alt}" data-full="{src}" style="max-width:100%;border-radius:8px;cursor:zoom-in;">'
|
||||
f'</figure>'
|
||||
)
|
||||
elif t == "video":
|
||||
src = b.get("src", "")
|
||||
if src:
|
||||
html_parts.append(
|
||||
f'<video controls preload="metadata" style="max-width:100%;border-radius:8px;display:block;margin:12px auto;">'
|
||||
f'<source src="{src}"></video>'
|
||||
)
|
||||
elif t == "audio":
|
||||
src = b.get("src", "")
|
||||
if src:
|
||||
html_parts.append(
|
||||
f'<audio controls preload="metadata" style="width:100%;margin:8px 0;"><source src="{src}"></audio>'
|
||||
)
|
||||
elif t == "bookmark":
|
||||
url = b.get("url") or b.get("src") or ""
|
||||
title = b.get("title") or url
|
||||
desc = b.get("description") or ""
|
||||
img = b.get("image") or ""
|
||||
site = b.get("site_name") or ""
|
||||
img_html = (
|
||||
f'<img src="{img}" alt="" style="width:120px;height:90px;object-fit:cover;border-radius:8px;flex-shrink:0;">' if img else ""
|
||||
)
|
||||
desc_html = f'<div style="font-size:13px;opacity:.75;margin-top:4px;">{desc}</div>' if desc else ""
|
||||
site_html = f'<div style="font-size:11px;opacity:.5;text-transform:uppercase;letter-spacing:.5px;margin-top:6px;">{site}</div>' if site else ""
|
||||
html_parts.append(
|
||||
f'<a href="{url}" target="_blank" rel="noopener noreferrer" style="text-decoration:none;color:inherit;">'
|
||||
f'<div style="display:flex;gap:14px;align-items:center;border:1px solid rgba(255,255,255,.12);border-radius:10px;'
|
||||
f'padding:14px 16px;margin:14px 0;background:rgba(255,255,255,.03);">'
|
||||
f'<div style="flex:1;min-width:0;"><div style="font-weight:600;font-size:15px;">{title}</div>'
|
||||
f'{desc_html}{site_html}</div>{img_html}</div></a>'
|
||||
)
|
||||
elif t == "embed":
|
||||
url = b.get("src", "")
|
||||
emb = b.get("embed_type") or ""
|
||||
if emb in ("inline_dbs", "collection"):
|
||||
html_parts.append('<div>[Embedded content]</div>')
|
||||
elif emb == "download":
|
||||
html_parts.append(
|
||||
f'<a href="{url}" download style="display:inline-block;margin:12px 0;color:var(--accent,#4c9aff);">⬇ {b.get("file_name") or "Download"}</a>'
|
||||
)
|
||||
elif emb == "pdf" and url:
|
||||
html_parts.append(
|
||||
f'<iframe src="{url}" style="width:100%;height:70vh;border:none;border-radius:8px;margin:12px 0;"></iframe>'
|
||||
)
|
||||
elif url:
|
||||
from app.services.embeds import embed_src
|
||||
src = b.get("embed_src") or embed_src(url) or url
|
||||
height = b.get("height") or 520
|
||||
try:
|
||||
height = int(height)
|
||||
except (ValueError, TypeError):
|
||||
height = 520
|
||||
html_parts.append(
|
||||
f'<div style="position:relative;width:100%;height:{height}px;border-radius:8px;overflow:hidden;'
|
||||
f'background:#0a0a0a;"><iframe src="{src}" loading="lazy" frameborder="0" '
|
||||
f'style="position:absolute;inset:0;width:100%;height:100%;" allowfullscreen allow="autoplay; encrypted-media; picture-in-picture"></iframe></div>'
|
||||
)
|
||||
elif t == "synced":
|
||||
# v6.5.0: render synced block instances (resolved server-side).
|
||||
if b.get("_synced_deleted"):
|
||||
html_parts.append(
|
||||
'<div style="margin:8px 0;padding:8px 12px;border-left:3px solid #e05e5e;'
|
||||
'background:rgba(224,94,94,.08);border-radius:4px;font-size:13px;opacity:.8;">'
|
||||
'Deleted synced block</div>'
|
||||
)
|
||||
else:
|
||||
inner = b.get("_synced_content")
|
||||
if not isinstance(inner, list) or not inner:
|
||||
try:
|
||||
import json as _sj
|
||||
parsed = _sj.loads(b.get("content") or "[]")
|
||||
inner = parsed if isinstance(parsed, list) else []
|
||||
except (ValueError, TypeError):
|
||||
inner = []
|
||||
inner = [{"type": "paragraph", "content": str(x)} if not isinstance(x, dict) else x
|
||||
for x in inner]
|
||||
if inner:
|
||||
html_parts.append(
|
||||
'<div style="margin:8px 0;padding-left:12px;'
|
||||
'border-left:3px solid var(--accent,#4c9aff);">'
|
||||
+ _render_blocks_public(inner, titles) + '</div>'
|
||||
)
|
||||
else:
|
||||
html_parts.append(f'<p style="margin:4px 0;line-height:1.7;">{c}</p>')
|
||||
return "\n".join(html_parts)
|
||||
|
||||
|
||||
# ═══════════ Library page actions API ═══════════
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""FlowDeck — Dashboard : account_api.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/dashboard.py (2 735 lignes, 63 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
from ._common import _require_user_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["dashboard"])
|
||||
|
||||
|
||||
|
||||
|
||||
@router.put("/api/user/profile")
|
||||
def update_profile(request: Request, body: dict = Body(default={})):
|
||||
full_name = body.get("full_name", "").strip()
|
||||
uid = _require_user_id(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE users SET full_name=? WHERE id=?", (full_name, uid))
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.put("/api/user/password")
|
||||
def update_password(request: Request, body: dict = Body(default={})):
|
||||
from app.password_utils import hash_password, verify_password
|
||||
password = body.get("password", "").strip()
|
||||
if len(password) < 6:
|
||||
return {"error": "Password must be at least 6 characters"}
|
||||
uid = _require_user_id(request)
|
||||
# A3 : le mot de passe actuel est exigé (session volée ≠ droit de changer le mdp).
|
||||
current = body.get("current_password", "")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT password_hash FROM users WHERE id=?", (uid,)).fetchone()
|
||||
if not row or not verify_password(current, row["password_hash"]):
|
||||
raise HTTPException(403, "Current password is incorrect")
|
||||
conn.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(password), uid))
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/user/token")
|
||||
def generate_token(request: Request):
|
||||
import secrets
|
||||
uid = _require_user_id(request)
|
||||
token = secrets.token_hex(32)
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO user_tokens (gitea_user_id, gitea_token, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)",
|
||||
(uid, token),
|
||||
)
|
||||
conn.commit()
|
||||
return {"token": f"fd_{token}"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/api/user/forge/{provider}")
|
||||
def disconnect_forge(request: Request, provider: str):
|
||||
uid = _require_user_id(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM user_oauth_tokens WHERE user_id=? AND provider=?", (uid, provider)
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,439 @@
|
||||
"""FlowDeck — Dashboard : settings.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/dashboard.py (2 735 lignes, 63 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
|
||||
from ._common import _format_size, _get_active_workspace, _get_user_id, _sidebar_data
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["dashboard"])
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ Settings Page ═══════════
|
||||
|
||||
@router.get("/settings", response_class=HTMLResponse)
|
||||
def app_settings_page(request: Request):
|
||||
"""Settings & configuration page."""
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return RedirectResponse("/auth/login?provider=local", status_code=302)
|
||||
ctx = {**sidebar, "user": user}
|
||||
# Pass active workspace for breadcrumb nav menu
|
||||
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
||||
ctx["nav_workspace_id"] = ws["id"] if ws else 0
|
||||
template = env.get_template("settings.html")
|
||||
return template.render(**ctx)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/settings/avatar")
|
||||
async def upload_avatar(request: Request):
|
||||
"""Upload a user avatar image."""
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
form = await request.form()
|
||||
file = form.get("file")
|
||||
if not file:
|
||||
return {"error": "No file"}, 400
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return {"error": "Not authenticated"}, 401
|
||||
# Save to data/avatars
|
||||
avatars_dir = Path("/data/avatars")
|
||||
avatars_dir.mkdir(parents=True, exist_ok=True)
|
||||
ext = os.path.splitext(file.filename)[1] or ".png"
|
||||
filename = f"{user['id']}_{uuid.uuid4().hex[:8]}{ext}"
|
||||
filepath = avatars_dir / filename
|
||||
content = await file.read()
|
||||
filepath.write_bytes(content)
|
||||
# Update user avatar_url
|
||||
avatar_url = f"/api/settings/avatar/{filename}"
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE users SET avatar_url = ? WHERE id = ?", (avatar_url, user["id"]))
|
||||
conn.commit()
|
||||
return {"avatar_url": avatar_url}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/settings/avatar/{filename:path}")
|
||||
def serve_avatar_file(filename: str):
|
||||
"""Serve an uploaded avatar image file."""
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.responses import FileResponse
|
||||
# A11 : garde path traversal (motif de serve_uploaded_file) — `:path` Starlette
|
||||
# accepte les `/`, donc `..%2f` ressortirait du dossier avatars.
|
||||
base_dir = Path("/data/avatars").resolve()
|
||||
filepath = (base_dir / filename).resolve()
|
||||
try:
|
||||
filepath.relative_to(base_dir)
|
||||
except ValueError:
|
||||
return JSONResponse({"error": "Path traversal denied"}, status_code=403)
|
||||
if not filepath.is_file():
|
||||
return JSONResponse({"error": "Not found"}, status_code=404)
|
||||
return FileResponse(filepath)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/avatar/{user_id:int}")
|
||||
def get_avatar(user_id: int):
|
||||
"""Redirect to the user's avatar."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT avatar_url FROM users WHERE id = ?", (user_id,)).fetchone()
|
||||
if row and row["avatar_url"]:
|
||||
return RedirectResponse(row["avatar_url"], status_code=302)
|
||||
return JSONResponse({"error": "No avatar"}, status_code=404)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/settings/avatar-color")
|
||||
def set_avatar_color(request: Request, body: dict = Body(default={})):
|
||||
"""Set the user's avatar background color."""
|
||||
color = body.get("color", "#3A3A3A")
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return {"error": "Not authenticated"}, 401
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE users SET avatar_url = '', avatar_color = ? WHERE id = ?", (color, user["id"]))
|
||||
conn.commit()
|
||||
return {"status": "ok", "color": color}
|
||||
|
||||
|
||||
# ═══════════ Tag Management API (per-user) ═══════════
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ Tag Management API (per-user) ═══════════
|
||||
|
||||
@router.post("/api/settings/tags")
|
||||
def create_tag_global(request: Request, body: dict = Body(default={})):
|
||||
"""Create a tag for the current user."""
|
||||
tag_name = body.get("name", "").strip().lower()
|
||||
color = body.get("color", "#787774")
|
||||
uid = _get_user_id(request)
|
||||
if not tag_name or not uid:
|
||||
return {"error": "Tag name required"}, 400
|
||||
with get_conn() as conn:
|
||||
tag = conn.execute("SELECT id FROM tags WHERE name = ? AND user_id = ?", (tag_name, uid)).fetchone()
|
||||
if tag:
|
||||
conn.execute("UPDATE tags SET color = ? WHERE id = ?", (color, tag["id"]))
|
||||
conn.commit()
|
||||
return {"tag": {"id": tag["id"], "name": tag_name, "color": color}}
|
||||
cursor = conn.execute("INSERT INTO tags (name, color, user_id) VALUES (?, ?, ?)", (tag_name, color, uid))
|
||||
conn.commit()
|
||||
return {"tag": {"id": cursor.lastrowid, "name": tag_name, "color": color}}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.put("/api/settings/tags/{tag_id:int}")
|
||||
def update_tag_global(tag_id: int, request: Request, body: dict = Body(default={})):
|
||||
"""Update a tag (name or color) — only if owned by user."""
|
||||
uid = _get_user_id(request)
|
||||
with get_conn() as conn:
|
||||
if "name" in body:
|
||||
conn.execute("UPDATE tags SET name = ? WHERE id = ? AND user_id = ?", (body["name"].strip().lower(), tag_id, uid))
|
||||
if "color" in body:
|
||||
conn.execute("UPDATE tags SET color = ? WHERE id = ? AND user_id = ?", (body["color"], tag_id, uid))
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/api/settings/tags/{tag_id:int}")
|
||||
def delete_tag_global(tag_id: int, request: Request):
|
||||
"""Delete a tag — only if owned by user."""
|
||||
uid = _get_user_id(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM page_tags WHERE tag_id = ?", (tag_id,))
|
||||
conn.execute("DELETE FROM tags WHERE id = ? AND user_id = ?", (tag_id, uid))
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/settings/tags/all")
|
||||
def list_all_tags_global(request: Request):
|
||||
"""List current user's tags with counts."""
|
||||
uid = _get_user_id(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT t.id, t.name, t.color, COUNT(pt.page_id) as count "
|
||||
"FROM tags t LEFT JOIN page_tags pt ON pt.tag_id = t.id "
|
||||
"WHERE t.user_id = ? GROUP BY t.id ORDER BY t.name",
|
||||
(uid,),
|
||||
).fetchall()
|
||||
return JSONResponse(
|
||||
{"tags": [dict(r) for r in rows]},
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
# ═══════════ Workspace Tags API ═══════════
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ Workspace Tags API ═══════════
|
||||
|
||||
@router.get("/api/local-workspace/tags")
|
||||
def list_tags(request: Request):
|
||||
"""List ALL user tags with counts scoped to the active workspace."""
|
||||
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
||||
ws_id = ws["id"] if ws else None
|
||||
uid = _get_user_id(request)
|
||||
if not ws_id:
|
||||
return {"tags": []}
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT t.id, t.name, t.color, "
|
||||
"(SELECT COUNT(*) FROM page_tags pt "
|
||||
" JOIN pages p ON p.id = pt.page_id AND p.workspace_id = ? "
|
||||
" WHERE pt.tag_id = t.id) as count "
|
||||
"FROM tags t WHERE t.user_id = ? ORDER BY t.name",
|
||||
(ws_id, uid),
|
||||
).fetchall()
|
||||
return JSONResponse(
|
||||
{"tags": [dict(r) for r in rows]},
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/local-workspace/items/{item_id:int}/tags")
|
||||
def get_item_tags(item_id: int):
|
||||
"""Get tags for a specific item."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT t.id, t.name, t.color FROM tags t "
|
||||
"JOIN page_tags pt ON pt.tag_id = t.id "
|
||||
"WHERE pt.page_id = ? ORDER BY t.name",
|
||||
(item_id,),
|
||||
).fetchall()
|
||||
return {"tags": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/local-workspace/items/{item_id:int}/tags")
|
||||
def add_item_tag(request: Request, item_id: int, body: dict = Body(default={})):
|
||||
"""Add a tag to an item (creates tag if new, scoped to user)."""
|
||||
tag_name = body.get("name", "").strip().lower()
|
||||
tag_color = body.get("color", "#787774")
|
||||
uid = _get_user_id(request)
|
||||
if not tag_name:
|
||||
return {"error": "Tag name required"}, 400
|
||||
|
||||
with get_conn() as conn:
|
||||
# Get or create tag (per user)
|
||||
tag = conn.execute("SELECT id, name, color FROM tags WHERE name = ? AND user_id = ?", (tag_name, uid)).fetchone()
|
||||
if not tag:
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO tags (name, color, user_id) VALUES (?, ?, ?)", (tag_name, tag_color, uid)
|
||||
)
|
||||
conn.commit()
|
||||
tag_id = cursor.lastrowid
|
||||
tag = {"id": tag_id, "name": tag_name, "color": tag_color}
|
||||
else:
|
||||
tag_id = tag["id"]
|
||||
|
||||
# Link tag to page (ignore duplicate)
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO page_tags (page_id, tag_id) VALUES (?, ?)",
|
||||
(item_id, tag_id),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
logger.exception("add_item_tag")
|
||||
|
||||
return {"tag": {"id": tag["id"], "name": tag["name"], "color": tag["color"]}}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/api/local-workspace/items/{item_id:int}/tags/{tag_id:int}")
|
||||
def remove_item_tag(item_id: int, tag_id: int):
|
||||
"""Remove a tag from an item."""
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM page_tags WHERE page_id = ? AND tag_id = ?",
|
||||
(item_id, tag_id),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/local-workspace/tags/search")
|
||||
def search_by_tags(request: Request, tags: str = ""):
|
||||
"""Search items by tags (comma-separated)."""
|
||||
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
||||
ws_id = ws["id"] if ws else None
|
||||
if not ws_id:
|
||||
return {"items": []}
|
||||
tag_names = [t.strip().lower() for t in tags.split(",") if t.strip()]
|
||||
if not tag_names:
|
||||
return {"items": []}
|
||||
with get_conn() as conn:
|
||||
placeholders = ",".join("?" for _ in tag_names)
|
||||
rows = conn.execute(
|
||||
f"SELECT DISTINCT p.id, p.title, p.content_format, p.parent_section, "
|
||||
f"p.content, p.created_at, p.updated_at "
|
||||
f"FROM pages p "
|
||||
f"JOIN page_tags pt ON pt.page_id = p.id "
|
||||
f"JOIN tags t ON t.id = pt.tag_id "
|
||||
f"WHERE t.name IN ({placeholders}) AND p.workspace_id = ? AND p.deleted_at IS NULL "
|
||||
f"ORDER BY p.updated_at DESC",
|
||||
tag_names + [ws_id],
|
||||
).fetchall()
|
||||
items = []
|
||||
for r in rows:
|
||||
is_folder = r["parent_section"] == "Workspace"
|
||||
size = len(r["content"] or "")
|
||||
items.append({
|
||||
"id": r["id"],
|
||||
"name": r["title"] or "Untitled",
|
||||
"is_folder": is_folder,
|
||||
"content_format": r["content_format"],
|
||||
"created_at": r["created_at"],
|
||||
"updated_at": r["updated_at"],
|
||||
"size": size,
|
||||
"size_display": _format_size(size),
|
||||
})
|
||||
return {"items": items}
|
||||
|
||||
|
||||
# ── Account update ──
|
||||
|
||||
|
||||
|
||||
|
||||
# ── Account update ──
|
||||
@router.put("/api/settings/account")
|
||||
def update_account(request: Request, body: dict = Body(default={})):
|
||||
"""Update current user's profile: full_name, login, email, password."""
|
||||
from app.password_utils import hash_password
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
||||
with get_conn() as conn:
|
||||
uid = user["id"]
|
||||
if "full_name" in body:
|
||||
conn.execute("UPDATE users SET full_name=? WHERE id=?", (body["full_name"].strip(), uid))
|
||||
if "login" in body:
|
||||
new_login = body["login"].strip()
|
||||
if new_login and new_login != user.get("login"):
|
||||
existing = conn.execute("SELECT id FROM users WHERE login=? AND id!=?", (new_login, uid)).fetchone()
|
||||
if existing:
|
||||
return JSONResponse({"error": "Username already taken"}, status_code=409)
|
||||
conn.execute("UPDATE users SET login=? WHERE id=?", (new_login, uid))
|
||||
if "email" in body:
|
||||
conn.execute("UPDATE users SET email=? WHERE id=?", (body["email"].strip(), uid))
|
||||
if "password" in body and body["password"].strip():
|
||||
pw = body["password"].strip()
|
||||
if len(pw) < 6:
|
||||
return JSONResponse({"error": "Password must be at least 6 characters"}, status_code=400)
|
||||
conn.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(pw), uid))
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM users WHERE id=?", (uid,)).fetchone()
|
||||
user_data = dict(row)
|
||||
# Refresh session cookie with updated data (keeps the same session id)
|
||||
cookie = request.cookies.get("flowdeck_session", "")
|
||||
new_session = SessionManager.refresh_session(cookie, user_data, request)
|
||||
response = JSONResponse({"status": "ok", "user": {k: user_data[k] for k in ("id","login","full_name","email","is_admin")}})
|
||||
response.set_cookie("flowdeck_session", new_session, httponly=True, max_age=86400 * 7, samesite="lax", path="/")
|
||||
return response
|
||||
|
||||
|
||||
# ═══════════ Sidebar Refresh API ═══════════
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ Sidebar Refresh API ═══════════
|
||||
|
||||
@router.get("/api/sidebar/workspace-tree")
|
||||
def sidebar_workspace_tree(request: Request):
|
||||
"""Return the sidebar workspace tree as HTML fragment.
|
||||
|
||||
Called by appState().refreshSidebarTree() after CRUD operations
|
||||
in the main content area to keep the sidebar in sync.
|
||||
"""
|
||||
from app.routers.board import _load_workspace_pages
|
||||
from app.templating import ENV
|
||||
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return HTMLResponse("")
|
||||
|
||||
ws_cookie = request.cookies.get("flowdeck_workspace", "")
|
||||
if not ws_cookie:
|
||||
return HTMLResponse('<li class="sidebar-item empty-hint"><span class="page-icon">📄</span><span class="page-name text-dim">No pages yet</span></li>')
|
||||
|
||||
# Gitea workspace — no server-side tree, loaded client-side
|
||||
if ws_cookie.startswith("gitea:"):
|
||||
return HTMLResponse('<li class="sidebar-item empty-hint"><span class="page-icon">🔗</span><span class="page-name text-dim">Remote workspace</span></li>')
|
||||
|
||||
try:
|
||||
ws_id = int(ws_cookie)
|
||||
with get_conn() as conn:
|
||||
# Verify workspace belongs to user
|
||||
row = conn.execute(
|
||||
"SELECT id FROM workspaces WHERE id=? AND owner_id=?",
|
||||
(ws_id, user["id"])
|
||||
).fetchone()
|
||||
if not row:
|
||||
return HTMLResponse(
|
||||
'<li class="sidebar-item empty-hint"><span class="page-icon">📄</span>'
|
||||
'<span class="page-name text-dim">No pages yet</span></li>'
|
||||
)
|
||||
|
||||
pages = _load_workspace_pages(ws_cookie)
|
||||
if not pages:
|
||||
return HTMLResponse(
|
||||
'<li class="sidebar-item empty-hint"><span class="page-icon">📄</span>'
|
||||
'<span class="page-name text-dim">No pages yet</span></li>'
|
||||
)
|
||||
|
||||
# Render the tree using the extracted macro
|
||||
env = ENV
|
||||
template = env.from_string(
|
||||
"{% from '_workspace_tree_macro.html' import render_workspace_tree %}"
|
||||
"{{ render_workspace_tree(pages) }}"
|
||||
)
|
||||
html = template.render(pages=pages)
|
||||
return HTMLResponse(html)
|
||||
except (ValueError, Exception) as e:
|
||||
logger.error(f"sidebar_workspace_tree failed: {e}", exc_info=True)
|
||||
return HTMLResponse(
|
||||
'<li class="sidebar-item empty-hint"><span class="page-icon">📄</span>'
|
||||
'<span class="page-name text-dim">No pages yet</span></li>'
|
||||
)
|
||||
|
||||
|
||||
# ═══════════ Public Published Page ═══════════
|
||||
@@ -0,0 +1,559 @@
|
||||
"""FlowDeck — Dashboard : local_workspace.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/dashboard.py (2 735 lignes, 63 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC
|
||||
|
||||
from fastapi import APIRouter, Body, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
|
||||
from ._common import (
|
||||
_build_breadcrumb,
|
||||
_build_tree_children,
|
||||
_file_page_disk_path,
|
||||
_get_active_workspace,
|
||||
_get_user_id,
|
||||
_require_page_view,
|
||||
_require_user_id,
|
||||
_sidebar_data,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["dashboard"])
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ Local Workspace (file/folder CRUD) ═══════════
|
||||
|
||||
@router.get("/local-workspace", response_class=HTMLResponse)
|
||||
def local_workspace_page(request: Request, folder: int = None):
|
||||
"""Local workspace page with file/folder tree.
|
||||
|
||||
If ?folder=ID is provided, shows that folder's contents with breadcrumb.
|
||||
"""
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return RedirectResponse("/auth/login?provider=local", status_code=302)
|
||||
|
||||
ws = _get_active_workspace(request, user_id=user["id"])
|
||||
ws_id = ws["id"] if ws else None
|
||||
|
||||
# If no workspace exists for this user, redirect to workspaces page
|
||||
if not ws_id:
|
||||
return RedirectResponse("/workspaces", status_code=302)
|
||||
|
||||
# Build breadcrumb if navigating into a folder
|
||||
breadcrumb = []
|
||||
current_folder_id = folder
|
||||
if folder and ws_id:
|
||||
with get_conn() as conn:
|
||||
breadcrumb = _build_breadcrumb(conn, folder)
|
||||
|
||||
ctx = {
|
||||
**sidebar,
|
||||
"user": user,
|
||||
"workspace_name": sidebar.get("active_ws_name", "My Workspace"),
|
||||
"current_folder_id": current_folder_id or 0,
|
||||
"workspace_id": ws_id or 0,
|
||||
"nav_workspace_id": ws_id or 0,
|
||||
"breadcrumb": breadcrumb,
|
||||
"breadcrumbs": breadcrumb,
|
||||
}
|
||||
template = env.get_template("local_workspace.html")
|
||||
return HTMLResponse(
|
||||
content=template.render(**ctx),
|
||||
headers={
|
||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||
"Pragma": "no-cache",
|
||||
"Expires": "0",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/local-workspace/tree")
|
||||
def local_workspace_tree(request: Request, folder: int = None):
|
||||
"""Return the file/folder tree filtered by active workspace.
|
||||
|
||||
If ?folder=ID is provided, returns only that folder's children.
|
||||
Otherwise returns the full recursive tree from root.
|
||||
"""
|
||||
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
||||
ws_id = ws["id"] if ws else None
|
||||
if not ws_id:
|
||||
return {"tree": [], "breadcrumb": []}
|
||||
uid = _get_user_id(request)
|
||||
|
||||
with get_conn() as conn:
|
||||
if folder:
|
||||
# Show only this folder's children + build breadcrumb
|
||||
children = _build_tree_children(conn, folder, ws_id, uid)
|
||||
breadcrumb = _build_breadcrumb(conn, folder)
|
||||
return {"tree": children, "breadcrumb": breadcrumb, "current_folder": folder}
|
||||
else:
|
||||
# Full tree from root
|
||||
roots = _build_tree_children(conn, None, ws_id, uid)
|
||||
return {"tree": roots, "breadcrumb": [], "current_folder": None}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/local-workspace/page-content/{page_id:int}")
|
||||
def get_page_content(page_id: int):
|
||||
"""Return the raw content of a page (for preview)."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT content, content_format FROM pages WHERE id=?", (page_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return JSONResponse({"error": "Not found"}, status_code=404)
|
||||
fmt = row["content_format"]
|
||||
if fmt == "file":
|
||||
return JSONResponse({"content": "(uploaded file)", "format": fmt})
|
||||
# v6.5.0: resolve synced blocks server-side (fresh content on read).
|
||||
from app.services.synced_blocks import resolve_content_json
|
||||
return {"content": resolve_content_json(row["content"] or "", fmt), "format": fmt}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}/download")
|
||||
def download_page_file(request: Request, page_id: int):
|
||||
"""Download the original uploaded file of a ``file`` page (attachment)."""
|
||||
_require_page_view(request, page_id)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, title, content, content_format FROM pages "
|
||||
"WHERE id=? AND deleted_at IS NULL",
|
||||
(page_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return JSONResponse({"error": "Not found"}, status_code=404)
|
||||
resolved = _file_page_disk_path(dict(row))
|
||||
if not resolved:
|
||||
return JSONResponse({"error": "No downloadable file"}, status_code=404)
|
||||
full, filename, mime, _size = resolved
|
||||
from fastapi.responses import FileResponse
|
||||
return FileResponse(
|
||||
str(full), media_type=mime or "application/octet-stream",
|
||||
filename=filename, content_disposition_type="attachment",
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}/file-content")
|
||||
def page_file_content(request: Request, page_id: int):
|
||||
"""Return the textual content of a ``file`` page (for copy to clipboard).
|
||||
|
||||
Binary files (PDF, images…) answer ``{ok: false}`` — the clipboard copy is
|
||||
only meaningful for plain-text / code / markdown files.
|
||||
"""
|
||||
from app.services.export import _file_text
|
||||
|
||||
_require_page_view(request, page_id)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, title, content, content_format FROM pages "
|
||||
"WHERE id=? AND deleted_at IS NULL",
|
||||
(page_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return JSONResponse({"error": "Not found"}, status_code=404)
|
||||
page = dict(row)
|
||||
text = _file_text(page)
|
||||
if text is None:
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": "Not a textual file", "name": page.get("title", "")},
|
||||
status_code=415,
|
||||
)
|
||||
return {"ok": True, "name": page.get("title") or "File", "content": text}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/local-workspace/breadcrumb")
|
||||
def local_workspace_breadcrumb(request: Request, folder: int):
|
||||
"""Return breadcrumb trail for a folder."""
|
||||
with get_conn() as conn:
|
||||
breadcrumb = _build_breadcrumb(conn, folder)
|
||||
return {"breadcrumb": breadcrumb}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/nav/menu")
|
||||
def nav_menu(request: Request, workspace_id: int = None, parent_id: int = None):
|
||||
"""Return the pages at one level for the header breadcrumb navigation menu.
|
||||
|
||||
If ``parent_id`` is given, returns that page's children; otherwise the
|
||||
workspace's root pages. Each item includes ``has_children`` so the frontend
|
||||
can render an expandable sub-menu.
|
||||
"""
|
||||
from app.routers.board import _file_icon
|
||||
ws_id = workspace_id
|
||||
if not ws_id:
|
||||
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
||||
ws_id = ws["id"] if ws else None
|
||||
if not ws_id:
|
||||
return {"items": []}
|
||||
with get_conn() as conn:
|
||||
if parent_id:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, parent_section, content_format FROM pages "
|
||||
"WHERE parent_id=? AND workspace_id=? AND deleted_at IS NULL "
|
||||
"ORDER BY sort_order ASC, created_at DESC",
|
||||
(parent_id, ws_id),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, parent_section, content_format FROM pages "
|
||||
"WHERE parent_id IS NULL AND workspace_id=? AND deleted_at IS NULL AND collection_row_id IS NULL "
|
||||
"ORDER BY sort_order ASC, created_at DESC",
|
||||
(ws_id,),
|
||||
).fetchall()
|
||||
ids = [r["id"] for r in rows]
|
||||
child_counts = {}
|
||||
if ids:
|
||||
placeholders = ",".join("?" for _ in ids)
|
||||
cc_rows = conn.execute(
|
||||
f"SELECT parent_id, COUNT(*) AS c FROM pages "
|
||||
f"WHERE parent_id IN ({placeholders}) AND deleted_at IS NULL "
|
||||
f"GROUP BY parent_id",
|
||||
ids,
|
||||
).fetchall()
|
||||
for cr in cc_rows:
|
||||
child_counts[cr["parent_id"]] = cr["c"]
|
||||
items = []
|
||||
for r in rows:
|
||||
is_folder = r["parent_section"] == "Workspace"
|
||||
title = r["title"] or "Untitled"
|
||||
items.append({
|
||||
"id": r["id"],
|
||||
"name": title,
|
||||
"icon": "folder" if is_folder else _file_icon(title, r["content_format"]),
|
||||
"has_children": child_counts.get(r["id"], 0) > 0,
|
||||
"url": f"/pages/{r['id']}",
|
||||
})
|
||||
return {"items": items}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/local-workspace/items")
|
||||
def create_local_workspace_item(request: Request, body: dict = Body(default={})):
|
||||
"""Create a new file in the active workspace."""
|
||||
name = (body.get("name") or "").strip() or "Untitled"
|
||||
item_type = body.get("type", "page")
|
||||
parent_id = body.get("parent_id")
|
||||
explicit_ws_id = body.get("workspace_id")
|
||||
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
||||
ws_id = explicit_ws_id or (ws["id"] if ws else None)
|
||||
ws_key = (ws["name"] if ws else "Workspace") if not explicit_ws_id else ""
|
||||
section = 'Workspace' if item_type == 'folder' else 'Private'
|
||||
with get_conn() as conn:
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id) "
|
||||
"VALUES (?, ?, ?, '', 'blocks', ?, ?)",
|
||||
(ws_key, ws_id, name, section, parent_id)
|
||||
)
|
||||
conn.commit()
|
||||
pid = cursor.lastrowid
|
||||
return {"id": pid, "name": name, "type": item_type}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.put("/api/local-workspace/items/{item_id:int}")
|
||||
def rename_local_workspace_item(request: Request, item_id: int, body: dict = Body(default={})):
|
||||
"""Rename a file."""
|
||||
name = body.get("name", "Untitled").strip()
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET title=? WHERE id=?", (name, item_id))
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/api/local-workspace/items/{item_id:int}")
|
||||
def delete_local_workspace_item(request: Request, item_id: int):
|
||||
"""Soft-delete a file/folder (sets deleted_at)."""
|
||||
from datetime import datetime
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE pages SET deleted_at=? WHERE id=? AND deleted_at IS NULL",
|
||||
(datetime.now(UTC).replace(tzinfo=None).isoformat(), item_id),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/local-workspace/items/{item_id:int}/restore")
|
||||
def restore_local_workspace_item(request: Request, item_id: int):
|
||||
"""Restore a soft-deleted file/folder."""
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE pages SET deleted_at=NULL WHERE id=?",
|
||||
(item_id,),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/files/{ws_id:int}/{filename:path}")
|
||||
def serve_uploaded_file(ws_id: int, filename: str):
|
||||
"""Serve an uploaded file from disk."""
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
root = Path(settings.data_dir)
|
||||
base_dir = (root / f"uploads/workspace_{ws_id}").resolve()
|
||||
fp = (base_dir / filename).resolve()
|
||||
try:
|
||||
fp.relative_to(base_dir)
|
||||
except ValueError:
|
||||
return JSONResponse({"error": "Path traversal denied"}, status_code=403)
|
||||
if not fp.exists():
|
||||
return JSONResponse({"error": "File not found"}, status_code=404)
|
||||
mime, _ = mimetypes.guess_type(str(fp))
|
||||
content = fp.read_bytes()
|
||||
from fastapi.responses import Response
|
||||
return Response(content=content, media_type=mime or "application/octet-stream")
|
||||
|
||||
|
||||
|
||||
|
||||
@router.put("/api/local-workspace/items/{item_id:int}/move")
|
||||
def move_local_workspace_item(request: Request, item_id: int, body: dict = Body(default={})):
|
||||
"""Move an item to a new parent (drag & drop)."""
|
||||
new_parent_id = body.get("parent_id") # None = move to root
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE pages SET parent_id=? WHERE id=?",
|
||||
(new_parent_id, item_id),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/local-workspace/upload")
|
||||
async def upload_local_workspace_file(request: Request):
|
||||
"""Upload one or more files via drag-and-drop.
|
||||
|
||||
Accepts multipart form with 'files' field (one or multiple files).
|
||||
Optional: 'parent_id' to place files in a specific folder.
|
||||
Stores files on disk at /data/uploads/workspace_{id}/ and creates DB records.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
uid = _require_user_id(request) # A22 : pas d'upload anonyme
|
||||
ws = _get_active_workspace(request, user_id=uid)
|
||||
ws_id = ws["id"] if ws else None
|
||||
if not ws_id:
|
||||
return JSONResponse({"error": "No active workspace"}, status_code=400)
|
||||
|
||||
try:
|
||||
form = await request.form()
|
||||
except Exception:
|
||||
return JSONResponse({"error": "Invalid form data"}, status_code=400)
|
||||
|
||||
parent_id_raw = form.get("parent_id")
|
||||
parent_id = int(parent_id_raw) if parent_id_raw else None
|
||||
files = form.getlist("files")
|
||||
|
||||
if not files:
|
||||
return JSONResponse({"error": "No files provided"}, status_code=400)
|
||||
|
||||
|
||||
from app.middleware.security import validate_upload
|
||||
|
||||
data_root = Path(settings.data_dir)
|
||||
upload_dir = data_root / f"uploads/workspace_{ws_id}"
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
results = []
|
||||
with get_conn() as conn:
|
||||
for f in files:
|
||||
filename = f.filename or "untitled"
|
||||
# Sanitize filename: only keep basename, prevent path traversal
|
||||
safe_name = Path(filename).name
|
||||
if not safe_name:
|
||||
safe_name = "untitled"
|
||||
|
||||
# Unique filename on disk
|
||||
file_path = upload_dir / safe_name
|
||||
stem, suffix = file_path.stem, file_path.suffix
|
||||
counter = 1
|
||||
while file_path.exists():
|
||||
file_path = upload_dir / f"{stem} ({counter}){suffix}"
|
||||
counter += 1
|
||||
|
||||
content = await f.read()
|
||||
err = validate_upload(safe_name, len(content)) # A22 : taille + extension
|
||||
if err:
|
||||
results.append({"name": safe_name, "error": err})
|
||||
continue
|
||||
file_path.write_bytes(content)
|
||||
|
||||
# Determine if this is a folder marker or actual file
|
||||
rel_path = str(file_path.relative_to(data_root))
|
||||
size = len(content)
|
||||
mime = f.content_type or "application/octet-stream"
|
||||
|
||||
cursor = conn.execute(
|
||||
"""INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id)
|
||||
VALUES ('', ?, ?, ?, 'file', 'Private', ?)""",
|
||||
(ws_id, file_path.name,
|
||||
json.dumps({"file_path": rel_path, "size": size, "mime_type": mime}),
|
||||
parent_id),
|
||||
)
|
||||
results.append({"id": cursor.lastrowid, "name": file_path.name, "type": "page", "size": size})
|
||||
|
||||
conn.commit()
|
||||
|
||||
return {"status": "ok", "items": results}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/local-workspace/upload-folder")
|
||||
async def upload_local_workspace_folder(request: Request):
|
||||
"""Handle recursive folder upload.
|
||||
|
||||
Frontend walks the directory tree with webkitGetAsEntry and sends:
|
||||
- 'structure': JSON array of {path: str, type: 'folder'|'file'}
|
||||
- 'files': multipart files (one per file in the structure)
|
||||
- 'parent_id': target folder (optional)
|
||||
|
||||
Creates folders first, then uploads files into their respective folders.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
uid = _require_user_id(request) # A22 : pas d'upload anonyme
|
||||
ws = _get_active_workspace(request, user_id=uid)
|
||||
ws_id = ws["id"] if ws else None
|
||||
if not ws_id:
|
||||
return JSONResponse({"error": "No active workspace"}, status_code=400)
|
||||
|
||||
try:
|
||||
form = await request.form()
|
||||
except Exception:
|
||||
return JSONResponse({"error": "Invalid form data"}, status_code=400)
|
||||
|
||||
parent_id_raw = form.get("parent_id")
|
||||
root_parent_id = int(parent_id_raw) if parent_id_raw else None
|
||||
structure_raw = form.get("structure")
|
||||
|
||||
if not structure_raw:
|
||||
return JSONResponse({"error": "No structure provided"}, status_code=400)
|
||||
|
||||
try:
|
||||
structure = json.loads(structure_raw)
|
||||
except json.JSONDecodeError:
|
||||
return JSONResponse({"error": "Invalid structure JSON"}, status_code=400)
|
||||
|
||||
|
||||
from app.middleware.security import validate_upload
|
||||
|
||||
data_root = Path(settings.data_dir)
|
||||
upload_dir = data_root / f"uploads/workspace_{ws_id}"
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
results = []
|
||||
created_folders = {} # relative_path -> db_id
|
||||
|
||||
with get_conn() as conn:
|
||||
# Phase 1: Create all folders
|
||||
for item in structure:
|
||||
if item.get("type") != "folder":
|
||||
continue
|
||||
path_parts = item["path"].strip("/").split("/")
|
||||
folder_name = path_parts[-1]
|
||||
# Determine parent: parent of this folder in the tree
|
||||
if len(path_parts) == 1:
|
||||
actual_parent = root_parent_id
|
||||
else:
|
||||
parent_path = "/".join(path_parts[:-1])
|
||||
actual_parent = created_folders.get(parent_path)
|
||||
|
||||
cursor = conn.execute(
|
||||
"""INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id)
|
||||
VALUES ('', ?, ?, '', 'blocks', 'Workspace', ?)""",
|
||||
(ws_id, folder_name, actual_parent),
|
||||
)
|
||||
fid = cursor.lastrowid
|
||||
created_folders[item["path"].strip("/")] = fid
|
||||
results.append({"id": fid, "name": folder_name, "type": "folder"})
|
||||
|
||||
# Phase 2: Upload files into their respective folders
|
||||
for item in structure:
|
||||
if item.get("type") != "file":
|
||||
continue
|
||||
path_parts = item["path"].strip("/").split("/")
|
||||
file_name = path_parts[-1]
|
||||
if len(path_parts) == 1:
|
||||
file_parent = root_parent_id
|
||||
else:
|
||||
parent_path = "/".join(path_parts[:-1])
|
||||
file_parent = created_folders.get(parent_path)
|
||||
|
||||
# Find the matching file in multipart data
|
||||
matched = None
|
||||
for f in form.getlist("files"):
|
||||
if f.filename and (f.filename == item["path"] or f.filename.endswith("/" + file_name)):
|
||||
matched = f
|
||||
break
|
||||
if not matched:
|
||||
continue
|
||||
|
||||
safe_name = Path(file_name).name
|
||||
file_path = upload_dir / safe_name
|
||||
stem, suffix = file_path.stem, file_path.suffix
|
||||
counter = 1
|
||||
while file_path.exists():
|
||||
file_path = upload_dir / f"{stem} ({counter}){suffix}"
|
||||
counter += 1
|
||||
|
||||
content = await matched.read()
|
||||
err = validate_upload(safe_name, len(content)) # A22 : taille + extension
|
||||
if err:
|
||||
results.append({"name": safe_name, "error": err})
|
||||
continue
|
||||
file_path.write_bytes(content)
|
||||
|
||||
rel_path = str(file_path.relative_to(data_root))
|
||||
cursor = conn.execute(
|
||||
"""INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id)
|
||||
VALUES ('', ?, ?, ?, 'file', 'Private', ?)""",
|
||||
(ws_id, file_path.name,
|
||||
json.dumps({"file_path": rel_path, "size": len(content), "mime_type": matched.content_type or "application/octet-stream"}),
|
||||
file_parent),
|
||||
)
|
||||
results.append({"id": cursor.lastrowid, "name": file_path.name, "type": "page", "size": len(content)})
|
||||
|
||||
conn.commit()
|
||||
|
||||
return {"status": "ok", "items": results}
|
||||
|
||||
|
||||
# ═══════════ Workspaces CRUD ═══════════
|
||||
@@ -0,0 +1,240 @@
|
||||
"""FlowDeck — Dashboard : pages_api.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/dashboard.py (2 735 lignes, 63 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["dashboard"])
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ Library page actions API ═══════════
|
||||
|
||||
@router.get("/api/pages/{page_id:int}/content")
|
||||
def api_page_content(page_id: int):
|
||||
"""Get page content for side peek preview."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT title, content, content_format FROM pages WHERE id=? AND deleted_at IS NULL",
|
||||
(page_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return JSONResponse({"error": "Not found"}, status_code=404)
|
||||
# v6.5.0: resolve synced blocks server-side (fresh content on read).
|
||||
from app.services.synced_blocks import resolve_content_json
|
||||
return {
|
||||
"title": row["title"],
|
||||
"content": resolve_content_json(row["content"], row["content_format"]),
|
||||
"format": row["content_format"] or "blocks",
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.put("/api/pages/{page_id:int}/rename")
|
||||
def api_rename_page(page_id: int, request: Request, body: dict = Body(default={})):
|
||||
"""Inline rename a page title."""
|
||||
title = (body.get("title") or "").strip()
|
||||
if not title:
|
||||
return JSONResponse({"error": "Title required"}, status_code=400)
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=? AND deleted_at IS NULL",
|
||||
(title, page_id),
|
||||
)
|
||||
# v6.5.0: renaming a database row's content page updates the row.
|
||||
from app.services.row_pages import sync_page_title_to_row
|
||||
sync_page_title_to_row(conn, page_id)
|
||||
conn.commit()
|
||||
return {"status": "ok", "title": title}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id:int}/trash")
|
||||
def api_trash_page(page_id: int):
|
||||
"""Soft-delete a page (move to trash)."""
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE pages SET parent_section='Trash', deleted_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(page_id,),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id:int}/convert-to-database")
|
||||
def api_convert_to_database(page_id: int, request: Request, body: dict = Body(default={})):
|
||||
"""Convert a page into a full-page database (Notion-style).
|
||||
|
||||
Creates a collection linked to this page, adds the default 'Name' property,
|
||||
and sets the page's content_format to 'collection'.
|
||||
"""
|
||||
import json as _json
|
||||
db_name = (body.get("name") or "").strip()
|
||||
|
||||
with get_conn() as conn:
|
||||
page = conn.execute(
|
||||
"SELECT id, title, workspace_id FROM pages WHERE id=? AND deleted_at IS NULL",
|
||||
(page_id,),
|
||||
).fetchone()
|
||||
if not page:
|
||||
return JSONResponse({"error": "Page not found"}, status_code=404)
|
||||
|
||||
if not db_name:
|
||||
db_name = page["title"] or "New Database"
|
||||
|
||||
# Create the collection
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collections
|
||||
(name, description, icon, schema_json, is_inline, parent_page_id, workspace_id)
|
||||
VALUES (?, '', '📋', '[]', 0, ?, ?)""",
|
||||
(db_name, page_id, page["workspace_id"]),
|
||||
)
|
||||
collection_id = cur.lastrowid
|
||||
|
||||
# Create default "Name" property (text, position 0)
|
||||
conn.execute(
|
||||
"""INSERT INTO collection_properties
|
||||
(collection_id, name, prop_type, position, required, visible_in_views)
|
||||
VALUES (?, 'Name', 'title', 0, 1, 1)""",
|
||||
(collection_id,),
|
||||
)
|
||||
|
||||
# Create default "Table" view
|
||||
conn.execute(
|
||||
"""INSERT INTO collection_views
|
||||
(collection_id, name, view_type, config_json, position)
|
||||
VALUES (?, 'Table', 'table', ?, 0)""",
|
||||
(collection_id, _json.dumps({"visible_properties": ["Name"]})),
|
||||
)
|
||||
|
||||
# Update the page to be a database page
|
||||
conn.execute(
|
||||
"UPDATE pages SET content_format='collection', collection_id=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(collection_id, page_id),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
return {
|
||||
"status": "converted",
|
||||
"collection_id": collection_id,
|
||||
"name": db_name,
|
||||
"view_url": f"/pages/{page_id}",
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/collections/{collection_id:int}/table-data")
|
||||
def api_collection_table_data(collection_id: int):
|
||||
"""Get collection properties + pages for rendering the table view."""
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute(
|
||||
"SELECT * FROM collections WHERE id=?", (collection_id,)
|
||||
).fetchone()
|
||||
if not coll:
|
||||
return JSONResponse({"error": "Collection not found"}, status_code=404)
|
||||
|
||||
properties = [
|
||||
dict(r) for r in conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
]
|
||||
|
||||
pages = [
|
||||
dict(r) for r in conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
]
|
||||
|
||||
views = [
|
||||
dict(r) for r in conn.execute(
|
||||
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
]
|
||||
|
||||
return {
|
||||
"collection": dict(coll),
|
||||
"properties": properties,
|
||||
"pages": pages,
|
||||
"views": views,
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/collections/{collection_id:int}/pages")
|
||||
def api_create_collection_page(collection_id: int, request: Request, body: dict = Body(default={})):
|
||||
"""Create a new page (row) in a collection."""
|
||||
import json as _json
|
||||
title = body.get("title", "New page").strip() or "New page"
|
||||
icon = body.get("icon", "file")
|
||||
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute(
|
||||
"SELECT id FROM collections WHERE id=?", (collection_id,)
|
||||
).fetchone()
|
||||
if not coll:
|
||||
return JSONResponse({"error": "Collection not found"}, status_code=404)
|
||||
|
||||
# Get next position
|
||||
max_pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchone()[0]
|
||||
|
||||
# Load default property values from collection properties
|
||||
props = [
|
||||
dict(r) for r in conn.execute(
|
||||
"SELECT id, name, prop_type FROM collection_properties WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
]
|
||||
default_values = {}
|
||||
for p in props:
|
||||
if p["prop_type"] == "title":
|
||||
default_values[str(p["id"])] = title
|
||||
# Caller-provided values (e.g. board "add card in column X") win.
|
||||
incoming = body.get("properties") or {}
|
||||
if isinstance(incoming, dict):
|
||||
default_values.update(incoming)
|
||||
|
||||
from app.services.property_types import apply_auto_properties
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) or {"login": "admin", "id": 1}
|
||||
apply_auto_properties(props, default_values, user, is_create=True)
|
||||
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collection_pages
|
||||
(collection_id, title, icon, cover_url, position, property_values_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(collection_id, title, icon, body.get("cover_url", ""), max_pos,
|
||||
_json.dumps(default_values)),
|
||||
)
|
||||
page_id = cur.lastrowid
|
||||
conn.commit()
|
||||
|
||||
return {
|
||||
"id": page_id,
|
||||
"title": title,
|
||||
"icon": icon,
|
||||
"position": max_pos,
|
||||
"property_values_json": default_values,
|
||||
"status": "created",
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
"""FlowDeck — Dashboard : pages_html.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/dashboard.py (2 735 lignes, 63 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Query, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
|
||||
from ._common import _get_active_workspace, _get_user_id, _nav_breadcrumb, _sidebar_data
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["dashboard"])
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/trash", response_class=HTMLResponse)
|
||||
def trash_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
|
||||
"""Trash page — scoped to workspace if owner/repo provided."""
|
||||
from app.routers.board import _sidebar_data as board_sidebar
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = board_sidebar(request, owner, repo)
|
||||
with get_conn() as conn:
|
||||
ws_key = f"{owner}/{repo}" if owner and repo else ""
|
||||
# Pages are soft-deleted via parent_section='Trash'
|
||||
if ws_key:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, workspace FROM pages WHERE parent_section='Trash' AND workspace=? ORDER BY updated_at DESC",
|
||||
(ws_key,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, workspace FROM pages WHERE parent_section='Trash' ORDER BY updated_at DESC",
|
||||
).fetchall()
|
||||
sidebar["trash_items"] = [{"id": r["id"], "name": r["title"] or "Untitled", "workspace": r["workspace"]} for r in rows]
|
||||
# Pass active workspace for breadcrumb nav menu
|
||||
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
||||
sidebar["nav_workspace_id"] = ws["id"] if ws else 0
|
||||
template = env.get_template("trash.html")
|
||||
return template.render(**sidebar)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/library", response_class=HTMLResponse)
|
||||
def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
|
||||
"""Library page — tabbed view (recents, favorites, shared, published, private, workspace).
|
||||
|
||||
Sidebar data is kept intact. Tab content is loaded client-side via /api/library/* endpoints.
|
||||
"""
|
||||
from app.routers.board import _sidebar_data as board_sidebar
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = board_sidebar(request, owner, repo)
|
||||
# Pass active workspace for breadcrumb nav menu
|
||||
ws_key_ws = f"{owner}/{repo}" if owner and repo else ""
|
||||
if ws_key_ws:
|
||||
with get_conn() as conn:
|
||||
ws_row = conn.execute("SELECT id FROM workspaces WHERE name=? AND owner_id=?", (ws_key_ws, _get_user_id(request))).fetchone()
|
||||
sidebar["nav_workspace_id"] = ws_row["id"] if ws_row else 0
|
||||
else:
|
||||
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
||||
sidebar["nav_workspace_id"] = ws["id"] if ws else 0
|
||||
|
||||
template = env.get_template("library.html")
|
||||
sidebar["active_workspace_id"] = sidebar.get("nav_workspace_id", 0)
|
||||
# Gitea workspace context for Repository tab
|
||||
sidebar["is_gitea_workspace"] = bool(owner and repo)
|
||||
sidebar["gitea_workspace_owner"] = owner
|
||||
sidebar["gitea_workspace_repo"] = repo
|
||||
return template.render(**sidebar)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}", response_class=HTMLResponse)
|
||||
def view_page_root(request: Request, page_id: int):
|
||||
"""Render a Markdown page at root level with workspace context — or file viewer.
|
||||
?embed=1 — minimal mode for side peek (editor only, no header)."""
|
||||
embed = request.query_params.get("embed") == "1"
|
||||
from app.routers.board import _sidebar_data as board_sidebar
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
return RedirectResponse("/workspaces", status_code=302)
|
||||
page = dict(row)
|
||||
# v6.5.0: synced blocks resolve server-side at read time.
|
||||
from app.services.synced_blocks import resolve_content_json
|
||||
page["content"] = resolve_content_json(page.get("content", ""), page.get("content_format"))
|
||||
|
||||
ws = page.get("workspace", "")
|
||||
parts = ws.split("/") if "/" in ws else ["", ""]
|
||||
owner, repo = parts[0], parts[1] if len(parts) > 1 else ""
|
||||
sidebar = board_sidebar(request, owner, repo)
|
||||
# Load sub-pages
|
||||
with get_conn() as conn:
|
||||
subs = conn.execute(
|
||||
"SELECT id, title FROM pages WHERE parent_id=? ORDER BY updated_at DESC",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
fav = conn.execute(
|
||||
"SELECT id FROM favorites WHERE user_id=? AND page_id=?",
|
||||
(1, page_id),
|
||||
).fetchone()
|
||||
|
||||
# Build page_data, including file metadata for uploaded files
|
||||
_locked = bool(page.get("is_locked", 0))
|
||||
_locked_by = page.get("locked_by") if "locked_by" in page else None
|
||||
_sess_user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
_uid = _sess_user.get("id") if _sess_user and _sess_user.get("id") else None
|
||||
_can_edit = (not _locked) or bool(_sess_user and _sess_user.get("is_admin")) or (_locked_by and _uid and _locked_by == _uid)
|
||||
page_data = {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": fav is not None, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("published", 0)),
|
||||
"is_locked": _locked,
|
||||
"locked_by": _locked_by,
|
||||
"can_edit": _can_edit,
|
||||
"full_width": bool(page.get("full_width", 0)) if "full_width" in page else False,
|
||||
"font_small": bool(page.get("font_small", 0)) if "font_small" in page else False}
|
||||
|
||||
# For file pages, extract file metadata and add to page_data
|
||||
if page.get("content_format") == "file":
|
||||
import json as _json
|
||||
try:
|
||||
meta = _json.loads(page.get("content", "{}"))
|
||||
except _json.JSONDecodeError:
|
||||
meta = {}
|
||||
file_path = meta.get("file_path", "").replace("\\", "/")
|
||||
mime_type = meta.get("mime_type", "application/octet-stream")
|
||||
file_size = meta.get("size", 0)
|
||||
fp_parts = file_path.split("/")
|
||||
ws_id = ""
|
||||
for p in fp_parts:
|
||||
if p.startswith("workspace_"):
|
||||
ws_id = p.replace("workspace_", "")
|
||||
break
|
||||
filename = fp_parts[-1] if fp_parts else page.get("title", "File")
|
||||
from urllib.parse import quote
|
||||
safe_name = quote(filename, safe='')
|
||||
file_url = f"/api/files/{ws_id}/{safe_name}" if ws_id else ""
|
||||
page_data["file_url"] = file_url
|
||||
page_data["file_mime"] = mime_type
|
||||
page_data["file_size"] = file_size
|
||||
page_data["file_name"] = filename
|
||||
|
||||
# For collection (database) pages, load collection + properties + pages
|
||||
collection_data = None
|
||||
if page.get("content_format") == "collection" and page.get("collection_id"):
|
||||
with get_conn() as conn:
|
||||
col = conn.execute(
|
||||
"SELECT * FROM collections WHERE id=?", (page["collection_id"],)
|
||||
).fetchone()
|
||||
if col:
|
||||
props = [
|
||||
dict(r) for r in conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
|
||||
(page["collection_id"],),
|
||||
).fetchall()
|
||||
]
|
||||
cpages = [
|
||||
dict(r) for r in conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position",
|
||||
(page["collection_id"],),
|
||||
).fetchall()
|
||||
]
|
||||
cviews = [
|
||||
dict(r) for r in conn.execute(
|
||||
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position",
|
||||
(page["collection_id"],),
|
||||
).fetchall()
|
||||
]
|
||||
collection_data = {
|
||||
"collection": dict(col),
|
||||
"properties": props,
|
||||
"pages": cpages,
|
||||
"views": cviews,
|
||||
}
|
||||
page_data["collection_id"] = page["collection_id"]
|
||||
|
||||
with get_conn() as conn:
|
||||
nav_crumbs = _nav_breadcrumb(conn, page_id)
|
||||
# dérivé calculé UNE fois : ctx ET page_data (JSON) l'utilisent (A27)
|
||||
page_is_shared = (
|
||||
bool(page.get("is_shared", 0))
|
||||
or page.get("share_mode", "private") != "private"
|
||||
or bool(page.get("published", 0))
|
||||
)
|
||||
ctx = {**sidebar, "page": page, "sub_pages": [dict(s) for s in subs],
|
||||
"page_favorited": fav is not None,
|
||||
"page_share_mode": page.get("share_mode", "private"),
|
||||
"page_published": bool(page.get("published", 0)),
|
||||
"page_is_shared": page_is_shared,
|
||||
"page_data": page_data,
|
||||
"collection_data": collection_data,
|
||||
"breadcrumb_items": nav_crumbs,
|
||||
"nav_workspace_id": page.get("workspace_id") or 0,
|
||||
"nav_page_id": page_id,
|
||||
"embed_mode": embed}
|
||||
# A27 phase 2 : le JS de l'éditeur lit ces valeurs dans page-data (JSON)
|
||||
# au lieu des interpolations Jinja — une seule source, même calculs que le
|
||||
# ctx ci-dessus.
|
||||
from app.templating import ENV as _ENV27
|
||||
page_data.update(
|
||||
updated_at=page.get("updated_at", ""),
|
||||
created_at=page.get("created_at", ""),
|
||||
user_id=_uid or 0,
|
||||
is_shared=page_is_shared,
|
||||
clip_icon=_ENV27.from_string(
|
||||
"{% from '_icons.html' import fd_icon %}{{ fd_icon('paperclip', 14) }}"
|
||||
).render(),
|
||||
)
|
||||
# Select template: collection pages use database table view
|
||||
if page.get("content_format") == "collection" and not embed:
|
||||
template = env.get_template("page_editor_collection.html")
|
||||
else:
|
||||
template = env.get_template("page_editor_embed.html" if embed else "page_editor.html")
|
||||
response = template.render(**ctx)
|
||||
return HTMLResponse(content=response, headers={"Cache-Control": "no-store, max-age=0"})
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/accounts", response_class=HTMLResponse)
|
||||
def accounts_page(request: Request):
|
||||
"""Account management panel."""
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
with get_conn() as conn:
|
||||
users = conn.execute(
|
||||
"SELECT id, login, full_name, email, avatar_url, avatar_color, "
|
||||
"is_admin, is_active, created_at FROM users ORDER BY created_at DESC"
|
||||
).fetchall()
|
||||
ctx = {**sidebar, "user": user, "users": [dict(u) for u in users]}
|
||||
template = env.get_template("accounts.html")
|
||||
return template.render(**ctx)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/help", response_class=HTMLResponse)
|
||||
def help_page(request: Request):
|
||||
"""Comprehensive help & documentation page."""
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
# Render via a block-based template so content_html lands in {% block content %}
|
||||
block_tpl = env.from_string(
|
||||
'{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}'
|
||||
)
|
||||
return HTMLResponse(block_tpl.render(
|
||||
**sidebar,
|
||||
request=request,
|
||||
page_title="Help",
|
||||
title_prefix="Help",
|
||||
page_icon="❓",
|
||||
content_html="""<style>
|
||||
.help-page{max-width:900px;margin:0 auto;padding:40px 24px 80px;}
|
||||
.help-hero{text-align:center;margin-bottom:48px;}
|
||||
.help-hero h1{font-size:32px;font-weight:800;margin:0 0 8px;}
|
||||
.help-hero p{font-size:16px;color:var(--text-dim);max-width:500px;margin:0 auto;}
|
||||
.help-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:16px;margin-bottom:48px;}
|
||||
.help-card{background:var(--bg-card);border:1px solid var(--border);border-radius:12px;padding:24px;transition:border-color .15s;}
|
||||
.help-card:hover{border-color:rgba(255,255,255,.12);}
|
||||
.help-card h3{font-size:15px;font-weight:600;margin:0 0 4px;display:flex;align-items:center;gap:8px;}
|
||||
.help-card .icon{font-size:20px;}
|
||||
.help-card p{font-size:13px;color:var(--text-dim);line-height:1.5;margin:8px 0 0;}
|
||||
.help-card ul{list-style:none;padding:0;margin:12px 0 0;}
|
||||
.help-card li{font-size:13px;padding:3px 0;color:var(--text-dim);}
|
||||
.help-card li::before{content:'• ';color:var(--accent);}
|
||||
.help-section{margin-bottom:48px;}
|
||||
.help-section h2{font-size:20px;font-weight:700;margin:0 0 16px;padding-bottom:8px;border-bottom:1px solid var(--border);}
|
||||
.help-kbd{display:inline-block;padding:2px 8px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:4px;font-family:monospace;font-size:12px;color:var(--text);min-width:16px;text-align:center;}
|
||||
.help-shortcut-row{display:flex;align-items:center;gap:12px;padding:8px 12px;border-radius:6px;margin-bottom:2px;}
|
||||
.help-shortcut-row:hover{background:var(--bg-hover);}
|
||||
.help-shortcut-row .keys{display:flex;gap:4px;min-width:140px;}
|
||||
.help-shortcut-row .desc{font-size:13px;color:var(--text-dim);}
|
||||
.help-badge{display:inline-block;padding:2px 10px;border-radius:20px;font-size:11px;font-weight:600;}
|
||||
.help-badge.local{background:rgba(35,131,226,.15);color:#2C8CEB;}
|
||||
.help-badge.gitea{background:rgba(0,200,100,.15);color:#00CC66;}
|
||||
.help-badge.github{background:rgba(130,80,220,.15);color:#A060F0;}
|
||||
.help-badge.sso{background:rgba(217,115,13,.18);color:#E0952B;}
|
||||
</style>
|
||||
<div class="help-page">
|
||||
<div class="help-hero">
|
||||
<h1>❓ FlowDeck Help</h1>
|
||||
<p>Everything you need to know about your Notion-style workspace with Gitea & GitHub integration.</p>
|
||||
</div>
|
||||
|
||||
<div class="help-grid">
|
||||
<div class="help-card">
|
||||
<h3><span class="icon">🚀</span>Getting Started</h3>
|
||||
<p>FlowDeck is your private, self-hosted workspace. Create pages, organize projects, and integrate with your Git forge.</p>
|
||||
<ul>
|
||||
<li>Create a workspace from the <b>Workspaces</b> page</li>
|
||||
<li>Click <b>📄 New Page</b> in the sidebar to start writing</li>
|
||||
<li>Use <span class="help-kbd">Ctrl+N</span> anywhere to create a page</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="help-card">
|
||||
<h3><span class="icon">📝</span>Pages & Editor</h3>
|
||||
<p>Notion-style block editor with slash commands, markdown shortcuts, and rich formatting.</p>
|
||||
<ul>
|
||||
<li>Type <span class="help-kbd">/</span> for the slash command menu</li>
|
||||
<li>Drag & drop pages in the sidebar to reorganize</li>
|
||||
<li>Right-click for context menu (duplicate, rename, delete)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="help-card">
|
||||
<h3><span class="icon">{{ fd_icon("folder",16) }}</span>Workspaces</h3>
|
||||
<p>Organize your work into separate workspaces. Each has its own pages and files.</p>
|
||||
<ul>
|
||||
<li><span class="help-badge local">Local</span> Files stored on your server</li>
|
||||
<li><span class="help-badge gitea">Gitea</span> Connect to browse & edit repos</li>
|
||||
<li><span class="help-badge github">GitHub</span> Connect via Settings → Integrations</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="help-card">
|
||||
<h3><span class="icon">🦎</span>Gitea Integration</h3>
|
||||
<p>Connect your Gitea account to access repositories directly from FlowDeck.</p>
|
||||
<ul>
|
||||
<li>Go to <b>Settings → Integrations</b> to connect</li>
|
||||
<li>Browse repo file trees in the sidebar</li>
|
||||
<li>Create & edit files with commit messages</li>
|
||||
<li>Sync labels as tags</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="help-card">
|
||||
<h3><span class="icon">🌐</span>Sharing & Publishing</h3>
|
||||
<p>Share pages with collaborators or publish them to the web.</p>
|
||||
<ul>
|
||||
<li>Click <b>Share</b> in the page editor top-right</li>
|
||||
<li>Share with specific users or get a public link</li>
|
||||
<li>Publish to make a page visible at <code>/p/your-slug</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="help-card">
|
||||
<h3><span class="icon">📚</span>Library, Trash & Tasks</h3>
|
||||
<p>Find all your content in one place with powerful filtering.</p>
|
||||
<ul>
|
||||
<li><b>Library</b> — Tabs for Recents, Favorites, Shared, Published</li>
|
||||
<li><b>Trash</b> — Soft-deleted pages (30-day retention)</li>
|
||||
<li><b>My Tasks</b> — Aggregated tasks from all collections</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="help-card">
|
||||
<h3><span class="icon">📶</span>Offline & PWA</h3>
|
||||
<p>Install FlowDeck as an app and keep working without a connection.</p>
|
||||
<ul>
|
||||
<li><b>Install</b> — browser menu → <i>Install app</i> / <i>Add to Home Screen</i></li>
|
||||
<li>Edits made offline are queued locally and synced automatically</li>
|
||||
<li>A <b>⟳</b> marker shows pages with pending changes</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h2>⌨️ Keyboard Shortcuts</h2>
|
||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">N</span></div><div class="desc">Create new page</div></div>
|
||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">K</span></div><div class="desc">Quick find / command palette</div></div>
|
||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">S</span></div><div class="desc">Save current page</div></div>
|
||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">F2</span></div><div class="desc">Rename selected item</div></div>
|
||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Delete</span></div><div class="desc">Move selected item to trash</div></div>
|
||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Escape</span></div><div class="desc">Close modal / cancel editing</div></div>
|
||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Enter</span></div><div class="desc">Open selected page</div></div>
|
||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">O</span></div><div class="desc">New AI chat (in footer)</div></div>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h2>🔐 Authentication</h2>
|
||||
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
|
||||
FlowDeck supports three authentication methods:<br>
|
||||
<span class="help-badge local">Local</span> Email + password — create an account on the login page.<br>
|
||||
<span class="help-badge gitea">Gitea OAuth</span> Login with your Gitea account. Your repos appear as workspaces.<br>
|
||||
<span class="help-badge github">GitHub OAuth</span> Login or link your GitHub account in Settings → Integrations.<br><br>
|
||||
<b>Tip:</b> You can connect Gitea/GitHub to an existing local account — your identity stays as your local user.
|
||||
</p>
|
||||
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
|
||||
<span class="help-badge sso">SSO</span> <b>Enterprise SSO</b> (v6.7.0) — sign in with your organization account.<br>
|
||||
<i>For administrators:</i> open <b>Settings → Admin → SSO / Enterprise</b> and pick a provider:<br>
|
||||
• <b>SAML 2.0</b> — paste the IdP <i>Entity ID</i>, <i>SSO URL</i> and signing certificate, then give the IdP this
|
||||
<code>/auth/saml/metadata</code> link (it contains the SP Entity ID, ACS URL and certificate).<br>
|
||||
• <b>OpenID Connect</b> — paste the <i>Issuer URL</i>, <i>Client ID</i> and <i>Client Secret</i> (PKCE is used, scopes default to <code>openid profile email</code>).<br>
|
||||
• <b>Provisioning</b> — accounts are created automatically on first login, groups from the IdP map to workspace roles,
|
||||
and <i>SSO only</i> disables local login (admins keep their local door). Every attempt is audited in
|
||||
<b>Settings → Admin → SSO / Enterprise</b> (login history).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h2>📶 Offline mode (PWA)</h2>
|
||||
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
|
||||
FlowDeck is a Progressive Web App: pages you visited stay available offline and your
|
||||
edits are saved locally, then synchronised when the connection returns.<br><br>
|
||||
<b>Install:</b> open your browser menu and choose <i>Install app</i> (Chrome/Edge) or
|
||||
<i>Add to Home Screen</i> (Safari/iOS). FlowDeck then opens in its own window.<br>
|
||||
<b>Offline editing:</b> while offline, the editor stores changes in the browser
|
||||
(IndexedDB) and shows an offline banner with the number of pending changes. A
|
||||
<b>⟳</b> icon appears next to pages that have unsynced edits.<br>
|
||||
<b>Reconnection:</b> the queue is replayed automatically (and via Background Sync).
|
||||
A spinner badge appears while syncing, followed by a confirmation toast.<br>
|
||||
<b>Conflicts:</b> if a page changed on the server, the latest edit wins and a notice is
|
||||
shown. If a page was deleted server-side, your offline copy is recreated as an orphan
|
||||
page. If a page with the same title already exists, the offline copy is renamed
|
||||
<i>“Title (copie offline)”</i>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h2>🔌 API publique v2</h2>
|
||||
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
|
||||
FlowDeck exposes a full REST API under <b>/api/v2</b> for third-party integrations.<br>
|
||||
<b>Auth:</b> create a token in Settings → API tokens, then send it as
|
||||
<code>Authorization: Bearer <token></code>. Tokens carry scopes
|
||||
<code>read</code>, <code>write</code> or <code>admin</code> (a higher scope implies the lower ones).<br>
|
||||
<b>Features:</b> CRUD on collections, pages, properties, views, comments, notifications,
|
||||
favorites, tags, sharing, sprints and templates; pagination (<code>?limit=&offset=</code> +
|
||||
<code>X-Total-Count</code>), filters (<code>filter[prop]=value</code>), sorting, full-text search
|
||||
(<code>/api/v2/search</code>), idempotency (<code>Idempotency-Key</code>) and RFC 7807 error bodies.<br>
|
||||
<b>Reference:</b> interactive OpenAPI docs at <a href="/docs" target="_blank" rel="noopener">/docs</a>
|
||||
(also <code>/redoc</code>, <code>docs/openapi-v2.json</code>).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h2>💡 Tips</h2>
|
||||
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
|
||||
• Toggle the sidebar with the <b>«</b> button in the top-left corner.<br>
|
||||
• Switch between workspaces using the dropdown menu in the sidebar header.<br>
|
||||
• The <b>Private</b> section appears when a remote workspace is active — files here stay local.<br>
|
||||
• Hover over any sidebar item to see action buttons (favorite, share, delete).<br>
|
||||
• Use <b>Ctrl+Click</b> or <b>Shift+Click</b> to multi-select items in the sidebar.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>"""
|
||||
))
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/accounts/settings", response_class=HTMLResponse)
|
||||
def settings_page(request: Request):
|
||||
"""User settings page — profile, forges, tokens."""
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse("/auth/login?provider=local", status_code=302)
|
||||
# Check forge connections
|
||||
gitea_connected = False
|
||||
github_connected = False
|
||||
if user.get("id"):
|
||||
with get_conn() as conn:
|
||||
tokens = conn.execute(
|
||||
"SELECT provider FROM user_oauth_tokens WHERE user_id=?", (user["id"],)
|
||||
).fetchall()
|
||||
for t in tokens:
|
||||
if t["provider"] == "gitea":
|
||||
gitea_connected = True
|
||||
elif t["provider"] == "github":
|
||||
github_connected = True
|
||||
ctx = {**sidebar, "user": user, "gitea_connected": gitea_connected, "github_connected": github_connected}
|
||||
template = env.get_template("settings.html")
|
||||
response = template.render(**ctx)
|
||||
return HTMLResponse(content=response)
|
||||
|
||||
|
||||
# ═══════════ User API endpoints ═══════════
|
||||
@@ -0,0 +1,78 @@
|
||||
"""FlowDeck — Dashboard : public.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/dashboard.py (2 735 lignes, 63 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
from ._common import _render_blocks_public
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["dashboard"])
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ Public Published Page ═══════════
|
||||
|
||||
|
||||
@router.get("/p/{slug}", response_class=HTMLResponse)
|
||||
def public_published_page(request: Request, slug: str):
|
||||
"""Serve a published page at /p/<slug> — no auth required."""
|
||||
from app.templating import ENV
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, title, content, content_format, updated_at, created_at, cover_url, page_icon "
|
||||
"FROM pages WHERE publish_slug=? AND is_published=1",
|
||||
(slug,),
|
||||
).fetchone()
|
||||
|
||||
if not row:
|
||||
return HTMLResponse(
|
||||
"""<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">
|
||||
<title>Not Found — FlowDeck</title>
|
||||
<style>body{font-family:system-ui,sans-serif;display:flex;align-items:center;
|
||||
justify-content:center;height:100vh;margin:0;background:#191919;color:#ccc;}
|
||||
h1{font-size:3rem;opacity:.3}</style></head><body><h1>404</h1></body></html>""",
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
page = dict(row)
|
||||
env = ENV
|
||||
|
||||
# Convert blocks to HTML for rendering
|
||||
content_html = ""
|
||||
if page.get("content_format") == "blocks" and page.get("content"):
|
||||
import json as _json
|
||||
try:
|
||||
blocks = _json.loads(page["content"])
|
||||
from app.services.synced_blocks import resolve_synced_block
|
||||
blocks = resolve_synced_block(blocks)
|
||||
from app.db import get_conn as _gc
|
||||
from app.services.wiki_links import token_labels
|
||||
with _gc() as conn:
|
||||
wiki_titles_map = token_labels(conn, page["content"])
|
||||
content_html = _render_blocks_public(blocks, wiki_titles_map)
|
||||
except (_json.JSONDecodeError, Exception):
|
||||
content_html = f"<p>{page.get('content', '')}</p>"
|
||||
elif page.get("content"):
|
||||
# Plain text / markdown
|
||||
text = page["content"]
|
||||
content_html = f"<pre style='white-space:pre-wrap;font-family:system-ui;font-size:16px;line-height:1.6;'>{text}</pre>"
|
||||
|
||||
template = env.get_template("public_page.html")
|
||||
return template.render(
|
||||
title=page["title"] or "Untitled",
|
||||
content_html=content_html,
|
||||
updated_at=page.get("updated_at", ""),
|
||||
created_at=page.get("created_at", ""),
|
||||
cover_url=page.get("cover_url", ""),
|
||||
page_icon=page.get("page_icon", ""),
|
||||
)
|
||||
@@ -0,0 +1,321 @@
|
||||
"""FlowDeck — Dashboard : workspace.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/dashboard.py (2 735 lignes, 63 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Query, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.gitea_client import get_user_gitea_client, gitea
|
||||
|
||||
from ._common import _get_active_workspace, _get_user_id, _sidebar_data
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["dashboard"])
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def dashboard(
|
||||
request: Request,
|
||||
search: str = Query(default=""),
|
||||
show_archived: bool = Query(default=False),
|
||||
):
|
||||
"""Smart root route: landing for visitors, local workspace for new users, dashboard for Gitea users."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
|
||||
# ── Not authenticated → show landing page ──
|
||||
if not user:
|
||||
# Allow through if DB is empty (fresh install)
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||
if count == 0:
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
template = env.get_template("landing.html")
|
||||
return template.render()
|
||||
except Exception:
|
||||
logger.exception("dashboard")
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
template = env.get_template("landing.html")
|
||||
return template.render()
|
||||
|
||||
# ── Authenticated ──
|
||||
user_id = user.get("id", 1)
|
||||
has_gitea = False
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
tok = conn.execute(
|
||||
"SELECT access_token FROM user_oauth_tokens WHERE user_id=? AND provider='gitea'",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
has_gitea = bool(tok)
|
||||
except Exception:
|
||||
logger.exception("dashboard")
|
||||
|
||||
if not has_gitea:
|
||||
# Check if user has any workspace
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
ws_count = conn.execute(
|
||||
"SELECT COUNT(*) FROM workspaces WHERE owner_id=?", (user_id,)
|
||||
).fetchone()[0]
|
||||
if ws_count == 0:
|
||||
# v5.2.0: first-launch → onboarding wizard
|
||||
return RedirectResponse("/welcome", status_code=302)
|
||||
except Exception:
|
||||
logger.exception("dashboard")
|
||||
return RedirectResponse("/local-workspace", status_code=302)
|
||||
|
||||
# ── Gitea user → full dashboard ──
|
||||
try:
|
||||
repos = await gitea.get_user_repos(page=1, limit=50)
|
||||
if search:
|
||||
q = search.lower()
|
||||
repos = [r for r in repos if q in r.get("full_name", "").lower()
|
||||
or q in (r.get("description") or "").lower()]
|
||||
if not show_archived:
|
||||
repos = [r for r in repos if not r.get("archived", False)]
|
||||
repos.sort(key=lambda r: r.get("updated_at", ""), reverse=True)
|
||||
except Exception as e:
|
||||
logger.error("Dashboard error: %s", e)
|
||||
repos = []
|
||||
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, repos)
|
||||
template = env.get_template("dashboard.html")
|
||||
return template.render(request=request, repos=repos, search=search,
|
||||
show_archived=show_archived, **sidebar)
|
||||
|
||||
|
||||
# ═══════════ Workspace ═══════════
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ Workspace ═══════════
|
||||
|
||||
@router.get("/workspace", response_class=HTMLResponse)
|
||||
def workspace_page(request: Request):
|
||||
"""Unified workspace showing all projects."""
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return RedirectResponse("/auth/login?provider=local", status_code=302)
|
||||
ctx = {**sidebar, "user": user}
|
||||
# Pass active workspace for breadcrumb nav menu
|
||||
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
||||
ctx["nav_workspace_id"] = ws["id"] if ws else 0
|
||||
template = env.get_template("workspace.html")
|
||||
return template.render(**ctx)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/gitea-workspace", response_class=HTMLResponse)
|
||||
def gitea_workspace_page(request: Request):
|
||||
"""Gitea workspace — browse repo files."""
|
||||
import json
|
||||
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return RedirectResponse("/auth/login?provider=local", status_code=302)
|
||||
owner = request.query_params.get("owner", "")
|
||||
repo = request.query_params.get("repo", "")
|
||||
ws_key = f"{owner}/{repo}" if owner and repo else ""
|
||||
ws_name = ws_key or "Gitea Workspace"
|
||||
# Auto-create local workspace mirror for storing local files
|
||||
local_ws_id = None
|
||||
if ws_key:
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT id, owner_id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?",
|
||||
(user["id"], ws_key, "%gitea_repo%")
|
||||
).fetchone()
|
||||
if existing:
|
||||
local_ws_id = existing["id"]
|
||||
else:
|
||||
c = conn.execute(
|
||||
"INSERT INTO workspaces (name, owner_id, settings_json) VALUES (?, ?, ?)",
|
||||
(ws_key, user["id"], json.dumps({"gitea_repo": ws_key, "gitea_owner": owner}))
|
||||
)
|
||||
local_ws_id = c.lastrowid
|
||||
conn.execute(
|
||||
"INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, ?)",
|
||||
(local_ws_id, user["id"], "admin")
|
||||
)
|
||||
conn.commit()
|
||||
ctx = {
|
||||
**sidebar,
|
||||
"user": user,
|
||||
"active_ws_name": ws_name,
|
||||
"workspace_key": ws_key,
|
||||
"gitea_workspace": True, # always true on this page
|
||||
"gitea_owner": owner,
|
||||
"gitea_repo": repo,
|
||||
"workspace_name": ws_name,
|
||||
"workspace_initial": repo[0].upper() if repo else "G",
|
||||
"owner": owner,
|
||||
"repo": repo,
|
||||
"nav_workspace_id": local_ws_id or 0, # for breadcrumb nav menu
|
||||
}
|
||||
template = env.get_template("gitea_workspace.html")
|
||||
resp = HTMLResponse(content=template.render(**ctx))
|
||||
resp.set_cookie("flowdeck_workspace", f"gitea:{owner}:{repo}", path="/", samesite="lax")
|
||||
return resp
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/workspace/projects")
|
||||
async def list_workspace_projects(request: Request):
|
||||
"""List all projects: built-in + Gitea + GitHub.
|
||||
Uses the user's own Gitea token if connected, not the global admin token."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
|
||||
builtin = []
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title FROM pages WHERE parent_id IS NULL AND collection_row_id IS NULL ORDER BY updated_at DESC LIMIT 20"
|
||||
).fetchall()
|
||||
# A23 : une seule agrégation GROUP BY au lieu d'un COUNT par ligne.
|
||||
counts = {}
|
||||
if rows:
|
||||
for c in conn.execute(
|
||||
"SELECT parent_id, COUNT(*) AS c FROM pages WHERE parent_id IN ({}) GROUP BY parent_id".format(
|
||||
",".join("?" * len(rows))
|
||||
),
|
||||
[r["id"] for r in rows],
|
||||
).fetchall():
|
||||
counts[c["parent_id"]] = c["c"]
|
||||
for r in rows:
|
||||
builtin.append({"id": str(r["id"]), "name": r["title"] or "Untitled", "pageCount": counts.get(r["id"], 0), "forge": "builtin"})
|
||||
|
||||
gitea_repos = []
|
||||
# Use per-user token if available, otherwise return empty
|
||||
user_gitea = get_user_gitea_client(request) if user else None
|
||||
if user_gitea:
|
||||
try:
|
||||
repos = await user_gitea.get_user_repos(page=1, limit=50)
|
||||
for repo in repos:
|
||||
gitea_repos.append({
|
||||
"id": str(repo.get("id", "")),
|
||||
"name": repo.get("name", ""),
|
||||
"full_name": repo.get("full_name", ""),
|
||||
"description": repo.get("description", ""),
|
||||
"html_url": repo.get("html_url", ""),
|
||||
"language": repo.get("language", ""),
|
||||
"forge": "gitea",
|
||||
})
|
||||
except Exception:
|
||||
logger.exception("list_workspace_projects")
|
||||
|
||||
return {"builtin": builtin, "gitea": gitea_repos, "github": []}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/workspace/projects")
|
||||
def create_workspace_project(request: Request, body: dict = Body(default={})):
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
return {"error": "Name required"}
|
||||
with get_conn() as conn:
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO pages (workspace, title, content, content_format, parent_section) VALUES ('', ?, '', 'blocks', 'Private')",
|
||||
(name,),
|
||||
)
|
||||
conn.commit()
|
||||
pid = cursor.lastrowid
|
||||
return {"id": pid, "name": name, "forge": "builtin"}
|
||||
|
||||
|
||||
# ═══════════ Workspace Members API ═══════════
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ Workspace Members API ═══════════
|
||||
|
||||
@router.get("/api/workspace/{ws_id:int}/members")
|
||||
def list_members(request: Request, ws_id: int):
|
||||
"""List all members of a workspace."""
|
||||
with get_conn() as conn:
|
||||
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""", (ws_id,)
|
||||
).fetchall()
|
||||
return {"members": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/workspace/{ws_id:int}/members")
|
||||
def invite_member(request: Request, ws_id: int, body: dict = Body(default={})):
|
||||
"""Invite a user to a workspace by email."""
|
||||
email = body.get("email", "").strip()
|
||||
role = body.get("role", "editor")
|
||||
if role not in ("owner", "admin", "editor", "viewer"):
|
||||
return {"error": "Invalid role"}, 400
|
||||
with get_conn() as conn:
|
||||
user = conn.execute("SELECT id FROM users WHERE login=? OR email=?", (email, email)).fetchone()
|
||||
if not user:
|
||||
return {"error": "User not found"}, 404
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, ?)",
|
||||
(ws_id, user["id"], role),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
return {"error": "Already a member"}, 409
|
||||
return {"status": "ok", "user_id": user["id"], "role": role}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.put("/api/workspace/{ws_id:int}/members/{user_id:int}")
|
||||
def update_member_role(request: Request, ws_id: int, user_id: int, body: dict = Body(default={})):
|
||||
"""Change a member's role."""
|
||||
role = body.get("role", "editor")
|
||||
if role not in ("owner", "admin", "editor", "viewer"):
|
||||
return {"error": "Invalid role"}
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE workspace_members SET role=? WHERE workspace_id=? AND user_id=?",
|
||||
(role, ws_id, user_id),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/api/workspace/{ws_id:int}/members/{user_id:int}")
|
||||
def remove_member(request: Request, ws_id: int, user_id: int):
|
||||
"""Remove a member from a workspace."""
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM workspace_members WHERE workspace_id=? AND user_id=?",
|
||||
(ws_id, user_id),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ═══════════ Local Workspace (file/folder CRUD) ═══════════
|
||||
@@ -0,0 +1,131 @@
|
||||
"""FlowDeck — Dashboard : workspaces.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/dashboard.py (2 735 lignes, 63 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
|
||||
from ._common import WORKSPACE_COOKIE, _get_active_workspace, _get_user_id, _sidebar_data
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["dashboard"])
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/workspaces", response_class=HTMLResponse)
|
||||
def workspaces_page(request: Request):
|
||||
"""Workspaces list page."""
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [], include_workspace=False)
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return RedirectResponse("/auth/login?provider=local&expired=1", status_code=302)
|
||||
ctx = {**sidebar, "user": user}
|
||||
# Pass active workspace for breadcrumb nav menu (null on workspaces home)
|
||||
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
||||
ctx["nav_workspace_id"] = ws["id"] if ws else 0
|
||||
template = env.get_template("workspaces.html")
|
||||
return template.render(**ctx)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/workspaces")
|
||||
def list_workspaces(request: Request):
|
||||
"""List all workspaces for the current user."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = user["id"] if user and user.get("id") else 1
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT w.*, (SELECT COUNT(*) FROM pages WHERE workspace_id=w.id AND collection_row_id IS NULL) as page_count "
|
||||
"FROM workspaces w WHERE w.owner_id=? ORDER BY w.created_at DESC",
|
||||
(uid,),
|
||||
).fetchall()
|
||||
workspaces = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
workspaces.append(d)
|
||||
active = _get_active_workspace(request, user_id=_get_user_id(request))
|
||||
return {"workspaces": workspaces, "active_id": active["id"] if active else None}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/workspaces")
|
||||
def create_workspace(request: Request, body: dict = Body(default={})):
|
||||
"""Create a new workspace."""
|
||||
name = body.get("name", "New Workspace").strip()
|
||||
if not name:
|
||||
return {"error": "Name required"}
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = user["id"] if user and user.get("id") else 1
|
||||
with get_conn() as conn:
|
||||
# Ensure user exists (FK constraint)
|
||||
uid_ok = conn.execute("SELECT id FROM users WHERE id=?", (uid,)).fetchone()
|
||||
if not uid_ok:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?, ?, ?, 1)",
|
||||
(uid, user.get("login", "admin") if user else "admin",
|
||||
user.get("full_name", "Admin") if user else "Admin"),
|
||||
)
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO workspaces (name, owner_id) VALUES (?, ?)",
|
||||
(name, uid),
|
||||
)
|
||||
ws_id = cursor.lastrowid
|
||||
# Add owner as member
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, 'owner')",
|
||||
(ws_id, uid),
|
||||
)
|
||||
conn.commit()
|
||||
return {"id": ws_id, "name": name}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.put("/api/workspaces/{ws_id:int}")
|
||||
def rename_workspace(request: Request, ws_id: int, body: dict = Body(default={})):
|
||||
"""Rename a workspace."""
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
return {"error": "Name required"}
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE workspaces SET name=? WHERE id=?", (name, ws_id))
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/api/workspaces/{ws_id:int}")
|
||||
def delete_workspace(request: Request, ws_id: int):
|
||||
"""Delete a workspace and all its pages."""
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM pages WHERE workspace_id=?", (ws_id,))
|
||||
conn.execute("DELETE FROM workspace_members WHERE workspace_id=?", (ws_id,))
|
||||
conn.execute("DELETE FROM workspaces WHERE id=?", (ws_id,))
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/workspaces/{ws_id:int}/select")
|
||||
def select_workspace(request: Request, ws_id: int):
|
||||
"""Set the active workspace via cookie."""
|
||||
response = JSONResponse({"status": "ok", "workspace_id": ws_id})
|
||||
response.set_cookie(WORKSPACE_COOKIE, str(ws_id), max_age=86400 * 30, httponly=True, path="/")
|
||||
return response
|
||||
|
||||
|
||||
# ═══════════ Settings Page ═══════════
|
||||
@@ -88,7 +88,7 @@ document.addEventListener('alpine:init', function () {
|
||||
return Math.floor(diff / 86400) + 'd ago';
|
||||
},
|
||||
csrf() {
|
||||
return (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
return getCsrf();
|
||||
},
|
||||
refreshCount() {
|
||||
var self = this;
|
||||
|
||||
@@ -546,7 +546,7 @@
|
||||
<div style="position:absolute;inset:0;background:linear-gradient(180deg,rgba(0,0,0,0) 50%,rgba(0,0,0,.6) 100%);z-index:1;"></div>
|
||||
</div>
|
||||
<div class="page-title-block">
|
||||
<span class="page-icon-emoji" @click="openIconPicker()" title="Change icon" x-html="iconHtml()"></span>
|
||||
<span class="page-icon-emoji" @click="openIconPicker()" title="Change icon" x-init="bindIconHtml($el)"></span>
|
||||
<div
|
||||
class="page-title-input"
|
||||
contenteditable="true"
|
||||
@@ -583,7 +583,7 @@
|
||||
style="display:none;position:fixed;z-index:1100;padding:7px 12px;font-size:13px;font-weight:600;
|
||||
color:#fff;background:var(--accent,#2383E2);border:none;border-radius:8px;cursor:pointer;
|
||||
box-shadow:0 4px 16px rgba(0,0,0,.35);" title="Comment on selection"
|
||||
@click="window.E && window.E.commentOnSelection()">
|
||||
@click="edCall('commentOnSelection')">
|
||||
💬 Comment
|
||||
</button>
|
||||
|
||||
@@ -820,7 +820,7 @@
|
||||
<div style="padding:24px;text-align:center;color:var(--text-dim);">No pages link here</div>
|
||||
</template>
|
||||
<template x-for="b in backlinksList" :key="b.id">
|
||||
<a :href="'/pages/' + b.id" style="display:block;padding:12px 16px;border-bottom:1px solid var(--border);text-decoration:none;color:inherit;" @click.prevent="backlinksOpen=false;window.location.href='/pages/'+b.id">
|
||||
<a :href="'/pages/' + b.id" style="display:block;padding:12px 16px;border-bottom:1px solid var(--border);text-decoration:none;color:inherit;" @click.prevent="openBacklink(b)">
|
||||
<div style="font-weight:500;color:var(--text-primary);" x-text="b.title"></div>
|
||||
<div style="font-size:12px;color:var(--text-dim);margin-top:2px;" x-text="b.workspace || ''"></div>
|
||||
</a>
|
||||
@@ -856,7 +856,7 @@
|
||||
<template x-if="importFile">
|
||||
<div style="margin-top:8px;display:flex;align-items:center;gap:12px;color:var(--text-dim);font-size:13px;">
|
||||
<span x-text="importFile.name"></span>
|
||||
<span x-text="Math.round(importFile.size/1024)+' KB'"></span>
|
||||
<span x-text="fmtImportSize(importFile)"></span>
|
||||
</div>
|
||||
</template>
|
||||
<div style="display:flex;gap:8px;margin-top:10px;">
|
||||
|
||||
@@ -105,6 +105,11 @@
|
||||
|
||||
{% block scripts %}
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
|
||||
|
||||
// globales window — probe « Undefined variable: accountsData »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('accountsData', accountsData); });
|
||||
function accountsData() {
|
||||
return {
|
||||
profile: { full_name: '', email: '' },
|
||||
@@ -117,8 +122,8 @@
|
||||
} catch(e) {}
|
||||
},
|
||||
saveProfile() {
|
||||
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
const token = csrf ? csrf[1] : '';
|
||||
const csrf = getCsrf();;
|
||||
const token = csrf;
|
||||
fetch(`/api/users/me?full_name=${encodeURIComponent(this.profile.full_name)}&email=${encodeURIComponent(this.profile.email)}`, {
|
||||
method: 'PUT', headers: { 'X-CSRF-Token': token }
|
||||
}).then(() => {
|
||||
|
||||
@@ -305,7 +305,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="fd-agent-msg-content" x-show="m.generating" x-cloak>Génération…</div>
|
||||
<div class="fd-agent-msg-content fd-agent-msg-markdown" x-show="!m.generating && m.role==='assistant' && m.content" x-html="renderMarkdown(m.content)"></div>
|
||||
<div class="fd-agent-msg-content fd-agent-msg-markdown" x-show="!m.generating && m.role==='assistant' && m.content" x-init="bindMarkdown($el, m)"></div>
|
||||
<div class="fd-agent-msg-content" x-show="!m.generating && m.role!=='assistant' && m.content" x-text="m.content"></div>
|
||||
<template x-if="m.proposal">
|
||||
<div class="fd-proposal-bar">
|
||||
|
||||
+79
-26
@@ -115,12 +115,21 @@
|
||||
</style>
|
||||
{# A20 : htmx copie les <script nonce="{{ csp_nonce() }}"> des réponses boostées — il remet leur nonce
|
||||
depuis cette config (le nonce de la réponse courante, pas celui du fetch). #}
|
||||
<meta name="htmx-config" content='{"inlineScriptNonce": "{{ csp_nonce() }}"}'>
|
||||
<meta name="htmx-config" content='{"inlineScriptNonce": "{{ csp_nonce() }}", "allowEval": false}'>
|
||||
<script nonce="{{ csp_nonce() }}">
|
||||
// A38 : helper CSRF unique — défini le plus tôt possible (head) pour
|
||||
// tous les scripts inline/externes de la page (welcome.html, isolé de
|
||||
// base, garde sa propre lecture du cookie).
|
||||
window.getCsrf = function() {
|
||||
var m = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
return m ? m[1] : '';
|
||||
};
|
||||
</script>
|
||||
<script src="/static/js/htmx.min.js" data-cfasync="false"></script>
|
||||
<script src="/static/js/alpine.min.js" defer data-cfasync="false"></script>
|
||||
<script src="/static/js/alpine.csp.min.js" defer data-cfasync="false"></script>
|
||||
<script src="/static/js/sortable.min.js" defer data-cfasync="false"></script>
|
||||
</head>
|
||||
<body hx-headers='{"X-CSRF-Token":"__CSRF_PLACEHOLDER__"}'{% if embed_mode %} class="embed-mode"{% endif %}>
|
||||
<body hx-headers='{"X-CSRF-Token":{{ csrf_token() | tojson }}}'{% if embed_mode %} class="embed-mode"{% endif %}>
|
||||
<div class="app-layout" x-data="appState()">
|
||||
|
||||
<!-- ═══════════ MOBILE SIDEBAR OVERLAY ═══════════ -->
|
||||
@@ -179,8 +188,8 @@
|
||||
:class="{ collapsed: sidebarCollapsed && !sidebarPeek, 'mobile-open': mobileSidebarOpen, peeking: sidebarPeek }"
|
||||
id="sidebar"
|
||||
@mouseleave="!sidebarResizing && (sidebarPeek = false)"
|
||||
x-effect="document.documentElement.classList.toggle('fd-sidebar-collapsed', sidebarCollapsed && !sidebarPeek)"
|
||||
x-init="$nextTick(() => { if(typeof initTreeSortable==='function') initTreeSortable(); })">
|
||||
x-effect="syncSidebarClass()"
|
||||
x-init="initSidebarSort()"
|
||||
<!-- Header + dropdown wrapper -->
|
||||
<div style="position:relative;">
|
||||
<div class="sidebar-workspace-header" @click="workspaceMenuOpen = !workspaceMenuOpen" @mouseenter="wsHeaderHover=true" @mouseleave="wsHeaderHover=false"
|
||||
@@ -263,8 +272,8 @@
|
||||
</div>
|
||||
{% if has_active_workspace %}
|
||||
<div class="sidebar-section-actions">
|
||||
<button class="section-action-btn" title="New Page" @click.stop="window.FlowDeck.createPage()">{{ fd_icon("file",16) }}</button>
|
||||
<button class="section-action-btn" title="New Folder" @click.stop="window.FlowDeck.showCreateFolderModal()">{{ fd_icon("folder",16) }}</button>
|
||||
<button class="section-action-btn" title="New Page" @click.stop="fdCreatePage()">{{ fd_icon("file",16) }}</button>
|
||||
<button class="section-action-btn" title="New Folder" @click.stop="fdCreateFolder()">{{ fd_icon("folder",16) }}</button>
|
||||
<a class="section-action-btn" href="/local-workspace" title="Open workspace page" style="text-decoration:none;">{{ fd_icon("external-link",16) }}</a>
|
||||
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'workspace')" @contextmenu.stop="openSectionMenu($event, 'workspace')">⋮</button>
|
||||
</div>
|
||||
@@ -299,7 +308,7 @@
|
||||
<span class="section-label">Repository (Gitea)</span>
|
||||
</div>
|
||||
<div class="sidebar-section-actions">
|
||||
<button class="section-action-btn" title="Refresh" @click.stop="if(window._gwData)window._gwData.refreshTree()">{{ fd_icon("refresh",16) }}</button>
|
||||
<button class="section-action-btn" title="Refresh" @click.stop="fdGwRefresh()">{{ fd_icon("refresh",16) }}</button>
|
||||
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'gitea')">⋮</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -321,7 +330,7 @@
|
||||
</div>
|
||||
<div class="sidebar-section-actions">
|
||||
{% if has_active_workspace %}
|
||||
<button class="section-action-btn" title="Add" @click.stop="window.FlowDeck.createPage()">+</button>
|
||||
<button class="section-action-btn" title="Add" @click.stop="fdCreatePage()">+</button>
|
||||
{% endif %}
|
||||
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'meetings')">⋮</button>
|
||||
</div>
|
||||
@@ -398,7 +407,7 @@
|
||||
<ul class="sidebar-items" data-section="agents">
|
||||
<template x-for="a in agentList" :key="a.id">
|
||||
<li class="sidebar-item" @click="agentOpen(a.id)">
|
||||
<span class="page-icon page-icon-svg" x-html="a.icon || '🤖'"></span>
|
||||
<span class="page-icon page-icon-svg" x-init="bindAgentIcon($el, a)"></span>
|
||||
<span class="page-name" x-text="a.name"></span>
|
||||
</li>
|
||||
</template>
|
||||
@@ -532,12 +541,7 @@
|
||||
<button class="scp-done" @click="toggleCustomize()">Done</button>
|
||||
</div>
|
||||
<div class="scp-list">
|
||||
<template x-for="(cfg, key) in (Object.keys(sidebarConfig).length ? sidebarConfig : {
|
||||
workspace:{visible:true,order:0},teamspaces:{visible:true,order:1},
|
||||
meetings:{visible:true,order:2},recents:{visible:true,order:3},
|
||||
favorites:{visible:true,order:4},agents:{visible:true,order:5},
|
||||
shared:{visible:true,order:6},published:{visible:true,order:7}
|
||||
})" :key="key">
|
||||
<template x-for="(cfg, key) in sidebarSections()" :key="key">
|
||||
<div class="scp-item" @click="toggleSectionVisibility(key)">
|
||||
<div class="scp-item-left">
|
||||
<span class="scp-item-icon" x-text="getSectionIcon(key)"></span>
|
||||
@@ -586,7 +590,7 @@
|
||||
<!-- Section options menu (⋮ dropdown) -->
|
||||
<div class="section-menu-overlay" x-show="sectionMenu.visible" @click="closeSectionMenu()" @contextmenu.prevent="closeSectionMenu()"></div>
|
||||
<div class="section-menu" x-show="sectionMenu.visible" x-cloak
|
||||
:style="{ top: sectionMenu.y + 'px', left: Math.min(sectionMenu.x, window.innerWidth - 220) + 'px' }"
|
||||
:style="sectionMenuPos()"
|
||||
@click.outside="closeSectionMenu()">
|
||||
<div class="section-menu-item" @click="closeSectionMenu(); setShowCount(sectionMenu.section, 5)">
|
||||
<span class="smi-label">Show 5 items</span>
|
||||
@@ -796,10 +800,6 @@
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||
// Inject CSRF token into HTMX headers
|
||||
(function() {
|
||||
const getCsrf = () => {
|
||||
const m = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
return m ? m[1] : '';
|
||||
};
|
||||
document.body.setAttribute('hx-headers', JSON.stringify({'X-CSRF-Token': getCsrf()}));
|
||||
document.addEventListener('htmx:configRequest', function(evt) {
|
||||
evt.detail.headers['X-CSRF-Token'] = getCsrf();
|
||||
@@ -853,8 +853,7 @@
|
||||
// these functions for consistent behaviour.
|
||||
window.FlowDeck = {
|
||||
getCsrfToken: function() {
|
||||
var m = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
return m ? m[1] : '';
|
||||
return getCsrf();
|
||||
},
|
||||
|
||||
/** Reflète le rename d'une page/dossier dans toute l'UI : sidebar gauche
|
||||
@@ -1123,6 +1122,12 @@
|
||||
}
|
||||
};
|
||||
|
||||
// A20 phase 3 : le build CSP ne résout que le registre Alpine.data
|
||||
// (probe : globale window → « Undefined variable: appState »).
|
||||
document.addEventListener('alpine:init', function () {
|
||||
Alpine.data('appState', appState);
|
||||
});
|
||||
|
||||
function appState() {
|
||||
return {
|
||||
init() {
|
||||
@@ -1564,6 +1569,55 @@
|
||||
else window.location.href = url;
|
||||
},
|
||||
|
||||
// ── A20 phase 3 : expressions → méthode (build CSP : appel seul ;
|
||||
// document/Math/window/FlowsDeck = JS réel, hors évaluateur) ──
|
||||
bindProjectIcon(el, p) {
|
||||
Alpine.effect(() => { el.innerHTML = getSvgIcon(p.icon || 'folder', 20); });
|
||||
},
|
||||
// ── A20 ph3 : délégués du topbar éditeur (window.E hors portée CSP,
|
||||
// scope du topbar = appState) — voir right_actions de page_editor ──
|
||||
edCall(name) {
|
||||
if (window.E && typeof window.E[name] === 'function') window.E[name]();
|
||||
},
|
||||
edTimeAgo() { return (window.E && window.E.timeAgo) || ''; },
|
||||
edCommentCount() {
|
||||
return (window.E && window.E.commentCount > 0) ? window.E.commentCount : '';
|
||||
},
|
||||
edShared() { return !!(window.E && window.E.pageIsShared); },
|
||||
// les 2 branches du ternaire favorited étaient identiques → rendu 1×
|
||||
bindStar(el) { Alpine.effect(() => { el.innerHTML = getSvgIcon('star', 14); }); },
|
||||
|
||||
bindAgentIcon(el, a) {
|
||||
Alpine.effect(() => { el.innerHTML = a.icon || '🤖'; });
|
||||
},
|
||||
syncSidebarClass() {
|
||||
document.documentElement.classList.toggle(
|
||||
'fd-sidebar-collapsed', this.sidebarCollapsed && !this.sidebarPeek);
|
||||
},
|
||||
initSidebarSort() {
|
||||
Alpine.nextTick(() => {
|
||||
if (typeof initTreeSortable === 'function') initTreeSortable();
|
||||
});
|
||||
},
|
||||
fdCreatePage() { window.FlowDeck.createPage(); },
|
||||
fdCreateFolder() { window.FlowDeck.showCreateFolderModal(); },
|
||||
fdGwRefresh() { if (window._gwData) window._gwData.refreshTree(); },
|
||||
sidebarSections() {
|
||||
if (Object.keys(this.sidebarConfig).length) return this.sidebarConfig;
|
||||
return {
|
||||
workspace: { visible: true, order: 0 }, teamspaces: { visible: true, order: 1 },
|
||||
meetings: { visible: true, order: 2 }, recents: { visible: true, order: 3 },
|
||||
favorites: { visible: true, order: 4 }, agents: { visible: true, order: 5 },
|
||||
shared: { visible: true, order: 6 }, published: { visible: true, order: 7 },
|
||||
};
|
||||
},
|
||||
sectionMenuPos() {
|
||||
return {
|
||||
top: this.sectionMenu.y + 'px',
|
||||
left: Math.min(this.sectionMenu.x, window.innerWidth - 220) + 'px',
|
||||
};
|
||||
},
|
||||
|
||||
toggleSidebar() {
|
||||
this.sidebarPeek = false;
|
||||
this.sidebarCollapsed = !this.sidebarCollapsed;
|
||||
@@ -1733,8 +1787,7 @@
|
||||
addPage(section) { this.newPage(section); },
|
||||
addSubPage(id) { this.newSubPage(id); },
|
||||
getCsrfToken() {
|
||||
const m = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
return m ? m[1] : '';
|
||||
return getCsrf();
|
||||
},
|
||||
newPage(section) {
|
||||
const project = this.workspaceKey || '';
|
||||
@@ -2292,7 +2345,7 @@
|
||||
return;
|
||||
}
|
||||
state.actions = false;
|
||||
fetch('/api/search?q='+encodeURIComponent(q), {headers:{'X-CSRF-Token': document.body.getAttribute('hx-headers') ? (JSON.parse(document.body.getAttribute('hx-headers'))['X-CSRF-Token']||'') : ''}})
|
||||
fetch('/api/search?q='+encodeURIComponent(q))
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(data){
|
||||
if(input.value.trim()!==q) return; // stale
|
||||
|
||||
@@ -49,12 +49,5 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<script nonce="{{ csp_nonce() }}">
|
||||
function openCardDetail(id) {
|
||||
htmx.ajax('GET', `/api/issues/${owner}/${repo}/${id}?format=html`, {
|
||||
target: '#card-modal-content', swap: 'innerHTML'
|
||||
});
|
||||
document.getElementById('card-modal').style.display = 'flex';
|
||||
document.getElementById('card-modal-title').textContent = 'Issue #' + id;
|
||||
}
|
||||
</script>
|
||||
<!-- A38 : openCardDetail vit dans static/js/app.js (dedupliquee,
|
||||
corps identique x2 dans ces deux fragments de vue) -->
|
||||
|
||||
@@ -100,12 +100,11 @@
|
||||
</div>
|
||||
|
||||
<script nonce="{{ csp_nonce() }}">
|
||||
// ponytail: CSRF helper
|
||||
function getCsrf() {
|
||||
const m = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
return m ? m[1] : '';
|
||||
}
|
||||
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
|
||||
|
||||
// globales window — probe « Undefined variable: cardDetail »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('cardDetail', cardDetail); });
|
||||
function cardDetail() {
|
||||
return {
|
||||
updateField(field, value) {
|
||||
|
||||
@@ -58,12 +58,5 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<script nonce="{{ csp_nonce() }}">
|
||||
function openCardDetail(id) {
|
||||
htmx.ajax('GET', `/api/issues/${owner}/${repo}/${id}?format=html`, {
|
||||
target: '#card-modal-content', swap: 'innerHTML'
|
||||
});
|
||||
document.getElementById('card-modal').style.display = 'flex';
|
||||
document.getElementById('card-modal-title').textContent = 'Issue #' + id;
|
||||
}
|
||||
</script>
|
||||
<!-- A38 : openCardDetail vit dans static/js/app.js (dedupliquee,
|
||||
corps identique x2 dans ces deux fragments de vue) -->
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
{% block topbar %}
|
||||
{% set breadcrumb_items = [{"label": owner ~ "/" ~ repo, "url": None}] %}
|
||||
{% set page_icon = "link" %}
|
||||
{% set right_actions = '<button class="page-action-btn" @click="showNewFile=!showNewFile" title="New file">' ~ fd_icon("file",14) ~ '+</button><button class="page-action-btn" @click="triggerUpload()" title="Upload file">' ~ fd_icon("upload",14) ~ '</button><input type="file" id="gitea-upload-input" style="display:none" @change="doUpload($event)" multiple><button class="page-action-btn" @click="refreshTree()" title="Refresh">' ~ fd_icon("refresh",14) ~ '</button><a href="/accounts/settings" class="topbar-btn" title="Settings">' ~ fd_icon("settings",16) ~ '</a>' %}
|
||||
{% set right_actions %}<button class="page-action-btn" @click="showNewFile=!showNewFile" title="New file">{{ fd_icon("file",14) }}+</button><button class="page-action-btn" @click="triggerUpload()" title="Upload file">{{ fd_icon("upload",14) }}</button><input type="file" id="gitea-upload-input" style="display:none" @change="doUpload($event)" multiple><button class="page-action-btn" @click="refreshTree()" title="Refresh">{{ fd_icon("refresh",14) }}</button><a href="/accounts/settings" class="topbar-btn" title="Settings">{{ fd_icon("settings",16) }}</a>{% endset %}
|
||||
{% include '_header.html' %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
</style>
|
||||
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}" src="/static/js/gitea_workspace.js?v={{ asset_version }}"></script>
|
||||
<div class="gw-main" x-data="giteaWorkspace">
|
||||
<div class="gw-main" x-data="giteaWorkspace()">
|
||||
<!-- File view -->
|
||||
<div x-show="showFile && !showEditor" x-transition>
|
||||
<div class="gw-file-toolbar">
|
||||
@@ -120,7 +120,7 @@
|
||||
@click="item.type==='folder' ? drillDown(item.path) : openFile(item.path, item.sha)"
|
||||
@contextmenu.prevent="openGwContext($event, item)"
|
||||
style="display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;cursor:pointer;">
|
||||
<span x-html="item.type==='folder' ? getSvgIcon('folder',16) : getSvgIcon('file',16)" style="font-size:16px;"></span>
|
||||
<span x-init="bindGwIcon($el, item)" style="font-size:16px;"></span>
|
||||
<span x-text="item.name" style="flex:1;font-size:14px;" :style="{ fontWeight: item.type==='folder' ? '500' : '400' }"></span>
|
||||
<span x-text="item.type==='folder' ? '' : formatSize(item.size)" style="font-size:12px;color:var(--text-tertiary);"></span>
|
||||
</div>
|
||||
@@ -165,7 +165,7 @@
|
||||
@click="openPrivate(pp.id)">
|
||||
<div>
|
||||
<div style="font-weight:500;" x-text="pp.title"></div>
|
||||
<div style="font-size:12px;color:var(--text-dim);" x-text="pp.updated_at ? new Date(pp.updated_at+'Z').toLocaleString() : ''"></div>
|
||||
<div style="font-size:12px;color:var(--text-dim);" x-text="fmtGwDate(pp)"></div>
|
||||
</div>
|
||||
<button class="btn btn-danger" style="padding:4px 8px;font-size:11px;" @click.stop="deletePrivate(pp.id)">{{ fd_icon('trash',14) }}</button>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Importer — FlowDeck</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<script src="/static/js/alpine.min.js" defer data-cfasync="false"></script>
|
||||
<script src="/static/js/alpine.csp.min.js" defer data-cfasync="false"></script>
|
||||
<style>
|
||||
:root{--bg:#191919;--bg2:#1F1F1F;--bg3:#2D2D2D;--border:#333;--text:#fff;--dim:#9B9A97;--accent:#2383E2;--accent-h:#1a6bc0;--success:#0F7B6C;--danger:#E03E3E;--warn:#D9730D;}
|
||||
*{margin:0;padding:0;box-sizing:border-box;}
|
||||
@@ -201,7 +201,7 @@ select,input[type=text]{background:var(--bg3);color:var(--text);border:1px solid
|
||||
<template x-for="(e,i) in (report ? report.errors : [])" :key="i"><div x-text="'✕ '+e.title+' : '+e.error"></div></template>
|
||||
</div>
|
||||
<div class="warnings" x-show="report && report.relations && report.relations.relations_resolved">
|
||||
<div x-text="'🔗 '+report.relations.relations_resolved+' relation(s) reconstruite(s)'"></div>
|
||||
<div x-text="report && report.relations ? '🔗 '+report.relations.relations_resolved+' relation(s) reconstruite(s)' : ''"></div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn btn-ghost" @click="downloadReport()">Télécharger le rapport (JSON)</button>
|
||||
|
||||
@@ -255,7 +255,7 @@
|
||||
<div class="dd-item" :class="{active: sortBy==='author'}" @click="setSort('author')"><span class="check" x-text="sortBy==='author' ? '✓' : ''"></span> Created by</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="lib-icon-btn" :class="{active: searchOpen}" title="Search" @click.stop="searchOpen=!searchOpen; $nextTick(()=>{ if(searchOpen) document.getElementById('lib-search-input').focus(); })">
|
||||
<button class="lib-icon-btn" :class="{active: searchOpen}" title="Search" @click.stop="toggleSearch()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
</button>
|
||||
<div class="lib-toolbar-wrap">
|
||||
@@ -310,7 +310,7 @@
|
||||
<button @click="copyLinks()" title="Copy links">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
|
||||
</button>
|
||||
<button @click="openMovePicker(Object.keys(selected).map(Number))" title="Move to">
|
||||
<button @click="openMoveSelected()" title="Move to">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
|
||||
</button>
|
||||
<div style="position:relative;margin-left:auto;">
|
||||
@@ -326,7 +326,7 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
|
||||
Copy links to all
|
||||
</div>
|
||||
<div class="menu-item" @click="openMovePicker(Object.keys(selected).map(Number))">
|
||||
<div class="menu-item" @click="openMoveSelected()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
|
||||
Move to
|
||||
</div>
|
||||
@@ -378,7 +378,7 @@
|
||||
|
||||
<!-- ── Empty state ── -->
|
||||
<div class="lib-empty" id="lib-empty">
|
||||
<div class="empty-icon" x-html="getSvgIcon(emptyIcon,48)"></div>
|
||||
<div class="empty-icon" x-init="bindHtmlIcon($el)"></div>
|
||||
<h3 x-text="emptyTitle"></h3>
|
||||
<p x-text="emptyText"></p>
|
||||
</div>
|
||||
@@ -397,7 +397,7 @@
|
||||
<div class="move-modal-item" @click="confirmMove(0)">{{ fd_icon("file",14) }} <span>Root (no parent)</span></div>
|
||||
<template x-for="it in moveCandidates" :key="it.id">
|
||||
<div class="move-modal-item" @click="confirmMove(it.id)">
|
||||
<span x-html="_renderIcon(it)"></span> <span x-text="it.title"></span>
|
||||
<span x-init="bindHtmlItem($el, it)"></span> <span x-text="it.title"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -414,7 +414,7 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
<div class="peek-title">
|
||||
<span x-html="_renderIcon(peekItem)"></span>
|
||||
<span x-init="bindHtmlItem($el, peekItem)"></span>
|
||||
<span x-text="peekItem.title"></span>
|
||||
</div>
|
||||
<button @click="openItem(peekItem)" title="Open full page">
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
{% block content %}
|
||||
<span id="fd-local-ws-id" hidden>{{ workspace_id }}</span>
|
||||
<div x-data="_wsInitData"
|
||||
<div x-data="wsInitData()"
|
||||
@dragover.prevent="onDragOver($event)"
|
||||
@dragleave="onDragLeave($event)"
|
||||
@drop.prevent="onDrop($event)"
|
||||
@@ -482,10 +482,10 @@
|
||||
|
||||
<script type="application/json" id="lw-config" nonce="{{ csp_nonce() }}">{{ {"current_folder_id": current_folder_id, "workspace_id": workspace_id} | tojson }}</script>
|
||||
<script data-cfasync="false" src="/static/js/local_workspace.js?v={{ asset_version }}"></script>
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">console.log('FLOWDECK v2.4.6 — ws-split about to render, _wsInitData exists:', typeof window._wsInitData !== 'undefined');</script>
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">console.log('FLOWDECK v2.4.6 — ws-split about to render, _wsInitData exists:', typeof _wsInitData !== 'undefined');</script>
|
||||
|
||||
<div class="ws-split"
|
||||
@contextmenu.prevent="_wsInitData.onContextMenu($event)"
|
||||
@contextmenu.prevent="onContextMenu($event)"
|
||||
@touchstart="onTouchStart($event)"
|
||||
@touchend="onTouchEnd($event)"
|
||||
@touchmove="onTouchMove($event)"
|
||||
@@ -597,7 +597,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- Search toggle -->
|
||||
<button class="ws-icon-btn" :class="{active: searchOpen}" title="Search" @click.stop="searchOpen=!searchOpen; if(searchOpen) $nextTick(()=>$refs.searchInput?.focus())">
|
||||
<button class="ws-icon-btn" :class="{active: searchOpen}" title="Search" @click.stop="toggleSearch()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
</button>
|
||||
<!-- Expand/Collapse (tree only) -->
|
||||
@@ -650,12 +650,12 @@
|
||||
|
||||
<!-- Filter chips (visual indicator when filter is active — shown in all views) -->
|
||||
<div class="filter-row" x-show="filterType" style="padding:4px 0;margin-bottom:6px;">
|
||||
<button class="filter-chip" :class="{ active: filterType === 'folder' }" @click="filterByType('folder')" title="Folders" x-html="getSvgIcon('folder',14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'page' }" @click="filterByType('page')" title="Pages" x-html="getSvgIcon('file',14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'pdf' }" @click="filterByType('pdf')" title="PDF" x-html="getSvgIcon('file',14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'image' }" @click="filterByType('image')" title="Images" x-html="getSvgIcon('image',14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'code' }" @click="filterByType('code')" title="Code" x-html="getSvgIcon('file',14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'text' }" @click="filterByType('text')" title="Text" x-html="getSvgIcon('file',14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'folder' }" @click="filterByType('folder')" title="Folders" x-init="bindSvg($el, 'folder', 14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'page' }" @click="filterByType('page')" title="Pages" x-init="bindSvg($el, 'file', 14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'pdf' }" @click="filterByType('pdf')" title="PDF" x-init="bindSvg($el, 'file', 14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'image' }" @click="filterByType('image')" title="Images" x-init="bindSvg($el, 'image', 14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'code' }" @click="filterByType('code')" title="Code" x-init="bindSvg($el, 'file', 14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'text' }" @click="filterByType('text')" title="Text" x-init="bindSvg($el, 'file', 14)"></button>
|
||||
<span class="filter-clear" @click="filterType='', doFilter()">clear filter</span>
|
||||
</div>
|
||||
|
||||
@@ -723,7 +723,7 @@
|
||||
<span style="width:16px;flex-shrink:0;"></span>
|
||||
</template>
|
||||
|
||||
<span class="icon" x-html="_fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></span>
|
||||
<span class="icon" x-init="bindFileIcon($el, node)"></span>
|
||||
|
||||
<template x-if="node.is_folder">
|
||||
<span class="name folder" @click.stop="navigateToFolder(node.id)"
|
||||
@@ -754,8 +754,8 @@
|
||||
<div class="actions">
|
||||
<template x-if="node.is_folder">
|
||||
<span style="display:flex;gap:2px">
|
||||
<button class="ws-act" @click.stop="window.FlowDeck.createPage(node.id)" title="New file">{{ fd_icon("file",14) }}</button>
|
||||
<button class="ws-act" @click.stop="window.FlowDeck.showCreateFolderModal(node.id)" title="New folder">{{ fd_icon("folder",14) }}</button>
|
||||
<button class="ws-act" @click.stop="createPageAt(node)" title="New file">{{ fd_icon("file",14) }}</button>
|
||||
<button class="ws-act" @click.stop="createFolderAt(node)" title="New folder">{{ fd_icon("folder",14) }}</button>
|
||||
<a class="ws-act" href="/local-workspace" title="Open workspace page" style="text-decoration:none;display:inline-flex;align-items:center;">{{ fd_icon("external-link",14) }}</a>
|
||||
</span>
|
||||
</template>
|
||||
@@ -765,7 +765,7 @@
|
||||
</div>
|
||||
<!-- Children -->
|
||||
<ul class="ws-tree" x-show="expanded[node.id]" x-transition
|
||||
x-html="renderChildren(node.children, (node.depth||0)+1, tagsVersion)">
|
||||
x-init="bindChildren($el, node)">
|
||||
</ul>
|
||||
</li>
|
||||
</template>
|
||||
@@ -807,7 +807,7 @@
|
||||
<tr :class="{ selected: !!selectedIds[node.id] }">
|
||||
<td><input type="checkbox" :checked="!!selectedIds[node.id]" @click.stop="toggleSelect(node, $event)"></td>
|
||||
<td>
|
||||
<span class="table-icon" x-html="_fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></span>
|
||||
<span class="table-icon" x-init="bindFileIcon($el, node)"></span>
|
||||
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span>
|
||||
<span class="col-name" @click="node.is_folder ? navigateToFolder(node.id) : openPage(node.id)" x-text="node.name"></span>
|
||||
</td>
|
||||
@@ -864,7 +864,7 @@
|
||||
</tr>
|
||||
<template x-for="node in displayTree" :key="'l'+node.id">
|
||||
<tr :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()" style="cursor:pointer">
|
||||
<td><span x-html="_fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></span>
|
||||
<td><span x-init="bindFileIcon($el, node)"></span>
|
||||
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span></td>
|
||||
<td class="col-name" x-text="node.name"></td>
|
||||
<td style="font-size:12px;color:var(--text-tertiary)" x-text="node.is_folder ? 'Folder' : _fileTypeLabel(node.name, node.content_format)"></td>
|
||||
@@ -902,7 +902,7 @@
|
||||
<template x-for="node in displayTree" :key="'d'+node.id">
|
||||
<tr :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()" style="cursor:pointer"
|
||||
:class="{ 'selected-row': previewItem && previewItem.id === node.id }">
|
||||
<td><span class="table-icon" x-html="_fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></span>
|
||||
<td><span class="table-icon" x-init="bindFileIcon($el, node)"></span>
|
||||
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span></td>
|
||||
<td class="col-name" x-text="node.name"></td>
|
||||
<td class="col-path" x-text="node._path || '—'"></td>
|
||||
@@ -933,7 +933,7 @@
|
||||
<template x-for="node in displayTree" :key="'g'+node.id">
|
||||
<div class="title-card" :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()"
|
||||
:class="{ 'selected-card': previewItem && previewItem.id === node.id }">
|
||||
<div class="title-card-icon" x-html="node.is_folder ? getSvgIcon('folder',24) : _fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></div>
|
||||
<div class="title-card-icon" x-init="bindNodeIcon($el, node)"></div>
|
||||
<div class="title-card-name">
|
||||
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span>
|
||||
<span x-text="node.name"></span>
|
||||
@@ -960,7 +960,7 @@
|
||||
<div class="content-item" :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()"
|
||||
:class="{ 'selected-row': previewItem && previewItem.id === node.id }">
|
||||
<div class="content-item-header">
|
||||
<span x-html="_fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></span>
|
||||
<span x-init="bindFileIcon($el, node)"></span>
|
||||
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span>
|
||||
<span class="col-name" x-text="node.name"></span>
|
||||
<span style="font-size:11px;color:var(--text-tertiary);margin-left:auto" x-text="_formatDate(node.updated_at)"></span>
|
||||
@@ -1023,7 +1023,7 @@
|
||||
<h3><span x-show="createType==='folder'">{{ fd_icon('folder',16) }}</span><span x-show="createType!='folder'">{{ fd_icon('file',16) }}</span> <span x-text="createType==='folder'?'New Folder':'New File'"></span></h3>
|
||||
<p class="modal-sub">
|
||||
In <strong>{{ workspace_name }}</strong> <span x-show="folderStack.length>1">/ <span x-text="(folderStack[folderStack.length-1]||{}).name||''"></span></span>
|
||||
<span x-show="parentFolder"> / <span x-text="parentFolder?.name"></span></span>
|
||||
<span x-show="parentFolder"> / <span x-text="parentFolder ? parentFolder.name : ''"></span></span>
|
||||
</p>
|
||||
<input class="modal-input" x-model="newName" :placeholder="createType==='folder'?'Folder name':'File name'" @keydown.enter="doCreate" x-ref="cinp">
|
||||
<div class="modal-actions">
|
||||
@@ -1037,7 +1037,7 @@
|
||||
<div class="modal-overlay" x-show="showRename" @click.outside="showRename=false" @keydown.escape="showRename=false" style="display:none">
|
||||
<div class="modal-box">
|
||||
<h3>{{ fd_icon("edit",14) }} Rename</h3>
|
||||
<p class="modal-sub">Rename <strong x-text="target?.name"></strong></p>
|
||||
<p class="modal-sub">Rename <strong x-text="target ? target.name : ''"></strong></p>
|
||||
<input class="modal-input" x-model="newName" @keydown.enter="doRename" x-ref="rinp">
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" @click="showRename=false">Cancel</button>
|
||||
@@ -1050,7 +1050,7 @@
|
||||
<div class="modal-overlay" x-show="showDelete" @click.outside="showDelete=false" @keydown.escape="showDelete=false" style="display:none">
|
||||
<div class="modal-box">
|
||||
<h3>{{ fd_icon("trash",16) }} Delete</h3>
|
||||
<p class="modal-sub">Delete <strong x-text="target?.name"></strong>?</p>
|
||||
<p class="modal-sub">Delete <strong x-text="target ? target.name : ''"></strong>?</p>
|
||||
<div class="delete-confirm">This cannot be undone. Children will also be deleted.</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" @click="showDelete=false">Cancel</button>
|
||||
@@ -1065,11 +1065,14 @@
|
||||
{% include "_ctx_menu.html" %}
|
||||
|
||||
<!-- Preview tooltip -->
|
||||
<div class="file-preview" x-show="previewVisible"
|
||||
<!-- ponytail: le parseur referme la div racine avant ce bloc (structure
|
||||
pré-existante, masquée par le fallback window d'Alpine standard) →
|
||||
wsPreview = wrapper déléguant vers _wsInitData (voir local_workspace.js) -->
|
||||
<div class="file-preview" x-data="wsPreview()" x-show="previewVisible"
|
||||
:style="{ left: previewX + 'px', top: previewY + 'px' }"
|
||||
@mouseenter="previewVisible = true" @mouseleave="previewVisible = false">
|
||||
<div class="file-preview-header" x-text="previewName"></div>
|
||||
<div class="file-preview-body" x-html="previewContent"></div>
|
||||
<div class="file-preview-body" x-init="bindPreview($el)"></div>
|
||||
</div>
|
||||
|
||||
<!-- Preview Panel — identical to Library peek-overlay -->
|
||||
|
||||
@@ -3,7 +3,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
|
||||
{% set page_icon = "file" if page.content_format != 'file' else "paperclip" %}
|
||||
{% set page_title = page.title %}
|
||||
{% set nav_page_id = page.id %}
|
||||
{% set right_actions = '<span class="topbar-edited" style="cursor:pointer;" @click="window.E && window.E.toggleActivityOpen()">Edited <span x-text="window.E && window.E.timeAgo || \'\'"></span> ▾</span><button class="topbar-btn" @click="window.E && window.E.toggleComments()" title="Comments"><span class="fd-comment-btn-ico">💬</span><span class="fd-comment-count" x-text="window.E && window.E.commentCount>0 ? window.E.commentCount : \'\'"></span></button><button class="topbar-btn share-btn" @click="window.E && window.E.toggleShareOpen()"><span x-show="!window.E || !window.E.pageIsShared">' ~ fd_icon("lock",14) ~ ' Share ▾</span><span x-show="window.E && window.E.pageIsShared" title="This page is shared">👥 Shared ▾</span></button><button class="topbar-btn" @click="window.E && window.E.copyPageLink()" title="Copy link">' ~ fd_icon("link",14) ~ '</button><button class="topbar-btn star-btn" @click="window.E && window.E.toggleFavorite()" x-html="(window.E && window.E.favorited) ? getSvgIcon(\'star\',14) : getSvgIcon(\'star\',14)"></button><button class="topbar-btn relative" @click="window.E && window.E.toggleMoreOpen()">⋯</button>' %}
|
||||
{% set right_actions %}<span class="topbar-edited" style="cursor:pointer;" @click="edCall('toggleActivityOpen')">Edited <span x-text="edTimeAgo()"></span> ▾</span><button class="topbar-btn" @click="edCall('toggleComments')" title="Comments"><span class="fd-comment-btn-ico">💬</span><span class="fd-comment-count" x-text="edCommentCount()"></span></button><button class="topbar-btn share-btn" @click="edCall('toggleShareOpen')"><span x-show="!edShared()">{{ fd_icon("lock",14) }} Share ▾</span><span x-show="edShared()" title="This page is shared">👥 Shared ▾</span></button><button class="topbar-btn" @click="edCall('copyPageLink')" title="Copy link">{{ fd_icon("link",14) }}</button><button class="topbar-btn star-btn" @click="edCall('toggleFavorite')" x-init="bindStar($el)"></button><button class="topbar-btn relative" @click="edCall('toggleMoreOpen')">⋯</button>{% endset %}
|
||||
{% include '_header.html' %}
|
||||
{% endblock %} {% block content %}
|
||||
{% include "_page_editor_content.html" %}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
page_title %}{{ page.title }}{% endblock %} {% block topbar %}
|
||||
{% set page_icon = "file" %}
|
||||
{% set page_title = page.title %}
|
||||
{% set right_actions = '<span class="topbar-edited" style="cursor:pointer;" @click="window.E && window.E.toggleActivityOpen()">Edited <span x-text="window.E && window.E.timeAgo || \'\'"></span> ▾</span><button class="topbar-btn share-btn" @click="window.E && window.E.toggleShareOpen()"><span x-show="!window.E || !window.E.pageIsShared">' ~ fd_icon("lock",14) ~ ' Share ▾</span><span x-show="window.E && window.E.pageIsShared" title="This page is shared">👥 Shared ▾</span></button><button class="topbar-btn" @click="window.E && window.E.copyPageLink()" title="Copy link">' ~ fd_icon("link",14) ~ '</button><button class="topbar-btn star-btn" @click="window.E && window.E.toggleFavorite()" x-html="(window.E && window.E.favorited) ? getSvgIcon(\'star\',14) : getSvgIcon(\'star\',14)"></button><button class="topbar-btn relative" @click="window.E && window.E.toggleMoreOpen()">⋯</button>' %}
|
||||
{% set right_actions %}<span class="topbar-edited" style="cursor:pointer;" @click="edCall('toggleActivityOpen')">Edited <span x-text="edTimeAgo()"></span> ▾</span><button class="topbar-btn share-btn" @click="edCall('toggleShareOpen')"><span x-show="!edShared()">{{ fd_icon("lock",14) }} Share ▾</span><span x-show="edShared()" title="This page is shared">👥 Shared ▾</span></button><button class="topbar-btn" @click="edCall('copyPageLink')" title="Copy link">{{ fd_icon("link",14) }}</button><button class="topbar-btn star-btn" @click="edCall('toggleFavorite')" x-init="bindStar($el)"></button><button class="topbar-btn relative" @click="edCall('toggleMoreOpen')">⋯</button>{% endset %}
|
||||
{% include '_header.html' %}
|
||||
{% endblock %} {% block content %}
|
||||
{% include "_database_table.html" %}
|
||||
|
||||
@@ -127,7 +127,7 @@
|
||||
@media (max-width:760px){.llm-layout{grid-template-columns:1fr;}.llm-list{max-height:220px;}}
|
||||
</style>
|
||||
|
||||
<div class="settings-overlay" x-data="settingsInit()" @keydown.escape="window.history.back()">
|
||||
<div class="settings-overlay" x-data="settingsInit()" @keydown.escape="historyBack()">
|
||||
<div class="settings-panel" style="position:relative;">
|
||||
<button class="settings-close" @click="closeSettings()" title="Close">×</button>
|
||||
|
||||
@@ -515,9 +515,9 @@
|
||||
<div class="modal-box" style="max-width:360px;">
|
||||
<h3 style="margin:0 0 8px;">Delete tag</h3>
|
||||
<p style="color:var(--text-dim);margin:0 0 16px;">
|
||||
Are you sure you want to delete the tag "<strong x-text="deletingTag?.name"></strong>"?
|
||||
<span x-show="deletingTag?.count > 0" style="color:var(--danger);display:block;margin-top:4px;">
|
||||
{{ fd_icon('alert-triangle',14) }} This tag is used on <span x-text="deletingTag?.count"></span> item(s).
|
||||
Are you sure you want to delete the tag "<strong x-text="deletingTag ? deletingTag.name : ''"></strong>"?
|
||||
<span x-show="deletingTag && deletingTag.count > 0" style="color:var(--danger);display:block;margin-top:4px;">
|
||||
{{ fd_icon('alert-triangle',14) }} This tag is used on <span x-text="deletingTag ? deletingTag.count : 0"></span> item(s).
|
||||
</span>
|
||||
</p>
|
||||
<div style="display:flex;gap:8px;justify-content:flex-end;">
|
||||
@@ -531,7 +531,7 @@
|
||||
<div class="modal-box" style="max-width:360px;">
|
||||
<h3 style="margin:0 0 12px;">Rename tag</h3>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<div class="tag-color-dot" :style="{background: renamingTag?.color}" style="width:16px;height:16px;"></div>
|
||||
<div class="tag-color-dot" :style="{background: renamingTag ? renamingTag.color : ''}" style="width:16px;height:16px;"></div>
|
||||
<input type="text" class="settings-input" x-model="renameValue"
|
||||
@keydown.enter="confirmRenameTag()" @keydown.escape="renamingTag=null"
|
||||
style="flex:1;" autofocus>
|
||||
@@ -770,7 +770,7 @@
|
||||
<td x-text="u.folder_count" style="text-align:center;"></td>
|
||||
<td x-text="u.total_mb + ' MB'" style="text-align:right;"></td>
|
||||
<td>
|
||||
<span style="font-size:11px;color:var(--text-dim);" x-text="u.last_login ? new Date(u.last_login*1000).toLocaleDateString() : 'Never'"></span>
|
||||
<span style="font-size:11px;color:var(--text-dim);" x-text="fmtLastLogin(u)"></span>
|
||||
</td>
|
||||
<td>
|
||||
<div style="display:flex;gap:4px;">
|
||||
@@ -837,7 +837,7 @@
|
||||
<tbody>
|
||||
<template x-for="e in auditLogs" :key="e.at + '-' + e.source + '-' + e.actor">
|
||||
<tr>
|
||||
<td><span style="font-size:12px;" x-text="new Date(e.at).toLocaleString()"></span></td>
|
||||
<td><span style="font-size:12px;" x-text="fmtAuditDate(e)"></span></td>
|
||||
<td><span style="font-size:11px;" :class="'audit-src audit-src-'+e.source" x-text="e.source"></span></td>
|
||||
<td><code style="font-size:11px;" x-text="e.actor"></code></td>
|
||||
<td><code style="font-size:11px;" x-text="e.action"></code></td>
|
||||
|
||||
@@ -100,6 +100,11 @@
|
||||
</div>
|
||||
|
||||
<script nonce="{{ csp_nonce() }}">
|
||||
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
|
||||
|
||||
// globales window — probe « Undefined variable: tableView »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('tableView', tableView); });
|
||||
function tableView() {
|
||||
return {
|
||||
sortField: '',
|
||||
|
||||
@@ -44,6 +44,11 @@
|
||||
</div>
|
||||
|
||||
<script nonce="{{ csp_nonce() }}">
|
||||
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
|
||||
|
||||
// globales window — probe « Undefined variable: teamLoad »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('teamLoad', teamLoad); });
|
||||
function teamLoad() {
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -63,13 +63,18 @@
|
||||
|
||||
{% block scripts %}
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
|
||||
|
||||
// globales window — probe « Undefined variable: trashData »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('trashData', trashData); });
|
||||
function trashData() {
|
||||
return {
|
||||
search: '',
|
||||
items: [],
|
||||
async init() {
|
||||
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
const token = csrf ? csrf[1] : '';
|
||||
const csrf = getCsrf();;
|
||||
const token = csrf;
|
||||
try {
|
||||
const r = await fetch('/board/api/trash', { headers: { 'X-CSRF-Token': token } });
|
||||
this.items = await r.json();
|
||||
@@ -80,13 +85,13 @@
|
||||
return this.items.filter(i => !q || i.name.toLowerCase().includes(q) || (i.path||'').toLowerCase().includes(q));
|
||||
},
|
||||
async restore(id) {
|
||||
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
const csrf = getCsrf();;
|
||||
const r = await fetch(`/board/api/trash/${id}/restore`, { method: 'POST', headers: { 'X-CSRF-Token': csrf?.[1] || '' } });
|
||||
if (r.ok) { this.items = this.items.filter(i => i.id !== id); }
|
||||
},
|
||||
async deleteForever(id) {
|
||||
if (!confirm('Permanently delete this page? This cannot be undone.')) return;
|
||||
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
const csrf = getCsrf();;
|
||||
const r = await fetch(`/board/api/trash/${id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf?.[1] || '' } });
|
||||
if (r.ok) { this.items = this.items.filter(i => i.id !== id); }
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Bienvenue sur FlowDeck</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<script src="/static/js/alpine.min.js" defer data-cfasync="false"></script>
|
||||
<script src="/static/js/alpine.csp.min.js" defer data-cfasync="false"></script>
|
||||
<style>
|
||||
:root{--bg:#191919;--bg2:#1F1F1F;--bg3:#2D2D2D;--border:#333;--text:#fff;--dim:#9B9A97;--accent:#2383E2;--accent-h:#1a6bc0;--success:#0F7B6C;--danger:#E03E3E;}
|
||||
*{margin:0;padding:0;box-sizing:border-box;}
|
||||
@@ -134,6 +134,11 @@ h1{font-size:22px;font-weight:700;margin-bottom:6px;}
|
||||
</div>
|
||||
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
|
||||
|
||||
// globales window — probe « Undefined variable: onboarding »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('onboarding', onboarding); });
|
||||
function onboarding() {
|
||||
return {
|
||||
steps: ['Bienvenue', 'Espace de travail', 'Connecter une forge', 'Premier projet'],
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
{% block topbar %}
|
||||
{% set page_icon = "home" %}
|
||||
{% set page_title = "Workspace — Projects" %}
|
||||
{% set right_actions = '<a href="/auth/login?provider=local" class="topbar-btn" title="Login">' ~ fd_icon("key",16) ~ '</a><a href="/accounts/settings" class="topbar-btn" title="Settings">' ~ fd_icon("settings",16) ~ '</a>' %}
|
||||
{% set right_actions %}<a href="/auth/login?provider=local" class="topbar-btn" title="Login">{{ fd_icon("key",16) }}</a><a href="/accounts/settings" class="topbar-btn" title="Settings">{{ fd_icon("settings",16) }}</a>{% endset %}
|
||||
{% include '_header.html' %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
<template x-for="p in builtinProjects" :key="p.id">
|
||||
<div class="project-card" @click="openProject(p)">
|
||||
<div class="project-card-top">
|
||||
<span class="project-card-icon" x-html="getSvgIcon(p.icon || 'folder',20)"></span>
|
||||
<span class="project-card-icon" x-init="bindProjectIcon($el, p)"></span>
|
||||
<span class="forge-badge builtin">Built-in</span>
|
||||
</div>
|
||||
<div class="project-card-name" x-text="p.name"></div>
|
||||
@@ -141,6 +141,11 @@
|
||||
</div>
|
||||
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
|
||||
|
||||
// globales window — probe « Undefined variable: workspacePage »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('workspacePage', workspacePage); });
|
||||
function workspacePage() {
|
||||
return {
|
||||
builtinProjects: [],
|
||||
@@ -174,7 +179,7 @@ function workspacePage() {
|
||||
if (!this.newProjectName.trim()) return;
|
||||
const r = await fetch('/api/workspace/projects', {
|
||||
method: 'POST',
|
||||
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json'},
|
||||
headers: {'X-CSRF-Token': getCsrf(), 'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({name: this.newProjectName.trim()})
|
||||
});
|
||||
if (r.ok) {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
{% block topbar %}
|
||||
{% set breadcrumb_items = [{"label": "Workspaces", "url": None}] %}
|
||||
{% set page_icon = "home" %}
|
||||
{% set right_actions = '<a href="/auth/login?provider=local" class="topbar-btn" title="Login">' ~ fd_icon("key",16) ~ '</a><a href="/accounts/settings" class="topbar-btn" title="Settings">' ~ fd_icon("settings",16) ~ '</a>' %}
|
||||
{% set right_actions %}<a href="/auth/login?provider=local" class="topbar-btn" title="Login">{{ fd_icon("key",16) }}</a><a href="/accounts/settings" class="topbar-btn" title="Settings">{{ fd_icon("settings",16) }}</a>{% endset %}
|
||||
{% include '_header.html' %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -16,6 +16,12 @@ from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
# dans ce cas, donc rien n'est bloqué).
|
||||
CSP_NONCE: ContextVar[str] = ContextVar("csp_nonce", default="")
|
||||
|
||||
# A43 : jeton CSRF rendu côté serveur dans `hx-headers` (base.html) — posé
|
||||
# par le middleware CSRF AVANT call_next, lu via `{{ csrf_token() }}`
|
||||
# (vide = cookie absent sur cette requête, htmx:configRequest re-lit le
|
||||
# cookie au moment de l'appel → jamais de « __CSRF_PLACEHOLDER__ » servi).
|
||||
CSRF_TOKEN: ContextVar[str] = ContextVar("csrf_token", default="")
|
||||
|
||||
ENV = Environment(
|
||||
loader=FileSystemLoader("app/templates"),
|
||||
autoescape=select_autoescape(["html"]),
|
||||
@@ -33,3 +39,4 @@ except OSError: # pragma: no cover
|
||||
|
||||
ENV.globals["asset_version"] = ASSET_VERSION
|
||||
ENV.globals["csp_nonce"] = lambda: CSP_NONCE.get()
|
||||
ENV.globals["csrf_token"] = lambda: CSRF_TOKEN.get()
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "FlowDeck",
|
||||
"version": "7.28.0"
|
||||
"version": "7.43.0"
|
||||
},
|
||||
"paths": {
|
||||
"/auth/register": {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// CSP build : x-data="foo()" — globale window vs Alpine.data, lequel résout ?
|
||||
const { chromium } = require('playwright-core');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
function findChromium() {
|
||||
const root = path.join(process.env.LOCALAPPDATA, 'ms-playwright');
|
||||
const dirs = fs.readdirSync(root).filter((d) => d.startsWith('chromium-') && !d.includes('headless'));
|
||||
dirs.sort();
|
||||
return path.join(root, dirs[dirs.length - 1], 'chrome-win64', 'chrome.exe');
|
||||
}
|
||||
const ALPINE = fs.readFileSync(
|
||||
'C:/Users/bruno/AppData/Local/hermes/cache/scratch/alpine_csp.js',
|
||||
'utf-8'
|
||||
);
|
||||
const html = `<!DOCTYPE html><html><body>
|
||||
<div id="a" x-data="composantGlobal()" x-init="init()"><span id="s1" x-text="v"></span></div>
|
||||
<div id="b" x-data="composantAlpineData()" x-init="init()"><span id="s2" x-text="v"></span></div>
|
||||
<script>${ALPINE.replace(/<\/script>/g, '<\\/script>')}</script>
|
||||
<script>
|
||||
function composantGlobal() { return { v: '?', init() { this.v = 'glok'; } }; }
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('composantAlpineData', () => ({ v: '?', init() { this.v = 'adok'; } }));
|
||||
});
|
||||
</script></body></html>`;
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true, executablePath: findChromium() });
|
||||
const page = await browser.newPage();
|
||||
const errs = [];
|
||||
page.on('pageerror', (e) => errs.push(e.message.slice(0, 120)));
|
||||
await page.setContent(html, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(500);
|
||||
const res = await page.evaluate(() => ({
|
||||
global: document.querySelector('#s1').textContent,
|
||||
alpineData: document.querySelector('#s2').textContent,
|
||||
}));
|
||||
console.log('RESULTATS:', JSON.stringify(res), 'ERREURS:', errs.length ? errs : 'aucune');
|
||||
await browser.close();
|
||||
})();
|
||||
@@ -0,0 +1,202 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
/**
|
||||
* Aperçu CSP strict (A20 phase 3) : charge la page avec le build CSP
|
||||
* d'Alpine (fichier officiel `@alpinejs/csp`, 0 eval) servi à la place de
|
||||
* alpine.min.js via interception — SANS déployer. Toute expression que le
|
||||
* parseur maison ne digère pas = pageerror « CSP Parser Error » (filet) ;
|
||||
* les x-html restants = directive interdite du build (console error).
|
||||
* Quand toutes les surfaces passent ici → bascule réelle + retrait
|
||||
* d'unsafe-eval (ROADMAP A20 phase 3).
|
||||
*/
|
||||
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
|
||||
const USER = process.env.FD_USER || '[email protected]';
|
||||
const PASS = process.env.FD_PASS || 'e2e-secret-123';
|
||||
|
||||
test.use({ serviceWorkers: 'block' });
|
||||
|
||||
const errors = [];
|
||||
let currentUrl = '';
|
||||
test.beforeEach(async ({ page }) => {
|
||||
errors.length = 0;
|
||||
currentUrl = '';
|
||||
await page.route('**/static/js/alpine.min.js', (route) =>
|
||||
route.fulfill({
|
||||
path: require('path').join(__dirname, 'fixtures', 'alpine.csp.js'),
|
||||
contentType: 'application/javascript',
|
||||
})
|
||||
);
|
||||
page.on('console', (m) => {
|
||||
if (m.type() !== 'error') return;
|
||||
if (/Failed to load resource/.test(m.text())) return;
|
||||
errors.push(m.text());
|
||||
});
|
||||
page.on('pageerror', (e) =>
|
||||
errors.push('pageerror@' + (currentUrl || '?') + ': ' + e.message)
|
||||
);
|
||||
});
|
||||
test.afterEach(() => expect(errors).toEqual([]));
|
||||
|
||||
async function login(page) {
|
||||
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
|
||||
await page.fill('#email', USER);
|
||||
await page.fill('#password', PASS);
|
||||
await page.click('.btn-primary');
|
||||
const ok = await page
|
||||
.waitForURL('**/workspaces', { timeout: 10000 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (!ok) {
|
||||
const resp = await page.request.post(`${FD_BASE}/auth/register`, {
|
||||
data: { email: USER, password: PASS, name: 'E2E' },
|
||||
});
|
||||
if (resp.status() === 409) throw new Error('compte e2e existant — FD_USER/FD_PASS incorrects');
|
||||
if (!resp.ok()) throw new Error(`register ${resp.status()}: ${await resp.text()}`);
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForURL('**/workspaces', { timeout: 15000 });
|
||||
}
|
||||
}
|
||||
|
||||
async function assertBound(page) {
|
||||
return page.evaluate(() => {
|
||||
const el = document.querySelector('[x-data]');
|
||||
if (!el || !window.Alpine) return 'absent';
|
||||
try {
|
||||
const d = window.Alpine.$data(el);
|
||||
return d && typeof d === 'object' ? 'ok' : 'vide';
|
||||
} catch (e) {
|
||||
return 'throw:' + e.message;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('A20-ph3 : surfaces simples sous build CSP (welcome/trash/accounts/workspace)', async ({ page }) => {
|
||||
// scan statique = 0 expression/x-html sur ces gabarits → ici on traque
|
||||
// les échecs RUNTIME (globales, timing de registre, scope de structure)
|
||||
// /welcome est anonyme (avant login aussi) mais login() ne gêne pas
|
||||
await login(page);
|
||||
for (const url of ['/welcome', '/trash', '/accounts', '/workspace', '/import',
|
||||
'/gitea-workspace']) {
|
||||
currentUrl = url;
|
||||
await page.goto(FD_BASE + url, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(500);
|
||||
expect(await assertBound(page), `x-data non lié sur ${url}`).toBe('ok');
|
||||
}
|
||||
|
||||
// panneau agent (composant de base, x-html markdown migré via bindMarkdown)
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(400);
|
||||
const agent = await page.evaluate(() => {
|
||||
const el = document.querySelector('#fd-agent-panel');
|
||||
if (!el || !window.Alpine) return 'absent';
|
||||
try {
|
||||
const d = window.Alpine.$data(el);
|
||||
return d && typeof d === 'object' ? 'ok' : 'vide';
|
||||
} catch (e) {
|
||||
return 'throw:' + e.message;
|
||||
}
|
||||
});
|
||||
expect(agent).toBe('ok');
|
||||
});
|
||||
|
||||
test('A20-ph3 : éditeur de page (right_actions) sous build CSP', async ({ page }) => {
|
||||
// le topbar vit dans le scope appState : les12 sites window.E ont été
|
||||
// remplacés par edCall/edTimeAgo/edCommentCount/edShared/bindStar —
|
||||
// toute expression non parsable = pageerror (filet).
|
||||
await login(page);
|
||||
const coll = await page.evaluate(async () => {
|
||||
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
const r = await fetch('/db/api', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
|
||||
body: JSON.stringify({ name: 'e2e-csp-editor' }),
|
||||
});
|
||||
return r.json();
|
||||
});
|
||||
expect(coll.id, JSON.stringify(coll)).toBeTruthy();
|
||||
try {
|
||||
await page.goto(`${FD_BASE}/pages/${coll.id}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(600);
|
||||
// le composant éditeur est lié
|
||||
const editor = await page.evaluate(() => {
|
||||
const el = document.querySelector('#page-editor, .page-editor, [x-data]');
|
||||
const root = document.querySelector('.app-layout');
|
||||
const d1 = root && window.Alpine ? window.Alpine.$data(root) : null;
|
||||
const hasEd = d1 && typeof d1.edCall === 'function';
|
||||
let ed = 'absent';
|
||||
try {
|
||||
const cand = Array.from(document.querySelectorAll('[x-data]'))
|
||||
.map((e) => e.getAttribute('x-data'))
|
||||
.filter((a) => a && a.startsWith('editorState'));
|
||||
ed = cand.length ? 'ok' : 'aucun-editorState';
|
||||
} catch (e) { ed = 'throw'; }
|
||||
return { ed: ed, delegates: hasEd ? 'ok' : 'absent', el: !!el };
|
||||
});
|
||||
expect(editor.delegates).toBe('ok');
|
||||
expect(editor.ed).toBe('ok');
|
||||
// les boutons du topbar sont réellement servis (bug A10 « ~ + Markup »
|
||||
// corrigé : block-set) ET leurs expressions passent le filet CSP
|
||||
await expect(page.locator('.star-btn').first()).toBeAttached();
|
||||
} finally {
|
||||
await page.evaluate(async (id) => {
|
||||
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
await fetch(`/db/api/${id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf } });
|
||||
}, coll.id);
|
||||
}
|
||||
});
|
||||
|
||||
test('A20-ph3 : settings sous build Alpine CSP', async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto(`${FD_BASE}/settings`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(600);
|
||||
expect(await assertBound(page)).toBe('ok');
|
||||
await expect(page.locator('.settings-overlay')).toBeVisible();
|
||||
});
|
||||
|
||||
test('A20-ph3 : local workspace sous build Alpine CSP', async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto(`${FD_BASE}/local-workspace`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(600);
|
||||
expect(await assertBound(page)).toBe('ok');
|
||||
|
||||
// recherche : toggleSearch() (méthode réelle) + focus Alpine.nextTick
|
||||
await page.click('button.ws-icon-btn[title="Search"]');
|
||||
await page.waitForTimeout(400);
|
||||
const focused = await page.evaluate(
|
||||
() => document.activeElement && document.activeElement.getAttribute('x-ref') === 'searchInput'
|
||||
);
|
||||
expect(focused).toBe(true);
|
||||
|
||||
// chips filtre : bindSvg() via x-init (x-html interdit en CSP) —
|
||||
// x-init tourne même si la rangée est masquée (x-show=filterType)
|
||||
const svg = await page.evaluate(
|
||||
() => (document.querySelector('button.filter-chip[title="Folders"]') || {}).innerHTML || ''
|
||||
);
|
||||
expect(svg).toContain('<svg');
|
||||
});
|
||||
|
||||
test('A20-ph3 : library sous build Alpine CSP', async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto(`${FD_BASE}/library`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(600);
|
||||
|
||||
// le composant est lié par le registre Alpine.data (scope CSP)
|
||||
expect(await assertBound(page)).toBe('ok');
|
||||
|
||||
// icône du empty-state : x-html remplacé par x-init + Alpine.effect
|
||||
await expect(page.locator('#lib-empty .empty-icon')).toBeVisible({ timeout: 8000 });
|
||||
const svg = await page.evaluate(
|
||||
() => document.querySelector('#lib-empty .empty-icon').innerHTML
|
||||
);
|
||||
expect(svg).toContain('<svg');
|
||||
|
||||
// bouton recherche : @click.stop → toggleSearch() (méthode réelle,
|
||||
// Alpine.nextTick pour le focus) — l'expression inline arrow n'existe plus
|
||||
await page.click('.lib-icon-btn[title="Search"]');
|
||||
await expect(page.locator('#lib-search-input')).toBeVisible();
|
||||
await page.waitForTimeout(300);
|
||||
const focused = await page.evaluate(
|
||||
() => document.activeElement && document.activeElement.id === 'lib-search-input'
|
||||
);
|
||||
expect(focused).toBe(true);
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,167 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
/**
|
||||
* Smoke E2E — fondations vérifiant les portes des reports d'audit :
|
||||
* - A39 : bascule de vues (création d'une vue Board depuis la barre de
|
||||
* vues d'une collection → rendu de la grille)
|
||||
* - A20 : Alpine + palette de commandes (Ctrl+K, recherche GET, fermeture)
|
||||
* - filet : 0 erreur console (les violations CSP atterrissent ici)
|
||||
*
|
||||
* READ-ONLY sur les données existantes : crée puis SUPPRIME sa collection
|
||||
* (répétable). Instance de test attendue sur FD_BASE_URL (défaut 8080).
|
||||
*/
|
||||
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
|
||||
const USER = process.env.FD_USER || '[email protected]';
|
||||
const PASS = process.env.FD_PASS || 'e2e-secret-123';
|
||||
|
||||
// Service Workers BLOQUÉS : /sw.js sert sa page « hors ligne » quand la
|
||||
// réponse de navigation est une redirection (redirect:'manual' sur les
|
||||
// requêtes navigate) — bruit PWA hors sujet ici (pwa_offline.spec.js
|
||||
// couvre le SW). On interroge le serveur directement.
|
||||
test.use({ serviceWorkers: 'block' });
|
||||
|
||||
const consoleErrors = [];
|
||||
test.beforeEach(async ({ page }) => {
|
||||
consoleErrors.length = 0;
|
||||
page.on('console', (m) => {
|
||||
if (m.type() !== 'error') return;
|
||||
// les 401 de ressources (checks de session sur login) sont du bruit
|
||||
// navigateur, pas une erreur JS/CSP — le reste compte
|
||||
if (/Failed to load resource/.test(m.text())) return;
|
||||
consoleErrors.push(m.text());
|
||||
});
|
||||
page.on('pageerror', (e) => consoleErrors.push('pageerror: ' + e.message));
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
// Aucune erreur JS/CSP pendant le scénario
|
||||
expect(consoleErrors).toEqual([]);
|
||||
});
|
||||
|
||||
async function login(page) {
|
||||
await page.goto(`${FD_BASE}/auth/login?provider=local`, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
await page.fill('#email', USER);
|
||||
await page.fill('#password', PASS);
|
||||
await page.click('.btn-primary');
|
||||
const ok = await Promise.race([
|
||||
page
|
||||
.waitForURL('**/workspaces', { timeout: 8000 })
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
]);
|
||||
if (!ok) {
|
||||
// Compte absent de l'instance de test → création (bootstrap du harness,
|
||||
// pas un mot de passe deviné : c'est le compte e2e documenté du repo).
|
||||
const resp = await page.request.post(`${FD_BASE}/auth/register`, {
|
||||
data: { email: USER, password: PASS, name: 'E2E' },
|
||||
});
|
||||
if (resp.status() === 409) {
|
||||
throw new Error(
|
||||
'compte e2e existant mais mot de passe refusé — définir FD_USER/FD_PASS'
|
||||
);
|
||||
}
|
||||
if (!resp.ok()) {
|
||||
throw new Error(`register ${resp.status()}: ${await resp.text()}`);
|
||||
}
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForURL('**/workspaces', { timeout: 15000 });
|
||||
}
|
||||
// workspace requis pour créer une collection (compte neuf = aucun ws)
|
||||
const ws = await page.evaluate(async () => (await fetch('/api/workspaces')).json());
|
||||
if (!ws.workspaces || ws.workspaces.length === 0) {
|
||||
await page.evaluate(async () => {
|
||||
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
const r = await fetch('/api/workspaces', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
|
||||
body: JSON.stringify({ name: 'E2E workspace' }),
|
||||
});
|
||||
const w = await r.json();
|
||||
await fetch(`/api/workspaces/${w.id}/select`, { method: 'POST' });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('gate A39 : bascule de vues (table → Calendar, rendu par onglet)', async ({ page }) => {
|
||||
await login(page);
|
||||
|
||||
// collection jetable (créée puis supprimée = répétable). /db/{id} est une
|
||||
// page STANDALONE (hors base.html) : les onglets .view-tabs naviguent en
|
||||
// ?view=… et le corps est rendu côté serveur par _render_view().
|
||||
const coll = await page.evaluate(async () => {
|
||||
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
const r = await fetch('/db/api', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
|
||||
body: JSON.stringify({ name: 'e2e-view-switch' }),
|
||||
});
|
||||
return r.json();
|
||||
});
|
||||
expect(coll.id, `création collection: ${JSON.stringify(coll)}`).toBeTruthy();
|
||||
try {
|
||||
await page.goto(`${FD_BASE}/db/${coll.id}`, { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.locator('.view-tabs a.tab')).toHaveCount(11);
|
||||
await expect(page.locator('.calendar')).toHaveCount(0); // vue table par défaut
|
||||
|
||||
// bascule réelle : clic sur l'onglet Calendar → navigation ?view=calendar
|
||||
await page.click('.view-tabs a.tab:has-text("Calendar")');
|
||||
await page.waitForURL(/view_type=calendar/, { timeout: 10000 });
|
||||
await expect(page.locator('.view-tabs a.tab.active')).toContainText('Calendar');
|
||||
// corps Calendar rendu par _render_calendar (grille 6×7)
|
||||
await expect(page.locator('.calendar')).toHaveCount(1);
|
||||
expect(await page.locator('.cal-header').count()).toBeGreaterThanOrEqual(7);
|
||||
} finally {
|
||||
await page.evaluate(async (id) => {
|
||||
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
await fetch(`/db/api/${id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf } });
|
||||
}, coll.id);
|
||||
}
|
||||
});
|
||||
|
||||
test('gate A20 : palette Ctrl+K (Alpine + recherche GET)', async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
await page.keyboard.press('Control+k');
|
||||
await page.waitForTimeout(400);
|
||||
// overlay ouvert (classe .open pilotée par l'IIFE de base.html)
|
||||
const open = await page.evaluate(() => {
|
||||
const ov = document.querySelector('#fd-command-palette');
|
||||
return !!ov && ov.classList.contains('open');
|
||||
});
|
||||
expect(open).toBe(true);
|
||||
|
||||
// tape une requête → la recherche GET répond et rend des résultats
|
||||
await page.keyboard.type('a');
|
||||
await page.waitForTimeout(900);
|
||||
const items = await page.evaluate(
|
||||
() => document.querySelectorAll('.cmd-palette-item').length
|
||||
);
|
||||
expect(items).toBeGreaterThan(0);
|
||||
|
||||
// Alpine doit être lié (build CSP : le x-data + ses expressions évalués
|
||||
// SANS eval) sur un composant réel de la page
|
||||
const alpine = await page.evaluate(() => {
|
||||
const el = document.querySelector('[x-data]');
|
||||
if (!el || !window.Alpine) return 'absent';
|
||||
try {
|
||||
const data = window.Alpine.$data(el);
|
||||
return data && typeof data === 'object' ? 'ok:' + Object.keys(data).slice(0, 3).join(',') : 'vide';
|
||||
} catch (e) {
|
||||
return 'throw:' + e.message;
|
||||
}
|
||||
});
|
||||
expect(alpine).toMatch(/^ok:/);
|
||||
|
||||
// Échap ferme la palette
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(300);
|
||||
const closed = await page.evaluate(() => {
|
||||
const ov = document.querySelector('#fd-command-palette');
|
||||
return !ov || !ov.classList.contains('open');
|
||||
});
|
||||
expect(closed).toBe(true);
|
||||
});
|
||||
@@ -40,6 +40,8 @@ const browserGlobals = {
|
||||
Alpine: "readonly", htmx: "readonly", Sortable: "readonly",
|
||||
// Globals exposed on window by app.js
|
||||
openModal: "readonly", closeModal: "readonly",
|
||||
// getCsrf : helper unique posé dans le <head> de base.html (A38 phase 1)
|
||||
getCsrf: "readonly",
|
||||
// getSvgIcon : script inline de base.html (nonce) ; Prism : CDN des vues ;
|
||||
// TextDecoder : API navigateur (ES2015)
|
||||
getSvgIcon: "readonly", Prism: "readonly", TextDecoder: "readonly",
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ pythonpath = ["."]
|
||||
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py312"
|
||||
target-version = "py313"
|
||||
line-length = 110
|
||||
exclude = [".venv", ".venv311", ".pytest_cache", "static/js/vendor"]
|
||||
|
||||
|
||||
+83
-1
@@ -3,7 +3,89 @@
|
||||
Référence: Notion Light Mode (par défaut)
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||
/* Inter auto-hébergé — remplace l'@import Google Fonts que la CSP bloque
|
||||
(style-src sans fonts.googleapis depuis v7.27). Police variable v20 :
|
||||
une face par sous-ensemble, font-weight 100-900. */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/inter-latin-ext.woff2') format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/inter-latin.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/inter-400-latin.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/inter-500-latin-ext.woff2') format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/inter-500-latin.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/inter-600-latin-ext.woff2') format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/inter-600-latin.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/inter-700-latin-ext.woff2') format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/inter-700-latin.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
|
||||
/* ===== LIGHT THEME (default) ===== */
|
||||
:root {
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -188,7 +188,7 @@
|
||||
var fd = new FormData();
|
||||
fd.append('name', this.customName || 'emoji');
|
||||
fd.append('file', this.customFile);
|
||||
var csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
var csrf = getCsrf();
|
||||
var r = await fetch('/api/custom-emojis', { method: 'POST', headers: { 'X-CSRF-Token': csrf }, body: fd, credentials: 'same-origin' });
|
||||
var d = await r.json();
|
||||
if (d && d.emoji) {
|
||||
@@ -203,7 +203,7 @@
|
||||
},
|
||||
async deleteCustom(id) {
|
||||
try {
|
||||
var csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
var csrf = getCsrf();
|
||||
await fetch('/api/custom-emojis/' + id, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf }, credentials: 'same-origin' });
|
||||
this.custom = this.custom.filter(function (e) { return e.id !== id; });
|
||||
} catch { /* volontaire */ }
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
var payload = {prompt: message};
|
||||
if(context && context.trim()) payload.context = String(context).slice(0, 20000);
|
||||
return fetch('/api/agent/generate', {
|
||||
method:'POST', headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
method:'POST', headers:{'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
|
||||
body: JSON.stringify(payload)
|
||||
}).then(function(resp){
|
||||
return resp.json();
|
||||
@@ -243,7 +243,7 @@
|
||||
if(self.llmModel) payload.model = self.llmModel;
|
||||
|
||||
fetch('/api/agent/generate', {
|
||||
method:'POST', headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'}, body: JSON.stringify(payload)
|
||||
method:'POST', headers:{'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'}, body: JSON.stringify(payload)
|
||||
}).then(function(r){ return r.json(); }).then(function(d){
|
||||
self.messages = self.messages.filter(function(m){ return m.id !== thinkId; });
|
||||
if(d && d.ok && d.text){
|
||||
@@ -265,6 +265,10 @@
|
||||
});
|
||||
}).catch(function(){});
|
||||
},
|
||||
// ── A20 ph3 : x-html (markdown) → x-init + effet réactif ──
|
||||
bindMarkdown(el, m){
|
||||
Alpine.effect(() => { el.innerHTML = this.renderMarkdown(m.content); });
|
||||
},
|
||||
approveProposal(m){
|
||||
if(!m || m.applied || m.offline) return;
|
||||
var ok = window.fdApplyDocument ? window.fdApplyDocument(m.content, m.mode) : false;
|
||||
@@ -366,7 +370,7 @@
|
||||
installGallerySkill(slug, icon, name){
|
||||
var self = this;
|
||||
fetch('/api/agent/skills/gallery/' + encodeURIComponent(slug) + '/install', {
|
||||
method: 'POST', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json'}, body: '{}'
|
||||
method: 'POST', headers: {'X-CSRF-Token': getCsrf(), 'Content-Type': 'application/json'}, body: '{}'
|
||||
}).then(function(r){
|
||||
return r.json().then(function(d){ return {ok: r.ok, d: d}; });
|
||||
}).then(function(res){
|
||||
@@ -530,7 +534,7 @@
|
||||
var body = {title:'Nouvelle conversation'};
|
||||
if(self.llmProvider) body.provider = self.llmProvider;
|
||||
if(self.llmModel) body.model = self.llmModel;
|
||||
fetch('/api/agent/conversations', {method:'POST', headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'}, body: JSON.stringify(body)})
|
||||
fetch('/api/agent/conversations', {method:'POST', headers:{'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'}, body: JSON.stringify(body)})
|
||||
.then(function(r){return r.json()}).then(function(d){
|
||||
var conv = {id:d.id, title:d.title, updated_at:new Date().toISOString()};
|
||||
self.conversations.unshift(conv); self.currentConv = conv;
|
||||
@@ -547,7 +551,7 @@
|
||||
if(self.llmProvider) body.provider = self.llmProvider;
|
||||
if(self.llmModel) body.model = self.llmModel;
|
||||
self._ensuring = fetch('/api/agent/conversations', {
|
||||
method:'POST', headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'}, body: JSON.stringify(body)
|
||||
method:'POST', headers:{'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'}, body: JSON.stringify(body)
|
||||
}).then(function(r){return r.json()}).then(function(d){
|
||||
var conv = {id:d.id, title:d.title, updated_at:new Date().toISOString()};
|
||||
self.conversations.unshift(conv); self.currentConv = conv;
|
||||
@@ -569,7 +573,7 @@
|
||||
if(self.llmProvider) body.provider = self.llmProvider;
|
||||
if(self.llmModel) body.model = self.llmModel;
|
||||
fetch('/api/agent/conversations/'+self.currentConv.id, {
|
||||
method:'PATCH', headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'}, body: JSON.stringify(body)
|
||||
method:'PATCH', headers:{'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'}, body: JSON.stringify(body)
|
||||
}).catch(function(){});
|
||||
},
|
||||
|
||||
@@ -1370,7 +1374,7 @@
|
||||
if(m.id && /^\d+$/.test(String(m.id))) payload.message_id = Number(m.id);
|
||||
fetch('/api/agent/feedback', {
|
||||
method: 'POST',
|
||||
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json'},
|
||||
headers: {'X-CSRF-Token': getCsrf(), 'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(payload)
|
||||
}).then(function(r){ return r.json(); }).then(function(d){
|
||||
if(d && d.status === 'recorded'){ m.fb = rating; }
|
||||
@@ -1468,7 +1472,7 @@
|
||||
|
||||
return fetch('/api/agent/conversations/'+self.currentConv.id+'/run', {
|
||||
method:'POST',
|
||||
headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
headers:{'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
|
||||
body: JSON.stringify(payload)
|
||||
}).then(function(resp){
|
||||
if(!resp.ok){ return resp.json().then(function(j){ throw new Error(j.detail || ('HTTP '+resp.status)); }); }
|
||||
|
||||
Vendored
+23
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user