Découpe par concern de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) en package `app/routers/api_v2/` : - 12 modules de routes : collections 566 L (23 r.), engagement 338 (21), workspaces 230 (9), templates_io 205 (9), webhooks 195 (8), identity 195 (7), views 164 (8), sharing 160 (8), properties 151 (7), planning 148 (7), projects 93 (4), admin 91 (4) - `_common.py` : helpers partagés (_hash, _v2_rate_check) - `__init__.py` : router = APIRouter(prefix="/api/v2") + include_router sur les routers de sections (sans prefix, tags « api-v2 ») Preuve contractuelle : `docs/openapi-v2.json` régénéré = IDENTIQUE byte-à-byte (0 changement de chemin/tag/operation_id). Seul importateur (app/main.py : from app.routers.api_v2 import router) fonctionne via le package. En-tête d'imports copié par module puis émondé par ruff --fix (143 imports morts), I001 réordonnés. Reste A28 : dashboard.py 2 735 L, collections.py 2 622 L, board.py 2 101 L (même recette, lots suivants). suite **1091/1091** · ruff OK · OpenAPI 509 identique · docs à jour
94 lines
4.1 KiB
Python
94 lines
4.1 KiB
Python
"""FlowDeck — Public API v2 : projects.
|
|
|
|
Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
from fastapi import APIRouter, Header, HTTPException, Request
|
|
|
|
from app.db import get_conn
|
|
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
|
|
audit_log,
|
|
check_idempotency,
|
|
check_v2_rate_limit,
|
|
get_bearer_user,
|
|
has_scope,
|
|
paginate_headers,
|
|
parse_pagination,
|
|
require_scope,
|
|
row_to_dict,
|
|
store_idempotency,
|
|
to_iso8601,
|
|
validate_scopes_input,
|
|
)
|
|
|
|
from ._common import _v2_rate_check
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(tags=["api-v2"])
|
|
|
|
|
|
|
|
@router.get("/projects")
|
|
def list_projects_v2(request: Request, authorization: str | None = Header(default=None)):
|
|
user = get_bearer_user(request, authorization)
|
|
_v2_rate_check(request, user)
|
|
with get_conn() as conn:
|
|
rows = conn.execute("SELECT * FROM projects ORDER BY proj_type, owner, name").fetchall()
|
|
return {"projects": [row_to_dict(r) for r in rows]}
|
|
|
|
|
|
@router.get("/projects/{owner}/{repo}/tree")
|
|
async def project_tree_v2(owner: str, repo: str, request: Request, path: str = "", authorization: str | None = Header(default=None)):
|
|
get_bearer_user(request, authorization)
|
|
# proxy to gitea client? Return placeholder listing from projects table
|
|
with get_conn() as conn:
|
|
proj = conn.execute("SELECT * FROM projects WHERE owner=? AND name=?", (owner, repo)).fetchone()
|
|
if not proj:
|
|
raise HTTPException(404, "Project not found")
|
|
# delegate to gitea API if available (best-effort)
|
|
try:
|
|
from app.services.gitea_client import gitea
|
|
tree = await gitea.list_repo_files(owner, repo, path or "")
|
|
return {"owner": owner, "repo": repo, "path": path, "tree": tree}
|
|
except Exception:
|
|
return {"owner": owner, "repo": repo, "path": path, "tree": []}
|
|
|
|
|
|
@router.get("/search")
|
|
def search_v2(request: Request, query: str = "", workspace_id: int | None = None, type: str = "all", authorization: str | None = Header(default=None)):
|
|
user = get_bearer_user(request, authorization)
|
|
_v2_rate_check(request, user)
|
|
q = (query or request.query_params.get("query") or "").strip()
|
|
if not q:
|
|
return {"results": [], "query": q}
|
|
like = f"%{q}%"
|
|
with get_conn() as conn:
|
|
pages = []
|
|
# try FTS5
|
|
try:
|
|
rows = conn.execute("SELECT p.id, p.title, p.content, p.workspace_id, snippet(pages_fts, -1, '<mark>', '</mark>', '...', 32) as snippet FROM pages_fts f JOIN pages p ON p.id=f.rowid WHERE pages_fts MATCH ? LIMIT 20", (q,)).fetchall()
|
|
pages = [{"type": "page", "id": r["id"], "title": r["title"], "snippet": r["snippet"]} for r in rows]
|
|
except Exception:
|
|
rows = conn.execute("SELECT id, title FROM pages WHERE title LIKE ? OR content LIKE ? LIMIT 20", (like, like)).fetchall()
|
|
pages = [{"type": "page", "id": r["id"], "title": r["title"]} for r in rows]
|
|
# collections
|
|
colls = conn.execute("SELECT id, name FROM collections WHERE name LIKE ? LIMIT 10", (like,)).fetchall()
|
|
results = pages + [{"type": "collection", "id": r["id"], "title": r["name"]} for r in colls]
|
|
return {"query": q, "results": results}
|
|
|
|
|
|
@router.get("/admin/users")
|
|
def admin_list_users_v2(request: Request, limit: int = 30, offset: int = 0, authorization: str | None = Header(default=None)):
|
|
user = get_bearer_user(request, authorization)
|
|
_v2_rate_check(request, user)
|
|
if not user.get("is_admin") and not has_scope(user.get("_token_scopes"), "admin"):
|
|
raise HTTPException(403, "Admin scope required")
|
|
limit = max(1, min(limit, 100))
|
|
with get_conn() as conn:
|
|
total = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
|
rows = conn.execute("SELECT id, login, full_name, email, is_admin, is_active, created_at FROM users ORDER BY id LIMIT ? OFFSET ?", (limit, offset)).fetchall()
|
|
return {"users": [row_to_dict(r) for r in rows], "total": total, "limit": limit, "offset": offset}
|