added acceptance criteria page

This commit is contained in:
gulimabr
2026-01-18 22:37:13 -03:00
parent 3be3c22be9
commit 5af9aa8358
11 changed files with 1000 additions and 20 deletions

View File

@@ -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.

View File

@@ -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
# ===========================================

View File

@@ -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)."""

View File

@@ -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",
]

View 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
]