diff --git a/CHANGELOG.md b/CHANGELOG.md index 39ad523..b183ab0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog — FlowDeck +## v1.3.0 (2026-07-10) — Database Concept + +### Added +- **Nouvelles tables**: `collections`, `collection_pages`, `collection_views` — fondation Database Notion +- **Router `/db`**: API CRUD complète pour collections et pages + - `GET/POST /db/api` — lister/créer collections + - `GET/PUT/DELETE /db/api/{id}` — lire/modifier/supprimer collection + - `GET/POST /db/{id}/pages/api` — lister/créer pages + - `GET/PUT/DELETE /db/pages/{id}/api` — CRUD page standalone + - `GET /db/{id}` — vue HTML basique par collection +- **GiteaBoardCompat**: adaptateur boards Gitea legacy → Collections + - `GET /db/boards/api` — liste boards comme pseudo-collections + - `GET /db/board/{owner}/{repo}/api` — board spécifique comme collection + - `POST /db/board/{owner}/{repo}/sync` — sync board → vraie collection +- **Tests**: 6 nouveaux tests (34/34 passent, +21%) +- **CSRF**: `/db/` exempté (API interne) + +### Changed +- Version 1.0.0 → 1.3.0 (main.py, api.py, health) +- `db.py` init: 3 nouvelles tables + index +- Architecture DB: `collection_pages` utilise `property_values_json` (JSON flexible) + ## v1.0.0 (2026-07-08) — Production ### Added diff --git a/app/main.py b/app/main.py index c785a1a..a3c9e57 100644 --- a/app/main.py +++ b/app/main.py @@ -31,13 +31,13 @@ async def lifespan(_app: FastAPI): "INSERT OR IGNORE INTO users (login, full_name, email, is_admin) VALUES ('admin', 'Admin', '', 1)" ) conn.commit() - logger.info("FlowDeck v1.0.0 started on port %d", settings.app_port) + logger.info("FlowDeck v1.3.0 started on port %d", settings.app_port) yield app = FastAPI( title="FlowDeck", - version="1.0.0", + version="1.3.0", docs_url="/docs" if settings.log_level == "DEBUG" else None, redoc_url=None, lifespan=lifespan, diff --git a/app/routers/api.py b/app/routers/api.py index a411515..ccde543 100644 --- a/app/routers/api.py +++ b/app/routers/api.py @@ -57,7 +57,7 @@ async def health(): return { "status": "ok" if (db_ok and gitea_ok) else "degraded", - "version": "1.0.0", + "version": "1.3.0", "db": db_ok, "gitea": gitea_ok, } diff --git a/app/routers/collections.py b/app/routers/collections.py index e5f9381..c3daf1b 100644 --- a/app/routers/collections.py +++ b/app/routers/collections.py @@ -5,7 +5,7 @@ import json import logging from fastapi import APIRouter, Request, HTTPException, Query -from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.responses import HTMLResponse from app.db import get_conn from app.auth.session import SessionManager @@ -14,15 +14,7 @@ logger = logging.getLogger(__name__) router = APIRouter(tags=["collections"], prefix="/db") -def _get_workspace(request: Request) -> str: - """Returns the current workspace name from session.""" - user = SessionManager.decode_session( - request.cookies.get("flowdeck_session", "") - ) - return user.get("login", "Default") if user else "Default" - - -# ── Collection CRUD ── +# ── API: List & Create (no path params) ── @router.get("", response_class=HTMLResponse) @@ -79,7 +71,6 @@ async def create_collection_api(request: Request): ) collection_id = cur.lastrowid - # Create a default table view conn.execute( """INSERT INTO collection_views (collection_id, name, view_type, config_json) @@ -95,6 +86,170 @@ async def create_collection_api(request: Request): return {"id": collection_id, "name": name, "status": "created"} +# ── API: Update & Delete (no path-param conflicts) ── + + +@router.put("/api/{collection_id}") +async def update_collection_api(request: Request, collection_id: int): + """API: update a collection.""" + try: + body = await request.json() + except Exception: + body = {} + + with get_conn() as conn: + existing = conn.execute( + "SELECT * FROM collections WHERE id=?", (collection_id,) + ).fetchone() + if not existing: + raise HTTPException(status_code=404, detail="Collection not found") + + name = body.get("name", existing["name"]) + description = body.get("description", existing["description"]) + icon = body.get("icon", existing["icon"]) + is_locked = body.get("is_locked", existing["is_locked"]) + schema_json = json.dumps(body.get("schema", json.loads(existing["schema_json"]))) + gitea_owner = body.get("gitea_owner", existing["gitea_owner"]) + gitea_repo = body.get("gitea_repo", existing["gitea_repo"]) + + conn.execute( + """UPDATE collections SET name=?, description=?, icon=?, schema_json=?, + is_locked=?, gitea_owner=?, gitea_repo=?, updated_at=CURRENT_TIMESTAMP + WHERE id=?""", + (name, description, icon, schema_json, int(is_locked), + gitea_owner, gitea_repo, collection_id), + ) + conn.commit() + + return {"id": collection_id, "status": "updated"} + + +@router.delete("/api/{collection_id}") +async def delete_collection_api(request: Request, collection_id: int): + """API: delete a collection and its pages (CASCADE).""" + with get_conn() as conn: + existing = conn.execute( + "SELECT * FROM collections WHERE id=?", (collection_id,) + ).fetchone() + if not existing: + raise HTTPException(status_code=404, detail="Collection not found") + + conn.execute("DELETE FROM collections WHERE id=?", (collection_id,)) + conn.commit() + + return {"id": collection_id, "status": "deleted"} + + +# ── Page CRUD (standalone, BEFORE collection wildcards) ── + + +@router.get("/pages/{page_id}/api") +async def get_page_api(request: Request, page_id: int): + """API: get a single page.""" + with get_conn() as conn: + page = conn.execute( + "SELECT * FROM collection_pages WHERE id=?", (page_id,) + ).fetchone() + if not page: + raise HTTPException(status_code=404, detail="Page not found") + + return dict(page) + + +@router.put("/pages/{page_id}/api") +async def update_page_api(request: Request, page_id: int): + """API: update a page's properties.""" + try: + body = await request.json() + except Exception: + body = {} + + with get_conn() as conn: + existing = conn.execute( + "SELECT * FROM collection_pages WHERE id=?", (page_id,) + ).fetchone() + if not existing: + raise HTTPException(status_code=404, detail="Page not found") + + title = body.get("title", existing["title"]) + icon = body.get("icon", existing["icon"]) + position = body.get("position", existing["position"]) + parent_id = body.get("parent_id", existing["parent_id"]) + property_values = json.dumps( + body.get("properties", json.loads(existing["property_values_json"])) + ) + + conn.execute( + """UPDATE collection_pages + SET title=?, icon=?, position=?, parent_id=?, property_values_json=?, + updated_at=CURRENT_TIMESTAMP + WHERE id=?""", + (title, icon, position, parent_id, property_values, page_id), + ) + conn.commit() + + return {"id": page_id, "status": "updated"} + + +@router.delete("/pages/{page_id}/api") +async def delete_page_api(request: Request, page_id: int): + """API: delete a page from its collection.""" + with get_conn() as conn: + existing = conn.execute( + "SELECT * FROM collection_pages WHERE id=?", (page_id,) + ).fetchone() + if not existing: + raise HTTPException(status_code=404, detail="Page not found") + + conn.execute("DELETE FROM collection_pages WHERE id=?", (page_id,)) + conn.commit() + + return {"id": page_id, "status": "deleted"} + + +# ── Gitea Board Compatibility (BEFORE {collection_id} wildcards) ── + + +@router.get("/boards/api") +async def list_boards_as_collections(request: Request): + """API: list all Gitea boards as pseudo-collections.""" + from app.services.collection_adapter import GiteaBoardCompat + boards = GiteaBoardCompat.list_boards_as_collections() + return {"boards": boards} + + +@router.get("/board/{owner}/{repo}/api") +async def get_board_as_collection(request: Request, owner: str, repo: str): + """API: get a specific Gitea board as a pseudo-collection.""" + from app.services.collection_adapter import GiteaBoardCompat + coll = GiteaBoardCompat.get_board_as_collection(owner, repo) + if not coll: + raise HTTPException(status_code=404, detail="Board not found") + + from app.services.gitea_client import gitea + issues = await gitea.get_issues(owner, repo, state="all") + cards = GiteaBoardCompat.get_board_cards(owner, repo, issues) + + return {"collection": coll, "pages": cards} + + +@router.post("/board/{owner}/{repo}/sync") +async def sync_board_to_collection(request: Request, owner: str, repo: str): + """Sync a Gitea board to a real collection.""" + from app.services.collection_adapter import GiteaBoardCompat + from app.services.gitea_client import gitea + + issues = await gitea.get_issues(owner, repo, state="all") + coll_id = GiteaBoardCompat.sync_to_collection(owner, repo, issues) + if coll_id is None: + raise HTTPException(status_code=404, detail="Board not found") + + return {"collection_id": coll_id, "status": "synced"} + + +# ── {collection_id} wildcards (LAST — catches everything else) ── + + @router.get("/{collection_id}", response_class=HTMLResponse) async def view_collection(request: Request, collection_id: int): """Main view of a collection (default view).""" @@ -158,60 +313,6 @@ async def get_collection_api(request: Request, collection_id: int): } -@router.put("/api/{collection_id}") -async def update_collection_api(request: Request, collection_id: int): - """API: update a collection.""" - try: - body = await request.json() - except Exception: - body = {} - - with get_conn() as conn: - existing = conn.execute( - "SELECT * FROM collections WHERE id=?", (collection_id,) - ).fetchone() - if not existing: - raise HTTPException(status_code=404, detail="Collection not found") - - name = body.get("name", existing["name"]) - description = body.get("description", existing["description"]) - icon = body.get("icon", existing["icon"]) - is_locked = body.get("is_locked", existing["is_locked"]) - schema_json = json.dumps(body.get("schema", json.loads(existing["schema_json"]))) - gitea_owner = body.get("gitea_owner", existing["gitea_owner"]) - gitea_repo = body.get("gitea_repo", existing["gitea_repo"]) - - conn.execute( - """UPDATE collections SET name=?, description=?, icon=?, schema_json=?, - is_locked=?, gitea_owner=?, gitea_repo=?, updated_at=CURRENT_TIMESTAMP - WHERE id=?""", - (name, description, icon, schema_json, int(is_locked), - gitea_owner, gitea_repo, collection_id), - ) - conn.commit() - - return {"id": collection_id, "status": "updated"} - - -@router.delete("/api/{collection_id}") -async def delete_collection_api(request: Request, collection_id: int): - """API: delete a collection and its pages (CASCADE).""" - with get_conn() as conn: - existing = conn.execute( - "SELECT * FROM collections WHERE id=?", (collection_id,) - ).fetchone() - if not existing: - raise HTTPException(status_code=404, detail="Collection not found") - - conn.execute("DELETE FROM collections WHERE id=?", (collection_id,)) - conn.commit() - - return {"id": collection_id, "status": "deleted"} - - -# ── Collection Pages CRUD ── - - @router.post("/{collection_id}/pages/api") async def create_page_api(request: Request, collection_id: int): """API: create a page in a collection.""" @@ -230,12 +331,10 @@ async def create_page_api(request: Request, collection_id: int): gitea_issue_number = body.get("gitea_issue_number") with get_conn() as conn: - # Verify collection exists coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone() if not coll: raise HTTPException(status_code=404, detail="Collection not found") - # Get next position max_pos = conn.execute( "SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?", (collection_id,), @@ -251,67 +350,3 @@ async def create_page_api(request: Request, collection_id: int): page_id = cur.lastrowid return {"id": page_id, "title": title, "status": "created"} - - -@router.get("/pages/{page_id}/api") -async def get_page_api(request: Request, page_id: int): - """API: get a single page.""" - with get_conn() as conn: - page = conn.execute( - "SELECT * FROM collection_pages WHERE id=?", (page_id,) - ).fetchone() - if not page: - raise HTTPException(status_code=404, detail="Page not found") - - return dict(page) - - -@router.put("/pages/{page_id}/api") -async def update_page_api(request: Request, page_id: int): - """API: update a page's properties.""" - try: - body = await request.json() - except Exception: - body = {} - - with get_conn() as conn: - existing = conn.execute( - "SELECT * FROM collection_pages WHERE id=?", (page_id,) - ).fetchone() - if not existing: - raise HTTPException(status_code=404, detail="Page not found") - - title = body.get("title", existing["title"]) - icon = body.get("icon", existing["icon"]) - position = body.get("position", existing["position"]) - parent_id = body.get("parent_id", existing["parent_id"]) - property_values = json.dumps( - body.get("properties", json.loads(existing["property_values_json"])) - ) - - conn.execute( - """UPDATE collection_pages - SET title=?, icon=?, position=?, parent_id=?, property_values_json=?, - updated_at=CURRENT_TIMESTAMP - WHERE id=?""", - (title, icon, position, parent_id, property_values, page_id), - ) - conn.commit() - - return {"id": page_id, "status": "updated"} - - -@router.delete("/pages/{page_id}/api") -async def delete_page_api(request: Request, page_id: int): - """API: delete a page from its collection.""" - with get_conn() as conn: - existing = conn.execute( - "SELECT * FROM collection_pages WHERE id=?", (page_id,) - ).fetchone() - if not existing: - raise HTTPException(status_code=404, detail="Page not found") - - conn.execute("DELETE FROM collection_pages WHERE id=?", (page_id,)) - conn.commit() - - return {"id": page_id, "status": "deleted"} diff --git a/app/services/collection_adapter.py b/app/services/collection_adapter.py new file mode 100644 index 0000000..1182f95 --- /dev/null +++ b/app/services/collection_adapter.py @@ -0,0 +1,222 @@ +"""FlowDeck — GiteaBoardCompat: legacy board → Collection adapter (v1.3.0). + +Converts existing Gitea-linked boards and cards into the new collection model +without breaking the existing board routes. +""" +from __future__ import annotations + +import json +from typing import Optional + +from app.db import get_conn + + +class GiteaBoardCompat: + """Adaptateur: board Gitea legacy → Collection. + + Permet de visualiser les boards Gitea existants comme des collections, + avec rétrocompatibilité totale. Les anciennes routes /board/... continuent + de fonctionner normalement. + """ + + @staticmethod + def from_board(board_row) -> dict: + """Convertit un board existant en pseudo-collection.""" + columns = json.loads(board_row.get("columns_json", "[]")) + return { + "collection_id": board_row["id"], + "name": f"{board_row['project_owner']}/{board_row['project_name']}", + "description": f"Board Gitea: {board_row['project_name']}", + "icon": "📋", + "gitea_owner": board_row["project_owner"], + "gitea_repo": board_row["project_name"], + "is_gitea_linked": True, + "is_locked": False, + "schema": [ + {"name": "Title", "type": "title"}, + {"name": "Status", "type": "select", "options": [ + {"name": c, "color": "gray"} for c in columns + ]}, + {"name": "Priority", "type": "select", "options": [ + {"name": "P1", "color": "red"}, + {"name": "P2", "color": "orange"}, + {"name": "P3", "color": "yellow"}, + {"name": "P4", "color": "gray"}, + ]}, + {"name": "Due Date", "type": "date"}, + {"name": "Assignee", "type": "person"}, + ], + } + + @staticmethod + def from_card(card_row, gitea_issue: Optional[dict] = None) -> dict: + """Convertit une card legacy en pseudo collection_page.""" + title = gitea_issue.get("title", f"Card #{card_row['id']}") if gitea_issue else f"Card #{card_row['id']}" + priority = card_row.get("priority", "Medium") + due_date = card_row.get("due_date", "") + + props = { + "Status": card_row.get("column_name", "Backlog"), + "Priority": priority, + "Due Date": due_date if due_date else None, + } + + if gitea_issue: + assignee = gitea_issue.get("assignee") + if assignee: + props["Assignee"] = [{"login": assignee.get("login", ""), "avatar_url": assignee.get("avatar_url", "")}] + + return { + "id": card_row["id"], + "title": title, + "position": card_row.get("position", 0), + "gitea_issue_id": card_row.get("gitea_issue_id"), + "gitea_issue_number": gitea_issue.get("number") if gitea_issue else None, + "property_values_json": json.dumps(props), + } + + @staticmethod + def list_boards_as_collections() -> list[dict]: + """Liste tous les boards comme des collections compatibles.""" + with get_conn() as conn: + rows = conn.execute("SELECT * FROM boards ORDER BY project_owner, project_name").fetchall() + return [GiteaBoardCompat.from_board(dict(r)) for r in rows] + + @staticmethod + def get_board_as_collection(owner: str, repo: str) -> Optional[dict]: + """Récupère un board spécifique comme collection.""" + with get_conn() as conn: + row = conn.execute( + "SELECT * FROM boards WHERE project_owner=? AND project_name=?", + (owner, repo), + ).fetchone() + if not row: + return None + return GiteaBoardCompat.from_board(dict(row)) + + @staticmethod + def get_board_cards(owner: str, repo: str, gitea_issues: Optional[list[dict]] = None) -> list[dict]: + """Récupère les cartes d'un board comme collection_pages.""" + with get_conn() as conn: + board = conn.execute( + "SELECT id FROM boards WHERE project_owner=? AND project_name=?", + (owner, repo), + ).fetchone() + if not board: + return [] + + cards = conn.execute( + "SELECT * FROM cards WHERE board_id=? ORDER BY position", + (board["id"],), + ).fetchall() + + issues_by_id = {} + if gitea_issues: + issues_by_id = {i.get("id"): i for i in gitea_issues} + + return [ + GiteaBoardCompat.from_card(dict(c), issues_by_id.get(c["gitea_issue_id"])) + for c in cards + ] + + @staticmethod + def sync_to_collection(owner: str, repo: str, gitea_issues: list[dict]) -> Optional[int]: + """Sync un board Gitea vers une vraie collection. + + Crée ou met à jour une collection liée à Gitea et importe les pages. + Retourne l'ID de la collection. + """ + with get_conn() as conn: + board = conn.execute( + "SELECT * FROM boards WHERE project_owner=? AND project_name=?", + (owner, repo), + ).fetchone() + if not board: + return None + + board_dict = dict(board) + columns = json.loads(board_dict.get("columns_json", "[]")) + + # Upsert collection + existing = conn.execute( + "SELECT id FROM collections WHERE gitea_owner=? AND gitea_repo=?", + (owner, repo), + ).fetchone() + + if existing: + coll_id = existing["id"] + conn.execute( + "UPDATE collections SET name=?, schema_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", + (f"{owner}/{repo}", json.dumps([ + {"name": "Title", "type": "title"}, + {"name": "Status", "type": "select", + "options": [{"name": c, "color": "gray"} for c in columns]}, + {"name": "Priority", "type": "select", + "options": [{"name": "P1", "color": "red"}, {"name": "P2", "color": "orange"}, + {"name": "P3", "color": "yellow"}, {"name": "P4", "color": "gray"}]}, + ]), coll_id), + ) + else: + cur = conn.execute( + """INSERT INTO collections (name, icon, schema_json, gitea_owner, gitea_repo) + VALUES (?, '📋', ?, ?, ?)""", + (f"{owner}/{repo}", json.dumps([ + {"name": "Title", "type": "title"}, + {"name": "Status", "type": "select", + "options": [{"name": c, "color": "gray"} for c in columns]}, + {"name": "Priority", "type": "select", + "options": [{"name": "P1", "color": "red"}, {"name": "P2", "color": "orange"}, + {"name": "P3", "color": "yellow"}, {"name": "P4", "color": "gray"}]}, + ]), owner, repo), + ) + coll_id = cur.lastrowid + + # Create default view + conn.execute( + "INSERT INTO collection_views (collection_id, name, view_type, config_json) VALUES (?, ?, ?, ?)", + (coll_id, "Default View", "table", json.dumps({"visible_properties": ["Title", "Status", "Priority"]})), + ) + + # Sync cards → collection_pages + cards = conn.execute( + "SELECT * FROM cards WHERE board_id=? ORDER BY position", + (board_dict["id"],), + ).fetchall() + + issues_by_id = {i.get("id"): i for i in gitea_issues} + + for card in cards: + card_dict = dict(card) + issue = issues_by_id.get(card_dict.get("gitea_issue_id")) + title = issue.get("title", f"Issue #{card_dict.get('gitea_issue_id', card_dict['id'])}") if issue else f"Card #{card_dict['id']}" + + props = { + "Status": card_dict.get("column_name", "Backlog"), + "Priority": card_dict.get("priority", "Medium"), + } + + # Upsert collection_page + existing_page = conn.execute( + "SELECT id FROM collection_pages WHERE collection_id=? AND gitea_issue_id=?", + (coll_id, card_dict.get("gitea_issue_id")), + ).fetchone() + + if existing_page: + conn.execute( + """UPDATE collection_pages SET title=?, position=?, property_values_json=?, + updated_at=CURRENT_TIMESTAMP WHERE id=?""", + (title, card_dict.get("position", 0), json.dumps(props), existing_page["id"]), + ) + else: + conn.execute( + """INSERT INTO collection_pages + (collection_id, title, position, gitea_issue_id, gitea_issue_number, + property_values_json) + VALUES (?, ?, ?, ?, ?, ?)""", + (coll_id, title, card_dict.get("position", 0), + card_dict.get("gitea_issue_id"), issue.get("number") if issue else None, + json.dumps(props)), + ) + + conn.commit() + return coll_id diff --git a/tests/test_app.py b/tests/test_app.py index dff623b..7bb6986 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -34,7 +34,7 @@ def test_health(client): data = resp.json() assert "status" in data assert data["db"] is True - assert data["version"] == "1.0.0" + assert data["version"] == "1.3.0" def test_dashboard(client): @@ -191,7 +191,7 @@ def test_csrf_protects_sync(client): def test_version_in_health(client): resp = client.get("/api/health") - assert resp.json()["version"] == "1.0.0" + assert resp.json()["version"] == "1.3.0" def test_db_tables_exist(client): @@ -347,3 +347,12 @@ def test_collections_db_page_renders(client): # Cleanup client.delete(f"/db/api/{coll_id}") + + +def test_boards_as_collections(client): + """GET /db/boards/api — should list boards as pseudo-collections.""" + resp = client.get("/db/boards/api") + assert resp.status_code == 200 + data = resp.json() + assert "boards" in data + assert isinstance(data["boards"], list)