Update project files

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
ANDREW HOSFORD
2026-04-07 11:59:48 -05:00
parent dd227be9b0
commit 0e8978f48c
50 changed files with 5878 additions and 241 deletions

View File

@@ -0,0 +1,135 @@
"""Database switching endpoints — list, switch, create, upload, and download."""
import shutil
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from fastapi.responses import FileResponse
from pydantic import BaseModel
from ..auth import verify_token
from .. import database as db_module
router = APIRouter(
prefix="/api/databases",
tags=["databases"],
dependencies=[Depends(verify_token)],
)
class DatabaseInfo(BaseModel):
name: str
path: str
size_bytes: int
active: bool
class SwitchRequest(BaseModel):
path: str
class CreateRequest(BaseModel):
name: str
class CopyRequest(BaseModel):
name: str
def _db_info(path: Path, active: bool) -> DatabaseInfo:
return DatabaseInfo(
name=path.stem,
path=str(path),
size_bytes=path.stat().st_size,
active=active,
)
@router.get("", response_model=list[DatabaseInfo])
def list_databases():
"""List all .db files in the data directory."""
current = Path(db_module.DB_PATH).resolve()
dbs = []
for f in sorted(db_module.DATA_DIR.glob("*.db")):
dbs.append(_db_info(f, f.resolve() == current))
return dbs
@router.post("/switch", response_model=DatabaseInfo)
def switch_db(req: SwitchRequest):
"""Switch to a different database file. Path must be an existing .db file."""
db_path = Path(req.path)
if not db_path.exists():
raise HTTPException(404, f"Database not found: {req.path}")
if db_path.suffix != ".db":
raise HTTPException(400, "Path must point to a .db file")
db_module.switch_database(db_path)
return _db_info(db_path, True)
@router.post("/create", response_model=DatabaseInfo)
def create_db(req: CreateRequest):
"""Create a new empty database and switch to it."""
name = req.name.strip()
if not name:
raise HTTPException(400, "Name is required")
if not name.endswith(".db"):
name += ".db"
db_path = db_module.DATA_DIR / name
if db_path.exists():
raise HTTPException(409, f"Database already exists: {name}")
db_module.switch_database(db_path)
return _db_info(db_path, True)
@router.post("/copy", response_model=DatabaseInfo)
def copy_current_db(req: CopyRequest):
"""Copy the current active database to a new file (snapshot/backup)."""
name = req.name.strip()
if not name:
raise HTTPException(400, "Name is required")
if not name.endswith(".db"):
name += ".db"
dest = db_module.DATA_DIR / name
if dest.exists():
raise HTTPException(409, f"Database already exists: {name}")
shutil.copy2(db_module.DB_PATH, dest)
return _db_info(dest, False)
@router.get("/active", response_model=DatabaseInfo)
def get_active():
"""Return info about the currently active database."""
return _db_info(Path(db_module.DB_PATH), True)
@router.post("/upload", response_model=DatabaseInfo)
def upload_db(file: UploadFile = File(...), switch: bool = True):
"""Upload a .db file from the client and optionally switch to it."""
if not file.filename or not file.filename.endswith(".db"):
raise HTTPException(400, "File must be a .db file")
# Sanitize filename
safe_name = Path(file.filename).name
dest = db_module.DATA_DIR / safe_name
# If it already exists, add a suffix
counter = 1
while dest.exists():
dest = db_module.DATA_DIR / f"{Path(safe_name).stem}_{counter}.db"
counter += 1
with open(dest, "wb") as f:
shutil.copyfileobj(file.file, f)
if switch:
db_module.switch_database(dest)
return _db_info(dest, switch)
@router.get("/download")
def download_db():
"""Download the currently active database file."""
p = Path(db_module.DB_PATH)
if not p.exists():
raise HTTPException(404, "Active database not found")
return FileResponse(
path=str(p),
filename=p.name,
media_type="application/octet-stream",
)

View File

@@ -29,14 +29,52 @@ def _to_detail_response(layout: GraphLayout) -> GraphLayoutDetailResponse:
)
AUTOSAVE_NAME = "__autosave__"
@router.get("", response_model=list[GraphLayoutResponse])
def list_layouts(project_id: int | None = None, db: Session = Depends(get_db)):
query = db.query(GraphLayout)
query = db.query(GraphLayout).filter(GraphLayout.name != AUTOSAVE_NAME)
if project_id is not None:
query = query.filter(GraphLayout.project_id == project_id)
return [_to_response(l) for l in query.order_by(GraphLayout.updated_at.desc()).all()]
@router.get("/autosave", response_model=GraphLayoutDetailResponse)
def get_autosave(db: Session = Depends(get_db)):
layout = db.query(GraphLayout).filter(GraphLayout.name == AUTOSAVE_NAME).first()
if not layout:
raise HTTPException(404, "No autosave layout found")
return _to_detail_response(layout)
@router.put("/autosave", response_model=GraphLayoutDetailResponse)
def upsert_autosave(data: GraphLayoutUpdate, db: Session = Depends(get_db)):
layout = db.query(GraphLayout).filter(GraphLayout.name == AUTOSAVE_NAME).first()
update_data = data.model_dump(exclude_unset=True)
if "node_positions" in update_data:
update_data["node_positions"] = json.dumps(update_data["node_positions"])
if "viewport" in update_data:
update_data["viewport"] = json.dumps(update_data["viewport"])
if layout:
for key, value in update_data.items():
setattr(layout, key, value)
else:
layout = GraphLayout(
name=AUTOSAVE_NAME,
description="Auto-saved working state",
node_positions=update_data.get("node_positions", "{}"),
viewport=update_data.get("viewport", "{}"),
is_default=False,
)
db.add(layout)
db.commit()
db.refresh(layout)
return _to_detail_response(layout)
@router.get("/{layout_id}", response_model=GraphLayoutDetailResponse)
def get_layout(layout_id: int, db: Session = Depends(get_db)):
layout = db.get(GraphLayout, layout_id)

