init commit
This commit is contained in:
252
backend/app/routers/requirements.py
Normal file
252
backend/app/routers/requirements.py
Normal file
@@ -0,0 +1,252 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user