feat(v5.2.0): finalize Infrastructure & Polish (tests isolation, xdist, lint, CI)
FlowDeck CI / lint (push) Successful in 43s
FlowDeck CI / test (push) Successful in 4m2s
FlowDeck CI / lint (pull_request) Successful in 42s
FlowDeck CI / test (pull_request) Successful in 4m3s
FlowDeck CI / docker (push) Successful in 1m2s
FlowDeck CI / docker (pull_request) Successful in 35s

tests/conftest.py: mutate the settings singleton (instead of rebinding) so DB + backup dir are isolated per test -> pytest-xdist safe.
Real backup tests (snapshot/prune/admin API) and OAuth mock tests (Gitea/GitHub/link) replace the previous skips.
init_db() now also creates webhook_subscriptions (full schema without the FastAPI lifespan).
ruff check is clean; .eslintrc.json migrated to eslint.config.mjs (flat config).
CI: lint job (ruff + eslint), parallel tests (-n auto), run on every branch push.
VERSION 5.11.1.
This commit is contained in:
2026-09-11 23:36:53 -04:00
parent 881c3e3e0a
commit ba363eaee9
42 changed files with 566 additions and 206 deletions
-43
View File
@@ -1,43 +0,0 @@
{
"root": true,
"env": {
"browser": true,
"es2022": true
},
"parserOptions": {
"ecmaVersion": 2022,
"sourceType": "script"
},
"globals": {
"Alpine": "readonly",
"htmx": "readonly",
"Sortable": "readonly",
"window": "readonly",
"document": "readonly",
"localStorage": "readonly",
"confirm": "readonly",
"fetch": "readonly",
"navigator": "readonly",
"setTimeout": "readonly",
"setInterval": "readonly",
"history": "readonly",
"Location": "readonly",
"URLSearchParams": "readonly",
"location": "readonly"
},
"rules": {
"no-unused-vars": ["warn", { "args": "none" }],
"no-undef": "error",
"no-extra-semi": "warn",
"no-empty": "warn"
},
"overrides": [
{
"files": ["static/js/**/*.js"],
"rules": {
"no-undef": "warn"
}
}
],
"ignorePatterns": ["static/js/*.min.js", "static/js/vendor/**"]
}
+21 -6
View File
@@ -1,12 +1,26 @@
name: FlowDeck CI name: FlowDeck CI
on: on:
# Run on every pushed branch so feature branches are validated before the PR.
push: push:
branches: [main]
pull_request: pull_request:
branches: [main] branches: [main, develop]
jobs: jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install lint tools
run: pip install -r requirements-dev.txt
- name: Ruff (Python)
run: ruff check app tests
- name: ESLint (JavaScript)
run: npx --yes eslint static/js
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
# NOTE: no `container:` here. A `python:*-slim` image ships no Node.js, so the # NOTE: no `container:` here. A `python:*-slim` image ships no Node.js, so the
@@ -19,7 +33,7 @@ jobs:
with: with:
python-version: '3.12' python-version: '3.12'
- name: Install system dependencies (WeasyPrint / emoji fonts) - name: Install system dependencies (WeasyPrint / emoji fonts)
run: | run: |-
SUDO="" SUDO=""
if command -v sudo >/dev/null 2>&1; then SUDO="sudo"; fi if command -v sudo >/dev/null 2>&1; then SUDO="sudo"; fi
$SUDO apt-get update $SUDO apt-get update
@@ -27,13 +41,14 @@ jobs:
libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b libffi-dev \ libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b libffi-dev \
libjpeg-dev libopenjp2-7 libcairo2 fonts-noto-color-emoji libjpeg-dev libopenjp2-7 libcairo2 fonts-noto-color-emoji
- name: Install Python dependencies - name: Install Python dependencies
run: pip install -r requirements.txt pytest pytest-cov run: pip install -r requirements-dev.txt pytest-cov
- name: Run tests with coverage - name: Run tests (parallel) with coverage
env: env:
GITEA_URL: https://git.dracodev.net GITEA_URL: https://git.dracodev.net
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
APP_SECRET_KEY: ci-test-key APP_SECRET_KEY: ci-test-key
run: python -m pytest tests/ -v --tb=short --cov=app --cov-report=term # `-n auto` needs pytest-xdist, provided by requirements-dev.txt.
run: python -m pytest tests/ -v --tb=short -n auto --cov=app --cov-report=term
- name: Coverage summary - name: Coverage summary
if: always() if: always()
run: coverage report -m || true run: coverage report -m || true
+30
View File
@@ -1,5 +1,35 @@
# Changelog - FlowDeck # Changelog - FlowDeck
## v5.11.1 (2026-09-11) — v5.2.0 Infrastructure & Polish (complétion)
> Finalise le chantier v5.2.0 : la plupart des briques étaient déjà livrées
> (design tokens/components, API tokens, sessions, onboarding, backups, projets +
> sync, GitHubAdapter, Docker multi-stage) ; ce patch sécurise l'isolation des
> tests, active les tests parallèles et rend le linting vert.
- **Tests parallèles (pytest-xdist)** — `tests/conftest.py` **mute** désormais le
singleton `app.config.settings` au lieu de le remplacer. Les modules qui
importaient `settings` au chargement (ex. `app/services/backup.py`) gardaient
sinon les valeurs par défaut, ce qui rendait les tests backup instables. Chaque
test a une base SQLite temporaire + un dossier de backup dédiés → tests
parallèles lancés explicitement (`pytest -n auto` en local et en CI). La config
de base ne force plus `-n` (évite l'échec `unrecognized arguments: -n` si
`pytest-xdist` n'est pas installé).
- **Backups réellement testés** — les 2 tests précédemment `skip` (flaky) sont
remplacés par des tests réels : snapshot daté, `prune_old_backups`, `backup_due`
et API admin `/api/settings/backups`.
- **OAuth (mock)** — tests d'intégration complets dans `tests/test_v52_infra.py` :
redirect authorize → callback (Gitea + GitHub), mode `link` sur un compte local,
rejet d'un `state` invalide, construction des URLs d'autorisation.
- **Schéma complet hors lifespan** — `init_db()` crée aussi
`webhook_subscriptions` (auparavant uniquement dans le lifespan FastAPI).
- **Linting** — `ruff check app tests` passe sans erreur (corrections E701/E702,
B904, B007, E741, F821, F841, W293, UP031, E731 + config FastAPI pour B008) ;
migration de `.eslintrc.json` vers `eslint.config.mjs` (flat config, ESLint v9+).
- **CI** — nouveau job `lint` (ruff + eslint) ; tests exécutés en parallèle avec
couverture ; déclencheurs élargis à `develop`.
- **Version** — 5.11.1. **397 tests** verts.
## v5.11.0 (2026-09-11) — FlowDeck Agent : UX chat améliorée ## v5.11.0 (2026-09-11) — FlowDeck Agent : UX chat améliorée
> Quatre améliorations majeures du panneau Agent (v4.10.0 → v4.14.0) pour > Quatre améliorations majeures du panneau Agent (v4.10.0 → v4.14.0) pour
+33 -28
View File
@@ -437,40 +437,44 @@ app/
- [x] Hooks événements dans collections.py / board.py / workspace.py (apply_page_template) - [x] Hooks événements dans collections.py / board.py / workspace.py (apply_page_template)
- [x] **11 tests** `tests/test_automations.py` ; suite complète 289 verte - [x] **11 tests** `tests/test_automations.py` ; suite complète 289 verte
### v5.2.0 — Infrastructure & Polish 🔄 (en cours) ### v5.2.0 — Infrastructure & Polish ✅ (2026-09-11)
> ✅ **Priorité n°1 livrée** : les **migrations versionnées** (voir `app/migrations.py` + > **Objectif** : fondations de production — design system, sécurité, infra, forge.
> table `schema_version`) — la base comptait 30+ tables créées ad-hoc ; tout nouveau > **COMPLETED**.
> schéma passe désormais par des étapes versionnées (baseline v1, indexes v2, FTS5 v3).
> **État actuel** : API publique + tokens **partiellement implémentés** (`routers/public_api.py`,
> test `test_public_api_token`) — à compléter dans la section Sécurité.
**Design system** **Design system**
- [ ] **Design tokens** — `design-tokens.css` (couleurs, espacements, typographie unifiés) - [x] **Design tokens** — `static/css/design-tokens.css` (couleurs, espacements, typo, ombres, z-index) chargé après `app.css`
- [ ] **Composants réutilisables** — boutons, inputs, modales, dropdowns, toasts - [x] **Composants réutilisables** — `static/css/components.css` (btn/input/modal/dropdown/toast/card/badge/empty/table)
**Sécurité & Utilisateur** **Sécurité & Utilisateur**
- [ ] **API Tokens** — générer/révoquer des clés API utilisateur *(PARTIEL : `public_api.py` + tokens existent, manque la gestion UI dans Settings)* - [x] **API Tokens** — générer/lister/révoquer dans Settings → API tokens (`app/routers/security.py`, table `api_tokens`, bearer `/api/v1`)
- [ ] **Sessions actives** — voir et révoquer les sessions - [x] **Sessions actives** — liste + révocation (`user_sessions`, `SessionManager.list_sessions/revoke_session`)
- [ ] **Onboarding wizard** — `/welcome` au premier lancement (créer compte → lier forges → premier projet) - [x] **Onboarding wizard** — `/welcome` (workspace → forge → premier projet) + `welcome.html`
**Infrastructure** **Infrastructure**
- [x] **🥇 Migrations versionnées** — table `schema_version` + runner `app/migrations.py` (baseline v1, indexes v2, FTS5 v3) ✅ - [x] **🥇 Migrations versionnées** — table `schema_version` + runner `app/migrations.py` (baseline v1, indexes v2, FTS5 v3)
- [ ] **Backup automatique** — cron daily → fichier daté - [x] **Backup automatique** — snapshot SQLite quotidien (`app/services/backup.py`, scheduler + API admin, rétention configurable)
- [x] **Index manquants** — `users.email`, `user_oauth_tokens(user_id, provider)`, `collections/pages(workspace_id)`, `pages(deleted_at)` ✅ - [x] **Index manquants** — `users.email`, `user_oauth_tokens(user_id, provider)`, `collections/pages(workspace_id)`, `pages(deleted_at)`
- [ ] **Linting** — ruff (Python), eslint (JS) *(aucune config actuellement)* - [x] **Linting** — ruff (Python, `pyproject.toml`) + eslint flat (`eslint.config.mjs`) ; `ruff check` et `eslint` sans erreur
- [ ] **Tests parallèles** — pytest-xdist - [x] **Tests parallèles** — pytest-xdist (`pytest -n auto` en local et en CI ; DB + dossier backup isolés par test)
- [ ] **Build Docker multi-stage** — optimiser taille d'image - [x] **Build Docker multi-stage** — builder + runtime (libs WeasyPrint), image allégée
**Forge integration** **Forge integration**
- [ ] **DB: table `projects`** — type (builtin/gitea/github), forge_id, clone_url, default_branch, language - [x] **DB: table `projects`** — type (builtin/gitea/github), forge_id, clone_url, default_branch, language
- [ ] **Cron: sync périodique des projets** — configurable (défaut: chaque heure) - [x] **Cron: sync périodique des projets** — `project_sync_scheduler` (défaut : chaque heure)
- [ ] **GitHubAdapter** complet — API GitHub v3 → interface ForgeAdapter - [x] **GitHubAdapter** complet — API GitHub v3 → interface `ForgeAdapter`
**Tests (cibles)** **Tests (cibles)**
- [x] **267 tests** — 259+ (dont 8 nouveaux migrations/search) ✅ - [x] **397 tests** — dont backups, projets, adapters forge (mock HTTP), OAuth (mock), multi-user ✅
- [ ] Tests d'intégration auth (OAuth mock) - [x] Tests d'intégration auth (OAuth mock) — login/callback Gitea + GitHub, mode `link`, rejet d'état invalide
- [ ] Tests des adapters forge (mock HTTP) - [x] Tests des adapters forge (mock HTTP) — `GitHubAdapter` via `httpx.MockTransport`
- [ ] Tests multi-user (permissions croisées) - [x] Tests multi-user (permissions croisées) — `PermissionManager` owner/editor/viewer
**Complétion du 2026-09-11** :
- Isolation des tests corrigée : `tests/conftest.py` **mute** le singleton `settings` (au lieu de le remplacer), sinon les modules ayant importé `settings` au chargement (ex. `backup.py`) gardaient les valeurs par défaut → backups flaky. DB temporaire unique par test, sûr en xdist.
- Backups réellement testés (snapshot + prune + API admin) : les 2 skips supprimés.
- `init_db()` crée désormais aussi `webhook_subscriptions` (schéma complet sans dépendre du lifespan FastAPI).
- Tests OAuth mock (flux complet) ajoutés dans `tests/test_v52_infra.py`.
- CI : job `lint` (ruff + eslint) + tests parallèles.
### v5.3.0 — Database Avancée ✅ (2026-09-06) ### v5.3.0 — Database Avancée ✅ (2026-09-06)
> **Objectif** : databases inline + templates prédéfinis + validation propriétés. **COMPLETED**. > **Objectif** : databases inline + templates prédéfinis + validation propriétés. **COMPLETED**.
@@ -616,11 +620,12 @@ app/
## 🎯 Ordre de priorité recommandé (état 2026-09) ## 🎯 Ordre de priorité recommandé (état 2026-09)
1. ~~**v5.2.0 → Migrations versionnées**~~ ✅ livré (`schema_version` + `app/migrations.py`) 1. ~~**v5.2.0 → Infrastructure & Polish**~~ ✅ livré (design tokens/components, API tokens, sessions, onboarding, backups, projets + sync, GitHubAdapter, lint ruff/eslint, tests parallèles, Docker multi-stage)
2. ~~**v5.0.0 → Command palette + FTS5**~~ ✅ livré (palette Ctrl+K + `GET /api/search`) 2. ~~**v5.0.0 → Command palette + FTS5**~~ ✅ livré (palette Ctrl+K + `GET /api/search`)
3. ~~**v5.3.0 → Inline databases + templates + validation**~~ ✅ livré (slash `/database`, 6 templates, validation propriétés) 3. ~~**v5.3.0 → Inline databases + templates + validation**~~ ✅ livré (slash `/database`, 6 templates, validation propriétés)
1. ~~**v5.13.0 → Realtime (WS + présence)**~~ ✅ livré (`app/services/realtime_server.py` + `WS /ws/pages/{id}`, présence, curseurs live, merge LWW, 12 tests) 4. ~~**v5.13.0 → Realtime (WS + présence)**~~ ✅ livré (`app/services/realtime_server.py` + `WS /ws/pages/{id}`, présence, curseurs live, merge LWW, 12 tests)
2. ~~**v5.10.0 → Interactions de bloc**~~ ✅ livré (drag&drop multi, undo/redo, duplicate, menu ⋮, en-têtes de tableau, 9 tests) 5. ~~**v5.10.0 → Interactions de bloc**~~ ✅ livré (drag&drop multi, undo/redo, duplicate, menu ⋮, en-têtes de tableau, 9 tests)
6. **v5.4.0 → Expérience éditeur** — backlinks, duplicate, corbeille globale, historique de version UI, import (prochain)
--- ---
## Résumé des phases ## Résumé des phases
@@ -639,4 +644,4 @@ Quality DB views, Agent IA Palette → Realtime + S
DB avancée, redo, drag&drop, DB avancée, redo, drag&drop,
Calendrier, AI duplicate) Calendrier, AI duplicate)
*Dernière mise à jour: 2026-09-10 — v5.9.0 AI Writing Assist livré (slash `/ai`, autocomplétion `AIAC`, AI properties, service `ai_writing.py`, 2 endpoints, 29 tests) ; reste v5.4, v5.11 → v6.0* *Dernière mise à jour: 2026-09-11 — v5.2.0 Infrastructure & Polish complété (isolation tests + xdist, backups testés, OAuth mock, lint ruff/eslint vert, CI lint) ; reste v5.4, v5.11 → v6.0*
+1 -1
View File
@@ -1 +1 @@
5.11.0 5.11.1
+2
View File
@@ -1,3 +1,5 @@
"""FlowDeck — Auth module: session, OAuth2, dependencies.""" """FlowDeck — Auth module: session, OAuth2, dependencies."""
from app.auth.oauth import GiteaOAuth from app.auth.oauth import GiteaOAuth
from app.auth.session import SessionManager, get_current_user from app.auth.session import SessionManager, get_current_user
__all__ = ["GiteaOAuth", "SessionManager", "get_current_user"]
+5
View File
@@ -817,6 +817,11 @@ def init_db():
from app.migrations import apply_migrations from app.migrations import apply_migrations
apply_migrations(conn) apply_migrations(conn)
# 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
init_webhook_tables()
@contextmanager @contextmanager
def get_conn(): def get_conn():
+2 -2
View File
@@ -85,7 +85,7 @@ async def lifespan(_app: FastAPI):
from app.services.trash import trash_purge_scheduler from app.services.trash import trash_purge_scheduler
trash_task = asyncio.create_task(trash_purge_scheduler()) trash_task = asyncio.create_task(trash_purge_scheduler())
logger.info("FlowDeck v5.10.0 started on port %d", settings.app_port) logger.info("FlowDeck v5.11.1 started on port %d", settings.app_port)
try: try:
yield yield
finally: finally:
@@ -100,7 +100,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI( app = FastAPI(
title="FlowDeck", title="FlowDeck",
version="5.10.0", version="5.11.1",
docs_url="/docs" if settings.log_level == "DEBUG" else None, docs_url="/docs" if settings.log_level == "DEBUG" else None,
redoc_url=None, redoc_url=None,
lifespan=lifespan, lifespan=lifespan,
+2
View File
@@ -1,2 +1,4 @@
"""FlowDeck — Custom middleware.""" """FlowDeck — Custom middleware."""
from app.middleware.csrf import CSRFMiddleware from app.middleware.csrf import CSRFMiddleware
__all__ = ["CSRFMiddleware"]
+4 -4
View File
@@ -178,7 +178,7 @@ async def create_agent(request: Request):
) )
conn.commit() conn.commit()
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=409, detail=f"Impossible de créer l'agent: {exc}") raise HTTPException(status_code=409, detail=f"Impossible de créer l'agent: {exc}") from exc
return {"id": cur.lastrowid, "name": name, "status": "created"} return {"id": cur.lastrowid, "name": name, "status": "created"}
@@ -466,9 +466,9 @@ async def undo(request: Request, action_id: int):
try: try:
undo_action(action_id) undo_action(action_id)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=500, detail=f"Rollback échoué: {exc}") raise HTTPException(status_code=500, detail=f"Rollback échoué: {exc}") from exc
return {"id": action_id, "status": "reverted"} return {"id": action_id, "status": "reverted"}
@@ -501,7 +501,7 @@ async def create_skill(request: Request):
) )
conn.commit() conn.commit()
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=409, detail=f"Skill existe déjà: {exc}") raise HTTPException(status_code=409, detail=f"Skill existe déjà: {exc}") from exc
return {"id": cur.lastrowid, "name": name, "status": "created"} return {"id": cur.lastrowid, "name": name, "status": "created"}
+4 -4
View File
@@ -149,7 +149,7 @@ async def move_card(
issue = await gitea.get_issue(owner, repo, issue_id) issue = await gitea.get_issue(owner, repo, issue_id)
current_labels = [lbl["name"] for lbl in issue.get("labels", [])] current_labels = [lbl["name"] for lbl in issue.get("labels", [])]
status_labels = await _get_status_labels(owner, repo, board_id) status_labels = await _get_status_labels(owner, repo, board_id)
filtered_names = [l for l in current_labels if l not in status_labels] filtered_names = [name for name in current_labels if name not in status_labels]
filtered_names.append(mapping["gitea_label"]) filtered_names.append(mapping["gitea_label"])
# Resolve label names to IDs # Resolve label names to IDs
@@ -293,7 +293,7 @@ async def create_issue(
if not _check_rate_limit(request): if not _check_rate_limit(request):
raise HTTPException(status_code=429, detail="Rate limit exceeded") raise HTTPException(status_code=429, detail="Rate limit exceeded")
label_ids = [int(l) for l in labels.split(",") if l.strip().isdigit()] if labels else None label_ids = [int(lbl) for lbl in labels.split(",") if lbl.strip().isdigit()] if labels else None
milestone_id = int(milestone) if milestone.strip().isdigit() else None milestone_id = int(milestone) if milestone.strip().isdigit() else None
issue = await gitea.create_issue( issue = await gitea.create_issue(
@@ -345,7 +345,7 @@ async def update_issue_api(
if state: if state:
kwargs["state"] = state kwargs["state"] = state
if labels: if labels:
label_ids = [int(l) for l in labels.split(",") if l.strip().isdigit()] label_ids = [int(lbl) for lbl in labels.split(",") if lbl.strip().isdigit()]
if milestone and milestone.strip().isdigit(): if milestone and milestone.strip().isdigit():
kwargs["milestone"] = int(milestone) kwargs["milestone"] = int(milestone)
if assignee: if assignee:
@@ -388,7 +388,7 @@ async def get_issue_detail(owner: str, repo: str, issue_id: int, format: str = Q
comments = await gitea.get_issue_comments(owner, repo, issue_id) comments = await gitea.get_issue_comments(owner, repo, issue_id)
except Exception as e: except Exception as e:
logger.warning("Failed to fetch issue %s/%s #%d: %s", owner, repo, issue_id, e) logger.warning("Failed to fetch issue %s/%s #%d: %s", owner, repo, issue_id, e)
raise HTTPException(status_code=404, detail=f"Issue #{issue_id} not found") raise HTTPException(status_code=404, detail=f"Issue #{issue_id} not found") from e
# Get checklists from local DB # Get checklists from local DB
with get_conn() as conn: with get_conn() as conn:
+1 -1
View File
@@ -53,7 +53,7 @@ def _validate_payload(body: dict) -> None:
else: else:
json.dumps(val) json.dumps(val)
except (TypeError, json.JSONDecodeError): except (TypeError, json.JSONDecodeError):
raise HTTPException(status_code=400, detail=f"{key} must be valid JSON") raise HTTPException(status_code=400, detail=f"{key} must be valid JSON") from None
@router.get("/workspace/automations") @router.get("/workspace/automations")
+41 -27
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import json import json
import logging import logging
from pathlib import Path
from fastapi import APIRouter, HTTPException, Query, Request from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse from fastapi.responses import HTMLResponse, JSONResponse
@@ -150,20 +151,34 @@ def _file_icon(name: str, content_format: str = "") -> str:
if content_format and content_format != 'file': if content_format and content_format != 'file':
return 'edit' return 'edit'
n = name.lower() n = name.lower()
if re.search(r'\.(png|jpe?g|gif|webp|svg|bmp|ico)$', n): return 'image' if re.search(r'\.(png|jpe?g|gif|webp|svg|bmp|ico)$', n):
if n.endswith('.pdf'): return 'file' return 'image'
if re.search(r'\.(md|markdown)$', n): return 'edit' if n.endswith('.pdf'):
if n.endswith('.py'): return 'file' return 'file'
if re.search(r'\.(js|jsx|ts|tsx)$', n): return 'file' if re.search(r'\.(md|markdown)$', n):
if re.search(r'\.(html?|xml)$', n): return 'file' return 'edit'
if n.endswith('.css'): return 'file' if n.endswith('.py'):
if n.endswith('.json'): return 'file' return 'file'
if n.endswith('.sql'): return 'file' if re.search(r'\.(js|jsx|ts|tsx)$', n):
if re.search(r'\.(sh|bash|zsh)$', n): return 'file' return 'file'
if n.endswith('.ps1'): return 'file' if re.search(r'\.(html?|xml)$', n):
if re.search(r'\.(rs|go|java|rb|php|c|cpp|h|swift|kt|scala|r)$', n): return 'file' return 'file'
if re.search(r'\.(txt|log)$', n): return 'file' if n.endswith('.css'):
if re.search(r'\.(zip|tar|gz|rar|7z)$', n): return 'file' 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' return 'file'
@@ -847,7 +862,7 @@ async def create_property(owner: str, repo: str, name: str = Query(...),
) )
conn.commit() conn.commit()
except Exception as e: except Exception as e:
raise HTTPException(409, f"Property already exists: {e}") raise HTTPException(409, f"Property already exists: {e}") from e
return {"status": "ok", "name": name, "type": prop_type} return {"status": "ok", "name": name, "type": prop_type}
@@ -901,7 +916,7 @@ async def extract_ai_keywords(owner: str, repo: str):
if not issue.get("pull_request"): if not issue.get("pull_request"):
_extract_ai_keywords(owner, repo, issue.get("labels", []), issue.get("body", "")) _extract_ai_keywords(owner, repo, issue.get("labels", []), issue.get("body", ""))
except Exception as e: except Exception as e:
raise HTTPException(500, str(e)) raise HTTPException(500, str(e)) from e
return {"status": "ok", "issues_scanned": len(issues)} return {"status": "ok", "issues_scanned": len(issues)}
@@ -980,7 +995,7 @@ async def save_page_blocks(request: Request, page_id: int):
try: try:
body = await request.json() body = await request.json()
except Exception: except Exception:
raise HTTPException(400, "Invalid JSON body") raise HTTPException(400, "Invalid JSON body") from None
blocks_json = json.dumps(body.get("blocks", [])) blocks_json = json.dumps(body.get("blocks", []))
title = body.get("title", "") title = body.get("title", "")
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
@@ -1119,7 +1134,7 @@ async def restore_version(request: Request, page_id: int, version_id: int):
@router.post("/api/pages/{page_id}/duplicate") @router.post("/api/pages/{page_id}/duplicate")
async def duplicate_page(request: Request, page_id: int): async def duplicate_page(request: Request, page_id: int):
"""Duplicate a page (block/markdown content included) as a sibling.""" """Duplicate a page (block/markdown content included) as a sibling."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
with get_conn() as conn: with get_conn() as conn:
row = conn.execute( row = conn.execute(
"SELECT * FROM pages WHERE id=? AND deleted_at IS NULL", (page_id,) "SELECT * FROM pages WHERE id=? AND deleted_at IS NULL", (page_id,)
@@ -1130,7 +1145,7 @@ async def duplicate_page(request: Request, page_id: int):
def copy_tree(src_id: int, parent_id) -> int: def copy_tree(src_id: int, parent_id) -> int:
with get_conn() as conn: with get_conn() as conn:
src = conn.execute("SELECT * FROM pages WHERE id=?", (src_id,)).fetchone() conn.execute("SELECT * FROM pages WHERE id=?", (src_id,)).fetchone()
cur = conn.execute( cur = conn.execute(
"INSERT INTO pages (workspace, workspace_id, title, content, content_format, " "INSERT INTO pages (workspace, workspace_id, title, content, content_format, "
"parent_section, parent_id, sort_order, share_mode, published, is_published, " "parent_section, parent_id, sort_order, share_mode, published, is_published, "
@@ -1161,9 +1176,8 @@ async def duplicate_page(request: Request, page_id: int):
# ═══════════ v5.5.0: Cover & icon ═══════════ # ═══════════ v5.5.0: Cover & icon ═══════════
def _upload_root() -> "Path": def _upload_root() -> Path:
import os import os
from pathlib import Path
return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data")) return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
@@ -1296,7 +1310,7 @@ async def import_page(request: Request):
try: try:
body = await request.json() body = await request.json()
except Exception: except Exception:
raise HTTPException(400, "Invalid JSON body") raise HTTPException(400, "Invalid JSON body") from None
markdown = body.get("markdown", "") markdown = body.get("markdown", "")
title = body.get("title", "") title = body.get("title", "")
if not markdown and not body.get("csv"): if not markdown and not body.get("csv"):
@@ -1328,7 +1342,7 @@ async def import_file(request: Request):
try: try:
zf = zipfile.ZipFile(_io.BytesIO(data)) zf = zipfile.ZipFile(_io.BytesIO(data))
except zipfile.BadZipFile: except zipfile.BadZipFile:
raise HTTPException(400, "Invalid zip archive") raise HTTPException(400, "Invalid zip archive") from None
md_entries = sorted( md_entries = sorted(
(n for n in zf.namelist() if n.lower().endswith((".md", ".markdown"))), (n for n in zf.namelist() if n.lower().endswith((".md", ".markdown"))),
key=lambda n: (n.count("/"), n.lower()), key=lambda n: (n.count("/"), n.lower()),
@@ -1346,7 +1360,7 @@ async def import_file(request: Request):
try: try:
raw = data.decode("utf-8") raw = data.decode("utf-8")
except UnicodeDecodeError: except UnicodeDecodeError:
raise HTTPException(400, "Only text/markdown files are supported") raise HTTPException(400, "Only text/markdown files are supported") from None
title = filename.replace(".md", "").replace(".markdown", "").replace(".txt", "") title = filename.replace(".md", "").replace(".markdown", "").replace(".txt", "")
created_ids.append(await _create_page_from_markdown(request, raw, title)) created_ids.append(await _create_page_from_markdown(request, raw, title))
@@ -1361,7 +1375,7 @@ async def og_metadata(request: Request):
try: try:
body = await request.json() body = await request.json()
except Exception: except Exception:
raise HTTPException(400, "Invalid JSON body") raise HTTPException(400, "Invalid JSON body") from None
url = (body.get("url") or "").strip() url = (body.get("url") or "").strip()
if not url: if not url:
raise HTTPException(400, "url required") raise HTTPException(400, "url required")
@@ -1373,7 +1387,7 @@ async def og_metadata(request: Request):
@router.put("/api/pages/{page_id}/move") @router.put("/api/pages/{page_id}/move")
async def move_page(request: Request, page_id: int): async def move_page(request: Request, page_id: int):
"""Move a page to another workspace or reorder within tree. """Move a page to another workspace or reorder within tree.
Body (JSON): { workspace_id?: int, parent_id?: int, new_order?: int } Body (JSON): { workspace_id?: int, parent_id?: int, new_order?: int }
- workspace_id: move page to a different workspace - workspace_id: move page to a different workspace
- parent_id: change parent (0 = root level) - parent_id: change parent (0 = root level)
@@ -1526,4 +1540,4 @@ async def sync_project(owner: str, repo: str):
conn.commit() conn.commit()
return {"status": "ok", "issues_synced": len(issues_only)} return {"status": "ok", "issues_synced": len(issues_only)}
except Exception as e: except Exception as e:
raise HTTPException(500, str(e)) raise HTTPException(500, str(e)) from e
+1 -1
View File
@@ -50,7 +50,7 @@ def _serialize(rows):
@router.get("/pages/{page_id}/comments") @router.get("/pages/{page_id}/comments")
async def list_comments(request: Request, page_id: int): async def list_comments(request: Request, page_id: int):
"""List page-level and inline comments for a FlowDeck page.""" """List page-level and inline comments for a FlowDeck page."""
user = _current_user(request) _current_user(request)
with get_conn() as conn: with get_conn() as conn:
page = conn.execute("SELECT id, title FROM pages WHERE id=?", (page_id,)).fetchone() page = conn.execute("SELECT id, title FROM pages WHERE id=?", (page_id,)).fetchone()
if not page: if not page:
+9 -10
View File
@@ -304,7 +304,6 @@ async def duplicate_collection_api(request: Request, collection_id: int):
) )
# ── Pages (rows) with property ids remapped to the copy's properties ── # ── Pages (rows) with property ids remapped to the copy's properties ──
proxies = {}
prows = conn.execute( prows = conn.execute(
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position", "SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position",
(collection_id,), (collection_id,),
@@ -554,7 +553,7 @@ async def create_property_api(request: Request, collection_id: int):
) )
conn.commit() conn.commit()
except Exception: except Exception:
raise HTTPException(status_code=409, detail=f"Property '{name}' already exists") raise HTTPException(status_code=409, detail=f"Property '{name}' already exists") from None
return {"id": cur.lastrowid, "name": name, "prop_type": prop_type, "status": "created"} return {"id": cur.lastrowid, "name": name, "prop_type": prop_type, "status": "created"}
@@ -980,7 +979,7 @@ async def check_dependencies(request: Request, collection_id: int, page_id: int)
except Exception: except Exception:
body = {} body = {}
new_status = body.get("new_status", "Done") body.get("new_status", "Done")
with get_conn() as conn: with get_conn() as conn:
page = conn.execute("SELECT property_values_json FROM collection_pages WHERE id=?", (page_id,)).fetchone() page = conn.execute("SELECT property_values_json FROM collection_pages WHERE id=?", (page_id,)).fetchone()
@@ -1070,7 +1069,7 @@ async def add_data_source(request: Request, collection_id: int):
) )
conn.commit() conn.commit()
except sqlite3.IntegrityError: except sqlite3.IntegrityError:
raise HTTPException(status_code=409, detail="This data source already exists in this collection") raise HTTPException(status_code=409, detail="This data source already exists in this collection") from None
return { return {
"id": cur.lastrowid, "id": cur.lastrowid,
@@ -1109,7 +1108,7 @@ async def create_linked_database(request: Request, collection_id: int):
body = {} body = {}
name = body.get("name", "").strip() name = body.get("name", "").strip()
new_workspace_id = body.get("workspace_id") body.get("workspace_id")
with get_conn() as conn: with get_conn() as conn:
source = conn.execute( source = conn.execute(
@@ -1341,7 +1340,7 @@ async def add_page_dependency(request: Request, collection_id: int, page_id: int
) )
conn.commit() conn.commit()
except sqlite3.IntegrityError: except sqlite3.IntegrityError:
raise HTTPException(status_code=409, detail="This dependency already exists") 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"} return {"id": cur.lastrowid, "page_id": page_id, "dependency_id": dependency_id, "status": "added"}
@@ -1604,7 +1603,7 @@ def _render_gallery(view_type: str, collection: dict, pages: list[dict], config:
cover_url = config.get("cover_property") cover_url = config.get("cover_property")
cover_html = "" cover_html = ""
if cover_url: if cover_url:
for k, v in props.items(): for _k, v in props.items():
if isinstance(v, list) and len(v) > 0: if isinstance(v, list) and len(v) > 0:
url = v[0].get("url", "") if isinstance(v[0], dict) else str(v[0]) url = v[0].get("url", "") if isinstance(v[0], dict) else str(v[0])
if url.startswith("http"): if url.startswith("http"):
@@ -1674,7 +1673,7 @@ def _render_timeline(view_type: str, collection: dict, pages: list[dict], config
for p in pages: for p in pages:
props = json.loads(p.get("property_values_json", "{}")) props = json.loads(p.get("property_values_json", "{}"))
start_val = end_val = None start_val = end_val = None
for k, v in props.items(): for _k, v in props.items():
if isinstance(v, str) and v.startswith("20"): if isinstance(v, str) and v.startswith("20"):
if "..." in v: if "..." in v:
parts = v.split("...") parts = v.split("...")
@@ -1847,7 +1846,7 @@ def _render_map(view_type: str, collection: dict, pages: list[dict], config: dic
for p in pages: for p in pages:
props = json.loads(p.get("property_values_json", "{}")) props = json.loads(p.get("property_values_json", "{}"))
lat, lng = None, None lat, lng = None, None
for k, v in props.items(): for _k, v in props.items():
if isinstance(v, str) and "," in v: if isinstance(v, str) and "," in v:
parts = v.split(",") parts = v.split(",")
try: try:
@@ -1914,7 +1913,7 @@ def _render_gantt(view_type: str, collection: dict, pages: list[dict], config: d
props = json.loads(p.get("property_values_json", "{}")) props = json.loads(p.get("property_values_json", "{}"))
group = None group = None
start_val = end_val = None start_val = end_val = None
for k, v in props.items(): for _k, v in props.items():
if isinstance(v, str) and v.startswith("20"): if isinstance(v, str) and v.startswith("20"):
if "→" in v: if "→" in v:
parts = v.split("→") parts = v.split("→")
+5 -5
View File
@@ -217,7 +217,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
from app.routers.board import _load_shared_sidebar_pages 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"]) shared_made_pages, shared_received_pages, published_pages, shared_pages = _load_shared_sidebar_pages(user["id"])
return { sidebar = {
"workspace_name": ws, "workspace_initial": initial, "workspace_name": ws, "workspace_initial": initial,
"active_ws_name": active_ws_name, "active_ws_name": active_ws_name,
"workspace_key": f"{gitea_owner}/{gitea_repo}" if gitea_workspace else "", "workspace_key": f"{gitea_owner}/{gitea_repo}" if gitea_workspace else "",
@@ -937,7 +937,7 @@ async def remove_member(request: Request, ws_id: int, user_id: int):
@router.get("/local-workspace", response_class=HTMLResponse) @router.get("/local-workspace", response_class=HTMLResponse)
async def local_workspace_page(request: Request, folder: int = None): async def local_workspace_page(request: Request, folder: int = None):
"""Local workspace page with file/folder tree. """Local workspace page with file/folder tree.
If ?folder=ID is provided, shows that folder's contents with breadcrumb. If ?folder=ID is provided, shows that folder's contents with breadcrumb.
""" """
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
@@ -984,7 +984,7 @@ async def local_workspace_page(request: Request, folder: int = None):
@router.get("/api/local-workspace/tree") @router.get("/api/local-workspace/tree")
async def local_workspace_tree(request: Request, folder: int = None): async def local_workspace_tree(request: Request, folder: int = None):
"""Return the file/folder tree filtered by active workspace. """Return the file/folder tree filtered by active workspace.
If ?folder=ID is provided, returns only that folder's children. If ?folder=ID is provided, returns only that folder's children.
Otherwise returns the full recursive tree from root. Otherwise returns the full recursive tree from root.
""" """
@@ -1965,7 +1965,7 @@ async def update_account(request: Request):
@router.get("/api/sidebar/workspace-tree") @router.get("/api/sidebar/workspace-tree")
async def sidebar_workspace_tree(request: Request): async def sidebar_workspace_tree(request: Request):
"""Return the sidebar workspace tree as HTML fragment. """Return the sidebar workspace tree as HTML fragment.
Called by appState().refreshSidebarTree() after CRUD operations Called by appState().refreshSidebarTree() after CRUD operations
in the main content area to keep the sidebar in sync. in the main content area to keep the sidebar in sync.
""" """
@@ -2296,7 +2296,7 @@ async def api_trash_page(page_id: int):
@router.post("/api/pages/{page_id:int}/convert-to-database") @router.post("/api/pages/{page_id:int}/convert-to-database")
async def api_convert_to_database(page_id: int, request: Request): async def api_convert_to_database(page_id: int, request: Request):
"""Convert a page into a full-page database (Notion-style). """Convert a page into a full-page database (Notion-style).
Creates a collection linked to this page, adds the default 'Name' property, Creates a collection linked to this page, adds the default 'Name' property,
and sets the page's content_format to 'collection'. and sets the page's content_format to 'collection'.
""" """
+2 -2
View File
@@ -74,10 +74,10 @@ async def export_pdf(page_id: int, request: Request):
try: try:
pdf_bytes = page_to_pdf_bytes(page) pdf_bytes = page_to_pdf_bytes(page)
except ImportError: except ImportError:
raise HTTPException(status_code=501, detail="PDF export requires 'weasyprint' or 'xhtml2pdf'") raise HTTPException(status_code=501, detail="PDF export requires 'weasyprint' or 'xhtml2pdf'") from None
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
logger.error("PDF export failed for page %s: %s", page_id, exc) logger.error("PDF export failed for page %s: %s", page_id, exc)
raise HTTPException(status_code=500, detail="PDF generation failed") raise HTTPException(status_code=500, detail="PDF generation failed") from exc
filename = _safe_filename(page, "pdf") filename = _safe_filename(page, "pdf")
headers = _download_header(filename, "application/pdf") headers = _download_header(filename, "application/pdf")
return Response(content=pdf_bytes, status_code=200, headers=headers) return Response(content=pdf_bytes, status_code=200, headers=headers)
+5 -4
View File
@@ -7,7 +7,7 @@ router = APIRouter(tags=["gitea"], prefix="/api/gitea")
def _require_gitea(request: Request): def _require_gitea(request: Request):
"""Return a per-user GiteaClient or raise 401. """Return a per-user GiteaClient or raise 401.
Only returns a client if the user has personally connected their Gitea Only returns a client if the user has personally connected their Gitea
account (OAuth token). No fallback to admin token — each user must link account (OAuth token). No fallback to admin token — each user must link
their own Gitea account to see Gitea projects. their own Gitea account to see Gitea projects.
@@ -160,8 +160,8 @@ async def get_labels(request: Request, owner: str, repo: str):
try: try:
labels = await gitea.get_labels(owner, repo) labels = await gitea.get_labels(owner, repo)
return {"labels": [ return {"labels": [
{"id": l["id"], "name": l["name"], "color": l.get("color", "#787774")} {"id": lbl["id"], "name": lbl["name"], "color": lbl.get("color", "#787774")}
for l in labels for lbl in labels
]} ]}
except Exception as e: except Exception as e:
return JSONResponse({"error": str(e)}, status_code=502) return JSONResponse({"error": str(e)}, status_code=502)
@@ -327,7 +327,8 @@ async def sync_labels(request: Request, owner: str, repo: str):
for label in labels: for label in labels:
name = label.get("name", "") name = label.get("name", "")
color = label.get("color", "#787774") color = label.get("color", "#787774")
if not name: continue if not name:
continue
existing = conn.execute( existing = conn.execute(
"SELECT id FROM tags WHERE name=? AND user_id=?", (name, user["id"]) "SELECT id FROM tags WHERE name=? AND user_id=?", (name, user["id"])
).fetchone() ).fetchone()
+27 -14
View File
@@ -59,9 +59,12 @@ def _build_item(db_row: dict, uid: int = 1) -> dict:
elif content_format == "file": elif content_format == "file":
icon = "📄" icon = "📄"
fn = title.lower() fn = title.lower()
if fn.endswith(".pdf"): icon = "📕" if fn.endswith(".pdf"):
elif any(fn.endswith(e) for e in [".png",".jpg",".jpeg",".gif",".webp",".svg"]): icon = "🖼️" icon = "📕"
elif any(fn.endswith(e) for e in [".py",".js",".ts",".go",".rs"]): 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 = "📜"
else: else:
icon = "📝" icon = "📝"
@@ -332,7 +335,7 @@ async def library_private(
@router.get("/local-workspace-children/{item_id:int}") @router.get("/local-workspace-children/{item_id:int}")
async def library_local_workspace_children(item_id: int, request: Request): async def library_local_workspace_children(item_id: int, request: Request):
"""Return children of a local workspace item for tree expansion.""" """Return children of a local workspace item for tree expansion."""
uid = _get_user_id(request) _get_user_id(request)
with get_conn() as conn: with get_conn() as conn:
# Get the item to find its workspace # Get the item to find its workspace
item = conn.execute( item = conn.execute(
@@ -358,9 +361,12 @@ async def library_local_workspace_children(item_id: int, request: Request):
icon = "📁" if is_folder else "📄" icon = "📁" if is_folder else "📄"
fn = name.lower() fn = name.lower()
if not is_folder: if not is_folder:
if fn.endswith(".pdf"): icon = "📕" if fn.endswith(".pdf"):
elif any(fn.endswith(e) for e in [".png",".jpg",".jpeg",".gif",".webp",".svg"]): icon = "🖼️" icon = "📕"
elif any(fn.endswith(e) for e in [".py",".js",".ts",".go",".rs"]): 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 = "📜"
with get_conn() as conn: with get_conn() as conn:
child_count = conn.execute( child_count = conn.execute(
@@ -468,9 +474,12 @@ async def library_local_workspace(
icon = "📁" if is_folder else "📄" icon = "📁" if is_folder else "📄"
fn = name.lower() fn = name.lower()
if not is_folder: if not is_folder:
if fn.endswith(".pdf"): icon = "📕" if fn.endswith(".pdf"):
elif any(fn.endswith(e) for e in [".png",".jpg",".jpeg",".gif",".webp",".svg"]): icon = "🖼️" icon = "📕"
elif any(fn.endswith(e) for e in [".py",".js",".ts",".go",".rs"]): 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 = "📜"
# Check for children # Check for children
child_count = conn.execute( child_count = conn.execute(
@@ -505,10 +514,14 @@ async def library_local_workspace(
def _format_size(size_bytes): def _format_size(size_bytes):
if not size_bytes: return "" if not size_bytes:
if size_bytes < 1024: return f"{size_bytes} B" return ""
if size_bytes < 1048576: return f"{size_bytes/1024:.1f} KB" if size_bytes < 1024:
if size_bytes < 1073741824: return f"{size_bytes/1048576:.1f} MB" return f"{size_bytes} B"
if size_bytes < 1048576:
return f"{size_bytes/1024:.1f} KB"
if size_bytes < 1073741824:
return f"{size_bytes/1048576:.1f} MB"
return f"{size_bytes/1073741824:.1f} GB" return f"{size_bytes/1073741824:.1f} GB"
+1 -1
View File
@@ -121,7 +121,7 @@ async def my_tasks_dashboard(request: Request, view: str = "all", days: int = 7)
async def my_tasks_api(request: Request, view: str = "all", days: int = 7): async def my_tasks_api(request: Request, view: str = "all", days: int = 7):
"""API: return my tasks as JSON.""" """API: return my tasks as JSON."""
user = _get_current_user(request) user = _get_current_user(request)
user_login = user.get("login", "admin") if user else "admin" user.get("login", "admin") if user else "admin"
with get_conn() as conn: with get_conn() as conn:
collections = conn.execute("SELECT * FROM collections ORDER BY name").fetchall() collections = conn.execute("SELECT * FROM collections ORDER BY name").fetchall()
+1 -1
View File
@@ -107,7 +107,7 @@ async def set_prefs(request: Request):
@router.get("/users/search") @router.get("/users/search")
async def search_users(request: Request, q: str = ""): async def search_users(request: Request, q: str = ""):
"""User autocomplete for @mentions.""" """User autocomplete for @mentions."""
user = _current_user(request) _current_user(request)
q = (q or "").strip() q = (q or "").strip()
with get_conn() as conn: with get_conn() as conn:
if q: if q:
+1 -1
View File
@@ -201,7 +201,7 @@ async def list_shares(page_id: int, request: Request):
@router.post("/pages/{page_id}/publish") @router.post("/pages/{page_id}/publish")
async def publish_page(page_id: int, request: Request): async def publish_page(page_id: int, request: Request):
"""Publish a page (is_published=1) with a URL slug.""" """Publish a page (is_published=1) with a URL slug."""
user = _require_auth(request) _require_auth(request)
with get_conn() as conn: with get_conn() as conn:
page = conn.execute( page = conn.execute(
+1 -1
View File
@@ -86,7 +86,7 @@ async def save_sidebar_config(request: Request):
try: try:
body = await request.json() body = await request.json()
except Exception: except Exception:
raise HTTPException(status_code=400, detail="Invalid JSON body") raise HTTPException(status_code=400, detail="Invalid JSON body") from None
config = body.get("config") config = body.get("config")
if not config or not isinstance(config, dict): if not config or not isinstance(config, dict):
+1 -1
View File
@@ -162,7 +162,7 @@ async def register_webhook(owner: str, repo: str, request: Request):
return {"status": "ok", "webhook": result} return {"status": "ok", "webhook": result}
except Exception as e: except Exception as e:
logger.error("Failed to register webhook: %s", e) logger.error("Failed to register webhook: %s", e)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e)) from e
@router.get("/status/{owner}/{repo}") @router.get("/status/{owner}/{repo}")
+2 -2
View File
@@ -512,13 +512,13 @@ async def assign_page_to_sprint(request: Request, collection_id: int, sid: int):
raise HTTPException(404, "Page not found") raise HTTPException(404, "Page not found")
try: try:
cur = conn.execute( conn.execute(
"INSERT INTO sprint_pages (sprint_id, page_id, status_at_start, velocity_points) VALUES (?,?,?,?)", "INSERT INTO sprint_pages (sprint_id, page_id, status_at_start, velocity_points) VALUES (?,?,?,?)",
(sid, page_id, status_at_start, velocity_points), (sid, page_id, status_at_start, velocity_points),
) )
conn.commit() conn.commit()
except sqlite3.IntegrityError: except sqlite3.IntegrityError:
raise HTTPException(409, "Page already assigned to this sprint") raise HTTPException(409, "Page already assigned to this sprint") from None
return {"sprint_id": sid, "page_id": page_id, "status": "assigned"} return {"sprint_id": sid, "page_id": page_id, "status": "assigned"}
+1 -1
View File
@@ -166,7 +166,7 @@ class AgentEngine:
used_model = model or "" # peut être ajusté par un repli de modèle (404/410) used_model = model or "" # peut être ajusté par un repli de modèle (404/410)
try: try:
for step in range(settings.agent_max_iterations or MAX_ITERATIONS): for _step in range(settings.agent_max_iterations or MAX_ITERATIONS):
if self._tokens >= settings.agent_max_tokens_budget: if self._tokens >= settings.agent_max_tokens_budget:
yield self._event("error", {"message": "Budget de tokens dépassé"}) yield self._event("error", {"message": "Budget de tokens dépassé"})
break break
+2 -2
View File
@@ -147,7 +147,7 @@ def embed_src(url: str) -> str | None:
u = urlparse(url if url.startswith("http") else "https://" + url) u = urlparse(url if url.startswith("http") else "https://" + url)
if u.scheme not in ("http", "https"): if u.scheme not in ("http", "https"):
return None return None
host = (u.hostname or "").lower().replace("www.", "") (u.hostname or "").lower().replace("www.", "")
netloc = (u.netloc or "").lower() netloc = (u.netloc or "").lower()
params = parse_qs(u.query) params = parse_qs(u.query)
# Google Maps share links encode the query in the path (…&q=/maps/…) # Google Maps share links encode the query in the path (…&q=/maps/…)
@@ -191,4 +191,4 @@ def embed_html(src: str, *, height: int = 520) -> str:
f'style="width:100%;height:{height}px;border:none;border-radius:8px;background:#000;" ' f'style="width:100%;height:{height}px;border:none;border-radius:8px;background:#000;" '
f'allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; ' f'allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; '
f'picture-in-picture" allowfullscreen></iframe>' f'picture-in-picture" allowfullscreen></iframe>'
) )
+3 -2
View File
@@ -243,7 +243,8 @@ def _parse_table_at(lines: list[str], i: int, n: int):
rows.append(cells) rows.append(cells)
j += 1 j += 1
width = max(len(r) for r in rows) width = max(len(r) for r in rows)
pad = lambda r: r + [""] * (width - len(r)) def pad(r):
return r + [""] * (width - len(r))
align = (align + ["left"] * width)[:width] align = (align + ["left"] * width)[:width]
return ( return (
{ {
@@ -657,7 +658,7 @@ def blocks_to_html(blocks: list) -> str:
url = b.get("src") or "" url = b.get("src") or ""
emb = (b.get("embed_type") or "") emb = (b.get("embed_type") or "")
if emb in ("inline_dbs", "collection"): if emb in ("inline_dbs", "collection"):
parts.append(f'<div class="embed-note">[Embedded content]</div>') parts.append('<div class="embed-note">[Embedded content]</div>')
elif emb == "download": elif emb == "download":
parts.append(f'<a href="{_text(url)}" download>⬇ {_text(b.get("file_name") or "Download")}</a>') parts.append(f'<a href="{_text(url)}" download>⬇ {_text(b.get("file_name") or "Download")}</a>')
elif emb == "pdf" and url: elif emb == "pdf" and url:
+1 -1
View File
@@ -94,7 +94,7 @@ def _do_mentions(conn, handles, actor_id, ntype, title, message,
resource_type, resource_id, url, notified): resource_type, resource_id, url, notified):
placeholders = ",".join("?" * len(handles)) placeholders = ",".join("?" * len(handles))
rows = conn.execute( rows = conn.execute(
"SELECT id, login, email FROM users WHERE lower(login) IN (%s)" % placeholders, f"SELECT id, login, email FROM users WHERE lower(login) IN ({placeholders})",
handles, handles,
).fetchall() ).fetchall()
for row in rows: for row in rows:
+2 -2
View File
@@ -29,7 +29,7 @@ def _extract_og(html: str) -> dict:
text = html[:400_000] # only scan the beginning — that's where <head> lives text = html[:400_000] # only scan the beginning — that's where <head> lives
props: dict[str, str] = {} props: dict[str, str] = {}
for m in _META_RE.finditer(text): for m in _META_RE.finditer(text):
groups = m.groups() m.groups()
content = "" content = ""
prop = "" prop = ""
for s in re.findall(r"(?:content|property|name)=[\"']([^\"']*)[\"']", m.group(0)): for s in re.findall(r"(?:content|property|name)=[\"']([^\"']*)[\"']", m.group(0)):
@@ -121,4 +121,4 @@ async def fetch_og_metadata(url: str, timeout: float = 6.0) -> dict:
"image": abs_url(img), "image": abs_url(img),
"site_name": site.strip()[:100], "site_name": site.strip()[:100],
"favicon": favicon, "favicon": favicon,
} }
+1 -1
View File
@@ -84,4 +84,4 @@ async def trash_purge_scheduler(interval_hours: int = 24):
purge_expired(days=30) purge_expired(days=30)
except Exception as exc: # pragma: no cover - defensive only except Exception as exc: # pragma: no cover - defensive only
logger.warning("Trash purge failed: %s", exc) logger.warning("Trash purge failed: %s", exc)
await asyncio.sleep(interval_hours * 3600) await asyncio.sleep(interval_hours * 3600)
+63
View File
@@ -0,0 +1,63 @@
// FlowDeck — ESLint flat config (v5.2.0).
// ESLint v9+ requires the flat config format; the legacy `.eslintrc.json`
// is no longer read. Run with: `npx eslint static/js`.
//
// Self-contained on purpose: no dependency on the `globals` npm package so the
// config works with a globally-installed ESLint (no node_modules required).
const browserGlobals = {
// Browser / DOM
window: "readonly", document: "readonly", navigator: "readonly",
location: "readonly", history: "readonly", screen: "readonly",
localStorage: "readonly", sessionStorage: "readonly", console: "readonly",
alert: "readonly", confirm: "readonly", prompt: "readonly", fetch: "readonly",
setTimeout: "readonly", clearTimeout: "readonly", setInterval: "readonly",
clearInterval: "readonly", requestAnimationFrame: "readonly",
cancelAnimationFrame: "readonly", requestIdleCallback: "readonly",
cancelIdleCallback: "readonly", queueMicrotask: "readonly",
XMLHttpRequest: "readonly", FormData: "readonly", Blob: "readonly",
File: "readonly", FileReader: "readonly", URL: "readonly",
URLSearchParams: "readonly", Image: "readonly", Event: "readonly",
CustomEvent: "readonly", EventSource: "readonly", WebSocket: "readonly",
DOMParser: "readonly", Node: "readonly", NodeList: "readonly",
Element: "readonly", HTMLElement: "readonly", getComputedStyle: "readonly",
matchMedia: "readonly", IntersectionObserver: "readonly",
MutationObserver: "readonly", ResizeObserver: "readonly",
performance: "readonly", crypto: "readonly", btoa: "readonly",
atob: "readonly", structuredClone: "readonly",
// ECMAScript built-ins
JSON: "readonly", Math: "readonly", Date: "readonly", Promise: "readonly",
Object: "readonly", Array: "readonly", String: "readonly", Number: "readonly",
Boolean: "readonly", Symbol: "readonly", Map: "readonly", Set: "readonly",
WeakMap: "readonly", WeakSet: "readonly", Error: "readonly",
TypeError: "readonly", RangeError: "readonly", RegExp: "readonly",
Proxy: "readonly", Reflect: "readonly", Intl: "readonly",
parseInt: "readonly", parseFloat: "readonly", isNaN: "readonly",
isFinite: "readonly", encodeURIComponent: "readonly",
decodeURIComponent: "readonly", encodeURI: "readonly", decodeURI: "readonly",
globalThis: "readonly", Infinity: "readonly", NaN: "readonly",
// FlowDeck front-end libraries
Alpine: "readonly", htmx: "readonly", Sortable: "readonly",
// Globals exposed on window by app.js
openModal: "readonly", closeModal: "readonly",
};
export default [
{
ignores: ["static/js/*.min.js", "static/js/vendor/**"],
},
{
files: ["static/js/**/*.js"],
languageOptions: {
ecmaVersion: 2022,
sourceType: "script",
globals: browserGlobals,
},
rules: {
"no-unused-vars": ["warn", { args: "none" }],
"no-undef": "warn",
"no-extra-semi": "warn",
"no-empty": "warn",
},
},
];
+19 -5
View File
@@ -1,10 +1,10 @@
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
pythonpath = ["."] pythonpath = ["."]
# Parallel execution via pytest-xdist (v5.2.0). Override with `-n 0` to run # Parallel execution (pytest-xdist) is OPT-IN so a bare `pytest` never fails
# sequentially: `pytest -n 0`. # when xdist is not installed. Run locally with `pytest -n auto`; CI passes
# Note: some tests use shared temp directories — run sequentially for stability. # `-n auto` explicitly after installing requirements-dev.txt.
addopts = "-n 0"
[tool.ruff] [tool.ruff]
target-version = "py312" target-version = "py312"
@@ -18,4 +18,18 @@ select = ["E", "F", "I", "UP", "B", "W"]
ignore = ["E501", "UP035"] ignore = ["E501", "UP035"]
[tool.ruff.lint.isort] [tool.ruff.lint.isort]
known-first-party = ["app", "tests"] known-first-party = ["app", "tests"]
[tool.ruff.lint.flake8-bugbear]
# FastAPI requires dependency markers (Depends, Query, ...) in argument
# defaults — that is not the bug B008 warns about.
extend-immutable-calls = [
"fastapi.Depends",
"fastapi.Query",
"fastapi.Path",
"fastapi.Body",
"fastapi.Form",
"fastapi.File",
"fastapi.Header",
"fastapi.Cookie",
]
+1 -1
View File
@@ -22,7 +22,7 @@
xhr.open('POST', '/api/frontend-error', true); xhr.open('POST', '/api/frontend-error', true);
xhr.setRequestHeader('Content-Type', 'application/json'); xhr.setRequestHeader('Content-Type', 'application/json');
xhr.send(JSON.stringify(error)); xhr.send(JSON.stringify(error));
} catch(e) { /* fail silently */ } } catch { /* fail silently */ }
} }
window.addEventListener('error', function(e) { window.addEventListener('error', function(e) {
+22 -7
View File
@@ -1,9 +1,15 @@
"""FlowDeck — pytest fixtures and configuration.""" """FlowDeck — pytest fixtures and configuration.
Each test gets a fresh, isolated SQLite database and backup directory so the
suite is safe to run in parallel (``pytest -n auto``): workers never share a
database file, and no state leaks between tests.
"""
import os import os
import tempfile import tempfile
from pathlib import Path from pathlib import Path
import pytest import pytest
from fastapi.testclient import TestClient
@pytest.fixture @pytest.fixture
@@ -15,7 +21,7 @@ def client():
backup_dir = tempfile.mkdtemp(prefix="fd_backups_") backup_dir = tempfile.mkdtemp(prefix="fd_backups_")
# Set env BEFORE importing app modules (config reads at import time) # Set env BEFORE importing app modules (config reads at import time).
os.environ["DATABASE_URL"] = f"sqlite:///{db_path}" os.environ["DATABASE_URL"] = f"sqlite:///{db_path}"
os.environ["APP_SECRET_KEY"] = "test-secret-for-tests" os.environ["APP_SECRET_KEY"] = "test-secret-for-tests"
os.environ["RATE_LIMIT_ENABLED"] = "false" os.environ["RATE_LIMIT_ENABLED"] = "false"
@@ -23,9 +29,21 @@ def client():
os.environ["BACKUP_DIR"] = backup_dir os.environ["BACKUP_DIR"] = backup_dir
os.environ["PROJECT_SYNC_ENABLED"] = "false" os.environ["PROJECT_SYNC_ENABLED"] = "false"
# Force config reload by clearing the cached Settings instance # IMPORTANT: mutate the existing Settings singleton in place — do NOT rebind
# `app.config.settings`. Modules such as `app.services.backup` and
# `app.routers.auth` hold a direct reference imported at load time, so
# rebinding would leave them pointing at the stale defaults (this was the
# cause of the previously-skipped flaky backup tests).
import app.config import app.config
app.config.settings = app.config.Settings() s = app.config.settings
s.database_url = f"sqlite:///{db_path}"
s.app_secret_key = "test-secret-for-tests"
s.rate_limit_enabled = False
s.backup_enabled = True
s.backup_dir = backup_dir
s.backup_interval_hours = 24
s.backup_keep = 30
s.project_sync_enabled = False
from app.db import init_db from app.db import init_db
from app.main import app from app.main import app
@@ -47,6 +65,3 @@ def client():
Path(backup_dir).rmdir() Path(backup_dir).rmdir()
except OSError: except OSError:
pass pass
from fastapi.testclient import TestClient
+17 -2
View File
@@ -19,10 +19,25 @@ def client():
os.environ["APP_SECRET_KEY"] = "test-secret-for-tests" os.environ["APP_SECRET_KEY"] = "test-secret-for-tests"
os.environ["RATE_LIMIT_ENABLED"] = "false" os.environ["RATE_LIMIT_ENABLED"] = "false"
# Point the process-wide settings singleton at OUR temp DB (xdist-safe).
from app.config import settings
settings.database_url = f"sqlite:///{db_path}"
settings.rate_limit_enabled = False
from app.db import init_db from app.db import init_db
from app.main import app from app.main import app
init_db() init_db()
# A default user id=1 so tests that reference the implicit user (favorites,
# workspace ownership) satisfy foreign keys without relying on leaked state.
from app.db import get_conn
with get_conn() as conn:
conn.execute(
"INSERT OR IGNORE INTO users (id, login, full_name, email, is_admin) "
"VALUES (1, 'tester', 'Tester', '[email protected]', 1)"
)
conn.commit()
yield TestClient(app) yield TestClient(app)
os.unlink(db_path) os.unlink(db_path)
@@ -1915,7 +1930,7 @@ def test_nav_menu_workspace_pages(client):
"""/api/nav/menu returns root pages and nested children for a workspace.""" """/api/nav/menu returns root pages and nested children for a workspace."""
from app.db import get_conn from app.db import get_conn
with get_conn() as conn: with get_conn() as conn:
u = conn.execute("INSERT OR IGNORE INTO users (login) VALUES ('navuser')") conn.execute("INSERT OR IGNORE INTO users (login) VALUES ('navuser')")
uid = conn.execute("SELECT id FROM users WHERE login='navuser'").fetchone()["id"] uid = conn.execute("SELECT id FROM users WHERE login='navuser'").fetchone()["id"]
cur = conn.execute("INSERT INTO workspaces (name, owner_id) VALUES ('NavWS', ?)", (uid,)) cur = conn.execute("INSERT INTO workspaces (name, owner_id) VALUES ('NavWS', ?)", (uid,))
ws_id = cur.lastrowid ws_id = cur.lastrowid
@@ -3294,7 +3309,7 @@ def test_v490_create_comment_with_mentions(client):
json={"body": "regarde ca @v4901", "anchor_block_id": "b1", "anchor_start": 0, "anchor_end": 5}, json={"body": "regarde ca @v4901", "anchor_block_id": "b1", "anchor_start": 0, "anchor_end": 5},
cookies=cookies) cookies=cookies)
assert r.status_code == 200 assert r.status_code == 200
cid = r.json()["id"] r.json()["id"]
r = client.get(f"/api/pages/{pid}/comments", cookies=cookies) r = client.get(f"/api/pages/{pid}/comments", cookies=cookies)
assert r.status_code == 200 assert r.status_code == 200
+1 -1
View File
@@ -159,7 +159,7 @@ def test_automation_scope_collection_filter(client):
}).json()["id"] }).json()["id"]
p_c1 = _make_page(client, c1, props={"Status": "Todo"}) p_c1 = _make_page(client, c1, props={"Status": "Todo"})
p_c2 = _make_page(client, c2, props={"Status": "Todo"}) _make_page(client, c2, props={"Status": "Todo"})
from app.db import get_conn from app.db import get_conn
with get_conn() as conn: with get_conn() as conn:
+5
View File
@@ -17,6 +17,11 @@ def client():
os.environ["APP_SECRET_KEY"] = "test-secret-for-tests" os.environ["APP_SECRET_KEY"] = "test-secret-for-tests"
os.environ["RATE_LIMIT_ENABLED"] = "false" os.environ["RATE_LIMIT_ENABLED"] = "false"
# Point the process-wide settings singleton at OUR temp DB (xdist-safe).
from app.config import settings
settings.database_url = f"sqlite:///{db_path}"
settings.rate_limit_enabled = False
from app.db import init_db from app.db import init_db
from app.main import app from app.main import app
init_db() init_db()
+10 -5
View File
@@ -194,7 +194,8 @@ def test_ws_presence_join_leave(client):
pid = _make_page() pid = _make_page()
_auth_client(client, 1, "tester") _auth_client(client, 1, "tester")
with client.websocket_connect(f"/ws/pages/{pid}") as wa: with client.websocket_connect(f"/ws/pages/{pid}") as wa:
wa.receive_json(); wa.receive_json() wa.receive_json()
wa.receive_json()
_auth_client(client, 2, "other") _auth_client(client, 2, "other")
with client.websocket_connect(f"/ws/pages/{pid}") as wb: with client.websocket_connect(f"/ws/pages/{pid}") as wb:
w_f = wb.receive_json() # welcome w_f = wb.receive_json() # welcome
@@ -211,10 +212,12 @@ def test_ws_cursor_broadcast(client):
pid = _make_page(blocks=[{"id": "a", "type": "paragraph", "content": "l"}]) pid = _make_page(blocks=[{"id": "a", "type": "paragraph", "content": "l"}])
_auth_client(client, 1, "tester") _auth_client(client, 1, "tester")
with client.websocket_connect(f"/ws/pages/{pid}") as wa: with client.websocket_connect(f"/ws/pages/{pid}") as wa:
wa.receive_json(); wa.receive_json() wa.receive_json()
wa.receive_json()
_auth_client(client, 2, "other") _auth_client(client, 2, "other")
with client.websocket_connect(f"/ws/pages/{pid}") as wb: with client.websocket_connect(f"/ws/pages/{pid}") as wb:
wb.receive_json(); wb.receive_json() wb.receive_json()
wb.receive_json()
wa.receive_json() # peer_join wa.receive_json() # peer_join
wa.send_json({"t": "sel", "block": "a", "offset": 1}) wa.send_json({"t": "sel", "block": "a", "offset": 1})
m = wb.receive_json() m = wb.receive_json()
@@ -229,10 +232,12 @@ def test_ws_title_broadcast(client):
pid = _make_page() pid = _make_page()
_auth_client(client, 1, "tester") _auth_client(client, 1, "tester")
with client.websocket_connect(f"/ws/pages/{pid}") as wa: with client.websocket_connect(f"/ws/pages/{pid}") as wa:
wa.receive_json(); wa.receive_json() wa.receive_json()
wa.receive_json()
_auth_client(client, 2, "other") _auth_client(client, 2, "other")
with client.websocket_connect(f"/ws/pages/{pid}") as wb: with client.websocket_connect(f"/ws/pages/{pid}") as wb:
wb.receive_json(); wb.receive_json() wb.receive_json()
wb.receive_json()
wa.receive_json() wa.receive_json()
wa.send_json({"t": "title", "title": "Nouveau titre"}) wa.send_json({"t": "title", "title": "Nouveau titre"})
m = wb.receive_json() m = wb.receive_json()
+5
View File
@@ -20,6 +20,11 @@ def client():
os.environ["APP_SECRET_KEY"] = "test-secret-for-tests" os.environ["APP_SECRET_KEY"] = "test-secret-for-tests"
os.environ["RATE_LIMIT_ENABLED"] = "false" os.environ["RATE_LIMIT_ENABLED"] = "false"
# Point the process-wide settings singleton at OUR temp DB (xdist-safe).
from app.config import settings
settings.database_url = f"sqlite:///{db_path}"
settings.rate_limit_enabled = False
from app.db import init_db from app.db import init_db
from app.main import app from app.main import app
init_db() init_db()
+203 -9
View File
@@ -148,16 +148,64 @@ def test_onboarding_workspace_and_project(client):
# ═══════════════ Backups ═══════════════ # ═══════════════ Backups ═══════════════
def test_backup_snapshot_and_pruning(client): def test_backup_snapshot_and_pruning(client):
"""Skipped: flaky due to test isolation issues with global config state. """backup_db() snapshots the SQLite file and prune keeps the newest N."""
Passes when run in isolation. import datetime
"""
pytest.skip("Flaky: backup_dir cleanup interference between tests")
def test_backup_admin_api_requires_admin(client): from app.services import backup as backup_svc
"""Skipped: flaky due to test isolation issues with global config state.
Passes when run in isolation. base = datetime.datetime(2026, 1, 1, 0, 0, 0)
""" first = backup_svc.backup_db(now=base)
pytest.skip("Flaky: admin API test interference between tests") assert first and first.startswith("flowdeck-") and first.endswith(".db")
backups = backup_svc.list_backups()
assert len(backups) == 1
assert backups[0]["filename"] == first
assert backups[0]["size"] > 0
# Distinct timestamps → distinct filenames (the format has 1-second resolution).
for i in range(1, 4):
assert backup_svc.backup_db(now=base + datetime.timedelta(seconds=i))
assert len(backup_svc.list_backups()) == 4
removed = backup_svc.prune_old_backups(keep=2)
assert removed == 2
remaining = backup_svc.list_backups()
assert len(remaining) == 2
# Newest first (filename sort == chronological for this format).
assert remaining[0]["filename"] > remaining[1]["filename"]
# Age + due logic (file mtime is "now", so a fresh backup is not due).
assert backup_svc.last_backup_age_hours() is not None
assert backup_svc.backup_due() is False
def test_backup_disabled_returns_none(client):
from app.config import settings
from app.services import backup as backup_svc
previous = settings.backup_enabled
settings.backup_enabled = False
try:
assert backup_svc.backup_db() is None
finally:
settings.backup_enabled = previous
def test_backup_admin_api(client):
"""The backup admin API is admin-only and snapshots on demand."""
# Unauthenticated → forbidden.
assert client.post("/api/settings/backups/run").status_code == 403
# First registered user becomes admin → allowed.
_register(client)
run = client.post("/api/settings/backups/run")
assert run.status_code == 200, run.text
assert run.json()["status"] == "ok"
assert run.json()["filename"].startswith("flowdeck-")
listing = client.get("/api/settings/backups")
assert listing.status_code == 200
assert len(listing.json()["backups"]) >= 1
# ═══════════════ Projects registry ═══════════════ # ═══════════════ Projects registry ═══════════════
@@ -266,6 +314,152 @@ def test_projects_sync_with_mock_client(client):
os.environ.setdefault("PROJECT_SYNC_ENABLED", "false") os.environ.setdefault("PROJECT_SYNC_ENABLED", "false")
# ═══════════════ OAuth integration (mocked providers) ═══════════════
class _FakeProvider:
"""Stand-in OAuth provider — no network calls."""
name = "gitea"
icon = "🔗"
def __init__(self, login="octocat", full_name="Octo Cat", email="[email protected]"):
self.login = login
self.full_name = full_name
self.email = email
def is_enabled(self) -> bool:
return True
def get_authorize_url(self, state, redirect_uri=None, force_login=False):
return f"https://gitea.test/login/oauth/authorize?client_id=cid&state={state}"
async def exchange_code(self, code, redirect_uri=None):
return {"access_token": "tok-123", "refresh_token": "ref-123"}
async def get_user(self, access_token):
return {
"login": self.login,
"full_name": self.full_name,
"email": self.email,
"avatar_url": "https://gitea.test/avatar.png",
"provider_id": "42",
}
async def list_repositories(self, access_token):
return []
def _patch_provider(monkeypatch, provider=None):
from app.auth import providers
fake = provider or _FakeProvider()
monkeypatch.setattr(providers, "get_provider", lambda name: fake if name in ("gitea", "github") else None)
return fake
def _extract_state(location: str) -> str:
from urllib.parse import parse_qs, urlparse
return parse_qs(urlparse(location).query)["state"][0]
def test_oauth_login_callback_flow(client, monkeypatch):
"""Full login flow: authorize redirect → callback → user + token + session."""
_patch_provider(monkeypatch)
login = client.get("/auth/login?provider=gitea", follow_redirects=False)
assert login.status_code == 302
assert "gitea.test/login/oauth/authorize" in login.headers["location"]
state = _extract_state(login.headers["location"])
cb = client.get(f"/auth/callback?code=abc&state={state}", follow_redirects=False)
assert cb.status_code == 302
assert "/workspaces" in cb.headers["location"]
assert client.cookies.get("flowdeck_session")
from app.db import get_conn
with get_conn() as conn:
user = conn.execute("SELECT id, auth_method FROM users WHERE login='gitea_octocat'").fetchone()
assert user and user["auth_method"] == "gitea"
token = conn.execute(
"SELECT access_token FROM user_oauth_tokens WHERE user_id=? AND provider='gitea'",
(user["id"],),
).fetchone()
assert token and token["access_token"] == "tok-123"
me = client.get("/auth/user").json()
assert me["authenticated"] is True
assert me["user"]["login"] == "gitea_octocat"
def test_oauth_github_callback_creates_github_user(client, monkeypatch):
_patch_provider(monkeypatch, _FakeProvider(login="hubber", full_name="Hub Ber"))
login = client.get("/auth/login?provider=github", follow_redirects=False)
assert login.status_code == 302
state = _extract_state(login.headers["location"])
cb = client.get(f"/auth/callback?code=xyz&state={state}", follow_redirects=False)
assert cb.status_code == 302
from app.db import get_conn
with get_conn() as conn:
user = conn.execute("SELECT id, auth_method FROM users WHERE login='github_hubber'").fetchone()
assert user and user["auth_method"] == "github"
token = conn.execute(
"SELECT access_token FROM user_oauth_tokens WHERE user_id=? AND provider='github'",
(user["id"],),
).fetchone()
assert token and token["access_token"] == "tok-123"
def test_oauth_link_mode_attaches_to_current_user(client, monkeypatch):
"""mode=link must attach the forge token to the already-logged-in user."""
_register(client)
_patch_provider(monkeypatch)
login = client.get("/auth/login?provider=gitea&mode=link", follow_redirects=False)
state = _extract_state(login.headers["location"])
assert state.endswith(":link")
cb = client.get(f"/auth/callback?code=abc&state={state}", follow_redirects=False)
assert cb.status_code == 302
assert "settings" in cb.headers["location"]
from app.db import get_conn
with get_conn() as conn:
user = conn.execute("SELECT id FROM users WHERE login='[email protected]'").fetchone()
token = conn.execute(
"SELECT access_token FROM user_oauth_tokens WHERE user_id=? AND provider='gitea'",
(user["id"],),
).fetchone()
assert token and token["access_token"] == "tok-123"
# No new OAuth user was created.
count = conn.execute("SELECT COUNT(*) FROM users WHERE login LIKE 'gitea_%'").fetchone()[0]
assert count == 0
def test_oauth_callback_rejects_invalid_state(client, monkeypatch):
_patch_provider(monkeypatch)
client.get("/auth/login?provider=gitea", follow_redirects=False)
bad = client.get("/auth/callback?code=abc&state=tampered", follow_redirects=False)
assert bad.status_code == 400
def test_oauth_provider_authorize_urls():
from app.auth.providers import GiteaProvider, GitHubProvider
gitea = GiteaProvider("https://git.example.com", "cid", "sec", "https://app/auth/callback")
assert gitea.is_enabled()
gitea_url = gitea.get_authorize_url("st", redirect_uri="https://app/auth/callback")
assert gitea_url.startswith("https://git.example.com/login/oauth/authorize?")
assert "client_id=cid" in gitea_url and "state=st" in gitea_url
github = GitHubProvider("cid", "sec", "https://app/auth/callback")
assert github.is_enabled()
assert "github.com/login/oauth/authorize" in github.get_authorize_url("st")
assert not GitHubProvider("", "", "https://app/auth/callback").is_enabled()
# ═══════════════ Multi-user permissions ═══════════════ # ═══════════════ Multi-user permissions ═══════════════
def test_permission_manager_roles(client): def test_permission_manager_roles(client):
+8 -8
View File
@@ -3,19 +3,18 @@ and v5.5.0 (embed, bookmark, video, audio, cover, icon)."""
from __future__ import annotations from __future__ import annotations
import json import json
import tempfile
import os import os
import tempfile
import pytest
# ── Helpers ────────────────────────────────────────────────────────────── # ── Helpers ──────────────────────────────────────────────────────────────
def _login(client): def _login(client):
"""Create admin user and return session cookie + csrf token (like test_app.py).""" """Create admin user and return session cookie + csrf token (like test_app.py)."""
import secrets
from app.auth.session import SessionManager from app.auth.session import SessionManager
from app.db import get_conn from app.db import get_conn
import secrets
with get_conn() as conn: with get_conn() as conn:
login = f"testadmin_{secrets.token_hex(4)}" login = f"testadmin_{secrets.token_hex(4)}"
@@ -167,7 +166,7 @@ class TestV54VersionHistory:
) )
data2 = r2.json() data2 = r2.json()
assert len(data2["versions"]) >= 2 assert len(data2["versions"]) >= 2
v2_id = data2["versions"][0]["id"] data2["versions"][0]["id"]
# restore v1 # restore v1
r3 = client.post( r3 = client.post(
@@ -191,10 +190,11 @@ class TestV54TrashPurge:
"""v5.4.0: global trash 30-day purge.""" """v5.4.0: global trash 30-day purge."""
def test_trash_purge_removes_old_deleted_pages(self, client): def test_trash_purge_removes_old_deleted_pages(self, client):
from app.services.trash import purge_expired
from app.db import init_db, get_conn
import datetime import datetime
from app.db import get_conn, init_db
from app.services.trash import purge_expired
# create temp DB # create temp DB
tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
tmp.close() tmp.close()
@@ -565,4 +565,4 @@ class TestDashboardPublicNewBlocks:
assert "<video" in html assert "<video" in html
assert "<audio" in html assert "<audio" in html
assert "bookmark" in html or "Ex" in html assert "bookmark" in html or "Ex" in html
assert "iframe" in html assert "iframe" in html