View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Body, Depends, HTTPException
from sqlalchemy import func
from sqlalchemy.orm import Session
@@ -54,11 +54,21 @@ def _next_version(db: Session, req_id: str) -> int:
return (max_ver or 0) + 1
def _auto_summary(update_data: dict) -> str:
fields = [k for k in update_data if k in _SNAPSHOT_FIELDS]
if not fields:
return "Update"
return "Updated " + ", ".join(fields)
def _auto_summary(update_data: dict, node: RequirementNode) -> str:
changed = []
for k in update_data:
if k not in _SNAPSHOT_FIELDS:
continue
old_val = getattr(node, k, None)
new_val = update_data[k]
# Normalize enums for comparison
if hasattr(old_val, "value"):
old_val = old_val.value
if old_val != new_val:
changed.append(k)
if not changed:
return "No changes"
return "Updated " + ", ".join(changed)
def _to_response(node: RequirementNode, include_children: bool = False) -> RequirementResponse:
@@ -150,6 +160,8 @@ def update_requirement(req_id: str, data: RequirementUpdate, db: Session = Depen
raise HTTPException(404, f"Requirement {req_id} not found")
update_data = data.model_dump(exclude_unset=True)
summary = _auto_summary(update_data, node)
for key, value in update_data.items():
setattr(node, key, value)
@@ -158,7 +170,7 @@ def update_requirement(req_id: str, data: RequirementUpdate, db: Session = Depen
node.traceable = quality.traceable
version = _next_version(db, req_id)
_create_history_entry(db, node, version, _auto_summary(update_data))
_create_history_entry(db, node, version, summary)
db.commit()
db.refresh(node)
@@ -166,10 +178,53 @@ def update_requirement(req_id: str, data: RequirementUpdate, db: Session = Depen
@router.delete("/{req_id}", status_code=204)
def delete_requirement(req_id: str, db: Session = Depends(get_db)):
def delete_requirement(
req_id: str,
db: Session = Depends(get_db),
delete_ids: list[str] = Body(default=[]),
):
node = db.get(RequirementNode, req_id)
if not node:
raise HTTPException(404, f"Requirement {req_id} not found")
# Recursively collect all descendants of the IDs marked for deletion
ids_to_delete: set[str] = set(delete_ids)
if ids_to_delete:
queue = list(ids_to_delete)
while queue:
nid = queue.pop()
child_node = db.get(RequirementNode, nid)
if child_node:
for c in child_node.children:
if c.id not in ids_to_delete:
ids_to_delete.add(c.id)
queue.append(c.id)
# Orphan direct children not in the delete set
for child in list(node.children):
if child.id in ids_to_delete:
continue
child.parent_id = None
quality = validate_requirement(child, child.children)
child.traceable = quality.traceable
# Flush so the parent_id = NULL changes are persisted before we delete the parent
db.flush()
# Delete the marked descendants
for did in ids_to_delete:
desc = db.get(RequirementNode, did)
if desc:
# Orphan any grandchildren of a deleted node that aren't themselves being deleted
for grandchild in list(desc.children):
if grandchild.id not in ids_to_delete:
grandchild.parent_id = None
quality = validate_requirement(grandchild, grandchild.children)
grandchild.traceable = quality.traceable
db.flush()
db.delete(desc)
db.flush()
db.delete(node)
db.commit()
@@ -197,7 +252,7 @@ def get_children(req_id: str, db: Session = Depends(get_db)):
node = db.get(RequirementNode, req_id)
if not node:
raise HTTPException(404, f"Requirement {req_id} not found")
return [_to_response(c) for c in node.children]
return [_to_response(c, include_children=True) for c in node.children]
# ─── History Endpoints ────────────────────────────────────────────────────

View File

@@ -25,6 +25,7 @@ def get_stats(project_id: int | None = None, db: Session = Depends(get_db)):
quality_passing = 0
leaves_with_tests = 0
total_leaves = 0
orphaned = 0
for n in nodes:
by_type[n.node_type.value] = by_type.get(n.node_type.value, 0) + 1
@@ -39,6 +40,9 @@ def get_stats(project_id: int | None = None, db: Session = Depends(get_db)):
if n.test_spec:
leaves_with_tests += 1
if n.node_type != NodeType.pillar and not n.parent_id:
orphaned += 1
coverage = (leaves_with_tests / total_leaves * 100) if total_leaves > 0 else 0.0
return StatsResponse(
@@ -47,6 +51,7 @@ def get_stats(project_id: int | None = None, db: Session = Depends(get_db)):
requirements=by_type.get("requirement", 0),
sub_requirements=by_type.get("sub_requirement", 0),
leaves=by_type.get("leaf", 0),
orphaned=orphaned,
by_status=by_status,
by_priority=by_priority,
quality_passing=quality_passing,