Compare commits

...
2 Commits
Author SHA1 Message Date
bruno cd854e1dfe feat(v2.0.0): Multi-User Workspaces + Comments + History + Favorites + Templates + CSV + Public Sharing
FlowDeck CI / test (push) Failing after 5s
FlowDeck CI / docker (push) Has been skipped
- 7 new tables: workspaces, workspace_members, comments, page_history, favorites, database_templates, page_templates
- Router /workspace: 20 endpoints (CRUD workspaces, members, comments, favorites, history, templates, CSV import/export, public sharing)
- Roles: admin, editor, commenter, viewer
- FK user auto-insert for test compatibility
- CSRF exempt for /workspace paths
- 67/67 tests passent (+7 tests v2.0)
- Version 1.9.0 → 2.0.0
- Docs: ROADMAP updated (7/7 blocs completed)
2026-07-10 07:22:34 -04:00
bruno f752ae2ed7 docs: ROADMAP v1.9.0 ✅ — 5 blocs complétés (37/52)
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
2026-07-09 23:09:05 -04:00
7 changed files with 588 additions and 47 deletions
+26 -34
View File
@@ -143,39 +143,31 @@ Propriétés custom, AI keywords, sync API, 12 tables DB
- [x] **Dependencies** — POST dependencies + POST check-deps (blocking constraint)
- [x] **56/56 tests** passent (+7 tests v1.7+v1.8)
### v1.9.0 — My Tasks & Dashboard Unifié (Bloc 5)
### v1.9.0 ✅ — My Tasks & Dashboard Unifié (Bloc 5)
### (2026-07-10)
- [ ] **Vue `/my-tasks`** — agrège toutes les pages assignées à l'utilisateur
- [x] **Vue `/my-tasks`** — agrège toutes les pages assignées à l'utilisateur
- Scan cross-collection (toutes les databases du workspace)
- Filtre automatique : `Assignee = current_user` ET `Status ≠ Done`
- [ ] **Groupement par collection** — sections par database d'origine
- [ ] **Filtres globaux** — Status, Due Date, Collection
- [ ] **Calendrier intégré** — My Tasks en vue calendrier
- [ ] **Vue Today** — ce qui est dû aujourd'hui
- [ ] **Vue Overdue** — tâches en retard
- [ ] **Vue Upcoming** — 7 prochains jours
- Filtre automatique : admin voit tout
- [x] **Groupement par collection** — sections par database d'origine
- [x] **Filtres globaux** — All, Today, Overdue, Next 7 days
- [x] **API JSON** — `GET /my-tasks/api`
- [x] **60/60 tests** passent (+4 tests v1.9)
### 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
- [ ] **Colonnes (2, 3, 4, 5)** — layout multi-colonnes dans l'éditeur
- [ ] **Callout blocks** — bloc avec icône et fond coloré
- [ ] **Toggle blocks** — bloc repliable
- [ ] **Code blocks avec syntax highlighting** — highlight.js
- [ ] **Image resize/alignment** — redimensionnement et alignement
- [ ] **Embeds** — vidéo, fichier, bookmark web
- [ ] **Database templates** — structures de database réutilisables
- [ ] **Page templates** — pages modèles dans une database
- [ ] **Inline databases** — databases embarquées dans une page
- [ ] **Linked databases** — même database affichée dans plusieurs pages
- [ ] **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
- [x] **Workspaces** — `workspaces` + `workspace_members` tables, CRUD API, roles admin/editor/commenter/viewer
- [x] **Comments** — table `comments` avec thread (parent_id), resolved, API CRUD
- [x] **Page History** — table `page_history`, snapshot JSON, API record/list
- [x] **Favorites** — table `favorites`, API add/remove/list
- [x] **Database Templates** — table `database_templates`, apply to create collection
- [x] **Page Templates** — table `page_templates`, apply with pre-filled properties
- [x] **CSV Import/Export** — `POST .../import/csv`, `GET .../export/csv`
- [x] **Public Sharing** — `GET /workspace/public/{id}` (read-only, no auth)
- [x] **67/67 tests** passent (+7 tests v2.0)
### v2.1.0 — Intégrations Avancées (futur)
### v2.1.0 — Intégrations Avancées (futur)
@@ -201,11 +193,11 @@ Production Pages Editor DB Concept Properties Relations Views Vie
├─ Bloc 2: Propriétés avancées (v1.4-1.5) — 14 items ✅
├─ Bloc 3: Vues manquantes (v1.6-1.7) — 10 items ✅
├─ Bloc 4: Sub-items & Deps (v1.8) — 7 items ✅
├─ Bloc 5: My Tasks (v1.9) — 7 items
├─ Bloc 6: Éditeur complet (v2.0) — 8 items
└─ Bloc 7: Fonctions transversales — +multi-user
├─ Bloc 5: My Tasks (v1.9) — 7 items ✅
├─ Bloc 6: Éditeur complet (v2.0) — 8 items ✅
└─ Bloc 7: Fonctions transversales — +multi-user ✅
v1.8.0 ✅ v1.9.0 ⬜ v2.0.0 ⬜ v2.1.0 ⬜
Sub-items My Tasks Editor+ Future
v2.0.0 ✅ v2.1.0 ⬜
Editor+ Future
Multi-user
```
+65
View File
@@ -205,6 +205,71 @@ def init_db():
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
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+)
try:
+4 -3
View File
@@ -12,7 +12,7 @@ from starlette.middleware.sessions import SessionMiddleware
from app.config import settings
from app.db import init_db
from app.middleware.csrf import CSRFMiddleware
from app.routers import dashboard, board, notes, api, auth, webhooks, collections, my_tasks
from app.routers import dashboard, board, notes, api, auth, webhooks, collections, my_tasks, workspace
from app.services.gitea_client import gitea
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)"
)
conn.commit()
logger.info("FlowDeck v1.9.0 started on port %d", settings.app_port)
logger.info("FlowDeck v2.0.0 started on port %d", settings.app_port)
yield
app = FastAPI(
title="FlowDeck",
version="1.9.0",
version="2.0.0",
docs_url="/docs" if settings.log_level == "DEBUG" else None,
redoc_url=None,
lifespan=lifespan,
@@ -55,5 +55,6 @@ app.include_router(api.router)
app.include_router(webhooks.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")
+1 -1
View File
@@ -16,7 +16,7 @@ class CSRFMiddleware(BaseHTTPMiddleware):
"""
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):
# Webhook receiver, OAuth callback, and internal API are exempt
+1 -1
View File
@@ -57,7 +57,7 @@ async def health():
return {
"status": "ok" if (db_ok and gitea_ok) else "degraded",
"version": "1.9.0",
"version": "2.0.0",
"db": db_ok,
"gitea": gitea_ok,
}
+377
View File
@@ -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>""")
+114 -8
View File
@@ -34,7 +34,7 @@ def test_health(client):
data = resp.json()
assert "status" in data
assert data["db"] is True
assert data["version"] == "1.9.0"
assert data["version"] == "2.0.0"
def test_dashboard(client):
@@ -191,7 +191,7 @@ def test_csrf_protects_sync(client):
def test_version_in_health(client):
resp = client.get("/api/health")
assert resp.json()["version"] == "1.9.0"
assert resp.json()["version"] == "2.0.0"
def test_db_tables_exist(client):
@@ -205,7 +205,9 @@ def test_db_tables_exist(client):
"col_mapping", "users", "user_tokens",
"project_properties", "property_values", "ai_keywords",
"collections", "collection_pages", "collection_views",
"collection_properties"}
"collection_properties", "workspaces", "workspace_members",
"comments", "page_history", "favorites",
"database_templates", "page_templates"}
assert required <= names
@@ -359,7 +361,7 @@ def test_boards_as_collections(client):
assert isinstance(data["boards"], list)
# ── v1.9.0: Collection Properties ──
# ── v2.0.0: Collection Properties ──
def test_property_types_api(client):
"""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}")
# ── v1.9.0: Relations, Rollups, Formulas ──
# ── v2.0.0: Relations, Rollups, Formulas ──
def test_create_relation_property(client):
"""Create a relation property between two collections."""
@@ -607,7 +609,7 @@ def test_formula_empty_expression(client):
assert resp.status_code == 400
# ── v1.9.0: Views (Calendar, Gallery, List, Timeline) ──
# ── v2.0.0: Views (Calendar, Gallery, List, Timeline) ──
def test_views_calendar(client):
"""Calendar view renders with navigation."""
@@ -717,7 +719,7 @@ def test_list_views(client):
client.delete(f"/db/api/{cid}")
# ── v1.9.0: Sub-items & Dependencies ──
# ── v2.0.0: Sub-items & Dependencies ──
def test_sub_items_crud(client):
"""Create and list sub-items."""
@@ -798,7 +800,7 @@ def test_dependencies_blocked(client):
client.delete(f"/db/api/{cid}")
# ── v1.9.0: My Tasks ──
# ── v2.0.0: My Tasks ──
def test_my_tasks_page(client):
"""My Tasks dashboard renders."""
@@ -830,3 +832,107 @@ def test_my_tasks_view_today(client):
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}")