308 lines
11 KiB
Python
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)
|