fix: A21 phase 2c — 190 routes hors loop, 86 % total (v7.26.0)
FlowDeck CI / lint (push) Successful in 1m55s
FlowDeck CI / test (push) Successful in 15m23s
FlowDeck CI / docker (push) Canceled after 0s

4 passes (283 → 93 routes async sur 667 = 86 % hors loop, avant 61 %) :

A. RACINE AUTH — `get_current_user` (auth/session.py) était `async def`
   SANS aucun await (cookie decode = synchrone) ; idem ses clones :
   `agent._current_user_id/_workspace_id/_current_admin` (34 sites) et
   `sso._require_admin` (corps 0 await, 6 sites) → `def` +
   47 `await` supprimés. Piège : 3 call sites passaient par l'alias `gcu`
   (grep littéral aveugle) — 8 tests en échec → corrigés.

B. Re-scan : 19 routes devenues SANS await → `def` (agent 8, sso 5,
   web_clipper 3, projects 2, auth 1…).

C/D. 155 routes dont les seuls awaits = `request.json()` / événements :
   - try/except `body = {}` → `Body(default={})` (même tolérance)
   - try/except `raise HTTPException(400)` → `Body(...)` REQUIS
     (422 FastAPI — aucun test ne couvrait le 400)
   - forme conditionnelle `request.json() if content-type else {}`
     (54 sites) → défaut `{}` (sans corps = `{}` dans les 2 cas)
   - `await fire_*` → `run_event_sync(...)` ; imports `Body` /
     `run_event_sync` ajoutés aux routers convertis

Reste async (93, justifié) : form/upload/file (22), réseau gitea/llm/oidc,
`_json_body` (9), 2 JSON inline en argument, 1 fallback logique
(capture_frontend_error), 1 lecture conditionnelle (web_clipper), mixtes.

