Compare commits

..
2 Commits
Author SHA1 Message Date
bruno 6a5fe0524a refactor: A28 lot 1 — api_v2.py (2 110 L) → package 14 fichiers (v7.29.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
Découpe par concern de l'ancien app/routers/api_v2.py (2 110 lignes,
115 routes) en package `app/routers/api_v2/` :

- 12 modules de routes : collections 566 L (23 r.), engagement 338 (21),
  workspaces 230 (9), templates_io 205 (9), webhooks 195 (8),
  identity 195 (7), views 164 (8), sharing 160 (8), properties 151 (7),
  planning 148 (7), projects 93 (4), admin 91 (4)
- `_common.py` : helpers partagés (_hash, _v2_rate_check)
- `__init__.py` : router = APIRouter(prefix="/api/v2") + include_router
  sur les routers de sections (sans prefix, tags « api-v2 »)

Preuve contractuelle : `docs/openapi-v2.json` régénéré = IDENTIQUE
byte-à-byte (0 changement de chemin/tag/operation_id). Seul importateur
(app/main.py : from app.routers.api_v2 import router) fonctionne via le
package. En-tête d'imports copié par module puis émondé par ruff --fix
(143 imports morts), I001 réordonnés.

Reste A28 : dashboard.py 2 735 L, collections.py 2 622 L, board.py 2 101 L
(même recette, lots suivants).

