Files
graph-requirements-planner/backend/app/routers/requirements.py
ANDREW HOSFORD 0e8978f48c Update project files
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 11:59:48 -05:00

308 lines
11 KiB
Python

from fastapi import APIRouter, Body, Depends, HTTPException
from sqlalchemy import func
from sqlalchemy.orm import Session
from ..auth import verify_token
from ..database import get_db
from ..models import RequirementNode, RequirementHistory
from ..quality import validate_requirement
from ..schemas import (
RequirementCreate,
RequirementResponse,
RequirementTreeNode,
RequirementUpdate,
QualityReport,
RequirementHistorySummary,
RequirementHistoryDetail,
)
router = APIRouter(prefix="/api/requirements", tags=["requirements"], dependencies=[Depends(verify_token)])
# ─── Snapshot fields to copy between RequirementNode and RequirementHistory ───
_SNAPSHOT_FIELDS = [
"title", "description", "priority", "phase", "status", "node_type",
"acceptance_criteria", "test_spec", "test_code", "impl_code",
"code_language", "target_test_file", "target_impl_file",
"measurable", "consistent", "single_purpose",
"gitea_issue_number", "gitea_repo",
]
def _create_history_entry(
db: Session, node: RequirementNode, version: int, summary: str | None = None
) -> RequirementHistory:
data = {field: getattr(node, field) for field in _SNAPSHOT_FIELDS}
# Convert enums to their string values for plain String columns
for key in ("priority", "status", "node_type", "code_language"):
val = data.get(key)
if val is not None and hasattr(val, "value"):
data[key] = val.value
entry = RequirementHistory(
requirement_id=node.id,
version=version,
change_summary=summary,
**data,
)
db.add(entry)
return entry
def _next_version(db: Session, req_id: str) -> int:
max_ver = db.query(func.max(RequirementHistory.version)).filter(
RequirementHistory.requirement_id == req_id
).scalar()
return (max_ver or 0) + 1
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:
quality = validate_requirement(node, node.children)
resp = RequirementResponse.model_validate(node)
resp.quality = quality
if include_children:
resp.children = [_to_response(c) for c in node.children]
return resp
def _to_tree_node(node: RequirementNode) -> RequirementTreeNode:
quality = validate_requirement(node, node.children)
return RequirementTreeNode(
id=node.id,
title=node.title,
priority=node.priority,
phase=node.phase,
status=node.status,
node_type=node.node_type,
parent_id=node.parent_id,
child_count=len(node.children),
traceable=quality.traceable,
measurable=quality.measurable,
consistent=node.consistent,
single_purpose=node.single_purpose,
gitea_issue_number=node.gitea_issue_number,
children=[_to_tree_node(c) for c in node.children],
)
@router.get("", response_model=list[RequirementResponse])
def list_requirements(
project_id: int | None = None,
parent_id: str | None = None,
db: Session = Depends(get_db),
):
query = db.query(RequirementNode)
if project_id is not None:
query = query.filter(RequirementNode.project_id == project_id)
if parent_id is not None:
query = query.filter(RequirementNode.parent_id == parent_id)
nodes = query.order_by(RequirementNode.sort_order).all()
return [_to_response(n) for n in nodes]
@router.get("/tree", response_model=list[RequirementTreeNode])
def get_tree(project_id: int | None = None, db: Session = Depends(get_db)):
query = db.query(RequirementNode).filter(RequirementNode.parent_id.is_(None))
if project_id is not None:
query = query.filter(RequirementNode.project_id == project_id)
roots = query.order_by(RequirementNode.sort_order).all()
return [_to_tree_node(r) for r in roots]
@router.get("/{req_id}", response_model=RequirementResponse)
def get_requirement(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(node, include_children=True)
@router.post("", response_model=RequirementResponse, status_code=201)
def create_requirement(data: RequirementCreate, db: Session = Depends(get_db)):
if db.get(RequirementNode, data.id):
raise HTTPException(409, f"Requirement {data.id} already exists")
if data.parent_id and not db.get(RequirementNode, data.parent_id):
raise HTTPException(404, f"Parent {data.parent_id} not found")
node = RequirementNode(**data.model_dump())
# Auto-compute traceable flag only
quality = validate_requirement(node, [])
node.traceable = quality.traceable
db.add(node)
db.flush()
_create_history_entry(db, node, version=1, summary="Initial version")
db.commit()
db.refresh(node)
return _to_response(node)
@router.put("/{req_id}", response_model=RequirementResponse)
def update_requirement(req_id: str, data: RequirementUpdate, db: Session = Depends(get_db)):
node = db.get(RequirementNode, req_id)
if not node:
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)
# Auto-compute traceable flag only (M, C, S are user-set toggles)
quality = validate_requirement(node, node.children)
node.traceable = quality.traceable
version = _next_version(db, req_id)
_create_history_entry(db, node, version, summary)
db.commit()
db.refresh(node)
return _to_response(node, include_children=True)
@router.delete("/{req_id}", status_code=204)
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()
@router.patch("/{req_id}/status")
def update_status(req_id: str, status: str, db: Session = Depends(get_db)):
node = db.get(RequirementNode, req_id)
if not node:
raise HTTPException(404, f"Requirement {req_id} not found")
node.status = status
db.commit()
return {"id": req_id, "status": status}
@router.post("/{req_id}/validate", response_model=QualityReport)
def validate_node(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 validate_requirement(node, node.children)
@router.get("/{req_id}/children", response_model=list[RequirementResponse])
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, include_children=True) for c in node.children]
# ─── History Endpoints ────────────────────────────────────────────────────
@router.get("/{req_id}/history", response_model=list[RequirementHistorySummary])
def get_history(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")
entries = db.query(RequirementHistory).filter(
RequirementHistory.requirement_id == req_id
).order_by(RequirementHistory.version.desc()).all()
return entries
@router.get("/{req_id}/history/{version}", response_model=RequirementHistoryDetail)
def get_history_version(req_id: str, version: int, db: Session = Depends(get_db)):
entry = db.query(RequirementHistory).filter(
RequirementHistory.requirement_id == req_id,
RequirementHistory.version == version,
).first()
if not entry:
raise HTTPException(404, f"Version {version} not found for {req_id}")
return entry
@router.post("/{req_id}/history/{version}/restore", response_model=RequirementResponse)
def restore_version(req_id: str, version: int, db: Session = Depends(get_db)):
node = db.get(RequirementNode, req_id)
if not node:
raise HTTPException(404, f"Requirement {req_id} not found")
entry = db.query(RequirementHistory).filter(
RequirementHistory.requirement_id == req_id,
RequirementHistory.version == version,
).first()
if not entry:
raise HTTPException(404, f"Version {version} not found for {req_id}")
# Restore fields from history snapshot
for field in _SNAPSHOT_FIELDS:
setattr(node, field, getattr(entry, field))
quality = validate_requirement(node, node.children)
node.traceable = quality.traceable
new_version = _next_version(db, req_id)
_create_history_entry(db, node, new_version, f"Restored from version {version}")
db.commit()
db.refresh(node)
return _to_response(node, include_children=True)