diff --git a/app/db.py b/app/db.py index af5f629..3a683e2 100644 --- a/app/db.py +++ b/app/db.py @@ -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(): diff --git a/app/routers/collections.py b/app/routers/collections.py index b06cbc6..d1a51f1 100644 --- a/app/routers/collections.py +++ b/app/routers/collections.py @@ -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) ── diff --git a/tests/test_app.py b/tests/test_app.py index 1530cde..666ed7b 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -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 "" in resp.text \ No newline at end of file + assert "
" 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 \ No newline at end of file