Added relationship between requirements
This commit is contained in:
@@ -57,6 +57,7 @@ class User(Base):
|
|||||||
secondary="project_members",
|
secondary="project_members",
|
||||||
back_populates="members"
|
back_populates="members"
|
||||||
)
|
)
|
||||||
|
created_links: Mapped[List["RequirementLink"]] = relationship("RequirementLink", back_populates="creator")
|
||||||
|
|
||||||
|
|
||||||
class Tag(Base):
|
class Tag(Base):
|
||||||
@@ -119,6 +120,7 @@ class Project(Base):
|
|||||||
back_populates="projects"
|
back_populates="projects"
|
||||||
)
|
)
|
||||||
requirements: Mapped[List["Requirement"]] = relationship("Requirement", back_populates="project")
|
requirements: Mapped[List["Requirement"]] = relationship("Requirement", back_populates="project")
|
||||||
|
relationship_types: Mapped[List["RelationshipType"]] = relationship("RelationshipType", back_populates="project")
|
||||||
|
|
||||||
|
|
||||||
class ProjectMember(Base):
|
class ProjectMember(Base):
|
||||||
@@ -211,6 +213,16 @@ class Requirement(Base):
|
|||||||
back_populates="requirements"
|
back_populates="requirements"
|
||||||
)
|
)
|
||||||
validations: Mapped[List["Validation"]] = relationship("Validation", back_populates="requirement")
|
validations: Mapped[List["Validation"]] = relationship("Validation", back_populates="requirement")
|
||||||
|
outgoing_links: Mapped[List["RequirementLink"]] = relationship(
|
||||||
|
"RequirementLink",
|
||||||
|
foreign_keys="RequirementLink.source_req_id",
|
||||||
|
back_populates="source_requirement"
|
||||||
|
)
|
||||||
|
incoming_links: Mapped[List["RequirementLink"]] = relationship(
|
||||||
|
"RequirementLink",
|
||||||
|
foreign_keys="RequirementLink.target_req_id",
|
||||||
|
back_populates="target_requirement"
|
||||||
|
)
|
||||||
|
|
||||||
# Indexes
|
# Indexes
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
@@ -285,3 +297,85 @@ class RequirementHistory(Base):
|
|||||||
valid_from: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
valid_from: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
valid_to: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
valid_to: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
edited_by: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
edited_by: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class RelationshipType(Base):
|
||||||
|
"""
|
||||||
|
Defines valid relationship types per project.
|
||||||
|
E.g., "Depends On", "Parent", "Conflicts With".
|
||||||
|
"""
|
||||||
|
__tablename__ = "relationship_types"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
project_id: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("projects.id", ondelete="CASCADE"),
|
||||||
|
nullable=False
|
||||||
|
)
|
||||||
|
type_name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
type_description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
inverse_type_name: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
project: Mapped["Project"] = relationship("Project", back_populates="relationship_types")
|
||||||
|
links: Mapped[List["RequirementLink"]] = relationship("RequirementLink", back_populates="relationship_type")
|
||||||
|
|
||||||
|
# Constraints
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("project_id", "type_name", name="uq_rel_type_name_project"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RequirementLink(Base):
|
||||||
|
"""
|
||||||
|
Links between requirements (e.g., dependencies, parent/child, conflicts).
|
||||||
|
"""
|
||||||
|
__tablename__ = "requirement_links"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
source_req_id: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("requirements.id", ondelete="CASCADE"),
|
||||||
|
nullable=False
|
||||||
|
)
|
||||||
|
target_req_id: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("requirements.id", ondelete="CASCADE"),
|
||||||
|
nullable=False
|
||||||
|
)
|
||||||
|
relationship_type_id: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("relationship_types.id", ondelete="CASCADE"),
|
||||||
|
nullable=False
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=datetime.utcnow,
|
||||||
|
nullable=True
|
||||||
|
)
|
||||||
|
created_by: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
|
nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
source_requirement: Mapped["Requirement"] = relationship(
|
||||||
|
"Requirement",
|
||||||
|
foreign_keys=[source_req_id],
|
||||||
|
back_populates="outgoing_links"
|
||||||
|
)
|
||||||
|
target_requirement: Mapped["Requirement"] = relationship(
|
||||||
|
"Requirement",
|
||||||
|
foreign_keys=[target_req_id],
|
||||||
|
back_populates="incoming_links"
|
||||||
|
)
|
||||||
|
relationship_type: Mapped["RelationshipType"] = relationship("RelationshipType", back_populates="links")
|
||||||
|
creator: Mapped[Optional["User"]] = relationship("User", back_populates="created_links")
|
||||||
|
|
||||||
|
# Indexes and constraints
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_link_source", "source_req_id"),
|
||||||
|
Index("idx_link_target", "target_req_id"),
|
||||||
|
UniqueConstraint("source_req_id", "target_req_id", "relationship_type_id", name="uq_req_link_pair"),
|
||||||
|
)
|
||||||
|
|||||||
@@ -10,14 +10,17 @@ from src.models import (
|
|||||||
TagResponse, RequirementResponse, PriorityResponse,
|
TagResponse, RequirementResponse, PriorityResponse,
|
||||||
RequirementCreateRequest, RequirementUpdateRequest,
|
RequirementCreateRequest, RequirementUpdateRequest,
|
||||||
ProjectResponse, ProjectCreateRequest, ProjectUpdateRequest, ProjectMemberRequest,
|
ProjectResponse, ProjectCreateRequest, ProjectUpdateRequest, ProjectMemberRequest,
|
||||||
ValidationStatusResponse, ValidationHistoryResponse, ValidationCreateRequest
|
ValidationStatusResponse, ValidationHistoryResponse, ValidationCreateRequest,
|
||||||
|
RelationshipTypeResponse, RelationshipTypeCreateRequest,
|
||||||
|
RequirementLinkResponse, RequirementLinkCreateRequest, RequirementSearchResult
|
||||||
)
|
)
|
||||||
from src.controller import AuthController
|
from src.controller import AuthController
|
||||||
from src.config import get_openid, get_settings
|
from src.config import get_openid, get_settings
|
||||||
from src.database import init_db, close_db, get_db
|
from src.database import init_db, close_db, get_db
|
||||||
from src.repositories import (
|
from src.repositories import (
|
||||||
RoleRepository, GroupRepository, TagRepository, RequirementRepository,
|
RoleRepository, GroupRepository, TagRepository, RequirementRepository,
|
||||||
PriorityRepository, ProjectRepository, ValidationStatusRepository, ValidationRepository
|
PriorityRepository, ProjectRepository, ValidationStatusRepository, ValidationRepository,
|
||||||
|
RelationshipTypeRepository, RequirementLinkRepository
|
||||||
)
|
)
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@@ -926,3 +929,297 @@ async def get_validation_history(
|
|||||||
)
|
)
|
||||||
for v in validations
|
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()
|
||||||
|
|||||||
@@ -193,3 +193,67 @@ class RequirementUpdateRequest(BaseModel):
|
|||||||
tag_id: Optional[int] = None
|
tag_id: Optional[int] = None
|
||||||
priority_id: Optional[int] = None
|
priority_id: Optional[int] = None
|
||||||
group_ids: Optional[List[int]] = None
|
group_ids: Optional[List[int]] = None
|
||||||
|
|
||||||
|
|
||||||
|
# Relationship Type schemas
|
||||||
|
class RelationshipTypeResponse(BaseModel):
|
||||||
|
"""Response schema for a relationship type."""
|
||||||
|
id: int
|
||||||
|
project_id: int
|
||||||
|
type_name: str
|
||||||
|
type_description: Optional[str] = None
|
||||||
|
inverse_type_name: Optional[str] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class RelationshipTypeCreateRequest(BaseModel):
|
||||||
|
"""Request schema for creating a relationship type."""
|
||||||
|
type_name: str
|
||||||
|
type_description: Optional[str] = None
|
||||||
|
inverse_type_name: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# Requirement Link schemas
|
||||||
|
class LinkedRequirementInfo(BaseModel):
|
||||||
|
"""Brief info about a linked requirement."""
|
||||||
|
id: int
|
||||||
|
req_name: str
|
||||||
|
tag_code: str
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class RequirementLinkResponse(BaseModel):
|
||||||
|
"""Response schema for a requirement link with direction."""
|
||||||
|
id: int
|
||||||
|
direction: str # 'outgoing' or 'incoming'
|
||||||
|
type_name: str
|
||||||
|
type_id: int
|
||||||
|
inverse_type_name: Optional[str] = None
|
||||||
|
linked_requirement: LinkedRequirementInfo
|
||||||
|
created_by_username: Optional[str] = None
|
||||||
|
created_by_id: Optional[int] = None
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class RequirementLinkCreateRequest(BaseModel):
|
||||||
|
"""Request schema for creating a requirement link."""
|
||||||
|
relationship_type_id: int
|
||||||
|
target_requirement_id: int
|
||||||
|
|
||||||
|
|
||||||
|
# Requirement Search schemas
|
||||||
|
class RequirementSearchResult(BaseModel):
|
||||||
|
"""Response schema for requirement search results (for autocomplete)."""
|
||||||
|
id: int
|
||||||
|
req_name: str
|
||||||
|
tag_code: str
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ from src.repositories.priority_repository import PriorityRepository
|
|||||||
from src.repositories.project_repository import ProjectRepository
|
from src.repositories.project_repository import ProjectRepository
|
||||||
from src.repositories.validation_status_repository import ValidationStatusRepository
|
from src.repositories.validation_status_repository import ValidationStatusRepository
|
||||||
from src.repositories.validation_repository import ValidationRepository
|
from src.repositories.validation_repository import ValidationRepository
|
||||||
|
from src.repositories.relationship_type_repository import RelationshipTypeRepository
|
||||||
|
from src.repositories.requirement_link_repository import RequirementLinkRepository
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"UserRepository",
|
"UserRepository",
|
||||||
@@ -20,4 +22,6 @@ __all__ = [
|
|||||||
"ProjectRepository",
|
"ProjectRepository",
|
||||||
"ValidationStatusRepository",
|
"ValidationStatusRepository",
|
||||||
"ValidationRepository",
|
"ValidationRepository",
|
||||||
|
"RelationshipTypeRepository",
|
||||||
|
"RequirementLinkRepository",
|
||||||
]
|
]
|
||||||
|
|||||||
97
backend/src/repositories/relationship_type_repository.py
Normal file
97
backend/src/repositories/relationship_type_repository.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
"""
|
||||||
|
Repository for RelationshipType database operations.
|
||||||
|
"""
|
||||||
|
from typing import List, Optional
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from src.db_models import RelationshipType
|
||||||
|
|
||||||
|
|
||||||
|
class RelationshipTypeRepository:
|
||||||
|
"""Repository for relationship type CRUD operations."""
|
||||||
|
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def get_by_project_id(self, project_id: int) -> List[RelationshipType]:
|
||||||
|
"""
|
||||||
|
Get all relationship types for a project.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: The project ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of relationship types for the project.
|
||||||
|
"""
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(RelationshipType)
|
||||||
|
.where(RelationshipType.project_id == project_id)
|
||||||
|
.order_by(RelationshipType.type_name)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def get_by_id(self, relationship_type_id: int) -> Optional[RelationshipType]:
|
||||||
|
"""
|
||||||
|
Get a relationship type by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
relationship_type_id: The relationship type ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The relationship type or None if not found.
|
||||||
|
"""
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(RelationshipType)
|
||||||
|
.where(RelationshipType.id == relationship_type_id)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def create(
|
||||||
|
self,
|
||||||
|
project_id: int,
|
||||||
|
type_name: str,
|
||||||
|
type_description: Optional[str] = None,
|
||||||
|
inverse_type_name: Optional[str] = None
|
||||||
|
) -> RelationshipType:
|
||||||
|
"""
|
||||||
|
Create a new relationship type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: The project this type belongs to
|
||||||
|
type_name: Name of the relationship (e.g., "Depends On")
|
||||||
|
type_description: Optional description
|
||||||
|
inverse_type_name: Optional inverse name (e.g., "Depended By")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The created relationship type.
|
||||||
|
"""
|
||||||
|
relationship_type = RelationshipType(
|
||||||
|
project_id=project_id,
|
||||||
|
type_name=type_name,
|
||||||
|
type_description=type_description,
|
||||||
|
inverse_type_name=inverse_type_name
|
||||||
|
)
|
||||||
|
self.db.add(relationship_type)
|
||||||
|
await self.db.flush()
|
||||||
|
await self.db.refresh(relationship_type)
|
||||||
|
return relationship_type
|
||||||
|
|
||||||
|
async def delete(self, relationship_type_id: int) -> bool:
|
||||||
|
"""
|
||||||
|
Delete a relationship type by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
relationship_type_id: The ID of the relationship type to delete
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if deleted, False if not found.
|
||||||
|
"""
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(RelationshipType).where(RelationshipType.id == relationship_type_id)
|
||||||
|
)
|
||||||
|
relationship_type = result.scalar_one_or_none()
|
||||||
|
if relationship_type:
|
||||||
|
await self.db.delete(relationship_type)
|
||||||
|
await self.db.flush()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
192
backend/src/repositories/requirement_link_repository.py
Normal file
192
backend/src/repositories/requirement_link_repository.py
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
"""
|
||||||
|
Repository for RequirementLink database operations.
|
||||||
|
"""
|
||||||
|
from typing import List, Optional, Dict, Any
|
||||||
|
from sqlalchemy import select, or_
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from src.db_models import RequirementLink, Requirement, RelationshipType, User
|
||||||
|
|
||||||
|
|
||||||
|
class RequirementLinkRepository:
|
||||||
|
"""Repository for requirement link CRUD operations."""
|
||||||
|
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def get_by_requirement_id(self, requirement_id: int) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get all links for a requirement (both outgoing and incoming).
|
||||||
|
Returns a list of dicts with direction info.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
requirement_id: The requirement ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of link data with direction ('outgoing' or 'incoming').
|
||||||
|
"""
|
||||||
|
# Get outgoing links (this requirement is the source)
|
||||||
|
outgoing_result = await self.db.execute(
|
||||||
|
select(RequirementLink)
|
||||||
|
.options(
|
||||||
|
selectinload(RequirementLink.target_requirement).selectinload(Requirement.tag),
|
||||||
|
selectinload(RequirementLink.relationship_type),
|
||||||
|
selectinload(RequirementLink.creator)
|
||||||
|
)
|
||||||
|
.where(RequirementLink.source_req_id == requirement_id)
|
||||||
|
.order_by(RequirementLink.created_at.desc())
|
||||||
|
)
|
||||||
|
outgoing_links = outgoing_result.scalars().all()
|
||||||
|
|
||||||
|
# Get incoming links (this requirement is the target)
|
||||||
|
incoming_result = await self.db.execute(
|
||||||
|
select(RequirementLink)
|
||||||
|
.options(
|
||||||
|
selectinload(RequirementLink.source_requirement).selectinload(Requirement.tag),
|
||||||
|
selectinload(RequirementLink.relationship_type),
|
||||||
|
selectinload(RequirementLink.creator)
|
||||||
|
)
|
||||||
|
.where(RequirementLink.target_req_id == requirement_id)
|
||||||
|
.order_by(RequirementLink.created_at.desc())
|
||||||
|
)
|
||||||
|
incoming_links = incoming_result.scalars().all()
|
||||||
|
|
||||||
|
# Build result list with direction info
|
||||||
|
result = []
|
||||||
|
|
||||||
|
for link in outgoing_links:
|
||||||
|
result.append({
|
||||||
|
"id": link.id,
|
||||||
|
"direction": "outgoing",
|
||||||
|
"type_name": link.relationship_type.type_name,
|
||||||
|
"type_id": link.relationship_type_id,
|
||||||
|
"inverse_type_name": link.relationship_type.inverse_type_name,
|
||||||
|
"linked_requirement": {
|
||||||
|
"id": link.target_requirement.id,
|
||||||
|
"req_name": link.target_requirement.req_name,
|
||||||
|
"tag_code": link.target_requirement.tag.tag_code
|
||||||
|
},
|
||||||
|
"created_by_username": link.creator.sub if link.creator else None,
|
||||||
|
"created_by_id": link.created_by,
|
||||||
|
"created_at": link.created_at
|
||||||
|
})
|
||||||
|
|
||||||
|
for link in incoming_links:
|
||||||
|
result.append({
|
||||||
|
"id": link.id,
|
||||||
|
"direction": "incoming",
|
||||||
|
"type_name": link.relationship_type.inverse_type_name or link.relationship_type.type_name,
|
||||||
|
"type_id": link.relationship_type_id,
|
||||||
|
"inverse_type_name": link.relationship_type.type_name,
|
||||||
|
"linked_requirement": {
|
||||||
|
"id": link.source_requirement.id,
|
||||||
|
"req_name": link.source_requirement.req_name,
|
||||||
|
"tag_code": link.source_requirement.tag.tag_code
|
||||||
|
},
|
||||||
|
"created_by_username": link.creator.sub if link.creator else None,
|
||||||
|
"created_by_id": link.created_by,
|
||||||
|
"created_at": link.created_at
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort by created_at descending
|
||||||
|
result.sort(key=lambda x: x["created_at"] or "", reverse=True)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def get_by_id(self, link_id: int) -> Optional[RequirementLink]:
|
||||||
|
"""
|
||||||
|
Get a link by ID with related data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
link_id: The link ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The link or None if not found.
|
||||||
|
"""
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(RequirementLink)
|
||||||
|
.options(
|
||||||
|
selectinload(RequirementLink.source_requirement),
|
||||||
|
selectinload(RequirementLink.target_requirement),
|
||||||
|
selectinload(RequirementLink.relationship_type),
|
||||||
|
selectinload(RequirementLink.creator)
|
||||||
|
)
|
||||||
|
.where(RequirementLink.id == link_id)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def create(
|
||||||
|
self,
|
||||||
|
source_req_id: int,
|
||||||
|
target_req_id: int,
|
||||||
|
relationship_type_id: int,
|
||||||
|
created_by: Optional[int] = None
|
||||||
|
) -> RequirementLink:
|
||||||
|
"""
|
||||||
|
Create a new requirement link.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_req_id: The source requirement ID
|
||||||
|
target_req_id: The target requirement ID
|
||||||
|
relationship_type_id: The type of relationship
|
||||||
|
created_by: The user creating the link
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The created link.
|
||||||
|
"""
|
||||||
|
link = RequirementLink(
|
||||||
|
source_req_id=source_req_id,
|
||||||
|
target_req_id=target_req_id,
|
||||||
|
relationship_type_id=relationship_type_id,
|
||||||
|
created_by=created_by
|
||||||
|
)
|
||||||
|
self.db.add(link)
|
||||||
|
await self.db.flush()
|
||||||
|
await self.db.refresh(link)
|
||||||
|
return link
|
||||||
|
|
||||||
|
async def delete(self, link_id: int) -> bool:
|
||||||
|
"""
|
||||||
|
Delete a link by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
link_id: The ID of the link to delete
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if deleted, False if not found.
|
||||||
|
"""
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(RequirementLink).where(RequirementLink.id == link_id)
|
||||||
|
)
|
||||||
|
link = result.scalar_one_or_none()
|
||||||
|
if link:
|
||||||
|
await self.db.delete(link)
|
||||||
|
await self.db.flush()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def link_exists(
|
||||||
|
self,
|
||||||
|
source_req_id: int,
|
||||||
|
target_req_id: int,
|
||||||
|
relationship_type_id: int
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Check if a link already exists between two requirements with the same type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_req_id: The source requirement ID
|
||||||
|
target_req_id: The target requirement ID
|
||||||
|
relationship_type_id: The relationship type ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if link exists, False otherwise.
|
||||||
|
"""
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(RequirementLink)
|
||||||
|
.where(
|
||||||
|
RequirementLink.source_req_id == source_req_id,
|
||||||
|
RequirementLink.target_req_id == target_req_id,
|
||||||
|
RequirementLink.relationship_type_id == relationship_type_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none() is not None
|
||||||
@@ -300,3 +300,52 @@ class RequirementRepository:
|
|||||||
await self.session.delete(requirement)
|
await self.session.delete(requirement)
|
||||||
await self.session.flush()
|
await self.session.flush()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
async def search_by_name_or_tag(
|
||||||
|
self,
|
||||||
|
project_id: int,
|
||||||
|
query: str,
|
||||||
|
exclude_id: Optional[int] = None,
|
||||||
|
limit: int = 20
|
||||||
|
) -> List[Requirement]:
|
||||||
|
"""
|
||||||
|
Search requirements by name or tag code within a project.
|
||||||
|
Used for autocomplete in the add relationship modal.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: The project ID to search within
|
||||||
|
query: The search query (searches req_name and tag_code)
|
||||||
|
exclude_id: Optional requirement ID to exclude from results
|
||||||
|
limit: Maximum number of results to return
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of matching requirements
|
||||||
|
"""
|
||||||
|
from sqlalchemy import or_, func
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(Requirement)
|
||||||
|
.options(selectinload(Requirement.tag))
|
||||||
|
.join(Requirement.tag)
|
||||||
|
.where(Requirement.project_id == project_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add search filter if query is provided
|
||||||
|
if query:
|
||||||
|
# Trim whitespace and search
|
||||||
|
search_term = f"%{query.strip().lower()}%"
|
||||||
|
stmt = stmt.where(
|
||||||
|
or_(
|
||||||
|
func.lower(Requirement.req_name).like(search_term),
|
||||||
|
func.lower(Tag.tag_code).like(search_term)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Exclude specific requirement (to prevent self-linking)
|
||||||
|
if exclude_id is not None:
|
||||||
|
stmt = stmt.where(Requirement.id != exclude_id)
|
||||||
|
|
||||||
|
stmt = stmt.order_by(Tag.tag_code, Requirement.req_name).limit(limit)
|
||||||
|
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useAuth, useProject } from '@/hooks'
|
import { useAuth, useProject } from '@/hooks'
|
||||||
import { useParams, Link } from 'react-router-dom'
|
import { useParams, Link } from 'react-router-dom'
|
||||||
import { requirementService, validationService } from '@/services'
|
import { requirementService, validationService, relationshipService } from '@/services'
|
||||||
import type { Requirement } from '@/services/requirementService'
|
import type { Requirement } from '@/services/requirementService'
|
||||||
|
import type { RelationshipType, RequirementLink, RequirementSearchResult } from '@/services/relationshipService'
|
||||||
import type { ValidationStatus, ValidationHistory } from '@/types'
|
import type { ValidationStatus, ValidationHistory } from '@/types'
|
||||||
|
|
||||||
// Tab types
|
// Tab types
|
||||||
type TabType = 'description' | 'sub-requirements' | 'co-requirements' | 'acceptance-criteria' | 'shared-comments' | 'validate'
|
type TabType = 'description' | 'relationships' | 'acceptance-criteria' | 'shared-comments' | 'validate'
|
||||||
|
|
||||||
export default function RequirementDetailPage() {
|
export default function RequirementDetailPage() {
|
||||||
const { user, logout, isAuditor } = useAuth()
|
const { user, logout, isAuditor } = useAuth()
|
||||||
@@ -26,6 +27,20 @@ export default function RequirementDetailPage() {
|
|||||||
const [validationError, setValidationError] = useState<string | null>(null)
|
const [validationError, setValidationError] = useState<string | null>(null)
|
||||||
const [historyLoading, setHistoryLoading] = useState(false)
|
const [historyLoading, setHistoryLoading] = useState(false)
|
||||||
|
|
||||||
|
// Relationships state
|
||||||
|
const [relationshipLinks, setRelationshipLinks] = useState<RequirementLink[]>([])
|
||||||
|
const [relationshipTypes, setRelationshipTypes] = useState<RelationshipType[]>([])
|
||||||
|
const [relationshipsLoading, setRelationshipsLoading] = useState(false)
|
||||||
|
const [showAddRelationshipModal, setShowAddRelationshipModal] = useState(false)
|
||||||
|
const [selectedRelationshipType, setSelectedRelationshipType] = useState<number | ''>('')
|
||||||
|
const [targetSearchQuery, setTargetSearchQuery] = useState('')
|
||||||
|
const [searchResults, setSearchResults] = useState<RequirementSearchResult[]>([])
|
||||||
|
const [selectedTarget, setSelectedTarget] = useState<RequirementSearchResult | null>(null)
|
||||||
|
const [searchLoading, setSearchLoading] = useState(false)
|
||||||
|
const [addLinkLoading, setAddLinkLoading] = useState(false)
|
||||||
|
const [addLinkError, setAddLinkError] = useState<string | null>(null)
|
||||||
|
const [deletingLinkId, setDeletingLinkId] = useState<number | null>(null)
|
||||||
|
|
||||||
// Fetch requirement data on mount
|
// Fetch requirement data on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchRequirement = async () => {
|
const fetchRequirement = async () => {
|
||||||
@@ -74,6 +89,56 @@ export default function RequirementDetailPage() {
|
|||||||
fetchValidationData()
|
fetchValidationData()
|
||||||
}, [activeTab, id])
|
}, [activeTab, id])
|
||||||
|
|
||||||
|
// Fetch relationships data when relationships tab is active
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchRelationshipsData = async () => {
|
||||||
|
if (activeTab !== 'relationships' || !id || !currentProject) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
setRelationshipsLoading(true)
|
||||||
|
const [links, types] = await Promise.all([
|
||||||
|
relationshipService.getRequirementLinks(parseInt(id, 10)),
|
||||||
|
relationshipService.getRelationshipTypes(currentProject.id)
|
||||||
|
])
|
||||||
|
setRelationshipLinks(links)
|
||||||
|
setRelationshipTypes(types)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch relationships data:', err)
|
||||||
|
} finally {
|
||||||
|
setRelationshipsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchRelationshipsData()
|
||||||
|
}, [activeTab, id, currentProject])
|
||||||
|
|
||||||
|
// Debounced search for target requirements
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showAddRelationshipModal || !currentProject || !id) return
|
||||||
|
if (targetSearchQuery.length < 1) {
|
||||||
|
setSearchResults([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
setSearchLoading(true)
|
||||||
|
const results = await relationshipService.searchRequirements(
|
||||||
|
currentProject.id,
|
||||||
|
targetSearchQuery,
|
||||||
|
parseInt(id, 10) // Exclude current requirement
|
||||||
|
)
|
||||||
|
setSearchResults(results)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to search requirements:', err)
|
||||||
|
} finally {
|
||||||
|
setSearchLoading(false)
|
||||||
|
}
|
||||||
|
}, 300)
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId)
|
||||||
|
}, [targetSearchQuery, showAddRelationshipModal, currentProject, id])
|
||||||
|
|
||||||
// Handle validation submission
|
// Handle validation submission
|
||||||
const handleSubmitValidation = async () => {
|
const handleSubmitValidation = async () => {
|
||||||
if (!selectedStatusId || !id) {
|
if (!selectedStatusId || !id) {
|
||||||
@@ -108,6 +173,80 @@ export default function RequirementDetailPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle opening add relationship modal
|
||||||
|
const openAddRelationshipModal = () => {
|
||||||
|
setShowAddRelationshipModal(true)
|
||||||
|
setSelectedRelationshipType('')
|
||||||
|
setTargetSearchQuery('')
|
||||||
|
setSearchResults([])
|
||||||
|
setSelectedTarget(null)
|
||||||
|
setAddLinkError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle closing add relationship modal
|
||||||
|
const closeAddRelationshipModal = () => {
|
||||||
|
setShowAddRelationshipModal(false)
|
||||||
|
setAddLinkError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle selecting a target requirement from search
|
||||||
|
const handleSelectTarget = (result: RequirementSearchResult) => {
|
||||||
|
setSelectedTarget(result)
|
||||||
|
setTargetSearchQuery(`${result.tag_code} - ${result.req_name}`)
|
||||||
|
setSearchResults([])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle creating a new relationship link
|
||||||
|
const handleCreateLink = async () => {
|
||||||
|
if (!selectedRelationshipType || !selectedTarget || !id) {
|
||||||
|
setAddLinkError('Please select a relationship type and target requirement')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setAddLinkLoading(true)
|
||||||
|
setAddLinkError(null)
|
||||||
|
|
||||||
|
const newLink = await relationshipService.createLink(parseInt(id, 10), {
|
||||||
|
relationship_type_id: selectedRelationshipType as number,
|
||||||
|
target_requirement_id: selectedTarget.id
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add to links list
|
||||||
|
setRelationshipLinks(prev => [newLink, ...prev])
|
||||||
|
|
||||||
|
// Close modal
|
||||||
|
closeAddRelationshipModal()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to create link:', err)
|
||||||
|
setAddLinkError(err instanceof Error ? err.message : 'Failed to create link. Please try again.')
|
||||||
|
} finally {
|
||||||
|
setAddLinkLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle deleting a relationship link
|
||||||
|
const handleDeleteLink = async (linkId: number) => {
|
||||||
|
if (!confirm('Are you sure you want to delete this relationship?')) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
setDeletingLinkId(linkId)
|
||||||
|
await relationshipService.deleteLink(linkId)
|
||||||
|
setRelationshipLinks(prev => prev.filter(link => link.id !== linkId))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete link:', err)
|
||||||
|
alert(err instanceof Error ? err.message : 'Failed to delete link')
|
||||||
|
} finally {
|
||||||
|
setDeletingLinkId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user can delete a link (creator or admin)
|
||||||
|
const canDeleteLink = (link: RequirementLink): boolean => {
|
||||||
|
if (!user) return false
|
||||||
|
return user.role_id === 1 || link.created_by_id === user.db_user_id
|
||||||
|
}
|
||||||
|
|
||||||
// Get validation status style
|
// Get validation status style
|
||||||
const getValidationStatusStyle = (status: string): string => {
|
const getValidationStatusStyle = (status: string): string => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@@ -151,8 +290,7 @@ export default function RequirementDetailPage() {
|
|||||||
|
|
||||||
const tabs: { id: TabType; label: string }[] = [
|
const tabs: { id: TabType; label: string }[] = [
|
||||||
{ id: 'description', label: 'Description' },
|
{ id: 'description', label: 'Description' },
|
||||||
{ id: 'sub-requirements', label: 'Sub-Requirements' },
|
{ id: 'relationships', label: 'Relationships' },
|
||||||
{ id: 'co-requirements', label: 'Co-Requirements' },
|
|
||||||
{ id: 'acceptance-criteria', label: 'Acceptance Criteria' },
|
{ id: 'acceptance-criteria', label: 'Acceptance Criteria' },
|
||||||
{ id: 'shared-comments', label: 'Shared Comments' },
|
{ id: 'shared-comments', label: 'Shared Comments' },
|
||||||
{ id: 'validate', label: 'Validate' },
|
{ id: 'validate', label: 'Validate' },
|
||||||
@@ -205,31 +343,100 @@ export default function RequirementDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
case 'sub-requirements':
|
case 'relationships':
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xl font-bold text-gray-800 mb-4">Sub-Requirements</h3>
|
<div className="flex items-center justify-between mb-4">
|
||||||
<p className="text-gray-500">No sub-requirements defined yet.</p>
|
<h3 className="text-xl font-bold text-gray-800">Relationships</h3>
|
||||||
{!isAuditor && (
|
{!isAuditor && (
|
||||||
<div className="mt-4">
|
<button
|
||||||
<button className="px-4 py-1.5 border border-gray-400 rounded text-sm text-gray-700 hover:bg-gray-50">
|
onClick={openAddRelationshipModal}
|
||||||
Add Sub-Requirement
|
disabled={relationshipTypes.length === 0}
|
||||||
|
className="px-4 py-1.5 bg-teal-600 text-white rounded text-sm font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
title={relationshipTypes.length === 0 ? 'No relationship types defined for this project. Contact an admin to set them up.' : ''}
|
||||||
|
>
|
||||||
|
Add Relationship
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
|
||||||
|
|
||||||
case 'co-requirements':
|
{relationshipTypes.length === 0 && !relationshipsLoading && (
|
||||||
return (
|
<div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded text-sm text-yellow-800">
|
||||||
<div>
|
No relationship types have been defined for this project. Contact an administrator to set up relationship types.
|
||||||
<h3 className="text-xl font-bold text-gray-800 mb-4">Co-Requirements</h3>
|
</div>
|
||||||
<p className="text-gray-500">No co-requirements defined yet.</p>
|
)}
|
||||||
{!isAuditor && (
|
|
||||||
<div className="mt-4">
|
{relationshipsLoading ? (
|
||||||
<button className="px-4 py-1.5 border border-gray-400 rounded text-sm text-gray-700 hover:bg-gray-50">
|
<div className="text-center py-8">
|
||||||
Add Co-Requirement
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-teal-600 mx-auto"></div>
|
||||||
|
<p className="mt-2 text-gray-500 text-sm">Loading relationships...</p>
|
||||||
|
</div>
|
||||||
|
) : relationshipLinks.length === 0 ? (
|
||||||
|
<p className="text-gray-500 text-center py-8">No relationships defined yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full border border-gray-200 rounded">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Direction</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Type</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Linked Requirement</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Created By</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{relationshipLinks.map((link) => (
|
||||||
|
<tr key={link.id}>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-700">
|
||||||
|
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
|
||||||
|
link.direction === 'outgoing'
|
||||||
|
? 'bg-blue-100 text-blue-800'
|
||||||
|
: 'bg-purple-100 text-purple-800'
|
||||||
|
}`}>
|
||||||
|
{link.direction === 'outgoing' ? '→ Outgoing' : '← Incoming'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-700">
|
||||||
|
{link.type_name}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm">
|
||||||
|
<Link
|
||||||
|
to={`/requirements/${link.linked_requirement.id}`}
|
||||||
|
className="text-teal-600 hover:underline"
|
||||||
|
>
|
||||||
|
{link.linked_requirement.tag_code} - {link.linked_requirement.req_name}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-700">
|
||||||
|
{link.created_by_username ? `@${link.created_by_username}` : <span className="text-gray-400 italic">Unknown</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-700">
|
||||||
|
{link.created_at
|
||||||
|
? new Date(link.created_at).toLocaleDateString()
|
||||||
|
: 'N/A'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm">
|
||||||
|
{canDeleteLink(link) && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteLink(link.id)}
|
||||||
|
disabled={deletingLinkId === link.id}
|
||||||
|
className="text-red-600 hover:text-red-800 disabled:opacity-50"
|
||||||
|
title="Delete relationship"
|
||||||
|
>
|
||||||
|
{deletingLinkId === link.id ? (
|
||||||
|
<span className="animate-spin inline-block">⏳</span>
|
||||||
|
) : (
|
||||||
|
'🗑️'
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -552,6 +759,121 @@ export default function RequirementDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Add Relationship Modal */}
|
||||||
|
{showAddRelationshipModal && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl w-full max-w-lg mx-4">
|
||||||
|
{/* Modal Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-800">Add Relationship</h2>
|
||||||
|
<button
|
||||||
|
onClick={closeAddRelationshipModal}
|
||||||
|
className="text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Body */}
|
||||||
|
<div className="px-6 py-4 space-y-4">
|
||||||
|
{addLinkError && (
|
||||||
|
<div className="p-3 bg-red-100 border border-red-400 text-red-700 rounded text-sm">
|
||||||
|
{addLinkError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Relationship Type Selection */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Relationship Type <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedRelationshipType}
|
||||||
|
onChange={(e) => setSelectedRelationshipType(e.target.value ? Number(e.target.value) : '')}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent"
|
||||||
|
disabled={addLinkLoading}
|
||||||
|
>
|
||||||
|
<option value="">Select a relationship type...</option>
|
||||||
|
{relationshipTypes.map((type) => (
|
||||||
|
<option key={type.id} value={type.id}>
|
||||||
|
{type.type_name}
|
||||||
|
{type.inverse_type_name && ` (inverse: ${type.inverse_type_name})`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Target Requirement Search */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Target Requirement <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={targetSearchQuery}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTargetSearchQuery(e.target.value)
|
||||||
|
setSelectedTarget(null)
|
||||||
|
}}
|
||||||
|
placeholder="Search by tag code or name..."
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent"
|
||||||
|
disabled={addLinkLoading}
|
||||||
|
/>
|
||||||
|
{searchLoading && (
|
||||||
|
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-teal-600"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Search Results Dropdown */}
|
||||||
|
{searchResults.length > 0 && !selectedTarget && (
|
||||||
|
<div className="absolute z-10 w-full mt-1 bg-white border border-gray-300 rounded shadow-lg max-h-48 overflow-y-auto">
|
||||||
|
{searchResults.map((result) => (
|
||||||
|
<button
|
||||||
|
key={result.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelectTarget(result)}
|
||||||
|
className="w-full px-3 py-2 text-left text-sm hover:bg-gray-100 focus:bg-gray-100 focus:outline-none"
|
||||||
|
>
|
||||||
|
<span className="font-medium text-teal-600">{result.tag_code}</span>
|
||||||
|
<span className="text-gray-600"> - {result.req_name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{selectedTarget && (
|
||||||
|
<p className="mt-1 text-sm text-green-600">
|
||||||
|
✓ Selected: {selectedTarget.tag_code} - {selectedTarget.req_name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Footer */}
|
||||||
|
<div className="flex justify-end gap-3 px-6 py-4 border-t border-gray-200 bg-gray-50 rounded-b-lg">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={closeAddRelationshipModal}
|
||||||
|
className="px-4 py-2 border border-gray-300 rounded text-sm text-gray-700 hover:bg-gray-100"
|
||||||
|
disabled={addLinkLoading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCreateLink}
|
||||||
|
disabled={addLinkLoading || !selectedRelationshipType || !selectedTarget}
|
||||||
|
className="px-4 py-2 bg-teal-600 text-white rounded text-sm font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{addLinkLoading ? 'Creating...' : 'Create Relationship'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,3 +10,10 @@ export type { Requirement, RequirementCreateRequest, RequirementUpdateRequest }
|
|||||||
export { projectService } from './projectService'
|
export { projectService } from './projectService'
|
||||||
export type { Project, ProjectCreateRequest, ProjectUpdateRequest } from './projectService'
|
export type { Project, ProjectCreateRequest, ProjectUpdateRequest } from './projectService'
|
||||||
export { validationService } from './validationService'
|
export { validationService } from './validationService'
|
||||||
|
export { relationshipService } from './relationshipService'
|
||||||
|
export type {
|
||||||
|
RelationshipType,
|
||||||
|
RequirementLink,
|
||||||
|
RequirementSearchResult,
|
||||||
|
RequirementLinkCreateRequest
|
||||||
|
} from './relationshipService'
|
||||||
|
|||||||
154
frontend/src/services/relationshipService.ts
Normal file
154
frontend/src/services/relationshipService.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
const API_BASE_URL = '/api'
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export interface RelationshipType {
|
||||||
|
id: number
|
||||||
|
project_id: number
|
||||||
|
type_name: string
|
||||||
|
type_description: string | null
|
||||||
|
inverse_type_name: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LinkedRequirementInfo {
|
||||||
|
id: number
|
||||||
|
req_name: string
|
||||||
|
tag_code: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequirementLink {
|
||||||
|
id: number
|
||||||
|
direction: 'outgoing' | 'incoming'
|
||||||
|
type_name: string
|
||||||
|
type_id: number
|
||||||
|
inverse_type_name: string | null
|
||||||
|
linked_requirement: LinkedRequirementInfo
|
||||||
|
created_by_username: string | null
|
||||||
|
created_by_id: number | null
|
||||||
|
created_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequirementSearchResult {
|
||||||
|
id: number
|
||||||
|
req_name: string
|
||||||
|
tag_code: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequirementLinkCreateRequest {
|
||||||
|
relationship_type_id: number
|
||||||
|
target_requirement_id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
class RelationshipService {
|
||||||
|
/**
|
||||||
|
* Get all relationship types for a project.
|
||||||
|
*/
|
||||||
|
async getRelationshipTypes(projectId: number): Promise<RelationshipType[]> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/relationship-types`, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search requirements by name or tag code (for autocomplete).
|
||||||
|
*/
|
||||||
|
async searchRequirements(
|
||||||
|
projectId: number,
|
||||||
|
query: string,
|
||||||
|
excludeId?: number
|
||||||
|
): Promise<RequirementSearchResult[]> {
|
||||||
|
const params = new URLSearchParams({ q: query })
|
||||||
|
if (excludeId !== undefined) {
|
||||||
|
params.append('exclude_id', excludeId.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_BASE_URL}/projects/${projectId}/requirements/search?${params}`,
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all links for a requirement (both outgoing and incoming).
|
||||||
|
*/
|
||||||
|
async getRequirementLinks(requirementId: number): Promise<RequirementLink[]> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/requirements/${requirementId}/links`, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new link from a requirement to another.
|
||||||
|
*/
|
||||||
|
async createLink(
|
||||||
|
requirementId: number,
|
||||||
|
data: RequirementLinkCreateRequest
|
||||||
|
): Promise<RequirementLink> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/requirements/${requirementId}/links`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}))
|
||||||
|
throw new Error(errorData.detail || `HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a requirement link.
|
||||||
|
*/
|
||||||
|
async deleteLink(linkId: number): Promise<void> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/requirement-links/${linkId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}))
|
||||||
|
throw new Error(errorData.detail || `HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const relationshipService = new RelationshipService()
|
||||||
@@ -4,6 +4,7 @@ export interface User {
|
|||||||
full_name: string | null
|
full_name: string | null
|
||||||
role: string | null
|
role: string | null
|
||||||
role_id: number | null
|
role_id: number | null
|
||||||
|
db_user_id: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthContextType {
|
export interface AuthContextType {
|
||||||
|
|||||||
Reference in New Issue
Block a user