feat(v4.5.0): Sprints & My Tasks
- New tables: sprints + sprint_pages with velocity_points - API: CRUD sprints (list/create/update/delete) - API: assign/remove pages to sprints - API: burndown chart data (total/completed/remaining points, ideal line) - 4 new tests (175 total)
This commit is contained in:
@@ -536,6 +536,32 @@ def init_db():
|
||||
""")
|
||||
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
|
||||
def get_conn():
|
||||
|
||||
@@ -5,6 +5,7 @@ import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException
|
||||
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"}
|
||||
|
||||
|
||||
# ── 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 ──
|
||||
|
||||
@router.post("/collections/{collection_id}/import/csv")
|
||||
|
||||
+140
-2
@@ -812,7 +812,7 @@ def test_my_tasks_page(client):
|
||||
assert "My Tasks" in resp.text
|
||||
|
||||
|
||||
def test_my_tasks_api(client):
|
||||
def test_my_tasks_cross_db(client):
|
||||
"""My Tasks API returns JSON."""
|
||||
r = client.post("/db/api", json={"name": "My Project"})
|
||||
cid = r.json()["id"]
|
||||
@@ -2568,4 +2568,142 @@ def test_auto_shift_no_blockers(client):
|
||||
|
||||
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
|
||||
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