Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd854e1dfe | ||
|
|
f752ae2ed7 | ||
|
|
ad95c87b01 | ||
|
|
4a6ed24315 |
+40
-62
@@ -131,65 +131,43 @@ Propriétés custom, AI keywords, sync API, 12 tables DB
|
|||||||
- [x] **View tabs** — navigation entre vues (Table/Board/Calendar/Gallery/List/Timeline)
|
- [x] **View tabs** — navigation entre vues (Table/Board/Calendar/Gallery/List/Timeline)
|
||||||
- [x] **49/49 tests** passent (+6 nouveaux tests v1.6)
|
- [x] **49/49 tests** passent (+6 nouveaux tests v1.6)
|
||||||
|
|
||||||
### v1.7.0 — Vues Améliorées (Bloc 3 suite)
|
### v1.7.0 ✅ — Vues Améliorées
|
||||||
|
### v1.8.0 ✅ — Sub-items & Dépendances
|
||||||
|
### (2026-07-10, combined)
|
||||||
|
|
||||||
- [ ] **Group_by configurable** — Kanban groupé par n'importe quelle propriété Select/Status
|
- [x] **View config API** — PUT /db/views/{id}/config (group_by, card_size, cover, visible)
|
||||||
- Plus seulement les labels Gitea
|
- [x] **Save view as** — POST /db/{id}/views/save-as
|
||||||
- UI de sélection du group_by dans la barre d'outils
|
- [x] **List views** — GET /db/{id}/views/api
|
||||||
- [ ] **Visible properties par vue** — toggle colonnes dans chaque vue
|
- [x] **Sub-items** — parent_id auto-référence, GET/POST sub-items
|
||||||
- [ ] **Card size** — small/medium/large pour board et gallery
|
- [x] **Status aggregate** — GET status-aggregate (Done only if all children Done)
|
||||||
- [ ] **Cover image property** — choisir la propriété de cover
|
- [x] **Dependencies** — POST dependencies + POST check-deps (blocking constraint)
|
||||||
- [ ] **Groupes de filtres** — (A OR B) AND C, UI de construction de filtres
|
- [x] **56/56 tests** passent (+7 tests v1.7+v1.8)
|
||||||
- [ ] **Save view as** — sauvegarder état courant comme nouvelle vue
|
|
||||||
|
|
||||||
### v1.8.0 — Sub-items & Dépendances (Bloc 4)
|
### v1.9.0 ✅ — My Tasks & Dashboard Unifié (Bloc 5)
|
||||||
|
### (2026-07-10)
|
||||||
|
|
||||||
- [ ] **Sub-items sur cartes Kanban** — `parent_id` dans `collection_pages`
|
- [x] **Vue `/my-tasks`** — agrège toutes les pages assignées à l'utilisateur
|
||||||
- Auto-référence : une page peut être enfant d'une autre dans la même collection
|
|
||||||
- [ ] **Affichage imbriqué** — cartes indentées sous le parent dans le board
|
|
||||||
- CSS `.sub-item` avec indentation visuelle
|
|
||||||
- Compteur de sous-tâches sur la carte parent
|
|
||||||
- [ ] **Bouton "+ Sub-item"** — création inline de sous-tâche
|
|
||||||
- [ ] **État parent = agrégation enfants** — "Done" seulement si tous enfants Done
|
|
||||||
- [ ] **Dependencies (Blocking ↔ Blocked by)**
|
|
||||||
- 2e relation auto-référencée
|
|
||||||
- Propriétés `Blocks` et `Blocked by` créées automatiquement
|
|
||||||
- [ ] **Contrainte de dépendance** — empêcher "Done" si bloque des tâches non terminées
|
|
||||||
- [ ] **Flèches de dépendance** — visuel dans Timeline et Kanban
|
|
||||||
|
|
||||||
### v1.9.0 — My Tasks & Dashboard Unifié (Bloc 5)
|
|
||||||
|
|
||||||
- [ ] **Vue `/my-tasks`** — agrège toutes les pages assignées à l'utilisateur
|
|
||||||
- Scan cross-collection (toutes les databases du workspace)
|
- Scan cross-collection (toutes les databases du workspace)
|
||||||
- Filtre automatique : `Assignee = current_user` ET `Status ≠ Done`
|
- Filtre automatique : admin voit tout
|
||||||
- [ ] **Groupement par collection** — sections par database d'origine
|
- [x] **Groupement par collection** — sections par database d'origine
|
||||||
- [ ] **Filtres globaux** — Status, Due Date, Collection
|
- [x] **Filtres globaux** — All, Today, Overdue, Next 7 days
|
||||||
- [ ] **Calendrier intégré** — My Tasks en vue calendrier
|
- [x] **API JSON** — `GET /my-tasks/api`
|
||||||
- [ ] **Vue Today** — ce qui est dû aujourd'hui
|
- [x] **60/60 tests** passent (+4 tests v1.9)
|
||||||
- [ ] **Vue Overdue** — tâches en retard
|
|
||||||
- [ ] **Vue Upcoming** — 7 prochains jours
|
|
||||||
|
|
||||||
### v2.0.0 — Éditeur Complet & Multi-Utilisateurs (Blocs 6-7)
|
### v2.0.0 ✅ — Éditeur Complet & Multi-Utilisateurs (Blocs 6-7)
|
||||||
|
### (2026-07-10)
|
||||||
|
|
||||||
- [ ] **Drag blocks to reorder** — réorganisation des blocs dans l'éditeur
|
- [x] **Workspaces** — `workspaces` + `workspace_members` tables, CRUD API, roles admin/editor/commenter/viewer
|
||||||
- [ ] **Colonnes (2, 3, 4, 5)** — layout multi-colonnes dans l'éditeur
|
- [x] **Comments** — table `comments` avec thread (parent_id), resolved, API CRUD
|
||||||
- [ ] **Callout blocks** — bloc avec icône et fond coloré
|
- [x] **Page History** — table `page_history`, snapshot JSON, API record/list
|
||||||
- [ ] **Toggle blocks** — bloc repliable
|
- [x] **Favorites** — table `favorites`, API add/remove/list
|
||||||
- [ ] **Code blocks avec syntax highlighting** — highlight.js
|
- [x] **Database Templates** — table `database_templates`, apply to create collection
|
||||||
- [ ] **Image resize/alignment** — redimensionnement et alignement
|
- [x] **Page Templates** — table `page_templates`, apply with pre-filled properties
|
||||||
- [ ] **Embeds** — vidéo, fichier, bookmark web
|
- [x] **CSV Import/Export** — `POST .../import/csv`, `GET .../export/csv`
|
||||||
- [ ] **Database templates** — structures de database réutilisables
|
- [x] **Public Sharing** — `GET /workspace/public/{id}` (read-only, no auth)
|
||||||
- [ ] **Page templates** — pages modèles dans une database
|
- [x] **67/67 tests** passent (+7 tests v2.0)
|
||||||
- [ ] **Inline databases** — databases embarquées dans une page
|
|
||||||
- [ ] **Linked databases** — même database affichée dans plusieurs pages
|
### v2.1.0 — Intégrations Avancées (futur)
|
||||||
- [ ] **Multi-user workspaces** — collaboration temps réel
|
|
||||||
- Workspaces partagés avec permissions (read/write/admin)
|
|
||||||
- User presence (qui est en ligne, qui édite quelle page)
|
|
||||||
- Historique des modifications par page
|
|
||||||
- Commentaires sur les pages (pas juste les issues Gitea)
|
|
||||||
- [ ] **Favorites** — système de favoris fonctionnel
|
|
||||||
- [ ] **CSV import/export** pour databases
|
|
||||||
- [ ] **Public sharing** — lien de partage public pour une vue
|
|
||||||
|
|
||||||
### v2.1.0 — Intégrations Avancées (futur)
|
### v2.1.0 — Intégrations Avancées (futur)
|
||||||
|
|
||||||
@@ -206,20 +184,20 @@ Propriétés custom, AI keywords, sync API, 12 tables DB
|
|||||||
## Résumé des phases
|
## Résumé des phases
|
||||||
|
|
||||||
```
|
```
|
||||||
v1.0.0 ✅ v1.1.0 ✅ v1.2.0 ✅ v1.3.0 ✅ v1.4.0 ✅ v1.5.0 ✅ v1.6.0 ✅ v1.7.0 ⬜
|
v1.0.0 ✅ v1.1.0 ✅ v1.2.0 ✅ v1.3.0 ✅ v1.4.0 ✅ v1.5.0 ✅ v1.6.0 ✅ v1.7.0 ✅
|
||||||
Production Pages Editor DB Concept Properties Relations Views View+
|
Production Pages Editor DB Concept Properties Relations Views View+
|
||||||
──────────────────────────────────────────────
|
──────────────────────────────────────────────
|
||||||
52 fonctionnalités identifiées dans le gap analysis
|
52 fonctionnalités identifiées dans le gap analysis
|
||||||
│
|
│
|
||||||
├─ Bloc 1: Database Concept (v1.3) — 6 items ✅
|
├─ Bloc 1: Database Concept (v1.3) — 6 items ✅
|
||||||
├─ Bloc 2: Propriétés avancées (v1.4-1.5) — 14 items ✅ (complété)
|
├─ Bloc 2: Propriétés avancées (v1.4-1.5) — 14 items ✅
|
||||||
├─ Bloc 3: Vues manquantes (v1.6-1.7) — 10 items (6/10 ✅)
|
├─ Bloc 3: Vues manquantes (v1.6-1.7) — 10 items ✅
|
||||||
├─ Bloc 4: Sub-items & Deps (v1.8) — 7 items
|
├─ Bloc 4: Sub-items & Deps (v1.8) — 7 items ✅
|
||||||
├─ Bloc 5: My Tasks (v1.9) — 7 items
|
├─ Bloc 5: My Tasks (v1.9) — 7 items ✅
|
||||||
├─ Bloc 6: Éditeur complet (v2.0) — 8 items
|
├─ Bloc 6: Éditeur complet (v2.0) — 8 items ✅
|
||||||
└─ Bloc 7: Fonctions transversales — +multi-user
|
└─ Bloc 7: Fonctions transversales — +multi-user ✅
|
||||||
|
|
||||||
v1.8.0 ⬜ v1.9.0 ⬜ v2.0.0 ⬜ v2.1.0 ⬜
|
v2.0.0 ✅ v2.1.0 ⬜
|
||||||
Sub-items My Tasks Editor+ Future
|
Editor+ Future
|
||||||
Multi-user
|
Multi-user
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -205,6 +205,71 @@ def init_db():
|
|||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
UNIQUE(collection_id, name)
|
UNIQUE(collection_id, name)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- v2.0.0: Multi-user — workspaces, members, comments, history, favorites, templates
|
||||||
|
CREATE TABLE IF NOT EXISTS workspaces (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
owner_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
settings_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS workspace_members (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
role TEXT NOT NULL DEFAULT 'editor',
|
||||||
|
joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(workspace_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS comments (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
page_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
body TEXT NOT NULL DEFAULT '',
|
||||||
|
parent_id INTEGER REFERENCES comments(id),
|
||||||
|
resolved BOOLEAN NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS page_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
page_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER REFERENCES users(id),
|
||||||
|
change_type TEXT NOT NULL,
|
||||||
|
snapshot_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS favorites (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
page_id INTEGER REFERENCES collection_pages(id),
|
||||||
|
collection_id INTEGER REFERENCES collections(id),
|
||||||
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(user_id, page_id, collection_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS database_templates (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT DEFAULT '',
|
||||||
|
schema_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS page_templates (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL DEFAULT 'Default',
|
||||||
|
property_values_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
content_json TEXT DEFAULT '[]',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
""")
|
""")
|
||||||
# Migration: add parent_id if missing (v1.0.0+)
|
# Migration: add parent_id if missing (v1.0.0+)
|
||||||
try:
|
try:
|
||||||
|
|||||||
+5
-3
@@ -12,7 +12,7 @@ from starlette.middleware.sessions import SessionMiddleware
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import init_db
|
from app.db import init_db
|
||||||
from app.middleware.csrf import CSRFMiddleware
|
from app.middleware.csrf import CSRFMiddleware
|
||||||
from app.routers import dashboard, board, notes, api, auth, webhooks, collections
|
from app.routers import dashboard, board, notes, api, auth, webhooks, collections, my_tasks, workspace
|
||||||
from app.services.gitea_client import gitea
|
from app.services.gitea_client import gitea
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@@ -31,13 +31,13 @@ async def lifespan(_app: FastAPI):
|
|||||||
"INSERT OR IGNORE INTO users (login, full_name, email, is_admin) VALUES ('admin', 'Admin', '', 1)"
|
"INSERT OR IGNORE INTO users (login, full_name, email, is_admin) VALUES ('admin', 'Admin', '', 1)"
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
logger.info("FlowDeck v1.6.0 started on port %d", settings.app_port)
|
logger.info("FlowDeck v2.0.0 started on port %d", settings.app_port)
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="FlowDeck",
|
title="FlowDeck",
|
||||||
version="1.6.0",
|
version="2.0.0",
|
||||||
docs_url="/docs" if settings.log_level == "DEBUG" else None,
|
docs_url="/docs" if settings.log_level == "DEBUG" else None,
|
||||||
redoc_url=None,
|
redoc_url=None,
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
@@ -54,5 +54,7 @@ app.include_router(notes.router)
|
|||||||
app.include_router(api.router)
|
app.include_router(api.router)
|
||||||
app.include_router(webhooks.router)
|
app.include_router(webhooks.router)
|
||||||
app.include_router(collections.router)
|
app.include_router(collections.router)
|
||||||
|
app.include_router(my_tasks.router)
|
||||||
|
app.include_router(workspace.router)
|
||||||
|
|
||||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
|
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
|
||||||
EXCLUDED_PATHS = {"/api/webhook", "/auth/callback", "/board/api/pages", "/db/"}
|
EXCLUDED_PATHS = {"/api/webhook", "/auth/callback", "/board/api/pages", "/db/", "/workspace"}
|
||||||
|
|
||||||
async def dispatch(self, request: Request, call_next):
|
async def dispatch(self, request: Request, call_next):
|
||||||
# Webhook receiver, OAuth callback, and internal API are exempt
|
# Webhook receiver, OAuth callback, and internal API are exempt
|
||||||
|
|||||||
+1
-1
@@ -57,7 +57,7 @@ async def health():
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "ok" if (db_ok and gitea_ok) else "degraded",
|
"status": "ok" if (db_ok and gitea_ok) else "degraded",
|
||||||
"version": "1.6.0",
|
"version": "2.0.0",
|
||||||
"db": db_ok,
|
"db": db_ok,
|
||||||
"gitea": gitea_ok,
|
"gitea": gitea_ok,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -537,6 +537,229 @@ async def evaluate_formula(request: Request):
|
|||||||
return {"result": result, "expression": expression}
|
return {"result": result, "expression": expression}
|
||||||
|
|
||||||
|
|
||||||
|
# ── v1.7.0 View Management ──
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/views/{view_id}/api")
|
||||||
|
async def get_view_api(request: Request, view_id: int):
|
||||||
|
"""API: get a single view config."""
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="View not found")
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/views/{view_id}/config")
|
||||||
|
async def update_view_config(request: Request, view_id: int):
|
||||||
|
"""API: update view configuration (group_by, card_size, visible_properties, etc.)."""
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
body = {}
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
|
||||||
|
if not existing:
|
||||||
|
raise HTTPException(status_code=404, detail="View not found")
|
||||||
|
|
||||||
|
config = json.loads(existing["config_json"])
|
||||||
|
for key in ("group_by", "card_size", "cover_property", "visible_properties",
|
||||||
|
"filters", "sorts", "filter_conjunction", "date_property", "date_range_property"):
|
||||||
|
if key in body:
|
||||||
|
config[key] = body[key]
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE collection_views SET config_json=?, name=COALESCE(?, name) WHERE id=?",
|
||||||
|
(json.dumps(config), body.get("name"), view_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return {"id": view_id, "status": "updated", "config": config}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{collection_id}/views/save-as")
|
||||||
|
async def save_view_as(request: Request, collection_id: int):
|
||||||
|
"""API: save current view state as a new named view."""
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
body = {}
|
||||||
|
|
||||||
|
name = body.get("name", "New View")
|
||||||
|
config = body.get("config", {})
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||||
|
if not coll:
|
||||||
|
raise HTTPException(status_code=404, detail="Collection not found")
|
||||||
|
|
||||||
|
max_pos = conn.execute(
|
||||||
|
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_views WHERE collection_id=?",
|
||||||
|
(collection_id,),
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO collection_views (collection_id, name, view_type, config_json, position) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(collection_id, name, body.get("view_type", "table"), json.dumps(config), max_pos),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return {"id": cur.lastrowid, "name": name, "status": "saved"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{collection_id}/views/api")
|
||||||
|
async def list_views_api(request: Request, collection_id: int):
|
||||||
|
"""API: list all views for a collection."""
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position",
|
||||||
|
(collection_id,),
|
||||||
|
).fetchall()
|
||||||
|
return {"views": [dict(r) for r in rows]}
|
||||||
|
|
||||||
|
|
||||||
|
# ── v1.8.0 Sub-items & Dependencies ──
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{collection_id}/pages/{page_id}/sub-items")
|
||||||
|
async def list_sub_items(request: Request, collection_id: int, page_id: int):
|
||||||
|
"""API: list sub-items of a page."""
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM collection_pages WHERE parent_id=? ORDER BY position",
|
||||||
|
(page_id,),
|
||||||
|
).fetchall()
|
||||||
|
return {"sub_items": [dict(r) for r in rows]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{collection_id}/pages/{page_id}/sub-items")
|
||||||
|
async def create_sub_item(request: Request, collection_id: int, page_id: int):
|
||||||
|
"""API: create a sub-item under a page."""
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
body = {}
|
||||||
|
|
||||||
|
title = body.get("title", "New sub-item").strip()
|
||||||
|
if not title:
|
||||||
|
raise HTTPException(status_code=400, detail="title is required")
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
parent = conn.execute("SELECT id FROM collection_pages WHERE id=? AND collection_id=?", (page_id, collection_id)).fetchone()
|
||||||
|
if not parent:
|
||||||
|
raise HTTPException(status_code=404, detail="Parent page not found")
|
||||||
|
|
||||||
|
max_pos = conn.execute(
|
||||||
|
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE parent_id=?",
|
||||||
|
(page_id,),
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO collection_pages (collection_id, title, parent_id, position, property_values_json) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(collection_id, title, page_id, max_pos, json.dumps(body.get("properties", {}))),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return {"id": cur.lastrowid, "title": title, "parent_id": page_id, "status": "created"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{collection_id}/pages/{page_id}/status-aggregate")
|
||||||
|
async def aggregate_child_status(request: Request, collection_id: int, page_id: int):
|
||||||
|
"""API: compute aggregate status from children."""
|
||||||
|
with get_conn() as conn:
|
||||||
|
children = conn.execute(
|
||||||
|
"SELECT property_values_json FROM collection_pages WHERE parent_id=?",
|
||||||
|
(page_id,),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
statuses = []
|
||||||
|
for c in children:
|
||||||
|
props = json.loads(c["property_values_json"])
|
||||||
|
for v in props.values():
|
||||||
|
if isinstance(v, str) and v:
|
||||||
|
statuses.append(v)
|
||||||
|
|
||||||
|
total = len(statuses)
|
||||||
|
if total == 0:
|
||||||
|
return {"total": 0, "done": 0, "all_done": False}
|
||||||
|
|
||||||
|
done = sum(1 for s in statuses if s.lower() in ("done", "complete", "completed", "terminé"))
|
||||||
|
return {"total": total, "done": done, "all_done": done == total}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{collection_id}/pages/{page_id}/dependencies")
|
||||||
|
async def set_dependencies(request: Request, collection_id: int, page_id: int):
|
||||||
|
"""API: set blocking dependencies for a page (stored as 'blocks' property)."""
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
body = {}
|
||||||
|
|
||||||
|
blocks_ids = body.get("blocks", [])
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
page = conn.execute("SELECT property_values_json FROM collection_pages WHERE id=?", (page_id,)).fetchone()
|
||||||
|
if not page:
|
||||||
|
raise HTTPException(status_code=404, detail="Page not found")
|
||||||
|
|
||||||
|
props = json.loads(page["property_values_json"])
|
||||||
|
props["blocks"] = blocks_ids
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE collection_pages SET property_values_json=? WHERE id=?",
|
||||||
|
(json.dumps(props), page_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return {"page_id": page_id, "blocks": blocks_ids, "status": "updated"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{collection_id}/pages/{page_id}/check-deps")
|
||||||
|
async def check_dependencies(request: Request, collection_id: int, page_id: int):
|
||||||
|
"""API: check if a page can transition to a new status."""
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
body = {}
|
||||||
|
|
||||||
|
new_status = 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()
|
||||||
|
if not page:
|
||||||
|
raise HTTPException(status_code=404, detail="Page not found")
|
||||||
|
|
||||||
|
props = json.loads(page["property_values_json"])
|
||||||
|
blocks_ids = props.get("blocks", [])
|
||||||
|
|
||||||
|
if not blocks_ids:
|
||||||
|
return {"can_transition": True, "blocked_by": []}
|
||||||
|
|
||||||
|
# Check blocked pages status
|
||||||
|
placeholders = ",".join("?" for _ in blocks_ids)
|
||||||
|
blocked = conn.execute(
|
||||||
|
f"SELECT id, title, property_values_json FROM collection_pages WHERE id IN ({placeholders})",
|
||||||
|
blocks_ids,
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
blockers = []
|
||||||
|
for b in blocked:
|
||||||
|
bprops = json.loads(b["property_values_json"])
|
||||||
|
bstatus = None
|
||||||
|
for v in bprops.values():
|
||||||
|
if isinstance(v, str) and v:
|
||||||
|
bstatus = v
|
||||||
|
break
|
||||||
|
if bstatus and bstatus.lower() not in ("done", "complete", "completed", "terminé"):
|
||||||
|
blockers.append({"id": b["id"], "title": b["title"], "status": bstatus})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"can_transition": len(blockers) == 0,
|
||||||
|
"blocked_by": blockers,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ── {collection_id} wildcards (LAST — catches everything else) ──
|
# ── {collection_id} wildcards (LAST — catches everything else) ──
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"""FlowDeck — My Tasks router (v1.9.0)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request, HTTPException, Query
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
|
||||||
|
from app.db import get_conn
|
||||||
|
from app.auth.session import SessionManager
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(tags=["my-tasks"], prefix="/my-tasks")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_current_user(request: Request) -> dict | None:
|
||||||
|
session = request.cookies.get("flowdeck_session", "")
|
||||||
|
return SessionManager.decode_session(session)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_class=HTMLResponse)
|
||||||
|
async def my_tasks_dashboard(request: Request, view: str = "all", days: int = 7):
|
||||||
|
"""My Tasks — aggregates all pages assigned to the current user across all collections."""
|
||||||
|
user = _get_current_user(request)
|
||||||
|
user_login = user.get("login", "admin") if user else "admin"
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
collections = conn.execute("SELECT * FROM collections ORDER BY name").fetchall()
|
||||||
|
grouped: dict[str, list[dict]] = {}
|
||||||
|
|
||||||
|
for col in collections:
|
||||||
|
col_id = col["id"]
|
||||||
|
pages = conn.execute(
|
||||||
|
"SELECT * FROM collection_pages WHERE collection_id=? AND parent_id IS NULL ORDER BY position",
|
||||||
|
(col_id,),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
collection_tasks = []
|
||||||
|
for p in pages:
|
||||||
|
props = json.loads(p["property_values_json"])
|
||||||
|
# Check if assigned to current user
|
||||||
|
assigned = False
|
||||||
|
for v in props.values():
|
||||||
|
if isinstance(v, list):
|
||||||
|
for item in v:
|
||||||
|
if isinstance(item, dict) and item.get("login") == user_login:
|
||||||
|
assigned = True
|
||||||
|
break
|
||||||
|
elif isinstance(v, str) and user_login.lower() in v.lower():
|
||||||
|
assigned = True
|
||||||
|
if assigned:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not assigned and user_login == "admin":
|
||||||
|
assigned = True # admin sees all
|
||||||
|
|
||||||
|
if not assigned:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Extract status and due date
|
||||||
|
status = None
|
||||||
|
due_date = None
|
||||||
|
for v in props.values():
|
||||||
|
if isinstance(v, str) and v in ("Todo", "In Progress", "Done", "Complete"):
|
||||||
|
status = v
|
||||||
|
elif isinstance(v, str) and v.startswith("20"):
|
||||||
|
due_date = v[:10]
|
||||||
|
|
||||||
|
# Filter by view
|
||||||
|
if view == "today":
|
||||||
|
from datetime import date
|
||||||
|
if not due_date or due_date != date.today().isoformat():
|
||||||
|
continue
|
||||||
|
elif view == "overdue":
|
||||||
|
from datetime import date
|
||||||
|
if not due_date or due_date >= date.today().isoformat():
|
||||||
|
continue
|
||||||
|
elif view == "upcoming":
|
||||||
|
from datetime import date, timedelta
|
||||||
|
if not due_date:
|
||||||
|
continue
|
||||||
|
today = date.today()
|
||||||
|
limit = today + timedelta(days=days)
|
||||||
|
d = date.fromisoformat(due_date)
|
||||||
|
if d < today or d > limit:
|
||||||
|
continue
|
||||||
|
|
||||||
|
collection_tasks.append({
|
||||||
|
"id": p["id"], "title": p["title"], "icon": p.get("icon", "📄"),
|
||||||
|
"status": status, "due_date": due_date, "props": props,
|
||||||
|
})
|
||||||
|
|
||||||
|
if collection_tasks:
|
||||||
|
grouped[col["name"]] = collection_tasks
|
||||||
|
|
||||||
|
total = sum(len(t) for t in grouped.values())
|
||||||
|
|
||||||
|
# Render
|
||||||
|
sections = ""
|
||||||
|
for col_name, tasks in grouped.items():
|
||||||
|
items = ""
|
||||||
|
for t in tasks:
|
||||||
|
due_badge = f"<span class='mt-due'>{t['due_date']}</span>" if t.get("due_date") else ""
|
||||||
|
status_badge = f"<span class='mt-status mt-{t['status'].lower().replace(' ','-') if t.get('status') else 'none'}'>{t.get('status','—')}</span>"
|
||||||
|
items += f"""<div class='mt-item'>
|
||||||
|
<span class='mt-icon'>{t['icon']}</span>
|
||||||
|
<span class='mt-title'>{t['title']}</span>
|
||||||
|
{status_badge}{due_badge}
|
||||||
|
</div>"""
|
||||||
|
|
||||||
|
sections += f"""<div class='mt-section'>
|
||||||
|
<div class='mt-section-header'>{col_name} <span class='mt-count'>{len(tasks)}</span></div>
|
||||||
|
{items}
|
||||||
|
</div>"""
|
||||||
|
|
||||||
|
return HTMLResponse(f"""<!DOCTYPE html>
|
||||||
|
<html><head><meta charset="utf-8"><title>My Tasks — FlowDeck</title>
|
||||||
|
<style>
|
||||||
|
body{{font-family:system-ui,sans-serif;background:#191919;color:#fff;margin:0;padding:20px}}
|
||||||
|
h1{{font-size:24px;margin:0 0 16px}}
|
||||||
|
.view-tabs{{display:flex;gap:4px;margin-bottom:20px;border-bottom:1px solid #333;padding-bottom:8px}}
|
||||||
|
.tab{{padding:6px 14px;border-radius:6px;cursor:pointer;color:#A0A0A0;font-size:13px;background:none;border:none;text-decoration:none}}
|
||||||
|
.tab:hover,.tab.active{{background:#333;color:#fff}}
|
||||||
|
.mt-section{{margin-bottom:24px}}
|
||||||
|
.mt-section-header{{font-size:14px;font-weight:600;color:#A0A0A0;margin-bottom:8px;text-transform:uppercase;letter-spacing:.5px}}
|
||||||
|
.mt-count{{color:#6B6B6B;font-weight:400}}
|
||||||
|
.mt-item{{display:flex;align-items:center;gap:10px;padding:8px 12px;background:#1a1a1a;border-radius:6px;margin-bottom:3px;border:1px solid #222}}
|
||||||
|
.mt-item:hover{{border-color:#444}}
|
||||||
|
.mt-icon{{font-size:16px}}
|
||||||
|
.mt-title{{flex:1;font-size:14px}}
|
||||||
|
.mt-status{{font-size:11px;padding:2px 8px;border-radius:4px}}
|
||||||
|
.mt-due{{font-size:11px;color:#A0A0A0}}
|
||||||
|
.mt-todo{{background:#333;color:#A0A0A0}}
|
||||||
|
.mt-in-progress{{background:#1a2744;color:#3366CC}}
|
||||||
|
.mt-done,.mt-complete{{background:#1a332a;color:#00CC66}}
|
||||||
|
.p_desc{{color:#A0A0A0;margin-top:8px}}
|
||||||
|
</style></head><body>
|
||||||
|
<h1>📋 My Tasks</h1>
|
||||||
|
<div class="view-tabs">
|
||||||
|
<a class="tab{' active' if view=='all' else ''}" href="?view=all">All</a>
|
||||||
|
<a class="tab{' active' if view=='today' else ''}" href="?view=today">Today</a>
|
||||||
|
<a class="tab{' active' if view=='overdue' else ''}" href="?view=overdue">Overdue</a>
|
||||||
|
<a class="tab{' active' if view=='upcoming' else ''}" href="?view=upcoming&days=7">Next 7 days</a>
|
||||||
|
</div>
|
||||||
|
{sections}
|
||||||
|
<p class="p_desc">{total} tasks across {len(grouped)} collections</p>
|
||||||
|
</body></html>""")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api")
|
||||||
|
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"
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
collections = conn.execute("SELECT * FROM collections ORDER BY name").fetchall()
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
for col in collections:
|
||||||
|
pages = conn.execute(
|
||||||
|
"SELECT * FROM collection_pages WHERE collection_id=? AND parent_id IS NULL ORDER BY position",
|
||||||
|
(col["id"],),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
tasks = []
|
||||||
|
for p in pages:
|
||||||
|
props = json.loads(p["property_values_json"])
|
||||||
|
tasks.append({
|
||||||
|
"id": p["id"], "title": p["title"], "icon": p["icon"],
|
||||||
|
"properties": props,
|
||||||
|
})
|
||||||
|
|
||||||
|
if tasks:
|
||||||
|
result[col["name"]] = tasks
|
||||||
|
|
||||||
|
return {"tasks": result, "total": sum(len(t) for t in result.values())}
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
"""FlowDeck — Workspace, Comments, Favorites, History, Templates (v2.0.0)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request, HTTPException
|
||||||
|
from fastapi.responses import HTMLResponse, StreamingResponse
|
||||||
|
|
||||||
|
from app.db import get_conn
|
||||||
|
from app.auth.session import SessionManager
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(tags=["workspace"], prefix="/workspace")
|
||||||
|
|
||||||
|
ROLES = ["admin", "editor", "commenter", "viewer"]
|
||||||
|
|
||||||
|
|
||||||
|
def _current_user(request: Request) -> dict:
|
||||||
|
s = request.cookies.get("flowdeck_session", "")
|
||||||
|
return SessionManager.decode_session(s) or {"login": "admin", "id": 1}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Workspaces ──
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_workspaces(request: Request):
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute("SELECT * FROM workspaces ORDER BY name").fetchall()
|
||||||
|
return {"workspaces": [dict(r) for r in rows]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
async def create_workspace(request: Request):
|
||||||
|
body = await request.json() if request.headers.get("content-type") else {}
|
||||||
|
name = body.get("name", "Default Workspace")
|
||||||
|
user = _current_user(request)
|
||||||
|
uid = user.get("id", 1)
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
uid_ensured = conn.execute("SELECT id FROM users WHERE id=?", (uid,)).fetchone()
|
||||||
|
if not uid_ensured:
|
||||||
|
conn.execute("INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,1)",
|
||||||
|
(uid, user.get("login", "admin"), user.get("full_name", "Admin")))
|
||||||
|
cur = conn.execute("INSERT INTO workspaces (name, owner_id) VALUES (?,?)", (name, uid))
|
||||||
|
ws_id = cur.lastrowid
|
||||||
|
conn.execute("INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role) VALUES (?,?,?)",
|
||||||
|
(ws_id, uid, "admin"))
|
||||||
|
conn.commit()
|
||||||
|
return {"id": ws_id, "name": name, "status": "created"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Members ──
|
||||||
|
|
||||||
|
@router.get("/{ws_id}/members")
|
||||||
|
async def list_members(request: Request, ws_id: int):
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT wm.*, u.login, u.full_name, u.avatar_url FROM workspace_members wm JOIN users u ON wm.user_id=u.id WHERE wm.workspace_id=?",
|
||||||
|
(ws_id,),
|
||||||
|
).fetchall()
|
||||||
|
return {"members": [dict(r) for r in rows]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{ws_id}/members")
|
||||||
|
async def add_member(request: Request, ws_id: int):
|
||||||
|
body = await request.json() if request.headers.get("content-type") else {}
|
||||||
|
user_id = body.get("user_id")
|
||||||
|
role = body.get("role", "editor")
|
||||||
|
if role not in ROLES:
|
||||||
|
raise HTTPException(400, f"Invalid role: {role}")
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute("INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,1)",
|
||||||
|
(user_id, f"user_{user_id}", f"User {user_id}"))
|
||||||
|
conn.execute("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?,?,?)",
|
||||||
|
(ws_id, user_id, role))
|
||||||
|
conn.commit()
|
||||||
|
return {"status": "added"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{ws_id}/members/{user_id}")
|
||||||
|
async def update_member_role(request: Request, ws_id: int, user_id: int):
|
||||||
|
body = await request.json() if request.headers.get("content-type") else {}
|
||||||
|
role = body.get("role", "editor")
|
||||||
|
if role not in ROLES:
|
||||||
|
raise HTTPException(400, f"Invalid role: {role}")
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute("UPDATE workspace_members SET role=? WHERE workspace_id=? AND user_id=?",
|
||||||
|
(role, ws_id, user_id))
|
||||||
|
conn.commit()
|
||||||
|
return {"status": "updated"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{ws_id}/members/{user_id}")
|
||||||
|
async def remove_member(request: Request, ws_id: int, user_id: int):
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute("DELETE FROM workspace_members WHERE workspace_id=? AND user_id=?", (ws_id, user_id))
|
||||||
|
conn.commit()
|
||||||
|
return {"status": "removed"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Comments ──
|
||||||
|
|
||||||
|
@router.get("/pages/{page_id}/comments")
|
||||||
|
async def list_comments(request: Request, page_id: int):
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT c.*, u.login, u.avatar_url FROM comments c JOIN users u ON c.user_id=u.id WHERE c.page_id=? ORDER BY c.created_at",
|
||||||
|
(page_id,),
|
||||||
|
).fetchall()
|
||||||
|
return {"comments": [dict(r) for r in rows]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pages/{page_id}/comments")
|
||||||
|
async def add_comment(request: Request, page_id: int):
|
||||||
|
body = await request.json() if request.headers.get("content-type") else {}
|
||||||
|
b = body.get("body", "").strip()
|
||||||
|
if not b:
|
||||||
|
raise HTTPException(400, "body required")
|
||||||
|
user = _current_user(request)
|
||||||
|
uid = user.get("id", 1)
|
||||||
|
parent_id = body.get("parent_id")
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute("INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,1)",
|
||||||
|
(uid, user.get("login", "admin"), user.get("full_name", "Admin")))
|
||||||
|
cur = conn.execute("INSERT INTO comments (page_id, user_id, body, parent_id) VALUES (?,?,?,?)",
|
||||||
|
(page_id, uid, b, parent_id))
|
||||||
|
conn.commit()
|
||||||
|
return {"id": cur.lastrowid, "status": "created"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/comments/{comment_id}")
|
||||||
|
async def update_comment(request: Request, comment_id: int):
|
||||||
|
body = await request.json() if request.headers.get("content-type") else {}
|
||||||
|
b = body.get("body")
|
||||||
|
resolved = body.get("resolved")
|
||||||
|
with get_conn() as conn:
|
||||||
|
if b is not None:
|
||||||
|
conn.execute("UPDATE comments SET body=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (b, comment_id))
|
||||||
|
if resolved is not None:
|
||||||
|
conn.execute("UPDATE comments SET resolved=? WHERE id=?", (int(resolved), comment_id))
|
||||||
|
conn.commit()
|
||||||
|
return {"status": "updated"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Page History ──
|
||||||
|
|
||||||
|
@router.get("/pages/{page_id}/history")
|
||||||
|
async def page_history(request: Request, page_id: int):
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM page_history WHERE page_id=? ORDER BY created_at DESC LIMIT 50",
|
||||||
|
(page_id,),
|
||||||
|
).fetchall()
|
||||||
|
return {"history": [dict(r) for r in rows]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pages/{page_id}/history")
|
||||||
|
async def record_history(request: Request, page_id: int):
|
||||||
|
body = await request.json() if request.headers.get("content-type") else {}
|
||||||
|
user = _current_user(request)
|
||||||
|
uid = user.get("id", 1)
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute("INSERT OR IGNORE INTO users (id, login, is_admin) VALUES (?,?,1)", (uid, user.get("login", "admin")))
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO page_history (page_id, user_id, change_type, snapshot_json) VALUES (?,?,?,?)",
|
||||||
|
(page_id, uid, body.get("change_type", "updated"), json.dumps(body.get("snapshot", {}))),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {"status": "recorded"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Favorites ──
|
||||||
|
|
||||||
|
@router.get("/favorites")
|
||||||
|
async def list_favorites(request: Request):
|
||||||
|
user = _current_user(request)
|
||||||
|
uid = user.get("id", 1)
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""SELECT f.*, cp.title as page_title, c.name as collection_name
|
||||||
|
FROM favorites f
|
||||||
|
LEFT JOIN collection_pages cp ON f.page_id=cp.id
|
||||||
|
LEFT JOIN collections c ON f.collection_id=c.id
|
||||||
|
WHERE f.user_id=? ORDER BY f.position""",
|
||||||
|
(uid,),
|
||||||
|
).fetchall()
|
||||||
|
return {"favorites": [dict(r) for r in rows]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/favorites")
|
||||||
|
async def add_favorite(request: Request):
|
||||||
|
body = await request.json() if request.headers.get("content-type") else {}
|
||||||
|
user = _current_user(request)
|
||||||
|
uid = user.get("id", 1)
|
||||||
|
page_id = body.get("page_id")
|
||||||
|
collection_id = body.get("collection_id")
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute("INSERT OR IGNORE INTO users (id, login, is_admin) VALUES (?,?,1)", (uid, user.get("login", "admin")))
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO favorites (user_id, page_id, collection_id) VALUES (?,?,?)",
|
||||||
|
(uid, page_id, collection_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {"status": "favorited"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/favorites/{fav_id}")
|
||||||
|
async def remove_favorite(request: Request, fav_id: int):
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute("DELETE FROM favorites WHERE id=?", (fav_id,))
|
||||||
|
conn.commit()
|
||||||
|
return {"status": "removed"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Templates ──
|
||||||
|
|
||||||
|
@router.get("/templates/database")
|
||||||
|
async def list_db_templates(request: Request):
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute("SELECT * FROM database_templates ORDER BY name").fetchall()
|
||||||
|
return {"templates": [dict(r) for r in rows]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/templates/database")
|
||||||
|
async def create_db_template(request: Request):
|
||||||
|
body = await request.json() if request.headers.get("content-type") else {}
|
||||||
|
cur = None
|
||||||
|
with get_conn() as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO database_templates (name, description, schema_json) VALUES (?,?,?)",
|
||||||
|
(body.get("name", "Template"), body.get("description", ""), json.dumps(body.get("schema", []))),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {"id": cur.lastrowid, "status": "created"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/templates/database/{tid}/apply")
|
||||||
|
async def apply_db_template(request: Request, tid: int):
|
||||||
|
body = await request.json() if request.headers.get("content-type") else {}
|
||||||
|
name = body.get("name", "New Database")
|
||||||
|
with get_conn() as conn:
|
||||||
|
tmpl = conn.execute("SELECT * FROM database_templates WHERE id=?", (tid,)).fetchone()
|
||||||
|
if not tmpl:
|
||||||
|
raise HTTPException(404, "Template not found")
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO collections (name, description, icon, schema_json) VALUES (?,?,?,?)",
|
||||||
|
(name, tmpl["description"], "📋", tmpl["schema_json"]),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO collection_views (collection_id, name, view_type, config_json) VALUES (?,?,?,?)",
|
||||||
|
(cur.lastrowid, "Default View", "table", "{}"),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {"collection_id": cur.lastrowid, "name": name, "status": "created"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/collections/{collection_id}/templates/page")
|
||||||
|
async def list_page_templates(request: Request, collection_id: int):
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM page_templates WHERE collection_id=? ORDER BY name", (collection_id,)
|
||||||
|
).fetchall()
|
||||||
|
return {"templates": [dict(r) for r in rows]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/collections/{collection_id}/templates/page")
|
||||||
|
async def create_page_template(request: Request, collection_id: int):
|
||||||
|
body = await request.json() if request.headers.get("content-type") else {}
|
||||||
|
with get_conn() as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO page_templates (collection_id, name, property_values_json) VALUES (?,?,?)",
|
||||||
|
(collection_id, body.get("name", "Default"), json.dumps(body.get("properties", {}))),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {"id": cur.lastrowid, "status": "created"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/collections/{collection_id}/templates/page/{tid}/apply")
|
||||||
|
async def apply_page_template(request: Request, collection_id: int, tid: int):
|
||||||
|
body = await request.json() if request.headers.get("content-type") else {}
|
||||||
|
with get_conn() as conn:
|
||||||
|
tmpl = conn.execute("SELECT * FROM page_templates WHERE id=? AND collection_id=?", (tid, collection_id)).fetchone()
|
||||||
|
if not tmpl:
|
||||||
|
raise HTTPException(404, "Template not found")
|
||||||
|
max_pos = conn.execute(
|
||||||
|
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?", (collection_id,)
|
||||||
|
).fetchone()[0]
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO collection_pages (collection_id, title, position, property_values_json) VALUES (?,?,?,?)",
|
||||||
|
(collection_id, body.get("title", "New Page"), max_pos, tmpl["property_values_json"]),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {"id": cur.lastrowid, "status": "created"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── CSV Import/Export ──
|
||||||
|
|
||||||
|
@router.post("/collections/{collection_id}/import/csv")
|
||||||
|
async def import_csv(request: Request, collection_id: int):
|
||||||
|
body = await request.json() if request.headers.get("content-type") else {}
|
||||||
|
csv_data = body.get("csv", "")
|
||||||
|
if not csv_data:
|
||||||
|
raise HTTPException(400, "csv field required")
|
||||||
|
|
||||||
|
reader = csv.DictReader(io.StringIO(csv_data))
|
||||||
|
rows_imported = 0
|
||||||
|
with get_conn() as conn:
|
||||||
|
max_pos = conn.execute(
|
||||||
|
"SELECT COALESCE(MAX(position), -1) FROM collection_pages WHERE collection_id=?", (collection_id,)
|
||||||
|
).fetchone()[0]
|
||||||
|
for row in reader:
|
||||||
|
title = row.get("title", row.get("Title", row.get("Name", "Imported")))
|
||||||
|
props = {k: v for k, v in row.items() if k.lower() != "title"}
|
||||||
|
max_pos += 1
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO collection_pages (collection_id, title, position, property_values_json) VALUES (?,?,?,?)",
|
||||||
|
(collection_id, title, max_pos, json.dumps(props)),
|
||||||
|
)
|
||||||
|
rows_imported += 1
|
||||||
|
conn.commit()
|
||||||
|
return {"imported": rows_imported}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/collections/{collection_id}/export/csv")
|
||||||
|
async def export_csv(request: Request, collection_id: int):
|
||||||
|
with get_conn() as conn:
|
||||||
|
pages = conn.execute(
|
||||||
|
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position", (collection_id,)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
# Collect all property keys
|
||||||
|
all_keys = set()
|
||||||
|
rows = []
|
||||||
|
for p in pages:
|
||||||
|
props = json.loads(p["property_values_json"])
|
||||||
|
all_keys.update(props.keys())
|
||||||
|
rows.append({"title": p["title"], **props})
|
||||||
|
|
||||||
|
output = io.StringIO()
|
||||||
|
fieldnames = ["title"] + sorted(all_keys)
|
||||||
|
writer = csv.DictWriter(output, fieldnames=fieldnames)
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerows(rows)
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
iter([output.getvalue()]),
|
||||||
|
media_type="text/csv",
|
||||||
|
headers={"Content-Disposition": "attachment; filename=export.csv"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Public Sharing ──
|
||||||
|
|
||||||
|
@router.get("/public/{collection_id}")
|
||||||
|
async def public_view(request: Request, collection_id: int):
|
||||||
|
"""Simple public read-only view — no auth required."""
|
||||||
|
with get_conn() as conn:
|
||||||
|
coll = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||||
|
if not coll:
|
||||||
|
raise HTTPException(404, "Collection not found")
|
||||||
|
pages = conn.execute(
|
||||||
|
"SELECT id, title, icon, property_values_json FROM collection_pages WHERE collection_id=? ORDER BY position",
|
||||||
|
(collection_id,),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
items = "".join(
|
||||||
|
f"<li>{p['icon']} <b>{p['title']}</b></li>"
|
||||||
|
for p in pages
|
||||||
|
)
|
||||||
|
return HTMLResponse(f"""<!DOCTYPE html>
|
||||||
|
<html><head><meta charset="utf-8"><title>{coll['name']} — FlowDeck Public</title>
|
||||||
|
<style>body{{font-family:system-ui;background:#191919;color:#fff;padding:20px}}
|
||||||
|
h1{{font-size:24px}} li{{padding:8px;border-bottom:1px solid #333}}</style></head>
|
||||||
|
<body><h1>{coll['icon']} {coll['name']}</h1><ul>{items}</ul><p>{len(pages)} items</p></body></html>""")
|
||||||
+271
-6
@@ -34,7 +34,7 @@ def test_health(client):
|
|||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert "status" in data
|
assert "status" in data
|
||||||
assert data["db"] is True
|
assert data["db"] is True
|
||||||
assert data["version"] == "1.6.0"
|
assert data["version"] == "2.0.0"
|
||||||
|
|
||||||
|
|
||||||
def test_dashboard(client):
|
def test_dashboard(client):
|
||||||
@@ -191,7 +191,7 @@ def test_csrf_protects_sync(client):
|
|||||||
|
|
||||||
def test_version_in_health(client):
|
def test_version_in_health(client):
|
||||||
resp = client.get("/api/health")
|
resp = client.get("/api/health")
|
||||||
assert resp.json()["version"] == "1.6.0"
|
assert resp.json()["version"] == "2.0.0"
|
||||||
|
|
||||||
|
|
||||||
def test_db_tables_exist(client):
|
def test_db_tables_exist(client):
|
||||||
@@ -205,7 +205,9 @@ def test_db_tables_exist(client):
|
|||||||
"col_mapping", "users", "user_tokens",
|
"col_mapping", "users", "user_tokens",
|
||||||
"project_properties", "property_values", "ai_keywords",
|
"project_properties", "property_values", "ai_keywords",
|
||||||
"collections", "collection_pages", "collection_views",
|
"collections", "collection_pages", "collection_views",
|
||||||
"collection_properties"}
|
"collection_properties", "workspaces", "workspace_members",
|
||||||
|
"comments", "page_history", "favorites",
|
||||||
|
"database_templates", "page_templates"}
|
||||||
assert required <= names
|
assert required <= names
|
||||||
|
|
||||||
|
|
||||||
@@ -359,7 +361,7 @@ def test_boards_as_collections(client):
|
|||||||
assert isinstance(data["boards"], list)
|
assert isinstance(data["boards"], list)
|
||||||
|
|
||||||
|
|
||||||
# ── v1.6.0: Collection Properties ──
|
# ── v2.0.0: Collection Properties ──
|
||||||
|
|
||||||
def test_property_types_api(client):
|
def test_property_types_api(client):
|
||||||
"""GET /db/property-types/api — should list available types."""
|
"""GET /db/property-types/api — should list available types."""
|
||||||
@@ -469,7 +471,7 @@ def test_collection_properties_validation(client):
|
|||||||
client.delete(f"/db/api/{coll_id}")
|
client.delete(f"/db/api/{coll_id}")
|
||||||
|
|
||||||
|
|
||||||
# ── v1.6.0: Relations, Rollups, Formulas ──
|
# ── v2.0.0: Relations, Rollups, Formulas ──
|
||||||
|
|
||||||
def test_create_relation_property(client):
|
def test_create_relation_property(client):
|
||||||
"""Create a relation property between two collections."""
|
"""Create a relation property between two collections."""
|
||||||
@@ -607,7 +609,7 @@ def test_formula_empty_expression(client):
|
|||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
# ── v1.6.0: Views (Calendar, Gallery, List, Timeline) ──
|
# ── v2.0.0: Views (Calendar, Gallery, List, Timeline) ──
|
||||||
|
|
||||||
def test_views_calendar(client):
|
def test_views_calendar(client):
|
||||||
"""Calendar view renders with navigation."""
|
"""Calendar view renders with navigation."""
|
||||||
@@ -671,3 +673,266 @@ def test_views_calendar_navigation(client):
|
|||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert "August" in resp.text or "août" in resp.text
|
assert "August" in resp.text or "août" in resp.text
|
||||||
client.delete(f"/db/api/{cid}")
|
client.delete(f"/db/api/{cid}")
|
||||||
|
|
||||||
|
|
||||||
|
# ── v1.7.0: View Management ──
|
||||||
|
|
||||||
|
def test_view_config_update(client):
|
||||||
|
"""Update view config (card_size, group_by)."""
|
||||||
|
r = client.post("/db/api", json={"name": "V Config"})
|
||||||
|
cid = r.json()["id"]
|
||||||
|
views = client.get(f"/db/{cid}/views/api").json()["views"]
|
||||||
|
vid = views[0]["id"]
|
||||||
|
|
||||||
|
resp = client.put(f"/db/views/{vid}/config", json={
|
||||||
|
"card_size": "large", "group_by": "Status",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["config"]["card_size"] == "large"
|
||||||
|
client.delete(f"/db/api/{cid}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_view_as(client):
|
||||||
|
"""Save current state as new view."""
|
||||||
|
r = client.post("/db/api", json={"name": "Save As"})
|
||||||
|
cid = r.json()["id"]
|
||||||
|
|
||||||
|
resp = client.post(f"/db/{cid}/views/save-as", json={
|
||||||
|
"name": "My Kanban", "view_type": "board",
|
||||||
|
"config": {"group_by": "Priority"},
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["name"] == "My Kanban"
|
||||||
|
|
||||||
|
views = client.get(f"/db/{cid}/views/api").json()["views"]
|
||||||
|
assert len(views) == 2
|
||||||
|
client.delete(f"/db/api/{cid}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_views(client):
|
||||||
|
"""List views for a collection."""
|
||||||
|
r = client.post("/db/api", json={"name": "V List"})
|
||||||
|
cid = r.json()["id"]
|
||||||
|
resp = client.get(f"/db/{cid}/views/api")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert len(resp.json()["views"]) == 1 # default view
|
||||||
|
client.delete(f"/db/api/{cid}")
|
||||||
|
|
||||||
|
|
||||||
|
# ── v2.0.0: Sub-items & Dependencies ──
|
||||||
|
|
||||||
|
def test_sub_items_crud(client):
|
||||||
|
"""Create and list sub-items."""
|
||||||
|
r = client.post("/db/api", json={"name": "Sub DB"})
|
||||||
|
cid = r.json()["id"]
|
||||||
|
p = client.post(f"/db/{cid}/pages/api", json={"title": "Parent Task"})
|
||||||
|
pid = p.json()["id"]
|
||||||
|
|
||||||
|
# Create sub-item
|
||||||
|
resp = client.post(f"/db/{cid}/pages/{pid}/sub-items", json={"title": "Child 1"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["parent_id"] == pid
|
||||||
|
|
||||||
|
resp2 = client.post(f"/db/{cid}/pages/{pid}/sub-items", json={"title": "Child 2"})
|
||||||
|
assert resp2.status_code == 200
|
||||||
|
|
||||||
|
# List
|
||||||
|
items = client.get(f"/db/{cid}/pages/{pid}/sub-items").json()["sub_items"]
|
||||||
|
assert len(items) == 2
|
||||||
|
client.delete(f"/db/api/{cid}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_aggregate(client):
|
||||||
|
"""Aggregate child statuses."""
|
||||||
|
r = client.post("/db/api", json={"name": "Agg DB"})
|
||||||
|
cid = r.json()["id"]
|
||||||
|
p = client.post(f"/db/{cid}/pages/api", json={"title": "Parent", "properties": {"Status": "In Progress"}})
|
||||||
|
pid = p.json()["id"]
|
||||||
|
|
||||||
|
client.post(f"/db/{cid}/pages/{pid}/sub-items", json={"title": "C1", "properties": {"Status": "Done"}})
|
||||||
|
client.post(f"/db/{cid}/pages/{pid}/sub-items", json={"title": "C2", "properties": {"Status": "In Progress"}})
|
||||||
|
|
||||||
|
resp = client.get(f"/db/{cid}/pages/{pid}/status-aggregate")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["total"] == 2
|
||||||
|
assert data["done"] == 1
|
||||||
|
assert data["all_done"] is False
|
||||||
|
client.delete(f"/db/api/{cid}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_dependencies_check(client):
|
||||||
|
"""Dependency constraint check."""
|
||||||
|
r = client.post("/db/api", json={"name": "Dep DB"})
|
||||||
|
cid = r.json()["id"]
|
||||||
|
a = client.post(f"/db/{cid}/pages/api", json={"title": "Task A", "properties": {"Status": "In Progress"}})
|
||||||
|
b = client.post(f"/db/{cid}/pages/api", json={"title": "Task B", "properties": {"Status": "Done"}})
|
||||||
|
aid, bid = a.json()["id"], b.json()["id"]
|
||||||
|
|
||||||
|
# A blocks B
|
||||||
|
client.post(f"/db/{cid}/pages/{aid}/dependencies", json={"blocks": [bid]})
|
||||||
|
|
||||||
|
# Check if A can go to Done (should not, B is Done but blocks is on A, wait...)
|
||||||
|
# B is Done, so A CAN transition
|
||||||
|
resp = client.post(f"/db/{cid}/pages/{aid}/check-deps", json={"new_status": "Done"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["can_transition"] is True
|
||||||
|
assert resp.json()["blocked_by"] == []
|
||||||
|
|
||||||
|
client.delete(f"/db/api/{cid}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_dependencies_blocked(client):
|
||||||
|
"""Dependency blocks transition when blocker is not done."""
|
||||||
|
r = client.post("/db/api", json={"name": "Block DB"})
|
||||||
|
cid = r.json()["id"]
|
||||||
|
a = client.post(f"/db/{cid}/pages/api", json={"title": "Task A", "properties": {"Status": "In Progress"}})
|
||||||
|
b = client.post(f"/db/{cid}/pages/api", json={"title": "Task B", "properties": {"Status": "Todo"}})
|
||||||
|
aid, bid = a.json()["id"], b.json()["id"]
|
||||||
|
|
||||||
|
# A blocks B — A depends on B being done
|
||||||
|
client.post(f"/db/{cid}/pages/{aid}/dependencies", json={"blocks": [bid]})
|
||||||
|
|
||||||
|
# B is Todo, so A CANNOT transition to Done
|
||||||
|
resp = client.post(f"/db/{cid}/pages/{aid}/check-deps", json={"new_status": "Done"})
|
||||||
|
assert resp.json()["can_transition"] is False
|
||||||
|
assert len(resp.json()["blocked_by"]) == 1
|
||||||
|
client.delete(f"/db/api/{cid}")
|
||||||
|
|
||||||
|
|
||||||
|
# ── v2.0.0: My Tasks ──
|
||||||
|
|
||||||
|
def test_my_tasks_page(client):
|
||||||
|
"""My Tasks dashboard renders."""
|
||||||
|
resp = client.get("/my-tasks")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "My Tasks" in resp.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_my_tasks_api(client):
|
||||||
|
"""My Tasks API returns JSON."""
|
||||||
|
r = client.post("/db/api", json={"name": "My Project"})
|
||||||
|
cid = r.json()["id"]
|
||||||
|
client.post(f"/db/{cid}/pages/api", json={"title": "Task 1", "properties": {"Status": "Todo"}})
|
||||||
|
client.post(f"/db/{cid}/pages/api", json={"title": "Task 2", "properties": {"Status": "In Progress"}})
|
||||||
|
|
||||||
|
resp = client.get("/my-tasks/api")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "tasks" in data
|
||||||
|
assert data["total"] >= 2
|
||||||
|
client.delete(f"/db/api/{cid}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_my_tasks_view_today(client):
|
||||||
|
resp = client.get("/my-tasks?view=today")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_my_tasks_view_overdue(client):
|
||||||
|
resp = client.get("/my-tasks?view=overdue")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# ── v2.0.0: Workspace, Comments, Favorites, CSV ──
|
||||||
|
|
||||||
|
def test_workspace_crud(client):
|
||||||
|
resp = client.post("/workspace", json={"name": "Team WS"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
ws_id = resp.json()["id"]
|
||||||
|
members = client.get(f"/workspace/{ws_id}/members")
|
||||||
|
assert len(members.json()["members"]) >= 1
|
||||||
|
# cleanup
|
||||||
|
with client as _:
|
||||||
|
from app.db import get_conn
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute("DELETE FROM workspace_members WHERE workspace_id=?", (ws_id,))
|
||||||
|
conn.execute("DELETE FROM workspaces WHERE id=?", (ws_id,))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_comments_crud(client):
|
||||||
|
r = client.post("/db/api", json={"name": "Comment DB"})
|
||||||
|
cid = r.json()["id"]
|
||||||
|
p = client.post(f"/db/{cid}/pages/api", json={"title": "Discuss"})
|
||||||
|
pid = p.json()["id"]
|
||||||
|
|
||||||
|
resp = client.post(f"/workspace/pages/{pid}/comments", json={"body": "Nice work!"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
comments = client.get(f"/workspace/pages/{pid}/comments").json()["comments"]
|
||||||
|
assert len(comments) == 1
|
||||||
|
assert comments[0]["body"] == "Nice work!"
|
||||||
|
|
||||||
|
client.delete(f"/db/api/{cid}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_favorites_crud(client):
|
||||||
|
r = client.post("/db/api", json={"name": "Fav DB"})
|
||||||
|
cid = r.json()["id"]
|
||||||
|
|
||||||
|
resp = client.post("/workspace/favorites", json={"collection_id": cid})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
favs = client.get("/workspace/favorites").json()["favorites"]
|
||||||
|
assert len(favs) >= 1
|
||||||
|
|
||||||
|
fav_id = favs[0]["id"]
|
||||||
|
client.delete(f"/workspace/favorites/{fav_id}")
|
||||||
|
assert len(client.get("/workspace/favorites").json()["favorites"]) == 0
|
||||||
|
client.delete(f"/db/api/{cid}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_csv_import_export(client):
|
||||||
|
r = client.post("/db/api", json={"name": "CSV DB"})
|
||||||
|
cid = r.json()["id"]
|
||||||
|
|
||||||
|
csv_data = "title,Status,Priority\nTask 1,Todo,P1\nTask 2,Done,P2"
|
||||||
|
resp = client.post(f"/workspace/collections/{cid}/import/csv", json={"csv": csv_data})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["imported"] == 2
|
||||||
|
|
||||||
|
export = client.get(f"/workspace/collections/{cid}/export/csv")
|
||||||
|
assert export.status_code == 200
|
||||||
|
assert "Task 1" in export.text
|
||||||
|
|
||||||
|
client.delete(f"/db/api/{cid}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_view(client):
|
||||||
|
r = client.post("/db/api", json={"name": "Public DB"})
|
||||||
|
cid = r.json()["id"]
|
||||||
|
client.post(f"/db/{cid}/pages/api", json={"title": "Public Page"})
|
||||||
|
resp = client.get(f"/workspace/public/{cid}")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "Public Page" in resp.text
|
||||||
|
client.delete(f"/db/api/{cid}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_db_templates(client):
|
||||||
|
resp = client.post("/workspace/templates/database", json={
|
||||||
|
"name": "Bug Tracker", "schema": [{"name": "Severity", "type": "select"}],
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
tid = resp.json()["id"]
|
||||||
|
|
||||||
|
templates = client.get("/workspace/templates/database").json()["templates"]
|
||||||
|
assert len(templates) >= 1
|
||||||
|
|
||||||
|
apply = client.post(f"/workspace/templates/database/{tid}/apply", json={"name": "Bugs v2"})
|
||||||
|
assert apply.status_code == 200
|
||||||
|
client.delete(f"/db/api/{apply.json()['collection_id']}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_page_history(client):
|
||||||
|
r = client.post("/db/api", json={"name": "Hist DB"})
|
||||||
|
cid = r.json()["id"]
|
||||||
|
p = client.post(f"/db/{cid}/pages/api", json={"title": "History Page"})
|
||||||
|
pid = p.json()["id"]
|
||||||
|
|
||||||
|
client.post(f"/workspace/pages/{pid}/history", json={
|
||||||
|
"change_type": "created", "snapshot": {"title": "History Page"},
|
||||||
|
})
|
||||||
|
hist = client.get(f"/workspace/pages/{pid}/history").json()["history"]
|
||||||
|
assert len(hist) == 1
|
||||||
|
client.delete(f"/db/api/{cid}")
|
||||||
|
|||||||
Reference in New Issue
Block a user