from fastapi import APIRouter, 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) -> str: fields = [k for k in update_data if k in _SNAPSHOT_FIELDS] if not fields: return "Update" return "Updated " + ", ".join(fields) 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) 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, _auto_summary(update_data)) 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)): node = db.get(RequirementNode, req_id) if not node: raise HTTPException(404, f"Requirement {req_id} not found") 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) 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)