Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12beab01c9 | ||
|
|
090174df0a | ||
|
|
fc8548194a | ||
|
|
d0452e10dd | ||
|
|
57c6cbec03 | ||
|
|
1fedafa808 | ||
|
|
d95178156f | ||
|
|
9ea0cddf3c | ||
|
|
0218d8f5e5 | ||
|
|
7468e497c6 | ||
|
|
162eb257c7 | ||
|
|
b9a776b626 | ||
|
|
674baf6954 | ||
|
|
3d3b46b58b | ||
|
|
7cf922106f | ||
|
|
d58c321c07 | ||
|
|
db6bf03a00 | ||
|
|
c031bf67de | ||
|
|
ab6977733d | ||
|
|
00c5e2a60d | ||
|
|
858649e3c3 | ||
|
|
400a827e3f | ||
|
|
699165469d | ||
|
|
3a6445ca4e | ||
|
|
033490c464 | ||
|
|
f132885b08 | ||
|
|
a79bdc1641 | ||
|
|
a67bd252aa | ||
|
|
39d34ac866 | ||
|
|
efa3d57e93 | ||
|
|
992200a90a | ||
|
|
21b96afa12 | ||
|
|
16f73fe39e | ||
|
|
f8acb906e5 | ||
|
|
1d7750bda0 | ||
|
|
18c72d77fe | ||
|
|
64c85caffc | ||
|
|
a68e8848ea | ||
|
|
18a2d45d46 | ||
|
|
f271ac9b7a | ||
|
|
f006880394 | ||
|
|
81746d9440 | ||
|
|
f53205bc45 | ||
|
|
3706689eca | ||
|
|
7dbaefb381 | ||
|
|
15b6b163f4 | ||
|
|
2e5efcfe07 | ||
|
|
1d1cdbd618 | ||
|
|
894004a1f5 | ||
|
|
6582df3bcb | ||
|
|
aa88cf6665 | ||
|
|
b2ea8ec537 | ||
|
|
1051a72b52 | ||
|
|
afbc236cc2 | ||
|
|
c20aeaada1 | ||
|
|
6d7af3fb64 | ||
|
|
b0af1bbfb6 | ||
|
|
de751ffe35 | ||
|
|
48b5551b93 | ||
|
|
e4552c3763 | ||
|
|
6914780f24 | ||
|
|
d7d9966edf | ||
|
|
3cab76fed5 | ||
|
|
6ff88237fc | ||
|
|
840d2b2615 | ||
|
|
ab6ac1e84c | ||
|
|
3a74ea8bbd | ||
|
|
13dc8fdaad | ||
|
|
770fdc2b68 | ||
|
|
0bc74ad728 | ||
|
|
adf56a2dd8 | ||
|
|
c0925e511b | ||
|
|
6a5fe0524a | ||
|
|
3bb8e87ef2 | ||
|
|
0f86294abc |
@@ -1,6 +1,10 @@
|
||||
# ── Gitea ──
|
||||
GITEA_URL=https://git.dracodev.net
|
||||
GITEA_TOKEN=change-me
|
||||
# Laissez VIDES pour activer le login local seul : depuis v7.46.0 les valeurs de
|
||||
# substitution (`test-id`, `test-secret`, `change-me`) comptent comme « provider
|
||||
# non configuré » — avant, /auth/login redirigeait vers Gitea avec un client_id
|
||||
# invalide (authentification OAuth impossible).
|
||||
GITEA_OAUTH_CLIENT_ID=
|
||||
GITEA_OAUTH_CLIENT_SECRET=
|
||||
GITEA_WEBHOOK_SECRET=
|
||||
@@ -16,6 +20,16 @@ GITHUB_OAUTH_CLIENT_SECRET=
|
||||
# Exemple : OAUTH_REDIRECT_URI=https://flowdeck.dracodev.net/auth/callback
|
||||
OAUTH_REDIRECT_URI=
|
||||
|
||||
# ── Connecteurs OAuth (v7.56.0) — Google / Microsoft 365 ──
|
||||
# Vidés = connecteur « non configuré » (le menu + affiche la raison).
|
||||
# Le redirect URI à enregistrer est dérivé d'APP_BASE_URL :
|
||||
# {APP_BASE_URL}/api/agent/connectors/oauth/google/callback
|
||||
# {APP_BASE_URL}/api/agent/connectors/oauth/ms365/callback
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
MS_CLIENT_ID=
|
||||
MS_CLIENT_SECRET=
|
||||
|
||||
# ── App ──
|
||||
APP_SECRET_KEY=change-me-to-random
|
||||
APP_HOST=0.0.0.0
|
||||
@@ -82,3 +96,15 @@ APP_BASE_URL=http://localhost:8080
|
||||
# SSO_ATTRIBUTE_MAPPING={"email":"email","full_name":"name","groups":"groups"}
|
||||
# SSO_GROUPS_MAPPING=[{"sso_group":"FlowDeck Admins","workspace_role":"admin","workspace_id":1}]
|
||||
# SSO_DEFAULT_WORKSPACE_ID=0
|
||||
|
||||
# ── Web tools de l'agent (v7.46.0) ──
|
||||
# Skills « recherche-marche », « veille-techno », « debug-web ».
|
||||
# web_search → EXA (recherche sémantique + extraits, ~10 $/mois offerts)
|
||||
# fetch_url → aucune config : lecture d'une page, garde-fou SSRF actif
|
||||
# search_code → GitHub public (10 req/min sans jeton, 30 avec)
|
||||
# Sans EXA_API_KEY, web_search bascule sur DuckDuckGo (repli dégradé, sans
|
||||
# compte mais parsé au HTML) : utilisable, moins fiable.
|
||||
WEB_SEARCH_PROVIDER=exa
|
||||
EXA_API_KEY=
|
||||
GITHUB_TOKEN=
|
||||
WEB_FETCH_MAX_CHARS=20000
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
python-version: '3.13'
|
||||
- name: Install lint tools
|
||||
run: pip install -r requirements-dev.txt
|
||||
- name: Ruff (Python)
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
python-version: '3.13'
|
||||
- name: Install system dependencies (WeasyPrint / emoji fonts)
|
||||
run: |-
|
||||
SUDO=""
|
||||
|
||||
@@ -23,6 +23,10 @@ uv.lock
|
||||
*.db-*
|
||||
test-commit.md
|
||||
upload_test.txt
|
||||
commit_msg.txt
|
||||
e2e/node_modules/
|
||||
e2e/shots/
|
||||
e2e/test-results/
|
||||
|
||||
# Artefacts du navigateur Playwright MCP (logs / snapshots)
|
||||
.playwright-mcp/
|
||||
|
||||
+871
-1513
File diff suppressed because it is too large
Load Diff
+2839
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -3,7 +3,7 @@
|
||||
# Stage 1 "builder": build Python wheels once.
|
||||
# Stage 2 "runtime": minimal image with WeasyPrint system libs.
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
FROM python:3.12-slim AS builder
|
||||
FROM python:3.13-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -11,7 +11,7 @@ COPY requirements.txt .
|
||||
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt
|
||||
|
||||
# ── runtime stage ───────────────────────────────────────────
|
||||
FROM python:3.12-slim AS runtime
|
||||
FROM python:3.13-slim AS runtime
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -73,9 +73,9 @@ docker compose up -d
|
||||
| Couche | Techno |
|
||||
|--------|--------|
|
||||
| Frontend | Jinja2 + HTMX + Alpine.js + SortableJS + CSS |
|
||||
| Backend | Python 3.12 + FastAPI + httpx |
|
||||
| Backend | Python 3.13 + FastAPI + httpx |
|
||||
| BDD | SQLite (WAL, 21 tables) — `/data/flowdeck.db` |
|
||||
| Déploiement | Docker (python:3.12-slim), docker-compose |
|
||||
| Déploiement | Docker (python:3.13-slim), docker-compose |
|
||||
|
||||
## Configuration
|
||||
|
||||
|
||||
+610
-9
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,6 +1,6 @@
|
||||
# WORKLOAD — FlowDeck Notion Clone
|
||||
|
||||
> **Début**: 2026-07-08 | **Version**: v7.27.0 (audit — A20 phase 2 : CDN retiré, connect-src fermé) | **Statut**: EN COURS 🔄
|
||||
> **Début**: 2026-07-08 | **Version**: v7.69.4 (sélection multi-blocs fonctionnelle ; identité visuelle logo/bannière/favicon ; Aide alignée sur Settings ; close Settings réparé)
|
||||
> **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
|
||||
|
||||
+3
-4
@@ -4,9 +4,8 @@ from __future__ import annotations
|
||||
import logging
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
from app.services.http_client import shared_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -39,7 +38,7 @@ class GiteaOAuth:
|
||||
async def exchange_code(self, code: str) -> dict | None:
|
||||
"""Exchange authorization code for access token."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
resp = await client.post(
|
||||
self.TOKEN_URL,
|
||||
data={
|
||||
@@ -61,7 +60,7 @@ class GiteaOAuth:
|
||||
async def get_user(self, access_token: str) -> dict | None:
|
||||
"""Get user info from Gitea API."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
async with shared_client(timeout=10) as client:
|
||||
resp = await client.get(
|
||||
self.USER_URL,
|
||||
headers={"Authorization": f"token {access_token}"},
|
||||
|
||||
@@ -7,10 +7,21 @@ import time
|
||||
from abc import ABC, abstractmethod
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
from app.services.http_client import shared_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Valeurs de substitution livrées dans app/config.py : elles sont NON VIDES,
|
||||
# donc un simple `bool(client_id and client_secret)` les considérait comme
|
||||
# configurées et /auth/login redirigeait vers Gitea avec client_id=test-id
|
||||
# (échec d'authentification garanti). Un placeholder == provider non configuré.
|
||||
_PLACEHOLDER_CREDS = {"test-id", "test-secret", "change-me", "changeme", "your-client-id"}
|
||||
|
||||
|
||||
def _real(value: str | None) -> bool:
|
||||
"""True when ``value`` is a real credential (not empty, not a placeholder)."""
|
||||
return bool(value) and value.strip().lower() not in _PLACEHOLDER_CREDS
|
||||
|
||||
|
||||
class OAuthProvider(ABC):
|
||||
"""Abstract OAuth2 provider interface."""
|
||||
@@ -52,7 +63,7 @@ class GiteaProvider(OAuthProvider):
|
||||
self.redirect_uri = redirect_uri
|
||||
|
||||
def is_enabled(self) -> bool:
|
||||
return bool(self.client_id and self.client_secret)
|
||||
return _real(self.client_id) and _real(self.client_secret)
|
||||
|
||||
def get_authorize_url(self, state: str, redirect_uri: str | None = None, force_login: bool = False) -> str:
|
||||
params = {
|
||||
@@ -76,7 +87,7 @@ class GiteaProvider(OAuthProvider):
|
||||
"grant_type": "authorization_code",
|
||||
"redirect_uri": redirect_uri or self.redirect_uri,
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
r = await client.post(url, json=data, headers={"Accept": "application/json"})
|
||||
if r.status_code != 200:
|
||||
logger.error("Gitea token exchange failed: %s", r.text)
|
||||
@@ -85,7 +96,7 @@ class GiteaProvider(OAuthProvider):
|
||||
|
||||
async def get_user(self, access_token: str) -> dict | None:
|
||||
url = f"{self.base}/api/v1/user"
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
r = await client.get(url, headers={"Authorization": f"token {access_token}"})
|
||||
if r.status_code != 200:
|
||||
return None
|
||||
@@ -100,7 +111,7 @@ class GiteaProvider(OAuthProvider):
|
||||
|
||||
async def list_repositories(self, access_token: str) -> list[dict]:
|
||||
repos = []
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
async with shared_client(timeout=30) as client:
|
||||
for page in range(1, 6):
|
||||
r = await client.get(
|
||||
f"{self.base}/api/v1/user/repos",
|
||||
@@ -144,7 +155,7 @@ class GitHubProvider(OAuthProvider):
|
||||
self.api_url = "https://api.github.com"
|
||||
|
||||
def is_enabled(self) -> bool:
|
||||
return bool(self.client_id and self.client_secret)
|
||||
return _real(self.client_id) and _real(self.client_secret)
|
||||
|
||||
def get_authorize_url(self, state: str, redirect_uri: str | None = None, force_login: bool = False) -> str:
|
||||
return (
|
||||
@@ -158,7 +169,7 @@ class GitHubProvider(OAuthProvider):
|
||||
)
|
||||
|
||||
async def exchange_code(self, code: str, redirect_uri: str | None = None) -> dict | None:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
r = await client.post(
|
||||
self.token_url,
|
||||
data={
|
||||
@@ -179,7 +190,7 @@ class GitHubProvider(OAuthProvider):
|
||||
|
||||
async def get_user(self, access_token: str) -> dict | None:
|
||||
url = f"{self.api_url}/user"
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
r = await client.get(
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {access_token}", "Accept": "application/vnd.github.v3+json"},
|
||||
@@ -197,7 +208,7 @@ class GitHubProvider(OAuthProvider):
|
||||
|
||||
async def list_repositories(self, access_token: str) -> list[dict]:
|
||||
repos = []
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
async with shared_client(timeout=30) as client:
|
||||
for page in range(1, 6):
|
||||
r = await client.get(
|
||||
f"{self.api_url}/user/repos",
|
||||
|
||||
@@ -15,7 +15,7 @@ import secrets
|
||||
import time
|
||||
import warnings
|
||||
|
||||
import httpx
|
||||
from app.services.http_client import shared_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -59,7 +59,7 @@ async def discover(issuer_url: str) -> dict:
|
||||
if hit and now - hit[0] < _DISCOVERY_TTL:
|
||||
return hit[1]
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
r = await client.get(url)
|
||||
r.raise_for_status()
|
||||
doc = r.json()
|
||||
@@ -112,7 +112,7 @@ async def exchange_code(
|
||||
if client_secret:
|
||||
auth = (client_id, client_secret)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
r = await client.post(doc["token_endpoint"], data=data, auth=auth)
|
||||
except Exception as err:
|
||||
raise OIDCError(f"OIDC token request failed: {err}") from err
|
||||
@@ -133,7 +133,7 @@ async def fetch_userinfo(doc: dict, access_token: str) -> dict:
|
||||
if not endpoint or not access_token:
|
||||
return {}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
r = await client.get(endpoint, headers={"Authorization": f"Bearer {access_token}"})
|
||||
if r.status_code != 200:
|
||||
return {}
|
||||
|
||||
+15
-2
@@ -13,6 +13,19 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
_serializer = URLSafeTimedSerializer(settings.app_secret_key)
|
||||
|
||||
# Colonnes de la table ``users`` qui ne doivent JAMAIS quitter le serveur :
|
||||
# le cookie de session est signé (HMAC) mais pas chiffré — son payload est
|
||||
# lisible en base64 par quiconque détient le cookie, et ``/auth/user`` le
|
||||
# renvoyait tel quel au client.
|
||||
_SECRET_USER_FIELDS = ("password_hash",)
|
||||
|
||||
|
||||
def public_user(user_data: dict | None) -> dict | None:
|
||||
"""Return a copy of ``user_data`` stripped of credential material."""
|
||||
if user_data is None:
|
||||
return None
|
||||
return {k: v for k, v in user_data.items() if k not in _SECRET_USER_FIELDS}
|
||||
|
||||
|
||||
class SessionManager:
|
||||
"""Manages user sessions via signed cookies (v5.2.0: revocable).
|
||||
@@ -30,7 +43,7 @@ class SessionManager:
|
||||
``user_sessions`` table (ip + user agent) and becomes revocable.
|
||||
"""
|
||||
payload = {
|
||||
"user": user_data,
|
||||
"user": public_user(user_data),
|
||||
"created_at": datetime.now(UTC).replace(tzinfo=None).isoformat(),
|
||||
}
|
||||
user_id = user_data.get("id")
|
||||
@@ -93,7 +106,7 @@ class SessionManager:
|
||||
"""Re-sign a cookie keeping its session id (used after profile edits)."""
|
||||
sid = SessionManager.session_id(cookie) if cookie else None
|
||||
payload = {
|
||||
"user": user_data,
|
||||
"user": public_user(user_data),
|
||||
"created_at": datetime.now(UTC).replace(tzinfo=None).isoformat(),
|
||||
}
|
||||
user_id = user_data.get("id")
|
||||
|
||||
@@ -126,6 +126,35 @@ class Settings(BaseSettings):
|
||||
agent_max_tokens_budget: int = 500000
|
||||
agent_run_timeout_seconds: int = 300
|
||||
|
||||
# ── Connecteurs OAuth (v7.56.0) — Google / Microsoft 365 ──
|
||||
# Client OAuth2 à créer dans les consoles : Google Cloud (OAuth client web)
|
||||
# et Entra ID (application web). Le redirect URI est dérivé d'APP_BASE_URL.
|
||||
google_client_id: str = ""
|
||||
google_client_secret: str = ""
|
||||
ms_client_id: str = ""
|
||||
ms_client_secret: str = ""
|
||||
|
||||
# ── Mémoire de l'agent (v7.54.0) ──
|
||||
# État initial du toggle « Mémoire » à la création d'une conversation ;
|
||||
# le basculement se fait ensuite par PATCH /api/agent/conversations/{id}.
|
||||
agent_memory_default: bool = True
|
||||
|
||||
# ── Web tools de l'agent (web_search / fetch_url / search_code) ──
|
||||
# `web_search_provider` : « exa » (recherche sémantique + snippets, clé
|
||||
# requise) ou « duckduckgo » (repli sans compte, parsing HTML — dégradé).
|
||||
web_search_provider: str = "exa"
|
||||
exa_api_key: str = ""
|
||||
github_token: str = "" # PAT GitHub : 30 req/min au lieu de 10
|
||||
web_fetch_max_chars: int = 20000 # texte renvoyé par fetch_url
|
||||
|
||||
# ── Vues Notion : géocodage + plafonds (§8.2, §13.4, §20) ──
|
||||
GEOCODER_PROVIDER: str = "none" # none | nominatim | custom
|
||||
GEOCODER_ENDPOINT: str = "https://nominatim.openstreetmap.org"
|
||||
VIEW_ROWS_MAX: int = 1000
|
||||
MAP_POINTS_MAX: int = 100
|
||||
TIMELINE_LOAD_MAX: int = 200
|
||||
CHART_MAX_SUBGROUPS: int = 50
|
||||
|
||||
@property
|
||||
def db_path(self) -> Path:
|
||||
if self.database_url == "sqlite:///:memory:":
|
||||
|
||||
@@ -348,6 +348,16 @@ def init_db():
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN deleted_at TEXT")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
# v7.45.5 : réparation des pages restaurées marquées 'Trash' — l'ancien
|
||||
# soft-delete (éditeur) réécrivait parent_section='Trash' et la
|
||||
# restauration ne le remettait pas → page invisible en Library/Recents/
|
||||
# Private. Idempotent (WHERE restrictif), rejoué à chaque boot.
|
||||
# ponytail: un dossier restauré repasse en 'Private' (l'état d'origine
|
||||
# n'est pas stocké) → icône/dossier à remettre à 'Workspace' si besoin.
|
||||
conn.execute(
|
||||
"UPDATE pages SET parent_section='Private' "
|
||||
"WHERE parent_section='Trash' AND deleted_at IS NULL"
|
||||
)
|
||||
try:
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN share_mode TEXT DEFAULT 'private'")
|
||||
except sqlite3.OperationalError:
|
||||
@@ -823,6 +833,17 @@ def init_db():
|
||||
from app.migrations import apply_migrations
|
||||
apply_migrations(conn)
|
||||
|
||||
# ── Templates unifiés (§9.5, §12) : backfill idempotent des silos
|
||||
# legacy + presets système. Non bloquant : un échec laisse les silos
|
||||
# legacy servir leurs anciennes surfaces (dégradation, pas panne).
|
||||
try:
|
||||
from app.services.fd_templates import ensure_registry, reset_registry_cache
|
||||
reset_registry_cache()
|
||||
ensure_registry(conn)
|
||||
except Exception:
|
||||
import logging as _logging
|
||||
_logging.getLogger(__name__).exception("ensure_registry templates")
|
||||
|
||||
# Webhook subscriptions (v2.1.0) — created here (idempotent) so the full
|
||||
# schema exists without depending on the FastAPI lifespan startup.
|
||||
from app.services.webhook_outbound import init_webhook_tables
|
||||
|
||||
+21
-6
@@ -5,7 +5,7 @@ import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi import Depends, FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.exceptions import HTTPException as _StarHTTPException
|
||||
@@ -44,6 +44,7 @@ from app.routers.audit import router as audit_router
|
||||
from app.routers.automations import router as automations_router
|
||||
from app.routers.collaboration import router as collaboration_router
|
||||
from app.routers.emoji import router as emoji_router
|
||||
from app.routers.fd_templates import router as fd_templates_router
|
||||
from app.routers.gitea import router as gitea_router
|
||||
from app.routers.github_routes import router as github_router
|
||||
from app.routers.governance import router as governance_router
|
||||
@@ -62,6 +63,7 @@ from app.routers.web_clipper import router as web_clipper_router
|
||||
from app.routers.webauthn import router as webauthn_router
|
||||
from app.routers.wiki import router as wiki_router
|
||||
from app.routers.workers import router as workers_router
|
||||
from app.services import plugins as plugins_service
|
||||
from app.services.webhook_outbound import init_webhook_tables
|
||||
|
||||
logging.basicConfig(
|
||||
@@ -153,6 +155,10 @@ async def lifespan(_app: FastAPI):
|
||||
from app.services.reminders import reminder_scheduler
|
||||
reminder_task = _spawn("reminder_scheduler", reminder_scheduler)
|
||||
|
||||
# ── Templates récurrents (§11.2) : scan 60 s dédupliqué ──
|
||||
from app.services.fd_templates import template_recurrence_scheduler
|
||||
template_rec_task = _spawn("template_recurrence_scheduler", template_recurrence_scheduler)
|
||||
|
||||
# ── Semantic search (v6.9.0): incremental vector indexing ──
|
||||
from app.services.semantic_search import semantic_index_scheduler
|
||||
semantic_task = _spawn("semantic_index_scheduler", semantic_index_scheduler)
|
||||
@@ -171,7 +177,7 @@ async def lifespan(_app: FastAPI):
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_tasks = (scheduler_task, automation_task, backup_task, projects_task, trash_task, reminder_task, semantic_task, calendar_task)
|
||||
_tasks = (scheduler_task, automation_task, backup_task, projects_task, trash_task, reminder_task, template_rec_task, semantic_task, calendar_task)
|
||||
if webhook_task is not None:
|
||||
_tasks = _tasks + (webhook_task,)
|
||||
for task in _tasks:
|
||||
@@ -185,7 +191,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="7.27.0",
|
||||
version="7.71.5",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
@@ -224,6 +230,8 @@ app.include_router(webhooks.router)
|
||||
app.include_router(collections.router)
|
||||
app.include_router(my_tasks.router)
|
||||
app.include_router(workspace.router)
|
||||
# Partage public :-exempt de la dépendance d'authentification du router.
|
||||
app.include_router(workspace.public_router)
|
||||
app.include_router(library.router)
|
||||
app.include_router(admin.router)
|
||||
app.include_router(gitea_router)
|
||||
@@ -233,7 +241,9 @@ app.include_router(sharing.router)
|
||||
app.include_router(sidebar_config.router)
|
||||
app.include_router(export.router)
|
||||
app.include_router(notifications_router)
|
||||
app.include_router(automations_router)
|
||||
# Plugin « automations » OFF → chaque route de ce router renvoie 404
|
||||
app.include_router(automations_router,
|
||||
dependencies=[Depends(plugins_service.plugin_required("automations"))])
|
||||
app.include_router(collaboration_router)
|
||||
app.include_router(emoji_router)
|
||||
app.include_router(realtime_router)
|
||||
@@ -245,8 +255,11 @@ app.include_router(sync.router)
|
||||
app.include_router(imports_router)
|
||||
app.include_router(import_page_router)
|
||||
app.include_router(permissions_router)
|
||||
app.include_router(web_clipper_api_router)
|
||||
app.include_router(web_clipper_router)
|
||||
# Plugin « web-clipper » OFF → page /extensions + API clip refusées
|
||||
app.include_router(web_clipper_api_router,
|
||||
dependencies=[Depends(plugins_service.plugin_required("web-clipper"))])
|
||||
app.include_router(web_clipper_router,
|
||||
dependencies=[Depends(plugins_service.plugin_required("web-clipper"))])
|
||||
app.include_router(api_v2_router)
|
||||
app.include_router(api_v2_agent_router)
|
||||
app.include_router(sites_router)
|
||||
@@ -260,6 +273,8 @@ app.include_router(audit_router)
|
||||
app.include_router(governance_router)
|
||||
# v7.3.0 — teamspaces + verified wiki
|
||||
app.include_router(wiki_router)
|
||||
# Templates unifiés façon Notion (registre + gestionnaire + API interne)
|
||||
app.include_router(fd_templates_router)
|
||||
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from app.templating import CSRF_TOKEN
|
||||
|
||||
|
||||
class CSRFMiddleware(BaseHTTPMiddleware):
|
||||
"""Lightweight CSRF protection for state-changing requests.
|
||||
@@ -35,6 +37,9 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
||||
}
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# A43 : jeton expose aux templates (base.html hx-headers) — posé AVANT
|
||||
# call_next, comme le nonce CSP (meme mecanisme ContextVar).
|
||||
CSRF_TOKEN.set(request.cookies.get("csrf_token", ""))
|
||||
# Webhook receiver, OAuth callback, and internal API are exempt
|
||||
if any(request.url.path.startswith(p) for p in self.EXCLUDED_PATHS):
|
||||
return await call_next(request)
|
||||
|
||||
+51
-13
@@ -20,16 +20,54 @@ ALLOWED_EXTENSIONS: frozenset[str] = frozenset({
|
||||
# Images
|
||||
".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".ico",
|
||||
# Documents
|
||||
".pdf", ".md", ".markdown", ".txt", ".log", ".csv",
|
||||
# Code
|
||||
".py", ".js", ".jsx", ".ts", ".tsx", ".html", ".htm", ".xml", ".css",
|
||||
".json", ".yaml", ".yml", ".toml", ".sql", ".sh", ".bash", ".zsh",
|
||||
".ps1", ".rs", ".go", ".java", ".rb", ".php", ".c", ".cpp", ".h",
|
||||
".swift", ".kt", ".scala", ".r",
|
||||
".pdf", ".md", ".markdown", ".mkd", ".mdown", ".mdwn", ".rmd", ".qmd",
|
||||
".txt", ".log", ".csv", ".tsv", ".tab", ".nfo", ".asc",
|
||||
".tex", ".latex", ".sty", ".cls", ".dtx", ".bib", ".bbl",
|
||||
# Code — kept in sync with app/services/export.py::_TEXTUAL_EXTS
|
||||
".py", ".pyw", ".pyi", ".gyp", ".gypi",
|
||||
".js", ".mjs", ".cjs", ".jsx", ".ts", ".mts", ".cts", ".tsx",
|
||||
".html", ".htm", ".xhtml", ".xht", ".xml", ".xsd", ".xsl", ".xslt",
|
||||
".rss", ".atom", ".plist", ".wsf", ".axml",
|
||||
".vue", ".svelte", ".astro",
|
||||
".css", ".scss", ".sass", ".less",
|
||||
".json", ".json5", ".jsonc", ".geojson", ".webmanifest",
|
||||
".har", ".avsc",
|
||||
".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf", ".env",
|
||||
".properties", ".prop", ".inf", ".reg", ".url",
|
||||
".gitconfig", ".gitmodules", ".editorconfig",
|
||||
".sql", ".pgsql", ".psql", ".mysql", ".mssql",
|
||||
".sh", ".bash", ".zsh", ".ksh", ".mksh", ".yash", ".fish",
|
||||
".ps1", ".psm1", ".psd1", ".bat", ".cmd",
|
||||
".rb", ".gemspec", ".rake", ".rbw",
|
||||
".rs", ".go", ".java", ".jsp", ".kt", ".kts",
|
||||
".scala", ".sc", ".groovy", ".gvy", ".gradle",
|
||||
".c", ".cc", ".cpp", ".c++", ".cxx", ".hh", ".hxx", ".hcc", ".h", ".hpp",
|
||||
".cs", ".csx", ".cake", ".ino", ".pde",
|
||||
".php", ".phtml", ".swift", ".r", ".jl", ".lua", ".dart",
|
||||
".pl", ".pm", ".pod",
|
||||
".hs", ".lhs", ".ex", ".exs", ".eex", ".heex", ".leex",
|
||||
".erl", ".hrl", ".escript",
|
||||
".clj", ".cljs", ".cljc", ".edn",
|
||||
".ml", ".mli", ".fs", ".fsi", ".fsx", ".fsscript",
|
||||
".nim", ".nims", ".d", ".di",
|
||||
".m", ".mm", ".vb", ".vbs", ".bas", ".pas", ".pp", ".dpr",
|
||||
".vhd", ".vhdl", ".v", ".vh", ".sv", ".svh",
|
||||
".asm", ".s", ".nasm", ".inc",
|
||||
".ada", ".ads", ".adb",
|
||||
".f", ".for", ".f90", ".f95", ".f03", ".f08",
|
||||
".cob", ".cbl", ".cpy",
|
||||
".dockerfile", ".containerfile",
|
||||
".tf", ".tfvars", ".hcl",
|
||||
".cmake", ".makefile", ".mk", ".mak", ".ninja",
|
||||
".gql", ".graphql", ".graphqls", ".proto", ".thrift",
|
||||
".vim", ".vimrc", ".gvimrc", ".awk",
|
||||
".gcode", ".gco", ".nc", ".ngc",
|
||||
".http", ".rest", ".nginx", ".apache", ".htaccess", ".wat",
|
||||
".diff", ".patch", ".rej",
|
||||
".mod", ".sum", ".lock", ".text",
|
||||
".gitignore", ".gitattributes", ".gitkeep", ".dockerignore", ".npmignore",
|
||||
# Archives
|
||||
".zip", ".tar", ".gz", ".rar", ".7z",
|
||||
# Misc
|
||||
".env", ".cfg", ".conf", ".ini", ".dockerfile", ".makefile",
|
||||
})
|
||||
|
||||
MAX_UPLOAD_SIZE = 10 * 1024 * 1024 # 10 MB
|
||||
@@ -71,15 +109,15 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
|
||||
# requête — un XSS injecté dans une page ne tourne plus). Les 74 handlers
|
||||
# inline `onclick=` restent couverts par `script-src-attr`, explicitement
|
||||
# détaché de script-src (sinon le nonce les désactiverait aussi).
|
||||
# `unsafe-eval` reste : Alpine (x-data en string) et htmx (hx-vars) en ont
|
||||
# besoin → à retirer avec le build CSP d'Alpine (voir reste d'A20).
|
||||
# A20 TERMINÉ : `unsafe-eval` retiré — Alpine tourne en build CSP
|
||||
# (static/js/alpine.csp.min.js, 0 eval) ; htmx allowEval=false.
|
||||
# 15 surfaces en csp_preview vert + scan statique 0 (board/gitea/cards
|
||||
# = props propres, gitea down empêche un gate dédié).
|
||||
CSP_VALUE = (
|
||||
"default-src 'self'; "
|
||||
# A20 phase 2 : chart.js/leaflet vendorisés dans /static/js/vendor
|
||||
# (test_csp_no_cdn_and_vendor), plus aucun hôte CDN tiers.
|
||||
# `unsafe-eval` reste : Alpine (x-data) + htmx (hx-on/hx-vars) en
|
||||
# ont besoin → retrait avec le build CSP d'Alpine (reste d'A20).
|
||||
"script-src 'self' 'unsafe-eval' 'nonce-{nonce}'; "
|
||||
"script-src 'self' 'nonce-{nonce}'; "
|
||||
"script-src-attr 'unsafe-inline'; "
|
||||
# ponytail: aucun @font-face Google (grep négatif) → les deux
|
||||
# hôtes fonts étaient morts, supprimés.
|
||||
|
||||
@@ -1553,3 +1553,475 @@ def _migration_sso_enterprise_auth(conn: sqlite3.Connection) -> None:
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_sso_requests_created ON sso_requests(created_at)"
|
||||
)
|
||||
|
||||
|
||||
@register(30, "v7.47.0: My Tasks — mapping des propriétés de base de tâches")
|
||||
def _migration_my_tasks_mapping(conn: sqlite3.Connection) -> None:
|
||||
"""My Tasks façon Notion : une base ne diffuse ses tâches qu'après
|
||||
conversion explicite, et les trois propriétés requises sont **nommées**
|
||||
(pas devinées d'après leur type).
|
||||
|
||||
``collections.is_task`` existait déjà mais ne mémorisait rien : My Tasks
|
||||
devait deviner « la colonne personne = Assigné à » et n'avait aucun moyen
|
||||
de distinguer deux bases connectées. Trois colonnes nullable REFERENCES
|
||||
fixent le mapping ; suppression de la propriété → `SET NULL`, et la base
|
||||
redevient « non configurée » au lieu de pointer sur du vide.
|
||||
"""
|
||||
cols = columns(conn, "collections")
|
||||
for name, target in (
|
||||
("task_assignee_prop", "collection_properties"),
|
||||
("task_status_prop", "collection_properties"),
|
||||
("task_due_prop", "collection_properties"),
|
||||
):
|
||||
if name in cols:
|
||||
continue
|
||||
conn.execute(
|
||||
f"ALTER TABLE collections ADD COLUMN {name} INTEGER "
|
||||
f"REFERENCES {target}(id) ON DELETE SET NULL"
|
||||
)
|
||||
# Index de lecture : « les bases de tâches de cet utilisateur ».
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_collections_task "
|
||||
"ON collections(is_task, workspace_id)"
|
||||
)
|
||||
|
||||
|
||||
@register(35, "v7.58.0: registre des plugins (on/off à effet réel)")
|
||||
def _migration_plugins(conn: sqlite3.Connection) -> None:
|
||||
"""Catalogue de l'instance : 3 modules câblés (routes/UI/outils réels)."""
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS plugins (
|
||||
slug TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
enabled INTEGER NOT NULL DEFAULT 1
|
||||
)"""
|
||||
)
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO plugins (slug, name, description, enabled) VALUES (?,?,?,1)",
|
||||
[
|
||||
("web-tools", "Outils web de l'agent",
|
||||
"Retire web_search et fetch_url du registre d'outils de l'agent"),
|
||||
("web-clipper", "Web Clipper",
|
||||
"Coupe la page /extensions et l'API /api/v2/web-clipper/*"),
|
||||
("automations", "Automations",
|
||||
"Coupe /workspace/automations* et le scheduler en arrière-plan"),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@register(34, "v7.57.0: connecteurs — kind/auth/tools_json (Discord, Telegram, MCP)")
|
||||
def _migration_connector_kinds(conn: sqlite3.Connection) -> None:
|
||||
"""Discord (auth « Bot »), Telegram (jeton dans l'URL via `{secret}`) et
|
||||
serveurs MCP (kind='mcp', cache d'outils) réutilisent la même table."""
|
||||
cols = columns(conn, "agent_connectors")
|
||||
if "kind" not in cols:
|
||||
conn.execute("ALTER TABLE agent_connectors ADD COLUMN kind TEXT NOT NULL DEFAULT 'custom'")
|
||||
if "auth" not in cols:
|
||||
conn.execute("ALTER TABLE agent_connectors ADD COLUMN auth TEXT NOT NULL DEFAULT 'bearer'")
|
||||
if "tools_json" not in cols:
|
||||
conn.execute("ALTER TABLE agent_connectors ADD COLUMN tools_json TEXT NOT NULL DEFAULT '[]'")
|
||||
|
||||
|
||||
@register(33, "v7.56.0: tokens OAuth des connecteurs (Google / M365)")
|
||||
def _migration_connector_tokens(conn: sqlite3.Connection) -> None:
|
||||
"""Tokens OAuth par (kind, utilisateur), chiffrés Fernet côté service."""
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS connector_tokens (
|
||||
kind TEXT NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
tokens_enc TEXT NOT NULL DEFAULT '',
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (kind, user_id)
|
||||
)"""
|
||||
)
|
||||
|
||||
|
||||
@register(32, "v7.55.0: connecteurs de l'agent (socle)")
|
||||
def _migration_agent_connectors(conn: sqlite3.Connection) -> None:
|
||||
"""Catalogue de connecteurs : 3 natifs servis à la volée + personnels ici.
|
||||
|
||||
Colonne ``url`` plate (pas de ``config_json``) : les scopes/OAuth des
|
||||
phases 6-7 ajouteront leur propre colonne le moment venu.
|
||||
"""
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS agent_connectors (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
secret_encrypted TEXT NOT NULL DEFAULT '',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
status TEXT NOT NULL DEFAULT 'unknown',
|
||||
detail TEXT NOT NULL DEFAULT '',
|
||||
created_by INTEGER
|
||||
)"""
|
||||
)
|
||||
|
||||
|
||||
@register(31, "v7.54.0: agent memory (mémoire de l'agent)")
|
||||
def _migration_agent_memory(conn: sqlite3.Connection) -> None:
|
||||
"""Toggle par conversation + table des résumés mémorisés.
|
||||
|
||||
Une ligne résumé par conversation (upsert) — voir `app/services/agent_memory.py`.
|
||||
"""
|
||||
if "memory_enabled" not in columns(conn, "agent_conversations"):
|
||||
conn.execute(
|
||||
"ALTER TABLE agent_conversations "
|
||||
"ADD COLUMN memory_enabled INTEGER NOT NULL DEFAULT 1"
|
||||
)
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS agent_memory (
|
||||
conversation_id INTEGER PRIMARY KEY
|
||||
REFERENCES agent_conversations(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL DEFAULT 'summary',
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)"""
|
||||
)
|
||||
|
||||
|
||||
@register(37, "v7.64.0: réactions emoji sur un texte sélectionné")
|
||||
def _migration_text_reactions(conn: sqlite3.Connection) -> None:
|
||||
"""Réactions emoji ancrées sur une plage de texte (façon Notion) : miroir des
|
||||
ancres de commentaires (block_id + offsets dans le texte rendu). Une ligne par
|
||||
(plage, emoji, utilisateur) → le POST agit comme un toggle et le GET regroupe
|
||||
par plage pour afficher la pastille d'émojis."""
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS text_reactions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||
block_id TEXT NOT NULL,
|
||||
anchor_start INTEGER NOT NULL,
|
||||
anchor_end INTEGER NOT NULL,
|
||||
emoji TEXT NOT NULL,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(page_id, block_id, anchor_start, anchor_end, emoji, user_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_text_reactions_page ON text_reactions(page_id)"
|
||||
)
|
||||
|
||||
|
||||
@register(36, "v7.59.1: mistral — modèles par défaut hors plan basique")
|
||||
def _migration_mistral_default_model(conn: sqlite3.Connection) -> None:
|
||||
"""mistral-large-latest / pixtral-large-latest renvoient 403
|
||||
« tier_not_allowed » sur les plans d'abonnement basiques : la clé est
|
||||
valide mais le test de connexion et l'agent échouaient. Reset du
|
||||
default_model stocké → vide = nouveau défaut du provider (small-latest,
|
||||
servi par tous les plans).
|
||||
"""
|
||||
blocked = ("mistral-large-latest", "pixtral-large-latest")
|
||||
conn.execute(
|
||||
"UPDATE user_llm_keys SET default_model='' "
|
||||
"WHERE provider='mistral' AND default_model IN (?,?)",
|
||||
blocked,
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE llm_config SET model='mistral-small-latest' "
|
||||
"WHERE provider='mistral' AND model IN (?,?)",
|
||||
blocked,
|
||||
)
|
||||
|
||||
|
||||
@register(38, "v7.1.1: meeting block Notion — états, segments, consentement, partage")
|
||||
def _migration_meeting_notion_block(conn: sqlite3.Connection) -> None:
|
||||
"""v7.1.1 — refonte Meetings façon Notion (§3A/3B du doc d'architecture).
|
||||
|
||||
``meeting_transcripts`` gagne la machine à états du bloc
|
||||
(idle/recording/paused/processing/done/failed), les segments groupés par
|
||||
source, la photo d'instructions, le journal de consentement résumé, les
|
||||
étapes Thinking persistées, le résumé structuré + titre généré + classe de
|
||||
complétude. Deux tables append-only : consentement et diffusions.
|
||||
Règle d'unicité : une occurrence calendrier = au plus une note.
|
||||
"""
|
||||
cols = columns(conn, "meeting_transcripts")
|
||||
for ddl in (
|
||||
"ALTER TABLE meeting_transcripts ADD COLUMN status TEXT NOT NULL DEFAULT 'idle'",
|
||||
"ALTER TABLE meeting_transcripts ADD COLUMN mode TEXT NOT NULL DEFAULT 'mic_only'",
|
||||
"ALTER TABLE meeting_transcripts ADD COLUMN channels_json TEXT NOT NULL DEFAULT '[]'",
|
||||
"ALTER TABLE meeting_transcripts ADD COLUMN instruction_id TEXT NOT NULL DEFAULT 'auto'",
|
||||
"ALTER TABLE meeting_transcripts ADD COLUMN instruction_snapshot TEXT NOT NULL DEFAULT ''",
|
||||
"ALTER TABLE meeting_transcripts ADD COLUMN consent_json TEXT NOT NULL DEFAULT ''",
|
||||
"ALTER TABLE meeting_transcripts ADD COLUMN segments_json TEXT NOT NULL DEFAULT '[]'",
|
||||
"ALTER TABLE meeting_transcripts ADD COLUMN processing_steps_json TEXT NOT NULL DEFAULT '[]'",
|
||||
"ALTER TABLE meeting_transcripts ADD COLUMN summary_json TEXT NOT NULL DEFAULT ''",
|
||||
"ALTER TABLE meeting_transcripts ADD COLUMN generated_title TEXT NOT NULL DEFAULT ''",
|
||||
"ALTER TABLE meeting_transcripts ADD COLUMN title_source TEXT NOT NULL DEFAULT 'user'",
|
||||
"ALTER TABLE meeting_transcripts ADD COLUMN completeness_class TEXT NOT NULL DEFAULT ''",
|
||||
"ALTER TABLE meeting_transcripts ADD COLUMN quality_score REAL",
|
||||
"ALTER TABLE meeting_transcripts ADD COLUMN duration_ms INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE meeting_transcripts ADD COLUMN paused_ms INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE meeting_transcripts ADD COLUMN started_at TIMESTAMP",
|
||||
"ALTER TABLE meeting_transcripts ADD COLUMN stopped_at TIMESTAMP",
|
||||
"ALTER TABLE meeting_transcripts ADD COLUMN event_occurrence_id TEXT NOT NULL DEFAULT ''",
|
||||
"ALTER TABLE meeting_transcripts ADD COLUMN notes_text TEXT NOT NULL DEFAULT ''",
|
||||
"ALTER TABLE meeting_transcripts ADD COLUMN banner_dismissed INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE meeting_transcripts ADD COLUMN share_bar_dismissed INTEGER NOT NULL DEFAULT 0",
|
||||
):
|
||||
name = ddl.split("ADD COLUMN")[1].strip().split()[0]
|
||||
if name not in cols:
|
||||
conn.execute(ddl)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS meeting_consent_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
transcript_id INTEGER NOT NULL REFERENCES meeting_transcripts(id) ON DELETE CASCADE,
|
||||
method TEXT NOT NULL DEFAULT 'start_attestation',
|
||||
message_ref TEXT NOT NULL DEFAULT '',
|
||||
attested_by INTEGER REFERENCES users(id),
|
||||
participants_json TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS meeting_distributions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
transcript_id INTEGER NOT NULL REFERENCES meeting_transcripts(id) ON DELETE CASCADE,
|
||||
channel TEXT NOT NULL DEFAULT 'copy_link',
|
||||
actor_id INTEGER REFERENCES users(id),
|
||||
recipients_json TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'created',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_meeting_consent_tr ON meeting_consent_log(transcript_id)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_meeting_distr_tr ON meeting_distributions(transcript_id)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS ux_meeting_one_per_occurrence "
|
||||
"ON meeting_transcripts(event_occurrence_id) "
|
||||
"WHERE event_occurrence_id <> ''"
|
||||
)
|
||||
|
||||
|
||||
# ── Vues Notion §10 : migrations 44 à 47 (après 39-43 réservées Agents&Skills) ──
|
||||
|
||||
@register(44, "vues-notion: view_config_v2 (config_version + index view_type)")
|
||||
def _migration_view_config_v2(conn: sqlite3.Connection) -> None:
|
||||
"""Migration 44 — versionnage des config_json de vues (phase 1, Chart)."""
|
||||
cols = columns(conn, "collection_views")
|
||||
if "config_version" not in cols:
|
||||
conn.execute(
|
||||
"ALTER TABLE collection_views ADD COLUMN config_version INTEGER NOT NULL DEFAULT 1"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_collection_views_type "
|
||||
"ON collection_views(collection_id, view_type)"
|
||||
)
|
||||
|
||||
|
||||
@register(45, "vues-notion: place_geocode_cache")
|
||||
def _migration_place_geocode_cache(conn: sqlite3.Connection) -> None:
|
||||
"""Migration 45 — cache partage du Geocoding Service (phase 3, Map)."""
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS geocode_cache (
|
||||
query_hash TEXT PRIMARY KEY,
|
||||
provider TEXT NOT NULL DEFAULT 'nominatim',
|
||||
query_text TEXT NOT NULL DEFAULT '',
|
||||
result_json TEXT NOT NULL DEFAULT '{}',
|
||||
lat REAL,
|
||||
lng REAL,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
hit_count INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_geocode_cache_created ON geocode_cache(created_at)"
|
||||
)
|
||||
|
||||
|
||||
@register(46, "vues-notion: dashboard_widgets")
|
||||
def _migration_dashboard_widgets(conn: sqlite3.Connection) -> None:
|
||||
"""Migration 46 — widgets de la vue Dashboard (phase 4)."""
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS dashboard_widgets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
view_id INTEGER NOT NULL REFERENCES collection_views(id) ON DELETE CASCADE,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
row_index INTEGER NOT NULL DEFAULT 0,
|
||||
col_span INTEGER NOT NULL DEFAULT 1,
|
||||
height_units INTEGER NOT NULL DEFAULT 2,
|
||||
source_collection_id INTEGER REFERENCES collections(id) ON DELETE SET NULL,
|
||||
source_view_id INTEGER REFERENCES collection_views(id) ON DELETE SET NULL,
|
||||
inline_config_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_dashboard_widgets_view "
|
||||
"ON dashboard_widgets(view_id, row_index, position)"
|
||||
)
|
||||
|
||||
|
||||
@register(47, "vues-notion: form_questions_v2")
|
||||
def _migration_form_questions_v2(conn: sqlite3.Connection) -> None:
|
||||
"""Migration 47 — questions du Form builder + repondant (phase 5)."""
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS form_questions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
view_id INTEGER NOT NULL REFERENCES collection_views(id) ON DELETE CASCADE,
|
||||
property_name TEXT NOT NULL,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
label_override TEXT,
|
||||
sync_label INTEGER NOT NULL DEFAULT 1,
|
||||
help_text TEXT NOT NULL DEFAULT '',
|
||||
required INTEGER NOT NULL DEFAULT 0,
|
||||
widget TEXT NOT NULL DEFAULT 'auto',
|
||||
max_selections INTEGER,
|
||||
conditional_json TEXT,
|
||||
UNIQUE(view_id, property_name)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_form_questions_view "
|
||||
"ON form_questions(view_id, position)"
|
||||
)
|
||||
for table, ddl, col in (
|
||||
("form_responses", "ALTER TABLE form_responses ADD COLUMN view_id INTEGER", "view_id"),
|
||||
("form_responses", "ALTER TABLE form_responses ADD COLUMN respondent_user_id INTEGER REFERENCES users(id)", "respondent_user_id"),
|
||||
("form_responses", "ALTER TABLE form_responses ADD COLUMN anonymous INTEGER NOT NULL DEFAULT 0", "anonymous"),
|
||||
):
|
||||
try:
|
||||
existing = columns(conn, table)
|
||||
except Exception:
|
||||
continue
|
||||
if col not in existing:
|
||||
try:
|
||||
conn.execute(ddl)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ── Templates Notion §9 : migrations 48 à 49 (registre unifié + journal) ──
|
||||
|
||||
@register(48, "templates-notion: registre unifié `templates`")
|
||||
def _migration_templates_registry(conn: sqlite3.Connection) -> None:
|
||||
"""Migration 48 — registre unifié des templates (phase 1).
|
||||
|
||||
Catalogue les trois silos legacy (``database_templates``,
|
||||
``page_templates``, ``page_global_templates``) SANS les détruire :
|
||||
colonnes ``source_kind``/``source_id`` + backfill idempotent côté
|
||||
service (``TemplateService.backfill_registry``), rejouable au boot.
|
||||
"""
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS templates (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id INTEGER NOT NULL DEFAULT 1,
|
||||
teamspace_id INTEGER,
|
||||
kind TEXT NOT NULL DEFAULT 'page'
|
||||
CHECK (kind IN ('page','row','database','blocks')),
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
icon TEXT NOT NULL DEFAULT '📄',
|
||||
cover_url TEXT NOT NULL DEFAULT '',
|
||||
category TEXT NOT NULL DEFAULT '',
|
||||
scope TEXT NOT NULL DEFAULT 'personal'
|
||||
CHECK (scope IN ('personal','workspace','teamspace','system')),
|
||||
owner_id INTEGER,
|
||||
target_collection_id INTEGER,
|
||||
content_page_id INTEGER,
|
||||
source_kind TEXT NOT NULL DEFAULT 'native'
|
||||
CHECK (source_kind IN ('native','legacy_database','legacy_page','legacy_global')),
|
||||
source_id TEXT NOT NULL DEFAULT '',
|
||||
manifest_json TEXT NOT NULL DEFAULT '{}',
|
||||
variables_json TEXT NOT NULL DEFAULT '[]',
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
is_system INTEGER NOT NULL DEFAULT 0,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_by INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT NOT NULL DEFAULT '',
|
||||
archived_at TEXT,
|
||||
deleted_at TEXT,
|
||||
sync_version INTEGER NOT NULL DEFAULT 1
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_templates_ws_kind "
|
||||
"ON templates(workspace_id, kind)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_templates_target "
|
||||
"ON templates(target_collection_id)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_templates_source "
|
||||
"ON templates(source_kind, source_id)"
|
||||
)
|
||||
# Un seul défaut actif par base (les lignes soft-deleted/archivées ne comptent pas).
|
||||
conn.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS uq_templates_default "
|
||||
"ON templates(target_collection_id) "
|
||||
"WHERE is_default=1 AND deleted_at IS NULL AND archived_at IS NULL"
|
||||
)
|
||||
|
||||
|
||||
@register(49, "templates-notion: récurrences + journal `template_runs`")
|
||||
def _migration_template_runs(conn: sqlite3.Connection) -> None:
|
||||
"""Migration 49 — récurrences des templates de ligne + journal des
|
||||
instanciations (compteurs du gestionnaire, dédup scheduler, audit)."""
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS template_recurrences (
|
||||
template_id TEXT PRIMARY KEY REFERENCES templates(id) ON DELETE CASCADE,
|
||||
rrule TEXT NOT NULL DEFAULT '',
|
||||
timezone TEXT NOT NULL DEFAULT '',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
next_run_at TEXT,
|
||||
last_run_at TEXT,
|
||||
end_count INTEGER,
|
||||
updated_by INTEGER,
|
||||
updated_at TEXT NOT NULL DEFAULT ''
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS template_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
template_id TEXT NOT NULL REFERENCES templates(id) ON DELETE CASCADE,
|
||||
run_source TEXT NOT NULL DEFAULT 'ui'
|
||||
CHECK (run_source IN ('ui','api','agent','automation','recurrence')),
|
||||
actor_id INTEGER,
|
||||
occurrence_key TEXT,
|
||||
created_type TEXT NOT NULL DEFAULT '',
|
||||
created_id TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'ok'
|
||||
CHECK (status IN ('ok','error','undone')),
|
||||
warnings_json TEXT NOT NULL DEFAULT '[]',
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
idempotency_key TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT ''
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS uq_template_runs_occurrence "
|
||||
"ON template_runs(occurrence_key) WHERE occurrence_key IS NOT NULL"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS uq_template_runs_idem "
|
||||
"ON template_runs(idempotency_key) WHERE idempotency_key IS NOT NULL"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_template_runs_tpl "
|
||||
"ON template_runs(template_id, created_at)"
|
||||
)
|
||||
|
||||
@@ -104,6 +104,10 @@ def update_user(user_id: int, request: Request, _admin=Depends(admin_required),
|
||||
def delete_user(user_id: int, _admin=Depends(admin_required)):
|
||||
"""Delete a user and cascade their data."""
|
||||
from app.db import get_conn
|
||||
from app.services.collection_lifecycle import (
|
||||
delete_collections,
|
||||
workspace_collection_ids,
|
||||
)
|
||||
with get_conn() as conn:
|
||||
user = conn.execute("SELECT id FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
if not user:
|
||||
@@ -121,6 +125,9 @@ def delete_user(user_id: int, _admin=Depends(admin_required)):
|
||||
# Delete workspaces owned by this user
|
||||
ws_rows = conn.execute("SELECT id FROM workspaces WHERE owner_id=?", (user_id,)).fetchall()
|
||||
for ws in ws_rows:
|
||||
# Les bases du workspace tombent avec lui (sinon : collections
|
||||
# orphelines listées par /db et My Tasks).
|
||||
delete_collections(conn, workspace_collection_ids(conn, ws["id"]))
|
||||
conn.execute("DELETE FROM pages WHERE workspace_id=?", (ws["id"],))
|
||||
conn.execute("DELETE FROM workspace_members WHERE workspace_id=?", (ws["id"],))
|
||||
conn.execute("DELETE FROM workspaces WHERE owner_id=?", (user_id,))
|
||||
|
||||
+237
-6
@@ -7,15 +7,17 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import UTC
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from fastapi.responses import JSONResponse, RedirectResponse, StreamingResponse
|
||||
|
||||
from app.auth.session import get_current_user
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
from app.services import skill_gallery
|
||||
from app.services import connectors, oauth_connectors, plugins, skill_gallery
|
||||
from app.services.agent_engine import AgentEngine, undo_action
|
||||
from app.services.llm_client import PROVIDER_MODELS, PROVIDERS, LLMClient
|
||||
from app.services.llm_config import (
|
||||
@@ -199,15 +201,17 @@ def create_conversation(request: Request, body: dict = Body(default={})):
|
||||
ws = _workspace_id(request)
|
||||
with get_conn() as conn:
|
||||
agent = _default_agent(conn, user_id)
|
||||
mem_default = 1 if settings.agent_memory_default else 0
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO agent_conversations (agent_id, user_id, title, context_json, provider, model)
|
||||
VALUES (?,?,?,?,?,?)""",
|
||||
"""INSERT INTO agent_conversations (agent_id, user_id, title, context_json, provider, model, memory_enabled)
|
||||
VALUES (?,?,?,?,?,?,?)""",
|
||||
(agent["id"], user_id, body.get("title", "New conversation"),
|
||||
json.dumps({"workspace_id": ws}),
|
||||
body.get("provider", ""), body.get("model", "")),
|
||||
body.get("provider", ""), body.get("model", ""), mem_default),
|
||||
)
|
||||
conn.commit()
|
||||
return {"id": cur.lastrowid, "title": body.get("title", "New conversation"), "status": "created"}
|
||||
return {"id": cur.lastrowid, "title": body.get("title", "New conversation"),
|
||||
"status": "created", "memory_enabled": mem_default}
|
||||
|
||||
|
||||
@router.get("/conversations/{conversation_id}")
|
||||
@@ -249,6 +253,10 @@ def patch_conversation(request: Request, conversation_id: int, body: dict = Body
|
||||
if body.get(col) is not None:
|
||||
sets.append(f"{col}=?")
|
||||
params.append(str(body[col]))
|
||||
# v7.54.0 — toggle « Mémoire » de la conversation (0/1).
|
||||
if body.get("memory_enabled") is not None:
|
||||
sets.append("memory_enabled=?")
|
||||
params.append(1 if body["memory_enabled"] else 0)
|
||||
params.append(conversation_id)
|
||||
conn.execute(f"UPDATE agent_conversations SET {', '.join(sets)} WHERE id=?", params)
|
||||
conn.commit()
|
||||
@@ -498,6 +506,43 @@ def create_skill(request: Request, body: dict = Body(default={})):
|
||||
return {"id": cur.lastrowid, "name": name, "status": "created"}
|
||||
|
||||
|
||||
@router.patch("/skills/{skill_id}")
|
||||
def update_skill(request: Request, skill_id: int, body: dict = Body(default={})):
|
||||
"""Édition d'un skill enregistré (v7.52.0 — « Gérer les compétences »).
|
||||
|
||||
Seuls les champs présents dans ``body`` sont mis à jour ; la liste des
|
||||
colonnes est figée ici (jamais interpolée depuis la requête).
|
||||
"""
|
||||
_current_user_id(request)
|
||||
fields: dict = {}
|
||||
if "name" in body:
|
||||
name = str(body.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name est requis")
|
||||
fields["name"] = name
|
||||
if "description" in body:
|
||||
fields["description"] = str(body.get("description") or "")
|
||||
if "prompt_template" in body:
|
||||
fields["prompt_template"] = str(body.get("prompt_template") or "")
|
||||
if "allowed_tools" in body:
|
||||
fields["allowed_tools_json"] = json.dumps(body.get("allowed_tools") or [])
|
||||
if not fields:
|
||||
raise HTTPException(status_code=400, detail="Aucun champ à mettre à jour")
|
||||
cols = ", ".join(f"{k}=?" for k in fields)
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM agent_skills WHERE id=?", (skill_id,)).fetchone():
|
||||
raise HTTPException(status_code=404, detail="Skill introuvable")
|
||||
try:
|
||||
conn.execute(
|
||||
f"UPDATE agent_skills SET {cols} WHERE id=?",
|
||||
(*fields.values(), skill_id),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception as exc: # noqa: BLE001 — contrainte UNIQUE(name)
|
||||
raise HTTPException(status_code=409, detail=f"Skill existe déjà: {exc}") from exc
|
||||
return {"id": skill_id, "status": "updated", "fields": sorted(fields)}
|
||||
|
||||
|
||||
@router.post("/skills/{skill_id}/apply")
|
||||
def apply_skill(request: Request, skill_id: int):
|
||||
"""Create a conversation pre-loaded with a skill, ready to run."""
|
||||
@@ -590,6 +635,192 @@ def delete_skill(request: Request, skill_id: int):
|
||||
return {"id": skill_id, "status": "deleted"}
|
||||
|
||||
|
||||
# ── Connecteurs (v7.55.0) ──
|
||||
# ponytail : catalogue partagé au même titre que les skills (`list_skills`
|
||||
# n'a pas non plus de filtre par créateur) — la clé n'est jamais renvoyée.
|
||||
|
||||
|
||||
@router.get("/connectors")
|
||||
def list_connectors_route(request: Request):
|
||||
"""Catalogue : natifs (statut dérivé de la config) + personnels."""
|
||||
user_id = _current_user_id(request)
|
||||
return {"connectors": connectors.list_connectors(user_id)}
|
||||
|
||||
|
||||
@router.get("/plugins")
|
||||
def list_plugins_route(request: Request):
|
||||
"""Catalogue des plugins de l'instance (menu + → « Add plugins »)."""
|
||||
_current_user_id(request)
|
||||
return {"plugins": plugins.list_plugins()}
|
||||
|
||||
|
||||
@router.patch("/plugins/{slug}")
|
||||
def set_plugin_route(request: Request, slug: str, body: dict = Body(default={})):
|
||||
"""Bascule un plugin : l'effet est réel (routes/UI/outils), pas un drapeau."""
|
||||
_current_user_id(request)
|
||||
try:
|
||||
return plugins.set_enabled(slug, bool(body.get("enabled")))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/connectors")
|
||||
def create_connectors_route(request: Request, body: dict = Body(default={})):
|
||||
"""Ajoute un connecteur personnalisé — l'URL est validée (garde SSRF)."""
|
||||
_current_user_id(request)
|
||||
try:
|
||||
return connectors.create_connector(
|
||||
body.get("name") or "", body.get("url") or "", str(body.get("secret") or ""),
|
||||
kind=str(body.get("kind") or "custom"),
|
||||
auth=str(body.get("auth") or "bearer"),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.patch("/connectors/{connector_id}")
|
||||
def update_connectors_route(request: Request, connector_id: int, body: dict = Body(default={})):
|
||||
_current_user_id(request)
|
||||
try:
|
||||
row = connectors.update_connector(
|
||||
connector_id,
|
||||
name=body.get("name"),
|
||||
enabled=body.get("enabled"),
|
||||
secret=body.get("secret"),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Connecteur introuvable")
|
||||
return row
|
||||
|
||||
|
||||
@router.delete("/connectors/{connector_id}")
|
||||
def delete_connectors_route(request: Request, connector_id: int):
|
||||
_current_user_id(request)
|
||||
if not connectors.delete_connector(connector_id):
|
||||
raise HTTPException(status_code=404, detail="Connecteur introuvable")
|
||||
return {"id": connector_id, "status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/connectors/probe")
|
||||
async def probe_connectors_route(request: Request, body: dict = Body(default={})):
|
||||
"""Teste un connecteur (id, nom ou kind natif) et persiste le résultat."""
|
||||
target = str(body.get("connector") or "").strip()
|
||||
if not target:
|
||||
raise HTTPException(status_code=400, detail="connector est requis")
|
||||
user_id = _current_user_id(request)
|
||||
try:
|
||||
return await connectors.probe(target, user_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
# ── Connecteurs OAuth : Google / Microsoft 365 (v7.56.0) ──
|
||||
# Flow : POST authorize (cookies state/verifier/next) → fournisseur →
|
||||
# GET callback (échange du code, tokens chiffrés) → redirection vers la page
|
||||
# d'origine. Le GET est en SAFE_METHODS : pas de CSRF (comme /auth/callback).
|
||||
|
||||
|
||||
def _oauth_kind(kind: str) -> str:
|
||||
if kind not in oauth_connectors.OAUTH_KINDS:
|
||||
raise HTTPException(status_code=404, detail="Connecteur OAuth inconnu")
|
||||
return kind
|
||||
|
||||
|
||||
def _oauth_next(request: Request, kind: str, key: str, default: str = "/") -> str:
|
||||
"""Chemin de retour same-origin (cookie `key` pour le callback)."""
|
||||
raw = request.cookies.get(key, "") or default
|
||||
path = urlparse(raw).path if raw.startswith("http") else raw
|
||||
if not path.startswith("/") or path.startswith("//"):
|
||||
return default
|
||||
return path
|
||||
|
||||
|
||||
@router.get("/connectors/oauth/{kind}/status")
|
||||
def connector_oauth_status(request: Request, kind: str):
|
||||
"""État du connecteur OAuth pour l'utilisateur courant."""
|
||||
_oauth_kind(kind)
|
||||
user_id = _current_user_id(request)
|
||||
try:
|
||||
oauth_connectors._client(kind)
|
||||
configured, detail = True, ""
|
||||
except ValueError as exc:
|
||||
configured, detail = False, str(exc)
|
||||
tok = oauth_connectors.tokens(kind, user_id)
|
||||
return {
|
||||
"kind": kind, "configured": configured, "configured_detail": detail,
|
||||
"connected": bool(tok.get("access_token")), "scope": tok.get("scope", ""),
|
||||
"expires_at": tok.get("expires_at", 0),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/connectors/oauth/{kind}/authorize")
|
||||
def connector_oauth_authorize(request: Request, kind: str):
|
||||
"""URL d'autorisation (PKCE) + cookies d'état éphémères (10 min)."""
|
||||
_oauth_kind(kind)
|
||||
_current_user_id(request)
|
||||
try:
|
||||
flow = oauth_connectors.begin(kind)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
referer = request.headers.get("referer") or ""
|
||||
next_path = urlparse(referer).path if referer else "/"
|
||||
if not next_path.startswith("/") or next_path.startswith("//"):
|
||||
next_path = "/"
|
||||
secure = request.url.scheme == "https"
|
||||
resp = JSONResponse({"url": flow["url"], "kind": kind})
|
||||
for key, value in (
|
||||
(f"fd_oauth_state_{kind}", flow["state"]),
|
||||
(f"fd_oauth_verifier_{kind}", flow["verifier"]),
|
||||
(f"fd_oauth_next_{kind}", next_path),
|
||||
):
|
||||
resp.set_cookie(key, value, max_age=600, httponly=True, samesite="lax", secure=secure)
|
||||
return resp
|
||||
|
||||
|
||||
@router.get("/connectors/oauth/{kind}/callback")
|
||||
async def connector_oauth_callback(
|
||||
request: Request, kind: str, code: str = "", state: str = "", error: str = ""
|
||||
):
|
||||
"""Reçoit le code du fournisseur, échange, stocke, renvoie sur la page."""
|
||||
_oauth_kind(kind)
|
||||
next_path = _oauth_next(request, kind, f"fd_oauth_next_{kind}")
|
||||
expected = request.cookies.get(f"fd_oauth_state_{kind}", "")
|
||||
verifier = request.cookies.get(f"fd_oauth_verifier_{kind}", "")
|
||||
|
||||
def _back(flag: str) -> RedirectResponse:
|
||||
sep = "&" if "?" in next_path else "?"
|
||||
resp = RedirectResponse(f"{next_path}{sep}{flag}", status_code=302)
|
||||
for key in (f"fd_oauth_state_{kind}", f"fd_oauth_verifier_{kind}",
|
||||
f"fd_oauth_next_{kind}"):
|
||||
resp.delete_cookie(key, samesite="lax")
|
||||
return resp
|
||||
|
||||
if error:
|
||||
return _back("oauth_error=" + error[:80])
|
||||
if not code or not expected or not secrets.compare_digest(expected, state):
|
||||
return _back("oauth_error=state")
|
||||
user = get_current_user(request)
|
||||
if not user or not user.get("id"):
|
||||
return _back("oauth_error=session")
|
||||
try:
|
||||
tokens = await oauth_connectors.complete(kind, code, verifier)
|
||||
except ValueError as exc:
|
||||
return _back("oauth_error=" + str(exc)[:80].replace(" ", "+"))
|
||||
oauth_connectors.save(kind, int(user["id"]), tokens)
|
||||
logger.info("Connector OAuth connected: %s (user #%s)", kind, user["id"])
|
||||
return _back("oauth=connected")
|
||||
|
||||
|
||||
@router.post("/connectors/oauth/{kind}/disconnect")
|
||||
def connector_oauth_disconnect(request: Request, kind: str):
|
||||
_oauth_kind(kind)
|
||||
user_id = _current_user_id(request)
|
||||
removed = oauth_connectors.clear(kind, user_id)
|
||||
return {"kind": kind, "status": "disconnected" if removed else "not-connected"}
|
||||
|
||||
|
||||
# ── Mentions (commande @ / +) & feedback (boutons 👍 / 👎) ──
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
"""FlowDeck — Public API v2.
|
||||
|
||||
Découpe A28 : l'ancien `api_v2.py` (2 110 lignes, 115 routes) est devenu
|
||||
ce package — un module par concern (`_common` = helpers), `router`
|
||||
agrégé ci-dessous avec le même prefix/tags qu'avant → 0 changement
|
||||
d'URL, 0 changement d'operation_id.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from . import (
|
||||
admin,
|
||||
collections,
|
||||
engagement,
|
||||
identity,
|
||||
planning,
|
||||
projects,
|
||||
properties,
|
||||
sharing,
|
||||
templates_io,
|
||||
views,
|
||||
webhooks,
|
||||
workspaces,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/v2")
|
||||
for _mod in (
|
||||
identity,
|
||||
workspaces,
|
||||
collections,
|
||||
properties,
|
||||
views,
|
||||
engagement,
|
||||
sharing,
|
||||
planning,
|
||||
templates_io,
|
||||
projects,
|
||||
admin,
|
||||
webhooks,
|
||||
):
|
||||
router.include_router(_mod.router)
|
||||
@@ -0,0 +1,36 @@
|
||||
"""FlowDeck — API v2 : helpers partagés des modules de routes (A28)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
|
||||
audit_log,
|
||||
check_idempotency,
|
||||
check_v2_rate_limit,
|
||||
get_bearer_user,
|
||||
has_scope,
|
||||
paginate_headers,
|
||||
parse_pagination,
|
||||
require_scope,
|
||||
row_to_dict,
|
||||
store_idempotency,
|
||||
to_iso8601,
|
||||
validate_scopes_input,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def _hash(token: str) -> str:
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
def _v2_rate_check(request: Request, user: dict) -> None:
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
th = user.get("_token_hash")
|
||||
if not check_v2_rate_limit(th, ip):
|
||||
raise HTTPException(status_code=429, detail="Rate limit exceeded: 300 req/min per token")
|
||||
|
||||
# ── Tokens ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""FlowDeck — Public API v2 : admin.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Header, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
|
||||
audit_log,
|
||||
check_idempotency,
|
||||
check_v2_rate_limit,
|
||||
get_bearer_user,
|
||||
has_scope,
|
||||
paginate_headers,
|
||||
parse_pagination,
|
||||
require_scope,
|
||||
row_to_dict,
|
||||
store_idempotency,
|
||||
to_iso8601,
|
||||
validate_scopes_input,
|
||||
)
|
||||
|
||||
from ._common import _v2_rate_check
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["api-v2"])
|
||||
|
||||
|
||||
|
||||
@router.patch("/admin/users/{uid}")
|
||||
def admin_patch_user_v2(uid: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not user.get("is_admin") and not has_scope(user.get("_token_scopes"), "admin"):
|
||||
raise HTTPException(403, "Admin scope required")
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM users WHERE id=?", (uid,)).fetchone():
|
||||
raise HTTPException(404, "User not found")
|
||||
sets = []
|
||||
params: list = []
|
||||
for k in ("is_active", "is_admin", "full_name", "email"):
|
||||
if k in body:
|
||||
sets.append(f"{k}=?")
|
||||
params.append(int(body[k]) if k in ("is_active", "is_admin") else body[k])
|
||||
if not sets:
|
||||
raise HTTPException(400, "No fields")
|
||||
params.append(uid)
|
||||
conn.execute(f"UPDATE users SET {', '.join(sets)} WHERE id=?", params)
|
||||
conn.commit()
|
||||
audit_log(user, "admin.user_update", "user", uid, "", request)
|
||||
return {"id": uid, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/admin/users/{uid}")
|
||||
def admin_delete_user_v2(uid: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not user.get("is_admin") and not has_scope(user.get("_token_scopes"), "admin"):
|
||||
raise HTTPException(403, "Admin scope required")
|
||||
if uid == user["id"]:
|
||||
raise HTTPException(400, "Cannot delete yourself")
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM users WHERE id=?", (uid,))
|
||||
conn.commit()
|
||||
audit_log(user, "admin.user_delete", "user", uid, "", request)
|
||||
return {"id": uid, "status": "deleted"}
|
||||
|
||||
|
||||
@router.get("/admin/audit-logs")
|
||||
def admin_audit_logs_v2(request: Request, limit: int = 50, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not user.get("is_admin") and not has_scope(user.get("_token_scopes"), "admin"):
|
||||
raise HTTPException(403, "Admin scope required")
|
||||
limit = max(1, min(limit, 200))
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM api_audit_log ORDER BY created_at DESC LIMIT ?", (limit,)).fetchall()
|
||||
return {"logs": [row_to_dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.get("/webhooks/events")
|
||||
def list_webhook_events_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
"""Catalogue of deliverable events (+ wildcard syntax)."""
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
from app.services.webhook_outbound import EVENTS
|
||||
return {"events": EVENTS, "wildcards": ["*", "page.*", "collection.*"]}
|
||||
@@ -0,0 +1,567 @@
|
||||
"""FlowDeck — Public API v2 : collections.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Header, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
|
||||
audit_log,
|
||||
check_idempotency,
|
||||
check_v2_rate_limit,
|
||||
get_bearer_user,
|
||||
has_scope,
|
||||
paginate_headers,
|
||||
parse_pagination,
|
||||
require_scope,
|
||||
row_to_dict,
|
||||
store_idempotency,
|
||||
to_iso8601,
|
||||
validate_scopes_input,
|
||||
)
|
||||
from app.services.automations import fire_event as _fire_event
|
||||
from app.services.automations import run_event_sync
|
||||
from app.services.collection_lifecycle import delete_collections
|
||||
|
||||
from ._common import _v2_rate_check
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["api-v2"])
|
||||
|
||||
|
||||
|
||||
@router.get("/collections")
|
||||
def list_collections_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
limit, offset = parse_pagination(request)
|
||||
ws_filter = request.query_params.get("workspace_id")
|
||||
q = (request.query_params.get("query") or "").strip()
|
||||
with get_conn() as conn:
|
||||
where = []
|
||||
params: list = []
|
||||
if ws_filter:
|
||||
try:
|
||||
wid = int(ws_filter)
|
||||
where.append("c.workspace_id=?")
|
||||
params.append(wid)
|
||||
except ValueError:
|
||||
pass
|
||||
if q:
|
||||
where.append("(c.name LIKE ? OR c.description LIKE ?)")
|
||||
like = f"%{q}%"
|
||||
params.extend([like, like])
|
||||
clause = ("WHERE " + " AND ".join(where)) if where else ""
|
||||
total = conn.execute(f"SELECT COUNT(*) FROM collections c {clause}", params).fetchone()[0]
|
||||
rows = conn.execute(f"SELECT c.* FROM collections c {clause} ORDER BY c.name LIMIT ? OFFSET ?", (*params, limit, offset)).fetchall()
|
||||
cols = []
|
||||
for r in rows:
|
||||
d = row_to_dict(r)
|
||||
# filter by visibility: skip private not visible (best-effort)
|
||||
cols.append(d)
|
||||
resp = {"collections": cols, "total": total, "limit": limit, "offset": offset}
|
||||
return JSONResponse(content=resp, headers=paginate_headers(total))
|
||||
|
||||
|
||||
@router.post("/collections")
|
||||
def create_collection_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
idem = check_idempotency(request, user["id"])
|
||||
if idem:
|
||||
return JSONResponse(content=idem["data"], status_code=idem["status"])
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "name is required")
|
||||
description = body.get("description", "")
|
||||
icon = body.get("icon", "📋")
|
||||
workspace_id = body.get("workspace_id")
|
||||
schema = body.get("schema") or body.get("schema_json") or []
|
||||
if isinstance(schema, str):
|
||||
try:
|
||||
schema = json.loads(schema)
|
||||
except Exception:
|
||||
schema = []
|
||||
schema_json = json.dumps(schema)
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("INSERT INTO collections (name, description, icon, schema_json, workspace_id, created_by) VALUES (?, ?, ?, ?, ?, ?)", (name, description, icon, schema_json, workspace_id, user["id"]))
|
||||
cid = cur.lastrowid
|
||||
# materialize properties if schema provided — A25 : PAS de try ici,
|
||||
# une exception doit interrompre la transaction (sinon la collection est
|
||||
# commitée sans son schéma et l'erreur disparaît).
|
||||
from app.services.db_templates import materialize_properties
|
||||
materialize_properties(conn, cid, schema)
|
||||
# default view
|
||||
conn.execute("INSERT INTO collection_views (collection_id, name, view_type, config_json) VALUES (?,?,?,?)", (cid, "Default View", "table", json.dumps({"visible_properties": ["Title"]})))
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM collections WHERE id=?", (cid,)).fetchone()
|
||||
audit_log(user, "collection.create", "collection", cid, name, request)
|
||||
data = {"id": cid, "name": name, "status": "created", "collection": row_to_dict(row)}
|
||||
key = (request.headers.get("Idempotency-Key") or "").strip()
|
||||
if key:
|
||||
store_idempotency(key, user["id"], data, 201)
|
||||
return JSONResponse(content=data, status_code=201)
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}")
|
||||
def get_collection_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Collection not found")
|
||||
pages = conn.execute("SELECT id, title, icon, position, property_values_json, created_at FROM collection_pages WHERE collection_id=? ORDER BY position LIMIT 50", (collection_id,)).fetchall()
|
||||
d = row_to_dict(row)
|
||||
d["pages"] = [row_to_dict(p) for p in pages]
|
||||
return d
|
||||
|
||||
|
||||
@router.patch("/collections/{collection_id}")
|
||||
def patch_collection_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Collection not found")
|
||||
name = body.get("name", row["name"])
|
||||
description = body.get("description", row["description"])
|
||||
icon = body.get("icon", row["icon"])
|
||||
schema = body.get("schema") or body.get("schema_json")
|
||||
if schema is not None:
|
||||
sj = json.dumps(schema) if isinstance(schema, (list, dict)) else str(schema)
|
||||
else:
|
||||
sj = row["schema_json"]
|
||||
conn.execute("UPDATE collections SET name=?, description=?, icon=?, schema_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (name, description, icon, sj, collection_id))
|
||||
conn.commit()
|
||||
audit_log(user, "collection.update", "collection", collection_id, "", request)
|
||||
return {"id": collection_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/collections/{collection_id}")
|
||||
def delete_collection_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Collection not found")
|
||||
delete_collections(conn, [collection_id])
|
||||
conn.commit()
|
||||
audit_log(user, "collection.delete", "collection", collection_id, "", request)
|
||||
return {"id": collection_id, "status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/linked")
|
||||
def create_linked_db(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
name = (body.get("name") or "").strip() or f"Linked DB {collection_id}"
|
||||
with get_conn() as conn:
|
||||
src = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not src:
|
||||
raise HTTPException(404, "Collection not found")
|
||||
cur = conn.execute("INSERT INTO collections (name, description, icon, schema_json, workspace_id, created_by) VALUES (?, ?, ?, ?, ?, ?)", (name, src["description"], src["icon"], src["schema_json"], src["workspace_id"] if "workspace_id" in src.keys() else None, user["id"]))
|
||||
nid = cur.lastrowid
|
||||
# copy data source as linked
|
||||
try:
|
||||
conn.execute("INSERT INTO collection_data_sources (collection_id, source_collection_id, is_linked) VALUES (?, ?, 1)", (nid, collection_id))
|
||||
except Exception:
|
||||
logger.exception("create_linked_db")
|
||||
# copy views + properties (light)
|
||||
rows = conn.execute("SELECT * FROM collection_properties WHERE collection_id=?", (collection_id,)).fetchall()
|
||||
for p in rows:
|
||||
try:
|
||||
conn.execute("INSERT INTO collection_properties (collection_id, name, prop_type, options_json, position) VALUES (?, ?, ?, ?, ?)", (nid, p["name"], p["prop_type"], p["options_json"], p["position"]))
|
||||
except Exception:
|
||||
logger.exception("create_linked_db")
|
||||
vrows = conn.execute("SELECT * FROM collection_views WHERE collection_id=?", (collection_id,)).fetchall()
|
||||
for v in vrows:
|
||||
try:
|
||||
conn.execute("INSERT INTO collection_views (collection_id, name, view_type, config_json, position) VALUES (?, ?, ?, ?, ?)", (nid, v["name"], v["view_type"], v["config_json"], v["position"]))
|
||||
except Exception:
|
||||
logger.exception("create_linked_db")
|
||||
conn.commit()
|
||||
audit_log(user, "collection.linked", "collection", nid, f"src={collection_id}", request)
|
||||
return {"id": nid, "name": name, "status": "created"}
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/task")
|
||||
def toggle_task(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT is_task FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Collection not found")
|
||||
cur_val = row["is_task"] if "is_task" in row.keys() else 0
|
||||
new_val = 0 if cur_val else 1
|
||||
conn.execute("UPDATE collections SET is_task=? WHERE id=?", (new_val, collection_id))
|
||||
conn.commit()
|
||||
audit_log(user, "collection.toggle_task", "collection", collection_id, str(new_val), request)
|
||||
return {"id": collection_id, "is_task": bool(new_val)}
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/sources")
|
||||
def list_sources(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone():
|
||||
raise HTTPException(404, "Collection not found")
|
||||
rows = conn.execute("SELECT * FROM collection_data_sources WHERE collection_id=? ORDER BY position", (collection_id,)).fetchall()
|
||||
return {"sources": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/sources")
|
||||
def add_source(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
src_id = body.get("source_collection_id") or body.get("source_id")
|
||||
if not src_id:
|
||||
raise HTTPException(400, "source_collection_id required")
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone():
|
||||
raise HTTPException(404, "Collection not found")
|
||||
if not conn.execute("SELECT id FROM collections WHERE id=?", (src_id,)).fetchone():
|
||||
raise HTTPException(404, "Source collection not found")
|
||||
try:
|
||||
conn.execute("INSERT INTO collection_data_sources (collection_id, source_collection_id) VALUES (?, ?)", (collection_id, src_id))
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
raise HTTPException(409, str(e)) from None
|
||||
audit_log(user, "collection.add_source", "collection", collection_id, str(src_id), request)
|
||||
return {"collection_id": collection_id, "source_collection_id": src_id, "status": "added"}
|
||||
|
||||
|
||||
@router.delete("/collections/{collection_id}/sources/{source_id}")
|
||||
def remove_source(collection_id: int, source_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM collection_data_sources WHERE collection_id=? AND (id=? OR source_collection_id=?)", (collection_id, source_id, source_id))
|
||||
conn.commit()
|
||||
audit_log(user, "collection.remove_source", "collection", collection_id, str(source_id), request)
|
||||
return {"status": "removed"}
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/pages")
|
||||
def list_collection_pages_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
limit, offset = parse_pagination(request)
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone():
|
||||
raise HTTPException(404, "Collection not found")
|
||||
total = conn.execute("SELECT COUNT(*) FROM collection_pages WHERE collection_id=?", (collection_id,)).fetchone()[0]
|
||||
# filters: filter[status]=Done etc., sort, fields
|
||||
# Simple: filter by property name via property_values_json LIKE (best-effort), sort by position or title
|
||||
sort = request.query_params.get("sort") or ""
|
||||
order = "position"
|
||||
desc = False
|
||||
if sort:
|
||||
if sort.startswith("-"):
|
||||
desc = True
|
||||
sort = sort[1:]
|
||||
# allow sorting by title/position/created_at
|
||||
if sort in ("title", "position", "created_at", "updated_at"):
|
||||
order = sort
|
||||
direction = "DESC" if desc else "ASC"
|
||||
rows = conn.execute(f"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY {order} {direction} LIMIT ? OFFSET ?", (collection_id, limit, offset)).fetchall()
|
||||
# apply filter[xxx] in-memory (small)
|
||||
filters = {k[7:-1]: v for k, v in request.query_params.items() if k.startswith("filter[") and k.endswith("]")}
|
||||
fields = request.query_params.get("fields")
|
||||
fields_set = set(fields.split(",")) if fields else None
|
||||
out = []
|
||||
for r in rows:
|
||||
d = row_to_dict(r)
|
||||
# property filter (AND)
|
||||
if filters:
|
||||
try:
|
||||
pv = json.loads(r["property_values_json"] or "{}") if isinstance(r["property_values_json"], str) else r["property_values_json"]
|
||||
except Exception:
|
||||
pv = {}
|
||||
ok = True
|
||||
for fk, fv in filters.items():
|
||||
# lookup by prop id or name
|
||||
found = False
|
||||
for kk, vv in (pv or {}).items():
|
||||
if str(kk) == str(fk) or str(kk).lower() == fk.lower():
|
||||
if str(vv) == str(fv):
|
||||
found = True
|
||||
break
|
||||
# also check title if filter field is title
|
||||
if fk == "title" and d.get("title") == fv:
|
||||
found = True
|
||||
if not found:
|
||||
ok = False
|
||||
break
|
||||
if not ok:
|
||||
continue
|
||||
if fields_set:
|
||||
d = {k: v for k, v in d.items() if k in fields_set or k in ("id", "collection_id")}
|
||||
out.append(d)
|
||||
return JSONResponse(content={"pages": out, "total": total, "limit": limit, "offset": offset}, headers=paginate_headers(total))
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/pages")
|
||||
def create_collection_page_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
idem = check_idempotency(request, user["id"])
|
||||
if idem:
|
||||
return JSONResponse(content=idem["data"], status_code=idem["status"])
|
||||
title = (body.get("title") or body.get("name") or "Untitled").strip() or "Untitled"
|
||||
icon = body.get("icon", "file")
|
||||
parent_id = body.get("parent_id")
|
||||
prop_vals = body.get("property_values") or body.get("properties") or body.get("property_values_json") or {}
|
||||
if isinstance(prop_vals, str):
|
||||
try:
|
||||
prop_vals = json.loads(prop_vals)
|
||||
except Exception:
|
||||
prop_vals = {}
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone():
|
||||
raise HTTPException(404, "Collection not found")
|
||||
# validate properties if helper exists
|
||||
try:
|
||||
pass
|
||||
# light validation: we rely on existing validators
|
||||
except Exception:
|
||||
logger.exception("create_collection_page_v2")
|
||||
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_pages WHERE collection_id=?", (collection_id,)).fetchone()[0]
|
||||
# apply auto props
|
||||
try:
|
||||
props_list = [dict(r) for r in conn.execute("SELECT * FROM collection_properties WHERE collection_id=?", (collection_id,)).fetchall()]
|
||||
from app.services.property_types import apply_auto_properties as _aap
|
||||
_aap(props_list, prop_vals, user, is_create=True)
|
||||
except Exception:
|
||||
logger.exception("create_collection_page_v2")
|
||||
cur = conn.execute("INSERT INTO collection_pages (collection_id, title, icon, position, parent_id, property_values_json) VALUES (?, ?, ?, ?, ?, ?)", (collection_id, title, icon, max_pos, parent_id, json.dumps(prop_vals)))
|
||||
pid = cur.lastrowid
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM collection_pages WHERE id=?", (pid,)).fetchone()
|
||||
audit_log(user, "page.create", "collection_page", pid, title, request)
|
||||
try:
|
||||
run_event_sync(_fire_event("collection.page.created", {"page_id": pid, "collection_id": collection_id, "title": title}))
|
||||
except Exception:
|
||||
logger.exception("create_collection_page_v2")
|
||||
data = {"id": pid, "title": title, "status": "created", "page": row_to_dict(row)}
|
||||
key = (request.headers.get("Idempotency-Key") or "").strip()
|
||||
if key:
|
||||
store_idempotency(key, user["id"], data, 201)
|
||||
return JSONResponse(content=data, status_code=201)
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}")
|
||||
def get_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
# also try pages table (block pages)
|
||||
row2 = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row2:
|
||||
raise HTTPException(404, "Page not found")
|
||||
d = row_to_dict(row2)
|
||||
# v6.5.0: resolve synced blocks server-side (fresh content).
|
||||
if (d.get("content_format") or "blocks") == "blocks" and d.get("content"):
|
||||
from app.services.synced_blocks import resolve_content_json
|
||||
d["content"] = resolve_content_json(d["content"], d["content_format"])
|
||||
return d
|
||||
d = row_to_dict(row)
|
||||
# property_values_json already parsed by row_to_dict
|
||||
# v6.5.0: expose the row's content page when it exists (no lazy
|
||||
# creation on a read-only endpoint).
|
||||
content_page_id = conn.execute(
|
||||
"SELECT id FROM pages WHERE collection_row_id=?",
|
||||
(page_id,),
|
||||
).fetchone()
|
||||
d["content_page_id"] = content_page_id["id"] if content_page_id else None
|
||||
return d
|
||||
|
||||
|
||||
@router.patch("/pages/{page_id}")
|
||||
def patch_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Page not found")
|
||||
title = body.get("title", row["title"])
|
||||
icon = body.get("icon", row["icon"])
|
||||
pos = body.get("position", row["position"])
|
||||
parent_id = body.get("parent_id", row["parent_id"])
|
||||
pv_raw = row["property_values_json"] or "{}"
|
||||
try:
|
||||
stored = json.loads(pv_raw) if isinstance(pv_raw, str) else dict(pv_raw)
|
||||
except Exception:
|
||||
stored = {}
|
||||
incoming = body.get("property_values") or body.get("properties")
|
||||
if incoming is not None:
|
||||
if isinstance(incoming, str):
|
||||
try:
|
||||
incoming = json.loads(incoming)
|
||||
except Exception:
|
||||
incoming = {}
|
||||
# merge
|
||||
for k, v in (incoming or {}).items():
|
||||
stored[str(k)] = v
|
||||
# apply auto props
|
||||
try:
|
||||
props_list = [dict(r) for r in conn.execute("SELECT * FROM collection_properties WHERE collection_id=?", (row["collection_id"],)).fetchall()]
|
||||
from app.services.property_types import apply_auto_properties as _aap
|
||||
_aap(props_list, stored, user, is_create=False)
|
||||
except Exception:
|
||||
logger.exception("patch_page_v2")
|
||||
conn.execute("UPDATE collection_pages SET title=?, icon=?, position=?, parent_id=?, property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, icon, pos, parent_id, json.dumps(stored), page_id))
|
||||
conn.commit()
|
||||
audit_log(user, "page.update", "collection_page", page_id, "", request)
|
||||
try:
|
||||
run_event_sync(_fire_event("collection.page.updated", {"page_id": page_id, "collection_id": row["collection_id"], "title": title}))
|
||||
except Exception:
|
||||
logger.exception("patch_page_v2")
|
||||
return {"id": page_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/pages/{page_id}")
|
||||
def delete_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Page not found")
|
||||
conn.execute("DELETE FROM collection_pages WHERE id=?", (page_id,))
|
||||
conn.commit()
|
||||
audit_log(user, "page.delete", "collection_page", page_id, "", request)
|
||||
try:
|
||||
run_event_sync(_fire_event("collection.page.deleted", {"page_id": page_id, "collection_id": row["collection_id"]}))
|
||||
except Exception:
|
||||
logger.exception("delete_page_v2")
|
||||
return {"id": page_id, "status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/restore")
|
||||
def restore_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
# For soft-deleted pages (deleted_at) - but collection_pages has no deleted_at; handle pages table
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT deleted_at FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if row and row["deleted_at"]:
|
||||
conn.execute("UPDATE pages SET deleted_at=NULL WHERE id=?", (page_id,))
|
||||
conn.commit()
|
||||
try:
|
||||
run_event_sync(_fire_event("page.restored", {"page_id": page_id}))
|
||||
except Exception:
|
||||
logger.exception("restore_page_v2")
|
||||
return {"id": page_id, "status": "restored"}
|
||||
raise HTTPException(404, "Page not found or not deleted")
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/move")
|
||||
def move_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Page not found")
|
||||
parent_id = body.get("parent_id", row["parent_id"])
|
||||
position = body.get("position", row["position"])
|
||||
conn.execute("UPDATE collection_pages SET parent_id=?, position=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (parent_id, position, page_id))
|
||||
conn.commit()
|
||||
audit_log(user, "page.move", "collection_page", page_id, f"parent={parent_id} pos={position}", request)
|
||||
return {"id": page_id, "status": "moved"}
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/sub-items")
|
||||
def list_sub_items_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM collection_pages WHERE id=?", (page_id,)).fetchone():
|
||||
raise HTTPException(404, "Page not found")
|
||||
rows = conn.execute("SELECT * FROM collection_pages WHERE parent_id=? ORDER BY position", (page_id,)).fetchall()
|
||||
return {"sub_items": [row_to_dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/sub-items")
|
||||
def create_sub_item_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
parent = conn.execute("SELECT collection_id FROM collection_pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not parent:
|
||||
raise HTTPException(404, "Page not found")
|
||||
title = (body.get("title") or "Untitled").strip()
|
||||
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_pages WHERE parent_id=?", (page_id,)).fetchone()[0]
|
||||
pv = json.dumps(body.get("property_values") or {})
|
||||
cur = conn.execute("INSERT INTO collection_pages (collection_id, title, parent_id, position, property_values_json) VALUES (?, ?, ?, ?, ?)", (parent["collection_id"], title, page_id, max_pos, pv))
|
||||
nid = cur.lastrowid
|
||||
conn.commit()
|
||||
audit_log(user, "page.create_subitem", "collection_page", nid, title, request)
|
||||
return {"id": nid, "status": "created"}
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/dependencies")
|
||||
def list_dependencies_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM page_dependencies WHERE page_id=?", (page_id,)).fetchall()
|
||||
return {"dependencies": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/dependencies")
|
||||
def add_dependency_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
dep_id = body.get("dependency_id") or body.get("depends_on")
|
||||
dtype = body.get("dependency_type") or "blocks"
|
||||
if not dep_id:
|
||||
raise HTTPException(400, "dependency_id required")
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM collection_pages WHERE id=?", (page_id,)).fetchone():
|
||||
raise HTTPException(404, "Page not found")
|
||||
if not conn.execute("SELECT id FROM collection_pages WHERE id=?", (dep_id,)).fetchone():
|
||||
raise HTTPException(404, "Dependency page not found")
|
||||
try:
|
||||
conn.execute("INSERT INTO page_dependencies (page_id, dependency_id, dependency_type) VALUES (?, ?, ?)", (page_id, dep_id, dtype))
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
raise HTTPException(409, str(e)) from None
|
||||
audit_log(user, "page.add_dependency", "collection_page", page_id, str(dep_id), request)
|
||||
return {"status": "added"}
|
||||
|
||||
|
||||
@router.delete("/pages/{page_id}/dependencies/{dep_id}")
|
||||
def remove_dependency_v2(page_id: int, dep_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM page_dependencies WHERE page_id=? AND dependency_id=?", (page_id, dep_id))
|
||||
conn.commit()
|
||||
audit_log(user, "page.remove_dependency", "collection_page", page_id, str(dep_id), request)
|
||||
return {"status": "removed"}
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/properties")
|
||||
def list_properties_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone():
|
||||
raise HTTPException(404, "Collection not found")
|
||||
rows = conn.execute("SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position", (collection_id,)).fetchall()
|
||||
return {"properties": [row_to_dict(r) for r in rows]}
|
||||
@@ -0,0 +1,338 @@
|
||||
"""FlowDeck — Public API v2 : engagement.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Header, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
|
||||
audit_log,
|
||||
check_idempotency,
|
||||
check_v2_rate_limit,
|
||||
get_bearer_user,
|
||||
has_scope,
|
||||
paginate_headers,
|
||||
parse_pagination,
|
||||
require_scope,
|
||||
row_to_dict,
|
||||
store_idempotency,
|
||||
to_iso8601,
|
||||
validate_scopes_input,
|
||||
)
|
||||
from app.services.automations import fire_event as _fire_event
|
||||
from app.services.automations import run_event_sync
|
||||
|
||||
from ._common import _v2_rate_check
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["api-v2"])
|
||||
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/comments")
|
||||
def list_comments_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
limit, offset = parse_pagination(request)
|
||||
with get_conn() as conn:
|
||||
total = conn.execute("SELECT COUNT(*) FROM comments WHERE target_id=? OR page_id=?", (page_id, page_id)).fetchone()[0]
|
||||
rows = conn.execute("SELECT c.*, u.login, u.full_name FROM comments c LEFT JOIN users u ON u.id=c.user_id WHERE c.target_id=? OR c.page_id=? ORDER BY c.created_at LIMIT ? OFFSET ?", (page_id, page_id, limit, offset)).fetchall()
|
||||
return {"comments": [row_to_dict(r) for r in rows], "total": total, "limit": limit, "offset": offset}
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/comments")
|
||||
def create_comment_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
text = (body.get("body") or body.get("content") or "").strip()
|
||||
if not text:
|
||||
raise HTTPException(400, "body is required")
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("INSERT INTO comments (page_id, user_id, body, target_type, target_id, anchor_block_id, anchor_start, anchor_end) VALUES (?, ?, ?, 'page', ?, ?, ?, ?)", (page_id, user["id"], text, page_id, body.get("anchor_block_id"), body.get("anchor_start"), body.get("anchor_end")))
|
||||
nid = cur.lastrowid
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM comments WHERE id=?", (nid,)).fetchone()
|
||||
audit_log(user, "comment.create", "comment", nid, text[:80], request)
|
||||
try:
|
||||
run_event_sync(_fire_event("comment.added", {"comment_id": nid, "page_id": page_id, "user_id": user["id"]}))
|
||||
except Exception:
|
||||
logger.exception("create_comment_v2")
|
||||
return {"id": nid, "status": "created", "comment": row_to_dict(row)}
|
||||
|
||||
|
||||
@router.patch("/comments/{comment_id}")
|
||||
def patch_comment_v2(comment_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM comments WHERE id=?", (comment_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Comment not found")
|
||||
if row["user_id"] != user["id"] and not user.get("is_admin"):
|
||||
raise HTTPException(403, "Not your comment")
|
||||
body_text = body.get("body", row["body"])
|
||||
resolved = body.get("resolved", row["resolved"])
|
||||
was_resolved = int(row["resolved"] or 0)
|
||||
conn.execute("UPDATE comments SET body=?, resolved=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (body_text, int(bool(resolved)), comment_id))
|
||||
conn.commit()
|
||||
if int(bool(resolved)) and not was_resolved:
|
||||
try:
|
||||
run_event_sync(_fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]}))
|
||||
except Exception:
|
||||
logger.exception("patch_comment_v2")
|
||||
return {"id": comment_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/comments/{comment_id}")
|
||||
def delete_comment_v2(comment_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM comments WHERE id=?", (comment_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Comment not found")
|
||||
if row["user_id"] != user["id"] and not user.get("is_admin"):
|
||||
raise HTTPException(403, "Not your comment")
|
||||
conn.execute("DELETE FROM comments WHERE id=?", (comment_id,))
|
||||
conn.commit()
|
||||
return {"id": comment_id, "status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/mentions")
|
||||
def create_mention_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
targets = body.get("user_ids") or body.get("mentions") or []
|
||||
if isinstance(targets, int):
|
||||
targets = [targets]
|
||||
if not targets:
|
||||
raise HTTPException(400, "user_ids required")
|
||||
created = 0
|
||||
with get_conn() as conn:
|
||||
for uid in targets:
|
||||
try:
|
||||
conn.execute("INSERT INTO notifications (user_id, actor_id, ntype, title, message, resource_type, resource_id, url) VALUES (?, ?, 'mention', 'You were mentioned', ?, 'page', ?, ?)", (uid, user["id"], body.get("message") or f"Mentioned in page {page_id}", page_id, f"/pages/{page_id}"))
|
||||
created += 1
|
||||
except Exception:
|
||||
logger.exception("create_mention_v2")
|
||||
conn.commit()
|
||||
if created:
|
||||
try:
|
||||
run_event_sync(_fire_event("mention.added", {"page_id": page_id, "user_ids": [u for u in targets if isinstance(u, int)], "count": created}))
|
||||
except Exception:
|
||||
logger.exception("create_mention_v2")
|
||||
return {"mentions": created, "status": "created"}
|
||||
|
||||
|
||||
@router.get("/notifications")
|
||||
def list_notifications_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
limit, offset = parse_pagination(request)
|
||||
unread = request.query_params.get("unread")
|
||||
with get_conn() as conn:
|
||||
where = "user_id=?"
|
||||
params: list = [user["id"]]
|
||||
if unread == "1":
|
||||
where += " AND is_read=0"
|
||||
total = conn.execute(f"SELECT COUNT(*) FROM notifications WHERE {where}", params).fetchone()[0]
|
||||
rows = conn.execute(f"SELECT * FROM notifications WHERE {where} ORDER BY created_at DESC LIMIT ? OFFSET ?", (*params, limit, offset)).fetchall()
|
||||
return {"notifications": [row_to_dict(r) for r in rows], "total": total, "limit": limit, "offset": offset}
|
||||
|
||||
|
||||
@router.get("/notifications/unread-count")
|
||||
def unread_count(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
cnt = conn.execute("SELECT COUNT(*) FROM notifications WHERE user_id=? AND is_read=0", (user["id"],)).fetchone()[0]
|
||||
return {"unread": cnt}
|
||||
|
||||
|
||||
@router.post("/notifications/{notif_id}/read")
|
||||
def mark_read(notif_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE notifications SET is_read=1 WHERE id=? AND user_id=?", (notif_id, user["id"]))
|
||||
conn.commit()
|
||||
return {"id": notif_id, "status": "read"}
|
||||
|
||||
|
||||
@router.post("/notifications/read-all")
|
||||
def mark_all_read(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE notifications SET is_read=1 WHERE user_id=?", (user["id"],))
|
||||
conn.commit()
|
||||
return {"status": "all read"}
|
||||
|
||||
|
||||
@router.patch("/users/me/preferences")
|
||||
def patch_prefs(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT notification_prefs FROM users WHERE id=?", (user["id"],)).fetchone()
|
||||
try:
|
||||
cur = json.loads(row["notification_prefs"] or "{}")
|
||||
except Exception:
|
||||
cur = {}
|
||||
cur.update(body)
|
||||
conn.execute("UPDATE users SET notification_prefs=? WHERE id=?", (json.dumps(cur), user["id"]))
|
||||
conn.commit()
|
||||
return {"preferences": cur}
|
||||
|
||||
|
||||
@router.get("/favorites")
|
||||
def list_favorites_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT f.*, p.title, p.workspace_id FROM favorites f JOIN pages p ON p.id=f.page_id WHERE f.user_id=? ORDER BY f.position", (user["id"],)).fetchall()
|
||||
return {"favorites": [row_to_dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/favorites")
|
||||
def add_favorite_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
pid = body.get("page_id")
|
||||
if not pid:
|
||||
raise HTTPException(400, "page_id required")
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
conn.execute("INSERT INTO favorites (user_id, page_id) VALUES (?, ?)", (user["id"], pid))
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
raise HTTPException(409, str(e)) from None
|
||||
try:
|
||||
run_event_sync(_fire_event("favorite.added", {"page_id": pid, "user_id": user["id"]}))
|
||||
except Exception:
|
||||
logger.exception("add_favorite_v2")
|
||||
return {"page_id": pid, "status": "added"}
|
||||
|
||||
|
||||
@router.delete("/favorites/{page_id}")
|
||||
def remove_favorite_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM favorites WHERE user_id=? AND page_id=?", (user["id"], page_id))
|
||||
conn.commit()
|
||||
try:
|
||||
run_event_sync(_fire_event("favorite.removed", {"page_id": page_id, "user_id": user["id"]}))
|
||||
except Exception:
|
||||
logger.exception("remove_favorite_v2")
|
||||
return {"page_id": page_id, "status": "removed"}
|
||||
|
||||
|
||||
@router.get("/tags")
|
||||
def list_tags_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
q = (request.query_params.get("q") or "").strip()
|
||||
with get_conn() as conn:
|
||||
if q:
|
||||
rows = conn.execute("SELECT * FROM tags WHERE user_id=? AND name LIKE ? ORDER BY name", (user["id"], f"%{q}%")).fetchall()
|
||||
else:
|
||||
rows = conn.execute("SELECT * FROM tags WHERE user_id=? ORDER BY name", (user["id"],)).fetchall()
|
||||
return {"tags": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/tags")
|
||||
def create_tag_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "name required")
|
||||
color = body.get("color", "#787774")
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
cur = conn.execute("INSERT INTO tags (name, color, user_id) VALUES (?, ?, ?)", (name, color, user["id"]))
|
||||
tid = cur.lastrowid
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
raise HTTPException(409, str(e)) from None
|
||||
return {"id": tid, "name": name, "color": color, "status": "created"}
|
||||
|
||||
|
||||
@router.patch("/tags/{tag_id}")
|
||||
def patch_tag_v2(tag_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM tags WHERE id=? AND user_id=?", (tag_id, user["id"])).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Tag not found")
|
||||
name = body.get("name", row["name"])
|
||||
color = body.get("color", row["color"])
|
||||
conn.execute("UPDATE tags SET name=?, color=? WHERE id=?", (name, color, tag_id))
|
||||
conn.commit()
|
||||
return {"id": tag_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/tags/{tag_id}")
|
||||
def delete_tag_v2(tag_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM tags WHERE id=? AND user_id=?", (tag_id, user["id"]))
|
||||
conn.commit()
|
||||
return {"id": tag_id, "status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/tags")
|
||||
def attach_tag_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
tag_id = body.get("tag_id")
|
||||
if not tag_id:
|
||||
raise HTTPException(400, "tag_id required")
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
conn.execute("INSERT INTO page_tags (page_id, tag_id) VALUES (?, ?)", (page_id, tag_id))
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
raise HTTPException(409, str(e)) from None
|
||||
return {"page_id": page_id, "tag_id": tag_id, "status": "attached"}
|
||||
|
||||
|
||||
@router.delete("/pages/{page_id}/tags/{tag_id}")
|
||||
def detach_tag_v2(page_id: int, tag_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM page_tags WHERE page_id=? AND tag_id=?", (page_id, tag_id))
|
||||
conn.commit()
|
||||
return {"status": "detached"}
|
||||
|
||||
|
||||
@router.get("/recents")
|
||||
def list_recents_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
limit = int(request.query_params.get("limit", "20"))
|
||||
st = request.query_params.get("source_type")
|
||||
with get_conn() as conn:
|
||||
if st:
|
||||
rows = conn.execute("SELECT * FROM recents WHERE user_id=? AND source_type=? ORDER BY accessed_at DESC LIMIT ?", (user["id"], st, limit)).fetchall()
|
||||
else:
|
||||
rows = conn.execute("SELECT * FROM recents WHERE user_id=? ORDER BY accessed_at DESC LIMIT ?", (user["id"], limit)).fetchall()
|
||||
return {"recents": [row_to_dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/shares")
|
||||
def list_shares_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM page_shares WHERE page_id=?", (page_id,)).fetchall()
|
||||
return {"shares": [dict(r) for r in rows]}
|
||||
@@ -0,0 +1,195 @@
|
||||
"""FlowDeck — Public API v2 : identity.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Body, Header, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
|
||||
audit_log,
|
||||
check_idempotency,
|
||||
check_v2_rate_limit,
|
||||
get_bearer_user,
|
||||
has_scope,
|
||||
paginate_headers,
|
||||
parse_pagination,
|
||||
require_scope,
|
||||
row_to_dict,
|
||||
store_idempotency,
|
||||
to_iso8601,
|
||||
validate_scopes_input,
|
||||
)
|
||||
|
||||
from ._common import _hash, _v2_rate_check
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["api-v2"])
|
||||
|
||||
|
||||
|
||||
@router.post("/tokens")
|
||||
def create_token(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
name = (body.get("name") or "API token").strip()[:100]
|
||||
scopes = validate_scopes_input(body.get("scopes") or "read,write")
|
||||
expires_at = body.get("expires_at")
|
||||
# Idempotency
|
||||
idem = check_idempotency(request, user["id"])
|
||||
if idem:
|
||||
return JSONResponse(content=idem["data"], status_code=idem["status"])
|
||||
token = f"fd_{secrets.token_urlsafe(32)}"
|
||||
prefix = token[:12]
|
||||
th = _hash(token)
|
||||
exp_val = None
|
||||
if expires_at:
|
||||
try:
|
||||
# accept ISO string
|
||||
exp_val = str(expires_at)
|
||||
# validate parse
|
||||
datetime.fromisoformat(exp_val.replace("Z", "+00:00"))
|
||||
except Exception as err:
|
||||
raise HTTPException(400, "Invalid expires_at, use ISO-8601") from err
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, scopes, expires_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(user["id"], name, th, prefix, scopes, exp_val),
|
||||
)
|
||||
conn.commit()
|
||||
tid = cur.lastrowid
|
||||
except Exception as e:
|
||||
raise HTTPException(409, f"Token creation failed: {e}") from None
|
||||
row = conn.execute("SELECT id, name, token_prefix, scopes, expires_at, created_at FROM api_tokens WHERE id=?", (tid,)).fetchone()
|
||||
audit_log(user, "token.create", "api_token", tid, f"scopes={scopes}", request)
|
||||
data = {"id": tid, "name": row["name"], "token": token, "prefix": prefix, "scopes": scopes, "expires_at": to_iso8601(row["expires_at"]) if row["expires_at"] else None, "note": "Copy token now — shown once. Use as Authorization: Bearer <token>"}
|
||||
key = (request.headers.get("Idempotency-Key") or request.headers.get("idempotency-key") or "").strip()
|
||||
if key:
|
||||
store_idempotency(key, user["id"], data, 200)
|
||||
return data
|
||||
|
||||
|
||||
@router.get("/tokens")
|
||||
def list_tokens(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT id, name, token_prefix, scopes, expires_at, last_used_at, created_at, revoked FROM api_tokens WHERE user_id=? ORDER BY created_at DESC", (user["id"],)).fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
d["created_at"] = to_iso8601(d.get("created_at"))
|
||||
d["expires_at"] = to_iso8601(d.get("expires_at")) if d.get("expires_at") else None
|
||||
d["last_used_at"] = to_iso8601(d.get("last_used_at")) if d.get("last_used_at") else None
|
||||
# never expose hash
|
||||
out.append({k: v for k, v in d.items() if k != "token_hash"})
|
||||
return {"tokens": out}
|
||||
|
||||
|
||||
@router.delete("/tokens/{token_id}")
|
||||
def revoke_token(token_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id, user_id FROM api_tokens WHERE id=?", (token_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Token not found")
|
||||
if row["user_id"] != user["id"] and not user.get("is_admin"):
|
||||
raise HTTPException(403, "Not your token")
|
||||
conn.execute("UPDATE api_tokens SET revoked=1 WHERE id=?", (token_id,))
|
||||
conn.commit()
|
||||
audit_log(user, "token.revoke", "api_token", token_id, "", request)
|
||||
return {"id": token_id, "status": "revoked"}
|
||||
|
||||
|
||||
@router.post("/tokens/{token_id}/rotate")
|
||||
def rotate_token(token_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id, user_id, name, scopes FROM api_tokens WHERE id=?", (token_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Token not found")
|
||||
if row["user_id"] != user["id"] and not user.get("is_admin"):
|
||||
raise HTTPException(403, "Not your token")
|
||||
# revoke old
|
||||
conn.execute("UPDATE api_tokens SET revoked=1 WHERE id=?", (token_id,))
|
||||
new_token = f"fd_{secrets.token_urlsafe(32)}"
|
||||
th = _hash(new_token)
|
||||
prefix = new_token[:12]
|
||||
cur = conn.execute("INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, scopes) VALUES (?, ?, ?, ?, ?)", (row["user_id"], row["name"], th, prefix, row["scopes"] or "read,write"))
|
||||
conn.commit()
|
||||
nid = cur.lastrowid
|
||||
audit_log(user, "token.rotate", "api_token", token_id, f"new_id={nid}", request)
|
||||
return {"id": nid, "token": new_token, "prefix": prefix, "scopes": row["scopes"], "note": "Copy token now — shown once"}
|
||||
|
||||
|
||||
@router.get("/users/me")
|
||||
def get_me(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id, login, full_name, email, avatar_url, avatar_color, is_admin, is_active, auth_method, sidebar_config, notification_prefs, timezone, created_at FROM users WHERE id=?", (user["id"],)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "User not found")
|
||||
d = row_to_dict(row)
|
||||
# parse json prefs
|
||||
for k in ("notification_prefs", "sidebar_config"):
|
||||
if isinstance(d.get(k), str):
|
||||
try:
|
||||
d[k] = json.loads(d[k] or "{}")
|
||||
except Exception:
|
||||
logger.exception("get_me")
|
||||
# never expose secrets
|
||||
return d
|
||||
|
||||
|
||||
@router.patch("/users/me")
|
||||
def patch_me(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
allowed = {"full_name", "email", "avatar_color", "notification_prefs", "sidebar_config", "timezone"}
|
||||
updates = {}
|
||||
for k in allowed:
|
||||
if k in body:
|
||||
updates[k] = body[k]
|
||||
if not updates:
|
||||
raise HTTPException(400, "No updatable fields")
|
||||
# validation
|
||||
if "email" in updates and updates["email"] and "@" not in str(updates["email"]):
|
||||
raise HTTPException(400, "Invalid email")
|
||||
with get_conn() as conn:
|
||||
sets = []
|
||||
params = []
|
||||
for k, v in updates.items():
|
||||
if k in ("notification_prefs", "sidebar_config"):
|
||||
v = json.dumps(v) if isinstance(v, (dict, list)) else str(v)
|
||||
sets.append(f"{k}=?")
|
||||
params.append(v)
|
||||
params.append(user["id"])
|
||||
conn.execute(f"UPDATE users SET {', '.join(sets)} WHERE id=?", params)
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT id, login, full_name, email, avatar_url, avatar_color, is_admin, timezone, notification_prefs FROM users WHERE id=?", (user["id"],)).fetchone()
|
||||
audit_log(user, "user.update", "user", user["id"], "", request)
|
||||
return row_to_dict(row)
|
||||
|
||||
|
||||
@router.get("/users/search")
|
||||
def search_users(request: Request, q: str = "", authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
q = (q or request.query_params.get("q") or "").strip()
|
||||
if not q:
|
||||
return {"users": []}
|
||||
like = f"%{q}%"
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT id, login, full_name, email, avatar_url, avatar_color FROM users WHERE login LIKE ? OR email LIKE ? OR full_name LIKE ? LIMIT 20", (like, like, like)).fetchall()
|
||||
return {"users": [dict(r) for r in rows]}
|
||||
@@ -0,0 +1,148 @@
|
||||
"""FlowDeck — Public API v2 : planning.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Header, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
|
||||
audit_log,
|
||||
check_idempotency,
|
||||
check_v2_rate_limit,
|
||||
get_bearer_user,
|
||||
has_scope,
|
||||
paginate_headers,
|
||||
parse_pagination,
|
||||
require_scope,
|
||||
row_to_dict,
|
||||
store_idempotency,
|
||||
to_iso8601,
|
||||
validate_scopes_input,
|
||||
)
|
||||
from app.services.automations import fire_event as _fire_event
|
||||
from app.services.automations import run_event_sync
|
||||
|
||||
from ._common import _v2_rate_check
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["api-v2"])
|
||||
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/sprints")
|
||||
def create_sprint_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
name = (body.get("name") or "Sprint").strip()
|
||||
start = body.get("start_date") or body.get("start") or ""
|
||||
end = body.get("end_date") or body.get("end") or ""
|
||||
if not start or not end:
|
||||
raise HTTPException(400, "start_date and end_date required (YYYY-MM-DD)")
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("INSERT INTO sprints (collection_id, name, start_date, end_date, goal) VALUES (?, ?, ?, ?, ?)", (collection_id, name, start, end, body.get("goal") or ""))
|
||||
sid = cur.lastrowid
|
||||
conn.commit()
|
||||
try:
|
||||
run_event_sync(_fire_event("sprint.created", {"sprint_id": sid, "collection_id": collection_id, "name": name}))
|
||||
except Exception:
|
||||
logger.exception("create_sprint_v2")
|
||||
return {"id": sid, "name": name, "status": "created"}
|
||||
|
||||
|
||||
@router.patch("/sprints/{sprint_id}")
|
||||
def patch_sprint_v2(sprint_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM sprints WHERE id=?", (sprint_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Sprint not found")
|
||||
name = body.get("name", row["name"])
|
||||
start = body.get("start_date", row["start_date"])
|
||||
end = body.get("end_date", row["end_date"])
|
||||
goal = body.get("goal", row["goal"])
|
||||
status = body.get("status", row["status"])
|
||||
conn.execute("UPDATE sprints SET name=?, start_date=?, end_date=?, goal=?, status=? WHERE id=?", (name, start, end, goal, status, sprint_id))
|
||||
conn.commit()
|
||||
try:
|
||||
run_event_sync(_fire_event("sprint.updated", {"sprint_id": sprint_id, "collection_id": row["collection_id"], "name": name, "status": status}))
|
||||
except Exception:
|
||||
logger.exception("patch_sprint_v2")
|
||||
return {"id": sprint_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/sprints/{sprint_id}")
|
||||
def delete_sprint_v2(sprint_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM sprints WHERE id=?", (sprint_id,))
|
||||
conn.commit()
|
||||
return {"id": sprint_id, "status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/sprints/{sprint_id}/assign")
|
||||
def assign_sprint_v2(sprint_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
pid = body.get("page_id")
|
||||
if not pid:
|
||||
raise HTTPException(400, "page_id required")
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
conn.execute("INSERT INTO sprint_pages (sprint_id, page_id, velocity_points) VALUES (?, ?, ?)", (sprint_id, pid, body.get("velocity_points", 1)))
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
raise HTTPException(409, str(e)) from None
|
||||
return {"sprint_id": sprint_id, "page_id": pid, "status": "assigned"}
|
||||
|
||||
|
||||
@router.delete("/sprints/{sprint_id}/assign/{page_id}")
|
||||
def unassign_sprint_v2(sprint_id: int, page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM sprint_pages WHERE sprint_id=? AND page_id=?", (sprint_id, page_id))
|
||||
conn.commit()
|
||||
return {"status": "removed"}
|
||||
|
||||
|
||||
@router.get("/sprints/{sprint_id}/burndown")
|
||||
def burndown_v2(sprint_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
s = conn.execute("SELECT * FROM sprints WHERE id=?", (sprint_id,)).fetchone()
|
||||
if not s:
|
||||
raise HTTPException(404, "Sprint not found")
|
||||
pages = conn.execute("SELECT sp.*, cp.property_values_json FROM sprint_pages sp JOIN collection_pages cp ON cp.id=sp.page_id WHERE sp.sprint_id=?", (sprint_id,)).fetchall()
|
||||
total = len(pages)
|
||||
# crude: completed where status property == Done (best-effort)
|
||||
completed = 0
|
||||
for p in pages:
|
||||
try:
|
||||
pv = json.loads(p["property_values_json"] or "{}")
|
||||
for v in pv.values():
|
||||
if str(v).lower() in ("done", "completed", "terminé"):
|
||||
completed += 1
|
||||
break
|
||||
except Exception:
|
||||
logger.exception("burndown_v2")
|
||||
remaining = total - completed
|
||||
# ideal linear
|
||||
ideal = [round(total * (1 - i / 10)) for i in range(11)]
|
||||
return {"sprint_id": sprint_id, "total": total, "completed": completed, "remaining": remaining, "ideal": ideal}
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/templates")
|
||||
def list_templates_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM page_templates WHERE collection_id=? ORDER BY created_at", (collection_id,)).fetchall()
|
||||
return {"templates": [row_to_dict(r) for r in rows]}
|
||||
@@ -0,0 +1,93 @@
|
||||
"""FlowDeck — Public API v2 : projects.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
|
||||
audit_log,
|
||||
check_idempotency,
|
||||
check_v2_rate_limit,
|
||||
get_bearer_user,
|
||||
has_scope,
|
||||
paginate_headers,
|
||||
parse_pagination,
|
||||
require_scope,
|
||||
row_to_dict,
|
||||
store_idempotency,
|
||||
to_iso8601,
|
||||
validate_scopes_input,
|
||||
)
|
||||
|
||||
from ._common import _v2_rate_check
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["api-v2"])
|
||||
|
||||
|
||||
|
||||
@router.get("/projects")
|
||||
def list_projects_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM projects ORDER BY proj_type, owner, name").fetchall()
|
||||
return {"projects": [row_to_dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.get("/projects/{owner}/{repo}/tree")
|
||||
async def project_tree_v2(owner: str, repo: str, request: Request, path: str = "", authorization: str | None = Header(default=None)):
|
||||
get_bearer_user(request, authorization)
|
||||
# proxy to gitea client? Return placeholder listing from projects table
|
||||
with get_conn() as conn:
|
||||
proj = conn.execute("SELECT * FROM projects WHERE owner=? AND name=?", (owner, repo)).fetchone()
|
||||
if not proj:
|
||||
raise HTTPException(404, "Project not found")
|
||||
# delegate to gitea API if available (best-effort)
|
||||
try:
|
||||
from app.services.gitea_client import gitea
|
||||
tree = await gitea.list_repo_files(owner, repo, path or "")
|
||||
return {"owner": owner, "repo": repo, "path": path, "tree": tree}
|
||||
except Exception:
|
||||
return {"owner": owner, "repo": repo, "path": path, "tree": []}
|
||||
|
||||
|
||||
@router.get("/search")
|
||||
def search_v2(request: Request, query: str = "", workspace_id: int | None = None, type: str = "all", authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
q = (query or request.query_params.get("query") or "").strip()
|
||||
if not q:
|
||||
return {"results": [], "query": q}
|
||||
like = f"%{q}%"
|
||||
with get_conn() as conn:
|
||||
pages = []
|
||||
# try FTS5
|
||||
try:
|
||||
rows = conn.execute("SELECT p.id, p.title, p.content, p.workspace_id, snippet(pages_fts, -1, '<mark>', '</mark>', '...', 32) as snippet FROM pages_fts f JOIN pages p ON p.id=f.rowid WHERE pages_fts MATCH ? LIMIT 20", (q,)).fetchall()
|
||||
pages = [{"type": "page", "id": r["id"], "title": r["title"], "snippet": r["snippet"]} for r in rows]
|
||||
except Exception:
|
||||
rows = conn.execute("SELECT id, title FROM pages WHERE title LIKE ? OR content LIKE ? LIMIT 20", (like, like)).fetchall()
|
||||
pages = [{"type": "page", "id": r["id"], "title": r["title"]} for r in rows]
|
||||
# collections
|
||||
colls = conn.execute("SELECT id, name FROM collections WHERE name LIKE ? LIMIT 10", (like,)).fetchall()
|
||||
results = pages + [{"type": "collection", "id": r["id"], "title": r["name"]} for r in colls]
|
||||
return {"query": q, "results": results}
|
||||
|
||||
|
||||
@router.get("/admin/users")
|
||||
def admin_list_users_v2(request: Request, limit: int = 30, offset: int = 0, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not user.get("is_admin") and not has_scope(user.get("_token_scopes"), "admin"):
|
||||
raise HTTPException(403, "Admin scope required")
|
||||
limit = max(1, min(limit, 100))
|
||||
with get_conn() as conn:
|
||||
total = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||
rows = conn.execute("SELECT id, login, full_name, email, is_admin, is_active, created_at FROM users ORDER BY id LIMIT ? OFFSET ?", (limit, offset)).fetchall()
|
||||
return {"users": [row_to_dict(r) for r in rows], "total": total, "limit": limit, "offset": offset}
|
||||
@@ -0,0 +1,151 @@
|
||||
"""FlowDeck — Public API v2 : properties.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Header, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
|
||||
audit_log,
|
||||
check_idempotency,
|
||||
check_v2_rate_limit,
|
||||
get_bearer_user,
|
||||
has_scope,
|
||||
paginate_headers,
|
||||
parse_pagination,
|
||||
require_scope,
|
||||
row_to_dict,
|
||||
store_idempotency,
|
||||
to_iso8601,
|
||||
validate_scopes_input,
|
||||
)
|
||||
|
||||
from ._common import _v2_rate_check
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["api-v2"])
|
||||
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/properties")
|
||||
def create_property_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "name is required")
|
||||
ptype = body.get("prop_type") or body.get("type") or "text"
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone():
|
||||
raise HTTPException(404, "Collection not found")
|
||||
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_properties WHERE collection_id=?", (collection_id,)).fetchone()[0]
|
||||
try:
|
||||
cur = conn.execute("INSERT INTO collection_properties (collection_id, name, prop_type, options_json, number_format, position, required, visible_in_views, validation_json, group_name) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", (collection_id, name, ptype, json.dumps(body.get("options") or []), body.get("number_format") or "number", max_pos, int(bool(body.get("required"))), int(bool(body.get("visible_in_views", True))), json.dumps(body.get("validation") or {}), (body.get("group_name") or "").strip()))
|
||||
conn.commit()
|
||||
pid = cur.lastrowid
|
||||
except Exception as e:
|
||||
raise HTTPException(409, f"Property exists: {e}") from None
|
||||
audit_log(user, "property.create", "property", pid, name, request)
|
||||
return {"id": pid, "name": name, "prop_type": ptype, "status": "created"}
|
||||
|
||||
|
||||
@router.patch("/properties/{prop_id}")
|
||||
def patch_property_v2(prop_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM collection_properties WHERE id=?", (prop_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Property not found")
|
||||
name = body.get("name", row["name"])
|
||||
opts = json.dumps(body.get("options", json.loads(row["options_json"] or "[]")))
|
||||
nf = body.get("number_format", row["number_format"])
|
||||
req = int(bool(body.get("required", row["required"])))
|
||||
vis = int(bool(body.get("visible_in_views", row["visible_in_views"])))
|
||||
vj = json.dumps(body.get("validation", json.loads(row["validation_json"] or "{}"))) if "validation" in body else (row["validation_json"] if "validation_json" in row.keys() else "{}")
|
||||
grp = body.get("group_name", row["group_name"] if "group_name" in row.keys() else "")
|
||||
conn.execute("UPDATE collection_properties SET name=?, options_json=?, number_format=?, required=?, visible_in_views=?, validation_json=?, group_name=? WHERE id=?", (name, opts, nf, req, vis, vj, grp, prop_id))
|
||||
conn.commit()
|
||||
audit_log(user, "property.update", "property", prop_id, "", request)
|
||||
return {"id": prop_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/properties/{prop_id}")
|
||||
def delete_property_v2(prop_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM collection_properties WHERE id=?", (prop_id,)).fetchone():
|
||||
raise HTTPException(404, "Property not found")
|
||||
conn.execute("DELETE FROM collection_properties WHERE id=?", (prop_id,))
|
||||
conn.commit()
|
||||
audit_log(user, "property.delete", "property", prop_id, "", request)
|
||||
return {"id": prop_id, "status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/properties/{prop_id}/relation")
|
||||
def create_relation_v2(prop_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
related_id = body.get("related_collection_id")
|
||||
reverse = (body.get("reverse_name") or "").strip()
|
||||
if not related_id:
|
||||
raise HTTPException(400, "related_collection_id required")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT collection_id FROM collection_properties WHERE id=?", (prop_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Property not found")
|
||||
conn.execute("UPDATE collection_properties SET prop_type='relation', related_collection_id=?, reverse_name=? WHERE id=?", (related_id, reverse, prop_id))
|
||||
if reverse:
|
||||
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_properties WHERE collection_id=?", (related_id,)).fetchone()[0]
|
||||
try:
|
||||
conn.execute("INSERT INTO collection_properties (collection_id, name, prop_type, related_collection_id, reverse_name, position) VALUES (?, ?, 'relation', ?, ?, ?)", (related_id, reverse, row["collection_id"], "", max_pos))
|
||||
except Exception:
|
||||
logger.exception("create_relation_v2")
|
||||
conn.commit()
|
||||
return {"id": prop_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.post("/properties/evaluate-formula")
|
||||
def evaluate_formula_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
expr = body.get("expression") or body.get("formula")
|
||||
if not expr:
|
||||
raise HTTPException(400, "expression required")
|
||||
ctx = body.get("context") or {}
|
||||
try:
|
||||
from app.services.formula_engine import FormulaEngine
|
||||
res = FormulaEngine().evaluate(expr, ctx)
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"Formula error: {e}") from None
|
||||
return {"result": res, "expression": expr}
|
||||
|
||||
|
||||
@router.post("/properties/compute-rollup")
|
||||
def compute_rollup_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
for k in ("collection_id", "relation_property_id", "target_property_id", "page_id"):
|
||||
if k not in body:
|
||||
raise HTTPException(400, f"{k} required")
|
||||
try:
|
||||
from app.services.rollup_engine import RollupEngine
|
||||
res = RollupEngine().compute(body["collection_id"], body["relation_property_id"], body["target_property_id"], body["page_id"], body.get("function", "count"))
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"Rollup error: {e}") from None
|
||||
return {"result": res}
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/views")
|
||||
def list_views_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM collection_views WHERE collection_id=? ORDER BY position", (collection_id,)).fetchall()
|
||||
return {"views": [row_to_dict(r) for r in rows]}
|
||||
@@ -0,0 +1,160 @@
|
||||
"""FlowDeck — Public API v2 : sharing.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Header, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
|
||||
audit_log,
|
||||
check_idempotency,
|
||||
check_v2_rate_limit,
|
||||
get_bearer_user,
|
||||
has_scope,
|
||||
paginate_headers,
|
||||
parse_pagination,
|
||||
require_scope,
|
||||
row_to_dict,
|
||||
store_idempotency,
|
||||
to_iso8601,
|
||||
validate_scopes_input,
|
||||
)
|
||||
from app.services.automations import fire_event as _fire_event
|
||||
from app.services.automations import run_event_sync
|
||||
from app.services.publish import fire_published, fire_unpublished, publish, unpublish
|
||||
|
||||
from ._common import _v2_rate_check
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["api-v2"])
|
||||
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/shares")
|
||||
def create_share_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
perm = (body.get("permission") or "view").strip().lower()
|
||||
if perm not in ("view", "comment", "edit"):
|
||||
raise HTTPException(400, "Invalid permission. Use view, comment, or edit")
|
||||
email = (body.get("email") or "").strip()
|
||||
uid = body.get("user_id")
|
||||
if not email and not uid:
|
||||
raise HTTPException(400, "email or user_id required")
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("INSERT INTO page_shares (page_id, shared_with_user_id, shared_with_email, permission, created_by) VALUES (?, ?, ?, ?, ?)", (page_id, uid, email, perm, user["id"]))
|
||||
conn.execute("UPDATE pages SET is_shared=1 WHERE id=?", (page_id,))
|
||||
conn.commit()
|
||||
nid = cur.lastrowid
|
||||
audit_log(user, "share.create", "share", nid, f"page={page_id}", request)
|
||||
try:
|
||||
run_event_sync(_fire_event("page.shared", {"page_id": page_id, "share_id": nid, "permission": perm}))
|
||||
except Exception:
|
||||
logger.exception("create_share_v2")
|
||||
return {"id": nid, "page_id": page_id, "status": "shared"}
|
||||
|
||||
|
||||
@router.patch("/shares/{share_id}")
|
||||
def patch_share_v2(share_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
perm = (body.get("permission") or "").strip().lower()
|
||||
if perm not in ("view", "comment", "edit"):
|
||||
raise HTTPException(400, "Invalid permission")
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM page_shares WHERE id=?", (share_id,)).fetchone():
|
||||
raise HTTPException(404, "Share not found")
|
||||
conn.execute("UPDATE page_shares SET permission=? WHERE id=?", (perm, share_id))
|
||||
conn.commit()
|
||||
return {"id": share_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/shares/{share_id}")
|
||||
def delete_share_v2(share_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT page_id FROM page_shares WHERE id=?", (share_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Share not found")
|
||||
conn.execute("DELETE FROM page_shares WHERE id=?", (share_id,))
|
||||
# unset is_shared if no shares left
|
||||
cnt = conn.execute("SELECT COUNT(*) FROM page_shares WHERE page_id=?", (row["page_id"],)).fetchone()[0]
|
||||
if cnt == 0:
|
||||
conn.execute("UPDATE pages SET is_shared=0 WHERE id=?", (row["page_id"],))
|
||||
conn.commit()
|
||||
return {"id": share_id, "status": "revoked"}
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/publish")
|
||||
def publish_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
slug_in = (body.get("slug") or body.get("publish_slug") or "").strip() or None
|
||||
slug, _title = publish(page_id, explicit_slug=slug_in)
|
||||
audit_log(user, "page.publish", "page", page_id, slug, request)
|
||||
run_event_sync(fire_published(page_id, slug))
|
||||
return {"page_id": page_id, "slug": slug, "url": f"/p/{slug}", "status": "published"}
|
||||
|
||||
|
||||
@router.delete("/pages/{page_id}/publish")
|
||||
def unpublish_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
unpublish(page_id)
|
||||
run_event_sync(fire_unpublished(page_id))
|
||||
return {"page_id": page_id, "status": "unpublished"}
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/history")
|
||||
def list_history_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT h.*, u.login FROM page_history h LEFT JOIN users u ON u.id=h.user_id WHERE h.page_id=? ORDER BY h.created_at DESC", (page_id,)).fetchall()
|
||||
# also page_versions for block pages
|
||||
vrows = conn.execute("SELECT * FROM page_versions WHERE page_id=? ORDER BY created_at DESC", (page_id,)).fetchall()
|
||||
return {"history": [row_to_dict(r) for r in rows], "versions": [row_to_dict(r) for r in vrows]}
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/history/restore")
|
||||
def restore_history_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
hid = body.get("history_id") or body.get("id") or body.get("version_id")
|
||||
if not hid:
|
||||
raise HTTPException(400, "history_id required")
|
||||
with get_conn() as conn:
|
||||
h = conn.execute("SELECT * FROM page_versions WHERE id=? AND page_id=?", (hid, page_id)).fetchone()
|
||||
if h:
|
||||
conn.execute("UPDATE pages SET content=?, title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (h["blocks_json"], h["title"], page_id))
|
||||
conn.commit()
|
||||
return {"page_id": page_id, "restored_version": hid, "status": "restored"}
|
||||
h2 = conn.execute("SELECT * FROM page_history WHERE id=? AND page_id=?", (hid, page_id)).fetchone()
|
||||
if h2:
|
||||
try:
|
||||
snap = json.loads(h2["snapshot_json"] or "{}")
|
||||
except Exception:
|
||||
snap = {}
|
||||
# best-effort restore content
|
||||
if snap.get("content"):
|
||||
conn.execute("UPDATE pages SET content=? WHERE id=?", (snap["content"], page_id))
|
||||
conn.commit()
|
||||
return {"page_id": page_id, "restored_version": hid, "status": "restored"}
|
||||
raise HTTPException(404, "History not found")
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/sprints")
|
||||
def list_sprints_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
limit, offset = parse_pagination(request)
|
||||
with get_conn() as conn:
|
||||
total = conn.execute("SELECT COUNT(*) FROM sprints WHERE collection_id=?", (collection_id,)).fetchone()[0]
|
||||
rows = conn.execute("SELECT * FROM sprints WHERE collection_id=? ORDER BY created_at DESC LIMIT ? OFFSET ?", (collection_id, limit, offset)).fetchall()
|
||||
return {"sprints": [row_to_dict(r) for r in rows], "total": total, "limit": limit, "offset": offset}
|
||||
@@ -0,0 +1,346 @@
|
||||
"""FlowDeck — Public API v2 : templates_io.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Header, HTTPException, Request
|
||||
from fastapi.responses import Response
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
|
||||
audit_log,
|
||||
check_idempotency,
|
||||
check_v2_rate_limit,
|
||||
get_bearer_user,
|
||||
has_scope,
|
||||
paginate_headers,
|
||||
parse_pagination,
|
||||
require_scope,
|
||||
row_to_dict,
|
||||
store_idempotency,
|
||||
to_iso8601,
|
||||
validate_scopes_input,
|
||||
)
|
||||
|
||||
from ._common import _v2_rate_check
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["api-v2"])
|
||||
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/templates")
|
||||
def create_template_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
name = (body.get("name") or "Template").strip()
|
||||
pv = json.dumps(body.get("property_values") or body.get("property_values_json") or {})
|
||||
cj = json.dumps(body.get("content") or body.get("content_json") or [])
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("INSERT INTO page_templates (collection_id, name, property_values_json, content_json) VALUES (?, ?, ?, ?)", (collection_id, name, pv, cj))
|
||||
tid = cur.lastrowid
|
||||
conn.commit()
|
||||
return {"id": tid, "name": name, "status": "created"}
|
||||
|
||||
|
||||
@router.patch("/templates/{template_id}")
|
||||
def patch_template_v2(template_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM page_templates WHERE id=?", (template_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Template not found")
|
||||
name = body.get("name", row["name"])
|
||||
pv = json.dumps(body.get("property_values", json.loads(row["property_values_json"] or "{}"))) if "property_values" in body else row["property_values_json"]
|
||||
cj = json.dumps(body.get("content", json.loads(row["content_json"] or "[]"))) if "content" in body else row["content_json"]
|
||||
conn.execute("UPDATE page_templates SET name=?, property_values_json=?, content_json=? WHERE id=?", (name, pv, cj, template_id))
|
||||
conn.commit()
|
||||
return {"id": template_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/templates/{template_id}")
|
||||
def delete_template_v2(template_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM page_templates WHERE id=?", (template_id,))
|
||||
conn.commit()
|
||||
return {"id": template_id, "status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/templates/{template_id}/apply")
|
||||
def apply_template_v2(template_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
tpl = conn.execute("SELECT * FROM page_templates WHERE id=?", (template_id,)).fetchone()
|
||||
if not tpl:
|
||||
raise HTTPException(404, "Template not found")
|
||||
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_pages WHERE collection_id=?", (tpl["collection_id"],)).fetchone()[0]
|
||||
pv = tpl["property_values_json"] or "{}"
|
||||
cur = conn.execute("INSERT INTO collection_pages (collection_id, title, position, property_values_json) VALUES (?, ?, ?, ?)", (tpl["collection_id"], tpl["name"], max_pos, pv))
|
||||
pid = cur.lastrowid
|
||||
conn.commit()
|
||||
return {"template_id": template_id, "page_id": pid, "status": "applied"}
|
||||
|
||||
|
||||
@router.get("/templates/database")
|
||||
def list_db_templates_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM database_templates ORDER BY name").fetchall()
|
||||
return {"templates": [row_to_dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/templates/database/{template_id}/apply")
|
||||
def apply_db_template_v2(template_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
tpl = conn.execute("SELECT * FROM database_templates WHERE id=?", (template_id,)).fetchone()
|
||||
if not tpl:
|
||||
raise HTTPException(404, "Template not found")
|
||||
name = (body.get("name") or tpl["name"]).strip()
|
||||
schema = json.loads(tpl["schema_json"] or "[]")
|
||||
cur = conn.execute("INSERT INTO collections (name, description, icon, schema_json, workspace_id, created_by) VALUES (?, ?, ?, ?, ?, ?)", (name, tpl["description"], tpl["icon"] if "icon" in tpl.keys() else "📋", json.dumps(schema), body.get("workspace_id"), user["id"]))
|
||||
cid = cur.lastrowid
|
||||
# A25 : pas de try — un échec de matérialisation doit interrompre la
|
||||
# transaction plutôt que de commiter une collection sans schéma.
|
||||
from app.services.db_templates import materialize_properties
|
||||
materialize_properties(conn, cid, schema)
|
||||
conn.commit()
|
||||
return {"collection_id": cid, "name": name, "status": "created"}
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/export")
|
||||
def export_page_v2(page_id: int, request: Request, format: str = "markdown", authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
fmt = (format or request.query_params.get("format") or "markdown").lower()
|
||||
if fmt not in ("markdown", "html", "pdf"):
|
||||
raise HTTPException(400, "format must be markdown, html or pdf")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
# try collection_pages
|
||||
row2 = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row2:
|
||||
raise HTTPException(404, "Page not found")
|
||||
# collection pages: return JSON
|
||||
return {"page": row_to_dict(row2), "format": fmt}
|
||||
# block pages: delegate to export service
|
||||
from app.services.export import export_page as _export
|
||||
try:
|
||||
data, mime, fname = _export(row, fmt) # type: ignore
|
||||
return Response(content=data, media_type=mime, headers={"Content-Disposition": f'attachment; filename="{fname}"'})
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Export failed: {e}") from None
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/export/csv")
|
||||
def export_csv_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
import csv
|
||||
import io
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone():
|
||||
raise HTTPException(404, "Collection not found")
|
||||
props = [dict(r) for r in conn.execute("SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position", (collection_id,)).fetchall()]
|
||||
rows = conn.execute("SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position", (collection_id,)).fetchall()
|
||||
out = io.StringIO()
|
||||
writer = csv.writer(out)
|
||||
header = ["Title"] + [p["name"] for p in props]
|
||||
writer.writerow(header)
|
||||
for r in rows:
|
||||
try:
|
||||
pv = json.loads(r["property_values_json"] or "{}")
|
||||
except Exception:
|
||||
pv = {}
|
||||
vals = [r["title"]]
|
||||
for p in props:
|
||||
vals.append(str(pv.get(str(p["id"])) or pv.get(p["name"]) or ""))
|
||||
writer.writerow(vals)
|
||||
return Response(content=out.getvalue().encode("utf-8"), media_type="text/csv", headers={"Content-Disposition": f'attachment; filename="collection-{collection_id}.csv"'})
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/import/csv")
|
||||
async def import_csv_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
try:
|
||||
form = await request.form()
|
||||
file = form.get("file")
|
||||
data = await file.read() if file else b""
|
||||
text = data.decode("utf-8", errors="ignore")
|
||||
except Exception as err:
|
||||
raise HTTPException(400, "file required (multipart)") from err
|
||||
import csv
|
||||
import io
|
||||
reader = csv.DictReader(io.StringIO(text))
|
||||
created = 0
|
||||
with get_conn() as conn:
|
||||
for row in reader:
|
||||
title = row.get("Title") or row.get("title") or "Untitled"
|
||||
# map remaining columns to property names
|
||||
pv = {}
|
||||
# resolve prop name -> id
|
||||
props = {p["name"]: p["id"] for p in conn.execute("SELECT id, name FROM collection_properties WHERE collection_id=?", (collection_id,)).fetchall()}
|
||||
for k, v in row.items():
|
||||
if k in ("Title", "title"):
|
||||
continue
|
||||
pid = props.get(k)
|
||||
if pid:
|
||||
pv[str(pid)] = v
|
||||
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_pages WHERE collection_id=?", (collection_id,)).fetchone()[0]
|
||||
conn.execute("INSERT INTO collection_pages (collection_id, title, position, property_values_json) VALUES (?, ?, ?, ?)", (collection_id, title, max_pos, json.dumps(pv)))
|
||||
created += 1
|
||||
conn.commit()
|
||||
return {"imported": created, "status": "ok"}
|
||||
|
||||
|
||||
# ── Registre unifié (§10.2) : liste / détail / instantiate / export / import ──
|
||||
# (chemins /fd-templates/* : les /templates/* historiques restent inchangés)
|
||||
|
||||
def _v2_actor(user) -> dict:
|
||||
return {"id": user.get("id"), "login": user.get("login", ""),
|
||||
"full_name": user.get("full_name", ""),
|
||||
"is_admin": bool(user.get("is_admin"))}
|
||||
|
||||
|
||||
@router.get("/fd-templates")
|
||||
def list_fd_templates_v2(request: Request, authorization: str | None = Header(default=None),
|
||||
kind: str | None = None, q: str | None = None):
|
||||
from app.services.fd_templates import TemplateService
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
items = TemplateService.list_templates(conn, _v2_actor(user), kind=kind, query=q)
|
||||
return {"templates": items}
|
||||
|
||||
|
||||
@router.get("/fd-templates/{template_id}")
|
||||
def get_fd_template_v2(template_id: str, request: Request, authorization: str | None = Header(default=None)):
|
||||
from app.services.fd_templates import TemplateService, _row_to_dto
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
row = TemplateService.get_template(conn, _v2_actor(user), template_id)
|
||||
return _row_to_dto(conn, row)
|
||||
except LookupError:
|
||||
raise HTTPException(404, "Template not found")
|
||||
except PermissionError:
|
||||
raise HTTPException(403, "Forbidden")
|
||||
|
||||
|
||||
@router.post("/fd-templates/{template_id}/instantiate")
|
||||
def instantiate_fd_template_v2(template_id: str, request: Request,
|
||||
authorization: str | None = Header(default=None),
|
||||
body: dict = Body(default={})):
|
||||
from app.services.fd_templates import TemplateService
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
idem = request.headers.get("Idempotency-Key") or body.get("idempotency_key")
|
||||
if idem:
|
||||
hit = check_idempotency(request, user["id"])
|
||||
if hit:
|
||||
return hit["data"]
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
result = TemplateService.instantiate(
|
||||
conn, _v2_actor(user), template_id, body.get("variables") or {},
|
||||
body.get("context") or {}, idempotency_key=idem, run_source="api")
|
||||
except LookupError:
|
||||
raise HTTPException(404, "Template not found")
|
||||
except PermissionError:
|
||||
raise HTTPException(403, "Forbidden")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc))
|
||||
audit_log(user, "template.instantiate", "template", template_id, "")
|
||||
if idem:
|
||||
store_idempotency(idem, user["id"], result, 200)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/fd-templates/{template_id}/export")
|
||||
def export_fd_template_v2(template_id: str, request: Request, authorization: str | None = Header(default=None)):
|
||||
"""Export au format manifeste flowdeck-template v1 (annexe B)."""
|
||||
from app.services.fd_templates import TemplateService, _read_holder_blocks, _row_to_dto
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
row = TemplateService.get_template(conn, _v2_actor(user), template_id)
|
||||
dto = _row_to_dto(conn, row)
|
||||
blocks, _w = _read_holder_blocks(conn, row)
|
||||
except LookupError:
|
||||
raise HTTPException(404, "Template not found")
|
||||
except PermissionError:
|
||||
raise HTTPException(403, "Forbidden")
|
||||
manifest = {
|
||||
"format": "flowdeck-template", "version": 1, "kind": dto["kind"],
|
||||
"name": dto["name"], "description": dto["description"], "icon": dto["icon"],
|
||||
"category": dto["category"], "variables": dto["variables"],
|
||||
"manifest": dto["manifest"], "content": {"content_format": "blocks", "blocks": blocks},
|
||||
}
|
||||
if dto.get("recurrence"):
|
||||
manifest["recurrence"] = {"rrule": dto["recurrence"].get("rrule"),
|
||||
"timezone": dto["recurrence"].get("timezone", "")}
|
||||
return manifest
|
||||
|
||||
|
||||
@router.post("/fd-templates/import")
|
||||
def import_fd_template_v2(request: Request, authorization: str | None = Header(default=None),
|
||||
body: dict = Body(default={})):
|
||||
"""Import d'un manifeste flowdeck-template v1 (scope personnel, §14)."""
|
||||
from app.services.fd_templates import KINDS, TemplateService
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if body.get("format") != "flowdeck-template":
|
||||
raise HTTPException(400, "format must be flowdeck-template")
|
||||
try:
|
||||
version = int(body.get("version") or 0)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, "version invalide")
|
||||
if version > 1:
|
||||
raise HTTPException(400, "version de manifeste non supportée")
|
||||
kind = body.get("kind") or "page"
|
||||
if kind not in KINDS:
|
||||
raise HTTPException(400, "kind inconnu")
|
||||
blocks = ((body.get("content") or {}).get("blocks") or [])
|
||||
if not isinstance(blocks, list) or len(blocks) > 2000:
|
||||
raise HTTPException(400, "content.blocks invalide ou trop volumineux")
|
||||
target = body.get("target") or {}
|
||||
spec = {"kind": kind, "name": (body.get("name") or "Sans titre")[:200],
|
||||
"description": (body.get("description") or "")[:2000],
|
||||
"icon": body.get("icon") or "📄", "category": (body.get("category") or "")[:120],
|
||||
"scope": "personal", "blocks": blocks,
|
||||
"manifest": body.get("manifest") or {}, "variables": body.get("variables") or [],
|
||||
"title_template": (body.get("manifest") or {}).get("title_template", "")}
|
||||
if kind == "row":
|
||||
# La base cible est résolue par nom dans le workspace, jamais par id
|
||||
# externe ; si absente, le template est créé « orphelin » et signalé.
|
||||
cname = target.get("collection_name", "")
|
||||
with get_conn() as conn:
|
||||
actor = _v2_actor(user)
|
||||
row = conn.execute("SELECT id FROM collections WHERE name=?", (cname,)).fetchone() if cname else None
|
||||
if row:
|
||||
spec["target_collection_id"] = row["id"]
|
||||
dto = TemplateService.create_template(conn, actor, spec)
|
||||
conn.commit()
|
||||
return {"id": dto["id"], "status": "imported"}
|
||||
# Orphelin personnel : on choisit la première base comme pis-aller ? Non :
|
||||
# le contrat exige un signalement — on refuse proprement.
|
||||
raise HTTPException(400, "collection cible introuvable : match_by=collection_name")
|
||||
with get_conn() as conn:
|
||||
dto = TemplateService.create_template(conn, _v2_actor(user), spec)
|
||||
conn.commit()
|
||||
audit_log(user, "template.import", "template", dto["id"], "")
|
||||
return {"id": dto["id"], "status": "imported"}
|
||||
@@ -0,0 +1,164 @@
|
||||
"""FlowDeck — Public API v2 : views.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Header, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
|
||||
audit_log,
|
||||
check_idempotency,
|
||||
check_v2_rate_limit,
|
||||
get_bearer_user,
|
||||
has_scope,
|
||||
paginate_headers,
|
||||
parse_pagination,
|
||||
require_scope,
|
||||
row_to_dict,
|
||||
store_idempotency,
|
||||
to_iso8601,
|
||||
validate_scopes_input,
|
||||
)
|
||||
from app.services.automations import fire_event as _fire_event
|
||||
from app.services.automations import run_event_sync
|
||||
|
||||
from ._common import _v2_rate_check
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["api-v2"])
|
||||
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/views")
|
||||
def create_view_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
name = (body.get("name") or "New View").strip()
|
||||
vtype = body.get("view_type") or body.get("type") or "table"
|
||||
config = body.get("config") or body.get("config_json") or {}
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone():
|
||||
raise HTTPException(404, "Collection not found")
|
||||
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_views WHERE collection_id=?", (collection_id,)).fetchone()[0]
|
||||
cur = conn.execute("INSERT INTO collection_views (collection_id, name, view_type, config_json, position, created_by) VALUES (?, ?, ?, ?, ?, ?)", (collection_id, name, vtype, json.dumps(config), max_pos, user["id"]))
|
||||
vid = cur.lastrowid
|
||||
conn.commit()
|
||||
audit_log(user, "view.create", "view", vid, name, request)
|
||||
try:
|
||||
run_event_sync(_fire_event("collection.view.created", {"view_id": vid, "collection_id": collection_id, "name": name, "view_type": vtype}))
|
||||
except Exception:
|
||||
logger.exception("create_view_v2")
|
||||
return {"id": vid, "name": name, "view_type": vtype, "status": "created"}
|
||||
|
||||
|
||||
@router.patch("/views/{view_id}")
|
||||
def patch_view_v2(view_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "View not found")
|
||||
cfg = json.loads(row["config_json"] or "{}")
|
||||
if "config" in body:
|
||||
cfg.update(body["config"])
|
||||
elif "config_json" in body:
|
||||
try:
|
||||
cfg.update(json.loads(body["config_json"]) if isinstance(body["config_json"], str) else body["config_json"])
|
||||
except Exception:
|
||||
logger.exception("patch_view_v2")
|
||||
# also flat keys
|
||||
for k in ("group_by", "sub_group_by", "wip_limits", "card_size", "cover_property", "cover_mode", "card_properties", "visible_properties", "filters", "sorts", "date_property"):
|
||||
if k in body:
|
||||
cfg[k] = body[k]
|
||||
name = body.get("name", row["name"])
|
||||
vtype = body.get("view_type") or body.get("type") or row["view_type"]
|
||||
conn.execute("UPDATE collection_views SET name=?, view_type=?, config_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (name, vtype, json.dumps(cfg), view_id))
|
||||
conn.commit()
|
||||
audit_log(user, "view.update", "view", view_id, "", request)
|
||||
return {"id": view_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/views/{view_id}")
|
||||
def delete_view_v2(view_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM collection_views WHERE id=?", (view_id,)).fetchone():
|
||||
raise HTTPException(404, "View not found")
|
||||
conn.execute("DELETE FROM collection_views WHERE id=?", (view_id,))
|
||||
conn.commit()
|
||||
audit_log(user, "view.delete", "view", view_id, "", request)
|
||||
return {"id": view_id, "status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/views/{view_id}/save-as")
|
||||
def save_as_view_v2(view_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
name = (body.get("name") or "").strip() or "Copy"
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "View not found")
|
||||
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_views WHERE collection_id=?", (row["collection_id"],)).fetchone()[0]
|
||||
cur = conn.execute("INSERT INTO collection_views (collection_id, name, view_type, config_json, position, created_by) VALUES (?, ?, ?, ?, ?, ?)", (row["collection_id"], name, row["view_type"], row["config_json"], max_pos, user["id"]))
|
||||
nid = cur.lastrowid
|
||||
conn.commit()
|
||||
return {"id": nid, "name": name, "status": "created"}
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/dashboards")
|
||||
def list_dashboards_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM collection_dashboards WHERE collection_id=? ORDER BY created_at", (collection_id,)).fetchall()
|
||||
return {"dashboards": [row_to_dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/dashboards")
|
||||
def create_dashboard_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
name = (body.get("name") or "Dashboard").strip()
|
||||
layout = body.get("layout") or body.get("layout_json") or {"columns": 1, "widgets": []}
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("INSERT INTO collection_dashboards (collection_id, name, layout_json) VALUES (?, ?, ?)", (collection_id, name, json.dumps(layout)))
|
||||
did = cur.lastrowid
|
||||
conn.commit()
|
||||
return {"id": did, "name": name, "status": "created"}
|
||||
|
||||
|
||||
@router.patch("/dashboards/{dashboard_id}")
|
||||
def patch_dashboard_v2(dashboard_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM collection_dashboards WHERE id=?", (dashboard_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Dashboard not found")
|
||||
name = body.get("name", row["name"])
|
||||
layout = body.get("layout") or body.get("layout_json")
|
||||
if layout is not None:
|
||||
layout_json = json.dumps(layout)
|
||||
else:
|
||||
layout_json = row["layout_json"]
|
||||
conn.execute("UPDATE collection_dashboards SET name=?, layout_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (name, layout_json, dashboard_id))
|
||||
conn.commit()
|
||||
return {"id": dashboard_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/dashboards/{dashboard_id}")
|
||||
def delete_dashboard_v2(dashboard_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM collection_dashboards WHERE id=?", (dashboard_id,))
|
||||
conn.commit()
|
||||
return {"id": dashboard_id, "status": "deleted"}
|
||||
@@ -0,0 +1,195 @@
|
||||
"""FlowDeck — Public API v2 : webhooks.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Header, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
|
||||
audit_log,
|
||||
check_idempotency,
|
||||
check_v2_rate_limit,
|
||||
get_bearer_user,
|
||||
has_scope,
|
||||
paginate_headers,
|
||||
parse_pagination,
|
||||
require_scope,
|
||||
row_to_dict,
|
||||
store_idempotency,
|
||||
to_iso8601,
|
||||
validate_scopes_input,
|
||||
)
|
||||
|
||||
from ._common import _v2_rate_check
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["api-v2"])
|
||||
|
||||
|
||||
|
||||
@router.get("/webhooks")
|
||||
def list_webhooks_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
limit, offset = parse_pagination(request)
|
||||
with get_conn() as conn:
|
||||
total = conn.execute("SELECT COUNT(*) AS n FROM webhook_subscriptions").fetchone()["n"]
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM webhook_subscriptions ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
||||
(limit, offset),
|
||||
).fetchall()
|
||||
return JSONResponse(
|
||||
content={"webhooks": [dict(r) for r in rows]},
|
||||
headers=paginate_headers(total),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/webhooks")
|
||||
def create_webhook_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
from app.services.webhook_outbound import EVENTS, _event_matches
|
||||
url = (body.get("url") or "").strip()
|
||||
event = (body.get("event") or "page.created").strip()
|
||||
secret = (body.get("secret") or "").strip()
|
||||
if not url or not url.startswith("http"):
|
||||
raise HTTPException(400, "url must start with http")
|
||||
if not event or (event not in EVENTS and not (event.endswith(".*") or event in ("*", "all"))):
|
||||
raise HTTPException(400, f"Unknown event '{event}'. See GET /api/v2/webhooks/events")
|
||||
# make sure the pattern matches at least one known event
|
||||
if not any(_event_matches(event, e) for e in EVENTS):
|
||||
raise HTTPException(400, f"Event pattern '{event}' matches no known event")
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("INSERT INTO webhook_subscriptions (url, event, secret) VALUES (?, ?, ?)", (url, event, secret))
|
||||
wid = cur.lastrowid
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM webhook_subscriptions WHERE id=?", (wid,)).fetchone()
|
||||
audit_log(user, "webhook.create", "webhook", wid, url, request)
|
||||
return {"id": wid, "status": "created", "webhook": dict(row) if row else {},
|
||||
"signature_header": "X-FlowDeck-Signature (HMAC-SHA256, sha256=<hex>)" if secret else None}
|
||||
|
||||
|
||||
@router.patch("/webhooks/{webhook_id}")
|
||||
def patch_webhook_v2(webhook_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM webhook_subscriptions WHERE id=?", (webhook_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Webhook not found")
|
||||
url = body.get("url", row["url"])
|
||||
event = body.get("event", row["event"])
|
||||
secret = body.get("secret", row["secret"])
|
||||
active = int(bool(body.get("active", row["active"])))
|
||||
conn.execute("UPDATE webhook_subscriptions SET url=?, event=?, secret=?, active=? WHERE id=?", (url, event, secret, active, webhook_id))
|
||||
conn.commit()
|
||||
audit_log(user, "webhook.update", "webhook", webhook_id, "", request)
|
||||
return {"id": webhook_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/webhooks/{webhook_id}")
|
||||
def delete_webhook_v2(webhook_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM webhook_subscriptions WHERE id=?", (webhook_id,))
|
||||
conn.commit()
|
||||
audit_log(user, "webhook.delete", "webhook", webhook_id, "", request)
|
||||
return {"id": webhook_id, "status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/webhooks/{webhook_id}/test")
|
||||
async def test_webhook_v2(webhook_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM webhook_subscriptions WHERE id=?", (webhook_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Webhook not found")
|
||||
# live delivery via the prod dispatcher (HMAC + retry + journal),
|
||||
# direct to this subscription only (no wildcard fan-out)
|
||||
from app.services.webhook_outbound import deliver_to_sub
|
||||
ok = await deliver_to_sub(webhook_id, row["url"], "ping",
|
||||
{"webhook_id": webhook_id, "test": True},
|
||||
row["secret"] or "")
|
||||
audit_log(user, "webhook.test", "webhook", webhook_id, f"ok={ok}", request)
|
||||
return {"webhook_id": webhook_id, "status": "tested", "delivered": ok}
|
||||
|
||||
|
||||
@router.get("/webhooks/{webhook_id}/deliveries")
|
||||
def list_deliveries_v2(webhook_id: int, request: Request,
|
||||
status: str | None = None,
|
||||
authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
limit, offset = parse_pagination(request)
|
||||
with get_conn() as conn:
|
||||
if status:
|
||||
total = conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM webhook_deliveries WHERE webhook_id=? AND status=?",
|
||||
(webhook_id, status)).fetchone()["n"]
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM webhook_deliveries WHERE webhook_id=? AND status=? "
|
||||
"ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
||||
(webhook_id, status, limit, offset)).fetchall()
|
||||
else:
|
||||
total = conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM webhook_deliveries WHERE webhook_id=?",
|
||||
(webhook_id,)).fetchone()["n"]
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM webhook_deliveries WHERE webhook_id=? "
|
||||
"ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
||||
(webhook_id, limit, offset)).fetchall()
|
||||
return JSONResponse(
|
||||
content={"deliveries": [row_to_dict(r) for r in rows]},
|
||||
headers=paginate_headers(total),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/webhooks/{webhook_id}/retry")
|
||||
async def retry_webhook_deliveries(webhook_id: int, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
"""Manually retry failed deliveries for a webhook."""
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
|
||||
from app.services.webhook_outbound import retry_due_deliveries
|
||||
|
||||
with get_conn() as conn:
|
||||
# Force retry by setting next_retry_at to the past
|
||||
conn.execute(
|
||||
"""UPDATE webhook_deliveries
|
||||
SET next_retry_at = strftime('%s', 'now', '-1 second')
|
||||
WHERE webhook_id = ? AND status = 'retrying'""",
|
||||
(webhook_id,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
retried = await retry_due_deliveries()
|
||||
audit_log(user, "webhook.retry", "webhook", webhook_id, f"retried={retried}", request)
|
||||
return {"webhook_id": webhook_id, "status": "retried", "retried_count": retried}
|
||||
|
||||
|
||||
@router.post("/webhooks/verify-signature")
|
||||
def verify_webhook_signature(request: Request,
|
||||
authorization: str | None = Header(default=None),
|
||||
body: dict = Body(default={})):
|
||||
"""Verify a webhook signature (for debugging/testing)."""
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
|
||||
from app.services.webhook_outbound import verify_signature
|
||||
|
||||
secret = body.get("secret", "")
|
||||
payload = body.get("payload", "{}")
|
||||
signature = body.get("signature", "")
|
||||
|
||||
is_valid = verify_signature(secret, payload.encode(), signature)
|
||||
|
||||
audit_log(user, "webhook.signature_verify", "webhook", 0, f"valid={is_valid}", request)
|
||||
return {"valid": is_valid, "secret": secret[:10] + "..." if len(secret) > 10 else secret}
|
||||
@@ -0,0 +1,230 @@
|
||||
"""FlowDeck — Public API v2 : workspaces.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Header, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
|
||||
audit_log,
|
||||
check_idempotency,
|
||||
check_v2_rate_limit,
|
||||
get_bearer_user,
|
||||
has_scope,
|
||||
paginate_headers,
|
||||
parse_pagination,
|
||||
require_scope,
|
||||
row_to_dict,
|
||||
store_idempotency,
|
||||
to_iso8601,
|
||||
validate_scopes_input,
|
||||
)
|
||||
|
||||
from ._common import _v2_rate_check
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["api-v2"])
|
||||
|
||||
|
||||
|
||||
@router.get("/workspaces")
|
||||
def list_workspaces(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
limit, offset = parse_pagination(request)
|
||||
with get_conn() as conn:
|
||||
total = conn.execute("SELECT COUNT(*) FROM workspaces WHERE owner_id=? OR id IN (SELECT workspace_id FROM workspace_members WHERE user_id=?)", (user["id"], user["id"])).fetchone()[0]
|
||||
rows = conn.execute("SELECT w.*, wm.role FROM workspaces w LEFT JOIN workspace_members wm ON wm.workspace_id=w.id AND wm.user_id=? WHERE w.owner_id=? OR w.id IN (SELECT workspace_id FROM workspace_members WHERE user_id=?) ORDER BY w.created_at DESC LIMIT ? OFFSET ?", (user["id"], user["id"], user["id"], limit, offset)).fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
d["created_at"] = to_iso8601(d.get("created_at"))
|
||||
try:
|
||||
d["settings"] = json.loads(d.get("settings_json") or "{}")
|
||||
except Exception:
|
||||
d["settings"] = {}
|
||||
out.append(d)
|
||||
return {"workspaces": out, "total": total, "limit": limit, "offset": offset}
|
||||
|
||||
|
||||
@router.post("/workspaces")
|
||||
def create_workspace(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
idem = check_idempotency(request, user["id"])
|
||||
if idem:
|
||||
return JSONResponse(content=idem["data"], status_code=idem["status"])
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "name is required")
|
||||
settings_json = json.dumps(body.get("settings") or body.get("settings_json") or {})
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("INSERT INTO workspaces (name, owner_id, settings_json) VALUES (?, ?, ?)", (name, user["id"], settings_json))
|
||||
wid = cur.lastrowid
|
||||
# owner is implicitly admin member
|
||||
try:
|
||||
conn.execute("INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, 'owner')", (wid, user["id"]))
|
||||
except Exception:
|
||||
logger.exception("create_workspace")
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM workspaces WHERE id=?", (wid,)).fetchone()
|
||||
audit_log(user, "workspace.create", "workspace", wid, name, request)
|
||||
data = {"id": wid, "name": name, "owner_id": user["id"], "status": "created", "workspace": row_to_dict(row)}
|
||||
key = (request.headers.get("Idempotency-Key") or "").strip()
|
||||
if key:
|
||||
store_idempotency(key, user["id"], data, 200)
|
||||
return JSONResponse(content=data, status_code=201)
|
||||
|
||||
|
||||
@router.get("/workspaces/{workspace_id}")
|
||||
def get_workspace(workspace_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Workspace not found")
|
||||
# ACL: must be member or owner
|
||||
member = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?", (workspace_id, user["id"])).fetchone()
|
||||
is_owner = row["owner_id"] == user["id"]
|
||||
if not is_owner and not member and not user.get("is_admin"):
|
||||
raise HTTPException(404, "Workspace not found")
|
||||
members = conn.execute("SELECT u.id, u.login, u.full_name, u.avatar_url, wm.role FROM workspace_members wm JOIN users u ON u.id=wm.user_id WHERE wm.workspace_id=? ORDER BY wm.joined_at", (workspace_id,)).fetchall()
|
||||
d = row_to_dict(row)
|
||||
d["members"] = [dict(m) for m in members]
|
||||
return d
|
||||
|
||||
|
||||
@router.patch("/workspaces/{workspace_id}")
|
||||
def patch_workspace(workspace_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Workspace not found")
|
||||
if row["owner_id"] != user["id"] and not user.get("is_admin"):
|
||||
# check admin member
|
||||
mem = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?", (workspace_id, user["id"])).fetchone()
|
||||
if not mem or mem["role"] not in ("owner", "admin"):
|
||||
raise HTTPException(403, "Only owner/admin can edit workspace")
|
||||
name = body.get("name", row["name"])
|
||||
sj = body.get("settings_json") or body.get("settings")
|
||||
if sj is not None:
|
||||
sj = json.dumps(sj) if isinstance(sj, (dict, list)) else str(sj)
|
||||
else:
|
||||
sj = row["settings_json"]
|
||||
conn.execute("UPDATE workspaces SET name=?, settings_json=? WHERE id=?", (name, sj, workspace_id))
|
||||
conn.commit()
|
||||
audit_log(user, "workspace.update", "workspace", workspace_id, "", request)
|
||||
return {"id": workspace_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/workspaces/{workspace_id}")
|
||||
def delete_workspace(workspace_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Workspace not found")
|
||||
if row["owner_id"] != user["id"] and not user.get("is_admin"):
|
||||
raise HTTPException(403, "Only owner can delete workspace")
|
||||
conn.execute("DELETE FROM workspaces WHERE id=?", (workspace_id,))
|
||||
conn.commit()
|
||||
audit_log(user, "workspace.delete", "workspace", workspace_id, "", request)
|
||||
return {"id": workspace_id, "status": "deleted"}
|
||||
|
||||
|
||||
@router.get("/workspaces/{workspace_id}/members")
|
||||
def list_workspace_members(workspace_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
ws = conn.execute("SELECT id FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
|
||||
if not ws:
|
||||
raise HTTPException(404, "Workspace not found")
|
||||
rows = conn.execute("SELECT u.id, u.login, u.full_name, u.avatar_url, wm.role, wm.joined_at FROM workspace_members wm JOIN users u ON u.id=wm.user_id WHERE wm.workspace_id=? ORDER BY wm.joined_at", (workspace_id,)).fetchall()
|
||||
return {"members": [row_to_dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/workspaces/{workspace_id}/members")
|
||||
def invite_member(workspace_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
target_id = body.get("user_id") or body.get("uid")
|
||||
email = (body.get("email") or "").strip()
|
||||
role = (body.get("role") or "editor").strip().lower()
|
||||
if role not in ("owner", "admin", "editor", "viewer", "commenter"):
|
||||
raise HTTPException(400, "Invalid role")
|
||||
with get_conn() as conn:
|
||||
ws = conn.execute("SELECT owner_id FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
|
||||
if not ws:
|
||||
raise HTTPException(404, "Workspace not found")
|
||||
# only owner/admin can invite
|
||||
if ws["owner_id"] != user["id"] and not user.get("is_admin"):
|
||||
mem = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?", (workspace_id, user["id"])).fetchone()
|
||||
if not mem or mem["role"] not in ("owner", "admin"):
|
||||
raise HTTPException(403, "Only owner/admin can invite")
|
||||
uid = target_id
|
||||
if not uid and email:
|
||||
u = conn.execute("SELECT id FROM users WHERE email=?", (email,)).fetchone()
|
||||
if not u:
|
||||
raise HTTPException(404, f"User with email {email} not found")
|
||||
uid = u["id"]
|
||||
if not uid:
|
||||
raise HTTPException(400, "user_id or email required")
|
||||
try:
|
||||
conn.execute("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, ?)", (workspace_id, uid, role))
|
||||
except Exception:
|
||||
conn.execute("UPDATE workspace_members SET role=? WHERE workspace_id=? AND user_id=?", (role, workspace_id, uid))
|
||||
conn.commit()
|
||||
audit_log(user, "workspace.invite", "workspace", workspace_id, f"uid={uid} role={role}", request)
|
||||
return {"workspace_id": workspace_id, "user_id": uid, "role": role, "status": "added"}
|
||||
|
||||
|
||||
@router.patch("/workspaces/{workspace_id}/members/{uid}")
|
||||
def update_member_role(workspace_id: int, uid: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
role = (body.get("role") or "").strip().lower()
|
||||
if role not in ("owner", "admin", "editor", "viewer", "commenter"):
|
||||
raise HTTPException(400, "Invalid role")
|
||||
with get_conn() as conn:
|
||||
ws = conn.execute("SELECT owner_id FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
|
||||
if not ws:
|
||||
raise HTTPException(404, "Workspace not found")
|
||||
if ws["owner_id"] != user["id"] and not user.get("is_admin"):
|
||||
mem = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?", (workspace_id, user["id"])).fetchone()
|
||||
if not mem or mem["role"] not in ("owner", "admin"):
|
||||
raise HTTPException(403, "Only owner/admin can change roles")
|
||||
conn.execute("UPDATE workspace_members SET role=? WHERE workspace_id=? AND user_id=?", (role, workspace_id, uid))
|
||||
if conn.total_changes == 0:
|
||||
raise HTTPException(404, "Member not found")
|
||||
conn.commit()
|
||||
audit_log(user, "workspace.role_change", "workspace", workspace_id, f"uid={uid} role={role}", request)
|
||||
return {"workspace_id": workspace_id, "user_id": uid, "role": role, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/workspaces/{workspace_id}/members/{uid}")
|
||||
def remove_member(workspace_id: int, uid: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
ws = conn.execute("SELECT owner_id FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
|
||||
if not ws:
|
||||
raise HTTPException(404, "Workspace not found")
|
||||
if ws["owner_id"] != user["id"] and not user.get("is_admin"):
|
||||
mem = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?", (workspace_id, user["id"])).fetchone()
|
||||
if not mem or mem["role"] not in ("owner", "admin"):
|
||||
raise HTTPException(403, "Only owner/admin can remove members")
|
||||
conn.execute("DELETE FROM workspace_members WHERE workspace_id=? AND user_id=?", (workspace_id, uid))
|
||||
conn.commit()
|
||||
audit_log(user, "workspace.remove_member", "workspace", workspace_id, f"uid={uid}", request)
|
||||
return {"workspace_id": workspace_id, "user_id": uid, "status": "removed"}
|
||||
@@ -156,7 +156,10 @@ async def create_agent_v2(request: Request, authorization: str | None = Header(d
|
||||
return JSONResponse(content=data, status_code=201)
|
||||
|
||||
|
||||
@router.get("/agents/{agent_id}")
|
||||
# v7.46.0 : `{agent_id:int}` — sans le contrainte de type, la routeparametrée
|
||||
# enregistree AVANT `/agents/conversations` capturait le segment « conversations »
|
||||
# et renvoyait 422 (int_parsing) au lieu de la liste des conversations.
|
||||
@router.get("/agents/{agent_id:int}")
|
||||
def get_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
_guard(request, authorization)
|
||||
with get_conn() as conn:
|
||||
@@ -166,7 +169,7 @@ def get_agent_v2(agent_id: int, request: Request, authorization: str | None = He
|
||||
return row_to_dict(row)
|
||||
|
||||
|
||||
@router.put("/agents/{agent_id}")
|
||||
@router.put("/agents/{agent_id:int}")
|
||||
async def update_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization, write=True)
|
||||
body = await _json_body(request)
|
||||
@@ -194,7 +197,7 @@ async def update_agent_v2(agent_id: int, request: Request, authorization: str |
|
||||
return {"id": agent_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/agents/{agent_id}")
|
||||
@router.delete("/agents/{agent_id:int}")
|
||||
def delete_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization, write=True)
|
||||
with get_conn() as conn:
|
||||
|
||||
+2
-1
@@ -475,10 +475,11 @@ def logout(request: Request):
|
||||
def current_user(request: Request):
|
||||
"""Return current user info as JSON."""
|
||||
from app.auth.session import get_current_user as gcu
|
||||
from app.auth.session import public_user
|
||||
user = gcu(request)
|
||||
if not user:
|
||||
return {"authenticated": False}
|
||||
return {"authenticated": True, "user": user}
|
||||
return {"authenticated": True, "user": public_user(user)}
|
||||
|
||||
|
||||
# ── v7.2.0 — TOTP 2FA ─────────────────────────────────────────────────────
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,108 @@
|
||||
"""FlowDeck — Board : Kanban Notion-style + multi-vues.
|
||||
|
||||
Découpe A28 : l'ancien `board.py` (2 101 lignes, 53 routes) est
|
||||
devenu ce package — un module par concern, helpers/constantes dans
|
||||
`_common`. Ré-exportés (importateurs inchangés) : api.py
|
||||
(STATUS_COLORS, STATUS_LABELS, _issue_column, _map_issue_to_card),
|
||||
webhooks (_issue_column), dashboard (_sidebar_data,
|
||||
_load_workspace_pages, _load_shared_sidebar_pages, _file_icon),
|
||||
tests (_build_page_tree, _REPO_REF_RE, _unfurl_repo).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from . import ( # ordre = ordre d'enregistrement d'origine
|
||||
board_views,
|
||||
embed,
|
||||
import_,
|
||||
library,
|
||||
page_api,
|
||||
page_media,
|
||||
page_ops,
|
||||
pages,
|
||||
sharing,
|
||||
sync,
|
||||
synced,
|
||||
wiki,
|
||||
)
|
||||
from ._common import ( # noqa: F401 — ré-exports
|
||||
_REPO_REF_RE,
|
||||
AI_KEYWORD_COLORS,
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
_apply_filters,
|
||||
_apply_sorts,
|
||||
_block_texts,
|
||||
_build_page_tree,
|
||||
_create_page_from_markdown,
|
||||
_ensure_block_ids,
|
||||
_ensure_page_editable,
|
||||
_extract_ai_keywords,
|
||||
_file_icon,
|
||||
_get_project_properties,
|
||||
_issue_column,
|
||||
_load_children,
|
||||
_load_shared_sidebar_pages,
|
||||
_load_workspace_pages,
|
||||
_local_workspaces_for_user,
|
||||
_map_issue_to_card,
|
||||
_record_version,
|
||||
_sidebar_data,
|
||||
_store_uploaded_file,
|
||||
_unfurl_repo,
|
||||
_upload_root,
|
||||
_ws_id_for,
|
||||
asyncio_get_labels,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
for _mod in (
|
||||
wiki,
|
||||
page_api,
|
||||
library,
|
||||
sharing,
|
||||
synced,
|
||||
board_views,
|
||||
pages,
|
||||
page_media,
|
||||
import_,
|
||||
embed,
|
||||
page_ops,
|
||||
sync,
|
||||
):
|
||||
router.include_router(_mod.router)
|
||||
|
||||
__all__ = [
|
||||
"router",
|
||||
"AI_KEYWORD_COLORS",
|
||||
"STATUS_COLORS",
|
||||
"STATUS_LABELS",
|
||||
"_REPO_REF_RE",
|
||||
"_apply_filters",
|
||||
"_apply_sorts",
|
||||
"_block_texts",
|
||||
"_build_page_tree",
|
||||
"_create_page_from_markdown",
|
||||
"_ensure_block_ids",
|
||||
"_ensure_page_editable",
|
||||
"_extract_ai_keywords",
|
||||
"_file_icon",
|
||||
"_get_project_properties",
|
||||
"_issue_column",
|
||||
"_load_children",
|
||||
"_load_shared_sidebar_pages",
|
||||
"_load_workspace_pages",
|
||||
"_local_workspaces_for_user",
|
||||
"_map_issue_to_card",
|
||||
"_record_version",
|
||||
"_sidebar_data",
|
||||
"_store_uploaded_file",
|
||||
"_unfurl_repo",
|
||||
"_upload_root",
|
||||
"_ws_id_for",
|
||||
"asyncio_get_labels",
|
||||
]
|
||||
@@ -0,0 +1,894 @@
|
||||
"""FlowDeck — Board : helpers et constantes partagés (A28).
|
||||
|
||||
Les 23 helpers de l'ancien board.py (dont 4 async) + constantes
|
||||
(STATUS_COLORS/STATUS_LABELS/AI_KEYWORD_COLORS/_REPO_REF_RE) —
|
||||
ré-exportés : api.py, webhooks, dashboard, tests.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
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.gitea_client import gitea
|
||||
from app.services.http_client import shared_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["board"], prefix="/board")
|
||||
|
||||
STATUS_COLORS = {"todo": "var(--gray)", "progress": "var(--blue)", "done": "var(--green)"}
|
||||
STATUS_LABELS = {"todo": "To-do", "progress": "In progress", "done": "Complete"}
|
||||
|
||||
AI_KEYWORD_COLORS = [
|
||||
"#E03E3E", "#D9730D", "#9B72F0", "#0F7B6C", "#3399CC",
|
||||
"#E255A1", "#787774", "#6B4E3D", "#6374C4", "#5A9E4B",
|
||||
]
|
||||
|
||||
_REPO_REF_RE = re.compile(r"^(gitea|github):([\w\-\.]+)/([\w\-\.]+)$")
|
||||
|
||||
|
||||
|
||||
def _ensure_page_editable(conn, page_id: int, user: dict | None) -> None:
|
||||
"""v5.12.0: raise 423 when the page is locked and the actor may not edit.
|
||||
|
||||
Allowed to edit a locked page: admins and the user who locked it
|
||||
(locked_by). Unauthenticated callers only pass when the page is unlocked.
|
||||
"""
|
||||
row = conn.execute("SELECT is_locked, locked_by FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row or not row["is_locked"]:
|
||||
return
|
||||
uid = (user or {}).get("id")
|
||||
is_admin = bool((user or {}).get("is_admin"))
|
||||
if is_admin or (uid and row["locked_by"] == uid):
|
||||
return
|
||||
raise HTTPException(423, "Page is locked — only the owner of the lock or an admin can edit")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _ensure_block_ids(blocks) -> None:
|
||||
"""Assign unique ids to blocks missing one, recursively.
|
||||
|
||||
Built-in page templates ship without ids (the editor used to assign them
|
||||
client-side only). Without persisted ids, the realtime layer and the editor
|
||||
disagree on block identity, which duplicated lines / shuffled blocks when
|
||||
editing a template-created page. We now materialize ids at creation time.
|
||||
"""
|
||||
import uuid
|
||||
if not isinstance(blocks, list):
|
||||
return
|
||||
for b in blocks:
|
||||
if isinstance(b, dict):
|
||||
if not b.get("id"):
|
||||
b["id"] = "b" + uuid.uuid4().hex[:12]
|
||||
if isinstance(b.get("children"), list):
|
||||
_ensure_block_ids(b["children"])
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ── Core helpers ──
|
||||
|
||||
def _issue_column(issue: dict, columns: list[str], board_id: int) -> str:
|
||||
if issue.get("state") == "closed":
|
||||
return "Terminé" if "Terminé" in columns else columns[-1]
|
||||
for lbl in issue.get("labels", []):
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT column_name FROM col_mapping WHERE board_id=? AND gitea_label=?",
|
||||
(board_id, lbl["name"]),
|
||||
).fetchone()
|
||||
if row and row["column_name"] in columns:
|
||||
return row["column_name"]
|
||||
return columns[0] if columns else "Backlog"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _map_issue_to_card(issue: dict, owner: str = "", repo: str = "") -> dict:
|
||||
title = issue.get("title", "Untitled")
|
||||
status = "todo"
|
||||
if issue.get("state") == "closed":
|
||||
status = "done"
|
||||
labels = issue.get("labels", [])
|
||||
for lbl in labels:
|
||||
name = lbl.get("name", "").lower()
|
||||
if "progress" in name or "doing" in name:
|
||||
status = "progress"
|
||||
elif "done" in name or "complete" in name or "terminé" in name:
|
||||
status = "done"
|
||||
|
||||
assignee = issue.get("assignee", {}) or {}
|
||||
assignee_name = assignee.get("login", "")
|
||||
tag = labels[0].get("name", "") if labels else ""
|
||||
tag_color = labels[0].get("color", "#787774") if labels else "#787774"
|
||||
if tag_color and not tag_color.startswith("#"):
|
||||
tag_color = f"#{tag_color}"
|
||||
|
||||
icon_map = {
|
||||
"bug": "🐛", "feature": "✨", "enhancement": "⚡", "documentation": "📄",
|
||||
"design": "🎨", "testing": "🧪", "refactor": "🔧", "security": "🔒",
|
||||
}
|
||||
icon = "file"
|
||||
for lbl in labels:
|
||||
for kw, emoji in icon_map.items():
|
||||
if kw in lbl.get("name", "").lower():
|
||||
icon = emoji
|
||||
break
|
||||
|
||||
# Load custom property values
|
||||
props = {}
|
||||
if owner and repo:
|
||||
with get_conn() as conn:
|
||||
pvs = conn.execute("""
|
||||
SELECT pp.name, pp.prop_type, pv.value
|
||||
FROM property_values pv
|
||||
JOIN project_properties pp ON pp.id = pv.property_id
|
||||
WHERE pp.project_owner=? AND pp.project_name=? AND pv.gitea_issue_id=?
|
||||
""", (owner, repo, issue.get("number", 0))).fetchall()
|
||||
for pv in pvs:
|
||||
props[pv["name"]] = {"type": pv["prop_type"], "value": pv["value"]}
|
||||
|
||||
# AI keywords from DB
|
||||
keywords = []
|
||||
if owner and repo:
|
||||
with get_conn() as conn:
|
||||
kw_rows = conn.execute(
|
||||
"SELECT keyword, color FROM ai_keywords WHERE project_owner=? AND project_name=? ORDER BY usage_count DESC",
|
||||
(owner, repo),
|
||||
).fetchall()
|
||||
# Filter: show keywords matching this issue's labels
|
||||
label_names = {lbl.get("name", "").lower() for lbl in labels}
|
||||
for kw in kw_rows:
|
||||
if kw["keyword"].lower() in label_names or any(
|
||||
kw["keyword"].lower() in lbl for lbl in label_names
|
||||
):
|
||||
keywords.append({"name": kw["keyword"], "color": kw["color"]})
|
||||
|
||||
return {
|
||||
"id": str(issue.get("number", 0)),
|
||||
"title": title,
|
||||
"status": status,
|
||||
"status_color": STATUS_COLORS.get(status, "var(--gray)"),
|
||||
"status_label": STATUS_LABELS.get(status, "To-do"),
|
||||
"icon": icon,
|
||||
"assignee": assignee_name,
|
||||
"tag": tag if tag else None,
|
||||
"tag_color": tag_color,
|
||||
"due_date": issue.get("due_date", ""),
|
||||
"url": issue.get("html_url", ""),
|
||||
"keywords": keywords,
|
||||
"custom_props": props,
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _build_page_tree(conn, parent_id: int | None, ws_key: str, depth: int = 0, max_depth: int = 3) -> list[dict]:
|
||||
"""Build nested page tree recursively. max_depth prevents infinite recursion."""
|
||||
if depth >= max_depth:
|
||||
return []
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, updated_at FROM pages WHERE workspace=? AND parent_id IS ? AND deleted_at IS NULL AND collection_row_id IS NULL ORDER BY sort_order ASC, updated_at DESC",
|
||||
(ws_key, parent_id),
|
||||
).fetchall()
|
||||
items = []
|
||||
for row in rows:
|
||||
children = _build_page_tree(conn, row["id"], ws_key, depth + 1, max_depth)
|
||||
items.append({
|
||||
"id": f"page/{row['id']}",
|
||||
"db_id": row["id"],
|
||||
"name": row["title"] or "New page",
|
||||
"icon": "📄",
|
||||
"url": f"/pages/{row['id']}",
|
||||
"active": False,
|
||||
"depth": depth,
|
||||
"has_children": len(children) > 0,
|
||||
"children": children,
|
||||
})
|
||||
return items
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _file_icon(name: str, content_format: str = "") -> str:
|
||||
"""Map file extension to icon name (SVG-safe)."""
|
||||
# FlowDeck internal pages (no extension)
|
||||
if content_format and content_format != 'file':
|
||||
return 'edit'
|
||||
n = name.lower()
|
||||
if re.search(r'\.(png|jpe?g|gif|webp|svg|bmp|ico)$', n):
|
||||
return 'image'
|
||||
if n.endswith('.pdf'):
|
||||
return 'file'
|
||||
if re.search(r'\.(md|markdown)$', n):
|
||||
return 'edit'
|
||||
if n.endswith('.py'):
|
||||
return 'file'
|
||||
if re.search(r'\.(js|jsx|ts|tsx)$', n):
|
||||
return 'file'
|
||||
if re.search(r'\.(html?|xml)$', n):
|
||||
return 'file'
|
||||
if n.endswith('.css'):
|
||||
return 'file'
|
||||
if n.endswith('.json'):
|
||||
return 'file'
|
||||
if n.endswith('.sql'):
|
||||
return 'file'
|
||||
if re.search(r'\.(sh|bash|zsh)$', n):
|
||||
return 'file'
|
||||
if n.endswith('.ps1'):
|
||||
return 'file'
|
||||
if re.search(r'\.(rs|go|java|rb|php|c|cpp|h|swift|kt|scala|r)$', n):
|
||||
return 'file'
|
||||
if re.search(r'\.(txt|log)$', n):
|
||||
return 'file'
|
||||
if re.search(r'\.(zip|tar|gz|rar|7z)$', n):
|
||||
return 'file'
|
||||
return 'file'
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _load_workspace_pages(ws_cookie: str) -> list:
|
||||
"""Load top-level pages with children for the active workspace.
|
||||
|
||||
v7.1.1 : les pages contenant un bloc AI Meeting Notes sont des notes de
|
||||
réunion — elles vivent dans la section Meetings de la sidebar
|
||||
(``GET /api/v2/meetings/notes``), pas dans l'arbre Workspace.
|
||||
"""
|
||||
if not ws_cookie:
|
||||
return []
|
||||
try:
|
||||
ws_id = int(ws_cookie)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, parent_section, content_format, "
|
||||
"is_shared, share_mode, COALESCE(published,0) AS published "
|
||||
"FROM pages WHERE workspace_id=? AND parent_id IS NULL AND deleted_at IS NULL AND collection_row_id IS NULL "
|
||||
"AND REPLACE(COALESCE(content,''), ' ', '') NOT LIKE '%\"type\":\"meeting\"%' ORDER BY created_at DESC",
|
||||
(ws_id,),
|
||||
).fetchall()
|
||||
items = []
|
||||
for r in rows:
|
||||
is_folder = r["parent_section"] == "Workspace"
|
||||
title = r["title"] or "Untitled"
|
||||
sub_children = _load_children(r["id"])
|
||||
is_shared = bool(r["is_shared"] or r["share_mode"] != "private" or r["published"])
|
||||
items.append({
|
||||
"db_id": r["id"], "name": title,
|
||||
"id": f"page/{r['id']}",
|
||||
"icon": "📁" if is_folder else _file_icon(title, r["content_format"]),
|
||||
"is_folder": is_folder,
|
||||
"is_shared": is_shared,
|
||||
"child_count": len(sub_children),
|
||||
"children": sub_children,
|
||||
})
|
||||
return items
|
||||
except (ValueError, Exception):
|
||||
return []
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _load_children(parent_id: int) -> list:
|
||||
"""Recursively load children of a page (meeting notes excluded, § ci-dessus)."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, parent_section, content_format, "
|
||||
"is_shared, share_mode, COALESCE(published,0) AS published "
|
||||
"FROM pages WHERE parent_id=? AND deleted_at IS NULL "
|
||||
"AND REPLACE(COALESCE(content,''), ' ', '') NOT LIKE '%\"type\":\"meeting\"%' ORDER BY created_at",
|
||||
(parent_id,),
|
||||
).fetchall()
|
||||
children = []
|
||||
for r in rows:
|
||||
is_folder = r["parent_section"] == "Workspace"
|
||||
title = r["title"] or "Untitled"
|
||||
sub_children = _load_children(r["id"])
|
||||
is_shared = bool(r["is_shared"] or r["share_mode"] != "private" or r["published"])
|
||||
children.append({
|
||||
"db_id": r["id"], "name": title,
|
||||
"id": f"page/{r['id']}",
|
||||
"icon": "📁" if is_folder else _file_icon(title, r["content_format"]),
|
||||
"is_folder": is_folder,
|
||||
"is_shared": is_shared,
|
||||
"child_count": len(sub_children),
|
||||
"children": sub_children,
|
||||
})
|
||||
return children
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _local_workspaces_for_user(user: dict | None, workspace_id: int = 0) -> list[dict]:
|
||||
"""Return list of local workspaces for a user."""
|
||||
if not user:
|
||||
return []
|
||||
try:
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, name FROM workspaces WHERE owner_id = ? ORDER BY name",
|
||||
(user["id"],)
|
||||
).fetchall()
|
||||
return [{"id": r["id"], "name": r["name"]} for r in rows]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _load_shared_sidebar_pages(user_id: int) -> tuple[list, list, list, list]:
|
||||
"""Shared / received / published pages for the sidebar (reused by dashboard)."""
|
||||
with get_conn() as conn:
|
||||
own_ws = (
|
||||
"SELECT w.id FROM workspaces w WHERE w.owner_id = ? "
|
||||
"UNION SELECT wm.workspace_id FROM workspace_members wm WHERE wm.user_id = ?"
|
||||
)
|
||||
own_ws_names = (
|
||||
"SELECT w.name FROM workspaces w WHERE w.owner_id = ? "
|
||||
"UNION SELECT w.name FROM workspaces w "
|
||||
"JOIN workspace_members wm ON wm.workspace_id = w.id WHERE wm.user_id = ?"
|
||||
)
|
||||
# Scope "shared by me"-style lists to pages in the user's own workspaces
|
||||
# (or legacy pages whose workspace_id is NULL but identify the workspace by text).
|
||||
scope_cond = (
|
||||
f"(workspace_id IN ({own_ws}) "
|
||||
f"OR (workspace_id IS NULL AND lower(workspace) IN "
|
||||
f"(SELECT lower(name) FROM ({own_ws_names}))) "
|
||||
f"OR (workspace_id IS NULL AND lower(workspace) = lower("
|
||||
f"(SELECT login FROM users WHERE id=?))))"
|
||||
)
|
||||
scope_params = (user_id, user_id, user_id, user_id, user_id)
|
||||
|
||||
made_nominal = conn.execute(
|
||||
"SELECT DISTINCT p.id, p.title, p.workspace, p.updated_at FROM page_shares s "
|
||||
"JOIN pages p ON p.id=s.page_id "
|
||||
"WHERE s.created_by=? AND p.deleted_at IS NULL",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
made_link = conn.execute(
|
||||
f"SELECT id, title, workspace, updated_at FROM pages "
|
||||
f"WHERE share_mode='anyone' AND published=0 AND deleted_at IS NULL "
|
||||
f"AND {scope_cond}",
|
||||
scope_params,
|
||||
).fetchall()
|
||||
made_flag = conn.execute(
|
||||
f"SELECT id, title, workspace, updated_at FROM pages "
|
||||
f"WHERE (is_shared=1 OR share_mode != 'private') AND COALESCE(published,0)=0 AND deleted_at IS NULL "
|
||||
f"AND {scope_cond}",
|
||||
scope_params,
|
||||
).fetchall()
|
||||
published_rows = conn.execute(
|
||||
f"SELECT id, title, workspace, updated_at FROM pages "
|
||||
f"WHERE (published=1 OR is_published=1) AND deleted_at IS NULL AND {scope_cond} "
|
||||
f"ORDER BY updated_at DESC LIMIT 20",
|
||||
scope_params,
|
||||
).fetchall()
|
||||
try:
|
||||
received_rows = conn.execute(
|
||||
"SELECT DISTINCT p.id, p.title, p.workspace, p.updated_at FROM page_shares s "
|
||||
"JOIN pages p ON p.id=s.page_id "
|
||||
"LEFT JOIN group_members gm ON gm.group_id = s.shared_with_group_id AND gm.user_id=? "
|
||||
"WHERE (s.shared_with_user_id=? OR gm.user_id=?) AND p.deleted_at IS NULL",
|
||||
(user_id, user_id, user_id),
|
||||
).fetchall()
|
||||
except Exception:
|
||||
received_rows = conn.execute(
|
||||
"SELECT DISTINCT p.id, p.title, p.workspace, p.updated_at FROM page_shares s "
|
||||
"JOIN pages p ON p.id=s.page_id "
|
||||
"WHERE s.shared_with_user_id=? AND p.deleted_at IS NULL",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
|
||||
def _entry(r, icon):
|
||||
return {
|
||||
"id": f"page/{r['id']}",
|
||||
"db_id": r["id"],
|
||||
"name": r["title"] or "New page",
|
||||
"icon": icon,
|
||||
"url": f"/pages/{r['id']}",
|
||||
"active": False,
|
||||
"indent": 0,
|
||||
"depth": 0,
|
||||
"has_children": False,
|
||||
"children": [],
|
||||
}
|
||||
|
||||
made_map = {}
|
||||
for r in (*made_nominal, *made_link, *made_flag):
|
||||
made_map.setdefault(r["id"], r)
|
||||
made_sorted = sorted(made_map.values(), key=lambda r: r["updated_at"] or "", reverse=True)[:20]
|
||||
shared_made = [_entry(r, "link") for r in made_sorted]
|
||||
received_sorted = [r for r in received_rows if r["id"] not in made_map]
|
||||
received_sorted = sorted(received_sorted, key=lambda r: r["updated_at"] or "", reverse=True)[:20]
|
||||
shared_received = [_entry(r, "users") for r in received_sorted]
|
||||
published = [_entry(r, "globe") for r in published_rows]
|
||||
shared_all = [_entry(r, "link") for r in made_sorted]
|
||||
return shared_made, shared_received, published, shared_all
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
ws_name = user.get("login", "Bruno") if user else "Bruno"
|
||||
ws_key = f"{owner}/{repo}" if owner and repo else ws_name # workspace = project context
|
||||
|
||||
# Active workspace name from cookie (for local workspace display)
|
||||
from app.routers.dashboard import WORKSPACE_COOKIE
|
||||
ws_cookie = request.cookies.get(WORKSPACE_COOKIE, "")
|
||||
active_ws_name = "Workspace"
|
||||
workspace_pages = []
|
||||
gitea_workspace = False
|
||||
gitea_owner = ""
|
||||
gitea_repo = ""
|
||||
has_active_workspace = False
|
||||
local_ws_id = 0
|
||||
|
||||
if ws_cookie and (ws_cookie.startswith("gitea:") or ws_cookie.startswith("github:")):
|
||||
# Forge workspace (Gitea/GitHub): preserve context across pages. Also
|
||||
# load the local mirror workspace so it appears in Workspace in
|
||||
# parallel with the Repository tree (same navigation as workspace-local).
|
||||
parts = ws_cookie.split(":", 2)
|
||||
if len(parts) >= 3:
|
||||
gitea_owner = parts[1]
|
||||
gitea_repo = parts[2]
|
||||
active_ws_name = f"{gitea_owner}/{gitea_repo}"
|
||||
gitea_workspace = True
|
||||
has_active_workspace = True
|
||||
# Get local workspace ID for mirror and load its tree
|
||||
if user:
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM workspaces WHERE owner_id=? AND name=? "
|
||||
"AND (settings_json LIKE '%gitea_repo%' OR settings_json LIKE '%github_repo%')",
|
||||
(user["id"], f"{gitea_owner}/{gitea_repo}"),
|
||||
).fetchone()
|
||||
if row:
|
||||
local_ws_id = row["id"]
|
||||
workspace_pages = _load_workspace_pages(str(local_ws_id))
|
||||
except Exception:
|
||||
logger.exception("_sidebar_data")
|
||||
elif ws_cookie and user:
|
||||
try:
|
||||
wsi = int(ws_cookie)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, name, owner_id FROM workspaces WHERE id=? AND owner_id=?",
|
||||
(wsi, user["id"])
|
||||
).fetchone()
|
||||
if row:
|
||||
active_ws_name = row["name"]
|
||||
workspace_pages = _load_workspace_pages(ws_cookie)
|
||||
has_active_workspace = True
|
||||
# v7.46.0 : comme pour le sidebar dashboard, exposer l'ID de
|
||||
# l'espace ACTIF (le bouton Home de base.html pointait sinon
|
||||
# sur ``local_workspaces[0]``, premier espace trie par nom).
|
||||
local_ws_id = wsi
|
||||
except (ValueError, Exception):
|
||||
pass
|
||||
recent = []
|
||||
if owner and repo:
|
||||
view_map = {
|
||||
"Kanban board": "kanban", "Detailed board": "detailed",
|
||||
"Table view": "table", "Status overview": "status", "Team Load": "teamload",
|
||||
}
|
||||
first = True
|
||||
for label, view in view_map.items():
|
||||
indent = 0 if first else 1
|
||||
active = first
|
||||
recent.append({
|
||||
"id": f"{owner}/{repo}/{view}",
|
||||
"name": label, "icon": "folder" if first else "",
|
||||
"url": f"/board/{owner}/{repo}?view={view}",
|
||||
"active": active, "indent": indent,
|
||||
"depth": indent, "has_children": False, "children": [],
|
||||
})
|
||||
first = False
|
||||
# Load pages as nested tree for this project workspace
|
||||
with get_conn() as conn:
|
||||
tree_pages = _build_page_tree(conn, None, ws_key)
|
||||
for p in tree_pages:
|
||||
recent.append(p)
|
||||
# Private pages: same as recent but filtered for page/ items (non-board views)
|
||||
private_items = [r for r in recent if r.get("active") or r["id"].startswith("page/")]
|
||||
|
||||
# Load favorite pages from DB
|
||||
uid = user["id"] if user and user.get("id") else 1
|
||||
with get_conn() as conn:
|
||||
fav_rows = conn.execute(
|
||||
"SELECT p.id, p.title, p.workspace, p.updated_at, p.parent_section, "
|
||||
"p.content_format FROM favorites f "
|
||||
"JOIN pages p ON p.id = f.page_id "
|
||||
"WHERE f.user_id=? AND p.deleted_at IS NULL ORDER BY f.position", (uid,)
|
||||
).fetchall()
|
||||
favorites = []
|
||||
for r in fav_rows:
|
||||
# v7.69.6 : mêmes icônes que le Workspace (noms SVG, pas d'emoji).
|
||||
is_folder = r["parent_section"] == "Workspace"
|
||||
favorites.append({
|
||||
"id": f"page/{r['id']}",
|
||||
"db_id": r["id"],
|
||||
"name": r["title"] or "New page",
|
||||
"icon": "folder" if is_folder else _file_icon(
|
||||
r["title"] or "", r["content_format"]),
|
||||
"url": f"/pages/{r['id']}",
|
||||
"active": False,
|
||||
"indent": 0,
|
||||
"depth": 0,
|
||||
"has_children": False,
|
||||
"children": [],
|
||||
})
|
||||
|
||||
# Load shared pages
|
||||
shared_made_pages, shared_received_pages, published_pages, shared_pages = _load_shared_sidebar_pages(uid)
|
||||
|
||||
# Auth method & OAuth badge data
|
||||
auth_method = "local"
|
||||
gitea_linked = False
|
||||
github_linked = False
|
||||
if user and user.get("id"):
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
am_row = conn.execute("SELECT auth_method FROM users WHERE id=?", (user["id"],)).fetchone()
|
||||
if am_row and am_row["auth_method"]:
|
||||
auth_method = am_row["auth_method"]
|
||||
tokens = conn.execute(
|
||||
"SELECT provider FROM user_oauth_tokens WHERE user_id=?", (user["id"],)
|
||||
).fetchall()
|
||||
for t in tokens:
|
||||
if t["provider"] == "gitea":
|
||||
gitea_linked = True
|
||||
elif t["provider"] == "github":
|
||||
github_linked = True
|
||||
except Exception:
|
||||
logger.exception("_sidebar_data")
|
||||
|
||||
return {"workspace_name": ws_name, "workspace_initial": ws_name[0].upper() if ws_name else "B",
|
||||
"active_ws_name": active_ws_name,
|
||||
"workspace_key": f"{gitea_owner}/{gitea_repo}" if gitea_workspace else ws_key,
|
||||
"workspace_pages": workspace_pages,
|
||||
"gitea_workspace": gitea_workspace,
|
||||
"gitea_owner": gitea_owner,
|
||||
"gitea_repo": gitea_repo,
|
||||
"local_ws_id": local_ws_id,
|
||||
"current_page": repo or "Dashboard", "last_edited": "now",
|
||||
"recent_pages": recent, "private_pages": private_items,
|
||||
"favorite_pages": favorites, "shared_pages": shared_pages,
|
||||
"shared_made_pages": shared_made_pages,
|
||||
"shared_received_pages": shared_received_pages,
|
||||
"published_pages": published_pages,
|
||||
"user": user,
|
||||
"auth_method": auth_method,
|
||||
"gitea_linked": gitea_linked,
|
||||
"github_linked": github_linked,
|
||||
"has_active_workspace": has_active_workspace,
|
||||
"app_version": _get_app_version(),
|
||||
"local_workspaces": _local_workspaces_for_user(user),
|
||||
"sidebar_config": get_sidebar_config_sync(uid)}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _extract_ai_keywords(owner: str, repo: str, labels: list[dict], body: str = ""):
|
||||
"""Extract and persist AI keywords from issue labels and body."""
|
||||
if not owner or not repo:
|
||||
return
|
||||
candidates = set()
|
||||
for lbl in labels:
|
||||
name = lbl.get("name", "").strip().lower()
|
||||
if name and len(name) > 1:
|
||||
candidates.add(name)
|
||||
# Simple extraction from body: single words > 3 chars
|
||||
for word in re.findall(r'\b[a-zA-Z]{4,}\b', body.lower()):
|
||||
if word not in ("this", "that", "with", "from", "have", "when", "will"):
|
||||
candidates.add(word)
|
||||
|
||||
with get_conn() as conn:
|
||||
for kw in candidates:
|
||||
kw = kw[:30]
|
||||
existing = conn.execute(
|
||||
"SELECT id, usage_count FROM ai_keywords WHERE project_owner=? AND project_name=? AND keyword=?",
|
||||
(owner, repo, kw),
|
||||
).fetchone()
|
||||
if existing:
|
||||
conn.execute("UPDATE ai_keywords SET usage_count=? WHERE id=?",
|
||||
(existing["usage_count"] + 1, existing["id"]))
|
||||
else:
|
||||
color_idx = len(candidates) % len(AI_KEYWORD_COLORS)
|
||||
conn.execute(
|
||||
"INSERT INTO ai_keywords (project_owner, project_name, keyword, color) VALUES (?,?,?,?)",
|
||||
(owner, repo, kw, AI_KEYWORD_COLORS[color_idx]),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _get_project_properties(owner: str, repo: str) -> list[dict]:
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM project_properties WHERE project_owner=? AND project_name=? ORDER BY position",
|
||||
(owner, repo),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
async def asyncio_get_labels(owner: str, repo: str):
|
||||
return await gitea.get_labels(owner, repo)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _apply_filters(cards: list[dict], status_filter: str, filters: str) -> list[dict]:
|
||||
if status_filter:
|
||||
allowed = set(status_filter.split(","))
|
||||
cards = [c for c in cards if c["status"] in allowed]
|
||||
if filters:
|
||||
for f in filters.split(","):
|
||||
if ":" in f:
|
||||
prop, val = f.split(":", 1)
|
||||
val_lower = val.lower()
|
||||
if prop == "assignee":
|
||||
cards = [c for c in cards if c.get("assignee", "").lower() == val_lower]
|
||||
elif prop == "tag":
|
||||
cards = [c for c in cards if (c.get("tag") or "").lower() == val_lower]
|
||||
elif prop == "keyword":
|
||||
cards = [c for c in cards if any(val_lower in kw.get("name", "").lower() for kw in c.get("keywords", []))]
|
||||
return cards
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _apply_sorts(cards: list[dict], sorts: str) -> list[dict]:
|
||||
if not sorts:
|
||||
return cards
|
||||
order = {"todo": 0, "progress": 1, "done": 2}
|
||||
for spec in reversed(sorts.split(",")):
|
||||
if ":" not in spec:
|
||||
continue
|
||||
field, direction = spec.split(":", 1)
|
||||
rev = direction == "desc"
|
||||
if field == "name":
|
||||
cards.sort(key=lambda c: c["title"].lower(), reverse=rev)
|
||||
elif field == "status":
|
||||
cards.sort(key=lambda c: order.get(c["status"], 0), reverse=rev)
|
||||
elif field == "assignee":
|
||||
cards.sort(key=lambda c: c.get("assignee", "").lower(), reverse=rev)
|
||||
elif field == "deadline":
|
||||
cards.sort(key=lambda c: c.get("due_date", ""), reverse=rev)
|
||||
return cards
|
||||
|
||||
|
||||
# ═══════════ Library page ═══════════
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _record_version(conn, page_id: int, user_id, title: str, blocks_json: str) -> None:
|
||||
"""Insert a version snapshot unless it is byte-identical to the latest one."""
|
||||
prev = conn.execute(
|
||||
"SELECT COALESCE(title, ''), blocks_json FROM page_versions "
|
||||
"WHERE page_id=? ORDER BY id DESC LIMIT 1",
|
||||
(page_id,),
|
||||
).fetchone()
|
||||
if prev is not None and prev["blocks_json"] == blocks_json:
|
||||
if prev["title"] != (title or ""):
|
||||
conn.execute(
|
||||
"UPDATE page_versions SET title=? WHERE id="
|
||||
"(SELECT id FROM page_versions WHERE page_id=? ORDER BY id DESC LIMIT 1)",
|
||||
(title or "", page_id),
|
||||
)
|
||||
return
|
||||
conn.execute(
|
||||
"INSERT INTO page_versions (page_id, user_id, title, blocks_json, note) VALUES (?,?,?,?,'edited')",
|
||||
(page_id, user_id, title or "", blocks_json),
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _block_texts(b: dict) -> list[str]:
|
||||
"""Flatten a block (including children) into searchable text chunks."""
|
||||
out = []
|
||||
raw = b.get("content")
|
||||
if isinstance(raw, str) and raw.strip():
|
||||
out.append(raw)
|
||||
for child in b.get("children") or []:
|
||||
out.extend(_block_texts(child))
|
||||
return out
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ v5.5.0: Cover & icon ═══════════
|
||||
|
||||
|
||||
def _upload_root() -> Path:
|
||||
return Path(settings.data_dir)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _ws_id_for(request: Request, page_id: int) -> int:
|
||||
"""The active workspace id for the page (cookie, then page, then fallback 1)."""
|
||||
cookie = request.cookies.get("flowdeck_workspace", "")
|
||||
try:
|
||||
ws_id = int(cookie)
|
||||
if ws_id > 0:
|
||||
return ws_id
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT workspace_id FROM pages WHERE id=?", (page_id,)
|
||||
).fetchone()
|
||||
if row and row["workspace_id"]:
|
||||
return int(row["workspace_id"])
|
||||
return 1
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
async def _store_uploaded_file(request: Request, ws_id: int) -> dict:
|
||||
"""Persist an uploaded file under uploads/workspace_{ws_id}/ and return
|
||||
{file_url, file_path, mime_type, size, file_name}."""
|
||||
import datetime
|
||||
import re as _re
|
||||
|
||||
form = await request.form()
|
||||
upload = form.get("file")
|
||||
if upload is None or not hasattr(upload, "filename"):
|
||||
raise HTTPException(400, "file field required")
|
||||
original = (upload.filename or "cover.png").replace("\\", "/").rsplit("/", 1)[-1]
|
||||
name = _re.sub(r"[^A-Za-z0-9._-]", "_", original)[:120]
|
||||
ext = name.rsplit(".", 1)[-1].lower() if "." in name else "bin"
|
||||
if ext not in {"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif"}:
|
||||
raise HTTPException(400, "Unsupported image format")
|
||||
stamp = datetime.datetime.now(datetime.UTC).replace(tzinfo=None).strftime("%Y%m%d%H%M%S")
|
||||
folder = _upload_root() / f"uploads/workspace_{ws_id}"
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
final = f"{stamp}_{name}"
|
||||
(folder / final).write_bytes(await upload.read())
|
||||
mime = f"image/{'svg+xml' if ext == 'svg' else 'jpeg' if ext == 'jpg' else ext}"
|
||||
return {
|
||||
"file_url": f"/api/files/{ws_id}/{final}",
|
||||
"file_path": f"uploads/workspace_{ws_id}/{final}",
|
||||
"mime_type": mime,
|
||||
"size": (folder / final).stat().st_size,
|
||||
"file_name": name,
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ v5.4.0: Import (Markdown / .md / Notion .zip) ═══════════
|
||||
|
||||
|
||||
async def _create_page_from_markdown(request: Request, markdown: str, title: str = "") -> int:
|
||||
"""Convert markdown → blocks (server-side, same mapping as the editor) and
|
||||
create a page in the caller's workspace."""
|
||||
from app.services.export import _md_to_blocks
|
||||
|
||||
blocks = _md_to_blocks(markdown or "")
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
ws_key = user.get("login", "Bruno") if user else "Bruno"
|
||||
file_title = title.strip() or "Import"
|
||||
fallback = (file_title or "Imported page").replace("/", "-").replace("\\", "-")[:120]
|
||||
if not blocks:
|
||||
blocks = [{"type": "paragraph", "content": markdown or ""}]
|
||||
with get_conn() as conn:
|
||||
next_order = conn.execute(
|
||||
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace=? AND parent_id IS NULL",
|
||||
(ws_key,),
|
||||
).fetchone()[0]
|
||||
cur = conn.execute(
|
||||
"INSERT INTO pages (workspace, title, content, content_format, parent_section, sort_order, workspace_id) "
|
||||
"VALUES (?,?,?,?,'Private',?,(SELECT id FROM workspaces WHERE name=? LIMIT 1))",
|
||||
(ws_key, fallback, json.dumps(blocks), "blocks", next_order, ws_key),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
async def _unfurl_repo(forge: str, owner: str, repo: str):
|
||||
"""Resolve a ``gitea:owner/repo`` / ``github:owner/repo`` ref."""
|
||||
try:
|
||||
if forge == "gitea":
|
||||
from app.services.gitea_client import GiteaClient
|
||||
info = await GiteaClient().get_repo_info(owner, repo)
|
||||
site = "Gitea"
|
||||
else:
|
||||
from app.config import settings
|
||||
from app.services.github_adapter import GitHubAdapter
|
||||
token = getattr(settings, "github_token", None) or ""
|
||||
if token:
|
||||
info = await GitHubAdapter(access_token=token).get_repo_info(owner, repo)
|
||||
else:
|
||||
async with shared_client(timeout=10) as client:
|
||||
r = await client.get(
|
||||
f"https://api.github.com/repos/{owner}/{repo}",
|
||||
headers={"Accept": "application/vnd.github+json"},
|
||||
)
|
||||
r.raise_for_status()
|
||||
info = r.json()
|
||||
site = "GitHub"
|
||||
except Exception as exc: # noqa: BLE001 — forge lookup is best-effort
|
||||
logging.getLogger(__name__).debug("unfurl %s failed: %s", forge, exc)
|
||||
return None
|
||||
branch = info.get("default_branch") or "main"
|
||||
return {
|
||||
"url": info.get("html_url") or f"https://{forge}.com/{owner}/{repo}",
|
||||
"title": info.get("full_name") or f"{owner}/{repo}",
|
||||
"description": (info.get("description") or f"{site} repository "
|
||||
f"{owner}/{repo} · default branch: {branch}"),
|
||||
"image": "",
|
||||
"site_name": site,
|
||||
"language": info.get("language") or "",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
"""FlowDeck — Board : board_views.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.gitea_client import gitea
|
||||
|
||||
from ._common import (
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
_apply_filters,
|
||||
_apply_sorts,
|
||||
_extract_ai_keywords,
|
||||
_get_project_properties,
|
||||
_map_issue_to_card,
|
||||
_sidebar_data,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["board"], prefix="/board")
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ Board page ═══════════
|
||||
|
||||
@router.get("/{owner}/{repo}", response_class=HTMLResponse)
|
||||
def board(request: Request, owner: str, repo: str, view: str = Query(default="")):
|
||||
from app.auth.session import SessionManager
|
||||
from app.routers.dashboard._common import _cleanup_empty_mirrors, _ensure_forge_mirror
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
# Selecting a repository also opens a workspace-local of the same name:
|
||||
# ensure the mirror exists, prune other empty mirrors.
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if user and owner and repo:
|
||||
try:
|
||||
mid = _ensure_forge_mirror(user["id"], owner, repo, forge="gitea")
|
||||
_cleanup_empty_mirrors(user["id"], keep_name=f"{owner}/{repo}", keep_id=mid or 0)
|
||||
except Exception:
|
||||
logger.exception("board mirror ensure")
|
||||
sidebar = _sidebar_data(request, owner, repo)
|
||||
template = env.get_template("board.html")
|
||||
return template.render(request=request, owner=owner, repo=repo, groups=[],
|
||||
initial_view=view, **sidebar)
|
||||
|
||||
|
||||
# ═══════════ View fragments ═══════════
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ View fragments ═══════════
|
||||
|
||||
@router.get("/{owner}/{repo}/view/{view}", response_class=HTMLResponse)
|
||||
async def board_view(
|
||||
request: Request, owner: str, repo: str, view: str,
|
||||
status: str = Query(default=""),
|
||||
filter: str = Query(default=""),
|
||||
sort: str = Query(default=""),
|
||||
):
|
||||
try:
|
||||
issues = await gitea.get_issues(owner, repo, state="all")
|
||||
issues_only = [i for i in issues if not i.get("pull_request")]
|
||||
cards = [_map_issue_to_card(i, owner, repo) for i in issues_only]
|
||||
cards = _apply_filters(cards, status, filter)
|
||||
cards = _apply_sorts(cards, sort)
|
||||
except Exception as e:
|
||||
logger.error("Board view error: %s", e)
|
||||
cards = []
|
||||
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
|
||||
# Dynamic groups from Gitea labels (fallback to hardcoded)
|
||||
group_names = ["Design", "Engineering", "No Team"]
|
||||
groups = []
|
||||
for gname in group_names:
|
||||
gid = gname.lower().replace(" ", "-")
|
||||
gcards = cards
|
||||
groups.append({
|
||||
"id": gid, "name": gname,
|
||||
"counts": {
|
||||
"todo": len([c for c in gcards if c["status"] == "todo"]),
|
||||
"progress": len([c for c in gcards if c["status"] == "progress"]),
|
||||
"done": len([c for c in gcards if c["status"] == "done"]),
|
||||
},
|
||||
"cards": gcards,
|
||||
})
|
||||
|
||||
ctx = {"owner": owner, "repo": repo, "groups": groups, "cards": cards}
|
||||
|
||||
template_map = {
|
||||
"table": "table_view.html",
|
||||
"status": "status_overview.html",
|
||||
"teamload": "team_load.html",
|
||||
"detailed": "detailed_board.html",
|
||||
}
|
||||
|
||||
if view == "table":
|
||||
grouped = {g["name"]: g["cards"] for g in groups}
|
||||
ctx["grouped_cards"] = grouped
|
||||
elif view == "status":
|
||||
counts = {"todo": 0, "progress": 0, "done": 0}
|
||||
for c in cards:
|
||||
if c["status"] in counts:
|
||||
counts[c["status"]] += 1
|
||||
ctx.update(status_data=counts, status_colors=STATUS_COLORS, status_labels=STATUS_LABELS)
|
||||
elif view == "teamload":
|
||||
members = {}
|
||||
for c in cards:
|
||||
name = c.get("assignee") or "Unassigned"
|
||||
if name not in members:
|
||||
members[name] = {"name": name, "initial": name[0].upper(),
|
||||
"todo": 0, "progress": 0, "complete": 0, "total": 0}
|
||||
sk = c["status"] if c["status"] in ("todo", "progress") else "complete"
|
||||
members[name][sk] += 1
|
||||
members[name]["total"] += 1
|
||||
ctx["team_data"] = list(members.values())
|
||||
elif view == "detailed":
|
||||
pass
|
||||
else:
|
||||
template_map["kanban"] = "board_fragment.html"
|
||||
|
||||
template_name = template_map.get(view, "board_fragment.html")
|
||||
template = env.get_template(template_name)
|
||||
return template.render(**ctx)
|
||||
|
||||
|
||||
# ═══════════ v0.9.0: Custom Properties API ═══════════
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ v0.9.0: Custom Properties API ═══════════
|
||||
|
||||
@router.get("/api/properties/{owner}/{repo}")
|
||||
def get_properties(owner: str, repo: str):
|
||||
return {"properties": _get_project_properties(owner, repo)}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/properties/{owner}/{repo}")
|
||||
def create_property(owner: str, repo: str, name: str = Query(...),
|
||||
prop_type: str = Query(default="select"),
|
||||
options: str = Query(default="")):
|
||||
opts = json.dumps([o.strip() for o in options.split(",") if o.strip()])
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO project_properties (project_owner, project_name, name, prop_type, options_json) VALUES (?,?,?,?,?)",
|
||||
(owner, repo, name, prop_type, opts),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
raise HTTPException(409, f"Property already exists: {e}") from e
|
||||
return {"status": "ok", "name": name, "type": prop_type}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/api/properties/{owner}/{repo}")
|
||||
def delete_property(owner: str, repo: str, name: str = Query(...)):
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM project_properties WHERE project_owner=? AND project_name=? AND name=?",
|
||||
(owner, repo, name),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/properties/{owner}/{repo}/values")
|
||||
def set_property_value(owner: str, repo: str, issue_id: int = Query(...),
|
||||
name: str = Query(...), value: str = Query(default="")):
|
||||
with get_conn() as conn:
|
||||
prop = conn.execute(
|
||||
"SELECT id FROM project_properties WHERE project_owner=? AND project_name=? AND name=?",
|
||||
(owner, repo, name),
|
||||
).fetchone()
|
||||
if not prop:
|
||||
raise HTTPException(404, f"Property '{name}' not found")
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO property_values (property_id, gitea_issue_id, value) VALUES (?,?,?)",
|
||||
(prop["id"], issue_id, value),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ═══════════ v0.9.0: AI Keywords API ═══════════
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ v0.9.0: AI Keywords API ═══════════
|
||||
|
||||
@router.get("/api/ai-keywords/{owner}/{repo}")
|
||||
def get_ai_keywords(owner: str, repo: str):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT keyword, color, usage_count FROM ai_keywords WHERE project_owner=? AND project_name=? ORDER BY usage_count DESC LIMIT 30",
|
||||
(owner, repo),
|
||||
).fetchall()
|
||||
return {"keywords": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/ai-keywords/{owner}/{repo}/extract")
|
||||
async def extract_ai_keywords(owner: str, repo: str):
|
||||
"""Re-extract keywords from all issues in the repo."""
|
||||
try:
|
||||
issues = await gitea.get_issues(owner, repo, state="all")
|
||||
for issue in issues:
|
||||
if not issue.get("pull_request"):
|
||||
_extract_ai_keywords(owner, repo, issue.get("labels", []), issue.get("body", ""))
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e)) from e
|
||||
return {"status": "ok", "issues_scanned": len(issues)}
|
||||
|
||||
|
||||
# ═══════════ Pages Markdown ═══════════
|
||||
@@ -0,0 +1,64 @@
|
||||
"""FlowDeck — Board : embed.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.config import settings
|
||||
|
||||
from ._common import _REPO_REF_RE, _unfurl_repo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["board"], prefix="/board")
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/og/metadata")
|
||||
async def og_metadata(request: Request):
|
||||
"""v5.5.0: Open Graph metadata for a bookmark card.
|
||||
|
||||
v7.3.0: ``gitea:owner/repo`` and ``github:owner/repo`` schemes are
|
||||
unfurled straight from the forge API (no HTTP fetch of the HTML page).
|
||||
"""
|
||||
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")
|
||||
m = _REPO_REF_RE.match(url)
|
||||
if m:
|
||||
forge, owner, repo = m.group(1).lower(), m.group(2), m.group(3)
|
||||
data = await _unfurl_repo(forge, owner, repo)
|
||||
if data:
|
||||
return {"ok": True, **data}
|
||||
from app.services.og_fetcher import fetch_og_metadata
|
||||
try:
|
||||
data = await fetch_og_metadata(url)
|
||||
except ValueError as exc:
|
||||
# A12 : SSRF — URL vers un hôte privé/loopback (ou redirection vers l'un).
|
||||
raise HTTPException(400, str(exc)) from None
|
||||
return {"ok": True, **data}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/embed/resolve")
|
||||
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…).
|
||||
"""
|
||||
url = (body.get("url") or "").strip()
|
||||
if not url:
|
||||
raise HTTPException(400, "url required")
|
||||
from app.services.embeds import resolve_embed as _resolve
|
||||
data = _resolve(url, parent=settings.app_base_url)
|
||||
return {"ok": True, "url": url, **data}
|
||||
@@ -0,0 +1,85 @@
|
||||
"""FlowDeck — Board : import_.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.services.automations import fire_event
|
||||
|
||||
from ._common import _create_page_from_markdown
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["board"], prefix="/board")
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/pages/import")
|
||||
async def import_page(request: Request):
|
||||
"""v5.4.0: import markdown text as a new page (blocks) in the workspace."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid JSON body") from None
|
||||
markdown = body.get("markdown", "")
|
||||
title = body.get("title", "")
|
||||
if not markdown and not body.get("csv"):
|
||||
raise HTTPException(400, "markdown field required")
|
||||
if not markdown.strip():
|
||||
raise HTTPException(400, "markdown is empty")
|
||||
page_id = await _create_page_from_markdown(request, markdown, title)
|
||||
await fire_event("page.created", {"page_id": page_id, "title": title or "Import",
|
||||
"workspace": ""})
|
||||
return {"status": "ok", "id": page_id}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/pages/import/file")
|
||||
async def import_file(request: Request):
|
||||
"""v5.4.0: import an uploaded .md file (or a Notion export .zip containing
|
||||
markdown pages) into the workspace. Returns the created page ids."""
|
||||
import io as _io
|
||||
import zipfile
|
||||
|
||||
form = await request.form()
|
||||
upload = form.get("file")
|
||||
if upload is None or not hasattr(upload, "filename"):
|
||||
raise HTTPException(400, "file field required")
|
||||
filename = (upload.filename or "import.md").replace("\\", "/").rsplit("/", 1)[-1]
|
||||
data = await upload.read()
|
||||
created_ids = []
|
||||
|
||||
if filename.lower().endswith(".zip"):
|
||||
try:
|
||||
zf = zipfile.ZipFile(_io.BytesIO(data))
|
||||
except zipfile.BadZipFile:
|
||||
raise HTTPException(400, "Invalid zip archive") from None
|
||||
md_entries = sorted(
|
||||
(n for n in zf.namelist() if n.lower().endswith((".md", ".markdown"))),
|
||||
key=lambda n: (n.count("/"), n.lower()),
|
||||
)
|
||||
if not md_entries:
|
||||
raise HTTPException(400, "No .md files found in archive")
|
||||
for name in md_entries:
|
||||
raw = zf.read(name).decode("utf-8", errors="replace")
|
||||
title = name.replace("\\", "/").rsplit("/", 1)[-1][:-3]
|
||||
try:
|
||||
created_ids.append(await _create_page_from_markdown(request, raw, title))
|
||||
except Exception as exc: # noqa: BLE001 - keep importing the rest
|
||||
logger.warning("import failed for %s: %s", name, exc)
|
||||
else:
|
||||
try:
|
||||
raw = data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
raise HTTPException(400, "Only text/markdown files are supported") from None
|
||||
title = filename.replace(".md", "").replace(".markdown", "").replace(".txt", "")
|
||||
created_ids.append(await _create_page_from_markdown(request, raw, title))
|
||||
|
||||
if not created_ids:
|
||||
raise HTTPException(422, "No pages could be imported")
|
||||
return {"status": "ok", "ids": created_ids, "count": len(created_ids)}
|
||||
@@ -0,0 +1,66 @@
|
||||
"""FlowDeck — Board : library.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Query, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
from ._common import _sidebar_data
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["board"], prefix="/board")
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ Library page ═══════════
|
||||
|
||||
@router.get("/library", response_class=HTMLResponse)
|
||||
def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
|
||||
"""Library page avec onglets Recents, Favorites, Shared, Private — scopé au workspace."""
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, owner, repo)
|
||||
# Load all pages for the workspace from DB
|
||||
ws_key = f"{owner}/{repo}" if owner and repo else ""
|
||||
with get_conn() as conn:
|
||||
if ws_key:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, workspace, updated_at FROM pages "
|
||||
"WHERE workspace=? AND collection_row_id IS NULL ORDER BY updated_at DESC",
|
||||
(ws_key,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, workspace, updated_at FROM pages "
|
||||
"WHERE collection_row_id IS NULL ORDER BY updated_at DESC",
|
||||
).fetchall()
|
||||
all_pages = []
|
||||
for r in rows:
|
||||
page = dict(r)
|
||||
all_pages.append({
|
||||
"id": f"page/{page['id']}",
|
||||
"name": page["title"] or "Untitled",
|
||||
"icon": "📄",
|
||||
"url": f"/pages/{page['id']}",
|
||||
"created_by": "You",
|
||||
"source": page.get("workspace") or "Private",
|
||||
"last_edited": page.get("updated_at", "now"),
|
||||
"last_visited": page.get("updated_at", "now"),
|
||||
})
|
||||
sidebar["recent_pages"] = all_pages
|
||||
sidebar["favorite_pages"] = []
|
||||
sidebar["private_pages"] = [p for p in all_pages if p.get("source") == "🔒 Private"]
|
||||
sidebar["shared_pages"] = []
|
||||
sidebar["shared_made_pages"] = []
|
||||
sidebar["shared_received_pages"] = []
|
||||
template = env.get_template("library.html")
|
||||
return template.render(**sidebar)
|
||||
|
||||
# ═══════════ Favorites API ═══════════
|
||||
@@ -0,0 +1,263 @@
|
||||
"""FlowDeck — Board : page_api.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
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, run_event_sync
|
||||
|
||||
from ._common import _ensure_block_ids
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["board"], prefix="/board")
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/lock")
|
||||
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")
|
||||
locked = bool(body.get("locked"))
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id, is_locked, locked_by, workspace FROM pages WHERE id=?",
|
||||
(page_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Page not found")
|
||||
is_admin = 1 if user.get("is_admin") else 0
|
||||
if row["is_locked"] and not is_admin and row["locked_by"] != user["id"]:
|
||||
raise HTTPException(403, "Only the person who locked this page (or an admin) can unlock it")
|
||||
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()
|
||||
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")
|
||||
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")
|
||||
updates = {}
|
||||
for key in ("full_width", "font_small"):
|
||||
if key in body:
|
||||
updates[key] = 1 if body[key] else 0
|
||||
if not updates:
|
||||
raise HTTPException(400, "nothing to update")
|
||||
sets = ", ".join(f"{k}=?" for k in updates)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Page not found")
|
||||
conn.execute(f"UPDATE pages SET {sets}, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(*updates.values(), page_id))
|
||||
conn.commit()
|
||||
return {"status": "ok", **{k: bool(v) for k, v in updates.items()}}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/page-templates")
|
||||
def list_page_templates_api(request: Request):
|
||||
"""v5.12.0: built-in + user global page templates for the picker."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = (user or {}).get("id")
|
||||
from app.services.block_templates import template_list
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT id, name, icon, description, created_by
|
||||
FROM page_global_templates
|
||||
WHERE created_by IS NULL OR created_by=?
|
||||
ORDER BY created_at""",
|
||||
(uid,),
|
||||
).fetchall()
|
||||
mine = [dict(r) for r in rows]
|
||||
for t in mine:
|
||||
t["builtin"] = False
|
||||
return {"templates": template_list() + mine}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/page-templates")
|
||||
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")
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "name is required")
|
||||
blocks = body.get("blocks")
|
||||
if body.get("page_id"):
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT content, content_format FROM pages WHERE id=?",
|
||||
(int(body["page_id"]),)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Page not found")
|
||||
if row["content_format"] == "blocks" and row["content"]:
|
||||
try:
|
||||
blocks = json.loads(row["content"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
raise HTTPException(400, "Page content is not block JSON") from None
|
||||
if not isinstance(blocks, list) or not blocks:
|
||||
raise HTTPException(400, "blocks (or page_id) required")
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO page_global_templates (name, icon, description, blocks_json, created_by)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
(name, body.get("icon") or "📄", body.get("description") or "",
|
||||
json.dumps(blocks), user["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
tid = cur.lastrowid
|
||||
return {"status": "ok", "id": tid}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/page-templates/{template_id}/use")
|
||||
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", ""))
|
||||
title = (body.get("title") or "").strip()
|
||||
blocks_json = None
|
||||
if template_id == 0:
|
||||
from app.services.block_templates import blocks_json_for
|
||||
key = body.get("key") or "empty"
|
||||
blocks_json = blocks_json_for(key)
|
||||
name = key
|
||||
if blocks_json is None:
|
||||
raise HTTPException(404, "Unknown built-in template")
|
||||
else:
|
||||
with get_conn() as conn:
|
||||
uid = (user or {}).get("id")
|
||||
row = conn.execute(
|
||||
"SELECT * FROM page_global_templates WHERE id=? AND (created_by IS NULL OR created_by=?)",
|
||||
(template_id, uid),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Template not found")
|
||||
blocks_json = row["blocks_json"]
|
||||
name = row["name"]
|
||||
title = title or row["name"]
|
||||
# Resolve the target workspace so the new page actually shows up in the
|
||||
# active local workspace (bugfix: template pages previously got
|
||||
# workspace_id = NULL and never appeared in the sidebar/tree).
|
||||
uid = (user or {}).get("id")
|
||||
ws_id_raw = body.get("workspace_id")
|
||||
ws_id = None
|
||||
if ws_id_raw is not None:
|
||||
try:
|
||||
ws_id = int(ws_id_raw)
|
||||
except (TypeError, ValueError):
|
||||
ws_id = None
|
||||
ws_key = user.get("login", "Bruno") if user else "Bruno"
|
||||
if ws_id is not None:
|
||||
with get_conn() as conn:
|
||||
ws_row = conn.execute(
|
||||
"SELECT id, name, owner_id FROM workspaces WHERE id=?", (ws_id,)
|
||||
).fetchone()
|
||||
if ws_row and (uid is None or ws_row["owner_id"] == uid):
|
||||
ws_key = ws_row["name"] or ws_key
|
||||
else:
|
||||
ws_id = None
|
||||
else:
|
||||
body_ws = (body.get("workspace") or "").strip()
|
||||
if body_ws:
|
||||
ws_key = body_ws
|
||||
# Optional target folder: instantiate the template as a child of it.
|
||||
parent_id = body.get("parent_id")
|
||||
try:
|
||||
parent_id = int(parent_id) if parent_id not in (None, "", 0, "0") else None
|
||||
except (TypeError, ValueError):
|
||||
parent_id = None
|
||||
try:
|
||||
parsed_blocks = json.loads(blocks_json)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
raise HTTPException(500, "Template content corrupted") from None
|
||||
_ensure_block_ids(parsed_blocks)
|
||||
blocks_json = json.dumps(parsed_blocks)
|
||||
with get_conn() as conn:
|
||||
if parent_id is not None:
|
||||
next_order = conn.execute(
|
||||
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE parent_id=?",
|
||||
(parent_id,),
|
||||
).fetchone()[0]
|
||||
elif ws_id is not None:
|
||||
next_order = conn.execute(
|
||||
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace_id=? AND parent_id IS NULL",
|
||||
(ws_id,),
|
||||
).fetchone()[0]
|
||||
else:
|
||||
next_order = conn.execute(
|
||||
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace=? AND parent_id IS NULL",
|
||||
(ws_key,),
|
||||
).fetchone()[0]
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id, sort_order)
|
||||
VALUES (?,?,?,?,?, 'Private', ?, ?)""",
|
||||
(ws_key, ws_id, title or name, blocks_json, "blocks", parent_id, next_order),
|
||||
)
|
||||
conn.commit()
|
||||
page_id = cur.lastrowid
|
||||
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}
|
||||
|
||||
|
||||
@router.get("/api/page-templates/{template_id}/blocks")
|
||||
def page_template_blocks(request: Request, template_id: int, key: str = ""):
|
||||
"""v7.53.0 : blocs d'un canevas, pour l'insérer dans le document ouvert.
|
||||
|
||||
Builtin : ``template_id=0`` + ``?key=`` (même convention que ``/use``).
|
||||
Canevas personnel : son ``id``. Les blocs builtin sont sans ``id`` — le
|
||||
front appelle ``ensureBlockIds()`` (déjà global côté éditeur).
|
||||
"""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if template_id == 0:
|
||||
from app.services.block_templates import blocks_json_for
|
||||
raw = blocks_json_for(key or "empty")
|
||||
if raw is None:
|
||||
raise HTTPException(404, "Unknown built-in template")
|
||||
else:
|
||||
uid = (user or {}).get("id")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT blocks_json FROM page_global_templates "
|
||||
"WHERE id=? AND (created_by IS NULL OR created_by=?)",
|
||||
(template_id, uid),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Template not found")
|
||||
raw = row["blocks_json"]
|
||||
try:
|
||||
blocks = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError) as exc:
|
||||
raise HTTPException(500, "Template content corrupted") from exc
|
||||
if not isinstance(blocks, list):
|
||||
raise HTTPException(500, "Template content corrupted")
|
||||
return {"blocks": blocks}
|
||||
|
||||
|
||||
# ── Core helpers ──
|
||||
@@ -0,0 +1,122 @@
|
||||
"""FlowDeck — Board : page_media.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
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, run_event_sync
|
||||
|
||||
from ._common import _store_uploaded_file, _ws_id_for
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["board"], prefix="/board")
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ v5.4.0: Page & collection duplication ═══════════
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/duplicate")
|
||||
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:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM pages WHERE id=? AND deleted_at IS NULL", (page_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Page not found")
|
||||
page = dict(row)
|
||||
|
||||
def copy_tree(src_id: int, parent_id) -> int:
|
||||
with get_conn() as conn:
|
||||
conn.execute("SELECT * FROM pages WHERE id=?", (src_id,)).fetchone()
|
||||
cur = conn.execute(
|
||||
"INSERT INTO pages (workspace, workspace_id, title, content, content_format, "
|
||||
"parent_section, parent_id, sort_order, share_mode, published, is_published, "
|
||||
"publish_slug, is_shared, cover_url, page_icon, created_at, updated_at) "
|
||||
"SELECT workspace, workspace_id, title || ' copy', content, content_format, "
|
||||
"parent_section, ?, sort_order, share_mode, 0, is_published, '', is_shared, "
|
||||
"cover_url, page_icon, created_at, updated_at FROM pages WHERE id=?",
|
||||
(parent_id, src_id),
|
||||
)
|
||||
new_id = cur.lastrowid
|
||||
conn.commit()
|
||||
for child in conn.execute(
|
||||
"SELECT id FROM pages WHERE parent_id=? ", (src_id,)
|
||||
).fetchall():
|
||||
copy_tree(child["id"], new_id)
|
||||
return new_id
|
||||
|
||||
new_id = copy_tree(page_id, page.get("parent_id"))
|
||||
title = (page.get("title") or "Untitled") + " copy"
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET title=? WHERE id=?", (title, new_id))
|
||||
conn.commit()
|
||||
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}
|
||||
|
||||
|
||||
# ═══════════ v5.5.0: Cover & icon ═══════════
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/cover")
|
||||
async def set_page_cover(request: Request, page_id: int):
|
||||
"""v5.5.0: upload an image cover for a page.
|
||||
|
||||
JSON body {cover_url} accepts an external URL; multipart ``file`` uploads
|
||||
an image stored in the workspace's uploads directory.
|
||||
"""
|
||||
ctype = (request.headers.get("content-type") or "").lower()
|
||||
if ctype.startswith("application/json"):
|
||||
body = await request.json()
|
||||
cover_url = (body.get("cover_url") or "").strip()
|
||||
if not cover_url:
|
||||
raise HTTPException(400, "cover_url required")
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET cover_url=? WHERE id=?", (cover_url, page_id))
|
||||
conn.commit()
|
||||
return {"status": "ok", "page_id": page_id, "cover_url": cover_url}
|
||||
ws_id = _ws_id_for(request, page_id)
|
||||
meta = await _store_uploaded_file(request, ws_id)
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET cover_url=? WHERE id=?", (meta["file_url"], page_id))
|
||||
conn.commit()
|
||||
return {"status": "ok", "page_id": page_id, "cover_url": meta["file_url"]}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/api/pages/{page_id}/cover")
|
||||
def remove_page_cover(request: Request, page_id: int):
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET cover_url='' WHERE id=?", (page_id,))
|
||||
conn.commit()
|
||||
return {"status": "ok", "page_id": page_id}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/icon")
|
||||
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)."""
|
||||
icon = (body.get("icon") or "").strip()
|
||||
if len(icon) > 512:
|
||||
raise HTTPException(400, "icon too long")
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET page_icon=? WHERE id=?", (icon, page_id))
|
||||
conn.commit()
|
||||
return {"status": "ok", "page_id": page_id, "icon": icon}
|
||||
|
||||
|
||||
# ═══════════ v5.4.0: Import (Markdown / .md / Notion .zip) ═══════════
|
||||
@@ -0,0 +1,176 @@
|
||||
"""FlowDeck — Board : page_ops.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event, run_event_sync
|
||||
from app.services.permission_manager import PermissionManager
|
||||
|
||||
from ._common import _sidebar_data
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["board"], prefix="/board")
|
||||
|
||||
|
||||
|
||||
|
||||
@router.put("/api/pages/{page_id}/move")
|
||||
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 }
|
||||
- workspace_id: move page to a different workspace
|
||||
- parent_id: change parent (0 = root level)
|
||||
- new_order: position among siblings (0 = append)
|
||||
"""
|
||||
new_ws_id = body.get("workspace_id")
|
||||
new_parent_id = body.get("parent_id", 0)
|
||||
new_order = body.get("new_order", 0)
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id, workspace, workspace_id FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Page not found")
|
||||
|
||||
if new_ws_id:
|
||||
# Move to a different workspace: get the workspace name
|
||||
ws_row = conn.execute("SELECT name FROM workspaces WHERE id=?", (new_ws_id,)).fetchone()
|
||||
if not ws_row:
|
||||
return JSONResponse({"status": "error", "detail": "Workspace not found"}, status_code=404)
|
||||
conn.execute(
|
||||
"UPDATE pages SET workspace_id=?, workspace=?, parent_id=NULL, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(new_ws_id, ws_row["name"], page_id),
|
||||
)
|
||||
else:
|
||||
# Reorder within same workspace
|
||||
conn.execute(
|
||||
"UPDATE pages SET parent_id=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(new_parent_id if new_parent_id > 0 else None, page_id),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE pages SET sort_order=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(new_order, page_id),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
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}")
|
||||
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")
|
||||
if not uid:
|
||||
raise HTTPException(403, "Authentication required")
|
||||
# v6.0.0: granular page permissions — need at least edit access to trash.
|
||||
if not PermissionManager(uid).can_edit_page(page_id):
|
||||
raise HTTPException(403, "You don't have edit access to this page")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id, title FROM pages WHERE id=? AND deleted_at IS NULL", (page_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Page not found")
|
||||
import datetime
|
||||
conn.execute("UPDATE pages SET deleted_at=? WHERE id=?", (datetime.datetime.now(datetime.UTC).replace(tzinfo=None).isoformat(), page_id,))
|
||||
conn.commit()
|
||||
run_event_sync(fire_event("page.deleted", {"page_id": page_id, "title": row["title"] or ""}))
|
||||
return {"status": "ok", "deleted": page_id, "title": row["title"]}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}", response_class=HTMLResponse)
|
||||
def view_page(request: Request, page_id: int):
|
||||
"""Render a page as HTML, or a file viewer for uploaded files.
|
||||
?embed=1 — minimal mode for side peek (editor only, no header)."""
|
||||
embed = request.query_params.get("embed") == "1"
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
# v6.0.0: granular page permissions — hide restricted pages (404).
|
||||
user_hdr = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if user_hdr and user_hdr.get("id") and not PermissionManager(user_hdr["id"]).can_view_page(page_id):
|
||||
return HTMLResponse("<h2>Page not found</h2>", status_code=404)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
return HTMLResponse("<h2>Page not found</h2>", status_code=404)
|
||||
page = dict(row)
|
||||
# v6.5.0: synced blocks resolve server-side at read time (fresh content
|
||||
# even when the stored cache is stale).
|
||||
from app.services.synced_blocks import resolve_content_json
|
||||
page["content"] = resolve_content_json(page.get("content", ""), page.get("content_format"))
|
||||
|
||||
ws = page.get("workspace", "")
|
||||
parts = ws.split("/") if "/" in ws else ["", ""]
|
||||
owner, repo = parts[0], parts[1] if len(parts) > 1 else ""
|
||||
sidebar = _sidebar_data(request, owner, repo)
|
||||
# Check if page is favorited
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = user["id"] if user and user.get("id") else 1
|
||||
with get_conn() as conn:
|
||||
fav = conn.execute(
|
||||
"SELECT id FROM favorites WHERE user_id=? AND page_id=?", (uid, page_id)
|
||||
).fetchone()
|
||||
|
||||
# Build page_data, including file metadata for uploaded files
|
||||
_locked = bool(page.get("is_locked", 0))
|
||||
_locked_by = page.get("locked_by") if "locked_by" in page else None
|
||||
_can_edit = (not _locked) or bool(user and user.get("is_admin")) or (uid and _locked_by == uid)
|
||||
page_data = {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": fav is not None, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("published", 0)), "cover_url": page.get("cover_url", "") or "", "page_icon": page.get("page_icon", "") or "",
|
||||
"is_locked": _locked,
|
||||
"locked_by": _locked_by,
|
||||
"can_edit": _can_edit,
|
||||
"full_width": bool(page.get("full_width", 0)) if "full_width" in page else False,
|
||||
"font_small": bool(page.get("font_small", 0)) if "font_small" in page else False}
|
||||
|
||||
# For file pages, extract file metadata and add to page_data
|
||||
if page.get("content_format") == "file":
|
||||
import json as _json
|
||||
try:
|
||||
meta = _json.loads(page.get("content", "{}"))
|
||||
except _json.JSONDecodeError:
|
||||
meta = {}
|
||||
file_path = meta.get("file_path", "").replace("\\", "/")
|
||||
mime_type = meta.get("mime_type", "application/octet-stream")
|
||||
file_size = meta.get("size", 0)
|
||||
# Build workspace_id from file_path
|
||||
fp_parts = file_path.split("/")
|
||||
ws_id = ""
|
||||
for p in fp_parts:
|
||||
if p.startswith("workspace_"):
|
||||
ws_id = p.replace("workspace_", "")
|
||||
break
|
||||
filename = fp_parts[-1] if fp_parts else page.get("title", "File")
|
||||
file_url = f"/api/files/{ws_id}/{filename}" if ws_id else ""
|
||||
page_data["file_url"] = file_url
|
||||
page_data["file_mime"] = mime_type
|
||||
page_data["file_size"] = file_size
|
||||
page_data["file_name"] = filename
|
||||
|
||||
from app.routers.dashboard import _nav_breadcrumb
|
||||
with get_conn() as conn:
|
||||
nav_crumbs = _nav_breadcrumb(conn, page_id)
|
||||
ctx = {**sidebar, "page": page, "page_favorited": fav is not None,
|
||||
"page_share_mode": page.get("share_mode", "private"),
|
||||
"page_published": bool(page.get("published", 0)),
|
||||
"page_is_shared": bool(page.get("is_shared", 0)) or page.get("share_mode", "private") != "private" or bool(page.get("published", 0)),
|
||||
"page_data": page_data,
|
||||
"breadcrumb_items": nav_crumbs,
|
||||
"nav_workspace_id": page.get("workspace_id") or 0,
|
||||
"nav_page_id": page_id,
|
||||
"embed_mode": embed}
|
||||
template = env.get_template("page_editor_embed.html" if embed else "page_editor.html")
|
||||
response = template.render(**ctx)
|
||||
return HTMLResponse(content=response, headers={"Cache-Control": "no-store, max-age=0"})
|
||||
@@ -0,0 +1,271 @@
|
||||
"""FlowDeck — Board : pages.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Query, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event, run_event_sync
|
||||
from app.services.permission_manager import PermissionManager
|
||||
|
||||
from ._common import _block_texts, _ensure_page_editable, _record_version
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["board"], prefix="/board")
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ Pages Markdown ═══════════
|
||||
|
||||
@router.post("/api/pages")
|
||||
def create_page(request: Request, title: str = Query(default=""),
|
||||
section: str = Query(default="Private"),
|
||||
project: str = Query(default=""),
|
||||
parent_id: int = Query(default=0)):
|
||||
"""Create a new Markdown page, optionally as a sub-page."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
# A7 : la création de page exige une session (route sortue de la liste CSRF).
|
||||
raise HTTPException(401, "Authentication required")
|
||||
ws_key = project if project else (user.get("login", "Bruno") if user else "Bruno")
|
||||
page_title = title.strip() if title else ""
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
# Compute next sort_order for this parent
|
||||
next_order = 0
|
||||
parent_val = parent_id if parent_id > 0 else None
|
||||
row = conn.execute(
|
||||
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace=? AND parent_id IS ?",
|
||||
(ws_key, parent_val),
|
||||
).fetchone()
|
||||
if row:
|
||||
next_order = row[0]
|
||||
cur = conn.execute(
|
||||
"INSERT INTO pages (workspace, title, parent_section, parent_id, sort_order) VALUES (?,?,?,?,?)",
|
||||
(ws_key, page_title, section, parent_val, next_order),
|
||||
)
|
||||
conn.commit()
|
||||
page_id = cur.lastrowid
|
||||
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)
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse({"error": "Failed to create page", "detail": str(e)}, status_code=500)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}")
|
||||
def get_page(request: Request, page_id: int):
|
||||
"""Get a Markdown page."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
# v6.0.0: granular page permissions — 404 (not 403) hides restricted pages.
|
||||
if not PermissionManager(user["id"], bool(user.get("is_admin"))).can_view_page(page_id):
|
||||
raise HTTPException(404, "Page not found")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Page not found")
|
||||
return dict(row)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.put("/api/pages/{page_id}")
|
||||
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."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(403, "Authentication required")
|
||||
# v6.0.0: granular page permissions — hidden pages 404 (not 403); a visible
|
||||
# page the caller cannot edit yields 403.
|
||||
pm = PermissionManager(user["id"], bool(user.get("is_admin")))
|
||||
if not pm.can_view_page(page_id):
|
||||
raise HTTPException(404, "Page not found")
|
||||
if not pm.can_edit_page(page_id):
|
||||
raise HTTPException(403, "You don't have edit access to this page")
|
||||
with get_conn() as conn:
|
||||
_ensure_page_editable(conn, page_id, user)
|
||||
if title:
|
||||
conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, page_id))
|
||||
# v6.5.0: renaming a database row's content page updates the row.
|
||||
from app.services.row_pages import sync_page_title_to_row
|
||||
sync_page_title_to_row(conn, page_id)
|
||||
if content:
|
||||
conn.execute("UPDATE pages SET content=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content, page_id))
|
||||
if content_format:
|
||||
conn.execute("UPDATE pages SET content_format=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content_format, page_id))
|
||||
conn.commit()
|
||||
run_event_sync(fire_event("page.updated", {"page_id": page_id, "title": title,
|
||||
"content_format": content_format or "markdown",
|
||||
"actor_id": user.get("id")}))
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/blocks")
|
||||
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.
|
||||
"""
|
||||
blocks = body.get("blocks", [])
|
||||
blocks_json = json.dumps(blocks)
|
||||
title = body.get("title", "")
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = (user or {}).get("id")
|
||||
# No session → legacy single-user behaviour; otherwise enforce edit rights.
|
||||
if uid and not PermissionManager(uid).can_edit_page(page_id):
|
||||
raise HTTPException(403, "You don't have edit access to this page")
|
||||
|
||||
# Extract synced block ids from the blocks
|
||||
def _extract_synced(blocks: list[dict]) -> set[int]:
|
||||
ids: set[int] = set()
|
||||
for b in blocks:
|
||||
if b.get("type") == "synced" and b.get("synced_id"):
|
||||
ids.add(b["synced_id"])
|
||||
if isinstance(b.get("children"), list):
|
||||
ids |= _extract_synced(b["children"])
|
||||
return ids
|
||||
|
||||
synced_ids = _extract_synced(blocks)
|
||||
|
||||
with get_conn() as conn:
|
||||
_ensure_page_editable(conn, page_id, user)
|
||||
if title:
|
||||
conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, page_id))
|
||||
# v6.5.0: renaming a database row's content page updates the row.
|
||||
from app.services.row_pages import sync_page_title_to_row
|
||||
sync_page_title_to_row(conn, page_id)
|
||||
conn.execute(
|
||||
"UPDATE pages SET content=?, content_format='blocks', updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(blocks_json, page_id),
|
||||
)
|
||||
_record_version(conn, page_id, uid, title or "", blocks_json)
|
||||
# Update synced block references
|
||||
existing = {r["synced_block_id"] for r in conn.execute(
|
||||
"SELECT synced_block_id FROM page_synced_blocks WHERE page_id=?", (page_id,)
|
||||
).fetchall()}
|
||||
for sid in synced_ids:
|
||||
if sid not in existing:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO page_synced_blocks (page_id, synced_block_id, block_index) VALUES (?, ?, 0)",
|
||||
(page_id, sid),
|
||||
)
|
||||
for sid in existing - synced_ids:
|
||||
conn.execute(
|
||||
"DELETE FROM page_synced_blocks WHERE page_id=? AND synced_block_id=?",
|
||||
(page_id, sid),
|
||||
)
|
||||
conn.commit()
|
||||
run_event_sync(fire_event("page.updated", {"page_id": page_id, "title": title or "",
|
||||
"content_format": "blocks",
|
||||
"actor_id": uid}))
|
||||
return {"status": "ok", "id": page_id}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}/backlinks")
|
||||
def page_backlinks(request: Request, page_id: int):
|
||||
"""v5.4.0: pages that link to this one ("Lié depuis…").
|
||||
|
||||
Scans every non-deleted page's blocks (and raw markdown) for an internal
|
||||
reference to ``/pages/{page_id}`` or ``#fdblk-…`` inside ``/pages/{page_id}``.
|
||||
"""
|
||||
target = f"/pages/{page_id}" if page_id else None
|
||||
wiki_target = f"[[fdpage:{page_id}]]" if page_id else None
|
||||
backlinks = []
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, workspace, content, content_format, updated_at "
|
||||
"FROM pages WHERE deleted_at IS NULL AND id != ?",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
fmt = r["content_format"]
|
||||
hits = False
|
||||
if fmt == "blocks" and r["content"]:
|
||||
try:
|
||||
blocks = json.loads(r["content"])
|
||||
for b in blocks if isinstance(blocks, list) else []:
|
||||
for text in _block_texts(b):
|
||||
if target and (target in text or (wiki_target and wiki_target in text)):
|
||||
hits = True
|
||||
break
|
||||
if hits:
|
||||
break
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
hits = target and (target in (r["content"] or "") or (wiki_target and wiki_target in (r["content"] or "")))
|
||||
elif fmt == "markdown":
|
||||
hits = target and (target in (r["content"] or "") or (wiki_target and wiki_target in (r["content"] or "")))
|
||||
elif r["content"]:
|
||||
hits = target and (target in json.dumps(r["content"]) or (wiki_target and wiki_target in json.dumps(r["content"])))
|
||||
if not hits and target:
|
||||
hits = f"/pages/{page_id}" in (r["content"] or "")
|
||||
if hits:
|
||||
backlinks.append({
|
||||
"id": r["id"],
|
||||
"title": r["title"] or "Untitled",
|
||||
"workspace": r["workspace"] or "",
|
||||
"updated_at": r["updated_at"] or "",
|
||||
})
|
||||
backlinks.sort(key=lambda x: x.get("updated_at") or "", reverse=True)
|
||||
return {"backlinks": backlinks}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}/versions")
|
||||
def page_versions(request: Request, page_id: int):
|
||||
"""v5.4.0: version history for a block-editor page."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT pv.id, pv.title, pv.note, pv.created_at, "
|
||||
"COALESCE(u.login, '') AS author "
|
||||
"FROM page_versions pv LEFT JOIN users u ON u.id=pv.user_id "
|
||||
"WHERE pv.page_id=? ORDER BY pv.id DESC LIMIT 100",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
return {"versions": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/versions/{version_id}/restore")
|
||||
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(
|
||||
"SELECT * FROM page_versions WHERE id=? AND page_id=?",
|
||||
(version_id, page_id),
|
||||
).fetchone()
|
||||
if not ver:
|
||||
raise HTTPException(404, "Version not found")
|
||||
conn.execute(
|
||||
"UPDATE pages SET content=?, title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(ver["blocks_json"], ver["title"] or "", page_id),
|
||||
)
|
||||
conn.commit()
|
||||
run_event_sync(fire_event("page.updated", {"page_id": page_id, "title": ver["title"] or "",
|
||||
"content_format": "blocks"}))
|
||||
return {"status": "ok", "restored": version_id}
|
||||
|
||||
|
||||
# ═══════════ v5.4.0: Page & collection duplication ═══════════
|
||||
@@ -0,0 +1,249 @@
|
||||
"""FlowDeck — Board : sharing.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
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, run_event_sync
|
||||
from app.services.collection_lifecycle import (
|
||||
collections_hosted_by_page,
|
||||
collections_hosted_by_pages,
|
||||
delete_collections,
|
||||
)
|
||||
from app.services.publish import fire_published, fire_unpublished, publish, unpublish
|
||||
|
||||
from ._common import _sidebar_data
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["board"], prefix="/board")
|
||||
|
||||
|
||||
|
||||
# ═══════════ Favorites API ═══════════
|
||||
|
||||
@router.get("/api/favorites")
|
||||
def list_favorites(request: Request):
|
||||
"""List favorited page IDs for the current user."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = user["id"] if user and user.get("id") else 1
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT page_id FROM favorites WHERE user_id=? ORDER BY position", (uid,)
|
||||
).fetchall()
|
||||
return {"favorites": [r["page_id"] for r in rows]}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/favorites/{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
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM favorites WHERE user_id=? AND page_id=?", (uid, page_id)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM favorites WHERE user_id=?", (uid,)
|
||||
).fetchone()[0]
|
||||
conn.execute(
|
||||
"INSERT INTO favorites (user_id, page_id, position) VALUES (?,?,?)",
|
||||
(uid, page_id, pos),
|
||||
)
|
||||
conn.commit()
|
||||
try:
|
||||
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}")
|
||||
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
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM favorites WHERE user_id=? AND page_id=?", (uid, page_id))
|
||||
conn.commit()
|
||||
try:
|
||||
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}
|
||||
|
||||
# ═══════════ Share API ═══════════
|
||||
|
||||
|
||||
|
||||
# ═══════════ Share API ═══════════
|
||||
|
||||
@router.post("/api/share/{page_id:int}")
|
||||
def update_share(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
"""Save share settings for a page."""
|
||||
mode = body.get("mode", "private")
|
||||
published = body.get("published", False)
|
||||
with get_conn() as conn:
|
||||
# v7.69.6 : les deux drapeaux ensemble (voir services/publish), plus
|
||||
# un slug garanti quand on publie depuis le share dialog.
|
||||
from app.services.publish import _unique_slug
|
||||
row = conn.execute(
|
||||
"SELECT publish_slug FROM pages WHERE id=?", (page_id,)
|
||||
).fetchone()
|
||||
slug = (row["publish_slug"] if row and row["publish_slug"] else "")
|
||||
if published and not slug:
|
||||
title_row = conn.execute(
|
||||
"SELECT title FROM pages WHERE id=?", (page_id,)
|
||||
).fetchone()
|
||||
slug = _unique_slug(
|
||||
conn, page_id, title_row["title"] if title_row else "")
|
||||
conn.execute(
|
||||
"UPDATE pages SET share_mode=?, published=?, is_published=?, publish_slug=? WHERE id=?",
|
||||
(mode, 1 if published else 0, 1 if published else 0, slug, page_id),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok", "share_mode": mode, "published": published}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id:int}/publish")
|
||||
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)
|
||||
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")
|
||||
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)
|
||||
run_event_sync(fire_unpublished(page_id))
|
||||
return {"is_published": False}
|
||||
|
||||
|
||||
# ═══════════ Trash (must be before /{owner}/{repo} catch-all) ═══════════
|
||||
|
||||
|
||||
def _trash_session(request: Request):
|
||||
"""Trash : session obligatoire (les 3 routes mutaient/ lisaient toutes les
|
||||
pages sans aucune vérification — le CSRF seul ne protège pas, le cookie est
|
||||
lisible par JS et un attaquant fixe cookie ET en-tête)."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
raise HTTPException(401, "Authentication required")
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/api/trash")
|
||||
def list_trash(request: Request):
|
||||
_trash_session(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, workspace, parent_section, deleted_at FROM pages "
|
||||
"WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC"
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"id": r["id"],
|
||||
"name": r["title"] or "Untitled",
|
||||
"icon": "📁" if r["parent_section"] == "Workspace" else "📄",
|
||||
"path": r["workspace"] or "Private",
|
||||
"deleted_at": r["deleted_at"],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/trash/{page_id}/restore")
|
||||
def restore_page(request: Request, page_id: int):
|
||||
_trash_session(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET deleted_at=NULL WHERE id=?", (page_id,))
|
||||
conn.commit()
|
||||
try:
|
||||
run_event_sync(fire_event("page.restored", {"page_id": page_id}))
|
||||
except Exception:
|
||||
logger.exception("restore_page")
|
||||
return {"status": "ok", "restored": page_id}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/api/trash/{page_id}")
|
||||
def permanent_delete(request: Request, page_id: int):
|
||||
_trash_session(request)
|
||||
with get_conn() as conn:
|
||||
# La page hôte d'une base disparaît définitivement → sa base et ses
|
||||
# lignes aussi, sinon My Tasks (et /db) continuaient de lister des
|
||||
# tâches sans document.
|
||||
collections = collections_hosted_by_page(conn, page_id)
|
||||
conn.execute("UPDATE pages SET parent_id=NULL WHERE parent_id=?", (page_id,))
|
||||
conn.execute("DELETE FROM pages WHERE id=? AND deleted_at IS NOT NULL", (page_id,))
|
||||
delete_collections(conn, collections)
|
||||
conn.commit()
|
||||
return {"status": "ok", "deleted": page_id, "collections": len(collections)}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/trash/empty")
|
||||
def empty_trash(request: Request):
|
||||
"""Empty Trash : purge en masse (les enfants des pages supprimées passent
|
||||
en racine, comme permanent_delete — comportement historique conservé)."""
|
||||
_trash_session(request)
|
||||
with get_conn() as conn:
|
||||
n = conn.execute(
|
||||
"SELECT COUNT(*) FROM pages WHERE deleted_at IS NOT NULL"
|
||||
).fetchone()[0]
|
||||
# Bases portées par les pages purgées (les pages contenu de ligne
|
||||
# n'hébergent aucune base et ne remontent rien).
|
||||
trashed = [
|
||||
r["id"]
|
||||
for r in conn.execute(
|
||||
"SELECT id FROM pages WHERE deleted_at IS NOT NULL"
|
||||
).fetchall()
|
||||
]
|
||||
collections = collections_hosted_by_pages(conn, trashed)
|
||||
conn.execute(
|
||||
"UPDATE pages SET parent_id=NULL WHERE parent_id IN "
|
||||
"(SELECT id FROM pages WHERE deleted_at IS NOT NULL)"
|
||||
)
|
||||
conn.execute("DELETE FROM pages WHERE deleted_at IS NOT NULL")
|
||||
delete_collections(conn, collections)
|
||||
conn.commit()
|
||||
return {"status": "ok", "deleted": n, "collections": len(collections)}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/trash", response_class=HTMLResponse)
|
||||
def trash_page(request: Request):
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
template = env.get_template("trash.html")
|
||||
return template.render(**_sidebar_data(request))
|
||||
|
||||
|
||||
# ═══════════════ v5.14.0: Synced Blocks ─═══════════════════
|
||||
# These routes MUST be registered before the catch-all /{owner}/{repo} below.
|
||||
@@ -0,0 +1,51 @@
|
||||
"""FlowDeck — Board : sync.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.gitea_client import gitea
|
||||
|
||||
from ._common import _extract_ai_keywords, _issue_column
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["board"], prefix="/board")
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/sync/{owner}/{repo}")
|
||||
async def sync_project(owner: str, repo: str):
|
||||
"""Full bidirectional sync: fetch Gitea issues → update local DB."""
|
||||
try:
|
||||
issues = await gitea.get_issues(owner, repo, state="all")
|
||||
issues_only = [i for i in issues if not i.get("pull_request")]
|
||||
with get_conn() as conn:
|
||||
board = conn.execute(
|
||||
"SELECT id, columns_json FROM boards WHERE project_owner=? AND project_name=?",
|
||||
(owner, repo),
|
||||
).fetchone()
|
||||
if board:
|
||||
board_id = board["id"]
|
||||
columns = json.loads(board["columns_json"])
|
||||
# A23 : un seul executemany pour toutes les cards.
|
||||
conn.executemany(
|
||||
"INSERT OR REPLACE INTO cards (board_id, gitea_issue_id, column_name) VALUES (?,?,?)",
|
||||
[
|
||||
(board_id, issue["number"], _issue_column(issue, columns, board_id))
|
||||
for issue in issues_only
|
||||
],
|
||||
)
|
||||
for issue in issues_only:
|
||||
# Extract AI keywords from each issue
|
||||
_extract_ai_keywords(owner, repo, issue.get("labels", []), issue.get("body", ""))
|
||||
conn.commit()
|
||||
return {"status": "ok", "issues_synced": len(issues_only)}
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e)) from e
|
||||
@@ -0,0 +1,165 @@
|
||||
"""FlowDeck — Board : synced.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Query, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["board"], prefix="/board")
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════════ v5.14.0: Synced Blocks ─═══════════════════# These routes MUST be registered before the catch-all /{owner}/{repo} below.
|
||||
|
||||
def _session_user(request: Request) -> dict:
|
||||
"""Session obligatoire — sans garde, un appel anonyme levait
|
||||
``AttributeError: 'NoneType' object has no attribute 'get'`` (HTTP 500)
|
||||
au lieu d'un 401."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
return user
|
||||
|
||||
|
||||
def _assert_can_edit_block(request: Request, sb: dict) -> None:
|
||||
"""Seul l'auteur du bloc (ou un admin global) peut le modifier/supprimer."""
|
||||
user = _session_user(request)
|
||||
if sb.get("created_by") in (None, user.get("id")):
|
||||
return
|
||||
if user.get("is_admin"):
|
||||
return
|
||||
raise HTTPException(403, "Not the author of this synced block")
|
||||
|
||||
|
||||
@router.get("/api/synced-blocks")
|
||||
def list_synced_blocks_api(request: Request, workspace: str = Query(default="")):
|
||||
"""List synced blocks for a workspace."""
|
||||
user = _session_user(request)
|
||||
from app.services.synced_blocks import list_synced_blocks
|
||||
return {"synced_blocks": list_synced_blocks(workspace or user.get("login", ""))}
|
||||
|
||||
|
||||
|
||||
@router.post("/api/synced-blocks")
|
||||
def create_synced_block_api(request: Request, body: dict = Body(...)):
|
||||
"""Create a new synced block."""
|
||||
user = _session_user(request)
|
||||
from app.services.synced_blocks import create_synced_block
|
||||
sid = create_synced_block(
|
||||
workspace=body.get("workspace", ""),
|
||||
title=body.get("title", "Synced block"),
|
||||
content=body.get("content", []),
|
||||
created_by=user.get("id"),
|
||||
)
|
||||
return {"status": "ok", "synced_block_id": sid}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.put("/api/synced-blocks/{sid}")
|
||||
async def update_synced_block_api(request: Request, sid: int):
|
||||
"""Update a synced block's content (propagates to all pages)."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid JSON body") from None
|
||||
from app.services.synced_blocks import (
|
||||
get_synced_block,
|
||||
page_ids_for_synced,
|
||||
sync_synced_blocks_in_page,
|
||||
update_synced_block,
|
||||
)
|
||||
sb = get_synced_block(sid)
|
||||
if not sb:
|
||||
raise HTTPException(404, "Synced block not found")
|
||||
_assert_can_edit_block(request, sb)
|
||||
update_synced_block(sid, body.get("title", sb["title"]), body.get("content", []))
|
||||
# v6.5.0: rewrite every referencing page's stored content first (DB row
|
||||
# content pages included), THEN push the realtime update so open rooms
|
||||
# reload the fresh content from the DB.
|
||||
for pid in page_ids_for_synced(sid):
|
||||
sync_synced_blocks_in_page(pid)
|
||||
from app.services.realtime_server import manager
|
||||
await manager._propagate_synced(sid)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/api/synced-blocks/{sid}")
|
||||
async def delete_synced_block_api(request: Request, sid: int):
|
||||
"""Delete a synced block."""
|
||||
from app.services.synced_blocks import (
|
||||
delete_synced_block,
|
||||
get_synced_block,
|
||||
mark_synced_block_deleted,
|
||||
page_ids_for_synced,
|
||||
)
|
||||
sb = get_synced_block(sid)
|
||||
if not sb:
|
||||
raise HTTPException(404, "Synced block not found")
|
||||
_assert_can_edit_block(request, sb)
|
||||
# v6.5.0: collect referencing pages BEFORE the FK cascade wipes the
|
||||
# refs, rewrite their stored content (deleted state), then broadcast.
|
||||
pids = page_ids_for_synced(sid)
|
||||
delete_synced_block(sid)
|
||||
mark_synced_block_deleted(sid, pids)
|
||||
from app.services.realtime_server import manager
|
||||
await manager._broadcast_synced_to(pids, sid)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/synced-blocks/{sid}")
|
||||
def get_synced_block_api(request: Request, sid: int):
|
||||
"""Get a synced block by id."""
|
||||
_session_user(request)
|
||||
from app.services.synced_blocks import get_synced_block
|
||||
sb = get_synced_block(sid)
|
||||
if not sb:
|
||||
raise HTTPException(404, "Synced block not found")
|
||||
return dict(sb)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/synced")
|
||||
def add_synced_to_page(request: Request, page_id: int, body: dict = Body(...)):
|
||||
"""Add a synced block reference to a page."""
|
||||
from app.services.synced_blocks import add_page_synced, get_synced_block
|
||||
sid = body.get("synced_block_id")
|
||||
sb = get_synced_block(sid)
|
||||
if not sb:
|
||||
raise HTTPException(404, "Synced block not found")
|
||||
add_page_synced(page_id, sid, body.get("block_index", 0))
|
||||
return {"status": "ok", "synced_block_id": sid}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/api/pages/{page_id}/synced/{sid}")
|
||||
def remove_synced_from_page(request: Request, page_id: int, sid: int):
|
||||
"""Remove a synced block reference from a page (unsync)."""
|
||||
from app.services.synced_blocks import remove_page_synced
|
||||
remove_page_synced(page_id, sid)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}/synced")
|
||||
def get_page_synced_refs(request: Request, page_id: int):
|
||||
"""Get all synced block references for a page."""
|
||||
from app.services.synced_blocks import get_page_synced
|
||||
return {"synced_blocks": get_page_synced(page_id)}
|
||||
|
||||
|
||||
# ═══════════ Board page ═══════════
|
||||
@@ -0,0 +1,76 @@
|
||||
"""FlowDeck — Board : wiki.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Query, Request
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["board"], prefix="/board")
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/wiki/pages")
|
||||
def wiki_page_search(request: Request, q: str = Query(default="")):
|
||||
"""v5.11.0: page picker for [[ wiki-links. Fuzzy-ish title search across
|
||||
every non-deleted page the current user can see (single source: pages)."""
|
||||
q = (q or "").strip().lower()
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT id, title, page_icon, workspace FROM pages
|
||||
WHERE deleted_at IS NULL
|
||||
ORDER BY updated_at DESC LIMIT 500"""
|
||||
).fetchall()
|
||||
results = []
|
||||
for r in rows:
|
||||
title = r["title"] or "Untitled"
|
||||
if q:
|
||||
# subsequence match ("mtg" → "Meeting notes") or plain substring.
|
||||
hay = title.lower()
|
||||
it = iter(hay)
|
||||
subseq = all(ch in it for ch in q)
|
||||
if q not in hay and not subseq:
|
||||
continue
|
||||
results.append({
|
||||
"id": r["id"],
|
||||
"title": title,
|
||||
"icon": r["page_icon"] or "",
|
||||
"workspace": r["workspace"] or "",
|
||||
})
|
||||
if len(results) >= 20:
|
||||
break
|
||||
return {"pages": results}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/wiki/titles")
|
||||
def wiki_titles(request: Request, ids: str = Query(default="")):
|
||||
"""v5.11.0: resolve page-id lists to current labels (rename propagation)."""
|
||||
parsed: list[int] = []
|
||||
for part in (ids or "").split(","):
|
||||
part = part.strip()
|
||||
if part.isdigit():
|
||||
parsed.append(int(part))
|
||||
parsed = parsed[:200]
|
||||
out: dict[str, str] = {}
|
||||
if parsed:
|
||||
placeholders = ",".join("?" * len(parsed))
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT id, title, page_icon, deleted_at FROM pages WHERE id IN ({placeholders})",
|
||||
parsed,
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
if r["deleted_at"]:
|
||||
out[str(r["id"])] = "Deleted page"
|
||||
else:
|
||||
icon = (r["page_icon"] or "")
|
||||
out[str(r["id"])] = (icon + " " if icon else "") + (r["title"] or "Untitled")
|
||||
return {"titles": out}
|
||||
@@ -206,3 +206,67 @@ def delete_comment(request: Request, comment_id: int):
|
||||
conn.execute("DELETE FROM comments WHERE id=? OR parent_id=?", (comment_id, comment_id))
|
||||
conn.commit()
|
||||
return {"id": comment_id, "status": "deleted"}
|
||||
|
||||
|
||||
# ── v7.64.0 : réactions emoji sur un texte sélectionné ──────────────────────
|
||||
|
||||
@router.get("/pages/{page_id}/reactions")
|
||||
def list_reactions(request: Request, page_id: int):
|
||||
"""Réactions regroupées par plage de texte : {block_id, start, end, emoji, count, mine}."""
|
||||
user = _current_user(request)
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone():
|
||||
raise HTTPException(404, "Page not found")
|
||||
rows = conn.execute(
|
||||
"""SELECT block_id, anchor_start, anchor_end, emoji,
|
||||
COUNT(*) AS count,
|
||||
MAX(CASE WHEN user_id=? THEN 1 ELSE 0 END) AS mine
|
||||
FROM text_reactions
|
||||
WHERE page_id=?
|
||||
GROUP BY block_id, anchor_start, anchor_end, emoji
|
||||
ORDER BY MIN(id)""",
|
||||
(user["id"], page_id),
|
||||
).fetchall()
|
||||
return {"page_id": page_id, "reactions": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/reactions")
|
||||
def toggle_reaction(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
"""Ajoute ou retire la réaction de l'utilisateur sur une plage de texte (toggle)."""
|
||||
user = _current_user(request)
|
||||
block = (body.get("block_id") or "").strip()
|
||||
emoji = (body.get("emoji") or "").strip()
|
||||
try:
|
||||
start = int(body.get("anchor_start"))
|
||||
end = int(body.get("anchor_end"))
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, "anchor offsets required") from None
|
||||
if not block or not emoji or start < 0 or end <= start:
|
||||
raise HTTPException(400, "block_id, emoji and a non-empty range are required")
|
||||
uid = user["id"]
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone():
|
||||
raise HTTPException(404, "Page not found")
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,1)",
|
||||
(uid, user.get("login", "admin"), user.get("full_name", "Admin")),
|
||||
)
|
||||
existing = conn.execute(
|
||||
"""SELECT id FROM text_reactions
|
||||
WHERE page_id=? AND block_id=? AND anchor_start=? AND anchor_end=?
|
||||
AND emoji=? AND user_id=?""",
|
||||
(page_id, block, start, end, emoji, uid),
|
||||
).fetchone()
|
||||
if existing:
|
||||
conn.execute("DELETE FROM text_reactions WHERE id=?", (existing["id"],))
|
||||
status = "removed"
|
||||
else:
|
||||
conn.execute(
|
||||
"""INSERT INTO text_reactions
|
||||
(page_id, block_id, anchor_start, anchor_end, emoji, user_id)
|
||||
VALUES (?,?,?,?,?,?)""",
|
||||
(page_id, block, start, end, emoji, uid),
|
||||
)
|
||||
status = "added"
|
||||
conn.commit()
|
||||
return {"status": status, "emoji": emoji}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,67 @@
|
||||
"""FlowDeck — Collections : bases de données façon Notion.
|
||||
|
||||
Découpe A28 : l'ancien `collections.py` (2 622 lignes, 53 routes) est
|
||||
devenu ce package — un module par concern, helpers dans `_common`
|
||||
(auth/permissions/validation) et `_renderers` (rendus HTML des vues).
|
||||
Ré-exports : automations importe `_validate_page_properties`, les
|
||||
tests importent les helpers de graphes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from . import ( # ordre = ordre d'enregistrement d'origine
|
||||
boards,
|
||||
crud,
|
||||
dashboard_views,
|
||||
data_api,
|
||||
index_page,
|
||||
linked,
|
||||
meta,
|
||||
pages,
|
||||
properties,
|
||||
structure,
|
||||
task_db,
|
||||
views,
|
||||
)
|
||||
from ._common import _validate_page_properties # noqa: F401
|
||||
from ._renderers import ( # noqa: F401 — ré-exports tests
|
||||
_chart_aggregate,
|
||||
_chart_values,
|
||||
_fmt_number,
|
||||
_render_chart,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
# `task_db` est enregistré **en premier** : sa route statique
|
||||
# `GET /db/task-dbs/api` serait sinon capturée par `data_api`'s
|
||||
# `GET /db/{collection_id}/api` (`collection_id="task-dbs"` → 422).
|
||||
# `index_page` (la page HTML `/db`) vient ensuite, avant tout module
|
||||
# déclarant `/db/{collection_id}` — sinon la racine serait capturée en 422.
|
||||
for _mod in (
|
||||
task_db,
|
||||
index_page,
|
||||
crud,
|
||||
pages,
|
||||
boards,
|
||||
meta,
|
||||
properties,
|
||||
views,
|
||||
structure,
|
||||
linked,
|
||||
dashboard_views,
|
||||
data_api,
|
||||
):
|
||||
router.include_router(_mod.router)
|
||||
|
||||
__all__ = [
|
||||
"router",
|
||||
"_chart_aggregate",
|
||||
"_chart_values",
|
||||
"_fmt_number",
|
||||
"_render_chart",
|
||||
"_validate_page_properties",
|
||||
]
|
||||
@@ -0,0 +1,220 @@
|
||||
"""FlowDeck — Collections : helpers partagés (A28).
|
||||
|
||||
Les 8 helpers de tête de l'ancien collections.py (auth, permissions,
|
||||
validation) — ré-exportés par le package.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.services.permission_manager import PermissionManager
|
||||
from app.services.property_types import (
|
||||
AUTO_TYPES,
|
||||
validate_property_rule,
|
||||
)
|
||||
from app.services.recurrence import (
|
||||
RECURRENCE_KEY,
|
||||
is_valid_timezone,
|
||||
validate_rule,
|
||||
)
|
||||
from app.services.reminders import REMINDER_KEY, parse_lead
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["collections"], prefix="/db")
|
||||
|
||||
|
||||
|
||||
def _current_user(request: Request) -> dict:
|
||||
"""Resolve the session user, falling back to the local admin (single-user)."""
|
||||
s = request.cookies.get("flowdeck_session", "")
|
||||
return SessionManager.decode_session(s) or {"login": "admin", "id": 1}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _session_user(request: Request) -> dict | None:
|
||||
"""Resolve the session user WITHOUT the admin fallback (for ACL checks)."""
|
||||
s = request.cookies.get("flowdeck_session", "")
|
||||
user = SessionManager.decode_session(s)
|
||||
return user if user and user.get("id") else None
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _require_view(collection_id: int, user: dict | None) -> None:
|
||||
"""Raise 404 when the user may not view the collection (404 hides it).
|
||||
|
||||
A6 : plus de session = accès refusé — l'absence de user ne vaut plus
|
||||
« legacy single-user » ( lecture anonyme de n'importe quelle collection ).
|
||||
"""
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
pm = PermissionManager(user["id"])
|
||||
if not pm.can_view_collection(collection_id):
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _require_edit(collection_id: int, user: dict | None) -> None:
|
||||
"""Raise 401/403 when the user may not edit pages in the collection."""
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
pm = PermissionManager(user["id"])
|
||||
if not pm.can_edit_collection(collection_id):
|
||||
raise HTTPException(status_code=403, detail="You don't have edit access to this collection")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _collection_properties(conn, collection_id: int) -> list[dict]:
|
||||
return [
|
||||
dict(r) for r in conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
]
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _apply_template(conn, template_name: str) -> dict | None:
|
||||
"""Resolve a database template by name (from the seeded/built-in set)."""
|
||||
if not template_name:
|
||||
return None
|
||||
row = conn.execute(
|
||||
"SELECT id, name, icon, description, schema_json FROM database_templates WHERE name=?",
|
||||
(template_name,),
|
||||
).fetchone()
|
||||
if row:
|
||||
return dict(row)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _validate_page_properties(conn, collection_id: int, properties: dict, exclude_page_id: int | None = None) -> None:
|
||||
"""Validate submitted property values against the collection's schema.
|
||||
|
||||
Raises ``HTTPException(400)`` with a user-friendly message on the first
|
||||
failure (type, required, unique, min/max).
|
||||
"""
|
||||
props = conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE collection_id=?", (collection_id,)
|
||||
).fetchall()
|
||||
|
||||
for prop in props:
|
||||
ptype = prop["prop_type"]
|
||||
if ptype == "title" or ptype in AUTO_TYPES:
|
||||
continue
|
||||
pid = prop["id"]
|
||||
# Values may be keyed by property id (FlowDeckDB UI) or by name (agent).
|
||||
value = properties.get(str(pid))
|
||||
if value is None:
|
||||
value = properties.get(prop["name"])
|
||||
validation = prop["validation_json"] if "validation_json" in prop.keys() else "{}"
|
||||
|
||||
existing_values = None
|
||||
try:
|
||||
import json as _json
|
||||
vcfg = _json.loads(validation) if validation else {}
|
||||
except Exception:
|
||||
vcfg = {}
|
||||
if vcfg.get("unique"):
|
||||
rows = conn.execute(
|
||||
"SELECT id, property_values_json FROM collection_pages WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
existing_values = []
|
||||
for r in rows:
|
||||
if exclude_page_id is not None and r["id"] == exclude_page_id:
|
||||
continue
|
||||
try:
|
||||
pv = _json.loads(r["property_values_json"] or "{}")
|
||||
except Exception:
|
||||
pv = {}
|
||||
existing_values.append(pv.get(str(pid)) or pv.get(prop["name"]))
|
||||
|
||||
ok, msg = validate_property_rule(ptype, value, validation, existing_values=existing_values)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=400, detail=f"Property '{prop['name']}': {msg}")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _validate_meta_keys(conn, collection_id: int, properties: dict) -> None:
|
||||
"""Validate the ``__recurrence__`` / ``__reminder__`` meta keys stored
|
||||
alongside real property values. Raises HTTPException(400) on bad shape.
|
||||
|
||||
Each meta key maps a date-property id to a rule/reminder object. We verify
|
||||
the target is actually a date property and the payload parses.
|
||||
"""
|
||||
date_ids = {
|
||||
str(r["id"]) for r in conn.execute(
|
||||
"SELECT id FROM collection_properties WHERE collection_id=? AND prop_type='date'",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
}
|
||||
|
||||
rec = properties.get(RECURRENCE_KEY)
|
||||
if rec not in (None, {}):
|
||||
if not isinstance(rec, dict):
|
||||
raise HTTPException(status_code=400, detail="Recurrence must be an object")
|
||||
for prop_id, rule in rec.items():
|
||||
if rule is None:
|
||||
continue
|
||||
if str(prop_id) not in date_ids:
|
||||
raise HTTPException(status_code=400, detail="Recurrence target must be a date property")
|
||||
ok, msg = validate_rule(rule)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=400, detail=f"Recurrence: {msg}")
|
||||
|
||||
rem = properties.get(REMINDER_KEY)
|
||||
if rem not in (None, {}):
|
||||
if not isinstance(rem, dict):
|
||||
raise HTTPException(status_code=400, detail="Reminder must be an object")
|
||||
for prop_id, reminder in rem.items():
|
||||
if reminder is None:
|
||||
continue
|
||||
if str(prop_id) not in date_ids:
|
||||
raise HTTPException(status_code=400, detail="Reminder target must be a date property")
|
||||
if not isinstance(reminder, dict):
|
||||
raise HTTPException(status_code=400, detail="Reminder must be an object")
|
||||
if reminder.get("unit") not in (None, "none", "minutes", "hours", "days"):
|
||||
raise HTTPException(status_code=400, detail="Reminder unit must be minutes/hours/days/none")
|
||||
if parse_lead(reminder) is None and reminder.get("unit") != "none":
|
||||
raise HTTPException(status_code=400, detail="Reminder value must be a positive integer")
|
||||
|
||||
from app.services.recurrence import TIMEZONE_KEY
|
||||
tzmap = properties.get(TIMEZONE_KEY)
|
||||
if tzmap not in (None, {}):
|
||||
if not isinstance(tzmap, dict):
|
||||
raise HTTPException(status_code=400, detail="Timezone map must be an object")
|
||||
for prop_id, value in tzmap.items():
|
||||
if str(prop_id) not in date_ids:
|
||||
raise HTTPException(status_code=400, detail="Timezone target must be a date property")
|
||||
if value and not is_valid_timezone(str(value)):
|
||||
raise HTTPException(status_code=400, detail=f"Unknown timezone '{value}'")
|
||||
|
||||
|
||||
# ── API: List & Create (no path params) ──
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,667 @@
|
||||
"""FlowDeck — Collections : rendus HTML des vues (A28).
|
||||
|
||||
Les 15 helpers de rendu de l'ancien collections.py (_render_view,
|
||||
_render_chart, …) + CHART_MAX_GROUPS — ré-exportés pour les tests.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from app.db import get_conn
|
||||
from app.templating import CSP_NONCE
|
||||
|
||||
CHART_MAX_GROUPS = 200
|
||||
|
||||
|
||||
|
||||
# ── View renderers (v1.6.0) ──
|
||||
|
||||
def _render_view(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
||||
if view_type == "calendar":
|
||||
return _render_calendar(view_type, collection, pages, config)
|
||||
elif view_type == "gallery":
|
||||
return _render_gallery(view_type, collection, pages, config)
|
||||
elif view_type == "list":
|
||||
return _render_list(view_type, collection, pages, config)
|
||||
elif view_type == "timeline":
|
||||
return _render_timeline(view_type, collection, pages, config)
|
||||
elif view_type == "chart":
|
||||
return _render_chart(view_type, collection, pages, config)
|
||||
elif view_type == "form":
|
||||
return _render_form(view_type, collection, pages, config)
|
||||
elif view_type == "map":
|
||||
return _render_map(view_type, collection, pages, config)
|
||||
elif view_type == "feed":
|
||||
return _render_feed(view_type, collection, pages, config)
|
||||
elif view_type == "gantt":
|
||||
return _render_gantt(view_type, collection, pages, config)
|
||||
else:
|
||||
return _render_table(view_type, collection, pages, config)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _base_html(title: str, icon: str, view_type: str, body: str) -> str:
|
||||
return f"""<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>{title} — FlowDeck</title>
|
||||
<style>
|
||||
body{{font-family:system-ui,sans-serif;background:#191919;color:#fff;margin:0;padding:20px}}
|
||||
h1{{font-size:24px;margin:0 0 8px}} p.desc{{color:#A0A0A0;margin:0 0 20px}}
|
||||
.view-tabs{{display:flex;gap:4px;margin-bottom:20px;border-bottom:1px solid #333;padding-bottom:8px}}
|
||||
.tab{{padding:6px 14px;border-radius:6px;cursor:pointer;color:#A0A0A0;font-size:13px;background:none;border:none}}
|
||||
.tab:hover,.tab.active{{background:#333;color:#fff}}
|
||||
</style></head><body>
|
||||
<h1>{icon} {title}</h1>
|
||||
<div class="view-tabs">
|
||||
<a class="tab{' active' if view_type=='table' else ''}" href="?view_type=table">📊 Table</a>
|
||||
<a class="tab{' active' if view_type=='board' else ''}" href="?view_type=board">📋 Board</a>
|
||||
<a class="tab{' active' if view_type=='calendar' else ''}" href="?view_type=calendar">📅 Calendar</a>
|
||||
<a class="tab{' active' if view_type=='gallery' else ''}" href="?view_type=gallery">🖼️ Gallery</a>
|
||||
<a class="tab{' active' if view_type=='list' else ''}" href="?view_type=list">📝 List</a>
|
||||
<a class="tab{' active' if view_type=='timeline' else ''}" href="?view_type=timeline">📈 Timeline</a>
|
||||
<a class="tab{' active' if view_type=='gantt' else ''}" href="?view_type=gantt">📊 Gantt</a>
|
||||
<a class="tab{' active' if view_type=='chart' else ''}" href="?view_type=chart">📉 Chart</a>
|
||||
<a class="tab{' active' if view_type=='form' else ''}" href="?view_type=form">📋 Form</a>
|
||||
<a class="tab{' active' if view_type=='map' else ''}" href="?view_type=map">🗺️ Map</a>
|
||||
<a class="tab{' active' if view_type=='feed' else ''}" href="?view_type=feed">📰 Feed</a>
|
||||
</div>
|
||||
{body}
|
||||
</body></html>"""
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _render_calendar(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
||||
from datetime import date as dt_date
|
||||
from datetime import timedelta
|
||||
today = dt_date.today()
|
||||
# Determine month/year from config or current
|
||||
year = config.get("year", today.year)
|
||||
month = config.get("month", today.month)
|
||||
first = dt_date(year, month, 1)
|
||||
# Start from Monday of first week
|
||||
start = first - timedelta(days=first.weekday())
|
||||
days_in_month = []
|
||||
for i in range(42): # 6 weeks
|
||||
d = start + timedelta(days=i)
|
||||
days_in_month.append(d)
|
||||
|
||||
# Map pages to dates
|
||||
date_pages: dict[str, list[dict]] = {}
|
||||
for p in pages:
|
||||
props = json.loads(p.get("property_values_json", "{}"))
|
||||
for v in props.values():
|
||||
if isinstance(v, str) and v.startswith("20"):
|
||||
d = v[:10]
|
||||
date_pages.setdefault(d, []).append(p)
|
||||
break
|
||||
|
||||
cells = ""
|
||||
for d in days_in_month:
|
||||
iso = d.isoformat()
|
||||
items = date_pages.get(iso, [])
|
||||
other_month = " other-month" if d.month != month else ""
|
||||
today_class = " today" if d == today else ""
|
||||
items_html = "".join(
|
||||
f"<div class='cal-item' title='{p['title']}'>{p.get('icon','📄')} {p['title'][:20]}</div>"
|
||||
for p in items
|
||||
)
|
||||
cells += f"<div class='cal-day{other_month}{today_class}'><span class='cal-num'>{d.day}</span>{items_html}</div>"
|
||||
|
||||
prev = first - timedelta(days=1)
|
||||
next_month = first + timedelta(days=32)
|
||||
next_month = next_month.replace(day=1)
|
||||
|
||||
return _base_html(collection["name"], collection.get("icon", "📅"), view_type, f"""
|
||||
<style>
|
||||
.calendar{{display:grid;grid-template-columns:repeat(7,1fr);gap:1px;background:#333;border-radius:8px;overflow:hidden}}
|
||||
.cal-header{{background:#222;padding:8px;text-align:center;font-size:11px;color:#A0A0A0;text-transform:uppercase}}
|
||||
.cal-day{{background:#1a1a1a;min-height:80px;padding:4px}}
|
||||
.cal-day.other-month{{opacity:.35}}
|
||||
.cal-day.today{{background:#1a2744}}
|
||||
.cal-num{{font-size:12px;color:#A0A0A0;display:block;margin-bottom:2px}}
|
||||
.cal-item{{font-size:11px;padding:2px 4px;margin:1px 0;background:#333;border-radius:3px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}}
|
||||
.cal-nav{{display:flex;gap:8px;align-items:center;margin-bottom:12px}}
|
||||
.cal-nav a{{color:#3366CC;text-decoration:none;font-size:14px}}
|
||||
.cal-nav span{{font-size:16px;font-weight:600}}
|
||||
</style>
|
||||
<div class="cal-nav">
|
||||
<a href="?view_type=calendar&year={prev.year}&month={prev.month}">← {prev.strftime('%B')}</a>
|
||||
<span>{first.strftime('%B %Y')}</span>
|
||||
<a href="?view_type=calendar&year={next_month.year}&month={next_month.month}">{next_month.strftime('%B')} →</a>
|
||||
</div>
|
||||
<div class="calendar">
|
||||
<div class="cal-header">Mon</div><div class="cal-header">Tue</div><div class="cal-header">Wed</div>
|
||||
<div class="cal-header">Thu</div><div class="cal-header">Fri</div><div class="cal-header">Sat</div><div class="cal-header">Sun</div>
|
||||
{cells}
|
||||
</div>
|
||||
<p class="desc">{len(pages)} pages in collection</p>
|
||||
""")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _render_gallery(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
||||
card_size = config.get("card_size", "medium")
|
||||
size_css = {"small": "160px", "medium": "220px", "large": "300px"}.get(card_size, "220px")
|
||||
|
||||
cards = ""
|
||||
for p in pages:
|
||||
props = json.loads(p.get("property_values_json", "{}"))
|
||||
cover_url = config.get("cover_property")
|
||||
cover_html = ""
|
||||
if cover_url:
|
||||
for _k, v in props.items():
|
||||
if isinstance(v, list) and len(v) > 0:
|
||||
url = v[0].get("url", "") if isinstance(v[0], dict) else str(v[0])
|
||||
if url.startswith("http"):
|
||||
cover_html = f"<div class='gal-cover' style='background-image:url({url})'></div>"
|
||||
break
|
||||
|
||||
prop_tags = "".join(
|
||||
f"<span class='gal-prop'>{str(v)[:30]}</span>"
|
||||
for v in list(props.values())[:3] if v
|
||||
)
|
||||
|
||||
cards += f"""<div class='gal-card'>
|
||||
{cover_html}
|
||||
<div class='gal-body'>
|
||||
<div class='gal-title'>{p.get('icon','📄')} {p['title']}</div>
|
||||
<div class='gal-props'>{prop_tags}</div>
|
||||
</div>
|
||||
</div>"""
|
||||
|
||||
return _base_html(collection["name"], collection.get("icon", "🖼️"), view_type, f"""
|
||||
<style>
|
||||
.gallery{{display:grid;grid-template-columns:repeat(auto-fill,minmax({size_css},1fr));gap:12px}}
|
||||
.gal-card{{background:#1a1a1a;border-radius:8px;overflow:hidden;border:1px solid #333}}
|
||||
.gal-card:hover{{border-color:#555}}
|
||||
.gal-cover{{height:120px;background:#222;background-size:cover;background-position:center}}
|
||||
.gal-body{{padding:12px}}
|
||||
.gal-title{{font-size:14px;font-weight:500;margin-bottom:6px}}
|
||||
.gal-props{{display:flex;flex-wrap:wrap;gap:4px}}
|
||||
.gal-prop{{font-size:11px;padding:2px 6px;background:#333;border-radius:4px;color:#A0A0A0}}
|
||||
</style>
|
||||
<div class="gallery">{cards}</div>
|
||||
<p class="desc">{len(pages)} cards</p>
|
||||
""")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _render_list(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
||||
items = ""
|
||||
for p in pages:
|
||||
props = json.loads(p.get("property_values_json", "{}"))
|
||||
preview = " · ".join(str(v)[:60] for v in list(props.values())[:3] if v)
|
||||
items += f"""<div class='list-item'>
|
||||
<span class='list-icon'>{p.get('icon','📄')}</span>
|
||||
<div class='list-content'>
|
||||
<div class='list-title'>{p['title']}</div>
|
||||
<div class='list-preview'>{preview}</div>
|
||||
</div>
|
||||
</div>"""
|
||||
|
||||
return _base_html(collection["name"], collection.get("icon", "📝"), view_type, f"""
|
||||
<style>
|
||||
.list-item{{display:flex;align-items:flex-start;gap:10px;padding:10px 12px;background:#1a1a1a;border-radius:6px;margin-bottom:4px;border:1px solid #222}}
|
||||
.list-item:hover{{border-color:#444}}
|
||||
.list-icon{{font-size:18px;margin-top:1px}}
|
||||
.list-content{{flex:1;min-width:0}}
|
||||
.list-title{{font-size:14px;font-weight:500}}
|
||||
.list-preview{{font-size:12px;color:#A0A0A0;margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}
|
||||
</style>
|
||||
{items}
|
||||
<p class="desc">{len(pages)} items</p>
|
||||
""")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _render_timeline(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
||||
# Find date range
|
||||
dates = []
|
||||
page_dates = []
|
||||
for p in pages:
|
||||
props = json.loads(p.get("property_values_json", "{}"))
|
||||
start_val = end_val = None
|
||||
for _k, v in props.items():
|
||||
if isinstance(v, str) and v.startswith("20"):
|
||||
if "..." in v:
|
||||
parts = v.split("...")
|
||||
start_val, end_val = parts[0][:10], parts[1][:10] if len(parts) > 1 else parts[0][:10]
|
||||
else:
|
||||
start_val = end_val = v[:10]
|
||||
break
|
||||
if start_val:
|
||||
dates.append(start_val)
|
||||
if end_val:
|
||||
dates.append(end_val)
|
||||
page_dates.append((p, start_val, end_val or start_val))
|
||||
|
||||
if not dates:
|
||||
return _base_html(collection["name"], collection.get("icon", "📈"), view_type,
|
||||
"<p class='desc'>No date data to display timeline.</p>")
|
||||
|
||||
from datetime import date as dt_date
|
||||
min_date = min(dt_date.fromisoformat(d) for d in dates)
|
||||
max_date = max(dt_date.fromisoformat(d) for d in dates)
|
||||
total = (max_date - min_date).days or 1
|
||||
|
||||
bars = ""
|
||||
for p, start, end in page_dates:
|
||||
sd = dt_date.fromisoformat(start)
|
||||
ed = dt_date.fromisoformat(end)
|
||||
left = (sd - min_date).days / total * 100
|
||||
width = max((ed - sd).days / total * 100, 1)
|
||||
bars += f"""<div class='tl-row'>
|
||||
<span class='tl-label'>{p.get('icon','📄')} {p['title']}</span>
|
||||
<div class='tl-track'>
|
||||
<div class='tl-bar' style='left:{left:.1f}%;width:{width:.1f}%' title='{start} → {end}'></div>
|
||||
</div>
|
||||
</div>"""
|
||||
|
||||
return _base_html(collection["name"], collection.get("icon", "📈"), view_type, f"""
|
||||
<style>
|
||||
.tl-row{{display:flex;align-items:center;margin-bottom:8px;gap:12px}}
|
||||
.tl-label{{width:160px;font-size:13px;text-align:right;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}
|
||||
.tl-track{{flex:1;height:28px;background:#222;border-radius:4px;position:relative}}
|
||||
.tl-bar{{position:absolute;top:4px;height:20px;background:#3366CC;border-radius:4px;min-width:4px}}
|
||||
</style>
|
||||
<div class="tl-header" style="display:flex;margin-bottom:16px;padding-left:172px">
|
||||
<span style="flex:1;font-size:11px;color:#A0A0A0">{min_date}</span>
|
||||
<span style="font-size:11px;color:#A0A0A0">{max_date}</span>
|
||||
</div>
|
||||
{bars}
|
||||
<p class="desc">{len(pages)} items · {min_date} → {max_date}</p>
|
||||
""")
|
||||
|
||||
|
||||
# ── v4.3.0: New view types ──
|
||||
|
||||
# Multi-collection dashboards and chart aggregations cap the number of
|
||||
# input rows/groups at 200 (keeps the rendered HTML and export reasonable).
|
||||
|
||||
|
||||
|
||||
|
||||
def _chart_values(pages: list[dict], chart_property: str) -> list[float]:
|
||||
"""Numeric values of ``chart_property`` across ``pages`` (cap 200)."""
|
||||
values: list[float] = []
|
||||
for p in pages[:CHART_MAX_GROUPS]:
|
||||
props = json.loads(p.get("property_values_json", "{}") or "{}")
|
||||
v = props.get(chart_property)
|
||||
if v is None or v == "":
|
||||
continue
|
||||
try:
|
||||
values.append(float(v))
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
return values
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _chart_aggregate(pages: list[dict], chart_property: str, aggregate: str) -> float:
|
||||
"""Compute count|sum|avg|min|max over a property (or row count)."""
|
||||
values = _chart_values(pages, chart_property)
|
||||
if aggregate == "count":
|
||||
return float(len(pages[:CHART_MAX_GROUPS]))
|
||||
if not values:
|
||||
return 0.0
|
||||
if aggregate == "sum":
|
||||
return float(sum(values))
|
||||
if aggregate == "avg":
|
||||
return float(sum(values) / len(values))
|
||||
if aggregate == "min":
|
||||
return float(min(values))
|
||||
if aggregate == "max":
|
||||
return float(max(values))
|
||||
return 0.0
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _fmt_number(value: float) -> str:
|
||||
if abs(value) >= 1e9:
|
||||
return f"{value / 1e9:.2f}B"
|
||||
if abs(value) >= 1e6:
|
||||
return f"{value / 1e6:.2f}M"
|
||||
if abs(value) >= 1e3:
|
||||
return f"{value / 1e3:.1f}K"
|
||||
if value == int(value):
|
||||
return str(int(value))
|
||||
return f"{value:.2f}"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _render_chart(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
||||
"""Chart view — bar, line, pie, doughnut, scatter via Chart.js CDN, plus
|
||||
the v7.3.0 ``number`` KPI widget (count|sum|avg|min|max aggregate)."""
|
||||
chart_type = config.get("chart_type", "bar")
|
||||
chart_property = config.get("chart_property", "")
|
||||
|
||||
if chart_type == "number":
|
||||
aggregate = config.get("aggregate", "sum" if chart_property else "count")
|
||||
if aggregate not in ("count", "sum", "avg", "min", "max"):
|
||||
aggregate = "sum" if chart_property else "count"
|
||||
num = _chart_aggregate(pages, chart_property, aggregate)
|
||||
label = config.get("title") or chart_property or collection["name"]
|
||||
return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""
|
||||
<style>
|
||||
.kpi{{max-width:420px;margin:60px auto;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.12);border-radius:14px;padding:36px;text-align:center}}
|
||||
.kpi-label{{font-size:13px;text-transform:uppercase;letter-spacing:1.2px;opacity:.6;margin-bottom:10px}}
|
||||
.kpi-value{{font-size:64px;font-weight:700;line-height:1;font-variant-numeric:tabular-nums}}
|
||||
.kpi-agg{{font-size:12px;color:var(--text-dim);margin-top:12px}}
|
||||
</style>
|
||||
<div class="kpi">
|
||||
<div class="kpi-label">{label}</div>
|
||||
<div class="kpi-value">{_fmt_number(num)}</div>
|
||||
<div class="kpi-agg">{aggregate} · {len(pages[:CHART_MAX_GROUPS])} row(s)
|
||||
{' of ' + chart_property if chart_property else ''}</div>
|
||||
</div>
|
||||
""")
|
||||
|
||||
labels = []
|
||||
values = []
|
||||
for p in pages[:CHART_MAX_GROUPS]:
|
||||
labels.append(str(p.get("title") or "")[:30])
|
||||
props = json.loads(p.get("property_values_json", "{}") or "{}")
|
||||
val = 0.0
|
||||
if chart_property:
|
||||
v_raw = props.get(chart_property, 0)
|
||||
try:
|
||||
val = float(v_raw) if v_raw else 0.0
|
||||
except (ValueError, TypeError):
|
||||
val = 0.0
|
||||
values.append(val)
|
||||
|
||||
labels_json = json.dumps(labels)
|
||||
values_json = json.dumps(values)
|
||||
subtitle = (f"{len(pages[:CHART_MAX_GROUPS])} entries"
|
||||
+ (f" (truncated at {CHART_MAX_GROUPS})" if len(pages) > CHART_MAX_GROUPS else ""))
|
||||
|
||||
return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""
|
||||
<style>
|
||||
.chart-container{{max-width:800px;margin:0 auto}}
|
||||
canvas{{max-height:400px}}
|
||||
</style>
|
||||
<div class="chart-container"><canvas id="chartCanvas"></canvas></div>
|
||||
<script src="/static/js/vendor/chart.umd.js"></script>
|
||||
<script nonce="{CSP_NONCE.get()}">
|
||||
new Chart(document.getElementById('chartCanvas'), {{
|
||||
type: '{chart_type}',
|
||||
data: {{
|
||||
labels: {labels_json},
|
||||
datasets: [{{
|
||||
label: '{config.get("title") or collection["name"]}',
|
||||
data: {values_json},
|
||||
backgroundColor: ['#3366CC','#DC3912','#FF9900','#109618','#990099','#0099C6','#DD4477','#66AA00'],
|
||||
}}]
|
||||
}},
|
||||
options: {{ responsive: true }}
|
||||
}});
|
||||
</script>
|
||||
<p class="desc">{subtitle}</p>
|
||||
""")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _render_form(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
||||
"""Form view — generates an HTML form that creates new pages in the collection."""
|
||||
properties = []
|
||||
with get_conn() as conn:
|
||||
props = conn.execute(
|
||||
"SELECT name, prop_type, options_json FROM collection_properties WHERE collection_id=? AND prop_type!='formula' ORDER BY position",
|
||||
(collection["id"],),
|
||||
).fetchall()
|
||||
for p in props:
|
||||
prop_dict = dict(p)
|
||||
prop_dict["options"] = json.loads(prop_dict.get("options_json", "[]"))
|
||||
properties.append(prop_dict)
|
||||
|
||||
fields = ""
|
||||
for prop in properties:
|
||||
name = prop["name"]
|
||||
ptype = prop["prop_type"]
|
||||
if ptype in ("text", "email", "url", "phone", "number"):
|
||||
fields += f"""<div class='form-field'><label>{name}</label><input type='{"number" if ptype=="number" else "text"}' name='prop_{name}' placeholder='{name}'></div>"""
|
||||
elif ptype in ("select", "status"):
|
||||
options = "".join(f"<option value='{o}'>{o}</option>" for o in prop.get("options", []))
|
||||
fields += f"""<div class='form-field'><label>{name}</label><select name='prop_{name}'>{options}</select></div>"""
|
||||
elif ptype == "checkbox":
|
||||
fields += f"""<div class='form-field'><label><input type='checkbox' name='prop_{name}' value='1'> {name}</label></div>"""
|
||||
elif ptype == "date":
|
||||
fields += f"""<div class='form-field'><label>{name}</label><input type='date' name='prop_{name}'></div>"""
|
||||
|
||||
return _base_html(collection["name"], collection.get("icon", "📝"), view_type, f"""
|
||||
<style>
|
||||
.form-field{{margin-bottom:12px}}
|
||||
.form-field label{{display:block;font-size:13px;color:#A0A0A0;margin-bottom:4px}}
|
||||
.form-field input,.form-field select{{width:100%;max-width:400px;padding:8px;background:#333;border:1px solid #555;border-radius:6px;color:#fff;font-size:14px}}
|
||||
.form-submit{{padding:8px 20px;background:#3366CC;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:14px;margin-top:8px}}
|
||||
.form-submit:hover{{background:#254E99}}
|
||||
</style>
|
||||
<div class="form-container">
|
||||
<h3>New entry in {collection['name']}</h3>
|
||||
<form id="collectionForm" onsubmit="submitForm(event)">
|
||||
{fields}
|
||||
<div class='form-field'><label>Title</label><input type='text' name='title' placeholder='Page title' required></div>
|
||||
<button type='submit' class='form-submit'>Submit</button>
|
||||
</form>
|
||||
<div id="formResult" style="margin-top:12px;color:#4CAF50;display:none">✓ Created successfully!</div>
|
||||
</div>
|
||||
<script nonce="{CSP_NONCE.get()}">
|
||||
async function submitForm(e) {{
|
||||
e.preventDefault();
|
||||
const form = document.getElementById('collectionForm');
|
||||
const fd = new FormData(form);
|
||||
const properties = {{}};
|
||||
const title = fd.get('title') || 'New entry';
|
||||
fd.forEach((v,k) => {{ if(k.startsWith('prop_')) properties[k.slice(5)] = v; }});
|
||||
const resp = await fetch('/db/{collection["id"]}/pages/api', {{
|
||||
method:'POST', headers:{{'Content-Type':'application/json'}},
|
||||
body: JSON.stringify({{title, properties}})
|
||||
}});
|
||||
if(resp.ok) {{
|
||||
document.getElementById('formResult').style.display='block';
|
||||
form.reset();
|
||||
}}
|
||||
}}
|
||||
</script>
|
||||
""")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _render_map(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
||||
"""Map view — displays pages with location data on Leaflet map."""
|
||||
markers = []
|
||||
for p in pages:
|
||||
props = json.loads(p.get("property_values_json", "{}"))
|
||||
lat, lng = None, None
|
||||
for _k, v in props.items():
|
||||
if isinstance(v, str) and "," in v:
|
||||
parts = v.split(",")
|
||||
try:
|
||||
lat, lng = float(parts[0].strip()), float(parts[1].strip())
|
||||
except ValueError:
|
||||
continue
|
||||
elif isinstance(v, dict):
|
||||
lat = v.get("lat")
|
||||
lng = v.get("lng")
|
||||
if lat and lng:
|
||||
markers.append({"title": p["title"], "lat": lat, "lng": lng})
|
||||
|
||||
markers_json = json.dumps(markers)
|
||||
center_lat = markers[0]["lat"] if markers else 45.5
|
||||
center_lng = markers[0]["lng"] if markers else -73.5
|
||||
|
||||
return _base_html(collection["name"], collection.get("icon", "🗺️"), view_type, f"""
|
||||
<style>
|
||||
#map{{height:400px;border-radius:8px}}
|
||||
</style>
|
||||
<link rel="stylesheet" href="/static/js/vendor/leaflet.css" />
|
||||
<div id="map"></div>
|
||||
<script src="/static/js/vendor/leaflet.js"></script>
|
||||
<script nonce="{CSP_NONCE.get()}">
|
||||
const map = L.map('map').setView([{center_lat}, {center_lng}], 6);
|
||||
L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png', {{attribution:'© OSM'}}).addTo(map);
|
||||
const markers = {markers_json};
|
||||
markers.forEach(m => L.marker([m.lat, m.lng]).addTo(map).bindPopup(m.title));
|
||||
if(markers.length===0) L.marker([{center_lat},{center_lng}]).addTo(map).bindPopup('Default');
|
||||
</script>
|
||||
<p class="desc">{len(markers)} location(s) mapped</p>
|
||||
""")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _render_feed(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
||||
"""Feed view — chronological feed of pages, newest first."""
|
||||
sorted_pages = sorted(pages, key=lambda p: p.get("created_at", ""), reverse=True)
|
||||
items = ""
|
||||
for p in sorted_pages:
|
||||
created = p.get("created_at", "")[:10] if p.get("created_at") else ""
|
||||
items += f"""<div class='feed-item'>
|
||||
<div class='feed-meta'>{created}</div>
|
||||
<div class='feed-title'>{p.get('icon','📄')} {p['title']}</div>
|
||||
</div>"""
|
||||
|
||||
return _base_html(collection["name"], collection.get("icon", "📰"), view_type, f"""
|
||||
<style>
|
||||
.feed-item{{padding:12px 16px;border-left:2px solid #333;margin-bottom:8px;background:#1a1a1a;border-radius:0 8px 8px 0}}
|
||||
.feed-item:hover{{border-left-color:#3366CC}}
|
||||
.feed-meta{{font-size:11px;color:#A0A0A0;margin-bottom:4px}}
|
||||
.feed-title{{font-size:14px;font-weight:500}}
|
||||
</style>
|
||||
{items}
|
||||
<p class="desc">{len(sorted_pages)} entries</p>
|
||||
""")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _render_gantt(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
||||
"""Gantt view — timeline with dependencies and group_by support."""
|
||||
group_by = config.get("group_by", "")
|
||||
|
||||
gantt_data = []
|
||||
for p in pages:
|
||||
props = json.loads(p.get("property_values_json", "{}"))
|
||||
group = None
|
||||
start_val = end_val = None
|
||||
for _k, v in props.items():
|
||||
if isinstance(v, str) and v.startswith("20"):
|
||||
if "→" in v:
|
||||
parts = v.split("→")
|
||||
start_val, end_val = parts[0].strip()[:10], parts[1].strip()[:10] if len(parts) > 1 else parts[0].strip()[:10]
|
||||
elif "..." in v:
|
||||
parts = v.split("...")
|
||||
start_val, end_val = parts[0][:10], parts[1][:10] if len(parts) > 1 else parts[0][:10]
|
||||
else:
|
||||
start_val = end_val = v[:10]
|
||||
break
|
||||
if group_by:
|
||||
group = str(props.get(group_by, props.get("Status", "")))[:20]
|
||||
if start_val:
|
||||
gantt_data.append({"title": p["title"], "start": start_val, "end": end_val or start_val, "group": group or ""})
|
||||
|
||||
if not gantt_data:
|
||||
return _base_html(collection["name"], collection.get("icon", "📊"), view_type, "<p class='desc'>No date data for Gantt chart.</p>")
|
||||
|
||||
from datetime import date as dt_date_2
|
||||
all_dates = [d["start"] for d in gantt_data] + [d["end"] for d in gantt_data]
|
||||
min_date = min(dt_date_2.fromisoformat(d) for d in all_dates)
|
||||
max_date = max(dt_date_2.fromisoformat(d) for d in all_dates)
|
||||
total_days = max((max_date - min_date).days, 1)
|
||||
|
||||
groups = {}
|
||||
for d in gantt_data:
|
||||
groups.setdefault(d["group"], []).append(d)
|
||||
if not groups or all(k == "" for k in groups):
|
||||
groups = {"": gantt_data}
|
||||
|
||||
rows = ""
|
||||
for group_name, items in sorted(groups.items()):
|
||||
if group_name:
|
||||
rows += f"<div class='gantt-group'>{group_name} ({len(items)})</div>"
|
||||
for item in items:
|
||||
sd = dt_date_2.fromisoformat(item["start"])
|
||||
ed = dt_date_2.fromisoformat(item["end"])
|
||||
left = max((sd - min_date).days / total_days * 100, 0)
|
||||
width = max((ed - sd).days / total_days * 100, 1)
|
||||
rows += f"""<div class='gantt-row'>
|
||||
<span class='gantt-label'>{item['title'][:30]}</span>
|
||||
<div class='gantt-track'><div class='gantt-bar' style='left:{left:.1f}%;width:{width:.1f}%' title='{item["start"]} → {item["end"]}'></div></div>
|
||||
<span class='gantt-dates'>{item['start']} → {item['end']}</span>
|
||||
</div>"""
|
||||
|
||||
return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""
|
||||
<style>
|
||||
.gantt-group{{padding:8px 12px;background:#222;font-size:13px;font-weight:600;margin:8px 0 4px;border-radius:4px}}
|
||||
.gantt-row{{display:flex;align-items:center;margin-bottom:6px;gap:8px}}
|
||||
.gantt-label{{width:180px;font-size:12px;text-align:right;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}
|
||||
.gantt-track{{flex:1;height:24px;background:#222;border-radius:4px;position:relative}}
|
||||
.gantt-bar{{position:absolute;top:3px;height:18px;background:linear-gradient(90deg,#3366CC,#5599EE);border-radius:4px;min-width:4px}}
|
||||
.gantt-dates{{font-size:10px;color:#A0A0A0;flex-shrink:0;min-width:140px}}
|
||||
</style>
|
||||
<div class="gantt-header" style="display:flex;margin-bottom:8px;padding-left:188px">
|
||||
<span style="flex:1;font-size:10px;color:#A0A0A0">{min_date}</span>
|
||||
<span style="font-size:10px;color:#A0A0A0">{max_date}</span>
|
||||
</div>
|
||||
{rows}
|
||||
<p class="desc">{len(gantt_data)} items · {min_date} → {max_date}</p>
|
||||
""")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _render_table(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
||||
rows = ""
|
||||
for p in pages:
|
||||
props = json.loads(p.get("property_values_json", "{}"))
|
||||
prop_cells = "".join(f"<td>{str(v)[:80]}</td>" for v in list(props.values())[:4])
|
||||
rows += f"<tr><td>{p.get('icon','📄')}</td><td>{p['title']}</td>{prop_cells}</tr>"
|
||||
|
||||
return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""
|
||||
<style>
|
||||
table{{width:100%;border-collapse:collapse}}
|
||||
th,td{{padding:8px 12px;text-align:left;font-size:13px;border-bottom:1px solid #333}}
|
||||
th{{color:#A0A0A0;font-weight:500;background:#1a1a1a;position:sticky;top:0}}
|
||||
tr:hover td{{background:#222}}
|
||||
</style>
|
||||
<table><thead><tr><th></th><th>Title</th><th>Properties</th></tr></thead><tbody>{rows}</tbody></table>
|
||||
<p class="desc">{len(pages)} rows</p>
|
||||
""")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""FlowDeck — Collections : boards.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/collections.py (2 622 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collections"], prefix="/db")
|
||||
|
||||
|
||||
|
||||
|
||||
# ── Gitea Board Compatibility (BEFORE {collection_id} wildcards) ──
|
||||
|
||||
|
||||
@router.get("/boards/api")
|
||||
def list_boards_as_collections(request: Request):
|
||||
"""API: list all Gitea boards as pseudo-collections."""
|
||||
from app.services.collection_adapter import GiteaBoardCompat
|
||||
boards = GiteaBoardCompat.list_boards_as_collections()
|
||||
return {"boards": boards}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/board/{owner}/{repo}/api")
|
||||
async def get_board_as_collection(request: Request, owner: str, repo: str):
|
||||
"""API: get a specific Gitea board as a pseudo-collection."""
|
||||
from app.services.collection_adapter import GiteaBoardCompat
|
||||
coll = GiteaBoardCompat.get_board_as_collection(owner, repo)
|
||||
if not coll:
|
||||
raise HTTPException(status_code=404, detail="Board not found")
|
||||
|
||||
from app.services.gitea_client import gitea
|
||||
issues = await gitea.get_issues(owner, repo, state="all")
|
||||
cards = GiteaBoardCompat.get_board_cards(owner, repo, issues)
|
||||
|
||||
return {"collection": coll, "pages": cards}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/board/{owner}/{repo}/sync")
|
||||
async def sync_board_to_collection(request: Request, owner: str, repo: str):
|
||||
"""Sync a Gitea board to a real collection."""
|
||||
from app.services.collection_adapter import GiteaBoardCompat
|
||||
from app.services.gitea_client import gitea
|
||||
|
||||
issues = await gitea.get_issues(owner, repo, state="all")
|
||||
coll_id = GiteaBoardCompat.sync_to_collection(owner, repo, issues)
|
||||
if coll_id is None:
|
||||
raise HTTPException(status_code=404, detail="Board not found")
|
||||
|
||||
return {"collection_id": coll_id, "status": "synced"}
|
||||
|
||||
|
||||
# ── Collection Properties (v1.4.0) ──
|
||||
@@ -0,0 +1,350 @@
|
||||
"""FlowDeck — Collections : crud.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/collections.py (2 622 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
from app.routers.dashboard._common import _get_active_workspace
|
||||
from app.services.automations import fire_event, run_event_sync
|
||||
from app.services.collection_lifecycle import delete_collections
|
||||
from app.services.db_templates import materialize_properties
|
||||
from app.services.permission_manager import PermissionManager
|
||||
|
||||
from ._common import _apply_template, _require_view, _session_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collections"], prefix="/db")
|
||||
|
||||
|
||||
|
||||
|
||||
# ── API: List & Create (no path params) ──
|
||||
#
|
||||
# `GET /db` (la page HTML) vit dans `index_page.py` : elle était auparavant
|
||||
# un dump JSON de debug, sans layout — alors qu'elle sert de destination au
|
||||
# bouton « Ouvrir mes bases » de My Tasks.
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api")
|
||||
def list_collections_api(request: Request):
|
||||
"""API: list all collections."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM collections ORDER BY name"
|
||||
).fetchall()
|
||||
return {"collections": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api")
|
||||
def create_collection_api(request: Request, body: dict = Body(default={})):
|
||||
"""API: create a new collection, optionally from a database template."""
|
||||
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name is required")
|
||||
|
||||
description = body.get("description", "")
|
||||
icon = body.get("icon", "📋")
|
||||
gitea_owner = body.get("gitea_owner")
|
||||
gitea_repo = body.get("gitea_repo")
|
||||
schema = body.get("schema", [])
|
||||
is_locked = body.get("is_locked", False)
|
||||
|
||||
# Workspace d'appartenance : celui du body si fourni et autorisé, sinon le
|
||||
# workspace actif de l'utilisateur. Sans cela la collection reste orpheline
|
||||
# et n'apparaît dans aucune vue scopée (ex. /my-tasks).
|
||||
user = _session_user(request)
|
||||
workspace_id = body.get("workspace_id")
|
||||
if workspace_id is not None:
|
||||
try:
|
||||
workspace_id = int(workspace_id)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status_code=400, detail="workspace_id must be an integer") from None
|
||||
with get_conn() as conn:
|
||||
exists = conn.execute("SELECT 1 FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
|
||||
if not exists:
|
||||
raise HTTPException(status_code=400, detail="workspace_id does not exist")
|
||||
else:
|
||||
active = _get_active_workspace(request, (user or {}).get("id"))
|
||||
workspace_id = active["id"] if active else None
|
||||
|
||||
with get_conn() as conn:
|
||||
# Apply a template if requested (provides schema + icon).
|
||||
tpl = _apply_template(conn, body.get("template"))
|
||||
if tpl:
|
||||
if body.get("name"):
|
||||
name = body["name"].strip()
|
||||
description = tpl["description"]
|
||||
icon = tpl.get("icon") or icon
|
||||
try:
|
||||
schema = json.loads(tpl["schema_json"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
schema = []
|
||||
|
||||
schema_json = json.dumps(schema)
|
||||
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collections
|
||||
(name, description, icon, schema_json, gitea_owner, gitea_repo, is_locked,
|
||||
workspace_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(name, description, icon, schema_json, gitea_owner, gitea_repo, int(is_locked),
|
||||
workspace_id),
|
||||
)
|
||||
collection_id = cur.lastrowid
|
||||
|
||||
materialize_properties(conn, collection_id, schema)
|
||||
|
||||
conn.execute(
|
||||
"""INSERT INTO collection_views
|
||||
(collection_id, name, view_type, config_json)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
(collection_id, "Default View", "table", json.dumps({
|
||||
"visible_properties": ["Title"],
|
||||
"sorts": [],
|
||||
"filters": [],
|
||||
})),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
run_event_sync(fire_event("collection.created", {"collection_id": collection_id, "name": name, "icon": icon}))
|
||||
return {"id": collection_id, "name": name, "status": "created"}
|
||||
|
||||
|
||||
# ── API: Update & Delete (no path-param conflicts) ──
|
||||
|
||||
|
||||
|
||||
|
||||
# ── API: Update & Delete (no path-param conflicts) ──
|
||||
|
||||
|
||||
@router.put("/api/{collection_id}")
|
||||
def update_collection_api(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""API: update a collection."""
|
||||
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT * FROM collections WHERE id=?", (collection_id,)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
name = body.get("name", existing["name"])
|
||||
description = body.get("description", existing["description"])
|
||||
icon = body.get("icon", existing["icon"])
|
||||
is_locked = body.get("is_locked", existing["is_locked"])
|
||||
schema_json = json.dumps(body.get("schema", json.loads(existing["schema_json"])))
|
||||
gitea_owner = body.get("gitea_owner", existing["gitea_owner"])
|
||||
gitea_repo = body.get("gitea_repo", existing["gitea_repo"])
|
||||
|
||||
conn.execute(
|
||||
"""UPDATE collections SET name=?, description=?, icon=?, schema_json=?,
|
||||
is_locked=?, gitea_owner=?, gitea_repo=?, updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=?""",
|
||||
(name, description, icon, schema_json, int(is_locked),
|
||||
gitea_owner, gitea_repo, collection_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
run_event_sync(fire_event("collection.updated", {"collection_id": collection_id, "name": name}))
|
||||
return {"id": collection_id, "status": "updated"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/api/{collection_id}")
|
||||
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)
|
||||
_require_view(collection_id, user)
|
||||
if user:
|
||||
pm = PermissionManager(user["id"])
|
||||
if not pm.can_manage_collection_permissions(collection_id):
|
||||
raise HTTPException(status_code=403, detail="Only a collection owner can delete it")
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT * FROM collections WHERE id=?", (collection_id,)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
# `delete_collections` détache la page hôte et les vues liées avant la
|
||||
# suppression : `pages.collection_id` et
|
||||
# `collection_data_sources.source_collection_id` sont en NO ACTION, la
|
||||
# suppression brute levait un IntegrityError.
|
||||
delete_collections(conn, [collection_id])
|
||||
conn.commit()
|
||||
|
||||
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")
|
||||
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:
|
||||
src = conn.execute(
|
||||
"SELECT * FROM collections WHERE id=?", (collection_id,)
|
||||
).fetchone()
|
||||
if not src:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
new_name = (src["name"] or "Database") + " copy"
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collections
|
||||
(name, description, icon, schema_json, gitea_owner, gitea_repo, is_locked,
|
||||
is_inline, parent_page_id, workspace_id, created_by, is_task, updated_at)
|
||||
SELECT ?, description, icon, schema_json, gitea_owner, gitea_repo, is_locked,
|
||||
is_inline, parent_page_id, workspace_id, created_by, is_task, updated_at
|
||||
FROM collections WHERE id=?""",
|
||||
(new_name, collection_id),
|
||||
)
|
||||
new_id = cur.lastrowid
|
||||
|
||||
# ── Properties (remap ids so relation/rollup refs stay valid) ──
|
||||
prop_map: dict[int, int] = {}
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
# A23 : un seul executemany ; les rowid sont contigus (même transaction,
|
||||
# insertion dans l'ordre de `rows`), donc le mappeur se fait par index.
|
||||
tuples = [
|
||||
(new_id, p["name"], p["prop_type"], p["options_json"], p["number_format"],
|
||||
None, p["reverse_name"], None, None, p["rollup_function"],
|
||||
p["formula_expression"], p["position"], p["required"],
|
||||
p["visible_in_views"])
|
||||
for p in rows
|
||||
]
|
||||
if tuples:
|
||||
ncur = conn.executemany(
|
||||
"""INSERT INTO collection_properties
|
||||
(collection_id, name, prop_type, options_json, number_format,
|
||||
related_collection_id, reverse_name, relation_property_id,
|
||||
target_property_id, rollup_function, formula_expression,
|
||||
position, required, visible_in_views)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
tuples,
|
||||
)
|
||||
new_ids = [
|
||||
r["id"]
|
||||
for r in conn.execute(
|
||||
"SELECT id FROM collection_properties WHERE collection_id=? ORDER BY id",
|
||||
(new_id,),
|
||||
).fetchall()
|
||||
]
|
||||
assert len(new_ids) == len(tuples), "remap des propriétés : effectif inattendu"
|
||||
for p, new_pid in zip(rows, new_ids, strict=True):
|
||||
prop_map[p["id"]] = new_pid
|
||||
|
||||
# Fix cross-property references after all rows exist (creates may target
|
||||
# columns not inserted yet). Related collection remapped to the copy.
|
||||
for p in rows:
|
||||
p = dict(p) # convert sqlite3.Row to dict
|
||||
new_pid = prop_map[p["id"]]
|
||||
related = p["related_collection_id"]
|
||||
related_new = new_id if related == collection_id else related
|
||||
if p["prop_type"] == "relation":
|
||||
conn.execute(
|
||||
"UPDATE collection_properties SET related_collection_id=? WHERE id=?",
|
||||
(related_new, new_pid),
|
||||
)
|
||||
if p.get("relation_property_id") and p["relation_property_id"] in prop_map:
|
||||
conn.execute(
|
||||
"UPDATE collection_properties SET relation_property_id=? WHERE id=?",
|
||||
(prop_map[p["relation_property_id"]], new_pid),
|
||||
)
|
||||
if p.get("target_property_id") and p["target_property_id"] in prop_map:
|
||||
conn.execute(
|
||||
"UPDATE collection_properties SET target_property_id=? WHERE id=?",
|
||||
(prop_map[p["target_property_id"]], new_pid),
|
||||
)
|
||||
|
||||
# ── Views ──
|
||||
vrows = conn.execute(
|
||||
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
for v in vrows:
|
||||
conn.execute(
|
||||
"""INSERT INTO collection_views
|
||||
(collection_id, name, view_type, config_json, position)
|
||||
VALUES (?,?,?,?,?)""",
|
||||
(new_id, v["name"], v["view_type"], v["config_json"], v["position"]),
|
||||
)
|
||||
|
||||
# ── Pages (rows) with property ids remapped to the copy's properties ──
|
||||
prows = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
page_map: dict[int, int] = {}
|
||||
for p in prows:
|
||||
try:
|
||||
pv = json.loads(p["property_values_json"]) if p["property_values_json"] else {}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pv = {}
|
||||
pv_new = {}
|
||||
for k, val in pv.items():
|
||||
try:
|
||||
prop_id = int(k)
|
||||
except (ValueError, TypeError):
|
||||
prop_id = None
|
||||
new_key = str(prop_map.get(prop_id, prop_id)) if prop_id is not None else k
|
||||
pv_new[new_key] = val
|
||||
ncur = conn.execute(
|
||||
"""INSERT INTO collection_pages
|
||||
(collection_id, title, icon, position, parent_id, gitea_issue_id,
|
||||
gitea_issue_number, property_values_json, created_at, updated_at)
|
||||
SELECT ?, title, icon, position, NULL, NULL, NULL, ?, created_at, updated_at
|
||||
FROM collection_pages WHERE id=?""",
|
||||
(new_id, json.dumps(pv_new), p["id"]),
|
||||
)
|
||||
page_map[p["id"]] = ncur.lastrowid
|
||||
|
||||
# Re-parent sub-items to the copied rows.
|
||||
for p in prows:
|
||||
if p["parent_id"] and p["parent_id"] in page_map:
|
||||
conn.execute(
|
||||
"UPDATE collection_pages SET parent_id=? WHERE id=?",
|
||||
(page_map[p["parent_id"]], page_map[p["id"]]),
|
||||
)
|
||||
|
||||
# ── Data sources (linked DBs) ──
|
||||
drows = conn.execute(
|
||||
"SELECT * FROM collection_data_sources WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
for d in drows:
|
||||
src_coll = d["source_collection_id"]
|
||||
src_now = new_id if src_coll == collection_id else src_coll
|
||||
conn.execute(
|
||||
"""INSERT INTO collection_data_sources
|
||||
(collection_id, source_collection_id, source_name, is_linked, position)
|
||||
VALUES (?,?,?,?,?)""",
|
||||
(new_id, src_now, d["source_name"], d["is_linked"], d["position"]),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
run_event_sync(fire_event("collection.created", {"collection_id": new_id, "name": new_name}))
|
||||
return {"id": new_id, "name": new_name, "status": "duplicated"}
|
||||
|
||||
|
||||
# ── Page CRUD (standalone, BEFORE collection wildcards) ──
|
||||
@@ -0,0 +1,214 @@
|
||||
"""FlowDeck — Collections : dashboard_views.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/collections.py (2 622 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import html as _htmlmod
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
from ._common import _require_view, _session_user
|
||||
from ._renderers import CHART_MAX_GROUPS, _base_html, _render_chart, _render_view
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collections"], prefix="/db")
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/{collection_id}/pages/{page_id}/auto-shift/api")
|
||||
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
|
||||
|
||||
skip_weekends = body.get("skip_weekends", False)
|
||||
|
||||
with get_conn() as conn:
|
||||
page = conn.execute("SELECT property_values_json FROM collection_pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not page:
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
|
||||
# Get all blocking dependencies
|
||||
deps = conn.execute(
|
||||
"SELECT * FROM page_dependencies WHERE page_id=? AND dependency_type='blocks'",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
|
||||
shifted = False
|
||||
new_start = None
|
||||
for dep in deps:
|
||||
dep_page = conn.execute(
|
||||
"SELECT title, property_values_json FROM collection_pages WHERE id=?",
|
||||
(dep["dependency_id"],),
|
||||
).fetchone()
|
||||
if not dep_page:
|
||||
continue
|
||||
dep_props = json.loads(dep_page["property_values_json"])
|
||||
# Find the latest end date among blockers
|
||||
for v in dep_props.values():
|
||||
if isinstance(v, str) and v.startswith("20"):
|
||||
end_date = v.split("...")[-1].split("→")[-1].strip()[:10]
|
||||
try:
|
||||
ed = dt_date.fromisoformat(end_date)
|
||||
if new_start is None or ed >= new_start:
|
||||
new_start = ed + timedelta(days=1)
|
||||
shifted = True
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if not shifted:
|
||||
return {"page_id": page_id, "shifted": False, "message": "No blocking dependencies with dates found"}
|
||||
|
||||
# Skip weekends if requested
|
||||
if skip_weekends and new_start:
|
||||
while new_start.weekday() >= 5: # 5=Sat, 6=Sun
|
||||
new_start = new_start + timedelta(days=1)
|
||||
|
||||
# Update the page's date properties
|
||||
props = json.loads(page["property_values_json"])
|
||||
for k, v in list(props.items()):
|
||||
if isinstance(v, str) and v.startswith("20"):
|
||||
old_parts = v.split("...")
|
||||
old_end = old_parts[-1] if len(old_parts) > 1 else old_parts[0]
|
||||
try:
|
||||
old_start_d = dt_date.fromisoformat(old_parts[0][:10])
|
||||
old_end_d = dt_date.fromisoformat(old_end[:10])
|
||||
duration = (old_end_d - old_start_d).days
|
||||
new_end = new_start + timedelta(days=max(duration, 0))
|
||||
props[k] = f"{new_start.isoformat()}...{new_end.isoformat()}"
|
||||
except ValueError:
|
||||
props[k] = new_start.isoformat()
|
||||
break
|
||||
|
||||
conn.execute(
|
||||
"UPDATE collection_pages SET property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(json.dumps(props), page_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {"page_id": page_id, "shifted": True, "new_start": new_start.isoformat(), "skip_weekends": skip_weekends}
|
||||
|
||||
|
||||
# ── {collection_id} wildcards (LAST — catches everything else) ──
|
||||
|
||||
|
||||
|
||||
|
||||
# ── {collection_id} wildcards (LAST — catches everything else) ──
|
||||
|
||||
|
||||
@router.get("/{collection_id}/dashboards/{dashboard_id}", response_class=HTMLResponse)
|
||||
def view_dashboard(request: Request, collection_id: int, dashboard_id: int):
|
||||
"""v7.3.0: render a collection dashboard grid (multi-DB widgets).
|
||||
|
||||
Widgets live in ``collection_dashboards.layout_json`` as
|
||||
``{"columns": N, "widgets": [{collection_id?, view_type?, chart_type?,
|
||||
chart_property?, aggregate?, title?, width?, height?}]}``. Each widget may
|
||||
point at *any* database (the dashboard's own collection is the default),
|
||||
which is what "dashboards multi-DB" means.
|
||||
"""
|
||||
uid = _session_user(request)
|
||||
_require_view(collection_id, uid)
|
||||
with get_conn() as conn:
|
||||
dash = conn.execute(
|
||||
"SELECT * FROM collection_dashboards WHERE id=? AND collection_id=?",
|
||||
(dashboard_id, collection_id)).fetchone()
|
||||
if not dash:
|
||||
raise HTTPException(404, "Dashboard not found")
|
||||
layout = json.loads(dash["layout_json"] or "{}")
|
||||
columns = max(1, int(layout.get("columns", 1) or 1))
|
||||
widgets = layout.get("widgets", []) or []
|
||||
if not isinstance(widgets, list):
|
||||
widgets = []
|
||||
|
||||
rendered = []
|
||||
for w in widgets[:40]:
|
||||
if not isinstance(w, dict):
|
||||
continue
|
||||
wc = int(w.get("collection_id") or 0) or collection_id
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT * FROM collections WHERE id=?", (wc,)).fetchone()
|
||||
if not coll:
|
||||
continue
|
||||
try:
|
||||
_require_view(wc, uid)
|
||||
except HTTPException:
|
||||
continue # restricted database → widget skipped, not rendered
|
||||
with get_conn() as conn:
|
||||
wpages = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position LIMIT ?",
|
||||
(wc, CHART_MAX_GROUPS)).fetchall()
|
||||
wconfig = {k: v for k, v in w.items()
|
||||
if k in ("chart_type", "chart_property", "aggregate", "title")}
|
||||
view_type = w.get("view_type") or "chart"
|
||||
if view_type == "chart":
|
||||
body = _render_chart(view_type, dict(coll), [dict(p) for p in wpages], wconfig)
|
||||
else:
|
||||
body = _render_view(view_type, dict(coll), [dict(p) for p in wpages], wconfig)
|
||||
width = int(w.get("width") or 0)
|
||||
span = f"grid-column: span {width};" if width and width > 0 else ""
|
||||
rendered.append(f'<div class="dash-widget" style="{span}">{body}</div>')
|
||||
|
||||
grid_css = f"grid-template-columns: repeat({columns}, minmax(0, 1fr));"
|
||||
body = f"""
|
||||
<style>
|
||||
.dash-grid{{display:grid;{grid_css} gap:16px;max-width:1200px;margin:0 auto;padding:24px}}
|
||||
.dash-widget{{background:rgba(255,255,255,.02);border:1px solid rgba(255,255,255,.08);border-radius:12px;overflow:hidden}}
|
||||
.dash-widget .desc{{color:var(--text-dim);font-size:12px;padding:8px 16px 16px}}
|
||||
</style>
|
||||
<h1 style="max-width:1200px;margin:24px auto 0;padding:0 24px;font-size:22px;">{_htmlmod.escape(dash['name'])}</h1>
|
||||
<div class="dash-grid">{''.join(rendered) if rendered else '<p style="color:var(--text-dim);padding:20px;">Empty dashboard — add widgets to <code>layout_json</code>.</p>'}</div>
|
||||
"""
|
||||
return HTMLResponse(_base_html(dash["name"], "📊", "dashboard", body))
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/{collection_id}", response_class=HTMLResponse)
|
||||
@router.get("/{collection_id}/view/{view_type}", response_class=HTMLResponse)
|
||||
def view_collection(request: Request, collection_id: int, view_type: str = "table"):
|
||||
"""Main view — renders collection in the requested view type."""
|
||||
# v6.0.0: granular collection permissions — hide restricted collections.
|
||||
_require_view(collection_id, _session_user(request))
|
||||
with get_conn() as conn:
|
||||
collection = conn.execute(
|
||||
"SELECT * FROM collections WHERE id=?", (collection_id,)
|
||||
).fetchone()
|
||||
if not collection:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
view = conn.execute(
|
||||
"SELECT * FROM collection_views WHERE collection_id=? AND view_type=? ORDER BY position LIMIT 1",
|
||||
(collection_id, view_type),
|
||||
).fetchone()
|
||||
if not view:
|
||||
view = conn.execute(
|
||||
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position LIMIT 1",
|
||||
(collection_id,),
|
||||
).fetchone()
|
||||
|
||||
pages = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
|
||||
collection_dict = dict(collection)
|
||||
pages_list = [dict(p) for p in pages]
|
||||
config = json.loads(view["config_json"]) if view else {}
|
||||
|
||||
if "year" in request.query_params:
|
||||
config["year"] = int(request.query_params["year"])
|
||||
if "month" in request.query_params:
|
||||
config["month"] = int(request.query_params["month"])
|
||||
|
||||
return HTMLResponse(_render_view(view_type, collection_dict, pages_list, config))
|
||||
|
||||
|
||||
# ── View renderers (v1.6.0) ──
|
||||
@@ -0,0 +1,122 @@
|
||||
"""FlowDeck — Collections : data_api.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/collections.py (2 622 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event, run_event_sync
|
||||
from app.services.property_types import (
|
||||
apply_auto_properties,
|
||||
)
|
||||
|
||||
from ._common import (
|
||||
_collection_properties,
|
||||
_current_user,
|
||||
_require_edit,
|
||||
_require_view,
|
||||
_session_user,
|
||||
_validate_meta_keys,
|
||||
_validate_page_properties,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collections"], prefix="/db")
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/{collection_id}/api")
|
||||
def get_collection_api(request: Request, collection_id: int):
|
||||
"""API: get a single collection with its pages."""
|
||||
# v6.0.0: granular collection permissions — hide restricted collections.
|
||||
_require_view(collection_id, _session_user(request))
|
||||
with get_conn() as conn:
|
||||
collection = conn.execute(
|
||||
"SELECT * FROM collections WHERE id=?", (collection_id,)
|
||||
).fetchone()
|
||||
if not collection:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
pages = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
views = conn.execute(
|
||||
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
|
||||
return {
|
||||
"collection": dict(collection),
|
||||
"pages": [dict(p) for p in pages],
|
||||
"views": [dict(v) for v in views],
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/{collection_id}/pages/api")
|
||||
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))
|
||||
|
||||
title = body.get("title", "").strip()
|
||||
if not title:
|
||||
raise HTTPException(status_code=400, detail="title is required")
|
||||
|
||||
icon = body.get("icon", "📄")
|
||||
property_values = body.get("properties", {})
|
||||
cover_url = body.get("cover_url", "")
|
||||
gitea_issue_id = body.get("gitea_issue_id")
|
||||
gitea_issue_number = body.get("gitea_issue_number")
|
||||
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not coll:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
_validate_page_properties(conn, collection_id, property_values)
|
||||
_validate_meta_keys(conn, collection_id, property_values)
|
||||
apply_auto_properties(
|
||||
_collection_properties(conn, collection_id),
|
||||
property_values,
|
||||
_current_user(request),
|
||||
is_create=True,
|
||||
)
|
||||
|
||||
max_pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchone()[0]
|
||||
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collection_pages
|
||||
(collection_id, title, icon, cover_url, position, gitea_issue_id, gitea_issue_number, property_values_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(collection_id, title, icon, cover_url, max_pos, gitea_issue_id, gitea_issue_number,
|
||||
json.dumps(property_values)),
|
||||
)
|
||||
conn.commit()
|
||||
page_id = cur.lastrowid
|
||||
|
||||
run_event_sync(fire_event("page.created", {
|
||||
"page_id": page_id,
|
||||
"collection_id": collection_id,
|
||||
"title": title,
|
||||
"icon": icon,
|
||||
"properties": property_values,
|
||||
}))
|
||||
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"}
|
||||
@@ -0,0 +1,259 @@
|
||||
"""FlowDeck — Collections : la page « /db » (liste des bases).
|
||||
|
||||
`GET /db` était un **dump JSON de debug** (le HTML renvoyé était un
|
||||
`<pre>` dans le vide, sans layout) : c'est pourtant la destination du bouton
|
||||
« Ouvrir mes bases » de My Tasks, et le point d'entrée pour convertir une base
|
||||
en base de tâches. On le remplace par une vraie page :
|
||||
|
||||
* bases **du workspace courant** d'abord (ce que voit l'utilisateur dans la
|
||||
sidebar), puis celles de ses **autres workspaces** dans une section séparée :
|
||||
sans cela, un utilisateur dont le workspace actif ne contient aucune base
|
||||
tombe sur une page vide alors qu'il en possède ailleurs ;
|
||||
* indicateur « Base de tâches » / « À configurer » par base ;
|
||||
* accès direct à la page de la base, où se trouve le bouton de conversion.
|
||||
|
||||
**Rattachement d'une base à un workspace.** `collections.workspace_id` n'est
|
||||
renseigné que pour les bases créées via `/db/api`. Une base née d'un document
|
||||
(page convertie en base, base inline) a `workspace_id` **NULL** et n'appartient
|
||||
qu'à travers sa page hôte : la résout par `COALESCE(workspace_id, page hôte)`
|
||||
(`app/services.collection_lifecycle.effective_workspace_sql`). Filtrer sur
|
||||
`workspace_id`, ou faire un `JOIN workspaces`, faisait disparaître ces bases de
|
||||
la page alors qu'elles sont listées dans la sidebar du workspace — l'utilisateur
|
||||
les voyait sans pouvoir les ouvrir.
|
||||
|
||||
Le filtre de vivacité (`live_collection_ids`) est appliqué : une base dont la
|
||||
page hôte est à la corbeille n'est pas proposée, puisqu'elle est inaccessible.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.db import get_conn
|
||||
from app.routers.dashboard._common import _get_active_workspace
|
||||
from app.services.collection_lifecycle import (
|
||||
effective_workspace_sql,
|
||||
live_collection_ids,
|
||||
user_workspace_ids,
|
||||
)
|
||||
from app.templating import ENV
|
||||
|
||||
from ._common import _session_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collections"], prefix="/db")
|
||||
|
||||
#: Colonnes communes aux deux listes (carte de base). `c.` qualification
|
||||
#: obligatoire : le nom de la collection d'icônes homonyme rendrait `rows` et
|
||||
#: `id` ambigus.
|
||||
_CARD_COLUMNS = """
|
||||
c.id, c.name, c.icon, c.parent_page_id, c.workspace_id, c.is_task,
|
||||
c.task_assignee_prop, c.task_status_prop, c.task_due_prop,
|
||||
(SELECT COUNT(*) FROM collection_pages cp
|
||||
WHERE cp.collection_id = c.id AND cp.parent_id IS NULL) AS rows
|
||||
"""
|
||||
|
||||
|
||||
def _esc(value) -> str:
|
||||
return (
|
||||
str(value if value is not None else "")
|
||||
.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
.replace('"', """).replace("'", "'")
|
||||
)
|
||||
|
||||
|
||||
def _card(r: dict) -> str:
|
||||
"""Une carte de base : état My Tasks + ce qu'il reste à faire."""
|
||||
target = (f"/pages/{r['parent_page_id']}" if r["parent_page_id"]
|
||||
else f"/db/{r['id']}")
|
||||
labels = {"assignee": "Assigné à", "status": "Statut", "due": "Échéance"}
|
||||
missing = [role for role in ("assignee", "status", "due")
|
||||
if not r.get(f"task_{role}_prop")]
|
||||
|
||||
if r["is_task"] and not missing:
|
||||
badge = '<span class="db-card-badge ok">Base de tâches</span>'
|
||||
hint = "Alimente My Tasks."
|
||||
elif r["is_task"]:
|
||||
badge = '<span class="db-card-badge warn">À configurer</span>'
|
||||
hint = ("Il manque : " + ", ".join(labels[m] for m in missing)
|
||||
+ ". Ouvrez la base puis « Configurer » dans sa barre.")
|
||||
else:
|
||||
badge = ""
|
||||
hint = "Ouvrez-la puis cliquez sur « Convertir en base de tâches »."
|
||||
|
||||
# `workspace_id` est NULL pour une base portée par une page : on affiche le
|
||||
# nom du workspace effectif plutôt que celui de la colonne brute.
|
||||
ws_label = r.get("ws_label") or ""
|
||||
meta = f'{r["rows"]} ligne(s)'
|
||||
if ws_label:
|
||||
meta += f" · {ws_label}"
|
||||
|
||||
return f"""<a class="db-card" href="{_esc(target)}">
|
||||
<div class="db-card-head">
|
||||
<span class="db-card-icon">{_esc(r["icon"] or "📋")}</span>
|
||||
<span class="db-card-name">{_esc(r["name"])}</span>
|
||||
{badge}
|
||||
</div>
|
||||
<div class="db-card-meta">{_esc(meta)}</div>
|
||||
<div class="db-card-hint">{_esc(hint)}</div>
|
||||
</a>"""
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
def list_collections(request: Request):
|
||||
"""Liste des bases du workspace, dans le layout de l'application."""
|
||||
user = _session_user(request)
|
||||
|
||||
if not user:
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
return RedirectResponse("/auth/login?provider=local", status_code=302)
|
||||
|
||||
with get_conn() as conn:
|
||||
workspace = _get_active_workspace(request, user.get("id")) or {}
|
||||
ws_id = workspace.get("id")
|
||||
ws_name = workspace.get("name") or "Workspace"
|
||||
|
||||
rows = _collections_of(conn, ws_id) if ws_id else []
|
||||
# Bases des autres workspaces : la page ne doit jamais laisser
|
||||
# l'utilisateur sans piste s'il en possède ailleurs.
|
||||
current_ids = {r["id"] for r in rows}
|
||||
others = [r for r in _all_collections(conn, user.get("id"), ws_id)
|
||||
if r["id"] not in current_ids]
|
||||
|
||||
connected = sum(1 for r in rows if r["is_task"])
|
||||
|
||||
return HTMLResponse(_render(request, ws_name, rows, connected, bool(ws_id),
|
||||
others))
|
||||
|
||||
|
||||
def _collections_of(conn, ws_id) -> list[dict]:
|
||||
"""Bases d'un workspace, vivantes seulement.
|
||||
|
||||
`live_collection_ids` écarte les bases dont la page hôte a disparu (corbeille
|
||||
ou suppression) : elles sont inaccessibles, les proposer serait trompeur.
|
||||
|
||||
Le rattachement passe par le workspace **effectif** : une base créée depuis
|
||||
un document a `workspace_id` NULL et n'est repérée que via sa page hôte.
|
||||
"""
|
||||
live = set(live_collection_ids(conn, ws_id))
|
||||
rows = conn.execute(
|
||||
f"""SELECT {_CARD_COLUMNS}
|
||||
FROM collections c
|
||||
WHERE {effective_workspace_sql("c")} = ?
|
||||
ORDER BY c.name""",
|
||||
(ws_id,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows if r["id"] in live]
|
||||
|
||||
|
||||
def _all_collections(conn, user_id, current_ws_id=None) -> list[dict]:
|
||||
"""Toutes les bases accessibles à l'utilisateur, tous workspaces confondus.
|
||||
|
||||
Sert à la section « Autres workspaces » : l'utilisateur doit pouvoir
|
||||
retrouver une base même si elle n'est pas dans le workspace actif (My Tasks
|
||||
étant transverse, ces bases l'alimentent aussi).
|
||||
|
||||
Le périmètre est celui de `user_workspace_ids` (propriétaire **ou** membre) :
|
||||
sans ce filtre, la page exposait les bases de tous les utilisateurs de
|
||||
l'instance. Les bases sans workspace rattachable sont exclues — elles
|
||||
n'appartiennent à personne.
|
||||
"""
|
||||
ws_ids = user_workspace_ids(conn, user_id)
|
||||
if not ws_ids:
|
||||
return []
|
||||
qs = ",".join("?" * len(ws_ids))
|
||||
names = {
|
||||
r["id"]: r["name"]
|
||||
for r in conn.execute(
|
||||
f"SELECT id, name FROM workspaces WHERE id IN ({qs})", ws_ids
|
||||
).fetchall()
|
||||
}
|
||||
rows = conn.execute(
|
||||
f"""SELECT {_CARD_COLUMNS},
|
||||
{effective_workspace_sql("c")} AS ws_effective
|
||||
FROM collections c
|
||||
WHERE {effective_workspace_sql("c")} IN ({qs})
|
||||
ORDER BY c.name""",
|
||||
ws_ids,
|
||||
).fetchall()
|
||||
|
||||
live: dict[int, set[int]] = {}
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
ws = d["ws_effective"]
|
||||
if ws not in live:
|
||||
live[ws] = set(live_collection_ids(conn, ws))
|
||||
if d["id"] not in live[ws]:
|
||||
continue
|
||||
# Le workspace courant est déjà indiqué dans l'en-tête de la page.
|
||||
d["ws_label"] = "" if ws == current_ws_id else names.get(ws, "")
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
def _render(request: Request, ws_name: str, rows: list[dict], connected: int,
|
||||
has_ws: bool, others: list[dict] | None = None) -> str:
|
||||
"""Rendu de la page, dans le layout commun (sidebar + base.html)."""
|
||||
from app.routers.dashboard import _sidebar_data
|
||||
|
||||
others = others or []
|
||||
blocks = [_card(r) for r in rows]
|
||||
other_blocks = [_card(r) for r in others]
|
||||
|
||||
if rows or others:
|
||||
total = len(rows) + len(others)
|
||||
summary = (f"{connected} base(s) de tâches connectée(s) sur {total}"
|
||||
if connected else
|
||||
f"Aucune base de tâches connectée sur {total}")
|
||||
sections = f"""<div class="db-grid">{''.join(blocks)}</div>"""
|
||||
if other_blocks:
|
||||
sections += ('<h2 class="db-section-title">Autres workspaces</h2>'
|
||||
'<p class="db-section-sub">Ces bases appartiennent à '
|
||||
"d'autres workspaces — elles alimentent tout de même "
|
||||
f'My Tasks.</p><div class="db-grid">'
|
||||
f"{''.join(other_blocks)}</div>")
|
||||
body = f"""<div class="db-index">
|
||||
<div class="db-index-head">
|
||||
<h1>📋 Bases de données</h1>
|
||||
<span class="db-index-ws">{_esc(ws_name)}</span>
|
||||
</div>
|
||||
<p class="db-index-sub">{summary} — les bases de tâches alimentent
|
||||
<a href="/my-tasks">My Tasks</a>.</p>
|
||||
{sections}
|
||||
</div>"""
|
||||
elif has_ws:
|
||||
body = """<div class="db-empty">
|
||||
<div class="db-empty-icon">📋</div>
|
||||
<h1>Aucune base dans ce workspace</h1>
|
||||
<p>Créez une page, puis convertissez-la en base de données — ou partez
|
||||
d'un modèle depuis le menu d'ajout.</p>
|
||||
<p class="db-empty-hint">Une base reliée à My Tasks apparaît ici avec son
|
||||
état de configuration.</p>
|
||||
</div>"""
|
||||
else:
|
||||
body = """<div class="db-empty">
|
||||
<div class="db-empty-icon">📋</div>
|
||||
<h1>Aucun workspace sélectionné</h1>
|
||||
<p>Créez ou ouvrez d'abord un workspace : vos bases y seront rattachées.</p>
|
||||
</div>"""
|
||||
|
||||
# `my_tasks.css` porte les styles `.db-*` de cette page (cartes, sections,
|
||||
# états vides) **et** ceux de My Tasks. Elle est liée par `base.html`
|
||||
# (le shell, jamais swappé) : la lier ici exposait la page à un <link>
|
||||
# retiré par htmx lors d'une navigation partielle.
|
||||
block_tpl = ENV.from_string(
|
||||
'{% extends "base.html" %}'
|
||||
"{% block content %}{{ content_html|safe }}{% endblock %}"
|
||||
)
|
||||
return block_tpl.render(
|
||||
**_sidebar_data(request, []),
|
||||
request=request,
|
||||
content_html=body,
|
||||
page_title="Bases de données",
|
||||
title_prefix="Bases de données",
|
||||
page_icon="grid",
|
||||
)
|
||||
@@ -0,0 +1,297 @@
|
||||
"""FlowDeck — Collections : linked.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/collections.py (2 622 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.db_templates import materialize_properties
|
||||
|
||||
from ._common import _apply_template
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collections"], prefix="/db")
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/{collection_id}/linked/api")
|
||||
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.
|
||||
"""
|
||||
|
||||
name = body.get("name", "").strip()
|
||||
body.get("workspace_id")
|
||||
|
||||
with get_conn() as conn:
|
||||
source = conn.execute(
|
||||
"SELECT * FROM collections WHERE id=?", (collection_id,)
|
||||
).fetchone()
|
||||
if not source:
|
||||
raise HTTPException(status_code=404, detail="Source collection not found")
|
||||
|
||||
if not name:
|
||||
name = f"{source['name']} (linked)"
|
||||
|
||||
# Create the linked collection (shallow copy of structure)
|
||||
# Inherit workspace_id from source for permission inheritance
|
||||
src_dict = dict(source)
|
||||
source_workspace_id = src_dict.get("workspace_id")
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collections
|
||||
(name, description, icon, schema_json, is_locked, is_inline, parent_page_id, workspace_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
name,
|
||||
src_dict["description"],
|
||||
src_dict["icon"],
|
||||
src_dict["schema_json"],
|
||||
0, # linked DB is never locked
|
||||
1, # linked DB starts as inline
|
||||
src_dict.get("parent_page_id"),
|
||||
source_workspace_id, # linked DB inherits source workspace permissions
|
||||
),
|
||||
)
|
||||
linked_id = cur.lastrowid
|
||||
|
||||
# Copy views from source
|
||||
views = conn.execute(
|
||||
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
for v in views:
|
||||
conn.execute(
|
||||
"INSERT INTO collection_views (collection_id, name, view_type, config_json, position) VALUES (?, ?, ?, ?, ?)",
|
||||
(linked_id, v["name"], v["view_type"], v["config_json"], v["position"]),
|
||||
)
|
||||
|
||||
# Add the source as a data source with is_linked=1
|
||||
conn.execute(
|
||||
"""INSERT INTO collection_data_sources
|
||||
(collection_id, source_collection_id, source_name, is_linked, position)
|
||||
VALUES (?, ?, ?, 1, 0)""",
|
||||
(linked_id, collection_id, source["name"]),
|
||||
)
|
||||
|
||||
# Copy properties from source
|
||||
props = conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
for p in props:
|
||||
conn.execute(
|
||||
"""INSERT INTO collection_properties
|
||||
(collection_id, name, prop_type, options_json, number_format,
|
||||
related_collection_id, reverse_name, relation_property_id,
|
||||
target_property_id, rollup_function, formula_expression,
|
||||
position, required, visible_in_views)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
linked_id, p["name"], p["prop_type"], p["options_json"],
|
||||
p["number_format"], p["related_collection_id"], p["reverse_name"],
|
||||
p["relation_property_id"], p["target_property_id"],
|
||||
p["rollup_function"], p["formula_expression"],
|
||||
p["position"], p["required"], p["visible_in_views"],
|
||||
),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
return {
|
||||
"linked_id": linked_id,
|
||||
"name": name,
|
||||
"source_collection_id": collection_id,
|
||||
"status": "created",
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/{collection_id}/toggle-inline/api")
|
||||
def toggle_inline(request: Request, collection_id: int):
|
||||
"""API: toggle a collection between full-page and inline mode."""
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute(
|
||||
"SELECT id, is_inline FROM collections WHERE id=?",
|
||||
(collection_id,),
|
||||
).fetchone()
|
||||
if not coll:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
new_inline = 0 if coll["is_inline"] else 1
|
||||
conn.execute(
|
||||
"UPDATE collections SET is_inline=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(new_inline, collection_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {
|
||||
"collection_id": collection_id,
|
||||
"is_inline": bool(new_inline),
|
||||
"mode": "inline" if new_inline else "full-page",
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/inline/api")
|
||||
def create_inline_database(request: Request, body: dict = Body(default={})):
|
||||
"""API: create an inline database within a parent page (optionally from a template)."""
|
||||
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name is required")
|
||||
|
||||
description = body.get("description", "")
|
||||
icon = body.get("icon", "📋")
|
||||
parent_page_id = body.get("parent_page_id")
|
||||
workspace_id = body.get("workspace_id")
|
||||
schema = body.get("schema", [])
|
||||
|
||||
with get_conn() as conn:
|
||||
tpl = _apply_template(conn, body.get("template"))
|
||||
if tpl:
|
||||
if body.get("name"):
|
||||
name = body["name"].strip()
|
||||
description = tpl["description"]
|
||||
icon = tpl.get("icon") or icon
|
||||
try:
|
||||
schema = json.loads(tpl["schema_json"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
schema = []
|
||||
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collections
|
||||
(name, description, icon, schema_json, is_inline, parent_page_id, workspace_id)
|
||||
VALUES (?, ?, ?, ?, 1, ?, ?)""",
|
||||
(name, description, icon, json.dumps(schema), parent_page_id, workspace_id),
|
||||
)
|
||||
collection_id = cur.lastrowid
|
||||
|
||||
materialize_properties(conn, collection_id, schema)
|
||||
|
||||
# Create default view
|
||||
conn.execute(
|
||||
"""INSERT INTO collection_views
|
||||
(collection_id, name, view_type, config_json)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
(collection_id, "Default View", "table", json.dumps({
|
||||
"visible_properties": ["Title"],
|
||||
"sorts": [],
|
||||
"filters": [],
|
||||
})),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {
|
||||
"id": collection_id,
|
||||
"name": name,
|
||||
"icon": icon,
|
||||
"is_inline": True,
|
||||
"parent_page_id": parent_page_id,
|
||||
"status": "created",
|
||||
}
|
||||
|
||||
|
||||
# ── v4.4.0: Tasks & Dependencies ──
|
||||
|
||||
|
||||
|
||||
|
||||
# ── v4.4.0: Tasks & Dependencies ──
|
||||
|
||||
|
||||
@router.put("/{collection_id}/toggle-task/api")
|
||||
def toggle_task(request: Request, collection_id: int):
|
||||
"""API: toggle is_task flag on a collection (Turn into Tasks).
|
||||
|
||||
Rétrocompatible : l'activation passe désormais par la même conversion que
|
||||
``POST /db/{id}/task-db/api``, donc les trois colonnes requises sont liées
|
||||
(créées si besoin). Sans cela, cette route laissait une base « connectée »
|
||||
mais muette — elle n'émettait aucune tâche faute de mapping.
|
||||
"""
|
||||
from .task_db import disable_task_db_in_conn, enable_task_db_in_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT id, is_task FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not coll:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
if coll["is_task"]:
|
||||
disable_task_db_in_conn(conn, collection_id)
|
||||
new_val = 0
|
||||
else:
|
||||
enable_task_db_in_conn(conn, collection_id)
|
||||
new_val = 1
|
||||
return {"collection_id": collection_id, "is_task": bool(new_val), "mode": "tasks" if new_val else "standard"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/{collection_id}/pages/{page_id}/dependencies/api")
|
||||
def list_page_dependencies(request: Request, collection_id: int, page_id: int):
|
||||
"""API: list dependencies for a page (blocks, blocked_by, related)."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM page_dependencies WHERE page_id=? ORDER BY created_at",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
deps = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
dep_page = conn.execute(
|
||||
"SELECT id, title FROM collection_pages WHERE id=?", (r["dependency_id"],)
|
||||
).fetchone()
|
||||
if dep_page:
|
||||
d["dependency_title"] = dep_page["title"]
|
||||
deps.append(d)
|
||||
return {"dependencies": deps}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/{collection_id}/pages/{page_id}/dependencies/api")
|
||||
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."""
|
||||
dependency_id = body.get("dependency_id")
|
||||
if not dependency_id:
|
||||
raise HTTPException(status_code=400, detail="dependency_id is required")
|
||||
dep_type = body.get("dependency_type", "blocks")
|
||||
auto_shift = body.get("auto_shift", "overlap")
|
||||
|
||||
with get_conn() as conn:
|
||||
for pid in (page_id, dependency_id):
|
||||
if not conn.execute("SELECT id FROM collection_pages WHERE id=?", (pid,)).fetchone():
|
||||
raise HTTPException(status_code=404, detail=f"Page {pid} not found")
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO page_dependencies (page_id, dependency_id, dependency_type, auto_shift) VALUES (?,?,?,?)",
|
||||
(page_id, dependency_id, dep_type, auto_shift),
|
||||
)
|
||||
conn.commit()
|
||||
except sqlite3.IntegrityError:
|
||||
raise HTTPException(status_code=409, detail="This dependency already exists") from None
|
||||
return {"id": cur.lastrowid, "page_id": page_id, "dependency_id": dependency_id, "status": "added"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/{collection_id}/pages/{page_id}/dependencies/{dep_id}/api")
|
||||
def remove_page_dependency(request: Request, collection_id: int, page_id: int, dep_id: int):
|
||||
"""API: remove a dependency."""
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT * FROM page_dependencies WHERE id=? AND page_id=?", (dep_id, page_id)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Dependency not found")
|
||||
conn.execute("DELETE FROM page_dependencies WHERE id=?", (dep_id,))
|
||||
conn.commit()
|
||||
return {"id": dep_id, "status": "removed"}
|
||||
@@ -0,0 +1,197 @@
|
||||
"""FlowDeck — Collections : meta.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/collections.py (2 622 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.permission_manager import PermissionManager
|
||||
from app.services.recurrence import (
|
||||
RECURRENCE_KEY,
|
||||
expand_rule,
|
||||
parse_date,
|
||||
)
|
||||
|
||||
from ._common import _collection_properties, _current_user, _require_view, _session_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collections"], prefix="/db")
|
||||
|
||||
|
||||
|
||||
|
||||
# ── Collection Properties (v1.4.0) ──
|
||||
|
||||
|
||||
@router.get("/property-types/api")
|
||||
def list_property_types_api(request: Request):
|
||||
"""API: list all available property types."""
|
||||
from app.services.property_types import PROPERTY_TYPES
|
||||
return {"types": PROPERTY_TYPES}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/{collection_id}/properties/api")
|
||||
def list_properties_api(request: Request, collection_id: int):
|
||||
"""API: list all properties visible to the current user."""
|
||||
user = _session_user(request)
|
||||
_require_view(collection_id, user)
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not coll:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
props = [dict(r) for r in rows]
|
||||
# v6.0.0: property-level visibility — owners/editors see everything, other
|
||||
# users only the properties explicitly granted or left open.
|
||||
if user:
|
||||
pm = PermissionManager(user["id"])
|
||||
visible = pm.get_visible_properties(collection_id)
|
||||
props = [p for p in props if p["id"] in visible]
|
||||
return {"properties": props}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/{collection_id}/members/api")
|
||||
def list_collection_members_api(request: Request, collection_id: int):
|
||||
"""API: list workspace members available for a ``person`` property.
|
||||
|
||||
Resolves the collection's workspace and returns its members (falling back to
|
||||
every active user for standalone databases without a workspace).
|
||||
"""
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute(
|
||||
"SELECT workspace_id FROM collections WHERE id=?", (collection_id,)
|
||||
).fetchone()
|
||||
if not coll:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
ws_id = coll["workspace_id"] if "workspace_id" in coll.keys() else None
|
||||
if ws_id:
|
||||
rows = conn.execute(
|
||||
"""SELECT u.id, u.login, u.full_name, u.avatar_url, u.avatar_color, wm.role
|
||||
FROM workspace_members wm JOIN users u ON wm.user_id=u.id
|
||||
WHERE wm.workspace_id=? AND u.is_active=1 ORDER BY u.full_name, u.login""",
|
||||
(ws_id,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = []
|
||||
if not rows:
|
||||
rows = conn.execute(
|
||||
"""SELECT id, login, full_name, avatar_url, avatar_color, '' AS role
|
||||
FROM users WHERE is_active=1 ORDER BY full_name, login"""
|
||||
).fetchall()
|
||||
|
||||
return {"members": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/{collection_id}/calendar/api")
|
||||
def collection_calendar_api(request: Request, collection_id: int,
|
||||
start: str = "", end: str = "",
|
||||
date_property: str = ""):
|
||||
"""API (v5.8.0): expanded calendar events for a window [start, end].
|
||||
|
||||
Returns every occurrence (recurrence-aware, virtual — never persisted)
|
||||
of the rows in the collection whose ``date_property`` falls inside the
|
||||
inclusive window. Rows without a rule yield their base date.
|
||||
"""
|
||||
user = _current_user(request)
|
||||
s = parse_date(start)
|
||||
e = parse_date(end)
|
||||
if s is None or e is None or s > e:
|
||||
raise HTTPException(status_code=400, detail="start/end must be YYYY-MM-DD")
|
||||
if (e - s).days > 370:
|
||||
raise HTTPException(status_code=400, detail="window too large (max 370 days)")
|
||||
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not coll:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
props = _collection_properties(conn, collection_id)
|
||||
date_props = [p for p in props if p["prop_type"] == "date"]
|
||||
target = None
|
||||
if date_property:
|
||||
target = next((p for p in date_props
|
||||
if str(p["id"]) == str(date_property) or p["name"] == date_property), None)
|
||||
if target is None:
|
||||
raise HTTPException(status_code=400, detail="Unknown date property")
|
||||
elif date_props:
|
||||
target = date_props[0]
|
||||
if target is None:
|
||||
return {"events": [], "timezone": "", "property": None}
|
||||
|
||||
urow = conn.execute("SELECT timezone FROM users WHERE id=?", (user.get("id") or 1,)).fetchone()
|
||||
user_tz = (urow["timezone"] if urow and "timezone" in urow.keys() else "") or ""
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, icon, property_values_json FROM collection_pages WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
|
||||
pid = str(target["id"])
|
||||
events: list[dict] = []
|
||||
for r in rows:
|
||||
try:
|
||||
pv = json.loads(r["property_values_json"] or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
base_value = pv.get(pid)
|
||||
if base_value is None:
|
||||
base_value = pv.get(target["name"])
|
||||
if parse_date(base_value) is None:
|
||||
continue
|
||||
rec_all = pv.get(RECURRENCE_KEY) if isinstance(pv.get(RECURRENCE_KEY), dict) else {}
|
||||
rule = rec_all.get(pid) or rec_all.get(target["name"])
|
||||
row_tz = user_tz
|
||||
if isinstance(rule, dict) and rule.get("timezone"):
|
||||
row_tz = rule["timezone"]
|
||||
tzmap = pv.get("__timezone__")
|
||||
if isinstance(tzmap, dict):
|
||||
ev_tz = tzmap.get(pid) or tzmap.get(target["name"])
|
||||
if ev_tz:
|
||||
row_tz = str(ev_tz)
|
||||
if rule:
|
||||
dates = expand_rule(base_value, rule, s, e, max_occurrences=500)
|
||||
else:
|
||||
d = parse_date(base_value)
|
||||
dates = [d.isoformat()] if d and s <= d <= e else []
|
||||
for iso in dates:
|
||||
events.append({
|
||||
"date": iso,
|
||||
"page_id": r["id"],
|
||||
"title": r["title"],
|
||||
"icon": r["icon"],
|
||||
"recurring": bool(rule),
|
||||
"time": str(base_value)[11:16] if len(str(base_value)) >= 16 else "",
|
||||
"timezone": row_tz,
|
||||
})
|
||||
events.sort(key=lambda ev: (ev["date"], ev["page_id"]))
|
||||
return {"events": events, "timezone": user_tz, "property": {"id": target["id"], "name": target["name"]}}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/timezones/api")
|
||||
def timezones_api(request: Request):
|
||||
"""API (v5.8.0): the user's timezone plus a picker-friendly zone list."""
|
||||
user = _current_user(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT timezone FROM users WHERE id=?", (user.get("id") or 1,)).fetchone()
|
||||
from app.services.recurrence import common_timezones
|
||||
return {
|
||||
"timezone": (row["timezone"] if row and "timezone" in row.keys() else "") or "",
|
||||
"zones": common_timezones(),
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
"""FlowDeck — Collections : pages.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/collections.py (2 622 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event, run_event_sync
|
||||
from app.services.property_types import (
|
||||
apply_auto_properties,
|
||||
)
|
||||
|
||||
from ._common import (
|
||||
_collection_properties,
|
||||
_current_user,
|
||||
_require_edit,
|
||||
_require_view,
|
||||
_session_user,
|
||||
_validate_meta_keys,
|
||||
_validate_page_properties,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collections"], prefix="/db")
|
||||
|
||||
|
||||
|
||||
|
||||
# ── Page CRUD (standalone, BEFORE collection wildcards) ──
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/api")
|
||||
def get_page_api(request: Request, page_id: int):
|
||||
"""API: get a single page."""
|
||||
with get_conn() as conn:
|
||||
page = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE id=?", (page_id,)
|
||||
).fetchone()
|
||||
if not page:
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
# v6.0.0: granular collection/page permissions.
|
||||
_require_view(page["collection_id"], _session_user(request))
|
||||
return dict(page)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/open/api")
|
||||
def open_row_page_api(request: Request, page_id: int):
|
||||
"""v6.5.0 — content page of a database row (lazy-created).
|
||||
|
||||
Any DB view (table/board/gallery/list/calendar) opens a row through
|
||||
this endpoint: it returns the shadow ``pages`` id whose full page
|
||||
editor carries the row's block content (synced blocks included).
|
||||
"""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT collection_id FROM collection_pages WHERE id=?",
|
||||
(page_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
coll_id = row["collection_id"]
|
||||
# v6.0.0: granular collection permissions (same gate as the row itself).
|
||||
_require_view(coll_id, _session_user(request))
|
||||
from app.services.row_pages import ensure_row_page
|
||||
try:
|
||||
content_page_id = ensure_row_page(page_id)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="Page not found") from None
|
||||
return {"page_id": content_page_id, "row_id": page_id}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.put("/pages/{page_id}/api")
|
||||
def update_page_api(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
"""API: update a page's properties."""
|
||||
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE id=?", (page_id,)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
# v6.0.0: granular collection/page permissions.
|
||||
_require_edit(existing["collection_id"], _session_user(request))
|
||||
|
||||
title = body.get("title", existing["title"])
|
||||
icon = body.get("icon", existing["icon"])
|
||||
cover_url = body.get("cover_url", existing["cover_url"] if "cover_url" in existing.keys() else "")
|
||||
position = body.get("position", existing["position"])
|
||||
parent_id = body.get("parent_id", existing["parent_id"])
|
||||
|
||||
try:
|
||||
stored = json.loads(existing["property_values_json"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
stored = {}
|
||||
|
||||
if "properties" in body:
|
||||
# Partial PATCH semantics: merge submitted values over stored ones.
|
||||
props = dict(stored)
|
||||
props.update(body["properties"])
|
||||
else:
|
||||
props = stored
|
||||
|
||||
_validate_page_properties(conn, existing["collection_id"], props, exclude_page_id=page_id)
|
||||
_validate_meta_keys(conn, existing["collection_id"], props)
|
||||
apply_auto_properties(
|
||||
_collection_properties(conn, existing["collection_id"]),
|
||||
props,
|
||||
_current_user(request),
|
||||
is_create=False,
|
||||
)
|
||||
|
||||
property_values = json.dumps(props)
|
||||
|
||||
conn.execute(
|
||||
"""UPDATE collection_pages
|
||||
SET title=?, icon=?, cover_url=?, position=?, parent_id=?, property_values_json=?,
|
||||
updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=?""",
|
||||
(title, icon, cover_url, position, parent_id, property_values, page_id),
|
||||
)
|
||||
# v6.5.0: keep the row's content page title in sync (row → page).
|
||||
from app.services.row_pages import sync_row_title_to_page
|
||||
sync_row_title_to_page(conn, page_id)
|
||||
conn.commit()
|
||||
|
||||
run_event_sync(fire_event("page.updated", {
|
||||
"page_id": page_id,
|
||||
"collection_id": existing["collection_id"],
|
||||
"title": title,
|
||||
"icon": icon,
|
||||
"properties": props,
|
||||
}))
|
||||
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)
|
||||
notify_assignment(existing["collection_id"], page_id, title,
|
||||
stored, props, user.get("id"))
|
||||
return {"id": page_id, "status": "updated"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/pages/{page_id}/api")
|
||||
def delete_page_api(request: Request, page_id: int):
|
||||
"""API: delete a page from its collection."""
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE id=?", (page_id,)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
# v6.0.0: granular collection/page permissions.
|
||||
_require_edit(existing["collection_id"], _session_user(request))
|
||||
|
||||
conn.execute("DELETE FROM collection_pages WHERE id=?", (page_id,))
|
||||
conn.commit()
|
||||
|
||||
run_event_sync(fire_event("page.deleted", {
|
||||
"page_id": page_id,
|
||||
"collection_id": existing["collection_id"],
|
||||
"title": existing["title"],
|
||||
}))
|
||||
run_event_sync(fire_event("collection.page.deleted", {
|
||||
"page_id": page_id,
|
||||
"collection_id": existing["collection_id"],
|
||||
}))
|
||||
return {"id": page_id, "status": "deleted"}
|
||||
|
||||
|
||||
# ── Gitea Board Compatibility (BEFORE {collection_id} wildcards) ──
|
||||
@@ -0,0 +1,322 @@
|
||||
"""FlowDeck — Collections : properties.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/collections.py (2 622 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collections"], prefix="/db")
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/{collection_id}/property-groups/api")
|
||||
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.
|
||||
"""
|
||||
groups = body.get("groups", [])
|
||||
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not coll:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
conn.execute(
|
||||
"UPDATE collection_properties SET group_name='' WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
)
|
||||
for grp in groups:
|
||||
gname = (grp.get("name") or "").strip()
|
||||
if not gname:
|
||||
continue
|
||||
for pid in grp.get("property_ids", []) or []:
|
||||
conn.execute(
|
||||
"UPDATE collection_properties SET group_name=? WHERE id=? AND collection_id=?",
|
||||
(gname, pid, collection_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {"status": "updated"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/{collection_id}/properties/api")
|
||||
def create_property_api(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""API: create a new property on a collection."""
|
||||
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name is required")
|
||||
|
||||
prop_type = body.get("prop_type", "text")
|
||||
options_json = json.dumps(body.get("options", []))
|
||||
number_format = body.get("number_format", "number")
|
||||
required = int(body.get("required", False))
|
||||
visible = int(body.get("visible_in_views", True))
|
||||
validation_json = json.dumps(body.get("validation", {}))
|
||||
group_name = (body.get("group_name") or "").strip()
|
||||
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not coll:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
max_pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_properties WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchone()[0]
|
||||
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collection_properties
|
||||
(collection_id, name, prop_type, options_json, number_format,
|
||||
position, required, visible_in_views, validation_json, group_name)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(collection_id, name, prop_type, options_json, number_format, max_pos,
|
||||
required, visible, validation_json, group_name),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
raise HTTPException(status_code=409, detail=f"Property '{name}' already exists") from None
|
||||
|
||||
return {"id": cur.lastrowid, "name": name, "prop_type": prop_type,
|
||||
"group_name": group_name, "status": "created"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.put("/properties/{prop_id}/api")
|
||||
def update_property_api(request: Request, prop_id: int, body: dict = Body(default={})):
|
||||
"""API: update a property."""
|
||||
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE id=?", (prop_id,)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Property not found")
|
||||
|
||||
name = body.get("name", existing["name"])
|
||||
options_json = json.dumps(body.get("options", json.loads(existing["options_json"])))
|
||||
number_format = body.get("number_format", existing["number_format"])
|
||||
required = int(body.get("required", existing["required"]))
|
||||
visible = int(body.get("visible_in_views", existing["visible_in_views"]))
|
||||
if "validation" in body:
|
||||
validation_json = json.dumps(body.get("validation", {}))
|
||||
else:
|
||||
validation_json = existing["validation_json"] if "validation_json" in existing.keys() else "{}"
|
||||
group_name = body.get("group_name", existing["group_name"] if "group_name" in existing.keys() else "")
|
||||
|
||||
conn.execute(
|
||||
"""UPDATE collection_properties
|
||||
SET name=?, options_json=?, number_format=?, required=?, visible_in_views=?,
|
||||
validation_json=?, group_name=?
|
||||
WHERE id=?""",
|
||||
(name, options_json, number_format, required, visible, validation_json,
|
||||
group_name, prop_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {"id": prop_id, "status": "updated"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/properties/{prop_id}/api")
|
||||
def delete_property_api(request: Request, prop_id: int):
|
||||
"""API: delete a property."""
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE id=?", (prop_id,)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Property not found")
|
||||
conn.execute("DELETE FROM collection_properties WHERE id=?", (prop_id,))
|
||||
conn.commit()
|
||||
|
||||
return {"id": prop_id, "status": "deleted"}
|
||||
|
||||
|
||||
# ── Relations, Rollups, Formulas (v1.5.0) ──
|
||||
|
||||
|
||||
|
||||
|
||||
# ── Relations, Rollups, Formulas (v1.5.0) ──
|
||||
|
||||
|
||||
@router.post("/{collection_id}/properties/relation")
|
||||
def create_relation_property(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""Create a relation property between two collections."""
|
||||
|
||||
name = body.get("name", "").strip()
|
||||
related_collection_id = body.get("related_collection_id")
|
||||
reverse_name = body.get("reverse_name", "").strip()
|
||||
|
||||
if not name or not related_collection_id:
|
||||
raise HTTPException(status_code=400, detail="name and related_collection_id are required")
|
||||
|
||||
with get_conn() as conn:
|
||||
# Verify both collections exist
|
||||
for cid in (collection_id, related_collection_id):
|
||||
if not conn.execute("SELECT id FROM collections WHERE id=?", (cid,)).fetchone():
|
||||
raise HTTPException(status_code=404, detail=f"Collection {cid} not found")
|
||||
|
||||
max_pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_properties WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchone()[0]
|
||||
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collection_properties
|
||||
(collection_id, name, prop_type, related_collection_id, reverse_name, position)
|
||||
VALUES (?, ?, 'relation', ?, ?, ?)""",
|
||||
(collection_id, name, related_collection_id, reverse_name, max_pos),
|
||||
)
|
||||
prop_id = cur.lastrowid
|
||||
|
||||
# Create reverse relation on the related collection
|
||||
if reverse_name:
|
||||
max_pos2 = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_properties WHERE collection_id=?",
|
||||
(related_collection_id,),
|
||||
).fetchone()[0]
|
||||
conn.execute(
|
||||
"""INSERT INTO collection_properties
|
||||
(collection_id, name, prop_type, related_collection_id, reverse_name, position)
|
||||
VALUES (?, ?, 'relation', ?, ?, ?)""",
|
||||
(related_collection_id, reverse_name, collection_id, name, max_pos2),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
return {"id": prop_id, "name": name, "prop_type": "relation", "status": "created"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/{collection_id}/properties/relation/link")
|
||||
def link_pages(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""Link two pages via a relation property."""
|
||||
|
||||
property_id = body.get("property_id")
|
||||
source_page_id = body.get("source_page_id")
|
||||
target_page_id = body.get("target_page_id")
|
||||
|
||||
if not all([property_id, source_page_id, target_page_id]):
|
||||
raise HTTPException(status_code=400, detail="property_id, source_page_id, target_page_id required")
|
||||
|
||||
with get_conn() as conn:
|
||||
# Get the relation property
|
||||
prop = conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE id=? AND prop_type='relation'",
|
||||
(property_id,),
|
||||
).fetchone()
|
||||
if not prop:
|
||||
raise HTTPException(status_code=404, detail="Relation property not found")
|
||||
|
||||
# Update source page's property_values_json
|
||||
source = conn.execute(
|
||||
"SELECT property_values_json FROM collection_pages WHERE id=?",
|
||||
(source_page_id,),
|
||||
).fetchone()
|
||||
if not source:
|
||||
raise HTTPException(status_code=404, detail="Source page not found")
|
||||
|
||||
props = json.loads(source["property_values_json"])
|
||||
current = props.get(str(property_id), [])
|
||||
if not isinstance(current, list):
|
||||
current = []
|
||||
if target_page_id not in current:
|
||||
current.append(target_page_id)
|
||||
props[str(property_id)] = current
|
||||
|
||||
conn.execute(
|
||||
"UPDATE collection_pages SET property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(json.dumps(props), source_page_id),
|
||||
)
|
||||
|
||||
# Update reverse relation if exists
|
||||
if prop["reverse_name"]:
|
||||
reverse_prop = conn.execute(
|
||||
"SELECT id FROM collection_properties WHERE collection_id=? AND name=? AND prop_type='relation'",
|
||||
(prop["related_collection_id"], prop["reverse_name"]),
|
||||
).fetchone()
|
||||
if reverse_prop:
|
||||
target = conn.execute(
|
||||
"SELECT property_values_json FROM collection_pages WHERE id=?",
|
||||
(target_page_id,),
|
||||
).fetchone()
|
||||
if target:
|
||||
tprops = json.loads(target["property_values_json"])
|
||||
tcurrent = tprops.get(str(reverse_prop["id"]), [])
|
||||
if not isinstance(tcurrent, list):
|
||||
tcurrent = []
|
||||
if source_page_id not in tcurrent:
|
||||
tcurrent.append(source_page_id)
|
||||
tprops[str(reverse_prop["id"])] = tcurrent
|
||||
conn.execute(
|
||||
"UPDATE collection_pages SET property_values_json=? WHERE id=?",
|
||||
(json.dumps(tprops), target_page_id),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
return {"status": "linked", "source": source_page_id, "target": target_page_id}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/rollup/compute")
|
||||
def compute_rollup(request: Request, body: dict = Body(default={})):
|
||||
"""Compute a rollup aggregation."""
|
||||
|
||||
collection_id = body.get("collection_id")
|
||||
relation_property_id = body.get("relation_property_id")
|
||||
target_property_id = body.get("target_property_id")
|
||||
page_id = body.get("page_id")
|
||||
rollup_function = body.get("function", "count")
|
||||
|
||||
if not all([collection_id, relation_property_id, target_property_id, page_id]):
|
||||
raise HTTPException(status_code=400, detail="collection_id, relation_property_id, target_property_id, page_id required")
|
||||
|
||||
from app.services.rollup_engine import RollupEngine
|
||||
engine = RollupEngine()
|
||||
result = engine.compute(
|
||||
collection_id, relation_property_id, target_property_id, page_id, rollup_function,
|
||||
)
|
||||
|
||||
return {"result": result, "function": rollup_function}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/formula/evaluate")
|
||||
def evaluate_formula(request: Request, body: dict = Body(default={})):
|
||||
"""Evaluate a formula expression."""
|
||||
|
||||
expression = body.get("expression", "")
|
||||
context = body.get("context", {})
|
||||
|
||||
if not expression:
|
||||
raise HTTPException(status_code=400, detail="expression is required")
|
||||
|
||||
from app.services.formula_engine import FormulaEngine
|
||||
engine = FormulaEngine()
|
||||
result = engine.evaluate(expression, context)
|
||||
|
||||
return {"result": result, "expression": expression}
|
||||
|
||||
|
||||
# ── v1.7.0 View Management ──
|
||||
@@ -0,0 +1,267 @@
|
||||
"""FlowDeck — Collections : structure.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/collections.py (2 622 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event, run_event_sync
|
||||
from app.services.property_types import (
|
||||
apply_auto_properties,
|
||||
)
|
||||
|
||||
from ._common import _collection_properties, _current_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collections"], prefix="/db")
|
||||
|
||||
|
||||
|
||||
|
||||
# ── v1.8.0 Sub-items & Dependencies ──
|
||||
|
||||
|
||||
@router.get("/{collection_id}/pages/{page_id}/sub-items")
|
||||
def list_sub_items(request: Request, collection_id: int, page_id: int):
|
||||
"""API: list sub-items of a page."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE parent_id=? ORDER BY position",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
return {"sub_items": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/{collection_id}/pages/{page_id}/sub-items")
|
||||
def create_sub_item(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})):
|
||||
"""API: create a sub-item under a page."""
|
||||
|
||||
title = body.get("title", "New sub-item").strip()
|
||||
if not title:
|
||||
raise HTTPException(status_code=400, detail="title is required")
|
||||
|
||||
with get_conn() as conn:
|
||||
parent = conn.execute("SELECT id FROM collection_pages WHERE id=? AND collection_id=?", (page_id, collection_id)).fetchone()
|
||||
if not parent:
|
||||
raise HTTPException(status_code=404, detail="Parent page not found")
|
||||
|
||||
max_pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE parent_id=?",
|
||||
(page_id,),
|
||||
).fetchone()[0]
|
||||
|
||||
sub_props = body.get("properties", {}) or {}
|
||||
apply_auto_properties(
|
||||
_collection_properties(conn, collection_id),
|
||||
sub_props,
|
||||
_current_user(request),
|
||||
is_create=True,
|
||||
)
|
||||
|
||||
cur = conn.execute(
|
||||
"INSERT INTO collection_pages (collection_id, title, parent_id, position, property_values_json) VALUES (?, ?, ?, ?, ?)",
|
||||
(collection_id, title, page_id, max_pos, json.dumps(sub_props)),
|
||||
)
|
||||
conn.commit()
|
||||
new_id = cur.lastrowid
|
||||
|
||||
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", {}),
|
||||
}))
|
||||
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"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/{collection_id}/pages/{page_id}/status-aggregate")
|
||||
def aggregate_child_status(request: Request, collection_id: int, page_id: int):
|
||||
"""API: compute aggregate status from children."""
|
||||
with get_conn() as conn:
|
||||
children = conn.execute(
|
||||
"SELECT property_values_json FROM collection_pages WHERE parent_id=?",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
|
||||
statuses = []
|
||||
for c in children:
|
||||
props = json.loads(c["property_values_json"])
|
||||
for v in props.values():
|
||||
if isinstance(v, str) and v:
|
||||
statuses.append(v)
|
||||
|
||||
total = len(statuses)
|
||||
if total == 0:
|
||||
return {"total": 0, "done": 0, "all_done": False}
|
||||
|
||||
done = sum(1 for s in statuses if s.lower() in ("done", "complete", "completed", "terminé"))
|
||||
return {"total": total, "done": done, "all_done": done == total}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/{collection_id}/pages/{page_id}/dependencies")
|
||||
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)."""
|
||||
|
||||
blocks_ids = body.get("blocks", [])
|
||||
|
||||
with get_conn() as conn:
|
||||
page = conn.execute("SELECT property_values_json FROM collection_pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not page:
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
|
||||
props = json.loads(page["property_values_json"])
|
||||
props["blocks"] = blocks_ids
|
||||
|
||||
conn.execute(
|
||||
"UPDATE collection_pages SET property_values_json=? WHERE id=?",
|
||||
(json.dumps(props), page_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {"page_id": page_id, "blocks": blocks_ids, "status": "updated"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/{collection_id}/pages/{page_id}/check-deps")
|
||||
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."""
|
||||
|
||||
body.get("new_status", "Done")
|
||||
|
||||
with get_conn() as conn:
|
||||
page = conn.execute("SELECT property_values_json FROM collection_pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not page:
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
|
||||
props = json.loads(page["property_values_json"])
|
||||
blocks_ids = props.get("blocks", [])
|
||||
|
||||
if not blocks_ids:
|
||||
return {"can_transition": True, "blocked_by": []}
|
||||
|
||||
# Check blocked pages status
|
||||
placeholders = ",".join("?" for _ in blocks_ids)
|
||||
blocked = conn.execute(
|
||||
f"SELECT id, title, property_values_json FROM collection_pages WHERE id IN ({placeholders})",
|
||||
blocks_ids,
|
||||
).fetchall()
|
||||
|
||||
blockers = []
|
||||
for b in blocked:
|
||||
bprops = json.loads(b["property_values_json"])
|
||||
bstatus = None
|
||||
for v in bprops.values():
|
||||
if isinstance(v, str) and v:
|
||||
bstatus = v
|
||||
break
|
||||
if bstatus and bstatus.lower() not in ("done", "complete", "completed", "terminé"):
|
||||
blockers.append({"id": b["id"], "title": b["title"], "status": bstatus})
|
||||
|
||||
return {
|
||||
"can_transition": len(blockers) == 0,
|
||||
"blocked_by": blockers,
|
||||
}
|
||||
|
||||
|
||||
# ── v4.1.0: Data Sources & Linked Databases ──
|
||||
|
||||
|
||||
|
||||
|
||||
# ── v4.1.0: Data Sources & Linked Databases ──
|
||||
|
||||
|
||||
@router.get("/{collection_id}/sources/api")
|
||||
def list_data_sources(request: Request, collection_id: int):
|
||||
"""API: list all data sources for a collection."""
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not coll:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM collection_data_sources WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
return {"sources": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/{collection_id}/sources/api")
|
||||
def add_data_source(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""API: add a data source to a collection."""
|
||||
|
||||
source_collection_id = body.get("source_collection_id")
|
||||
if not source_collection_id:
|
||||
raise HTTPException(status_code=400, detail="source_collection_id is required")
|
||||
|
||||
source_name = body.get("source_name", "").strip()
|
||||
is_linked = body.get("is_linked", False)
|
||||
|
||||
with get_conn() as conn:
|
||||
# Verify both collections exist
|
||||
for cid in (collection_id, source_collection_id):
|
||||
if not conn.execute("SELECT id FROM collections WHERE id=?", (cid,)).fetchone():
|
||||
raise HTTPException(status_code=404, detail=f"Collection {cid} not found")
|
||||
|
||||
max_pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_data_sources WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchone()[0]
|
||||
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collection_data_sources
|
||||
(collection_id, source_collection_id, source_name, is_linked, position)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
(collection_id, source_collection_id, source_name, int(is_linked), max_pos),
|
||||
)
|
||||
conn.commit()
|
||||
except sqlite3.IntegrityError:
|
||||
raise HTTPException(status_code=409, detail="This data source already exists in this collection") from None
|
||||
|
||||
return {
|
||||
"id": cur.lastrowid,
|
||||
"collection_id": collection_id,
|
||||
"source_collection_id": source_collection_id,
|
||||
"status": "added",
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/{collection_id}/sources/{source_id}/api")
|
||||
def remove_data_source(request: Request, collection_id: int, source_id: int):
|
||||
"""API: remove a data source from a collection."""
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT * FROM collection_data_sources WHERE id=? AND collection_id=?",
|
||||
(source_id, collection_id),
|
||||
).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Data source not found")
|
||||
|
||||
conn.execute("DELETE FROM collection_data_sources WHERE id=?", (source_id,))
|
||||
conn.commit()
|
||||
|
||||
return {"id": source_id, "status": "removed"}
|
||||
@@ -0,0 +1,270 @@
|
||||
"""FlowDeck — Collections : « base de tâches » (My Tasks façon Notion).
|
||||
|
||||
Unlike Notion — where any base can be added to the dashboard widget from
|
||||
scratch — a base doit **explicitement** alimenter My Tasks :
|
||||
``POST /db/{id}/task-db`` bascule ``collections.is_task`` et enregistre le
|
||||
mapping des trois propriétés requises (Assigné à / Statut / Échéance).
|
||||
Tant que le mapping est incomplet, la base est connectée mais **n'émet
|
||||
aucune tâche** (`task_databases.collect_tasks` la saute) — l'UI le signale
|
||||
plutôt que de deviner une colonne.
|
||||
|
||||
Limite : 10 bases connectées au tableau de bord (`MAX_TASK_SOURCES`).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.task_databases import (
|
||||
MAX_TASK_SOURCES,
|
||||
ROLE_LABELS,
|
||||
ROLE_TYPES,
|
||||
TASK_ROLES,
|
||||
list_task_sources,
|
||||
source_state,
|
||||
)
|
||||
|
||||
from ._common import _require_edit, _session_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collections"], prefix="/db")
|
||||
|
||||
#: Trio de statuts amorcé quand une colonne Statut est créée à la conversion.
|
||||
DEFAULT_STATUS_OPTIONS = [
|
||||
{"name": "À faire", "color": "gray"},
|
||||
{"name": "En cours", "color": "blue"},
|
||||
{"name": "Terminée", "color": "green"},
|
||||
]
|
||||
|
||||
|
||||
def _user_id(request: Request) -> int:
|
||||
"""ID de l'appelant — session obligatoire.
|
||||
|
||||
`_current_user` retombe sur l'admin (id 1) en cas d'absence de session :
|
||||
wrong pour une route qui liste *les bases de l'utilisateur* (un anonyme
|
||||
verrait le tableau de bord de l'admin). On passe donc par `_session_user`.
|
||||
"""
|
||||
user = _session_user(request)
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
return user["id"]
|
||||
|
||||
|
||||
@router.get("/task-dbs/api")
|
||||
def list_connected_task_databases(request: Request):
|
||||
"""Bases de tâches connectées au tableau de bord My Tasks."""
|
||||
uid = _user_id(request)
|
||||
with get_conn() as conn:
|
||||
sources = list_task_sources(conn, uid)
|
||||
return {
|
||||
"sources": [
|
||||
{k: s[k] for k in (
|
||||
"id", "name", "icon", "workspace_id", "workspace_name",
|
||||
"configured", "missing_roles", "status_options",
|
||||
)}
|
||||
for s in sources
|
||||
],
|
||||
"total": len(sources),
|
||||
"max_sources": MAX_TASK_SOURCES,
|
||||
"limit_reached": len(sources) >= MAX_TASK_SOURCES,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{collection_id}/task-db/api")
|
||||
def get_task_db_state(request: Request, collection_id: int):
|
||||
"""État de conversion + colonnes candidates pour la modale."""
|
||||
_require_edit(collection_id, _session_user(request))
|
||||
with get_conn() as conn:
|
||||
state = source_state(conn, collection_id)
|
||||
if not state["exists"]:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
return state
|
||||
|
||||
|
||||
@router.post("/{collection_id}/task-db/api")
|
||||
def enable_task_db(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""Convertit la base en base de tâches (mapping explicite des 3 rôles).
|
||||
|
||||
``mapping`` : ``{"assignee": <prop_id>, "status": …, "due": …}``.
|
||||
``create_missing`` : ``{"status": {"name": "Statut", "options": [...]}}``
|
||||
pour qu'une colonne absente soit créée dans la transaction.
|
||||
"""
|
||||
_require_edit(collection_id, _session_user(request))
|
||||
|
||||
with get_conn() as conn:
|
||||
state = enable_task_db_in_conn(
|
||||
conn,
|
||||
collection_id,
|
||||
mapping=body.get("mapping") or {},
|
||||
create_missing=body.get("create_missing") or {},
|
||||
)
|
||||
|
||||
return {"status": "enabled", "collection_id": collection_id,
|
||||
"mapping": state["mapping"], "configured": state["configured"]}
|
||||
|
||||
|
||||
def enable_task_db_in_conn(conn, collection_id: int, *, mapping: dict | None = None,
|
||||
create_missing: dict | None = None) -> dict:
|
||||
"""Active ``is_task`` et enregistre le mapping — dans la transaction
|
||||
appelante.
|
||||
|
||||
Point d'entrée unique de la conversion : la route `/task-db/api` comme
|
||||
l'ancien `PUT /db/{id}/toggle-task/api` (qui aurait sinon laissé une base
|
||||
« connectée » sans aucune colonne liée, donc muette).
|
||||
"""
|
||||
state = source_state(conn, collection_id)
|
||||
if not state["exists"]:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
# ── Limite de sources (on recompte : la base elle-même peut déjà être
|
||||
# connectée, auquel cas on ne la compte pas deux fois).
|
||||
if not state["is_task"] and state["sources_count"] >= MAX_TASK_SOURCES:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
f"Limite de {MAX_TASK_SOURCES} bases de tâches atteinte. "
|
||||
"Déconnectez une base avant d'en ajouter une autre."
|
||||
),
|
||||
)
|
||||
|
||||
# ── Création des colonnes manquantes ──
|
||||
create_missing = create_missing or {}
|
||||
mapping = dict(mapping or {})
|
||||
by_name = {p["name"]: p for p in state["all_properties"]}
|
||||
for role in TASK_ROLES:
|
||||
if mapping.get(role):
|
||||
continue
|
||||
spec = create_missing.get(role) or {}
|
||||
name = (spec.get("name") or ROLE_LABELS[role]).strip()
|
||||
prop_type = spec.get("prop_type") or ROLE_TYPES[role][0]
|
||||
if prop_type not in ROLE_TYPES[role]:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Type '{prop_type}' incompatible avec le rôle '{role}'",
|
||||
)
|
||||
if name in by_name:
|
||||
# La colonne existe déjà (nom saisi) → on la lie.
|
||||
mapping[role] = by_name[name]["id"]
|
||||
continue
|
||||
created = _create_property(conn, collection_id, name, prop_type,
|
||||
spec.get("options"))
|
||||
mapping[role] = created["id"]
|
||||
|
||||
# ── Validation : les 3 rôles pointent une colonne de la bonne base ──
|
||||
props = {
|
||||
p["id"]: p
|
||||
for p in conn.execute(
|
||||
"SELECT id, name, prop_type FROM collection_properties WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
}
|
||||
seen: dict[int, str] = {}
|
||||
for role in TASK_ROLES:
|
||||
prop_id = mapping.get(role)
|
||||
if not prop_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Colonne « {ROLE_LABELS[role]} » manquante",
|
||||
)
|
||||
try:
|
||||
prop_id = int(prop_id)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status_code=400,
|
||||
detail="Identifiant de propriété invalide") from None
|
||||
prop = props.get(prop_id)
|
||||
if prop is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"« {ROLE_LABELS[role]} » n'appartient pas à cette base",
|
||||
)
|
||||
if prop["prop_type"] not in ROLE_TYPES[role]:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"« {prop['name']} » est de type {prop['prop_type']}, "
|
||||
f"incompatible avec « {ROLE_LABELS[role]} »"
|
||||
),
|
||||
)
|
||||
if prop_id in seen:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"« {prop['name']} » est déjà utilisé pour « {seen[prop_id]} »",
|
||||
)
|
||||
seen[prop_id] = ROLE_LABELS[role]
|
||||
mapping[role] = prop_id
|
||||
|
||||
conn.execute(
|
||||
"""UPDATE collections
|
||||
SET is_task = 1,
|
||||
task_assignee_prop = ?, task_status_prop = ?, task_due_prop = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?""",
|
||||
(mapping["assignee"], mapping["status"], mapping["due"], collection_id),
|
||||
)
|
||||
conn.commit()
|
||||
return source_state(conn, collection_id)
|
||||
|
||||
|
||||
@router.delete("/{collection_id}/task-db/api")
|
||||
def disable_task_db(request: Request, collection_id: int):
|
||||
"""Déconnecte la base du tableau de bord (les tâches sont conservées)."""
|
||||
_require_edit(collection_id, _session_user(request))
|
||||
with get_conn() as conn:
|
||||
disable_task_db_in_conn(conn, collection_id)
|
||||
return {"status": "disabled", "collection_id": collection_id}
|
||||
|
||||
|
||||
def disable_task_db_in_conn(conn, collection_id: int) -> None:
|
||||
"""Déconnexion dans la transaction appelante : efface aussi le mapping,
|
||||
pour ne pas laisser des identifiants de colonnes mortes."""
|
||||
row = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
conn.execute(
|
||||
"""UPDATE collections
|
||||
SET is_task = 0, task_assignee_prop = NULL, task_status_prop = NULL,
|
||||
task_due_prop = NULL, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?""",
|
||||
(collection_id,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _create_property(conn, collection_id: int, name: str, prop_type: str,
|
||||
options=None) -> dict:
|
||||
"""Crée une propriété (à la position suivante) et la renvoie.
|
||||
|
||||
Une colonne Statut amorcée sans options reçoit le trio classique
|
||||
« À faire / En cours / Terminée » : sans options, le Kanban n'aurait
|
||||
aucune colonne à afficher.
|
||||
"""
|
||||
import json
|
||||
|
||||
row = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_properties "
|
||||
"WHERE collection_id = ?",
|
||||
(collection_id,),
|
||||
).fetchone()
|
||||
position = row[0] if row else 0
|
||||
|
||||
opts = []
|
||||
for opt in options or []:
|
||||
if isinstance(opt, str):
|
||||
opts.append({"name": opt, "color": "gray"})
|
||||
elif isinstance(opt, dict) and opt.get("name"):
|
||||
opts.append({"name": opt["name"], "color": opt.get("color") or "gray"})
|
||||
if not opts and prop_type in ("status", "select"):
|
||||
opts = list(DEFAULT_STATUS_OPTIONS)
|
||||
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collection_properties
|
||||
(collection_id, name, prop_type, options_json, position, required,
|
||||
visible_in_views)
|
||||
VALUES (?, ?, ?, ?, ?, 0, 1)""",
|
||||
(collection_id, name, prop_type, json.dumps(opts), position),
|
||||
)
|
||||
prop_id = cur.lastrowid
|
||||
conn.commit()
|
||||
return {"id": prop_id, "name": name, "prop_type": prop_type}
|
||||
@@ -0,0 +1,515 @@
|
||||
"""FlowDeck — Collections : views.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/collections.py (2 622 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event, run_event_sync
|
||||
|
||||
from ._common import _current_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collections"], prefix="/db")
|
||||
|
||||
|
||||
|
||||
|
||||
# ── v1.7.0 View Management ──
|
||||
|
||||
|
||||
@router.get("/views/{view_id}/api")
|
||||
def get_view_api(request: Request, view_id: int):
|
||||
"""API: get a single view config."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="View not found")
|
||||
return dict(row)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.put("/views/{view_id}/config")
|
||||
def update_view_config(request: Request, view_id: int, body: dict = Body(default={})):
|
||||
"""API: update view configuration (group_by, card_size, visible_properties, etc.)."""
|
||||
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="View not found")
|
||||
|
||||
config = json.loads(existing["config_json"])
|
||||
for key in ("group_by", "sub_group_by", "wip_limits", "card_size", "cover_property",
|
||||
"cover_mode", "card_properties", "visible_properties", "filters", "sorts",
|
||||
"filter_conjunction", "date_property", "date_range_property",
|
||||
"property_groups", "view_type",
|
||||
# Vues Notion (Chart/Timeline/Feed/Map/Dashboard/Form) — §10.4
|
||||
"version", "chart_type", "chart_property", "measure", "omit_zero",
|
||||
"cumulative", "hidden_groups", "group_order", "style", "order",
|
||||
"aggregate", "title", "center_value", "palette", "height",
|
||||
"data_labels", "legend", "grid", "smooth", "gradient",
|
||||
"color_by_value", "separate_end_property", "scale", "show_table",
|
||||
"table_properties", "load_limit", "show_dependencies", "sub_items",
|
||||
"row_order", "show_view_count", "excerpt", "place_property",
|
||||
"cluster", "default_style", "global_filters", "row_heights",
|
||||
"excluded_widget_ids", "sharing", "submit_screen", "notify_email",
|
||||
"description", "form_view_id"):
|
||||
if key in body:
|
||||
config[key] = body[key]
|
||||
|
||||
new_type = body.get("view_type") or existing["view_type"]
|
||||
conn.execute(
|
||||
"UPDATE collection_views SET config_json=?, name=COALESCE(?, name), view_type=?, "
|
||||
"updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(json.dumps(config), body.get("name"), new_type, view_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {"id": view_id, "status": "updated", "config": config, "view_type": new_type}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/{collection_id}/views/save-as")
|
||||
def save_view_as(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""API: save current view state as a new named view."""
|
||||
|
||||
name = body.get("name", "New View")
|
||||
config = body.get("config", {})
|
||||
user = _current_user(request)
|
||||
user_id = user.get("id") if user else None
|
||||
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not coll:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
max_pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_views WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchone()[0]
|
||||
|
||||
view_type = body.get("view_type", "table")
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collection_views
|
||||
(collection_id, name, view_type, config_json, position, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(collection_id, name, view_type, json.dumps(config), max_pos, user_id),
|
||||
)
|
||||
conn.commit()
|
||||
new_view_id = cur.lastrowid
|
||||
|
||||
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"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/{collection_id}/views/api")
|
||||
def list_views_api(request: Request, collection_id: int):
|
||||
"""API: list views for a collection visible to the current user.
|
||||
|
||||
Shared/legacy views (``created_by IS NULL``) are visible to everyone;
|
||||
personal views (``created_by = user``) only to their owner.
|
||||
"""
|
||||
user = _current_user(request)
|
||||
user_id = user.get("id") if user else None
|
||||
with get_conn() as conn:
|
||||
if user_id is not None:
|
||||
rows = conn.execute(
|
||||
"""SELECT * FROM collection_views
|
||||
WHERE collection_id=? AND (created_by IS NULL OR created_by=?)
|
||||
ORDER BY position""",
|
||||
(collection_id, user_id),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM collection_views WHERE collection_id=? AND created_by IS NULL ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
return {"views": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/views/{view_id}/api")
|
||||
def delete_view_api(request: Request, view_id: int):
|
||||
"""API: delete a saved view."""
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="View not found")
|
||||
conn.execute("DELETE FROM collection_views WHERE id=?", (view_id,))
|
||||
conn.commit()
|
||||
return {"id": view_id, "status": "deleted"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/views/{view_id}/duplicate")
|
||||
def duplicate_view_api(request: Request, view_id: int, body: dict = Body(default={})):
|
||||
"""API: duplicate a view (config + type), owned by the current user."""
|
||||
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="View not found")
|
||||
|
||||
max_pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_views WHERE collection_id=?",
|
||||
(existing["collection_id"],),
|
||||
).fetchone()[0]
|
||||
|
||||
user = _current_user(request)
|
||||
user_id = user.get("id") if user else None
|
||||
name = body.get("name") or (existing["name"] + " copy")
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collection_views
|
||||
(collection_id, name, view_type, config_json, position, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(existing["collection_id"], name, existing["view_type"],
|
||||
existing["config_json"], max_pos, user_id),
|
||||
)
|
||||
conn.commit()
|
||||
dup_view_id = cur.lastrowid
|
||||
|
||||
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"}
|
||||
|
||||
|
||||
# ── Vues Notion : donnees de vues, agregation, geocodage, dashboard, form ──
|
||||
|
||||
def _load_view_collection(conn, collection_id: int, view_id: int):
|
||||
view = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
|
||||
if not view or int(view["collection_id"]) != int(collection_id):
|
||||
raise HTTPException(status_code=404, detail="View not found")
|
||||
try:
|
||||
config = json.loads(view["config_json"] or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
config = {}
|
||||
props = [dict(r) for r in conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,)).fetchall()]
|
||||
pages = [dict(r) for r in conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,)).fetchall()]
|
||||
return dict(view), config, props, pages
|
||||
|
||||
|
||||
@router.get("/{collection_id}/views/{view_id}/rows")
|
||||
def view_rows_api(request: Request, collection_id: int, view_id: int,
|
||||
cursor: str | None = None, limit: int = 50,
|
||||
window: str | None = None):
|
||||
"""Lignes filtrees/triees/projetees d'une vue (Feed, Timeline, Map...)."""
|
||||
from app.services.view_query import query_view_rows
|
||||
with get_conn() as conn:
|
||||
view, config, props, pages = _load_view_collection(conn, collection_id, view_id)
|
||||
if window:
|
||||
# Fenetre temporelle Timeline : "2026-01-01..2026-12-31" — filtree cote client.
|
||||
config = dict(config)
|
||||
limit = max(1, min(int(limit or 50), 1000))
|
||||
rows, next_cursor = query_view_rows(pages, props, config, limit=limit, cursor=cursor)
|
||||
undated = 0
|
||||
date_prop = (config.get("date_property") or config.get("date_range_property"))
|
||||
if date_prop and (view.get("view_type") == "timeline"):
|
||||
from app.services.view_query import prop_value, text_of
|
||||
by_name = {p["name"]: p for p in props}
|
||||
by_id = {str(p["id"]): p for p in props}
|
||||
dp = by_name.get(date_prop) or by_id.get(str(date_prop))
|
||||
if dp:
|
||||
for p in pages:
|
||||
if not text_of(dp, prop_value(p, dp)):
|
||||
undated += 1
|
||||
return {"rows": rows, "next_cursor": next_cursor, "total": len(pages),
|
||||
"undated": undated, "view_type": view.get("view_type")}
|
||||
|
||||
|
||||
@router.post("/{collection_id}/views/{view_id}/aggregate")
|
||||
def view_aggregate_api(request: Request, collection_id: int, view_id: int,
|
||||
body: dict = Body(default={})):
|
||||
"""Agregat groupe (Chart, KPI, widgets, drilldown) — §8.3."""
|
||||
from app.services.view_aggregate import aggregate
|
||||
with get_conn() as conn:
|
||||
view, config, props, pages = _load_view_collection(conn, collection_id, view_id)
|
||||
spec = {
|
||||
"group_by": body.get("group_by", config.get("group_by")),
|
||||
"sub_group_by": body.get("sub_group_by", config.get("sub_group_by")),
|
||||
"measure": body.get("measure", config.get("measure", {"kind": "count"})),
|
||||
"cumulative": body.get("cumulative", config.get("cumulative", False)),
|
||||
"omit_zero": body.get("omit_zero", config.get("omit_zero", True)),
|
||||
"hidden_groups": body.get("hidden_groups", config.get("hidden_groups", [])),
|
||||
"order": body.get("order", config.get("order", config.get("group_order", "group_order"))),
|
||||
"filters": (config.get("filters") or []) + (body.get("filters") or []),
|
||||
"filter_conjunction": config.get("filter_conjunction", "and"),
|
||||
"sorts": config.get("sorts"),
|
||||
}
|
||||
# Compat : ancienne config SSR {chart_property, aggregate}
|
||||
if not spec["group_by"] and (config.get("chart_property") or body.get("chart_property")):
|
||||
spec["group_by"] = config.get("chart_property") or body.get("chart_property")
|
||||
if isinstance(spec["measure"], str):
|
||||
spec["measure"] = {"kind": spec["measure"]}
|
||||
if isinstance(spec["order"], str):
|
||||
spec["order"] = {"by": spec["order"], "direction": "asc"}
|
||||
return aggregate(pages, props, spec)
|
||||
|
||||
|
||||
@router.get("/{collection_id}/views/{view_id}/drilldown")
|
||||
def view_drilldown_api(request: Request, collection_id: int, view_id: int,
|
||||
group: str = "", sub: str | None = None, limit: int = 100):
|
||||
"""Tableau restreint des lignes d'un segment de Chart (§8.4)."""
|
||||
from app.services.view_query import query_view_rows
|
||||
with get_conn() as conn:
|
||||
view, config, props, pages = _load_view_collection(conn, collection_id, view_id)
|
||||
group_by = config.get("group_by") or config.get("chart_property")
|
||||
extra = [{"property": group_by, "operator": "is", "value": group}] if group_by and group else []
|
||||
if sub and config.get("sub_group_by"):
|
||||
extra.append({"property": config["sub_group_by"], "operator": "is", "value": sub})
|
||||
rows, _ = query_view_rows(pages, props, config, extra_filters=extra,
|
||||
limit=max(1, min(int(limit or 100), 500)))
|
||||
return {"rows": rows, "group": group, "sub": sub, "count": len(rows)}
|
||||
|
||||
|
||||
@router.get("/geocode/search")
|
||||
def geocode_search_api(request: Request, q: str = "", limit: int = 5):
|
||||
"""Autocompletion d'adresses (editeur place). Provider none -> []."""
|
||||
from app.services import geocoding as _geo
|
||||
with get_conn() as conn:
|
||||
return {"suggestions": _geo.search(conn, q, limit=min(int(limit or 5), 10))}
|
||||
|
||||
|
||||
@router.post("/geocode/resolve")
|
||||
def geocode_resolve_api(request: Request, body: dict = Body(default={})):
|
||||
"""Adresse -> coordonnees (cache migration 45)."""
|
||||
from app.services import geocoding as _geo
|
||||
with get_conn() as conn:
|
||||
result = _geo.resolve(conn, body.get("q") or body.get("address") or "")
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Place not resolved")
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/{collection_id}/views/{view_id}/widgets")
|
||||
def dashboard_widgets_get(request: Request, collection_id: int, view_id: int):
|
||||
with get_conn() as conn:
|
||||
view, config, _, _ = _load_view_collection(conn, collection_id, view_id)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM dashboard_widgets WHERE view_id=? ORDER BY row_index, position",
|
||||
(view_id,)).fetchall()
|
||||
widgets = [dict(r) for r in rows]
|
||||
except Exception:
|
||||
widgets = json.loads(config.get("widgets_json") or "[]") if isinstance(config.get("widgets_json"), str) else (config.get("widgets_json") or [])
|
||||
return {"widgets": widgets, "config": config}
|
||||
|
||||
|
||||
@router.put("/{collection_id}/views/{view_id}/widgets")
|
||||
def dashboard_widgets_put(request: Request, collection_id: int, view_id: int,
|
||||
body: dict = Body(default={})):
|
||||
from app.routers.collections._common import _require_edit
|
||||
from app.routers.collections._common import _session_user as _su
|
||||
_require_edit(collection_id, _su(request))
|
||||
widgets = body.get("widgets") or []
|
||||
if len(widgets) > 12:
|
||||
raise HTTPException(status_code=400, detail="12 widgets maximum")
|
||||
with get_conn() as conn:
|
||||
view = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
|
||||
if not view or int(view["collection_id"]) != int(collection_id):
|
||||
raise HTTPException(status_code=404, detail="View not found")
|
||||
try:
|
||||
conn.execute("DELETE FROM dashboard_widgets WHERE view_id=?", (view_id,))
|
||||
for i, w in enumerate(widgets):
|
||||
conn.execute(
|
||||
"""INSERT INTO dashboard_widgets
|
||||
(view_id, position, row_index, col_span, height_units,
|
||||
source_collection_id, source_view_id, inline_config_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(view_id, i, int(w.get("row_index", i // 4)), int(w.get("col_span", 1) or 1),
|
||||
int(w.get("height_units", 2) or 2),
|
||||
w.get("source_collection_id"), w.get("source_view_id"),
|
||||
json.dumps(w.get("inline_config") or w.get("inline_config_json") or {})),
|
||||
)
|
||||
conn.commit()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
# Repli : stockage JSON dans le config (pre-migration 46).
|
||||
config = json.loads(view["config_json"] or "{}")
|
||||
config["widgets_json"] = widgets
|
||||
config["global_filters"] = body.get("global_filters", config.get("global_filters", []))
|
||||
if body.get("global_filters") is not None:
|
||||
config["global_filters"] = body["global_filters"]
|
||||
conn.execute("UPDATE collection_views SET config_json=? WHERE id=?",
|
||||
(json.dumps(config), view_id))
|
||||
conn.commit()
|
||||
return {"widgets": widgets, "fallback": "config_json"}
|
||||
if body.get("global_filters") is not None:
|
||||
config = json.loads(view["config_json"] or "{}")
|
||||
config["global_filters"] = body["global_filters"]
|
||||
conn.execute("UPDATE collection_views SET config_json=? WHERE id=?",
|
||||
(json.dumps(config), view_id))
|
||||
conn.commit()
|
||||
try:
|
||||
run_event_sync(fire_event("dashboard.layout_updated", {"view_id": view_id}))
|
||||
except Exception:
|
||||
pass
|
||||
return {"widgets": widgets, "status": "saved"}
|
||||
|
||||
|
||||
@router.get("/{collection_id}/views/{view_id}/widgets/{widget_id}/data")
|
||||
def dashboard_widget_data(request: Request, collection_id: int, view_id: int, widget_id: int):
|
||||
from app.services.view_aggregate import aggregate
|
||||
from app.services.view_query import query_view_rows
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
w = conn.execute("SELECT * FROM dashboard_widgets WHERE id=? AND view_id=?",
|
||||
(widget_id, view_id)).fetchone()
|
||||
widget = dict(w) if w else None
|
||||
except Exception:
|
||||
widget = None
|
||||
if widget is None:
|
||||
raise HTTPException(status_code=404, detail="Widget not found")
|
||||
src_cid = widget.get("source_collection_id") or collection_id
|
||||
props = [dict(r) for r in conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
|
||||
(src_cid,)).fetchall()]
|
||||
pages = [dict(r) for r in conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position",
|
||||
(src_cid,)).fetchall()]
|
||||
src_view = None
|
||||
if widget.get("source_view_id"):
|
||||
src_view = conn.execute("SELECT * FROM collection_views WHERE id=?",
|
||||
(widget["source_view_id"],)).fetchone()
|
||||
try:
|
||||
inline = json.loads(widget.get("inline_config_json") or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
inline = {}
|
||||
cfg = dict(inline)
|
||||
if src_view:
|
||||
try:
|
||||
base = json.loads(src_view["config_json"] or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
base = {}
|
||||
cfg = {**base, **inline}
|
||||
vtype = (cfg.get("view_type") or src_view["view_type"] if src_view else cfg.get("view_type", "table"))
|
||||
if (cfg.get("group_by") or cfg.get("chart_type")) and vtype in ("chart", "dashboard", None):
|
||||
return aggregate(pages, props, {
|
||||
"group_by": cfg.get("group_by"), "sub_group_by": cfg.get("sub_group_by"),
|
||||
"measure": cfg.get("measure", {"kind": "count"}),
|
||||
"omit_zero": cfg.get("omit_zero", True), "hidden_groups": cfg.get("hidden_groups", []),
|
||||
"order": cfg.get("order", "group_order"), "filters": cfg.get("filters") or [],
|
||||
})
|
||||
rows, _ = query_view_rows(pages, props, cfg, limit=50)
|
||||
return {"rows": rows, "view_type": vtype or "table"}
|
||||
|
||||
|
||||
@router.get("/{collection_id}/views/{view_id}/form")
|
||||
def form_builder_get(request: Request, collection_id: int, view_id: int):
|
||||
from app.services.form_projection import default_questions_for
|
||||
with get_conn() as conn:
|
||||
view, config, props, _ = _load_view_collection(conn, collection_id, view_id)
|
||||
try:
|
||||
qs = [dict(r) for r in conn.execute(
|
||||
"SELECT * FROM form_questions WHERE view_id=? ORDER BY position", (view_id,)).fetchall()]
|
||||
except Exception:
|
||||
qs = []
|
||||
if not qs:
|
||||
qs = default_questions_for([{"name": p["name"], "prop_type": p["prop_type"]} for p in props])
|
||||
return {"config": config, "questions": qs}
|
||||
|
||||
|
||||
@router.put("/{collection_id}/views/{view_id}/form")
|
||||
def form_builder_put(request: Request, collection_id: int, view_id: int,
|
||||
body: dict = Body(default={})):
|
||||
from app.routers.collections._common import _require_edit
|
||||
from app.routers.collections._common import _session_user as _su
|
||||
from app.services.form_projection import project_form_config
|
||||
_require_edit(collection_id, _su(request))
|
||||
questions = body.get("questions") or []
|
||||
form_cfg = body.get("config") or {}
|
||||
with get_conn() as conn:
|
||||
view = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
|
||||
if not view or int(view["collection_id"]) != int(collection_id):
|
||||
raise HTTPException(status_code=404, detail="View not found")
|
||||
try:
|
||||
base = json.loads(view["config_json"] or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
base = {}
|
||||
base.update({k: v for k, v in form_cfg.items()
|
||||
if k in ("title", "description", "sharing", "submit_screen", "notify_email")})
|
||||
base["version"] = 2
|
||||
try:
|
||||
conn.execute("DELETE FROM form_questions WHERE view_id=?", (view_id,))
|
||||
for i, q in enumerate(questions):
|
||||
conn.execute(
|
||||
"""INSERT INTO form_questions
|
||||
(view_id, property_name, position, label_override, sync_label,
|
||||
help_text, required, widget, max_selections, conditional_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(view_id, q.get("property_name"), i,
|
||||
q.get("label_override"), 1 if q.get("sync_label", True) else 0,
|
||||
q.get("help_text", ""), 1 if q.get("required") else 0,
|
||||
q.get("widget", "auto"), q.get("max_selections"),
|
||||
json.dumps(q.get("conditional")) if q.get("conditional") else q.get("conditional_json")),
|
||||
)
|
||||
stored_qs = [dict(r) for r in conn.execute(
|
||||
"SELECT * FROM form_questions WHERE view_id=? ORDER BY position", (view_id,)).fetchall()]
|
||||
except Exception:
|
||||
base["form_questions_json"] = questions
|
||||
stored_qs = questions
|
||||
conn.execute("UPDATE collection_views SET config_json=? WHERE id=?",
|
||||
(json.dumps(base), view_id))
|
||||
conn.commit()
|
||||
return {"questions": stored_qs, "status": "saved", "fallback": "config_json"}
|
||||
projected = project_form_config(base, [{
|
||||
"property_name": r["property_name"], "position": r["position"],
|
||||
"label_override": r["label_override"], "sync_label": r["sync_label"],
|
||||
"help_text": r["help_text"], "required": r["required"],
|
||||
"widget": r["widget"], "max_selections": r["max_selections"],
|
||||
"conditional_json": r["conditional_json"],
|
||||
} for r in stored_qs])
|
||||
base["projected_form_config"] = projected
|
||||
# Synchro libelle -> nom de propriete (defaut Notion).
|
||||
for r in stored_qs:
|
||||
if r["sync_label"] and r["label_override"]:
|
||||
conn.execute(
|
||||
"UPDATE collection_properties SET name=? WHERE collection_id=? AND name=?",
|
||||
(r["label_override"], collection_id, r["property_name"]))
|
||||
conn.execute("UPDATE collection_views SET config_json=? WHERE id=?",
|
||||
(json.dumps(base), view_id))
|
||||
# La projection alimente aussi le pipeline public /f/ existant (meme collection).
|
||||
try:
|
||||
conn.execute("UPDATE collections SET form_config_json=? WHERE id=?",
|
||||
(json.dumps(projected), collection_id))
|
||||
except Exception:
|
||||
pass
|
||||
conn.commit()
|
||||
return {"questions": stored_qs, "status": "saved",
|
||||
"projected_fields": len(projected.get("fields", []))}
|
||||
|
||||
|
||||
@router.post("/{collection_id}/views/{view_id}/form/preview-token")
|
||||
def form_preview_token(request: Request, collection_id: int, view_id: int):
|
||||
"""Jeton d'apercu non-soumissible (soumissions refusees cote serveur)."""
|
||||
import secrets
|
||||
return {"preview_token": "preview_" + secrets.token_hex(8), "submittable": False}
|
||||
|
||||
|
||||
# ── v1.8.0 Sub-items & Dependencies ──
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,77 @@
|
||||
"""FlowDeck — Dashboard (pages HTML + API de l'app).
|
||||
|
||||
Découpe A28 : l'ancien `dashboard.py` (2 735 lignes, 63 routes) est
|
||||
devenu ce package — un module par concern, helpers dans `_common`,
|
||||
re-export de tout ce que les 7 importateurs existants utilisent
|
||||
(main, board, my_tasks, web_clipper, wiki, sites, tests).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from . import ( # ordre = ordre d'enregistrement d'origine (openapi identique)
|
||||
account_api,
|
||||
account_settings,
|
||||
local_workspace,
|
||||
pages_api,
|
||||
pages_html,
|
||||
public,
|
||||
workspace,
|
||||
workspaces,
|
||||
)
|
||||
from ._common import ( # noqa: F401 — re-export des helpers
|
||||
_VERSION,
|
||||
WORKSPACE_COOKIE,
|
||||
_build_breadcrumb,
|
||||
_build_tree_children,
|
||||
_file_page_disk_path,
|
||||
_format_size,
|
||||
_get_active_workspace,
|
||||
_get_app_version,
|
||||
_get_user_id,
|
||||
_get_user_or_redirect,
|
||||
_local_workspaces_for_user,
|
||||
_nav_breadcrumb,
|
||||
_render_blocks_public,
|
||||
_require_page_view,
|
||||
_require_user_id,
|
||||
_sanitize_id,
|
||||
_sidebar_data,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
for _mod in (
|
||||
pages_html,
|
||||
account_api,
|
||||
workspace,
|
||||
local_workspace,
|
||||
workspaces,
|
||||
account_settings,
|
||||
public,
|
||||
pages_api,
|
||||
):
|
||||
router.include_router(_mod.router)
|
||||
|
||||
__all__ = [
|
||||
"router",
|
||||
"WORKSPACE_COOKIE",
|
||||
"_VERSION",
|
||||
"_build_breadcrumb",
|
||||
"_build_tree_children",
|
||||
"_file_page_disk_path",
|
||||
"_format_size",
|
||||
"_get_active_workspace",
|
||||
"_get_app_version",
|
||||
"_get_user_id",
|
||||
"_get_user_or_redirect",
|
||||
"_local_workspaces_for_user",
|
||||
"_nav_breadcrumb",
|
||||
"_render_blocks_public",
|
||||
"_require_page_view",
|
||||
"_require_user_id",
|
||||
"_sanitize_id",
|
||||
"_sidebar_data",
|
||||
]
|
||||
@@ -0,0 +1,942 @@
|
||||
"""FlowDeck — Dashboard : helpers partagés des modules de routes (A28).
|
||||
|
||||
Les 15 helpers top-level de l'ancien dashboard.py vivent ici (état :
|
||||
_VERSION, WORKSPACE_COOKIE) — ré-exportés par le package.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_VERSION = None
|
||||
|
||||
WORKSPACE_COOKIE = "flowdeck_workspace"
|
||||
|
||||
|
||||
|
||||
def _get_app_version() -> str:
|
||||
"""Read version from VERSION file with caching."""
|
||||
global _VERSION
|
||||
if _VERSION is not None:
|
||||
return _VERSION
|
||||
try:
|
||||
import os
|
||||
version_path = os.path.join(os.path.dirname(__file__), "..", "..", "VERSION")
|
||||
if os.path.exists(version_path):
|
||||
with open(version_path) as f:
|
||||
_VERSION = f.read().strip()
|
||||
else:
|
||||
# Docker fallback
|
||||
version_path = "/app/VERSION"
|
||||
if os.path.exists(version_path):
|
||||
with open(version_path) as f:
|
||||
_VERSION = f.read().strip()
|
||||
else:
|
||||
_VERSION = "0.0.0"
|
||||
except Exception:
|
||||
_VERSION = "0.0.0"
|
||||
return _VERSION
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _get_user_or_redirect(request: Request):
|
||||
"""Return decoded user or a RedirectResponse to login page.
|
||||
Skips redirect when DB has no users (fresh install / test env)."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
# Allow through if no users exist yet (fresh install / tests)
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||
if count == 0:
|
||||
return {"id": 1, "login": "admin", "full_name": "Admin", "is_admin": True}
|
||||
except Exception:
|
||||
logger.exception("_get_user_or_redirect")
|
||||
return RedirectResponse("/auth/login?provider=local&expired=1", status_code=302)
|
||||
return user
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _local_workspaces_for_user(user: dict | None, workspace_id: int = 0) -> list[dict]:
|
||||
"""Return list of local workspaces for a user."""
|
||||
if not user:
|
||||
return []
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, name FROM workspaces WHERE owner_id = ? ORDER BY name",
|
||||
(user["id"],)
|
||||
).fetchall()
|
||||
return [{"id": r["id"], "name": r["name"]} for r in rows]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _recent_sidebar_pages(uid: int, limit: int = 8) -> list:
|
||||
"""Fichiers réellement récents (dernière édition) pour la sidebar.
|
||||
|
||||
v7.69.6 : avant, seuls les dépôts Gitea alimentaient la section Recents
|
||||
(vide sans dépôt). Icônes identiques au Workspace (noms SVG, pas
|
||||
d'emoji). Notes de réunion exclues → section Meetings.
|
||||
"""
|
||||
if not uid:
|
||||
return []
|
||||
try:
|
||||
from app.routers.board import _file_icon
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT p.id, p.title, p.parent_section, p.content_format
|
||||
FROM pages p
|
||||
LEFT JOIN workspaces w ON w.id = p.workspace_id
|
||||
WHERE p.deleted_at IS NULL AND p.parent_section != 'Trash'
|
||||
AND (w.owner_id = ? OR p.workspace_id IS NULL)
|
||||
AND REPLACE(COALESCE(p.content,''), ' ', '')
|
||||
NOT LIKE '%"type":"meeting"%'
|
||||
ORDER BY p.updated_at DESC LIMIT ?""",
|
||||
(uid, limit),
|
||||
).fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
is_folder = r["parent_section"] == "Workspace"
|
||||
out.append({
|
||||
"id": f"page/{r['id']}",
|
||||
"name": r["title"] or "Untitled",
|
||||
"icon": "folder" if is_folder else _file_icon(
|
||||
r["title"] or "", r["content_format"]),
|
||||
"url": f"/pages/{r['id']}",
|
||||
"active": False,
|
||||
"indent": 0,
|
||||
"depth": 0,
|
||||
"has_children": False,
|
||||
"children": [],
|
||||
})
|
||||
return out
|
||||
except Exception:
|
||||
logger.exception("_recent_sidebar_pages")
|
||||
return []
|
||||
|
||||
|
||||
def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool = True,
|
||||
gitea_owner: str = "", gitea_repo: str = "") -> dict:
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
ws = user.get("login", "Bruno") if user else "Bruno"
|
||||
initial = ws[0].upper() if ws else "B"
|
||||
|
||||
# Get avatar info from DB
|
||||
avatar_url = ""
|
||||
avatar_color = "#3A3A3A"
|
||||
if user:
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT avatar_url, avatar_color FROM users WHERE id = ?", (user["id"],)).fetchone()
|
||||
if row:
|
||||
avatar_url = row["avatar_url"] or ""
|
||||
avatar_color = row["avatar_color"] or "#3A3A3A"
|
||||
except Exception:
|
||||
logger.exception("_sidebar_data")
|
||||
|
||||
recent_pages = _recent_sidebar_pages(user["id"] if user else 0) if user else []
|
||||
for repo in repos[:10]:
|
||||
full_name = repo.get("full_name", "")
|
||||
recent_pages.append({
|
||||
"id": full_name,
|
||||
"name": repo.get("name", full_name),
|
||||
"icon": "folder",
|
||||
"url": f"/board/{full_name}",
|
||||
"active": False,
|
||||
"indent": 0,
|
||||
"depth": 0,
|
||||
"has_children": False,
|
||||
"children": [],
|
||||
})
|
||||
|
||||
# Active workspace from cookie (skip on pages like /workspaces where no
|
||||
# workspace context should be shown)
|
||||
from app.routers.board import _load_workspace_pages
|
||||
ws_cookie = request.cookies.get("flowdeck_workspace", "")
|
||||
active_ws_name = "Workspace"
|
||||
workspace_pages = []
|
||||
gitea_workspace = False
|
||||
has_active_workspace = False
|
||||
local_ws_id = 0
|
||||
|
||||
# Explicit project context (e.g. /gitea-workspace?owner=…&repo=…) wins over
|
||||
# the cookie: on a direct navigation / bookmark the cookie may be missing,
|
||||
# yet the project is unambiguously identified by the URL. Without this the
|
||||
# sidebar showed "No workspace open" even though the route just created the
|
||||
# local mirror.
|
||||
eff_owner, eff_repo = gitea_owner, gitea_repo
|
||||
if not (eff_owner and eff_repo) and (ws_cookie.startswith("gitea:") or ws_cookie.startswith("github:")):
|
||||
parts = ws_cookie.split(":", 2)
|
||||
if len(parts) >= 3:
|
||||
eff_owner, eff_repo = parts[1], parts[2]
|
||||
|
||||
if eff_owner and eff_repo:
|
||||
# Forge workspace (Gitea or GitHub): set owner/repo for client-side
|
||||
# tree loading AND open the local workspace mirror of the same name
|
||||
# in the sidebar's top Workspace section, in parallel with the
|
||||
# Repository tree (same navigation structure as workspace-local).
|
||||
gitea_owner = eff_owner
|
||||
gitea_repo = eff_repo
|
||||
active_ws_name = f"{gitea_owner}/{gitea_repo}"
|
||||
gitea_workspace = True
|
||||
has_active_workspace = True
|
||||
# Load the local mirror workspace tree so it appears in Workspace
|
||||
# alongside the Repository section (gitea_repo or github_repo marker).
|
||||
if user:
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM workspaces WHERE owner_id=? AND name=? "
|
||||
"AND (settings_json LIKE '%gitea_repo%' OR settings_json LIKE '%github_repo%')",
|
||||
(user["id"], f"{gitea_owner}/{gitea_repo}"),
|
||||
).fetchone()
|
||||
if row:
|
||||
local_ws_id = row["id"]
|
||||
workspace_pages = _load_workspace_pages(str(local_ws_id))
|
||||
except (ValueError, Exception):
|
||||
pass
|
||||
elif include_workspace and ws_cookie and user:
|
||||
try:
|
||||
wsi = int(ws_cookie)
|
||||
with get_conn() as conn:
|
||||
# Verify this workspace belongs to the current user
|
||||
row = conn.execute(
|
||||
"SELECT id, name, owner_id FROM workspaces WHERE id=? AND owner_id=?",
|
||||
(wsi, user["id"])
|
||||
).fetchone()
|
||||
if row:
|
||||
active_ws_name = row["name"]
|
||||
workspace_pages = _load_workspace_pages(ws_cookie)
|
||||
has_active_workspace = True
|
||||
# v7.46.0 : exposer l'ID de l'espace ACTIF. Avant, le sidebar
|
||||
# ne fournissait que son nom et le bouton Home de la sidebar
|
||||
# retombait sur ``local_workspaces[0]`` (premier espace TRIE
|
||||
# PAR NOM) → clic sur Home = changement d'espace surprise.
|
||||
local_ws_id = wsi
|
||||
# else: stale cookie from another user — ignore
|
||||
except (ValueError, Exception):
|
||||
pass
|
||||
|
||||
# Auth method & OAuth badge data
|
||||
auth_method = "local"
|
||||
gitea_linked = False
|
||||
github_linked = False
|
||||
if user and user.get("id"):
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
am_row = conn.execute("SELECT auth_method FROM users WHERE id=?", (user["id"],)).fetchone()
|
||||
if am_row and am_row["auth_method"]:
|
||||
auth_method = am_row["auth_method"]
|
||||
tokens = conn.execute(
|
||||
"SELECT provider FROM user_oauth_tokens WHERE user_id=?", (user["id"],)
|
||||
).fetchall()
|
||||
for t in tokens:
|
||||
if t["provider"] == "gitea":
|
||||
gitea_linked = True
|
||||
elif t["provider"] == "github":
|
||||
github_linked = True
|
||||
except Exception:
|
||||
logger.exception("_sidebar_data")
|
||||
|
||||
# Get local workspace ID for forge workspace mirror (le miroir est un
|
||||
# espace DISTINCT : on ne doit pas écraser ``local_ws_id`` (espace actif).
|
||||
gitea_mirror_ws_id = 0
|
||||
if gitea_workspace and gitea_owner and gitea_repo:
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM workspaces WHERE owner_id=? AND name=? "
|
||||
"AND (settings_json LIKE '%gitea_repo%' OR settings_json LIKE '%github_repo%')",
|
||||
(user["id"], f"{gitea_owner}/{gitea_repo}"),
|
||||
).fetchone()
|
||||
if row:
|
||||
gitea_mirror_ws_id = row["id"]
|
||||
except Exception:
|
||||
logger.exception("_sidebar_data")
|
||||
|
||||
# Private pages for mirror workspace (when Gitea remote active)
|
||||
private_pages = []
|
||||
if gitea_workspace and gitea_mirror_ws_id:
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
pp_rows = conn.execute(
|
||||
"SELECT id, title FROM pages WHERE parent_section='Private' AND workspace_id=? AND deleted_at IS NULL ORDER BY updated_at DESC LIMIT 20",
|
||||
(gitea_mirror_ws_id,)
|
||||
).fetchall()
|
||||
private_pages = [{"id": r[0], "title": r[1] or "Untitled"} for r in pp_rows]
|
||||
except Exception:
|
||||
logger.exception("_sidebar_data")
|
||||
|
||||
# Shared / received / published pages for the sidebar (Par moi / Avec moi)
|
||||
shared_made_pages = []
|
||||
shared_received_pages = []
|
||||
published_pages = []
|
||||
shared_pages = []
|
||||
if user and user.get("id"):
|
||||
from app.routers.board import _load_shared_sidebar_pages
|
||||
shared_made_pages, shared_received_pages, published_pages, shared_pages = _load_shared_sidebar_pages(user["id"])
|
||||
|
||||
sidebar = {
|
||||
"workspace_name": ws, "workspace_initial": initial,
|
||||
"active_ws_name": active_ws_name,
|
||||
"workspace_key": f"{gitea_owner}/{gitea_repo}" if gitea_workspace else "",
|
||||
"gitea_workspace": gitea_workspace,
|
||||
"gitea_owner": gitea_owner,
|
||||
"gitea_repo": gitea_repo,
|
||||
"local_ws_id": local_ws_id,
|
||||
"workspace_pages": workspace_pages,
|
||||
"current_page": "Dashboard", "last_edited": "now",
|
||||
"recent_pages": recent_pages,
|
||||
"private_pages": private_pages,
|
||||
"favorite_pages": [],
|
||||
"shared_pages": shared_pages,
|
||||
"shared_made_pages": shared_made_pages,
|
||||
"shared_received_pages": shared_received_pages,
|
||||
"published_pages": published_pages,
|
||||
"user": user,
|
||||
"avatar_url": avatar_url,
|
||||
"avatar_color": avatar_color,
|
||||
"auth_method": auth_method,
|
||||
"gitea_linked": gitea_linked,
|
||||
"github_linked": github_linked,
|
||||
"has_active_workspace": has_active_workspace,
|
||||
"app_version": _get_app_version(),
|
||||
}
|
||||
|
||||
sidebar["local_workspaces"] = _local_workspaces_for_user(user)
|
||||
|
||||
return sidebar
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ User API endpoints ═══════════
|
||||
|
||||
def _get_user_id(request: Request) -> int:
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
return user["id"] if user and user.get("id") else 1
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _require_user_id(request: Request) -> int:
|
||||
"""A3/A4 — 401 sans session (les routes qui mutent un compte ne tolèrent
|
||||
plus le fallback « legacy single-user » → id 1 = l'admin seedé)."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
return user["id"]
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _file_page_disk_path(page: dict):
|
||||
"""Resolve the on-disk file behind a ``content_format == 'file'`` page.
|
||||
|
||||
Returns ``(abs_path: Path, filename: str, mime: str, size: int)`` or None
|
||||
when the row is not a file page, references a non-textual/missing file, or
|
||||
the path escapes the data root (path-traversal guard).
|
||||
"""
|
||||
if (page.get("content_format") or "") != "file":
|
||||
return None
|
||||
import json as _json
|
||||
try:
|
||||
meta = _json.loads(page.get("content", "{}"))
|
||||
except (_json.JSONDecodeError, TypeError):
|
||||
meta = {}
|
||||
if not isinstance(meta, dict):
|
||||
return None
|
||||
rel = (meta.get("file_path") or "").replace("\\", "/").strip()
|
||||
if not rel or not rel.startswith("uploads/"):
|
||||
return None
|
||||
parts = rel.split("/")
|
||||
if ".." in parts or "." in parts:
|
||||
return None
|
||||
from pathlib import Path
|
||||
root = Path(settings.data_dir).resolve()
|
||||
full = (root / rel).resolve()
|
||||
try:
|
||||
full.relative_to(root)
|
||||
except ValueError:
|
||||
return None
|
||||
if not full.exists() or not full.is_file():
|
||||
return None
|
||||
filename = parts[-1] or page.get("title", "file")
|
||||
mime = meta.get("mime_type") or "application/octet-stream"
|
||||
size = meta.get("size") or 0
|
||||
return (full, filename, mime, size)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _require_page_view(request: Request, page_id: int) -> None:
|
||||
"""A16 : lecture d'une pièce jointe = session + `can_view_page` (404 sinon)."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
from app.services.permission_manager import PermissionManager
|
||||
|
||||
if not PermissionManager(user["id"], bool(user.get("is_admin"))).can_view_page(page_id):
|
||||
raise HTTPException(404, "Page not found")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _build_tree_children(conn, parent_id: int | None, ws_id: int, uid: int | None = None) -> list:
|
||||
"""Recursively build the tree of children for a node."""
|
||||
# v7.69.6 : notes de réunion exclues (section Meetings, comme la sidebar).
|
||||
no_meet = "AND REPLACE(COALESCE(content,''), ' ', '') NOT LIKE '%\"type\":\"meeting\"%'"
|
||||
if parent_id is None:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, parent_section, content_format, content, page_icon, "
|
||||
"COALESCE(is_published,0) AS pub1, COALESCE(published,0) AS pub2, "
|
||||
"(is_shared OR share_mode != 'private' OR COALESCE(published,0) OR COALESCE(is_published,0)) as is_shared, "
|
||||
"created_at, updated_at FROM pages "
|
||||
"WHERE parent_id IS NULL AND workspace_id=? AND deleted_at IS NULL AND collection_row_id IS NULL "
|
||||
f"{no_meet}"
|
||||
"ORDER BY created_at DESC",
|
||||
(ws_id,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, parent_section, content_format, content, page_icon, "
|
||||
"COALESCE(is_published,0) AS pub1, COALESCE(published,0) AS pub2, "
|
||||
"(is_shared OR share_mode != 'private' OR COALESCE(published,0) OR COALESCE(is_published,0)) as is_shared, "
|
||||
"created_at, updated_at FROM pages "
|
||||
f"WHERE parent_id=? AND workspace_id=? AND deleted_at IS NULL {no_meet} "
|
||||
"ORDER BY created_at DESC",
|
||||
(parent_id, ws_id),
|
||||
).fetchall()
|
||||
|
||||
# Get workspace owner name for author display
|
||||
ws_owner = conn.execute(
|
||||
"SELECT u.full_name, u.login FROM workspaces w JOIN users u ON u.id=w.owner_id WHERE w.id=?",
|
||||
(ws_id,),
|
||||
).fetchone()
|
||||
author = ws_owner["full_name"] or ws_owner["login"] if ws_owner else "—"
|
||||
|
||||
# Collect all page IDs to fetch tags in one query
|
||||
all_ids = [r["id"] for r in rows]
|
||||
tags_map = {}
|
||||
favorited_ids = set()
|
||||
if all_ids:
|
||||
placeholders = ",".join("?" for _ in all_ids)
|
||||
tag_rows = conn.execute(
|
||||
f"SELECT pt.page_id, t.id, t.name, t.color FROM page_tags pt "
|
||||
f"JOIN tags t ON t.id=pt.tag_id WHERE pt.page_id IN ({placeholders})",
|
||||
all_ids,
|
||||
).fetchall()
|
||||
for tr in tag_rows:
|
||||
tags_map.setdefault(tr["page_id"], []).append({
|
||||
"id": tr["id"], "name": tr["name"], "color": tr["color"],
|
||||
})
|
||||
if uid is not None:
|
||||
fav_rows = conn.execute(
|
||||
f"SELECT page_id FROM favorites WHERE user_id=? AND page_id IN ({placeholders})",
|
||||
[uid, *all_ids],
|
||||
).fetchall()
|
||||
favorited_ids = {fr["page_id"] for fr in fav_rows}
|
||||
|
||||
tree = []
|
||||
for r in rows:
|
||||
is_folder = r["parent_section"] == "Workspace"
|
||||
children = _build_tree_children(conn, r["id"], ws_id, uid)
|
||||
|
||||
# Compute size
|
||||
size = 0
|
||||
if r["content_format"] == "file":
|
||||
import json as _json
|
||||
try:
|
||||
meta = _json.loads(r["content"])
|
||||
size = meta.get("size", 0)
|
||||
except Exception:
|
||||
size = len(r["content"] or "")
|
||||
else:
|
||||
size = len(r["content"] or "")
|
||||
|
||||
tree.append({
|
||||
"id": r["id"],
|
||||
"name": r["title"] or "Untitled",
|
||||
"type": "folder" if is_folder else "page",
|
||||
"is_folder": is_folder,
|
||||
"content_format": r["content_format"] if not is_folder else None,
|
||||
"page_icon": r["page_icon"] or "",
|
||||
"children": children,
|
||||
"has_children": len(children) > 0,
|
||||
"child_count": len(children),
|
||||
"size": size,
|
||||
"size_display": _format_size(size),
|
||||
"created_at": r["created_at"],
|
||||
"updated_at": r["updated_at"],
|
||||
"author": author,
|
||||
"tags": tags_map.get(r["id"], []),
|
||||
"is_shared": bool(r["is_shared"]),
|
||||
"published": bool(r["pub1"] or r["pub2"]),
|
||||
"favorited": r["id"] in favorited_ids,
|
||||
})
|
||||
return tree
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _format_size(size_bytes: int) -> str:
|
||||
"""Human-readable file size."""
|
||||
if size_bytes < 1024:
|
||||
return f"{size_bytes} B"
|
||||
elif size_bytes < 1024 * 1024:
|
||||
return f"{size_bytes / 1024:.1f} KB"
|
||||
elif size_bytes < 1024 * 1024 * 1024:
|
||||
return f"{size_bytes / (1024 * 1024):.1f} MB"
|
||||
return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _build_breadcrumb(conn, folder_id: int) -> list:
|
||||
"""Build breadcrumb trail from root to folder_id."""
|
||||
breadcrumb = []
|
||||
current = folder_id
|
||||
seen = set()
|
||||
while current and current not in seen:
|
||||
seen.add(current)
|
||||
row = conn.execute(
|
||||
"SELECT id, title, parent_id, parent_section FROM pages WHERE id=?",
|
||||
(current,),
|
||||
).fetchone()
|
||||
if row:
|
||||
breadcrumb.insert(0, {
|
||||
"id": row["id"],
|
||||
"name": row["title"] or "Untitled",
|
||||
"is_folder": row["parent_section"] == "Workspace",
|
||||
})
|
||||
current = row["parent_id"]
|
||||
else:
|
||||
break
|
||||
return breadcrumb
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _nav_breadcrumb(conn, page_id: int) -> list:
|
||||
"""Build a Notion-style breadcrumb chain (root -> page) for the header.
|
||||
|
||||
Returns a list of dicts: {id, label, url, icon, menu}. The last item is the
|
||||
current page (url = None). Every item has ``menu: True`` so the header can
|
||||
open a sibling-navigation dropdown for it.
|
||||
"""
|
||||
from app.routers.board import _file_icon
|
||||
chain = []
|
||||
current = page_id
|
||||
seen = set()
|
||||
while current and current not in seen:
|
||||
seen.add(current)
|
||||
row = conn.execute(
|
||||
"SELECT id, title, parent_id, parent_section, content_format "
|
||||
"FROM pages WHERE id=? AND deleted_at IS NULL",
|
||||
(current,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
break
|
||||
is_folder = row["parent_section"] == "Workspace"
|
||||
title = row["title"] or "Untitled"
|
||||
chain.insert(0, {
|
||||
"id": row["id"],
|
||||
"label": title,
|
||||
"url": None,
|
||||
"icon": "folder" if is_folder else _file_icon(title, row["content_format"]),
|
||||
"menu": True,
|
||||
})
|
||||
current = row["parent_id"]
|
||||
# All items except the current page are navigable links.
|
||||
for i, item in enumerate(chain):
|
||||
if i < len(chain) - 1:
|
||||
item["url"] = f"/pages/{item['id']}"
|
||||
return chain
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _get_active_workspace(request: Request, user_id: int = None) -> dict | None:
|
||||
"""Get the active workspace ID from the cookie (verified for current user), or first user workspace, or None."""
|
||||
ws_id = request.cookies.get(WORKSPACE_COOKIE)
|
||||
if ws_id:
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
ws = conn.execute("SELECT * FROM workspaces WHERE id=?",
|
||||
(int(ws_id),)).fetchone()
|
||||
if ws:
|
||||
ws_dict = dict(ws)
|
||||
# Verify ownership — only return if it belongs to the current user
|
||||
if user_id is None or ws_dict.get("owner_id") == user_id:
|
||||
return ws_dict
|
||||
except (ValueError, Exception):
|
||||
pass
|
||||
# Fallback: first workspace owned by this user
|
||||
if user_id:
|
||||
with get_conn() as conn:
|
||||
ws = conn.execute(
|
||||
"SELECT * FROM workspaces WHERE owner_id=? ORDER BY id LIMIT 1",
|
||||
(user_id,)
|
||||
).fetchone()
|
||||
if ws:
|
||||
return dict(ws)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _ensure_forge_mirror(user_id: int, owner: str, repo: str, forge: str = "gitea") -> int | None:
|
||||
"""Ensure the local workspace mirror ``owner/repo`` exists, return its id.
|
||||
|
||||
Shared by /gitea-workspace and /github-workspace so selecting a repository
|
||||
always opens a workspace-local of the same name (section Workspace).
|
||||
Only auto-created mirrors carry ``settings_json`` with ``gitea_repo`` /
|
||||
``github_repo`` — never touch regular user workspaces.
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
ws_key = f"{owner}/{repo}" if owner and repo else ""
|
||||
if not ws_key or not user_id:
|
||||
return None
|
||||
marker = f"{forge}_repo"
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?",
|
||||
(user_id, ws_key, f"%{marker}%"),
|
||||
).fetchone()
|
||||
if existing:
|
||||
return existing["id"]
|
||||
cur = conn.execute(
|
||||
"INSERT INTO workspaces (name, owner_id, settings_json) VALUES (?, ?, ?)",
|
||||
(ws_key, user_id, _json.dumps({marker: ws_key, f"{forge}_owner": owner})),
|
||||
)
|
||||
mirror_id = cur.lastrowid
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, ?)",
|
||||
(mirror_id, user_id, "admin"),
|
||||
)
|
||||
conn.commit()
|
||||
return mirror_id
|
||||
|
||||
|
||||
def _is_mirror_empty(conn, ws_id: int) -> bool:
|
||||
"""True when no file/folder was ever created in this mirror workspace.
|
||||
|
||||
« Vide » = zéro page active (``deleted_at IS NULL``). Trash / soft-deleted
|
||||
rows don't count — an emptied-then-trashed mirror is still collectable.
|
||||
"""
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) AS c FROM pages WHERE workspace_id=? AND deleted_at IS NULL",
|
||||
(ws_id,),
|
||||
).fetchone()
|
||||
return (row["c"] if row else 0) == 0
|
||||
|
||||
|
||||
def _cleanup_empty_mirrors(user_id: int, keep_name: str = "", keep_id: int = 0) -> int:
|
||||
"""Delete empty auto-created forge mirrors, except the one being opened.
|
||||
|
||||
Called when switching workspace / repository so empty workspace-locals
|
||||
don't accumulate. Only workspaces whose ``settings_json`` contains
|
||||
``gitea_repo`` or ``github_repo`` are eligible — regular workspaces are
|
||||
never deleted here. Returns number of removed mirrors.
|
||||
"""
|
||||
if not user_id:
|
||||
return 0
|
||||
removed = 0
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, name FROM workspaces WHERE owner_id=? "
|
||||
"AND (settings_json LIKE '%gitea_repo%' OR settings_json LIKE '%github_repo%')",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
wid, name = r["id"], r["name"]
|
||||
if keep_id and wid == keep_id:
|
||||
continue
|
||||
if keep_name and name == keep_name:
|
||||
continue
|
||||
try:
|
||||
if not _is_mirror_empty(conn, wid):
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
from app.services.collection_lifecycle import (
|
||||
delete_collections,
|
||||
workspace_collection_ids,
|
||||
)
|
||||
try:
|
||||
collections = workspace_collection_ids(conn, wid)
|
||||
if collections:
|
||||
delete_collections(conn, collections)
|
||||
except Exception:
|
||||
logger.exception("_cleanup_empty_mirrors collections")
|
||||
conn.execute("DELETE FROM pages WHERE workspace_id=?", (wid,))
|
||||
conn.execute("DELETE FROM workspace_members WHERE workspace_id=?", (wid,))
|
||||
conn.execute("DELETE FROM workspaces WHERE id=?", (wid,))
|
||||
removed += 1
|
||||
except Exception:
|
||||
logger.exception("_cleanup_empty_mirrors")
|
||||
continue
|
||||
conn.commit()
|
||||
return removed
|
||||
|
||||
|
||||
def _sanitize_id(block_id: str) -> str:
|
||||
"""Sanitize a block id for use as an HTML anchor (only alnum kept)."""
|
||||
if not block_id:
|
||||
return ""
|
||||
return "".join(ch for ch in str(block_id) if ch.isalnum())
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _render_blocks_public(blocks: list, titles: dict | None = None) -> str:
|
||||
"""Render FlowDeck blocks as plain HTML for public pages.
|
||||
|
||||
v5.11.0: ``titles`` (token → label, see app.services.wiki_links) turns
|
||||
``[[fdpage:ID]]`` / ``[[fddate:...]]`` tokens into chips/links.
|
||||
"""
|
||||
html_parts = []
|
||||
|
||||
def _wiki(c: str) -> str:
|
||||
if titles and ("[[fdpage:" in c or "[[fddate:" in c):
|
||||
from app.services.wiki_links import resolve_tokens_html
|
||||
return resolve_tokens_html(c, titles)
|
||||
return c
|
||||
|
||||
for b in blocks:
|
||||
t = b.get("type", "paragraph")
|
||||
c = _wiki(b.get("content", "") or "")
|
||||
if t == "heading_1":
|
||||
html_parts.append(f'<h1 id="h-{_sanitize_id(b.get("id",""))}" style="font-size:2.5rem;font-weight:700;margin:32px 0 8px;">{c}</h1>')
|
||||
elif t == "heading_2":
|
||||
html_parts.append(f'<h2 id="h-{_sanitize_id(b.get("id",""))}" style="font-size:1.75rem;font-weight:600;margin:28px 0 6px;">{c}</h2>')
|
||||
elif t == "heading_3":
|
||||
html_parts.append(f'<h3 id="h-{_sanitize_id(b.get("id",""))}" style="font-size:1.35rem;font-weight:600;margin:24px 0 4px;">{c}</h3>')
|
||||
elif t == "heading_4":
|
||||
html_parts.append(f'<h4 id="h-{_sanitize_id(b.get("id",""))}" style="font-size:1.15rem;font-weight:600;margin:20px 0 4px;">{c}</h4>')
|
||||
elif t == "bulleted_list":
|
||||
html_parts.append(f'<li style="margin-left:24px;">{c}</li>')
|
||||
elif t == "numbered_list":
|
||||
html_parts.append(f'<li style="margin-left:24px;list-style:decimal;">{c}</li>')
|
||||
elif t == "to_do":
|
||||
checked = "checked" if b.get("checked") else ""
|
||||
todo_style = "text-decoration:line-through;opacity:.5" if b.get("checked") else ""
|
||||
html_parts.append(
|
||||
f'<div style="display:flex;align-items:center;gap:8px;margin:4px 0;">'
|
||||
f'<input type="checkbox" {checked} disabled>'
|
||||
f'<span style="{todo_style}">{c}</span>'
|
||||
f'</div>'
|
||||
)
|
||||
elif t == "toggle":
|
||||
children_html = ""
|
||||
if b.get("children"):
|
||||
children_html = '<div style="margin-left:22px;padding-left:12px;border-left:1px solid rgba(255,255,255,.1);margin-top:4px;">'
|
||||
children_html += _render_blocks_public(b["children"], titles)
|
||||
children_html += "</div>"
|
||||
html_parts.append(
|
||||
f'<details style="margin:8px 0;" open><summary style="cursor:pointer;font-weight:500;">{c}</summary>{children_html}</details>'
|
||||
)
|
||||
elif t == "quote":
|
||||
html_parts.append(
|
||||
f'<blockquote style="border-left:3px solid var(--accent,#4c9aff);margin:12px 0;padding:4px 16px;opacity:.85;">{c}</blockquote>'
|
||||
)
|
||||
elif t == "table_of_contents":
|
||||
toc = [
|
||||
x for x in blocks
|
||||
if x.get("type", "").startswith("heading_") and (x.get("content") or "").strip()
|
||||
]
|
||||
if toc:
|
||||
items = []
|
||||
for h in toc:
|
||||
lvl = int(h["type"].split("_")[-1])
|
||||
items.append(
|
||||
f'<div style="margin-left:{max(0, lvl - 1) * 14}px;padding:5px 8px;font-size:14px;">'
|
||||
f'<a href="#h-{_sanitize_id(h.get("id",""))}" style="color:inherit;text-decoration:none;display:block;">{h.get("content","")}</a></div>'
|
||||
)
|
||||
html_parts.append(
|
||||
'<div style="border:1px solid rgba(255,255,255,.1);border-radius:8px;padding:16px 20px;margin:4px 0;">'
|
||||
'<div style="font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;opacity:.5;margin-bottom:10px;">On this page</div>'
|
||||
+ "".join(items) + "</div>"
|
||||
)
|
||||
elif t == "math":
|
||||
tex = c.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
html_parts.append(
|
||||
f'<div data-katex="{tex}" style="margin:12px 0;padding:12px 16px;background:rgba(255,255,255,.04);border-radius:8px;overflow-x:auto;"></div>'
|
||||
)
|
||||
elif t == "columns":
|
||||
cols_html = ""
|
||||
for child in b.get("children") or []:
|
||||
cols_html += (
|
||||
'<div style="flex:1;min-width:0;padding:10px 12px;background:rgba(255,255,255,.05);'
|
||||
'border-radius:8px;box-sizing:border-box;">'
|
||||
+ _render_blocks_public([child], titles) + "</div>"
|
||||
)
|
||||
html_parts.append(
|
||||
f'<div style="display:flex;gap:12px;margin:8px 0 16px;align-items:stretch;">{cols_html}</div>'
|
||||
)
|
||||
elif t == "callout":
|
||||
icon = b.get("icon", "💡")
|
||||
bg = (b.get("style") or {}).get("bgColor", "rgba(76,154,255,.1)")
|
||||
html_parts.append(
|
||||
f'<div style="display:flex;gap:10px;padding:14px 18px;margin:12px 0;border-radius:8px;'
|
||||
f'background:{bg};align-items:flex-start;">'
|
||||
f'<span style="font-size:20px;flex-shrink:0;">{icon}</span>'
|
||||
f'<span>{c}</span></div>'
|
||||
)
|
||||
elif t == "code":
|
||||
lang = b.get("language", "")
|
||||
try:
|
||||
from app.services.export import normalize_code_lang
|
||||
hljs_lang = normalize_code_lang(lang)
|
||||
except Exception: # noqa: BLE001 — never break public rendering
|
||||
hljs_lang = "plaintext"
|
||||
lang_label = f"<div style='font-size:11px;opacity:.4;margin-bottom:8px;'>{lang}</div>" if lang else ""
|
||||
html_parts.append(
|
||||
f'<pre style="background:rgba(255,255,255,.05);padding:16px 20px;border-radius:8px;'
|
||||
f'overflow-x:auto;font-size:14px;line-height:1.5;margin:12px 0;">'
|
||||
f'{lang_label}'
|
||||
f'<code class="hljs language-{hljs_lang}" data-hljs-lang="{hljs_lang}">{c}</code></pre>'
|
||||
)
|
||||
elif t == "divider":
|
||||
html_parts.append('<hr style="border:none;border-top:1px solid rgba(255,255,255,.1);margin:16px 0;">')
|
||||
elif t == "image":
|
||||
src = b.get("src", "")
|
||||
alt = b.get("alt", "")
|
||||
html_parts.append(
|
||||
f'<figure style="margin:16px 0;text-align:center;">'
|
||||
f'<img src="{src}" alt="{alt}" data-full="{src}" style="max-width:100%;border-radius:8px;cursor:zoom-in;">'
|
||||
f'</figure>'
|
||||
)
|
||||
elif t == "video":
|
||||
src = b.get("src", "")
|
||||
if src:
|
||||
html_parts.append(
|
||||
f'<video controls preload="metadata" style="max-width:100%;border-radius:8px;display:block;margin:12px auto;">'
|
||||
f'<source src="{src}"></video>'
|
||||
)
|
||||
elif t == "audio":
|
||||
src = b.get("src", "")
|
||||
if src:
|
||||
html_parts.append(
|
||||
f'<audio controls preload="metadata" style="width:100%;margin:8px 0;"><source src="{src}"></audio>'
|
||||
)
|
||||
elif t == "bookmark":
|
||||
url = b.get("url") or b.get("src") or ""
|
||||
title = b.get("title") or url
|
||||
desc = b.get("description") or ""
|
||||
img = b.get("image") or ""
|
||||
site = b.get("site_name") or ""
|
||||
img_html = (
|
||||
f'<img src="{img}" alt="" style="width:120px;height:90px;object-fit:cover;border-radius:8px;flex-shrink:0;">' if img else ""
|
||||
)
|
||||
desc_html = f'<div style="font-size:13px;opacity:.75;margin-top:4px;">{desc}</div>' if desc else ""
|
||||
site_html = f'<div style="font-size:11px;opacity:.5;text-transform:uppercase;letter-spacing:.5px;margin-top:6px;">{site}</div>' if site else ""
|
||||
html_parts.append(
|
||||
f'<a href="{url}" target="_blank" rel="noopener noreferrer" style="text-decoration:none;color:inherit;">'
|
||||
f'<div style="display:flex;gap:14px;align-items:center;border:1px solid rgba(255,255,255,.12);border-radius:10px;'
|
||||
f'padding:14px 16px;margin:14px 0;background:rgba(255,255,255,.03);">'
|
||||
f'<div style="flex:1;min-width:0;"><div style="font-weight:600;font-size:15px;">{title}</div>'
|
||||
f'{desc_html}{site_html}</div>{img_html}</div></a>'
|
||||
)
|
||||
elif t == "embed":
|
||||
url = b.get("src", "")
|
||||
emb = b.get("embed_type") or ""
|
||||
if emb in ("inline_dbs", "collection"):
|
||||
html_parts.append('<div>[Embedded content]</div>')
|
||||
elif emb == "download":
|
||||
html_parts.append(
|
||||
f'<a href="{url}" download style="display:inline-block;margin:12px 0;color:var(--accent,#4c9aff);">⬇ {b.get("file_name") or "Download"}</a>'
|
||||
)
|
||||
elif emb == "pdf" and url:
|
||||
html_parts.append(
|
||||
f'<iframe src="{url}" style="width:100%;height:70vh;border:none;border-radius:8px;margin:12px 0;"></iframe>'
|
||||
)
|
||||
elif url:
|
||||
from app.services.embeds import embed_src
|
||||
src = b.get("embed_src") or embed_src(url) or url
|
||||
height = b.get("height") or 520
|
||||
try:
|
||||
height = int(height)
|
||||
except (ValueError, TypeError):
|
||||
height = 520
|
||||
html_parts.append(
|
||||
f'<div style="position:relative;width:100%;height:{height}px;border-radius:8px;overflow:hidden;'
|
||||
f'background:#0a0a0a;"><iframe src="{src}" loading="lazy" frameborder="0" '
|
||||
f'style="position:absolute;inset:0;width:100%;height:100%;" allowfullscreen allow="autoplay; encrypted-media; picture-in-picture"></iframe></div>'
|
||||
)
|
||||
elif t == "synced":
|
||||
# v6.5.0: render synced block instances (resolved server-side).
|
||||
if b.get("_synced_deleted"):
|
||||
html_parts.append(
|
||||
'<div style="margin:8px 0;padding:8px 12px;border-left:3px solid #e05e5e;'
|
||||
'background:rgba(224,94,94,.08);border-radius:4px;font-size:13px;opacity:.8;">'
|
||||
'Deleted synced block</div>'
|
||||
)
|
||||
else:
|
||||
inner = b.get("_synced_content")
|
||||
if not isinstance(inner, list) or not inner:
|
||||
try:
|
||||
import json as _sj
|
||||
parsed = _sj.loads(b.get("content") or "[]")
|
||||
inner = parsed if isinstance(parsed, list) else []
|
||||
except (ValueError, TypeError):
|
||||
inner = []
|
||||
inner = [{"type": "paragraph", "content": str(x)} if not isinstance(x, dict) else x
|
||||
for x in inner]
|
||||
if inner:
|
||||
html_parts.append(
|
||||
'<div style="margin:8px 0;padding-left:12px;'
|
||||
'border-left:3px solid var(--accent,#4c9aff);">'
|
||||
+ _render_blocks_public(inner, titles) + '</div>'
|
||||
)
|
||||
else:
|
||||
html_parts.append(f'<p style="margin:4px 0;line-height:1.7;">{c}</p>')
|
||||
return "\n".join(html_parts)
|
||||
|
||||
|
||||
# ═══════════ Library page actions API ═══════════
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""FlowDeck — Dashboard : account_api.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/dashboard.py (2 735 lignes, 63 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
from ._common import _require_user_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["dashboard"])
|
||||
|
||||
|
||||
|
||||
|
||||
@router.put("/api/user/profile")
|
||||
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:
|
||||
conn.execute("UPDATE users SET full_name=? WHERE id=?", (full_name, uid))
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.put("/api/user/password")
|
||||
def update_password(request: Request, body: dict = Body(default={})):
|
||||
from app.password_utils import hash_password, verify_password
|
||||
password = body.get("password", "").strip()
|
||||
if len(password) < 6:
|
||||
return {"error": "Password must be at least 6 characters"}
|
||||
uid = _require_user_id(request)
|
||||
# A3 : le mot de passe actuel est exigé (session volée ≠ droit de changer le mdp).
|
||||
current = body.get("current_password", "")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT password_hash FROM users WHERE id=?", (uid,)).fetchone()
|
||||
if not row or not verify_password(current, row["password_hash"]):
|
||||
raise HTTPException(403, "Current password is incorrect")
|
||||
conn.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(password), uid))
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/user/token")
|
||||
def generate_token(request: Request):
|
||||
import secrets
|
||||
uid = _require_user_id(request)
|
||||
token = secrets.token_hex(32)
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO user_tokens (gitea_user_id, gitea_token, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)",
|
||||
(uid, token),
|
||||
)
|
||||
conn.commit()
|
||||
return {"token": f"fd_{token}"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/api/user/forge/{provider}")
|
||||
def disconnect_forge(request: Request, provider: str):
|
||||
uid = _require_user_id(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM user_oauth_tokens WHERE user_id=? AND provider=?", (uid, provider)
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,463 @@
|
||||
"""FlowDeck — Dashboard : settings.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/dashboard.py (2 735 lignes, 63 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
|
||||
from ._common import _format_size, _get_active_workspace, _get_user_id, _sidebar_data
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["dashboard"])
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ Settings Page ═══════════
|
||||
|
||||
@router.get("/settings", response_class=HTMLResponse)
|
||||
def app_settings_page(request: Request):
|
||||
"""Settings & configuration page."""
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return RedirectResponse("/auth/login?provider=local", status_code=302)
|
||||
ctx = {**sidebar, "user": user}
|
||||
# Pass active workspace for breadcrumb nav menu
|
||||
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
||||
ctx["nav_workspace_id"] = ws["id"] if ws else 0
|
||||
template = env.get_template("settings.html")
|
||||
return template.render(**ctx)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/settings/avatar")
|
||||
async def upload_avatar(request: Request):
|
||||
"""Upload a user avatar image."""
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
form = await request.form()
|
||||
file = form.get("file")
|
||||
if not file:
|
||||
return {"error": "No file"}, 400
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return {"error": "Not authenticated"}, 401
|
||||
# Save to data/avatars
|
||||
avatars_dir = Path("/data/avatars")
|
||||
avatars_dir.mkdir(parents=True, exist_ok=True)
|
||||
ext = os.path.splitext(file.filename)[1] or ".png"
|
||||
filename = f"{user['id']}_{uuid.uuid4().hex[:8]}{ext}"
|
||||
filepath = avatars_dir / filename
|
||||
content = await file.read()
|
||||
filepath.write_bytes(content)
|
||||
# Update user avatar_url
|
||||
avatar_url = f"/api/settings/avatar/{filename}"
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE users SET avatar_url = ? WHERE id = ?", (avatar_url, user["id"]))
|
||||
conn.commit()
|
||||
return {"avatar_url": avatar_url}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/settings/avatar/{filename:path}")
|
||||
def serve_avatar_file(filename: str):
|
||||
"""Serve an uploaded avatar image file."""
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.responses import FileResponse
|
||||
# A11 : garde path traversal (motif de serve_uploaded_file) — `:path` Starlette
|
||||
# accepte les `/`, donc `..%2f` ressortirait du dossier avatars.
|
||||
base_dir = Path("/data/avatars").resolve()
|
||||
filepath = (base_dir / filename).resolve()
|
||||
try:
|
||||
filepath.relative_to(base_dir)
|
||||
except ValueError:
|
||||
return JSONResponse({"error": "Path traversal denied"}, status_code=403)
|
||||
if not filepath.is_file():
|
||||
return JSONResponse({"error": "Not found"}, status_code=404)
|
||||
return FileResponse(filepath)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/avatar/{user_id:int}")
|
||||
def get_avatar(user_id: int):
|
||||
"""Redirect to the user's avatar."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT avatar_url FROM users WHERE id = ?", (user_id,)).fetchone()
|
||||
if row and row["avatar_url"]:
|
||||
return RedirectResponse(row["avatar_url"], status_code=302)
|
||||
return JSONResponse({"error": "No avatar"}, status_code=404)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/settings/avatar-color")
|
||||
def set_avatar_color(request: Request, body: dict = Body(default={})):
|
||||
"""Set the user's avatar background color."""
|
||||
color = body.get("color", "#3A3A3A")
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return {"error": "Not authenticated"}, 401
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE users SET avatar_url = '', avatar_color = ? WHERE id = ?", (color, user["id"]))
|
||||
conn.commit()
|
||||
return {"status": "ok", "color": color}
|
||||
|
||||
|
||||
# ═══════════ Tag Management API (per-user) ═══════════
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ Tag Management API (per-user) ═══════════
|
||||
|
||||
@router.post("/api/settings/tags")
|
||||
def create_tag_global(request: Request, body: dict = Body(default={})):
|
||||
"""Create a tag for the current user."""
|
||||
tag_name = body.get("name", "").strip().lower()
|
||||
color = body.get("color", "#787774")
|
||||
uid = _get_user_id(request)
|
||||
if not tag_name or not uid:
|
||||
return {"error": "Tag name required"}, 400
|
||||
with get_conn() as conn:
|
||||
tag = conn.execute("SELECT id FROM tags WHERE name = ? AND user_id = ?", (tag_name, uid)).fetchone()
|
||||
if tag:
|
||||
conn.execute("UPDATE tags SET color = ? WHERE id = ?", (color, tag["id"]))
|
||||
conn.commit()
|
||||
return {"tag": {"id": tag["id"], "name": tag_name, "color": color}}
|
||||
cursor = conn.execute("INSERT INTO tags (name, color, user_id) VALUES (?, ?, ?)", (tag_name, color, uid))
|
||||
conn.commit()
|
||||
return {"tag": {"id": cursor.lastrowid, "name": tag_name, "color": color}}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.put("/api/settings/tags/{tag_id:int}")
|
||||
def update_tag_global(tag_id: int, request: Request, body: dict = Body(default={})):
|
||||
"""Update a tag (name or color) — only if owned by user."""
|
||||
uid = _get_user_id(request)
|
||||
with get_conn() as conn:
|
||||
if "name" in body:
|
||||
conn.execute("UPDATE tags SET name = ? WHERE id = ? AND user_id = ?", (body["name"].strip().lower(), tag_id, uid))
|
||||
if "color" in body:
|
||||
conn.execute("UPDATE tags SET color = ? WHERE id = ? AND user_id = ?", (body["color"], tag_id, uid))
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/api/settings/tags/{tag_id:int}")
|
||||
def delete_tag_global(tag_id: int, request: Request):
|
||||
"""Delete a tag — only if owned by user."""
|
||||
uid = _get_user_id(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM page_tags WHERE tag_id = ?", (tag_id,))
|
||||
conn.execute("DELETE FROM tags WHERE id = ? AND user_id = ?", (tag_id, uid))
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/settings/tags/all")
|
||||
def list_all_tags_global(request: Request):
|
||||
"""List current user's tags with counts."""
|
||||
uid = _get_user_id(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT t.id, t.name, t.color, COUNT(pt.page_id) as count "
|
||||
"FROM tags t LEFT JOIN page_tags pt ON pt.tag_id = t.id "
|
||||
"WHERE t.user_id = ? GROUP BY t.id ORDER BY t.name",
|
||||
(uid,),
|
||||
).fetchall()
|
||||
return JSONResponse(
|
||||
{"tags": [dict(r) for r in rows]},
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
# ═══════════ Workspace Tags API ═══════════
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ Workspace Tags API ═══════════
|
||||
|
||||
@router.get("/api/local-workspace/tags")
|
||||
def list_tags(request: Request):
|
||||
"""List ALL user tags with counts scoped to the active workspace."""
|
||||
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
||||
ws_id = ws["id"] if ws else None
|
||||
uid = _get_user_id(request)
|
||||
if not ws_id:
|
||||
return {"tags": []}
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT t.id, t.name, t.color, "
|
||||
"(SELECT COUNT(*) FROM page_tags pt "
|
||||
" JOIN pages p ON p.id = pt.page_id AND p.workspace_id = ? "
|
||||
" WHERE pt.tag_id = t.id) as count "
|
||||
"FROM tags t WHERE t.user_id = ? ORDER BY t.name",
|
||||
(ws_id, uid),
|
||||
).fetchall()
|
||||
return JSONResponse(
|
||||
{"tags": [dict(r) for r in rows]},
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/local-workspace/items/{item_id:int}/tags")
|
||||
def get_item_tags(item_id: int):
|
||||
"""Get tags for a specific item."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT t.id, t.name, t.color FROM tags t "
|
||||
"JOIN page_tags pt ON pt.tag_id = t.id "
|
||||
"WHERE pt.page_id = ? ORDER BY t.name",
|
||||
(item_id,),
|
||||
).fetchall()
|
||||
return {"tags": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/local-workspace/items/{item_id:int}/tags")
|
||||
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)."""
|
||||
tag_name = body.get("name", "").strip().lower()
|
||||
tag_color = body.get("color", "#787774")
|
||||
uid = _get_user_id(request)
|
||||
if not tag_name:
|
||||
return {"error": "Tag name required"}, 400
|
||||
|
||||
with get_conn() as conn:
|
||||
# Get or create tag (per user)
|
||||
tag = conn.execute("SELECT id, name, color FROM tags WHERE name = ? AND user_id = ?", (tag_name, uid)).fetchone()
|
||||
if not tag:
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO tags (name, color, user_id) VALUES (?, ?, ?)", (tag_name, tag_color, uid)
|
||||
)
|
||||
conn.commit()
|
||||
tag_id = cursor.lastrowid
|
||||
tag = {"id": tag_id, "name": tag_name, "color": tag_color}
|
||||
else:
|
||||
tag_id = tag["id"]
|
||||
|
||||
# Link tag to page (ignore duplicate)
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO page_tags (page_id, tag_id) VALUES (?, ?)",
|
||||
(item_id, tag_id),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
logger.exception("add_item_tag")
|
||||
|
||||
return {"tag": {"id": tag["id"], "name": tag["name"], "color": tag["color"]}}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/api/local-workspace/items/{item_id:int}/tags/{tag_id:int}")
|
||||
def remove_item_tag(item_id: int, tag_id: int):
|
||||
"""Remove a tag from an item."""
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM page_tags WHERE page_id = ? AND tag_id = ?",
|
||||
(item_id, tag_id),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/local-workspace/tags/search")
|
||||
def search_by_tags(request: Request, tags: str = ""):
|
||||
"""Search items by tags (comma-separated)."""
|
||||
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
||||
ws_id = ws["id"] if ws else None
|
||||
if not ws_id:
|
||||
return {"items": []}
|
||||
tag_names = [t.strip().lower() for t in tags.split(",") if t.strip()]
|
||||
if not tag_names:
|
||||
return {"items": []}
|
||||
with get_conn() as conn:
|
||||
placeholders = ",".join("?" for _ in tag_names)
|
||||
rows = conn.execute(
|
||||
f"SELECT DISTINCT p.id, p.title, p.content_format, p.parent_section, "
|
||||
f"p.content, p.created_at, p.updated_at "
|
||||
f"FROM pages p "
|
||||
f"JOIN page_tags pt ON pt.page_id = p.id "
|
||||
f"JOIN tags t ON t.id = pt.tag_id "
|
||||
f"WHERE t.name IN ({placeholders}) AND p.workspace_id = ? AND p.deleted_at IS NULL "
|
||||
f"ORDER BY p.updated_at DESC",
|
||||
tag_names + [ws_id],
|
||||
).fetchall()
|
||||
items = []
|
||||
for r in rows:
|
||||
is_folder = r["parent_section"] == "Workspace"
|
||||
size = len(r["content"] or "")
|
||||
items.append({
|
||||
"id": r["id"],
|
||||
"name": r["title"] or "Untitled",
|
||||
"is_folder": is_folder,
|
||||
"content_format": r["content_format"],
|
||||
"created_at": r["created_at"],
|
||||
"updated_at": r["updated_at"],
|
||||
"size": size,
|
||||
"size_display": _format_size(size),
|
||||
})
|
||||
return {"items": items}
|
||||
|
||||
|
||||
# ── Account update ──
|
||||
|
||||
|
||||
|
||||
|
||||
# ── Account update ──
|
||||
@router.put("/api/settings/account")
|
||||
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)
|
||||
with get_conn() as conn:
|
||||
uid = user["id"]
|
||||
if "full_name" in body:
|
||||
conn.execute("UPDATE users SET full_name=? WHERE id=?", (body["full_name"].strip(), uid))
|
||||
if "login" in body:
|
||||
new_login = body["login"].strip()
|
||||
if new_login and new_login != user.get("login"):
|
||||
existing = conn.execute("SELECT id FROM users WHERE login=? AND id!=?", (new_login, uid)).fetchone()
|
||||
if existing:
|
||||
return JSONResponse({"error": "Username already taken"}, status_code=409)
|
||||
conn.execute("UPDATE users SET login=? WHERE id=?", (new_login, uid))
|
||||
if "email" in body:
|
||||
conn.execute("UPDATE users SET email=? WHERE id=?", (body["email"].strip(), uid))
|
||||
if "password" in body and body["password"].strip():
|
||||
pw = body["password"].strip()
|
||||
if len(pw) < 6:
|
||||
return JSONResponse({"error": "Password must be at least 6 characters"}, status_code=400)
|
||||
conn.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(pw), uid))
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM users WHERE id=?", (uid,)).fetchone()
|
||||
user_data = dict(row)
|
||||
# Refresh session cookie with updated data (keeps the same session id)
|
||||
cookie = request.cookies.get("flowdeck_session", "")
|
||||
new_session = SessionManager.refresh_session(cookie, user_data, request)
|
||||
response = JSONResponse({"status": "ok", "user": {k: user_data[k] for k in ("id","login","full_name","email","is_admin")}})
|
||||
response.set_cookie("flowdeck_session", new_session, httponly=True, max_age=86400 * 7, samesite="lax", path="/")
|
||||
return response
|
||||
|
||||
|
||||
# ═══════════ Sidebar Refresh API ═══════════
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ Sidebar Refresh API ═══════════
|
||||
|
||||
@router.get("/api/sidebar/workspace-tree")
|
||||
def sidebar_workspace_tree(request: Request):
|
||||
"""Return the sidebar workspace tree as HTML fragment.
|
||||
|
||||
Called by appState().refreshSidebarTree() after CRUD operations
|
||||
in the main content area to keep the sidebar in sync.
|
||||
"""
|
||||
from app.routers.board import _load_workspace_pages
|
||||
from app.templating import ENV
|
||||
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return HTMLResponse("")
|
||||
|
||||
ws_cookie = request.cookies.get("flowdeck_workspace", "")
|
||||
if not ws_cookie:
|
||||
return HTMLResponse('<li class="sidebar-item empty-hint"><span class="page-icon">📄</span><span class="page-name text-dim">No pages yet</span></li>')
|
||||
|
||||
# Forge workspace (Gitea/GitHub) — the sidebar Workspace section shows the
|
||||
# LOCAL mirror of the repository (the "parallel local workspace"), not a
|
||||
# remote placeholder. Older behaviour returned "Remote workspace", which
|
||||
# wiped the mirror tree on every CRUD refresh.
|
||||
if ws_cookie.startswith("gitea:") or ws_cookie.startswith("github:"):
|
||||
parts = ws_cookie.split(":", 2)
|
||||
if len(parts) >= 3:
|
||||
mirror = None
|
||||
with get_conn() as conn:
|
||||
mirror = conn.execute(
|
||||
"SELECT id FROM workspaces WHERE owner_id=? AND name=? "
|
||||
"AND (settings_json LIKE '%gitea_repo%' OR settings_json LIKE '%github_repo%')",
|
||||
(user["id"], f"{parts[1]}/{parts[2]}"),
|
||||
).fetchone()
|
||||
if mirror:
|
||||
pages = _load_workspace_pages(str(mirror["id"]))
|
||||
if pages:
|
||||
env = ENV
|
||||
template = env.from_string(
|
||||
"{% from '_workspace_tree_macro.html' import render_workspace_tree %}"
|
||||
"{{ render_workspace_tree(pages) }}"
|
||||
)
|
||||
return HTMLResponse(template.render(pages=pages))
|
||||
return HTMLResponse(
|
||||
'<li class="sidebar-item empty-hint"><span class="page-icon">📄</span>'
|
||||
'<span class="page-name text-dim">No pages yet</span></li>'
|
||||
)
|
||||
|
||||
try:
|
||||
ws_id = int(ws_cookie)
|
||||
with get_conn() as conn:
|
||||
# Verify workspace belongs to user
|
||||
row = conn.execute(
|
||||
"SELECT id FROM workspaces WHERE id=? AND owner_id=?",
|
||||
(ws_id, user["id"])
|
||||
).fetchone()
|
||||
if not row:
|
||||
return HTMLResponse(
|
||||
'<li class="sidebar-item empty-hint"><span class="page-icon">📄</span>'
|
||||
'<span class="page-name text-dim">No pages yet</span></li>'
|
||||
)
|
||||
|
||||
pages = _load_workspace_pages(ws_cookie)
|
||||
if not pages:
|
||||
return HTMLResponse(
|
||||
'<li class="sidebar-item empty-hint"><span class="page-icon">📄</span>'
|
||||
'<span class="page-name text-dim">No pages yet</span></li>'
|
||||
)
|
||||
|
||||
# Render the tree using the extracted macro
|
||||
env = ENV
|
||||
template = env.from_string(
|
||||
"{% from '_workspace_tree_macro.html' import render_workspace_tree %}"
|
||||
"{{ render_workspace_tree(pages) }}"
|
||||
)
|
||||
html = template.render(pages=pages)
|
||||
return HTMLResponse(html)
|
||||
except (ValueError, Exception) as e:
|
||||
logger.error(f"sidebar_workspace_tree failed: {e}", exc_info=True)
|
||||
return HTMLResponse(
|
||||
'<li class="sidebar-item empty-hint"><span class="page-icon">📄</span>'
|
||||
'<span class="page-name text-dim">No pages yet</span></li>'
|
||||
)
|
||||
|
||||
|
||||
# ═══════════ Public Published Page ═══════════
|
||||
@@ -0,0 +1,636 @@
|
||||
"""FlowDeck — Dashboard : local_workspace.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/dashboard.py (2 735 lignes, 63 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC
|
||||
|
||||
from fastapi import APIRouter, Body, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
|
||||
from ._common import (
|
||||
WORKSPACE_COOKIE,
|
||||
_build_breadcrumb,
|
||||
_build_tree_children,
|
||||
_cleanup_empty_mirrors,
|
||||
_file_page_disk_path,
|
||||
_get_active_workspace,
|
||||
_get_user_id,
|
||||
_require_page_view,
|
||||
_require_user_id,
|
||||
_sidebar_data,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["dashboard"])
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ Local Workspace (file/folder CRUD) ═══════════
|
||||
|
||||
@router.get("/local-workspace", response_class=HTMLResponse)
|
||||
def local_workspace_page(request: Request, folder: int = None, ws: int = None):
|
||||
"""Local workspace page with file/folder tree.
|
||||
|
||||
If ?folder=ID is provided, shows that folder's contents with breadcrumb.
|
||||
If ?ws=ID is provided, shows that workspace — c'est ce que fait le bouton
|
||||
« Home » de la sidebar (``/local-workspace?ws=<id>``). Le paramètre était
|
||||
produit par le sidebar mais **ignoré** par la route : le contenu affiché
|
||||
restait celui du workspace *actif* (cookie), donc Home pouvait montrer un
|
||||
autre workspace que celui annoncé dans l'URL. Un ``ws`` qui n'appartient
|
||||
pas à l'utilisateur est ignoré (et le workspace actif conservé).
|
||||
"""
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return RedirectResponse("/auth/login?provider=local", status_code=302)
|
||||
|
||||
ws_row = _get_active_workspace(request, user_id=user["id"])
|
||||
if ws is not None:
|
||||
# Workspace demandé par l'URL : on ne l'accepte que s'il appartient à
|
||||
# l'utilisateur (même règle de propriété que _get_active_workspace).
|
||||
with get_conn() as conn:
|
||||
requested = conn.execute(
|
||||
"SELECT id, name FROM workspaces WHERE id=? AND owner_id=?",
|
||||
(ws, user["id"]),
|
||||
).fetchone()
|
||||
if requested is not None:
|
||||
ws_row = dict(requested)
|
||||
# Switching workspace: drop other EMPTY forge mirrors (no file or
|
||||
# folder ever created there) instead of keeping empty locals.
|
||||
try:
|
||||
_cleanup_empty_mirrors(user["id"], keep_id=ws_row["id"])
|
||||
except Exception:
|
||||
pass
|
||||
# L'URL fait foi : on aligne le cookie pour que le reste de
|
||||
# l'application (sidebar, API tree, breadcrumbs) suive le même
|
||||
# workspace que celui affiché.
|
||||
response = _render_local_workspace(env, sidebar, user, ws_row, folder)
|
||||
response.set_cookie(WORKSPACE_COOKIE, str(ws_row["id"]),
|
||||
max_age=86400 * 30, httponly=True, path="/")
|
||||
return response
|
||||
|
||||
ws_id = ws_row["id"] if ws_row else None
|
||||
|
||||
# If no workspace exists for this user, redirect to workspaces page
|
||||
if not ws_id:
|
||||
return RedirectResponse("/workspaces", status_code=302)
|
||||
|
||||
return _render_local_workspace(env, sidebar, user, ws_row, folder)
|
||||
|
||||
|
||||
def _render_local_workspace(env, sidebar: dict, user: dict, ws_row: dict,
|
||||
folder: int | None) -> HTMLResponse:
|
||||
"""Render the local-workspace page for ``ws_row`` (shared by both paths).
|
||||
|
||||
``sidebar`` a été calculé **avant** le éventuel changement de workspace
|
||||
(``?ws=``) : on force donc le nom affiché et la sidebar sur ``ws_row``,
|
||||
sinon la page afficherait le contenu d'un workspace sous le titre d'un
|
||||
autre.
|
||||
"""
|
||||
ws_id = ws_row["id"]
|
||||
ws_name = (ws_row.get("name") or sidebar.get("active_ws_name") or "My Workspace")
|
||||
|
||||
# Build breadcrumb if navigating into a folder
|
||||
breadcrumb = []
|
||||
current_folder_id = folder
|
||||
if folder and ws_id:
|
||||
with get_conn() as conn:
|
||||
breadcrumb = _build_breadcrumb(conn, folder)
|
||||
|
||||
# En-tête Notion-style (même mécanique que les pages) :
|
||||
# Home / <workspace> / <dossier> / <sous-dossier> / …
|
||||
# Chaque niveau cliquable renvoie à /local-workspace?folder=<id> — sans
|
||||
# ça le header restait bloqué sur « Home / <workspace> » même quand le
|
||||
# contenu affiché était celui d'un sous-dossier.
|
||||
nav_crumbs: list = [{
|
||||
"id": None,
|
||||
"label": ws_name,
|
||||
"url": "/local-workspace",
|
||||
"icon": "folder",
|
||||
"menu": False,
|
||||
}]
|
||||
for i, bcrumb in enumerate(breadcrumb):
|
||||
is_last = i == len(breadcrumb) - 1
|
||||
nav_crumbs.append({
|
||||
"id": bcrumb["id"],
|
||||
"label": bcrumb["name"],
|
||||
"url": None if is_last else f"/local-workspace?folder={bcrumb['id']}",
|
||||
"icon": "folder",
|
||||
"menu": True,
|
||||
})
|
||||
|
||||
ctx = {
|
||||
**sidebar,
|
||||
"user": user,
|
||||
"workspace_name": ws_name,
|
||||
"active_ws_name": ws_name,
|
||||
"active_workspace_id": ws_id,
|
||||
"current_folder_id": current_folder_id or 0,
|
||||
"workspace_id": ws_id or 0,
|
||||
"nav_workspace_id": ws_id or 0,
|
||||
"breadcrumb": breadcrumb,
|
||||
"breadcrumbs": breadcrumb,
|
||||
"breadcrumb_items": nav_crumbs,
|
||||
}
|
||||
template = env.get_template("local_workspace.html")
|
||||
return HTMLResponse(
|
||||
content=template.render(**ctx),
|
||||
headers={
|
||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||
"Pragma": "no-cache",
|
||||
"Expires": "0",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/local-workspace/tree")
|
||||
def local_workspace_tree(request: Request, folder: int = None):
|
||||
"""Return the file/folder tree filtered by active workspace.
|
||||
|
||||
If ?folder=ID is provided, returns only that folder's children.
|
||||
Otherwise returns the full recursive tree from root.
|
||||
"""
|
||||
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
||||
ws_id = ws["id"] if ws else None
|
||||
if not ws_id:
|
||||
return {"tree": [], "breadcrumb": []}
|
||||
uid = _get_user_id(request)
|
||||
|
||||
with get_conn() as conn:
|
||||
if folder:
|
||||
# Show only this folder's children + build breadcrumb
|
||||
children = _build_tree_children(conn, folder, ws_id, uid)
|
||||
breadcrumb = _build_breadcrumb(conn, folder)
|
||||
return {"tree": children, "breadcrumb": breadcrumb, "current_folder": folder}
|
||||
else:
|
||||
# Full tree from root
|
||||
roots = _build_tree_children(conn, None, ws_id, uid)
|
||||
return {"tree": roots, "breadcrumb": [], "current_folder": None}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/local-workspace/page-content/{page_id:int}")
|
||||
def get_page_content(page_id: int):
|
||||
"""Return the raw content of a page (for preview)."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT content, content_format FROM pages WHERE id=?", (page_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return JSONResponse({"error": "Not found"}, status_code=404)
|
||||
fmt = row["content_format"]
|
||||
if fmt == "file":
|
||||
return JSONResponse({"content": "(uploaded file)", "format": fmt})
|
||||
# v6.5.0: resolve synced blocks server-side (fresh content on read).
|
||||
from app.services.synced_blocks import resolve_content_json
|
||||
return {"content": resolve_content_json(row["content"] or "", fmt), "format": fmt}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}/download")
|
||||
def download_page_file(request: Request, page_id: int):
|
||||
"""Download the original uploaded file of a ``file`` page (attachment)."""
|
||||
_require_page_view(request, page_id)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, title, content, content_format FROM pages "
|
||||
"WHERE id=? AND deleted_at IS NULL",
|
||||
(page_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return JSONResponse({"error": "Not found"}, status_code=404)
|
||||
resolved = _file_page_disk_path(dict(row))
|
||||
if not resolved:
|
||||
return JSONResponse({"error": "No downloadable file"}, status_code=404)
|
||||
full, filename, mime, _size = resolved
|
||||
from fastapi.responses import FileResponse
|
||||
return FileResponse(
|
||||
str(full), media_type=mime or "application/octet-stream",
|
||||
filename=filename, content_disposition_type="attachment",
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}/file-content")
|
||||
def page_file_content(request: Request, page_id: int):
|
||||
"""Return the textual content of a ``file`` page (for copy to clipboard).
|
||||
|
||||
Binary files (PDF, images…) answer ``{ok: false}`` — the clipboard copy is
|
||||
only meaningful for plain-text / code / markdown files.
|
||||
"""
|
||||
from app.services.export import _file_text
|
||||
|
||||
_require_page_view(request, page_id)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, title, content, content_format FROM pages "
|
||||
"WHERE id=? AND deleted_at IS NULL",
|
||||
(page_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return JSONResponse({"error": "Not found"}, status_code=404)
|
||||
page = dict(row)
|
||||
text = _file_text(page)
|
||||
if text is None:
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": "Not a textual file", "name": page.get("title", "")},
|
||||
status_code=415,
|
||||
)
|
||||
return {"ok": True, "name": page.get("title") or "File", "content": text}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/local-workspace/breadcrumb")
|
||||
def local_workspace_breadcrumb(request: Request, folder: int):
|
||||
"""Return breadcrumb trail for a folder."""
|
||||
with get_conn() as conn:
|
||||
breadcrumb = _build_breadcrumb(conn, folder)
|
||||
return {"breadcrumb": breadcrumb}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/nav/menu")
|
||||
def nav_menu(request: Request, workspace_id: int = None, parent_id: int = None):
|
||||
"""Return the pages at one level for the header breadcrumb navigation menu.
|
||||
|
||||
If ``parent_id`` is given, returns that page's children; otherwise the
|
||||
workspace's root pages. Each item includes ``has_children`` so the frontend
|
||||
can render an expandable sub-menu.
|
||||
"""
|
||||
from app.routers.board import _file_icon
|
||||
ws_id = workspace_id
|
||||
if not ws_id:
|
||||
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
||||
ws_id = ws["id"] if ws else None
|
||||
if not ws_id:
|
||||
return {"items": []}
|
||||
with get_conn() as conn:
|
||||
if parent_id:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, parent_section, content_format FROM pages "
|
||||
"WHERE parent_id=? AND workspace_id=? AND deleted_at IS NULL "
|
||||
"ORDER BY sort_order ASC, created_at DESC",
|
||||
(parent_id, ws_id),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, parent_section, content_format FROM pages "
|
||||
"WHERE parent_id IS NULL AND workspace_id=? AND deleted_at IS NULL AND collection_row_id IS NULL "
|
||||
"ORDER BY sort_order ASC, created_at DESC",
|
||||
(ws_id,),
|
||||
).fetchall()
|
||||
ids = [r["id"] for r in rows]
|
||||
child_counts = {}
|
||||
if ids:
|
||||
placeholders = ",".join("?" for _ in ids)
|
||||
cc_rows = conn.execute(
|
||||
f"SELECT parent_id, COUNT(*) AS c FROM pages "
|
||||
f"WHERE parent_id IN ({placeholders}) AND deleted_at IS NULL "
|
||||
f"GROUP BY parent_id",
|
||||
ids,
|
||||
).fetchall()
|
||||
for cr in cc_rows:
|
||||
child_counts[cr["parent_id"]] = cr["c"]
|
||||
items = []
|
||||
for r in rows:
|
||||
is_folder = r["parent_section"] == "Workspace"
|
||||
title = r["title"] or "Untitled"
|
||||
items.append({
|
||||
"id": r["id"],
|
||||
"name": title,
|
||||
"icon": "folder" if is_folder else _file_icon(title, r["content_format"]),
|
||||
"has_children": child_counts.get(r["id"], 0) > 0,
|
||||
"url": f"/pages/{r['id']}",
|
||||
})
|
||||
return {"items": items}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/local-workspace/items")
|
||||
def create_local_workspace_item(request: Request, body: dict = Body(default={})):
|
||||
"""Create a new file in the active workspace."""
|
||||
_require_user_id(request) # A3/A4 : pas de creation de page anonyme
|
||||
name = (body.get("name") or "").strip() or "Untitled"
|
||||
item_type = body.get("type", "page")
|
||||
parent_id = body.get("parent_id")
|
||||
explicit_ws_id = body.get("workspace_id")
|
||||
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
||||
ws_id = explicit_ws_id or (ws["id"] if ws else None)
|
||||
ws_key = (ws["name"] if ws else "Workspace") if not explicit_ws_id else ""
|
||||
section = 'Workspace' if item_type == 'folder' else 'Private'
|
||||
with get_conn() as conn:
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id) "
|
||||
"VALUES (?, ?, ?, '', 'blocks', ?, ?)",
|
||||
(ws_key, ws_id, name, section, parent_id)
|
||||
)
|
||||
conn.commit()
|
||||
pid = cursor.lastrowid
|
||||
return {"id": pid, "name": name, "type": item_type}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.put("/api/local-workspace/items/{item_id:int}")
|
||||
def rename_local_workspace_item(request: Request, item_id: int, body: dict = Body(default={})):
|
||||
"""Rename a file."""
|
||||
_require_user_id(request)
|
||||
name = body.get("name", "Untitled").strip()
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET title=? WHERE id=?", (name, item_id))
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/api/local-workspace/items/{item_id:int}")
|
||||
def delete_local_workspace_item(request: Request, item_id: int):
|
||||
"""Soft-delete a file/folder (sets deleted_at)."""
|
||||
_require_user_id(request)
|
||||
from datetime import datetime
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE pages SET deleted_at=? WHERE id=? AND deleted_at IS NULL",
|
||||
(datetime.now(UTC).replace(tzinfo=None).isoformat(), item_id),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/local-workspace/items/{item_id:int}/restore")
|
||||
def restore_local_workspace_item(request: Request, item_id: int):
|
||||
"""Restore a soft-deleted file/folder."""
|
||||
_require_user_id(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE pages SET deleted_at=NULL WHERE id=?",
|
||||
(item_id,),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/files/{ws_id:int}/{filename:path}")
|
||||
def serve_uploaded_file(ws_id: int, filename: str):
|
||||
"""Serve an uploaded file from disk."""
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
root = Path(settings.data_dir)
|
||||
base_dir = (root / f"uploads/workspace_{ws_id}").resolve()
|
||||
fp = (base_dir / filename).resolve()
|
||||
try:
|
||||
fp.relative_to(base_dir)
|
||||
except ValueError:
|
||||
return JSONResponse({"error": "Path traversal denied"}, status_code=403)
|
||||
if not fp.exists():
|
||||
return JSONResponse({"error": "File not found"}, status_code=404)
|
||||
mime, _ = mimetypes.guess_type(str(fp))
|
||||
content = fp.read_bytes()
|
||||
from fastapi.responses import Response
|
||||
return Response(content=content, media_type=mime or "application/octet-stream")
|
||||
|
||||
|
||||
|
||||
|
||||
@router.put("/api/local-workspace/items/{item_id:int}/move")
|
||||
def move_local_workspace_item(request: Request, item_id: int, body: dict = Body(default={})):
|
||||
"""Move an item to a new parent (drag & drop)."""
|
||||
_require_user_id(request)
|
||||
new_parent_id = body.get("parent_id") # None = move to root
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE pages SET parent_id=? WHERE id=?",
|
||||
(new_parent_id, item_id),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/local-workspace/upload")
|
||||
async def upload_local_workspace_file(request: Request):
|
||||
"""Upload one or more files via drag-and-drop.
|
||||
|
||||
Accepts multipart form with 'files' field (one or multiple files).
|
||||
Optional: 'parent_id' to place files in a specific folder.
|
||||
Stores files on disk at /data/uploads/workspace_{id}/ and creates DB records.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
uid = _require_user_id(request) # A22 : pas d'upload anonyme
|
||||
ws = _get_active_workspace(request, user_id=uid)
|
||||
ws_id = ws["id"] if ws else None
|
||||
if not ws_id:
|
||||
return JSONResponse({"error": "No active workspace"}, status_code=400)
|
||||
|
||||
try:
|
||||
form = await request.form()
|
||||
except Exception:
|
||||
return JSONResponse({"error": "Invalid form data"}, status_code=400)
|
||||
|
||||
parent_id_raw = form.get("parent_id")
|
||||
parent_id = int(parent_id_raw) if parent_id_raw else None
|
||||
files = form.getlist("files")
|
||||
|
||||
if not files:
|
||||
return JSONResponse({"error": "No files provided"}, status_code=400)
|
||||
|
||||
|
||||
from app.middleware.security import validate_upload
|
||||
|
||||
data_root = Path(settings.data_dir)
|
||||
upload_dir = data_root / f"uploads/workspace_{ws_id}"
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
results = []
|
||||
with get_conn() as conn:
|
||||
for f in files:
|
||||
filename = f.filename or "untitled"
|
||||
# Sanitize filename: only keep basename, prevent path traversal
|
||||
safe_name = Path(filename).name
|
||||
if not safe_name:
|
||||
safe_name = "untitled"
|
||||
|
||||
# Unique filename on disk
|
||||
file_path = upload_dir / safe_name
|
||||
stem, suffix = file_path.stem, file_path.suffix
|
||||
counter = 1
|
||||
while file_path.exists():
|
||||
file_path = upload_dir / f"{stem} ({counter}){suffix}"
|
||||
counter += 1
|
||||
|
||||
content = await f.read()
|
||||
err = validate_upload(safe_name, len(content)) # A22 : taille + extension
|
||||
if err:
|
||||
results.append({"name": safe_name, "error": err})
|
||||
continue
|
||||
file_path.write_bytes(content)
|
||||
|
||||
# Determine if this is a folder marker or actual file
|
||||
rel_path = str(file_path.relative_to(data_root))
|
||||
size = len(content)
|
||||
mime = f.content_type or "application/octet-stream"
|
||||
|
||||
cursor = conn.execute(
|
||||
"""INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id)
|
||||
VALUES ('', ?, ?, ?, 'file', 'Private', ?)""",
|
||||
(ws_id, file_path.name,
|
||||
json.dumps({"file_path": rel_path, "size": size, "mime_type": mime}),
|
||||
parent_id),
|
||||
)
|
||||
results.append({"id": cursor.lastrowid, "name": file_path.name, "type": "page", "size": size})
|
||||
|
||||
conn.commit()
|
||||
|
||||
return {"status": "ok", "items": results}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/local-workspace/upload-folder")
|
||||
async def upload_local_workspace_folder(request: Request):
|
||||
"""Handle recursive folder upload.
|
||||
|
||||
Frontend walks the directory tree with webkitGetAsEntry and sends:
|
||||
- 'structure': JSON array of {path: str, type: 'folder'|'file'}
|
||||
- 'files': multipart files (one per file in the structure)
|
||||
- 'parent_id': target folder (optional)
|
||||
|
||||
Creates folders first, then uploads files into their respective folders.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
uid = _require_user_id(request) # A22 : pas d'upload anonyme
|
||||
ws = _get_active_workspace(request, user_id=uid)
|
||||
ws_id = ws["id"] if ws else None
|
||||
if not ws_id:
|
||||
return JSONResponse({"error": "No active workspace"}, status_code=400)
|
||||
|
||||
try:
|
||||
form = await request.form()
|
||||
except Exception:
|
||||
return JSONResponse({"error": "Invalid form data"}, status_code=400)
|
||||
|
||||
parent_id_raw = form.get("parent_id")
|
||||
root_parent_id = int(parent_id_raw) if parent_id_raw else None
|
||||
structure_raw = form.get("structure")
|
||||
|
||||
if not structure_raw:
|
||||
return JSONResponse({"error": "No structure provided"}, status_code=400)
|
||||
|
||||
try:
|
||||
structure = json.loads(structure_raw)
|
||||
except json.JSONDecodeError:
|
||||
return JSONResponse({"error": "Invalid structure JSON"}, status_code=400)
|
||||
|
||||
|
||||
from app.middleware.security import validate_upload
|
||||
|
||||
data_root = Path(settings.data_dir)
|
||||
upload_dir = data_root / f"uploads/workspace_{ws_id}"
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
results = []
|
||||
created_folders = {} # relative_path -> db_id
|
||||
|
||||
with get_conn() as conn:
|
||||
# Phase 1: Create all folders
|
||||
for item in structure:
|
||||
if item.get("type") != "folder":
|
||||
continue
|
||||
path_parts = item["path"].strip("/").split("/")
|
||||
folder_name = path_parts[-1]
|
||||
# Determine parent: parent of this folder in the tree
|
||||
if len(path_parts) == 1:
|
||||
actual_parent = root_parent_id
|
||||
else:
|
||||
parent_path = "/".join(path_parts[:-1])
|
||||
actual_parent = created_folders.get(parent_path)
|
||||
|
||||
cursor = conn.execute(
|
||||
"""INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id)
|
||||
VALUES ('', ?, ?, '', 'blocks', 'Workspace', ?)""",
|
||||
(ws_id, folder_name, actual_parent),
|
||||
)
|
||||
fid = cursor.lastrowid
|
||||
created_folders[item["path"].strip("/")] = fid
|
||||
results.append({"id": fid, "name": folder_name, "type": "folder"})
|
||||
|
||||
# Phase 2: Upload files into their respective folders
|
||||
for item in structure:
|
||||
if item.get("type") != "file":
|
||||
continue
|
||||
path_parts = item["path"].strip("/").split("/")
|
||||
file_name = path_parts[-1]
|
||||
if len(path_parts) == 1:
|
||||
file_parent = root_parent_id
|
||||
else:
|
||||
parent_path = "/".join(path_parts[:-1])
|
||||
file_parent = created_folders.get(parent_path)
|
||||
|
||||
# Find the matching file in multipart data
|
||||
matched = None
|
||||
for f in form.getlist("files"):
|
||||
if f.filename and (f.filename == item["path"] or f.filename.endswith("/" + file_name)):
|
||||
matched = f
|
||||
break
|
||||
if not matched:
|
||||
continue
|
||||
|
||||
safe_name = Path(file_name).name
|
||||
file_path = upload_dir / safe_name
|
||||
stem, suffix = file_path.stem, file_path.suffix
|
||||
counter = 1
|
||||
while file_path.exists():
|
||||
file_path = upload_dir / f"{stem} ({counter}){suffix}"
|
||||
counter += 1
|
||||
|
||||
content = await matched.read()
|
||||
err = validate_upload(safe_name, len(content)) # A22 : taille + extension
|
||||
if err:
|
||||
results.append({"name": safe_name, "error": err})
|
||||
continue
|
||||
file_path.write_bytes(content)
|
||||
|
||||
rel_path = str(file_path.relative_to(data_root))
|
||||
cursor = conn.execute(
|
||||
"""INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id)
|
||||
VALUES ('', ?, ?, ?, 'file', 'Private', ?)""",
|
||||
(ws_id, file_path.name,
|
||||
json.dumps({"file_path": rel_path, "size": len(content), "mime_type": matched.content_type or "application/octet-stream"}),
|
||||
file_parent),
|
||||
)
|
||||
results.append({"id": cursor.lastrowid, "name": file_path.name, "type": "page", "size": len(content)})
|
||||
|
||||
conn.commit()
|
||||
|
||||
return {"status": "ok", "items": results}
|
||||
|
||||
|
||||
# ═══════════ Workspaces CRUD ═══════════
|
||||
@@ -0,0 +1,249 @@
|
||||
"""FlowDeck — Dashboard : pages_api.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/dashboard.py (2 735 lignes, 63 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["dashboard"])
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ Library page actions API ═══════════
|
||||
|
||||
@router.get("/api/pages/{page_id:int}/content")
|
||||
def api_page_content(page_id: int):
|
||||
"""Get page content for side peek preview."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT title, content, content_format FROM pages WHERE id=? AND deleted_at IS NULL",
|
||||
(page_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return JSONResponse({"error": "Not found"}, status_code=404)
|
||||
# v6.5.0: resolve synced blocks server-side (fresh content on read).
|
||||
from app.services.synced_blocks import resolve_content_json
|
||||
return {
|
||||
"title": row["title"],
|
||||
"content": resolve_content_json(row["content"], row["content_format"]),
|
||||
"format": row["content_format"] or "blocks",
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.put("/api/pages/{page_id:int}/rename")
|
||||
def api_rename_page(page_id: int, request: Request, body: dict = Body(default={})):
|
||||
"""Inline rename a page title."""
|
||||
title = (body.get("title") or "").strip()
|
||||
if not title:
|
||||
return JSONResponse({"error": "Title required"}, status_code=400)
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=? AND deleted_at IS NULL",
|
||||
(title, page_id),
|
||||
)
|
||||
# v6.5.0: renaming a database row's content page updates the row.
|
||||
from app.services.row_pages import sync_page_title_to_row
|
||||
sync_page_title_to_row(conn, page_id)
|
||||
conn.commit()
|
||||
return {"status": "ok", "title": title}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id:int}/trash")
|
||||
def api_trash_page(page_id: int, request: Request):
|
||||
"""Soft-delete a page (move to trash).
|
||||
|
||||
v7.45.5 : ``parent_section`` n'est plus réécrit en 'Trash' — ``deleted_at``
|
||||
est la seule source de vérité. L'ancienne écriture marquait définitivement
|
||||
la page : à la restauration elle restait 'Trash' et disparaissait des
|
||||
listings Library / Recents / Private (``parent_section != 'Trash'``).
|
||||
"""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
raise HTTPException(401, "Authentication required")
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE pages SET deleted_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(page_id,),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id:int}/convert-to-database")
|
||||
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
|
||||
db_name = (body.get("name") or "").strip()
|
||||
|
||||
with get_conn() as conn:
|
||||
page = conn.execute(
|
||||
"SELECT id, title, workspace_id FROM pages WHERE id=? AND deleted_at IS NULL",
|
||||
(page_id,),
|
||||
).fetchone()
|
||||
if not page:
|
||||
return JSONResponse({"error": "Page not found"}, status_code=404)
|
||||
|
||||
if not db_name:
|
||||
db_name = page["title"] or "New Database"
|
||||
|
||||
# Create the collection
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collections
|
||||
(name, description, icon, schema_json, is_inline, parent_page_id, workspace_id)
|
||||
VALUES (?, '', '📋', '[]', 0, ?, ?)""",
|
||||
(db_name, page_id, page["workspace_id"]),
|
||||
)
|
||||
collection_id = cur.lastrowid
|
||||
|
||||
# Create default "Name" property (text, position 0)
|
||||
conn.execute(
|
||||
"""INSERT INTO collection_properties
|
||||
(collection_id, name, prop_type, position, required, visible_in_views)
|
||||
VALUES (?, 'Name', 'title', 0, 1, 1)""",
|
||||
(collection_id,),
|
||||
)
|
||||
|
||||
# Create default "Table" view
|
||||
conn.execute(
|
||||
"""INSERT INTO collection_views
|
||||
(collection_id, name, view_type, config_json, position)
|
||||
VALUES (?, 'Table', 'table', ?, 0)""",
|
||||
(collection_id, _json.dumps({"visible_properties": ["Name"]})),
|
||||
)
|
||||
|
||||
# Update the page to be a database page
|
||||
conn.execute(
|
||||
"UPDATE pages SET content_format='collection', collection_id=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(collection_id, page_id),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
return {
|
||||
"status": "converted",
|
||||
"collection_id": collection_id,
|
||||
"name": db_name,
|
||||
"view_url": f"/pages/{page_id}",
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/collections/{collection_id:int}/table-data")
|
||||
def api_collection_table_data(collection_id: int):
|
||||
"""Get collection properties + pages for rendering the table view."""
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute(
|
||||
"SELECT * FROM collections WHERE id=?", (collection_id,)
|
||||
).fetchone()
|
||||
if not coll:
|
||||
return JSONResponse({"error": "Collection not found"}, status_code=404)
|
||||
|
||||
properties = [
|
||||
dict(r) for r in conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
]
|
||||
|
||||
pages = [
|
||||
dict(r) for r in conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
]
|
||||
|
||||
views = [
|
||||
dict(r) for r in conn.execute(
|
||||
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
]
|
||||
|
||||
return {
|
||||
"collection": dict(coll),
|
||||
"properties": properties,
|
||||
"pages": pages,
|
||||
"views": views,
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/collections/{collection_id:int}/pages")
|
||||
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
|
||||
title = body.get("title", "New page").strip() or "New page"
|
||||
icon = body.get("icon", "file")
|
||||
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute(
|
||||
"SELECT id FROM collections WHERE id=?", (collection_id,)
|
||||
).fetchone()
|
||||
if not coll:
|
||||
return JSONResponse({"error": "Collection not found"}, status_code=404)
|
||||
|
||||
# Get next position
|
||||
max_pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchone()[0]
|
||||
|
||||
# Load default property values from collection properties
|
||||
props = [
|
||||
dict(r) for r in conn.execute(
|
||||
"SELECT id, name, prop_type FROM collection_properties WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
]
|
||||
default_values = {}
|
||||
for p in props:
|
||||
if p["prop_type"] == "title":
|
||||
default_values[str(p["id"])] = title
|
||||
# Caller-provided values (e.g. board "add card in column X") win.
|
||||
incoming = body.get("properties") or {}
|
||||
if isinstance(incoming, dict):
|
||||
default_values.update(incoming)
|
||||
|
||||
from app.services.property_types import apply_auto_properties
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) or {"login": "admin", "id": 1}
|
||||
apply_auto_properties(props, default_values, user, is_create=True)
|
||||
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collection_pages
|
||||
(collection_id, title, icon, cover_url, position, property_values_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(collection_id, title, icon, body.get("cover_url", ""), max_pos,
|
||||
_json.dumps(default_values)),
|
||||
)
|
||||
page_id = cur.lastrowid
|
||||
conn.commit()
|
||||
|
||||
return {
|
||||
"id": page_id,
|
||||
"title": title,
|
||||
"icon": icon,
|
||||
"position": max_pos,
|
||||
"property_values_json": default_values,
|
||||
"status": "created",
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
"""FlowDeck — Dashboard : pages_html.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/dashboard.py (2 735 lignes, 63 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Query, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
|
||||
from ._common import _get_active_workspace, _get_user_id, _nav_breadcrumb, _sidebar_data
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["dashboard"])
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/trash", response_class=HTMLResponse)
|
||||
def trash_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
|
||||
"""Trash page — scoped to workspace if owner/repo provided."""
|
||||
from app.routers.board import _sidebar_data as board_sidebar
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = board_sidebar(request, owner, repo)
|
||||
with get_conn() as conn:
|
||||
ws_key = f"{owner}/{repo}" if owner and repo else ""
|
||||
# Pages are soft-deleted via deleted_at (parent_section n'est plus
|
||||
# touché par le trash → source de vérité unique, v7.45.5)
|
||||
if ws_key:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, workspace FROM pages WHERE deleted_at IS NOT NULL AND workspace=? ORDER BY updated_at DESC",
|
||||
(ws_key,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, workspace FROM pages WHERE deleted_at IS NOT NULL ORDER BY updated_at DESC",
|
||||
).fetchall()
|
||||
sidebar["trash_items"] = [{"id": r["id"], "name": r["title"] or "Untitled", "workspace": r["workspace"]} for r in rows]
|
||||
# Pass active workspace for breadcrumb nav menu
|
||||
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
||||
sidebar["nav_workspace_id"] = ws["id"] if ws else 0
|
||||
template = env.get_template("trash.html")
|
||||
return template.render(**sidebar)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/library", response_class=HTMLResponse)
|
||||
def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
|
||||
"""Library page — tabbed view (recents, favorites, shared, published, private, workspace).
|
||||
|
||||
Sidebar data is kept intact. Tab content is loaded client-side via /api/library/* endpoints.
|
||||
"""
|
||||
from app.routers.board import _sidebar_data as board_sidebar
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = board_sidebar(request, owner, repo)
|
||||
# Pass active workspace for breadcrumb nav menu
|
||||
ws_key_ws = f"{owner}/{repo}" if owner and repo else ""
|
||||
if ws_key_ws:
|
||||
with get_conn() as conn:
|
||||
ws_row = conn.execute("SELECT id FROM workspaces WHERE name=? AND owner_id=?", (ws_key_ws, _get_user_id(request))).fetchone()
|
||||
sidebar["nav_workspace_id"] = ws_row["id"] if ws_row else 0
|
||||
else:
|
||||
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
||||
sidebar["nav_workspace_id"] = ws["id"] if ws else 0
|
||||
|
||||
template = env.get_template("library.html")
|
||||
sidebar["active_workspace_id"] = sidebar.get("nav_workspace_id", 0)
|
||||
# Gitea workspace context for Repository tab
|
||||
sidebar["is_gitea_workspace"] = bool(owner and repo)
|
||||
sidebar["gitea_workspace_owner"] = owner
|
||||
sidebar["gitea_workspace_repo"] = repo
|
||||
return template.render(**sidebar)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}", response_class=HTMLResponse)
|
||||
def view_page_root(request: Request, page_id: int):
|
||||
"""Render a Markdown page at root level with workspace context — or file viewer.
|
||||
?embed=1 — minimal mode for side peek (editor only, no header)."""
|
||||
embed = request.query_params.get("embed") == "1"
|
||||
from app.routers.board import _sidebar_data as board_sidebar
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
return RedirectResponse("/workspaces", status_code=302)
|
||||
page = dict(row)
|
||||
# v7.69.6 : tracer la visite (colonne Library « Last visited », table recents).
|
||||
try:
|
||||
from datetime import UTC, datetime
|
||||
_sess = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if _sess and _sess.get("id"):
|
||||
with get_conn() as _conn:
|
||||
_conn.execute(
|
||||
"""INSERT INTO recents (user_id, page_id, workspace, source_type, accessed_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id, page_id)
|
||||
DO UPDATE SET workspace=excluded.workspace,
|
||||
accessed_at=excluded.accessed_at""",
|
||||
(_sess["id"], page_id, page.get("workspace", "") or "",
|
||||
"local", datetime.now(UTC).replace(tzinfo=None).isoformat()),
|
||||
)
|
||||
_conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
# v6.5.0: synced blocks resolve server-side at read time.
|
||||
from app.services.synced_blocks import resolve_content_json
|
||||
page["content"] = resolve_content_json(page.get("content", ""), page.get("content_format"))
|
||||
|
||||
ws = page.get("workspace", "")
|
||||
parts = ws.split("/") if "/" in ws else ["", ""]
|
||||
owner, repo = parts[0], parts[1] if len(parts) > 1 else ""
|
||||
sidebar = board_sidebar(request, owner, repo)
|
||||
# Load sub-pages
|
||||
with get_conn() as conn:
|
||||
subs = conn.execute(
|
||||
"SELECT id, title FROM pages WHERE parent_id=? ORDER BY updated_at DESC",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
fav = conn.execute(
|
||||
"SELECT id FROM favorites WHERE user_id=? AND page_id=?",
|
||||
(1, page_id),
|
||||
).fetchone()
|
||||
|
||||
# Build page_data, including file metadata for uploaded files
|
||||
_locked = bool(page.get("is_locked", 0))
|
||||
_locked_by = page.get("locked_by") if "locked_by" in page else None
|
||||
_sess_user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
_uid = _sess_user.get("id") if _sess_user and _sess_user.get("id") else None
|
||||
_can_edit = (not _locked) or bool(_sess_user and _sess_user.get("is_admin")) or (_locked_by and _uid and _locked_by == _uid)
|
||||
page_data = {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": fav is not None, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("is_published", 0) or page.get("published", 0)),
|
||||
"is_locked": _locked,
|
||||
"locked_by": _locked_by,
|
||||
"can_edit": _can_edit,
|
||||
"full_width": bool(page.get("full_width", 0)) if "full_width" in page else False,
|
||||
"font_small": bool(page.get("font_small", 0)) if "font_small" in page else False}
|
||||
|
||||
# For file pages, extract file metadata and add to page_data
|
||||
if page.get("content_format") == "file":
|
||||
import json as _json
|
||||
try:
|
||||
meta = _json.loads(page.get("content", "{}"))
|
||||
except _json.JSONDecodeError:
|
||||
meta = {}
|
||||
file_path = meta.get("file_path", "").replace("\\", "/")
|
||||
mime_type = meta.get("mime_type", "application/octet-stream")
|
||||
file_size = meta.get("size", 0)
|
||||
fp_parts = file_path.split("/")
|
||||
ws_id = ""
|
||||
for p in fp_parts:
|
||||
if p.startswith("workspace_"):
|
||||
ws_id = p.replace("workspace_", "")
|
||||
break
|
||||
filename = fp_parts[-1] if fp_parts else page.get("title", "File")
|
||||
from urllib.parse import quote
|
||||
safe_name = quote(filename, safe='')
|
||||
file_url = f"/api/files/{ws_id}/{safe_name}" if ws_id else ""
|
||||
page_data["file_url"] = file_url
|
||||
page_data["file_mime"] = mime_type
|
||||
page_data["file_size"] = file_size
|
||||
page_data["file_name"] = filename
|
||||
|
||||
# For collection (database) pages, load collection + properties + pages
|
||||
collection_data = None
|
||||
if page.get("content_format") == "collection" and page.get("collection_id"):
|
||||
with get_conn() as conn:
|
||||
col = conn.execute(
|
||||
"SELECT * FROM collections WHERE id=?", (page["collection_id"],)
|
||||
).fetchone()
|
||||
if col:
|
||||
props = [
|
||||
dict(r) for r in conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
|
||||
(page["collection_id"],),
|
||||
).fetchall()
|
||||
]
|
||||
cpages = [
|
||||
dict(r) for r in conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position",
|
||||
(page["collection_id"],),
|
||||
).fetchall()
|
||||
]
|
||||
cviews = [
|
||||
dict(r) for r in conn.execute(
|
||||
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position",
|
||||
(page["collection_id"],),
|
||||
).fetchall()
|
||||
]
|
||||
collection_data = {
|
||||
"collection": dict(col),
|
||||
"properties": props,
|
||||
"pages": cpages,
|
||||
"views": cviews,
|
||||
}
|
||||
page_data["collection_id"] = page["collection_id"]
|
||||
|
||||
with get_conn() as conn:
|
||||
nav_crumbs = _nav_breadcrumb(conn, page_id)
|
||||
# dérivé calculé UNE fois : ctx ET page_data (JSON) l'utilisent (A27)
|
||||
page_is_shared = (
|
||||
bool(page.get("is_shared", 0))
|
||||
or page.get("share_mode", "private") != "private"
|
||||
or bool(page.get("published", 0))
|
||||
)
|
||||
ctx = {**sidebar, "page": page, "sub_pages": [dict(s) for s in subs],
|
||||
"page_favorited": fav is not None,
|
||||
"page_share_mode": page.get("share_mode", "private"),
|
||||
"page_published": bool(page.get("published", 0)),
|
||||
"page_is_shared": page_is_shared,
|
||||
"page_data": page_data,
|
||||
"collection_data": collection_data,
|
||||
"breadcrumb_items": nav_crumbs,
|
||||
"nav_workspace_id": page.get("workspace_id") or 0,
|
||||
"nav_page_id": page_id,
|
||||
"embed_mode": embed}
|
||||
# A27 phase 2 : le JS de l'éditeur lit ces valeurs dans page-data (JSON)
|
||||
# au lieu des interpolations Jinja — une seule source, même calculs que le
|
||||
# ctx ci-dessus.
|
||||
from app.templating import ENV as _ENV27
|
||||
page_data.update(
|
||||
updated_at=page.get("updated_at", ""),
|
||||
created_at=page.get("created_at", ""),
|
||||
user_id=_uid or 0,
|
||||
is_shared=page_is_shared,
|
||||
clip_icon=_ENV27.from_string(
|
||||
"{% from '_icons.html' import fd_icon %}{{ fd_icon('paperclip', 14) }}"
|
||||
).render(),
|
||||
)
|
||||
# Select template: collection pages use database table view (aussi en
|
||||
# mode embed : `page_editor_collection.html` + body.embed-mode = le
|
||||
# tableau SANS sidebar ni barre — le peek d'une base reste le tableau).
|
||||
if page.get("content_format") == "collection":
|
||||
template = env.get_template("page_editor_collection.html")
|
||||
else:
|
||||
template = env.get_template("page_editor_embed.html" if embed else "page_editor.html")
|
||||
response = template.render(**ctx)
|
||||
return HTMLResponse(content=response, headers={"Cache-Control": "no-store, max-age=0"})
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/accounts", response_class=HTMLResponse)
|
||||
def accounts_page(request: Request):
|
||||
"""Account management panel."""
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
with get_conn() as conn:
|
||||
users = conn.execute(
|
||||
"SELECT id, login, full_name, email, avatar_url, avatar_color, "
|
||||
"is_admin, is_active, created_at FROM users ORDER BY created_at DESC"
|
||||
).fetchall()
|
||||
ctx = {**sidebar, "user": user, "users": [dict(u) for u in users]}
|
||||
template = env.get_template("accounts.html")
|
||||
return template.render(**ctx)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/help", response_class=HTMLResponse)
|
||||
def help_page(request: Request):
|
||||
"""Help & documentation page — settings-style panel with side navigation."""
|
||||
from app.templating import ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
return ENV.get_template("help.html").render(**sidebar, request=request)
|
||||
|
||||
|
||||
@router.get("/accounts/settings", response_class=HTMLResponse)
|
||||
def settings_page(request: Request):
|
||||
"""User settings page — profile, forges, tokens."""
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse("/auth/login?provider=local", status_code=302)
|
||||
# Check forge connections
|
||||
gitea_connected = False
|
||||
github_connected = False
|
||||
if user.get("id"):
|
||||
with get_conn() as conn:
|
||||
tokens = conn.execute(
|
||||
"SELECT provider FROM user_oauth_tokens WHERE user_id=?", (user["id"],)
|
||||
).fetchall()
|
||||
for t in tokens:
|
||||
if t["provider"] == "gitea":
|
||||
gitea_connected = True
|
||||
elif t["provider"] == "github":
|
||||
github_connected = True
|
||||
ctx = {**sidebar, "user": user, "gitea_connected": gitea_connected, "github_connected": github_connected}
|
||||
template = env.get_template("settings.html")
|
||||
response = template.render(**ctx)
|
||||
return HTMLResponse(content=response)
|
||||
|
||||
|
||||
# ═══════════ User API endpoints ═══════════
|
||||
@@ -0,0 +1,78 @@
|
||||
"""FlowDeck — Dashboard : public.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/dashboard.py (2 735 lignes, 63 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
from ._common import _render_blocks_public
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["dashboard"])
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ Public Published Page ═══════════
|
||||
|
||||
|
||||
@router.get("/p/{slug}", response_class=HTMLResponse)
|
||||
def public_published_page(request: Request, slug: str):
|
||||
"""Serve a published page at /p/<slug> — no auth required."""
|
||||
from app.templating import ENV
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, title, content, content_format, updated_at, created_at, cover_url, page_icon "
|
||||
"FROM pages WHERE publish_slug=? AND is_published=1",
|
||||
(slug,),
|
||||
).fetchone()
|
||||
|
||||
if not row:
|
||||
return HTMLResponse(
|
||||
"""<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">
|
||||
<title>Not Found — FlowDeck</title>
|
||||
<style>body{font-family:system-ui,sans-serif;display:flex;align-items:center;
|
||||
justify-content:center;height:100vh;margin:0;background:#191919;color:#ccc;}
|
||||
h1{font-size:3rem;opacity:.3}</style></head><body><h1>404</h1></body></html>""",
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
page = dict(row)
|
||||
env = ENV
|
||||
|
||||
# Convert blocks to HTML for rendering
|
||||
content_html = ""
|
||||
if page.get("content_format") == "blocks" and page.get("content"):
|
||||
import json as _json
|
||||
try:
|
||||
blocks = _json.loads(page["content"])
|
||||
from app.services.synced_blocks import resolve_synced_block
|
||||
blocks = resolve_synced_block(blocks)
|
||||
from app.db import get_conn as _gc
|
||||
from app.services.wiki_links import token_labels
|
||||
with _gc() as conn:
|
||||
wiki_titles_map = token_labels(conn, page["content"])
|
||||
content_html = _render_blocks_public(blocks, wiki_titles_map)
|
||||
except (_json.JSONDecodeError, Exception):
|
||||
content_html = f"<p>{page.get('content', '')}</p>"
|
||||
elif page.get("content"):
|
||||
# Plain text / markdown
|
||||
text = page["content"]
|
||||
content_html = f"<pre style='white-space:pre-wrap;font-family:system-ui;font-size:16px;line-height:1.6;'>{text}</pre>"
|
||||
|
||||
template = env.get_template("public_page.html")
|
||||
return template.render(
|
||||
title=page["title"] or "Untitled",
|
||||
content_html=content_html,
|
||||
updated_at=page.get("updated_at", ""),
|
||||
created_at=page.get("created_at", ""),
|
||||
cover_url=page.get("cover_url", ""),
|
||||
page_icon=page.get("page_icon", ""),
|
||||
)
|
||||
@@ -0,0 +1,353 @@
|
||||
"""FlowDeck — Dashboard : workspace.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/dashboard.py (2 735 lignes, 63 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Query, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.gitea_client import get_user_gitea_client, gitea
|
||||
|
||||
from ._common import (
|
||||
_cleanup_empty_mirrors,
|
||||
_ensure_forge_mirror,
|
||||
_get_active_workspace,
|
||||
_get_user_id,
|
||||
_sidebar_data,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["dashboard"])
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def dashboard(
|
||||
request: Request,
|
||||
search: str = Query(default=""),
|
||||
show_archived: bool = Query(default=False),
|
||||
):
|
||||
"""Smart root route: landing for visitors, local workspace for new users, dashboard for Gitea users."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
|
||||
# ── Not authenticated → show landing page ──
|
||||
if not user:
|
||||
# Allow through if DB is empty (fresh install)
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||
if count == 0:
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
template = env.get_template("landing.html")
|
||||
return template.render()
|
||||
except Exception:
|
||||
logger.exception("dashboard")
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
template = env.get_template("landing.html")
|
||||
return template.render()
|
||||
|
||||
# ── Authenticated ──
|
||||
user_id = user.get("id", 1)
|
||||
has_gitea = False
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
tok = conn.execute(
|
||||
"SELECT access_token FROM user_oauth_tokens WHERE user_id=? AND provider='gitea'",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
has_gitea = bool(tok)
|
||||
except Exception:
|
||||
logger.exception("dashboard")
|
||||
|
||||
if not has_gitea:
|
||||
# Check if user has any workspace
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
ws_count = conn.execute(
|
||||
"SELECT COUNT(*) FROM workspaces WHERE owner_id=?", (user_id,)
|
||||
).fetchone()[0]
|
||||
if ws_count == 0:
|
||||
# v5.2.0: first-launch → onboarding wizard
|
||||
return RedirectResponse("/welcome", status_code=302)
|
||||
except Exception:
|
||||
logger.exception("dashboard")
|
||||
return RedirectResponse("/local-workspace", status_code=302)
|
||||
|
||||
# ── Gitea user → full dashboard ──
|
||||
try:
|
||||
repos = await gitea.get_user_repos(page=1, limit=50)
|
||||
if search:
|
||||
q = search.lower()
|
||||
repos = [r for r in repos if q in r.get("full_name", "").lower()
|
||||
or q in (r.get("description") or "").lower()]
|
||||
if not show_archived:
|
||||
repos = [r for r in repos if not r.get("archived", False)]
|
||||
repos.sort(key=lambda r: r.get("updated_at", ""), reverse=True)
|
||||
except Exception as e:
|
||||
logger.error("Dashboard error: %s", e)
|
||||
repos = []
|
||||
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, repos)
|
||||
template = env.get_template("dashboard.html")
|
||||
return template.render(request=request, repos=repos, search=search,
|
||||
show_archived=show_archived, **sidebar)
|
||||
|
||||
|
||||
# ═══════════ Workspace ═══════════
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ Workspace ═══════════
|
||||
|
||||
@router.get("/workspace", response_class=HTMLResponse)
|
||||
def workspace_page(request: Request):
|
||||
"""Unified workspace showing all projects."""
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return RedirectResponse("/auth/login?provider=local", status_code=302)
|
||||
ctx = {**sidebar, "user": user}
|
||||
# Pass active workspace for breadcrumb nav menu
|
||||
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
||||
ctx["nav_workspace_id"] = ws["id"] if ws else 0
|
||||
template = env.get_template("workspace.html")
|
||||
return template.render(**ctx)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/gitea-workspace", response_class=HTMLResponse)
|
||||
def gitea_workspace_page(request: Request):
|
||||
"""Gitea workspace — browse repo files (same layout as /local-workspace)."""
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return RedirectResponse("/auth/login?provider=local", status_code=302)
|
||||
owner = request.query_params.get("owner", "")
|
||||
repo = request.query_params.get("repo", "")
|
||||
ws_key = f"{owner}/{repo}" if owner and repo else ""
|
||||
ws_name = ws_key or "Gitea Workspace"
|
||||
# Ensure the local workspace mirror of the same name BEFORE building the
|
||||
# sidebar, then drop other EMPTY mirrors so switching repositories never
|
||||
# accumulates empty workspace-locals.
|
||||
local_ws_id = _ensure_forge_mirror(user["id"], owner, repo, forge="gitea") if ws_key else None
|
||||
if ws_key:
|
||||
_cleanup_empty_mirrors(user["id"], keep_name=ws_key, keep_id=local_ws_id or 0)
|
||||
# Build the sidebar with the explicit project context so the local mirror
|
||||
# (''Workspace'' section) is resolved even when the flowdeck_workspace
|
||||
# cookie is absent (direct navigation / bookmark).
|
||||
sidebar = _sidebar_data(request, [], gitea_owner=owner, gitea_repo=repo)
|
||||
ctx = {
|
||||
**sidebar,
|
||||
"user": user,
|
||||
"active_ws_name": ws_name,
|
||||
"workspace_key": ws_key,
|
||||
"gitea_workspace": True, # always true on this page
|
||||
"gitea_owner": owner,
|
||||
"gitea_repo": repo,
|
||||
"forge": "gitea",
|
||||
"workspace_name": ws_name,
|
||||
"workspace_initial": repo[0].upper() if repo else "G",
|
||||
"owner": owner,
|
||||
"repo": repo,
|
||||
"nav_workspace_id": local_ws_id or 0, # for breadcrumb nav menu
|
||||
}
|
||||
template = env.get_template("gitea_workspace.html")
|
||||
resp = HTMLResponse(content=template.render(**ctx))
|
||||
resp.set_cookie("flowdeck_workspace", f"gitea:{owner}:{repo}", path="/", samesite="lax")
|
||||
return resp
|
||||
|
||||
|
||||
@router.get("/github-workspace", response_class=HTMLResponse)
|
||||
def github_workspace_page(request: Request):
|
||||
"""GitHub workspace — same layout as /local-workspace and /gitea-workspace."""
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return RedirectResponse("/auth/login?provider=local", status_code=302)
|
||||
owner = request.query_params.get("owner", "")
|
||||
repo = request.query_params.get("repo", "")
|
||||
ws_key = f"{owner}/{repo}" if owner and repo else ""
|
||||
ws_name = ws_key or "GitHub Workspace"
|
||||
local_ws_id = _ensure_forge_mirror(user["id"], owner, repo, forge="github") if ws_key else None
|
||||
if ws_key:
|
||||
_cleanup_empty_mirrors(user["id"], keep_name=ws_key, keep_id=local_ws_id or 0)
|
||||
# Reuse the same sidebar mechanism (Repository section + parallel local
|
||||
# mirror). The cookie prefix carries the forge so refresh APIs resolve it.
|
||||
sidebar = _sidebar_data(request, [], gitea_owner=owner, gitea_repo=repo)
|
||||
ctx = {
|
||||
**sidebar,
|
||||
"user": user,
|
||||
"active_ws_name": ws_name,
|
||||
"workspace_key": ws_key,
|
||||
"gitea_workspace": True, # sidebar Repository section visible
|
||||
"gitea_owner": owner,
|
||||
"gitea_repo": repo,
|
||||
"forge": "github",
|
||||
"workspace_name": ws_name,
|
||||
"workspace_initial": repo[0].upper() if repo else "G",
|
||||
"owner": owner,
|
||||
"repo": repo,
|
||||
"nav_workspace_id": local_ws_id or 0,
|
||||
}
|
||||
template = env.get_template("github_workspace.html")
|
||||
resp = HTMLResponse(content=template.render(**ctx))
|
||||
resp.set_cookie("flowdeck_workspace", f"github:{owner}:{repo}", path="/", samesite="lax")
|
||||
return resp
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/workspace/projects")
|
||||
async def list_workspace_projects(request: Request):
|
||||
"""List all projects: built-in + Gitea + GitHub.
|
||||
Uses the user's own Gitea token if connected, not the global admin token."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
|
||||
builtin = []
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title FROM pages WHERE parent_id IS NULL AND collection_row_id IS NULL ORDER BY updated_at DESC LIMIT 20"
|
||||
).fetchall()
|
||||
# A23 : une seule agrégation GROUP BY au lieu d'un COUNT par ligne.
|
||||
counts = {}
|
||||
if rows:
|
||||
for c in conn.execute(
|
||||
"SELECT parent_id, COUNT(*) AS c FROM pages WHERE parent_id IN ({}) GROUP BY parent_id".format(
|
||||
",".join("?" * len(rows))
|
||||
),
|
||||
[r["id"] for r in rows],
|
||||
).fetchall():
|
||||
counts[c["parent_id"]] = c["c"]
|
||||
for r in rows:
|
||||
builtin.append({"id": str(r["id"]), "name": r["title"] or "Untitled", "pageCount": counts.get(r["id"], 0), "forge": "builtin"})
|
||||
|
||||
gitea_repos = []
|
||||
# Use per-user token if available, otherwise return empty
|
||||
user_gitea = get_user_gitea_client(request) if user else None
|
||||
if user_gitea:
|
||||
try:
|
||||
repos = await user_gitea.get_user_repos(page=1, limit=50)
|
||||
for repo in repos:
|
||||
gitea_repos.append({
|
||||
"id": str(repo.get("id", "")),
|
||||
"name": repo.get("name", ""),
|
||||
"full_name": repo.get("full_name", ""),
|
||||
"description": repo.get("description", ""),
|
||||
"html_url": repo.get("html_url", ""),
|
||||
"language": repo.get("language", ""),
|
||||
"forge": "gitea",
|
||||
})
|
||||
except Exception:
|
||||
logger.exception("list_workspace_projects")
|
||||
|
||||
return {"builtin": builtin, "gitea": gitea_repos, "github": []}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/workspace/projects")
|
||||
def create_workspace_project(request: Request, body: dict = Body(default={})):
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
return {"error": "Name required"}
|
||||
with get_conn() as conn:
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO pages (workspace, title, content, content_format, parent_section) VALUES ('', ?, '', 'blocks', 'Private')",
|
||||
(name,),
|
||||
)
|
||||
conn.commit()
|
||||
pid = cursor.lastrowid
|
||||
return {"id": pid, "name": name, "forge": "builtin"}
|
||||
|
||||
|
||||
# ═══════════ Workspace Members API ═══════════
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ Workspace Members API ═══════════
|
||||
|
||||
@router.get("/api/workspace/{ws_id:int}/members")
|
||||
def list_members(request: Request, ws_id: int):
|
||||
"""List all members of a workspace."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT u.id, u.login, u.full_name, u.avatar_url, wm.role, wm.joined_at
|
||||
FROM workspace_members wm JOIN users u ON u.id = wm.user_id
|
||||
WHERE wm.workspace_id=? ORDER BY wm.joined_at""", (ws_id,)
|
||||
).fetchall()
|
||||
return {"members": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/workspace/{ws_id:int}/members")
|
||||
def invite_member(request: Request, ws_id: int, body: dict = Body(default={})):
|
||||
"""Invite a user to a workspace by email."""
|
||||
email = body.get("email", "").strip()
|
||||
role = body.get("role", "editor")
|
||||
if role not in ("owner", "admin", "editor", "viewer"):
|
||||
return {"error": "Invalid role"}, 400
|
||||
with get_conn() as conn:
|
||||
user = conn.execute("SELECT id FROM users WHERE login=? OR email=?", (email, email)).fetchone()
|
||||
if not user:
|
||||
return {"error": "User not found"}, 404
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, ?)",
|
||||
(ws_id, user["id"], role),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
return {"error": "Already a member"}, 409
|
||||
return {"status": "ok", "user_id": user["id"], "role": role}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.put("/api/workspace/{ws_id:int}/members/{user_id:int}")
|
||||
def update_member_role(request: Request, ws_id: int, user_id: int, body: dict = Body(default={})):
|
||||
"""Change a member's role."""
|
||||
role = body.get("role", "editor")
|
||||
if role not in ("owner", "admin", "editor", "viewer"):
|
||||
return {"error": "Invalid role"}
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE workspace_members SET role=? WHERE workspace_id=? AND user_id=?",
|
||||
(role, ws_id, user_id),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/api/workspace/{ws_id:int}/members/{user_id:int}")
|
||||
def remove_member(request: Request, ws_id: int, user_id: int):
|
||||
"""Remove a member from a workspace."""
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM workspace_members WHERE workspace_id=? AND user_id=?",
|
||||
(ws_id, user_id),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ═══════════ Local Workspace (file/folder CRUD) ═══════════
|
||||
@@ -0,0 +1,173 @@
|
||||
"""FlowDeck — Dashboard : workspaces.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/dashboard.py (2 735 lignes, 63 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.collection_lifecycle import (
|
||||
delete_collections,
|
||||
workspace_collection_ids,
|
||||
)
|
||||
|
||||
from ._common import (
|
||||
WORKSPACE_COOKIE,
|
||||
_cleanup_empty_mirrors,
|
||||
_get_active_workspace,
|
||||
_get_user_id,
|
||||
_require_user_id,
|
||||
_sidebar_data,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["dashboard"])
|
||||
|
||||
|
||||
def _require_ws_owner(request: Request, ws_id: int) -> int:
|
||||
"""A3/A4 — session obligatoire + l'appelant doit posseder l'espace
|
||||
(ou être admin global). Sans ce garde, un POST/DELETE anonyme créait ou
|
||||
supprimait des espaces au nom de l'utilisateur 1."""
|
||||
uid = _require_user_id(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT owner_id FROM workspaces WHERE id=?", (ws_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Workspace not found")
|
||||
is_admin = conn.execute(
|
||||
"SELECT is_admin FROM users WHERE id=?", (uid,)
|
||||
).fetchone()
|
||||
if row["owner_id"] != uid and not (is_admin and is_admin["is_admin"]):
|
||||
raise HTTPException(403, "Not your workspace")
|
||||
return uid
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/workspaces", response_class=HTMLResponse)
|
||||
def workspaces_page(request: Request):
|
||||
"""Workspaces list page."""
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [], include_workspace=False)
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return RedirectResponse("/auth/login?provider=local&expired=1", status_code=302)
|
||||
ctx = {**sidebar, "user": user}
|
||||
# Pass active workspace for breadcrumb nav menu (null on workspaces home)
|
||||
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
||||
ctx["nav_workspace_id"] = ws["id"] if ws else 0
|
||||
template = env.get_template("workspaces.html")
|
||||
return template.render(**ctx)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/workspaces")
|
||||
def list_workspaces(request: Request):
|
||||
"""List all workspaces for the current user."""
|
||||
uid = _require_user_id(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT w.*, (SELECT COUNT(*) FROM pages WHERE workspace_id=w.id AND collection_row_id IS NULL) as page_count "
|
||||
"FROM workspaces w WHERE w.owner_id=? ORDER BY w.created_at DESC",
|
||||
(uid,),
|
||||
).fetchall()
|
||||
workspaces = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
workspaces.append(d)
|
||||
active = _get_active_workspace(request, user_id=_get_user_id(request))
|
||||
return {"workspaces": workspaces, "active_id": active["id"] if active else None}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/workspaces")
|
||||
def create_workspace(request: Request, body: dict = Body(default={})):
|
||||
"""Create a new workspace."""
|
||||
name = body.get("name", "New Workspace").strip()
|
||||
if not name:
|
||||
return {"error": "Name required"}
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = _require_user_id(request)
|
||||
with get_conn() as conn:
|
||||
# Ensure user exists (FK constraint)
|
||||
uid_ok = conn.execute("SELECT id FROM users WHERE id=?", (uid,)).fetchone()
|
||||
if not uid_ok:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?, ?, ?, 1)",
|
||||
(uid, user.get("login", "admin") if user else "admin",
|
||||
user.get("full_name", "Admin") if user else "Admin"),
|
||||
)
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO workspaces (name, owner_id) VALUES (?, ?)",
|
||||
(name, uid),
|
||||
)
|
||||
ws_id = cursor.lastrowid
|
||||
# Add owner as member
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, 'owner')",
|
||||
(ws_id, uid),
|
||||
)
|
||||
conn.commit()
|
||||
return {"id": ws_id, "name": name}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.put("/api/workspaces/{ws_id:int}")
|
||||
def rename_workspace(request: Request, ws_id: int, body: dict = Body(default={})):
|
||||
"""Rename a workspace."""
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
return {"error": "Name required"}
|
||||
_require_ws_owner(request, ws_id)
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE workspaces SET name=? WHERE id=?", (name, ws_id))
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/api/workspaces/{ws_id:int}")
|
||||
def delete_workspace(request: Request, ws_id: int):
|
||||
"""Delete a workspace, its pages and its databases."""
|
||||
_require_ws_owner(request, ws_id)
|
||||
with get_conn() as conn:
|
||||
# Les bases du workspace partaient avec ses pages : les laisser créait
|
||||
# des collections orphelines (workspace_id pointant sur rien) que
|
||||
# /db et My Tasks continuaient de lister.
|
||||
collections = workspace_collection_ids(conn, ws_id)
|
||||
delete_collections(conn, collections)
|
||||
conn.execute("DELETE FROM pages WHERE workspace_id=?", (ws_id,))
|
||||
conn.execute("DELETE FROM workspace_members WHERE workspace_id=?", (ws_id,))
|
||||
conn.execute("DELETE FROM workspaces WHERE id=?", (ws_id,))
|
||||
conn.commit()
|
||||
return {"status": "ok", "collections": len(collections)}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/workspaces/{ws_id:int}/select")
|
||||
def select_workspace(request: Request, ws_id: int):
|
||||
"""Set the active workspace via cookie."""
|
||||
uid = _require_ws_owner(request, ws_id)
|
||||
# Switching workspace: prune other empty forge mirrors.
|
||||
try:
|
||||
_cleanup_empty_mirrors(uid, keep_id=ws_id)
|
||||
except Exception:
|
||||
pass
|
||||
response = JSONResponse({"status": "ok", "workspace_id": ws_id})
|
||||
response.set_cookie(WORKSPACE_COOKIE, str(ws_id), max_age=86400 * 30, httponly=True, path="/")
|
||||
return response
|
||||
|
||||
|
||||
# ═══════════ Settings Page ═══════════
|
||||
@@ -0,0 +1,480 @@
|
||||
"""FlowDeck — Templates unifiés : gestionnaire SSR + API interne (cookie).
|
||||
|
||||
Cahier des charges : ``docs/architecture-templates-notion-flowdeck.md`` §10.1.
|
||||
Toutes les surfaces (UI, API v2, agent, scheduler) passent par le même
|
||||
``TemplateService`` — jamais d'accès direct aux silos legacy (P4, brief §0).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Query, Request
|
||||
from fastapi.exceptions import HTTPException
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.fd_templates import TemplateService, ensure_registry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["fd-templates"])
|
||||
|
||||
|
||||
def _actor(request: Request) -> dict | None:
|
||||
try:
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
except Exception:
|
||||
user = None
|
||||
return user
|
||||
|
||||
|
||||
def _require_actor(request: Request) -> dict:
|
||||
user = _actor(request)
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
return user
|
||||
|
||||
|
||||
def _as_http(exc: Exception) -> HTTPException:
|
||||
if isinstance(exc, LookupError):
|
||||
return HTTPException(404, str(exc) or "Template introuvable")
|
||||
if isinstance(exc, PermissionError):
|
||||
return HTTPException(403, str(exc) or "Accès refusé")
|
||||
if isinstance(exc, ValueError):
|
||||
return HTTPException(400, str(exc) or "Requête invalide")
|
||||
logger.exception("templates API")
|
||||
return HTTPException(500, "Erreur interne")
|
||||
|
||||
|
||||
# ═══════════ Page SSR — gestionnaire /templates (§7.2) ═══════════
|
||||
|
||||
@router.get("/templates", response_class=HTMLResponse)
|
||||
def templates_manager_page(request: Request):
|
||||
"""Gestionnaire de templates : liste filtrable + side peek d'aperçu."""
|
||||
from app.routers.board import _sidebar_data as board_sidebar
|
||||
from app.templating import ENV
|
||||
|
||||
sidebar = board_sidebar(request, "", "")
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
ensure_registry(conn)
|
||||
except Exception:
|
||||
logger.exception("ensure_registry /templates")
|
||||
template = ENV.get_template("fd_templates_manager.html")
|
||||
return template.render(**sidebar)
|
||||
|
||||
|
||||
# ═══════════ API interne — lecture ═══════════
|
||||
|
||||
@router.get("/api/templates")
|
||||
def list_templates(
|
||||
request: Request,
|
||||
kind: str | None = Query(default=None),
|
||||
scope: str | None = Query(default=None),
|
||||
target_collection_id: int | None = Query(default=None),
|
||||
q: str | None = Query(default=None),
|
||||
include_archived: bool = Query(default=False),
|
||||
):
|
||||
actor = _actor(request)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
items = TemplateService.list_templates(
|
||||
conn, actor, kind=kind, scope=scope,
|
||||
target_collection_id=target_collection_id, query=q,
|
||||
include_archived=include_archived,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return {"templates": items}
|
||||
|
||||
|
||||
@router.get("/api/templates/{template_id}")
|
||||
def get_template(request: Request, template_id: str):
|
||||
actor = _actor(request)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
row = TemplateService.get_template(conn, actor, template_id)
|
||||
from app.services.fd_templates import _row_to_dto
|
||||
|
||||
dto = _row_to_dto(conn, row)
|
||||
preview = TemplateService.preview(conn, actor, template_id)
|
||||
dto["blocks_preview"] = preview.get("blocks_preview", [])
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return dto
|
||||
|
||||
|
||||
@router.get("/api/collections/{collection_id}/templates")
|
||||
def list_collection_templates(request: Request, collection_id: int):
|
||||
"""Menu *New ▾* d'une base : templates de lignes + défaut (§7.3)."""
|
||||
actor = _actor(request)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
items = TemplateService.list_templates(
|
||||
conn, actor, kind="row", target_collection_id=collection_id
|
||||
)
|
||||
default_id = next(
|
||||
(t["id"] for t in items if t.get("is_default")), None
|
||||
)
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return {"templates": items, "default_id": default_id}
|
||||
|
||||
|
||||
@router.get("/api/templates/{template_id}/runs")
|
||||
def list_template_runs(request: Request, template_id: str, limit: int = Query(default=20)):
|
||||
actor = _require_actor(request)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
TemplateService.get_template(conn, actor, template_id)
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM template_runs WHERE template_id=? ORDER BY created_at DESC LIMIT ?",
|
||||
(template_id, max(1, min(int(limit), 100))),
|
||||
).fetchall()
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return {"runs": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
# ═══════════ API interne — écriture ═══════════
|
||||
|
||||
@router.post("/api/templates")
|
||||
def create_template(request: Request, body: dict = Body(default={})):
|
||||
actor = _require_actor(request)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
# Création depuis une source existante (create_from_*, §2.1).
|
||||
if body.get("from_page_id"):
|
||||
dto = _create_from_page(conn, actor, int(body["from_page_id"]), body)
|
||||
elif body.get("from_collection_id"):
|
||||
dto = _create_from_collection(
|
||||
conn, actor, int(body["from_collection_id"]), body
|
||||
)
|
||||
elif body.get("from_row"):
|
||||
dto = _create_from_row(conn, actor, body["from_row"], body)
|
||||
else:
|
||||
dto = TemplateService.create_template(conn, actor, body)
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return dto
|
||||
|
||||
|
||||
@router.patch("/api/templates/{template_id}")
|
||||
def update_template(request: Request, template_id: str, body: dict = Body(default={})):
|
||||
actor = _require_actor(request)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
dto = TemplateService.update_template(conn, actor, template_id, body)
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return dto
|
||||
|
||||
|
||||
@router.post("/api/templates/{template_id}/duplicate")
|
||||
def duplicate_template(request: Request, template_id: str, body: dict = Body(default={})):
|
||||
actor = _require_actor(request)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
dto = TemplateService.duplicate_template(conn, actor, template_id, body)
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return dto
|
||||
|
||||
|
||||
@router.delete("/api/templates/{template_id}")
|
||||
def delete_template(request: Request, template_id: str):
|
||||
actor = _require_actor(request)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
result = TemplateService.delete_template(conn, actor, template_id)
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/api/templates/{template_id}/archive")
|
||||
def archive_template(request: Request, template_id: str):
|
||||
actor = _require_actor(request)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
dto = TemplateService.archive_template(conn, actor, template_id, archived=True)
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return dto
|
||||
|
||||
|
||||
@router.post("/api/templates/{template_id}/restore")
|
||||
def restore_template(request: Request, template_id: str):
|
||||
actor = _require_actor(request)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
dto = TemplateService.archive_template(conn, actor, template_id, archived=False)
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return dto
|
||||
|
||||
|
||||
@router.put("/api/templates/{template_id}/property-defaults")
|
||||
def set_property_defaults(request: Request, template_id: str, body: dict = Body(default={})):
|
||||
actor = _require_actor(request)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
dto = TemplateService.update_template(
|
||||
conn, actor, template_id,
|
||||
{"property_defaults": body.get("property_defaults") or body},
|
||||
)
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return dto
|
||||
|
||||
|
||||
@router.put("/api/templates/{template_id}/variables")
|
||||
def set_variables(request: Request, template_id: str, body: dict = Body(default={})):
|
||||
actor = _require_actor(request)
|
||||
variables = body.get("variables") if isinstance(body.get("variables"), list) else body.get("variables_json")
|
||||
if variables is None and isinstance(body, list):
|
||||
variables = body
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
dto = TemplateService.update_template(
|
||||
conn, actor, template_id, {"variables": variables or []}
|
||||
)
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return dto
|
||||
|
||||
|
||||
@router.post("/api/templates/{template_id}/set-default")
|
||||
def set_default(request: Request, template_id: str):
|
||||
actor = _require_actor(request)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
dto = TemplateService.set_default(conn, actor, template_id)
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return dto
|
||||
|
||||
|
||||
@router.post("/api/templates/{template_id}/unset-default")
|
||||
def unset_default(request: Request, template_id: str):
|
||||
actor = _require_actor(request)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
dto = TemplateService.unset_default(conn, actor, template_id)
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return dto
|
||||
|
||||
|
||||
@router.put("/api/templates/{template_id}/recurrence")
|
||||
def set_recurrence(request: Request, template_id: str, body: dict = Body(default={})):
|
||||
actor = _require_actor(request)
|
||||
spec = None
|
||||
if body and body.get("rrule"):
|
||||
spec = {
|
||||
"rrule": body.get("rrule"),
|
||||
"timezone": body.get("timezone") or "",
|
||||
"enabled": body.get("enabled", True),
|
||||
}
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
recurrence = TemplateService.set_recurrence(conn, actor, template_id, spec)
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return {"template_id": template_id, "recurrence": recurrence}
|
||||
|
||||
|
||||
@router.post("/api/templates/{template_id}/preview")
|
||||
def preview_template(request: Request, template_id: str, body: dict = Body(default={})):
|
||||
actor = _actor(request)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
result = TemplateService.preview(
|
||||
conn, actor, template_id, body.get("variables") or {},
|
||||
title=(body.get("title") or ""),
|
||||
)
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/api/templates/{template_id}/instantiate")
|
||||
def instantiate_template(request: Request, template_id: str, body: dict = Body(default={})):
|
||||
actor = _actor(request)
|
||||
idempotency_key = request.headers.get("Idempotency-Key") or body.get("idempotency_key")
|
||||
context = body.get("context") or {}
|
||||
# La cible « page courante » porte l'id de page pour ajout/remplacement.
|
||||
if body.get("page_id") and "target" not in context:
|
||||
context["target"] = {"type": "current_page", "page_id": body.get("page_id"),
|
||||
"mode": body.get("mode") or "fill"}
|
||||
if body.get("collection_id") and "collection_id" not in context:
|
||||
context["collection_id"] = body.get("collection_id")
|
||||
if body.get("parent_page_id") and "parent_page_id" not in context:
|
||||
context["parent_page_id"] = body.get("parent_page_id")
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
result = TemplateService.instantiate(
|
||||
conn, actor, template_id, body.get("variables") or {}, context,
|
||||
idempotency_key=idempotency_key,
|
||||
run_source="api" if request.headers.get("authorization") else "ui",
|
||||
)
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/api/templates/runs/{run_id}/undo")
|
||||
def undo_run(request: Request, run_id: str):
|
||||
actor = _require_actor(request)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
result = TemplateService.mark_undone(conn, actor, run_id)
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return result
|
||||
|
||||
|
||||
# ═══════════ Création depuis l'existant (create_from_*) ═══════════
|
||||
|
||||
def _create_from_page(conn, actor, page_id: int, spec: dict) -> dict:
|
||||
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
raise LookupError("Page source introuvable")
|
||||
blocks: list = []
|
||||
try:
|
||||
if (row["content_format"] or "") == "blocks" and row["content"]:
|
||||
blocks = json.loads(row["content"]) or []
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
blocks = []
|
||||
return TemplateService.create_template(
|
||||
conn, actor,
|
||||
{
|
||||
"kind": spec.get("kind") or "page",
|
||||
"name": spec.get("name") or f"{row['title'] or 'Sans titre'} (template)",
|
||||
"description": spec.get("description") or "",
|
||||
"icon": spec.get("icon") or (row["page_icon"] if "page_icon" in row.keys() else "📄") or "📄",
|
||||
"category": spec.get("category") or "",
|
||||
"scope": spec.get("scope") or "personal",
|
||||
"workspace_id": spec.get("workspace_id"),
|
||||
"target_collection_id": spec.get("target_collection_id"),
|
||||
"blocks": blocks if isinstance(blocks, list) else [],
|
||||
"variables": spec.get("variables") or [],
|
||||
"title_template": spec.get("title_template") or f"{row['title'] or ''}",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _create_from_collection(conn, actor, collection_id: int, spec: dict) -> dict:
|
||||
collection = conn.execute(
|
||||
"SELECT * FROM collections WHERE id=?", (collection_id,)
|
||||
).fetchone()
|
||||
if not collection:
|
||||
raise LookupError("Base source introuvable")
|
||||
props = [
|
||||
dict(p)
|
||||
for p in conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
]
|
||||
schema = [
|
||||
{
|
||||
"name": p["name"],
|
||||
"type": p["prop_type"] if "prop_type" in p else p.get("type", "text"),
|
||||
"options": json.loads(p.get("options_json") or "[]"),
|
||||
}
|
||||
for p in props
|
||||
]
|
||||
manifest = {
|
||||
"format": "flowdeck-template",
|
||||
"version": 1,
|
||||
"collection": {
|
||||
"name": collection["name"],
|
||||
"icon": collection["icon"] if "icon" in collection.keys() else "📋",
|
||||
"schema": schema,
|
||||
},
|
||||
}
|
||||
if spec.get("with_seed_rows"):
|
||||
manifest["collection"]["seed_rows"] = []
|
||||
return TemplateService.create_template(
|
||||
conn, actor,
|
||||
{
|
||||
"kind": "database",
|
||||
"name": spec.get("name") or f"{collection['name']} (template)",
|
||||
"description": spec.get("description") or (collection["description"] or ""),
|
||||
"icon": (collection["icon"] if "icon" in collection.keys() else "") or "🗂",
|
||||
"category": spec.get("category") or "Base",
|
||||
"scope": spec.get("scope") or "personal",
|
||||
"workspace_id": spec.get("workspace_id"),
|
||||
"manifest": manifest,
|
||||
"variables": spec.get("variables") or [],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _create_from_row(conn, actor, from_row: dict | int, spec: dict) -> dict:
|
||||
if isinstance(from_row, dict):
|
||||
collection_id = from_row.get("collection_id")
|
||||
row_id = from_row.get("row_id")
|
||||
else:
|
||||
collection_id, row_id = spec.get("target_collection_id"), from_row
|
||||
if not collection_id or not row_id:
|
||||
raise ValueError("from_row exige {collection_id, row_id}")
|
||||
source = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE id=? AND collection_id=?",
|
||||
(row_id, collection_id),
|
||||
).fetchone()
|
||||
if not source:
|
||||
raise LookupError("Ligne source introuvable")
|
||||
try:
|
||||
pv = json.loads(source["property_values_json"] or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pv = {}
|
||||
# Les valeurs sont re-cléées par nom de propriété pour survivre à l'export.
|
||||
props = {
|
||||
str(p["id"]): p["name"]
|
||||
for p in conn.execute(
|
||||
"SELECT id, name FROM collection_properties WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
}
|
||||
defaults = {props.get(k, k): v for k, v in (pv if isinstance(pv, dict) else {}).items()}
|
||||
shadow_blocks: list = []
|
||||
try:
|
||||
shadow = conn.execute(
|
||||
"SELECT content, content_format FROM pages WHERE collection_row_id=?",
|
||||
(row_id,),
|
||||
).fetchone()
|
||||
if shadow and (shadow["content_format"] or "") == "blocks" and shadow["content"]:
|
||||
shadow_blocks = json.loads(shadow["content"]) or []
|
||||
except Exception:
|
||||
shadow_blocks = []
|
||||
return TemplateService.create_template(
|
||||
conn, actor,
|
||||
{
|
||||
"kind": "row",
|
||||
"name": spec.get("name") or f"{source['title'] or 'Sans titre'} (template)",
|
||||
"description": spec.get("description") or "",
|
||||
"icon": spec.get("icon") or "📄",
|
||||
"category": spec.get("category") or "",
|
||||
"scope": spec.get("scope") or "personal",
|
||||
"workspace_id": spec.get("workspace_id"),
|
||||
"target_collection_id": collection_id,
|
||||
"blocks": shadow_blocks if isinstance(shadow_blocks, list) else [],
|
||||
"manifest": {"property_defaults": defaults},
|
||||
"title_template": spec.get("title_template") or (source["title"] or ""),
|
||||
"variables": spec.get("variables") or [],
|
||||
},
|
||||
)
|
||||
@@ -1,10 +1,30 @@
|
||||
"""GitHub OAuth — status and disconnect routes."""
|
||||
from fastapi import APIRouter, Request
|
||||
"""GitHub OAuth + repository browsing (mirrors /api/gitea shape for shared UI)."""
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
router = APIRouter(tags=["github"], prefix="/api/github")
|
||||
|
||||
|
||||
def _require_github(request: Request):
|
||||
"""Return a per-user GitHubAdapter or raise 401 (same contract as Gitea)."""
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.github_adapter import GitHubAdapter
|
||||
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT access_token FROM user_oauth_tokens WHERE user_id=? AND provider='github' "
|
||||
"AND access_token IS NOT NULL AND access_token != '' ORDER BY updated_at DESC LIMIT 1",
|
||||
(user["id"],),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=401, detail="GitHub account not linked. Go to Settings → Integrations to connect.")
|
||||
return GitHubAdapter(row["access_token"])
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def github_status(request: Request):
|
||||
"""Check if the current user has GitHub linked."""
|
||||
@@ -33,3 +53,68 @@ def disconnect_github(request: Request):
|
||||
conn.execute("DELETE FROM user_oauth_tokens WHERE user_id=? AND provider='github'", (user["id"],))
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ── Repo tree (same shape as /api/gitea/projects/{owner}/{repo}/tree) ──
|
||||
@router.get("/projects/{owner}/{repo}/tree")
|
||||
async def github_repo_tree(request: Request, owner: str, repo: str, path: str = ""):
|
||||
"""List one level of a GitHub repo (folders first client-side)."""
|
||||
adapter = _require_github(request)
|
||||
try:
|
||||
tree = await adapter.get_repo_tree(owner, repo)
|
||||
prefix = path.strip("/").strip()
|
||||
items = []
|
||||
seen_dirs: set[str] = set()
|
||||
for entry in tree:
|
||||
p = (entry.get("path") or "").strip("/")
|
||||
if not p:
|
||||
continue
|
||||
if prefix:
|
||||
if p != prefix and not p.startswith(prefix + "/"):
|
||||
continue
|
||||
rel = p[len(prefix) + 1:] if p != prefix else ""
|
||||
else:
|
||||
rel = p
|
||||
if not rel:
|
||||
continue
|
||||
if "/" in rel:
|
||||
top = rel.split("/")[0]
|
||||
dir_path = f"{prefix}/{top}".strip("/") if prefix else top
|
||||
if dir_path not in seen_dirs:
|
||||
seen_dirs.add(dir_path)
|
||||
items.append({"name": top, "path": dir_path, "type": "folder", "size": 0, "sha": ""})
|
||||
else:
|
||||
is_dir = entry.get("type") == "tree"
|
||||
items.append({
|
||||
"name": rel,
|
||||
"path": p,
|
||||
"type": "folder" if is_dir else "file",
|
||||
"size": entry.get("size", 0),
|
||||
"sha": entry.get("sha", ""),
|
||||
})
|
||||
# De-duplicate (recursive tree can list both tree + blob entries)
|
||||
dedup: dict[str, dict] = {}
|
||||
for it in items:
|
||||
key = it["path"]
|
||||
if key not in dedup or (dedup[key]["type"] == "file" and it["type"] == "folder"):
|
||||
dedup[key] = it
|
||||
return {"tree": sorted(dedup.values(), key=lambda x: (x["type"] != "folder", x["name"].lower()))}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=502)
|
||||
|
||||
|
||||
@router.get("/projects/{owner}/{repo}/file")
|
||||
async def github_get_file(request: Request, owner: str, repo: str, path: str = ""):
|
||||
"""Get decoded file content (same shape as Gitea endpoint)."""
|
||||
adapter = _require_github(request)
|
||||
if not path:
|
||||
return JSONResponse({"error": "Path required"}, status_code=400)
|
||||
try:
|
||||
content = await adapter.get_file_content(owner, repo, path)
|
||||
return {"content": content, "path": path}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=502)
|
||||
|
||||
+124
-15
@@ -52,21 +52,18 @@ def _build_item(db_row: dict, uid: int = 1) -> dict:
|
||||
else:
|
||||
url = f"/pages/{page_id}"
|
||||
|
||||
# Icon
|
||||
# Icon — convention Workspace (noms SVG, comme la sidebar) : la liste
|
||||
# des favoris de la sidebar consomme item.icon via fdIconHtml.
|
||||
content_format = db_row.get("content_format", "blocks")
|
||||
if db_row.get("is_folder"):
|
||||
icon = "📁"
|
||||
icon = "folder"
|
||||
elif content_format == "file":
|
||||
icon = "📄"
|
||||
icon = "file"
|
||||
fn = title.lower()
|
||||
if fn.endswith(".pdf"):
|
||||
icon = "📕"
|
||||
elif any(fn.endswith(e) for e in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]):
|
||||
icon = "🖼️"
|
||||
elif any(fn.endswith(e) for e in [".py", ".js", ".ts", ".go", ".rs"]):
|
||||
icon = "📜"
|
||||
if any(fn.endswith(e) for e in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]):
|
||||
icon = "image"
|
||||
else:
|
||||
icon = "📝"
|
||||
icon = "edit"
|
||||
|
||||
return {
|
||||
"id": page_id,
|
||||
@@ -75,6 +72,8 @@ def _build_item(db_row: dict, uid: int = 1) -> dict:
|
||||
"page_icon": db_row.get("page_icon") or "",
|
||||
"is_folder": bool(db_row.get("is_folder", 0)),
|
||||
"source_type": source_type,
|
||||
"source_icon": {"local": "file", "gitea": "link",
|
||||
"github": "external-link"}.get(source_type, "file"),
|
||||
"source_label": _source_label(source_type, workspace),
|
||||
"workspace": workspace,
|
||||
"workspace_name": _source_label(source_type, workspace),
|
||||
@@ -104,7 +103,63 @@ def _apply_source_filter(query: str, params: list, source_type: str) -> tuple[st
|
||||
|
||||
def _rows_to_items(rows, uid: int = 1) -> list:
|
||||
items = [_build_item(dict(r), uid) for r in rows]
|
||||
return _attach_tags(items)
|
||||
items = _attach_tags(items)
|
||||
return _attach_provenance(items, uid)
|
||||
|
||||
|
||||
def _attach_provenance(items: list, uid: int = 1) -> list:
|
||||
"""Created by / Last edited by / Last visited (colonnes Library).
|
||||
|
||||
- created_by : premier auteur des versions, sinon propriétaire du workspace ;
|
||||
- edited_by : dernier auteur des versions, sinon created_by ;
|
||||
- visited_at : dernier accès tracé de l'utilisateur (table recents).
|
||||
Requêtes groupées : 3 allers-retours quelle que soit la taille du lot.
|
||||
"""
|
||||
if not items:
|
||||
return items
|
||||
ids = [it["id"] for it in items]
|
||||
placeholders = ",".join("?" for _ in ids)
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
ver = conn.execute(
|
||||
f"SELECT v.page_id, COALESCE(NULLIF(u.full_name,''), u.login, '') AS name "
|
||||
f"FROM page_versions v LEFT JOIN users u ON u.id = v.user_id "
|
||||
f"WHERE v.page_id IN ({placeholders}) ORDER BY v.id", ids,
|
||||
).fetchall()
|
||||
first: dict = {}
|
||||
last: dict = {}
|
||||
for r in ver:
|
||||
first.setdefault(r["page_id"], r["name"] or "")
|
||||
last[r["page_id"]] = r["name"] or ""
|
||||
own = conn.execute(
|
||||
f"SELECT p.id, COALESCE(NULLIF(u.full_name,''), u.login, '') AS owner "
|
||||
f"FROM pages p LEFT JOIN workspaces w ON w.id = p.workspace_id "
|
||||
f"LEFT JOIN users u ON u.id = w.owner_id "
|
||||
f"WHERE p.id IN ({placeholders})", ids,
|
||||
).fetchall()
|
||||
owners = {r["id"]: r["owner"] or "" for r in own}
|
||||
try:
|
||||
vis = conn.execute(
|
||||
f"SELECT page_id, MAX(accessed_at) AS visited_at FROM recents "
|
||||
f"WHERE user_id = ? AND page_id IN ({placeholders}) "
|
||||
f"GROUP BY page_id", [uid, *ids],
|
||||
).fetchall()
|
||||
except Exception:
|
||||
vis = []
|
||||
visited = {r["page_id"]: r["visited_at"] or "" for r in vis}
|
||||
except Exception:
|
||||
logger.exception("_attach_provenance")
|
||||
return items
|
||||
for it in items:
|
||||
pid = it["id"]
|
||||
author = first.get(pid) or owners.get(pid, "")
|
||||
it["author"] = author
|
||||
it["author_initial"] = (author or "?")[0].upper()
|
||||
edited_by = last.get(pid) or author
|
||||
it["edited_by"] = edited_by
|
||||
it["edited_by_initial"] = (edited_by or "?")[0].upper()
|
||||
it["visited_at"] = visited.get(pid, "")
|
||||
return items
|
||||
|
||||
|
||||
def _attach_tags(items: list) -> list:
|
||||
@@ -156,6 +211,59 @@ BASE_SELECT = (
|
||||
)
|
||||
|
||||
|
||||
# Lignes contenant un bloc AI Meeting Notes (formats JSON navigateur/Python).
|
||||
MEETING_BLOCK_LIKE = "REPLACE(COALESCE(p.content,''), ' ', '') LIKE '%\"type\":\"meeting\"%'"
|
||||
NO_MEETING_BLOCK = (
|
||||
"REPLACE(COALESCE(p.content,''), ' ', '') NOT LIKE '%\"type\":\"meeting\"%'"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/meetings")
|
||||
def library_meetings(
|
||||
request: Request,
|
||||
source_type: str = Query(default="all"),
|
||||
):
|
||||
"""Onglet Library « AI Meeting Notes » : notes de réunion de l'utilisateur.
|
||||
|
||||
Regroupées côté client par date (Today / Yesterday / …). Les notes sont
|
||||
exclues des onglets de navigation courante (recents/workspace/private).
|
||||
"""
|
||||
if source_type not in SOURCE_TYPES:
|
||||
source_type = "all"
|
||||
|
||||
uid = _get_user_id(request)
|
||||
query = (
|
||||
f"{BASE_SELECT}, "
|
||||
"(SELECT mt.status FROM meeting_transcripts mt WHERE mt.page_id = p.id "
|
||||
" ORDER BY mt.id DESC LIMIT 1) AS meeting_status, "
|
||||
"(SELECT mt.summary FROM meeting_transcripts mt WHERE mt.page_id = p.id "
|
||||
" ORDER BY mt.id DESC LIMIT 1) AS meeting_summary "
|
||||
"FROM pages p LEFT JOIN workspaces w ON w.id = p.workspace_id "
|
||||
"WHERE p.deleted_at IS NULL AND (w.owner_id = ? OR p.workspace_id IS NULL) "
|
||||
f"AND {MEETING_BLOCK_LIKE}"
|
||||
)
|
||||
params: list = [uid]
|
||||
query, params = _apply_source_filter(query, params, source_type)
|
||||
query += " ORDER BY p.updated_at DESC LIMIT 100"
|
||||
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
|
||||
items = _rows_to_items(rows, uid)
|
||||
for it, r in zip(items, rows, strict=True):
|
||||
d = dict(r)
|
||||
st = d.get("meeting_status") or "idle"
|
||||
it["is_meeting"] = True
|
||||
it["meeting_status"] = st
|
||||
it["source_label"] = (
|
||||
"Summary ready" if (d.get("meeting_summary") or "").strip()
|
||||
else ("Ready to record" if st == "idle"
|
||||
else st.replace("_", " ").capitalize())
|
||||
)
|
||||
_enrich_children(items)
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@router.get("/recents")
|
||||
def library_recents(
|
||||
request: Request,
|
||||
@@ -167,7 +275,7 @@ def library_recents(
|
||||
source_type = "all"
|
||||
|
||||
uid = _get_user_id(request)
|
||||
query = f"{BASE_SELECT} FROM pages p WHERE p.parent_section != 'Trash' AND p.deleted_at IS NULL"
|
||||
query = f"{BASE_SELECT} FROM pages p WHERE p.parent_section != 'Trash' AND p.deleted_at IS NULL AND {NO_MEETING_BLOCK}"
|
||||
params: list = []
|
||||
|
||||
# Hierarchical view: show only root-level pages at the top
|
||||
@@ -301,7 +409,8 @@ def library_published(
|
||||
source_type = "all"
|
||||
|
||||
uid = _get_user_id(request)
|
||||
query = f"{BASE_SELECT} FROM pages p WHERE p.published = 1 AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
|
||||
# v7.69.6 : les deux drapeaux (lignes historiques issues d'un seul chemin).
|
||||
query = f"{BASE_SELECT} FROM pages p WHERE (p.is_published = 1 OR p.published = 1) AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
|
||||
params: list = []
|
||||
if tree:
|
||||
query += " AND p.parent_id IS NULL"
|
||||
@@ -326,7 +435,7 @@ def library_private(
|
||||
source_type = "all"
|
||||
|
||||
uid = _get_user_id(request)
|
||||
query = f"{BASE_SELECT} FROM pages p WHERE p.parent_section = 'Private' AND p.deleted_at IS NULL"
|
||||
query = f"{BASE_SELECT} FROM pages p WHERE p.parent_section = 'Private' AND p.deleted_at IS NULL AND {NO_MEETING_BLOCK}"
|
||||
params: list = []
|
||||
if tree:
|
||||
query += " AND p.parent_id IS NULL"
|
||||
@@ -392,7 +501,7 @@ def library_workspace(
|
||||
source_type = "all"
|
||||
|
||||
uid = _get_user_id(request)
|
||||
query = f"{BASE_SELECT} FROM pages p WHERE (p.workspace != '' OR p.workspace_id IS NOT NULL) AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
|
||||
query = f"{BASE_SELECT} FROM pages p WHERE (p.workspace != '' OR p.workspace_id IS NOT NULL) AND p.parent_section != 'Trash' AND p.deleted_at IS NULL AND {NO_MEETING_BLOCK}"
|
||||
params: list = []
|
||||
|
||||
if tree:
|
||||
|
||||
+397
-2
@@ -9,7 +9,9 @@ See ``docs/V71_Calendar_Meetings.md``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import secrets
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
@@ -115,8 +117,109 @@ def freebusy(collection_id: int, request: Request):
|
||||
return out
|
||||
|
||||
|
||||
# ── upcoming (sidebar "Meeting" tab) ────────────────────────────────────────
|
||||
|
||||
@router.get("/api/v2/meetings/upcoming")
|
||||
def upcoming_meetings(request: Request, days: int = 30):
|
||||
"""Upcoming calendar events for the sidebar Meeting tab.
|
||||
|
||||
Reads every calendar collection linked to the current user, extracts the
|
||||
date property of each linked row and returns the ones falling in the next
|
||||
``days`` days (today included), sorted chronologically. Rows imported from
|
||||
Google/CalDAV carry an ``external_event_id`` and are flagged ``synced``.
|
||||
"""
|
||||
user = _auth_user(request)
|
||||
horizon = max(1, min(days, 365))
|
||||
today = datetime.now(UTC).date()
|
||||
end = today + timedelta(days=horizon)
|
||||
events: list[dict] = []
|
||||
with get_conn() as conn:
|
||||
links = conn.execute(
|
||||
"SELECT id, collection_id, date_property, calendar_id, provider "
|
||||
"FROM calendar_links WHERE user_id=? ORDER BY id",
|
||||
(user["id"],),
|
||||
).fetchall()
|
||||
for link in links:
|
||||
collection_id = link["collection_id"]
|
||||
if not collection_id:
|
||||
continue
|
||||
date_prop = cal.date_prop_id(conn, collection_id,
|
||||
link["date_property"] or "")
|
||||
if not date_prop:
|
||||
continue
|
||||
prop_id, prop_name = date_prop
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, property_values_json, external_event_id "
|
||||
"FROM collection_pages WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
try:
|
||||
values = json.loads(row["property_values_json"] or "{}")
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
raw = cal.row_date(values, prop_id, prop_name)[:10]
|
||||
if len(raw) != 10:
|
||||
continue
|
||||
try:
|
||||
day = date.fromisoformat(raw)
|
||||
except ValueError:
|
||||
continue
|
||||
if not (today <= day <= end):
|
||||
continue
|
||||
events.append({
|
||||
"id": row["id"],
|
||||
"title": row["title"] or "Untitled",
|
||||
"date": raw,
|
||||
"today": day == today,
|
||||
"collection_id": collection_id,
|
||||
"calendar_id": link["calendar_id"] or "primary",
|
||||
"provider": link["provider"],
|
||||
"synced": bool(row["external_event_id"]),
|
||||
"url": f"/db/{collection_id}",
|
||||
})
|
||||
events.sort(key=lambda e: (e["date"], e["title"].lower()))
|
||||
return {"today": today.isoformat(), "days": horizon,
|
||||
"events": events[:200], "count": len(events)}
|
||||
|
||||
|
||||
# ── meetings ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/api/v2/meetings/transcripts")
|
||||
def create_transcript(request: Request, body: dict = Body(default={})):
|
||||
"""Crée une note de réunion vide (état idle) pour une page — §6.5.
|
||||
|
||||
Idempotent par occurrence calendrier : renvoie la note existante.
|
||||
"""
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
page_id = int(body.get("page_id", 0))
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, "page_id required") from None
|
||||
occ = (body.get("event_occurrence_id") or "").strip()[:200]
|
||||
with get_conn() as conn:
|
||||
if occ:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM meeting_transcripts WHERE event_occurrence_id=?",
|
||||
(occ,)).fetchone()
|
||||
if row:
|
||||
return {**row_to_dict(row), "already_existed": True}
|
||||
try:
|
||||
tid = meet.save_transcript(
|
||||
page_id, "", (body.get("language") or "fr")[:10],
|
||||
mode=(body.get("mode") or "mic_only")
|
||||
if (body.get("mode") or "mic_only") in meet.CAPTURE_MODES else "mic_only",
|
||||
instruction_id=(body.get("instruction_id") or "auto")[:40],
|
||||
event_occurrence_id=occ)
|
||||
except ValueError as exc:
|
||||
msg = str(exc)
|
||||
raise HTTPException(404 if "not found" in msg else 400, msg) from None
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM meeting_transcripts WHERE id=?", (tid,)).fetchone()
|
||||
audit_log(user, "meeting.create", "page", page_id, f"transcript={tid}", request)
|
||||
return JSONResponse(status_code=201,
|
||||
content={**row_to_dict(row), "already_existed": False})
|
||||
|
||||
@router.post("/api/v2/meetings/transcribe")
|
||||
async def upload_and_transcribe(request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
@@ -186,13 +289,305 @@ def set_transcript_text(transcript_id: int, request: Request, body: dict = Body(
|
||||
|
||||
|
||||
@router.post("/api/v2/meetings/transcripts/{transcript_id}/summarize")
|
||||
async def summarize(transcript_id: int, request: Request):
|
||||
async def summarize(transcript_id: int, request: Request,
|
||||
body: dict = Body(default={})):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
out = await meet.summarize_transcript(transcript_id, user.get("id"))
|
||||
out = await meet.summarize_transcript(
|
||||
transcript_id, user.get("id"),
|
||||
allow_empty=bool((body or {}).get("allow_empty")))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from None
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(502, str(exc)) from None
|
||||
audit_log(user, "meeting.summarize", "transcript", transcript_id, "", request)
|
||||
return out
|
||||
|
||||
|
||||
# ── Bloc Notion v7.1.1 : machine à états, consentement, segments, partage ───
|
||||
|
||||
@router.get("/api/v2/meetings/instructions")
|
||||
def meeting_instructions(request: Request):
|
||||
_auth_user(request)
|
||||
return {"instructions": [
|
||||
{"id": k, "label": v["label"]} for k, v in meet.INSTRUCTIONS.items()
|
||||
]}
|
||||
|
||||
|
||||
# Une page est une « note de réunion » dès qu'elle contient un bloc
|
||||
# AI Meeting Notes. Le contenu est du JSON (``"type":"meeting"`` côté
|
||||
# navigateur, ``"type": "meeting"`` côté Python) → comparaison sans espaces.
|
||||
MEETING_BLOCK_PREDICATE = (
|
||||
"REPLACE(COALESCE({col},''), ' ', '') LIKE '%\"type\":\"meeting\"%'"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/v2/meetings/notes")
|
||||
def meeting_notes(request: Request, limit: int = 50):
|
||||
"""Notes de réunion : pages contenant un bloc AI Meeting Notes.
|
||||
|
||||
Alimente la section Meetings de la sidebar (ces pages sont exclues de
|
||||
l'arbre Workspace, voir ``_load_workspace_pages``). Inclut les notes
|
||||
jamais enregistrées (aucune ligne ``meeting_transcripts``) avec
|
||||
``status = idle``.
|
||||
"""
|
||||
user = _auth_user(request)
|
||||
uid = user.get("id")
|
||||
lim = max(1, min(int(limit or 50), 200))
|
||||
pred = MEETING_BLOCK_PREDICATE.format(col="p.content")
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
f"""SELECT p.id, p.title, p.updated_at,
|
||||
(SELECT mt.status FROM meeting_transcripts mt
|
||||
WHERE mt.page_id = p.id ORDER BY mt.id DESC LIMIT 1) AS status,
|
||||
(SELECT mt.summary FROM meeting_transcripts mt
|
||||
WHERE mt.page_id = p.id ORDER BY mt.id DESC LIMIT 1) AS summary
|
||||
FROM pages p
|
||||
LEFT JOIN workspaces w ON w.id = p.workspace_id
|
||||
WHERE p.deleted_at IS NULL
|
||||
AND (w.owner_id = ? OR p.workspace_id IS NULL)
|
||||
AND {pred}
|
||||
ORDER BY p.updated_at DESC LIMIT ?""",
|
||||
(uid, lim),
|
||||
).fetchall()
|
||||
notes = []
|
||||
for r in rows:
|
||||
d = row_to_dict(r)
|
||||
notes.append({
|
||||
"id": d["id"],
|
||||
"title": d.get("title") or "Untitled",
|
||||
"updated_at": d.get("updated_at") or "",
|
||||
"status": d.get("status") or "idle",
|
||||
"has_summary": bool((d.get("summary") or "").strip()),
|
||||
})
|
||||
return {"notes": notes, "count": len(notes)}
|
||||
|
||||
|
||||
@router.get("/api/v2/meetings/history")
|
||||
def meetings_history(request: Request, query: str = "", limit: int = 50):
|
||||
"""Historique des notes de réunion (passé + recherche plein texte)."""
|
||||
user = _auth_user(request)
|
||||
_ = user
|
||||
like = f"%{query.strip()}%" if query.strip() else "%"
|
||||
lim = max(1, min(int(limit or 50), 200))
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT mt.*, p.title AS page_title FROM meeting_transcripts mt
|
||||
JOIN pages p ON p.id = mt.page_id
|
||||
WHERE (p.title LIKE ? OR mt.transcript LIKE ?
|
||||
OR mt.summary LIKE ? OR mt.generated_title LIKE ?)
|
||||
ORDER BY mt.id DESC LIMIT ?""",
|
||||
(like, like, like, like, lim),
|
||||
).fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
d = row_to_dict(r)
|
||||
d["title"] = d.get("generated_title") or d.get("page_title") or "Meeting"
|
||||
out.append(d)
|
||||
return {"meetings": out, "count": len(out)}
|
||||
|
||||
|
||||
@router.get("/api/v2/meetings/transcripts/{transcript_id}")
|
||||
def get_transcript_row(transcript_id: int, request: Request):
|
||||
_auth_user(request)
|
||||
try:
|
||||
return meet.get_transcript(transcript_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(404, str(exc)) from None
|
||||
|
||||
|
||||
@router.post("/api/v2/meetings/transcripts/{transcript_id}/consents")
|
||||
def post_consent(transcript_id: int, request: Request, body: dict = Body(default={})):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
entry = meet.record_consent(
|
||||
transcript_id, method=(body.get("method") or "start_attestation"),
|
||||
message_ref=(body.get("message_ref") or "")[:200],
|
||||
attested_by=user.get("id"),
|
||||
participants=body.get("participants") or [])
|
||||
except ValueError as exc:
|
||||
msg = str(exc)
|
||||
raise HTTPException(404 if "not found" in msg else 400, msg) from None
|
||||
audit_log(user, "meeting.consent", "transcript", transcript_id,
|
||||
entry["method"], request)
|
||||
return entry
|
||||
|
||||
|
||||
@router.post("/api/v2/meetings/transcripts/{transcript_id}/sessions")
|
||||
def start_session(transcript_id: int, request: Request, body: dict = Body(default={})):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
out = meet.start_session(
|
||||
transcript_id, mode=(body.get("mode") or "mic_only"),
|
||||
channels=body.get("channels"), language=(body.get("language") or "fr"),
|
||||
instruction_id=(body.get("instruction_id") or "auto"),
|
||||
user_id=user.get("id"))
|
||||
except ValueError as exc:
|
||||
msg = str(exc)
|
||||
raise HTTPException(404 if "not found" in msg else 400, msg) from None
|
||||
audit_log(user, "meeting.session.start", "transcript", transcript_id,
|
||||
out.get("mode", ""), request)
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/api/v2/meetings/transcripts/{transcript_id}/pause")
|
||||
def pause_session(transcript_id: int, request: Request, body: dict = Body(default={})):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
out = meet.pause_session(transcript_id,
|
||||
paused_ms_delta=int(body.get("paused_ms") or 0))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from None
|
||||
audit_log(user, "meeting.session.pause", "transcript", transcript_id, "", request)
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/api/v2/meetings/transcripts/{transcript_id}/resume")
|
||||
def resume_session(transcript_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
out = meet.resume_session(transcript_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from None
|
||||
audit_log(user, "meeting.session.resume", "transcript", transcript_id, "", request)
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/api/v2/meetings/transcripts/{transcript_id}/stop")
|
||||
async def stop_session(transcript_id: int, request: Request,
|
||||
body: dict = Body(default={})):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
stopped = meet.stop_session(transcript_id,
|
||||
duration_ms=int((body or {}).get("duration_ms") or 0))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from None
|
||||
audit_log(user, "meeting.session.stop", "transcript", transcript_id, "", request)
|
||||
# Le traitement démarre aussitôt (étapes Thinking persistées) ; en cas
|
||||
# d'échec LLM le transcript reste consultable (état failed, retry).
|
||||
# allow_empty : arrêt sans rien de capté → diagnostic honnête (§12.8),
|
||||
# jamais une ligne coincée en « processing ».
|
||||
try:
|
||||
out = await meet.run_processing(
|
||||
transcript_id, user.get("id"),
|
||||
allow_empty=bool((body or {}).get("allow_empty")))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from None
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(502, str(exc)) from None
|
||||
return {"session": {"id": stopped["id"], "status": "done"}, **out}
|
||||
|
||||
|
||||
@router.post("/api/v2/meetings/transcripts/{transcript_id}/segments")
|
||||
def append_segments(transcript_id: int, request: Request,
|
||||
body: dict = Body(default={})):
|
||||
_auth_user(request, require_write=True)
|
||||
try:
|
||||
return meet.append_segments(transcript_id, body.get("segments") or [])
|
||||
except ValueError as exc:
|
||||
msg = str(exc)
|
||||
raise HTTPException(404 if "not found" in msg else 400, msg) from None
|
||||
|
||||
|
||||
@router.get("/api/v2/meetings/transcripts/{transcript_id}/transcript")
|
||||
def grouped_transcript(transcript_id: int, request: Request):
|
||||
_auth_user(request)
|
||||
try:
|
||||
return meet.grouped_transcript(transcript_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(404, str(exc)) from None
|
||||
|
||||
|
||||
@router.patch("/api/v2/meetings/transcripts/{transcript_id}/segments/{seq}/speaker")
|
||||
def patch_speaker(transcript_id: int, seq: int, request: Request,
|
||||
body: dict = Body(default={})):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
return meet.set_segment_speaker(transcript_id, seq,
|
||||
body.get("speaker") or "",
|
||||
user.get("id"))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from None
|
||||
|
||||
|
||||
@router.get("/api/v2/meetings/transcripts/{transcript_id}/processing-steps")
|
||||
def processing_steps(transcript_id: int, request: Request):
|
||||
_auth_user(request)
|
||||
try:
|
||||
tr = meet.get_transcript(transcript_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(404, str(exc)) from None
|
||||
import json as _json
|
||||
try:
|
||||
steps = _json.loads(tr.get("processing_steps_json") or "[]")
|
||||
except (TypeError, ValueError):
|
||||
steps = []
|
||||
return {"transcript_id": transcript_id, "status": tr.get("status") or "idle",
|
||||
"steps": steps}
|
||||
|
||||
|
||||
@router.post("/api/v2/meetings/transcripts/{transcript_id}/processing:retry")
|
||||
async def retry_processing(transcript_id: int, request: Request,
|
||||
body: dict = Body(default={})):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
tr = meet.get_transcript(transcript_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(404, str(exc)) from None
|
||||
if tr.get("status") not in ("failed", "processing", "done"):
|
||||
raise HTTPException(400, "nothing to retry — no failed processing")
|
||||
try:
|
||||
return await meet.run_processing(
|
||||
transcript_id, user.get("id"),
|
||||
allow_empty=bool((body or {}).get("allow_empty")))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from None
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(502, str(exc)) from None
|
||||
|
||||
|
||||
@router.patch("/api/v2/meetings/transcripts/{transcript_id}/generated-title")
|
||||
def patch_title(transcript_id: int, request: Request, body: dict = Body(default={})):
|
||||
user = _auth_user(request, require_write=True)
|
||||
title = (body.get("title") or "").strip()[:200]
|
||||
if not title:
|
||||
raise HTTPException(400, "title required")
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM meeting_transcripts WHERE id=?",
|
||||
(transcript_id,)).fetchone():
|
||||
raise HTTPException(404, "Transcript not found")
|
||||
conn.execute("UPDATE meeting_transcripts SET generated_title=?,"
|
||||
" title_source='user' WHERE id=?", (title, transcript_id))
|
||||
conn.commit()
|
||||
audit_log(user, "meeting.retitle", "transcript", transcript_id, title, request)
|
||||
return {"transcript_id": transcript_id, "generated_title": title}
|
||||
|
||||
|
||||
@router.post("/api/v2/meetings/transcripts/{transcript_id}/distributions")
|
||||
def post_distribution(transcript_id: int, request: Request,
|
||||
body: dict = Body(default={})):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
out = meet.record_distribution(
|
||||
transcript_id, channel=(body.get("channel") or "copy_link"),
|
||||
actor_id=user.get("id"), recipients=(body.get("recipients") or "")[:500],
|
||||
status=body.get("status") or "created")
|
||||
except ValueError as exc:
|
||||
msg = str(exc)
|
||||
raise HTTPException(404 if "not found" in msg else 400, msg) from None
|
||||
audit_log(user, "meeting.share", "transcript", transcript_id,
|
||||
out["channel"], request)
|
||||
return out
|
||||
|
||||
|
||||
@router.patch("/api/v2/meetings/transcripts/{transcript_id}/notes")
|
||||
def patch_notes(transcript_id: int, request: Request, body: dict = Body(default={})):
|
||||
_auth_user(request, require_write=True)
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM meeting_transcripts WHERE id=?",
|
||||
(transcript_id,)).fetchone():
|
||||
raise HTTPException(404, "Transcript not found")
|
||||
conn.execute("UPDATE meeting_transcripts SET notes_text=? WHERE id=?",
|
||||
((body.get("notes") or "")[:50000], transcript_id))
|
||||
conn.commit()
|
||||
return {"transcript_id": transcript_id, "saved": True}
|
||||
|
||||
+374
-98
@@ -1,147 +1,423 @@
|
||||
"""FlowDeck — My Tasks router (v1.9.0)."""
|
||||
"""FlowDeck — My Tasks (tableau de bord de tâches façon Notion).
|
||||
|
||||
Contrairement à Notion, où *My Tasks* vit dans l'onglet **Home** sans être
|
||||
rattaché à une page, cette page est un **tableau de bord transverse** : elle
|
||||
réunit toutes les tâches qui vous sont assignées, **tous workspaces
|
||||
confondus**. Le cookie `flowdeck_workspace` ne cadre donc plus cette vue (il
|
||||
reste utilisé par la sidebar).
|
||||
|
||||
Les sources sont opt-in : une base n'alimente My Tasks qu'après conversion
|
||||
explicite (`collections.is_task` + mapping des trois propriétés requises —
|
||||
`app/services/task_databases.py`).
|
||||
|
||||
Trois vues (tableau / kanban / calendrier), filtres globaux (échéance
|
||||
aujourd'hui, masquer les terminées), tri (échéance, statut, source), édition
|
||||
en direct du statut et de l'échéance, et création rapide dans n'importe
|
||||
quelle base connectée.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.property_types import apply_auto_properties
|
||||
from app.services.task_databases import (
|
||||
MAX_TASK_SOURCES,
|
||||
collect_tasks,
|
||||
filter_and_sort,
|
||||
list_task_sources,
|
||||
user_today,
|
||||
)
|
||||
from app.templating import ENV
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["my-tasks"], prefix="/my-tasks")
|
||||
|
||||
VIEWS = ("table", "board", "calendar")
|
||||
DUE_FILTERS = ("all", "today", "overdue", "week")
|
||||
SORTS = ("due", "status", "source", "created")
|
||||
|
||||
|
||||
# ── Session ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_current_user(request: Request) -> dict | None:
|
||||
session = request.cookies.get("flowdeck_session", "")
|
||||
return SessionManager.decode_session(session)
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
def my_tasks_dashboard(request: Request, view: str = "all", days: int = 7):
|
||||
"""My Tasks — aggregates all pages assigned to the current user across all collections."""
|
||||
def _require_user(request: Request) -> dict:
|
||||
"""Session obligatoire, sans repli « admin » (id 1) : ce tableau de bord
|
||||
est strictement celui de l'appelant."""
|
||||
user = _get_current_user(request)
|
||||
user_login = user.get("login", "admin") if user else "admin"
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
return user
|
||||
|
||||
with get_conn() as conn:
|
||||
collections = conn.execute("SELECT * FROM collections ORDER BY name").fetchall()
|
||||
grouped: dict[str, list[dict]] = {}
|
||||
|
||||
for col in collections:
|
||||
col_id = col["id"]
|
||||
pages = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE collection_id=? AND parent_id IS NULL ORDER BY position",
|
||||
(col_id,),
|
||||
).fetchall()
|
||||
# ── Données ──────────────────────────────────────────────────────────────
|
||||
|
||||
collection_tasks = []
|
||||
for p in pages:
|
||||
props = {}
|
||||
try:
|
||||
props = json.loads(p["property_values_json"] or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
due_date = props.get("due_date", "")
|
||||
status = props.get("status", "—")
|
||||
assignee = props.get("assignee", "")
|
||||
if view != "all" and assignee and assignee != user_login:
|
||||
continue
|
||||
collection_tasks.append({
|
||||
"id": p["id"],
|
||||
"title": p["title"] or "Untitled",
|
||||
"icon": p["icon"] or "📋",
|
||||
"status": status,
|
||||
"due_date": due_date,
|
||||
})
|
||||
if collection_tasks:
|
||||
grouped[col["name"]] = collection_tasks
|
||||
def _workspace_names(conn, user_id: int) -> dict[int, str]:
|
||||
rows = conn.execute(
|
||||
"SELECT id, name FROM workspaces WHERE owner_id = ?", (user_id,)
|
||||
).fetchall()
|
||||
return {r["id"]: r["name"] for r in rows}
|
||||
|
||||
total = sum(len(t) for t in grouped.values())
|
||||
|
||||
# Build content HTML
|
||||
sections = ""
|
||||
for col_name, tasks in grouped.items():
|
||||
items = ""
|
||||
for t in tasks:
|
||||
due_badge = f"<span class='mt-due'>{t['due_date']}</span>" if t.get("due_date") else ""
|
||||
status_cls = t['status'].lower().replace(' ', '-') if t.get('status') else 'none'
|
||||
status_badge = f"<span class='mt-status mt-{status_cls}'>{t.get('status', '—')}</span>"
|
||||
items += f"<div class='mt-item'><span class='mt-icon'>{t['icon']}</span><span class='mt-title'>{t['title']}</span>{status_badge}{due_badge}</div>"
|
||||
def _validated(view: str, due: str, sort: str) -> tuple[str, str, str]:
|
||||
"""Paramètres d'URL bornés aux valeurs réellement implémentées."""
|
||||
return (
|
||||
view if view in VIEWS else "table",
|
||||
due if due in DUE_FILTERS else "all",
|
||||
sort if sort in SORTS else "due",
|
||||
)
|
||||
|
||||
sections += f"<div class='mt-section'><div class='mt-section-header'>{col_name} <span class='mt-count'>{len(tasks)}</span></div>{items}</div>"
|
||||
|
||||
content_html = f"""<div style="max-width:800px;margin:0 auto;padding:40px 24px;">
|
||||
<h1 style="font-size:24px;margin:0 0 8px;">📋 My Tasks</h1>
|
||||
<p style="color:var(--text-dim);font-size:14px;margin-bottom:24px;">{total} tasks across {len(grouped)} collections</p>
|
||||
<div class="view-tabs" style="display:flex;gap:4px;margin-bottom:24px;border-bottom:1px solid var(--border);padding-bottom:12px;">
|
||||
<a class="tab{' active' if view=='all' else ''}" href="/my-tasks?view=all" style="padding:6px 14px;border-radius:6px;cursor:pointer;color:var(--text-dim);font-size:13px;text-decoration:none;">All</a>
|
||||
<a class="tab{' active' if view=='today' else ''}" href="/my-tasks?view=today" style="padding:6px 14px;border-radius:6px;cursor:pointer;color:var(--text-dim);font-size:13px;text-decoration:none;">Today</a>
|
||||
<a class="tab{' active' if view=='overdue' else ''}" href="/my-tasks?view=overdue" style="padding:6px 14px;border-radius:6px;cursor:pointer;color:var(--text-dim);font-size:13px;text-decoration:none;">Overdue</a>
|
||||
<a class="tab{' active' if view=='upcoming' else ''}" href="/my-tasks?view=upcoming&days=7" style="padding:6px 14px;border-radius:6px;cursor:pointer;color:var(--text-dim);font-size:13px;text-decoration:none;">Next 7 days</a>
|
||||
</div>
|
||||
<style>
|
||||
.tab.active{{background:var(--accent);color:#fff!important;}}
|
||||
.tab:hover{{background:var(--bg-hover);color:var(--text);}}
|
||||
.mt-section{{margin-bottom:24px;}}
|
||||
.mt-section-header{{font-size:13px;font-weight:600;color:var(--text-dim);margin-bottom:8px;text-transform:uppercase;letter-spacing:.5px;}}
|
||||
.mt-count{{color:var(--text-dim);font-weight:400;opacity:.5;}}
|
||||
.mt-item{{display:flex;align-items:center;gap:10px;padding:10px 12px;background:var(--bg-card);border-radius:8px;margin-bottom:3px;border:1px solid var(--border);}}
|
||||
.mt-item:hover{{border-color:var(--accent);}}
|
||||
.mt-icon{{font-size:16px;}}
|
||||
.mt-title{{flex:1;font-size:14px;}}
|
||||
.mt-status{{font-size:11px;padding:2px 8px;border-radius:4px;}}
|
||||
.mt-due{{font-size:11px;color:var(--text-dim);}}
|
||||
.mt-todo,.mt-none{{background:rgba(255,255,255,.05);color:var(--text-dim);}}
|
||||
.mt-in-progress{{background:rgba(35,131,226,.15);color:#2C8CEB;}}
|
||||
.mt-done,.mt-completed,.mt-complete{{background:rgba(0,200,100,.15);color:#00CC66;}}
|
||||
</style>
|
||||
{sections}
|
||||
</div>"""
|
||||
def _public_source(s: dict) -> dict:
|
||||
return {
|
||||
"id": s["id"],
|
||||
"name": s["name"],
|
||||
"icon": s["icon"],
|
||||
"workspace_id": s["workspace_id"],
|
||||
"workspace_name": s.get("workspace_name") or "",
|
||||
"configured": s["configured"],
|
||||
"missing_roles": s["missing_roles"],
|
||||
"status_options": s["status_options"],
|
||||
}
|
||||
|
||||
|
||||
def _payload(conn, request: Request, user_id: int, *, due: str, sort: str,
|
||||
hide_done: bool) -> dict:
|
||||
"""Charge et normalise tout ce dont les trois vues ont besoin."""
|
||||
user = _get_current_user(request) or {}
|
||||
tasks = collect_tasks(conn, user_id, mine_only=True,
|
||||
login=user.get("login", ""))
|
||||
# Même jour de référence que `due_state` (fuseau de l'utilisateur).
|
||||
tasks = filter_and_sort(tasks, due=due, hide_done=hide_done, sort=sort,
|
||||
today=user_today(conn, user_id))
|
||||
sources = list_task_sources(conn, user_id)
|
||||
return {
|
||||
"tasks": tasks,
|
||||
"total": len(tasks),
|
||||
"sources": [_public_source(s) for s in sources],
|
||||
"sources_total": len(sources),
|
||||
"sources_configured": sum(1 for s in sources if s["configured"]),
|
||||
"max_sources": MAX_TASK_SOURCES,
|
||||
"filters": {"due": due, "sort": sort, "hide_done": hide_done},
|
||||
}
|
||||
|
||||
|
||||
# ── Rendu HTML ───────────────────────────────────────────────────────────
|
||||
|
||||
def _render_shell(request: Request, content_html: str, *, app_js: bool = True):
|
||||
"""Enveloppe le contenu dans le layout commun (sidebar + base.html).
|
||||
|
||||
`static/css/my_tasks.css` est lié par `base.html` (le shell, jamais swappé)
|
||||
: un `<link>` placé dans la zone swappée pouvait être retiré par htmx lors
|
||||
d'une navigation partielle, et la page revenait sans aucun style. Seul
|
||||
`static/js/my_tasks.js` reste conditionné à ``app_js`` : sans base
|
||||
connectée, il n'y a rien à monter côté client.
|
||||
"""
|
||||
from app.routers.dashboard import _sidebar_data
|
||||
env = ENV
|
||||
|
||||
sidebar = _sidebar_data(request, [])
|
||||
block_tpl = env.from_string(
|
||||
'{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}'
|
||||
# `assets` est rendu par un mini-template : `asset_version` (cache-busting)
|
||||
# n'est pas substitutionné si on le concatène à la main.
|
||||
assets = ENV.from_string(
|
||||
"{% if app_js %}"
|
||||
'<script src="/static/js/my_tasks.js?v={{ asset_version }}" defer></script>'
|
||||
"{% endif %}"
|
||||
).render(app_js=app_js)
|
||||
|
||||
block_tpl = ENV.from_string(
|
||||
'{% extends "base.html" %}'
|
||||
"{% block content %}{{ assets|safe }}{{ content_html|safe }}{% endblock %}"
|
||||
)
|
||||
return block_tpl.render(
|
||||
**sidebar,
|
||||
request=request,
|
||||
content_html=content_html,
|
||||
assets=assets,
|
||||
page_title="My Tasks",
|
||||
title_prefix="My Tasks",
|
||||
page_icon="📋",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api")
|
||||
def my_tasks_api(request: Request, view: str = "all", days: int = 7):
|
||||
"""API: return my tasks as JSON."""
|
||||
def _json_attr(obj) -> str:
|
||||
"""JSON échappé pour un attribut HTML."""
|
||||
return html.escape(json.dumps(obj, ensure_ascii=False), quote=True)
|
||||
|
||||
|
||||
def _render_app(data: dict, view: str) -> str:
|
||||
"""Coquille du tableau de bord : barre d'outils, filtres et zone de vue
|
||||
sont rendus côté client à partir de la configuration ci-dessous."""
|
||||
config = {
|
||||
"view": view,
|
||||
"filters": data["filters"],
|
||||
"sources": data["sources"],
|
||||
"maxSources": data["max_sources"],
|
||||
"views": list(VIEWS),
|
||||
}
|
||||
return ('<div id="my-tasks-app" class="my-tasks-app" '
|
||||
f'data-config="{_json_attr(config)}"></div>')
|
||||
|
||||
|
||||
def _render_empty(data: dict) -> str:
|
||||
"""État vide « aucune source » : explique la conversion en base de tâches.
|
||||
|
||||
Gabarit centré (`my_tasks-empty*` dans `static/css/my_tasks.css`), sans
|
||||
style inline : la mise en page suit le thème clair/sombre et reste
|
||||
modifiable en un seul endroit. Les autres états vides (filtres actifs,
|
||||
rien à faire) sont rendus par le client, qui connaît l'état des filtres.
|
||||
"""
|
||||
return f"""<div class="my-tasks-empty">
|
||||
<div class="my-tasks-empty-icon">📋</div>
|
||||
<h2>My Tasks</h2>
|
||||
<p>Aucune base n'alimente encore ce tableau de bord. Pour en ajouter une :</p>
|
||||
<ol class="my-tasks-empty-steps">
|
||||
<li><span class="n">1</span><span>Ouvrez une base de données depuis
|
||||
<a href="/db">la liste de vos bases</a>.</span></li>
|
||||
<li><span class="n">2</span><span>Dans la barre de la base, cliquez sur
|
||||
<b>« Convertir en base de tâches »</b> — il se trouve à gauche du bouton
|
||||
<b>New</b>.</span></li>
|
||||
<li><span class="n">3</span><span>Reliez les colonnes <b>Assigné à</b>,
|
||||
<b>Statut</b> et <b>Échéance</b>, puis validez.</span></li>
|
||||
</ol>
|
||||
<p class="my-tasks-empty-hint">Jusqu'à {data["max_sources"]} bases
|
||||
peuvent alimenter ce tableau de bord.</p>
|
||||
<a href="/db" class="my-tasks-empty-btn">Ouvrir mes bases</a>
|
||||
</div>"""
|
||||
|
||||
|
||||
def _render_no_workspace() -> str:
|
||||
return """<div class="my-tasks-empty">
|
||||
<div class="my-tasks-empty-icon">📋</div>
|
||||
<h2>My Tasks</h2>
|
||||
<p>Aucun workspace. Créez un workspace, puis une base de tâches : elle
|
||||
alimentera ce tableau de bord.</p>
|
||||
<a href="/workspaces" class="my-tasks-empty-btn">Créer un workspace</a>
|
||||
</div>"""
|
||||
|
||||
|
||||
|
||||
# ── Routes ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
def my_tasks_dashboard(request: Request, view: str = "table", due: str = "all",
|
||||
sort: str = "due", hide_done: bool = False):
|
||||
"""My Tasks — toutes vos tâches, tous workspaces confondus."""
|
||||
user = _get_current_user(request)
|
||||
user.get("login", "admin") if user else "admin"
|
||||
if not user or not user.get("id"):
|
||||
return RedirectResponse("/auth/login?provider=local", status_code=302)
|
||||
|
||||
view, due, sort = _validated(view, due, sort)
|
||||
with get_conn() as conn:
|
||||
if not _workspace_names(conn, user["id"]):
|
||||
return _render_shell(request, _render_no_workspace(), app_js=False)
|
||||
data = _payload(conn, request, user["id"], due=due, sort=sort,
|
||||
hide_done=hide_done)
|
||||
|
||||
# Dès qu'au moins une base alimente le tableau de bord, le rendu est pris
|
||||
# en charge par le client (3 vues + états vides contextualisés). Sans
|
||||
# aucune source, le serveur affiche l'invite à convertir une base — le
|
||||
# fichier JS n'est alors pas chargé.
|
||||
if data["total"] or data["sources_total"]:
|
||||
return _render_shell(request, _render_app(data, view))
|
||||
return _render_shell(request, _render_empty(data), app_js=False)
|
||||
|
||||
|
||||
@router.get("/api")
|
||||
def my_tasks_api(request: Request, view: str = "table", due: str = "all",
|
||||
sort: str = "due", hide_done: bool = False):
|
||||
"""API: tâches normalisées + sources connectées (alimente les 3 vues)."""
|
||||
user = _require_user(request)
|
||||
view, due, sort = _validated(view, due, sort)
|
||||
with get_conn() as conn:
|
||||
return _payload(conn, request, user["id"], due=due, sort=sort,
|
||||
hide_done=hide_done)
|
||||
|
||||
|
||||
@router.post("/api/task")
|
||||
def my_tasks_create(request: Request, body: dict = Body(default={})):
|
||||
"""Création rapide multi-destinations : la tâche part dans la base choisie.
|
||||
|
||||
Elle est **auto-assignée** : sans colonne « Assigné à » renseignée, une
|
||||
tâche nouvelle n'apparaîtrait pas dans « mes tâches », ce qui serait
|
||||
illisible pour l'utilisateur qui vient de la créer.
|
||||
"""
|
||||
user = _require_user(request)
|
||||
collection_id = body.get("collection_id")
|
||||
title = str(body.get("title") or "").strip()
|
||||
if not collection_id:
|
||||
raise HTTPException(status_code=400, detail="collection_id is required")
|
||||
if not title:
|
||||
raise HTTPException(status_code=400, detail="title is required")
|
||||
|
||||
with get_conn() as conn:
|
||||
collections = conn.execute("SELECT * FROM collections ORDER BY name").fetchall()
|
||||
result = {}
|
||||
coll = conn.execute(
|
||||
"SELECT id, task_assignee_prop, task_due_prop FROM collections "
|
||||
"WHERE id=? AND is_task=1",
|
||||
(collection_id,),
|
||||
).fetchone()
|
||||
if coll is None:
|
||||
raise HTTPException(status_code=400,
|
||||
detail="This database does not feed My Tasks")
|
||||
|
||||
for col in collections:
|
||||
pages = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE collection_id=? AND parent_id IS NULL ORDER BY position",
|
||||
(col["id"],),
|
||||
props_meta = [
|
||||
dict(p) for p in conn.execute(
|
||||
"SELECT id, name, prop_type FROM collection_properties "
|
||||
"WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
]
|
||||
|
||||
tasks = []
|
||||
for p in pages:
|
||||
props = json.loads(p["property_values_json"])
|
||||
tasks.append({
|
||||
"id": p["id"], "title": p["title"], "icon": p["icon"],
|
||||
"properties": props,
|
||||
})
|
||||
properties: dict = {}
|
||||
title_prop = next((p for p in props_meta if p["prop_type"] == "title"), None)
|
||||
if title_prop:
|
||||
properties[str(title_prop["id"])] = title
|
||||
|
||||
if tasks:
|
||||
result[col["name"]] = tasks
|
||||
if body.get("due") and coll["task_due_prop"]:
|
||||
properties[str(coll["task_due_prop"])] = str(body["due"])[:10]
|
||||
|
||||
return {"tasks": result, "total": sum(len(t) for t in result.values())}
|
||||
if coll["task_assignee_prop"]:
|
||||
member = conn.execute(
|
||||
"SELECT id, login, full_name, avatar_url, avatar_color "
|
||||
"FROM users WHERE id=?",
|
||||
(user["id"],),
|
||||
).fetchone()
|
||||
if member:
|
||||
properties[str(coll["task_assignee_prop"])] = [{
|
||||
"id": member["id"],
|
||||
"login": member["login"],
|
||||
"full_name": member["full_name"],
|
||||
"avatar_url": member["avatar_url"] or "",
|
||||
"avatar_color": member["avatar_color"] or "",
|
||||
}]
|
||||
|
||||
apply_auto_properties(props_meta, properties, user, is_create=True)
|
||||
|
||||
nxt = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages "
|
||||
"WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchone()[0]
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collection_pages
|
||||
(collection_id, title, icon, position, property_values_json)
|
||||
VALUES (?, ?, '📄', ?, ?)""",
|
||||
(collection_id, title, nxt, json.dumps(properties)),
|
||||
)
|
||||
page_id = cur.lastrowid
|
||||
conn.commit()
|
||||
|
||||
if title_prop:
|
||||
from app.services.row_pages import sync_row_title_to_page
|
||||
|
||||
sync_row_title_to_page(conn, page_id)
|
||||
conn.commit()
|
||||
|
||||
logger.info("my-tasks: task %s created in collection %s", page_id, collection_id)
|
||||
return {"status": "created", "id": page_id, "collection_id": collection_id,
|
||||
"title": title}
|
||||
|
||||
|
||||
@router.put("/api/task/{page_id}")
|
||||
def my_tasks_update(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
"""Édition en direct du statut / de l'échéance / du titre.
|
||||
|
||||
L'écriture passe par le **mapping enregistré de la base source** (et non
|
||||
par une devinette « première colonne de type X »), puis la tâche est relue
|
||||
et renvoyée normalisée : le front n'a rien à recalculer.
|
||||
"""
|
||||
user = _require_user(request)
|
||||
field = body.get("field")
|
||||
if field not in ("status", "due", "title", "assignee"):
|
||||
raise HTTPException(status_code=400, detail="Unsupported field")
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, collection_id, title, property_values_json "
|
||||
"FROM collection_pages WHERE id=?",
|
||||
(page_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
coll = conn.execute(
|
||||
"SELECT id, is_task, task_assignee_prop, task_status_prop, task_due_prop "
|
||||
"FROM collections WHERE id=?",
|
||||
(row["collection_id"],),
|
||||
).fetchone()
|
||||
if coll is None:
|
||||
raise HTTPException(status_code=404, detail="Source not found")
|
||||
if not coll["is_task"]:
|
||||
raise HTTPException(status_code=400,
|
||||
detail="This database no longer feeds My Tasks")
|
||||
|
||||
column = {"status": coll["task_status_prop"],
|
||||
"due": coll["task_due_prop"],
|
||||
"assignee": coll["task_assignee_prop"]}.get(field)
|
||||
if field != "title" and not column:
|
||||
raise HTTPException(status_code=400,
|
||||
detail=f"This database has no {field} column")
|
||||
|
||||
try:
|
||||
pv = json.loads(row["property_values_json"] or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pv = {}
|
||||
if not isinstance(pv, dict):
|
||||
pv = {}
|
||||
|
||||
new_title = None
|
||||
if field == "title":
|
||||
new_title = str(body.get("value") or "").strip()
|
||||
title_prop = conn.execute(
|
||||
"SELECT id FROM collection_properties "
|
||||
"WHERE collection_id=? AND prop_type='title' ORDER BY position LIMIT 1",
|
||||
(coll["id"],),
|
||||
).fetchone()
|
||||
if title_prop:
|
||||
pv[str(title_prop["id"])] = new_title
|
||||
elif field == "assignee":
|
||||
# La valeur est une liste de personnes : elle est écrite telle
|
||||
# quelle (booleenner écraserait la colonne).
|
||||
pv[str(column)] = body.get("value") or []
|
||||
elif field == "due":
|
||||
pv[str(column)] = str(body.get("value") or "")[:10]
|
||||
else:
|
||||
pv[str(column)] = str(body.get("value") or "")
|
||||
|
||||
if new_title is not None:
|
||||
conn.execute(
|
||||
"UPDATE collection_pages SET title=?, property_values_json=?, "
|
||||
"updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(new_title, json.dumps(pv), page_id),
|
||||
)
|
||||
from app.services.row_pages import sync_row_title_to_page
|
||||
|
||||
sync_row_title_to_page(conn, page_id)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE collection_pages SET property_values_json=?, "
|
||||
"updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(json.dumps(pv), page_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
tasks = collect_tasks(conn, user["id"], mine_only=True,
|
||||
login=user.get("login", ""))
|
||||
updated = next((t for t in tasks if t["id"] == page_id), None)
|
||||
|
||||
if updated is None:
|
||||
# La tâche vient de cesser d'être « mienne » (désassignée) : le front
|
||||
# doit la retirer, pas la re-rendre à l'identique.
|
||||
return {"status": "updated", "task": None, "left_mytasks": True}
|
||||
|
||||
return {"status": "updated", "task": updated, "left_mytasks": False}
|
||||
|
||||
+2
-2
@@ -18,6 +18,7 @@ from fastapi.responses import JSONResponse
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.api_v2_helpers import audit_log
|
||||
from app.services.http_client import shared_client
|
||||
|
||||
router = APIRouter(tags=["scim"])
|
||||
SCIM_SCHEMAS = ["urn:ietf:params:scim:schemas:core:2.0:User"]
|
||||
@@ -246,7 +247,6 @@ def create_domain(request: Request, body: dict = Body(default={})):
|
||||
@router.post("/api/v2/domain-claims/{domain_id}/verify")
|
||||
async def verify_domain(domain_id: int, request: Request):
|
||||
admin = _admin_session(request)
|
||||
import httpx
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM domain_claims WHERE id=?", (domain_id,)).fetchone()
|
||||
if not row:
|
||||
@@ -254,7 +254,7 @@ async def verify_domain(domain_id: int, request: Request):
|
||||
claim = dict(row)
|
||||
url = f"https://{claim['domain']}/.well-known/flowdeck-verify.txt"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10, follow_redirects=True) as client:
|
||||
async with shared_client(timeout=10, follow_redirects=True) as client:
|
||||
resp = await client.get(url)
|
||||
ok = resp.status_code == 200 and claim["txt_token"] in (resp.text or "")
|
||||
except Exception: # noqa: BLE001 — unreachable domain = not verified
|
||||
|
||||
+2
-2
@@ -24,6 +24,7 @@ from app.auth.providers import oidc_provider, saml_provider
|
||||
from app.auth.session import SessionManager
|
||||
from app.services import sso_provisioning as sso
|
||||
from app.services.api_v2_helpers import has_scope, resolve_bearer_token
|
||||
from app.services.http_client import shared_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["sso"])
|
||||
@@ -436,10 +437,9 @@ async def _fetch_jwks(doc: dict) -> dict:
|
||||
url = doc.get("jwks_uri")
|
||||
if not url:
|
||||
raise oidc_provider.OIDCError("Discovery document has no jwks_uri")
|
||||
import httpx
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
r = await client.get(url)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
|
||||
+50
-19
@@ -8,7 +8,7 @@ import json
|
||||
import logging
|
||||
import sqlite3
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, StreamingResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -16,14 +16,32 @@ from app.db import get_conn
|
||||
from app.services.automations import fire_event, run_event_sync
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["workspace"], prefix="/workspace")
|
||||
|
||||
ROLES = ["admin", "editor", "commenter", "viewer"]
|
||||
|
||||
|
||||
def _require_session(request: Request) -> dict:
|
||||
"""A3/A4 — session obligatoire sur TOUT le router `/workspace`.
|
||||
|
||||
Avant, ces routes s'exécutaient sans session : `GET /workspace/collections/
|
||||
{id}/export/csv` renvoyait le contenu réel d'une collection à un visiteur
|
||||
anonyme, et les POST créaient des données au nom de l'utilisateur 1.
|
||||
La seule route publique (`/workspace/public/...`) vit désormais dans
|
||||
``public_router``, sans dépendance.
|
||||
"""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
return user
|
||||
|
||||
|
||||
router = APIRouter(tags=["workspace"], prefix="/workspace", dependencies=[Depends(_require_session)])
|
||||
# Route de partage public : AUCUNE dépendance d'authentification.
|
||||
public_router = APIRouter(tags=["workspace-public"], prefix="/workspace")
|
||||
|
||||
|
||||
def _current_user(request: Request) -> dict:
|
||||
s = request.cookies.get("flowdeck_session", "")
|
||||
return SessionManager.decode_session(s) or {"login": "admin", "id": 1}
|
||||
"""Session obligatoire — plus de repli « admin implicite » (A3/A4)."""
|
||||
return _require_session(request)
|
||||
|
||||
|
||||
def _require_admin(request: Request) -> dict:
|
||||
@@ -209,45 +227,58 @@ def record_history(request: Request, page_id: int, body: dict = Body(default={})
|
||||
|
||||
@router.get("/favorites")
|
||||
def list_favorites(request: Request):
|
||||
user = _current_user(request)
|
||||
uid = user.get("id", 1)
|
||||
"""List the caller's favorites.
|
||||
|
||||
v7.46.0 — aligne sur le schéma v2.2.0 (``app/db.py``) : la table
|
||||
``favorites`` n'a plus de colonne ``collection_id`` et ``page_id``
|
||||
référence ``pages(id)``. L'ancienne requête (jointure ``collection_pages``)
|
||||
levait ``sqlite3.OperationalError: no such column: f.collection_id`` → 500.
|
||||
"""
|
||||
user = _require_session(request)
|
||||
uid = user["id"]
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT f.*, cp.title as page_title, c.name as collection_name
|
||||
"""SELECT f.*, p.title AS page_title
|
||||
FROM favorites f
|
||||
LEFT JOIN collection_pages cp ON f.page_id=cp.id
|
||||
LEFT JOIN collections c ON f.collection_id=c.id
|
||||
LEFT JOIN pages p ON f.page_id=p.id
|
||||
WHERE f.user_id=? ORDER BY f.position""",
|
||||
(uid,),
|
||||
).fetchall()
|
||||
return {"favorites": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
|
||||
@router.post("/favorites")
|
||||
def add_favorite(request: Request, body: dict = Body(default={})):
|
||||
user = _current_user(request)
|
||||
uid = user.get("id", 1)
|
||||
user = _require_session(request)
|
||||
uid = user["id"]
|
||||
page_id = body.get("page_id")
|
||||
collection_id = body.get("collection_id")
|
||||
if not page_id:
|
||||
raise HTTPException(400, "page_id required")
|
||||
with get_conn() as conn:
|
||||
conn.execute("INSERT OR IGNORE INTO users (id, login, is_admin) VALUES (?,?,1)", (uid, user.get("login", "admin")))
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO favorites (user_id, page_id, collection_id) VALUES (?,?,?)",
|
||||
(uid, page_id, collection_id),
|
||||
"INSERT OR IGNORE INTO favorites (user_id, page_id) VALUES (?,?)",
|
||||
(uid, page_id),
|
||||
)
|
||||
conn.commit()
|
||||
try:
|
||||
run_event_sync(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, "user_id": uid}))
|
||||
except Exception:
|
||||
logger.exception("add_favorite")
|
||||
return {"status": "favorited"}
|
||||
|
||||
|
||||
|
||||
@router.delete("/favorites/{fav_id}")
|
||||
def remove_favorite(request: Request, fav_id: int):
|
||||
user = _require_session(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM favorites WHERE id=?", (fav_id,))
|
||||
cur = conn.execute(
|
||||
"DELETE FROM favorites WHERE id=? AND user_id=?", (fav_id, user["id"])
|
||||
)
|
||||
conn.commit()
|
||||
if cur.rowcount == 0:
|
||||
raise HTTPException(404, "Favorite not found")
|
||||
return {"status": "removed"}
|
||||
|
||||
|
||||
@@ -714,7 +745,7 @@ def delete_webhook(request: Request, wh_id: int):
|
||||
|
||||
# ── Public Sharing ──
|
||||
|
||||
@router.get("/public/{collection_id}")
|
||||
@public_router.get("/public/{collection_id}")
|
||||
def public_view(request: Request, collection_id: int):
|
||||
"""Simple public read-only view — no auth required.
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import re
|
||||
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
from app.services import agent_memory
|
||||
from app.services.agent_policies import check_tool, get_policy
|
||||
from app.services.context_builder import ContextBuilder
|
||||
from app.services.llm_client import LLMClient
|
||||
@@ -156,6 +157,11 @@ class AgentEngine:
|
||||
skills = [s for s in (self._load_skill(i, scope) for i in ids) if s]
|
||||
system = self._build_system_prompt(agent, skills)
|
||||
context = self.ctx.build(mentions=mentions, files=files)
|
||||
# v7.54.0 — mémoire de la conversation (toggle) : le moteur n'envoie
|
||||
# jamais l'historique, sans elle chaque run repart de zéro.
|
||||
memory = agent_memory.context_for(conversation_id)
|
||||
if memory:
|
||||
context = (context + "\n\n" + memory) if context else memory
|
||||
if extra_context and extra_context.strip():
|
||||
context += "\n\n## Document / contexte fourni par l'utilisateur\n" + extra_context.strip()
|
||||
|
||||
@@ -304,6 +310,8 @@ class AgentEngine:
|
||||
self._persist_message(conversation_id, "assistant", final_text,
|
||||
model=used_model, tokens=self._tokens)
|
||||
await self._autotitle(conversation_id, objective, final_text)
|
||||
# v7.54.0 — memorise l'echange (no-op si le toggle memoire est OFF).
|
||||
agent_memory.remember(conversation_id, objective, final_text)
|
||||
# v6.4.0: emit agent.run.finished (outbound webhooks only).
|
||||
await _fire_agent_webhook("agent.run.finished", {
|
||||
"conversation_id": conversation_id,
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Mémoire de l'agent (v7.54.0).
|
||||
|
||||
Une ligne résumé **par conversation**, mise à jour à chaque run et ré-injectée
|
||||
au contexte du run suivant — le moteur n'envoie jamais l'historique des messages
|
||||
(`AgentEngine.run()` ne construit que system + objectif courant), donc sans
|
||||
mémoire chaque run repart de zéro.
|
||||
|
||||
Toggle : colonne `agent_conversations.memory_enabled` — OFF = aucune lecture ni
|
||||
écriture (contexte strict du run courant).
|
||||
|
||||
ponytail : mémoire par conversation seulement. Si besoin de notes partagées
|
||||
entre conversations, ajouter des lignes avec ``conversation_id NULL`` +
|
||||
``workspace_id`` et élargir la lecture dans ``context_for()``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sqlite3
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
INJECT_BUDGET = 4000 # caractères de mémoire max injectés dans un prompt
|
||||
NOTE_LINES = 20 # échanges max conservés
|
||||
NOTE_OBJECTIVE = 180 # troncature de l'objectif
|
||||
NOTE_ANSWER = 700 # troncature de la réponse
|
||||
|
||||
|
||||
def _enabled(conn: sqlite3.Connection, conversation_id: int) -> bool:
|
||||
row = conn.execute(
|
||||
"SELECT memory_enabled FROM agent_conversations WHERE id=?",
|
||||
(conversation_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return False
|
||||
return bool(row["memory_enabled"])
|
||||
|
||||
|
||||
def _one_line(text: str, limit: int) -> str:
|
||||
return " ".join((text or "").split())[:limit]
|
||||
|
||||
|
||||
def context_for(conversation_id: int) -> str:
|
||||
"""Bloc « mémoire » à préfixer au contexte, ou ``''`` (toggle OFF / vide)."""
|
||||
with get_conn() as conn:
|
||||
if not _enabled(conn, conversation_id):
|
||||
return ""
|
||||
row = conn.execute(
|
||||
"SELECT content FROM agent_memory WHERE conversation_id=?",
|
||||
(conversation_id,),
|
||||
).fetchone()
|
||||
content = ((row["content"] if row else "") or "").strip()
|
||||
if not content:
|
||||
return ""
|
||||
if len(content) > INJECT_BUDGET:
|
||||
content = "…(début de mémoire tronqué)\n" + content[-INJECT_BUDGET:]
|
||||
return "## Mémoire de la conversation (échanges précédents)\n" + content
|
||||
|
||||
|
||||
def remember(conversation_id: int, objective: str, final_text: str) -> None:
|
||||
"""Ajoute un échange à la mémoire — no-op si le toggle est OFF.
|
||||
|
||||
Ne doit **jamais** faire échouer un run : toute erreur est journalisée.
|
||||
"""
|
||||
note = f"- **{_one_line(objective, NOTE_OBJECTIVE)}** → {_one_line(final_text, NOTE_ANSWER)}"
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
if not _enabled(conn, conversation_id):
|
||||
return
|
||||
row = conn.execute(
|
||||
"SELECT content FROM agent_memory WHERE conversation_id=?",
|
||||
(conversation_id,),
|
||||
).fetchone()
|
||||
lines = [ln for ln in ((row["content"] if row else "") or "").split("\n") if ln.strip()]
|
||||
lines.append(note)
|
||||
if len(lines) > NOTE_LINES:
|
||||
lines = lines[-NOTE_LINES:]
|
||||
conn.execute(
|
||||
"INSERT INTO agent_memory (conversation_id, content, updated_at) "
|
||||
"VALUES (?, ?, CURRENT_TIMESTAMP) "
|
||||
"ON CONFLICT(conversation_id) DO UPDATE SET "
|
||||
"content=excluded.content, updated_at=CURRENT_TIMESTAMP",
|
||||
(conversation_id, "\n".join(lines)),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception: # noqa: BLE001 — la mémoire ne casse jamais un run
|
||||
logger.exception("Agent memory save failed for conversation #%s", conversation_id)
|
||||
@@ -26,10 +26,9 @@ import logging
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import httpx
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services import notifications
|
||||
from app.services import notifications, plugins
|
||||
from app.services.http_client import shared_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -179,7 +178,7 @@ async def _run_action(action: dict, context: dict, trigger_source: str) -> str:
|
||||
headers = {"Content-Type": "application/json", "X-FlowDeck-Event": context.get("event", "")}
|
||||
if secret:
|
||||
headers["X-FlowDeck-Secret"] = secret
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
async with shared_client(timeout=10) as client:
|
||||
resp = await client.post(url, json=context, headers=headers)
|
||||
if resp.status_code >= 400:
|
||||
raise RuntimeError(f"webhook returned HTTP {resp.status_code}")
|
||||
@@ -480,6 +479,9 @@ async def automation_scheduler():
|
||||
"""Background loop: fire due cron automations (checked every 60s)."""
|
||||
while True:
|
||||
try:
|
||||
if not plugins.is_enabled("automations"): # plugin OFF = rien de planifié
|
||||
await asyncio.sleep(60)
|
||||
continue
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM automations WHERE trigger_type='cron' AND enabled=1"
|
||||
@@ -719,7 +721,7 @@ async def fire_stepped_event(event: str, payload: dict) -> None:
|
||||
# ── v7.0.0 action backends (module-level = monkeypatchable in tests) ───────
|
||||
|
||||
async def _post_slack(webhook_url: str, text: str) -> str:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
async with shared_client(timeout=10) as client:
|
||||
resp = await client.post(webhook_url, json={"text": text})
|
||||
if resp.status_code >= 400:
|
||||
raise RuntimeError(f"slack webhook returned HTTP {resp.status_code}")
|
||||
@@ -765,7 +767,7 @@ async def _create_forge_issue(provider: str, owner: str, repo: str, title: str,
|
||||
if provider == "github":
|
||||
if not token:
|
||||
raise ValueError("github action needs a linked GitHub account (token)")
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
resp = await client.post(
|
||||
f"https://api.github.com/repos/{owner}/{repo}/issues",
|
||||
headers={"Authorization": f"Bearer {token}",
|
||||
|
||||
@@ -86,6 +86,34 @@ BUILTIN_TEMPLATES: dict[str, dict] = {
|
||||
_b("bulleted_list"),
|
||||
],
|
||||
},
|
||||
"design_system": {
|
||||
"name": "Design System",
|
||||
"icon": "🎨",
|
||||
"description": "Tokens, composants et grille — canevas de référence UI.",
|
||||
"blocks": [
|
||||
_b("heading_1", "Design System"),
|
||||
_b("callout",
|
||||
"Tokens : couleur, espacement, typographie, rayon, ombres "
|
||||
"(static/css/design-tokens.css).",
|
||||
icon="🎨"),
|
||||
_b("table_of_contents"),
|
||||
_b("heading_2", "Composants"),
|
||||
_b("toggle", "Boutons & actions", expanded=False,
|
||||
children=[_b("paragraph", "Primaire · secondaire · danger — états idle, hover, focus, disabled.")]),
|
||||
_b("toggle", "Cartes & panneaux", expanded=False,
|
||||
children=[_b("paragraph", "Élévation, rayon, bordure, padding, zones de clic.")]),
|
||||
_b("toggle", "Formulaires", expanded=False,
|
||||
children=[_b("paragraph", "Champs, labels, hints, erreurs, focus visible.")]),
|
||||
_b("heading_2", "Grille & espacement"),
|
||||
_b("bulleted_list", "Base 4 px — pas d'espacement hors échelle."),
|
||||
_b("bulleted_list", "Colonnes : 12 / 8 / 4 selon le point de rupture."),
|
||||
_b("heading_2", "Revue UI"),
|
||||
_b("to_do", "Contraste AA vérifié", checked=False),
|
||||
_b("to_do", "États hover / focus / disabled", checked=False),
|
||||
_b("to_do", "Responsive mobile", checked=False),
|
||||
_b("quote", "Une décision de design vaut mieux qu'une justification après coup."),
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -19,9 +19,8 @@ import time
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import httpx
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.http_client import shared_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -125,7 +124,7 @@ async def google_list_events(tokens: dict, calendar_id: str,
|
||||
url = (f"https://www.googleapis.com/calendar/v3/calendars/{calendar_id}"
|
||||
f"/events?singleEvents=true&orderBy=startTime"
|
||||
f"&timeMin={time_min}&timeMax={time_max}")
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
resp = await client.get(url, headers={"Authorization": f"Bearer {access}"})
|
||||
if resp.status_code == 401:
|
||||
raise SyncError("google token expired — relink the calendar")
|
||||
@@ -150,7 +149,7 @@ async def google_push_event(tokens: dict, calendar_id: str, event: dict,
|
||||
"start": {"date": event.get("start", "")[:10]},
|
||||
"end": {"date": event.get("start", "")[:10]}}
|
||||
base = f"https://www.googleapis.com/calendar/v3/calendars/{calendar_id}/events"
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
if remote_id:
|
||||
resp = await client.patch(f"{base}/{remote_id}",
|
||||
headers={"Authorization": f"Bearer {access}"}, json=body)
|
||||
@@ -224,7 +223,7 @@ async def caldav_list_events(creds: dict, time_min: str, time_max: str) -> list[
|
||||
body = _CALDAV_REPORT.format(
|
||||
start=time_min.replace("-", "").split("T")[0] + "T000000Z",
|
||||
end=time_max.replace("-", "").split("T")[0] + "T000000Z")
|
||||
async with httpx.AsyncClient(timeout=15, auth=auth if auth[0] else None) as client:
|
||||
async with shared_client(timeout=15, auth=auth if auth[0] else None) as client:
|
||||
resp = await client.request("REPORT", url, content=body,
|
||||
headers={"Depth": "1",
|
||||
"Content-Type": "application/xml"})
|
||||
@@ -245,7 +244,7 @@ async def caldav_push_event(creds: dict, event: dict, remote_id: str = "") -> st
|
||||
remote_id if remote_id.startswith("http") else f"{url}/{remote_id}")
|
||||
ics = _event_to_ics(uid.split("@")[0], event.get("title", ""),
|
||||
event.get("start", ""), event.get("description", ""))
|
||||
async with httpx.AsyncClient(timeout=15, auth=auth if auth[0] else None) as client:
|
||||
async with shared_client(timeout=15, auth=auth if auth[0] else None) as client:
|
||||
resp = await client.put(href, content=ics, headers={"Content-Type": "text/calendar"})
|
||||
if resp.status_code >= 400:
|
||||
raise SyncError(f"caldav returned HTTP {resp.status_code}")
|
||||
@@ -279,6 +278,12 @@ def _row_date(values: dict, prop_id: str, prop_name: str) -> str:
|
||||
return str(raw or "")
|
||||
|
||||
|
||||
# Public aliases — read-only helpers reused by the router layer (sidebar
|
||||
# "Meeting" tab needs the date property + value of a collection row).
|
||||
date_prop_id = _date_prop_id
|
||||
row_date = _row_date
|
||||
|
||||
|
||||
def _to_epoch(value: str | None) -> float:
|
||||
if not value:
|
||||
return 0.0
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
"""FlowDeck — cohérence collections ↔ workspace.
|
||||
|
||||
Une database (`collections`) peut être :
|
||||
|
||||
* **de workspace** — créée via `/db/api`, rattachée par `workspace_id` seul ;
|
||||
* **de page** — page convertie en base (`collections.parent_page_id` +
|
||||
`pages.collection_id`) ou base inline insérée dans une page.
|
||||
|
||||
Ce module centralise les deux sens de la relation :
|
||||
|
||||
* **lecture** — quelles collections sont encore *vivantes* dans un workspace
|
||||
(leur page hôte n'est pas à la corbeille / n'a pas été supprimée) et
|
||||
quelles lignes sont encore vivantes (leur page contenu n'est pas à la
|
||||
corbeille). Sans ce filtre, ``/my-tasks`` continue d'afficher les tâches
|
||||
d'un workspace dont tous les documents ont été supprimés ;
|
||||
* **écriture** — la suppression en cascade d'une collection. L'ordre des
|
||||
écritures est dicté par le schéma SQLite :
|
||||
|
||||
- ``pages.collection_id`` référence ``collections(id)`` en ``NO ACTION`` :
|
||||
la page hôte doit être détachée **avant** la suppression ;
|
||||
- ``collection_data_sources.source_collection_id`` référence également en
|
||||
``NO ACTION`` : les vues liées qui pointent vers la collection doivent
|
||||
être détachées ;
|
||||
- ``collection_pages`` (lignes), ``collection_properties``,
|
||||
``collection_views``, ``collection_permissions`` et les pages contenu
|
||||
(``pages.collection_row_id``) partent en ``CASCADE``.
|
||||
|
||||
Sans ce détachement, ``DELETE FROM collections`` lève un ``IntegrityError``
|
||||
dès qu'une page hôte (ou une vue liée) existe encore.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Lecture ──────────────────────────────────────────────────────────────
|
||||
|
||||
#: SQL du workspace **effectif** d'une collection.
|
||||
#:
|
||||
#: ``collections.workspace_id`` est renseigné pour les databases créées via
|
||||
#: ``/db/api``, mais reste **NULL** pour une base portée par une page (page
|
||||
#: convertie en base, base inline insérée dans un document) : son workspace
|
||||
#: est alors celui de la page hôte. Sans ce repli, la requête ``c.workspace_id
|
||||
#: = ?`` de `live_collection_ids` ne trouve jamais ces bases — et un JOIN
|
||||
#: ``workspaces`` les exclut définitivement.
|
||||
EFFECTIVE_WORKSPACE_SQL = (
|
||||
"COALESCE({a}.workspace_id,"
|
||||
" (SELECT p.workspace_id FROM pages p WHERE p.id = {a}.parent_page_id))"
|
||||
)
|
||||
|
||||
|
||||
def effective_workspace_sql(alias: str = "c") -> str:
|
||||
"""Workspace effectif d'une collection (colonne ou ``NULL`` si orpheline)."""
|
||||
return EFFECTIVE_WORKSPACE_SQL.format(a=alias)
|
||||
|
||||
|
||||
def user_workspace_ids(conn, user_id: int) -> list[int]:
|
||||
"""IDs des workspaces accessibles à l'utilisateur.
|
||||
|
||||
Propriétaire **ou** membre : c'est le même périmètre que la recherche
|
||||
globale (`app/services/search._scope_where`), à la différence près qu'on
|
||||
n'inclut pas les éléments sans workspace — une base non rattachable
|
||||
n'appartient à personne et ne doit donc apparaître chez personne.
|
||||
"""
|
||||
rows = conn.execute(
|
||||
"SELECT id FROM workspaces WHERE owner_id = ?"
|
||||
" UNION SELECT workspace_id FROM workspace_members WHERE user_id = ?",
|
||||
(user_id, user_id),
|
||||
).fetchall()
|
||||
return [r[0] for r in rows]
|
||||
|
||||
|
||||
def live_collection_ids(conn, workspace_id: int) -> list[int]:
|
||||
"""IDs des collections rattachées au workspace qui sont encore vivantes.
|
||||
|
||||
Une collection est exclue dès que sa page hôte a disparu :
|
||||
|
||||
* ``parent_page_id`` renseigné mais page absente ou à la corbeille ;
|
||||
* une page ``content_format='collection'`` qui pointe encore vers elle
|
||||
mais est à la corbeille.
|
||||
|
||||
Les collections sans page hôte (databases de workspace créées via
|
||||
``/db/api``) restent toujours visibles.
|
||||
|
||||
Le rattachement passe par le workspace **effectif** : une base créée depuis
|
||||
un document a ``workspace_id`` NULL et n'est repérée que via sa page hôte.
|
||||
"""
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT c.id
|
||||
FROM collections c
|
||||
WHERE {effective_workspace_sql("c")} = ?
|
||||
AND (c.parent_page_id IS NULL
|
||||
OR EXISTS (SELECT 1 FROM pages p
|
||||
WHERE p.id = c.parent_page_id AND p.deleted_at IS NULL))
|
||||
AND NOT EXISTS (SELECT 1 FROM pages p
|
||||
WHERE p.collection_id = c.id AND p.deleted_at IS NOT NULL)
|
||||
ORDER BY c.name
|
||||
""",
|
||||
(workspace_id,),
|
||||
).fetchall()
|
||||
return [r["id"] for r in rows]
|
||||
|
||||
|
||||
def live_row_ids(conn, collection_id: int) -> list[int]:
|
||||
"""IDs des lignes racines d'une collection dont la page contenu vit.
|
||||
|
||||
La page contenu d'une ligne est créée paresseusement (``ensure_row_page``)
|
||||
: son absence ne dit rien, mais son passage à la corbeille signifie que
|
||||
l'utilisateur a supprimé le document → la ligne sort de My Tasks.
|
||||
"""
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT cp.id
|
||||
FROM collection_pages cp
|
||||
WHERE cp.collection_id = ?
|
||||
AND cp.parent_id IS NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM pages p
|
||||
WHERE p.collection_row_id = cp.id AND p.deleted_at IS NOT NULL)
|
||||
ORDER BY cp.position
|
||||
""",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
return [r["id"] for r in rows]
|
||||
|
||||
|
||||
def live_document_count(conn, workspace_id: int) -> int:
|
||||
"""Nombre de documents visibles du workspace (mêmes règles que l'arbre
|
||||
de la sidebar : racines, non supprimés, hors pages contenu de ligne)."""
|
||||
return conn.execute(
|
||||
"SELECT COUNT(*) FROM pages "
|
||||
"WHERE workspace_id = ? AND parent_id IS NULL "
|
||||
"AND deleted_at IS NULL AND collection_row_id IS NULL",
|
||||
(workspace_id,),
|
||||
).fetchone()[0]
|
||||
|
||||
|
||||
def collections_hosted_by_pages(conn, page_ids) -> list[int]:
|
||||
"""IDs des collections dont l'une de ces pages est la page hôte.
|
||||
|
||||
Les deux relations sont couvertes : ``collections.parent_page_id`` (page
|
||||
convertie en base, base inline) et ``pages.collection_id`` (page hôte).
|
||||
Les pages contenu de ligne (``collection_row_id`` renseigné) n'hébergent
|
||||
aucune collection et ne remontent donc rien.
|
||||
|
||||
À appeler **avant** de supprimer les pages si l'on veut aussi capturer la
|
||||
relation ``pages.collection_id``.
|
||||
"""
|
||||
ids = [int(p) for p in page_ids if p is not None]
|
||||
if not ids:
|
||||
return []
|
||||
qs = ",".join("?" * len(ids))
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT id FROM collections WHERE parent_page_id IN ({qs})
|
||||
UNION
|
||||
SELECT collection_id FROM pages
|
||||
WHERE id IN ({qs}) AND collection_id IS NOT NULL
|
||||
""",
|
||||
[*ids, *ids],
|
||||
).fetchall()
|
||||
return [r["id"] for r in rows]
|
||||
|
||||
|
||||
def collections_hosted_by_page(conn, page_id: int) -> list[int]:
|
||||
"""``collections_hosted_by_pages`` pour une seule page."""
|
||||
return collections_hosted_by_pages(conn, [page_id])
|
||||
|
||||
|
||||
def workspace_collection_ids(conn, workspace_id: int) -> list[int]:
|
||||
"""IDs de **toutes** les collections du workspace (sans filtre de
|
||||
vivacité) — utilisé lors de la suppression du workspace."""
|
||||
rows = conn.execute(
|
||||
"SELECT id FROM collections WHERE workspace_id = ?", (workspace_id,)
|
||||
).fetchall()
|
||||
return [r["id"] for r in rows]
|
||||
|
||||
|
||||
# ── Écriture ─────────────────────────────────────────────────────────────
|
||||
|
||||
def delete_collections(conn, collection_ids) -> int:
|
||||
"""Supprime définitivement des collections et tout ce qu'elles portent.
|
||||
|
||||
Détache d'abord les références ``NO ACTION`` (pages hôtes, sources des
|
||||
vues liées) puis supprime : le reste part en ``CASCADE``.
|
||||
|
||||
Ne fait **pas** de ``commit`` — l'appelant garde la main sur la
|
||||
transaction. Retourne le nombre de collections supprimées.
|
||||
"""
|
||||
ids = [int(i) for i in collection_ids if i is not None]
|
||||
if not ids:
|
||||
return 0
|
||||
qs = ",".join("?" * len(ids))
|
||||
|
||||
# 1. Pages hôtes : `pages.collection_id` est en NO ACTION. Une page dont le
|
||||
# contenu EST la base redevient une page blocs normale.
|
||||
conn.execute(
|
||||
f"""UPDATE pages
|
||||
SET collection_id = NULL,
|
||||
content_format = CASE WHEN content_format = 'collection'
|
||||
THEN 'blocks' ELSE content_format END
|
||||
WHERE collection_id IN ({qs})""",
|
||||
ids,
|
||||
)
|
||||
# 2. Vues liées : `source_collection_id` est en NO ACTION également. La vue
|
||||
# liée perd sa source (elle n'a plus rien à afficher) mais survit.
|
||||
conn.execute(
|
||||
f"DELETE FROM collection_data_sources WHERE source_collection_id IN ({qs})",
|
||||
ids,
|
||||
)
|
||||
# 3. Suppression : lignes, propriétés, vues, permissions et pages contenu
|
||||
# suivent en CASCADE.
|
||||
cur = conn.execute(f"DELETE FROM collections WHERE id IN ({qs})", ids)
|
||||
return cur.rowcount or 0
|
||||
|
||||
|
||||
def delete_collections_hosted_by_pages(conn, page_ids) -> int:
|
||||
"""Supprime les databases portées par des pages définitivement supprimées
|
||||
(corbeille purgée, suppression définitive, workspace effacé)."""
|
||||
return delete_collections(conn, collections_hosted_by_pages(conn, page_ids))
|
||||
@@ -0,0 +1,336 @@
|
||||
"""Connecteurs de l'agent (v7.55.0) — socle : catalogue, statut, fetch gardé SSRF.
|
||||
|
||||
5 connecteurs **natifs** (gitea, github, web, google, ms365) servis à la volée,
|
||||
plus des connecteurs **personnels** persistés (URL + clé) en base avec un
|
||||
``kind`` : ``custom`` | ``discord`` (auth « Bot ») | ``telegram`` (jeton dans
|
||||
l'URL via ``{secret}``) | ``mcp`` (serveur Model Context Protocol, cache
|
||||
d'outils dans ``tools_json``).
|
||||
|
||||
ponytail : un seul fichier (pas de package ``connectors/`` ni de classe par
|
||||
provider) — 3 probes dans un dict + 1 fetch. Les adapters lourds des phases 6-7
|
||||
(Google, M365, Discord/Telegram, MCP) arriveront avec leur propre module.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
from app.services import oauth_connectors
|
||||
from app.services.sso_provisioning import decrypt_secret, encrypt_secret
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
NATIVE_KINDS = ("gitea", "github", "web", "google", "ms365")
|
||||
CUSTOM_KINDS = ("custom", "discord", "telegram", "mcp")
|
||||
AUTH_SCHEMES = ("bearer", "bot", "none")
|
||||
# Presets proposés par le formulaire du menu + (le jeton reste toujours saisi
|
||||
# par l'utilisateur — jamais codé en dur ici).
|
||||
PRESETS = {
|
||||
"discord": {"url": "https://discord.com/api/v10", "auth": "bot",
|
||||
"hint": "Bot Discord Developers → Token"},
|
||||
"telegram": {"url": "https://api.telegram.org/bot{secret}", "auth": "none",
|
||||
"hint": "BotFather → Bot Token (jeton dans l'URL)"},
|
||||
"mcp": {"url": "https://", "auth": "bearer",
|
||||
"hint": "URL du endpoint MCP (streamable HTTP)"},
|
||||
}
|
||||
OAUTH_KINDS = oauth_connectors.OAUTH_KINDS
|
||||
FETCH_LIMIT = 20000 # caractères max renvoyés au LLM (ponytail : borne fixe)
|
||||
|
||||
|
||||
# ── Natifs (config, sans réseau) ─────────────────────────────────────────────
|
||||
|
||||
def native_state(kind: str, user_id: int | None = None) -> tuple[str, str]:
|
||||
"""(status, detail) d'un connecteur natif — dérivé de la config, sans réseau."""
|
||||
if kind in OAUTH_KINDS:
|
||||
try:
|
||||
oauth_connectors._client(kind) # lève si client_id/secret absents
|
||||
except ValueError as exc:
|
||||
return ("missing", str(exc))
|
||||
tok = oauth_connectors.tokens(kind, user_id)
|
||||
if tok.get("access_token"):
|
||||
scope = (tok.get("scope") or "").split()
|
||||
return ("ok", f"connecté · {len(scope)} scope(s)")
|
||||
return ("missing", "non connecté — lancer la connexion OAuth")
|
||||
if kind == "gitea":
|
||||
ok = bool(settings.gitea_url) and settings.gitea_token not in ("", "change-me")
|
||||
return ("ok", settings.gitea_url if ok else "GITEA_TOKEN non configuré")
|
||||
if kind == "github":
|
||||
if settings.github_token:
|
||||
return ("ok", "PAT configuré (30 req/min)")
|
||||
return ("missing", "aucun GITHUB_TOKEN (10 req/min anonyme)")
|
||||
return ("ok", f"recherche web via {settings.web_search_provider or 'duckduckgo'}")
|
||||
|
||||
|
||||
def _row(r) -> dict:
|
||||
"""Ligne `agent_connectors` → dict API (le jeton n'y figure jamais)."""
|
||||
try:
|
||||
tools = json.loads(r["tools_json"] or "[]") if "tools_json" in r.keys() else []
|
||||
except (ValueError, TypeError):
|
||||
tools = []
|
||||
return {
|
||||
"id": r["id"], "builtin": False, "kind": r["kind"], "name": r["name"],
|
||||
"url": r["url"], "auth": r["auth"], "enabled": bool(r["enabled"]),
|
||||
"status": r["status"], "detail": r["detail"] or "",
|
||||
"has_secret": bool(r["secret_encrypted"]), "oauth": False,
|
||||
"tools_count": len(tools),
|
||||
}
|
||||
|
||||
|
||||
def list_connectors(user_id: int | None = None) -> list[dict]:
|
||||
"""Catalogue : natifs (toujours présents) + connecteurs personnels."""
|
||||
out: list[dict] = []
|
||||
for kind in NATIVE_KINDS:
|
||||
status, detail = native_state(kind, user_id)
|
||||
out.append({
|
||||
"id": None, "builtin": True, "kind": kind,
|
||||
"name": (oauth_connectors.PROVIDERS[kind]["name"] if kind in OAUTH_KINDS
|
||||
else kind.capitalize()),
|
||||
"url": "", "enabled": True, "status": status, "detail": detail,
|
||||
"has_secret": False, "oauth": kind in OAUTH_KINDS,
|
||||
})
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, name, url, secret_encrypted, enabled, status, detail, "
|
||||
"kind, auth, tools_json FROM agent_connectors ORDER BY id"
|
||||
).fetchall()
|
||||
return out + [_row(r) for r in rows]
|
||||
|
||||
|
||||
def get(connector_id: int) -> dict | None:
|
||||
with get_conn() as conn:
|
||||
r = conn.execute(
|
||||
"SELECT id, name, url, secret_encrypted, enabled, status, detail, "
|
||||
"kind, auth, tools_json FROM agent_connectors WHERE id=?",
|
||||
(connector_id,),
|
||||
).fetchone()
|
||||
return _row(r) if r else None
|
||||
|
||||
|
||||
# ── CRUD (personnels) ────────────────────────────────────────────────────────
|
||||
|
||||
def create_connector(name: str, url: str, secret: str = "", *,
|
||||
kind: str = "custom", auth: str = "bearer") -> dict:
|
||||
"""Valide l'URL (garde SSRF) puis stocke la clé **chiffrée** (Fernet)."""
|
||||
from app.services.importers.url_fetch import _validate_url
|
||||
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
raise ValueError("name est requis")
|
||||
if kind not in CUSTOM_KINDS:
|
||||
raise ValueError(f"kind invalide: {kind} (attendu {', '.join(CUSTOM_KINDS)})")
|
||||
if auth not in AUTH_SCHEMES:
|
||||
raise ValueError(f"auth invalide: {auth} (attendu {', '.join(AUTH_SCHEMES)})")
|
||||
url = (url or "").strip()
|
||||
if "{secret}" in url and not (secret or "").strip():
|
||||
raise ValueError("clé requise : l'URL contient {secret}")
|
||||
url = _validate_url(url) # lève ValueError si hôte interne
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO agent_connectors (name, url, secret_encrypted, status, "
|
||||
"detail, kind, auth) VALUES (?,?,?,?,?,?,?)",
|
||||
(name, url, encrypt_secret(secret or ""), "unknown", "", kind, auth),
|
||||
)
|
||||
conn.commit()
|
||||
cid = cur.lastrowid
|
||||
return get(cid)
|
||||
|
||||
|
||||
def update_connector(connector_id: int, *, name=None, enabled=None, secret=None) -> dict | None:
|
||||
row = get(connector_id)
|
||||
if row is None:
|
||||
return None
|
||||
sets, params = [], []
|
||||
if name is not None:
|
||||
name = str(name).strip()
|
||||
if not name:
|
||||
raise ValueError("name est requis")
|
||||
sets.append("name=?")
|
||||
params.append(name)
|
||||
if enabled is not None:
|
||||
sets.append("enabled=?")
|
||||
params.append(1 if enabled else 0)
|
||||
if secret is not None:
|
||||
sets.append("secret_encrypted=?")
|
||||
params.append(encrypt_secret(str(secret)))
|
||||
if sets:
|
||||
with get_conn() as conn:
|
||||
params.append(connector_id)
|
||||
conn.execute(f"UPDATE agent_connectors SET {', '.join(sets)} WHERE id=?", params)
|
||||
conn.commit()
|
||||
return get(connector_id)
|
||||
|
||||
|
||||
def delete_connector(connector_id: int) -> bool:
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("DELETE FROM agent_connectors WHERE id=?", (connector_id,))
|
||||
conn.commit()
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
# ── Réseau (toujours gardé SSRF) ─────────────────────────────────────────────
|
||||
|
||||
def _headers(connector_id: int | None = None) -> dict:
|
||||
"""En-têtes d'appel : la clé n'est lue (et déchiffrée) qu'ici, jamais renvoyée.
|
||||
|
||||
``auth`` = bearer (défaut) | bot (Discord, jeton préfixé) | none (jeton ailleurs).
|
||||
"""
|
||||
headers = {"User-Agent": "FlowDeck-Connectors/1.0"}
|
||||
secret, auth = "", "bearer"
|
||||
if connector_id:
|
||||
with get_conn() as conn:
|
||||
r = conn.execute(
|
||||
"SELECT secret_encrypted, auth FROM agent_connectors WHERE id=?",
|
||||
(connector_id,),
|
||||
).fetchone()
|
||||
if r:
|
||||
secret = decrypt_secret(r["secret_encrypted"] or "")
|
||||
auth = r["auth"] or "bearer"
|
||||
if secret and auth == "bot":
|
||||
headers["Authorization"] = f"Bot {secret}"
|
||||
elif secret and auth != "none":
|
||||
headers["Authorization"] = f"Bearer {secret}"
|
||||
headers["X-Api-Key"] = secret
|
||||
return headers
|
||||
|
||||
|
||||
def _with_secret(url: str, connector_id: int) -> str:
|
||||
"""Substitue ``{secret}`` (Telegram : le jeton vit dans l'URL, jamais stocké clair)."""
|
||||
if "{secret}" not in url:
|
||||
return url
|
||||
with get_conn() as conn:
|
||||
r = conn.execute(
|
||||
"SELECT secret_encrypted FROM agent_connectors WHERE id=?",
|
||||
(connector_id,),
|
||||
).fetchone()
|
||||
secret = decrypt_secret((r["secret_encrypted"] if r else "") or "")
|
||||
if not secret:
|
||||
raise ValueError("clé manquante pour une URL contenant {secret}")
|
||||
return url.replace("{secret}", secret)
|
||||
|
||||
|
||||
async def _get(url: str, headers: dict | None = None) -> tuple[int, str]:
|
||||
"""GET gardé SSRF (validation + re-vérification après redirection).
|
||||
|
||||
Point d'injection des tests : on monkeypatche ``connectors._get``.
|
||||
"""
|
||||
from app.services.http_client import shared_client
|
||||
from app.services.importers.url_fetch import _is_public_host, _validate_url
|
||||
|
||||
safe = _validate_url(url)
|
||||
async with shared_client(timeout=10, follow_redirects=True, headers=headers or {}) as client:
|
||||
resp = await client.get(safe)
|
||||
if resp.url.host and not _is_public_host(resp.url.host):
|
||||
raise ValueError("Redirection vers un hôte non autorisé")
|
||||
return resp.status_code, (resp.text or "")[:FETCH_LIMIT]
|
||||
|
||||
|
||||
def _resolve(connector: str) -> tuple[str, str | int]:
|
||||
"""« 3 », « Ma clé API » ou « gitea » → (kind, id_custom|kind)."""
|
||||
token = str(connector or "").strip()
|
||||
if not token:
|
||||
raise ValueError("connector est requis")
|
||||
if token.isdigit():
|
||||
row = get(int(token))
|
||||
if row is None:
|
||||
raise ValueError(f"Connecteur inconnu: {token}")
|
||||
return (row["kind"], row["id"])
|
||||
low = token.lower()
|
||||
if low in NATIVE_KINDS:
|
||||
return (low, low)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM agent_connectors WHERE lower(name)=?", (low,)
|
||||
).fetchone()
|
||||
if row:
|
||||
return (get(row["id"])["kind"], row["id"])
|
||||
raise ValueError(f"Connecteur inconnu: {token}")
|
||||
|
||||
|
||||
async def probe(connector: str, user_id: int | None = None) -> dict:
|
||||
"""Teste un connecteur et **persiste** le résultat (personnel uniquement)."""
|
||||
kind, ref = _resolve(connector)
|
||||
try:
|
||||
if kind == "mcp":
|
||||
from app.services import mcp_client
|
||||
tools = await mcp_client.initialize_and_list(int(ref))
|
||||
status, detail = "ok", f"MCP · {len(tools)} outil(s)"
|
||||
elif kind in OAUTH_KINDS:
|
||||
res = await oauth_connectors.api_get(kind, user_id,
|
||||
oauth_connectors.PROVIDERS[kind]["probe_path"])
|
||||
status, detail = res["status"], res["text"][:200]
|
||||
elif kind == "gitea":
|
||||
code, _ = await _get(f"{settings.gitea_url.rstrip('/')}/api/v1/version",
|
||||
{"Authorization": f"token {settings.gitea_token}"})
|
||||
status, detail = ("ok" if code < 400 else "error"), f"HTTP {code}"
|
||||
elif kind == "github":
|
||||
code, _ = await _get("https://api.github.com/rate_limit", _gh_headers())
|
||||
status, detail = ("ok" if code < 400 else "error"), f"HTTP {code}"
|
||||
elif kind == "web":
|
||||
status, detail = "ok", native_state("web")[1]
|
||||
else:
|
||||
row = get(int(ref))
|
||||
url = _with_secret(row["url"], int(ref))
|
||||
code, _ = await _get(url, _headers(int(ref)))
|
||||
status, detail = ("ok" if code < 400 else "error"), f"HTTP {code}"
|
||||
except Exception as exc: # noqa: BLE001 — un probe ne casse jamais l'UI
|
||||
logger.info("Connector probe failed (%s): %s", connector, exc)
|
||||
status, detail = "error", str(exc)[:200]
|
||||
if kind not in NATIVE_KINDS:
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE agent_connectors SET status=?, detail=? WHERE id=?",
|
||||
(status, detail, int(ref)),
|
||||
)
|
||||
conn.commit()
|
||||
return {"connector": connector, "status": status, "detail": detail}
|
||||
|
||||
|
||||
def _gh_headers() -> dict:
|
||||
headers = {"User-Agent": "FlowDeck-Connectors/1.0",
|
||||
"Accept": "application/vnd.github+json"}
|
||||
if settings.github_token:
|
||||
headers["Authorization"] = f"Bearer {settings.github_token}"
|
||||
return headers
|
||||
|
||||
|
||||
async def connector_fetch(connector: str, path: str = "", query: str = "",
|
||||
user_id: int | None = None) -> dict:
|
||||
"""Lit un connecteur pour l'LLM : natif = API, web = recherche, perso = GET."""
|
||||
kind, ref = _resolve(connector)
|
||||
path = (path or "").strip()
|
||||
if kind in OAUTH_KINDS:
|
||||
return await oauth_connectors.api_get(kind, user_id, path)
|
||||
if kind == "mcp":
|
||||
from app.services import mcp_client
|
||||
return {"status": "ok", "text": mcp_client.tools_text(int(ref))}
|
||||
if kind == "gitea":
|
||||
base = settings.gitea_url.rstrip("/")
|
||||
url = f"{base}/{path.lstrip('/')}" if path else f"{base}/api/v1/version"
|
||||
code, text = await _get(url, {"Authorization": f"token {settings.gitea_token}"})
|
||||
elif kind == "github":
|
||||
url = "https://api.github.com/" + path.lstrip("/") if path else "https://api.github.com/rate_limit"
|
||||
code, text = await _get(url, _gh_headers())
|
||||
elif kind == "web":
|
||||
from app.services.web_search import search_web
|
||||
if not (query or "").strip():
|
||||
return {"status": "error", "text": "query est requis pour le connecteur web"}
|
||||
results, provider = await search_web(query.strip(), 5)
|
||||
text = "\n".join(f"- {r.get('title', '')} — {r.get('url', '')}\n {r.get('snippet', '')}"
|
||||
for r in results) or "Aucun résultat."
|
||||
return {"status": "ok", "text": f"provider: {provider}\n{text}"[:FETCH_LIMIT]}
|
||||
else:
|
||||
row = get(int(ref))
|
||||
if row is None:
|
||||
return {"status": "error", "text": "Connecteur supprimé"}
|
||||
if not row["enabled"]:
|
||||
return {"status": "error", "text": "Connecteur désactivé"}
|
||||
url = _with_secret(row["url"], int(ref)).rstrip("/")
|
||||
if path:
|
||||
url += "/" + path.lstrip("/")
|
||||
code, text = await _get(url, _headers(int(ref)))
|
||||
if code >= 400:
|
||||
return {"status": "error", "text": f"HTTP {code} — {text[:400]}"}
|
||||
return {"status": "ok", "text": text[:FETCH_LIMIT]}
|
||||
@@ -118,7 +118,7 @@ class ContextBuilder:
|
||||
return "\n".join(lines)
|
||||
|
||||
def _mentions_context(self, mentions: list[str]) -> str:
|
||||
"""Resolve @document:x / @collection:x / @page:y / @repo:o/r mentions.
|
||||
"""Resolve @document:x / @collection:x / @page:y / @folder:d / @repo:o/r mentions.
|
||||
|
||||
Mentions bring the *actual content* of the referenced object into the
|
||||
context so the LLM can summarise / rewrite / analyse it directly without
|
||||
@@ -135,6 +135,8 @@ class ContextBuilder:
|
||||
elif m.startswith("page:"):
|
||||
pid = m.split(":", 1)[1]
|
||||
lines.append(self._single_page(pid))
|
||||
elif m.startswith("folder:"):
|
||||
lines.append(self._single_folder(m.split(":", 1)[1]))
|
||||
elif m.startswith("repo:"):
|
||||
lines.append(f"- @repo: {m.split(':', 1)[1]} (Gitea issues available via read_gitea_issues)")
|
||||
elif m == "ws":
|
||||
@@ -209,6 +211,37 @@ class ContextBuilder:
|
||||
return f"{head}:\n{body[:9000]}"
|
||||
return head
|
||||
|
||||
def _single_folder(self, fid: str) -> str:
|
||||
"""Folder (répertoire) mention : titre du dossier + ses documents directs.
|
||||
|
||||
ponytail : enfants directs seulement, budget ~12k caractères —
|
||||
ajouter une profondeur récursive si les arbres profonds deviennent réels.
|
||||
"""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, title FROM pages WHERE id=? AND deleted_at IS NULL",
|
||||
(fid,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return f"- dossier #{fid}: introuvable"
|
||||
kids = conn.execute(
|
||||
"SELECT id FROM pages WHERE parent_id=? AND deleted_at IS NULL "
|
||||
"ORDER BY sort_order ASC, created_at DESC",
|
||||
(fid,),
|
||||
).fetchall()
|
||||
title = row["title"] or "Sans titre"
|
||||
head = f"- dossier #{row['id']} **{title}** ({len(kids)} élément(s))"
|
||||
out = [head]
|
||||
used = len(head)
|
||||
for k in kids:
|
||||
part = self._single_document(str(k["id"]))
|
||||
if used + len(part) + 1 > 12000:
|
||||
out.append("- … (contenu du dossier tronqué)")
|
||||
break
|
||||
used += len(part) + 1
|
||||
out.append(part)
|
||||
return "\n".join(out)
|
||||
|
||||
def _single_collection(self, cid: str) -> str:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id, name, icon, schema_json FROM collections WHERE id=?", (cid,)).fetchone()
|
||||
|
||||
+224
-23
@@ -66,28 +66,226 @@ def _block_text(b: dict) -> str:
|
||||
# ── Source resolution: read real textual content for ANY page type ──
|
||||
|
||||
# Extensions whose content is plain text / code / markdown (textual exportable).
|
||||
# Mirrors static/js/code-highlight.js EXT_TO_LANG (single source of truth for
|
||||
# the viewer lives client-side; this set gates server-side text handling:
|
||||
# file viewer, copy-to-clipboard, exports, imports).
|
||||
_TEXTUAL_EXTS = {
|
||||
"md", "markdown", "txt", "log", "text",
|
||||
"py", "js", "ts", "jsx", "tsx", "html", "htm", "css", "json", "xml",
|
||||
"yaml", "yml", "toml", "ini", "cfg", "conf", "env", "sh", "bash", "zsh",
|
||||
"ps1", "bat", "cmd", "rb", "go", "rs", "java", "c", "cpp", "h", "hpp",
|
||||
"php", "swift", "kt", "scala", "sql", "r", "vue", "svelte", "astro",
|
||||
"properties", "gitignore", "dockerfile", "makefile",
|
||||
"md", "markdown", "mkd", "mdown", "mdwn", "rmd", "qmd",
|
||||
"txt", "log", "text", "csv", "tsv", "tab", "nfo", "asc",
|
||||
"py", "pyw", "pyi", "gyp", "gypi",
|
||||
"js", "mjs", "cjs", "jsx", "ts", "mts", "cts", "tsx",
|
||||
"html", "htm", "xhtml", "xht", "xml", "xsd", "xsl", "xslt",
|
||||
"rss", "atom", "plist", "wsf", "svg", "axml",
|
||||
"vue", "svelte", "astro",
|
||||
"css", "scss", "sass", "less",
|
||||
"json", "json5", "jsonc", "geojson", "webmanifest", "har", "avsc",
|
||||
"yaml", "yml", "toml", "ini", "cfg", "conf", "env", "properties",
|
||||
"inf", "reg", "url", "prop",
|
||||
"gitconfig", "gitmodules", "editorconfig",
|
||||
"sh", "bash", "zsh", "ksh", "mksh", "yash", "fish",
|
||||
"ps1", "psm1", "psd1", "bat", "cmd",
|
||||
"rb", "gemspec", "rake", "rbw",
|
||||
"go", "rs", "java", "jsp", "kt", "kts",
|
||||
"scala", "sc", "groovy", "gvy", "gradle",
|
||||
"c", "cpp", "cc", "cxx", "c++", "hh", "hxx", "hcc", "h", "hpp",
|
||||
"cs", "csx", "cake", "ino", "pde",
|
||||
"php", "phtml", "swift", "sql", "pgsql", "psql", "mysql", "mssql",
|
||||
"r", "jl", "lua", "dart",
|
||||
"pl", "pm", "pod",
|
||||
"hs", "lhs", "ex", "exs", "eex", "heex", "leex",
|
||||
"erl", "hrl", "escript",
|
||||
"clj", "cljs", "cljc", "edn",
|
||||
"ml", "mli", "fs", "fsi", "fsx", "fsscript",
|
||||
"nim", "nims", "d", "di",
|
||||
"m", "mm", "vb", "vbs", "bas", "pas", "pp", "dpr",
|
||||
"vhd", "vhdl", "v", "vh", "sv", "svh",
|
||||
"asm", "s", "nasm", "inc",
|
||||
"ada", "ads", "adb",
|
||||
"f", "for", "f90", "f95", "f03", "f08",
|
||||
"cob", "cbl", "cpy",
|
||||
"dockerfile", "containerfile", "tf", "tfvars", "hcl",
|
||||
"cmake", "makefile", "mk", "mak", "ninja",
|
||||
"gql", "graphql", "graphqls", "proto", "thrift",
|
||||
"vim", "vimrc", "gvimrc", "awk", "gcode", "gco", "nc", "ngc",
|
||||
"http", "rest", "nginx", "apache", "htaccess", "wat",
|
||||
"diff", "patch", "rej",
|
||||
"tex", "latex", "sty", "cls", "dtx", "bib", "bbl",
|
||||
"gitignore", "gitattributes", "gitkeep", "dockerignore", "npmignore",
|
||||
"lock", "mod", "sum",
|
||||
}
|
||||
# Well-known extensionless / dotfile basenames that are textual code.
|
||||
_TEXTUAL_FILENAMES = {
|
||||
"dockerfile", "containerfile", "makefile", "gnumakefile",
|
||||
"cmakelists.txt", "rakefile", "gemfile", "vagrantfile",
|
||||
"berksfile", "brewfile", "dangerfile", "fastfile", "podfile",
|
||||
"go.mod", "go.sum",
|
||||
".bashrc", ".bash_profile", ".bash_login", ".bash_logout",
|
||||
".zshrc", ".zshenv", ".profile", ".shrc", ".kshrc",
|
||||
".vimrc", ".gvimrc", ".viminfo",
|
||||
".gitconfig", ".editorconfig", ".htaccess",
|
||||
}
|
||||
# Exact basenames → highlight.js id (checked before the extension).
|
||||
_FILENAME_LANG = {
|
||||
"dockerfile": "dockerfile", "containerfile": "dockerfile",
|
||||
"makefile": "makefile", "gnumakefile": "makefile",
|
||||
"cmakelists.txt": "cmake",
|
||||
"rakefile": "ruby", "gemfile": "ruby", "vagrantfile": "ruby",
|
||||
"berksfile": "ruby", "brewfile": "ruby", "dangerfile": "ruby",
|
||||
"fastfile": "ruby", "podfile": "ruby",
|
||||
"go.mod": "go",
|
||||
"nginx.conf": "nginx",
|
||||
".bashrc": "bash", ".bash_profile": "bash", ".bash_login": "bash",
|
||||
".bash_logout": "bash", ".zshrc": "bash", ".zshenv": "bash",
|
||||
".profile": "bash", ".shrc": "bash", ".kshrc": "bash",
|
||||
".vimrc": "vim", ".gvimrc": "vim",
|
||||
".gitconfig": "ini", ".editorconfig": "ini", ".htaccess": "apache",
|
||||
}
|
||||
_CODE_LANG = {
|
||||
"py": "python", "js": "javascript", "ts": "typescript", "jsx": "javascript",
|
||||
"tsx": "typescript", "html": "html", "htm": "html", "css": "css",
|
||||
"json": "json", "xml": "xml", "yaml": "yaml", "yml": "yaml",
|
||||
"toml": "toml", "ini": "ini", "cfg": "ini", "conf": "ini", "env": "ini",
|
||||
"sh": "bash", "bash": "bash", "zsh": "bash", "ps1": "powershell",
|
||||
"bat": "batch", "cmd": "batch", "rb": "ruby", "go": "go", "rs": "rust",
|
||||
"java": "java", "c": "c", "cpp": "cpp", "h": "c", "hpp": "cpp",
|
||||
"php": "php", "swift": "swift", "kt": "kotlin", "scala": "scala",
|
||||
"sql": "sql", "r": "r", "vue": "html", "svelte": "html",
|
||||
"astro": "html", "properties": "ini", "md": "markdown",
|
||||
"markdown": "markdown", "txt": "plaintext", "log": "plaintext",
|
||||
"text": "plaintext",
|
||||
"py": "python", "pyw": "python", "pyi": "python",
|
||||
"gyp": "python", "gypi": "python",
|
||||
"js": "javascript", "mjs": "javascript", "cjs": "javascript",
|
||||
"jsx": "javascript",
|
||||
"ts": "typescript", "mts": "typescript", "cts": "typescript",
|
||||
"tsx": "typescript",
|
||||
"html": "xml", "htm": "xml", "xhtml": "xml", "xht": "xml",
|
||||
"xml": "xml", "xsd": "xml", "xsl": "xml", "xslt": "xml",
|
||||
"rss": "xml", "atom": "xml", "plist": "xml", "wsf": "xml",
|
||||
"svg": "xml", "axml": "xml",
|
||||
"vue": "xml", "svelte": "xml", "astro": "xml",
|
||||
"css": "css", "scss": "scss", "sass": "scss", "less": "less",
|
||||
"json": "json", "json5": "json", "jsonc": "json",
|
||||
"geojson": "json", "webmanifest": "json", "har": "json", "avsc": "json",
|
||||
"yaml": "yaml", "yml": "yaml",
|
||||
"toml": "ini", "ini": "ini", "cfg": "ini", "conf": "ini",
|
||||
"env": "ini", "properties": "ini", "prop": "ini",
|
||||
"inf": "ini", "reg": "ini", "url": "ini",
|
||||
"gitconfig": "ini", "gitmodules": "ini", "editorconfig": "ini",
|
||||
"ninja": "ini",
|
||||
"md": "markdown", "markdown": "markdown", "mkd": "markdown",
|
||||
"mdown": "markdown", "mdwn": "markdown", "rmd": "markdown",
|
||||
"qmd": "markdown",
|
||||
"tex": "latex", "latex": "latex", "sty": "latex", "cls": "latex",
|
||||
"dtx": "latex",
|
||||
"sql": "sql", "pgsql": "sql", "psql": "sql", "mysql": "sql",
|
||||
"mssql": "sql",
|
||||
"sh": "bash", "bash": "bash", "zsh": "bash", "ksh": "bash",
|
||||
"mksh": "bash", "yash": "bash",
|
||||
"fish": "shell",
|
||||
"ps1": "powershell", "psm1": "powershell", "psd1": "powershell",
|
||||
"bat": "dos", "cmd": "dos",
|
||||
"rb": "ruby", "gemspec": "ruby", "rake": "ruby", "rbw": "ruby",
|
||||
"go": "go", "rs": "rust",
|
||||
"java": "java", "jsp": "java",
|
||||
"kt": "kotlin", "kts": "kotlin",
|
||||
"scala": "scala", "sc": "scala",
|
||||
"groovy": "groovy", "gvy": "groovy", "gradle": "groovy",
|
||||
"c": "c", "h": "c",
|
||||
"cpp": "cpp", "cc": "cpp", "cxx": "cpp", "c++": "cpp",
|
||||
"hh": "cpp", "hxx": "cpp", "hcc": "cpp", "hpp": "cpp",
|
||||
"ino": "cpp", "pde": "cpp",
|
||||
"cs": "csharp", "csx": "csharp", "cake": "csharp",
|
||||
"php": "php", "phtml": "php-template",
|
||||
"swift": "swift", "r": "r", "jl": "julia", "lua": "lua",
|
||||
"dart": "dart", "pl": "perl", "pm": "perl", "pod": "perl",
|
||||
"hs": "haskell", "lhs": "haskell",
|
||||
"ex": "elixir", "exs": "elixir", "eex": "elixir",
|
||||
"heex": "elixir", "leex": "elixir",
|
||||
"erl": "erlang", "hrl": "erlang", "escript": "erlang",
|
||||
"clj": "clojure", "cljs": "clojure", "cljc": "clojure", "edn": "clojure",
|
||||
"ml": "ocaml", "mli": "ocaml",
|
||||
"fs": "fsharp", "fsi": "fsharp", "fsx": "fsharp", "fsscript": "fsharp",
|
||||
"nim": "nim", "nims": "nim",
|
||||
"d": "d", "di": "d",
|
||||
"m": "objectivec", "mm": "objectivec",
|
||||
"vb": "vbnet", "vbs": "vbnet", "bas": "vbnet",
|
||||
"pas": "delphi", "pp": "delphi", "dpr": "delphi",
|
||||
"vhd": "vhdl", "vhdl": "vhdl",
|
||||
"v": "verilog", "vh": "verilog", "sv": "verilog", "svh": "verilog",
|
||||
"asm": "x86asm", "s": "x86asm", "nasm": "x86asm", "inc": "x86asm",
|
||||
"ada": "ada", "ads": "ada", "adb": "ada",
|
||||
"f": "fortran", "for": "fortran", "f90": "fortran", "f95": "fortran",
|
||||
"f03": "fortran", "f08": "fortran",
|
||||
"cob": "plaintext", "cbl": "plaintext", "cpy": "plaintext",
|
||||
"dockerfile": "dockerfile",
|
||||
"tf": "plaintext", "tfvars": "plaintext", "hcl": "plaintext",
|
||||
"cmake": "cmake",
|
||||
"makefile": "makefile", "mk": "makefile", "mak": "makefile",
|
||||
"gql": "graphql", "graphql": "graphql", "graphqls": "graphql",
|
||||
"proto": "protobuf", "thrift": "thrift",
|
||||
"vim": "vim", "vimrc": "vim", "gvimrc": "vim",
|
||||
"awk": "awk",
|
||||
"gcode": "gcode", "gco": "gcode", "nc": "gcode", "ngc": "gcode",
|
||||
"http": "http", "rest": "http",
|
||||
"nginx": "nginx", "apache": "apache", "htaccess": "apache",
|
||||
"wat": "wasm",
|
||||
"diff": "diff", "patch": "diff", "rej": "diff",
|
||||
"bib": "plaintext", "bbl": "plaintext",
|
||||
"txt": "plaintext", "log": "plaintext", "text": "plaintext",
|
||||
"csv": "plaintext", "tsv": "plaintext", "tab": "plaintext",
|
||||
"nfo": "plaintext", "asc": "plaintext",
|
||||
"lock": "plaintext",
|
||||
"gitignore": "plaintext", "gitattributes": "plaintext",
|
||||
"gitkeep": "plaintext", "dockerignore": "plaintext",
|
||||
"npmignore": "plaintext",
|
||||
}
|
||||
# Aliases users may type in markdown fences / old block labels.
|
||||
_CODE_ALIASES = {
|
||||
"py": "python", "js": "javascript", "jsx": "javascript",
|
||||
"ts": "typescript", "tsx": "typescript",
|
||||
"html": "xml", "htm": "xml", "markup": "xml",
|
||||
"yml": "yaml", "md": "markdown",
|
||||
"sh": "bash", "zsh": "bash", "console": "shell",
|
||||
"ps1": "powershell", "ps": "powershell",
|
||||
"bat": "dos", "cmd": "dos", "batch": "dos",
|
||||
"c++": "cpp", "c#": "csharp", "cs": "csharp",
|
||||
"kt": "kotlin", "rb": "ruby", "rs": "rust", "pl": "perl",
|
||||
"toml": "ini", "cfg": "ini", "conf": "ini", "env": "ini",
|
||||
"tex": "latex", "docker": "dockerfile", "make": "makefile",
|
||||
"plain text": "plaintext", "plain": "plaintext", "text": "plaintext",
|
||||
"none": "plaintext", "": "plaintext",
|
||||
}
|
||||
# highlight.js ids served by static/js/highlight.min.js + highlight-extra.min.js.
|
||||
_HLJS_LANGS = frozenset({
|
||||
"ada", "apache", "armasm", "awk", "bash", "c", "clojure", "cmake", "cpp",
|
||||
"csharp", "css", "d", "dart", "delphi", "diff", "dockerfile", "dos",
|
||||
"elixir", "erlang", "fsharp", "fortran", "gcode", "go",
|
||||
"graphql", "groovy", "haskell", "http", "ini", "java", "javascript",
|
||||
"json", "julia", "kotlin", "latex", "less", "lua", "makefile",
|
||||
"markdown", "matlab", "nginx", "nim", "objectivec", "ocaml", "perl",
|
||||
"php", "php-template", "plaintext", "powershell", "protobuf", "python",
|
||||
"python-repl", "r", "ruby", "rust", "scala", "scss", "shell", "sql",
|
||||
"stan", "stata", "swift", "thrift", "typescript", "vbnet", "verilog",
|
||||
"vhdl", "vim", "wasm", "x86asm", "xml", "yaml",
|
||||
})
|
||||
|
||||
|
||||
def normalize_code_lang(lang: str | None) -> str:
|
||||
"""Normalize any code language label to a bundled highlight.js id.
|
||||
|
||||
Accepts highlight.js ids, file extensions, markdown fence names and the
|
||||
legacy ``Plain Text`` label. Unknown values degrade to ``plaintext``.
|
||||
"""
|
||||
lang = (lang or "").strip().lower()
|
||||
if not lang or lang in ("plain text", "none"):
|
||||
return "plaintext"
|
||||
if lang in _CODE_ALIASES:
|
||||
lang = _CODE_ALIASES[lang]
|
||||
if lang in _CODE_LANG.values() or lang in _HLJS_LANGS:
|
||||
return lang
|
||||
return "plaintext"
|
||||
|
||||
|
||||
def code_lang_for_file(filename: str | None) -> str:
|
||||
"""Best highlight.js id for an uploaded / repo file name."""
|
||||
name = (filename or "").replace("\\", "/").rsplit("/", 1)[-1].lower()
|
||||
if not name:
|
||||
return "plaintext"
|
||||
if name in _FILENAME_LANG:
|
||||
return _FILENAME_LANG[name]
|
||||
ext = name.rsplit(".", 1)[-1] if "." in name else ""
|
||||
return _CODE_LANG.get(ext, "plaintext")
|
||||
|
||||
|
||||
_MARKDOWN_MIMES = {"text/markdown", "text/x-markdown", "application/octet-stream"}
|
||||
|
||||
|
||||
@@ -119,7 +317,8 @@ def _file_text(page: dict) -> str | None:
|
||||
name = (rel.rsplit("/", 1)[-1] or "").lower()
|
||||
ext = name.rsplit(".", 1)[-1] if "." in name else ""
|
||||
mime = (meta.get("mime_type") or "").lower()
|
||||
if not (ext in _TEXTUAL_EXTS or mime.startswith("text/")):
|
||||
if not (ext in _TEXTUAL_EXTS or name in _TEXTUAL_FILENAMES
|
||||
or mime.startswith("text/")):
|
||||
return None
|
||||
try:
|
||||
full = (_data_root() / rel).resolve()
|
||||
@@ -161,8 +360,7 @@ def _page_source(page: dict):
|
||||
mime = (meta.get("mime_type") or "").lower()
|
||||
if ext in ("md", "markdown") or mime in _MARKDOWN_MIMES or mime.startswith("text/markdown"):
|
||||
return "md", text
|
||||
lang = _CODE_LANG.get(ext, "plaintext")
|
||||
return "code", (text, lang)
|
||||
return "code", (text, code_lang_for_file(name))
|
||||
|
||||
# Unknown format (e.g. legacy) -> try to dump as raw text
|
||||
if content.strip():
|
||||
@@ -497,7 +695,7 @@ def blocks_to_markdown(blocks: list) -> str:
|
||||
out.append("---")
|
||||
elif t == "code":
|
||||
lang = b.get("language") or ""
|
||||
out.append(f"```{lang}\n{c}\n```")
|
||||
out.append(f"```{normalize_code_lang(lang) if lang else ''}\n{c}\n```")
|
||||
elif t == "toggle":
|
||||
out.append(f"### {c}")
|
||||
if b.get("children"):
|
||||
@@ -615,8 +813,11 @@ def blocks_to_html(blocks: list) -> str:
|
||||
parts.append("<hr>")
|
||||
elif t == "code":
|
||||
lang = b.get("language") or ""
|
||||
hljs_lang = normalize_code_lang(lang)
|
||||
label = f'<div class="code-lang">{_text(lang)}</div>' if lang else ""
|
||||
parts.append(f"<pre>{label}<code>{c}</code></pre>")
|
||||
parts.append(
|
||||
f'<pre>{label}<code class="language-{hljs_lang}">{c}</code></pre>'
|
||||
)
|
||||
elif t == "math":
|
||||
parts.append(f'<div class="math">\\[{c}\\]</div>')
|
||||
elif t == "table_of_contents":
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user