diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 57c1e04..0000000 --- a/.eslintrc.json +++ /dev/null @@ -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/**"] -} \ No newline at end of file diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 09f4fea..f5d75f9 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -1,12 +1,26 @@ name: FlowDeck CI on: + # Run on every pushed branch so feature branches are validated before the PR. push: - branches: [main] pull_request: - branches: [main] + branches: [main, develop] 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: runs-on: ubuntu-latest # NOTE: no `container:` here. A `python:*-slim` image ships no Node.js, so the @@ -19,7 +33,7 @@ jobs: with: python-version: '3.12' - name: Install system dependencies (WeasyPrint / emoji fonts) - run: | + run: |- SUDO="" if command -v sudo >/dev/null 2>&1; then SUDO="sudo"; fi $SUDO apt-get update @@ -27,13 +41,14 @@ jobs: libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b libffi-dev \ libjpeg-dev libopenjp2-7 libcairo2 fonts-noto-color-emoji - name: Install Python dependencies - run: pip install -r requirements.txt pytest pytest-cov - - name: Run tests with coverage + run: pip install -r requirements-dev.txt pytest-cov + - name: Run tests (parallel) with coverage env: GITEA_URL: https://git.dracodev.net GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} 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 if: always() run: coverage report -m || true diff --git a/CHANGELOG.md b/CHANGELOG.md index f3410c9..aa3175c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,35 @@ # 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 > Quatre améliorations majeures du panneau Agent (v4.10.0 → v4.14.0) pour diff --git a/ROADMAP.md b/ROADMAP.md index 4c66e8f..d72bdc8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -437,40 +437,44 @@ app/ - [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 -### v5.2.0 — Infrastructure & Polish 🔄 (en cours) -> ✅ **Priorité n°1 livrée** : les **migrations versionnées** (voir `app/migrations.py` + -> table `schema_version`) — la base comptait 30+ tables créées ad-hoc ; tout nouveau -> 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é. +### v5.2.0 — Infrastructure & Polish ✅ (2026-09-11) +> **Objectif** : fondations de production — design system, sécurité, infra, forge. +> **COMPLETED**. **Design system** -- [ ] **Design tokens** — `design-tokens.css` (couleurs, espacements, typographie unifiés) -- [ ] **Composants réutilisables** — boutons, inputs, modales, dropdowns, toasts +- [x] **Design tokens** — `static/css/design-tokens.css` (couleurs, espacements, typo, ombres, z-index) chargé après `app.css` +- [x] **Composants réutilisables** — `static/css/components.css` (btn/input/modal/dropdown/toast/card/badge/empty/table) **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)* -- [ ] **Sessions actives** — voir et révoquer les sessions -- [ ] **Onboarding wizard** — `/welcome` au premier lancement (créer compte → lier forges → premier projet) +- [x] **API Tokens** — générer/lister/révoquer dans Settings → API tokens (`app/routers/security.py`, table `api_tokens`, bearer `/api/v1`) +- [x] **Sessions actives** — liste + révocation (`user_sessions`, `SessionManager.list_sessions/revoke_session`) +- [x] **Onboarding wizard** — `/welcome` (workspace → forge → premier projet) + `welcome.html` **Infrastructure** -- [x] **🥇 Migrations versionnées** — table `schema_version` + runner `app/migrations.py` (baseline v1, indexes v2, FTS5 v3) ✅ -- [ ] **Backup automatique** — cron daily → fichier daté -- [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)* -- [ ] **Tests parallèles** — pytest-xdist -- [ ] **Build Docker multi-stage** — optimiser taille d'image +- [x] **🥇 Migrations versionnées** — table `schema_version` + runner `app/migrations.py` (baseline v1, indexes v2, FTS5 v3) +- [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] **Linting** — ruff (Python, `pyproject.toml`) + eslint flat (`eslint.config.mjs`) ; `ruff check` et `eslint` sans erreur +- [x] **Tests parallèles** — pytest-xdist (`pytest -n auto` en local et en CI ; DB + dossier backup isolés par test) +- [x] **Build Docker multi-stage** — builder + runtime (libs WeasyPrint), image allégée **Forge integration** -- [ ] **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) -- [ ] **GitHubAdapter** complet — API GitHub v3 → interface ForgeAdapter +- [x] **DB: table `projects`** — type (builtin/gitea/github), forge_id, clone_url, default_branch, language +- [x] **Cron: sync périodique des projets** — `project_sync_scheduler` (défaut : chaque heure) +- [x] **GitHubAdapter** complet — API GitHub v3 → interface `ForgeAdapter` **Tests (cibles)** -- [x] **267 tests** — 259+ (dont 8 nouveaux migrations/search) ✅ -- [ ] Tests d'intégration auth (OAuth mock) -- [ ] Tests des adapters forge (mock HTTP) -- [ ] Tests multi-user (permissions croisées) +- [x] **397 tests** — dont backups, projets, adapters forge (mock HTTP), OAuth (mock), multi-user ✅ +- [x] Tests d'intégration auth (OAuth mock) — login/callback Gitea + GitHub, mode `link`, rejet d'état invalide +- [x] Tests des adapters forge (mock HTTP) — `GitHubAdapter` via `httpx.MockTransport` +- [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) > **Objectif** : databases inline + templates prédéfinis + validation propriétés. **COMPLETED**. @@ -616,11 +620,12 @@ app/ ## 🎯 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`) 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) -2. ~~**v5.10.0 → Interactions de bloc**~~ ✅ livré (drag&drop multi, undo/redo, duplicate, menu ⋮, en-têtes de tableau, 9 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) +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 @@ -639,4 +644,4 @@ Quality DB views, Agent IA Palette → Realtime + S DB avancée, redo, drag&drop, 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* diff --git a/VERSION b/VERSION index 57f82f7..32447ce 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.11.0 \ No newline at end of file +5.11.1 diff --git a/app/auth/__init__.py b/app/auth/__init__.py index f46c919..e46aff3 100644 --- a/app/auth/__init__.py +++ b/app/auth/__init__.py @@ -1,3 +1,5 @@ """FlowDeck — Auth module: session, OAuth2, dependencies.""" from app.auth.oauth import GiteaOAuth from app.auth.session import SessionManager, get_current_user + +__all__ = ["GiteaOAuth", "SessionManager", "get_current_user"] diff --git a/app/db.py b/app/db.py index a0fff76..bc3d475 100644 --- a/app/db.py +++ b/app/db.py @@ -817,6 +817,11 @@ def init_db(): from app.migrations import apply_migrations 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 def get_conn(): diff --git a/app/main.py b/app/main.py index ca3f9ac..60c74eb 100644 --- a/app/main.py +++ b/app/main.py @@ -85,7 +85,7 @@ async def lifespan(_app: FastAPI): from app.services.trash import 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: yield finally: @@ -100,7 +100,7 @@ async def lifespan(_app: FastAPI): app = FastAPI( title="FlowDeck", - version="5.10.0", + version="5.11.1", docs_url="/docs" if settings.log_level == "DEBUG" else None, redoc_url=None, lifespan=lifespan, diff --git a/app/middleware/__init__.py b/app/middleware/__init__.py index aa51a09..f3c8d80 100644 --- a/app/middleware/__init__.py +++ b/app/middleware/__init__.py @@ -1,2 +1,4 @@ """FlowDeck — Custom middleware.""" from app.middleware.csrf import CSRFMiddleware + +__all__ = ["CSRFMiddleware"] diff --git a/app/routers/agent.py b/app/routers/agent.py index 148f5cb..9560198 100644 --- a/app/routers/agent.py +++ b/app/routers/agent.py @@ -178,7 +178,7 @@ async def create_agent(request: Request): ) conn.commit() 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"} @@ -466,9 +466,9 @@ async def undo(request: Request, action_id: int): try: undo_action(action_id) 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 - 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"} @@ -501,7 +501,7 @@ async def create_skill(request: Request): ) conn.commit() 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"} diff --git a/app/routers/api.py b/app/routers/api.py index 43b723e..1d5cdaf 100644 --- a/app/routers/api.py +++ b/app/routers/api.py @@ -149,7 +149,7 @@ async def move_card( issue = await gitea.get_issue(owner, repo, issue_id) current_labels = [lbl["name"] for lbl in issue.get("labels", [])] 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"]) # Resolve label names to IDs @@ -293,7 +293,7 @@ async def create_issue( if not _check_rate_limit(request): 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 issue = await gitea.create_issue( @@ -345,7 +345,7 @@ async def update_issue_api( if state: kwargs["state"] = state 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(): kwargs["milestone"] = int(milestone) 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) except Exception as 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 with get_conn() as conn: diff --git a/app/routers/automations.py b/app/routers/automations.py index a3597cf..23d5dd2 100644 --- a/app/routers/automations.py +++ b/app/routers/automations.py @@ -53,7 +53,7 @@ def _validate_payload(body: dict) -> None: else: json.dumps(val) 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") diff --git a/app/routers/board.py b/app/routers/board.py index 3a1d55c..5021af3 100644 --- a/app/routers/board.py +++ b/app/routers/board.py @@ -3,6 +3,7 @@ from __future__ import annotations import json import logging +from pathlib import Path from fastapi import APIRouter, HTTPException, Query, Request 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': return 'edit' n = name.lower() - if re.search(r'\.(png|jpe?g|gif|webp|svg|bmp|ico)$', n): return 'image' - if n.endswith('.pdf'): return 'file' - if re.search(r'\.(md|markdown)$', n): return 'edit' - if n.endswith('.py'): return 'file' - if re.search(r'\.(js|jsx|ts|tsx)$', n): return 'file' - if re.search(r'\.(html?|xml)$', n): return 'file' - if n.endswith('.css'): return 'file' - if n.endswith('.json'): return 'file' - if n.endswith('.sql'): return 'file' - if re.search(r'\.(sh|bash|zsh)$', n): return 'file' - if n.endswith('.ps1'): return 'file' - if re.search(r'\.(rs|go|java|rb|php|c|cpp|h|swift|kt|scala|r)$', n): return 'file' - if re.search(r'\.(txt|log)$', n): return 'file' - if re.search(r'\.(zip|tar|gz|rar|7z)$', n): return 'file' + if re.search(r'\.(png|jpe?g|gif|webp|svg|bmp|ico)$', n): + return 'image' + if n.endswith('.pdf'): + return 'file' + if re.search(r'\.(md|markdown)$', n): + return 'edit' + if n.endswith('.py'): + return 'file' + if re.search(r'\.(js|jsx|ts|tsx)$', n): + return 'file' + if re.search(r'\.(html?|xml)$', n): + return 'file' + if n.endswith('.css'): + return 'file' + if n.endswith('.json'): + return 'file' + if n.endswith('.sql'): + return 'file' + if re.search(r'\.(sh|bash|zsh)$', n): + return 'file' + if n.endswith('.ps1'): + return 'file' + if re.search(r'\.(rs|go|java|rb|php|c|cpp|h|swift|kt|scala|r)$', n): + return 'file' + if re.search(r'\.(txt|log)$', n): + return 'file' + if re.search(r'\.(zip|tar|gz|rar|7z)$', n): + return 'file' return 'file' @@ -847,7 +862,7 @@ async def create_property(owner: str, repo: str, name: str = Query(...), ) conn.commit() 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} @@ -901,7 +916,7 @@ async def extract_ai_keywords(owner: str, repo: str): if not issue.get("pull_request"): _extract_ai_keywords(owner, repo, issue.get("labels", []), issue.get("body", "")) except Exception as e: - raise HTTPException(500, str(e)) + raise HTTPException(500, str(e)) from e return {"status": "ok", "issues_scanned": len(issues)} @@ -980,7 +995,7 @@ async def save_page_blocks(request: Request, page_id: int): try: body = await request.json() except Exception: - raise HTTPException(400, "Invalid JSON body") + raise HTTPException(400, "Invalid JSON body") from None blocks_json = json.dumps(body.get("blocks", [])) title = body.get("title", "") 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") async def duplicate_page(request: Request, page_id: int): """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: row = conn.execute( "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: 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( "INSERT INTO pages (workspace, workspace_id, title, content, content_format, " "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 ═══════════ -def _upload_root() -> "Path": +def _upload_root() -> Path: import os - from pathlib import Path return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data")) @@ -1296,7 +1310,7 @@ async def import_page(request: Request): try: body = await request.json() except Exception: - raise HTTPException(400, "Invalid JSON body") + raise HTTPException(400, "Invalid JSON body") from None markdown = body.get("markdown", "") title = body.get("title", "") if not markdown and not body.get("csv"): @@ -1328,7 +1342,7 @@ async def import_file(request: Request): try: zf = zipfile.ZipFile(_io.BytesIO(data)) except zipfile.BadZipFile: - raise HTTPException(400, "Invalid zip archive") + raise HTTPException(400, "Invalid zip archive") from None md_entries = sorted( (n for n in zf.namelist() if n.lower().endswith((".md", ".markdown"))), key=lambda n: (n.count("/"), n.lower()), @@ -1346,7 +1360,7 @@ async def import_file(request: Request): try: raw = data.decode("utf-8") 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", "") created_ids.append(await _create_page_from_markdown(request, raw, title)) @@ -1361,7 +1375,7 @@ async def og_metadata(request: Request): try: body = await request.json() except Exception: - raise HTTPException(400, "Invalid JSON body") + raise HTTPException(400, "Invalid JSON body") from None url = (body.get("url") or "").strip() if not url: raise HTTPException(400, "url required") @@ -1373,7 +1387,7 @@ async def og_metadata(request: Request): @router.put("/api/pages/{page_id}/move") async def move_page(request: Request, page_id: int): """Move a page to another workspace or reorder within tree. - + Body (JSON): { workspace_id?: int, parent_id?: int, new_order?: int } - workspace_id: move page to a different workspace - parent_id: change parent (0 = root level) @@ -1526,4 +1540,4 @@ async def sync_project(owner: str, repo: str): conn.commit() return {"status": "ok", "issues_synced": len(issues_only)} except Exception as e: - raise HTTPException(500, str(e)) + raise HTTPException(500, str(e)) from e diff --git a/app/routers/collaboration.py b/app/routers/collaboration.py index 681b2e4..571b01e 100644 --- a/app/routers/collaboration.py +++ b/app/routers/collaboration.py @@ -50,7 +50,7 @@ def _serialize(rows): @router.get("/pages/{page_id}/comments") async def list_comments(request: Request, page_id: int): """List page-level and inline comments for a FlowDeck page.""" - user = _current_user(request) + _current_user(request) with get_conn() as conn: page = conn.execute("SELECT id, title FROM pages WHERE id=?", (page_id,)).fetchone() if not page: diff --git a/app/routers/collections.py b/app/routers/collections.py index a793efe..89d07a3 100644 --- a/app/routers/collections.py +++ b/app/routers/collections.py @@ -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 ── - proxies = {} prows = conn.execute( "SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position", (collection_id,), @@ -554,7 +553,7 @@ async def create_property_api(request: Request, collection_id: int): ) conn.commit() 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"} @@ -980,7 +979,7 @@ async def check_dependencies(request: Request, collection_id: int, page_id: int) except Exception: body = {} - new_status = body.get("new_status", "Done") + body.get("new_status", "Done") with get_conn() as conn: page = conn.execute("SELECT property_values_json FROM collection_pages WHERE id=?", (page_id,)).fetchone() @@ -1070,7 +1069,7 @@ async def add_data_source(request: Request, collection_id: int): ) conn.commit() 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 { "id": cur.lastrowid, @@ -1109,7 +1108,7 @@ async def create_linked_database(request: Request, collection_id: int): body = {} name = body.get("name", "").strip() - new_workspace_id = body.get("workspace_id") + body.get("workspace_id") with get_conn() as conn: source = conn.execute( @@ -1341,7 +1340,7 @@ async def add_page_dependency(request: Request, collection_id: int, page_id: int ) conn.commit() 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"} @@ -1604,7 +1603,7 @@ def _render_gallery(view_type: str, collection: dict, pages: list[dict], config: cover_url = config.get("cover_property") cover_html = "" if cover_url: - for k, v in props.items(): + for _k, v in props.items(): if isinstance(v, list) and len(v) > 0: url = v[0].get("url", "") if isinstance(v[0], dict) else str(v[0]) if url.startswith("http"): @@ -1674,7 +1673,7 @@ def _render_timeline(view_type: str, collection: dict, pages: list[dict], config for p in pages: props = json.loads(p.get("property_values_json", "{}")) 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 "..." in v: parts = v.split("...") @@ -1847,7 +1846,7 @@ def _render_map(view_type: str, collection: dict, pages: list[dict], config: dic for p in pages: props = json.loads(p.get("property_values_json", "{}")) lat, lng = None, None - for k, v in props.items(): + for _k, v in props.items(): if isinstance(v, str) and "," in v: parts = v.split(",") 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", "{}")) group = 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 "→" in v: parts = v.split("→") diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py index 53ba168..0adf471 100644 --- a/app/routers/dashboard.py +++ b/app/routers/dashboard.py @@ -217,7 +217,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool = 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"]) - return { + sidebar = { "workspace_name": ws, "workspace_initial": initial, "active_ws_name": active_ws_name, "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) async def local_workspace_page(request: Request, folder: int = None): """Local workspace page with file/folder tree. - + If ?folder=ID is provided, shows that folder's contents with breadcrumb. """ 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") async def local_workspace_tree(request: Request, folder: int = None): """Return the file/folder tree filtered by active workspace. - + If ?folder=ID is provided, returns only that folder's children. Otherwise returns the full recursive tree from root. """ @@ -1965,7 +1965,7 @@ async def update_account(request: Request): @router.get("/api/sidebar/workspace-tree") async def sidebar_workspace_tree(request: Request): """Return the sidebar workspace tree as HTML fragment. - + Called by appState().refreshSidebarTree() after CRUD operations in the main content area to keep the sidebar in sync. """ @@ -2296,7 +2296,7 @@ async def api_trash_page(page_id: int): @router.post("/api/pages/{page_id:int}/convert-to-database") async def api_convert_to_database(page_id: int, request: Request): """Convert a page into a full-page database (Notion-style). - + Creates a collection linked to this page, adds the default 'Name' property, and sets the page's content_format to 'collection'. """ diff --git a/app/routers/export.py b/app/routers/export.py index 71330f5..dd23ee8 100644 --- a/app/routers/export.py +++ b/app/routers/export.py @@ -74,10 +74,10 @@ async def export_pdf(page_id: int, request: Request): try: pdf_bytes = page_to_pdf_bytes(page) 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 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") headers = _download_header(filename, "application/pdf") return Response(content=pdf_bytes, status_code=200, headers=headers) diff --git a/app/routers/gitea.py b/app/routers/gitea.py index 32b5e01..d817cba 100644 --- a/app/routers/gitea.py +++ b/app/routers/gitea.py @@ -7,7 +7,7 @@ router = APIRouter(tags=["gitea"], prefix="/api/gitea") def _require_gitea(request: Request): """Return a per-user GiteaClient or raise 401. - + Only returns a client if the user has personally connected their Gitea account (OAuth token). No fallback to admin token — each user must link their own Gitea account to see Gitea projects. @@ -160,8 +160,8 @@ async def get_labels(request: Request, owner: str, repo: str): try: labels = await gitea.get_labels(owner, repo) return {"labels": [ - {"id": l["id"], "name": l["name"], "color": l.get("color", "#787774")} - for l in labels + {"id": lbl["id"], "name": lbl["name"], "color": lbl.get("color", "#787774")} + for lbl in labels ]} except Exception as e: 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: name = label.get("name", "") color = label.get("color", "#787774") - if not name: continue + if not name: + continue existing = conn.execute( "SELECT id FROM tags WHERE name=? AND user_id=?", (name, user["id"]) ).fetchone() diff --git a/app/routers/library.py b/app/routers/library.py index 1313f07..91bf6cf 100644 --- a/app/routers/library.py +++ b/app/routers/library.py @@ -59,9 +59,12 @@ def _build_item(db_row: dict, uid: int = 1) -> dict: elif content_format == "file": icon = "📄" fn = title.lower() - if fn.endswith(".pdf"): icon = "📕" - elif any(fn.endswith(e) for e in [".png",".jpg",".jpeg",".gif",".webp",".svg"]): icon = "🖼️" - elif any(fn.endswith(e) for e in [".py",".js",".ts",".go",".rs"]): icon = "📜" + if fn.endswith(".pdf"): + icon = "📕" + elif any(fn.endswith(e) for e in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]): + icon = "🖼️" + elif any(fn.endswith(e) for e in [".py", ".js", ".ts", ".go", ".rs"]): + icon = "📜" else: icon = "📝" @@ -332,7 +335,7 @@ async def library_private( @router.get("/local-workspace-children/{item_id:int}") async def library_local_workspace_children(item_id: int, request: Request): """Return children of a local workspace item for tree expansion.""" - uid = _get_user_id(request) + _get_user_id(request) with get_conn() as conn: # Get the item to find its workspace item = conn.execute( @@ -358,9 +361,12 @@ async def library_local_workspace_children(item_id: int, request: Request): icon = "📁" if is_folder else "📄" fn = name.lower() if not is_folder: - if fn.endswith(".pdf"): icon = "📕" - elif any(fn.endswith(e) for e in [".png",".jpg",".jpeg",".gif",".webp",".svg"]): icon = "🖼️" - elif any(fn.endswith(e) for e in [".py",".js",".ts",".go",".rs"]): icon = "📜" + if fn.endswith(".pdf"): + icon = "📕" + elif any(fn.endswith(e) for e in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]): + icon = "🖼️" + elif any(fn.endswith(e) for e in [".py", ".js", ".ts", ".go", ".rs"]): + icon = "📜" with get_conn() as conn: child_count = conn.execute( @@ -468,9 +474,12 @@ async def library_local_workspace( icon = "📁" if is_folder else "📄" fn = name.lower() if not is_folder: - if fn.endswith(".pdf"): icon = "📕" - elif any(fn.endswith(e) for e in [".png",".jpg",".jpeg",".gif",".webp",".svg"]): icon = "🖼️" - elif any(fn.endswith(e) for e in [".py",".js",".ts",".go",".rs"]): icon = "📜" + if fn.endswith(".pdf"): + icon = "📕" + elif any(fn.endswith(e) for e in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]): + icon = "🖼️" + elif any(fn.endswith(e) for e in [".py", ".js", ".ts", ".go", ".rs"]): + icon = "📜" # Check for children child_count = conn.execute( @@ -505,10 +514,14 @@ async def library_local_workspace( def _format_size(size_bytes): - if not size_bytes: return "" - if size_bytes < 1024: 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" + if not size_bytes: + return "" + if size_bytes < 1024: + 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" diff --git a/app/routers/my_tasks.py b/app/routers/my_tasks.py index c67ff3b..1e69986 100644 --- a/app/routers/my_tasks.py +++ b/app/routers/my_tasks.py @@ -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): """API: return my tasks as JSON.""" 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: collections = conn.execute("SELECT * FROM collections ORDER BY name").fetchall() diff --git a/app/routers/notifications.py b/app/routers/notifications.py index 38be1c8..7e18c94 100644 --- a/app/routers/notifications.py +++ b/app/routers/notifications.py @@ -107,7 +107,7 @@ async def set_prefs(request: Request): @router.get("/users/search") async def search_users(request: Request, q: str = ""): """User autocomplete for @mentions.""" - user = _current_user(request) + _current_user(request) q = (q or "").strip() with get_conn() as conn: if q: diff --git a/app/routers/sharing.py b/app/routers/sharing.py index 8257c51..edb5c10 100644 --- a/app/routers/sharing.py +++ b/app/routers/sharing.py @@ -201,7 +201,7 @@ async def list_shares(page_id: int, request: Request): @router.post("/pages/{page_id}/publish") async def publish_page(page_id: int, request: Request): """Publish a page (is_published=1) with a URL slug.""" - user = _require_auth(request) + _require_auth(request) with get_conn() as conn: page = conn.execute( diff --git a/app/routers/sidebar_config.py b/app/routers/sidebar_config.py index 65fb473..5058aae 100644 --- a/app/routers/sidebar_config.py +++ b/app/routers/sidebar_config.py @@ -86,7 +86,7 @@ async def save_sidebar_config(request: Request): try: body = await request.json() 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") if not config or not isinstance(config, dict): diff --git a/app/routers/webhooks.py b/app/routers/webhooks.py index 476a15f..34c560a 100644 --- a/app/routers/webhooks.py +++ b/app/routers/webhooks.py @@ -162,7 +162,7 @@ async def register_webhook(owner: str, repo: str, request: Request): return {"status": "ok", "webhook": result} except Exception as 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}") diff --git a/app/routers/workspace.py b/app/routers/workspace.py index 42d19e8..e01a778 100644 --- a/app/routers/workspace.py +++ b/app/routers/workspace.py @@ -512,13 +512,13 @@ async def assign_page_to_sprint(request: Request, collection_id: int, sid: int): raise HTTPException(404, "Page not found") try: - cur = conn.execute( + conn.execute( "INSERT INTO sprint_pages (sprint_id, page_id, status_at_start, velocity_points) VALUES (?,?,?,?)", (sid, page_id, status_at_start, velocity_points), ) conn.commit() 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"} diff --git a/app/services/agent_engine.py b/app/services/agent_engine.py index f9ea654..8622d37 100644 --- a/app/services/agent_engine.py +++ b/app/services/agent_engine.py @@ -166,7 +166,7 @@ class AgentEngine: used_model = model or "" # peut être ajusté par un repli de modèle (404/410) 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: yield self._event("error", {"message": "Budget de tokens dépassé"}) break diff --git a/app/services/embeds.py b/app/services/embeds.py index dc084bc..64cc808 100644 --- a/app/services/embeds.py +++ b/app/services/embeds.py @@ -147,7 +147,7 @@ def embed_src(url: str) -> str | None: u = urlparse(url if url.startswith("http") else "https://" + url) if u.scheme not in ("http", "https"): return None - host = (u.hostname or "").lower().replace("www.", "") + (u.hostname or "").lower().replace("www.", "") netloc = (u.netloc or "").lower() params = parse_qs(u.query) # 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'allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; ' f'picture-in-picture" allowfullscreen>' - ) \ No newline at end of file + ) diff --git a/app/services/export.py b/app/services/export.py index 11526eb..c22f96d 100644 --- a/app/services/export.py +++ b/app/services/export.py @@ -243,7 +243,8 @@ def _parse_table_at(lines: list[str], i: int, n: int): rows.append(cells) j += 1 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] return ( { @@ -657,7 +658,7 @@ def blocks_to_html(blocks: list) -> str: url = b.get("src") or "" emb = (b.get("embed_type") or "") if emb in ("inline_dbs", "collection"): - parts.append(f'
[Embedded content]
') + parts.append('
[Embedded content]
') elif emb == "download": parts.append(f'⬇ {_text(b.get("file_name") or "Download")}') elif emb == "pdf" and url: diff --git a/app/services/notifications.py b/app/services/notifications.py index 89b0d22..31aee18 100644 --- a/app/services/notifications.py +++ b/app/services/notifications.py @@ -94,7 +94,7 @@ def _do_mentions(conn, handles, actor_id, ntype, title, message, resource_type, resource_id, url, notified): placeholders = ",".join("?" * len(handles)) 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, ).fetchall() for row in rows: diff --git a/app/services/og_fetcher.py b/app/services/og_fetcher.py index 00a2df5..4cd68d3 100644 --- a/app/services/og_fetcher.py +++ b/app/services/og_fetcher.py @@ -29,7 +29,7 @@ def _extract_og(html: str) -> dict: text = html[:400_000] # only scan the beginning — that's where lives props: dict[str, str] = {} for m in _META_RE.finditer(text): - groups = m.groups() + m.groups() content = "" prop = "" 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), "site_name": site.strip()[:100], "favicon": favicon, - } \ No newline at end of file + } diff --git a/app/services/trash.py b/app/services/trash.py index 87cb5f1..c5c37b5 100644 --- a/app/services/trash.py +++ b/app/services/trash.py @@ -84,4 +84,4 @@ async def trash_purge_scheduler(interval_hours: int = 24): purge_expired(days=30) except Exception as exc: # pragma: no cover - defensive only logger.warning("Trash purge failed: %s", exc) - await asyncio.sleep(interval_hours * 3600) \ No newline at end of file + await asyncio.sleep(interval_hours * 3600) diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..d925390 --- /dev/null +++ b/eslint.config.mjs @@ -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", + }, + }, +]; diff --git a/pyproject.toml b/pyproject.toml index 8203ef2..1fb0159 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,10 @@ [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["."] -# Parallel execution via pytest-xdist (v5.2.0). Override with `-n 0` to run -# sequentially: `pytest -n 0`. -# Note: some tests use shared temp directories — run sequentially for stability. -addopts = "-n 0" +# Parallel execution (pytest-xdist) is OPT-IN so a bare `pytest` never fails +# when xdist is not installed. Run locally with `pytest -n auto`; CI passes +# `-n auto` explicitly after installing requirements-dev.txt. + [tool.ruff] target-version = "py312" @@ -18,4 +18,18 @@ select = ["E", "F", "I", "UP", "B", "W"] ignore = ["E501", "UP035"] [tool.ruff.lint.isort] -known-first-party = ["app", "tests"] \ No newline at end of file +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", +] \ No newline at end of file diff --git a/static/js/app.js b/static/js/app.js index daffb73..25e30af 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -22,7 +22,7 @@ xhr.open('POST', '/api/frontend-error', true); xhr.setRequestHeader('Content-Type', 'application/json'); xhr.send(JSON.stringify(error)); - } catch(e) { /* fail silently */ } + } catch { /* fail silently */ } } window.addEventListener('error', function(e) { diff --git a/tests/conftest.py b/tests/conftest.py index 57b2507..22ae76b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 tempfile from pathlib import Path import pytest +from fastapi.testclient import TestClient @pytest.fixture @@ -15,7 +21,7 @@ def client(): 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["APP_SECRET_KEY"] = "test-secret-for-tests" os.environ["RATE_LIMIT_ENABLED"] = "false" @@ -23,9 +29,21 @@ def client(): os.environ["BACKUP_DIR"] = backup_dir 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 - 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.main import app @@ -47,6 +65,3 @@ def client(): Path(backup_dir).rmdir() except OSError: pass - - -from fastapi.testclient import TestClient diff --git a/tests/test_app.py b/tests/test_app.py index 098f3c7..df02f53 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -19,10 +19,25 @@ def client(): os.environ["APP_SECRET_KEY"] = "test-secret-for-tests" 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.main import app 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', 'tester@test.dev', 1)" + ) + conn.commit() + yield TestClient(app) 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.""" from app.db import get_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"] cur = conn.execute("INSERT INTO workspaces (name, owner_id) VALUES ('NavWS', ?)", (uid,)) 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}, cookies=cookies) assert r.status_code == 200 - cid = r.json()["id"] + r.json()["id"] r = client.get(f"/api/pages/{pid}/comments", cookies=cookies) assert r.status_code == 200 diff --git a/tests/test_automations.py b/tests/test_automations.py index febaae6..de09600 100644 --- a/tests/test_automations.py +++ b/tests/test_automations.py @@ -159,7 +159,7 @@ def test_automation_scope_collection_filter(client): }).json()["id"] 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 with get_conn() as conn: diff --git a/tests/test_db_advanced.py b/tests/test_db_advanced.py index 7c12a5e..dd650af 100644 --- a/tests/test_db_advanced.py +++ b/tests/test_db_advanced.py @@ -17,6 +17,11 @@ def client(): os.environ["APP_SECRET_KEY"] = "test-secret-for-tests" 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.main import app init_db() diff --git a/tests/test_realtime.py b/tests/test_realtime.py index 99e4df7..11c1981 100644 --- a/tests/test_realtime.py +++ b/tests/test_realtime.py @@ -194,7 +194,8 @@ def test_ws_presence_join_leave(client): pid = _make_page() _auth_client(client, 1, "tester") 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") with client.websocket_connect(f"/ws/pages/{pid}") as wb: 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"}]) _auth_client(client, 1, "tester") 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") 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.send_json({"t": "sel", "block": "a", "offset": 1}) m = wb.receive_json() @@ -229,10 +232,12 @@ def test_ws_title_broadcast(client): pid = _make_page() _auth_client(client, 1, "tester") 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") 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.send_json({"t": "title", "title": "Nouveau titre"}) m = wb.receive_json() diff --git a/tests/test_search_migrations.py b/tests/test_search_migrations.py index e09288f..86bc8fa 100644 --- a/tests/test_search_migrations.py +++ b/tests/test_search_migrations.py @@ -20,6 +20,11 @@ def client(): os.environ["APP_SECRET_KEY"] = "test-secret-for-tests" 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.main import app init_db() diff --git a/tests/test_v52_infra.py b/tests/test_v52_infra.py index df125a9..c4fecff 100644 --- a/tests/test_v52_infra.py +++ b/tests/test_v52_infra.py @@ -148,16 +148,64 @@ def test_onboarding_workspace_and_project(client): # ═══════════════ Backups ═══════════════ def test_backup_snapshot_and_pruning(client): - """Skipped: flaky due to test isolation issues with global config state. - Passes when run in isolation. - """ - pytest.skip("Flaky: backup_dir cleanup interference between tests") + """backup_db() snapshots the SQLite file and prune keeps the newest N.""" + import datetime -def test_backup_admin_api_requires_admin(client): - """Skipped: flaky due to test isolation issues with global config state. - Passes when run in isolation. - """ - pytest.skip("Flaky: admin API test interference between tests") + from app.services import backup as backup_svc + + base = datetime.datetime(2026, 1, 1, 0, 0, 0) + first = backup_svc.backup_db(now=base) + 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 ═══════════════ @@ -266,6 +314,152 @@ def test_projects_sync_with_mock_client(client): 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="octo@test.dev"): + 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='alice@test.dev'").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 ═══════════════ def test_permission_manager_roles(client): diff --git a/tests/test_v54.py b/tests/test_v54.py index ba2e47e..de80991 100644 --- a/tests/test_v54.py +++ b/tests/test_v54.py @@ -3,19 +3,18 @@ and v5.5.0 (embed, bookmark, video, audio, cover, icon).""" from __future__ import annotations import json -import tempfile import os - -import pytest +import tempfile # ── Helpers ────────────────────────────────────────────────────────────── def _login(client): """Create admin user and return session cookie + csrf token (like test_app.py).""" + import secrets + from app.auth.session import SessionManager from app.db import get_conn - import secrets with get_conn() as conn: login = f"testadmin_{secrets.token_hex(4)}" @@ -167,7 +166,7 @@ class TestV54VersionHistory: ) data2 = r2.json() assert len(data2["versions"]) >= 2 - v2_id = data2["versions"][0]["id"] + data2["versions"][0]["id"] # restore v1 r3 = client.post( @@ -191,10 +190,11 @@ class TestV54TrashPurge: """v5.4.0: global trash 30-day purge.""" 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 + from app.db import get_conn, init_db + from app.services.trash import purge_expired + # create temp DB tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) tmp.close() @@ -565,4 +565,4 @@ class TestDashboardPublicNewBlocks: assert "