diff --git a/app/db.py b/app/db.py index 601baa8..af5f629 100644 --- a/app/db.py +++ b/app/db.py @@ -487,6 +487,34 @@ def init_db(): pass conn.commit() + # v4.2.0: Collection Templates (enhanced) + Dashboards + # Add description, is_recurring, recurrence_rule to page_templates + try: + conn.execute("ALTER TABLE page_templates ADD COLUMN description TEXT DEFAULT ''") + except sqlite3.OperationalError: + pass + try: + conn.execute("ALTER TABLE page_templates ADD COLUMN is_recurring BOOLEAN NOT NULL DEFAULT 0") + except sqlite3.OperationalError: + pass + try: + conn.execute("ALTER TABLE page_templates ADD COLUMN recurrence_rule TEXT DEFAULT ''") + except sqlite3.OperationalError: + pass + + # Dashboard: combinaison de vues/widgets sur une page + conn.execute(""" + CREATE TABLE IF NOT EXISTS collection_dashboards ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + name TEXT NOT NULL DEFAULT 'Dashboard', + layout_json TEXT NOT NULL DEFAULT '{"columns":1,"widgets":[]}', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + conn.commit() + @contextmanager def get_conn(): diff --git a/app/routers/workspace.py b/app/routers/workspace.py index df6cd5f..1d46d7e 100644 --- a/app/routers/workspace.py +++ b/app/routers/workspace.py @@ -296,6 +296,115 @@ async def apply_page_template(request: Request, collection_id: int, tid: int): return {"id": cur.lastrowid, "status": "created"} +@router.put("/collections/{collection_id}/templates/page/{tid}") +async def update_page_template(request: Request, collection_id: int, tid: int): + """Update a page template — name, properties, content, recurrence.""" + 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") + + name = body.get("name", tmpl["name"]) + tmpl_dict = dict(tmpl) + description = body.get("description", tmpl_dict.get("description", "")) + property_values_json = json.dumps(body.get("properties", json.loads(tmpl["property_values_json"]))) + content_json = json.dumps(body.get("content", json.loads(tmpl_dict.get("content_json", "[]")))) + is_recurring = int(body.get("is_recurring", tmpl_dict.get("is_recurring", 0))) + recurrence_rule = body.get("recurrence_rule", tmpl_dict.get("recurrence_rule", "")) + + conn.execute( + """UPDATE page_templates SET name=?, description=?, property_values_json=?, + content_json=?, is_recurring=?, recurrence_rule=? WHERE id=?""", + (name, description, property_values_json, content_json, is_recurring, recurrence_rule, tid), + ) + conn.commit() + return {"id": tid, "status": "updated"} + + +@router.delete("/collections/{collection_id}/templates/page/{tid}") +async def delete_page_template(request: Request, collection_id: int, tid: int): + """Delete a page template.""" + 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") + conn.execute("DELETE FROM page_templates WHERE id=?", (tid,)) + conn.commit() + return {"id": tid, "status": "deleted"} + + +# ── v4.2.0: Dashboards ── + +@router.get("/collections/{collection_id}/dashboards") +async def list_dashboards(request: Request, collection_id: int): + """List all dashboards for a collection.""" + with get_conn() as conn: + rows = conn.execute( + "SELECT * FROM collection_dashboards WHERE collection_id=? ORDER BY name", (collection_id,) + ).fetchall() + return {"dashboards": [dict(r) for r in rows]} + + +@router.post("/collections/{collection_id}/dashboards") +async def create_dashboard(request: Request, collection_id: int): + """Create a new dashboard for a collection.""" + body = await request.json() if request.headers.get("content-type") else {} + name = body.get("name", "Dashboard").strip() + layout = json.dumps(body.get("layout", {"columns": 1, "widgets": []})) + + with get_conn() as conn: + coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone() + if not coll: + raise HTTPException(404, "Collection not found") + cur = conn.execute( + "INSERT INTO collection_dashboards (collection_id, name, layout_json) VALUES (?,?,?)", + (collection_id, name, layout), + ) + conn.commit() + return {"id": cur.lastrowid, "name": name, "status": "created"} + + +@router.put("/collections/{collection_id}/dashboards/{did}") +async def update_dashboard(request: Request, collection_id: int, did: int): + """Update a dashboard — name or layout (widgets grid).""" + body = await request.json() if request.headers.get("content-type") else {} + with get_conn() as conn: + dash = conn.execute( + "SELECT * FROM collection_dashboards WHERE id=? AND collection_id=?", (did, collection_id) + ).fetchone() + if not dash: + raise HTTPException(404, "Dashboard not found") + + name = body.get("name", dash["name"]) + layout = json.dumps(body.get("layout", json.loads(dash["layout_json"]))) + + conn.execute( + "UPDATE collection_dashboards SET name=?, layout_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", + (name, layout, did), + ) + conn.commit() + return {"id": did, "status": "updated"} + + +@router.delete("/collections/{collection_id}/dashboards/{did}") +async def delete_dashboard(request: Request, collection_id: int, did: int): + """Delete a dashboard.""" + with get_conn() as conn: + dash = conn.execute( + "SELECT * FROM collection_dashboards WHERE id=? AND collection_id=?", (did, collection_id) + ).fetchone() + if not dash: + raise HTTPException(404, "Dashboard not found") + conn.execute("DELETE FROM collection_dashboards WHERE id=?", (did,)) + conn.commit() + return {"id": did, "status": "deleted"} + + # ── CSV Import/Export ── @router.post("/collections/{collection_id}/import/csv") diff --git a/tests/test_app.py b/tests/test_app.py index ee58721..463680e 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -2228,3 +2228,150 @@ def test_remove_data_source_not_found(client): resp = client.delete(f"/db/{coll_id}/sources/99999/api") assert resp.status_code == 404 + + +# ── v4.2.0: Templates & Dashboards ── + + +def test_page_template_update(client): + """Update a page template with recurrence settings.""" + resp = client.post("/db/api", json={"name": "Template Update DB"}) + coll_id = resp.json()["id"] + + resp = client.post(f"/workspace/collections/{coll_id}/templates/page", json={ + "name": "Weekly Report", + "properties": {"Status": "Todo", "Priority": "P1"}, + }) + assert resp.status_code == 200 + tid = resp.json()["id"] + + resp = client.put(f"/workspace/collections/{coll_id}/templates/page/{tid}", json={ + "name": "Weekly Report v2", + "description": "Updated weekly report template", + "is_recurring": True, + "recurrence_rule": "weekly", + "properties": {"Status": "In Progress", "Priority": "P2"}, + }) + assert resp.status_code == 200 + assert resp.json()["status"] == "updated" + + resp = client.get(f"/workspace/collections/{coll_id}/templates/page") + tmpls = resp.json()["templates"] + assert len(tmpls) == 1 + assert tmpls[0]["name"] == "Weekly Report v2" + assert tmpls[0]["is_recurring"] == 1 + assert tmpls[0]["recurrence_rule"] == "weekly" + + +def test_page_template_delete(client): + """Delete a page template.""" + resp = client.post("/db/api", json={"name": "Template Delete DB"}) + coll_id = resp.json()["id"] + + resp = client.post(f"/workspace/collections/{coll_id}/templates/page", json={ + "name": "To Delete", + }) + tid = resp.json()["id"] + + resp = client.delete(f"/workspace/collections/{coll_id}/templates/page/{tid}") + assert resp.status_code == 200 + assert resp.json()["status"] == "deleted" + + resp = client.get(f"/workspace/collections/{coll_id}/templates/page") + assert len(resp.json()["templates"]) == 0 + + +def test_page_template_apply_with_content(client): + """Apply a page template that has content_json.""" + resp = client.post("/db/api", json={"name": "Content Template DB"}) + coll_id = resp.json()["id"] + + resp = client.post(f"/workspace/collections/{coll_id}/templates/page", json={ + "name": "Meeting Notes", + "properties": {"Status": "Todo"}, + "content": [{"type": "heading", "text": "Meeting Notes"}], + }) + tid = resp.json()["id"] + + resp = client.post(f"/workspace/collections/{coll_id}/templates/page/{tid}/apply", json={ + "title": "Sprint Review 2026-07-21", + }) + assert resp.status_code == 200 + page_id = resp.json()["id"] + + resp = client.get(f"/db/pages/{page_id}/api") + assert resp.status_code == 200 + assert resp.json()["title"] == "Sprint Review 2026-07-21" + + +def test_dashboard_crud(client): + """Full CRUD lifecycle for dashboards.""" + resp = client.post("/db/api", json={"name": "Dashboard CRUD DB"}) + coll_id = resp.json()["id"] + + resp = client.post(f"/workspace/collections/{coll_id}/dashboards", json={ + "name": "Project Dashboard", + "layout": { + "columns": 2, + "widgets": [ + {"view_id": 1, "x": 0, "y": 0, "width": 1, "height": 1}, + {"view_id": 2, "x": 1, "y": 0, "width": 1, "height": 1}, + ], + }, + }) + assert resp.status_code == 200 + assert resp.json()["status"] == "created" + did = resp.json()["id"] + + resp = client.get(f"/workspace/collections/{coll_id}/dashboards") + dashboards = resp.json()["dashboards"] + assert len(dashboards) == 1 + assert dashboards[0]["name"] == "Project Dashboard" + + resp = client.put(f"/workspace/collections/{coll_id}/dashboards/{did}", json={ + "name": "Project Dashboard v2", + "layout": {"columns": 3, "widgets": []}, + }) + assert resp.status_code == 200 + assert resp.json()["status"] == "updated" + + resp = client.get(f"/workspace/collections/{coll_id}/dashboards") + assert resp.json()["dashboards"][0]["name"] == "Project Dashboard v2" + + resp = client.delete(f"/workspace/collections/{coll_id}/dashboards/{did}") + assert resp.status_code == 200 + assert resp.json()["status"] == "deleted" + + resp = client.get(f"/workspace/collections/{coll_id}/dashboards") + assert len(resp.json()["dashboards"]) == 0 + + +def test_template_recurrence_defaults(client): + """New templates default to non-recurring with empty recurrence_rule.""" + resp = client.post("/db/api", json={"name": "Recur Defaults DB"}) + coll_id = resp.json()["id"] + + resp = client.post(f"/workspace/collections/{coll_id}/templates/page", json={ + "name": "Default Template", + }) + resp = client.get(f"/workspace/collections/{coll_id}/templates/page") + tmpl = resp.json()["templates"][0] + assert tmpl["is_recurring"] == 0 + assert tmpl["recurrence_rule"] == "" + + +def test_dashboard_create_default_layout(client): + """Dashboard creation with no layout parameter gets default grid.""" + resp = client.post("/db/api", json={"name": "Default Layout DB"}) + coll_id = resp.json()["id"] + + resp = client.post(f"/workspace/collections/{coll_id}/dashboards", json={ + "name": "Auto Layout", + }) + assert resp.status_code == 200 + + resp = client.get(f"/workspace/collections/{coll_id}/dashboards") + dash = resp.json()["dashboards"][0] + layout = json.loads(dash["layout_json"]) + assert layout["columns"] == 1 + assert layout["widgets"] == []