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
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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+33
-28
@@ -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*
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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():
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
"""FlowDeck — Custom middleware."""
|
||||
from app.middleware.csrf import CSRFMiddleware
|
||||
|
||||
__all__ = ["CSRFMiddleware"]
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
|
||||
+4
-4
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
+41
-27
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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("→")
|
||||
|
||||
@@ -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'.
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
+27
-14
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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></iframe>'
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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'<div class="embed-note">[Embedded content]</div>')
|
||||
parts.append('<div class="embed-note">[Embedded content]</div>')
|
||||
elif emb == "download":
|
||||
parts.append(f'<a href="{_text(url)}" download>⬇ {_text(b.get("file_name") or "Download")}</a>')
|
||||
elif emb == "pdf" and url:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -29,7 +29,7 @@ def _extract_og(html: str) -> dict:
|
||||
text = html[:400_000] # only scan the beginning — that's where <head> 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
await asyncio.sleep(interval_hours * 3600)
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
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
@@ -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) {
|
||||
|
||||
+22
-7
@@ -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
|
||||
|
||||
+17
-2
@@ -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', '[email protected]', 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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
+10
-5
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
+203
-9
@@ -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="[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 ═══════════════
|
||||
|
||||
def test_permission_manager_roles(client):
|
||||
|
||||
+8
-8
@@ -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 "<video" in html
|
||||
assert "<audio" in html
|
||||
assert "bookmark" in html or "Ex" in html
|
||||
assert "iframe" in html
|
||||
assert "iframe" in html
|
||||
|
||||
Reference in New Issue
Block a user