feat(v1.3.0): Database Concept — collections, pages, views, GiteaBoardCompat
Database foundation: - New tables: collections, collection_pages, collection_views - Router /db with full CRUD API - GiteaBoardCompat adapter for legacy board compatibility - CSRF exemption for /db/ routes - 34/34 tests pass - Version bump 1.0.0 → 1.3.0 - Deployed and verified on Docker port 8080
This commit is contained in:
@@ -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
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+1
-1
@@ -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,
|
||||
}
|
||||
|
||||
+166
-131
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
+11
-2
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user