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")
|
||||
comments: Mapped[List["RequirementComment"]] = relationship("RequirementComment", 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):
|
||||
@@ -261,6 +265,11 @@ class Requirement(Base):
|
||||
back_populates="requirement",
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
acceptance_criteria: Mapped[List["AcceptanceCriteria"]] = relationship(
|
||||
"AcceptanceCriteria",
|
||||
back_populates="requirement",
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
# Indexes
|
||||
__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):
|
||||
"""Validation records for requirements."""
|
||||
__tablename__ = "validations"
|
||||
@@ -344,6 +392,28 @@ class RequirementHistory(Base):
|
||||
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):
|
||||
"""
|
||||
Defines valid relationship types per project.
|
||||
|
||||
@@ -18,6 +18,7 @@ from src.models import (
|
||||
RoleResponse, ProjectMemberResponse, UserRoleUpdateRequest, ROLE_DISPLAY_NAMES,
|
||||
CommentResponse, CommentReplyResponse, CommentCreateRequest, ReplyCreateRequest,
|
||||
RequirementStatusResponse, DeletedRequirementResponse,
|
||||
AcceptanceCriteriaResponse, AcceptanceCriteriaCreateRequest, AcceptanceCriteriaUpdateRequest, AcceptanceCriteriaHistoryResponse,
|
||||
UserCreateRequest, UserCreateResponse,
|
||||
SystemUserResponse, SystemProjectResponse, SystemProjectMemberResponse,
|
||||
AssignUserToProjectRequest, SystemUserCreateRequest
|
||||
@@ -29,7 +30,7 @@ from src.repositories import (
|
||||
RoleRepository, GroupRepository, TagRepository, RequirementRepository,
|
||||
PriorityRepository, ProjectRepository, ValidationStatusRepository, ValidationRepository,
|
||||
RelationshipTypeRepository, RequirementLinkRepository, CommentRepository, ReplyRepository,
|
||||
RequirementStatusRepository, UserRepository
|
||||
RequirementStatusRepository, UserRepository, AcceptanceCriteriaRepository
|
||||
)
|
||||
from src.service import KeycloakAdminService
|
||||
import logging
|
||||
@@ -1842,6 +1843,211 @@ async def get_requirement_current_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
|
||||
# ===========================================
|
||||
|
||||
@@ -374,6 +374,47 @@ class RequirementLinkHistoryResponse(BaseModel):
|
||||
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
|
||||
class RequirementSearchResult(BaseModel):
|
||||
"""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.comment_repository import CommentRepository, ReplyRepository
|
||||
from src.repositories.requirement_status_repository import RequirementStatusRepository
|
||||
from src.repositories.acceptance_criteria_repository import AcceptanceCriteriaRepository
|
||||
|
||||
__all__ = [
|
||||
"UserRepository",
|
||||
@@ -29,4 +30,5 @@ __all__ = [
|
||||
"CommentRepository",
|
||||
"ReplyRepository",
|
||||
"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
|
||||
]
|
||||
Reference in New Issue
Block a user