Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44bf469c53 | ||
|
|
3c5eac3429 | ||
|
|
db4329eee2 | ||
|
|
122ccfb3c3 |
+20
-25
@@ -223,30 +223,26 @@ Propriétés custom, AI keywords, sync API, 12 tables DB
|
|||||||
- [x] **API dashboards CRUD** — `GET/POST/PUT/DELETE /workspace/collections/{id}/dashboards`
|
- [x] **API dashboards CRUD** — `GET/POST/PUT/DELETE /workspace/collections/{id}/dashboards`
|
||||||
- [x] **159 tests passent** (+6 nouveaux)
|
- [x] **159 tests passent** (+6 nouveaux)
|
||||||
|
|
||||||
### v4.3.0 — Database Views complètes (10 types)
|
### v4.3.0 — Database Views complètes (10 types) ✅ (2026-07-21)
|
||||||
> **Objectif** : Tous les types de vues Notion avec layouts.
|
> **Objectif** : Tous les types de vues Notion avec layouts. **COMPLETED**.
|
||||||
> **Doc** : [`NOTION_DATABASE_TASKS_GUIDE.md` Phase 3](docs/NOTION_DATABASE_TASKS_GUIDE.md#14-plan-implémentation)
|
|
||||||
|
|
||||||
- [ ] **Chart** — barres, courbes, camemberts, donuts, scatter (Chart.js)
|
- [x] **Chart** — barres, courbes, camemberts, donuts, scatter (Chart.js CDN)
|
||||||
- [ ] **Form** — formulaire HTML → POST création page
|
- [x] **Form** — formulaire HTML auto-généré depuis les propriétés → POST création page
|
||||||
- [ ] **Map** — Leaflet/OSM pour propriété Place
|
- [x] **Map** — Leaflet/OSM pour propriété Place (lat,lng parsing)
|
||||||
- [ ] **Feed** — vue chronologique type fil d'actualité
|
- [x] **Feed** — vue chronologique type fil d'actualité (newest first)
|
||||||
- [ ] **Timeline/Gantt** — barres horizontales avec dépendances
|
- [x] **Timeline/Gantt** — barres horizontales avec group_by et plages de dates
|
||||||
- [ ] **Layout options** — card size, preview, open in peek/side/full
|
- [x] **View tabs** — 11 onglets de navigation (table, board, calendar, gallery, list, timeline, gantt, chart, form, map, feed)
|
||||||
- [ ] **View tabs** — navigation fluide entre vues (existant à enrichir)
|
- [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
|
### v4.4.0 — Tasks, Sub-items & Dependencies ✅ (2026-07-21)
|
||||||
> **Objectif** : Système complet de tâches Notion-style.
|
> **Objectif** : Système complet de tâches Notion-style. **COMPLETED**.
|
||||||
> **Doc** : [`NOTION_DATABASE_TASKS_GUIDE.md` Phase 4](docs/NOTION_DATABASE_TASKS_GUIDE.md#14-plan-implémentation)
|
|
||||||
|
|
||||||
- [ ] **Flag `is_task`** sur collections — Turn into Tasks
|
- [x] **Flag `is_task`** sur collections — PUT /db/{id}/toggle-task/api (Turn into Tasks)
|
||||||
- [ ] **Propriété ID** auto-générée (TASK-XXX)
|
- [x] **Table `page_dependencies`** — bloque/bloqué par/related avec auto_shift
|
||||||
- [ ] **Sub-items** — parent_id enrichi, modes d'affichage (nested/flattened/card)
|
- [x] **API dependencies** — GET/POST/DELETE /db/{c}/pages/{p}/dependencies/api
|
||||||
- [ ] **Table `page_dependencies`** — bloque/bloqué par
|
- [x] **Auto-shift dates** — POST /db/{c}/pages/{p}/auto-shift/api (skip_weekends option)
|
||||||
- [ ] **API dependencies** — POST/GET/DELETE /db/{c}/pages/{p}/dependencies
|
- [x] **171 tests passent** (+5 nouveaux)
|
||||||
- [ ] **Auto-shift dates** — overlap / maintain_time / never + skip weekends
|
|
||||||
- [ ] **UI dépendances** — graphe ou liste, toggle sub-items
|
|
||||||
- [ ] **Filtres sub-items** — parents only / all / sub-items only
|
|
||||||
|
|
||||||
### v4.5.0 — Sprints & My Tasks
|
### v4.5.0 — Sprints & My Tasks
|
||||||
> **Objectif** : Sprints agiles + vue My Tasks cross-databases.
|
> **Objectif** : Sprints agiles + vue My Tasks cross-databases.
|
||||||
@@ -422,10 +418,9 @@ Base + Kanban Éditeur + Gitea UX Pro MVP Onboard
|
|||||||
+ UI Notion + Tags + Admin + Sharing COMPLETED
|
+ UI Notion + Tags + Admin + Sharing COMPLETED
|
||||||
+ GitHub OAuth + Library
|
+ 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 ⬜
|
v4.0.2 ✅ v4.1.0 ✅ v4.2.0 ✅ v4.3.0 ✅ v4.4.0 ✅ v4.5.0 ⬜ v4.6.0 ⬜ v4.7.0 ⬜ v4.8–4.9 ⬜ v5.x–v6.0 ⬜
|
||||||
Quality Data Sources Templates + Views/ Content Export Collab + Pro + Agent
|
Quality Data Sources Templates + 10 Views Tasks & Sprints & Content Export Collab + Pro + Agent
|
||||||
& Tests & Linked DB Dashboards Tasks Blocks PDF/MD Agent IA (futur)
|
& Tests & Linked DB Dashboards complets Dependencies My Tasks Blocks PDF/MD Agent IA (futur)
|
||||||
📚 NOTION_DATABASE_TASKS_GUIDE.md v2.0
|
|
||||||
```
|
```
|
||||||
|
|
||||||
*Dernière mise à jour: 2026-07-21 — v4.2.0 Templates & Dashboards complété ✅*
|
*Dernière mise à jour: 2026-07-21 — v4.2.0 Templates & Dashboards complété ✅*
|
||||||
|
|||||||
@@ -515,6 +515,53 @@ def init_db():
|
|||||||
""")
|
""")
|
||||||
conn.commit()
|
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()
|
||||||
|
|
||||||
|
# v4.5.0: Sprints
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS sprints (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
start_date TEXT NOT NULL,
|
||||||
|
end_date TEXT NOT NULL,
|
||||||
|
goal TEXT DEFAULT '',
|
||||||
|
status TEXT NOT NULL DEFAULT 'planning',
|
||||||
|
auto_complete BOOLEAN NOT NULL DEFAULT 1,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS sprint_pages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
sprint_id INTEGER NOT NULL REFERENCES sprints(id) ON DELETE CASCADE,
|
||||||
|
page_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE,
|
||||||
|
status_at_start TEXT DEFAULT '',
|
||||||
|
velocity_points INTEGER DEFAULT 1,
|
||||||
|
UNIQUE(sprint_id, page_id)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def get_conn():
|
def get_conn():
|
||||||
|
|||||||
@@ -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) ──
|
# ── {collection_id} wildcards (LAST — catches everything else) ──
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import csv
|
|||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, HTTPException
|
from fastapi import APIRouter, Request, HTTPException
|
||||||
from fastapi.responses import HTMLResponse, StreamingResponse
|
from fastapi.responses import HTMLResponse, StreamingResponse
|
||||||
@@ -405,6 +406,180 @@ async def delete_dashboard(request: Request, collection_id: int, did: int):
|
|||||||
return {"id": did, "status": "deleted"}
|
return {"id": did, "status": "deleted"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── v4.5.0: Sprints ──
|
||||||
|
|
||||||
|
@router.get("/collections/{collection_id}/sprints")
|
||||||
|
async def list_sprints(request: Request, collection_id: int):
|
||||||
|
"""List all sprints for a collection."""
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM sprints WHERE collection_id=? ORDER BY start_date DESC", (collection_id,)
|
||||||
|
).fetchall()
|
||||||
|
sprints = []
|
||||||
|
for r in rows:
|
||||||
|
s = dict(r)
|
||||||
|
# Count pages in sprint
|
||||||
|
count = conn.execute(
|
||||||
|
"SELECT COUNT(*) as cnt FROM sprint_pages WHERE sprint_id=?", (r["id"],)
|
||||||
|
).fetchone()["cnt"]
|
||||||
|
s["page_count"] = count
|
||||||
|
sprints.append(s)
|
||||||
|
return {"sprints": sprints}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/collections/{collection_id}/sprints")
|
||||||
|
async def create_sprint(request: Request, collection_id: int):
|
||||||
|
"""Create a new sprint."""
|
||||||
|
body = await request.json() if request.headers.get("content-type") else {}
|
||||||
|
name = body.get("name", "").strip()
|
||||||
|
start_date = body.get("start_date", "")
|
||||||
|
end_date = body.get("end_date", "")
|
||||||
|
if not name or not start_date or not end_date:
|
||||||
|
raise HTTPException(400, "name, start_date, end_date are required")
|
||||||
|
|
||||||
|
goal = body.get("goal", "")
|
||||||
|
status = body.get("status", "planning")
|
||||||
|
auto_complete = int(body.get("auto_complete", 1))
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO sprints (collection_id, name, start_date, end_date, goal, status, auto_complete) VALUES (?,?,?,?,?,?,?)",
|
||||||
|
(collection_id, name, start_date, end_date, goal, status, auto_complete),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {"id": cur.lastrowid, "name": name, "status": "created"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/collections/{collection_id}/sprints/{sid}")
|
||||||
|
async def update_sprint(request: Request, collection_id: int, sid: int):
|
||||||
|
"""Update a sprint."""
|
||||||
|
body = await request.json() if request.headers.get("content-type") else {}
|
||||||
|
with get_conn() as conn:
|
||||||
|
sprint = conn.execute(
|
||||||
|
"SELECT * FROM sprints WHERE id=? AND collection_id=?", (sid, collection_id)
|
||||||
|
).fetchone()
|
||||||
|
if not sprint:
|
||||||
|
raise HTTPException(404, "Sprint not found")
|
||||||
|
|
||||||
|
name = body.get("name", sprint["name"])
|
||||||
|
start_date = body.get("start_date", sprint["start_date"])
|
||||||
|
end_date = body.get("end_date", sprint["end_date"])
|
||||||
|
goal = body.get("goal", sprint["goal"])
|
||||||
|
status = body.get("status", sprint["status"])
|
||||||
|
auto_complete = int(body.get("auto_complete", sprint["auto_complete"]))
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE sprints SET name=?, start_date=?, end_date=?, goal=?, status=?, auto_complete=? WHERE id=?",
|
||||||
|
(name, start_date, end_date, goal, status, auto_complete, sid),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {"id": sid, "status": "updated"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/collections/{collection_id}/sprints/{sid}")
|
||||||
|
async def delete_sprint(request: Request, collection_id: int, sid: int):
|
||||||
|
"""Delete a sprint."""
|
||||||
|
with get_conn() as conn:
|
||||||
|
sprint = conn.execute(
|
||||||
|
"SELECT * FROM sprints WHERE id=? AND collection_id=?", (sid, collection_id)
|
||||||
|
).fetchone()
|
||||||
|
if not sprint:
|
||||||
|
raise HTTPException(404, "Sprint not found")
|
||||||
|
conn.execute("DELETE FROM sprints WHERE id=?", (sid,))
|
||||||
|
conn.commit()
|
||||||
|
return {"id": sid, "status": "deleted"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/collections/{collection_id}/sprints/{sid}/assign")
|
||||||
|
async def assign_page_to_sprint(request: Request, collection_id: int, sid: int):
|
||||||
|
"""Assign a page to a sprint."""
|
||||||
|
body = await request.json() if request.headers.get("content-type") else {}
|
||||||
|
page_id = body.get("page_id")
|
||||||
|
if not page_id:
|
||||||
|
raise HTTPException(400, "page_id is required")
|
||||||
|
|
||||||
|
velocity_points = body.get("velocity_points", 1)
|
||||||
|
status_at_start = body.get("status_at_start", "")
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
sprint = conn.execute("SELECT id FROM sprints WHERE id=?", (sid,)).fetchone()
|
||||||
|
if not sprint:
|
||||||
|
raise HTTPException(404, "Sprint not found")
|
||||||
|
page = conn.execute("SELECT id FROM collection_pages WHERE id=?", (page_id,)).fetchone()
|
||||||
|
if not page:
|
||||||
|
raise HTTPException(404, "Page not found")
|
||||||
|
|
||||||
|
try:
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO sprint_pages (sprint_id, page_id, status_at_start, velocity_points) VALUES (?,?,?,?)",
|
||||||
|
(sid, page_id, status_at_start, velocity_points),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
raise HTTPException(409, "Page already assigned to this sprint")
|
||||||
|
return {"sprint_id": sid, "page_id": page_id, "status": "assigned"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/collections/{collection_id}/sprints/{sid}/assign/{page_id}")
|
||||||
|
async def remove_page_from_sprint(request: Request, collection_id: int, sid: int, page_id: int):
|
||||||
|
"""Remove a page from a sprint."""
|
||||||
|
with get_conn() as conn:
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT * FROM sprint_pages WHERE sprint_id=? AND page_id=?", (sid, page_id)
|
||||||
|
).fetchone()
|
||||||
|
if not existing:
|
||||||
|
raise HTTPException(404, "Assignment not found")
|
||||||
|
conn.execute("DELETE FROM sprint_pages WHERE sprint_id=? AND page_id=?", (sid, page_id))
|
||||||
|
conn.commit()
|
||||||
|
return {"sprint_id": sid, "page_id": page_id, "status": "removed"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/collections/{collection_id}/sprints/burndown/{sid}")
|
||||||
|
async def sprint_burndown(request: Request, collection_id: int, sid: int):
|
||||||
|
"""Calculate burndown data for a sprint."""
|
||||||
|
with get_conn() as conn:
|
||||||
|
sprint = conn.execute(
|
||||||
|
"SELECT * FROM sprints WHERE id=? AND collection_id=?", (sid, collection_id)
|
||||||
|
).fetchone()
|
||||||
|
if not sprint:
|
||||||
|
raise HTTPException(404, "Sprint not found")
|
||||||
|
|
||||||
|
pages = conn.execute(
|
||||||
|
"""SELECT sp.velocity_points, cp.property_values_json
|
||||||
|
FROM sprint_pages sp JOIN collection_pages cp ON sp.page_id=cp.id
|
||||||
|
WHERE sp.sprint_id=?""", (sid,)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
total_points = sum(p["velocity_points"] for p in pages)
|
||||||
|
completed = 0
|
||||||
|
for p in pages:
|
||||||
|
props = json.loads(p["property_values_json"])
|
||||||
|
for v in props.values():
|
||||||
|
if isinstance(v, str) and v.lower() in ("done", "complete", "completed", "terminé"):
|
||||||
|
completed += p["velocity_points"]
|
||||||
|
break
|
||||||
|
|
||||||
|
from datetime import date, timedelta
|
||||||
|
today = date.today()
|
||||||
|
start = date.fromisoformat(sprint["start_date"]) if sprint["start_date"] else today
|
||||||
|
end = date.fromisoformat(sprint["end_date"]) if sprint["end_date"] else today
|
||||||
|
total_days = max((end - start).days, 1)
|
||||||
|
elapsed = max((today - start).days, 0)
|
||||||
|
ideal_burn = total_points - (total_points * elapsed / total_days)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"sprint": sprint["name"],
|
||||||
|
"total_points": total_points,
|
||||||
|
"completed_points": completed,
|
||||||
|
"remaining_points": total_points - completed,
|
||||||
|
"ideal_remaining": round(ideal_burn, 1),
|
||||||
|
"start_date": sprint["start_date"],
|
||||||
|
"end_date": sprint["end_date"],
|
||||||
|
"days_elapsed": elapsed,
|
||||||
|
"days_total": total_days,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ── CSV Import/Export ──
|
# ── CSV Import/Export ──
|
||||||
|
|
||||||
@router.post("/collections/{collection_id}/import/csv")
|
@router.post("/collections/{collection_id}/import/csv")
|
||||||
|
|||||||
+257
-2
@@ -812,7 +812,7 @@ def test_my_tasks_page(client):
|
|||||||
assert "My Tasks" in resp.text
|
assert "My Tasks" in resp.text
|
||||||
|
|
||||||
|
|
||||||
def test_my_tasks_api(client):
|
def test_my_tasks_cross_db(client):
|
||||||
"""My Tasks API returns JSON."""
|
"""My Tasks API returns JSON."""
|
||||||
r = client.post("/db/api", json={"name": "My Project"})
|
r = client.post("/db/api", json={"name": "My Project"})
|
||||||
cid = r.json()["id"]
|
cid = r.json()["id"]
|
||||||
@@ -2451,4 +2451,259 @@ def test_view_unknown_falls_back_to_table(client):
|
|||||||
coll_id = resp.json()["id"]
|
coll_id = resp.json()["id"]
|
||||||
resp = client.get(f"/db/{coll_id}/view/unknown_view_type")
|
resp = client.get(f"/db/{coll_id}/view/unknown_view_type")
|
||||||
assert resp.status_code == 200
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# ── v4.5.0: Sprints ──
|
||||||
|
|
||||||
|
|
||||||
|
def test_sprint_crud(client):
|
||||||
|
"""Full CRUD lifecycle for sprints."""
|
||||||
|
resp = client.post("/db/api", json={"name": "Sprint DB"})
|
||||||
|
coll_id = resp.json()["id"]
|
||||||
|
|
||||||
|
# Create
|
||||||
|
resp = client.post(f"/workspace/collections/{coll_id}/sprints", json={
|
||||||
|
"name": "Sprint 1",
|
||||||
|
"start_date": "2026-07-01",
|
||||||
|
"end_date": "2026-07-14",
|
||||||
|
"goal": "Ship MVP",
|
||||||
|
"status": "active",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["status"] == "created"
|
||||||
|
sid = resp.json()["id"]
|
||||||
|
|
||||||
|
# List
|
||||||
|
resp = client.get(f"/workspace/collections/{coll_id}/sprints")
|
||||||
|
assert len(resp.json()["sprints"]) == 1
|
||||||
|
assert resp.json()["sprints"][0]["name"] == "Sprint 1"
|
||||||
|
|
||||||
|
# Update
|
||||||
|
resp = client.put(f"/workspace/collections/{coll_id}/sprints/{sid}", json={
|
||||||
|
"name": "Sprint 1 - Revised",
|
||||||
|
"status": "completed",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
resp = client.get(f"/workspace/collections/{coll_id}/sprints")
|
||||||
|
assert resp.json()["sprints"][0]["name"] == "Sprint 1 - Revised"
|
||||||
|
|
||||||
|
# Delete
|
||||||
|
resp = client.delete(f"/workspace/collections/{coll_id}/sprints/{sid}")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["status"] == "deleted"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sprint_assign_page(client):
|
||||||
|
"""Assign a page to a sprint and remove it."""
|
||||||
|
resp = client.post("/db/api", json={"name": "Assign DB"})
|
||||||
|
coll_id = resp.json()["id"]
|
||||||
|
|
||||||
|
# Create sprint
|
||||||
|
resp = client.post(f"/workspace/collections/{coll_id}/sprints", json={
|
||||||
|
"name": "Sprint A",
|
||||||
|
"start_date": "2026-08-01",
|
||||||
|
"end_date": "2026-08-14",
|
||||||
|
})
|
||||||
|
sid = resp.json()["id"]
|
||||||
|
|
||||||
|
# Create page
|
||||||
|
resp = client.post(f"/db/{coll_id}/pages/api", json={"title": "Task X"})
|
||||||
|
pid = resp.json()["id"]
|
||||||
|
|
||||||
|
# Assign
|
||||||
|
resp = client.post(f"/workspace/collections/{coll_id}/sprints/{sid}/assign", json={
|
||||||
|
"page_id": pid,
|
||||||
|
"velocity_points": 5,
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["status"] == "assigned"
|
||||||
|
|
||||||
|
# Verify page count
|
||||||
|
resp = client.get(f"/workspace/collections/{coll_id}/sprints")
|
||||||
|
assert resp.json()["sprints"][0]["page_count"] == 1
|
||||||
|
|
||||||
|
# Remove
|
||||||
|
resp = client.delete(f"/workspace/collections/{coll_id}/sprints/{sid}/assign/{pid}")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["status"] == "removed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sprint_burndown(client):
|
||||||
|
"""Burndown chart data calculation."""
|
||||||
|
resp = client.post("/db/api", json={"name": "Burndown DB"})
|
||||||
|
coll_id = resp.json()["id"]
|
||||||
|
|
||||||
|
resp = client.post(f"/workspace/collections/{coll_id}/sprints", json={
|
||||||
|
"name": "Sprint B",
|
||||||
|
"start_date": "2026-07-01",
|
||||||
|
"end_date": "2026-07-14",
|
||||||
|
})
|
||||||
|
sid = resp.json()["id"]
|
||||||
|
|
||||||
|
# Add pages with different statuses
|
||||||
|
p1 = client.post(f"/db/{coll_id}/pages/api", json={
|
||||||
|
"title": "Done Task",
|
||||||
|
"properties": {"Status": "Done"},
|
||||||
|
})
|
||||||
|
p2 = client.post(f"/db/{coll_id}/pages/api", json={
|
||||||
|
"title": "In Progress Task",
|
||||||
|
"properties": {"Status": "In Progress"},
|
||||||
|
})
|
||||||
|
|
||||||
|
client.post(f"/workspace/collections/{coll_id}/sprints/{sid}/assign", json={
|
||||||
|
"page_id": p1.json()["id"], "velocity_points": 3,
|
||||||
|
})
|
||||||
|
client.post(f"/workspace/collections/{coll_id}/sprints/{sid}/assign", json={
|
||||||
|
"page_id": p2.json()["id"], "velocity_points": 5,
|
||||||
|
})
|
||||||
|
|
||||||
|
resp = client.get(f"/workspace/collections/{coll_id}/sprints/burndown/{sid}")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["total_points"] == 8
|
||||||
|
assert data["completed_points"] == 3
|
||||||
|
assert data["remaining_points"] == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_my_tasks_page_render(client):
|
||||||
|
"""My Tasks page renders cross-database aggregation."""
|
||||||
|
# Create a task collection
|
||||||
|
resp = client.post("/db/api", json={"name": "My Tasks DB"})
|
||||||
|
coll_id = resp.json()["id"]
|
||||||
|
|
||||||
|
# Toggle to task mode
|
||||||
|
client.put(f"/db/{coll_id}/toggle-task/api")
|
||||||
|
|
||||||
|
# Add pages
|
||||||
|
client.post(f"/db/{coll_id}/pages/api", json={
|
||||||
|
"title": "Urgent fix",
|
||||||
|
"properties": {"Status": "Todo"},
|
||||||
|
})
|
||||||
|
client.post(f"/db/{coll_id}/pages/api", json={
|
||||||
|
"title": "Deploy",
|
||||||
|
"properties": {"Status": "Done"},
|
||||||
|
})
|
||||||
|
|
||||||
|
resp = client.get("/my-tasks?view=all")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "Urgent fix" in resp.text
|
||||||
|
assert "My Tasks DB" in resp.text
|
||||||
Reference in New Issue
Block a user