Added relationship between requirements
This commit is contained in:
@@ -10,14 +10,17 @@ from src.models import (
|
||||
TagResponse, RequirementResponse, PriorityResponse,
|
||||
RequirementCreateRequest, RequirementUpdateRequest,
|
||||
ProjectResponse, ProjectCreateRequest, ProjectUpdateRequest, ProjectMemberRequest,
|
||||
ValidationStatusResponse, ValidationHistoryResponse, ValidationCreateRequest
|
||||
ValidationStatusResponse, ValidationHistoryResponse, ValidationCreateRequest,
|
||||
RelationshipTypeResponse, RelationshipTypeCreateRequest,
|
||||
RequirementLinkResponse, RequirementLinkCreateRequest, RequirementSearchResult
|
||||
)
|
||||
from src.controller import AuthController
|
||||
from src.config import get_openid, get_settings
|
||||
from src.database import init_db, close_db, get_db
|
||||
from src.repositories import (
|
||||
RoleRepository, GroupRepository, TagRepository, RequirementRepository,
|
||||
PriorityRepository, ProjectRepository, ValidationStatusRepository, ValidationRepository
|
||||
PriorityRepository, ProjectRepository, ValidationStatusRepository, ValidationRepository,
|
||||
RelationshipTypeRepository, RequirementLinkRepository
|
||||
)
|
||||
import logging
|
||||
|
||||
@@ -926,3 +929,297 @@ async def get_validation_history(
|
||||
)
|
||||
for v in validations
|
||||
]
|
||||
|
||||
|
||||
# ===========================================
|
||||
# Relationship Types Endpoints
|
||||
# ===========================================
|
||||
|
||||
@app.get("/api/projects/{project_id}/relationship-types", response_model=List[RelationshipTypeResponse])
|
||||
async def get_project_relationship_types(
|
||||
project_id: int,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get all relationship types for a project.
|
||||
User must be a member of the project.
|
||||
|
||||
Args:
|
||||
project_id: The project ID
|
||||
|
||||
Returns:
|
||||
List of relationship types for the project.
|
||||
"""
|
||||
user = await _get_current_user_db(request, db)
|
||||
await _verify_project_membership(project_id, user.id, db)
|
||||
|
||||
rel_type_repo = RelationshipTypeRepository(db)
|
||||
rel_types = await rel_type_repo.get_by_project_id(project_id)
|
||||
return [RelationshipTypeResponse.model_validate(rt) for rt in rel_types]
|
||||
|
||||
|
||||
@app.post("/api/projects/{project_id}/relationship-types", response_model=RelationshipTypeResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_relationship_type(
|
||||
project_id: int,
|
||||
request: Request,
|
||||
type_data: RelationshipTypeCreateRequest,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Create a new relationship type for a project.
|
||||
Only admins (role_id=1) can create relationship types.
|
||||
|
||||
Args:
|
||||
project_id: The project ID
|
||||
type_data: The relationship type data
|
||||
|
||||
Returns:
|
||||
The created relationship type.
|
||||
"""
|
||||
user = await _get_current_user_db(request, db)
|
||||
|
||||
# Only admins can create relationship types
|
||||
_require_role(user, [1], "create relationship types")
|
||||
|
||||
await _verify_project_membership(project_id, user.id, db)
|
||||
|
||||
rel_type_repo = RelationshipTypeRepository(db)
|
||||
rel_type = await rel_type_repo.create(
|
||||
project_id=project_id,
|
||||
type_name=type_data.type_name,
|
||||
type_description=type_data.type_description,
|
||||
inverse_type_name=type_data.inverse_type_name
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return RelationshipTypeResponse.model_validate(rel_type)
|
||||
|
||||
|
||||
# ===========================================
|
||||
# Requirement Search Endpoint (for autocomplete)
|
||||
# ===========================================
|
||||
|
||||
@app.get("/api/projects/{project_id}/requirements/search", response_model=List[RequirementSearchResult])
|
||||
async def search_requirements(
|
||||
project_id: int,
|
||||
request: Request,
|
||||
q: str = "",
|
||||
exclude_id: Optional[int] = None,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Search requirements by name or tag code for autocomplete.
|
||||
User must be a member of the project.
|
||||
|
||||
Args:
|
||||
project_id: The project ID
|
||||
q: Search query (searches tag_code and req_name)
|
||||
exclude_id: Optional requirement ID to exclude from results (to prevent self-linking)
|
||||
|
||||
Returns:
|
||||
List of matching requirements (limited to 20).
|
||||
"""
|
||||
user = await _get_current_user_db(request, db)
|
||||
await _verify_project_membership(project_id, user.id, db)
|
||||
|
||||
req_repo = RequirementRepository(db)
|
||||
requirements = await req_repo.search_by_name_or_tag(
|
||||
project_id=project_id,
|
||||
query=q,
|
||||
exclude_id=exclude_id,
|
||||
limit=20
|
||||
)
|
||||
|
||||
return [
|
||||
RequirementSearchResult(
|
||||
id=req.id,
|
||||
req_name=req.req_name,
|
||||
tag_code=req.tag.tag_code
|
||||
)
|
||||
for req in requirements
|
||||
]
|
||||
|
||||
|
||||
# ===========================================
|
||||
# Requirement Links Endpoints
|
||||
# ===========================================
|
||||
|
||||
@app.get("/api/requirements/{requirement_id}/links", response_model=List[RequirementLinkResponse])
|
||||
async def get_requirement_links(
|
||||
requirement_id: int,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get all links for a requirement (both outgoing and incoming).
|
||||
User must be a member of the requirement's project.
|
||||
|
||||
Args:
|
||||
requirement_id: The requirement ID
|
||||
|
||||
Returns:
|
||||
List of links with direction info.
|
||||
"""
|
||||
user = await _get_current_user_db(request, db)
|
||||
|
||||
# Check if requirement exists
|
||||
req_repo = RequirementRepository(db)
|
||||
requirement = await req_repo.get_by_id(requirement_id)
|
||||
if not requirement:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Requirement with id {requirement_id} not found"
|
||||
)
|
||||
|
||||
await _verify_project_membership(requirement.project_id, user.id, db)
|
||||
|
||||
link_repo = RequirementLinkRepository(db)
|
||||
links = await link_repo.get_by_requirement_id(requirement_id)
|
||||
|
||||
return [RequirementLinkResponse(**link) for link in links]
|
||||
|
||||
|
||||
@app.post("/api/requirements/{requirement_id}/links", response_model=RequirementLinkResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_requirement_link(
|
||||
requirement_id: int,
|
||||
request: Request,
|
||||
link_data: RequirementLinkCreateRequest,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Create a new link from this requirement to another.
|
||||
User must be a member of the project.
|
||||
Auditors (role_id=2) cannot create links.
|
||||
|
||||
Args:
|
||||
requirement_id: The source requirement ID
|
||||
link_data: The target requirement and relationship type
|
||||
|
||||
Returns:
|
||||
The created link.
|
||||
"""
|
||||
user = await _get_current_user_db(request, db)
|
||||
|
||||
# Auditors cannot create links
|
||||
_require_role(user, [1, 3], "create requirement links")
|
||||
|
||||
req_repo = RequirementRepository(db)
|
||||
|
||||
# Check if source requirement exists
|
||||
source_req = await req_repo.get_by_id(requirement_id)
|
||||
if not source_req:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Requirement with id {requirement_id} not found"
|
||||
)
|
||||
|
||||
await _verify_project_membership(source_req.project_id, user.id, db)
|
||||
|
||||
# Check if target requirement exists
|
||||
target_req = await req_repo.get_by_id(link_data.target_requirement_id)
|
||||
if not target_req:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Target requirement with id {link_data.target_requirement_id} not found"
|
||||
)
|
||||
|
||||
# Prevent self-linking
|
||||
if requirement_id == link_data.target_requirement_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot link a requirement to itself"
|
||||
)
|
||||
|
||||
# Verify both requirements are in the same project
|
||||
if source_req.project_id != target_req.project_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot link requirements from different projects"
|
||||
)
|
||||
|
||||
# Verify relationship type exists and belongs to the project
|
||||
rel_type_repo = RelationshipTypeRepository(db)
|
||||
rel_type = await rel_type_repo.get_by_id(link_data.relationship_type_id)
|
||||
if not rel_type:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Relationship type with id {link_data.relationship_type_id} not found"
|
||||
)
|
||||
if rel_type.project_id != source_req.project_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Relationship type does not belong to this project"
|
||||
)
|
||||
|
||||
# Check if link already exists
|
||||
link_repo = RequirementLinkRepository(db)
|
||||
if await link_repo.link_exists(requirement_id, link_data.target_requirement_id, link_data.relationship_type_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="This link already exists"
|
||||
)
|
||||
|
||||
# Create the link
|
||||
link = await link_repo.create(
|
||||
source_req_id=requirement_id,
|
||||
target_req_id=link_data.target_requirement_id,
|
||||
relationship_type_id=link_data.relationship_type_id,
|
||||
created_by=user.id
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return RequirementLinkResponse(
|
||||
id=link.id,
|
||||
direction="outgoing",
|
||||
type_name=rel_type.type_name,
|
||||
type_id=link.relationship_type_id,
|
||||
inverse_type_name=rel_type.inverse_type_name,
|
||||
linked_requirement={
|
||||
"id": target_req.id,
|
||||
"req_name": target_req.req_name,
|
||||
"tag_code": target_req.tag.tag_code
|
||||
},
|
||||
created_by_username=user.sub,
|
||||
created_by_id=user.id,
|
||||
created_at=link.created_at
|
||||
)
|
||||
|
||||
|
||||
@app.delete("/api/requirement-links/{link_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_requirement_link(
|
||||
link_id: int,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Delete a requirement link.
|
||||
Only the creator or an admin can delete a link.
|
||||
|
||||
Args:
|
||||
link_id: The link ID to delete
|
||||
"""
|
||||
user = await _get_current_user_db(request, db)
|
||||
|
||||
link_repo = RequirementLinkRepository(db)
|
||||
link = await link_repo.get_by_id(link_id)
|
||||
|
||||
if not link:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Link with id {link_id} not found"
|
||||
)
|
||||
|
||||
# Verify user is a member of the project
|
||||
await _verify_project_membership(link.source_requirement.project_id, user.id, db)
|
||||
|
||||
# Only creator or admin can delete
|
||||
if link.created_by != user.id and user.role_id != 1:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only the link creator or an admin can delete this link"
|
||||
)
|
||||
|
||||
await link_repo.delete(link_id)
|
||||
await db.commit()
|
||||
|
||||
Reference in New Issue
Block a user