Compare commits

...
Author SHA1 Message Date
bruno db4329eee2 feat(v4.4.0): Tasks, Sub-items & Dependencies
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- Add is_task flag on collections (toggle-task API)
- New page_dependencies table with auto_shift config (blocks/blocked_by/related)
- API: GET/POST/DELETE page dependencies
- API: POST auto-shift dates based on blocking dependencies (skip_weekends option)
- 5 new tests (171 total)
2026-07-21 22:26:24 -04:00
bruno 122ccfb3c3 docs: mark v4.3.0 as completed in ROADMAP
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
2026-07-21 22:24:46 -04:00
4 changed files with 307 additions and 15 deletions
+13 -14
View File
@@ -223,17 +223,17 @@ Propriétés custom, AI keywords, sync API, 12 tables DB
- [x] **API dashboards CRUD** — `GET/POST/PUT/DELETE /workspace/collections/{id}/dashboards`
- [x] **159 tests passent** (+6 nouveaux)
### v4.3.0 — Database Views complètes (10 types)
> **Objectif** : Tous les types de vues Notion avec layouts.
> **Doc** : [`NOTION_DATABASE_TASKS_GUIDE.md` Phase 3](docs/NOTION_DATABASE_TASKS_GUIDE.md#14-plan-implémentation)
### v4.3.0 — Database Views complètes (10 types) ✅ (2026-07-21)
> **Objectif** : Tous les types de vues Notion avec layouts. **COMPLETED**.
- [ ] **Chart** — barres, courbes, camemberts, donuts, scatter (Chart.js)
- [ ] **Form** — formulaire HTML → POST création page
- [ ] **Map** — Leaflet/OSM pour propriété Place
- [ ] **Feed** — vue chronologique type fil d'actualité
- [ ] **Timeline/Gantt** — barres horizontales avec dépendances
- [ ] **Layout options** — card size, preview, open in peek/side/full
- [ ] **View tabs** — navigation fluide entre vues (existant à enrichir)
- [x] **Chart** — barres, courbes, camemberts, donuts, scatter (Chart.js CDN)
- [x] **Form** — formulaire HTML auto-généré depuis les propriétés → POST création page
- [x] **Map** — Leaflet/OSM pour propriété Place (lat,lng parsing)
- [x] **Feed** — vue chronologique type fil d'actualité (newest first)
- [x] **Timeline/Gantt** — barres horizontales avec group_by et plages de dates
- [x] **View tabs** — 11 onglets de navigation (table, board, calendar, gallery, list, timeline, gantt, chart, form, map, feed)
- [x] **Layout options** — card_size, chart_type, group_by, place_property via config_json
- [x] **166 tests passent** (+7 nouveaux)
### v4.4.0 — Tasks, Sub-items & Dependencies
> **Objectif** : Système complet de tâches Notion-style.
@@ -422,10 +422,9 @@ Base + Kanban Éditeur + Gitea UX Pro MVP Onboard
+ UI Notion + Tags + Admin + Sharing COMPLETED
+ GitHub OAuth + Library
v4.0.2 ✅ v4.1.0 ✅ v4.2.0 ✅ v4.3–4.5 ⬜ v4.6.0 ⬜ v4.7.0 ⬜ v4.8–4.9 ⬜ v5.x–v6.0 ⬜
Quality Data Sources Templates + Views/ Content Export Collab + Pro + Agent
& Tests & Linked DB Dashboards Tasks Blocks PDF/MD Agent IA (futur)
📚 NOTION_DATABASE_TASKS_GUIDE.md v2.0
v4.0.2 ✅ v4.1.0 ✅ v4.2.0 ✅ v4.3.0 ✅ v4.4–4.5 ⬜ v4.6.0 ⬜ v4.7.0 ⬜ v4.8–4.9 ⬜ v5.x–v6.0 ⬜
Quality Data Sources Templates + 10 Views Tasks/ Content Export Collab + Pro + Agent
& Tests & Linked DB Dashboards complets Sprints Blocks PDF/MD Agent IA (futur)
```
*Dernière mise à jour: 2026-07-21 — v4.2.0 Templates & Dashboards complété ✅*
+21
View File
@@ -515,6 +515,27 @@ def init_db():
""")
conn.commit()
# v4.4.0: Tasks & Dependencies
# Add is_task flag to collections
try:
conn.execute("ALTER TABLE collections ADD COLUMN is_task BOOLEAN NOT NULL DEFAULT 0")
except sqlite3.OperationalError:
pass
# Dependencies table: bloque/bloqué par with auto-shift config
conn.execute("""
CREATE TABLE IF NOT EXISTS page_dependencies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE,
dependency_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE,
dependency_type TEXT NOT NULL DEFAULT 'blocks',
auto_shift TEXT DEFAULT 'overlap',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(page_id, dependency_id, dependency_type)
)
""")
conn.commit()
@contextmanager
def get_conn():
+155
View File
@@ -1010,6 +1010,161 @@ async def create_inline_database(request: Request):
}
# ── v4.4.0: Tasks & Dependencies ──
@router.put("/{collection_id}/toggle-task/api")
async def toggle_task(request: Request, collection_id: int):
"""API: toggle is_task flag on a collection (Turn into Tasks)."""
with get_conn() as conn:
coll = conn.execute("SELECT id, is_task FROM collections WHERE id=?", (collection_id,)).fetchone()
if not coll:
raise HTTPException(status_code=404, detail="Collection not found")
new_val = 0 if coll["is_task"] else 1
conn.execute("UPDATE collections SET is_task=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (new_val, collection_id))
conn.commit()
return {"collection_id": collection_id, "is_task": bool(new_val), "mode": "tasks" if new_val else "standard"}
@router.get("/{collection_id}/pages/{page_id}/dependencies/api")
async def list_page_dependencies(request: Request, collection_id: int, page_id: int):
"""API: list dependencies for a page (blocks, blocked_by, related)."""
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM page_dependencies WHERE page_id=? ORDER BY created_at",
(page_id,),
).fetchall()
deps = []
for r in rows:
d = dict(r)
dep_page = conn.execute(
"SELECT id, title FROM collection_pages WHERE id=?", (r["dependency_id"],)
).fetchone()
if dep_page:
d["dependency_title"] = dep_page["title"]
deps.append(d)
return {"dependencies": deps}
@router.post("/{collection_id}/pages/{page_id}/dependencies/api")
async def add_page_dependency(request: Request, collection_id: int, page_id: int):
"""API: add a dependency (blocks/blocked_by/related) between two pages."""
try:
body = await request.json()
except Exception:
body = {}
dependency_id = body.get("dependency_id")
if not dependency_id:
raise HTTPException(status_code=400, detail="dependency_id is required")
dep_type = body.get("dependency_type", "blocks")
auto_shift = body.get("auto_shift", "overlap")
with get_conn() as conn:
for pid in (page_id, dependency_id):
if not conn.execute("SELECT id FROM collection_pages WHERE id=?", (pid,)).fetchone():
raise HTTPException(status_code=404, detail=f"Page {pid} not found")
try:
cur = conn.execute(
"INSERT INTO page_dependencies (page_id, dependency_id, dependency_type, auto_shift) VALUES (?,?,?,?)",
(page_id, dependency_id, dep_type, auto_shift),
)
conn.commit()
except sqlite3.IntegrityError:
raise HTTPException(status_code=409, detail="This dependency already exists")
return {"id": cur.lastrowid, "page_id": page_id, "dependency_id": dependency_id, "status": "added"}
@router.delete("/{collection_id}/pages/{page_id}/dependencies/{dep_id}/api")
async def remove_page_dependency(request: Request, collection_id: int, page_id: int, dep_id: int):
"""API: remove a dependency."""
with get_conn() as conn:
existing = conn.execute(
"SELECT * FROM page_dependencies WHERE id=? AND page_id=?", (dep_id, page_id)
).fetchone()
if not existing:
raise HTTPException(status_code=404, detail="Dependency not found")
conn.execute("DELETE FROM page_dependencies WHERE id=?", (dep_id,))
conn.commit()
return {"id": dep_id, "status": "removed"}
@router.post("/{collection_id}/pages/{page_id}/auto-shift/api")
async def auto_shift_dates(request: Request, collection_id: int, page_id: int):
"""API: auto-shift dates based on blocking dependencies."""
from datetime import date as dt_date, timedelta
try:
body = await request.json()
except Exception:
body = {}
skip_weekends = body.get("skip_weekends", False)
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")
# Get all blocking dependencies
deps = conn.execute(
"SELECT * FROM page_dependencies WHERE page_id=? AND dependency_type='blocks'",
(page_id,),
).fetchall()
shifted = False
new_start = None
for dep in deps:
dep_page = conn.execute(
"SELECT title, property_values_json FROM collection_pages WHERE id=?",
(dep["dependency_id"],),
).fetchone()
if not dep_page:
continue
dep_props = json.loads(dep_page["property_values_json"])
# Find the latest end date among blockers
for v in dep_props.values():
if isinstance(v, str) and v.startswith("20"):
end_date = v.split("...")[-1].split("→")[-1].strip()[:10]
try:
ed = dt_date.fromisoformat(end_date)
if new_start is None or ed >= new_start:
new_start = ed + timedelta(days=1)
shifted = True
except ValueError:
continue
if not shifted:
return {"page_id": page_id, "shifted": False, "message": "No blocking dependencies with dates found"}
# Skip weekends if requested
if skip_weekends and new_start:
while new_start.weekday() >= 5: # 5=Sat, 6=Sun
new_start = new_start + timedelta(days=1)
# Update the page's date properties
props = json.loads(page["property_values_json"])
for k, v in list(props.items()):
if isinstance(v, str) and v.startswith("20"):
old_parts = v.split("...")
old_end = old_parts[-1] if len(old_parts) > 1 else old_parts[0]
try:
old_start_d = dt_date.fromisoformat(old_parts[0][:10])
old_end_d = dt_date.fromisoformat(old_end[:10])
duration = (old_end_d - old_start_d).days
new_end = new_start + timedelta(days=max(duration, 0))
props[k] = f"{new_start.isoformat()}...{new_end.isoformat()}"
except ValueError:
props[k] = new_start.isoformat()
break
conn.execute(
"UPDATE collection_pages SET property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(json.dumps(props), page_id),
)
conn.commit()
return {"page_id": page_id, "shifted": True, "new_start": new_start.isoformat(), "skip_weekends": skip_weekends}
# ── {collection_id} wildcards (LAST — catches everything else) ──
+118 -1
View File
@@ -2451,4 +2451,121 @@ def test_view_unknown_falls_back_to_table(client):
coll_id = resp.json()["id"]
resp = client.get(f"/db/{coll_id}/view/unknown_view_type")
assert resp.status_code == 200
assert "<table>" in resp.text
assert "<table>" in resp.text
# ── v4.4.0: Tasks & Dependencies ──
def test_toggle_task_flag(client):
"""Toggle is_task on a collection."""
resp = client.post("/db/api", json={"name": "Task DB"})
coll_id = resp.json()["id"]
resp = client.put(f"/db/{coll_id}/toggle-task/api")
assert resp.status_code == 200
assert resp.json()["is_task"] is True
assert resp.json()["mode"] == "tasks"
resp = client.get(f"/db/{coll_id}/api")
assert resp.json()["collection"]["is_task"] == 1
# Toggle back
resp = client.put(f"/db/{coll_id}/toggle-task/api")
assert resp.json()["is_task"] is False
assert resp.json()["mode"] == "standard"
def test_page_dependencies_crud(client):
"""Add, list, and remove page dependencies."""
resp = client.post("/db/api", json={"name": "Dep DB"})
coll_id = resp.json()["id"]
p1 = client.post(f"/db/{coll_id}/pages/api", json={"title": "Task A"})
p2 = client.post(f"/db/{coll_id}/pages/api", json={"title": "Task B"})
pid1 = p1.json()["id"]
pid2 = p2.json()["id"]
# Add dependency: Task A blocks Task B
resp = client.post(f"/db/{coll_id}/pages/{pid2}/dependencies/api", json={
"dependency_id": pid1,
"dependency_type": "blocks",
"auto_shift": "overlap",
})
assert resp.status_code == 200
assert resp.json()["status"] == "added"
dep_id = resp.json()["id"]
# List dependencies
resp = client.get(f"/db/{coll_id}/pages/{pid2}/dependencies/api")
deps = resp.json()["dependencies"]
assert len(deps) == 1
assert deps[0]["dependency_type"] == "blocks"
assert deps[0]["dependency_title"] == "Task A"
# Remove dependency
resp = client.delete(f"/db/{coll_id}/pages/{pid2}/dependencies/{dep_id}/api")
assert resp.status_code == 200
assert resp.json()["status"] == "removed"
resp = client.get(f"/db/{coll_id}/pages/{pid2}/dependencies/api")
assert len(resp.json()["dependencies"]) == 0
def test_page_dependencies_duplicate_rejected(client):
"""Duplicate dependency returns 409."""
resp = client.post("/db/api", json={"name": "Dup Dep DB"})
coll_id = resp.json()["id"]
p1 = client.post(f"/db/{coll_id}/pages/api", json={"title": "A"})
p2 = client.post(f"/db/{coll_id}/pages/api", json={"title": "B"})
client.post(f"/db/{coll_id}/pages/{p2.json()['id']}/dependencies/api", json={
"dependency_id": p1.json()["id"],
})
resp = client.post(f"/db/{coll_id}/pages/{p2.json()['id']}/dependencies/api", json={
"dependency_id": p1.json()["id"],
})
assert resp.status_code == 409
def test_auto_shift_dates(client):
"""Auto-shift dates when a blocking task is completed."""
resp = client.post("/db/api", json={"name": "Shift DB"})
coll_id = resp.json()["id"]
# Blocking task with end date July 10
p1 = client.post(f"/db/{coll_id}/pages/api", json={
"title": "Blocker",
"properties": {"Timeline": "2026-07-01...2026-07-10"},
})
# Blocked task
p2 = client.post(f"/db/{coll_id}/pages/api", json={
"title": "Dependent",
"properties": {"Timeline": "2026-07-11...2026-07-20"},
})
# Add dependency
client.post(f"/db/{coll_id}/pages/{p2.json()['id']}/dependencies/api", json={
"dependency_id": p1.json()["id"],
"dependency_type": "blocks",
})
# Auto-shift
resp = client.post(f"/db/{coll_id}/pages/{p2.json()['id']}/auto-shift/api", json={
"skip_weekends": False,
})
assert resp.status_code == 200
data = resp.json()
assert data["shifted"] is True
assert data["new_start"] == "2026-07-11" # day after blocker ends
def test_auto_shift_no_blockers(client):
"""Auto-shift with no blocking dependencies returns shifted=False."""
resp = client.post("/db/api", json={"name": "NoBlock DB"})
coll_id = resp.json()["id"]
p = client.post(f"/db/{coll_id}/pages/api", json={"title": "Lone Task"})
resp = client.post(f"/db/{coll_id}/pages/{p.json()['id']}/auto-shift/api")
assert resp.status_code == 200
assert resp.json()["shifted"] is False