Update project files
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -20,3 +20,14 @@ def get_db():
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def switch_database(db_path: Path):
|
||||
"""Hot-swap the active database engine and session factory."""
|
||||
global engine, SessionLocal, DB_PATH
|
||||
DB_PATH = db_path
|
||||
engine.dispose()
|
||||
engine = create_engine(f"sqlite:///{db_path}", echo=False)
|
||||
SessionLocal = sessionmaker(bind=engine, class_=Session, expire_on_commit=False)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
return engine
|
||||
|
||||
@@ -3,7 +3,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from .auth import router as auth_router
|
||||
from .database import Base, engine
|
||||
from .routers import layouts, links, projects, requirements, stats
|
||||
from .routers import databases, layouts, links, projects, requirements, stats
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
@@ -27,6 +27,7 @@ app.include_router(links.router)
|
||||
app.include_router(projects.router)
|
||||
app.include_router(stats.router)
|
||||
app.include_router(layouts.router)
|
||||
app.include_router(databases.router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
|
||||
@@ -58,7 +58,7 @@ class RequirementNode(Base):
|
||||
status: Mapped[Status] = mapped_column(Enum(Status), default=Status.draft)
|
||||
node_type: Mapped[NodeType] = mapped_column(Enum(NodeType), default=NodeType.requirement)
|
||||
parent_id: Mapped[str | None] = mapped_column(
|
||||
String(64), ForeignKey("requirement_nodes.id", ondelete="CASCADE"), nullable=True
|
||||
String(64), ForeignKey("requirement_nodes.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
project_id: Mapped[int | None] = mapped_column(
|
||||
@@ -99,7 +99,7 @@ class RequirementNode(Base):
|
||||
"RequirementNode", remote_side="RequirementNode.id", back_populates="children"
|
||||
)
|
||||
children: Mapped[list["RequirementNode"]] = relationship(
|
||||
"RequirementNode", back_populates="parent", cascade="all, delete-orphan",
|
||||
"RequirementNode", back_populates="parent",
|
||||
order_by="RequirementNode.sort_order"
|
||||
)
|
||||
project: Mapped["Project | None"] = relationship("Project", back_populates="requirements")
|
||||
|
||||
@@ -18,7 +18,7 @@ def validate_requirement(node: RequirementNode, children: list[RequirementNode])
|
||||
traceable = True
|
||||
if node.node_type != NodeType.pillar:
|
||||
if not node.parent_id:
|
||||
errors.append("Non-pillar node must have a parent (traceability)")
|
||||
errors.append("Orphaned node — must be assigned a parent for traceability")
|
||||
traceable = False
|
||||
if not node.description:
|
||||
warnings.append("Missing description — hard to trace how this serves parent")
|
||||
|
||||
135
backend/app/routers/databases.py
Normal file
135
backend/app/routers/databases.py
Normal 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",
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -222,6 +222,7 @@ class StatsResponse(BaseModel):
|
||||
requirements: int
|
||||
sub_requirements: int
|
||||
leaves: int
|
||||
orphaned: int
|
||||
by_status: dict[str, int]
|
||||
by_priority: dict[str, int]
|
||||
quality_passing: int
|
||||
|
||||
Reference in New Issue
Block a user