Compare commits

...
8 Commits
Author SHA1 Message Date
bruno 8b48dbdd4b test: A32 phase 2e — dashboard +9 routes, comptes A2/A3 (v7.16.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
Cumul dashboard : 27 → 36 des 63 routes. 6 nouveaux tests (fichier à 38) :

- /accounts + /accounts/settings : 200 HTML et « password_hash » ABSENT du
  rendu (whitelist A2 vérifiée côté page)
- PUT /api/user/profile : persistance relue en base, restauration finally
- PUT /api/user/password : 403 « current password is incorrect » (A3 — la
  session seule ne change pas le mdp) + quirk assumé documenté : la longueur
  est validée AVANT l'auth et répond 200 + message
- POST /api/user/token : format fd_ + 64 hex ; ligne user_tokens nettoyée
- DELETE /api/user/forge/{provider} : {"status": "ok"}
- PUT /api/settings/account : full_name/email persistés + 400 sur mdp court,
  restauration finally
- POST /api/workspaces/1/select : Set-Cookie flowdeck_workspace vérifié ;
  GET /api/local-workspace/breadcrumb : shape liste

Reste A32 : dashboard 27 routes (fichiers/avatars/local-workspace/collections)
+ 6 routes Gitea d'api.py (stub transport httpx).

suite **1075/1075** · `ruff check app tests` OK · docs à jour
2026-10-01 13:59:00 -04:00
bruno 360c705fd4 test: A32 phase 2d — dashboard +10 routes couvertes (v7.15.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
Scan strict dashboard.py : 44 routes à 0 référence stricte sur 63. 10
couvertes cette passe (test_smoke_uncovered.py → 32 tests) :

- Tags CRUD complet : POST (nom lowercasé `SmokeTag` → `smoketag`), GET all
  (présent), PUT couleur (relue), DELETE (absente de la liste ensuite)
- Vie d'une page : GET /api/pages/{id}/content (contenu seedé relu) →
  PUT rename (ok + **400 titre vide** + titre relu en base) →
  POST trash (parent_section='Trash' + deleted_at RELUS en base) ;
  nettoyage en finally
- GET /api/sidebar/workspace-tree : 200 HTML, fragment « No pages yet »
  (pas de cookie workspace)
- POST /api/settings/avatar-color : couleur relue SUR L'UTILISATEUR DE LA
  SESSION (pas LIMIT 1), avatar_color/avatar_url d'origine restaurés
- GET /api/workspace/1/members : shape {"members": [...]}

Helper _seed_page : les colonnes par défaut sont surchargeables (content=)
pour les seeds à contenu.

Reste A32 : dashboard 34 routes à 0 ref (fichiers/avatars/imports…) +
6 routes Gitea d'api.py (stub transport httpx).

suite **1069/1069** · `ruff check app tests` OK · docs à jour
2026-10-01 13:36:56 -04:00
bruno 0698645dbd test: A32 phase 2c — api_v2 : les 5 routes à 0 ref couvertes (v7.14.0)
FlowDeck CI / lint (push) Successful in 1m52s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 34m59s
Scan strict des 115 routes api_v2.py contre tous les tests (chaîne de chemin
littérale) → 5 routes sans AUCUNE référence, toutes couvertes maintenant :

- POST /properties/evaluate-formula : 200 + shape, 400 sans expression.
  Le moteur renvoie « 1 + 2 » tel quel aujourd'hui → le smoke valide le câble
  (bearer, Body param, parse), pas le moteur (réalm de ses propres tests).
- POST /properties/compute-rollup : 400 « collection_id required »,
  401 sans bearer.
- GET /admin/audit-logs : portail admin VÉRIFIÉ — l'attendu est calculé
  depuis /users/me (le tout premier utilisateur d'un worker est admin :
  état non contrôlable depuis un test isolé), + token scope admin → 200 + logs.
- GET /webhooks/events : catalogue non vide + wildcards * / page.*.
- POST /webhooks/verify-signature : valid=True avec sign_payload() (le même
  helper que le serveur), False avec signature bidon.

test_smoke_uncovered.py : 27 tests. Reste A32 : dashboard 17/63 + 6 routes
gitea d'api.py (stub transport).

suite **1064/1064** · `ruff check app tests` OK · docs à jour
2026-10-01 13:09:32 -04:00
bruno b2e38aece7 test: A32 phase 2b — api.py 3 → 16/22 routes couvertes (v7.13.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
+6 smokes dans test_smoke_uncovered.py (22 tests au total dans le fichier) :
- board-config GET/POST : défauts 5 colonnes sans board, création puis
  relecture du roundtrip (seed via l'endpoint lui-même, pas de SQL brut)
- col-mapping POST/DELETE : 404 sans board, upsert label vérifié, suppression
- card POST : 404 sans board, ok avec
- collaborators GET : gitea.get_collaborators STUBBÉ (zéro accès réseau réel)
- frontend-error(s) : capture, JSON invalide → ignored, DÉDUP d'une erreur
  répétée (count=2), lecture qui purge (cleared=true puis 0)
- checklist mutations : PATCH item (checked/content relus EN BASE), DELETE
  item, DELETE checklist (COUNT=0) — seed + cleanup en finally

Reste api.py : 6 routes Gitea (issues ×4 + créations checklists owner/repo) →
stub de transport httpx (phase suivante). Reste global : dashboard 17/63,
api_v2 50/115.

suite **1059/1059** (236 s) · `ruff check app tests` OK · docs à jour
2026-10-01 12:35:22 -04:00
bruno df9a269d76 test: A32 phase 2a — library 10/10 + 2 routes fantômes supprimées (v7.12.0)
FlowDeck CI / lint (push) Successful in 1m53s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 8m41s
- tests/test_smoke_uncovered.py : +6 tests pour library.py (1/10 → 8 routes
  couvertes) : les 5 listes en boucle (recents/favorites/published/private/
  workspace → 200 + items), /private avec page seedée retrouvée, /children/{id}
  avec parent/enfant seedés, /repository vide ET clé (string, aucun réseau),
  non-régression 404 sur les routes supprimées.
- DÉCOUVERTE (les smokes l'ont prouvé) : `/api/library/local-workspace-children`
  renvoyait un 500 systématique (test vert → ASGI double response.start) et
  `/api/library/local-workspace` 500 dès qu'un workspace existe — les deux
  lisaient `local_workspace_items`, table AUCUNEMENT créée dans le codebase
  (0 CREATE TABLE) et sans 1 seule référence front. Supprimés avec
  `library._format_size` devenu mort (la copie de dashboard.py est inchangée).
  `local_workspace_items` : 0 occurrence restante dans app/.
- helper `_seed_page` minimal (workspace NOT NULL inclus) + nettoyage en
  finally (pas de pollution des autres tests).

suite **1053/1053** (229 s) · `ruff check app tests` OK · docs à jour
restent phase 2b : api.py 3/23, dashboard.py 17/63, api_v2.py 50/115
2026-10-01 12:11:12 -04:00
bruno da7326ffde test: A32 phase 1 — 4 routers à 0 test couverts (10 smokes) (v7.11.0)
FlowDeck CI / lint (push) Successful in 1m50s
FlowDeck CI / test (push) Successful in 13m55s
FlowDeck CI / docker (push) Canceled after 0s
tests/test_smoke_uncovered.py — un smoke par route des 4 routers qui n'avaient
AUCUN test :
- webhooks.py 3/3 : réception sans secret → {"status":"ok"} ; HMAC faux → 401
  (secret piloté par monkeypatch, déterministe quel que soit le .env) ;
  register sans secret → 400 AVANT tout appel réseau ; status avec
  gitea.list_webhooks stubbé → {"registered": False} (zéro réseau réel)
- notes.py 2/2 : GET HTML + roundtrip POST→GET (upsert persisté en base,
  échappement HTML vérifié : &lt;b&gt; et non <b>)
- sidebar_config.py 2/2 : GET défauts ; PUT persisté puis RELU depuis
  users.sidebar_config ; 400 sans config ; remise en état en fin de test
- github_routes.py 2/2 : status {"linked": False} ; disconnect {"status": "ok"}

Reste (A32 phase 2) : quasi nuls — library 1/10, api 3/23, dashboard 17/63,
api_v2 50/115 → même recette, fixture client existante.

suite **1047/1047** · `ruff check app tests` OK · docs à jour
2026-10-01 11:54:43 -04:00
bruno 3a1276596c fix: A21 phase 2b — run_event_sync + 15 routes api_v2 en def (v7.10.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
- `run_event_sync(coro)` (app/services/automations.py) : exécute une coroutine
  d'événement depuis un handler synchrone — `asyncio.run` sur une boucle
  dédiée dans le worker threadpool : le worker est bloqué, JAMAIS la boucle
  d'event, et la réponse n'est produite qu'une fois l'événement terminé
  (déterministe, équivalent sémantique de l'await). ponytail: les clients
  httpx sont créés à chaque appel partout → aucun lien de boucle ; sinon
  run_coroutine_threadsafe + boucle du lifespan.
- 15 routes api_v2 dont les SEULS awaits étaient `request.json`,
  `_fire_event`, `fire_published`, `fire_unpublished` →
  `Body(default={})` + `run_event_sync(...)` + conversion en `def` (script
  : wrapping par appariement de parenthèses chaîne-aware, assert de flip
  « plus aucun await »).
- api_v2 : **111/115 routes hors loop**. Les 4 restantes ont de vrais awaits
  réseau et restent async volontairement : import_csv_v2 (multipart),
  project_tree_v2 (gitea), test_webhook_v2 (delivery), retry_webhook_deliveries.
- Repo-wide : 403 routes sync (hors loop) / 260 async (phase 2c).

tests : ciblé public_api_v2 + v65 + webhooks_v2 + audit = 90/90 (les webhooks
prouvent la détermination de run_event_sync) ; suite complète **1037/1037**
(228 s) · `ruff check app tests` OK · docs à jour
2026-10-01 11:35:43 -04:00
bruno 07904f05e5 fix: A21 phase 2a — api_v2 : body JSON en paramètre, 36 routes hors loop (v7.9.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
- Les 36 routes d'api_v2 dont le SEUL `await` était
  `body = await request.json()` (bloc try/except uniforme) → paramètre FastAPI
  `body: dict = Body(default={})` + conversion en `def` → threadpool :
  toute leur séquence SQLite quitte l'event loop.
- Équivalences vérifiées avant engament (probe FastAPI) :
  · corps absent → `{}` (identique à l'ancien try/except)
  · JSON invalide → 422 (avant : avalé comme `{}` — 422 est plus juste)
  · zéro `body[...]=` / setdefault / update dans api_v2 → défaut partagé
    jamais muté
- verify_webhook_signature (signature multi-ligne) traitée à la main.
- Piège courant évité : première version du script supprimait 5 lignes au
  lieu de 4 (slice m-1:m+4) → fichier restauré depuis git, slice corrigée,
  0 ligne perdue (diff logique +39/-183).

api_v2 : 96/115 routes hors loop (60 phase 1 + 36 ici) ; 19 async restantes
(fire_event, request.form, gitea/webhooks) = phase 2b.

suite **1037/1037** (242 s) · `ruff check app tests` OK · docs à jour
2026-10-01 11:19:58 -04:00
10 changed files with 1392 additions and 500 deletions
+177
View File
@@ -1,5 +1,182 @@
# Changelog - FlowDeck
## v7.16.0 (2026-10-01) — Audit : A32 phase 2e (dashboard +9, comptes)
### Tests
- +9 routes `dashboard.py` (cumul **27→36 sur 63**) :
· `/accounts` + `/accounts/settings` : 200 HTML, **`password_hash` absent**
du rendu (whitelist A2 vérifiée côté page)
· `PUT /api/user/profile` : persistance **relue en base**, valeur d'origine
restaurée en `finally`
· `PUT /api/user/password` : **403 « current password is incorrect »** (A3 —
la session seule ne change pas le mdp) + quirk assumé et documenté :
longueur validée AVANT auth → 200 + message d'erreur
· `POST /api/user/token` : format `fd_` + 64 hex, ligne `user_tokens`
nettoyée en `finally`
· `DELETE /api/user/forge/{provider}` : `{"status": "ok"}`
· `PUT /api/settings/account` : full_name/email persistés + **400 sur mdp
court** (validateur), restauration en `finally`
· `POST /api/workspaces/{id}/select` : `Set-Cookie flowdeck_workspace`
vérifié ; `GET /api/local-workspace/breadcrumb` : shape liste
- `test_smoke_uncovered.py` : 38 tests ; suite complète **1075/1075**
## v7.15.0 (2026-10-01) — Audit : A32 phase 2d (dashboard +10 routes)
### Tests
- Scan strict `dashboard.py` : **44 routes à 0 référence** (sur 63) — 10
couvertes cette passe dans `test_smoke_uncovered.py` (32 tests au fichier) :
· **Tags CRUD** : création (`SmokeTag` → `smoketag` lowercasé), présence
dans la liste, changement de couleur relu, suppression puis absence
· **Vie d'une page** : GET `content` (contenu seedé relu) → PUT `rename`
(ok + **400 sur titre vide** + titre relu en base) → POST `trash`
(`parent_section='Trash'` + `deleted_at` **relus en base**)
· `sidebar/workspace-tree` : 200 HTML, fragment « No pages yet » sans cookie
· `settings/avatar-color` : couleur relue **sur l'utilisateur de la
session** (pas `LIMIT 1`), valeurs d'origine restaurées en `finally`
· `workspace/{id}/members` : shape `{"members": [...]}`
- Helper `_seed_page` : surcharge des colonnes par défaut (`content=`, …)
- Suite complète : **1069/1069**
## v7.14.0 (2026-10-01) — Audit : A32 phase 2c (api_v2 +5 routes)
### Tests
- Scan strict des 115 routes `api_v2.py` contre tous les tests (chaîne de
chemin littérale) → **5 routes à 0 référence**, toutes couvertes :
· `POST /properties/evaluate-formula` : 200 + shape, 400 sans `expression`
(le moteur renvoie `1 + 2` tel quel aujourd'hui — le smoke valide le câble
route/auth/parse, pas le moteur)
· `POST /properties/compute-rollup` : 400 `collection_id required`,
401 sans bearer
· `GET /admin/audit-logs` : portail admin vérifié — attendu **calculé depuis
`/users/me`** (le tout premier utilisateur d'un worker est admin, état non
contrôlable depuis le test), + token scope `admin` → 200 + `logs` liste
· `GET /webhooks/events` : catalogue non vide + wildcards `*`/`page.*`
· `POST /webhooks/verify-signature` : **valid=True** avec
`sign_payload(secret, payload)` (même helper que le serveur), False avec
une signature bidon
- `test_smoke_uncovered.py` : 27 tests au total
- Suite complète : **1064/1064**
## v7.13.0 (2026-10-01) — Audit : A32 phase 2b (api.py 16/22)
### Tests
- `api.py` passe de **3 à 16 routes couvertes** (22 `@router` au total) :
· `board-config` GET/POST : défauts à 5 colonnes sans board, création puis
relecture du roundtrip
· `col-mapping` POST/DELETE : 404 sans board, upsert `label` vérifié,
suppression vérifiée
· `card` POST : 404 sans board, `{"status": "ok"}` avec
· `collaborators` GET : **`gitea.get_collaborators` stubbé** (zéro réseau réel)
· `frontend-error(s)` : capture, JSON invalide → `ignored`, **dédup** d'une
erreur répétée (`count=2`), lecture qui purge (`cleared=true` puis 0)
· checklist mutations : PATCH item (checked/content relus EN BASE),
DELETE item, DELETE checklist (réapparition `COUNT=0`) — seed + cleanup
- Reste `api.py` : 6 routes Gitea (issues ×4 + créations checklists) →
stub de transport httpx. Reste global : `dashboard.py` 17/63,
`api_v2.py` 50/115
### Tests
- Suite complète : **1059/1059** (236 s) ; `test_smoke_uncovered.py` : 22 tests
## v7.12.0 (2026-10-01) — Audit : A32 phase 2a (library 10/10)
### Tests
- `library.py` passe de **1/10 à 8 routes couvertes** : les 5 listes
(recents/favorites/published/private/workspace) en un test de boucle,
`/private` avec une page seedée et retrouvée, `/children/{id}` avec un
parent/enfant seedés (titre retrouvé), `/repository` vide et clé
(aucun appel réseau — la clé n'est qu'une string de workspace)
- Test de non-régression 404 sur les 2 routes supprimées
### Removed
- **2 routes cassées supprimées** (découverte des smokes) :
`/api/library/local-workspace-children/{id}` renvoyait un 500 systématique
et `/api/library/local-workspace` un 500 dès qu'un workspace existait —
les deux lisaient `local_workspace_items`, **une table qui n'est créée nulle
part** dans le codebase (grep : 0 `CREATE TABLE`), avec **0 référence front**.
`library._format_size` devenu mort : supprimé aussi (une version vit dans
`dashboard.py`, inchangée)
- `local_workspace_items` : plus aucune occurrence dans `app/`
### Tests
- Suite complète : **1053/1053** (229 s) ; `test_smoke_uncovered.py` : 16 tests
## v7.11.0 (2026-10-01) — Audit : A32 phase 1 (routers à 0 test)
### Tests
- `tests/test_smoke_uncovered.py` — **10 smoke tests**, un par route des 4
routers qui n'avaient AUCUN test :
· `webhooks.py` (3/3) : réception sans secret → `{"status":"ok"}` ;
HMAC faux → 401 (secret piloté par monkeypatch) ; register sans secret →
400 **avant** tout appel réseau ; status avec `gitea.list_webhooks` stubbé
(zéro accès réseau réel)
· `notes.py` (2/2) : GET HTML + roundtrip POST→GET (upsert persisté,
échappement HTML vérifié `&lt;b&gt;`)
· `sidebar_config.py` (2/2) : GET défauts, PUT persisté relu depuis
`users.sidebar_config`, 400 sans `config`, remise en état en fin de test
· `github_routes.py` (2/2) : status `{"linked": False}`, disconnect ok
- Reste (phase 2) : quasi nuls — `library.py` 1/10, `api.py` 3/23,
`dashboard.py` 17/63, `api_v2.py` 50/115
- Suite complète : **1047/1047**
## v7.10.0 (2026-10-01) — Audit : A21 phase 2b (api_v2 bouclé)
### Changed
- **Helper** `run_event_sync(coro)` (`app/services/automations.py`) : exécute
une coroutine d'événement depuis un handler synchrone — `asyncio.run` sur une
boucle dédiée dans le **worker threadpool** : bloqué = le worker, jamais la
boucle d'event, et la réponse n'est envoyée qu'une fois l'événement terminé
(déterministe, équivalent sémantique de l'`await` d'avant). Note
`ponytail:` : clients httpx créés à chaque appel partout → aucun lien de
boucle ; sinon `run_coroutine_threadsafe` + boucle du lifespan
- **A21 (phase 2b)** — 15 routes `api_v2` dont les seuls awaits étaient
`request.json` / `_fire_event` / `fire_published` / `fire_unpublished` →
paramètre `Body(default={})` + `run_event_sync(...)` + conversion en `def`
- **`api_v2` : 111/115 routes hors event loop** — il ne reste que 4 routes
async, toutes avec de vrais awaits réseau : `import_csv_v2` (multipart),
`project_tree_v2` (gitea), `test_webhook_v2`, `retry_webhook_deliveries`
- Repo-wide : **403 routes synchrones (hors loop) / 260 async** (phase 2c)
### Tests
- Ciblé (public_api_v2 + v65 + webhooks_v2 + audit) : 90/90 — les webhooks
prouvent la détermination de `run_event_sync` ; suite complète **1037/1037**
en 228 s
## v7.9.0 (2026-10-01) — Audit : A21 phase 2a (api_v2 hors loop)
### Changed
- **A21 (phase 2a)** — dans `api_v2`, les 36 routes dont le **seul** `await`
était `body = await request.json()` passent à un paramètre FastAPI
`body: dict = Body(default={})` (parsing async fait par FastAPI avant
l'appel) puis sont converties en `def` → threadpool. Équivalences vérifiées
avant-engagement : corps absent → `{}` (identique au `try/except` d'avant),
JSON invalide → **422** (avant : traité silencieusement comme `{}`),
zéro `body[...] = ` dans le fichier (le défaut partagé n'est jamais muté)
- `api_v2` : **96/115 routes hors event loop** (60 en phase 1 + 36 ici) ;
il ne reste que **19 routes async** dans ce router (`fire_event`,
`request.form`, appels gitea/webhooks — phase 2b)
- Bug de transformation évité en cours de route : première version du script
supprimait 5 lignes au lieu de 4 (`slice` fermant d'un cran trop loin) —
fichier restauré depuis git puis script corrigé, 0 ligne perdue (diff
logique : +39/-183 = 36 signatures + import, 4 lignes de try/except × 35)
### Tests
- Suite complète **1037/1037** (242 s) ; ciblée sur `test_public_api_v2` +
`test_v65` + audit : 62/62 verts avant la passe complète
## v7.8.0 (2026-10-01) — Audit : A21 phase 1 (SQLite hors event loop)
### Changed
+3 -3
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
7.8.0
7.16.0
+1 -1
View File
@@ -1,6 +1,6 @@
# WORKLOAD — FlowDeck Notion Clone
> **Début**: 2026-07-08 | **Version**: v7.8.0 (audit — A21 phase 1 : 352 routes hors loop) | **Statut**: EN COURS 🔄
> **Début**: 2026-07-08 | **Version**: v7.16.0 (audit — A32 phase 2e : dashboard +9 routes, comptes A2/A3) | **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
View File
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="7.8.0",
version="7.16.0",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
+70 -257
View File
@@ -12,7 +12,7 @@ import logging
import secrets
from datetime import datetime
from fastapi import APIRouter, Header, HTTPException, Request
from fastapi import APIRouter, Body, Header, HTTPException, Request
from fastapi.responses import JSONResponse, Response
from app.db import get_conn
@@ -31,6 +31,7 @@ from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est ut
validate_scopes_input,
)
from app.services.automations import fire_event as _fire_event
from app.services.automations import run_event_sync
from app.services.publish import fire_published, fire_unpublished, publish, unpublish
logger = logging.getLogger(__name__)
@@ -48,13 +49,9 @@ def _v2_rate_check(request: Request, user: dict) -> None:
# ── Tokens ────────────────────────────────────────────────────────────────
@router.post("/tokens")
async def create_token(request: Request, authorization: str | None = Header(default=None)):
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)
try:
body = await request.json()
except Exception:
body = {}
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")
@@ -166,13 +163,9 @@ def get_me(request: Request, authorization: str | None = Header(default=None)):
return d
@router.patch("/users/me")
async def patch_me(request: Request, authorization: str | None = Header(default=None)):
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)
try:
body = await request.json()
except Exception:
body = {}
allowed = {"full_name", "email", "avatar_color", "notification_prefs", "sidebar_config", "timezone"}
updates = {}
for k in allowed:
@@ -232,16 +225,12 @@ def list_workspaces(request: Request, authorization: str | None = Header(default
return {"workspaces": out, "total": total, "limit": limit, "offset": offset}
@router.post("/workspaces")
async def create_workspace(request: Request, authorization: str | None = Header(default=None)):
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"])
try:
body = await request.json()
except Exception:
body = {}
name = (body.get("name") or "").strip()
if not name:
raise HTTPException(400, "name is required")
@@ -282,13 +271,9 @@ def get_workspace(workspace_id: int, request: Request, authorization: str | None
return d
@router.patch("/workspaces/{workspace_id}")
async def patch_workspace(workspace_id: int, request: Request, authorization: str | None = Header(default=None)):
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)
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
row = conn.execute("SELECT * FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
if not row:
@@ -336,13 +321,9 @@ def list_workspace_members(workspace_id: int, request: Request, authorization: s
return {"members": [row_to_dict(r) for r in rows]}
@router.post("/workspaces/{workspace_id}/members")
async def invite_member(workspace_id: int, request: Request, authorization: str | None = Header(default=None)):
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)
try:
body = await request.json()
except Exception:
body = {}
target_id = body.get("user_id") or body.get("uid")
email = (body.get("email") or "").strip()
role = (body.get("role") or "editor").strip().lower()
@@ -374,13 +355,9 @@ async def invite_member(workspace_id: int, request: Request, authorization: str
return {"workspace_id": workspace_id, "user_id": uid, "role": role, "status": "added"}
@router.patch("/workspaces/{workspace_id}/members/{uid}")
async def update_member_role(workspace_id: int, uid: int, request: Request, authorization: str | None = Header(default=None)):
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)
try:
body = await request.json()
except Exception:
body = {}
role = (body.get("role") or "").strip().lower()
if role not in ("owner", "admin", "editor", "viewer", "commenter"):
raise HTTPException(400, "Invalid role")
@@ -451,16 +428,12 @@ def list_collections_v2(request: Request, authorization: str | None = Header(def
return JSONResponse(content=resp, headers=paginate_headers(total))
@router.post("/collections")
async def create_collection_v2(request: Request, authorization: str | None = Header(default=None)):
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"])
try:
body = await request.json()
except Exception:
body = {}
name = (body.get("name") or "").strip()
if not name:
raise HTTPException(400, "name is required")
@@ -507,13 +480,9 @@ def get_collection_v2(collection_id: int, request: Request, authorization: str |
return d
@router.patch("/collections/{collection_id}")
async def patch_collection_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
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)
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
row = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
if not row:
@@ -545,13 +514,9 @@ def delete_collection_v2(collection_id: int, request: Request, authorization: st
return {"id": collection_id, "status": "deleted"}
@router.post("/collections/{collection_id}/linked")
async def create_linked_db(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
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)
try:
body = await request.json()
except Exception:
body = {}
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()
@@ -607,13 +572,9 @@ def list_sources(collection_id: int, request: Request, authorization: str | None
return {"sources": [dict(r) for r in rows]}
@router.post("/collections/{collection_id}/sources")
async def add_source(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
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)
try:
body = await request.json()
except Exception:
body = {}
src_id = body.get("source_collection_id") or body.get("source_id")
if not src_id:
raise HTTPException(400, "source_collection_id required")
@@ -701,16 +662,12 @@ def list_collection_pages_v2(collection_id: int, request: Request, authorization
return JSONResponse(content={"pages": out, "total": total, "limit": limit, "offset": offset}, headers=paginate_headers(total))
@router.post("/collections/{collection_id}/pages")
async def create_collection_page_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
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"])
try:
body = await request.json()
except Exception:
body = {}
title = (body.get("title") or body.get("name") or "Untitled").strip() or "Untitled"
icon = body.get("icon", "file")
parent_id = body.get("parent_id")
@@ -743,7 +700,7 @@ async def create_collection_page_v2(collection_id: int, request: Request, author
row = conn.execute("SELECT * FROM collection_pages WHERE id=?", (pid,)).fetchone()
audit_log(user, "page.create", "collection_page", pid, title, request)
try:
await _fire_event("collection.page.created", {"page_id": pid, "collection_id": collection_id, "title": title})
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)}
@@ -781,13 +738,9 @@ def get_page_v2(page_id: int, request: Request, authorization: str | None = Head
return d
@router.patch("/pages/{page_id}")
async def patch_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
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)
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
row = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone()
if not row:
@@ -822,13 +775,13 @@ async def patch_page_v2(page_id: int, request: Request, authorization: str | Non
conn.commit()
audit_log(user, "page.update", "collection_page", page_id, "", request)
try:
await _fire_event("collection.page.updated", {"page_id": page_id, "collection_id": row["collection_id"], "title": title})
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}")
async def delete_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
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:
@@ -839,13 +792,13 @@ async def delete_page_v2(page_id: int, request: Request, authorization: str | No
conn.commit()
audit_log(user, "page.delete", "collection_page", page_id, "", request)
try:
await _fire_event("collection.page.deleted", {"page_id": page_id, "collection_id": row["collection_id"]})
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")
async def restore_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
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
@@ -855,20 +808,16 @@ async def restore_page_v2(page_id: int, request: Request, authorization: str | N
conn.execute("UPDATE pages SET deleted_at=NULL WHERE id=?", (page_id,))
conn.commit()
try:
await _fire_event("page.restored", {"page_id": page_id})
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")
async def move_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
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)
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
row = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone()
if not row:
@@ -891,13 +840,9 @@ def list_sub_items_v2(page_id: int, request: Request, authorization: str | None
return {"sub_items": [row_to_dict(r) for r in rows]}
@router.post("/pages/{page_id}/sub-items")
async def create_sub_item_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
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)
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
parent = conn.execute("SELECT collection_id FROM collection_pages WHERE id=?", (page_id,)).fetchone()
if not parent:
@@ -920,13 +865,9 @@ def list_dependencies_v2(page_id: int, request: Request, authorization: str | No
return {"dependencies": [dict(r) for r in rows]}
@router.post("/pages/{page_id}/dependencies")
async def add_dependency_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
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)
try:
body = await request.json()
except Exception:
body = {}
dep_id = body.get("dependency_id") or body.get("depends_on")
dtype = body.get("dependency_type") or "blocks"
if not dep_id:
@@ -967,13 +908,9 @@ def list_properties_v2(collection_id: int, request: Request, authorization: str
return {"properties": [row_to_dict(r) for r in rows]}
@router.post("/collections/{collection_id}/properties")
async def create_property_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
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)
try:
body = await request.json()
except Exception:
body = {}
name = (body.get("name") or "").strip()
if not name:
raise HTTPException(400, "name is required")
@@ -992,13 +929,9 @@ async def create_property_v2(collection_id: int, request: Request, authorization
return {"id": pid, "name": name, "prop_type": ptype, "status": "created"}
@router.patch("/properties/{prop_id}")
async def patch_property_v2(prop_id: int, request: Request, authorization: str | None = Header(default=None)):
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)
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
row = conn.execute("SELECT * FROM collection_properties WHERE id=?", (prop_id,)).fetchone()
if not row:
@@ -1028,13 +961,9 @@ def delete_property_v2(prop_id: int, request: Request, authorization: str | None
return {"id": prop_id, "status": "deleted"}
@router.post("/properties/{prop_id}/relation")
async def create_relation_v2(prop_id: int, request: Request, authorization: str | None = Header(default=None)):
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)
try:
body = await request.json()
except Exception:
body = {}
related_id = body.get("related_collection_id")
reverse = (body.get("reverse_name") or "").strip()
if not related_id:
@@ -1054,13 +983,9 @@ async def create_relation_v2(prop_id: int, request: Request, authorization: str
return {"id": prop_id, "status": "updated"}
@router.post("/properties/evaluate-formula")
async def evaluate_formula_v2(request: Request, authorization: str | None = Header(default=None)):
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)
try:
body = await request.json()
except Exception:
body = {}
expr = body.get("expression") or body.get("formula")
if not expr:
raise HTTPException(400, "expression required")
@@ -1073,13 +998,9 @@ async def evaluate_formula_v2(request: Request, authorization: str | None = Head
return {"result": res, "expression": expr}
@router.post("/properties/compute-rollup")
async def compute_rollup_v2(request: Request, authorization: str | None = Header(default=None)):
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)
try:
body = await request.json()
except Exception:
body = {}
for k in ("collection_id", "relation_property_id", "target_property_id", "page_id"):
if k not in body:
raise HTTPException(400, f"{k} required")
@@ -1101,13 +1022,9 @@ def list_views_v2(collection_id: int, request: Request, authorization: str | Non
return {"views": [row_to_dict(r) for r in rows]}
@router.post("/collections/{collection_id}/views")
async def create_view_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
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)
try:
body = await request.json()
except Exception:
body = {}
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 {}
@@ -1120,19 +1037,15 @@ async def create_view_v2(collection_id: int, request: Request, authorization: st
conn.commit()
audit_log(user, "view.create", "view", vid, name, request)
try:
await _fire_event("collection.view.created", {"view_id": vid, "collection_id": collection_id, "name": name, "view_type": vtype})
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}")
async def patch_view_v2(view_id: int, request: Request, authorization: str | None = Header(default=None)):
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)
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
row = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
if not row:
@@ -1169,13 +1082,9 @@ def delete_view_v2(view_id: int, request: Request, authorization: str | None = H
return {"id": view_id, "status": "deleted"}
@router.post("/views/{view_id}/save-as")
async def save_as_view_v2(view_id: int, request: Request, authorization: str | None = Header(default=None)):
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)
try:
body = await request.json()
except Exception:
body = {}
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()
@@ -1196,13 +1105,9 @@ def list_dashboards_v2(collection_id: int, request: Request, authorization: str
return {"dashboards": [row_to_dict(r) for r in rows]}
@router.post("/collections/{collection_id}/dashboards")
async def create_dashboard_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
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)
try:
body = await request.json()
except Exception:
body = {}
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:
@@ -1212,13 +1117,9 @@ async def create_dashboard_v2(collection_id: int, request: Request, authorizatio
return {"id": did, "name": name, "status": "created"}
@router.patch("/dashboards/{dashboard_id}")
async def patch_dashboard_v2(dashboard_id: int, request: Request, authorization: str | None = Header(default=None)):
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)
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
row = conn.execute("SELECT * FROM collection_dashboards WHERE id=?", (dashboard_id,)).fetchone()
if not row:
@@ -1255,13 +1156,9 @@ def list_comments_v2(page_id: int, request: Request, authorization: str | None =
return {"comments": [row_to_dict(r) for r in rows], "total": total, "limit": limit, "offset": offset}
@router.post("/pages/{page_id}/comments")
async def create_comment_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
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)
try:
body = await request.json()
except Exception:
body = {}
text = (body.get("body") or body.get("content") or "").strip()
if not text:
raise HTTPException(400, "body is required")
@@ -1272,19 +1169,15 @@ async def create_comment_v2(page_id: int, request: Request, authorization: str |
row = conn.execute("SELECT * FROM comments WHERE id=?", (nid,)).fetchone()
audit_log(user, "comment.create", "comment", nid, text[:80], request)
try:
await _fire_event("comment.added", {"comment_id": nid, "page_id": page_id, "user_id": user["id"]})
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}")
async def patch_comment_v2(comment_id: int, request: Request, authorization: str | None = Header(default=None)):
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)
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
row = conn.execute("SELECT * FROM comments WHERE id=?", (comment_id,)).fetchone()
if not row:
@@ -1298,7 +1191,7 @@ async def patch_comment_v2(comment_id: int, request: Request, authorization: str
conn.commit()
if int(bool(resolved)) and not was_resolved:
try:
await _fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]})
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"}
@@ -1318,13 +1211,9 @@ def delete_comment_v2(comment_id: int, request: Request, authorization: str | No
return {"id": comment_id, "status": "deleted"}
@router.post("/pages/{page_id}/mentions")
async def create_mention_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
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)
try:
body = await request.json()
except Exception:
body = {}
targets = body.get("user_ids") or body.get("mentions") or []
if isinstance(targets, int):
targets = [targets]
@@ -1341,7 +1230,7 @@ async def create_mention_v2(page_id: int, request: Request, authorization: str |
conn.commit()
if created:
try:
await _fire_event("mention.added", {"page_id": page_id, "user_ids": [u for u in targets if isinstance(u, int)], "count": created})
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"}
@@ -1390,13 +1279,9 @@ def mark_all_read(request: Request, authorization: str | None = Header(default=N
return {"status": "all read"}
@router.patch("/users/me/preferences")
async def patch_prefs(request: Request, authorization: str | None = Header(default=None)):
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)
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
row = conn.execute("SELECT notification_prefs FROM users WHERE id=?", (user["id"],)).fetchone()
try:
@@ -1419,13 +1304,9 @@ def list_favorites_v2(request: Request, authorization: str | None = Header(defau
return {"favorites": [row_to_dict(r) for r in rows]}
@router.post("/favorites")
async def add_favorite_v2(request: Request, authorization: str | None = Header(default=None)):
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)
try:
body = await request.json()
except Exception:
body = {}
pid = body.get("page_id")
if not pid:
raise HTTPException(400, "page_id required")
@@ -1436,20 +1317,20 @@ async def add_favorite_v2(request: Request, authorization: str | None = Header(d
except Exception as e:
raise HTTPException(409, str(e)) from None
try:
await _fire_event("favorite.added", {"page_id": pid, "user_id": user["id"]})
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}")
async def remove_favorite_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
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:
await _fire_event("favorite.removed", {"page_id": page_id, "user_id": user["id"]})
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"}
@@ -1467,13 +1348,9 @@ def list_tags_v2(request: Request, authorization: str | None = Header(default=No
return {"tags": [dict(r) for r in rows]}
@router.post("/tags")
async def create_tag_v2(request: Request, authorization: str | None = Header(default=None)):
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)
try:
body = await request.json()
except Exception:
body = {}
name = (body.get("name") or "").strip()
if not name:
raise HTTPException(400, "name required")
@@ -1488,13 +1365,9 @@ async def create_tag_v2(request: Request, authorization: str | None = Header(def
return {"id": tid, "name": name, "color": color, "status": "created"}
@router.patch("/tags/{tag_id}")
async def patch_tag_v2(tag_id: int, request: Request, authorization: str | None = Header(default=None)):
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)
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
row = conn.execute("SELECT * FROM tags WHERE id=? AND user_id=?", (tag_id, user["id"])).fetchone()
if not row:
@@ -1515,13 +1388,9 @@ def delete_tag_v2(tag_id: int, request: Request, authorization: str | None = Hea
return {"id": tag_id, "status": "deleted"}
@router.post("/pages/{page_id}/tags")
async def attach_tag_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
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)
try:
body = await request.json()
except Exception:
body = {}
tag_id = body.get("tag_id")
if not tag_id:
raise HTTPException(400, "tag_id required")
@@ -1566,13 +1435,9 @@ def list_shares_v2(page_id: int, request: Request, authorization: str | None = H
return {"shares": [dict(r) for r in rows]}
@router.post("/pages/{page_id}/shares")
async def create_share_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
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)
try:
body = await request.json()
except Exception:
body = {}
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")
@@ -1587,19 +1452,15 @@ async def create_share_v2(page_id: int, request: Request, authorization: str | N
nid = cur.lastrowid
audit_log(user, "share.create", "share", nid, f"page={page_id}", request)
try:
await _fire_event("page.shared", {"page_id": page_id, "share_id": nid, "permission": perm})
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}")
async def patch_share_v2(share_id: int, request: Request, authorization: str | None = Header(default=None)):
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)
try:
body = await request.json()
except Exception:
body = {}
perm = (body.get("permission") or "").strip().lower()
if perm not in ("view", "comment", "edit"):
raise HTTPException(400, "Invalid permission")
@@ -1627,25 +1488,21 @@ def delete_share_v2(share_id: int, request: Request, authorization: str | None =
return {"id": share_id, "status": "revoked"}
@router.post("/pages/{page_id}/publish")
async def publish_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
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)
try:
body = await request.json()
except Exception:
body = {}
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)
await fire_published(page_id, slug)
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")
async def unpublish_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
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)
await fire_unpublished(page_id)
run_event_sync(fire_unpublished(page_id))
return {"page_id": page_id, "status": "unpublished"}
# ── History ───────────────────────────────────────────────────────────────
@@ -1661,13 +1518,9 @@ def list_history_v2(page_id: int, request: Request, authorization: str | None =
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")
async def restore_history_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
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)
try:
body = await request.json()
except Exception:
body = {}
hid = body.get("history_id") or body.get("id") or body.get("version_id")
if not hid:
raise HTTPException(400, "history_id required")
@@ -1703,13 +1556,9 @@ def list_sprints_v2(collection_id: int, request: Request, authorization: str | N
return {"sprints": [row_to_dict(r) for r in rows], "total": total, "limit": limit, "offset": offset}
@router.post("/collections/{collection_id}/sprints")
async def create_sprint_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
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)
try:
body = await request.json()
except Exception:
body = {}
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 ""
@@ -1720,19 +1569,15 @@ async def create_sprint_v2(collection_id: int, request: Request, authorization:
sid = cur.lastrowid
conn.commit()
try:
await _fire_event("sprint.created", {"sprint_id": sid, "collection_id": collection_id, "name": name})
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}")
async def patch_sprint_v2(sprint_id: int, request: Request, authorization: str | None = Header(default=None)):
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)
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
row = conn.execute("SELECT * FROM sprints WHERE id=?", (sprint_id,)).fetchone()
if not row:
@@ -1745,7 +1590,7 @@ async def patch_sprint_v2(sprint_id: int, request: Request, authorization: str |
conn.execute("UPDATE sprints SET name=?, start_date=?, end_date=?, goal=?, status=? WHERE id=?", (name, start, end, goal, status, sprint_id))
conn.commit()
try:
await _fire_event("sprint.updated", {"sprint_id": sprint_id, "collection_id": row["collection_id"], "name": name, "status": status})
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"}
@@ -1760,13 +1605,9 @@ def delete_sprint_v2(sprint_id: int, request: Request, authorization: str | None
return {"id": sprint_id, "status": "deleted"}
@router.post("/sprints/{sprint_id}/assign")
async def assign_sprint_v2(sprint_id: int, request: Request, authorization: str | None = Header(default=None)):
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)
try:
body = await request.json()
except Exception:
body = {}
pid = body.get("page_id")
if not pid:
raise HTTPException(400, "page_id required")
@@ -1824,13 +1665,9 @@ def list_templates_v2(collection_id: int, request: Request, authorization: str |
return {"templates": [row_to_dict(r) for r in rows]}
@router.post("/collections/{collection_id}/templates")
async def create_template_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
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)
try:
body = await request.json()
except Exception:
body = {}
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 [])
@@ -1841,13 +1678,9 @@ async def create_template_v2(collection_id: int, request: Request, authorization
return {"id": tid, "name": name, "status": "created"}
@router.patch("/templates/{template_id}")
async def patch_template_v2(template_id: int, request: Request, authorization: str | None = Header(default=None)):
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)
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
row = conn.execute("SELECT * FROM page_templates WHERE id=?", (template_id,)).fetchone()
if not row:
@@ -1892,13 +1725,9 @@ def list_db_templates_v2(request: Request, authorization: str | None = Header(de
return {"templates": [row_to_dict(r) for r in rows]}
@router.post("/templates/database/{template_id}/apply")
async def apply_db_template_v2(template_id: int, request: Request, authorization: str | None = Header(default=None)):
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)
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
tpl = conn.execute("SELECT * FROM database_templates WHERE id=?", (template_id,)).fetchone()
if not tpl:
@@ -2065,15 +1894,11 @@ def admin_list_users_v2(request: Request, limit: int = 30, offset: int = 0, auth
return {"users": [row_to_dict(r) for r in rows], "total": total, "limit": limit, "offset": offset}
@router.patch("/admin/users/{uid}")
async def admin_patch_user_v2(uid: int, request: Request, authorization: str | None = Header(default=None)):
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")
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
if not conn.execute("SELECT id FROM users WHERE id=?", (uid,)).fetchone():
raise HTTPException(404, "User not found")
@@ -2144,13 +1969,9 @@ def list_webhooks_v2(request: Request, authorization: str | None = Header(defaul
)
@router.post("/webhooks")
async def create_webhook_v2(request: Request, authorization: str | None = Header(default=None)):
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)
try:
body = await request.json()
except Exception:
body = {}
from app.services.webhook_outbound import EVENTS, _event_matches
url = (body.get("url") or "").strip()
event = (body.get("event") or "page.created").strip()
@@ -2172,13 +1993,9 @@ async def create_webhook_v2(request: Request, authorization: str | None = Header
"signature_header": "X-FlowDeck-Signature (HMAC-SHA256, sha256=<hex>)" if secret else None}
@router.patch("/webhooks/{webhook_id}")
async def patch_webhook_v2(webhook_id: int, request: Request, authorization: str | None = Header(default=None)):
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)
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
row = conn.execute("SELECT * FROM webhook_subscriptions WHERE id=?", (webhook_id,)).fetchone()
if not row:
@@ -2274,19 +2091,15 @@ async def retry_webhook_deliveries(webhook_id: int, request: Request,
@router.post("/webhooks/verify-signature")
async def verify_webhook_signature(request: Request,
authorization: str | None = Header(default=None)):
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
try:
body = await request.json()
except Exception:
body = {}
secret = body.get("secret", "")
payload = body.get("payload", "{}")
signature = body.get("signature", "")
-153
View File
@@ -341,74 +341,6 @@ def library_private(
return {"items": items}
@router.get("/local-workspace-children/{item_id:int}")
def library_local_workspace_children(item_id: int, request: Request):
"""Return children of a local workspace item for tree expansion."""
_get_user_id(request)
with get_conn() as conn:
# Get the item to find its workspace
item = conn.execute(
"SELECT workspace_id FROM local_workspace_items WHERE id=? AND deleted_at IS NULL",
[item_id],
).fetchone()
if not item:
return {"items": []}
rows = conn.execute(
"SELECT id, name, is_folder, parent_id, content_format, size, "
"COALESCE(updated_at, created_at) as updated_at "
"FROM local_workspace_items "
"WHERE parent_id = ? AND deleted_at IS NULL "
"ORDER BY is_folder DESC, name COLLATE NOCASE",
[item_id],
).fetchall()
items = []
for r in rows:
name = r["name"] or "Untitled"
is_folder = bool(r["is_folder"])
icon = "📁" if is_folder else "📄"
fn = name.lower()
if not is_folder:
if fn.endswith(".pdf"):
icon = "📕"
elif any(fn.endswith(e) for e in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]):
icon = "🖼️"
elif any(fn.endswith(e) for e in [".py", ".js", ".ts", ".go", ".rs"]):
icon = "📜"
with get_conn() as conn:
child_count = conn.execute(
"SELECT COUNT(*) FROM local_workspace_items WHERE parent_id=? AND deleted_at IS NULL",
[r["id"]],
).fetchone()[0]
items.append({
"id": r["id"],
"title": name,
"icon": icon,
"is_folder": is_folder,
"source_type": "local-ws",
"source_label": "",
"workspace": "",
"workspace_name": "",
"author": "",
"author_initial": "?",
"updated_at": r["updated_at"] or "",
"visited_at": "",
"has_children": child_count > 0,
"children": [],
"url": f"/local-workspace?folder={r['id']}" if is_folder else f"/pages/{r['id']}",
"content_format": r["content_format"] or "file",
"favorited": False,
"page_icon": "",
"tags": [],
"size_display": _format_size(r["size"]) if r["size"] else "",
})
return {"items": items}
@router.get("/children/{page_id:int}")
def library_children(page_id: int, request: Request):
"""Return child pages for a given parent page (for tree expansion in Library)."""
@@ -449,91 +381,6 @@ def library_repository(
return {"items": items}
@router.get("/local-workspace")
def library_local_workspace(
request: Request,
workspace_id: int = Query(default=0),
):
"""Return local workspace items (files/folders) formatted for Library display."""
from app.routers.dashboard import _get_active_workspace
uid = _get_user_id(request)
# Get the active workspace
ws = _get_active_workspace(request, user_id=uid)
if not ws:
return {"items": []}
ws_id = workspace_id or ws["id"]
# Query local workspace tree
with get_conn() as conn:
rows = conn.execute(
"SELECT id, name, is_folder, parent_id, content_format, size, "
"COALESCE(updated_at, created_at) as updated_at "
"FROM local_workspace_items "
"WHERE workspace_id = ? AND deleted_at IS NULL "
"ORDER BY is_folder DESC, name COLLATE NOCASE",
[ws_id],
).fetchall()
items = []
for r in rows:
name = r["name"] or "Untitled"
is_folder = bool(r["is_folder"])
icon = "📁" if is_folder else "📄"
fn = name.lower()
if not is_folder:
if fn.endswith(".pdf"):
icon = "📕"
elif any(fn.endswith(e) for e in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]):
icon = "🖼️"
elif any(fn.endswith(e) for e in [".py", ".js", ".ts", ".go", ".rs"]):
icon = "📜"
# Check for children
child_count = conn.execute(
"SELECT COUNT(*) FROM local_workspace_items WHERE parent_id=? AND deleted_at IS NULL",
[r["id"]],
).fetchone()[0]
items.append({
"id": r["id"],
"title": name,
"icon": icon,
"is_folder": is_folder,
"source_type": "local-ws",
"source_label": ws.get("name", "Workspace"),
"workspace": ws.get("name", ""),
"workspace_name": ws.get("name", ""),
"author": "",
"author_initial": "?",
"updated_at": r["updated_at"] or "",
"visited_at": "",
"has_children": child_count > 0,
"children": [],
"url": f"/local-workspace?folder={r['id']}" if is_folder else f"/pages/{r['id']}",
"content_format": r["content_format"] or "file",
"favorited": False,
"page_icon": "",
"tags": [],
"size_display": _format_size(r["size"]) if r["size"] else "",
})
return {"items": items}
def _format_size(size_bytes):
if not size_bytes:
return ""
if size_bytes < 1024:
return f"{size_bytes} B"
if size_bytes < 1048576:
return f"{size_bytes/1024:.1f} KB"
if size_bytes < 1073741824:
return f"{size_bytes/1048576:.1f} MB"
return f"{size_bytes/1073741824:.1f} GB"
@router.get("/workspace")
def library_workspace(
request: Request,
+13
View File
@@ -355,6 +355,19 @@ async def run_automation(automation_id: int, trigger_source: str, context: dict)
return {"status": "error", "detail": str(exc)}
def run_event_sync(coro, timeout: float = 60.0):
"""A21 phase 2b : exécute une coroutine d'événement depuis un handler synchrone.
Bloque le worker threadpool (jamais la boucle d'event) et attend la fin —
déterministe, exactement ce que faisait l'await avant la conversion des
routes en `def`.
ponytail: les clients httpx des services sont créés à chaque appel (aucun
lien de boucle) ; si un jour un client/queue est lié à la boucle de l'app,
passer à `asyncio.run_coroutine_threadsafe` + boucle capturée au lifespan.
"""
return asyncio.run(asyncio.wait_for(coro, timeout))
async def fire_event(event: str, payload: dict):
"""Dispatch an event to outbound webhooks and matching automations."""
# v7.3.0: page.updated → in-app notification to followers (throttled).
+518 -84
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "FlowDeck",
"version": "7.8.0"
"version": "7.16.0"
},
"paths": {
"/auth/register": {
@@ -10765,47 +10765,6 @@
}
}
},
"/api/library/local-workspace-children/{item_id}": {
"get": {
"tags": [
"library"
],
"summary": "Library Local Workspace Children",
"description": "Return children of a local workspace item for tree expansion.",
"operationId": "library_local_workspace_children_api_library_local_workspace_children__item_id__get",
"parameters": [
{
"name": "item_id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"title": "Item Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/library/children/{page_id}": {
"get": {
"tags": [
@@ -10899,48 +10858,6 @@
}
}
},
"/api/library/local-workspace": {
"get": {
"tags": [
"library"
],
"summary": "Library Local Workspace",
"description": "Return local workspace items (files/folders) formatted for Library display.",
"operationId": "library_local_workspace_api_library_local_workspace_get",
"parameters": [
{
"name": "workspace_id",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"default": 0,
"title": "Workspace Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/library/workspace": {
"get": {
"tags": [
@@ -16725,6 +16642,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -16974,6 +16902,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -17123,6 +17062,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -17233,6 +17183,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -17397,6 +17358,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -17462,6 +17434,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -17617,6 +17600,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -17727,6 +17721,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -17837,6 +17842,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -18003,6 +18019,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -18178,6 +18205,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -18288,6 +18326,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -18454,6 +18503,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -18564,6 +18624,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -18674,6 +18745,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -18849,6 +18931,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -18905,6 +18998,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -19015,6 +19119,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -19062,6 +19177,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -19109,6 +19235,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -19219,6 +19356,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -19275,6 +19423,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -19385,6 +19544,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -19495,6 +19665,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -19551,6 +19732,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -19715,6 +19907,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -19771,6 +19974,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -19881,6 +20095,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -20125,6 +20350,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -20217,6 +20453,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -20365,6 +20612,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -20421,6 +20679,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -20531,6 +20800,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -20753,6 +21033,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -20809,6 +21100,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -20919,6 +21221,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -21085,6 +21398,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -21195,6 +21519,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -21251,6 +21586,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -21361,6 +21707,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -21592,6 +21949,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -21648,6 +22016,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -21861,6 +22240,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -22367,6 +22757,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -22618,6 +23019,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -22674,6 +23086,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -22961,6 +23384,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
+608
View File
@@ -0,0 +1,608 @@
"""A32 — smoke tests : un par route des 4 routers à 0 test.
`webhooks.py` (3), `notes.py` (2), `sidebar_config.py` (2), `github_routes.py` (2).
Aucun accès réseau réel : le secret webhook est piloté par monkeypatch et
`gitea.list_webhooks` est stubbé.
"""
from app.config import settings
OWNER, REPO = "smoke-a32", "repo-x"
# ── webhooks.py (0/3) ────────────────────────────────────────────────────────
def test_webhook_receive_ok_without_secret(client, monkeypatch):
monkeypatch.setattr(settings, "gitea_webhook_secret", "")
r = client.post("/api/webhook", json={"zen": "smoke"})
assert r.status_code == 200
assert r.json() == {"status": "ok"}
def test_webhook_receive_rejects_bad_signature(client, monkeypatch):
monkeypatch.setattr(settings, "gitea_webhook_secret", "s3cret")
r = client.post("/api/webhook", json={}) # pas d'en-tête X-Gitea-Signature
assert r.status_code == 401
assert r.json()["detail"] == "Invalid signature"
def test_webhook_register_fails_fast_without_secret(client, monkeypatch):
# secret vide → 400 AVANT tout appel réseau (gitea.list_webhooks)
monkeypatch.setattr(settings, "gitea_webhook_secret", "")
r = client.post(f"/api/webhook/register/{OWNER}/{REPO}")
assert r.status_code == 400
assert "not configured" in r.json()["detail"]
def test_webhook_status_stubbed(client, monkeypatch):
from app.services import gitea_client
async def _no_hooks(*_a, **_k):
return []
monkeypatch.setattr(gitea_client.gitea, "list_webhooks", _no_hooks)
r = client.get(f"/api/webhook/status/{OWNER}/{REPO}")
assert r.status_code == 200
assert r.json() == {"registered": False}
# ── notes.py (0/2) ───────────────────────────────────────────────────────────
def test_notes_get_renders_html(client):
r = client.get(f"/notes/{OWNER}/{REPO}")
assert r.status_code == 200
assert "text/html" in r.headers["content-type"]
def test_notes_save_roundtrip(client):
payload = "note de smoke A32"
html_payload = "<b>&co</b>"
r = client.post(
f"/notes/{OWNER}/{REPO}", data={"content": f"{payload} {html_payload}"}
)
assert r.status_code == 200
assert payload in r.text # contenu rendu
assert "&lt;b&gt;&amp;co&lt;/b&gt;" in r.text # échappé (A10 — pas d'HTML cru)
again = client.get(f"/notes/{OWNER}/{REPO}")
assert payload in again.text # persistée en base (upsert)
# ── sidebar_config.py (0/2) ──────────────────────────────────────────────────
def test_sidebar_config_get(client):
r = client.get("/api/sidebar/config")
assert r.status_code == 200
cfg = r.json()["config"]
assert isinstance(cfg, dict) and "meetings" in cfg
def test_sidebar_config_put_persists_and_validates(client):
r = client.put(
"/api/sidebar/config",
json={"config": {"meetings": {"visible": False, "order": 2, "show_count": 5}}},
)
assert r.status_code == 200
back = client.get("/api/sidebar/config").json()["config"]
assert back["meetings"]["visible"] is False # relu depuis users.sidebar_config
bad = client.put("/api/sidebar/config", json={})
assert bad.status_code == 400 # config dict requis
# remise en état pour les autres tests
restore = client.put(
"/api/sidebar/config",
json={"config": {"meetings": {"visible": True, "order": 2, "show_count": 5}}},
)
assert restore.status_code == 200
# ── github_routes.py (0/2) ───────────────────────────────────────────────────
def test_github_status_unlinked(client):
r = client.get("/api/github/status")
assert r.status_code == 200
assert r.json() == {"linked": False}
def test_github_disconnect_ok(client):
r = client.delete("/api/github/disconnect")
assert r.status_code == 200
assert r.json() == {"status": "ok"}
# ── library.py (1/10 → 10/10) ────────────────────────────────────────────────
def _seed_page(title: str, **cols) -> int:
"""Insert minimale (title/content/format + colonnes surnuméraires), id renvoyé."""
from app.db import get_conn
base = {"workspace": "", "title": title, "content": "", "content_format": "markdown"}
base.update(cols) # surcharge (content=, parent_id=, parent_section=…)
columns = list(base)
placeholders = ", ".join("?" for _ in columns)
values = list(base.values())
with get_conn() as conn:
cur = conn.execute(
f"INSERT INTO pages ({', '.join(columns)}) VALUES ({placeholders})", values
)
pid = cur.lastrowid
conn.commit()
return pid
def test_library_all_lists_return_items(client):
"""Les 5 listes de la bibliothèque répondent {"items": [...]}."""
for path in ("/recents", "/favorites", "/published", "/private", "/workspace"):
r = client.get(f"/api/library{path}")
assert r.status_code == 200, (path, r.status_code)
assert isinstance(r.json()["items"], list), path
def test_library_private_lists_seeded_page(client):
from app.db import get_conn
pid = _seed_page("A32 private page", parent_section="Private")
try:
items = client.get("/api/library/private").json()["items"]
assert "A32 private page" in [i["title"] for i in items]
finally:
with get_conn() as conn:
conn.execute("DELETE FROM pages WHERE id=?", (pid,))
conn.commit()
def test_library_children_lists_child(client):
from app.db import get_conn
parent = _seed_page("A32 parent")
child = _seed_page("A32 child", parent_id=parent)
try:
items = client.get(f"/api/library/children/{parent}").json()["items"]
assert [i["title"] for i in items] == ["A32 child"]
finally:
with get_conn() as conn:
conn.execute("DELETE FROM pages WHERE id IN (?, ?)", (parent, child))
conn.commit()
def test_library_local_workspace_routes_removed(client):
"""A32 → A30 : ces 2 routes lisaient `local_workspace_items`, une table
inexistante nulle part dans le codebase (500 systématique côté children,
500 d?s qu'un workspace existe côté local-workspace) et AUCUNE référence
front → supprimées avec `_format_size` devenu mort."""
assert client.get("/api/library/local-workspace-children/1").status_code == 404
assert client.get("/api/library/local-workspace").status_code == 404
def test_library_repository_empty_and_keyed(client):
empty = client.get("/api/library/repository")
assert empty.status_code == 200 and empty.json() == {"items": []}
keyed = client.get("/api/library/repository?gitea_owner=acme&gitea_repo=flowdeck")
assert keyed.status_code == 200
assert isinstance(keyed.json()["items"], list)
def test_library_lists_still_available_after_removal(client):
"""Les listes restantes (celles qu'utilise le front) répondent toujours."""
r = client.get("/api/library/recents")
assert r.status_code == 200 and isinstance(r.json()["items"], list)
# ── api.py (3/23 → 11/23) ────────────────────────────────────────────────────
def _seed_board(client) -> None:
"""Board via l'endpoint lui-même (pas de SQL brut)."""
r = client.post(
f"/api/board-config/{OWNER}/{REPO}",
params={"columns": '["Todo", "Doing"]', "wip_limits": "{}"},
)
assert r.status_code == 200 and r.json() == {"status": "ok"}
def test_api_board_config_default_then_roundtrip(client):
# pas de board → défauts
r = client.get(f"/api/board-config/{OWNER}/other-repo")
assert r.status_code == 200
assert r.json()["columns"] == ["Backlog", "À faire", "En cours", "Révision", "Terminé"]
# création puis relecture
_seed_board(client)
back = client.get(f"/api/board-config/{OWNER}/{REPO}")
assert back.json()["columns"] == ["Todo", "Doing"]
def test_api_col_mapping_requires_board_and_works(client):
# board inexistant → 404
r = client.post(
"/api/col-mapping",
params={"owner": OWNER, "repo": "absent", "column": "Doing", "gitea_label": "x"},
)
assert r.status_code == 404
# avec board → ok (upsert) puis suppression
_seed_board(client)
r2 = client.post(
"/api/col-mapping",
params={"owner": OWNER, "repo": REPO, "column": "Doing", "gitea_label": "in-progress"},
)
assert r2.status_code == 200
assert r2.json() == {"status": "ok", "column": "Doing", "label": "in-progress"}
r3 = client.delete(
"/api/col-mapping", params={"owner": OWNER, "repo": REPO, "column": "Doing"}
)
assert r3.status_code == 200 and r3.json() == {"status": "ok", "column": "Doing"}
def test_api_card_metadata(client):
r = client.post(f"/api/card/{OWNER}/absent/1", params={"priority": "high"})
assert r.status_code == 404 # board requis
_seed_board(client)
r2 = client.post(
f"/api/card/{OWNER}/{REPO}/1", params={"priority": "high", "due_date": "2026-10-01"}
)
assert r2.status_code == 200 and r2.json() == {"status": "ok"}
def test_api_collaborators_stubbed(client, monkeypatch):
from app.services import gitea_client
async def _none(*_a, **_k):
return []
monkeypatch.setattr(gitea_client.gitea, "get_collaborators", _none)
r = client.get(f"/api/collaborators/{OWNER}/{REPO}")
assert r.status_code == 200
assert r.json() == {"collaborators": []}
def test_api_checklist_item_toggle_and_deletes(client):
"""PATCH item / DELETE item / DELETE checklist : mutation vérifiée en base."""
from app.db import get_conn
_seed_board(client)
with get_conn() as conn:
board_id = conn.execute(
"SELECT id FROM boards WHERE project_owner=? AND project_name=?",
(OWNER, REPO),
).fetchone()["id"]
cur = conn.execute(
"INSERT INTO checklists (board_id, gitea_issue_id, title) VALUES (?, 1, 'A32')",
(board_id,),
)
cl_id = cur.lastrowid
cur = conn.execute(
"INSERT INTO checklist_items (checklist_id, content) VALUES (?, 'fait ?')",
(cl_id,),
)
item_id = cur.lastrowid
conn.commit()
try:
r = client.patch(
f"/api/checklist-items/{item_id}", params={"checked": True, "content": "fait !"}
)
assert r.status_code == 200 and r.json() == {"status": "ok"}
with get_conn() as conn:
row = conn.execute(
"SELECT checked, content FROM checklist_items WHERE id=?", (item_id,)
).fetchone()
assert row["checked"] == 1 and row["content"] == "fait !"
assert client.delete(f"/api/checklist-items/{item_id}").json() == {"status": "ok"}
assert client.delete(f"/api/checklists/{cl_id}").json() == {"status": "ok"}
with get_conn() as conn:
left = conn.execute(
"SELECT COUNT(*) AS n FROM checklists WHERE id=?", (cl_id,)
).fetchone()["n"]
assert left == 0
finally:
with get_conn() as conn:
conn.execute("DELETE FROM checklist_items WHERE checklist_id=?", (cl_id,))
conn.execute("DELETE FROM checklists WHERE id=?", (cl_id,))
conn.commit()
def _v2_headers(client, login: str) -> dict:
"""Compte local + token v1 en session (recette de test_public_api_v2)."""
r = client.post(
"/auth/register",
json={"email": f"{login}@test.dev", "password": "secret123", "name": login},
)
assert r.status_code == 200, r.text
tok = client.post("/api/v1/token").json()["token"]
return {"Authorization": f"Bearer {tok}"}
def test_api_frontend_error_capture_and_read(client):
# purge d'un éventuel résidu d'un autre test
client.get("/api/frontend-errors")
ok = client.post("/api/frontend-error", json={"message": "boom a32", "type": "error"})
assert ok.status_code == 200 and ok.json() == {"status": "ok"}
invalid = client.post(
"/api/frontend-error", content="pas du json",
headers={"Content-Type": "application/json"},
)
assert invalid.status_code == 200 and invalid.json()["status"] == "ignored"
# doublon consécutif dédupliqué (count=2), lecture puis purge
client.post("/api/frontend-error", json={"message": "boom a32", "type": "error"})
out = client.get("/api/frontend-errors").json()
assert out["count"] == 1 and out["errors"][0]["count"] == 2 and out["cleared"] is True
assert client.get("/api/frontend-errors").json()["count"] == 0
# ── api_v2.py (+5 routes à 0 ref) ────────────────────────────────────────────
def test_v2_evaluate_formula(client):
headers = _v2_headers(client, "smoke-formula")
r = client.post(
"/api/v2/properties/evaluate-formula",
json={"expression": "1 + 2"}, headers=headers,
)
assert r.status_code == 200
d = r.json()
# le moteur décide du résultat (1 + 2 y est renvoyé tel quel aujourd'hui) :
# on valide le câble route (auth, parse, shape), pas le moteur lui-même.
assert d["expression"] == "1 + 2" and "result" in d
missing = client.post("/api/v2/properties/evaluate-formula", json={}, headers=headers)
assert missing.status_code == 400 and "expression" in missing.json()["detail"]
def test_v2_compute_rollup_validates_inputs(client):
headers = _v2_headers(client, "smoke-rollup")
r = client.post("/api/v2/properties/compute-rollup", json={}, headers=headers)
assert r.status_code == 400
assert "collection_id required" in r.json()["detail"]
sans_auth = client.post("/api/v2/properties/compute-rollup", json={})
assert sans_auth.status_code == 401
def test_v2_admin_audit_logs_gated_then_readable(client):
headers = _v2_headers(client, "smoke-audit")
# le portail dépend de l'utilisateur porteur (le TOUT premier utilisateur
# du worker est admin) → on calcule l'attendu depuis /users/me plutôt que
# de durcir un état qu'on ne contrôle pas.
me = client.get("/api/v2/users/me", headers=headers).json()
r = client.get("/api/v2/admin/audit-logs", headers=headers)
if me.get("is_admin"):
assert r.status_code == 200
else:
assert r.status_code == 403
assert "Admin scope" in r.json()["detail"]
# token avec scope admin → 200 + liste paginée
admin_tok = client.post(
"/api/v2/tokens", json={"name": "adm", "scopes": "read,admin"}, headers=headers
).json()["token"]
r2 = client.get(
"/api/v2/admin/audit-logs", headers={"Authorization": f"Bearer {admin_tok}"}
)
assert r2.status_code == 200
assert isinstance(r2.json()["logs"], list)
def test_v2_webhooks_events_catalogue(client):
headers = _v2_headers(client, "smoke-events")
r = client.get("/api/v2/webhooks/events", headers=headers)
assert r.status_code == 200
d = r.json()
assert isinstance(d["events"], list) and len(d["events"]) > 0
assert "*" in d["wildcards"] and "page.*" in d["wildcards"]
def test_v2_webhook_verify_signature(client):
from app.services.webhook_outbound import sign_payload
headers = _v2_headers(client, "smoke-sig")
secret, payload = "topsecret", '{"event": "page.updated"}'
good = sign_payload(secret, payload.encode())
ok = client.post(
"/api/v2/webhooks/verify-signature",
json={"secret": secret, "payload": payload, "signature": good},
headers=headers,
)
assert ok.status_code == 200 and ok.json()["valid"] is True
bad = client.post(
"/api/v2/webhooks/verify-signature",
json={"secret": secret, "payload": payload, "signature": "deadbeef"},
headers=headers,
)
assert bad.json()["valid"] is False
# ── dashboard.py (17/63 → 27/63) ─────────────────────────────────────────────
def test_dashboard_tags_crud(client):
"""POST/GET/PUT/DELETE /api/settings/tags — cycle complet vérifié."""
r = client.post("/api/settings/tags", json={"name": "SmokeTag", "color": "#111111"})
assert r.status_code == 200
tag = r.json()["tag"]
assert tag["name"] == "smoketag" and tag["color"] == "#111111" # lowercasé
allr = client.get("/api/settings/tags/all").json()["tags"]
assert any(t["id"] == tag["id"] for t in allr)
assert client.put(
f"/api/settings/tags/{tag['id']}", json={"color": "#222222"}
).json() == {"status": "ok"}
allr2 = client.get("/api/settings/tags/all").json()["tags"]
mine = next(t for t in allr2 if t["id"] == tag["id"])
assert mine["color"] == "#222222"
assert client.delete(f"/api/settings/tags/{tag['id']}").json() == {"status": "ok"}
assert all(t["id"] != tag["id"] for t in client.get("/api/settings/tags/all").json()["tags"])
def test_dashboard_page_content_rename_trash(client):
"""GET content → PUT rename → POST trash : la vie d'une page vérifiée en base."""
from app.db import get_conn
pid = _seed_page("Page A32", content="contenu a32")
try:
r = client.get(f"/api/pages/{pid}/content")
assert r.status_code == 200
d = r.json()
assert d["title"] == "Page A32" and d["content"] == "contenu a32"
rr = client.put(f"/api/pages/{pid}/rename", json={"title": "Renommée A32"})
assert rr.status_code == 200 and rr.json() == {"status": "ok", "title": "Renommée A32"}
empty = client.put(f"/api/pages/{pid}/rename", json={"title": " "})
assert empty.status_code == 400 # titre vide refusé
rt = client.post(f"/api/pages/{pid}/trash")
assert rt.status_code == 200 and rt.json() == {"status": "ok"}
with get_conn() as conn:
row = conn.execute(
"SELECT title, parent_section, deleted_at FROM pages WHERE id=?", (pid,)
).fetchone()
assert row["title"] == "Renommée A32"
assert row["parent_section"] == "Trash" and row["deleted_at"] is not None
finally:
with get_conn() as conn:
conn.execute("DELETE FROM pages WHERE id=?", (pid,))
conn.commit()
def test_dashboard_sidebar_workspace_tree(client):
r = client.get("/api/sidebar/workspace-tree")
assert r.status_code == 200
assert "text/html" in r.headers["content-type"]
# pas de cookie workspace → fragment « No pages yet »
assert "No pages yet" in r.text
def test_dashboard_avatar_color_persisted(client):
"""POST avatar-color → couleur relue SUR L'UTILISATEUR DE LA SESSION."""
from app.db import get_conn
uid = client.get("/api/users/me").json()["id"]
with get_conn() as conn:
row = conn.execute(
"SELECT avatar_color, avatar_url FROM users WHERE id=?", (uid,)
).fetchone()
prev_color, prev_url = row["avatar_color"], row["avatar_url"]
try:
r = client.post("/api/settings/avatar-color", json={"color": "#112233"})
assert r.status_code == 200
assert r.json() == {"status": "ok", "color": "#112233"}
with get_conn() as conn:
row2 = conn.execute(
"SELECT avatar_color, avatar_url FROM users WHERE id=?", (uid,)
).fetchone()
assert row2["avatar_color"] == "#112233" and row2["avatar_url"] == ""
finally:
with get_conn() as conn:
conn.execute(
"UPDATE users SET avatar_color=?, avatar_url=? WHERE id=?",
(prev_color, prev_url, uid),
)
conn.commit()
def test_dashboard_workspace_members_list(client):
r = client.get("/api/workspace/1/members")
assert r.status_code == 200
assert isinstance(r.json()["members"], list)
def test_dashboard_account_pages_html(client):
"""Pages HTML /accounts et /accounts/settings (whitelist A2 vérifiée)."""
for path in ("/accounts", "/accounts/settings"):
r = client.get(path)
assert r.status_code == 200, path
assert "text/html" in r.headers["content-type"], path
# la page ne doit plus contenir de hash (colonnes whitelistées en A29)
assert "password_hash" not in client.get("/accounts").text
def test_dashboard_user_profile_update_persisted(client):
from app.db import get_conn
uid = client.get("/api/users/me").json()["id"]
with get_conn() as conn:
prev = conn.execute("SELECT full_name FROM users WHERE id=?", (uid,)).fetchone()[
"full_name"
]
try:
r = client.put("/api/user/profile", json={"full_name": "Smoke A32"})
assert r.status_code == 200 and r.json() == {"status": "ok"}
with get_conn() as conn:
now = conn.execute(
"SELECT full_name FROM users WHERE id=?", (uid,)
).fetchone()["full_name"]
assert now == "Smoke A32"
finally:
with get_conn() as conn:
conn.execute("UPDATE users SET full_name=? WHERE id=?", (prev, uid))
conn.commit()
def test_dashboard_password_change_gated_by_current(client):
"""A3 : sans mdp actuel correct, la session ne permet PAS de changer le mdp."""
court = client.put("/api/user/password", json={"password": "abc"})
# quirk assumé : la longueur est validée AVANT auth et répond 200 + erreur
assert "at least 6 characters" in court.json().get("error", "")
faux = client.put(
"/api/user/password",
json={"password": "nouveaumdp1", "current_password": "pas-le-bon"},
)
assert faux.status_code == 403
assert "incorrect" in faux.json()["detail"].lower()
def test_dashboard_user_token_and_forge_disconnect(client):
import re as _re
from app.db import get_conn
uid = client.get("/api/users/me").json()["id"]
try:
r = client.post("/api/user/token")
assert r.status_code == 200
assert _re.fullmatch(r"fd_[0-9a-f]{64}", r.json()["token"])
r2 = client.delete("/api/user/forge/gitea")
assert r2.status_code == 200 and r2.json() == {"status": "ok"}
finally:
with get_conn() as conn:
conn.execute("DELETE FROM user_tokens WHERE gitea_user_id=?", (uid,))
conn.commit()
def test_dashboard_settings_account_update(client):
from app.db import get_conn
uid = client.get("/api/users/me").json()["id"]
with get_conn() as conn:
prev = conn.execute(
"SELECT full_name, email FROM users WHERE id=?", (uid,)
).fetchone()
try:
r = client.put(
"/api/settings/account",
json={"full_name": "Compte A32", "email": "[email protected]"},
)
assert r.status_code == 200
with get_conn() as conn:
row = conn.execute(
"SELECT full_name, email FROM users WHERE id=?", (uid,)
).fetchone()
assert row["full_name"] == "Compte A32" and row["email"] == "[email protected]"
# mdp trop court → 400 (validateur, pas de changement)
court = client.put("/api/settings/account", json={"password": "abc"})
assert court.status_code == 400
finally:
with get_conn() as conn:
conn.execute(
"UPDATE users SET full_name=?, email=? WHERE id=?",
(prev["full_name"], prev["email"], uid),
)
conn.commit()
def test_dashboard_workspace_select_and_breadcrumb(client):
# select : cookie positionné + shape
r = client.post("/api/workspaces/1/select")
assert r.status_code == 200
assert r.json() == {"status": "ok", "workspace_id": 1}
assert "flowdeck_workspace" in r.headers.get("set-cookie", "")
# breadcrumb d'un dossier inexistant → liste (shape)
b = client.get("/api/local-workspace/breadcrumb", params={"folder": 999999})
assert b.status_code == 200
assert isinstance(b.json()["breadcrumb"], list)