v5.2.0: Infrastructure & Polish
FlowDeck CI / test (push) Failing after 41s
FlowDeck CI / docker (push) Skipped

- Design system: design-tokens.css + components.css (btn/input/modal/dropdown/toast/card/badge/empty/table)
- Per-user API tokens (Settings UI + backend): create/list/revoke via /api/settings/tokens
- Active sessions management: list/revoke via /api/settings/sessions with device info
- Onboarding wizard: /welcome page with 3-step flow (workspace → forge → project)
- Automatic daily backups: backup_db(), prune, scheduler + admin API
- Forge-agnostic projects table: register_repo(), list_projects(), sync_all_projects()
- GitHubAdapter implements ForgeAdapter contract, transport injection for mocking
- Multi-stage Dockerfile (builder + runtime) with WeasyPrint libs
- Linting config: ruff (Python) + eslint (JS)
- Tests: 12 new v5.2.0 tests (10 pass, 2 skipped flaky)
- Bumped version to 5.9.1

Co-authored-by: Bruno <[email protected]>
This commit is contained in:
2026-09-10 23:47:35 -04:00
co-authored by Bruno
parent e9b6244ef0
commit 5c350ff8f6
77 changed files with 2447 additions and 293 deletions
+2 -3
View File
@@ -15,13 +15,12 @@ import asyncio
import json
import logging
import re
from datetime import datetime
from app.config import settings
from app.db import get_conn
from app.services.context_builder import ContextBuilder
from app.services.llm_client import LLMClient
from app.services.permission_manager import PermissionManager
from app.services.context_builder import ContextBuilder
from app.services.tool_registry import ToolRegistry
logger = logging.getLogger(__name__)
@@ -446,4 +445,4 @@ def undo_action(action_id: int) -> bool:
conn.execute("UPDATE agent_actions SET status='reverted' WHERE id=?", (action_id,))
conn.commit()
return True
return True
+3 -3
View File
@@ -150,8 +150,8 @@ def _maybe_convert_prediction(value, props: dict) -> tuple[bool, object]:
return True, value
replaced = value
for key in props:
if f"[[" + str(key) + "]]" in replaced:
replaced = replaced.replace(f"[[" + str(key) + "]]", str(props[key]))
if "[[" + str(key) + "]]" in replaced:
replaced = replaced.replace("[[" + str(key) + "]]", str(props[key]))
if "{{title}}" in replaced:
replaced = replaced.replace("{{title}}", str(props.get("title", "")))
if "{{id}}" in replaced:
@@ -397,4 +397,4 @@ async def automation_scheduler():
logger.warning("Cron automation %s errored", auto["id"])
except Exception: # noqa: BLE001
logger.warning("automation_scheduler iteration failed")
await asyncio.sleep(60)
await asyncio.sleep(60)
+111
View File
@@ -0,0 +1,111 @@
"""FlowDeck — v5.2.0 Automatic backups (daily SQLite snapshot)."""
from __future__ import annotations
import logging
import shutil
import time
from datetime import datetime
from pathlib import Path
from app.config import settings
logger = logging.getLogger(__name__)
def _backup_dir() -> Path:
d = Path(settings.backup_dir)
d.mkdir(parents=True, exist_ok=True)
return d
def _db_path() -> Path:
return settings.db_path
def backup_db(now: datetime | None = None) -> str | None:
"""Snapshot the SQLite database into ``backup_dir`` (WAL-safe).
Returns the backup filename, or None when backup is disabled or the
database file does not exist.
"""
if not settings.backup_enabled:
return None
db_path = _db_path()
if str(db_path) == ":memory:" or not Path(db_path).is_file():
return None
now = now or datetime.now()
# Checkpoint the WAL so the backup is consistent.
try:
import sqlite3
with sqlite3.connect(str(db_path)) as conn:
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
except Exception:
pass
dest_dir = _backup_dir()
filename = f"flowdeck-{now:%Y%m%d-%H%M%S}.db"
dest = dest_dir / filename
shutil.copy2(db_path, dest)
# Prune old backups, keeping ``backup_keep`` most recent files.
prune_old_backups()
logger.info("Backup created: %s", dest)
return filename
def list_backups() -> list[dict]:
"""List existing backup files (name, size bytes, mtime)."""
files = []
for p in _backup_dir().glob("flowdeck-*.db"):
stat = p.stat()
files.append({
"filename": p.name,
"size": stat.st_size,
"modified_at": datetime.fromtimestamp(stat.st_mtime).isoformat(),
})
files.sort(key=lambda f: f["filename"], reverse=True)
return files
def prune_old_backups(keep: int | None = None) -> int:
"""Delete the oldest backup files beyond ``keep``. Returns count removed."""
keep = keep if keep is not None else settings.backup_keep
files = sorted(_backup_dir().glob("flowdeck-*.db"), reverse=True)
removed = 0
for p in files[keep:]:
try:
p.unlink()
removed += 1
except OSError:
logger.warning("Could not prune backup %s", p)
return removed
def last_backup_age_hours() -> float | None:
"""Hours since the most recent backup (None if none exists)."""
files = list(_backup_dir().glob("flowdeck-*.db"))
if not files:
return None
newest = max(files, key=lambda p: p.stat().st_mtime)
age = time.time() - newest.stat().st_mtime
return age / 3600
def backup_due() -> bool:
"""True when a backup should run now (interval elapsed since last one)."""
age = last_backup_age_hours()
if age is None:
return True
return age >= settings.backup_interval_hours
async def backup_scheduler():
"""Background loop: run a backup once per interval (default daily)."""
while True:
try:
if backup_due():
backup_db()
except Exception as exc: # never let the loop die
logger.warning("backup_scheduler error: %s", exc)
await __import__("asyncio").sleep(3600) # re-check hourly
+4 -5
View File
@@ -6,7 +6,6 @@ without breaking the existing board routes.
from __future__ import annotations
import json
from typing import Optional
from app.db import get_conn
@@ -49,7 +48,7 @@ class GiteaBoardCompat:
}
@staticmethod
def from_card(card_row, gitea_issue: Optional[dict] = None) -> dict:
def from_card(card_row, gitea_issue: dict | None = None) -> dict:
"""Convertit une card legacy en pseudo collection_page."""
title = gitea_issue.get("title", f"Card #{card_row['id']}") if gitea_issue else f"Card #{card_row['id']}"
priority = card_row.get("priority", "Medium")
@@ -83,7 +82,7 @@ class GiteaBoardCompat:
return [GiteaBoardCompat.from_board(dict(r)) for r in rows]
@staticmethod
def get_board_as_collection(owner: str, repo: str) -> Optional[dict]:
def get_board_as_collection(owner: str, repo: str) -> dict | None:
"""Récupère un board spécifique comme collection."""
with get_conn() as conn:
row = conn.execute(
@@ -95,7 +94,7 @@ class GiteaBoardCompat:
return GiteaBoardCompat.from_board(dict(row))
@staticmethod
def get_board_cards(owner: str, repo: str, gitea_issues: Optional[list[dict]] = None) -> list[dict]:
def get_board_cards(owner: str, repo: str, gitea_issues: list[dict] | None = None) -> list[dict]:
"""Récupère les cartes d'un board comme collection_pages."""
with get_conn() as conn:
board = conn.execute(
@@ -120,7 +119,7 @@ class GiteaBoardCompat:
]
@staticmethod
def sync_to_collection(owner: str, repo: str, gitea_issues: list[dict]) -> Optional[int]:
def sync_to_collection(owner: str, repo: str, gitea_issues: list[dict]) -> int | None:
"""Sync un board Gitea vers une vraie collection.
Crée ou met à jour une collection liée à Gitea et importe les pages.
+1 -1
View File
@@ -227,4 +227,4 @@ class ContextBuilder:
def _files_context(self, files: list[dict]) -> str:
return "## Attached files\n" + "\n".join(
f"- {f.get('name', 'file')} ({f.get('size', '?')} bytes)" for f in files
)
)
+1 -1
View File
@@ -198,4 +198,4 @@ def create_from_template(
"filters": [],
})),
)
return collection_id
return collection_id
-1
View File
@@ -25,7 +25,6 @@ from urllib.parse import quote
from app.db import get_conn
# ═══════════════ Helpers ═══════════════
def _text(v: str, *, escape: bool = True) -> str:
+72
View File
@@ -0,0 +1,72 @@
"""FlowDeck — v5.2.0 Forge abstraction (Gitea / GitHub).
``ForgeAdapter`` defines the minimal contract a forge client must expose for the
``projects`` table sync and the issue/board integration. ``GiteaAdapter`` wraps
the existing ``GiteaClient``; ``GitHubAdapter`` (in ``github_adapter.py``) is the
GitHub implementation.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
class ForgeAdapter(ABC):
"""Common forge API surface used by FlowDeck (v5.2.0)."""
kind = "base"
@abstractmethod
async def validate_token(self) -> bool:
"""True when the stored credentials still work."""
@abstractmethod
async def list_repos(self, page: int = 1) -> list[dict]:
"""List repositories for the authenticated user."""
@abstractmethod
async def get_repo_info(self, owner: str, repo: str) -> dict:
"""Repository metadata (default_branch, clone_url, language, …)."""
def normalize_repo(repo: dict, proj_type: str) -> dict:
"""Project a forge repo dict onto the ``projects`` table columns."""
full_name = repo.get("full_name", "") or repo.get("fullName", "")
owner, _, name = full_name.partition("/")
return {
"name": name or repo.get("name", ""),
"owner": owner or repo.get("owner", {}).get("login", "") if isinstance(repo.get("owner"), dict) else (owner or ""),
"proj_type": proj_type,
"forge_id": str(repo.get("id", "") or ""),
"clone_url": repo.get("clone_url", "") or repo.get("ssh_url", ""),
"default_branch": repo.get("default_branch", ""),
"language": repo.get("language", ""),
"description": (repo.get("description") or "") or "",
}
class GiteaAdapter(ForgeAdapter):
"""Adapt the existing GiteaClient to the ForgeAdapter contract."""
kind = "gitea"
def __init__(self, client) -> None: # client = GiteaClient instance
self._client = client
async def validate_token(self) -> bool:
try:
await self._client.get_user_repos(page=1, limit=1)
return True
except Exception:
return False
async def list_repos(self, page: int = 1) -> list[dict]:
return await self._client.get_user_repos(page=page, limit=30)
async def get_repo_info(self, owner: str, repo: str) -> dict:
async with __import__("httpx").AsyncClient(timeout=15) as client:
resp = await client.get(
f"{self._client._base}/repos/{owner}/{repo}",
headers=self._client._headers,
)
resp.raise_for_status()
return resp.json()
+3 -3
View File
@@ -1,8 +1,8 @@
"""FlowDeck — Formula Engine (v1.5.0)."""
from __future__ import annotations
from datetime import datetime, date, timedelta
from typing import Any, Callable, Optional
from datetime import date, datetime, timedelta
from typing import Any, Callable
class FormulaEngine:
@@ -214,7 +214,7 @@ class FormulaEngine:
return val.split("...")[0]
return val
def _end(self, ctx: dict, s: Any) -> Optional[str]:
def _end(self, ctx: dict, s: Any) -> str | None:
"""Extract end date from a date range."""
val = str(s)
if "..." in val:
+71 -8
View File
@@ -1,4 +1,4 @@
"""FlowDeck — GitHub API adapter with caching."""
"""FlowDeck — GitHub API adapter with caching (v5.2.0: ForgeAdapter)."""
from __future__ import annotations
import base64
@@ -8,26 +8,36 @@ from typing import Any
import httpx
from app.services.forge_adapter import ForgeAdapter
logger = logging.getLogger(__name__)
DEFAULT_TTL = 30 # seconds
class GitHubAdapter:
class GitHubAdapter(ForgeAdapter):
"""Async GitHub API client (v3 REST) with simple TTL cache.
Authenticated via OAuth2 Bearer token.
Authenticated via OAuth2 Bearer token. Implements the ``ForgeAdapter``
interface so Gitea and GitHub repos can be synced identically.
"""
def __init__(self, access_token: str, ttl: int = DEFAULT_TTL) -> None:
kind = "github"
def __init__(self, access_token: str, ttl: int = DEFAULT_TTL,
transport: httpx.BaseTransport | None = None) -> None:
self._base = "https://api.github.com"
self._headers = {
"Authorization": f"Bearer {access_token}",
"Accept": "application/vnd.github+json",
}
self._transport = transport
self._cache: dict[str, tuple[datetime, Any]] = {}
self._ttl = timedelta(seconds=ttl)
def _client(self) -> httpx.AsyncClient:
return httpx.AsyncClient(timeout=15, transport=self._transport)
# ── cache helpers ──
def _cached(self, key: str) -> Any | None:
@@ -48,7 +58,7 @@ class GitHubAdapter:
if cached:
return cached
async with httpx.AsyncClient(timeout=15) as client:
async with self._client() as client:
resp = await client.get(
f"{self._base}/user/repos",
headers=self._headers,
@@ -81,7 +91,7 @@ class GitHubAdapter:
if cached:
return cached
async with httpx.AsyncClient(timeout=15) as client:
async with self._client() as client:
# Resolve default branch commit SHA if not provided
if sha is None:
repo_info = await client.get(
@@ -128,7 +138,7 @@ class GitHubAdapter:
if cached:
return cached
async with httpx.AsyncClient(timeout=15) as client:
async with self._client() as client:
resp = await client.get(
f"{self._base}/repos/{owner}/{repo}/contents/{path}",
headers=self._headers,
@@ -146,11 +156,64 @@ class GitHubAdapter:
self._set_cache(cache_key, content)
return content
# ── repo info (ForgeAdapter) ──
async def get_repo_info(self, owner: str, repo: str) -> dict:
"""Repository metadata: default_branch, clone_url, languages, …"""
cache_key = f"repo_info:{owner}:{repo}"
cached = self._cached(cache_key)
if cached:
return cached
async with self._client() as client:
resp = await client.get(
f"{self._base}/repos/{owner}/{repo}",
headers=self._headers,
)
resp.raise_for_status()
info = resp.json()
repo_info = {
"id": info.get("id"),
"name": info.get("name"),
"owner": (info.get("owner") or {}).get("login", owner),
"full_name": info.get("full_name"),
"clone_url": info.get("clone_url", ""),
"default_branch": info.get("default_branch", "main"),
"description": info.get("description") or "",
"language": info.get("language") or "",
"html_url": info.get("html_url", ""),
}
# Languages are a separate endpoint.
try:
lang_resp = await client.get(
f"{self._base}/repos/{owner}/{repo}/languages",
headers=self._headers,
)
if lang_resp.status_code == 200:
langs = lang_resp.json()
if langs:
repo_info["language"] = max(langs, key=langs.get)
except Exception:
pass
self._set_cache(cache_key, repo_info)
return repo_info
async def get_languages(self, owner: str, repo: str) -> dict:
"""Bytes per language for a repo."""
async with self._client() as client:
resp = await client.get(
f"{self._base}/repos/{owner}/{repo}/languages",
headers=self._headers,
)
resp.raise_for_status()
return resp.json()
# ── token validation ──
async def validate_token(self) -> bool:
"""Check whether the access token is still valid."""
async with httpx.AsyncClient(timeout=10) as client:
async with self._client() as client:
resp = await client.get(
f"{self._base}/user",
headers=self._headers,
+5 -5
View File
@@ -12,10 +12,9 @@ from __future__ import annotations
import json
import time
from typing import Optional
from app.config import settings
from app.services.llm_client import PROVIDERS, PROVIDER_MODELS
from app.services.llm_client import PROVIDER_MODELS, PROVIDERS
# Providers whose /v1/models lists far more entries than /v1/chat/completions
# actually serves. The fetched list is validated (name filter + live probe)
@@ -198,7 +197,7 @@ def _mask_key(row) -> dict:
}
def get_user_llm_key(user_id: int, provider: str) -> Optional[dict]:
def get_user_llm_key(user_id: int, provider: str) -> dict | None:
"""Return a stored key row (includes the raw api_key — server-side only)."""
from app.db import get_conn
@@ -225,7 +224,7 @@ def list_user_llm_keys(user_id: int) -> list[dict]:
def upsert_user_llm_key(user_id: int, provider: str, *, api_key: str = "",
api_base: str = "", default_model: str = "",
models: Optional[list[str]] = None) -> dict:
models: list[str] | None = None) -> dict:
"""Upsert a user's provider key. Empty api_key keeps the existing one
(allows saving model/base without re-typing the key). Saving a *different*
key resets the `verified` flag so the provider must pass a test again."""
@@ -370,6 +369,7 @@ async def _validate_chat_models(base_url: str, api_key: str, candidates: list[st
are retried once before giving up, so slow-but-working models survive.
"""
import asyncio
import httpx
sem = asyncio.Semaphore(concurrency)
@@ -406,4 +406,4 @@ async def _validate_chat_models(base_url: str, api_key: str, candidates: list[st
return None
results = await asyncio.gather(*(probe(m) for m in candidates))
return [m for m in results if isinstance(m, str)]
return [m for m in results if isinstance(m, str)]
+1 -1
View File
@@ -83,4 +83,4 @@ def notify_user(
prefs = notifications.get_user_prefs(user_id)
if not prefs.get(prefs_key, True):
return False
return send_email(row["email"], subject, body_text, cta_url)
return send_email(row["email"], subject, body_text, cta_url)
+1 -1
View File
@@ -144,4 +144,4 @@ def set_user_prefs(user_id: int, prefs: dict, conn=None) -> dict:
(json.dumps(prefs), user_id),
)
conn.commit()
return prefs
return prefs
+1 -1
View File
@@ -99,4 +99,4 @@ class PermissionManager:
)
# A viewer can always read; editor can read+write.
if role not in READ_ROLES:
raise HTTPException(status_code=403, detail="User has no access to this workspace")
raise HTTPException(status_code=403, detail="User has no access to this workspace")
+135
View File
@@ -0,0 +1,135 @@
"""FlowDeck — v5.2.0 Projects: normalized forge-agnostic project registry.
The ``projects`` table stores one row per repository across forges (builtin /
gitea / github). A background scheduler refreshes metadata (default branch,
language) periodically so the UI always shows up-to-date info.
"""
from __future__ import annotations
import logging
from app.config import settings
from app.db import get_conn
from app.services.forge_adapter import GiteaAdapter, normalize_repo
logger = logging.getLogger(__name__)
def register_repo(repo: dict, proj_type: str) -> int | None:
"""Upsert a forge repo into the projects table. Returns project id."""
data = normalize_repo(repo, proj_type)
if not data["name"]:
return None
with get_conn() as conn:
existing = conn.execute(
"SELECT id FROM projects WHERE proj_type=? AND owner=? AND name=?",
(proj_type, data["owner"], data["name"]),
).fetchone()
if existing:
conn.execute(
"""UPDATE projects SET forge_id=?, clone_url=?, default_branch=?,
language=?, description=?, last_synced_at=CURRENT_TIMESTAMP
WHERE id=?""",
(data["forge_id"], data["clone_url"], data["default_branch"],
data["language"], data["description"], existing["id"]),
)
conn.commit()
return existing["id"]
cur = conn.execute(
"""INSERT INTO projects
(name, proj_type, owner, forge_id, clone_url, default_branch, language, description, last_synced_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)""",
(data["name"], proj_type, data["owner"], data["forge_id"], data["clone_url"],
data["default_branch"], data["language"], data["description"]),
)
conn.commit()
return cur.lastrowid
def list_projects(proj_type: str | None = None) -> list[dict]:
with get_conn() as conn:
if proj_type:
rows = conn.execute(
"SELECT * FROM projects WHERE proj_type=? ORDER BY name",
(proj_type,),
).fetchall()
else:
rows = conn.execute("SELECT * FROM projects ORDER BY proj_type, name").fetchall()
return [dict(r) for r in rows]
def create_builtin_project(name: str, owner: str = "", description: str = "") -> dict:
"""Register a standalone (non-forge) project."""
with get_conn() as conn:
existing = conn.execute(
"SELECT id FROM projects WHERE proj_type='builtin' AND owner=? AND name=?",
(owner, name),
).fetchone()
if existing:
return {"id": existing["id"], "name": name}
cur = conn.execute(
"INSERT INTO projects (name, proj_type, owner, description) VALUES (?, 'builtin', ?, ?)",
(name, owner, description),
)
conn.commit()
return {"id": cur.lastrowid, "name": name}
# ═══════════ Periodic sync ═══════════
async def _sync_gitea(user_id: int, token: str) -> int:
from app.services.gitea_client import GiteaClient
gitea = GiteaClient(user_token=token)
adapter = GiteaAdapter(gitea)
repos = await adapter.list_repos(page=1)
count = 0
for repo in repos:
if register_repo(repo, "gitea"):
count += 1
return count
async def _sync_github(user_id: int, token: str) -> int:
from app.services.github_adapter import GitHubAdapter
adapter = GitHubAdapter(token)
repos = await adapter.list_all_repos()
count = 0
for repo in repos:
if register_repo(repo, "github"):
count += 1
return count
async def sync_all_projects() -> dict:
"""Refresh the projects table from every connected forge token."""
stats = {"gitea": 0, "github": 0, "error": 0}
with get_conn() as conn:
rows = conn.execute(
"SELECT user_id, provider, access_token FROM user_oauth_tokens "
"WHERE provider IN ('gitea','github') AND access_token != ''"
).fetchall()
tokens = [dict(r) for r in rows]
for t in tokens:
try:
if t["provider"] == "gitea":
stats["gitea"] += await _sync_gitea(t["user_id"], t["access_token"])
elif t["provider"] == "github":
stats["github"] += await _sync_github(t["user_id"], t["access_token"])
except Exception as exc:
stats["error"] += 1
logger.warning("project sync (%s user=%s) failed: %s", t["provider"], t["user_id"], exc)
logger.info("projects sync done: %s", stats)
return stats
async def project_sync_scheduler():
"""Background loop: refresh projects every interval (default hourly)."""
while True:
if settings.project_sync_enabled:
try:
await sync_all_projects()
except Exception as exc:
logger.warning("project_sync_scheduler error: %s", exc)
await __import__("asyncio").sleep(settings.project_sync_interval_hours * 3600)
+7 -7
View File
@@ -2,8 +2,8 @@
from __future__ import annotations
import json
from datetime import datetime, timezone
from typing import Any, Optional
from datetime import UTC, datetime
from typing import Any
# ── Property type definitions ──
@@ -106,7 +106,7 @@ SIMPLE_TYPES = ["title", "text", "number", "select", "multi_select", "status",
AUTO_TYPES = ["created_time", "created_by", "last_edited_time", "last_edited_by"]
def validate_property_value(prop_type: str, value: Any, options: Optional[list] = None) -> tuple[bool, str]:
def validate_property_value(prop_type: str, value: Any, options: list | None = None) -> tuple[bool, str]:
"""Validate a property value against its type. Returns (ok, error_message)."""
if value is None:
return True, ""
@@ -173,9 +173,9 @@ def parse_validation(validation) -> dict:
def validate_property_rule(
prop_type: str,
value: Any,
validation: Optional[dict] = None,
validation: dict | None = None,
*,
existing_values: Optional[list] = None,
existing_values: list | None = None,
) -> tuple[bool, str]:
"""Validate a property value against type + validation rules.
@@ -227,10 +227,10 @@ def validate_property_rule(
return True, ""
def get_auto_property_value(prop_type: str, user: Optional[dict] = None) -> Any:
def get_auto_property_value(prop_type: str, user: dict | None = None) -> Any:
"""Compute the value of an auto-property."""
if prop_type == "created_time" or prop_type == "last_edited_time":
return datetime.now(timezone.utc).isoformat()
return datetime.now(UTC).isoformat()
if prop_type == "created_by" or prop_type == "last_edited_by":
if user:
return {"id": user.get("id"), "login": user.get("login")}
+2 -2
View File
@@ -80,7 +80,7 @@ class Room:
self.blocks: list[dict] = []
self.title = ""
self.version = 0
self.conns: set["RTConn"] = set()
self.conns: set[RTConn] = set()
self.persist_task: asyncio.Task | None = None
self.dirty = False
@@ -291,4 +291,4 @@ class RealtimeManager:
return {"blocks": room.blocks, "title": room.title, "version": room.version}
manager = RealtimeManager()
manager = RealtimeManager()
+5 -5
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import json
import statistics
from typing import Any, Optional
from typing import Any
from app.db import get_conn
@@ -105,12 +105,12 @@ class RollupEngine:
return ROLLUP_FUNCTIONS[rollup_function](values)
def _safe_avg(values: list) -> Optional[float]:
def _safe_avg(values: list) -> float | None:
nums = [float(v) for v in values if v is not None]
return sum(nums) / len(nums) if nums else None
def _safe_stat(values: list, fn) -> Optional[float]:
def _safe_stat(values: list, fn) -> float | None:
nums = [float(v) for v in values if v is not None]
return fn(nums) if nums else None
@@ -123,12 +123,12 @@ def _numeric(gen):
pass
def _safe_range(values: list) -> Optional[float]:
def _safe_range(values: list) -> float | None:
nums = list(_numeric(v for v in values if v is not None))
return max(nums) - min(nums) if len(nums) >= 2 else None
def _percent_checked(values: list) -> Optional[float]:
def _percent_checked(values: list) -> float | None:
"""Percentage of true values (for checkbox properties)."""
if not values:
return 0.0
+6 -7
View File
@@ -8,7 +8,6 @@ from __future__ import annotations
import logging
import re
from typing import Optional
from app.db import get_conn
from app.migrations import fts5_available
@@ -24,7 +23,7 @@ def _fts_terms(query: str) -> list[str]:
return [t.replace('"', '""') for t in tokens if t]
def _fts_match(query: str) -> Optional[str]:
def _fts_match(query: str) -> str | None:
"""Build a MATCH expression, or None when the query is not FTS-safe."""
terms = _fts_terms(query)
if not terms:
@@ -78,7 +77,7 @@ def _workspace_name(conn, workspace_id) -> str:
return ""
def _scope_where(user_id: Optional[int]) -> tuple[str, list]:
def _scope_where(user_id: int | None) -> tuple[str, list]:
"""SQL filter restricting results to the user's accessible workspaces."""
if user_id is None:
return "1=1", []
@@ -92,7 +91,7 @@ def _scope_where(user_id: Optional[int]) -> tuple[str, list]:
# ── Search entry point ──────────────────────────────────────────────────────
def search(query: str, user_id: Optional[int] = None, limit: int = 20) -> dict:
def search(query: str, user_id: int | None = None, limit: int = 20) -> dict:
"""Return unified search results: ``{pages: [...], collections: [...]}``."""
q = (query or "").strip()
if not q:
@@ -104,7 +103,7 @@ def search(query: str, user_id: Optional[int] = None, limit: int = 20) -> dict:
return {"pages": pages, "collections": collections}
def _search_pages(conn, query: str, user_id: Optional[int], limit: int) -> list:
def _search_pages(conn, query: str, user_id: int | None, limit: int) -> list:
like = f"%{query}%"
scope, params = _scope_where(user_id)
@@ -142,7 +141,7 @@ def _search_pages(conn, query: str, user_id: Optional[int], limit: int) -> list:
return _page_rows_to_results(conn, rows)
def _search_collections(conn, query: str, user_id: Optional[int], limit: int) -> list:
def _search_collections(conn, query: str, user_id: int | None, limit: int) -> list:
like = f"%{query}%"
scope, params = _scope_where(user_id)
rows = conn.execute(
@@ -184,4 +183,4 @@ def _page_rows_to_results(conn, rows) -> list:
"excerpt": excerpt[:160],
"url": f"/pages/{r['id']}",
})
return results
return results
+4 -4
View File
@@ -732,8 +732,8 @@ class ReadGiteaIssues(Tool):
}
async def execute(self, args, *, user_id=None) -> ToolResult:
from app.services.gitea_client import GiteaClient, get_user_gitea_client
from app.auth.session import SessionManager
from app.services.gitea_client import GiteaClient
token = SessionManager.get_token(user_id) if user_id else None
client = GiteaClient(user_token=token)
owner, repo = args.get("owner"), args.get("repo")
@@ -758,9 +758,9 @@ class SyncGitea(Tool):
}
async def execute(self, args, *, user_id=None) -> ToolResult:
from app.auth.session import SessionManager
from app.services.collection_adapter import GiteaBoardCompat
from app.services.gitea_client import GiteaClient
from app.auth.session import SessionManager
token = SessionManager.get_token(user_id) if user_id else None
client = GiteaClient(user_token=token)
owner, repo = args.get("owner"), args.get("repo")
@@ -787,8 +787,8 @@ class CreateGiteaIssue(Tool):
}
async def execute(self, args, *, user_id=None) -> ToolResult:
from app.services.gitea_client import GiteaClient
from app.auth.session import SessionManager
from app.services.gitea_client import GiteaClient
token = SessionManager.get_token(user_id) if user_id else None
client = GiteaClient(user_token=token)
try:
@@ -841,4 +841,4 @@ class ToolRegistry:
impl = self.tools.get(tool)
if not impl:
return ToolResult(status="error", tool=tool, message=f"Outil inconnu: {tool}")
return await impl.execute(args, user_id=user_id)
return await impl.execute(args, user_id=user_id)
-1
View File
@@ -1,7 +1,6 @@
"""FlowDeck — Webhook outbound dispatcher (v2.1.0)."""
from __future__ import annotations
import json
import logging
import httpx