added acceptance criteria page
This commit is contained in:
@@ -62,6 +62,10 @@ class User(Base):
|
|||||||
created_links: Mapped[List["RequirementLink"]] = relationship("RequirementLink", back_populates="creator")
|
created_links: Mapped[List["RequirementLink"]] = relationship("RequirementLink", back_populates="creator")
|
||||||
comments: Mapped[List["RequirementComment"]] = relationship("RequirementComment", back_populates="user")
|
comments: Mapped[List["RequirementComment"]] = relationship("RequirementComment", back_populates="user")
|
||||||
comment_replies: Mapped[List["RequirementCommentReply"]] = relationship("RequirementCommentReply", back_populates="user")
|
comment_replies: Mapped[List["RequirementCommentReply"]] = relationship("RequirementCommentReply", back_populates="user")
|
||||||
|
acceptance_criteria_edits: Mapped[List["AcceptanceCriteria"]] = relationship(
|
||||||
|
"AcceptanceCriteria",
|
||||||
|
back_populates="last_editor"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Tag(Base):
|
class Tag(Base):
|
||||||
@@ -261,6 +265,11 @@ class Requirement(Base):
|
|||||||
back_populates="requirement",
|
back_populates="requirement",
|
||||||
cascade="all, delete-orphan"
|
cascade="all, delete-orphan"
|
||||||
)
|
)
|
||||||
|
acceptance_criteria: Mapped[List["AcceptanceCriteria"]] = relationship(
|
||||||
|
"AcceptanceCriteria",
|
||||||
|
back_populates="requirement",
|
||||||
|
cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
# Indexes
|
# Indexes
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
@@ -293,6 +302,45 @@ class RequirementGroup(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AcceptanceCriteria(Base):
|
||||||
|
"""Acceptance criteria for requirements."""
|
||||||
|
__tablename__ = "acceptance_criteria"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
requirement_id: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("requirements.id", ondelete="CASCADE"),
|
||||||
|
nullable=False
|
||||||
|
)
|
||||||
|
criteria_text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
is_accepted: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=datetime.utcnow,
|
||||||
|
nullable=True
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=datetime.utcnow,
|
||||||
|
onupdate=datetime.utcnow,
|
||||||
|
nullable=True
|
||||||
|
)
|
||||||
|
last_editor_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("users.id"),
|
||||||
|
nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
requirement: Mapped["Requirement"] = relationship("Requirement", back_populates="acceptance_criteria")
|
||||||
|
last_editor: Mapped[Optional["User"]] = relationship("User", back_populates="acceptance_criteria_edits")
|
||||||
|
|
||||||
|
# Indexes
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_ac_requirement", "requirement_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Validation(Base):
|
class Validation(Base):
|
||||||
"""Validation records for requirements."""
|
"""Validation records for requirements."""
|
||||||
__tablename__ = "validations"
|
__tablename__ = "validations"
|
||||||
@@ -344,6 +392,28 @@ class RequirementHistory(Base):
|
|||||||
edited_by: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
edited_by: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class AcceptanceCriteriaHistory(Base):
|
||||||
|
"""
|
||||||
|
Historical records of acceptance criteria changes.
|
||||||
|
Note: This is populated by a database trigger on UPDATE/DELETE.
|
||||||
|
"""
|
||||||
|
__tablename__ = "acceptance_criteria_history"
|
||||||
|
|
||||||
|
history_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
original_ac_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
requirement_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||||
|
criteria_text: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
is_accepted: Mapped[Optional[bool]] = mapped_column(Boolean, 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)
|
||||||
|
edited_by: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||||
|
|
||||||
|
# Indexes
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_ach_req", "requirement_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class RelationshipType(Base):
|
class RelationshipType(Base):
|
||||||
"""
|
"""
|
||||||
Defines valid relationship types per project.
|
Defines valid relationship types per project.
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from src.models import (
|
|||||||
RoleResponse, ProjectMemberResponse, UserRoleUpdateRequest, ROLE_DISPLAY_NAMES,
|
RoleResponse, ProjectMemberResponse, UserRoleUpdateRequest, ROLE_DISPLAY_NAMES,
|
||||||
CommentResponse, CommentReplyResponse, CommentCreateRequest, ReplyCreateRequest,
|
CommentResponse, CommentReplyResponse, CommentCreateRequest, ReplyCreateRequest,
|
||||||
RequirementStatusResponse, DeletedRequirementResponse,
|
RequirementStatusResponse, DeletedRequirementResponse,
|
||||||
|
AcceptanceCriteriaResponse, AcceptanceCriteriaCreateRequest, AcceptanceCriteriaUpdateRequest, AcceptanceCriteriaHistoryResponse,
|
||||||
UserCreateRequest, UserCreateResponse,
|
UserCreateRequest, UserCreateResponse,
|
||||||
SystemUserResponse, SystemProjectResponse, SystemProjectMemberResponse,
|
SystemUserResponse, SystemProjectResponse, SystemProjectMemberResponse,
|
||||||
AssignUserToProjectRequest, SystemUserCreateRequest
|
AssignUserToProjectRequest, SystemUserCreateRequest
|
||||||
@@ -29,7 +30,7 @@ from src.repositories import (
|
|||||||
RoleRepository, GroupRepository, TagRepository, RequirementRepository,
|
RoleRepository, GroupRepository, TagRepository, RequirementRepository,
|
||||||
PriorityRepository, ProjectRepository, ValidationStatusRepository, ValidationRepository,
|
PriorityRepository, ProjectRepository, ValidationStatusRepository, ValidationRepository,
|
||||||
RelationshipTypeRepository, RequirementLinkRepository, CommentRepository, ReplyRepository,
|
RelationshipTypeRepository, RequirementLinkRepository, CommentRepository, ReplyRepository,
|
||||||
RequirementStatusRepository, UserRepository
|
RequirementStatusRepository, UserRepository, AcceptanceCriteriaRepository
|
||||||
)
|
)
|
||||||
from src.service import KeycloakAdminService
|
from src.service import KeycloakAdminService
|
||||||
import logging
|
import logging
|
||||||
@@ -1842,6 +1843,211 @@ async def get_requirement_current_groups(
|
|||||||
return [CurrentRequirementGroupResponse(**g) for g in groups]
|
return [CurrentRequirementGroupResponse(**g) for g in groups]
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# Acceptance Criteria Endpoints
|
||||||
|
# ===========================================
|
||||||
|
|
||||||
|
def _build_acceptance_criteria_response(criteria) -> AcceptanceCriteriaResponse:
|
||||||
|
return AcceptanceCriteriaResponse(
|
||||||
|
id=criteria.id,
|
||||||
|
requirement_id=criteria.requirement_id,
|
||||||
|
criteria_text=criteria.criteria_text,
|
||||||
|
is_accepted=criteria.is_accepted,
|
||||||
|
created_at=criteria.created_at,
|
||||||
|
updated_at=criteria.updated_at,
|
||||||
|
last_editor_username=_get_display_name(criteria.last_editor) if getattr(criteria, "last_editor", None) else None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/requirements/{requirement_id}/acceptance-criteria", response_model=List[AcceptanceCriteriaResponse])
|
||||||
|
async def get_acceptance_criteria(
|
||||||
|
requirement_id: int,
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get all acceptance criteria for a requirement.
|
||||||
|
User must be a member of the requirement's project.
|
||||||
|
"""
|
||||||
|
user = await _get_current_user_db(request, db)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
criteria_repo = AcceptanceCriteriaRepository(db)
|
||||||
|
criteria = await criteria_repo.get_by_requirement_id(requirement_id)
|
||||||
|
|
||||||
|
return [_build_acceptance_criteria_response(c) for c in criteria]
|
||||||
|
|
||||||
|
|
||||||
|
@app.post(
|
||||||
|
"/api/requirements/{requirement_id}/acceptance-criteria",
|
||||||
|
response_model=AcceptanceCriteriaResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED
|
||||||
|
)
|
||||||
|
async def create_acceptance_criteria(
|
||||||
|
requirement_id: int,
|
||||||
|
request: Request,
|
||||||
|
criteria_data: AcceptanceCriteriaCreateRequest,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Create a new acceptance criterion for a requirement.
|
||||||
|
Only editors and admins can create criteria.
|
||||||
|
"""
|
||||||
|
user = await _get_current_user_db(request, db)
|
||||||
|
_require_role(user, [1, 3], "create acceptance criteria")
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
if not criteria_data.criteria_text.strip():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Criteria text cannot be empty"
|
||||||
|
)
|
||||||
|
|
||||||
|
criteria_repo = AcceptanceCriteriaRepository(db)
|
||||||
|
criteria = await criteria_repo.create(
|
||||||
|
requirement_id=requirement_id,
|
||||||
|
criteria_text=criteria_data.criteria_text.strip(),
|
||||||
|
editor_id=user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
criteria = await criteria_repo.get_by_id(criteria.id)
|
||||||
|
|
||||||
|
return _build_acceptance_criteria_response(criteria)
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/acceptance-criteria/{criteria_id}", response_model=AcceptanceCriteriaResponse)
|
||||||
|
async def update_acceptance_criteria(
|
||||||
|
criteria_id: int,
|
||||||
|
request: Request,
|
||||||
|
criteria_data: AcceptanceCriteriaUpdateRequest,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Update an acceptance criterion (text and/or accepted flag).
|
||||||
|
Only editors and admins can update criteria.
|
||||||
|
"""
|
||||||
|
user = await _get_current_user_db(request, db)
|
||||||
|
_require_role(user, [1, 3], "update acceptance criteria")
|
||||||
|
|
||||||
|
criteria_repo = AcceptanceCriteriaRepository(db)
|
||||||
|
existing = await criteria_repo.get_by_id(criteria_id)
|
||||||
|
if not existing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Acceptance criteria with id {criteria_id} not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
req_repo = RequirementRepository(db)
|
||||||
|
requirement = await req_repo.get_by_id(existing.requirement_id)
|
||||||
|
await _verify_project_membership(requirement.project_id, user.id, db)
|
||||||
|
|
||||||
|
if criteria_data.criteria_text is None and criteria_data.is_accepted is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="No fields provided to update"
|
||||||
|
)
|
||||||
|
|
||||||
|
criteria_text = criteria_data.criteria_text
|
||||||
|
if criteria_text is not None:
|
||||||
|
criteria_text = criteria_text.strip()
|
||||||
|
if not criteria_text:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Criteria text cannot be empty"
|
||||||
|
)
|
||||||
|
|
||||||
|
await criteria_repo.update(
|
||||||
|
criteria_id=criteria_id,
|
||||||
|
editor_id=user.id,
|
||||||
|
criteria_text=criteria_text,
|
||||||
|
is_accepted=criteria_data.is_accepted
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
updated = await criteria_repo.get_by_id(criteria_id)
|
||||||
|
|
||||||
|
return _build_acceptance_criteria_response(updated)
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/acceptance-criteria/{criteria_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_acceptance_criteria(
|
||||||
|
criteria_id: int,
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Delete an acceptance criterion.
|
||||||
|
Only editors and admins can delete criteria.
|
||||||
|
"""
|
||||||
|
user = await _get_current_user_db(request, db)
|
||||||
|
_require_role(user, [1, 3], "delete acceptance criteria")
|
||||||
|
|
||||||
|
criteria_repo = AcceptanceCriteriaRepository(db)
|
||||||
|
existing = await criteria_repo.get_by_id(criteria_id)
|
||||||
|
if not existing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Acceptance criteria with id {criteria_id} not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
req_repo = RequirementRepository(db)
|
||||||
|
requirement = await req_repo.get_by_id(existing.requirement_id)
|
||||||
|
await _verify_project_membership(requirement.project_id, user.id, db)
|
||||||
|
|
||||||
|
await criteria_repo.delete(criteria_id)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get(
|
||||||
|
"/api/requirements/{requirement_id}/acceptance-criteria/history",
|
||||||
|
response_model=List[AcceptanceCriteriaHistoryResponse]
|
||||||
|
)
|
||||||
|
async def get_acceptance_criteria_history(
|
||||||
|
requirement_id: int,
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get acceptance criteria history for a requirement.
|
||||||
|
Returns update/delete history with editor info.
|
||||||
|
"""
|
||||||
|
user = await _get_current_user_db(request, db)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
criteria_repo = AcceptanceCriteriaRepository(db)
|
||||||
|
history = await criteria_repo.get_history_by_requirement_id(requirement_id)
|
||||||
|
|
||||||
|
return [AcceptanceCriteriaHistoryResponse(**h) for h in history]
|
||||||
|
|
||||||
|
|
||||||
# ===========================================
|
# ===========================================
|
||||||
# Comment Endpoints
|
# Comment Endpoints
|
||||||
# ===========================================
|
# ===========================================
|
||||||
|
|||||||
@@ -374,6 +374,47 @@ class RequirementLinkHistoryResponse(BaseModel):
|
|||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# Acceptance Criteria schemas
|
||||||
|
class AcceptanceCriteriaResponse(BaseModel):
|
||||||
|
"""Response schema for acceptance criteria."""
|
||||||
|
id: int
|
||||||
|
requirement_id: int
|
||||||
|
criteria_text: str
|
||||||
|
is_accepted: bool
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
last_editor_username: Optional[str] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class AcceptanceCriteriaCreateRequest(BaseModel):
|
||||||
|
"""Request schema for creating acceptance criteria."""
|
||||||
|
criteria_text: str
|
||||||
|
|
||||||
|
|
||||||
|
class AcceptanceCriteriaUpdateRequest(BaseModel):
|
||||||
|
"""Request schema for updating acceptance criteria."""
|
||||||
|
criteria_text: Optional[str] = None
|
||||||
|
is_accepted: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AcceptanceCriteriaHistoryResponse(BaseModel):
|
||||||
|
"""Response schema for acceptance criteria history."""
|
||||||
|
history_id: int
|
||||||
|
original_ac_id: int
|
||||||
|
requirement_id: int
|
||||||
|
criteria_text: Optional[str] = None
|
||||||
|
is_accepted: Optional[bool] = None
|
||||||
|
valid_from: Optional[datetime] = None
|
||||||
|
valid_to: Optional[datetime] = None
|
||||||
|
edited_by_username: Optional[str] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
# Requirement Search schemas
|
# Requirement Search schemas
|
||||||
class RequirementSearchResult(BaseModel):
|
class RequirementSearchResult(BaseModel):
|
||||||
"""Response schema for requirement search results (for autocomplete)."""
|
"""Response schema for requirement search results (for autocomplete)."""
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from src.repositories.relationship_type_repository import RelationshipTypeReposi
|
|||||||
from src.repositories.requirement_link_repository import RequirementLinkRepository
|
from src.repositories.requirement_link_repository import RequirementLinkRepository
|
||||||
from src.repositories.comment_repository import CommentRepository, ReplyRepository
|
from src.repositories.comment_repository import CommentRepository, ReplyRepository
|
||||||
from src.repositories.requirement_status_repository import RequirementStatusRepository
|
from src.repositories.requirement_status_repository import RequirementStatusRepository
|
||||||
|
from src.repositories.acceptance_criteria_repository import AcceptanceCriteriaRepository
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"UserRepository",
|
"UserRepository",
|
||||||
@@ -29,4 +30,5 @@ __all__ = [
|
|||||||
"CommentRepository",
|
"CommentRepository",
|
||||||
"ReplyRepository",
|
"ReplyRepository",
|
||||||
"RequirementStatusRepository",
|
"RequirementStatusRepository",
|
||||||
|
"AcceptanceCriteriaRepository",
|
||||||
]
|
]
|
||||||
|
|||||||
117
backend/src/repositories/acceptance_criteria_repository.py
Normal file
117
backend/src/repositories/acceptance_criteria_repository.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
"""
|
||||||
|
Repository for Acceptance Criteria database operations.
|
||||||
|
"""
|
||||||
|
from typing import List, Optional, Dict, Any
|
||||||
|
from sqlalchemy import select, text
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from src.db_models import AcceptanceCriteria
|
||||||
|
|
||||||
|
|
||||||
|
class AcceptanceCriteriaRepository:
|
||||||
|
"""Repository for acceptance criteria CRUD and history operations."""
|
||||||
|
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def get_by_requirement_id(self, requirement_id: int) -> List[AcceptanceCriteria]:
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(AcceptanceCriteria)
|
||||||
|
.options(selectinload(AcceptanceCriteria.last_editor))
|
||||||
|
.where(AcceptanceCriteria.requirement_id == requirement_id)
|
||||||
|
.order_by(AcceptanceCriteria.created_at.asc())
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def get_by_id(self, criteria_id: int) -> Optional[AcceptanceCriteria]:
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(AcceptanceCriteria)
|
||||||
|
.options(selectinload(AcceptanceCriteria.last_editor))
|
||||||
|
.where(AcceptanceCriteria.id == criteria_id)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def create(
|
||||||
|
self,
|
||||||
|
requirement_id: int,
|
||||||
|
criteria_text: str,
|
||||||
|
editor_id: int
|
||||||
|
) -> AcceptanceCriteria:
|
||||||
|
criteria = AcceptanceCriteria(
|
||||||
|
requirement_id=requirement_id,
|
||||||
|
criteria_text=criteria_text,
|
||||||
|
is_accepted=False,
|
||||||
|
last_editor_id=editor_id
|
||||||
|
)
|
||||||
|
self.db.add(criteria)
|
||||||
|
await self.db.flush()
|
||||||
|
await self.db.refresh(criteria)
|
||||||
|
return criteria
|
||||||
|
|
||||||
|
async def update(
|
||||||
|
self,
|
||||||
|
criteria_id: int,
|
||||||
|
editor_id: int,
|
||||||
|
criteria_text: Optional[str] = None,
|
||||||
|
is_accepted: Optional[bool] = None
|
||||||
|
) -> Optional[AcceptanceCriteria]:
|
||||||
|
criteria = await self.get_by_id(criteria_id)
|
||||||
|
if not criteria:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if criteria_text is not None:
|
||||||
|
criteria.criteria_text = criteria_text
|
||||||
|
if is_accepted is not None:
|
||||||
|
criteria.is_accepted = is_accepted
|
||||||
|
|
||||||
|
criteria.last_editor_id = editor_id
|
||||||
|
|
||||||
|
await self.db.flush()
|
||||||
|
await self.db.refresh(criteria)
|
||||||
|
return criteria
|
||||||
|
|
||||||
|
async def delete(self, criteria_id: int) -> bool:
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(AcceptanceCriteria).where(AcceptanceCriteria.id == criteria_id)
|
||||||
|
)
|
||||||
|
criteria = result.scalar_one_or_none()
|
||||||
|
if criteria:
|
||||||
|
await self.db.delete(criteria)
|
||||||
|
await self.db.flush()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def get_history_by_requirement_id(self, requirement_id: int) -> List[Dict[str, Any]]:
|
||||||
|
query = text("""
|
||||||
|
SELECT
|
||||||
|
ach.history_id,
|
||||||
|
ach.original_ac_id,
|
||||||
|
ach.requirement_id,
|
||||||
|
ach.criteria_text,
|
||||||
|
ach.is_accepted,
|
||||||
|
ach.valid_from,
|
||||||
|
ach.valid_to,
|
||||||
|
u.full_name as edited_by_full_name,
|
||||||
|
u.username as edited_by_username
|
||||||
|
FROM acceptance_criteria_history ach
|
||||||
|
LEFT JOIN users u ON ach.edited_by = u.id
|
||||||
|
WHERE ach.requirement_id = :requirement_id
|
||||||
|
ORDER BY ach.valid_to DESC
|
||||||
|
""")
|
||||||
|
|
||||||
|
result = await self.db.execute(query, {"requirement_id": requirement_id})
|
||||||
|
rows = result.fetchall()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"history_id": row.history_id,
|
||||||
|
"original_ac_id": row.original_ac_id,
|
||||||
|
"requirement_id": row.requirement_id,
|
||||||
|
"criteria_text": row.criteria_text,
|
||||||
|
"is_accepted": row.is_accepted,
|
||||||
|
"valid_from": row.valid_from,
|
||||||
|
"valid_to": row.valid_to,
|
||||||
|
"edited_by_username": row.edited_by_full_name or row.edited_by_username,
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
@@ -55,7 +55,14 @@
|
|||||||
"acceptanceCriteria": {
|
"acceptanceCriteria": {
|
||||||
"title": "Acceptance Criteria",
|
"title": "Acceptance Criteria",
|
||||||
"noCriteria": "No acceptance criteria defined yet.",
|
"noCriteria": "No acceptance criteria defined yet.",
|
||||||
"addButton": "Add Criterion"
|
"addButton": "Add Criterion",
|
||||||
|
"placeholder": "Describe a new criterion...",
|
||||||
|
"loading": "Loading acceptance criteria...",
|
||||||
|
"creating": "Creating...",
|
||||||
|
"editButton": "Edit",
|
||||||
|
"saveButton": "Save",
|
||||||
|
"cancelButton": "Cancel",
|
||||||
|
"deleteButton": "Delete"
|
||||||
},
|
},
|
||||||
"comments": {
|
"comments": {
|
||||||
"title": "Shared Comments",
|
"title": "Shared Comments",
|
||||||
@@ -117,7 +124,14 @@
|
|||||||
"showLess": "Show less",
|
"showLess": "Show less",
|
||||||
"showFullDiff": "Show full diff",
|
"showFullDiff": "Show full diff",
|
||||||
"deletedRequirement": "Deleted Requirement",
|
"deletedRequirement": "Deleted Requirement",
|
||||||
"unknownGroup": "Unknown Group"
|
"unknownGroup": "Unknown Group",
|
||||||
|
"criteriaAdded": "Criterion Added",
|
||||||
|
"criteriaUpdated": "Criterion Updated",
|
||||||
|
"criteriaDeleted": "Criterion Deleted",
|
||||||
|
"criteriaAccepted": "Accepted",
|
||||||
|
"criteriaNotAccepted": "Not accepted",
|
||||||
|
"criteriaUnknown": "Unknown",
|
||||||
|
"criteriaEmpty": "Empty"
|
||||||
},
|
},
|
||||||
"addRelationshipModal": {
|
"addRelationshipModal": {
|
||||||
"title": "Add Relationship",
|
"title": "Add Relationship",
|
||||||
@@ -151,6 +165,8 @@
|
|||||||
"deleteRelationship": "Delete Relationship",
|
"deleteRelationship": "Delete Relationship",
|
||||||
"confirmDeleteComment": "Are you sure you want to delete this comment? This will also hide all replies.",
|
"confirmDeleteComment": "Are you sure you want to delete this comment? This will also hide all replies.",
|
||||||
"confirmDeleteReply": "Are you sure you want to delete this reply?",
|
"confirmDeleteReply": "Are you sure you want to delete this reply?",
|
||||||
"confirmDeleteRelationship": "Are you sure you want to delete this relationship? This action cannot be undone."
|
"confirmDeleteRelationship": "Are you sure you want to delete this relationship? This action cannot be undone.",
|
||||||
|
"deleteCriteria": "Delete Criterion",
|
||||||
|
"confirmDeleteCriteria": "Are you sure you want to delete this criterion? This action cannot be undone."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,14 @@
|
|||||||
"acceptanceCriteria": {
|
"acceptanceCriteria": {
|
||||||
"title": "Critérios de Aceitação",
|
"title": "Critérios de Aceitação",
|
||||||
"noCriteria": "Nenhum critério de aceitação definido ainda.",
|
"noCriteria": "Nenhum critério de aceitação definido ainda.",
|
||||||
"addButton": "Adicionar Critério"
|
"addButton": "Adicionar Critério",
|
||||||
|
"placeholder": "Descreva um novo critério...",
|
||||||
|
"loading": "Carregando critérios de aceitação...",
|
||||||
|
"creating": "Criando...",
|
||||||
|
"editButton": "Editar",
|
||||||
|
"saveButton": "Salvar",
|
||||||
|
"cancelButton": "Cancelar",
|
||||||
|
"deleteButton": "Excluir"
|
||||||
},
|
},
|
||||||
"comments": {
|
"comments": {
|
||||||
"title": "Comentários Compartilhados",
|
"title": "Comentários Compartilhados",
|
||||||
@@ -117,7 +124,14 @@
|
|||||||
"showLess": "Mostrar menos",
|
"showLess": "Mostrar menos",
|
||||||
"showFullDiff": "Mostrar diferença completa",
|
"showFullDiff": "Mostrar diferença completa",
|
||||||
"deletedRequirement": "Requisito Excluído",
|
"deletedRequirement": "Requisito Excluído",
|
||||||
"unknownGroup": "Grupo Desconhecido"
|
"unknownGroup": "Grupo Desconhecido",
|
||||||
|
"criteriaAdded": "Critério Adicionado",
|
||||||
|
"criteriaUpdated": "Critério Atualizado",
|
||||||
|
"criteriaDeleted": "Critério Excluído",
|
||||||
|
"criteriaAccepted": "Aceito",
|
||||||
|
"criteriaNotAccepted": "Não aceito",
|
||||||
|
"criteriaUnknown": "Desconhecido",
|
||||||
|
"criteriaEmpty": "Vazio"
|
||||||
},
|
},
|
||||||
"addRelationshipModal": {
|
"addRelationshipModal": {
|
||||||
"title": "Adicionar Relacionamento",
|
"title": "Adicionar Relacionamento",
|
||||||
@@ -151,6 +165,8 @@
|
|||||||
"deleteRelationship": "Excluir Relacionamento",
|
"deleteRelationship": "Excluir Relacionamento",
|
||||||
"confirmDeleteComment": "Tem certeza de que deseja excluir este comentário? Isso também ocultará todas as respostas.",
|
"confirmDeleteComment": "Tem certeza de que deseja excluir este comentário? Isso também ocultará todas as respostas.",
|
||||||
"confirmDeleteReply": "Tem certeza de que deseja excluir esta resposta?",
|
"confirmDeleteReply": "Tem certeza de que deseja excluir esta resposta?",
|
||||||
"confirmDeleteRelationship": "Tem certeza de que deseja excluir este relacionamento? Esta ação não pode ser desfeita."
|
"confirmDeleteRelationship": "Tem certeza de que deseja excluir este relacionamento? Esta ação não pode ser desfeita.",
|
||||||
|
"deleteCriteria": "Excluir Critério",
|
||||||
|
"confirmDeleteCriteria": "Tem certeza de que deseja excluir este critério? Esta ação não pode ser desfeita."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,20 +2,20 @@ import { useState, useEffect } from 'react'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useAuth, useProject } from '@/hooks'
|
import { useAuth, useProject } from '@/hooks'
|
||||||
import { useParams, Link, useNavigate } from 'react-router-dom'
|
import { useParams, Link, useNavigate } from 'react-router-dom'
|
||||||
import { requirementService, validationService, relationshipService, commentService, tagService, priorityService, groupService, requirementStatusService } from '@/services'
|
import { requirementService, validationService, relationshipService, commentService, tagService, priorityService, groupService, requirementStatusService, acceptanceCriteriaService } from '@/services'
|
||||||
import { LanguageSelector } from '@/components'
|
import { LanguageSelector } from '@/components'
|
||||||
import type { Requirement } from '@/services/requirementService'
|
import type { Requirement } from '@/services/requirementService'
|
||||||
import type { RelationshipType, RequirementLink, RequirementSearchResult } from '@/services/relationshipService'
|
import type { RelationshipType, RequirementLink, RequirementSearchResult } from '@/services/relationshipService'
|
||||||
import type { Tag } from '@/services/tagService'
|
import type { Tag } from '@/services/tagService'
|
||||||
import type { Priority } from '@/services/priorityService'
|
import type { Priority } from '@/services/priorityService'
|
||||||
import type { Group } from '@/services/groupService'
|
import type { Group } from '@/services/groupService'
|
||||||
import type { ValidationStatus, ValidationHistory, Comment, RequirementHistory, RequirementStatus } from '@/types'
|
import type { ValidationStatus, ValidationHistory, Comment, RequirementHistory, RequirementStatus, AcceptanceCriteria, AcceptanceCriteriaHistory } from '@/types'
|
||||||
|
|
||||||
// Tab types
|
// Tab types
|
||||||
type TabType = 'description' | 'relationships' | 'acceptance-criteria' | 'shared-comments' | 'validate' | 'history'
|
type TabType = 'description' | 'relationships' | 'acceptance-criteria' | 'shared-comments' | 'validate' | 'history'
|
||||||
|
|
||||||
// Timeline event types for unified history view
|
// Timeline event types for unified history view
|
||||||
type TimelineEventType = 'requirement_edit' | 'link_created' | 'link_removed' | 'group_added' | 'group_removed'
|
type TimelineEventType = 'requirement_edit' | 'link_created' | 'link_removed' | 'group_added' | 'group_removed' | 'criteria_added' | 'criteria_updated' | 'criteria_deleted'
|
||||||
|
|
||||||
interface TimelineEvent {
|
interface TimelineEvent {
|
||||||
id: string
|
id: string
|
||||||
@@ -39,6 +39,15 @@ interface TimelineEvent {
|
|||||||
groupName: string | null
|
groupName: string | null
|
||||||
groupHexColor: string | null
|
groupHexColor: string | null
|
||||||
}
|
}
|
||||||
|
// For acceptance criteria events
|
||||||
|
criteriaData?: {
|
||||||
|
criteriaId: number
|
||||||
|
oldText: string | null
|
||||||
|
newText: string | null
|
||||||
|
oldAccepted: boolean | null
|
||||||
|
newAccepted: boolean | null
|
||||||
|
editedByUsername: string | null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RequirementDetailPage() {
|
export default function RequirementDetailPage() {
|
||||||
@@ -87,10 +96,22 @@ export default function RequirementDetailPage() {
|
|||||||
const [deletingCommentId, setDeletingCommentId] = useState<number | null>(null)
|
const [deletingCommentId, setDeletingCommentId] = useState<number | null>(null)
|
||||||
const [deletingReplyId, setDeletingReplyId] = useState<number | null>(null)
|
const [deletingReplyId, setDeletingReplyId] = useState<number | null>(null)
|
||||||
|
|
||||||
|
// Acceptance criteria state
|
||||||
|
const [acceptanceCriteria, setAcceptanceCriteria] = useState<AcceptanceCriteria[]>([])
|
||||||
|
const [criteriaLoading, setCriteriaLoading] = useState(false)
|
||||||
|
const [criteriaError, setCriteriaError] = useState<string | null>(null)
|
||||||
|
const [newCriteriaText, setNewCriteriaText] = useState('')
|
||||||
|
const [creatingCriteria, setCreatingCriteria] = useState(false)
|
||||||
|
const [editingCriteriaId, setEditingCriteriaId] = useState<number | null>(null)
|
||||||
|
const [editCriteriaText, setEditCriteriaText] = useState('')
|
||||||
|
const [updatingCriteriaId, setUpdatingCriteriaId] = useState<number | null>(null)
|
||||||
|
const [togglingCriteriaIds, setTogglingCriteriaIds] = useState<number[]>([])
|
||||||
|
const [deletingCriteriaId, setDeletingCriteriaId] = useState<number | null>(null)
|
||||||
|
|
||||||
// Delete confirmation modal state
|
// Delete confirmation modal state
|
||||||
const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false)
|
const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false)
|
||||||
const [deleteConfirmData, setDeleteConfirmData] = useState<{
|
const [deleteConfirmData, setDeleteConfirmData] = useState<{
|
||||||
type: 'comment' | 'reply' | 'link'
|
type: 'comment' | 'reply' | 'link' | 'criteria'
|
||||||
id: number
|
id: number
|
||||||
parentId?: number
|
parentId?: number
|
||||||
title: string
|
title: string
|
||||||
@@ -210,6 +231,27 @@ export default function RequirementDetailPage() {
|
|||||||
fetchCommentsData()
|
fetchCommentsData()
|
||||||
}, [activeTab, id])
|
}, [activeTab, id])
|
||||||
|
|
||||||
|
// Fetch acceptance criteria when tab is active
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchCriteriaData = async () => {
|
||||||
|
if (activeTab !== 'acceptance-criteria' || !id) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
setCriteriaLoading(true)
|
||||||
|
setCriteriaError(null)
|
||||||
|
const criteria = await acceptanceCriteriaService.getCriteria(parseInt(id, 10))
|
||||||
|
setAcceptanceCriteria(criteria)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch acceptance criteria:', err)
|
||||||
|
setCriteriaError('Failed to load acceptance criteria')
|
||||||
|
} finally {
|
||||||
|
setCriteriaLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchCriteriaData()
|
||||||
|
}, [activeTab, id])
|
||||||
|
|
||||||
// Fetch requirement history when history tab is active
|
// Fetch requirement history when history tab is active
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchHistoryData = async () => {
|
const fetchHistoryData = async () => {
|
||||||
@@ -219,13 +261,23 @@ export default function RequirementDetailPage() {
|
|||||||
setReqHistoryLoading(true)
|
setReqHistoryLoading(true)
|
||||||
const reqId = parseInt(id, 10)
|
const reqId = parseInt(id, 10)
|
||||||
|
|
||||||
// Fetch all five data sources in parallel
|
// Fetch all data sources in parallel
|
||||||
const [history, linkHistoryData, currentLinksData, groupHistoryData, currentGroupsData] = await Promise.all([
|
const [
|
||||||
|
history,
|
||||||
|
linkHistoryData,
|
||||||
|
currentLinksData,
|
||||||
|
groupHistoryData,
|
||||||
|
currentGroupsData,
|
||||||
|
currentCriteriaData,
|
||||||
|
criteriaHistoryData
|
||||||
|
] = await Promise.all([
|
||||||
requirementService.getRequirementHistory(reqId),
|
requirementService.getRequirementHistory(reqId),
|
||||||
relationshipService.getLinkHistory(reqId),
|
relationshipService.getLinkHistory(reqId),
|
||||||
relationshipService.getRequirementLinks(reqId),
|
relationshipService.getRequirementLinks(reqId),
|
||||||
groupService.getGroupHistory(reqId),
|
groupService.getGroupHistory(reqId),
|
||||||
groupService.getCurrentGroups(reqId)
|
groupService.getCurrentGroups(reqId),
|
||||||
|
acceptanceCriteriaService.getCriteria(reqId),
|
||||||
|
acceptanceCriteriaService.getCriteriaHistory(reqId)
|
||||||
])
|
])
|
||||||
|
|
||||||
// Build unified timeline
|
// Build unified timeline
|
||||||
@@ -309,6 +361,75 @@ export default function RequirementDetailPage() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Add current acceptance criteria as "criteria_added" events
|
||||||
|
currentCriteriaData.forEach((criteria) => {
|
||||||
|
events.push({
|
||||||
|
id: `criteria-current-${criteria.id}`,
|
||||||
|
type: 'criteria_added',
|
||||||
|
date: criteria.created_at || '',
|
||||||
|
criteriaData: {
|
||||||
|
criteriaId: criteria.id,
|
||||||
|
oldText: null,
|
||||||
|
newText: criteria.criteria_text,
|
||||||
|
oldAccepted: null,
|
||||||
|
newAccepted: criteria.is_accepted,
|
||||||
|
editedByUsername: criteria.last_editor_username
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Build acceptance criteria update/delete events from history
|
||||||
|
const criteriaGroups = new Map<number, AcceptanceCriteriaHistory[]>()
|
||||||
|
criteriaHistoryData.forEach((item) => {
|
||||||
|
const list = criteriaGroups.get(item.original_ac_id) || []
|
||||||
|
list.push(item)
|
||||||
|
criteriaGroups.set(item.original_ac_id, list)
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentCriteriaMap = new Map<number, AcceptanceCriteria>()
|
||||||
|
currentCriteriaData.forEach((criteria) => {
|
||||||
|
currentCriteriaMap.set(criteria.id, criteria)
|
||||||
|
})
|
||||||
|
|
||||||
|
criteriaGroups.forEach((items, criteriaId) => {
|
||||||
|
const sorted = [...items].sort((a, b) => {
|
||||||
|
const dateA = a.valid_to ? new Date(a.valid_to).getTime() : 0
|
||||||
|
const dateB = b.valid_to ? new Date(b.valid_to).getTime() : 0
|
||||||
|
return dateB - dateA
|
||||||
|
})
|
||||||
|
|
||||||
|
const current = currentCriteriaMap.get(criteriaId)
|
||||||
|
let nextState = {
|
||||||
|
text: current ? current.criteria_text : null,
|
||||||
|
accepted: current ? current.is_accepted : null
|
||||||
|
}
|
||||||
|
|
||||||
|
sorted.forEach((item) => {
|
||||||
|
const oldState = {
|
||||||
|
text: item.criteria_text ?? null,
|
||||||
|
accepted: item.is_accepted ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDeleted = nextState.text === null && nextState.accepted === null
|
||||||
|
|
||||||
|
events.push({
|
||||||
|
id: `criteria-hist-${item.history_id}`,
|
||||||
|
type: isDeleted ? 'criteria_deleted' : 'criteria_updated',
|
||||||
|
date: item.valid_to || '',
|
||||||
|
criteriaData: {
|
||||||
|
criteriaId,
|
||||||
|
oldText: oldState.text,
|
||||||
|
newText: nextState.text,
|
||||||
|
oldAccepted: oldState.accepted,
|
||||||
|
newAccepted: nextState.accepted,
|
||||||
|
editedByUsername: item.edited_by_username
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
nextState = oldState
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// Sort by date descending (newest first)
|
// Sort by date descending (newest first)
|
||||||
events.sort((a, b) => {
|
events.sort((a, b) => {
|
||||||
const dateA = a.date ? new Date(a.date).getTime() : 0
|
const dateA = a.date ? new Date(a.date).getTime() : 0
|
||||||
@@ -585,6 +706,98 @@ export default function RequirementDetailPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Acceptance criteria handlers
|
||||||
|
const handleCreateCriteria = async () => {
|
||||||
|
if (!newCriteriaText.trim() || !id) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
setCreatingCriteria(true)
|
||||||
|
const created = await acceptanceCriteriaService.createCriteria(parseInt(id, 10), {
|
||||||
|
criteria_text: newCriteriaText.trim()
|
||||||
|
})
|
||||||
|
setAcceptanceCriteria(prev => [...prev, created])
|
||||||
|
setNewCriteriaText('')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to create acceptance criteria:', err)
|
||||||
|
setCriteriaError('Failed to create acceptance criteria')
|
||||||
|
} finally {
|
||||||
|
setCreatingCriteria(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startEditCriteria = (criteria: AcceptanceCriteria) => {
|
||||||
|
setEditingCriteriaId(criteria.id)
|
||||||
|
setEditCriteriaText(criteria.criteria_text)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelEditCriteria = () => {
|
||||||
|
setEditingCriteriaId(null)
|
||||||
|
setEditCriteriaText('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveCriteria = async (criteriaId: number) => {
|
||||||
|
if (!editCriteriaText.trim()) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
setUpdatingCriteriaId(criteriaId)
|
||||||
|
const updated = await acceptanceCriteriaService.updateCriteria(criteriaId, {
|
||||||
|
criteria_text: editCriteriaText.trim()
|
||||||
|
})
|
||||||
|
setAcceptanceCriteria(prev => prev.map(c => (c.id === criteriaId ? updated : c)))
|
||||||
|
setEditingCriteriaId(null)
|
||||||
|
setEditCriteriaText('')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to update acceptance criteria:', err)
|
||||||
|
setCriteriaError('Failed to update acceptance criteria')
|
||||||
|
} finally {
|
||||||
|
setUpdatingCriteriaId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggleCriteria = async (criteria: AcceptanceCriteria) => {
|
||||||
|
if (!canManageCriteria) return
|
||||||
|
|
||||||
|
const nextValue = !criteria.is_accepted
|
||||||
|
const previousValue = criteria.is_accepted
|
||||||
|
|
||||||
|
setTogglingCriteriaIds(prev => [...prev, criteria.id])
|
||||||
|
setAcceptanceCriteria(prev => prev.map(c => (c.id === criteria.id ? { ...c, is_accepted: nextValue } : c)))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await acceptanceCriteriaService.updateCriteria(criteria.id, {
|
||||||
|
is_accepted: nextValue
|
||||||
|
})
|
||||||
|
setAcceptanceCriteria(prev => prev.map(c => (c.id === criteria.id ? updated : c)))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to toggle acceptance criteria:', err)
|
||||||
|
setAcceptanceCriteria(prev => prev.map(c => (c.id === criteria.id ? { ...c, is_accepted: previousValue } : c)))
|
||||||
|
} finally {
|
||||||
|
setTogglingCriteriaIds(prev => prev.filter(id => id !== criteria.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openDeleteCriteriaModal = (criteriaId: number) => {
|
||||||
|
setDeleteConfirmData({
|
||||||
|
type: 'criteria',
|
||||||
|
id: criteriaId,
|
||||||
|
title: t('deleteModal.deleteCriteria'),
|
||||||
|
message: t('deleteModal.confirmDeleteCriteria')
|
||||||
|
})
|
||||||
|
setShowDeleteConfirmModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const executeDeleteCriteria = async (criteriaId: number) => {
|
||||||
|
try {
|
||||||
|
setDeletingCriteriaId(criteriaId)
|
||||||
|
await acceptanceCriteriaService.deleteCriteria(criteriaId)
|
||||||
|
setAcceptanceCriteria(prev => prev.filter(c => c.id !== criteriaId))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete acceptance criteria:', err)
|
||||||
|
} finally {
|
||||||
|
setDeletingCriteriaId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle delete confirmation
|
// Handle delete confirmation
|
||||||
const handleConfirmDelete = async () => {
|
const handleConfirmDelete = async () => {
|
||||||
if (!deleteConfirmData) return
|
if (!deleteConfirmData) return
|
||||||
@@ -603,6 +816,9 @@ export default function RequirementDetailPage() {
|
|||||||
await executeDeleteReply(deleteConfirmData.id, deleteConfirmData.parentId)
|
await executeDeleteReply(deleteConfirmData.id, deleteConfirmData.parentId)
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
|
case 'criteria':
|
||||||
|
await executeDeleteCriteria(deleteConfirmData.id)
|
||||||
|
break
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setDeleteConfirmLoading(false)
|
setDeleteConfirmLoading(false)
|
||||||
@@ -719,6 +935,7 @@ export default function RequirementDetailPage() {
|
|||||||
// Check if requirement is in draft status
|
// Check if requirement is in draft status
|
||||||
const isDraftStatus = requirement?.status?.status_code === 'DRAFT'
|
const isDraftStatus = requirement?.status?.status_code === 'DRAFT'
|
||||||
const isAdmin = user?.role_id === 3
|
const isAdmin = user?.role_id === 3
|
||||||
|
const canManageCriteria = user?.role_id === 1 || user?.role_id === 3
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -961,12 +1178,110 @@ export default function RequirementDetailPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xl font-bold text-gray-800 mb-4">{t('acceptanceCriteria.title')}</h3>
|
<h3 className="text-xl font-bold text-gray-800 mb-4">{t('acceptanceCriteria.title')}</h3>
|
||||||
|
{criteriaLoading ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<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">{t('acceptanceCriteria.loading')}</p>
|
||||||
|
</div>
|
||||||
|
) : criteriaError ? (
|
||||||
|
<p className="text-red-500 text-sm">{criteriaError}</p>
|
||||||
|
) : acceptanceCriteria.length === 0 ? (
|
||||||
<p className="text-gray-500">{t('acceptanceCriteria.noCriteria')}</p>
|
<p className="text-gray-500">{t('acceptanceCriteria.noCriteria')}</p>
|
||||||
{!isAuditor && (
|
) : (
|
||||||
<div className="mt-4">
|
<div className="space-y-3">
|
||||||
<button className="px-4 py-1.5 border border-gray-400 rounded text-sm text-gray-700 hover:bg-gray-50">
|
{acceptanceCriteria.map((criteria) => {
|
||||||
{t('acceptanceCriteria.addButton')}
|
const isEditing = editingCriteriaId === criteria.id
|
||||||
|
const isToggling = togglingCriteriaIds.includes(criteria.id)
|
||||||
|
const isUpdating = updatingCriteriaId === criteria.id
|
||||||
|
const isDeleting = deletingCriteriaId === criteria.id
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={criteria.id} className="flex items-start gap-3 p-3 border border-gray-200 rounded">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={criteria.is_accepted}
|
||||||
|
onChange={() => handleToggleCriteria(criteria)}
|
||||||
|
disabled={!canManageCriteria || isToggling}
|
||||||
|
className="mt-1 h-4 w-4 text-teal-600 border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
{isEditing ? (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editCriteriaText}
|
||||||
|
onChange={(e) => setEditCriteriaText(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"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className={`text-sm ${criteria.is_accepted ? 'line-through text-gray-500' : 'text-gray-800'}`}>
|
||||||
|
{criteria.criteria_text}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canManageCriteria && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSaveCriteria(criteria.id)}
|
||||||
|
disabled={isUpdating || !editCriteriaText.trim()}
|
||||||
|
className="text-sm text-teal-600 hover:text-teal-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('acceptanceCriteria.saveButton')}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={cancelEditCriteria}
|
||||||
|
disabled={isUpdating}
|
||||||
|
className="text-sm text-gray-500 hover:text-gray-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('acceptanceCriteria.cancelButton')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => startEditCriteria(criteria)}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="text-sm text-gray-500 hover:text-gray-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('acceptanceCriteria.editButton')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => openDeleteCriteriaModal(criteria.id)}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="text-sm text-red-600 hover:text-red-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('acceptanceCriteria.deleteButton')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canManageCriteria && (
|
||||||
|
<div className="mt-6 border-t border-gray-200 pt-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newCriteriaText}
|
||||||
|
onChange={(e) => setNewCriteriaText(e.target.value)}
|
||||||
|
placeholder={t('acceptanceCriteria.placeholder')}
|
||||||
|
className="flex-1 px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleCreateCriteria}
|
||||||
|
disabled={creatingCriteria || !newCriteriaText.trim()}
|
||||||
|
className="px-4 py-2 bg-teal-600 text-white rounded text-sm font-medium hover:bg-teal-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{creatingCriteria ? t('acceptanceCriteria.creating') : t('acceptanceCriteria.addButton')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1649,6 +1964,70 @@ export default function RequirementDetailPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((event.type === 'criteria_added' || event.type === 'criteria_updated' || event.type === 'criteria_deleted') && event.criteriaData) {
|
||||||
|
const criteria = event.criteriaData
|
||||||
|
const isDeleted = event.type === 'criteria_deleted'
|
||||||
|
const isAdded = event.type === 'criteria_added'
|
||||||
|
|
||||||
|
const acceptanceLabel = (value: boolean | null) => {
|
||||||
|
if (value === null) return t('history.criteriaUnknown')
|
||||||
|
return value ? t('history.criteriaAccepted') : t('history.criteriaNotAccepted')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={event.id} className="border border-gray-200 rounded overflow-hidden">
|
||||||
|
<div className={`flex items-center justify-between px-4 py-3 border-l-4 ${
|
||||||
|
isAdded
|
||||||
|
? 'bg-green-50 border-green-400'
|
||||||
|
: isDeleted
|
||||||
|
? 'bg-red-50 border-red-400'
|
||||||
|
: 'bg-blue-50 border-blue-400'
|
||||||
|
}`}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-lg">✅</span>
|
||||||
|
<span className="font-medium text-gray-800">
|
||||||
|
{isAdded
|
||||||
|
? t('history.criteriaAdded')
|
||||||
|
: isDeleted
|
||||||
|
? t('history.criteriaDeleted')
|
||||||
|
: t('history.criteriaUpdated')}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-gray-600">
|
||||||
|
{isAdded ? (
|
||||||
|
<>
|
||||||
|
<span className="font-medium">{criteria.newText || t('history.criteriaEmpty')}</span>
|
||||||
|
<span className="ml-2 text-xs text-gray-500">({acceptanceLabel(criteria.newAccepted)})</span>
|
||||||
|
</>
|
||||||
|
) : isDeleted ? (
|
||||||
|
<>
|
||||||
|
<span className="line-through">{criteria.oldText || t('history.criteriaEmpty')}</span>
|
||||||
|
<span className="ml-2 text-xs text-gray-500">({acceptanceLabel(criteria.oldAccepted)})</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="line-through">{criteria.oldText || t('history.criteriaEmpty')}</span>
|
||||||
|
<span className="mx-2">→</span>
|
||||||
|
<span className="font-medium">{criteria.newText || t('history.criteriaEmpty')}</span>
|
||||||
|
<span className="ml-2 text-xs text-gray-500">
|
||||||
|
({acceptanceLabel(criteria.oldAccepted)} → {acceptanceLabel(criteria.newAccepted)})
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-sm text-gray-500">
|
||||||
|
{criteria.editedByUsername && (
|
||||||
|
<span>by {criteria.editedByUsername}</span>
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
{event.date ? new Date(event.date).toLocaleString() : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
101
frontend/src/services/acceptanceCriteriaService.ts
Normal file
101
frontend/src/services/acceptanceCriteriaService.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import type {
|
||||||
|
AcceptanceCriteria,
|
||||||
|
AcceptanceCriteriaCreateRequest,
|
||||||
|
AcceptanceCriteriaUpdateRequest,
|
||||||
|
AcceptanceCriteriaHistory
|
||||||
|
} from '@/types'
|
||||||
|
|
||||||
|
const API_BASE_URL = '/api'
|
||||||
|
|
||||||
|
class AcceptanceCriteriaService {
|
||||||
|
async getCriteria(requirementId: number): Promise<AcceptanceCriteria[]> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/requirements/${requirementId}/acceptance-criteria`, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCriteria(
|
||||||
|
requirementId: number,
|
||||||
|
data: AcceptanceCriteriaCreateRequest
|
||||||
|
): Promise<AcceptanceCriteria> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/requirements/${requirementId}/acceptance-criteria`, {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateCriteria(
|
||||||
|
criteriaId: number,
|
||||||
|
data: AcceptanceCriteriaUpdateRequest
|
||||||
|
): Promise<AcceptanceCriteria> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/acceptance-criteria/${criteriaId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteCriteria(criteriaId: number): Promise<void> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/acceptance-criteria/${criteriaId}`, {
|
||||||
|
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}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCriteriaHistory(requirementId: number): Promise<AcceptanceCriteriaHistory[]> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/requirements/${requirementId}/acceptance-criteria/history`, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const acceptanceCriteriaService = new AcceptanceCriteriaService()
|
||||||
@@ -23,6 +23,7 @@ export type {
|
|||||||
export { userService } from './userService'
|
export { userService } from './userService'
|
||||||
export type { Role, ProjectMember, UserRoleUpdateRequest } from './userService'
|
export type { Role, ProjectMember, UserRoleUpdateRequest } from './userService'
|
||||||
export { commentService } from './commentService'
|
export { commentService } from './commentService'
|
||||||
|
export { acceptanceCriteriaService } from './acceptanceCriteriaService'
|
||||||
export { superAdminService } from './superAdminService'
|
export { superAdminService } from './superAdminService'
|
||||||
export type {
|
export type {
|
||||||
SystemUser,
|
SystemUser,
|
||||||
|
|||||||
@@ -90,6 +90,37 @@ export interface RequirementLinkHistory {
|
|||||||
valid_to: string | null // When link was deleted
|
valid_to: string | null // When link was deleted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Acceptance Criteria types
|
||||||
|
export interface AcceptanceCriteria {
|
||||||
|
id: number
|
||||||
|
requirement_id: number
|
||||||
|
criteria_text: string
|
||||||
|
is_accepted: boolean
|
||||||
|
created_at: string | null
|
||||||
|
updated_at: string | null
|
||||||
|
last_editor_username: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AcceptanceCriteriaCreateRequest {
|
||||||
|
criteria_text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AcceptanceCriteriaUpdateRequest {
|
||||||
|
criteria_text?: string
|
||||||
|
is_accepted?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AcceptanceCriteriaHistory {
|
||||||
|
history_id: number
|
||||||
|
original_ac_id: number
|
||||||
|
requirement_id: number
|
||||||
|
criteria_text: string | null
|
||||||
|
is_accepted: boolean | null
|
||||||
|
valid_from: string | null
|
||||||
|
valid_to: string | null
|
||||||
|
edited_by_username: string | null
|
||||||
|
}
|
||||||
|
|
||||||
// Requirement Group History types
|
// Requirement Group History types
|
||||||
export interface RequirementGroupHistory {
|
export interface RequirementGroupHistory {
|
||||||
history_id: number
|
history_id: number
|
||||||
|
|||||||
Reference in New Issue
Block a user