v5.2.0: Infrastructure & Polish
- 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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
|
||||
@@ -198,4 +198,4 @@ def create_from_template(
|
||||
"filters": [],
|
||||
})),
|
||||
)
|
||||
return collection_id
|
||||
return collection_id
|
||||
|
||||
@@ -25,7 +25,6 @@ from urllib.parse import quote
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
|
||||
# ═══════════════ Helpers ═══════════════
|
||||
|
||||
def _text(v: str, *, escape: bool = True) -> str:
|
||||
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
@@ -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")}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,7 +1,6 @@
|
||||
"""FlowDeck — Webhook outbound dispatcher (v2.1.0)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
Reference in New Issue
Block a user