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)