suite **1089/1089** · ruff OK · docs à jour
This commit is contained in:
2026-10-01 22:17:48 -04:00
parent 8d0d69e7b8
commit 45e59009c3
34 changed files with 2479 additions and 680 deletions
+32
View File
@@ -1,5 +1,37 @@
# Changelog - FlowDeck
## v7.26.0 (2026-10-01) — Audit : A21 phase 2c (190 routes hors loop)
### Changed
- **A21 phase 2c** — **283 → 93 routes async** (**86 % des 667 routes** en
threadpool, avant : 61 %) en 4 passes :
· **Racine auth** : `get_current_user` (session.py) était `async def`
**sans aucun await** (cookie decode = synchrone) ; idem ses clones
(`agent._current_user_id/_workspace_id/_current_admin`,
`sso._require_admin` au corps 0 await) → `def` + **47 `await`
supprimés** (dont 3 via l'alias `gcu` — le piège : le grep littéral ne
les voyait pas, la suite les a attrapés)
· Re-scan : 19 routes devenues sans await → `def`
· **155 routes** dont les seuls awaits étaient `request.json()` /
événements → `Body(default={})` + `run_event_sync(...)` puis `def` :
- try/except `body = {}` → défaut `{}` (même tolérance, laissée intacte)
- try/except `raise HTTPException(400)` → `Body(...)` **requis**
(422 FastAPI — **aucun test ne couvrait le 400**, aucun call front
n'envoie de JSON invalide)
- forme conditionnelle `request.json() if content-type else {}`
(54 sites) → défaut `{}` : sans corps = `{}` dans les 2 cas
- Import `Body`/`run_event_sync` ajoutés aux routers convertis
### Notes
- **Reste async (93, justifié)** : `request.form`/`upload.read`/`file.read`
(corps réellement asynchrone), gitea/llm/oidc (réseau), `_json_body`
(9 — wrapper de validation), 2 JSON inline en argument d'appel,
1 fallback à logique (`capture_frontend_error`), 1 lecture conditionnelle
de taille (web_clipper)
- Suite complète : **1089/1089** · ruff OK
## v7.25.0 (2026-10-01) — Audit : A27 CLOSED (eslint 0/0)
### Fixed
+2 -2
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
7.25.0
7.26.0
+1 -1
View File
@@ -1,6 +1,6 @@
# WORKLOAD — FlowDeck Notion Clone
> **Début**: 2026-07-08 | **Version**: v7.25.0 (audit — A27 CLOSED : eslint 0/0, 285 warnings nettoyés) | **Statut**: EN COURS 🔄
> **Début**: 2026-07-08 | **Version**: v7.26.0 (audit — A21 phase 2c : 190 routes hors loop, 86 % total) | **Statut**: EN COURS 🔄
> **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0`
## Avancement Global
+1 -1
View File
@@ -175,7 +175,7 @@ def _touch_session(sid: str) -> None:
# FastAPI dependency
async def get_current_user(request) -> dict | None:
def get_current_user(request) -> dict | None:
"""FastAPI dependency: extract current user from session cookie."""
session = request.cookies.get("flowdeck_session")
if session:
+1 -1
View File
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="7.25.0",
version="7.26.0",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
+4 -12
View File
@@ -1,5 +1,5 @@
"""FlowDeck — Admin API: users, roles, stats, audit."""
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Body, Depends, HTTPException, Request
from fastapi.responses import JSONResponse
router = APIRouter(tags=["admin"], prefix="/api/admin")
@@ -8,7 +8,7 @@ router = APIRouter(tags=["admin"], prefix="/api/admin")
# ── Dependency ──
async def admin_required(request: Request):
from app.auth.session import get_current_user
user = await get_current_user(request)
user = get_current_user(request)
if not user:
raise HTTPException(status_code=403, detail="Admin access required")
# Also check DB directly (session cookie may be stale)
@@ -46,15 +46,11 @@ def list_users(_admin=Depends(admin_required)):
@router.post("/users")
async def create_user(request: Request, _admin=Depends(admin_required)):
def create_user(request: Request, _admin=Depends(admin_required), body: dict = Body(default={})):
"""Create a new user (admin only)."""
from app.db import get_conn
from app.password_utils import hash_password
try:
body = await request.json()
except Exception:
body = {}
login = body.get("login", "").strip()
name = body.get("name", login)
email = body.get("email", login)
@@ -78,15 +74,11 @@ async def create_user(request: Request, _admin=Depends(admin_required)):
@router.put("/users/{user_id:int}")
async def update_user(user_id: int, request: Request, _admin=Depends(admin_required)):
def update_user(user_id: int, request: Request, _admin=Depends(admin_required), body: dict = Body(default={})):
"""Update a user: name, email, password, admin status, active status."""
from app.db import get_conn
from app.password_utils import hash_password
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
user = conn.execute("SELECT id FROM users WHERE id=?", (user_id,)).fetchone()
if not user:
+58 -68
View File
@@ -9,7 +9,7 @@ import json
import logging
from datetime import UTC
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import StreamingResponse
from app.auth.session import get_current_user
@@ -93,16 +93,16 @@ async def agent_scheduler(interval_seconds: int = 60):
logger.exception("Agent scheduler tick failed")
async def _current_user_id(request: Request) -> int:
def _current_user_id(request: Request) -> int:
"""A14 : plus de fallback sur la row `admin` — 401 sans session."""
user = await get_current_user(request)
user = get_current_user(request)
if not user or not user.get("id"):
raise HTTPException(status_code=401, detail="Authentication required")
return user["id"]
async def _workspace_id(request: Request) -> int | None:
user = await get_current_user(request)
def _workspace_id(request: Request) -> int | None:
user = get_current_user(request)
if user and user.get("workspace_id"):
return user["workspace_id"]
try:
@@ -112,11 +112,11 @@ async def _workspace_id(request: Request) -> int | None:
return None
async def _current_admin(request: Request) -> dict:
def _current_admin(request: Request) -> dict:
"""A14 : session obligatoire, puis admin. L'ancien fallback « row admin »
laissait un anonymous diriger `PATCH /api/agent/providers` (et donc le
`ping()` vers un `api_base` de son choix = SSRF)."""
user = await get_current_user(request)
user = get_current_user(request)
if not user:
raise HTTPException(status_code=401, detail="Authentication required")
if not user.get("is_admin"):
@@ -146,9 +146,9 @@ def _default_agent(conn, user_id: int) -> dict:
@router.get("")
async def list_agents(request: Request):
user_id = await _current_user_id(request)
ws = await _workspace_id(request)
def list_agents(request: Request):
user_id = _current_user_id(request)
ws = _workspace_id(request)
with get_conn() as conn:
_default_agent(conn, user_id)
rows = conn.execute("SELECT * FROM agents WHERE workspace_id IS ? OR workspace_id=? ORDER BY agent_type, name", (ws, ws)).fetchall()
@@ -156,10 +156,9 @@ async def list_agents(request: Request):
@router.post("")
async def create_agent(request: Request):
user_id = await _current_user_id(request)
ws = await _workspace_id(request)
body = await request.json() if request.headers.get("content-type") else {}
def create_agent(request: Request, body: dict = Body(default={})):
user_id = _current_user_id(request)
ws = _workspace_id(request)
name = (body.get("name") or "").strip() or "Custom Agent"
with get_conn() as conn:
try:
@@ -184,8 +183,8 @@ async def create_agent(request: Request):
@router.get("/conversations")
async def list_conversations(request: Request):
user_id = await _current_user_id(request)
def list_conversations(request: Request):
user_id = _current_user_id(request)
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM agent_conversations WHERE user_id=? ORDER BY updated_at DESC",
@@ -195,10 +194,9 @@ async def list_conversations(request: Request):
@router.post("/conversations")
async def create_conversation(request: Request):
user_id = await _current_user_id(request)
body = await request.json() if request.headers.get("content-type") else {}
ws = await _workspace_id(request)
def create_conversation(request: Request, body: dict = Body(default={})):
user_id = _current_user_id(request)
ws = _workspace_id(request)
with get_conn() as conn:
agent = _default_agent(conn, user_id)
cur = conn.execute(
@@ -236,10 +234,9 @@ def delete_conversation(request: Request, conversation_id: int):
@router.patch("/conversations/{conversation_id}")
async def patch_conversation(request: Request, conversation_id: int):
def patch_conversation(request: Request, conversation_id: int, body: dict = Body(default={})):
"""Update a conversation's title / provider / model (slash-command support)."""
user_id = await _current_user_id(request)
body = await request.json() if request.headers.get("content-type") else {}
user_id = _current_user_id(request)
with get_conn() as conn:
conv = conn.execute(
"SELECT id FROM agent_conversations WHERE id=? AND user_id=?",
@@ -262,10 +259,9 @@ async def patch_conversation(request: Request, conversation_id: int):
@router.post("/conversations/{conversation_id}/run")
async def run_conversation(request: Request, conversation_id: int):
user_id = await _current_user_id(request)
ws = await _workspace_id(request)
body = await request.json() if request.headers.get("content-type") else {}
async def run_conversation(request: Request, conversation_id: int, body: dict = Body(default={})):
user_id = _current_user_id(request)
ws = _workspace_id(request)
objective = (body.get("message") or "").strip()
if not objective:
raise HTTPException(status_code=400, detail="message est requis")
@@ -323,7 +319,7 @@ async def agent_generate(request: Request):
Because no tool schema is offered, the model answers with plain text based on
the provided document context instead of issuing search_workspace / tools.
"""
user_id = await _current_user_id(request)
user_id = _current_user_id(request)
body = await request.json() if request.headers.get("content-type") else {}
prompt = (body.get("prompt") or "").strip()
if not prompt:
@@ -383,7 +379,7 @@ async def agent_writing(request: Request):
"""
from app.services.ai_writing import WRITING_ACTIONS, AIWritingService
user_id = await _current_user_id(request)
user_id = _current_user_id(request)
body = await request.json() if request.headers.get("content-type") else {}
action = (body.get("action") or "").strip().lower()
if not action:
@@ -422,7 +418,7 @@ async def agent_writing_properties(request: Request):
"""
from app.services.ai_writing import AIWritingService
user_id = await _current_user_id(request)
user_id = _current_user_id(request)
body = await request.json() if request.headers.get("content-type") else {}
properties = body.get("properties") or []
if not isinstance(properties, list) or not properties:
@@ -474,18 +470,17 @@ def undo(request: Request, action_id: int):
@router.get("/skills")
async def list_skills(request: Request):
ws = await _workspace_id(request)
def list_skills(request: Request):
ws = _workspace_id(request)
with get_conn() as conn:
rows = conn.execute("SELECT * FROM agent_skills WHERE workspace_id IS ? OR workspace_id=? ORDER BY name", (ws, ws)).fetchall()
return {"skills": [dict(r) for r in rows]}
@router.post("/skills")
async def create_skill(request: Request):
user_id = await _current_user_id(request)
ws = await _workspace_id(request)
body = await request.json() if request.headers.get("content-type") else {}
def create_skill(request: Request, body: dict = Body(default={})):
user_id = _current_user_id(request)
ws = _workspace_id(request)
name = (body.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name est requis")
@@ -504,10 +499,10 @@ async def create_skill(request: Request):
@router.post("/skills/{skill_id}/apply")
async def apply_skill(request: Request, skill_id: int):
def apply_skill(request: Request, skill_id: int):
"""Create a conversation pre-loaded with a skill, ready to run."""
user_id = await _current_user_id(request)
ws = await _workspace_id(request)
user_id = _current_user_id(request)
ws = _workspace_id(request)
with get_conn() as conn:
skill = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
if not skill:
@@ -535,13 +530,12 @@ def skills_gallery(request: Request):
@router.post("/skills/gallery/{slug}/install")
async def install_gallery_skill(request: Request, slug: str):
user_id = await _current_user_id(request)
ws = await _workspace_id(request)
def install_gallery_skill(request: Request, slug: str, body: dict = Body(default={})):
user_id = _current_user_id(request)
ws = _workspace_id(request)
preset = skill_gallery.get_gallery(slug)
if not preset:
raise HTTPException(status_code=404, detail=f"Skill inconnue dans la galerie: {slug}")
body = await request.json() if request.headers.get("content-type") else {}
try:
row, created = skill_gallery.upsert_skill(
skill_gallery.parse_payload(preset),
@@ -555,11 +549,10 @@ async def install_gallery_skill(request: Request, slug: str):
@router.post("/skills/import")
async def import_skill(request: Request):
def import_skill(request: Request, body: dict = Body(default={})):
"""Importe un skill portable (JSON exporté depuis une autre instance)."""
user_id = await _current_user_id(request)
ws = await _workspace_id(request)
body = await request.json() if request.headers.get("content-type") else {}
user_id = _current_user_id(request)
ws = _workspace_id(request)
payload = body.get("payload") if isinstance(body.get("payload"), dict) else body
try:
fields = skill_gallery.parse_payload(payload)
@@ -601,7 +594,7 @@ def delete_skill(request: Request, skill_id: int):
@router.get("/mentions")
async def list_mentions(request: Request, q: str = ""):
def list_mentions(request: Request, q: str = ""):
"""Éléments mentionnables dans le panneau agent (commande « @ » / bouton « + »).
Retourne des sections d'objets FlowDeck que l'utilisateur peut épingler au
@@ -624,7 +617,7 @@ async def list_mentions(request: Request, q: str = ""):
except (TypeError, ValueError):
ws = None
if not ws:
ws = await _workspace_id(request)
ws = _workspace_id(request)
def dedupe(items: list[dict]) -> list[dict]:
seen: set = set()
@@ -729,10 +722,9 @@ async def list_mentions(request: Request, q: str = ""):
@router.post("/feedback")
async def add_feedback(request: Request):
def add_feedback(request: Request, body: dict = Body(default={})):
"""Enregistre le retour (👍 / 👎) porté sur une réponse de l'agent."""
user_id = await _current_user_id(request)
body = await request.json() if request.headers.get("content-type") else {}
user_id = _current_user_id(request)
rating = (body.get("rating") or "").strip().lower()
if rating not in ("up", "down"):
raise HTTPException(status_code=400, detail="rating doit être 'up' ou 'down'")
@@ -766,8 +758,8 @@ async def add_feedback(request: Request):
async def trigger_agent(request: Request, agent_id: int):
"""Manually fire a custom agent: create a conversation and run it with the
agent's instructions as the objective (falls back to a generic prompt)."""
user_id = await _current_user_id(request)
ws = await _workspace_id(request)
user_id = _current_user_id(request)
ws = _workspace_id(request)
with get_conn() as conn:
agent = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
if not agent:
@@ -814,7 +806,7 @@ async def list_providers(request: Request):
- ``verified`` : the last connection test / model fetch succeeded.
- ``functional`` : the provider is ready to chat (verified, or `offline`).
"""
user_id = await _current_user_id(request)
user_id = _current_user_id(request)
llm = LLMClient()
cfg = get_llm_config()
keys = list_user_llm_keys(user_id)
@@ -869,20 +861,19 @@ async def list_providers(request: Request):
@router.get("/keys")
async def list_llm_keys(request: Request):
def list_llm_keys(request: Request):
"""The user's saved provider keys + API keys (masked)."""
user_id = await _current_user_id(request)
user_id = _current_user_id(request)
return {"keys": list_user_llm_keys(user_id)}
@router.put("/keys/{llm_provider}")
async def save_llm_key(request: Request, llm_provider: str):
def save_llm_key(request: Request, llm_provider: str, body: dict = Body(default={})):
"""Upsert a provider key for the current user (masked in responses)."""
user_id = await _current_user_id(request)
user_id = _current_user_id(request)
provider = llm_provider.lower()
if provider not in PROVIDERS:
raise HTTPException(status_code=400, detail=f"Provider inconnu: {provider}")
body = await request.json() if request.headers.get("content-type") else {}
api_base_raw = body.get("api_base")
raw = upsert_user_llm_key(
user_id,
@@ -898,9 +889,9 @@ async def save_llm_key(request: Request, llm_provider: str):
@router.delete("/keys/{llm_provider}")
async def delete_llm_key(request: Request, llm_provider: str):
def delete_llm_key(request: Request, llm_provider: str):
"""Remove a saved provider key for the current user."""
user_id = await _current_user_id(request)
user_id = _current_user_id(request)
provider = llm_provider.lower()
if provider not in PROVIDERS:
raise HTTPException(status_code=400, detail=f"Provider inconnu: {provider}")
@@ -915,7 +906,7 @@ async def test_user_llm_key(request: Request, llm_provider: str):
On success the provider is flagged ``verified`` so it can be offered in the
Agent panel; on failure the stored error is kept for display in Settings.
"""
user_id = await _current_user_id(request)
user_id = _current_user_id(request)
provider = llm_provider.lower()
if provider not in PROVIDERS:
raise HTTPException(status_code=400, detail=f"Provider inconnu: {provider}")
@@ -970,7 +961,7 @@ async def fetch_llm_models(request: Request, llm_provider: str):
A successful fetch proves connectivity, so when it used the *stored* key the
provider is flagged ``verified`` (functional) for the Agent panel.
"""
user_id = await _current_user_id(request)
user_id = _current_user_id(request)
provider = llm_provider.lower()
if provider not in PROVIDERS:
raise HTTPException(status_code=400, detail=f"Provider inconnu: {provider}")
@@ -1019,7 +1010,7 @@ def _check_api_base(value: str) -> str:
@router.patch("/providers")
async def update_provider_config(request: Request):
await _current_admin(request)
_current_admin(request)
body = await request.json() if request.headers.get("content-type") else {}
provider = (body.get("provider") or "").strip().lower()
if provider and provider not in PROVIDERS:
@@ -1049,7 +1040,7 @@ async def test_provider_config(request: Request):
A successful test flags the workspace default provider as ``verified`` so it
becomes available (functional) for every user in the Agent panel.
"""
await _current_admin(request)
_current_admin(request)
body = await request.json() if request.headers.get("content-type") else {}
provider = (body.get("provider") or "").strip().lower() or None
if provider and provider not in PROVIDERS:
@@ -1090,8 +1081,7 @@ def get_agent(request: Request, agent_id: int):
@router.put("/{agent_id}")
async def update_agent(request: Request, agent_id: int):
body = await request.json() if request.headers.get("content-type") else {}
def update_agent(request: Request, agent_id: int, body: dict = Body(default={})):
with get_conn() as conn:
existing = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
if not existing:
+8 -24
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import logging
import secrets
from fastapi import APIRouter, Query, Request
from fastapi import APIRouter, Body, Query, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from app.auth.session import SessionManager
@@ -220,15 +220,11 @@ def login(request: Request, provider: str = Query("gitea")):
@router.post("/register")
async def register(request: Request):
def register(request: Request, body: dict = Body(default={})):
"""Register a new local account."""
from app.db import get_conn
from app.password_utils import hash_password
try:
body = await request.json()
except Exception:
body = {}
email = body.get("email", "").strip()
password = body.get("password", "").strip()
name = body.get("name", email.split("@")[0] if "@" in email else email)
@@ -277,7 +273,7 @@ async def register(request: Request):
@router.post("/local-login")
async def local_login(request: Request):
def local_login(request: Request, body: dict = Body(default={})):
"""Login with email + password."""
import time
@@ -285,10 +281,6 @@ async def local_login(request: Request):
from app.db import get_conn
from app.password_utils import is_locked, verify_password
try:
body = await request.json()
except Exception:
body = {}
email = body.get("email", "").strip()
password = body.get("password", "").strip()
@@ -410,7 +402,7 @@ async def callback(
oauth_mode = request.session.pop("oauth_mode", "")
if oauth_mode == "link":
from app.auth.session import get_current_user as gcu
current = await gcu(request)
current = gcu(request)
if not current:
return HTMLResponse("<h1>Not logged in — please log in first</h1>", status_code=400)
from app.db import get_conn as _gc
@@ -480,10 +472,10 @@ def logout(request: Request):
@router.get("/user")
async def current_user(request: Request):
def current_user(request: Request):
"""Return current user info as JSON."""
from app.auth.session import get_current_user as gcu
user = await gcu(request)
user = gcu(request)
if not user:
return {"authenticated": False}
return {"authenticated": True, "user": user}
@@ -492,16 +484,12 @@ async def current_user(request: Request):
# ── v7.2.0 — TOTP 2FA ─────────────────────────────────────────────────────
@router.post("/local-verify")
async def local_verify(request: Request):
def local_verify(request: Request, body: dict = Body(default={})):
"""Exchange a 2FA ``pending`` token + TOTP/backup code for a session."""
from fastapi.responses import JSONResponse
from app.db import get_conn
from app.services import two_factor as _2fa
try:
body = await request.json()
except Exception:
body = {}
user_id = _2fa.redeem_pending(body.get("pending", ""))
if not user_id:
return JSONResponse({"error": "Challenge expired — log in again"}, status_code=401)
@@ -544,15 +532,11 @@ def twofa_setup(request: Request):
@router.post("/2fa/activate")
async def twofa_activate(request: Request):
def twofa_activate(request: Request, body: dict = Body(default={})):
from fastapi.responses import JSONResponse
from app.services import two_factor as _2fa
user = _session_user_or_401(request)
try:
body = await request.json()
except Exception:
body = {}
try:
codes = _2fa.activate_secret(user["id"], body.get("secret", ""),
body.get("code", ""))
+6 -11
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import json
import logging
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Body, Depends, HTTPException, Request
from app.auth.session import SessionManager
from app.db import get_conn
@@ -79,8 +79,7 @@ def list_automations(request: Request):
@router.post("/workspace/automations")
async def create_automation(request: Request):
body = await request.json() if request.headers.get("content-type") else {}
def create_automation(request: Request, body: dict = Body(default={})):
_validate_payload(body)
user = _current_user(request)
by = user["id"]
@@ -118,8 +117,7 @@ def get_automation(request: Request, auto_id: int):
@router.put("/workspace/automations/{auto_id}")
async def update_automation(request: Request, auto_id: int):
body = await request.json() if request.headers.get("content-type") else {}
def update_automation(request: Request, auto_id: int, body: dict = Body(default={})):
_validate_payload(body)
with get_conn() as conn:
row = conn.execute("SELECT id FROM automations WHERE id=?", (auto_id,)).fetchone()
@@ -233,11 +231,10 @@ def list_steps(request: Request, auto_id: int):
@router.post("/workspace/automations/{auto_id}/steps")
async def create_step(request: Request, auto_id: int):
def create_step(request: Request, auto_id: int, body: dict = Body(default={})):
_require_session(request)
if _get_auto(auto_id) is None:
return _auto_404()
body = await request.json() if request.headers.get("content-type") else {}
kind = body.get("kind", "")
config = body.get("config", {}) or {}
validate_step(kind, config)
@@ -256,9 +253,8 @@ async def create_step(request: Request, auto_id: int):
@router.put("/workspace/automations/steps/{step_id}")
async def update_step(request: Request, step_id: int):
def update_step(request: Request, step_id: int, body: dict = Body(default={})):
_require_session(request)
body = await request.json() if request.headers.get("content-type") else {}
with get_conn() as conn:
row = conn.execute("SELECT * FROM automation_steps WHERE id=?", (step_id,)).fetchone()
if not row:
@@ -288,12 +284,11 @@ def delete_step(request: Request, step_id: int):
@router.put("/workspace/automations/{auto_id}/mode")
async def set_trigger_mode(request: Request, auto_id: int):
def set_trigger_mode(request: Request, auto_id: int, body: dict = Body(default={})):
"""Set multi-trigger mode: any (default) or all (5-minute window)."""
_require_session(request)
if _get_auto(auto_id) is None:
return _auto_404()
body = await request.json() if request.headers.get("content-type") else {}
mode = (body.get("mode") or "any").lower()
if mode not in ("any", "all"):
raise HTTPException(status_code=400, detail="mode must be any or all")
+45 -83
View File
@@ -6,7 +6,7 @@ import logging
import re
from pathlib import Path
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi import APIRouter, Body, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse
from app.auth.session import SessionManager
@@ -14,7 +14,7 @@ from app.config import settings
from app.db import get_conn
from app.routers.dashboard import _get_app_version
from app.routers.sidebar_config import get_sidebar_config_sync
from app.services.automations import fire_event
from app.services.automations import fire_event, run_event_sync
from app.services.gitea_client import gitea
from app.services.permission_manager import PermissionManager
from app.services.publish import fire_published, fire_unpublished, publish, unpublish
@@ -124,16 +124,12 @@ def wiki_titles(request: Request, ids: str = Query(default="")):
@router.post("/api/pages/{page_id}/lock")
async def set_page_lock(request: Request, page_id: int):
def set_page_lock(request: Request, page_id: int, body: dict = Body(default={})):
"""v5.12.0: lock/unlock a page (read-only for everyone except the locker,
admins and the page creator)."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
raise HTTPException(401, "Authentication required")
try:
body = await request.json()
except Exception:
body = {}
locked = bool(body.get("locked"))
with get_conn() as conn:
row = conn.execute("SELECT id, is_locked, locked_by, workspace FROM pages WHERE id=?",
@@ -146,21 +142,17 @@ async def set_page_lock(request: Request, page_id: int):
conn.execute("UPDATE pages SET is_locked=?, locked_by=? WHERE id=?",
(1 if locked else 0, user["id"] if locked else None, page_id))
conn.commit()
await fire_event("page.locked" if locked else "page.unlocked",
{"page_id": page_id, "by": user["id"]})
run_event_sync(fire_event("page.locked" if locked else "page.unlocked",
{"page_id": page_id, "by": user["id"]}))
return {"status": "ok", "is_locked": int(locked)}
@router.post("/api/pages/{page_id}/options")
async def set_page_options(request: Request, page_id: int):
def set_page_options(request: Request, page_id: int, body: dict = Body(default={})):
"""v5.12.0: page layout options — full-width and compact typography."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
raise HTTPException(401, "Authentication required")
try:
body = await request.json()
except Exception:
body = {}
updates = {}
for key in ("full_width", "font_small"):
if key in body:
@@ -199,16 +191,12 @@ def list_page_templates_api(request: Request):
@router.post("/api/page-templates")
async def create_page_template(request: Request):
def create_page_template(request: Request, body: dict = Body(default={})):
"""v5.12.0: save the current page (or a raw block list) as a personal
global template: {name, icon?, description?, page_id? | blocks?}."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
raise HTTPException(401, "Authentication required")
try:
body = await request.json()
except Exception:
body = {}
name = (body.get("name") or "").strip()
if not name:
raise HTTPException(400, "name is required")
@@ -239,17 +227,13 @@ async def create_page_template(request: Request):
@router.post("/api/page-templates/{template_id}/use")
async def use_page_template(request: Request, template_id: int):
def use_page_template(request: Request, template_id: int, body: dict = Body(default={})):
"""v5.12.0: instantiate a page from a template (built-in or user).
Body: {key?} for built-ins OR uses the row id for user templates.
Creates 'blocks'-format page in the caller's workspace and returns its id.
"""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
try:
body = await request.json()
except Exception:
body = {}
title = (body.get("title") or "").strip()
blocks_json = None
if template_id == 0:
@@ -331,8 +315,8 @@ async def use_page_template(request: Request, template_id: int):
)
conn.commit()
page_id = cur.lastrowid
await fire_event("page.created", {"page_id": page_id, "title": title or name,
"workspace": ws_key, "from_template": name})
run_event_sync(fire_event("page.created", {"page_id": page_id, "title": title or name,
"workspace": ws_key, "from_template": name}))
return {"status": "ok", "id": page_id, "title": title or name}
@@ -955,7 +939,7 @@ def list_favorites(request: Request):
@router.post("/api/favorites/{page_id:int}")
async def add_favorite(request: Request, page_id: int):
def add_favorite(request: Request, page_id: int):
"""Add a page to favorites."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
uid = user["id"] if user and user.get("id") else 1
@@ -973,14 +957,14 @@ async def add_favorite(request: Request, page_id: int):
)
conn.commit()
try:
await fire_event("favorite.added", {"page_id": page_id, "user_id": uid})
run_event_sync(fire_event("favorite.added", {"page_id": page_id, "user_id": uid}))
except Exception:
logger.exception("add_favorite")
return {"status": "added", "page_id": page_id}
@router.delete("/api/favorites/{page_id:int}")
async def remove_favorite(request: Request, page_id: int):
def remove_favorite(request: Request, page_id: int):
"""Remove a page from favorites."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
uid = user["id"] if user and user.get("id") else 1
@@ -988,7 +972,7 @@ async def remove_favorite(request: Request, page_id: int):
conn.execute("DELETE FROM favorites WHERE user_id=? AND page_id=?", (uid, page_id))
conn.commit()
try:
await fire_event("favorite.removed", {"page_id": page_id, "user_id": uid})
run_event_sync(fire_event("favorite.removed", {"page_id": page_id, "user_id": uid}))
except Exception:
logger.exception("remove_favorite")
return {"status": "removed", "page_id": page_id}
@@ -996,9 +980,8 @@ async def remove_favorite(request: Request, page_id: int):
# ═══════════ Share API ═══════════
@router.post("/api/share/{page_id:int}")
async def update_share(request: Request, page_id: int):
def update_share(request: Request, page_id: int, body: dict = Body(default={})):
"""Save share settings for a page."""
body = await request.json()
mode = body.get("mode", "private")
published = body.get("published", False)
with get_conn() as conn:
@@ -1011,22 +994,22 @@ async def update_share(request: Request, page_id: int):
@router.post("/api/pages/{page_id:int}/publish")
async def publish_page(request: Request, page_id: int):
def publish_page(request: Request, page_id: int):
"""Publish a page to the web (generates publish_slug)."""
if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
raise HTTPException(401, "Authentication required")
slug, title = publish(page_id)
await fire_published(page_id, slug)
run_event_sync(fire_published(page_id, slug))
return {"is_published": True, "publish_slug": slug, "title": title}
@router.delete("/api/pages/{page_id:int}/publish")
async def unpublish_page(request: Request, page_id: int):
def unpublish_page(request: Request, page_id: int):
"""Unpublish a page from the web."""
if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
raise HTTPException(401, "Authentication required")
unpublish(page_id)
await fire_unpublished(page_id)
run_event_sync(fire_unpublished(page_id))
return {"is_published": False}
@@ -1040,12 +1023,12 @@ def list_trash(request: Request):
@router.post("/api/trash/{page_id}/restore")
async def restore_page(request: Request, page_id: int):
def restore_page(request: Request, page_id: int):
with get_conn() as conn:
conn.execute("UPDATE pages SET deleted_at=NULL WHERE id=?", (page_id,))
conn.commit()
try:
await fire_event("page.restored", {"page_id": page_id})
run_event_sync(fire_event("page.restored", {"page_id": page_id}))
except Exception:
logger.exception("restore_page")
return {"status": "ok", "restored": page_id}
@@ -1080,12 +1063,8 @@ def list_synced_blocks_api(request: Request, workspace: str = Query(default=""))
@router.post("/api/synced-blocks")
async def create_synced_block_api(request: Request):
def create_synced_block_api(request: Request, body: dict = Body(...)):
"""Create a new synced block."""
try:
body = await request.json()
except Exception:
raise HTTPException(400, "Invalid JSON body") from None
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
from app.services.synced_blocks import create_synced_block
sid = create_synced_block(
@@ -1157,12 +1136,8 @@ def get_synced_block_api(sid: int):
@router.post("/api/pages/{page_id}/synced")
async def add_synced_to_page(request: Request, page_id: int):
def add_synced_to_page(request: Request, page_id: int, body: dict = Body(...)):
"""Add a synced block reference to a page."""
try:
body = await request.json()
except Exception:
raise HTTPException(400, "Invalid JSON body") from None
from app.services.synced_blocks import add_page_synced, get_synced_block
sid = body.get("synced_block_id")
sb = get_synced_block(sid)
@@ -1357,7 +1332,7 @@ async def extract_ai_keywords(owner: str, repo: str):
# ═══════════ Pages Markdown ═══════════
@router.post("/api/pages")
async def create_page(request: Request, title: str = Query(default=""),
def create_page(request: Request, title: str = Query(default=""),
section: str = Query(default="Private"),
project: str = Query(default=""),
parent_id: int = Query(default=0)):
@@ -1385,8 +1360,8 @@ async def create_page(request: Request, title: str = Query(default=""),
)
conn.commit()
page_id = cur.lastrowid
await fire_event("page.created", {"page_id": page_id, "title": page_title,
"workspace": ws_key, "parent_id": parent_id})
run_event_sync(fire_event("page.created", {"page_id": page_id, "title": page_title,
"workspace": ws_key, "parent_id": parent_id}))
return {"status": "ok", "id": page_id, "title": page_title, "workspace": ws_key, "parent_id": parent_id}
except Exception as e:
logger.error("create_page failed: %s", e)
@@ -1411,7 +1386,7 @@ def get_page(request: Request, page_id: int):
@router.put("/api/pages/{page_id}")
async def update_page(request: Request, page_id: int, title: str = Query(default=""),
def update_page(request: Request, page_id: int, title: str = Query(default=""),
content: str = Query(default=""),
content_format: str = Query(default="")):
"""Update a page's title and/or content. Accepts JSON body for blocks."""
@@ -1437,24 +1412,20 @@ async def update_page(request: Request, page_id: int, title: str = Query(default
if content_format:
conn.execute("UPDATE pages SET content_format=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content_format, page_id))
conn.commit()
await fire_event("page.updated", {"page_id": page_id, "title": title,
run_event_sync(fire_event("page.updated", {"page_id": page_id, "title": title,
"content_format": content_format or "markdown",
"actor_id": user.get("id")})
"actor_id": user.get("id")}))
return {"status": "ok"}
@router.post("/api/pages/{page_id}/blocks")
async def save_page_blocks(request: Request, page_id: int):
def save_page_blocks(request: Request, page_id: int, body: dict = Body(...)):
"""Save blocks JSON content (Notion-style block editor).
v5.4.0: a version snapshot is recorded (if the block content actually
changed) so the UI can browse the version history and restore any of them.
v5.14.0: synced block references are tracked in page_synced_blocks.
"""
try:
body = await request.json()
except Exception:
raise HTTPException(400, "Invalid JSON body") from None
blocks = body.get("blocks", [])
blocks_json = json.dumps(blocks)
title = body.get("title", "")
@@ -1504,9 +1475,9 @@ async def save_page_blocks(request: Request, page_id: int):
(page_id, sid),
)
conn.commit()
await fire_event("page.updated", {"page_id": page_id, "title": title or "",
run_event_sync(fire_event("page.updated", {"page_id": page_id, "title": title or "",
"content_format": "blocks",
"actor_id": uid})
"actor_id": uid}))
return {"status": "ok", "id": page_id}
@@ -1605,7 +1576,7 @@ def page_versions(request: Request, page_id: int):
@router.post("/api/pages/{page_id}/versions/{version_id}/restore")
async def restore_version(request: Request, page_id: int, version_id: int):
def restore_version(request: Request, page_id: int, version_id: int):
"""v5.4.0: restore a page from a version snapshot."""
with get_conn() as conn:
ver = conn.execute(
@@ -1619,8 +1590,8 @@ async def restore_version(request: Request, page_id: int, version_id: int):
(ver["blocks_json"], ver["title"] or "", page_id),
)
conn.commit()
await fire_event("page.updated", {"page_id": page_id, "title": ver["title"] or "",
"content_format": "blocks"})
run_event_sync(fire_event("page.updated", {"page_id": page_id, "title": ver["title"] or "",
"content_format": "blocks"}))
return {"status": "ok", "restored": version_id}
@@ -1628,7 +1599,7 @@ async def restore_version(request: Request, page_id: int, version_id: int):
@router.post("/api/pages/{page_id}/duplicate")
async def duplicate_page(request: Request, page_id: int):
def duplicate_page(request: Request, page_id: int):
"""Duplicate a page (block/markdown content included) as a sibling."""
SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
with get_conn() as conn:
@@ -1664,8 +1635,8 @@ async def duplicate_page(request: Request, page_id: int):
with get_conn() as conn:
conn.execute("UPDATE pages SET title=? WHERE id=?", (title, new_id))
conn.commit()
await fire_event("page.created", {"page_id": new_id, "title": title,
"workspace": page.get("workspace")})
run_event_sync(fire_event("page.created", {"page_id": new_id, "title": title,
"workspace": page.get("workspace")}))
return {"status": "ok", "id": new_id, "title": title}
@@ -1758,9 +1729,8 @@ def remove_page_cover(request: Request, page_id: int):
@router.post("/api/pages/{page_id}/icon")
async def set_page_icon(request: Request, page_id: int):
def set_page_icon(request: Request, page_id: int, body: dict = Body(default={})):
"""v5.5.0: set a page emoji/icon label (or a custom-emoji image URL)."""
body = await request.json()
icon = (body.get("icon") or "").strip()
if len(icon) > 512:
raise HTTPException(400, "icon too long")
@@ -1935,16 +1905,12 @@ async def _unfurl_repo(forge: str, owner: str, repo: str):
@router.post("/api/embed/resolve")
async def resolve_embed(request: Request):
def resolve_embed(request: Request, body: dict = Body(...)):
"""v5.5.0: rewrite a pasted URL to its provider embed src.
Powers the universal ``/embed`` block (YouTube, Vimeo, Figma, Maps,
Docs, Loom, CodePen, Miro, Spotify, SoundCloud, Twitch, X/Twitter…).
"""
try:
body = await request.json()
except Exception:
raise HTTPException(400, "Invalid JSON body") from None
url = (body.get("url") or "").strip()
if not url:
raise HTTPException(400, "url required")
@@ -1955,7 +1921,7 @@ async def resolve_embed(request: Request):
@router.put("/api/pages/{page_id}/move")
async def move_page(request: Request, page_id: int):
def move_page(request: Request, page_id: int, body: dict = Body(default={})):
"""Move a page to another workspace or reorder within tree.
Body (JSON): { workspace_id?: int, parent_id?: int, new_order?: int }
@@ -1963,10 +1929,6 @@ async def move_page(request: Request, page_id: int):
- parent_id: change parent (0 = root level)
- new_order: position among siblings (0 = append)
"""
try:
body = await request.json()
except Exception:
body = {}
new_ws_id = body.get("workspace_id")
new_parent_id = body.get("parent_id", 0)
new_order = body.get("new_order", 0)
@@ -1997,13 +1959,13 @@ async def move_page(request: Request, page_id: int):
)
conn.commit()
await fire_event("page.moved", {"page_id": page_id, "workspace_id": new_ws_id or 0,
"parent_id": new_parent_id})
run_event_sync(fire_event("page.moved", {"page_id": page_id, "workspace_id": new_ws_id or 0,
"parent_id": new_parent_id}))
return {"status": "ok", "id": page_id}
@router.delete("/api/pages/{page_id}")
async def delete_page(request: Request, page_id: int):
def delete_page(request: Request, page_id: int):
"""Move a page to trash (soft delete)."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
uid = (user or {}).get("id")
@@ -2019,7 +1981,7 @@ async def delete_page(request: Request, page_id: int):
import datetime
conn.execute("UPDATE pages SET deleted_at=? WHERE id=?", (datetime.datetime.now(datetime.UTC).replace(tzinfo=None).isoformat(), page_id,))
conn.commit()
await fire_event("page.deleted", {"page_id": page_id, "title": row["title"] or ""})
run_event_sync(fire_event("page.deleted", {"page_id": page_id, "title": row["title"] or ""}))
return {"status": "ok", "deleted": page_id, "title": row["title"]}
+9 -11
View File
@@ -8,12 +8,13 @@ from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, Body, HTTPException, Request
from app.auth.session import SessionManager
from app.db import get_conn
from app.services import notifications as notif
from app.services.automations import fire_event as _fire_event
from app.services.automations import run_event_sync
logger = logging.getLogger(__name__)
router = APIRouter(tags=["collaboration"], prefix="/api")
@@ -70,10 +71,9 @@ def list_comments(request: Request, page_id: int):
@router.post("/pages/{page_id}/comments")
async def add_comment(request: Request, page_id: int):
def add_comment(request: Request, page_id: int, body: dict = Body(default={})):
"""Create a page or inline comment. Mentions (@login) notify users."""
user = _current_user(request)
body = await request.json() if request.headers.get("content-type") else {}
text = (body.get("body") or "").strip()
if not text:
raise HTTPException(400, "body required")
@@ -124,10 +124,10 @@ async def add_comment(request: Request, page_id: int):
conn.commit()
try:
await _fire_event("comment.added", {"comment_id": comment_id, "page_id": page_id, "user_id": uid})
run_event_sync(_fire_event("comment.added", {"comment_id": comment_id, "page_id": page_id, "user_id": uid}))
mentioned_ids = notif.extract_mentions(text)
if mentioned_ids:
await _fire_event("mention.added", {"page_id": page_id, "comment_id": comment_id, "count": len(mentioned_ids)})
run_event_sync(_fire_event("mention.added", {"page_id": page_id, "comment_id": comment_id, "count": len(mentioned_ids)}))
except Exception:
logger.exception("add_comment")
@@ -135,14 +135,13 @@ async def add_comment(request: Request, page_id: int):
@router.post("/pages/{page_id}/mentions")
async def notify_page_mentions(request: Request, page_id: int):
def notify_page_mentions(request: Request, page_id: int, body: dict = Body(default={})):
"""Notify users @-mentioned in a page's content (called on save).
Accepts {"text": "..."} containing @login handles. Deduplicated server-side
against a per-page cache so repeated auto-saves don't spam notifications.
"""
user = _current_user(request)
body = await request.json() if request.headers.get("content-type") else {}
text = body.get("text") or ""
with get_conn() as conn:
page = conn.execute("SELECT id, title FROM pages WHERE id=?", (page_id,)).fetchone()
@@ -157,17 +156,16 @@ async def notify_page_mentions(request: Request, page_id: int):
conn.commit()
if mentioned:
try:
await _fire_event("mention.added", {"page_id": page_id, "user_ids": mentioned, "count": len(mentioned)})
run_event_sync(_fire_event("mention.added", {"page_id": page_id, "user_ids": mentioned, "count": len(mentioned)}))
except Exception:
logger.exception("notify_page_mentions")
return {"mentioned": mentioned}
@router.put("/comments/{comment_id}")
async def update_comment(request: Request, comment_id: int):
def update_comment(request: Request, comment_id: int, body: dict = Body(default={})):
"""Update a comment body or resolve/unresolve it."""
user = _current_user(request)
body = await request.json() if request.headers.get("content-type") else {}
with get_conn() as conn:
row = conn.execute(
"SELECT * FROM comments WHERE id=?", (comment_id,)
@@ -188,7 +186,7 @@ async def update_comment(request: Request, comment_id: int):
conn.commit()
if body.get("resolved") and not was_resolved:
try:
await _fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]})
run_event_sync(_fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]}))
except Exception:
logger.exception("update_comment")
return {"id": comment_id, "status": "updated"}
+52 -140
View File
@@ -6,12 +6,12 @@ import json
import logging
import sqlite3
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import HTMLResponse
from app.auth.session import SessionManager
from app.db import get_conn
from app.services.automations import fire_event
from app.services.automations import fire_event, run_event_sync
from app.services.db_templates import materialize_properties
from app.services.permission_manager import PermissionManager
from app.services.property_types import (
@@ -222,12 +222,8 @@ def list_collections_api(request: Request):
@router.post("/api")
async def create_collection_api(request: Request):
def create_collection_api(request: Request, body: dict = Body(default={})):
"""API: create a new collection, optionally from a database template."""
try:
body = await request.json()
except Exception:
body = {}
name = body.get("name", "").strip()
if not name:
@@ -277,7 +273,7 @@ async def create_collection_api(request: Request):
)
conn.commit()
await fire_event("collection.created", {"collection_id": collection_id, "name": name, "icon": icon})
run_event_sync(fire_event("collection.created", {"collection_id": collection_id, "name": name, "icon": icon}))
return {"id": collection_id, "name": name, "status": "created"}
@@ -285,12 +281,8 @@ async def create_collection_api(request: Request):
@router.put("/api/{collection_id}")
async def update_collection_api(request: Request, collection_id: int):
def update_collection_api(request: Request, collection_id: int, body: dict = Body(default={})):
"""API: update a collection."""
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
existing = conn.execute(
@@ -316,12 +308,12 @@ async def update_collection_api(request: Request, collection_id: int):
)
conn.commit()
await fire_event("collection.updated", {"collection_id": collection_id, "name": name})
run_event_sync(fire_event("collection.updated", {"collection_id": collection_id, "name": name}))
return {"id": collection_id, "status": "updated"}
@router.delete("/api/{collection_id}")
async def delete_collection_api(request: Request, collection_id: int):
def delete_collection_api(request: Request, collection_id: int):
"""API: delete a collection and its pages (CASCADE)."""
# v6.0.0: granular collection permissions — owner/admin only.
user = _session_user(request)
@@ -340,13 +332,13 @@ async def delete_collection_api(request: Request, collection_id: int):
conn.execute("DELETE FROM collections WHERE id=?", (collection_id,))
conn.commit()
await fire_event("collection.deleted", {"collection_id": collection_id,
"name": existing["name"] if existing else ""})
run_event_sync(fire_event("collection.deleted", {"collection_id": collection_id,
"name": existing["name"] if existing else ""}))
return {"id": collection_id, "status": "deleted"}
@router.post("/{collection_id}/duplicate")
async def duplicate_collection_api(request: Request, collection_id: int):
def duplicate_collection_api(request: Request, collection_id: int):
"""v5.4.0: deep-duplicate a database (views + properties + pages + data
sources) into a new collection named '<original> (copy)'."""
with get_conn() as conn:
@@ -494,7 +486,7 @@ async def duplicate_collection_api(request: Request, collection_id: int):
conn.commit()
await fire_event("collection.created", {"collection_id": new_id, "name": new_name})
run_event_sync(fire_event("collection.created", {"collection_id": new_id, "name": new_name}))
return {"id": new_id, "name": new_name, "status": "duplicated"}
@@ -542,12 +534,8 @@ def open_row_page_api(request: Request, page_id: int):
@router.put("/pages/{page_id}/api")
async def update_page_api(request: Request, page_id: int):
def update_page_api(request: Request, page_id: int, body: dict = Body(default={})):
"""API: update a page's properties."""
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
existing = conn.execute(
@@ -599,18 +587,18 @@ async def update_page_api(request: Request, page_id: int):
sync_row_title_to_page(conn, page_id)
conn.commit()
await fire_event("page.updated", {
run_event_sync(fire_event("page.updated", {
"page_id": page_id,
"collection_id": existing["collection_id"],
"title": title,
"icon": icon,
"properties": props,
})
await fire_event("collection.page.updated", {
}))
run_event_sync(fire_event("collection.page.updated", {
"page_id": page_id,
"collection_id": existing["collection_id"],
"title": title,
})
}))
# Notify newly assigned people (person properties) — v5.8.0.
from app.services.notifications import notify_assignment
user = _current_user(request)
@@ -620,7 +608,7 @@ async def update_page_api(request: Request, page_id: int):
@router.delete("/pages/{page_id}/api")
async def delete_page_api(request: Request, page_id: int):
def delete_page_api(request: Request, page_id: int):
"""API: delete a page from its collection."""
with get_conn() as conn:
existing = conn.execute(
@@ -634,15 +622,15 @@ async def delete_page_api(request: Request, page_id: int):
conn.execute("DELETE FROM collection_pages WHERE id=?", (page_id,))
conn.commit()
await fire_event("page.deleted", {
run_event_sync(fire_event("page.deleted", {
"page_id": page_id,
"collection_id": existing["collection_id"],
"title": existing["title"],
})
await fire_event("collection.page.deleted", {
}))
run_event_sync(fire_event("collection.page.deleted", {
"page_id": page_id,
"collection_id": existing["collection_id"],
})
}))
return {"id": page_id, "status": "deleted"}
@@ -851,16 +839,12 @@ def timezones_api(request: Request):
@router.post("/{collection_id}/property-groups/api")
async def set_property_groups_api(request: Request, collection_id: int):
def set_property_groups_api(request: Request, collection_id: int, body: dict = Body(default={})):
"""API: (re)assign properties to collapsible groups in the table header.
Body: ``{"groups": [{"name": "Basics", "property_ids": [1, 2]}]}``. Properties
omitted from any group have their group cleared. Empty group names clear.
"""
try:
body = await request.json()
except Exception:
body = {}
groups = body.get("groups", [])
with get_conn() as conn:
@@ -887,12 +871,8 @@ async def set_property_groups_api(request: Request, collection_id: int):
@router.post("/{collection_id}/properties/api")
async def create_property_api(request: Request, collection_id: int):
def create_property_api(request: Request, collection_id: int, body: dict = Body(default={})):
"""API: create a new property on a collection."""
try:
body = await request.json()
except Exception:
body = {}
name = body.get("name", "").strip()
if not name:
@@ -934,12 +914,8 @@ async def create_property_api(request: Request, collection_id: int):
@router.put("/properties/{prop_id}/api")
async def update_property_api(request: Request, prop_id: int):
def update_property_api(request: Request, prop_id: int, body: dict = Body(default={})):
"""API: update a property."""
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
existing = conn.execute(
@@ -991,12 +967,8 @@ def delete_property_api(request: Request, prop_id: int):
@router.post("/{collection_id}/properties/relation")
async def create_relation_property(request: Request, collection_id: int):
def create_relation_property(request: Request, collection_id: int, body: dict = Body(default={})):
"""Create a relation property between two collections."""
try:
body = await request.json()
except Exception:
body = {}
name = body.get("name", "").strip()
related_collection_id = body.get("related_collection_id")
@@ -1043,12 +1015,8 @@ async def create_relation_property(request: Request, collection_id: int):
@router.post("/{collection_id}/properties/relation/link")
async def link_pages(request: Request, collection_id: int):
def link_pages(request: Request, collection_id: int, body: dict = Body(default={})):
"""Link two pages via a relation property."""
try:
body = await request.json()
except Exception:
body = {}
property_id = body.get("property_id")
source_page_id = body.get("source_page_id")
@@ -1117,12 +1085,8 @@ async def link_pages(request: Request, collection_id: int):
@router.post("/rollup/compute")
async def compute_rollup(request: Request):
def compute_rollup(request: Request, body: dict = Body(default={})):
"""Compute a rollup aggregation."""
try:
body = await request.json()
except Exception:
body = {}
collection_id = body.get("collection_id")
relation_property_id = body.get("relation_property_id")
@@ -1143,12 +1107,8 @@ async def compute_rollup(request: Request):
@router.post("/formula/evaluate")
async def evaluate_formula(request: Request):
def evaluate_formula(request: Request, body: dict = Body(default={})):
"""Evaluate a formula expression."""
try:
body = await request.json()
except Exception:
body = {}
expression = body.get("expression", "")
context = body.get("context", {})
@@ -1177,12 +1137,8 @@ def get_view_api(request: Request, view_id: int):
@router.put("/views/{view_id}/config")
async def update_view_config(request: Request, view_id: int):
def update_view_config(request: Request, view_id: int, body: dict = Body(default={})):
"""API: update view configuration (group_by, card_size, visible_properties, etc.)."""
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
@@ -1209,12 +1165,8 @@ async def update_view_config(request: Request, view_id: int):
@router.post("/{collection_id}/views/save-as")
async def save_view_as(request: Request, collection_id: int):
def save_view_as(request: Request, collection_id: int, body: dict = Body(default={})):
"""API: save current view state as a new named view."""
try:
body = await request.json()
except Exception:
body = {}
name = body.get("name", "New View")
config = body.get("config", {})
@@ -1241,12 +1193,12 @@ async def save_view_as(request: Request, collection_id: int):
conn.commit()
new_view_id = cur.lastrowid
await fire_event("collection.view.created", {
run_event_sync(fire_event("collection.view.created", {
"view_id": new_view_id,
"collection_id": collection_id,
"name": name,
"view_type": view_type,
})
}))
return {"id": new_view_id, "name": name, "view_type": view_type,
"config_json": json.dumps(config), "created_by": user_id, "status": "saved"}
@@ -1289,12 +1241,8 @@ def delete_view_api(request: Request, view_id: int):
@router.post("/views/{view_id}/duplicate")
async def duplicate_view_api(request: Request, view_id: int):
def duplicate_view_api(request: Request, view_id: int, body: dict = Body(default={})):
"""API: duplicate a view (config + type), owned by the current user."""
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
@@ -1319,12 +1267,12 @@ async def duplicate_view_api(request: Request, view_id: int):
conn.commit()
dup_view_id = cur.lastrowid
await fire_event("collection.view.created", {
run_event_sync(fire_event("collection.view.created", {
"view_id": dup_view_id,
"collection_id": existing["collection_id"],
"name": name,
"view_type": existing["view_type"],
})
}))
return {"id": dup_view_id, "name": name, "view_type": existing["view_type"],
"status": "duplicated"}
@@ -1344,12 +1292,8 @@ def list_sub_items(request: Request, collection_id: int, page_id: int):
@router.post("/{collection_id}/pages/{page_id}/sub-items")
async def create_sub_item(request: Request, collection_id: int, page_id: int):
def create_sub_item(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})):
"""API: create a sub-item under a page."""
try:
body = await request.json()
except Exception:
body = {}
title = body.get("title", "New sub-item").strip()
if not title:
@@ -1380,18 +1324,18 @@ async def create_sub_item(request: Request, collection_id: int, page_id: int):
conn.commit()
new_id = cur.lastrowid
await fire_event("page.created", {
run_event_sync(fire_event("page.created", {
"page_id": new_id,
"collection_id": collection_id,
"parent_id": page_id,
"title": title,
"properties": body.get("properties", {}),
})
await fire_event("collection.page.created", {
}))
run_event_sync(fire_event("collection.page.created", {
"page_id": new_id,
"collection_id": collection_id,
"title": title,
})
}))
return {"id": new_id, "title": title, "parent_id": page_id, "status": "created"}
@@ -1420,12 +1364,8 @@ def aggregate_child_status(request: Request, collection_id: int, page_id: int):
@router.post("/{collection_id}/pages/{page_id}/dependencies")
async def set_dependencies(request: Request, collection_id: int, page_id: int):
def set_dependencies(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})):
"""API: set blocking dependencies for a page (stored as 'blocks' property)."""
try:
body = await request.json()
except Exception:
body = {}
blocks_ids = body.get("blocks", [])
@@ -1447,12 +1387,8 @@ async def set_dependencies(request: Request, collection_id: int, page_id: int):
@router.post("/{collection_id}/pages/{page_id}/check-deps")
async def check_dependencies(request: Request, collection_id: int, page_id: int):
def check_dependencies(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})):
"""API: check if a page can transition to a new status."""
try:
body = await request.json()
except Exception:
body = {}
body.get("new_status", "Done")
@@ -1510,12 +1446,8 @@ def list_data_sources(request: Request, collection_id: int):
@router.post("/{collection_id}/sources/api")
async def add_data_source(request: Request, collection_id: int):
def add_data_source(request: Request, collection_id: int, body: dict = Body(default={})):
"""API: add a data source to a collection."""
try:
body = await request.json()
except Exception:
body = {}
source_collection_id = body.get("source_collection_id")
if not source_collection_id:
@@ -1572,15 +1504,11 @@ def remove_data_source(request: Request, collection_id: int, source_id: int):
@router.post("/{collection_id}/linked/api")
async def create_linked_database(request: Request, collection_id: int):
def create_linked_database(request: Request, collection_id: int, body: dict = Body(default={})):
"""API: create a linked database view from a source collection.
A linked database copies the structure (views, filters, sorts) of a source
but shares the same pages — edits to pages propagate to the source.
"""
try:
body = await request.json()
except Exception:
body = {}
name = body.get("name", "").strip()
body.get("workspace_id")
@@ -1693,12 +1621,8 @@ def toggle_inline(request: Request, collection_id: int):
@router.post("/inline/api")
async def create_inline_database(request: Request):
def create_inline_database(request: Request, body: dict = Body(default={})):
"""API: create an inline database within a parent page (optionally from a template)."""
try:
body = await request.json()
except Exception:
body = {}
name = body.get("name", "").strip()
if not name:
@@ -1792,12 +1716,8 @@ def list_page_dependencies(request: Request, collection_id: int, page_id: int):
@router.post("/{collection_id}/pages/{page_id}/dependencies/api")
async def add_page_dependency(request: Request, collection_id: int, page_id: int):
def add_page_dependency(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})):
"""API: add a dependency (blocks/blocked_by/related) between two pages."""
try:
body = await request.json()
except Exception:
body = {}
dependency_id = body.get("dependency_id")
if not dependency_id:
raise HTTPException(status_code=400, detail="dependency_id is required")
@@ -1834,15 +1754,11 @@ def remove_page_dependency(request: Request, collection_id: int, page_id: int, d
@router.post("/{collection_id}/pages/{page_id}/auto-shift/api")
async def auto_shift_dates(request: Request, collection_id: int, page_id: int):
def auto_shift_dates(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})):
"""API: auto-shift dates based on blocking dependencies."""
from datetime import date as dt_date
from datetime import timedelta
try:
body = await request.json()
except Exception:
body = {}
skip_weekends = body.get("skip_weekends", False)
with get_conn() as conn:
@@ -2646,15 +2562,11 @@ def get_collection_api(request: Request, collection_id: int):
@router.post("/{collection_id}/pages/api")
async def create_page_api(request: Request, collection_id: int):
def create_page_api(request: Request, collection_id: int, body: dict = Body(default={})):
"""API: create a page in a collection."""
# v6.0.0: granular collection permissions — viewer/commenter cannot create.
_require_view(collection_id, _session_user(request))
_require_edit(collection_id, _session_user(request))
try:
body = await request.json()
except Exception:
body = {}
title = body.get("title", "").strip()
if not title:
@@ -2695,16 +2607,16 @@ async def create_page_api(request: Request, collection_id: int):
conn.commit()
page_id = cur.lastrowid
await fire_event("page.created", {
run_event_sync(fire_event("page.created", {
"page_id": page_id,
"collection_id": collection_id,
"title": title,
"icon": icon,
"properties": property_values,
})
await fire_event("collection.page.created", {
}))
run_event_sync(fire_event("collection.page.created", {
"page_id": page_id,
"collection_id": collection_id,
"title": title,
})
}))
return {"id": page_id, "title": title, "status": "created"}
+19 -46
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import logging
from datetime import UTC
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi import APIRouter, Body, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from app.auth.session import SessionManager
@@ -720,8 +720,7 @@ def _require_user_id(request: Request) -> int:
@router.put("/api/user/profile")
async def update_profile(request: Request):
body = await request.json()
def update_profile(request: Request, body: dict = Body(default={})):
full_name = body.get("full_name", "").strip()
uid = _require_user_id(request)
with get_conn() as conn:
@@ -731,9 +730,8 @@ async def update_profile(request: Request):
@router.put("/api/user/password")
async def update_password(request: Request):
def update_password(request: Request, body: dict = Body(default={})):
from app.password_utils import hash_password, verify_password
body = await request.json()
password = body.get("password", "").strip()
if len(password) < 6:
return {"error": "Password must be at least 6 characters"}
@@ -972,8 +970,7 @@ async def list_workspace_projects(request: Request):
@router.post("/api/workspace/projects")
async def create_workspace_project(request: Request):
body = await request.json()
def create_workspace_project(request: Request, body: dict = Body(default={})):
name = body.get("name", "").strip()
if not name:
return {"error": "Name required"}
@@ -1002,9 +999,8 @@ def list_members(request: Request, ws_id: int):
@router.post("/api/workspace/{ws_id:int}/members")
async def invite_member(request: Request, ws_id: int):
def invite_member(request: Request, ws_id: int, body: dict = Body(default={})):
"""Invite a user to a workspace by email."""
body = await request.json()
email = body.get("email", "").strip()
role = body.get("role", "editor")
if role not in ("owner", "admin", "editor", "viewer"):
@@ -1025,9 +1021,8 @@ async def invite_member(request: Request, ws_id: int):
@router.put("/api/workspace/{ws_id:int}/members/{user_id:int}")
async def update_member_role(request: Request, ws_id: int, user_id: int):
def update_member_role(request: Request, ws_id: int, user_id: int, body: dict = Body(default={})):
"""Change a member's role."""
body = await request.json()
role = body.get("role", "editor")
if role not in ("owner", "admin", "editor", "viewer"):
return {"error": "Invalid role"}
@@ -1468,9 +1463,8 @@ def nav_menu(request: Request, workspace_id: int = None, parent_id: int = None):
@router.post("/api/local-workspace/items")
async def create_local_workspace_item(request: Request):
def create_local_workspace_item(request: Request, body: dict = Body(default={})):
"""Create a new file in the active workspace."""
body = await request.json()
name = (body.get("name") or "").strip() or "Untitled"
item_type = body.get("type", "page")
parent_id = body.get("parent_id")
@@ -1491,9 +1485,8 @@ async def create_local_workspace_item(request: Request):
@router.put("/api/local-workspace/items/{item_id:int}")
async def rename_local_workspace_item(request: Request, item_id: int):
def rename_local_workspace_item(request: Request, item_id: int, body: dict = Body(default={})):
"""Rename a file."""
body = await request.json()
name = body.get("name", "Untitled").strip()
with get_conn() as conn:
conn.execute("UPDATE pages SET title=? WHERE id=?", (name, item_id))
@@ -1547,9 +1540,8 @@ def serve_uploaded_file(ws_id: int, filename: str):
@router.put("/api/local-workspace/items/{item_id:int}/move")
async def move_local_workspace_item(request: Request, item_id: int):
def move_local_workspace_item(request: Request, item_id: int, body: dict = Body(default={})):
"""Move an item to a new parent (drag & drop)."""
body = await request.json()
new_parent_id = body.get("parent_id") # None = move to root
with get_conn() as conn:
conn.execute(
@@ -1829,9 +1821,8 @@ def list_workspaces(request: Request):
@router.post("/api/workspaces")
async def create_workspace(request: Request):
def create_workspace(request: Request, body: dict = Body(default={})):
"""Create a new workspace."""
body = await request.json()
name = body.get("name", "New Workspace").strip()
if not name:
return {"error": "Name required"}
@@ -1861,9 +1852,8 @@ async def create_workspace(request: Request):
@router.put("/api/workspaces/{ws_id:int}")
async def rename_workspace(request: Request, ws_id: int):
def rename_workspace(request: Request, ws_id: int, body: dict = Body(default={})):
"""Rename a workspace."""
body = await request.json()
name = body.get("name", "").strip()
if not name:
return {"error": "Name required"}
@@ -1971,9 +1961,8 @@ def get_avatar(user_id: int):
@router.post("/api/settings/avatar-color")
async def set_avatar_color(request: Request):
def set_avatar_color(request: Request, body: dict = Body(default={})):
"""Set the user's avatar background color."""
body = await request.json()
color = body.get("color", "#3A3A3A")
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
@@ -1987,9 +1976,8 @@ async def set_avatar_color(request: Request):
# ═══════════ Tag Management API (per-user) ═══════════
@router.post("/api/settings/tags")
async def create_tag_global(request: Request):
def create_tag_global(request: Request, body: dict = Body(default={})):
"""Create a tag for the current user."""
body = await request.json()
tag_name = body.get("name", "").strip().lower()
color = body.get("color", "#787774")
uid = _get_user_id(request)
@@ -2007,9 +1995,8 @@ async def create_tag_global(request: Request):
@router.put("/api/settings/tags/{tag_id:int}")
async def update_tag_global(tag_id: int, request: Request):
def update_tag_global(tag_id: int, request: Request, body: dict = Body(default={})):
"""Update a tag (name or color) — only if owned by user."""
body = await request.json()
uid = _get_user_id(request)
with get_conn() as conn:
if "name" in body:
@@ -2087,9 +2074,8 @@ def get_item_tags(item_id: int):
@router.post("/api/local-workspace/items/{item_id:int}/tags")
async def add_item_tag(request: Request, item_id: int):
def add_item_tag(request: Request, item_id: int, body: dict = Body(default={})):
"""Add a tag to an item (creates tag if new, scoped to user)."""
body = await request.json()
tag_name = body.get("name", "").strip().lower()
tag_color = body.get("color", "#787774")
uid = _get_user_id(request)
@@ -2175,16 +2161,12 @@ def search_by_tags(request: Request, tags: str = ""):
# ── Account update ──
@router.put("/api/settings/account")
async def update_account(request: Request):
def update_account(request: Request, body: dict = Body(default={})):
"""Update current user's profile: full_name, login, email, password."""
from app.password_utils import hash_password
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
return JSONResponse({"error": "Not authenticated"}, status_code=401)
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
uid = user["id"]
if "full_name" in body:
@@ -2563,9 +2545,8 @@ def api_page_content(page_id: int):
@router.put("/api/pages/{page_id:int}/rename")
async def api_rename_page(page_id: int, request: Request):
def api_rename_page(page_id: int, request: Request, body: dict = Body(default={})):
"""Inline rename a page title."""
body = await request.json()
title = (body.get("title") or "").strip()
if not title:
return JSONResponse({"error": "Title required"}, status_code=400)
@@ -2594,17 +2575,13 @@ def api_trash_page(page_id: int):
@router.post("/api/pages/{page_id:int}/convert-to-database")
async def api_convert_to_database(page_id: int, request: Request):
def api_convert_to_database(page_id: int, request: Request, body: dict = Body(default={})):
"""Convert a page into a full-page database (Notion-style).
Creates a collection linked to this page, adds the default 'Name' property,
and sets the page's content_format to 'collection'.
"""
import json as _json
try:
body = await request.json()
except Exception:
body = {}
db_name = (body.get("name") or "").strip()
with get_conn() as conn:
@@ -2699,13 +2676,9 @@ def api_collection_table_data(collection_id: int):
@router.post("/api/collections/{collection_id:int}/pages")
async def api_create_collection_page(collection_id: int, request: Request):
def api_create_collection_page(collection_id: int, request: Request, body: dict = Body(default={})):
"""Create a new page (row) in a collection."""
import json as _json
try:
body = await request.json()
except Exception:
body = {}
title = body.get("title", "New page").strip() or "New page"
icon = body.get("icon", "file")
+4 -12
View File
@@ -1,5 +1,5 @@
"""FlowDeck — Gitea integration API routes."""
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import JSONResponse
router = APIRouter(tags=["gitea"], prefix="/api/gitea")
@@ -202,7 +202,7 @@ def list_private_pages(owner: str, repo: str, request: Request):
@router.post("/projects/{owner}/{repo}/private-pages")
async def create_private_page(owner: str, repo: str, request: Request):
def create_private_page(owner: str, repo: str, request: Request, body: dict = Body(default={})):
"""Create a new private page for this Gitea project."""
from app.auth.session import SessionManager
@@ -210,10 +210,6 @@ async def create_private_page(owner: str, repo: str, request: Request):
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
return JSONResponse({"error": "Not authenticated"}, status_code=401)
try:
body = await request.json()
except Exception:
body = {}
title = body.get("title", "Untitled").strip() or "Untitled"
with get_conn() as conn:
cursor = conn.execute(
@@ -243,7 +239,7 @@ def get_private_page(owner: str, repo: str, page_id: int, request: Request):
@router.put("/projects/{owner}/{repo}/private-pages/{page_id}")
async def update_private_page(owner: str, repo: str, page_id: int, request: Request):
def update_private_page(owner: str, repo: str, page_id: int, request: Request, body: dict = Body(default={})):
"""Update a private page."""
from app.auth.session import SessionManager
@@ -251,10 +247,6 @@ async def update_private_page(owner: str, repo: str, page_id: int, request: Requ
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
return JSONResponse({"error": "Not authenticated"}, status_code=401)
try:
body = await request.json()
except Exception:
body = {}
title = body.get("title", "").strip()
content = body.get("content", "")
with get_conn() as conn:
@@ -306,7 +298,7 @@ async def sync_labels(request: Request, owner: str, repo: str):
"""Sync Gitea labels to FlowDeck tags for the current user."""
from app.auth.session import get_current_user as gcu
from app.db import get_conn
user = await gcu(request)
user = gcu(request)
if not user:
return JSONResponse({"error": "Not authenticated"}, status_code=401)
gitea = _require_gitea(request)
+3 -11
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import json
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import JSONResponse
from app.auth.session import SessionManager
@@ -45,12 +45,8 @@ def list_policies(request: Request):
@router.post("/api/v2/agent-policies")
async def upsert_policy(request: Request):
def upsert_policy(request: Request, body: dict = Body(default={})):
user = _owner_or_admin(request)
try:
body = await request.json()
except Exception:
body = {}
wid = body.get("workspace_id")
tools = body.get("allowed_tools")
if tools is not None and not isinstance(tools, list):
@@ -84,12 +80,8 @@ def list_approvals(request: Request):
@router.post("/api/v2/agent-approvals/{approval_id}/decide")
async def decide_approval(approval_id: int, request: Request):
def decide_approval(approval_id: int, request: Request, body: dict = Body(default={})):
user = _owner_or_admin(request)
try:
body = await request.json()
except Exception:
body = {}
out = policies.decide_approval(approval_id, user["id"], bool(body.get("approve")))
if out is None:
raise HTTPException(404, "Pending approval not found")
+1 -1
View File
@@ -39,7 +39,7 @@ def _current_user(request: Request) -> dict:
@page_router.get("/import", response_class=HTMLResponse)
async def import_page(request: Request):
def import_page(request: Request):
"""Standalone import wizard (source picker, dry-run, mapping, progress)."""
user = _current_user(request)
if not user:
+3 -11
View File
@@ -11,7 +11,7 @@ from __future__ import annotations
import secrets
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import JSONResponse
from app.auth.session import SessionManager
@@ -46,12 +46,8 @@ def _auth_user(request: Request, *, require_write: bool = False) -> dict:
# ── calendar links ─────────────────────────────────────────────────────────
@router.post("/api/v2/calendar-links")
async def create_link(request: Request):
def create_link(request: Request, body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
try:
body = await request.json()
except Exception:
body = {}
provider = (body.get("provider") or "").lower()
if provider not in cal.PROVIDERS:
raise HTTPException(400, "provider must be google|caldav")
@@ -171,13 +167,9 @@ async def upload_and_transcribe(request: Request):
@router.post("/api/v2/meetings/transcripts/{transcript_id}/text")
async def set_transcript_text(transcript_id: int, request: Request):
def set_transcript_text(transcript_id: int, request: Request, body: dict = Body(default={})):
"""Store a client-side (manual) transcript on an existing row."""
_auth_user(request, require_write=True)
try:
body = await request.json()
except Exception:
body = {}
text = (body.get("transcript") or "").strip()
if not text:
raise HTTPException(400, "transcript required")
+4 -7
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, Body, HTTPException, Request
from app.auth.session import SessionManager
from app.db import get_conn
@@ -56,10 +56,9 @@ def unread_count(request: Request):
@router.post("/read")
async def mark_read(request: Request):
def mark_read(request: Request, body: dict = Body(default={})):
"""Mark one notification as read (id) or all (id omitted)."""
user = _current_user(request)
body = await request.json() if request.headers.get("content-type") else {}
nid = body.get("id")
with get_conn() as conn:
if nid:
@@ -91,11 +90,10 @@ def get_prefs(request: Request):
@router.post("/prefs")
async def set_prefs(request: Request):
def set_prefs(request: Request, body: dict = Body(default={})):
"""Update the current user's notification email preferences."""
user = _current_user(request)
from app.services import notifications as notif
body = await request.json() if request.headers.get("content-type") else {}
prefs = notif.get_user_prefs(user["id"])
for key in ("comments", "mentions", "reminders", "assignments"):
if key in body:
@@ -116,10 +114,9 @@ def get_timezone(request: Request):
@router.post("/timezone")
async def set_timezone(request: Request):
def set_timezone(request: Request, body: dict = Body(default={})):
"""Update the current user's IANA timezone (empty string = UTC)."""
user = _current_user(request)
body = await request.json() if request.headers.get("content-type") else {}
tz = (body.get("timezone") or "").strip()
from app.services.recurrence import is_valid_timezone
if tz and not is_valid_timezone(tz):
+3 -11
View File
@@ -8,7 +8,7 @@ from __future__ import annotations
import json
import logging
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from app.auth.session import SessionManager
@@ -63,13 +63,9 @@ def _forge_configured(provider: str) -> bool:
@router.post("/api/onboarding/workspace")
async def onboarding_create_workspace(request: Request):
def onboarding_create_workspace(request: Request, body: dict = Body(default={})):
"""Step 1 — create the first local workspace."""
user = _require_user(request)
try:
body = await request.json()
except Exception:
body = {}
name = (body.get("name") or "").strip() or "My Workspace"
with get_conn() as conn:
@@ -90,13 +86,9 @@ async def onboarding_create_workspace(request: Request):
@router.post("/api/onboarding/project")
async def onboarding_create_project(request: Request):
def onboarding_create_project(request: Request, body: dict = Body(default={})):
"""Step 3 — create the first project: a welcome page in the workspace."""
user = _require_user(request)
try:
body = await request.json()
except Exception:
body = {}
title = (body.get("title") or "").strip() or "Welcome to FlowDeck"
workspace_id = body.get("workspace_id")
+8 -15
View File
@@ -9,7 +9,7 @@ from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, Body, HTTPException, Request
from app.auth.session import SessionManager
from app.db import get_conn
@@ -197,22 +197,20 @@ def _page_type(page_id: int) -> str:
@router.post("/pages/{page_id}/permissions")
async def grant_page_permission(page_id: int, request: Request):
def grant_page_permission(page_id: int, request: Request, body: dict = Body(default={})):
pm = _pm(request)
if not pm.can_manage_page_permissions(page_id):
raise HTTPException(403, "Only a page owner can manage its permissions")
body = await request.json()
return _grant_common(request, pm, "page", page_id, body,
"page_permissions", "page_id", PAGE_ROLES)
@router.post("/pages/{page_id}/permissions/batch")
async def batch_page_permissions(page_id: int, request: Request):
def batch_page_permissions(page_id: int, request: Request, body: dict = Body(default={})):
"""Grant several permissions in one call: {grants: [{user_id|group_id, role}, ...]}."""
pm = _pm(request)
if not pm.can_manage_page_permissions(page_id):
raise HTTPException(403, "Only a page owner can manage its permissions")
body = await request.json()
grants = body.get("grants") or []
if not isinstance(grants, list) or not grants:
raise HTTPException(400, "grants must be a non-empty list")
@@ -266,11 +264,10 @@ def _collection_type(collection_id: int) -> str:
@router.post("/collections/{collection_id}/permissions")
async def grant_collection_permission(collection_id: int, request: Request):
def grant_collection_permission(collection_id: int, request: Request, body: dict = Body(default={})):
pm = _pm(request)
if not pm.can_manage_collection_permissions(collection_id):
raise HTTPException(403, "Only a collection owner can manage its permissions")
body = await request.json()
return _grant_common(request, pm, "collection", collection_id, body,
"collection_permissions", "collection_id", COLLECTION_ROLES)
@@ -331,11 +328,10 @@ def list_property_permissions(collection_id: int, property_id: int, request: Req
@router.post("/collections/{collection_id}/properties/{property_id}/permissions")
async def grant_property_permission(collection_id: int, property_id: int, request: Request):
def grant_property_permission(collection_id: int, property_id: int, request: Request, body: dict = Body(default={})):
pm = _pm(request)
if not pm.can_manage_collection_permissions(collection_id):
raise HTTPException(403, "Only a collection owner can manage property permissions")
body = await request.json()
with get_conn() as conn:
prop = conn.execute(
"SELECT id FROM collection_properties WHERE id=? AND collection_id=?",
@@ -369,9 +365,8 @@ def list_groups(request: Request, workspace_id: int | None = None):
@router.post("/groups")
async def create_group(request: Request):
def create_group(request: Request, body: dict = Body(default={})):
pm = _pm(request)
body = await request.json()
ws_id = body.get("workspace_id")
gid = pm.create_group(ws_id, body.get("name") or "", body.get("description") or "",
created_by=pm.user_id)
@@ -382,9 +377,8 @@ async def create_group(request: Request):
@router.put("/groups/{group_id}")
async def update_group(group_id: int, request: Request):
def update_group(group_id: int, request: Request, body: dict = Body(default={})):
pm = _pm(request)
body = await request.json()
name = (body.get("name") or "").strip()
if not name:
raise HTTPException(400, "name is required")
@@ -428,9 +422,8 @@ def list_group_members(group_id: int, request: Request):
@router.post("/groups/{group_id}/members")
async def add_group_member(group_id: int, request: Request):
def add_group_member(group_id: int, request: Request, body: dict = Body(default={})):
pm = _pm(request)
body = await request.json()
user_id = body.get("user_id")
if not user_id or not isinstance(user_id, int):
raise HTTPException(400, "user_id is required")
+4 -5
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, Body, HTTPException, Request
from app.auth.session import SessionManager
from app.services import projects as projects_svc
@@ -29,9 +29,8 @@ def list_projects(request: Request):
@router.post("")
async def create_project(request: Request):
def create_project(request: Request, body: dict = Body(default={})):
"""Register a standalone (builtin) project."""
body = await request.json()
name = (body.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name required")
@@ -51,7 +50,7 @@ async def sync_projects(request: Request):
@backups_router.post("/api/settings/backups/run")
async def run_backup_now(request: Request):
def run_backup_now(request: Request):
"""Admin: create a database backup immediately."""
_require_admin(request)
filename = backup_db()
@@ -61,7 +60,7 @@ async def run_backup_now(request: Request):
@backups_router.get("/api/settings/backups")
async def admin_list_backups(request: Request):
def admin_list_backups(request: Request):
"""Admin: list stored backups."""
_require_admin(request)
return {"backups": list_backups()}
+6 -26
View File
@@ -12,7 +12,7 @@ from __future__ import annotations
import hashlib
import secrets
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import JSONResponse
from app.auth.session import SessionManager
@@ -70,12 +70,8 @@ def scim_list(request: Request):
@router.post("/scim/v2/Users")
async def scim_create(request: Request):
def scim_create(request: Request, body: dict = Body(default={})):
_scim_guard(request)
try:
body = await request.json()
except Exception:
body = {}
username = (body.get("userName") or "").strip()
if not username:
raise HTTPException(400, "userName required")
@@ -133,12 +129,8 @@ def _apply_scim_update(conn, user_id: str, body: dict) -> None:
@router.put("/scim/v2/Users/{user_id}")
async def scim_replace(user_id: str, request: Request):
def scim_replace(user_id: str, request: Request, body: dict = Body(default={})):
_scim_guard(request)
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
_apply_scim_update(conn, user_id, body)
row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
@@ -146,12 +138,8 @@ async def scim_replace(user_id: str, request: Request):
@router.patch("/scim/v2/Users/{user_id}")
async def scim_patch(user_id: str, request: Request):
def scim_patch(user_id: str, request: Request, body: dict = Body(default={})):
_scim_guard(request)
try:
body = await request.json()
except Exception:
body = {}
flat: dict = {}
for op in body.get("Operations") or []:
path = (op.get("path") or "").lower()
@@ -180,12 +168,8 @@ def scim_delete(user_id: str, request: Request):
# ── SCIM token management (admin, session) ─────────────────────────────────
@router.post("/api/v2/scim/tokens")
async def create_scim_token(request: Request):
def create_scim_token(request: Request, body: dict = Body(default={})):
admin = _admin_session(request)
try:
body = await request.json()
except Exception:
body = {}
raw = f"scim_{secrets.token_urlsafe(32)}"
digest = hashlib.sha256(raw.encode()).hexdigest()
with get_conn() as conn:
@@ -234,12 +218,8 @@ def list_domains(request: Request):
@router.post("/api/v2/domain-claims")
async def create_domain(request: Request):
def create_domain(request: Request, body: dict = Body(default={})):
admin = _admin_session(request)
try:
body = await request.json()
except Exception:
body = {}
domain = (body.get("domain") or "").strip().lower()
if not domain or "." not in domain or "/" in domain:
raise HTTPException(400, "valid domain required")
+2 -3
View File
@@ -8,7 +8,7 @@ import hashlib
import logging
from secrets import token_urlsafe
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, Body, HTTPException, Request
from app.auth.session import SessionManager
from app.db import get_conn
@@ -45,10 +45,9 @@ def list_tokens(request: Request):
@router.post("/tokens")
async def create_token(request: Request):
def create_token(request: Request, body: dict = Body(default={})):
"""Create an API token for the current user. The secret is returned once."""
uid = _current_user_id(request)
body = await request.json()
name = (body.get("name") or "").strip() or "API token"
token = f"fd_{token_urlsafe(24)}"
with get_conn() as conn:
+10 -12
View File
@@ -4,11 +4,12 @@ from __future__ import annotations
import logging
from datetime import UTC, datetime
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, Body, HTTPException, Request
from app.auth.session import SessionManager
from app.db import get_conn
from app.services.automations import fire_event as _fire_event
from app.services.automations import run_event_sync
from app.services.publish import fire_published, fire_unpublished, publish, unpublish
logger = logging.getLogger(__name__)
@@ -27,10 +28,9 @@ def _require_auth(request: Request) -> dict:
@router.post("/pages/{page_id}/share")
async def share_page(page_id: int, request: Request):
def share_page(page_id: int, request: Request, body: dict = Body(default={})):
"""Invite a user, an email, or a group to a page."""
user = _require_auth(request)
body = await request.json() if request.headers.get("content-type") else {}
target_user_id = body.get("user_id")
target_group_id = body.get("group_id")
email = body.get("email", "")
@@ -119,7 +119,7 @@ async def share_page(page_id: int, request: Request):
conn.commit()
try:
await _fire_event("page.shared", {"page_id": page_id, "share_id": share_id, "permission": permission})
run_event_sync(_fire_event("page.shared", {"page_id": page_id, "share_id": share_id, "permission": permission}))
except Exception:
logger.exception("share_page")
@@ -164,11 +164,10 @@ def _mirror_share_revoke(conn, page_id: int, group_id: int) -> None:
@router.put("/pages/{page_id}/share/{share_id}", description="Update a share's permission.")
async def update_share_permission(page_id: int, share_id: int, request: Request):
def update_share_permission(page_id: int, share_id: int, request: Request, body: dict = Body(default={})):
"""Change the permission level of an existing share entry."""
user = _require_auth(request)
body = await request.json() if request.headers.get("content-type") else {}
permission = body.get("permission", "")
if permission not in ("view", "comment", "edit"):
@@ -293,11 +292,11 @@ def list_shares(page_id: int, request: Request):
@router.post("/pages/{page_id}/publish")
async def publish_page(page_id: int, request: Request):
def publish_page(page_id: int, request: Request):
"""Publish a page (is_published=1) with a URL slug."""
_require_auth(request)
slug, _title = publish(page_id)
await fire_published(page_id, slug)
run_event_sync(fire_published(page_id, slug))
return {
"page_id": page_id,
"is_published": True,
@@ -307,11 +306,11 @@ async def publish_page(page_id: int, request: Request):
@router.delete("/pages/{page_id}/publish")
async def unpublish_page(page_id: int, request: Request):
def unpublish_page(page_id: int, request: Request):
"""Unpublish a page."""
_require_auth(request)
unpublish(page_id)
await fire_unpublished(page_id)
run_event_sync(fire_unpublished(page_id))
return {
"page_id": page_id,
"is_published": False,
@@ -323,10 +322,9 @@ async def unpublish_page(page_id: int, request: Request):
@router.post("/recents/track")
async def track_recent(request: Request):
def track_recent(request: Request, body: dict = Body(default={})):
"""Record a page access in recents."""
user = _require_auth(request)
body = await request.json() if request.headers.get("content-type") else {}
page_id = body.get("page_id")
workspace = body.get("workspace", "")
source_type = body.get("source_type", "local")
+2 -6
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import json
import logging
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, Body, HTTPException, Request
from app.auth.session import SessionManager
from app.db import get_conn
@@ -81,13 +81,9 @@ def get_sidebar_config_sync(user_id: int) -> dict:
@router.put("/config")
async def save_sidebar_config(request: Request):
def save_sidebar_config(request: Request, body: dict = Body(...)):
"""Save the current user's sidebar customization config."""
user = _get_user(request)
try:
body = await request.json()
except Exception:
raise HTTPException(status_code=400, detail="Invalid JSON body") from None
config = body.get("config")
if not config or not isinstance(config, dict):
+5 -21
View File
@@ -19,7 +19,7 @@ import time
import unicodedata
from datetime import UTC, datetime
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
from app.auth.session import SessionManager
@@ -256,12 +256,8 @@ def _check_form_rate(ip: str) -> None:
# ── Sites CRUD (session or Bearer) ─────────────────────────────────────────
@router.post("/api/v2/sites")
async def create_site(request: Request):
def create_site(request: Request, body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
try:
body = await request.json()
except Exception:
body = {}
root_page_id = body.get("root_page_id")
if not root_page_id:
raise HTTPException(400, "root_page_id is required")
@@ -349,12 +345,8 @@ def get_site(site_id: int, request: Request):
@router.patch("/api/v2/sites/{site_id}")
async def update_site(site_id: int, request: Request):
def update_site(site_id: int, request: Request, body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
if not row:
@@ -432,12 +424,8 @@ def list_site_pages(site_id: int, request: Request):
@router.post("/api/v2/sites/{site_id}/pages")
async def add_site_page(site_id: int, request: Request):
def add_site_page(site_id: int, request: Request, body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
try:
body = await request.json()
except Exception:
body = {}
page_id = body.get("page_id")
if not page_id:
raise HTTPException(400, "page_id is required")
@@ -622,12 +610,8 @@ def get_form_config(collection_id: int, request: Request):
@router.put("/api/v2/collections/{collection_id}/form")
async def put_form_config(collection_id: int, request: Request):
def put_form_config(collection_id: int, request: Request, body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
info = _form_config(conn, collection_id)
cfg = info["config"] if isinstance(info["config"], dict) else {}
+14 -18
View File
@@ -17,7 +17,7 @@ import logging
import secrets
import time
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from app.auth.providers import oidc_provider, saml_provider
@@ -496,7 +496,7 @@ async def oidc_logout_post(request: Request, next: str = "/auth/login?provider=l
# ═══════════════════════ Admin configuration API ══════════════════════════
async def _require_admin(request: Request, *, write: bool) -> dict:
def _require_admin(request: Request, *, write: bool) -> dict:
"""Admin identity: Bearer token (scope read/write) or an admin session.
Session-authenticated writes also need the CSRF header — ``/api/v2`` is
@@ -558,22 +558,18 @@ def sso_providers(request: Request):
@router.get("/api/v2/sso/config")
async def get_sso_config_api(request: Request):
def get_sso_config_api(request: Request):
"""Read the current SSO configuration (secrets never returned)."""
await _require_admin(request, write=False)
_require_admin(request, write=False)
cfg = _sso_config_or_error()
return sso.public_config_view(cfg)
@router.post("/api/v2/sso/config")
@router.put("/api/v2/sso/config")
async def save_sso_config_api(request: Request):
def save_sso_config_api(request: Request, payload: dict = Body(...)):
"""Create/replace the SSO configuration (admin, scope write)."""
admin = await _require_admin(request, write=True)
try:
payload = await request.json()
except Exception as err:
raise HTTPException(status_code=400, detail="Invalid JSON body") from err
admin = _require_admin(request, write=True)
try:
saved = sso.save_sso_config(payload, created_by=admin.get("id"))
except sso.SSOConfigError as err:
@@ -586,9 +582,9 @@ async def save_sso_config_api(request: Request):
@router.delete("/api/v2/sso/config")
async def delete_sso_config_api(request: Request):
def delete_sso_config_api(request: Request):
"""Disable SSO — local logins keep working (design §8 « SSO disable »)."""
admin = await _require_admin(request, write=True)
admin = _require_admin(request, write=True)
removed = sso.delete_sso_config()
from app.services.api_v2_helpers import audit_log
@@ -597,9 +593,9 @@ async def delete_sso_config_api(request: Request):
@router.get("/api/v2/sso/workspaces")
async def sso_workspaces(request: Request):
def sso_workspaces(request: Request):
"""Workspaces available for default assignment / group mapping."""
await _require_admin(request, write=False)
_require_admin(request, write=False)
from app.db import get_conn
with get_conn() as conn:
@@ -616,9 +612,9 @@ async def sso_workspaces(request: Request):
@router.post("/api/v2/sso/sync")
async def sso_sync(request: Request):
def sso_sync(request: Request):
"""Re-apply group → workspace role mapping for every SSO user."""
admin = await _require_admin(request, write=True)
admin = _require_admin(request, write=True)
try:
result = sso.force_sync_all_groups()
except sso.SSOProvisioningError as err:
@@ -630,9 +626,9 @@ async def sso_sync(request: Request):
@router.get("/api/v2/sso/history")
async def sso_history(request: Request, limit: int = 50):
def sso_history(request: Request, limit: int = 50):
"""Audit trail of SSO login attempts (successes and rejections)."""
await _require_admin(request, write=False)
_require_admin(request, write=False)
from app.db import get_conn
limit = max(1, min(int(limit or 50), 200))
+6 -14
View File
@@ -15,7 +15,7 @@ from __future__ import annotations
import hashlib
import logging
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import HTMLResponse
from app.auth.session import SessionManager
@@ -88,7 +88,7 @@ def _require_user(request: Request) -> dict:
# ── API: status ──
@api_router.get("/status")
async def clipper_status(request: Request):
def clipper_status(request: Request):
user = _user_from_request(request)
if not user:
return {"authenticated": False}
@@ -101,12 +101,8 @@ async def clipper_status(request: Request):
# ── API: auth verify / device registration ──
@api_router.post("/auth/verify")
async def auth_verify(request: Request):
def auth_verify(request: Request, body: dict = Body(default={})):
user = _require_user(request)
try:
body = await request.json()
except Exception:
body = {}
device_id = (body.get("device_id") or request.headers.get("x-device-id") or "").strip()
device_name = (body.get("device_name") or body.get("deviceName") or "").strip()[:200]
extension_name = (body.get("extension_name") or body.get("extensionName") or "chrome").strip()[:20].lower()
@@ -124,7 +120,7 @@ async def auth_verify(request: Request):
@api_router.post("/clip")
async def clip_page(request: Request):
def clip_page(request: Request, body: dict = Body(...)):
user = _require_user(request)
# Enforce max body size early (10 MB)
clen = request.headers.get("content-length")
@@ -134,10 +130,6 @@ async def clip_page(request: Request):
raise HTTPException(status_code=413, detail="Clip too large (max 10 MB)")
except ValueError:
pass
try:
body = await request.json()
except Exception:
raise HTTPException(status_code=400, detail="Invalid JSON") from None
# Device identification for rate limiting and logging
device_id = (body.get("device_id") or request.headers.get("x-device-id") or "web").strip()[:128] or "web"
@@ -209,14 +201,14 @@ async def clip_page(request: Request):
@api_router.get("/devices")
async def list_extension_devices(request: Request):
def list_extension_devices(request: Request):
user = _require_user(request)
devices = list_devices(user["id"])
return {"devices": devices}
@api_router.delete("/devices/{device_id}")
async def revoke_extension_device(device_id: int, request: Request):
def revoke_extension_device(device_id: int, request: Request):
user = _require_user(request)
ok = revoke_device(user["id"], device_id)
if not ok:
+4 -16
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
import secrets
import time
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import JSONResponse
from app.auth.session import SessionManager
@@ -76,15 +76,11 @@ def register_begin(request: Request):
@router.post("/register/finish")
async def register_finish(request: Request):
def register_finish(request: Request, body: dict = Body(default={})):
if not _require_lib():
raise HTTPException(501, "WebAuthn library not installed")
from webauthn import verify_registration_response
user = _session_user(request)
try:
body = await request.json()
except Exception:
body = {}
challenge = _take_challenge(f"reg:{user['id']}")
if not challenge:
raise HTTPException(400, "Challenge expired — begin again")
@@ -115,14 +111,10 @@ async def register_finish(request: Request):
@router.post("/login/begin")
async def login_begin(request: Request):
def login_begin(request: Request, body: dict = Body(default={})):
if not _require_lib():
raise HTTPException(501, "WebAuthn library not installed")
from webauthn import generate_authentication_options, options_to_json
try:
body = await request.json()
except Exception:
body = {}
login = (body.get("login") or "").strip()
if not login:
raise HTTPException(400, "login required")
@@ -144,14 +136,10 @@ async def login_begin(request: Request):
@router.post("/login/finish")
async def login_finish(request: Request):
def login_finish(request: Request, body: dict = Body(default={})):
if not _require_lib():
raise HTTPException(501, "WebAuthn library not installed")
from webauthn import verify_authentication_response
try:
body = await request.json()
except Exception:
body = {}
login = (body.get("login") or "").strip()
challenge = _take_challenge(f"login:{login}")
if not login or not challenge:
+7 -31
View File
@@ -7,7 +7,7 @@ from __future__ import annotations
import html
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse
from app.auth.session import SessionManager
@@ -172,12 +172,8 @@ def teamspace_page(teamspace_id: int, request: Request):
@router.post("/api/v2/wiki/teamspaces")
async def create_teamspace(request: Request):
def create_teamspace(request: Request, body: dict = Body(default={})):
user = _user(request)
try:
body = await request.json()
except Exception:
body = {}
name = (body.get("name") or "").strip()
if not name or len(name) > 120:
raise HTTPException(400, "name required (max 120 chars)")
@@ -225,15 +221,11 @@ def list_members(teamspace_id: int, request: Request):
@router.put("/api/v2/wiki/teamspaces/{teamspace_id}/members/{member_id}")
async def set_member(teamspace_id: int, member_id: int, request: Request):
def set_member(teamspace_id: int, member_id: int, request: Request, body: dict = Body(default={})):
user = _user(request)
_teamspace_or_404(teamspace_id, user["id"])
if not wiki.can_write_teamspace(user["id"], teamspace_id):
raise HTTPException(403, "Editor role required")
try:
body = await request.json()
except Exception:
body = {}
role = body.get("role")
if role not in wiki.TEAMSPACE_ROLES:
raise HTTPException(400, f"role must be one of {', '.join(wiki.TEAMSPACE_ROLES)}")
@@ -275,15 +267,11 @@ def get_verification(page_id: int, request: Request):
@router.post("/api/v2/wiki/pages/{page_id}/verify")
async def verify_page(page_id: int, request: Request):
def verify_page(page_id: int, request: Request, body: dict = Body(default={})):
user = _user(request)
page = _page_or_404(page_id)
if not _can_verify(user, page):
raise HTTPException(403, "Editor role required to verify a page")
try:
body = await request.json()
except Exception:
body = {}
out = wiki.verify_page(page_id, user["id"],
days=body.get("days") or wiki.VERIFICATION_DAYS_DEFAULT,
note=body.get("note") or "")
@@ -355,12 +343,8 @@ def list_followers(page_id: int, request: Request):
# ── comment reactions ──────────────────────────────────────────────────────
@router.post("/api/v2/wiki/comments/{comment_id}/reactions")
async def react(comment_id: int, request: Request):
def react(comment_id: int, request: Request, body: dict = Body(default={})):
user = _user(request)
try:
body = await request.json()
except Exception:
body = {}
emoji = (body.get("emoji") or "").strip()
if not emoji:
raise HTTPException(400, "emoji required")
@@ -380,15 +364,11 @@ def list_reactions(comment_id: int, request: Request):
# ── guest shares ───────────────────────────────────────────────────────────
@router.post("/api/v2/wiki/pages/{page_id}/guests")
async def create_guest(page_id: int, request: Request):
def create_guest(page_id: int, request: Request, body: dict = Body(default={})):
user = _user(request)
page = _page_or_404(page_id)
if page.get("teamspace_id") and not wiki.can_write_teamspace(user["id"], page["teamspace_id"]):
raise HTTPException(403, "Editor role required to share")
try:
body = await request.json()
except Exception:
body = {}
try:
share = wiki.create_guest_share(page_id, body.get("email") or "",
body.get("role") or "viewer",
@@ -519,13 +499,9 @@ def sweep_expiry(request: Request):
# ── blocks (mermaid / equation_inline / progress) ───────────────────────────
@router.post("/api/v2/wiki/blocks/preview")
async def preview_blocks(request: Request):
def preview_blocks(request: Request, body: dict = Body(default={})):
"""Render v7.3 blocks to HTML (same renderer used by the export pipeline)."""
_user(request)
try:
body = await request.json()
except Exception:
body = {}
blocks = body.get("blocks")
if not isinstance(blocks, list):
raise HTTPException(400, "blocks must be a list")
+3 -11
View File
@@ -1,7 +1,7 @@
"""FlowDeck — Workers API (v7.0.0): CRUD, manual run, history, fork, usage."""
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import JSONResponse
from app.auth.session import SessionManager
@@ -41,12 +41,8 @@ def _row_to_api(row) -> dict:
@router.post("/api/v2/workers")
async def create_worker(request: Request):
def create_worker(request: Request, body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
try:
body = await request.json()
except Exception:
body = {}
name = (body.get("name") or "Untitled worker").strip()[:200]
code = body.get("code_py") or ""
try:
@@ -101,12 +97,8 @@ def get_worker(worker_id: int, request: Request):
@router.patch("/api/v2/workers/{worker_id}")
async def update_worker(worker_id: int, request: Request):
def update_worker(worker_id: int, request: Request, body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
if not row:
+28 -47
View File
@@ -8,12 +8,12 @@ import json
import logging
import sqlite3
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import HTMLResponse, StreamingResponse
from app.auth.session import SessionManager
from app.db import get_conn
from app.services.automations import fire_event
from app.services.automations import fire_event, run_event_sync
logger = logging.getLogger(__name__)
router = APIRouter(tags=["workspace"], prefix="/workspace")
@@ -57,8 +57,7 @@ def _require_ws_admin(request: Request, ws_id: int) -> None:
# ── Workspaces ──
@router.post("")
async def create_workspace(request: Request):
body = await request.json() if request.headers.get("content-type") else {}
def create_workspace(request: Request, body: dict = Body(default={})):
name = body.get("name", "Default Workspace")
user = _current_user(request)
uid = user.get("id", 1)
@@ -91,9 +90,8 @@ def list_members(request: Request, ws_id: int):
@router.post("/{ws_id}/members")
async def add_member(request: Request, ws_id: int):
def add_member(request: Request, ws_id: int, body: dict = Body(default={})):
_require_ws_admin(request, ws_id)
body = await request.json() if request.headers.get("content-type") else {}
user_id = body.get("user_id")
role = body.get("role", "editor")
if role not in ROLES:
@@ -108,9 +106,8 @@ async def add_member(request: Request, ws_id: int):
@router.put("/{ws_id}/members/{user_id}")
async def update_member_role(request: Request, ws_id: int, user_id: int):
def update_member_role(request: Request, ws_id: int, user_id: int, body: dict = Body(default={})):
_require_ws_admin(request, ws_id)
body = await request.json() if request.headers.get("content-type") else {}
role = body.get("role", "editor")
if role not in ROLES:
raise HTTPException(400, f"Invalid role: {role}")
@@ -143,8 +140,7 @@ def list_comments(request: Request, page_id: int):
@router.post("/pages/{page_id}/comments")
async def add_comment(request: Request, page_id: int):
body = await request.json() if request.headers.get("content-type") else {}
def add_comment(request: Request, page_id: int, body: dict = Body(default={})):
b = body.get("body", "").strip()
if not b:
raise HTTPException(400, "body required")
@@ -158,15 +154,14 @@ async def add_comment(request: Request, page_id: int):
(page_id, uid, b, parent_id))
conn.commit()
try:
await fire_event("comment.added", {"comment_id": cur.lastrowid, "page_id": page_id, "user_id": uid})
run_event_sync(fire_event("comment.added", {"comment_id": cur.lastrowid, "page_id": page_id, "user_id": uid}))
except Exception:
logger.exception("add_comment")
return {"id": cur.lastrowid, "status": "created"}
@router.put("/comments/{comment_id}")
async def update_comment(request: Request, comment_id: int):
body = await request.json() if request.headers.get("content-type") else {}
def update_comment(request: Request, comment_id: int, body: dict = Body(default={})):
b = body.get("body")
resolved = body.get("resolved")
with get_conn() as conn:
@@ -178,7 +173,7 @@ async def update_comment(request: Request, comment_id: int):
conn.commit()
if resolved and row and not int(row["resolved"] or 0):
try:
await fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]})
run_event_sync(fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]}))
except Exception:
logger.exception("update_comment")
return {"status": "updated"}
@@ -197,8 +192,7 @@ def page_history(request: Request, page_id: int):
@router.post("/pages/{page_id}/history")
async def record_history(request: Request, page_id: int):
body = await request.json() if request.headers.get("content-type") else {}
def record_history(request: Request, page_id: int, body: dict = Body(default={})):
user = _current_user(request)
uid = user.get("id", 1)
with get_conn() as conn:
@@ -230,8 +224,7 @@ def list_favorites(request: Request):
@router.post("/favorites")
async def add_favorite(request: Request):
body = await request.json() if request.headers.get("content-type") else {}
def add_favorite(request: Request, body: dict = Body(default={})):
user = _current_user(request)
uid = user.get("id", 1)
page_id = body.get("page_id")
@@ -244,7 +237,7 @@ async def add_favorite(request: Request):
)
conn.commit()
try:
await fire_event("favorite.added", {"page_id": page_id, "collection_id": collection_id, "user_id": uid})
run_event_sync(fire_event("favorite.added", {"page_id": page_id, "collection_id": collection_id, "user_id": uid}))
except Exception:
logger.exception("add_favorite")
return {"status": "favorited"}
@@ -268,8 +261,7 @@ def list_db_templates(request: Request):
@router.post("/templates/database")
async def create_db_template(request: Request):
body = await request.json() if request.headers.get("content-type") else {}
def create_db_template(request: Request, body: dict = Body(default={})):
cur = None
with get_conn() as conn:
cur = conn.execute(
@@ -282,9 +274,8 @@ async def create_db_template(request: Request):
@router.post("/templates/database/{tid}/apply")
async def apply_db_template(request: Request, tid: int):
def apply_db_template(request: Request, tid: int, body: dict = Body(default={})):
from app.services.db_templates import create_from_template
body = await request.json() if request.headers.get("content-type") else {}
name = body.get("name", "New Database")
with get_conn() as conn:
tmpl = conn.execute("SELECT * FROM database_templates WHERE id=?", (tid,)).fetchone()
@@ -305,8 +296,7 @@ def list_page_templates(request: Request, collection_id: int):
@router.post("/collections/{collection_id}/templates/page")
async def create_page_template(request: Request, collection_id: int):
body = await request.json() if request.headers.get("content-type") else {}
def create_page_template(request: Request, collection_id: int, body: dict = Body(default={})):
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO page_templates (collection_id, name, property_values_json) VALUES (?,?,?)",
@@ -317,8 +307,7 @@ async def create_page_template(request: Request, collection_id: int):
@router.post("/collections/{collection_id}/templates/page/{tid}/apply")
async def apply_page_template(request: Request, collection_id: int, tid: int):
body = await request.json() if request.headers.get("content-type") else {}
def apply_page_template(request: Request, collection_id: int, tid: int, body: dict = Body(default={})):
with get_conn() as conn:
tmpl = conn.execute("SELECT * FROM page_templates WHERE id=? AND collection_id=?", (tid, collection_id)).fetchone()
if not tmpl:
@@ -331,19 +320,18 @@ async def apply_page_template(request: Request, collection_id: int, tid: int):
(collection_id, body.get("title", "New Page"), max_pos, tmpl["property_values_json"]),
)
conn.commit()
await fire_event("page.created", {
run_event_sync(fire_event("page.created", {
"page_id": cur.lastrowid,
"collection_id": collection_id,
"title": body.get("title", "New Page"),
"properties": json.loads(tmpl["property_values_json"]) if tmpl["property_values_json"] else {},
})
}))
return {"id": cur.lastrowid, "status": "created"}
@router.put("/collections/{collection_id}/templates/page/{tid}")
async def update_page_template(request: Request, collection_id: int, tid: int):
def update_page_template(request: Request, collection_id: int, tid: int, body: dict = Body(default={})):
"""Update a page template — name, properties, content, recurrence."""
body = await request.json() if request.headers.get("content-type") else {}
with get_conn() as conn:
tmpl = conn.execute(
"SELECT * FROM page_templates WHERE id=? AND collection_id=?", (tid, collection_id)
@@ -395,9 +383,8 @@ def list_dashboards(request: Request, collection_id: int):
@router.post("/collections/{collection_id}/dashboards")
async def create_dashboard(request: Request, collection_id: int):
def create_dashboard(request: Request, collection_id: int, body: dict = Body(default={})):
"""Create a new dashboard for a collection."""
body = await request.json() if request.headers.get("content-type") else {}
name = body.get("name", "Dashboard").strip()
layout = json.dumps(body.get("layout", {"columns": 1, "widgets": []}))
@@ -414,9 +401,8 @@ async def create_dashboard(request: Request, collection_id: int):
@router.put("/collections/{collection_id}/dashboards/{did}")
async def update_dashboard(request: Request, collection_id: int, did: int):
def update_dashboard(request: Request, collection_id: int, did: int, body: dict = Body(default={})):
"""Update a dashboard — name or layout (widgets grid)."""
body = await request.json() if request.headers.get("content-type") else {}
with get_conn() as conn:
dash = conn.execute(
"SELECT * FROM collection_dashboards WHERE id=? AND collection_id=?", (did, collection_id)
@@ -471,9 +457,8 @@ def list_sprints(request: Request, collection_id: int):
@router.post("/collections/{collection_id}/sprints")
async def create_sprint(request: Request, collection_id: int):
def create_sprint(request: Request, collection_id: int, body: dict = Body(default={})):
"""Create a new sprint."""
body = await request.json() if request.headers.get("content-type") else {}
name = body.get("name", "").strip()
start_date = body.get("start_date", "")
end_date = body.get("end_date", "")
@@ -491,16 +476,15 @@ async def create_sprint(request: Request, collection_id: int):
)
conn.commit()
try:
await fire_event("sprint.created", {"sprint_id": cur.lastrowid, "collection_id": collection_id, "name": name})
run_event_sync(fire_event("sprint.created", {"sprint_id": cur.lastrowid, "collection_id": collection_id, "name": name}))
except Exception:
logger.exception("create_sprint")
return {"id": cur.lastrowid, "name": name, "status": "created"}
@router.put("/collections/{collection_id}/sprints/{sid}")
async def update_sprint(request: Request, collection_id: int, sid: int):
def update_sprint(request: Request, collection_id: int, sid: int, body: dict = Body(default={})):
"""Update a sprint."""
body = await request.json() if request.headers.get("content-type") else {}
with get_conn() as conn:
sprint = conn.execute(
"SELECT * FROM sprints WHERE id=? AND collection_id=?", (sid, collection_id)
@@ -521,7 +505,7 @@ async def update_sprint(request: Request, collection_id: int, sid: int):
)
conn.commit()
try:
await fire_event("sprint.updated", {"sprint_id": sid, "collection_id": collection_id, "name": name, "status": status})
run_event_sync(fire_event("sprint.updated", {"sprint_id": sid, "collection_id": collection_id, "name": name, "status": status}))
except Exception:
logger.exception("update_sprint")
return {"id": sid, "status": "updated"}
@@ -542,9 +526,8 @@ def delete_sprint(request: Request, collection_id: int, sid: int):
@router.post("/collections/{collection_id}/sprints/{sid}/assign")
async def assign_page_to_sprint(request: Request, collection_id: int, sid: int):
def assign_page_to_sprint(request: Request, collection_id: int, sid: int, body: dict = Body(default={})):
"""Assign a page to a sprint."""
body = await request.json() if request.headers.get("content-type") else {}
page_id = body.get("page_id")
if not page_id:
raise HTTPException(400, "page_id is required")
@@ -634,8 +617,7 @@ def sprint_burndown(request: Request, collection_id: int, sid: int):
# ── CSV Import/Export ──
@router.post("/collections/{collection_id}/import/csv")
async def import_csv(request: Request, collection_id: int):
body = await request.json() if request.headers.get("content-type") else {}
def import_csv(request: Request, collection_id: int, body: dict = Body(default={})):
csv_data = body.get("csv", "")
if not csv_data:
raise HTTPException(400, "csv field required")
@@ -698,9 +680,8 @@ def list_webhooks(request: Request):
@router.post("/webhooks")
async def create_webhook(request: Request):
def create_webhook(request: Request, body: dict = Body(default={})):
_require_admin(request)
body = await request.json() if request.headers.get("content-type") else {}
url = body.get("url", "").strip()
event = body.get("event", "page.created")
secret = body.get("secret", "")
+2123 -1
View File
File diff suppressed because it is too large Load Diff