suite **1091/1091** · ruff OK · OpenAPI 509 identique · docs à jour
2026-10-01 23:26:45 -04:00
bruno 3bb8e87ef2 fix: A42 terminé — client httpx partagé par boucle (v7.28.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
- `app/services/http_client.py` : `async with shared_client(timeout=15)
  as client:` remplace les 49 créations `async with httpx.AsyncClient(`
  de 14 fichiers (gitea ×21, providers oidc/oauth ×11, calendar ×4,
  automations ×3…) — le pool de connexions est réutilisé au lieu d'être
  recréé à chaque appel. __aexit__ no-op (le client partagé ne se ferme
  pas à la sortie).
- Cache par (boucle d'event, kwargs) en WeakKeyDictionary : un
  AsyncClient n'est JAMAIS partagé entre deux loops (piège des tests
  « Event loop is closed ») — une boucle par test = client propre
  collecté avec la boucle. Clé = kwargs triés, repr() pour les valeurs
  non hashables (`headers=` dict → TypeError rattrapé par la suite).
- Laissés délibérément : github_adapter (transport MockTransport
  injecté), webhook_outbound (client « own_client » fermé par la
  fonction).
- Tests : `test_http_client_shared_and_loop_scoped` (réutilisation mêmes
  kwargs / cloisonné kwargs / cloisonné loop) ; le stub des webhooks
  patche aussi la fabrique `http_client.httpx` + purge du cache (avant :
  webhook_outbound.httpx patché mais la fabrique partagée créait un vrai
  client → réseau réel dans les tests).

suite **1091/1091** · ruff OK · docs à jour
2026-10-01 23:09:45 -04:00
38 changed files with 2840 additions and 2182 deletions
+54
View File
@@ -1,5 +1,59 @@
# Changelog - FlowDeck # Changelog - FlowDeck
## v7.29.0 (2026-10-01) — Audit : A28 lot 1 (api_v2 → package 14 fichiers)
### Changed
- **A28 lot 1** : `app/routers/api_v2.py` (**2 110 lignes, 115 routes**)
devient le package `app/routers/api_v2/` — un module par concern :
· `collections` 566 L (23 routes), `engagement` 338 (21), `workspaces`
230 (9), `templates_io` 205 (9), `webhooks` 195 (8), `identity` 195 (7),
`views` 164 (8), `sharing` 160 (8), `properties` 151 (7),
`planning` 148 (7), `projects` 93 (4), `admin` 91 (4)
· `_common.py` : helpers partagés (`_hash`, `_v2_rate_check`)
· `__init__.py` : `router = APIRouter(prefix="/api/v2")` + agrégat
`include_router` (section routers sans prefix, tags `api-v2`)
- Preuve contractuelle : **`docs/openapi-v2.json` régénéré = identique
byte-à-byte** (0 changement de chemin, tag ni operation_id)
- Seul importateur (`app/main.py` → `from app.routers.api_v2 import
router`) fonctionne via le package ; en-tête d'imports copié par module,
émondé par `ruff --fix` (143 imports morts supprimés automatiquement)
### Notes
- Reste A28 : `dashboard.py` 2 735 L, `collections.py` 2 622 L,
`board.py` 2 101 L (même recette, lots suivants)
- Suite complète : **1091/1091** · ruff OK
## v7.28.0 (2026-10-01) — Audit : A42 TERMINÉ (client httpx partagé)
### Changed
- **`app/services/http_client.py`** : `async with shared_client(timeout=15)
as client:` remplace les **49 créations `async with httpx.AsyncClient(`**
réparties dans 14 fichiers (gitea ×21, providers ×11, calendar ×4,
automations ×3, …) — le pool de connexions est réutilisé au lieu d'être
recréé à chaque appel
- Cache **par (boucle d'event, kwargs)** en `WeakKeyDictionary` : un
`AsyncClient` n'est jamais partagé entre deux loops (le piège des tests :
« Event loop is closed ») — une boucle par test = un client propre,
collecté avec elle. Clé = kwargs triés, `repr()` pour les valeurs non
hashables (`headers=` dict)
- Context manager no-op à la sortie (pas de fermeture du client partagé) ;
`ponytail:` documenté : pas d'`aclose` explicite, plafond = pools non
fermés à la main (GC des sockets), upgrade = lifespan
- **Laissés délibérément** : `github_adapter` (transport MockTransport
injecté), `webhook_outbound` (client « own_client » fermé par la fonction)
### Tests
- `test_http_client_shared_and_loop_scoped` : réutilisation (mêmes kwargs),
cloisonnement par kwargs, cloisonnement par boucle (2× `asyncio.run`)
- Stub webhooks : patch étendu à la fabrique `http_client.httpx` + purge du
cache (les tests patchaient `webhook_outbound.httpx`, contourné par la
fabrique partagée)
- Suite complète : **1091/1091**
## v7.27.0 (2026-10-01) — Audit : A20 phase 2 (CDN retiré, connect-src fermé) ## v7.27.0 (2026-10-01) — Audit : A20 phase 2 (CDN retiré, connect-src fermé)
### Changed ### Changed
+3 -3
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
7.27.0 7.29.0
+1 -1
View File
@@ -1,6 +1,6 @@
# WORKLOAD — FlowDeck Notion Clone # WORKLOAD — FlowDeck Notion Clone
> **Début**: 2026-07-08 | **Version**: v7.27.0 (audit — A20 phase 2 : CDN retiré, connect-src fermé) | **Statut**: EN COURS 🔄 > **Début**: 2026-07-08 | **Version**: v7.29.0 (audit — A28 lot 1 : api_v2.py 2 110 L → package 14 fichiers) | **Statut**: EN COURS 🔄
> **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0` > **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
+3 -4
View File
@@ -4,9 +4,8 @@ from __future__ import annotations
import logging import logging
from urllib.parse import urlencode from urllib.parse import urlencode
import httpx
from app.config import settings from app.config import settings
from app.services.http_client import shared_client
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -39,7 +38,7 @@ class GiteaOAuth:
async def exchange_code(self, code: str) -> dict | None: async def exchange_code(self, code: str) -> dict | None:
"""Exchange authorization code for access token.""" """Exchange authorization code for access token."""
try: try:
async with httpx.AsyncClient(timeout=15) as client: async with shared_client(timeout=15) as client:
resp = await client.post( resp = await client.post(
self.TOKEN_URL, self.TOKEN_URL,
data={ data={
@@ -61,7 +60,7 @@ class GiteaOAuth:
async def get_user(self, access_token: str) -> dict | None: async def get_user(self, access_token: str) -> dict | None:
"""Get user info from Gitea API.""" """Get user info from Gitea API."""
try: try:
async with httpx.AsyncClient(timeout=10) as client: async with shared_client(timeout=10) as client:
resp = await client.get( resp = await client.get(
self.USER_URL, self.USER_URL,
headers={"Authorization": f"token {access_token}"}, headers={"Authorization": f"token {access_token}"},
+7 -7
View File
@@ -7,7 +7,7 @@ import time
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from urllib.parse import urlencode from urllib.parse import urlencode
import httpx from app.services.http_client import shared_client
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -76,7 +76,7 @@ class GiteaProvider(OAuthProvider):
"grant_type": "authorization_code", "grant_type": "authorization_code",
"redirect_uri": redirect_uri or self.redirect_uri, "redirect_uri": redirect_uri or self.redirect_uri,
} }
async with httpx.AsyncClient(timeout=15) as client: async with shared_client(timeout=15) as client:
r = await client.post(url, json=data, headers={"Accept": "application/json"}) r = await client.post(url, json=data, headers={"Accept": "application/json"})
if r.status_code != 200: if r.status_code != 200:
logger.error("Gitea token exchange failed: %s", r.text) logger.error("Gitea token exchange failed: %s", r.text)
@@ -85,7 +85,7 @@ class GiteaProvider(OAuthProvider):
async def get_user(self, access_token: str) -> dict | None: async def get_user(self, access_token: str) -> dict | None:
url = f"{self.base}/api/v1/user" url = f"{self.base}/api/v1/user"
async with httpx.AsyncClient(timeout=15) as client: async with shared_client(timeout=15) as client:
r = await client.get(url, headers={"Authorization": f"token {access_token}"}) r = await client.get(url, headers={"Authorization": f"token {access_token}"})
if r.status_code != 200: if r.status_code != 200:
return None return None
@@ -100,7 +100,7 @@ class GiteaProvider(OAuthProvider):
async def list_repositories(self, access_token: str) -> list[dict]: async def list_repositories(self, access_token: str) -> list[dict]:
repos = [] repos = []
async with httpx.AsyncClient(timeout=30) as client: async with shared_client(timeout=30) as client:
for page in range(1, 6): for page in range(1, 6):
r = await client.get( r = await client.get(
f"{self.base}/api/v1/user/repos", f"{self.base}/api/v1/user/repos",
@@ -158,7 +158,7 @@ class GitHubProvider(OAuthProvider):
) )
async def exchange_code(self, code: str, redirect_uri: str | None = None) -> dict | None: async def exchange_code(self, code: str, redirect_uri: str | None = None) -> dict | None:
async with httpx.AsyncClient(timeout=15) as client: async with shared_client(timeout=15) as client:
r = await client.post( r = await client.post(
self.token_url, self.token_url,
data={ data={
@@ -179,7 +179,7 @@ class GitHubProvider(OAuthProvider):
async def get_user(self, access_token: str) -> dict | None: async def get_user(self, access_token: str) -> dict | None:
url = f"{self.api_url}/user" url = f"{self.api_url}/user"
async with httpx.AsyncClient(timeout=15) as client: async with shared_client(timeout=15) as client:
r = await client.get( r = await client.get(
url, url,
headers={"Authorization": f"Bearer {access_token}", "Accept": "application/vnd.github.v3+json"}, headers={"Authorization": f"Bearer {access_token}", "Accept": "application/vnd.github.v3+json"},
@@ -197,7 +197,7 @@ class GitHubProvider(OAuthProvider):
async def list_repositories(self, access_token: str) -> list[dict]: async def list_repositories(self, access_token: str) -> list[dict]:
repos = [] repos = []
async with httpx.AsyncClient(timeout=30) as client: async with shared_client(timeout=30) as client:
for page in range(1, 6): for page in range(1, 6):
r = await client.get( r = await client.get(
f"{self.api_url}/user/repos", f"{self.api_url}/user/repos",
+4 -4
View File
@@ -15,7 +15,7 @@ import secrets
import time import time
import warnings import warnings
import httpx from app.services.http_client import shared_client
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -59,7 +59,7 @@ async def discover(issuer_url: str) -> dict:
if hit and now - hit[0] < _DISCOVERY_TTL: if hit and now - hit[0] < _DISCOVERY_TTL:
return hit[1] return hit[1]
try: try:
async with httpx.AsyncClient(timeout=15) as client: async with shared_client(timeout=15) as client:
r = await client.get(url) r = await client.get(url)
r.raise_for_status() r.raise_for_status()
doc = r.json() doc = r.json()
@@ -112,7 +112,7 @@ async def exchange_code(
if client_secret: if client_secret:
auth = (client_id, client_secret) auth = (client_id, client_secret)
try: try:
async with httpx.AsyncClient(timeout=15) as client: async with shared_client(timeout=15) as client:
r = await client.post(doc["token_endpoint"], data=data, auth=auth) r = await client.post(doc["token_endpoint"], data=data, auth=auth)
except Exception as err: except Exception as err:
raise OIDCError(f"OIDC token request failed: {err}") from err raise OIDCError(f"OIDC token request failed: {err}") from err
@@ -133,7 +133,7 @@ async def fetch_userinfo(doc: dict, access_token: str) -> dict:
if not endpoint or not access_token: if not endpoint or not access_token:
return {} return {}
try: try:
async with httpx.AsyncClient(timeout=15) as client: async with shared_client(timeout=15) as client:
r = await client.get(endpoint, headers={"Authorization": f"Bearer {access_token}"}) r = await client.get(endpoint, headers={"Authorization": f"Bearer {access_token}"})
if r.status_code != 200: if r.status_code != 200:
return {} return {}
+1 -1
View File
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI( app = FastAPI(
title="FlowDeck", title="FlowDeck",
version="7.27.0", version="7.29.0",
docs_url="/docs", docs_url="/docs",
redoc_url="/redoc", redoc_url="/redoc",
lifespan=lifespan, lifespan=lifespan,
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
"""FlowDeck — Public API v2.
Découpe A28 : l'ancien `api_v2.py` (2 110 lignes, 115 routes) est devenu
ce package — un module par concern (`_common` = helpers), `router`
agrégé ci-dessous avec le même prefix/tags qu'avant → 0 changement
d'URL, 0 changement d'operation_id.
"""
from __future__ import annotations
from fastapi import APIRouter
from . import (
admin,
collections,
engagement,
identity,
planning,
projects,
properties,
sharing,
templates_io,
views,
webhooks,
workspaces,
)
router = APIRouter(prefix="/api/v2")
for _mod in (
identity,
workspaces,
collections,
properties,
views,
engagement,
sharing,
planning,
templates_io,
projects,
admin,
webhooks,
):
router.include_router(_mod.router)
+36
View File
@@ -0,0 +1,36 @@
"""FlowDeck — API v2 : helpers partagés des modules de routes (A28)."""
from __future__ import annotations
import hashlib
import logging
from fastapi import HTTPException, Request
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
audit_log,
check_idempotency,
check_v2_rate_limit,
get_bearer_user,
has_scope,
paginate_headers,
parse_pagination,
require_scope,
row_to_dict,
store_idempotency,
to_iso8601,
validate_scopes_input,
)
logger = logging.getLogger(__name__)
def _hash(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
def _v2_rate_check(request: Request, user: dict) -> None:
ip = request.client.host if request.client else "unknown"
th = user.get("_token_hash")
if not check_v2_rate_limit(th, ip):
raise HTTPException(status_code=429, detail="Rate limit exceeded: 300 req/min per token")
# ── Tokens ────────────────────────────────────────────────────────────────
+91
View File
@@ -0,0 +1,91 @@
"""FlowDeck — Public API v2 : admin.
Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency).
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Body, Header, HTTPException, Request
from app.db import get_conn
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
audit_log,
check_idempotency,
check_v2_rate_limit,
get_bearer_user,
has_scope,
paginate_headers,
parse_pagination,
require_scope,
row_to_dict,
store_idempotency,
to_iso8601,
validate_scopes_input,
)
from ._common import _v2_rate_check
logger = logging.getLogger(__name__)
router = APIRouter(tags=["api-v2"])
@router.patch("/admin/users/{uid}")
def admin_patch_user_v2(uid: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
if not user.get("is_admin") and not has_scope(user.get("_token_scopes"), "admin"):
raise HTTPException(403, "Admin scope required")
with get_conn() as conn:
if not conn.execute("SELECT id FROM users WHERE id=?", (uid,)).fetchone():
raise HTTPException(404, "User not found")
sets = []
params: list = []
for k in ("is_active", "is_admin", "full_name", "email"):
if k in body:
sets.append(f"{k}=?")
params.append(int(body[k]) if k in ("is_active", "is_admin") else body[k])
if not sets:
raise HTTPException(400, "No fields")
params.append(uid)
conn.execute(f"UPDATE users SET {', '.join(sets)} WHERE id=?", params)
conn.commit()
audit_log(user, "admin.user_update", "user", uid, "", request)
return {"id": uid, "status": "updated"}
@router.delete("/admin/users/{uid}")
def admin_delete_user_v2(uid: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
if not user.get("is_admin") and not has_scope(user.get("_token_scopes"), "admin"):
raise HTTPException(403, "Admin scope required")
if uid == user["id"]:
raise HTTPException(400, "Cannot delete yourself")
with get_conn() as conn:
conn.execute("DELETE FROM users WHERE id=?", (uid,))
conn.commit()
audit_log(user, "admin.user_delete", "user", uid, "", request)
return {"id": uid, "status": "deleted"}
@router.get("/admin/audit-logs")
def admin_audit_logs_v2(request: Request, limit: int = 50, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
if not user.get("is_admin") and not has_scope(user.get("_token_scopes"), "admin"):
raise HTTPException(403, "Admin scope required")
limit = max(1, min(limit, 200))
with get_conn() as conn:
rows = conn.execute("SELECT * FROM api_audit_log ORDER BY created_at DESC LIMIT ?", (limit,)).fetchall()
return {"logs": [row_to_dict(r) for r in rows]}
@router.get("/webhooks/events")
def list_webhook_events_v2(request: Request, authorization: str | None = Header(default=None)):
"""Catalogue of deliverable events (+ wildcard syntax)."""
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
from app.services.webhook_outbound import EVENTS
return {"events": EVENTS, "wildcards": ["*", "page.*", "collection.*"]}
+566
View File
@@ -0,0 +1,566 @@
"""FlowDeck — Public API v2 : collections.
Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency).
"""
from __future__ import annotations
import json
import logging
from fastapi import APIRouter, Body, Header, HTTPException, Request
from fastapi.responses import JSONResponse
from app.db import get_conn
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
audit_log,
check_idempotency,
check_v2_rate_limit,
get_bearer_user,
has_scope,
paginate_headers,
parse_pagination,
require_scope,
row_to_dict,
store_idempotency,
to_iso8601,
validate_scopes_input,
)
from app.services.automations import fire_event as _fire_event
from app.services.automations import run_event_sync
from ._common import _v2_rate_check
logger = logging.getLogger(__name__)
router = APIRouter(tags=["api-v2"])
@router.get("/collections")
def list_collections_v2(request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
limit, offset = parse_pagination(request)
ws_filter = request.query_params.get("workspace_id")
q = (request.query_params.get("query") or "").strip()
with get_conn() as conn:
where = []
params: list = []
if ws_filter:
try:
wid = int(ws_filter)
where.append("c.workspace_id=?")
params.append(wid)
except ValueError:
pass
if q:
where.append("(c.name LIKE ? OR c.description LIKE ?)")
like = f"%{q}%"
params.extend([like, like])
clause = ("WHERE " + " AND ".join(where)) if where else ""
total = conn.execute(f"SELECT COUNT(*) FROM collections c {clause}", params).fetchone()[0]
rows = conn.execute(f"SELECT c.* FROM collections c {clause} ORDER BY c.name LIMIT ? OFFSET ?", (*params, limit, offset)).fetchall()
cols = []
for r in rows:
d = row_to_dict(r)
# filter by visibility: skip private not visible (best-effort)
cols.append(d)
resp = {"collections": cols, "total": total, "limit": limit, "offset": offset}
return JSONResponse(content=resp, headers=paginate_headers(total))
@router.post("/collections")
def create_collection_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
idem = check_idempotency(request, user["id"])
if idem:
return JSONResponse(content=idem["data"], status_code=idem["status"])
name = (body.get("name") or "").strip()
if not name:
raise HTTPException(400, "name is required")
description = body.get("description", "")
icon = body.get("icon", "📋")
workspace_id = body.get("workspace_id")
schema = body.get("schema") or body.get("schema_json") or []
if isinstance(schema, str):
try:
schema = json.loads(schema)
except Exception:
schema = []
schema_json = json.dumps(schema)
with get_conn() as conn:
cur = conn.execute("INSERT INTO collections (name, description, icon, schema_json, workspace_id, created_by) VALUES (?, ?, ?, ?, ?, ?)", (name, description, icon, schema_json, workspace_id, user["id"]))
cid = cur.lastrowid
# materialize properties if schema provided — A25 : PAS de try ici,
# une exception doit interrompre la transaction (sinon la collection est
# commitée sans son schéma et l'erreur disparaît).
from app.services.db_templates import materialize_properties
materialize_properties(conn, cid, schema)
# default view
conn.execute("INSERT INTO collection_views (collection_id, name, view_type, config_json) VALUES (?,?,?,?)", (cid, "Default View", "table", json.dumps({"visible_properties": ["Title"]})))
conn.commit()
row = conn.execute("SELECT * FROM collections WHERE id=?", (cid,)).fetchone()
audit_log(user, "collection.create", "collection", cid, name, request)
data = {"id": cid, "name": name, "status": "created", "collection": row_to_dict(row)}
key = (request.headers.get("Idempotency-Key") or "").strip()
if key:
store_idempotency(key, user["id"], data, 201)
return JSONResponse(content=data, status_code=201)
@router.get("/collections/{collection_id}")
def get_collection_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
row = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
if not row:
raise HTTPException(404, "Collection not found")
pages = conn.execute("SELECT id, title, icon, position, property_values_json, created_at FROM collection_pages WHERE collection_id=? ORDER BY position LIMIT 50", (collection_id,)).fetchall()
d = row_to_dict(row)
d["pages"] = [row_to_dict(p) for p in pages]
return d
@router.patch("/collections/{collection_id}")
def patch_collection_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
row = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
if not row:
raise HTTPException(404, "Collection not found")
name = body.get("name", row["name"])
description = body.get("description", row["description"])
icon = body.get("icon", row["icon"])
schema = body.get("schema") or body.get("schema_json")
if schema is not None:
sj = json.dumps(schema) if isinstance(schema, (list, dict)) else str(schema)
else:
sj = row["schema_json"]
conn.execute("UPDATE collections SET name=?, description=?, icon=?, schema_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (name, description, icon, sj, collection_id))
conn.commit()
audit_log(user, "collection.update", "collection", collection_id, "", request)
return {"id": collection_id, "status": "updated"}
@router.delete("/collections/{collection_id}")
def delete_collection_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
row = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
if not row:
raise HTTPException(404, "Collection not found")
conn.execute("DELETE FROM collections WHERE id=?", (collection_id,))
conn.commit()
audit_log(user, "collection.delete", "collection", collection_id, "", request)
return {"id": collection_id, "status": "deleted"}
@router.post("/collections/{collection_id}/linked")
def create_linked_db(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
name = (body.get("name") or "").strip() or f"Linked DB {collection_id}"
with get_conn() as conn:
src = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
if not src:
raise HTTPException(404, "Collection not found")
cur = conn.execute("INSERT INTO collections (name, description, icon, schema_json, workspace_id, created_by) VALUES (?, ?, ?, ?, ?, ?)", (name, src["description"], src["icon"], src["schema_json"], src["workspace_id"] if "workspace_id" in src.keys() else None, user["id"]))
nid = cur.lastrowid
# copy data source as linked
try:
conn.execute("INSERT INTO collection_data_sources (collection_id, source_collection_id, is_linked) VALUES (?, ?, 1)", (nid, collection_id))
except Exception:
logger.exception("create_linked_db")
# copy views + properties (light)
rows = conn.execute("SELECT * FROM collection_properties WHERE collection_id=?", (collection_id,)).fetchall()
for p in rows:
try:
conn.execute("INSERT INTO collection_properties (collection_id, name, prop_type, options_json, position) VALUES (?, ?, ?, ?, ?)", (nid, p["name"], p["prop_type"], p["options_json"], p["position"]))
except Exception:
logger.exception("create_linked_db")
vrows = conn.execute("SELECT * FROM collection_views WHERE collection_id=?", (collection_id,)).fetchall()
for v in vrows:
try:
conn.execute("INSERT INTO collection_views (collection_id, name, view_type, config_json, position) VALUES (?, ?, ?, ?, ?)", (nid, v["name"], v["view_type"], v["config_json"], v["position"]))
except Exception:
logger.exception("create_linked_db")
conn.commit()
audit_log(user, "collection.linked", "collection", nid, f"src={collection_id}", request)
return {"id": nid, "name": name, "status": "created"}
@router.post("/collections/{collection_id}/task")
def toggle_task(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
row = conn.execute("SELECT is_task FROM collections WHERE id=?", (collection_id,)).fetchone()
if not row:
raise HTTPException(404, "Collection not found")
cur_val = row["is_task"] if "is_task" in row.keys() else 0
new_val = 0 if cur_val else 1
conn.execute("UPDATE collections SET is_task=? WHERE id=?", (new_val, collection_id))
conn.commit()
audit_log(user, "collection.toggle_task", "collection", collection_id, str(new_val), request)
return {"id": collection_id, "is_task": bool(new_val)}
@router.get("/collections/{collection_id}/sources")
def list_sources(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
if not conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone():
raise HTTPException(404, "Collection not found")
rows = conn.execute("SELECT * FROM collection_data_sources WHERE collection_id=? ORDER BY position", (collection_id,)).fetchall()
return {"sources": [dict(r) for r in rows]}
@router.post("/collections/{collection_id}/sources")
def add_source(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
src_id = body.get("source_collection_id") or body.get("source_id")
if not src_id:
raise HTTPException(400, "source_collection_id required")
with get_conn() as conn:
if not conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone():
raise HTTPException(404, "Collection not found")
if not conn.execute("SELECT id FROM collections WHERE id=?", (src_id,)).fetchone():
raise HTTPException(404, "Source collection not found")
try:
conn.execute("INSERT INTO collection_data_sources (collection_id, source_collection_id) VALUES (?, ?)", (collection_id, src_id))
conn.commit()
except Exception as e:
raise HTTPException(409, str(e)) from None
audit_log(user, "collection.add_source", "collection", collection_id, str(src_id), request)
return {"collection_id": collection_id, "source_collection_id": src_id, "status": "added"}
@router.delete("/collections/{collection_id}/sources/{source_id}")
def remove_source(collection_id: int, source_id: int, request: Request, authorization: str | None = Header(default=None)):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
conn.execute("DELETE FROM collection_data_sources WHERE collection_id=? AND (id=? OR source_collection_id=?)", (collection_id, source_id, source_id))
conn.commit()
audit_log(user, "collection.remove_source", "collection", collection_id, str(source_id), request)
return {"status": "removed"}
@router.get("/collections/{collection_id}/pages")
def list_collection_pages_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
limit, offset = parse_pagination(request)
with get_conn() as conn:
if not conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone():
raise HTTPException(404, "Collection not found")
total = conn.execute("SELECT COUNT(*) FROM collection_pages WHERE collection_id=?", (collection_id,)).fetchone()[0]
# filters: filter[status]=Done etc., sort, fields
# Simple: filter by property name via property_values_json LIKE (best-effort), sort by position or title
sort = request.query_params.get("sort") or ""
order = "position"
desc = False
if sort:
if sort.startswith("-"):
desc = True
sort = sort[1:]
# allow sorting by title/position/created_at
if sort in ("title", "position", "created_at", "updated_at"):
order = sort
direction = "DESC" if desc else "ASC"
rows = conn.execute(f"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY {order} {direction} LIMIT ? OFFSET ?", (collection_id, limit, offset)).fetchall()
# apply filter[xxx] in-memory (small)
filters = {k[7:-1]: v for k, v in request.query_params.items() if k.startswith("filter[") and k.endswith("]")}
fields = request.query_params.get("fields")
fields_set = set(fields.split(",")) if fields else None
out = []
for r in rows:
d = row_to_dict(r)
# property filter (AND)
if filters:
try:
pv = json.loads(r["property_values_json"] or "{}") if isinstance(r["property_values_json"], str) else r["property_values_json"]
except Exception:
pv = {}
ok = True
for fk, fv in filters.items():
# lookup by prop id or name
found = False
for kk, vv in (pv or {}).items():
if str(kk) == str(fk) or str(kk).lower() == fk.lower():
if str(vv) == str(fv):
found = True
break
# also check title if filter field is title
if fk == "title" and d.get("title") == fv:
found = True
if not found:
ok = False
break
if not ok:
continue
if fields_set:
d = {k: v for k, v in d.items() if k in fields_set or k in ("id", "collection_id")}
out.append(d)
return JSONResponse(content={"pages": out, "total": total, "limit": limit, "offset": offset}, headers=paginate_headers(total))
@router.post("/collections/{collection_id}/pages")
def create_collection_page_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
idem = check_idempotency(request, user["id"])
if idem:
return JSONResponse(content=idem["data"], status_code=idem["status"])
title = (body.get("title") or body.get("name") or "Untitled").strip() or "Untitled"
icon = body.get("icon", "file")
parent_id = body.get("parent_id")
prop_vals = body.get("property_values") or body.get("properties") or body.get("property_values_json") or {}
if isinstance(prop_vals, str):
try:
prop_vals = json.loads(prop_vals)
except Exception:
prop_vals = {}
with get_conn() as conn:
if not conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone():
raise HTTPException(404, "Collection not found")
# validate properties if helper exists
try:
pass
# light validation: we rely on existing validators
except Exception:
logger.exception("create_collection_page_v2")
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_pages WHERE collection_id=?", (collection_id,)).fetchone()[0]
# apply auto props
try:
props_list = [dict(r) for r in conn.execute("SELECT * FROM collection_properties WHERE collection_id=?", (collection_id,)).fetchall()]
from app.services.property_types import apply_auto_properties as _aap
_aap(props_list, prop_vals, user, is_create=True)
except Exception:
logger.exception("create_collection_page_v2")
cur = conn.execute("INSERT INTO collection_pages (collection_id, title, icon, position, parent_id, property_values_json) VALUES (?, ?, ?, ?, ?, ?)", (collection_id, title, icon, max_pos, parent_id, json.dumps(prop_vals)))
pid = cur.lastrowid
conn.commit()
row = conn.execute("SELECT * FROM collection_pages WHERE id=?", (pid,)).fetchone()
audit_log(user, "page.create", "collection_page", pid, title, request)
try:
run_event_sync(_fire_event("collection.page.created", {"page_id": pid, "collection_id": collection_id, "title": title}))
except Exception:
logger.exception("create_collection_page_v2")
data = {"id": pid, "title": title, "status": "created", "page": row_to_dict(row)}
key = (request.headers.get("Idempotency-Key") or "").strip()
if key:
store_idempotency(key, user["id"], data, 201)
return JSONResponse(content=data, status_code=201)
@router.get("/pages/{page_id}")
def get_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
row = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone()
if not row:
# also try pages table (block pages)
row2 = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
if not row2:
raise HTTPException(404, "Page not found")
d = row_to_dict(row2)
# v6.5.0: resolve synced blocks server-side (fresh content).
if (d.get("content_format") or "blocks") == "blocks" and d.get("content"):
from app.services.synced_blocks import resolve_content_json
d["content"] = resolve_content_json(d["content"], d["content_format"])
return d
d = row_to_dict(row)
# property_values_json already parsed by row_to_dict
# v6.5.0: expose the row's content page when it exists (no lazy
# creation on a read-only endpoint).
content_page_id = conn.execute(
"SELECT id FROM pages WHERE collection_row_id=?",
(page_id,),
).fetchone()
d["content_page_id"] = content_page_id["id"] if content_page_id else None
return d
@router.patch("/pages/{page_id}")
def patch_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
row = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone()
if not row:
raise HTTPException(404, "Page not found")
title = body.get("title", row["title"])
icon = body.get("icon", row["icon"])
pos = body.get("position", row["position"])
parent_id = body.get("parent_id", row["parent_id"])
pv_raw = row["property_values_json"] or "{}"
try:
stored = json.loads(pv_raw) if isinstance(pv_raw, str) else dict(pv_raw)
except Exception:
stored = {}
incoming = body.get("property_values") or body.get("properties")
if incoming is not None:
if isinstance(incoming, str):
try:
incoming = json.loads(incoming)
except Exception:
incoming = {}
# merge
for k, v in (incoming or {}).items():
stored[str(k)] = v
# apply auto props
try:
props_list = [dict(r) for r in conn.execute("SELECT * FROM collection_properties WHERE collection_id=?", (row["collection_id"],)).fetchall()]
from app.services.property_types import apply_auto_properties as _aap
_aap(props_list, stored, user, is_create=False)
except Exception:
logger.exception("patch_page_v2")
conn.execute("UPDATE collection_pages SET title=?, icon=?, position=?, parent_id=?, property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, icon, pos, parent_id, json.dumps(stored), page_id))
conn.commit()
audit_log(user, "page.update", "collection_page", page_id, "", request)
try:
run_event_sync(_fire_event("collection.page.updated", {"page_id": page_id, "collection_id": row["collection_id"], "title": title}))
except Exception:
logger.exception("patch_page_v2")
return {"id": page_id, "status": "updated"}
@router.delete("/pages/{page_id}")
def delete_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
row = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone()
if not row:
raise HTTPException(404, "Page not found")
conn.execute("DELETE FROM collection_pages WHERE id=?", (page_id,))
conn.commit()
audit_log(user, "page.delete", "collection_page", page_id, "", request)
try:
run_event_sync(_fire_event("collection.page.deleted", {"page_id": page_id, "collection_id": row["collection_id"]}))
except Exception:
logger.exception("delete_page_v2")
return {"id": page_id, "status": "deleted"}
@router.post("/pages/{page_id}/restore")
def restore_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
# For soft-deleted pages (deleted_at) - but collection_pages has no deleted_at; handle pages table
with get_conn() as conn:
row = conn.execute("SELECT deleted_at FROM pages WHERE id=?", (page_id,)).fetchone()
if row and row["deleted_at"]:
conn.execute("UPDATE pages SET deleted_at=NULL WHERE id=?", (page_id,))
conn.commit()
try:
run_event_sync(_fire_event("page.restored", {"page_id": page_id}))
except Exception:
logger.exception("restore_page_v2")
return {"id": page_id, "status": "restored"}
raise HTTPException(404, "Page not found or not deleted")
@router.post("/pages/{page_id}/move")
def move_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
row = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone()
if not row:
raise HTTPException(404, "Page not found")
parent_id = body.get("parent_id", row["parent_id"])
position = body.get("position", row["position"])
conn.execute("UPDATE collection_pages SET parent_id=?, position=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (parent_id, position, page_id))
conn.commit()
audit_log(user, "page.move", "collection_page", page_id, f"parent={parent_id} pos={position}", request)
return {"id": page_id, "status": "moved"}
@router.get("/pages/{page_id}/sub-items")
def list_sub_items_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
if not conn.execute("SELECT id FROM collection_pages WHERE id=?", (page_id,)).fetchone():
raise HTTPException(404, "Page not found")
rows = conn.execute("SELECT * FROM collection_pages WHERE parent_id=? ORDER BY position", (page_id,)).fetchall()
return {"sub_items": [row_to_dict(r) for r in rows]}
@router.post("/pages/{page_id}/sub-items")
def create_sub_item_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
parent = conn.execute("SELECT collection_id FROM collection_pages WHERE id=?", (page_id,)).fetchone()
if not parent:
raise HTTPException(404, "Page not found")
title = (body.get("title") or "Untitled").strip()
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_pages WHERE parent_id=?", (page_id,)).fetchone()[0]
pv = json.dumps(body.get("property_values") or {})
cur = conn.execute("INSERT INTO collection_pages (collection_id, title, parent_id, position, property_values_json) VALUES (?, ?, ?, ?, ?)", (parent["collection_id"], title, page_id, max_pos, pv))
nid = cur.lastrowid
conn.commit()
audit_log(user, "page.create_subitem", "collection_page", nid, title, request)
return {"id": nid, "status": "created"}
@router.get("/pages/{page_id}/dependencies")
def list_dependencies_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
rows = conn.execute("SELECT * FROM page_dependencies WHERE page_id=?", (page_id,)).fetchall()
return {"dependencies": [dict(r) for r in rows]}
@router.post("/pages/{page_id}/dependencies")
def add_dependency_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
dep_id = body.get("dependency_id") or body.get("depends_on")
dtype = body.get("dependency_type") or "blocks"
if not dep_id:
raise HTTPException(400, "dependency_id required")
with get_conn() as conn:
if not conn.execute("SELECT id FROM collection_pages WHERE id=?", (page_id,)).fetchone():
raise HTTPException(404, "Page not found")
if not conn.execute("SELECT id FROM collection_pages WHERE id=?", (dep_id,)).fetchone():
raise HTTPException(404, "Dependency page not found")
try:
conn.execute("INSERT INTO page_dependencies (page_id, dependency_id, dependency_type) VALUES (?, ?, ?)", (page_id, dep_id, dtype))
conn.commit()
except Exception as e:
raise HTTPException(409, str(e)) from None
audit_log(user, "page.add_dependency", "collection_page", page_id, str(dep_id), request)
return {"status": "added"}
@router.delete("/pages/{page_id}/dependencies/{dep_id}")
def remove_dependency_v2(page_id: int, dep_id: int, request: Request, authorization: str | None = Header(default=None)):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
conn.execute("DELETE FROM page_dependencies WHERE page_id=? AND dependency_id=?", (page_id, dep_id))
conn.commit()
audit_log(user, "page.remove_dependency", "collection_page", page_id, str(dep_id), request)
return {"status": "removed"}
@router.get("/collections/{collection_id}/properties")
def list_properties_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
if not conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone():
raise HTTPException(404, "Collection not found")
rows = conn.execute("SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position", (collection_id,)).fetchall()
return {"properties": [row_to_dict(r) for r in rows]}
+338
View File
@@ -0,0 +1,338 @@
"""FlowDeck — Public API v2 : engagement.
Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency).
"""
from __future__ import annotations
import json
import logging
from fastapi import APIRouter, Body, Header, HTTPException, Request
from app.db import get_conn
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
audit_log,
check_idempotency,
check_v2_rate_limit,
get_bearer_user,
has_scope,
paginate_headers,
parse_pagination,
require_scope,
row_to_dict,
store_idempotency,
to_iso8601,
validate_scopes_input,
)
from app.services.automations import fire_event as _fire_event
from app.services.automations import run_event_sync
from ._common import _v2_rate_check
logger = logging.getLogger(__name__)
router = APIRouter(tags=["api-v2"])
@router.get("/pages/{page_id}/comments")
def list_comments_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
limit, offset = parse_pagination(request)
with get_conn() as conn:
total = conn.execute("SELECT COUNT(*) FROM comments WHERE target_id=? OR page_id=?", (page_id, page_id)).fetchone()[0]
rows = conn.execute("SELECT c.*, u.login, u.full_name FROM comments c LEFT JOIN users u ON u.id=c.user_id WHERE c.target_id=? OR c.page_id=? ORDER BY c.created_at LIMIT ? OFFSET ?", (page_id, page_id, limit, offset)).fetchall()
return {"comments": [row_to_dict(r) for r in rows], "total": total, "limit": limit, "offset": offset}
@router.post("/pages/{page_id}/comments")
def create_comment_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
text = (body.get("body") or body.get("content") or "").strip()
if not text:
raise HTTPException(400, "body is required")
with get_conn() as conn:
cur = conn.execute("INSERT INTO comments (page_id, user_id, body, target_type, target_id, anchor_block_id, anchor_start, anchor_end) VALUES (?, ?, ?, 'page', ?, ?, ?, ?)", (page_id, user["id"], text, page_id, body.get("anchor_block_id"), body.get("anchor_start"), body.get("anchor_end")))
nid = cur.lastrowid
conn.commit()
row = conn.execute("SELECT * FROM comments WHERE id=?", (nid,)).fetchone()
audit_log(user, "comment.create", "comment", nid, text[:80], request)
try:
run_event_sync(_fire_event("comment.added", {"comment_id": nid, "page_id": page_id, "user_id": user["id"]}))
except Exception:
logger.exception("create_comment_v2")
return {"id": nid, "status": "created", "comment": row_to_dict(row)}
@router.patch("/comments/{comment_id}")
def patch_comment_v2(comment_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
row = conn.execute("SELECT * FROM comments WHERE id=?", (comment_id,)).fetchone()
if not row:
raise HTTPException(404, "Comment not found")
if row["user_id"] != user["id"] and not user.get("is_admin"):
raise HTTPException(403, "Not your comment")
body_text = body.get("body", row["body"])
resolved = body.get("resolved", row["resolved"])
was_resolved = int(row["resolved"] or 0)
conn.execute("UPDATE comments SET body=?, resolved=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (body_text, int(bool(resolved)), comment_id))
conn.commit()
if int(bool(resolved)) and not was_resolved:
try:
run_event_sync(_fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]}))
except Exception:
logger.exception("patch_comment_v2")
return {"id": comment_id, "status": "updated"}
@router.delete("/comments/{comment_id}")
def delete_comment_v2(comment_id: int, request: Request, authorization: str | None = Header(default=None)):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
row = conn.execute("SELECT * FROM comments WHERE id=?", (comment_id,)).fetchone()
if not row:
raise HTTPException(404, "Comment not found")
if row["user_id"] != user["id"] and not user.get("is_admin"):
raise HTTPException(403, "Not your comment")
conn.execute("DELETE FROM comments WHERE id=?", (comment_id,))
conn.commit()
return {"id": comment_id, "status": "deleted"}
@router.post("/pages/{page_id}/mentions")
def create_mention_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
targets = body.get("user_ids") or body.get("mentions") or []
if isinstance(targets, int):
targets = [targets]
if not targets:
raise HTTPException(400, "user_ids required")
created = 0
with get_conn() as conn:
for uid in targets:
try:
conn.execute("INSERT INTO notifications (user_id, actor_id, ntype, title, message, resource_type, resource_id, url) VALUES (?, ?, 'mention', 'You were mentioned', ?, 'page', ?, ?)", (uid, user["id"], body.get("message") or f"Mentioned in page {page_id}", page_id, f"/pages/{page_id}"))
created += 1
except Exception:
logger.exception("create_mention_v2")
conn.commit()
if created:
try:
run_event_sync(_fire_event("mention.added", {"page_id": page_id, "user_ids": [u for u in targets if isinstance(u, int)], "count": created}))
except Exception:
logger.exception("create_mention_v2")
return {"mentions": created, "status": "created"}
@router.get("/notifications")
def list_notifications_v2(request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
limit, offset = parse_pagination(request)
unread = request.query_params.get("unread")
with get_conn() as conn:
where = "user_id=?"
params: list = [user["id"]]
if unread == "1":
where += " AND is_read=0"
total = conn.execute(f"SELECT COUNT(*) FROM notifications WHERE {where}", params).fetchone()[0]
rows = conn.execute(f"SELECT * FROM notifications WHERE {where} ORDER BY created_at DESC LIMIT ? OFFSET ?", (*params, limit, offset)).fetchall()
return {"notifications": [row_to_dict(r) for r in rows], "total": total, "limit": limit, "offset": offset}
@router.get("/notifications/unread-count")
def unread_count(request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
cnt = conn.execute("SELECT COUNT(*) FROM notifications WHERE user_id=? AND is_read=0", (user["id"],)).fetchone()[0]
return {"unread": cnt}
@router.post("/notifications/{notif_id}/read")
def mark_read(notif_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
conn.execute("UPDATE notifications SET is_read=1 WHERE id=? AND user_id=?", (notif_id, user["id"]))
conn.commit()
return {"id": notif_id, "status": "read"}
@router.post("/notifications/read-all")
def mark_all_read(request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
conn.execute("UPDATE notifications SET is_read=1 WHERE user_id=?", (user["id"],))
conn.commit()
return {"status": "all read"}
@router.patch("/users/me/preferences")
def patch_prefs(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
row = conn.execute("SELECT notification_prefs FROM users WHERE id=?", (user["id"],)).fetchone()
try:
cur = json.loads(row["notification_prefs"] or "{}")
except Exception:
cur = {}
cur.update(body)
conn.execute("UPDATE users SET notification_prefs=? WHERE id=?", (json.dumps(cur), user["id"]))
conn.commit()
return {"preferences": cur}
@router.get("/favorites")
def list_favorites_v2(request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
rows = conn.execute("SELECT f.*, p.title, p.workspace_id FROM favorites f JOIN pages p ON p.id=f.page_id WHERE f.user_id=? ORDER BY f.position", (user["id"],)).fetchall()
return {"favorites": [row_to_dict(r) for r in rows]}
@router.post("/favorites")
def add_favorite_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
pid = body.get("page_id")
if not pid:
raise HTTPException(400, "page_id required")
with get_conn() as conn:
try:
conn.execute("INSERT INTO favorites (user_id, page_id) VALUES (?, ?)", (user["id"], pid))
conn.commit()
except Exception as e:
raise HTTPException(409, str(e)) from None
try:
run_event_sync(_fire_event("favorite.added", {"page_id": pid, "user_id": user["id"]}))
except Exception:
logger.exception("add_favorite_v2")
return {"page_id": pid, "status": "added"}
@router.delete("/favorites/{page_id}")
def remove_favorite_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
conn.execute("DELETE FROM favorites WHERE user_id=? AND page_id=?", (user["id"], page_id))
conn.commit()
try:
run_event_sync(_fire_event("favorite.removed", {"page_id": page_id, "user_id": user["id"]}))
except Exception:
logger.exception("remove_favorite_v2")
return {"page_id": page_id, "status": "removed"}
@router.get("/tags")
def list_tags_v2(request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
q = (request.query_params.get("q") or "").strip()
with get_conn() as conn:
if q:
rows = conn.execute("SELECT * FROM tags WHERE user_id=? AND name LIKE ? ORDER BY name", (user["id"], f"%{q}%")).fetchall()
else:
rows = conn.execute("SELECT * FROM tags WHERE user_id=? ORDER BY name", (user["id"],)).fetchall()
return {"tags": [dict(r) for r in rows]}
@router.post("/tags")
def create_tag_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
name = (body.get("name") or "").strip()
if not name:
raise HTTPException(400, "name required")
color = body.get("color", "#787774")
with get_conn() as conn:
try:
cur = conn.execute("INSERT INTO tags (name, color, user_id) VALUES (?, ?, ?)", (name, color, user["id"]))
tid = cur.lastrowid
conn.commit()
except Exception as e:
raise HTTPException(409, str(e)) from None
return {"id": tid, "name": name, "color": color, "status": "created"}
@router.patch("/tags/{tag_id}")
def patch_tag_v2(tag_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
row = conn.execute("SELECT * FROM tags WHERE id=? AND user_id=?", (tag_id, user["id"])).fetchone()
if not row:
raise HTTPException(404, "Tag not found")
name = body.get("name", row["name"])
color = body.get("color", row["color"])
conn.execute("UPDATE tags SET name=?, color=? WHERE id=?", (name, color, tag_id))
conn.commit()
return {"id": tag_id, "status": "updated"}
@router.delete("/tags/{tag_id}")
def delete_tag_v2(tag_id: int, request: Request, authorization: str | None = Header(default=None)):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
conn.execute("DELETE FROM tags WHERE id=? AND user_id=?", (tag_id, user["id"]))
conn.commit()
return {"id": tag_id, "status": "deleted"}
@router.post("/pages/{page_id}/tags")
def attach_tag_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
tag_id = body.get("tag_id")
if not tag_id:
raise HTTPException(400, "tag_id required")
with get_conn() as conn:
try:
conn.execute("INSERT INTO page_tags (page_id, tag_id) VALUES (?, ?)", (page_id, tag_id))
conn.commit()
except Exception as e:
raise HTTPException(409, str(e)) from None
return {"page_id": page_id, "tag_id": tag_id, "status": "attached"}
@router.delete("/pages/{page_id}/tags/{tag_id}")
def detach_tag_v2(page_id: int, tag_id: int, request: Request, authorization: str | None = Header(default=None)):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
conn.execute("DELETE FROM page_tags WHERE page_id=? AND tag_id=?", (page_id, tag_id))
conn.commit()
return {"status": "detached"}
@router.get("/recents")
def list_recents_v2(request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
limit = int(request.query_params.get("limit", "20"))
st = request.query_params.get("source_type")
with get_conn() as conn:
if st:
rows = conn.execute("SELECT * FROM recents WHERE user_id=? AND source_type=? ORDER BY accessed_at DESC LIMIT ?", (user["id"], st, limit)).fetchall()
else:
rows = conn.execute("SELECT * FROM recents WHERE user_id=? ORDER BY accessed_at DESC LIMIT ?", (user["id"], limit)).fetchall()
return {"recents": [row_to_dict(r) for r in rows]}
@router.get("/pages/{page_id}/shares")
def list_shares_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
rows = conn.execute("SELECT * FROM page_shares WHERE page_id=?", (page_id,)).fetchall()
return {"shares": [dict(r) for r in rows]}
+195
View File
@@ -0,0 +1,195 @@
"""FlowDeck — Public API v2 : identity.
Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency).
"""
from __future__ import annotations
import json
import logging
import secrets
from datetime import datetime
from fastapi import APIRouter, Body, Header, HTTPException, Request
from fastapi.responses import JSONResponse
from app.db import get_conn
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
audit_log,
check_idempotency,
check_v2_rate_limit,
get_bearer_user,
has_scope,
paginate_headers,
parse_pagination,
require_scope,
row_to_dict,
store_idempotency,
to_iso8601,
validate_scopes_input,
)
from ._common import _hash, _v2_rate_check
logger = logging.getLogger(__name__)
router = APIRouter(tags=["api-v2"])
@router.post("/tokens")
def create_token(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
name = (body.get("name") or "API token").strip()[:100]
scopes = validate_scopes_input(body.get("scopes") or "read,write")
expires_at = body.get("expires_at")
# Idempotency
idem = check_idempotency(request, user["id"])
if idem:
return JSONResponse(content=idem["data"], status_code=idem["status"])
token = f"fd_{secrets.token_urlsafe(32)}"
prefix = token[:12]
th = _hash(token)
exp_val = None
if expires_at:
try:
# accept ISO string
exp_val = str(expires_at)
# validate parse
datetime.fromisoformat(exp_val.replace("Z", "+00:00"))
except Exception as err:
raise HTTPException(400, "Invalid expires_at, use ISO-8601") from err
with get_conn() as conn:
try:
cur = conn.execute(
"INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, scopes, expires_at) VALUES (?, ?, ?, ?, ?, ?)",
(user["id"], name, th, prefix, scopes, exp_val),
)
conn.commit()
tid = cur.lastrowid
except Exception as e:
raise HTTPException(409, f"Token creation failed: {e}") from None
row = conn.execute("SELECT id, name, token_prefix, scopes, expires_at, created_at FROM api_tokens WHERE id=?", (tid,)).fetchone()
audit_log(user, "token.create", "api_token", tid, f"scopes={scopes}", request)
data = {"id": tid, "name": row["name"], "token": token, "prefix": prefix, "scopes": scopes, "expires_at": to_iso8601(row["expires_at"]) if row["expires_at"] else None, "note": "Copy token now — shown once. Use as Authorization: Bearer <token>"}
key = (request.headers.get("Idempotency-Key") or request.headers.get("idempotency-key") or "").strip()
if key:
store_idempotency(key, user["id"], data, 200)
return data
@router.get("/tokens")
def list_tokens(request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
rows = conn.execute("SELECT id, name, token_prefix, scopes, expires_at, last_used_at, created_at, revoked FROM api_tokens WHERE user_id=? ORDER BY created_at DESC", (user["id"],)).fetchall()
out = []
for r in rows:
d = dict(r)
d["created_at"] = to_iso8601(d.get("created_at"))
d["expires_at"] = to_iso8601(d.get("expires_at")) if d.get("expires_at") else None
d["last_used_at"] = to_iso8601(d.get("last_used_at")) if d.get("last_used_at") else None
# never expose hash
out.append({k: v for k, v in d.items() if k != "token_hash"})
return {"tokens": out}
@router.delete("/tokens/{token_id}")
def revoke_token(token_id: int, request: Request, authorization: str | None = Header(default=None)):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
row = conn.execute("SELECT id, user_id FROM api_tokens WHERE id=?", (token_id,)).fetchone()
if not row:
raise HTTPException(404, "Token not found")
if row["user_id"] != user["id"] and not user.get("is_admin"):
raise HTTPException(403, "Not your token")
conn.execute("UPDATE api_tokens SET revoked=1 WHERE id=?", (token_id,))
conn.commit()
audit_log(user, "token.revoke", "api_token", token_id, "", request)
return {"id": token_id, "status": "revoked"}
@router.post("/tokens/{token_id}/rotate")
def rotate_token(token_id: int, request: Request, authorization: str | None = Header(default=None)):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
row = conn.execute("SELECT id, user_id, name, scopes FROM api_tokens WHERE id=?", (token_id,)).fetchone()
if not row:
raise HTTPException(404, "Token not found")
if row["user_id"] != user["id"] and not user.get("is_admin"):
raise HTTPException(403, "Not your token")
# revoke old
conn.execute("UPDATE api_tokens SET revoked=1 WHERE id=?", (token_id,))
new_token = f"fd_{secrets.token_urlsafe(32)}"
th = _hash(new_token)
prefix = new_token[:12]
cur = conn.execute("INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, scopes) VALUES (?, ?, ?, ?, ?)", (row["user_id"], row["name"], th, prefix, row["scopes"] or "read,write"))
conn.commit()
nid = cur.lastrowid
audit_log(user, "token.rotate", "api_token", token_id, f"new_id={nid}", request)
return {"id": nid, "token": new_token, "prefix": prefix, "scopes": row["scopes"], "note": "Copy token now — shown once"}
@router.get("/users/me")
def get_me(request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
row = conn.execute("SELECT id, login, full_name, email, avatar_url, avatar_color, is_admin, is_active, auth_method, sidebar_config, notification_prefs, timezone, created_at FROM users WHERE id=?", (user["id"],)).fetchone()
if not row:
raise HTTPException(404, "User not found")
d = row_to_dict(row)
# parse json prefs
for k in ("notification_prefs", "sidebar_config"):
if isinstance(d.get(k), str):
try:
d[k] = json.loads(d[k] or "{}")
except Exception:
logger.exception("get_me")
# never expose secrets
return d
@router.patch("/users/me")
def patch_me(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
allowed = {"full_name", "email", "avatar_color", "notification_prefs", "sidebar_config", "timezone"}
updates = {}
for k in allowed:
if k in body:
updates[k] = body[k]
if not updates:
raise HTTPException(400, "No updatable fields")
# validation
if "email" in updates and updates["email"] and "@" not in str(updates["email"]):
raise HTTPException(400, "Invalid email")
with get_conn() as conn:
sets = []
params = []
for k, v in updates.items():
if k in ("notification_prefs", "sidebar_config"):
v = json.dumps(v) if isinstance(v, (dict, list)) else str(v)
sets.append(f"{k}=?")
params.append(v)
params.append(user["id"])
conn.execute(f"UPDATE users SET {', '.join(sets)} WHERE id=?", params)
conn.commit()
row = conn.execute("SELECT id, login, full_name, email, avatar_url, avatar_color, is_admin, timezone, notification_prefs FROM users WHERE id=?", (user["id"],)).fetchone()
audit_log(user, "user.update", "user", user["id"], "", request)
return row_to_dict(row)
@router.get("/users/search")
def search_users(request: Request, q: str = "", authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
q = (q or request.query_params.get("q") or "").strip()
if not q:
return {"users": []}
like = f"%{q}%"
with get_conn() as conn:
rows = conn.execute("SELECT id, login, full_name, email, avatar_url, avatar_color FROM users WHERE login LIKE ? OR email LIKE ? OR full_name LIKE ? LIMIT 20", (like, like, like)).fetchall()
return {"users": [dict(r) for r in rows]}
+148
View File
@@ -0,0 +1,148 @@
"""FlowDeck — Public API v2 : planning.
Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency).
"""
from __future__ import annotations
import json
import logging
from fastapi import APIRouter, Body, Header, HTTPException, Request
from app.db import get_conn
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
audit_log,
check_idempotency,
check_v2_rate_limit,
get_bearer_user,
has_scope,
paginate_headers,
parse_pagination,
require_scope,
row_to_dict,
store_idempotency,
to_iso8601,
validate_scopes_input,
)
from app.services.automations import fire_event as _fire_event
from app.services.automations import run_event_sync
from ._common import _v2_rate_check
logger = logging.getLogger(__name__)
router = APIRouter(tags=["api-v2"])
@router.post("/collections/{collection_id}/sprints")
def create_sprint_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
name = (body.get("name") or "Sprint").strip()
start = body.get("start_date") or body.get("start") or ""
end = body.get("end_date") or body.get("end") or ""
if not start or not end:
raise HTTPException(400, "start_date and end_date required (YYYY-MM-DD)")
with get_conn() as conn:
cur = conn.execute("INSERT INTO sprints (collection_id, name, start_date, end_date, goal) VALUES (?, ?, ?, ?, ?)", (collection_id, name, start, end, body.get("goal") or ""))
sid = cur.lastrowid
conn.commit()
try:
run_event_sync(_fire_event("sprint.created", {"sprint_id": sid, "collection_id": collection_id, "name": name}))
except Exception:
logger.exception("create_sprint_v2")
return {"id": sid, "name": name, "status": "created"}
@router.patch("/sprints/{sprint_id}")
def patch_sprint_v2(sprint_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
row = conn.execute("SELECT * FROM sprints WHERE id=?", (sprint_id,)).fetchone()
if not row:
raise HTTPException(404, "Sprint not found")
name = body.get("name", row["name"])
start = body.get("start_date", row["start_date"])
end = body.get("end_date", row["end_date"])
goal = body.get("goal", row["goal"])
status = body.get("status", row["status"])
conn.execute("UPDATE sprints SET name=?, start_date=?, end_date=?, goal=?, status=? WHERE id=?", (name, start, end, goal, status, sprint_id))
conn.commit()
try:
run_event_sync(_fire_event("sprint.updated", {"sprint_id": sprint_id, "collection_id": row["collection_id"], "name": name, "status": status}))
except Exception:
logger.exception("patch_sprint_v2")
return {"id": sprint_id, "status": "updated"}
@router.delete("/sprints/{sprint_id}")
def delete_sprint_v2(sprint_id: int, request: Request, authorization: str | None = Header(default=None)):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
conn.execute("DELETE FROM sprints WHERE id=?", (sprint_id,))
conn.commit()
return {"id": sprint_id, "status": "deleted"}
@router.post("/sprints/{sprint_id}/assign")
def assign_sprint_v2(sprint_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
pid = body.get("page_id")
if not pid:
raise HTTPException(400, "page_id required")
with get_conn() as conn:
try:
conn.execute("INSERT INTO sprint_pages (sprint_id, page_id, velocity_points) VALUES (?, ?, ?)", (sprint_id, pid, body.get("velocity_points", 1)))
conn.commit()
except Exception as e:
raise HTTPException(409, str(e)) from None
return {"sprint_id": sprint_id, "page_id": pid, "status": "assigned"}
@router.delete("/sprints/{sprint_id}/assign/{page_id}")
def unassign_sprint_v2(sprint_id: int, page_id: int, request: Request, authorization: str | None = Header(default=None)):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
conn.execute("DELETE FROM sprint_pages WHERE sprint_id=? AND page_id=?", (sprint_id, page_id))
conn.commit()
return {"status": "removed"}
@router.get("/sprints/{sprint_id}/burndown")
def burndown_v2(sprint_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
s = conn.execute("SELECT * FROM sprints WHERE id=?", (sprint_id,)).fetchone()
if not s:
raise HTTPException(404, "Sprint not found")
pages = conn.execute("SELECT sp.*, cp.property_values_json FROM sprint_pages sp JOIN collection_pages cp ON cp.id=sp.page_id WHERE sp.sprint_id=?", (sprint_id,)).fetchall()
total = len(pages)
# crude: completed where status property == Done (best-effort)
completed = 0
for p in pages:
try:
pv = json.loads(p["property_values_json"] or "{}")
for v in pv.values():
if str(v).lower() in ("done", "completed", "terminé"):
completed += 1
break
except Exception:
logger.exception("burndown_v2")
remaining = total - completed
# ideal linear
ideal = [round(total * (1 - i / 10)) for i in range(11)]
return {"sprint_id": sprint_id, "total": total, "completed": completed, "remaining": remaining, "ideal": ideal}
@router.get("/collections/{collection_id}/templates")
def list_templates_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
rows = conn.execute("SELECT * FROM page_templates WHERE collection_id=? ORDER BY created_at", (collection_id,)).fetchall()
return {"templates": [row_to_dict(r) for r in rows]}
+93
View File
@@ -0,0 +1,93 @@
"""FlowDeck — Public API v2 : projects.
Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency).
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Header, HTTPException, Request
from app.db import get_conn
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
audit_log,
check_idempotency,
check_v2_rate_limit,
get_bearer_user,
has_scope,
paginate_headers,
parse_pagination,
require_scope,
row_to_dict,
store_idempotency,
to_iso8601,
validate_scopes_input,
)
from ._common import _v2_rate_check
logger = logging.getLogger(__name__)
router = APIRouter(tags=["api-v2"])
@router.get("/projects")
def list_projects_v2(request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
rows = conn.execute("SELECT * FROM projects ORDER BY proj_type, owner, name").fetchall()
return {"projects": [row_to_dict(r) for r in rows]}
@router.get("/projects/{owner}/{repo}/tree")
async def project_tree_v2(owner: str, repo: str, request: Request, path: str = "", authorization: str | None = Header(default=None)):
get_bearer_user(request, authorization)
# proxy to gitea client? Return placeholder listing from projects table
with get_conn() as conn:
proj = conn.execute("SELECT * FROM projects WHERE owner=? AND name=?", (owner, repo)).fetchone()
if not proj:
raise HTTPException(404, "Project not found")
# delegate to gitea API if available (best-effort)
try:
from app.services.gitea_client import gitea
tree = await gitea.list_repo_files(owner, repo, path or "")
return {"owner": owner, "repo": repo, "path": path, "tree": tree}
except Exception:
return {"owner": owner, "repo": repo, "path": path, "tree": []}
@router.get("/search")
def search_v2(request: Request, query: str = "", workspace_id: int | None = None, type: str = "all", authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
q = (query or request.query_params.get("query") or "").strip()
if not q:
return {"results": [], "query": q}
like = f"%{q}%"
with get_conn() as conn:
pages = []
# try FTS5
try:
rows = conn.execute("SELECT p.id, p.title, p.content, p.workspace_id, snippet(pages_fts, -1, '<mark>', '</mark>', '...', 32) as snippet FROM pages_fts f JOIN pages p ON p.id=f.rowid WHERE pages_fts MATCH ? LIMIT 20", (q,)).fetchall()
pages = [{"type": "page", "id": r["id"], "title": r["title"], "snippet": r["snippet"]} for r in rows]
except Exception:
rows = conn.execute("SELECT id, title FROM pages WHERE title LIKE ? OR content LIKE ? LIMIT 20", (like, like)).fetchall()
pages = [{"type": "page", "id": r["id"], "title": r["title"]} for r in rows]
# collections
colls = conn.execute("SELECT id, name FROM collections WHERE name LIKE ? LIMIT 10", (like,)).fetchall()
results = pages + [{"type": "collection", "id": r["id"], "title": r["name"]} for r in colls]
return {"query": q, "results": results}
@router.get("/admin/users")
def admin_list_users_v2(request: Request, limit: int = 30, offset: int = 0, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
if not user.get("is_admin") and not has_scope(user.get("_token_scopes"), "admin"):
raise HTTPException(403, "Admin scope required")
limit = max(1, min(limit, 100))
with get_conn() as conn:
total = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
rows = conn.execute("SELECT id, login, full_name, email, is_admin, is_active, created_at FROM users ORDER BY id LIMIT ? OFFSET ?", (limit, offset)).fetchall()
return {"users": [row_to_dict(r) for r in rows], "total": total, "limit": limit, "offset": offset}
+151
View File
@@ -0,0 +1,151 @@
"""FlowDeck — Public API v2 : properties.
Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency).
"""
from __future__ import annotations
import json
import logging
from fastapi import APIRouter, Body, Header, HTTPException, Request
from app.db import get_conn
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
audit_log,
check_idempotency,
check_v2_rate_limit,
get_bearer_user,
has_scope,
paginate_headers,
parse_pagination,
require_scope,
row_to_dict,
store_idempotency,
to_iso8601,
validate_scopes_input,
)
from ._common import _v2_rate_check
logger = logging.getLogger(__name__)
router = APIRouter(tags=["api-v2"])
@router.post("/collections/{collection_id}/properties")
def create_property_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
name = (body.get("name") or "").strip()
if not name:
raise HTTPException(400, "name is required")
ptype = body.get("prop_type") or body.get("type") or "text"
with get_conn() as conn:
if not conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone():
raise HTTPException(404, "Collection not found")
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_properties WHERE collection_id=?", (collection_id,)).fetchone()[0]
try:
cur = conn.execute("INSERT INTO collection_properties (collection_id, name, prop_type, options_json, number_format, position, required, visible_in_views, validation_json, group_name) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", (collection_id, name, ptype, json.dumps(body.get("options") or []), body.get("number_format") or "number", max_pos, int(bool(body.get("required"))), int(bool(body.get("visible_in_views", True))), json.dumps(body.get("validation") or {}), (body.get("group_name") or "").strip()))
conn.commit()
pid = cur.lastrowid
except Exception as e:
raise HTTPException(409, f"Property exists: {e}") from None
audit_log(user, "property.create", "property", pid, name, request)
return {"id": pid, "name": name, "prop_type": ptype, "status": "created"}
@router.patch("/properties/{prop_id}")
def patch_property_v2(prop_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
row = conn.execute("SELECT * FROM collection_properties WHERE id=?", (prop_id,)).fetchone()
if not row:
raise HTTPException(404, "Property not found")
name = body.get("name", row["name"])
opts = json.dumps(body.get("options", json.loads(row["options_json"] or "[]")))
nf = body.get("number_format", row["number_format"])
req = int(bool(body.get("required", row["required"])))
vis = int(bool(body.get("visible_in_views", row["visible_in_views"])))
vj = json.dumps(body.get("validation", json.loads(row["validation_json"] or "{}"))) if "validation" in body else (row["validation_json"] if "validation_json" in row.keys() else "{}")
grp = body.get("group_name", row["group_name"] if "group_name" in row.keys() else "")
conn.execute("UPDATE collection_properties SET name=?, options_json=?, number_format=?, required=?, visible_in_views=?, validation_json=?, group_name=? WHERE id=?", (name, opts, nf, req, vis, vj, grp, prop_id))
conn.commit()
audit_log(user, "property.update", "property", prop_id, "", request)
return {"id": prop_id, "status": "updated"}
@router.delete("/properties/{prop_id}")
def delete_property_v2(prop_id: int, request: Request, authorization: str | None = Header(default=None)):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
if not conn.execute("SELECT id FROM collection_properties WHERE id=?", (prop_id,)).fetchone():
raise HTTPException(404, "Property not found")
conn.execute("DELETE FROM collection_properties WHERE id=?", (prop_id,))
conn.commit()
audit_log(user, "property.delete", "property", prop_id, "", request)
return {"id": prop_id, "status": "deleted"}
@router.post("/properties/{prop_id}/relation")
def create_relation_v2(prop_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
related_id = body.get("related_collection_id")
reverse = (body.get("reverse_name") or "").strip()
if not related_id:
raise HTTPException(400, "related_collection_id required")
with get_conn() as conn:
row = conn.execute("SELECT collection_id FROM collection_properties WHERE id=?", (prop_id,)).fetchone()
if not row:
raise HTTPException(404, "Property not found")
conn.execute("UPDATE collection_properties SET prop_type='relation', related_collection_id=?, reverse_name=? WHERE id=?", (related_id, reverse, prop_id))
if reverse:
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_properties WHERE collection_id=?", (related_id,)).fetchone()[0]
try:
conn.execute("INSERT INTO collection_properties (collection_id, name, prop_type, related_collection_id, reverse_name, position) VALUES (?, ?, 'relation', ?, ?, ?)", (related_id, reverse, row["collection_id"], "", max_pos))
except Exception:
logger.exception("create_relation_v2")
conn.commit()
return {"id": prop_id, "status": "updated"}
@router.post("/properties/evaluate-formula")
def evaluate_formula_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
expr = body.get("expression") or body.get("formula")
if not expr:
raise HTTPException(400, "expression required")
ctx = body.get("context") or {}
try:
from app.services.formula_engine import FormulaEngine
res = FormulaEngine().evaluate(expr, ctx)
except Exception as e:
raise HTTPException(400, f"Formula error: {e}") from None
return {"result": res, "expression": expr}
@router.post("/properties/compute-rollup")
def compute_rollup_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
for k in ("collection_id", "relation_property_id", "target_property_id", "page_id"):
if k not in body:
raise HTTPException(400, f"{k} required")
try:
from app.services.rollup_engine import RollupEngine
res = RollupEngine().compute(body["collection_id"], body["relation_property_id"], body["target_property_id"], body["page_id"], body.get("function", "count"))
except Exception as e:
raise HTTPException(400, f"Rollup error: {e}") from None
return {"result": res}
@router.get("/collections/{collection_id}/views")
def list_views_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
rows = conn.execute("SELECT * FROM collection_views WHERE collection_id=? ORDER BY position", (collection_id,)).fetchall()
return {"views": [row_to_dict(r) for r in rows]}
+160
View File
@@ -0,0 +1,160 @@
"""FlowDeck — Public API v2 : sharing.
Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency).
"""
from __future__ import annotations
import json
import logging
from fastapi import APIRouter, Body, Header, HTTPException, Request
from app.db import get_conn
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
audit_log,
check_idempotency,
check_v2_rate_limit,
get_bearer_user,
has_scope,
paginate_headers,
parse_pagination,
require_scope,
row_to_dict,
store_idempotency,
to_iso8601,
validate_scopes_input,
)
from app.services.automations import fire_event as _fire_event
from app.services.automations import run_event_sync
from app.services.publish import fire_published, fire_unpublished, publish, unpublish
from ._common import _v2_rate_check
logger = logging.getLogger(__name__)
router = APIRouter(tags=["api-v2"])
@router.post("/pages/{page_id}/shares")
def create_share_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
perm = (body.get("permission") or "view").strip().lower()
if perm not in ("view", "comment", "edit"):
raise HTTPException(400, "Invalid permission. Use view, comment, or edit")
email = (body.get("email") or "").strip()
uid = body.get("user_id")
if not email and not uid:
raise HTTPException(400, "email or user_id required")
with get_conn() as conn:
cur = conn.execute("INSERT INTO page_shares (page_id, shared_with_user_id, shared_with_email, permission, created_by) VALUES (?, ?, ?, ?, ?)", (page_id, uid, email, perm, user["id"]))
conn.execute("UPDATE pages SET is_shared=1 WHERE id=?", (page_id,))
conn.commit()
nid = cur.lastrowid
audit_log(user, "share.create", "share", nid, f"page={page_id}", request)
try:
run_event_sync(_fire_event("page.shared", {"page_id": page_id, "share_id": nid, "permission": perm}))
except Exception:
logger.exception("create_share_v2")
return {"id": nid, "page_id": page_id, "status": "shared"}
@router.patch("/shares/{share_id}")
def patch_share_v2(share_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
perm = (body.get("permission") or "").strip().lower()
if perm not in ("view", "comment", "edit"):
raise HTTPException(400, "Invalid permission")
with get_conn() as conn:
if not conn.execute("SELECT id FROM page_shares WHERE id=?", (share_id,)).fetchone():
raise HTTPException(404, "Share not found")
conn.execute("UPDATE page_shares SET permission=? WHERE id=?", (perm, share_id))
conn.commit()
return {"id": share_id, "status": "updated"}
@router.delete("/shares/{share_id}")
def delete_share_v2(share_id: int, request: Request, authorization: str | None = Header(default=None)):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
row = conn.execute("SELECT page_id FROM page_shares WHERE id=?", (share_id,)).fetchone()
if not row:
raise HTTPException(404, "Share not found")
conn.execute("DELETE FROM page_shares WHERE id=?", (share_id,))
# unset is_shared if no shares left
cnt = conn.execute("SELECT COUNT(*) FROM page_shares WHERE page_id=?", (row["page_id"],)).fetchone()[0]
if cnt == 0:
conn.execute("UPDATE pages SET is_shared=0 WHERE id=?", (row["page_id"],))
conn.commit()
return {"id": share_id, "status": "revoked"}
@router.post("/pages/{page_id}/publish")
def publish_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
slug_in = (body.get("slug") or body.get("publish_slug") or "").strip() or None
slug, _title = publish(page_id, explicit_slug=slug_in)
audit_log(user, "page.publish", "page", page_id, slug, request)
run_event_sync(fire_published(page_id, slug))
return {"page_id": page_id, "slug": slug, "url": f"/p/{slug}", "status": "published"}
@router.delete("/pages/{page_id}/publish")
def unpublish_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
unpublish(page_id)
run_event_sync(fire_unpublished(page_id))
return {"page_id": page_id, "status": "unpublished"}
@router.get("/pages/{page_id}/history")
def list_history_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
rows = conn.execute("SELECT h.*, u.login FROM page_history h LEFT JOIN users u ON u.id=h.user_id WHERE h.page_id=? ORDER BY h.created_at DESC", (page_id,)).fetchall()
# also page_versions for block pages
vrows = conn.execute("SELECT * FROM page_versions WHERE page_id=? ORDER BY created_at DESC", (page_id,)).fetchall()
return {"history": [row_to_dict(r) for r in rows], "versions": [row_to_dict(r) for r in vrows]}
@router.post("/pages/{page_id}/history/restore")
def restore_history_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
hid = body.get("history_id") or body.get("id") or body.get("version_id")
if not hid:
raise HTTPException(400, "history_id required")
with get_conn() as conn:
h = conn.execute("SELECT * FROM page_versions WHERE id=? AND page_id=?", (hid, page_id)).fetchone()
if h:
conn.execute("UPDATE pages SET content=?, title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (h["blocks_json"], h["title"], page_id))
conn.commit()
return {"page_id": page_id, "restored_version": hid, "status": "restored"}
h2 = conn.execute("SELECT * FROM page_history WHERE id=? AND page_id=?", (hid, page_id)).fetchone()
if h2:
try:
snap = json.loads(h2["snapshot_json"] or "{}")
except Exception:
snap = {}
# best-effort restore content
if snap.get("content"):
conn.execute("UPDATE pages SET content=? WHERE id=?", (snap["content"], page_id))
conn.commit()
return {"page_id": page_id, "restored_version": hid, "status": "restored"}
raise HTTPException(404, "History not found")
@router.get("/collections/{collection_id}/sprints")
def list_sprints_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
limit, offset = parse_pagination(request)
with get_conn() as conn:
total = conn.execute("SELECT COUNT(*) FROM sprints WHERE collection_id=?", (collection_id,)).fetchone()[0]
rows = conn.execute("SELECT * FROM sprints WHERE collection_id=? ORDER BY created_at DESC LIMIT ? OFFSET ?", (collection_id, limit, offset)).fetchall()
return {"sprints": [row_to_dict(r) for r in rows], "total": total, "limit": limit, "offset": offset}
+205
View File
@@ -0,0 +1,205 @@
"""FlowDeck — Public API v2 : templates_io.
Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency).
"""
from __future__ import annotations
import json
import logging
from fastapi import APIRouter, Body, Header, HTTPException, Request
from fastapi.responses import Response
from app.db import get_conn
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
audit_log,
check_idempotency,
check_v2_rate_limit,
get_bearer_user,
has_scope,
paginate_headers,
parse_pagination,
require_scope,
row_to_dict,
store_idempotency,
to_iso8601,
validate_scopes_input,
)
from ._common import _v2_rate_check
logger = logging.getLogger(__name__)
router = APIRouter(tags=["api-v2"])
@router.post("/collections/{collection_id}/templates")
def create_template_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
name = (body.get("name") or "Template").strip()
pv = json.dumps(body.get("property_values") or body.get("property_values_json") or {})
cj = json.dumps(body.get("content") or body.get("content_json") or [])
with get_conn() as conn:
cur = conn.execute("INSERT INTO page_templates (collection_id, name, property_values_json, content_json) VALUES (?, ?, ?, ?)", (collection_id, name, pv, cj))
tid = cur.lastrowid
conn.commit()
return {"id": tid, "name": name, "status": "created"}
@router.patch("/templates/{template_id}")
def patch_template_v2(template_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
row = conn.execute("SELECT * FROM page_templates WHERE id=?", (template_id,)).fetchone()
if not row:
raise HTTPException(404, "Template not found")
name = body.get("name", row["name"])
pv = json.dumps(body.get("property_values", json.loads(row["property_values_json"] or "{}"))) if "property_values" in body else row["property_values_json"]
cj = json.dumps(body.get("content", json.loads(row["content_json"] or "[]"))) if "content" in body else row["content_json"]
conn.execute("UPDATE page_templates SET name=?, property_values_json=?, content_json=? WHERE id=?", (name, pv, cj, template_id))
conn.commit()
return {"id": template_id, "status": "updated"}
@router.delete("/templates/{template_id}")
def delete_template_v2(template_id: int, request: Request, authorization: str | None = Header(default=None)):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
conn.execute("DELETE FROM page_templates WHERE id=?", (template_id,))
conn.commit()
return {"id": template_id, "status": "deleted"}
@router.post("/templates/{template_id}/apply")
def apply_template_v2(template_id: int, request: Request, authorization: str | None = Header(default=None)):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
tpl = conn.execute("SELECT * FROM page_templates WHERE id=?", (template_id,)).fetchone()
if not tpl:
raise HTTPException(404, "Template not found")
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_pages WHERE collection_id=?", (tpl["collection_id"],)).fetchone()[0]
pv = tpl["property_values_json"] or "{}"
cur = conn.execute("INSERT INTO collection_pages (collection_id, title, position, property_values_json) VALUES (?, ?, ?, ?)", (tpl["collection_id"], tpl["name"], max_pos, pv))
pid = cur.lastrowid
conn.commit()
return {"template_id": template_id, "page_id": pid, "status": "applied"}
@router.get("/templates/database")
def list_db_templates_v2(request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
rows = conn.execute("SELECT * FROM database_templates ORDER BY name").fetchall()
return {"templates": [row_to_dict(r) for r in rows]}
@router.post("/templates/database/{template_id}/apply")
def apply_db_template_v2(template_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
tpl = conn.execute("SELECT * FROM database_templates WHERE id=?", (template_id,)).fetchone()
if not tpl:
raise HTTPException(404, "Template not found")
name = (body.get("name") or tpl["name"]).strip()
schema = json.loads(tpl["schema_json"] or "[]")
cur = conn.execute("INSERT INTO collections (name, description, icon, schema_json, workspace_id, created_by) VALUES (?, ?, ?, ?, ?, ?)", (name, tpl["description"], tpl["icon"] if "icon" in tpl.keys() else "📋", json.dumps(schema), body.get("workspace_id"), user["id"]))
cid = cur.lastrowid
# A25 : pas de try — un échec de matérialisation doit interrompre la
# transaction plutôt que de commiter une collection sans schéma.
from app.services.db_templates import materialize_properties
materialize_properties(conn, cid, schema)
conn.commit()
return {"collection_id": cid, "name": name, "status": "created"}
@router.get("/pages/{page_id}/export")
def export_page_v2(page_id: int, request: Request, format: str = "markdown", authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
fmt = (format or request.query_params.get("format") or "markdown").lower()
if fmt not in ("markdown", "html", "pdf"):
raise HTTPException(400, "format must be markdown, html or pdf")
with get_conn() as conn:
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
if not row:
# try collection_pages
row2 = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone()
if not row2:
raise HTTPException(404, "Page not found")
# collection pages: return JSON
return {"page": row_to_dict(row2), "format": fmt}
# block pages: delegate to export service
from app.services.export import export_page as _export
try:
data, mime, fname = _export(row, fmt) # type: ignore
return Response(content=data, media_type=mime, headers={"Content-Disposition": f'attachment; filename="{fname}"'})
except Exception as e:
raise HTTPException(500, f"Export failed: {e}") from None
@router.get("/collections/{collection_id}/export/csv")
def export_csv_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
import csv
import io
with get_conn() as conn:
if not conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone():
raise HTTPException(404, "Collection not found")
props = [dict(r) for r in conn.execute("SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position", (collection_id,)).fetchall()]
rows = conn.execute("SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position", (collection_id,)).fetchall()
out = io.StringIO()
writer = csv.writer(out)
header = ["Title"] + [p["name"] for p in props]
writer.writerow(header)
for r in rows:
try:
pv = json.loads(r["property_values_json"] or "{}")
except Exception:
pv = {}
vals = [r["title"]]
for p in props:
vals.append(str(pv.get(str(p["id"])) or pv.get(p["name"]) or ""))
writer.writerow(vals)
return Response(content=out.getvalue().encode("utf-8"), media_type="text/csv", headers={"Content-Disposition": f'attachment; filename="collection-{collection_id}.csv"'})
@router.post("/collections/{collection_id}/import/csv")
async def import_csv_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
try:
form = await request.form()
file = form.get("file")
data = await file.read() if file else b""
text = data.decode("utf-8", errors="ignore")
except Exception as err:
raise HTTPException(400, "file required (multipart)") from err
import csv
import io
reader = csv.DictReader(io.StringIO(text))
created = 0
with get_conn() as conn:
for row in reader:
title = row.get("Title") or row.get("title") or "Untitled"
# map remaining columns to property names
pv = {}
# resolve prop name -> id
props = {p["name"]: p["id"] for p in conn.execute("SELECT id, name FROM collection_properties WHERE collection_id=?", (collection_id,)).fetchall()}
for k, v in row.items():
if k in ("Title", "title"):
continue
pid = props.get(k)
if pid:
pv[str(pid)] = v
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_pages WHERE collection_id=?", (collection_id,)).fetchone()[0]
conn.execute("INSERT INTO collection_pages (collection_id, title, position, property_values_json) VALUES (?, ?, ?, ?)", (collection_id, title, max_pos, json.dumps(pv)))
created += 1
conn.commit()
return {"imported": created, "status": "ok"}
+164
View File
@@ -0,0 +1,164 @@
"""FlowDeck — Public API v2 : views.
Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency).
"""
from __future__ import annotations
import json
import logging
from fastapi import APIRouter, Body, Header, HTTPException, Request
from app.db import get_conn
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
audit_log,
check_idempotency,
check_v2_rate_limit,
get_bearer_user,
has_scope,
paginate_headers,
parse_pagination,
require_scope,
row_to_dict,
store_idempotency,
to_iso8601,
validate_scopes_input,
)
from app.services.automations import fire_event as _fire_event
from app.services.automations import run_event_sync
from ._common import _v2_rate_check
logger = logging.getLogger(__name__)
router = APIRouter(tags=["api-v2"])
@router.post("/collections/{collection_id}/views")
def create_view_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
name = (body.get("name") or "New View").strip()
vtype = body.get("view_type") or body.get("type") or "table"
config = body.get("config") or body.get("config_json") or {}
with get_conn() as conn:
if not conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone():
raise HTTPException(404, "Collection not found")
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_views WHERE collection_id=?", (collection_id,)).fetchone()[0]
cur = conn.execute("INSERT INTO collection_views (collection_id, name, view_type, config_json, position, created_by) VALUES (?, ?, ?, ?, ?, ?)", (collection_id, name, vtype, json.dumps(config), max_pos, user["id"]))
vid = cur.lastrowid
conn.commit()
audit_log(user, "view.create", "view", vid, name, request)
try:
run_event_sync(_fire_event("collection.view.created", {"view_id": vid, "collection_id": collection_id, "name": name, "view_type": vtype}))
except Exception:
logger.exception("create_view_v2")
return {"id": vid, "name": name, "view_type": vtype, "status": "created"}
@router.patch("/views/{view_id}")
def patch_view_v2(view_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
row = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
if not row:
raise HTTPException(404, "View not found")
cfg = json.loads(row["config_json"] or "{}")
if "config" in body:
cfg.update(body["config"])
elif "config_json" in body:
try:
cfg.update(json.loads(body["config_json"]) if isinstance(body["config_json"], str) else body["config_json"])
except Exception:
logger.exception("patch_view_v2")
# also flat keys
for k in ("group_by", "sub_group_by", "wip_limits", "card_size", "cover_property", "cover_mode", "card_properties", "visible_properties", "filters", "sorts", "date_property"):
if k in body:
cfg[k] = body[k]
name = body.get("name", row["name"])
vtype = body.get("view_type") or body.get("type") or row["view_type"]
conn.execute("UPDATE collection_views SET name=?, view_type=?, config_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (name, vtype, json.dumps(cfg), view_id))
conn.commit()
audit_log(user, "view.update", "view", view_id, "", request)
return {"id": view_id, "status": "updated"}
@router.delete("/views/{view_id}")
def delete_view_v2(view_id: int, request: Request, authorization: str | None = Header(default=None)):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
if not conn.execute("SELECT id FROM collection_views WHERE id=?", (view_id,)).fetchone():
raise HTTPException(404, "View not found")
conn.execute("DELETE FROM collection_views WHERE id=?", (view_id,))
conn.commit()
audit_log(user, "view.delete", "view", view_id, "", request)
return {"id": view_id, "status": "deleted"}
@router.post("/views/{view_id}/save-as")
def save_as_view_v2(view_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
name = (body.get("name") or "").strip() or "Copy"
with get_conn() as conn:
row = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
if not row:
raise HTTPException(404, "View not found")
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_views WHERE collection_id=?", (row["collection_id"],)).fetchone()[0]
cur = conn.execute("INSERT INTO collection_views (collection_id, name, view_type, config_json, position, created_by) VALUES (?, ?, ?, ?, ?, ?)", (row["collection_id"], name, row["view_type"], row["config_json"], max_pos, user["id"]))
nid = cur.lastrowid
conn.commit()
return {"id": nid, "name": name, "status": "created"}
@router.get("/collections/{collection_id}/dashboards")
def list_dashboards_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
rows = conn.execute("SELECT * FROM collection_dashboards WHERE collection_id=? ORDER BY created_at", (collection_id,)).fetchall()
return {"dashboards": [row_to_dict(r) for r in rows]}
@router.post("/collections/{collection_id}/dashboards")
def create_dashboard_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
name = (body.get("name") or "Dashboard").strip()
layout = body.get("layout") or body.get("layout_json") or {"columns": 1, "widgets": []}
with get_conn() as conn:
cur = conn.execute("INSERT INTO collection_dashboards (collection_id, name, layout_json) VALUES (?, ?, ?)", (collection_id, name, json.dumps(layout)))
did = cur.lastrowid
conn.commit()
return {"id": did, "name": name, "status": "created"}
@router.patch("/dashboards/{dashboard_id}")
def patch_dashboard_v2(dashboard_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
row = conn.execute("SELECT * FROM collection_dashboards WHERE id=?", (dashboard_id,)).fetchone()
if not row:
raise HTTPException(404, "Dashboard not found")
name = body.get("name", row["name"])
layout = body.get("layout") or body.get("layout_json")
if layout is not None:
layout_json = json.dumps(layout)
else:
layout_json = row["layout_json"]
conn.execute("UPDATE collection_dashboards SET name=?, layout_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (name, layout_json, dashboard_id))
conn.commit()
return {"id": dashboard_id, "status": "updated"}
@router.delete("/dashboards/{dashboard_id}")
def delete_dashboard_v2(dashboard_id: int, request: Request, authorization: str | None = Header(default=None)):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
conn.execute("DELETE FROM collection_dashboards WHERE id=?", (dashboard_id,))
conn.commit()
return {"id": dashboard_id, "status": "deleted"}
+195
View File
@@ -0,0 +1,195 @@
"""FlowDeck — Public API v2 : webhooks.
Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency).
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Body, Header, HTTPException, Request
from fastapi.responses import JSONResponse
from app.db import get_conn
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
audit_log,
check_idempotency,
check_v2_rate_limit,
get_bearer_user,
has_scope,
paginate_headers,
parse_pagination,
require_scope,
row_to_dict,
store_idempotency,
to_iso8601,
validate_scopes_input,
)
from ._common import _v2_rate_check
logger = logging.getLogger(__name__)
router = APIRouter(tags=["api-v2"])
@router.get("/webhooks")
def list_webhooks_v2(request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
limit, offset = parse_pagination(request)
with get_conn() as conn:
total = conn.execute("SELECT COUNT(*) AS n FROM webhook_subscriptions").fetchone()["n"]
rows = conn.execute(
"SELECT * FROM webhook_subscriptions ORDER BY created_at DESC LIMIT ? OFFSET ?",
(limit, offset),
).fetchall()
return JSONResponse(
content={"webhooks": [dict(r) for r in rows]},
headers=paginate_headers(total),
)
@router.post("/webhooks")
def create_webhook_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
from app.services.webhook_outbound import EVENTS, _event_matches
url = (body.get("url") or "").strip()
event = (body.get("event") or "page.created").strip()
secret = (body.get("secret") or "").strip()
if not url or not url.startswith("http"):
raise HTTPException(400, "url must start with http")
if not event or (event not in EVENTS and not (event.endswith(".*") or event in ("*", "all"))):
raise HTTPException(400, f"Unknown event '{event}'. See GET /api/v2/webhooks/events")
# make sure the pattern matches at least one known event
if not any(_event_matches(event, e) for e in EVENTS):
raise HTTPException(400, f"Event pattern '{event}' matches no known event")
with get_conn() as conn:
cur = conn.execute("INSERT INTO webhook_subscriptions (url, event, secret) VALUES (?, ?, ?)", (url, event, secret))
wid = cur.lastrowid
conn.commit()
row = conn.execute("SELECT * FROM webhook_subscriptions WHERE id=?", (wid,)).fetchone()
audit_log(user, "webhook.create", "webhook", wid, url, request)
return {"id": wid, "status": "created", "webhook": dict(row) if row else {},
"signature_header": "X-FlowDeck-Signature (HMAC-SHA256, sha256=<hex>)" if secret else None}
@router.patch("/webhooks/{webhook_id}")
def patch_webhook_v2(webhook_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
row = conn.execute("SELECT * FROM webhook_subscriptions WHERE id=?", (webhook_id,)).fetchone()
if not row:
raise HTTPException(404, "Webhook not found")
url = body.get("url", row["url"])
event = body.get("event", row["event"])
secret = body.get("secret", row["secret"])
active = int(bool(body.get("active", row["active"])))
conn.execute("UPDATE webhook_subscriptions SET url=?, event=?, secret=?, active=? WHERE id=?", (url, event, secret, active, webhook_id))
conn.commit()
audit_log(user, "webhook.update", "webhook", webhook_id, "", request)
return {"id": webhook_id, "status": "updated"}
@router.delete("/webhooks/{webhook_id}")
def delete_webhook_v2(webhook_id: int, request: Request, authorization: str | None = Header(default=None)):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
conn.execute("DELETE FROM webhook_subscriptions WHERE id=?", (webhook_id,))
conn.commit()
audit_log(user, "webhook.delete", "webhook", webhook_id, "", request)
return {"id": webhook_id, "status": "deleted"}
@router.post("/webhooks/{webhook_id}/test")
async def test_webhook_v2(webhook_id: int, request: Request, authorization: str | None = Header(default=None)):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
row = conn.execute("SELECT * FROM webhook_subscriptions WHERE id=?", (webhook_id,)).fetchone()
if not row:
raise HTTPException(404, "Webhook not found")
# live delivery via the prod dispatcher (HMAC + retry + journal),
# direct to this subscription only (no wildcard fan-out)
from app.services.webhook_outbound import deliver_to_sub
ok = await deliver_to_sub(webhook_id, row["url"], "ping",
{"webhook_id": webhook_id, "test": True},
row["secret"] or "")
audit_log(user, "webhook.test", "webhook", webhook_id, f"ok={ok}", request)
return {"webhook_id": webhook_id, "status": "tested", "delivered": ok}
@router.get("/webhooks/{webhook_id}/deliveries")
def list_deliveries_v2(webhook_id: int, request: Request,
status: str | None = None,
authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
limit, offset = parse_pagination(request)
with get_conn() as conn:
if status:
total = conn.execute(
"SELECT COUNT(*) AS n FROM webhook_deliveries WHERE webhook_id=? AND status=?",
(webhook_id, status)).fetchone()["n"]
rows = conn.execute(
"SELECT * FROM webhook_deliveries WHERE webhook_id=? AND status=? "
"ORDER BY created_at DESC LIMIT ? OFFSET ?",
(webhook_id, status, limit, offset)).fetchall()
else:
total = conn.execute(
"SELECT COUNT(*) AS n FROM webhook_deliveries WHERE webhook_id=?",
(webhook_id,)).fetchone()["n"]
rows = conn.execute(
"SELECT * FROM webhook_deliveries WHERE webhook_id=? "
"ORDER BY created_at DESC LIMIT ? OFFSET ?",
(webhook_id, limit, offset)).fetchall()
return JSONResponse(
content={"deliveries": [row_to_dict(r) for r in rows]},
headers=paginate_headers(total),
)
@router.post("/webhooks/{webhook_id}/retry")
async def retry_webhook_deliveries(webhook_id: int, request: Request,
authorization: str | None = Header(default=None)):
"""Manually retry failed deliveries for a webhook."""
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
from app.services.webhook_outbound import retry_due_deliveries
with get_conn() as conn:
# Force retry by setting next_retry_at to the past
conn.execute(
"""UPDATE webhook_deliveries
SET next_retry_at = strftime('%s', 'now', '-1 second')
WHERE webhook_id = ? AND status = 'retrying'""",
(webhook_id,),
)
conn.commit()
retried = await retry_due_deliveries()
audit_log(user, "webhook.retry", "webhook", webhook_id, f"retried={retried}", request)
return {"webhook_id": webhook_id, "status": "retried", "retried_count": retried}
@router.post("/webhooks/verify-signature")
def verify_webhook_signature(request: Request,
authorization: str | None = Header(default=None),
body: dict = Body(default={})):
"""Verify a webhook signature (for debugging/testing)."""
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
from app.services.webhook_outbound import verify_signature
secret = body.get("secret", "")
payload = body.get("payload", "{}")
signature = body.get("signature", "")
is_valid = verify_signature(secret, payload.encode(), signature)
audit_log(user, "webhook.signature_verify", "webhook", 0, f"valid={is_valid}", request)
return {"valid": is_valid, "secret": secret[:10] + "..." if len(secret) > 10 else secret}
+230
View File
@@ -0,0 +1,230 @@
"""FlowDeck — Public API v2 : workspaces.
Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency).
"""
from __future__ import annotations
import json
import logging
from fastapi import APIRouter, Body, Header, HTTPException, Request
from fastapi.responses import JSONResponse
from app.db import get_conn
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
audit_log,
check_idempotency,
check_v2_rate_limit,
get_bearer_user,
has_scope,
paginate_headers,
parse_pagination,
require_scope,
row_to_dict,
store_idempotency,
to_iso8601,
validate_scopes_input,
)
from ._common import _v2_rate_check
logger = logging.getLogger(__name__)
router = APIRouter(tags=["api-v2"])
@router.get("/workspaces")
def list_workspaces(request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
limit, offset = parse_pagination(request)
with get_conn() as conn:
total = conn.execute("SELECT COUNT(*) FROM workspaces WHERE owner_id=? OR id IN (SELECT workspace_id FROM workspace_members WHERE user_id=?)", (user["id"], user["id"])).fetchone()[0]
rows = conn.execute("SELECT w.*, wm.role FROM workspaces w LEFT JOIN workspace_members wm ON wm.workspace_id=w.id AND wm.user_id=? WHERE w.owner_id=? OR w.id IN (SELECT workspace_id FROM workspace_members WHERE user_id=?) ORDER BY w.created_at DESC LIMIT ? OFFSET ?", (user["id"], user["id"], user["id"], limit, offset)).fetchall()
out = []
for r in rows:
d = dict(r)
d["created_at"] = to_iso8601(d.get("created_at"))
try:
d["settings"] = json.loads(d.get("settings_json") or "{}")
except Exception:
d["settings"] = {}
out.append(d)
return {"workspaces": out, "total": total, "limit": limit, "offset": offset}
@router.post("/workspaces")
def create_workspace(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
idem = check_idempotency(request, user["id"])
if idem:
return JSONResponse(content=idem["data"], status_code=idem["status"])
name = (body.get("name") or "").strip()
if not name:
raise HTTPException(400, "name is required")
settings_json = json.dumps(body.get("settings") or body.get("settings_json") or {})
with get_conn() as conn:
cur = conn.execute("INSERT INTO workspaces (name, owner_id, settings_json) VALUES (?, ?, ?)", (name, user["id"], settings_json))
wid = cur.lastrowid
# owner is implicitly admin member
try:
conn.execute("INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, 'owner')", (wid, user["id"]))
except Exception:
logger.exception("create_workspace")
conn.commit()
row = conn.execute("SELECT * FROM workspaces WHERE id=?", (wid,)).fetchone()
audit_log(user, "workspace.create", "workspace", wid, name, request)
data = {"id": wid, "name": name, "owner_id": user["id"], "status": "created", "workspace": row_to_dict(row)}
key = (request.headers.get("Idempotency-Key") or "").strip()
if key:
store_idempotency(key, user["id"], data, 200)
return JSONResponse(content=data, status_code=201)
@router.get("/workspaces/{workspace_id}")
def get_workspace(workspace_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
row = conn.execute("SELECT * FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
if not row:
raise HTTPException(404, "Workspace not found")
# ACL: must be member or owner
member = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?", (workspace_id, user["id"])).fetchone()
is_owner = row["owner_id"] == user["id"]
if not is_owner and not member and not user.get("is_admin"):
raise HTTPException(404, "Workspace not found")
members = conn.execute("SELECT u.id, u.login, u.full_name, u.avatar_url, wm.role FROM workspace_members wm JOIN users u ON u.id=wm.user_id WHERE wm.workspace_id=? ORDER BY wm.joined_at", (workspace_id,)).fetchall()
d = row_to_dict(row)
d["members"] = [dict(m) for m in members]
return d
@router.patch("/workspaces/{workspace_id}")
def patch_workspace(workspace_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
row = conn.execute("SELECT * FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
if not row:
raise HTTPException(404, "Workspace not found")
if row["owner_id"] != user["id"] and not user.get("is_admin"):
# check admin member
mem = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?", (workspace_id, user["id"])).fetchone()
if not mem or mem["role"] not in ("owner", "admin"):
raise HTTPException(403, "Only owner/admin can edit workspace")
name = body.get("name", row["name"])
sj = body.get("settings_json") or body.get("settings")
if sj is not None:
sj = json.dumps(sj) if isinstance(sj, (dict, list)) else str(sj)
else:
sj = row["settings_json"]
conn.execute("UPDATE workspaces SET name=?, settings_json=? WHERE id=?", (name, sj, workspace_id))
conn.commit()
audit_log(user, "workspace.update", "workspace", workspace_id, "", request)
return {"id": workspace_id, "status": "updated"}
@router.delete("/workspaces/{workspace_id}")
def delete_workspace(workspace_id: int, request: Request, authorization: str | None = Header(default=None)):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
row = conn.execute("SELECT * FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
if not row:
raise HTTPException(404, "Workspace not found")
if row["owner_id"] != user["id"] and not user.get("is_admin"):
raise HTTPException(403, "Only owner can delete workspace")
conn.execute("DELETE FROM workspaces WHERE id=?", (workspace_id,))
conn.commit()
audit_log(user, "workspace.delete", "workspace", workspace_id, "", request)
return {"id": workspace_id, "status": "deleted"}
@router.get("/workspaces/{workspace_id}/members")
def list_workspace_members(workspace_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
ws = conn.execute("SELECT id FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
if not ws:
raise HTTPException(404, "Workspace not found")
rows = conn.execute("SELECT u.id, u.login, u.full_name, u.avatar_url, wm.role, wm.joined_at FROM workspace_members wm JOIN users u ON u.id=wm.user_id WHERE wm.workspace_id=? ORDER BY wm.joined_at", (workspace_id,)).fetchall()
return {"members": [row_to_dict(r) for r in rows]}
@router.post("/workspaces/{workspace_id}/members")
def invite_member(workspace_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
target_id = body.get("user_id") or body.get("uid")
email = (body.get("email") or "").strip()
role = (body.get("role") or "editor").strip().lower()
if role not in ("owner", "admin", "editor", "viewer", "commenter"):
raise HTTPException(400, "Invalid role")
with get_conn() as conn:
ws = conn.execute("SELECT owner_id FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
if not ws:
raise HTTPException(404, "Workspace not found")
# only owner/admin can invite
if ws["owner_id"] != user["id"] and not user.get("is_admin"):
mem = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?", (workspace_id, user["id"])).fetchone()
if not mem or mem["role"] not in ("owner", "admin"):
raise HTTPException(403, "Only owner/admin can invite")
uid = target_id
if not uid and email:
u = conn.execute("SELECT id FROM users WHERE email=?", (email,)).fetchone()
if not u:
raise HTTPException(404, f"User with email {email} not found")
uid = u["id"]
if not uid:
raise HTTPException(400, "user_id or email required")
try:
conn.execute("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, ?)", (workspace_id, uid, role))
except Exception:
conn.execute("UPDATE workspace_members SET role=? WHERE workspace_id=? AND user_id=?", (role, workspace_id, uid))
conn.commit()
audit_log(user, "workspace.invite", "workspace", workspace_id, f"uid={uid} role={role}", request)
return {"workspace_id": workspace_id, "user_id": uid, "role": role, "status": "added"}
@router.patch("/workspaces/{workspace_id}/members/{uid}")
def update_member_role(workspace_id: int, uid: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
role = (body.get("role") or "").strip().lower()
if role not in ("owner", "admin", "editor", "viewer", "commenter"):
raise HTTPException(400, "Invalid role")
with get_conn() as conn:
ws = conn.execute("SELECT owner_id FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
if not ws:
raise HTTPException(404, "Workspace not found")
if ws["owner_id"] != user["id"] and not user.get("is_admin"):
mem = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?", (workspace_id, user["id"])).fetchone()
if not mem or mem["role"] not in ("owner", "admin"):
raise HTTPException(403, "Only owner/admin can change roles")
conn.execute("UPDATE workspace_members SET role=? WHERE workspace_id=? AND user_id=?", (role, workspace_id, uid))
if conn.total_changes == 0:
raise HTTPException(404, "Member not found")
conn.commit()
audit_log(user, "workspace.role_change", "workspace", workspace_id, f"uid={uid} role={role}", request)
return {"workspace_id": workspace_id, "user_id": uid, "role": role, "status": "updated"}
@router.delete("/workspaces/{workspace_id}/members/{uid}")
def remove_member(workspace_id: int, uid: int, request: Request, authorization: str | None = Header(default=None)):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
ws = conn.execute("SELECT owner_id FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
if not ws:
raise HTTPException(404, "Workspace not found")
if ws["owner_id"] != user["id"] and not user.get("is_admin"):
mem = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?", (workspace_id, user["id"])).fetchone()
if not mem or mem["role"] not in ("owner", "admin"):
raise HTTPException(403, "Only owner/admin can remove members")
conn.execute("DELETE FROM workspace_members WHERE workspace_id=? AND user_id=?", (workspace_id, uid))
conn.commit()
audit_log(user, "workspace.remove_member", "workspace", workspace_id, f"uid={uid}", request)
return {"workspace_id": workspace_id, "user_id": uid, "status": "removed"}
+2 -2
View File
@@ -16,6 +16,7 @@ from app.routers.dashboard import _get_app_version
from app.routers.sidebar_config import get_sidebar_config_sync from app.routers.sidebar_config import get_sidebar_config_sync
from app.services.automations import fire_event, run_event_sync from app.services.automations import fire_event, run_event_sync
from app.services.gitea_client import gitea from app.services.gitea_client import gitea
from app.services.http_client import shared_client
from app.services.permission_manager import PermissionManager from app.services.permission_manager import PermissionManager
from app.services.publish import fire_published, fire_unpublished, publish, unpublish from app.services.publish import fire_published, fire_unpublished, publish, unpublish
@@ -1880,8 +1881,7 @@ async def _unfurl_repo(forge: str, owner: str, repo: str):
if token: if token:
info = await GitHubAdapter(access_token=token).get_repo_info(owner, repo) info = await GitHubAdapter(access_token=token).get_repo_info(owner, repo)
else: else:
import httpx async with shared_client(timeout=10) as client:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.get( r = await client.get(
f"https://api.github.com/repos/{owner}/{repo}", f"https://api.github.com/repos/{owner}/{repo}",
headers={"Accept": "application/vnd.github+json"}, headers={"Accept": "application/vnd.github+json"},
+2 -2
View File
@@ -18,6 +18,7 @@ from fastapi.responses import JSONResponse
from app.auth.session import SessionManager from app.auth.session import SessionManager
from app.db import get_conn from app.db import get_conn
from app.services.api_v2_helpers import audit_log from app.services.api_v2_helpers import audit_log
from app.services.http_client import shared_client
router = APIRouter(tags=["scim"]) router = APIRouter(tags=["scim"])
SCIM_SCHEMAS = ["urn:ietf:params:scim:schemas:core:2.0:User"] SCIM_SCHEMAS = ["urn:ietf:params:scim:schemas:core:2.0:User"]
@@ -246,7 +247,6 @@ def create_domain(request: Request, body: dict = Body(default={})):
@router.post("/api/v2/domain-claims/{domain_id}/verify") @router.post("/api/v2/domain-claims/{domain_id}/verify")
async def verify_domain(domain_id: int, request: Request): async def verify_domain(domain_id: int, request: Request):
admin = _admin_session(request) admin = _admin_session(request)
import httpx
with get_conn() as conn: with get_conn() as conn:
row = conn.execute("SELECT * FROM domain_claims WHERE id=?", (domain_id,)).fetchone() row = conn.execute("SELECT * FROM domain_claims WHERE id=?", (domain_id,)).fetchone()
if not row: if not row:
@@ -254,7 +254,7 @@ async def verify_domain(domain_id: int, request: Request):
claim = dict(row) claim = dict(row)
url = f"https://{claim['domain']}/.well-known/flowdeck-verify.txt" url = f"https://{claim['domain']}/.well-known/flowdeck-verify.txt"
try: try:
async with httpx.AsyncClient(timeout=10, follow_redirects=True) as client: async with shared_client(timeout=10, follow_redirects=True) as client:
resp = await client.get(url) resp = await client.get(url)
ok = resp.status_code == 200 and claim["txt_token"] in (resp.text or "") ok = resp.status_code == 200 and claim["txt_token"] in (resp.text or "")
except Exception: # noqa: BLE001 — unreachable domain = not verified except Exception: # noqa: BLE001 — unreachable domain = not verified
+2 -2
View File
@@ -24,6 +24,7 @@ from app.auth.providers import oidc_provider, saml_provider
from app.auth.session import SessionManager from app.auth.session import SessionManager
from app.services import sso_provisioning as sso from app.services import sso_provisioning as sso
from app.services.api_v2_helpers import has_scope, resolve_bearer_token from app.services.api_v2_helpers import has_scope, resolve_bearer_token
from app.services.http_client import shared_client
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(tags=["sso"]) router = APIRouter(tags=["sso"])
@@ -436,10 +437,9 @@ async def _fetch_jwks(doc: dict) -> dict:
url = doc.get("jwks_uri") url = doc.get("jwks_uri")
if not url: if not url:
raise oidc_provider.OIDCError("Discovery document has no jwks_uri") raise oidc_provider.OIDCError("Discovery document has no jwks_uri")
import httpx
try: try:
async with httpx.AsyncClient(timeout=15) as client: async with shared_client(timeout=15) as client:
r = await client.get(url) r = await client.get(url)
r.raise_for_status() r.raise_for_status()
data = r.json() data = r.json()
+4 -5
View File
@@ -26,10 +26,9 @@ import logging
import time import time
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
import httpx
from app.db import get_conn from app.db import get_conn
from app.services import notifications from app.services import notifications
from app.services.http_client import shared_client
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -179,7 +178,7 @@ async def _run_action(action: dict, context: dict, trigger_source: str) -> str:
headers = {"Content-Type": "application/json", "X-FlowDeck-Event": context.get("event", "")} headers = {"Content-Type": "application/json", "X-FlowDeck-Event": context.get("event", "")}
if secret: if secret:
headers["X-FlowDeck-Secret"] = secret headers["X-FlowDeck-Secret"] = secret
async with httpx.AsyncClient(timeout=10) as client: async with shared_client(timeout=10) as client:
resp = await client.post(url, json=context, headers=headers) resp = await client.post(url, json=context, headers=headers)
if resp.status_code >= 400: if resp.status_code >= 400:
raise RuntimeError(f"webhook returned HTTP {resp.status_code}") raise RuntimeError(f"webhook returned HTTP {resp.status_code}")
@@ -719,7 +718,7 @@ async def fire_stepped_event(event: str, payload: dict) -> None:
# ── v7.0.0 action backends (module-level = monkeypatchable in tests) ─────── # ── v7.0.0 action backends (module-level = monkeypatchable in tests) ───────
async def _post_slack(webhook_url: str, text: str) -> str: async def _post_slack(webhook_url: str, text: str) -> str:
async with httpx.AsyncClient(timeout=10) as client: async with shared_client(timeout=10) as client:
resp = await client.post(webhook_url, json={"text": text}) resp = await client.post(webhook_url, json={"text": text})
if resp.status_code >= 400: if resp.status_code >= 400:
raise RuntimeError(f"slack webhook returned HTTP {resp.status_code}") raise RuntimeError(f"slack webhook returned HTTP {resp.status_code}")
@@ -765,7 +764,7 @@ async def _create_forge_issue(provider: str, owner: str, repo: str, title: str,
if provider == "github": if provider == "github":
if not token: if not token:
raise ValueError("github action needs a linked GitHub account (token)") raise ValueError("github action needs a linked GitHub account (token)")
async with httpx.AsyncClient(timeout=15) as client: async with shared_client(timeout=15) as client:
resp = await client.post( resp = await client.post(
f"https://api.github.com/repos/{owner}/{repo}/issues", f"https://api.github.com/repos/{owner}/{repo}/issues",
headers={"Authorization": f"Bearer {token}", headers={"Authorization": f"Bearer {token}",
+5 -6
View File
@@ -19,9 +19,8 @@ import time
import uuid import uuid
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
import httpx
from app.db import get_conn from app.db import get_conn
from app.services.http_client import shared_client
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -125,7 +124,7 @@ async def google_list_events(tokens: dict, calendar_id: str,
url = (f"https://www.googleapis.com/calendar/v3/calendars/{calendar_id}" url = (f"https://www.googleapis.com/calendar/v3/calendars/{calendar_id}"
f"/events?singleEvents=true&orderBy=startTime" f"/events?singleEvents=true&orderBy=startTime"
f"&timeMin={time_min}&timeMax={time_max}") f"&timeMin={time_min}&timeMax={time_max}")
async with httpx.AsyncClient(timeout=15) as client: async with shared_client(timeout=15) as client:
resp = await client.get(url, headers={"Authorization": f"Bearer {access}"}) resp = await client.get(url, headers={"Authorization": f"Bearer {access}"})
if resp.status_code == 401: if resp.status_code == 401:
raise SyncError("google token expired — relink the calendar") raise SyncError("google token expired — relink the calendar")
@@ -150,7 +149,7 @@ async def google_push_event(tokens: dict, calendar_id: str, event: dict,
"start": {"date": event.get("start", "")[:10]}, "start": {"date": event.get("start", "")[:10]},
"end": {"date": event.get("start", "")[:10]}} "end": {"date": event.get("start", "")[:10]}}
base = f"https://www.googleapis.com/calendar/v3/calendars/{calendar_id}/events" base = f"https://www.googleapis.com/calendar/v3/calendars/{calendar_id}/events"
async with httpx.AsyncClient(timeout=15) as client: async with shared_client(timeout=15) as client:
if remote_id: if remote_id:
resp = await client.patch(f"{base}/{remote_id}", resp = await client.patch(f"{base}/{remote_id}",
headers={"Authorization": f"Bearer {access}"}, json=body) headers={"Authorization": f"Bearer {access}"}, json=body)
@@ -224,7 +223,7 @@ async def caldav_list_events(creds: dict, time_min: str, time_max: str) -> list[
body = _CALDAV_REPORT.format( body = _CALDAV_REPORT.format(
start=time_min.replace("-", "").split("T")[0] + "T000000Z", start=time_min.replace("-", "").split("T")[0] + "T000000Z",
end=time_max.replace("-", "").split("T")[0] + "T000000Z") end=time_max.replace("-", "").split("T")[0] + "T000000Z")
async with httpx.AsyncClient(timeout=15, auth=auth if auth[0] else None) as client: async with shared_client(timeout=15, auth=auth if auth[0] else None) as client:
resp = await client.request("REPORT", url, content=body, resp = await client.request("REPORT", url, content=body,
headers={"Depth": "1", headers={"Depth": "1",
"Content-Type": "application/xml"}) "Content-Type": "application/xml"})
@@ -245,7 +244,7 @@ async def caldav_push_event(creds: dict, event: dict, remote_id: str = "") -> st
remote_id if remote_id.startswith("http") else f"{url}/{remote_id}") remote_id if remote_id.startswith("http") else f"{url}/{remote_id}")
ics = _event_to_ics(uid.split("@")[0], event.get("title", ""), ics = _event_to_ics(uid.split("@")[0], event.get("title", ""),
event.get("start", ""), event.get("description", "")) event.get("start", ""), event.get("description", ""))
async with httpx.AsyncClient(timeout=15, auth=auth if auth[0] else None) as client: async with shared_client(timeout=15, auth=auth if auth[0] else None) as client:
resp = await client.put(href, content=ics, headers={"Content-Type": "text/calendar"}) resp = await client.put(href, content=ics, headers={"Content-Type": "text/calendar"})
if resp.status_code >= 400: if resp.status_code >= 400:
raise SyncError(f"caldav returned HTTP {resp.status_code}") raise SyncError(f"caldav returned HTTP {resp.status_code}")
+22 -23
View File
@@ -5,9 +5,8 @@ import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any from typing import Any
import httpx
from app.config import settings from app.config import settings
from app.services.http_client import shared_client
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -48,7 +47,7 @@ class GiteaClient:
if cached: if cached:
return cached return cached
async with httpx.AsyncClient(timeout=15) as client: async with shared_client(timeout=15) as client:
resp = await client.get( resp = await client.get(
f"{self._base}/user/repos", f"{self._base}/user/repos",
headers=self._headers, headers=self._headers,
@@ -65,7 +64,7 @@ class GiteaClient:
if cached: if cached:
return cached return cached
async with httpx.AsyncClient(timeout=15) as client: async with shared_client(timeout=15) as client:
resp = await client.get( resp = await client.get(
f"{self._base}/orgs/{org}/repos", f"{self._base}/orgs/{org}/repos",
headers=self._headers, headers=self._headers,
@@ -82,7 +81,7 @@ class GiteaClient:
cached = self._cached(cache_key) cached = self._cached(cache_key)
if cached: if cached:
return cached return cached
async with httpx.AsyncClient(timeout=15) as client: async with shared_client(timeout=15) as client:
resp = await client.get( resp = await client.get(
f"{self._base}/repos/{owner}/{repo}", f"{self._base}/repos/{owner}/{repo}",
headers=self._headers, headers=self._headers,
@@ -109,7 +108,7 @@ class GiteaClient:
if cached: if cached:
return cached return cached
async with httpx.AsyncClient(timeout=10) as client: async with shared_client(timeout=10) as client:
resp = await client.get( resp = await client.get(
f"{self._base}/user/orgs", headers=self._headers f"{self._base}/user/orgs", headers=self._headers
) )
@@ -128,7 +127,7 @@ class GiteaClient:
if cached: if cached:
return cached return cached
async with httpx.AsyncClient(timeout=15) as client: async with shared_client(timeout=15) as client:
resp = await client.get( resp = await client.get(
f"{self._base}/repos/{owner}/{repo}/issues", f"{self._base}/repos/{owner}/{repo}/issues",
headers=self._headers, headers=self._headers,
@@ -140,7 +139,7 @@ class GiteaClient:
return data return data
async def get_issue(self, owner: str, repo: str, issue_id: int) -> dict: async def get_issue(self, owner: str, repo: str, issue_id: int) -> dict:
async with httpx.AsyncClient(timeout=10) as client: async with shared_client(timeout=10) as client:
resp = await client.get( resp = await client.get(
f"{self._base}/repos/{owner}/{repo}/issues/{issue_id}", f"{self._base}/repos/{owner}/{repo}/issues/{issue_id}",
headers=self._headers, headers=self._headers,
@@ -163,7 +162,7 @@ class GiteaClient:
payload["assignees"] = [assignee] payload["assignees"] = [assignee]
self._invalidate_issue_cache(owner, repo) self._invalidate_issue_cache(owner, repo)
async with httpx.AsyncClient(timeout=10) as client: async with shared_client(timeout=10) as client:
resp = await client.post( resp = await client.post(
f"{self._base}/repos/{owner}/{repo}/issues", f"{self._base}/repos/{owner}/{repo}/issues",
headers=self._headers, headers=self._headers,
@@ -177,7 +176,7 @@ class GiteaClient:
) -> dict: ) -> dict:
"""Update an issue (title, body, state, labels, milestone, assignees).""" """Update an issue (title, body, state, labels, milestone, assignees)."""
self._invalidate_issue_cache(owner, repo) self._invalidate_issue_cache(owner, repo)
async with httpx.AsyncClient(timeout=10) as client: async with shared_client(timeout=10) as client:
resp = await client.patch( resp = await client.patch(
f"{self._base}/repos/{owner}/{repo}/issues/{issue_id}", f"{self._base}/repos/{owner}/{repo}/issues/{issue_id}",
headers=self._headers, headers=self._headers,
@@ -191,7 +190,7 @@ class GiteaClient:
) -> list[dict]: ) -> list[dict]:
"""Replace all labels on an issue (PUT endpoint).""" """Replace all labels on an issue (PUT endpoint)."""
self._invalidate_issue_cache(owner, repo) self._invalidate_issue_cache(owner, repo)
async with httpx.AsyncClient(timeout=10) as client: async with shared_client(timeout=10) as client:
resp = await client.put( resp = await client.put(
f"{self._base}/repos/{owner}/{repo}/issues/{issue_id}/labels", f"{self._base}/repos/{owner}/{repo}/issues/{issue_id}/labels",
headers=self._headers, headers=self._headers,
@@ -210,7 +209,7 @@ class GiteaClient:
self, owner: str, repo: str, issue_id: int self, owner: str, repo: str, issue_id: int
) -> list[dict]: ) -> list[dict]:
"""Get comments for an issue.""" """Get comments for an issue."""
async with httpx.AsyncClient(timeout=10) as client: async with shared_client(timeout=10) as client:
resp = await client.get( resp = await client.get(
f"{self._base}/repos/{owner}/{repo}/issues/{issue_id}/comments", f"{self._base}/repos/{owner}/{repo}/issues/{issue_id}/comments",
headers=self._headers, headers=self._headers,
@@ -232,7 +231,7 @@ class GiteaClient:
if cached: if cached:
return cached return cached
async with httpx.AsyncClient(timeout=10) as client: async with shared_client(timeout=10) as client:
resp = await client.get( resp = await client.get(
f"{self._base}/repos/{owner}/{repo}/labels", f"{self._base}/repos/{owner}/{repo}/labels",
headers=self._headers, headers=self._headers,
@@ -252,7 +251,7 @@ class GiteaClient:
if cached: if cached:
return cached return cached
async with httpx.AsyncClient(timeout=10) as client: async with shared_client(timeout=10) as client:
resp = await client.get( resp = await client.get(
f"{self._base}/repos/{owner}/{repo}/milestones", f"{self._base}/repos/{owner}/{repo}/milestones",
headers=self._headers, headers=self._headers,
@@ -267,7 +266,7 @@ class GiteaClient:
async def list_webhooks(self, owner: str, repo: str) -> list[dict]: async def list_webhooks(self, owner: str, repo: str) -> list[dict]:
"""List webhooks for a repo.""" """List webhooks for a repo."""
async with httpx.AsyncClient(timeout=10) as client: async with shared_client(timeout=10) as client:
resp = await client.get( resp = await client.get(
f"{self._base}/repos/{owner}/{repo}/hooks", f"{self._base}/repos/{owner}/{repo}/hooks",
headers=self._headers, headers=self._headers,
@@ -292,7 +291,7 @@ class GiteaClient:
"events": events, "events": events,
"active": True, "active": True,
} }
async with httpx.AsyncClient(timeout=10) as client: async with shared_client(timeout=10) as client:
resp = await client.post( resp = await client.post(
f"{self._base}/repos/{owner}/{repo}/hooks", f"{self._base}/repos/{owner}/{repo}/hooks",
headers=self._headers, headers=self._headers,
@@ -303,7 +302,7 @@ class GiteaClient:
async def delete_webhook(self, owner: str, repo: str, hook_id: int) -> bool: async def delete_webhook(self, owner: str, repo: str, hook_id: int) -> bool:
"""Delete a webhook.""" """Delete a webhook."""
async with httpx.AsyncClient(timeout=10) as client: async with shared_client(timeout=10) as client:
resp = await client.delete( resp = await client.delete(
f"{self._base}/repos/{owner}/{repo}/hooks/{hook_id}", f"{self._base}/repos/{owner}/{repo}/hooks/{hook_id}",
headers=self._headers, headers=self._headers,
@@ -319,7 +318,7 @@ class GiteaClient:
if cached: if cached:
return cached return cached
async with httpx.AsyncClient(timeout=10) as client: async with shared_client(timeout=10) as client:
resp = await client.get( resp = await client.get(
f"{self._base}/repos/{owner}/{repo}/collaborators", f"{self._base}/repos/{owner}/{repo}/collaborators",
headers=self._headers, headers=self._headers,
@@ -341,7 +340,7 @@ class GiteaClient:
cached = self._cached(cache_key) cached = self._cached(cache_key)
if cached: if cached:
return cached return cached
async with httpx.AsyncClient(timeout=15) as client: async with shared_client(timeout=15) as client:
resp = await client.get(url, headers=self._headers) resp = await client.get(url, headers=self._headers)
resp.raise_for_status() resp.raise_for_status()
data = resp.json() data = resp.json()
@@ -357,7 +356,7 @@ class GiteaClient:
cached = self._cached(cache_key) cached = self._cached(cache_key)
if cached: if cached:
return cached return cached
async with httpx.AsyncClient(timeout=15) as client: async with shared_client(timeout=15) as client:
resp = await client.get( resp = await client.get(
f"{self._base}/repos/{owner}/{repo}/contents/{path}", f"{self._base}/repos/{owner}/{repo}/contents/{path}",
headers=self._headers, headers=self._headers,
@@ -382,7 +381,7 @@ class GiteaClient:
} }
if sha: if sha:
body["sha"] = sha body["sha"] = sha
async with httpx.AsyncClient(timeout=15) as client: async with shared_client(timeout=15) as client:
resp = await client.put( resp = await client.put(
f"{self._base}/repos/{owner}/{repo}/contents/{path}", f"{self._base}/repos/{owner}/{repo}/contents/{path}",
headers=self._headers, headers=self._headers,
@@ -398,7 +397,7 @@ class GiteaClient:
async def delete_file(self, owner, repo, path, sha, message): async def delete_file(self, owner, repo, path, sha, message):
"""Delete a file from the repo.""" """Delete a file from the repo."""
async with httpx.AsyncClient(timeout=15) as client: async with shared_client(timeout=15) as client:
resp = await client.delete( resp = await client.delete(
f"{self._base}/repos/{owner}/{repo}/contents/{path}", f"{self._base}/repos/{owner}/{repo}/contents/{path}",
headers=self._headers, headers=self._headers,
@@ -422,7 +421,7 @@ class GiteaClient:
cached = self._cached(key) cached = self._cached(key)
if cached: if cached:
return cached return cached
async with httpx.AsyncClient(timeout=15) as client: async with shared_client(timeout=15) as client:
resp = await client.get( resp = await client.get(
f"{self._base}/repos/{owner}/{repo}/commits", f"{self._base}/repos/{owner}/{repo}/commits",
headers=self._headers, headers=self._headers,
+68
View File
@@ -0,0 +1,68 @@
"""Client HTTP partagé (A42 — reliquat du audit).
51 créations `httpx.AsyncClient(...)` éparpillées dans 15 fichiers = un
nouveau pool de connexions par appel (pas de keep-alive). Ici le pool est
réutilisé, **closé par boucle d'event** : un `AsyncClient` ne traverse pas
de boucle à boucle (les tests en créent une par test → client propre par
boucle, collecté avec elle).
`async with shared_client(timeout=15) as client:` — même syntaxe que
before, l'`__aexit__` est un no-op (on ne ferme pas le client partagé).
ponytail: pas d'`aclose` explicite — le client meurt avec sa boucle
(WeakKeyDictionary, les sockets sont fermés par le GC) ; plafond : le pool
du loop produit n'est pas fermé à la main. Upgrade si besoin : lifespan
qui ferme le client du loop principal.
"""
from __future__ import annotations
import asyncio
import weakref
import httpx
_clients: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, dict] = (
weakref.WeakKeyDictionary()
)
def _hashable(v) -> bool:
try:
hash(v)
return True
except TypeError:
return False
def _key(kwargs: dict) -> tuple:
"""Clé de cache = kwargs (timeout/auth/transport/headers…). Valeurs non
hashables (dict `headers=`…) → repr, même contrat que la clé."""
return tuple(
(k, v if _hashable(v) else repr(v))
for k, v in sorted(kwargs.items())
)
def get_shared_client(**kwargs) -> httpx.AsyncClient:
loop = asyncio.get_running_loop()
per_loop = _clients.setdefault(loop, {})
k = _key(kwargs)
client = per_loop.get(k)
if client is None:
client = httpx.AsyncClient(**kwargs)
per_loop[k] = client
return client
class shared_client:
"""Context manager async : yield du client partagé, no-op à la sortie."""
def __init__(self, **kwargs):
self._kwargs = kwargs
async def __aenter__(self) -> httpx.AsyncClient:
return get_shared_client(**self._kwargs)
async def __aexit__(self, *exc) -> bool:
return False
+2 -1
View File
@@ -12,6 +12,7 @@ from urllib.parse import urlparse
import httpx import httpx
from app.services.export import markdown_to_blocks from app.services.export import markdown_to_blocks
from app.services.http_client import shared_client
from app.services.importers.base import ImportPage, ImportResult from app.services.importers.base import ImportPage, ImportResult
from app.services.importers.html_notes import _html_to_markdown from app.services.importers.html_notes import _html_to_markdown
@@ -61,7 +62,7 @@ async def fetch_url_result(url: str, *, transport: httpx.BaseTransport | None =
safe_url = _validate_url(url) safe_url = _validate_url(url)
result = ImportResult(source="url") result = ImportResult(source="url")
try: try:
async with httpx.AsyncClient( async with shared_client(
timeout=15, follow_redirects=True, transport=transport, timeout=15, follow_redirects=True, transport=transport,
headers={"User-Agent": "FlowDeck-Importer/1.0"}, headers={"User-Agent": "FlowDeck-Importer/1.0"},
) as client: ) as client:
+2 -1
View File
@@ -27,6 +27,7 @@ from dataclasses import dataclass, field
import httpx import httpx
from app.config import settings from app.config import settings
from app.services.http_client import shared_client
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -270,7 +271,7 @@ class LLMClient:
if self.api_key: if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}" headers["Authorization"] = f"Bearer {self.api_key}"
try: try:
async with httpx.AsyncClient(timeout=settings.agent_run_timeout_seconds) as client: async with shared_client(timeout=settings.agent_run_timeout_seconds) as client:
resp = await client.post(self._endpoint(), json=payload, headers=headers) resp = await client.post(self._endpoint(), json=payload, headers=headers)
resp.raise_for_status() resp.raise_for_status()
data = resp.json() data = resp.json()
+3 -4
View File
@@ -14,6 +14,7 @@ import json
import time import time
from app.config import settings from app.config import settings
from app.services.http_client import shared_client
from app.services.llm_client import PROVIDER_LABELS, PROVIDER_MODELS, PROVIDERS from app.services.llm_client import PROVIDER_LABELS, PROVIDER_MODELS, PROVIDERS
# Providers whose /v1/models lists far more entries than /v1/chat/completions # Providers whose /v1/models lists far more entries than /v1/chat/completions
@@ -331,7 +332,6 @@ async def fetch_provider_models(provider: str, *, api_key: str = "",
Anthropic's native model listing uses `x-api-key` + `anthropic-version`. Anthropic's native model listing uses `x-api-key` + `anthropic-version`.
Returns a de-duplicated list capped at 300 models. Returns a de-duplicated list capped at 300 models.
""" """
import httpx
provider = provider.lower() provider = provider.lower()
base = (api_base or "").strip() or (PROVIDERS.get(provider) or (None, None))[0] base = (api_base or "").strip() or (PROVIDERS.get(provider) or (None, None))[0]
@@ -345,7 +345,7 @@ async def fetch_provider_models(provider: str, *, api_key: str = "",
elif api_key: elif api_key:
headers = {"Authorization": f"Bearer {api_key}"} headers = {"Authorization": f"Bearer {api_key}"}
async with httpx.AsyncClient(timeout=timeout) as client: async with shared_client(timeout=timeout) as client:
resp = await client.get(f"{base_url}/models", headers=headers) resp = await client.get(f"{base_url}/models", headers=headers)
if resp.status_code >= 400: if resp.status_code >= 400:
body = (resp.text or "").strip() body = (resp.text or "").strip()
@@ -405,7 +405,6 @@ async def _validate_chat_models(base_url: str, api_key: str, candidates: list[st
""" """
import asyncio import asyncio
import httpx
sem = asyncio.Semaphore(concurrency) sem = asyncio.Semaphore(concurrency)
start = time.monotonic() start = time.monotonic()
@@ -427,7 +426,7 @@ async def _validate_chat_models(base_url: str, api_key: str, candidates: list[st
return None return None
try: try:
async with sem: async with sem:
async with httpx.AsyncClient(timeout=timeout) as client: async with shared_client(timeout=timeout) as client:
resp = await client.post(url, headers=headers, json=payload) resp = await client.post(url, headers=headers, json=payload)
code = resp.status_code code = resp.status_code
if code < 300 or code == 429: if code < 300 or code == 429:
+3 -2
View File
@@ -12,6 +12,8 @@ import logging
import re import re
from urllib.parse import urljoin, urlparse from urllib.parse import urljoin, urlparse
from app.services.http_client import shared_client
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_META_TAG_RE = re.compile(r"<meta\b[^>]*?>", re.I) _META_TAG_RE = re.compile(r"<meta\b[^>]*?>", re.I)
@@ -143,7 +145,6 @@ async def fetch_og_metadata(url: str, timeout: float = 6.0, transport=None) -> d
src = "https://" + src src = "https://" + src
base = {"url": src, "title": "", "description": "", "image": "", "site_name": "", "favicon": ""} base = {"url": src, "title": "", "description": "", "image": "", "site_name": "", "favicon": ""}
try: try:
import httpx
headers = { headers = {
"User-Agent": "FlowDeck/5.5 bookmark-fetcher (+https://flowdeck.dracodev.net)", "User-Agent": "FlowDeck/5.5 bookmark-fetcher (+https://flowdeck.dracodev.net)",
@@ -152,7 +153,7 @@ async def fetch_og_metadata(url: str, timeout: float = 6.0, transport=None) -> d
kwargs = {"timeout": timeout} kwargs = {"timeout": timeout}
if transport is not None: if transport is not None:
kwargs["transport"] = transport kwargs["transport"] = transport
async with httpx.AsyncClient(**kwargs) as client: async with shared_client(**kwargs) as client:
resp = await _get_checked(client, src, headers) resp = await _get_checked(client, src, headers)
except ValueError: except ValueError:
# A12 : hôte privé/loopback ou trop de redirections → refus explicite. # A12 : hôte privé/loopback ou trop de redirections → refus explicite.
+3 -2
View File
@@ -15,6 +15,7 @@ import time
import httpx import httpx
from app.db import get_conn from app.db import get_conn
from app.services.http_client import shared_client
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -189,7 +190,7 @@ async def fire_event(event: str, payload: dict):
if not subs: if not subs:
logger.debug("No subscribers for event: %s", event) logger.debug("No subscribers for event: %s", event)
return return
async with httpx.AsyncClient(timeout=30.0) as client: async with shared_client(timeout=30.0) as client:
for sub in subs: for sub in subs:
try: try:
await deliver_to_sub(sub["id"], sub["url"], event, await deliver_to_sub(sub["id"], sub["url"], event,
@@ -219,7 +220,7 @@ async def retry_due_deliveries(now: float | None = None) -> int:
).fetchall() ).fetchall()
if not rows: if not rows:
return 0 return 0
async with httpx.AsyncClient(timeout=10) as client: async with shared_client(timeout=10) as client:
for r in rows: for r in rows:
try: try:
payload = json.loads(r["payload"] or "{}") payload = json.loads(r["payload"] or "{}")
+1 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0", "openapi": "3.1.0",
"info": { "info": {
"title": "FlowDeck", "title": "FlowDeck",
"version": "7.27.0" "version": "7.29.0"
}, },
"paths": { "paths": {
"/auth/register": { "/auth/register": {
+27
View File
@@ -309,6 +309,33 @@ def test_csp_no_cdn_and_vendor(client):
assert len(r.content) > 500, (path, len(r.content)) assert len(r.content) > 500, (path, len(r.content))
def test_http_client_shared_and_loop_scoped():
"""A42 : le client HTTP partagé est réutilisé dans la même boucle,
cloisonné par kwargs, et JAMAIS partagé entre deux boucles (un
AsyncClient lié à une boucle morte lèverait « Event loop is closed »)."""
import asyncio
from app.services.http_client import shared_client
async def same_loop():
async with shared_client(timeout=15) as a:
async with shared_client(timeout=15) as b:
assert a is b, "même boucle + mêmes kwargs = même client"
async with shared_client(timeout=30) as c:
assert c is not a, "kwargs différents = client différent"
return a
first = asyncio.run(same_loop())
# nouvelle boucle (façon tests : une boucle par test) → nouveau client
async def other_loop():
async with shared_client(timeout=15) as d:
assert d is not first, "client jamais réutilisé sur une boucle morte"
return d
second = asyncio.run(other_loop())
assert second is not first
def test_no_duplicate_routes(): def test_no_duplicate_routes():
"""A24 : deux routes même méthode+chemin → l'une écrase silencieusement l'autre.""" """A24 : deux routes même méthode+chemin → l'une écrase silencieusement l'autre."""
from app.main import app from app.main import app
+6
View File
@@ -357,6 +357,12 @@ def _patch_async_client(monkeypatch, handler) -> None:
RequestError=httpx.RequestError, RequestError=httpx.RequestError,
) )
monkeypatch.setattr(webhook_outbound, "httpx", stub) monkeypatch.setattr(webhook_outbound, "httpx", stub)
# A42 : la fabrique partagée crée les clients — elle doit voir le stub
# (boucle neuve par test → aucun cache à purger, on purge par sécurité).
from app.services import http_client as _hc
monkeypatch.setattr(_hc, "httpx", stub)
_hc._clients.clear()
@pytest.mark.asyncio @pytest.mark.asyncio