Compare commits

...
1 Commits
Author SHA1 Message Date
bruno ffa1fa89ab fix: A25 + A21 (partiel) — plus d'exception muque, transaction protégée (v7.3.8)
FlowDeck CI / lint (push) Successful in 1m51s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 10m12s
- A25 — 84 `except Exception: pass/…` → `logger.exception("<fonction>")`
  (19 fichiers : api_v2 30, dashboard 10, board 7, sites 5, workspace 5,
  api_v2_helpers 5, …) ; `logger` ajouté là où il manquait (api_v2_helpers,
  sites + `import logging`)
- A25 critique — les `try` autour de `materialize_properties` supprimés dans
  `create_collection_v2` ET `apply_db_template_v2` : un échec interrompt la
  transaction au lieu de commiter une collection sans schéma
- test `test_collection_rollback_when_materialize_fails` (Bearer v2, monkeypatch
  qui lève, assertions : RuntimeError + 0 ligne commitée)
- A21 partiel — `PRAGMA busy_timeout=5000` dans `get_conn()` (point d'entrée
  unique) ; commentaire `ponytail:` : le wrapper async + les 510 call sites
  restent à migrer module par module
- suite **1028/1028** · `ruff check app tests` OK
2026-10-01 08:16:42 -04:00
26 changed files with 151 additions and 100 deletions
+18
View File
@@ -1,5 +1,23 @@
# Changelog - FlowDeck # Changelog - FlowDeck
## v7.3.8 (2026-10-01) — Audit : A25 (exceptions muettes) + A21 partiel
### Fixed
- **A25** — 84 `except Exception: pass/…` deviennent `logger.exception(fn)`
(19 fichiers, 63 dans des handlers `async`) : les échecs du pipeline
d'événements/webhooks et des écritures sont enfin visibles dans les logs
- **A25 (critique)** — plus de `try` autour de `materialize_properties` dans
`create_collection_v2` et `apply_db_template_v2` : un échec annule la
transaction au lieu de commiter une collection sans schéma
- **A21 (partiel)** — `PRAGMA busy_timeout=5000` dans `get_conn()` (le seul
point d'entrée des connexions) ; le wrapper async + les 510 call sites
synchrones sur l'event loop restent à migrer
### Tests
- `test_collection_rollback_when_materialize_fails` → suite **1028/1028**
## v7.3.7 (2026-09-30) — Audit sécurité : A14 (fallback admin agent) ## v7.3.7 (2026-09-30) — Audit sécurité : A14 (fallback admin agent)
### Fixed ### Fixed
+3 -3
View File
@@ -1142,11 +1142,11 @@ Quality DB views, Agent IA Palette → Realtime + E
- [x] **A18 — Collection publiée quelconque + stocké XSS** : `GET /workspace/public/{collection_id}` (`workspace.py:699-719`) « no auth required », **ignore les flags `restricted/private`**, et interpole `coll['name']`/`p['title']` dans un `HTMLResponse(f"""…""")` sans `html.escape`. *Fix : respecter les flags de partage + `html.escape`. Effort : **S**.* - [x] **A18 — Collection publiée quelconque + stocké XSS** : `GET /workspace/public/{collection_id}` (`workspace.py:699-719`) « no auth required », **ignore les flags `restricted/private`**, et interpole `coll['name']`/`p['title']` dans un `HTMLResponse(f"""…""")` sans `html.escape`. *Fix : respecter les flags de partage + `html.escape`. Effort : **S**.*
- [x] **A19 — Liste CSRF trop large (34 préfixes) — TERMINÉ 2026-09-30 : 17 préfixes retirés, aucun préfixe cookie-auth n'est plus exempté** (12 en v7.3.5 après scan + 5 en v7.3.6 après équipement des 46 call sites) (`/db/`, `/workspace`, `/workspace/automations`, `/board/api/favorites`, `/api/pages`, `/api/recents`, `/api/notifications`, `/api/comments`, `/api/automations`, `/api/github`, `/api/admin`, `/api/onboarding`) puis les 5 derniers en v7.3.6 (46 call sites équipés de `X-CSRF-Token` dans agent_panel, settings, local_workspace, library, gitea_workspace, workspace, workspaces, welcome) — il ne reste que du Bearer/callback/public/infra : `csrf.py:21,25` couvre `/api/v2`, `/api/admin`, `/db/`, `/workspace`, `/api/user`, `/api/settings`, `/board/api/pages`, `/api/local-workspace`, `/api/comments`, `/api/agent`, `/api/automations`, `/auth/2fa` — tous **cookie-auth**. Seul `/scim/v2` est justifié par le commentaire de la ligne 19-20. Bonus : `/api/workspace` exempt aussi `/api/workspaces/*`. Filet restant = `SameSite=Lax` par défaut (jamais déclaré explicitement dans `main.py:150`). *Fix : garder un petit ensemble SAFE (webhooks, `/api/v1`, `/api/v2` Bearer, `/scim/v2`, callbacks OAuth/SSO) + ancrer les préfixes ; ajouter le header sur les 49 `fetch()` concernés (helper `csrfFetch` existe déjà : `base.html:892`). Effort : **M**.* - [x] **A19 — Liste CSRF trop large (34 préfixes) — TERMINÉ 2026-09-30 : 17 préfixes retirés, aucun préfixe cookie-auth n'est plus exempté** (12 en v7.3.5 après scan + 5 en v7.3.6 après équipement des 46 call sites) (`/db/`, `/workspace`, `/workspace/automations`, `/board/api/favorites`, `/api/pages`, `/api/recents`, `/api/notifications`, `/api/comments`, `/api/automations`, `/api/github`, `/api/admin`, `/api/onboarding`) puis les 5 derniers en v7.3.6 (46 call sites équipés de `X-CSRF-Token` dans agent_panel, settings, local_workspace, library, gitea_workspace, workspace, workspaces, welcome) — il ne reste que du Bearer/callback/public/infra : `csrf.py:21,25` couvre `/api/v2`, `/api/admin`, `/db/`, `/workspace`, `/api/user`, `/api/settings`, `/board/api/pages`, `/api/local-workspace`, `/api/comments`, `/api/agent`, `/api/automations`, `/auth/2fa` — tous **cookie-auth**. Seul `/scim/v2` est justifié par le commentaire de la ligne 19-20. Bonus : `/api/workspace` exempt aussi `/api/workspaces/*`. Filet restant = `SameSite=Lax` par défaut (jamais déclaré explicitement dans `main.py:150`). *Fix : garder un petit ensemble SAFE (webhooks, `/api/v1`, `/api/v2` Bearer, `/scim/v2`, callbacks OAuth/SSO) + ancrer les préfixes ; ajouter le header sur les 49 `fetch()` concernés (helper `csrfFetch` existe déjà : `base.html:892`). Effort : **M**.*
- [ ] **A20 — CSP sans filet : `script-src 'unsafe-inline' 'unsafe-eval'`** (`security.py:67`) → aucun nonce/hash ; combiné à A10, chaque sink XSS ci-dessus tourne sans violation CSP. *Fix : externaliser le JS inline (A27), passer à `'nonce-…'`, retirer `'unsafe-eval'` (Alpine/HTMX n'en ont pas besoin par défaut), resserrer `img-src`/`connect-src`. Effort : **L**.* - [ ] **A20 — CSP sans filet : `script-src 'unsafe-inline' 'unsafe-eval'`** (`security.py:67`) → aucun nonce/hash ; combiné à A10, chaque sink XSS ci-dessus tourne sans violation CSP. *Fix : externaliser le JS inline (A27), passer à `'nonce-…'`, retirer `'unsafe-eval'` (Alpine/HTMX n'en ont pas besoin par défaut), resserrer `img-src`/`connect-src`. Effort : **L**.*
- [ ] **A21 — `sqlite3` synchrone sur l'event loop** : `get_conn()` (`db.py:833-843`) est synchrone et **510 des 689 `async def` de routes** l'appellent (805 occurrences au total ; 0 `run_in_threadpool`, 1 seul `asyncio.to_thread` dans tout le dépôt : `semantic_search.py:262`) ; connexion neuve par requête (`connect` + 2 PRAGMA), **aucun `busy_timeout`**. Chaque requête bloque la boucle. *Fix : wrapper async (`anyio.to_thread.run_sync`) partagé, migrer d'abord `api_v2`/`dashboard`/`collections`/`board` + `PRAGMA busy_timeout=5000`. Effort : **M**.* - [ ] **A21 — `sqlite3` synchrone sur l'event loop — PARTIEL 2026-10-01 : `PRAGMA busy_timeout=5000` ajouté au point d'entrée unique `get_conn()` (db.py)** ; reste le wrapper async `anyio.to_thread` + la migration des 510 call sites : `get_conn()` (`db.py:833-843`) est synchrone et **510 des 689 `async def` de routes** l'appellent (805 occurrences au total ; 0 `run_in_threadpool`, 1 seul `asyncio.to_thread` dans tout le dépôt : `semantic_search.py:262`) ; connexion neuve par requête (`connect` + 2 PRAGMA), **aucun `busy_timeout`**. Chaque requête bloque la boucle. *Fix : wrapper async (`anyio.to_thread.run_sync`) partagé, migrer d'abord `api_v2`/`dashboard`/`collections`/`board` + `PRAGMA busy_timeout=5000`. Effort : **M**.*
- [x] **A22 — Validateur d'upload = code mort** : `validate_upload()` + `ALLOWED_EXTENSIONS` + `MAX_UPLOAD_SIZE` (`security.py:33-40`) n'ont **aucun appelant** ; `upload_local_workspace_file` (`dashboard.py:1507-1574`) fait `file_path.write_bytes(content)` après seulement `Path(filename).name` — ni taille, ni extension, ni auth, et `/api/local-workspace` est exclu du CSRF. Chemin aussi codé en dur `Path(f"/data/uploads/...")` au lieu de `FLOWDECK_DATA_DIR`. *Fix : appeler `validate_upload()` avant `read()` + session. Effort : **S**.* - [x] **A22 — Validateur d'upload = code mort** : `validate_upload()` + `ALLOWED_EXTENSIONS` + `MAX_UPLOAD_SIZE` (`security.py:33-40`) n'ont **aucun appelant** ; `upload_local_workspace_file` (`dashboard.py:1507-1574`) fait `file_path.write_bytes(content)` après seulement `Path(filename).name` — ni taille, ni extension, ni auth, et `/api/local-workspace` est exclu du CSRF. Chemin aussi codé en dur `Path(f"/data/uploads/...")` au lieu de `FLOWDECK_DATA_DIR`. *Fix : appeler `validate_upload()` avant `read()` + session. Effort : **S**.*
- [x] **A23 — N+1 avérés** : `dashboard.py:905` (`COUNT(*) FROM pages` par page dans une boucle de 20), `board.py:2141-2146` (INSERT + `_extract_ai_keywords` par issue), `collections.py:378` (INSERT par propriété en boucle). *Fix : une agrégation `GROUP BY` / un `executemany`. Effort : **S**.* - [x] **A23 — N+1 avérés** : `dashboard.py:905` (`COUNT(*) FROM pages` par page dans une boucle de 20), `board.py:2141-2146` (INSERT + `_extract_ai_keywords` par issue), `collections.py:378` (INSERT par propriété en boucle). *Fix : une agrégation `GROUP BY` / un `executemany`. Effort : **S**.*
- [x] **A24 — 2 routes silencieusement mortes (vérifié en servant l'app)** : `GET /workspace` → `dashboard.py:820` (HTML, inclus 1er, `main.py:158`) écrase `workspace.py:30` **et** `library.py:537` (JSON, inclus après) ; `GET /api/projects` → `projects.py:24` (inclus 161) écrase `api.py:82` (handler Gitea). *Fix : supprimer/renommer les doublons + assertion au boot « pas deux routes même méthode+chemin ». Effort : **XS**.* - [x] **A24 — 2 routes silencieusement mortes (vérifié en servant l'app)** : `GET /workspace` → `dashboard.py:820` (HTML, inclus 1er, `main.py:158`) écrase `workspace.py:30` **et** `library.py:537` (JSON, inclus après) ; `GET /api/projects` → `projects.py:24` (inclus 161) écrase `api.py:82` (handler Gitea). *Fix : supprimer/renommer les doublons + assertion au boot « pas deux routes même méthode+chemin ». Effort : **XS**.*
- [ ] **A25 — 92 `except Exception: pass` dont 68 dans des handlers async, 0 loggé** (471 `except Exception` au total, 102 dans `api_v2.py` seul) ; pire : `api_v2.py:504-511` avale l'échec de `materialize_properties` **dans la transaction de création de collection** (collection commitée sans schéma), `api_v2.py:859`, et tous les `await _fire_event(...)` (`1750`, `885`, `903`) → pipeline d'événements/webhooks cassé = invisible. *Fix : `logger.exception` sur les 68 sites ; supprimer le try autour des 2 writes de `create_collection_v2`. Effort : **M**.* - [x] **A25 — 92 `except Exception: pass` dont 68 dans des handlers async, 0 loggé** (471 `except Exception` au total, 102 dans `api_v2.py` seul) ; pire : `api_v2.py:504-511` avale l'échec de `materialize_properties` **dans la transaction de création de collection** (collection commitée sans schéma), `api_v2.py:859`, et tous les `await _fire_event(...)` (`1750`, `885`, `903`) → pipeline d'événements/webhooks cassé = invisible. *Fix : `logger.exception` sur les 68 sites ; supprimer le try autour des 2 writes de `create_collection_v2`. Effort : **M**.*
### 🟡 P2 — Moyennes ### 🟡 P2 — Moyennes
@@ -1185,4 +1185,4 @@ Quality DB views, Agent IA Palette → Realtime + E
→ Puis **A3–A8** (le bloc « fallback admin ») d'un seul tenant, puis **A10** (autoescape) qui débloque A18/A20. → Puis **A3–A8** (le bloc « fallback admin ») d'un seul tenant, puis **A10** (autoescape) qui débloque A18/A20.
*Audit produit le 2026-09-30 · 43 items · aucun code modifié ( ROADMAP seul ).* *Audit produit le 2026-09-30 · 43 items · aucun code modifié ( ROADMAP seul ).*
→ **A1–A9 corrigés le 2026-09-30** : deps réinstallées (`pyotp`/`webauthn`/`cbor2`), rebinding de `settings` supprimé dans `test_v54.py` → **suite 1016/1016 verts**, cycle committé (`1706ad1`) + tag `v7.3.0` poussé, `.db`/fichiers de test désindexés, `APP_SECRET_KEY` roté dans `.env` (sessions révoquées) · **A3–A8 : 401 sans session sur les routes de compte (mdp actuel exigé), tokens `/api/v1` + `/api/user` sans session → 401, CRUD membres d'espace sous session+role admin, `_require_view`/`_require_edit` sans session → 404/401, création/lecture de page sous session, `/board/api/pages` + `/api/user` sortis du CSRF exempt, seed admin sans mdp en dur (aléatoire ou `FLOWDECK_ADMIN_PASSWORD`). Tests : client connecte par defaut (`_TestSessionAuth`), helper `anon()` sur les 40 tests d'anonymat → suite 1016/1016 + ruff OK, commit `d125eb3` · **A10 : `app/templating.py` (ENV partagé + autoescape `select_autoescape(["html"])`) remplace les 29 instantiations, `|safe` retriés (corps d'issue/commentaires echappes, `sidebar_config` en `|tojson`) → suite 1016/1016, version 7.3.1 · **A11 (traversal avatar) + A18 (vue publique : 404 restricted/private + html.escape)** : `tests/test_audit_p0_fixes.py`, suite 1019/1019, version 7.3.2 · **A12/A13/A15/A17/A22/A23/A24** : garde SSRF par hop, routers automations + /api sous session/Bearer, webhooks admin+URL publique, uploads validés, N+1 en GROUP BY/executemany, 2 doublons de routes supprimés → suite 1025/1025, version 7.3.3 · **A16** : exports + pièces jointes sous session + `can_view_page` → suite 1026/1026, version 7.3.4 · **A19 (partiel)** : 12 préfixes CSRF retirés après scan front (12 préfixes = tous ceux dont les appels portent déjà le header), reste 5 préfixes / 49 fetchs → suite 1026/1026, version 7.3.5 · **A19 terminé** : 46 call sites front équipés, plus aucun préfixe cookie-auth exempté → suite 1026/1026, version 7.3.6 · **A14** : fallback `admin` supprimé de `_current_user_id`/`_current_admin` (401 sans session), `api_base` validé scheme http(s) sans identifiants (hôtes privés acceptés = Ollama localhost, commentaire `ponytail:`) → suite 1027/1027, version 7.3.7.** → **A1–A9 corrigés le 2026-09-30** : deps réinstallées (`pyotp`/`webauthn`/`cbor2`), rebinding de `settings` supprimé dans `test_v54.py` → **suite 1016/1016 verts**, cycle committé (`1706ad1`) + tag `v7.3.0` poussé, `.db`/fichiers de test désindexés, `APP_SECRET_KEY` roté dans `.env` (sessions révoquées) · **A3–A8 : 401 sans session sur les routes de compte (mdp actuel exigé), tokens `/api/v1` + `/api/user` sans session → 401, CRUD membres d'espace sous session+role admin, `_require_view`/`_require_edit` sans session → 404/401, création/lecture de page sous session, `/board/api/pages` + `/api/user` sortis du CSRF exempt, seed admin sans mdp en dur (aléatoire ou `FLOWDECK_ADMIN_PASSWORD`). Tests : client connecte par defaut (`_TestSessionAuth`), helper `anon()` sur les 40 tests d'anonymat → suite 1016/1016 + ruff OK, commit `d125eb3` · **A10 : `app/templating.py` (ENV partagé + autoescape `select_autoescape(["html"])`) remplace les 29 instantiations, `|safe` retriés (corps d'issue/commentaires echappes, `sidebar_config` en `|tojson`) → suite 1016/1016, version 7.3.1 · **A11 (traversal avatar) + A18 (vue publique : 404 restricted/private + html.escape)** : `tests/test_audit_p0_fixes.py`, suite 1019/1019, version 7.3.2 · **A12/A13/A15/A17/A22/A23/A24** : garde SSRF par hop, routers automations + /api sous session/Bearer, webhooks admin+URL publique, uploads validés, N+1 en GROUP BY/executemany, 2 doublons de routes supprimés → suite 1025/1025, version 7.3.3 · **A16** : exports + pièces jointes sous session + `can_view_page` → suite 1026/1026, version 7.3.4 · **A19 (partiel)** : 12 préfixes CSRF retirés après scan front (12 préfixes = tous ceux dont les appels portent déjà le header), reste 5 préfixes / 49 fetchs → suite 1026/1026, version 7.3.5 · **A19 terminé** : 46 call sites front équipés, plus aucun préfixe cookie-auth exempté → suite 1026/1026, version 7.3.6 · **A14** : fallback `admin` supprimé de `_current_user_id`/`_current_admin` (401 sans session), `api_base` validé scheme http(s) sans identifiants (hôtes privés acceptés = Ollama localhost, commentaire `ponytail:`) → suite 1027/1027, version 7.3.7 · **A25** : 84 `except Exception: pass` remplacés par `logger.exception(fn)` (19 fichiers, +`logger` manquants), try supprimé sur `materialize_properties` dans `create_collection_v2` et `apply_db_template_v2` (rollback au lieu d'un commit sans schéma), test de rollback · **A21 (partiel)** : `busy_timeout=5000` dans `get_conn()` → suite 1028/1028, version 7.3.8.**
+1 -1
View File
@@ -1 +1 @@
7.3.7 7.3.8
+1 -1
View File
@@ -1,6 +1,6 @@
# WORKLOAD — FlowDeck Notion Clone # WORKLOAD — FlowDeck Notion Clone
> **Début**: 2026-07-08 | **Version**: v7.3.7 (audit sécurité — A14 terminé) | **Statut**: EN COURS 🔄 > **Début**: 2026-07-08 | **Version**: v7.3.8 (audit sécurité — A25 + A21 partiel) | **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` > **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 ## Avancement Global
+1 -1
View File
@@ -171,7 +171,7 @@ def _touch_session(sid: str) -> None:
) )
conn.commit() conn.commit()
except Exception: except Exception:
pass logger.exception("_touch_session")
# FastAPI dependency # FastAPI dependency
+5
View File
@@ -837,6 +837,11 @@ def get_conn():
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON") conn.execute("PRAGMA foreign_keys=ON")
# A21 (partiel) : un seul writer par DB — sans timeout la requête concurrente
# échoue immédiatement avec « database is locked » (tests xdist, schedulers).
# ponytail: le reste d'A21 (wrapper async + 510 call sites synchrones sur
# l'event loop) reste à migrer module par module.
conn.execute("PRAGMA busy_timeout=5000")
try: try:
yield conn yield conn
finally: finally:
+1 -1
View File
@@ -153,7 +153,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI( app = FastAPI(
title="FlowDeck", title="FlowDeck",
version="7.3.7", version="7.3.8",
docs_url="/docs", docs_url="/docs",
redoc_url="/redoc", redoc_url="/redoc",
lifespan=lifespan, lifespan=lifespan,
+1 -1
View File
@@ -862,7 +862,7 @@ def _migration_v630_api_v2(conn: sqlite3.Connection) -> None:
try: try:
conn.execute("UPDATE api_tokens SET scopes='read,write' WHERE scopes='' OR scopes IS NULL") conn.execute("UPDATE api_tokens SET scopes='read,write' WHERE scopes='' OR scopes IS NULL")
except Exception: except Exception:
pass logger.exception("_migration_v630_api_v2")
conn.execute( conn.execute(
"""CREATE TABLE IF NOT EXISTS api_audit_log ( """CREATE TABLE IF NOT EXISTS api_audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
+2 -2
View File
@@ -67,12 +67,12 @@ async def health(request: Request):
conn.execute("SELECT 1") conn.execute("SELECT 1")
db_ok = True db_ok = True
except Exception: except Exception:
pass logger.exception("health")
try: try:
await gitea.get_user_repos(page=1, limit=1) await gitea.get_user_repos(page=1, limit=1)
gitea_ok = True gitea_ok = True
except Exception: except Exception:
pass logger.exception("health")
return { return {
"status": "ok" if (db_ok and gitea_ok) else "degraded", "status": "ok" if (db_ok and gitea_ok) else "degraded",
+37 -42
View File
@@ -165,7 +165,7 @@ async def get_me(request: Request, authorization: str | None = Header(default=No
try: try:
d[k] = json.loads(d[k] or "{}") d[k] = json.loads(d[k] or "{}")
except Exception: except Exception:
pass logger.exception("get_me")
# never expose secrets # never expose secrets
return d return d
@@ -261,7 +261,7 @@ async def create_workspace(request: Request, authorization: str | None = Header(
try: try:
conn.execute("INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, 'owner')", (wid, user["id"])) conn.execute("INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, 'owner')", (wid, user["id"]))
except Exception: except Exception:
pass logger.exception("create_workspace")
conn.commit() conn.commit()
row = conn.execute("SELECT * FROM workspaces WHERE id=?", (wid,)).fetchone() row = conn.execute("SELECT * FROM workspaces WHERE id=?", (wid,)).fetchone()
audit_log(user, "workspace.create", "workspace", wid, name, request) audit_log(user, "workspace.create", "workspace", wid, name, request)
@@ -497,17 +497,13 @@ async def create_collection_v2(request: Request, authorization: str | None = Hea
with get_conn() as conn: with get_conn() as conn:
cur = conn.execute("INSERT INTO collections (name, description, icon, schema_json, workspace_id, created_by) VALUES (?, ?, ?, ?, ?, ?)", (name, description, icon, schema_json, workspace_id, user["id"])) cur = conn.execute("INSERT INTO collections (name, description, icon, schema_json, workspace_id, created_by) VALUES (?, ?, ?, ?, ?, ?)", (name, description, icon, schema_json, workspace_id, user["id"]))
cid = cur.lastrowid cid = cur.lastrowid
# materialize properties if schema provided # materialize properties if schema provided — A25 : PAS de try ici,
try: # une exception doit interrompre la transaction (sinon la collection est
from app.services.db_templates import materialize_properties # commitée sans son schéma et l'erreur disparaît).
materialize_properties(conn, cid, schema) from app.services.db_templates import materialize_properties
except Exception: materialize_properties(conn, cid, schema)
pass
# default view # default view
try: conn.execute("INSERT INTO collection_views (collection_id, name, view_type, config_json) VALUES (?,?,?,?)", (cid, "Default View", "table", json.dumps({"visible_properties": ["Title"]})))
conn.execute("INSERT INTO collection_views (collection_id, name, view_type, config_json) VALUES (?, ?, ?, ?)", (cid, "Default View", "table", json.dumps({"visible_properties": ["Title"]})))
except Exception:
pass
conn.commit() conn.commit()
row = conn.execute("SELECT * FROM collections WHERE id=?", (cid,)).fetchone() row = conn.execute("SELECT * FROM collections WHERE id=?", (cid,)).fetchone()
audit_log(user, "collection.create", "collection", cid, name, request) audit_log(user, "collection.create", "collection", cid, name, request)
@@ -593,20 +589,20 @@ async def create_linked_db(collection_id: int, request: Request, authorization:
try: try:
conn.execute("INSERT INTO collection_data_sources (collection_id, source_collection_id, is_linked) VALUES (?, ?, 1)", (nid, collection_id)) conn.execute("INSERT INTO collection_data_sources (collection_id, source_collection_id, is_linked) VALUES (?, ?, 1)", (nid, collection_id))
except Exception: except Exception:
pass logger.exception("create_linked_db")
# copy views + properties (light) # copy views + properties (light)
rows = conn.execute("SELECT * FROM collection_properties WHERE collection_id=?", (collection_id,)).fetchall() rows = conn.execute("SELECT * FROM collection_properties WHERE collection_id=?", (collection_id,)).fetchall()
for p in rows: for p in rows:
try: try:
conn.execute("INSERT INTO collection_properties (collection_id, name, prop_type, options_json, position) VALUES (?, ?, ?, ?, ?)", (nid, p["name"], p["prop_type"], p["options_json"], p["position"])) conn.execute("INSERT INTO collection_properties (collection_id, name, prop_type, options_json, position) VALUES (?, ?, ?, ?, ?)", (nid, p["name"], p["prop_type"], p["options_json"], p["position"]))
except Exception: except Exception:
pass logger.exception("create_linked_db")
vrows = conn.execute("SELECT * FROM collection_views WHERE collection_id=?", (collection_id,)).fetchall() vrows = conn.execute("SELECT * FROM collection_views WHERE collection_id=?", (collection_id,)).fetchall()
for v in vrows: for v in vrows:
try: try:
conn.execute("INSERT INTO collection_views (collection_id, name, view_type, config_json, position) VALUES (?, ?, ?, ?, ?)", (nid, v["name"], v["view_type"], v["config_json"], v["position"])) conn.execute("INSERT INTO collection_views (collection_id, name, view_type, config_json, position) VALUES (?, ?, ?, ?, ?)", (nid, v["name"], v["view_type"], v["config_json"], v["position"]))
except Exception: except Exception:
pass logger.exception("create_linked_db")
conn.commit() conn.commit()
audit_log(user, "collection.linked", "collection", nid, f"src={collection_id}", request) audit_log(user, "collection.linked", "collection", nid, f"src={collection_id}", request)
return {"id": nid, "name": name, "status": "created"} return {"id": nid, "name": name, "status": "created"}
@@ -766,7 +762,7 @@ async def create_collection_page_v2(collection_id: int, request: Request, author
pass pass
# light validation: we rely on existing validators # light validation: we rely on existing validators
except Exception: except Exception:
pass logger.exception("create_collection_page_v2")
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_pages WHERE collection_id=?", (collection_id,)).fetchone()[0] max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_pages WHERE collection_id=?", (collection_id,)).fetchone()[0]
# apply auto props # apply auto props
try: try:
@@ -774,7 +770,7 @@ async def create_collection_page_v2(collection_id: int, request: Request, author
from app.services.property_types import apply_auto_properties as _aap from app.services.property_types import apply_auto_properties as _aap
_aap(props_list, prop_vals, user, is_create=True) _aap(props_list, prop_vals, user, is_create=True)
except Exception: except Exception:
pass logger.exception("create_collection_page_v2")
cur = conn.execute("INSERT INTO collection_pages (collection_id, title, icon, position, parent_id, property_values_json) VALUES (?, ?, ?, ?, ?, ?)", (collection_id, title, icon, max_pos, parent_id, json.dumps(prop_vals))) cur = conn.execute("INSERT INTO collection_pages (collection_id, title, icon, position, parent_id, property_values_json) VALUES (?, ?, ?, ?, ?, ?)", (collection_id, title, icon, max_pos, parent_id, json.dumps(prop_vals)))
pid = cur.lastrowid pid = cur.lastrowid
conn.commit() conn.commit()
@@ -783,7 +779,7 @@ async def create_collection_page_v2(collection_id: int, request: Request, author
try: try:
await _fire_event("collection.page.created", {"page_id": pid, "collection_id": collection_id, "title": title}) await _fire_event("collection.page.created", {"page_id": pid, "collection_id": collection_id, "title": title})
except Exception: except Exception:
pass logger.exception("create_collection_page_v2")
data = {"id": pid, "title": title, "status": "created", "page": row_to_dict(row)} data = {"id": pid, "title": title, "status": "created", "page": row_to_dict(row)}
key = (request.headers.get("Idempotency-Key") or "").strip() key = (request.headers.get("Idempotency-Key") or "").strip()
if key: if key:
@@ -857,14 +853,14 @@ async def patch_page_v2(page_id: int, request: Request, authorization: str | Non
from app.services.property_types import apply_auto_properties as _aap from app.services.property_types import apply_auto_properties as _aap
_aap(props_list, stored, user, is_create=False) _aap(props_list, stored, user, is_create=False)
except Exception: except Exception:
pass logger.exception("patch_page_v2")
conn.execute("UPDATE collection_pages SET title=?, icon=?, position=?, parent_id=?, property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, icon, pos, parent_id, json.dumps(stored), page_id)) conn.execute("UPDATE collection_pages SET title=?, icon=?, position=?, parent_id=?, property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, icon, pos, parent_id, json.dumps(stored), page_id))
conn.commit() conn.commit()
audit_log(user, "page.update", "collection_page", page_id, "", request) audit_log(user, "page.update", "collection_page", page_id, "", request)
try: try:
await _fire_event("collection.page.updated", {"page_id": page_id, "collection_id": row["collection_id"], "title": title}) await _fire_event("collection.page.updated", {"page_id": page_id, "collection_id": row["collection_id"], "title": title})
except Exception: except Exception:
pass logger.exception("patch_page_v2")
return {"id": page_id, "status": "updated"} return {"id": page_id, "status": "updated"}
@router.delete("/pages/{page_id}") @router.delete("/pages/{page_id}")
@@ -883,7 +879,7 @@ async def delete_page_v2(page_id: int, request: Request, authorization: str | No
try: try:
await _fire_event("collection.page.deleted", {"page_id": page_id, "collection_id": row["collection_id"]}) await _fire_event("collection.page.deleted", {"page_id": page_id, "collection_id": row["collection_id"]})
except Exception: except Exception:
pass logger.exception("delete_page_v2")
return {"id": page_id, "status": "deleted"} return {"id": page_id, "status": "deleted"}
@router.post("/pages/{page_id}/restore") @router.post("/pages/{page_id}/restore")
@@ -901,7 +897,7 @@ async def restore_page_v2(page_id: int, request: Request, authorization: str | N
try: try:
await _fire_event("page.restored", {"page_id": page_id}) await _fire_event("page.restored", {"page_id": page_id})
except Exception: except Exception:
pass logger.exception("restore_page_v2")
return {"id": page_id, "status": "restored"} return {"id": page_id, "status": "restored"}
raise HTTPException(404, "Page not found or not deleted") raise HTTPException(404, "Page not found or not deleted")
@@ -1109,7 +1105,7 @@ async def create_relation_v2(prop_id: int, request: Request, authorization: str
try: try:
conn.execute("INSERT INTO collection_properties (collection_id, name, prop_type, related_collection_id, reverse_name, position) VALUES (?, ?, 'relation', ?, ?, ?)", (related_id, reverse, row["collection_id"], "", max_pos)) conn.execute("INSERT INTO collection_properties (collection_id, name, prop_type, related_collection_id, reverse_name, position) VALUES (?, ?, 'relation', ?, ?, ?)", (related_id, reverse, row["collection_id"], "", max_pos))
except Exception: except Exception:
pass logger.exception("create_relation_v2")
conn.commit() conn.commit()
return {"id": prop_id, "status": "updated"} return {"id": prop_id, "status": "updated"}
@@ -1184,7 +1180,7 @@ async def create_view_v2(collection_id: int, request: Request, authorization: st
try: try:
await _fire_event("collection.view.created", {"view_id": vid, "collection_id": collection_id, "name": name, "view_type": vtype}) await _fire_event("collection.view.created", {"view_id": vid, "collection_id": collection_id, "name": name, "view_type": vtype})
except Exception: except Exception:
pass logger.exception("create_view_v2")
return {"id": vid, "name": name, "view_type": vtype, "status": "created"} return {"id": vid, "name": name, "view_type": vtype, "status": "created"}
@router.patch("/views/{view_id}") @router.patch("/views/{view_id}")
@@ -1208,7 +1204,7 @@ async def patch_view_v2(view_id: int, request: Request, authorization: str | Non
try: try:
cfg.update(json.loads(body["config_json"]) if isinstance(body["config_json"], str) else body["config_json"]) cfg.update(json.loads(body["config_json"]) if isinstance(body["config_json"], str) else body["config_json"])
except Exception: except Exception:
pass logger.exception("patch_view_v2")
# also flat keys # also flat keys
for k in ("group_by", "sub_group_by", "wip_limits", "card_size", "cover_property", "cover_mode", "card_properties", "visible_properties", "filters", "sorts", "date_property"): for k in ("group_by", "sub_group_by", "wip_limits", "card_size", "cover_property", "cover_mode", "card_properties", "visible_properties", "filters", "sorts", "date_property"):
if k in body: if k in body:
@@ -1350,7 +1346,7 @@ async def create_comment_v2(page_id: int, request: Request, authorization: str |
try: try:
await _fire_event("comment.added", {"comment_id": nid, "page_id": page_id, "user_id": user["id"]}) await _fire_event("comment.added", {"comment_id": nid, "page_id": page_id, "user_id": user["id"]})
except Exception: except Exception:
pass logger.exception("create_comment_v2")
return {"id": nid, "status": "created", "comment": row_to_dict(row)} return {"id": nid, "status": "created", "comment": row_to_dict(row)}
@router.patch("/comments/{comment_id}") @router.patch("/comments/{comment_id}")
@@ -1378,7 +1374,7 @@ async def patch_comment_v2(comment_id: int, request: Request, authorization: str
try: try:
await _fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]}) await _fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]})
except Exception: except Exception:
pass logger.exception("patch_comment_v2")
return {"id": comment_id, "status": "updated"} return {"id": comment_id, "status": "updated"}
@router.delete("/comments/{comment_id}") @router.delete("/comments/{comment_id}")
@@ -1419,13 +1415,13 @@ async def create_mention_v2(page_id: int, request: Request, authorization: str |
conn.execute("INSERT INTO notifications (user_id, actor_id, ntype, title, message, resource_type, resource_id, url) VALUES (?, ?, 'mention', 'You were mentioned', ?, 'page', ?, ?)", (uid, user["id"], body.get("message") or f"Mentioned in page {page_id}", page_id, f"/pages/{page_id}")) conn.execute("INSERT INTO notifications (user_id, actor_id, ntype, title, message, resource_type, resource_id, url) VALUES (?, ?, 'mention', 'You were mentioned', ?, 'page', ?, ?)", (uid, user["id"], body.get("message") or f"Mentioned in page {page_id}", page_id, f"/pages/{page_id}"))
created += 1 created += 1
except Exception: except Exception:
pass logger.exception("create_mention_v2")
conn.commit() conn.commit()
if created: if created:
try: try:
await _fire_event("mention.added", {"page_id": page_id, "user_ids": [u for u in targets if isinstance(u, int)], "count": created}) await _fire_event("mention.added", {"page_id": page_id, "user_ids": [u for u in targets if isinstance(u, int)], "count": created})
except Exception: except Exception:
pass logger.exception("create_mention_v2")
return {"mentions": created, "status": "created"} return {"mentions": created, "status": "created"}
# ── Notifications ───────────────────────────────────────────────────────── # ── Notifications ─────────────────────────────────────────────────────────
@@ -1522,7 +1518,7 @@ async def add_favorite_v2(request: Request, authorization: str | None = Header(d
try: try:
await _fire_event("favorite.added", {"page_id": pid, "user_id": user["id"]}) await _fire_event("favorite.added", {"page_id": pid, "user_id": user["id"]})
except Exception: except Exception:
pass logger.exception("add_favorite_v2")
return {"page_id": pid, "status": "added"} return {"page_id": pid, "status": "added"}
@router.delete("/favorites/{page_id}") @router.delete("/favorites/{page_id}")
@@ -1537,7 +1533,7 @@ async def remove_favorite_v2(page_id: int, request: Request, authorization: str
try: try:
await _fire_event("favorite.removed", {"page_id": page_id, "user_id": user["id"]}) await _fire_event("favorite.removed", {"page_id": page_id, "user_id": user["id"]})
except Exception: except Exception:
pass logger.exception("remove_favorite_v2")
return {"page_id": page_id, "status": "removed"} return {"page_id": page_id, "status": "removed"}
@router.get("/tags") @router.get("/tags")
@@ -1687,7 +1683,7 @@ async def create_share_v2(page_id: int, request: Request, authorization: str | N
try: try:
await _fire_event("page.shared", {"page_id": page_id, "share_id": nid, "permission": perm}) await _fire_event("page.shared", {"page_id": page_id, "share_id": nid, "permission": perm})
except Exception: except Exception:
pass logger.exception("create_share_v2")
return {"id": nid, "page_id": page_id, "status": "shared"} return {"id": nid, "page_id": page_id, "status": "shared"}
@router.patch("/shares/{share_id}") @router.patch("/shares/{share_id}")
@@ -1748,7 +1744,7 @@ async def publish_page_v2(page_id: int, request: Request, authorization: str | N
try: try:
await _fire_event("page.published", {"page_id": page_id, "slug": slug}) await _fire_event("page.published", {"page_id": page_id, "slug": slug})
except Exception: except Exception:
pass logger.exception("publish_page_v2")
return {"page_id": page_id, "slug": slug, "url": f"/p/{slug}", "status": "published"} return {"page_id": page_id, "slug": slug, "url": f"/p/{slug}", "status": "published"}
@router.delete("/pages/{page_id}/publish") @router.delete("/pages/{page_id}/publish")
@@ -1763,7 +1759,7 @@ async def unpublish_page_v2(page_id: int, request: Request, authorization: str |
try: try:
await _fire_event("page.unpublished", {"page_id": page_id}) await _fire_event("page.unpublished", {"page_id": page_id})
except Exception: except Exception:
pass logger.exception("unpublish_page_v2")
return {"page_id": page_id, "status": "unpublished"} return {"page_id": page_id, "status": "unpublished"}
# ── History ─────────────────────────────────────────────────────────────── # ── History ───────────────────────────────────────────────────────────────
@@ -1844,7 +1840,7 @@ async def create_sprint_v2(collection_id: int, request: Request, authorization:
try: try:
await _fire_event("sprint.created", {"sprint_id": sid, "collection_id": collection_id, "name": name}) await _fire_event("sprint.created", {"sprint_id": sid, "collection_id": collection_id, "name": name})
except Exception: except Exception:
pass logger.exception("create_sprint_v2")
return {"id": sid, "name": name, "status": "created"} return {"id": sid, "name": name, "status": "created"}
@router.patch("/sprints/{sprint_id}") @router.patch("/sprints/{sprint_id}")
@@ -1871,7 +1867,7 @@ async def patch_sprint_v2(sprint_id: int, request: Request, authorization: str |
try: try:
await _fire_event("sprint.updated", {"sprint_id": sprint_id, "collection_id": row["collection_id"], "name": name, "status": status}) await _fire_event("sprint.updated", {"sprint_id": sprint_id, "collection_id": row["collection_id"], "name": name, "status": status})
except Exception: except Exception:
pass logger.exception("patch_sprint_v2")
return {"id": sprint_id, "status": "updated"} return {"id": sprint_id, "status": "updated"}
@router.delete("/sprints/{sprint_id}") @router.delete("/sprints/{sprint_id}")
@@ -1937,7 +1933,7 @@ async def burndown_v2(sprint_id: int, request: Request, authorization: str | Non
completed += 1 completed += 1
break break
except Exception: except Exception:
pass logger.exception("burndown_v2")
remaining = total - completed remaining = total - completed
# ideal linear # ideal linear
ideal = [round(total * (1 - i / 10)) for i in range(11)] ideal = [round(total * (1 - i / 10)) for i in range(11)]
@@ -2047,11 +2043,10 @@ async def apply_db_template_v2(template_id: int, request: Request, authorization
schema = json.loads(tpl["schema_json"] or "[]") schema = json.loads(tpl["schema_json"] or "[]")
cur = conn.execute("INSERT INTO collections (name, description, icon, schema_json, workspace_id, created_by) VALUES (?, ?, ?, ?, ?, ?)", (name, tpl["description"], tpl["icon"] if "icon" in tpl.keys() else "📋", json.dumps(schema), body.get("workspace_id"), user["id"])) cur = conn.execute("INSERT INTO collections (name, description, icon, schema_json, workspace_id, created_by) VALUES (?, ?, ?, ?, ?, ?)", (name, tpl["description"], tpl["icon"] if "icon" in tpl.keys() else "📋", json.dumps(schema), body.get("workspace_id"), user["id"]))
cid = cur.lastrowid cid = cur.lastrowid
try: # A25 : pas de try — un échec de matérialisation doit interrompre la
from app.services.db_templates import materialize_properties # transaction plutôt que de commiter une collection sans schéma.
materialize_properties(conn, cid, schema) from app.services.db_templates import materialize_properties
except Exception: materialize_properties(conn, cid, schema)
pass
conn.commit() conn.commit()
return {"collection_id": cid, "name": name, "status": "created"} return {"collection_id": cid, "name": name, "status": "created"}
+1 -1
View File
@@ -573,4 +573,4 @@ def _log_login(user_id: int, request: Request):
) )
conn.commit() conn.commit()
except Exception: except Exception:
pass logger.exception("_log_login")
+7 -7
View File
@@ -695,7 +695,7 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
local_ws_id = row["id"] local_ws_id = row["id"]
workspace_pages = _load_workspace_pages(str(local_ws_id)) workspace_pages = _load_workspace_pages(str(local_ws_id))
except Exception: except Exception:
pass logger.exception("_sidebar_data")
elif ws_cookie and user: elif ws_cookie and user:
try: try:
wsi = int(ws_cookie) wsi = int(ws_cookie)
@@ -781,7 +781,7 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
elif t["provider"] == "github": elif t["provider"] == "github":
github_linked = True github_linked = True
except Exception: except Exception:
pass logger.exception("_sidebar_data")
return {"workspace_name": ws_name, "workspace_initial": ws_name[0].upper() if ws_name else "B", return {"workspace_name": ws_name, "workspace_initial": ws_name[0].upper() if ws_name else "B",
"active_ws_name": active_ws_name, "active_ws_name": active_ws_name,
@@ -982,7 +982,7 @@ async def add_favorite(request: Request, page_id: int):
try: try:
await fire_event("favorite.added", {"page_id": page_id, "user_id": uid}) await fire_event("favorite.added", {"page_id": page_id, "user_id": uid})
except Exception: except Exception:
pass logger.exception("add_favorite")
return {"status": "added", "page_id": page_id} return {"status": "added", "page_id": page_id}
@@ -997,7 +997,7 @@ async def remove_favorite(request: Request, page_id: int):
try: try:
await fire_event("favorite.removed", {"page_id": page_id, "user_id": uid}) await fire_event("favorite.removed", {"page_id": page_id, "user_id": uid})
except Exception: except Exception:
pass logger.exception("remove_favorite")
return {"status": "removed", "page_id": page_id} return {"status": "removed", "page_id": page_id}
# ═══════════ Share API ═══════════ # ═══════════ Share API ═══════════
@@ -1032,7 +1032,7 @@ async def publish_page(request: Request, page_id: int):
try: try:
await fire_event("page.published", {"page_id": page_id, "slug": slug}) await fire_event("page.published", {"page_id": page_id, "slug": slug})
except Exception: except Exception:
pass logger.exception("publish_page")
return {"is_published": True, "publish_slug": slug, "title": row["title"] if row else ""} return {"is_published": True, "publish_slug": slug, "title": row["title"] if row else ""}
@@ -1048,7 +1048,7 @@ async def unpublish_page(request: Request, page_id: int):
try: try:
await fire_event("page.unpublished", {"page_id": page_id}) await fire_event("page.unpublished", {"page_id": page_id})
except Exception: except Exception:
pass logger.exception("unpublish_page")
return {"is_published": False} return {"is_published": False}
@@ -1069,7 +1069,7 @@ async def restore_page(request: Request, page_id: int):
try: try:
await fire_event("page.restored", {"page_id": page_id}) await fire_event("page.restored", {"page_id": page_id})
except Exception: except Exception:
pass logger.exception("restore_page")
return {"status": "ok", "restored": page_id} return {"status": "ok", "restored": page_id}
+3 -3
View File
@@ -129,7 +129,7 @@ async def add_comment(request: Request, page_id: int):
if mentioned_ids: if mentioned_ids:
await _fire_event("mention.added", {"page_id": page_id, "comment_id": comment_id, "count": len(mentioned_ids)}) await _fire_event("mention.added", {"page_id": page_id, "comment_id": comment_id, "count": len(mentioned_ids)})
except Exception: except Exception:
pass logger.exception("add_comment")
return {"id": comment_id, "status": "created"} return {"id": comment_id, "status": "created"}
@@ -159,7 +159,7 @@ async def notify_page_mentions(request: Request, page_id: int):
try: try:
await _fire_event("mention.added", {"page_id": page_id, "user_ids": mentioned, "count": len(mentioned)}) await _fire_event("mention.added", {"page_id": page_id, "user_ids": mentioned, "count": len(mentioned)})
except Exception: except Exception:
pass logger.exception("notify_page_mentions")
return {"mentioned": mentioned} return {"mentioned": mentioned}
@@ -190,7 +190,7 @@ async def update_comment(request: Request, comment_id: int):
try: try:
await _fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]}) await _fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]})
except Exception: except Exception:
pass logger.exception("update_comment")
return {"id": comment_id, "status": "updated"} return {"id": comment_id, "status": "updated"}
+10 -10
View File
@@ -52,7 +52,7 @@ def _get_user_or_redirect(request: Request):
if count == 0: if count == 0:
return {"id": 1, "login": "admin", "full_name": "Admin", "is_admin": True} return {"id": 1, "login": "admin", "full_name": "Admin", "is_admin": True}
except Exception: except Exception:
pass logger.exception("_get_user_or_redirect")
return RedirectResponse("/auth/login?provider=local&expired=1", status_code=302) return RedirectResponse("/auth/login?provider=local&expired=1", status_code=302)
return user return user
@@ -88,7 +88,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
avatar_url = row["avatar_url"] or "" avatar_url = row["avatar_url"] or ""
avatar_color = row["avatar_color"] or "#3A3A3A" avatar_color = row["avatar_color"] or "#3A3A3A"
except Exception: except Exception:
pass logger.exception("_sidebar_data")
recent_pages = [] recent_pages = []
for repo in repos[:10]: for repo in repos[:10]:
@@ -179,7 +179,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
elif t["provider"] == "github": elif t["provider"] == "github":
github_linked = True github_linked = True
except Exception: except Exception:
pass logger.exception("_sidebar_data")
# Get local workspace ID for Gitea workspace mirror # Get local workspace ID for Gitea workspace mirror
local_ws_id = 0 local_ws_id = 0
@@ -193,7 +193,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
if row: if row:
local_ws_id = row["id"] local_ws_id = row["id"]
except Exception: except Exception:
pass logger.exception("_sidebar_data")
# Private pages for mirror workspace (when Gitea remote active) # Private pages for mirror workspace (when Gitea remote active)
private_pages = [] private_pages = []
@@ -206,7 +206,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
).fetchall() ).fetchall()
private_pages = [{"id": r[0], "title": r[1] or "Untitled"} for r in pp_rows] private_pages = [{"id": r[0], "title": r[1] or "Untitled"} for r in pp_rows]
except Exception: except Exception:
pass logger.exception("_sidebar_data")
# Shared / received / published pages for the sidebar (Par moi / Avec moi) # Shared / received / published pages for the sidebar (Par moi / Avec moi)
shared_made_pages = [] shared_made_pages = []
@@ -771,7 +771,7 @@ async def dashboard(
template = env.get_template("landing.html") template = env.get_template("landing.html")
return template.render() return template.render()
except Exception: except Exception:
pass logger.exception("dashboard")
from app.templating import ENV from app.templating import ENV
env = ENV env = ENV
template = env.get_template("landing.html") template = env.get_template("landing.html")
@@ -788,7 +788,7 @@ async def dashboard(
).fetchone() ).fetchone()
has_gitea = bool(tok) has_gitea = bool(tok)
except Exception: except Exception:
pass logger.exception("dashboard")
if not has_gitea: if not has_gitea:
# Check if user has any workspace # Check if user has any workspace
@@ -801,7 +801,7 @@ async def dashboard(
# v5.2.0: first-launch → onboarding wizard # v5.2.0: first-launch → onboarding wizard
return RedirectResponse("/welcome", status_code=302) return RedirectResponse("/welcome", status_code=302)
except Exception: except Exception:
pass logger.exception("dashboard")
return RedirectResponse("/local-workspace", status_code=302) return RedirectResponse("/local-workspace", status_code=302)
# ── Gitea user → full dashboard ── # ── Gitea user → full dashboard ──
@@ -942,7 +942,7 @@ async def list_workspace_projects(request: Request):
"forge": "gitea", "forge": "gitea",
}) })
except Exception: except Exception:
pass logger.exception("list_workspace_projects")
return {"builtin": builtin, "gitea": gitea_repos, "github": []} return {"builtin": builtin, "gitea": gitea_repos, "github": []}
@@ -2097,7 +2097,7 @@ async def add_item_tag(request: Request, item_id: int):
) )
conn.commit() conn.commit()
except Exception: except Exception:
pass logger.exception("add_item_tag")
return {"tag": {"id": tag["id"], "name": tag["name"], "color": tag["color"]}} return {"tag": {"id": tag["id"], "name": tag["name"], "color": tag["color"]}}
+1 -1
View File
@@ -41,7 +41,7 @@ async def ws_page(websocket: WebSocket, page_id: int):
try: try:
await websocket.close(code=4401) await websocket.close(code=4401)
except Exception: except Exception:
pass logger.exception("ws_page")
return return
conn = await manager.connect(websocket, page_id, user) conn = await manager.connect(websocket, page_id, user)
+1 -1
View File
@@ -117,5 +117,5 @@ async def revoke_session(sid: str, request: Request):
try: try:
request.session.clear() request.session.clear()
except Exception: except Exception:
pass logger.exception("revoke_session")
return {"status": "revoked"} return {"status": "revoked"}
+3 -3
View File
@@ -130,7 +130,7 @@ async def share_page(page_id: int, request: Request):
try: try:
await _fire_event("page.shared", {"page_id": page_id, "share_id": share_id, "permission": permission}) await _fire_event("page.shared", {"page_id": page_id, "share_id": share_id, "permission": permission})
except Exception: except Exception:
pass logger.exception("share_page")
return { return {
"id": share_id, "id": share_id,
@@ -332,7 +332,7 @@ async def publish_page(page_id: int, request: Request):
try: try:
await _fire_event("page.published", {"page_id": page_id, "slug": slug}) await _fire_event("page.published", {"page_id": page_id, "slug": slug})
except Exception: except Exception:
pass logger.exception("publish_page")
return { return {
"page_id": page_id, "page_id": page_id,
@@ -363,7 +363,7 @@ async def unpublish_page(page_id: int, request: Request):
try: try:
await _fire_event("page.unpublished", {"page_id": page_id}) await _fire_event("page.unpublished", {"page_id": page_id})
except Exception: except Exception:
pass logger.exception("unpublish_page")
return { return {
"page_id": page_id, "page_id": page_id,
+10 -5
View File
@@ -12,6 +12,7 @@ from __future__ import annotations
import hashlib import hashlib
import html import html
import json import json
import logging
import re import re
import secrets import secrets
import time import time
@@ -35,6 +36,8 @@ from app.services.api_v2_helpers import (
row_to_dict, row_to_dict,
) )
logger = logging.getLogger(__name__)
router = APIRouter(tags=["sites"]) router = APIRouter(tags=["sites"])
_SLUG_RE = re.compile(r"^[a-z0-9-]{3,50}$") _SLUG_RE = re.compile(r"^[a-z0-9-]{3,50}$")
@@ -160,7 +163,7 @@ def _render_page_html(page: dict) -> str:
from app.services.synced_blocks import resolve_synced_block from app.services.synced_blocks import resolve_synced_block
blocks = resolve_synced_block(blocks) blocks = resolve_synced_block(blocks)
except Exception: except Exception:
pass logger.exception("_render_page_html")
titles: dict = {} titles: dict = {}
try: try:
from app.db import get_conn as _gc from app.db import get_conn as _gc
@@ -223,7 +226,7 @@ def _track_view(site_id: int) -> None:
) )
conn.commit() conn.commit()
except Exception: except Exception:
pass logger.exception("_track_view")
def _form_config(conn, collection_id: int) -> dict: def _form_config(conn, collection_id: int) -> dict:
@@ -589,12 +592,14 @@ async def public_site_auth(request: Request, slug: str):
try: try:
password = (await request.json()).get("password", "") password = (await request.json()).get("password", "")
except Exception: except Exception:
logger.exception("public_site_auth")
password = "" password = ""
else: else:
try: try:
form = await request.form() form = await request.form()
password = form.get("password", "") password = form.get("password", "")
except Exception: except Exception:
logger.exception("public_site_auth")
password = "" password = ""
if not verify_password(password or "", site["password_hash"] or ""): if not verify_password(password or "", site["password_hash"] or ""):
raise HTTPException(401, "Wrong password") raise HTTPException(401, "Wrong password")
@@ -781,7 +786,7 @@ async def submit_form(request: Request, token: str):
except HTTPException: except HTTPException:
raise raise
except Exception: except Exception:
pass logger.exception("submit_form")
title = str(data.get(by_name[fields[0]]["name"], "Form response") if fields else "Form response")[:200] title = str(data.get(by_name[fields[0]]["name"], "Form response") if fields else "Form response")[:200]
cur = conn.execute( cur = conn.execute(
"""INSERT INTO collection_pages (collection_id, title, property_values_json) """INSERT INTO collection_pages (collection_id, title, property_values_json)
@@ -808,12 +813,12 @@ async def submit_form(request: Request, token: str):
except Exception: except Exception:
continue continue
except Exception: except Exception:
pass logger.exception("submit_form")
try: try:
from app.services.automations import fire_event as _fire from app.services.automations import fire_event as _fire
await _fire("form.submitted", {"collection_id": coll["id"], "row_id": row_id}) await _fire("form.submitted", {"collection_id": coll["id"], "row_id": row_id})
except Exception: except Exception:
pass logger.exception("submit_form")
if "application/json" in ctype: if "application/json" in ctype:
return {"status": "ok", "row_id": row_id, return {"status": "ok", "row_id": row_id,
"message": cfg.get("success_message") or "Merci !"} "message": cfg.get("success_message") or "Merci !"}
+3 -3
View File
@@ -179,7 +179,7 @@ async def clip_page(request: Request):
if isinstance(_imgs, list) and _imgs: if isinstance(_imgs, list) and _imgs:
_img_b64 = _imgs[0].get("base64") or _imgs[0].get("src") or "" _img_b64 = _imgs[0].get("base64") or _imgs[0].get("src") or ""
except Exception: except Exception:
pass logger.exception("clip_page")
clip_data = { clip_data = {
"url": url, "url": url,
"title": title[:200], "title": title[:200],
@@ -203,7 +203,7 @@ async def clip_page(request: Request):
try: try:
log_clip(user["id"], device_id, clip_type, url, result["page_id"], result["workspace_id"], result["title"]) log_clip(user["id"], device_id, clip_type, url, result["page_id"], result["workspace_id"], result["title"])
except Exception: except Exception:
pass logger.exception("clip_page")
return {"status": "ok", "page_id": result["page_id"], "title": result["title"], "workspace_id": result["workspace_id"], "url": f"/pages/{result['page_id']}"} return {"status": "ok", "page_id": result["page_id"], "title": result["title"], "workspace_id": result["workspace_id"], "url": f"/pages/{result['page_id']}"}
@@ -249,7 +249,7 @@ async def extensions_page(request: Request):
devices = list_devices(user["id"]) devices = list_devices(user["id"])
clips = sum(d.get("clips_count", 0) for d in devices) clips = sum(d.get("clips_count", 0) for d in devices)
except Exception: except Exception:
pass logger.exception("extensions_page")
content_html = f""" content_html = f"""
<style> <style>
.ext-page{{max-width:900px;margin:0 auto;padding:32px 24px 80px;}} .ext-page{{max-width:900px;margin:0 auto;padding:32px 24px 80px;}}
+5 -5
View File
@@ -160,7 +160,7 @@ async def add_comment(request: Request, page_id: int):
try: try:
await fire_event("comment.added", {"comment_id": cur.lastrowid, "page_id": page_id, "user_id": uid}) await fire_event("comment.added", {"comment_id": cur.lastrowid, "page_id": page_id, "user_id": uid})
except Exception: except Exception:
pass logger.exception("add_comment")
return {"id": cur.lastrowid, "status": "created"} return {"id": cur.lastrowid, "status": "created"}
@@ -180,7 +180,7 @@ async def update_comment(request: Request, comment_id: int):
try: try:
await fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]}) await fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]})
except Exception: except Exception:
pass logger.exception("update_comment")
return {"status": "updated"} return {"status": "updated"}
@@ -246,7 +246,7 @@ async def add_favorite(request: Request):
try: try:
await fire_event("favorite.added", {"page_id": page_id, "collection_id": collection_id, "user_id": uid}) await fire_event("favorite.added", {"page_id": page_id, "collection_id": collection_id, "user_id": uid})
except Exception: except Exception:
pass logger.exception("add_favorite")
return {"status": "favorited"} return {"status": "favorited"}
@@ -493,7 +493,7 @@ async def create_sprint(request: Request, collection_id: int):
try: try:
await fire_event("sprint.created", {"sprint_id": cur.lastrowid, "collection_id": collection_id, "name": name}) await fire_event("sprint.created", {"sprint_id": cur.lastrowid, "collection_id": collection_id, "name": name})
except Exception: except Exception:
pass logger.exception("create_sprint")
return {"id": cur.lastrowid, "name": name, "status": "created"} return {"id": cur.lastrowid, "name": name, "status": "created"}
@@ -523,7 +523,7 @@ async def update_sprint(request: Request, collection_id: int, sid: int):
try: try:
await fire_event("sprint.updated", {"sprint_id": sid, "collection_id": collection_id, "name": name, "status": status}) await fire_event("sprint.updated", {"sprint_id": sid, "collection_id": collection_id, "name": name, "status": status})
except Exception: except Exception:
pass logger.exception("update_sprint")
return {"id": sid, "status": "updated"} return {"id": sid, "status": "updated"}
+8 -5
View File
@@ -7,6 +7,7 @@ from __future__ import annotations
import hashlib import hashlib
import json import json
import logging
import time import time
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any from typing import Any
@@ -17,6 +18,8 @@ from fastapi.responses import JSONResponse
from app.config import settings from app.config import settings
from app.db import get_conn from app.db import get_conn
logger = logging.getLogger(__name__)
# ── ISO-8601 ────────────────────────────────────────────────────────────── # ── ISO-8601 ──────────────────────────────────────────────────────────────
def to_iso8601(value: str | None) -> str | None: def to_iso8601(value: str | None) -> str | None:
@@ -54,7 +57,7 @@ def row_to_dict(row, *, iso_fields: tuple[str, ...] = ("created_at", "updated_at
try: try:
d[k] = json.loads(d[k] or "{}" if d[k].strip().startswith("{") or d[k].strip().startswith("[") else d[k]) d[k] = json.loads(d[k] or "{}" if d[k].strip().startswith("{") or d[k].strip().startswith("[") else d[k])
except Exception: except Exception:
pass logger.exception("row_to_dict")
return d return d
# ── Pagination ──────────────────────────────────────────────────────────── # ── Pagination ────────────────────────────────────────────────────────────
@@ -163,7 +166,7 @@ def resolve_bearer_token(token: str) -> dict | None:
if dt.timestamp() < time.time(): if dt.timestamp() < time.time():
return None return None
except Exception: except Exception:
pass logger.exception("resolve_bearer_token")
u = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE id=?", (row["user_id"],)).fetchone() u = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE id=?", (row["user_id"],)).fetchone()
if u: if u:
d = dict(u) d = dict(u)
@@ -175,7 +178,7 @@ def resolve_bearer_token(token: str) -> dict | None:
conn.execute("UPDATE api_tokens SET last_used_at=CURRENT_TIMESTAMP WHERE id=?", (row["id"],)) conn.execute("UPDATE api_tokens SET last_used_at=CURRENT_TIMESTAMP WHERE id=?", (row["id"],))
conn.commit() conn.commit()
except Exception: except Exception:
pass logger.exception("resolve_bearer_token")
return d return d
# 2) extension_devices # 2) extension_devices
row = conn.execute("SELECT user_id, scopes FROM extension_devices WHERE token_hash=? AND revoked=0", (th,)).fetchone() row = conn.execute("SELECT user_id, scopes FROM extension_devices WHERE token_hash=? AND revoked=0", (th,)).fetchone()
@@ -257,7 +260,7 @@ def audit_log(user: dict, action: str, resource_type: str = "", resource_id: str
) )
conn.commit() conn.commit()
except Exception: except Exception:
pass logger.exception("audit_log")
# ── Rate limit per token (in-memory) ───────────────────────────────────── # ── Rate limit per token (in-memory) ─────────────────────────────────────
@@ -307,4 +310,4 @@ def store_idempotency(key: str, user_id: int, data: Any, status_code: int = 200)
) )
conn.commit() conn.commit()
except Exception: except Exception:
pass logger.exception("store_idempotency")
+1 -1
View File
@@ -41,7 +41,7 @@ def backup_db(now: datetime | None = None) -> str | None:
with sqlite3.connect(str(db_path)) as conn: with sqlite3.connect(str(db_path)) as conn:
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
except Exception: except Exception:
pass logger.exception("backup_db")
dest_dir = _backup_dir() dest_dir = _backup_dir()
filename = f"flowdeck-{now:%Y%m%d-%H%M%S}.db" filename = f"flowdeck-{now:%Y%m%d-%H%M%S}.db"
+1 -1
View File
@@ -194,7 +194,7 @@ class GitHubAdapter(ForgeAdapter):
if langs: if langs:
repo_info["language"] = max(langs, key=langs.get) repo_info["language"] = max(langs, key=langs.get)
except Exception: except Exception:
pass logger.exception("get_repo_info")
self._set_cache(cache_key, repo_info) self._set_cache(cache_key, repo_info)
return repo_info return repo_info
+1 -1
View File
@@ -349,7 +349,7 @@ class RealtimeManager:
try: try:
await conn.ws.close(code=4413) await conn.ws.close(code=4413)
except Exception: except Exception:
pass logger.exception("_evict_slow")
async def _broadcast(self, room: Room, msg: dict, exclude: RTConn | None = None): async def _broadcast(self, room: Room, msg: dict, exclude: RTConn | None = None):
"""Enfile ``msg`` chez chaque membre — jamais d'attente sur le socket.""" """Enfile ``msg`` chez chaque membre — jamais d'attente sur le socket."""
+1 -1
View File
@@ -780,4 +780,4 @@ def purge_stale_requests() -> None:
) )
conn.commit() conn.commit()
except Exception: except Exception:
pass logger.exception("purge_stale_requests")
+25
View File
@@ -114,6 +114,31 @@ def test_agent_providers_require_admin_and_valid_api_base(client):
assert client.post("/api/agent/providers/test", json={"provider": "ollama"}).status_code == 401 assert client.post("/api/agent/providers/test", json={"provider": "ollama"}).status_code == 401
def test_collection_rollback_when_materialize_fails(client, monkeypatch):
"""A25 : un échec de `materialize_properties` ne doit pas commiter la collection."""
import pytest
from app.services import db_templates
def boom(*_a, **_k):
raise RuntimeError("materialize boom")
monkeypatch.setattr(db_templates, "materialize_properties", boom)
tok = client.post("/api/v1/token").json()["token"]
with pytest.raises(RuntimeError):
client.post(
"/api/v2/collections",
json={"name": "Broken", "schema": [{"name": "Title", "type": "title"}]},
headers={"Authorization": f"Bearer {tok}"},
)
from app.db import get_conn
with get_conn() as conn:
n = conn.execute("SELECT COUNT(*) FROM collections WHERE name='Broken'").fetchone()[0]
assert n == 0, "la collection ne doit pas survivre à un schéma non matérialisé"
def test_exports_and_attachments_require_auth(client): def test_exports_and_attachments_require_auth(client):
"""A16 : export + pièces jointes = session et `can_view_page` (jamais le contenu).""" """A16 : export + pièces jointes = session et `can_view_page` (jamais le contenu)."""
pid = client.post("/board/api/pages?title=Secret&section=Private").json()["id"] pid = client.post("/board/api/pages?title=Secret&section=Private").json()["id"]