Added auditor logic
This commit is contained in:
@@ -9,12 +9,16 @@ from src.models import (
|
|||||||
TokenResponse, UserInfo, GroupResponse,
|
TokenResponse, UserInfo, GroupResponse,
|
||||||
TagResponse, RequirementResponse, PriorityResponse,
|
TagResponse, RequirementResponse, PriorityResponse,
|
||||||
RequirementCreateRequest, RequirementUpdateRequest,
|
RequirementCreateRequest, RequirementUpdateRequest,
|
||||||
ProjectResponse, ProjectCreateRequest, ProjectUpdateRequest, ProjectMemberRequest
|
ProjectResponse, ProjectCreateRequest, ProjectUpdateRequest, ProjectMemberRequest,
|
||||||
|
ValidationStatusResponse, ValidationHistoryResponse, ValidationCreateRequest
|
||||||
)
|
)
|
||||||
from src.controller import AuthController
|
from src.controller import AuthController
|
||||||
from src.config import get_openid, get_settings
|
from src.config import get_openid, get_settings
|
||||||
from src.database import init_db, close_db, get_db
|
from src.database import init_db, close_db, get_db
|
||||||
from src.repositories import RoleRepository, GroupRepository, TagRepository, RequirementRepository, PriorityRepository, ProjectRepository
|
from src.repositories import (
|
||||||
|
RoleRepository, GroupRepository, TagRepository, RequirementRepository,
|
||||||
|
PriorityRepository, ProjectRepository, ValidationStatusRepository, ValidationRepository
|
||||||
|
)
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
@@ -45,6 +49,23 @@ async def lifespan(app: FastAPI):
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
logger.info("Default roles ensured")
|
logger.info("Default roles ensured")
|
||||||
|
|
||||||
|
# Ensure default validation statuses exist
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
await session.execute(
|
||||||
|
__import__('sqlalchemy').text(
|
||||||
|
"""
|
||||||
|
INSERT INTO validation_statuses (id, status_name) VALUES
|
||||||
|
(1, 'Approved'),
|
||||||
|
(2, 'Denied'),
|
||||||
|
(3, 'Partial'),
|
||||||
|
(4, 'Not Validated')
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
logger.info("Default validation statuses ensured")
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
# Shutdown
|
# Shutdown
|
||||||
@@ -126,14 +147,27 @@ async def callback(request: Request, db: AsyncSession = Depends(get_db)):
|
|||||||
|
|
||||||
# Define the auth/me endpoint to get current user from cookie
|
# Define the auth/me endpoint to get current user from cookie
|
||||||
@app.get("/api/auth/me", response_model=UserInfo)
|
@app.get("/api/auth/me", response_model=UserInfo)
|
||||||
async def get_current_user(request: Request):
|
async def get_current_user(request: Request, db: AsyncSession = Depends(get_db)):
|
||||||
"""
|
"""
|
||||||
Get the current authenticated user from the session cookie.
|
Get the current authenticated user from the session cookie.
|
||||||
|
Includes role information from the database.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
UserInfo: Information about the authenticated user.
|
UserInfo: Information about the authenticated user including role.
|
||||||
"""
|
"""
|
||||||
return AuthController.get_current_user(request)
|
user_info = AuthController.get_current_user(request)
|
||||||
|
|
||||||
|
# Fetch role information from database
|
||||||
|
from src.repositories import UserRepository
|
||||||
|
user_repo = UserRepository(db)
|
||||||
|
db_user = await user_repo.get_by_sub(user_info.sub)
|
||||||
|
|
||||||
|
if db_user:
|
||||||
|
user_info.db_user_id = db_user.id
|
||||||
|
user_info.role_id = db_user.role_id
|
||||||
|
user_info.role = db_user.role.role_name if db_user.role else None
|
||||||
|
|
||||||
|
return user_info
|
||||||
|
|
||||||
|
|
||||||
# Define the logout endpoint
|
# Define the logout endpoint
|
||||||
@@ -257,6 +291,25 @@ async def _get_current_user_db(request: Request, db: AsyncSession):
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _require_role(user, allowed_role_ids: List[int], action: str = "perform this action"):
|
||||||
|
"""
|
||||||
|
Helper to check if user has one of the allowed roles.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: The database user object
|
||||||
|
allowed_role_ids: List of role IDs that are permitted (e.g., [1, 3] for admin and user)
|
||||||
|
action: Description of the action for error message
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 403 Forbidden if user's role is not in allowed list
|
||||||
|
"""
|
||||||
|
if user.role_id not in allowed_role_ids:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"Your role does not have permission to {action}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _verify_project_membership(project_id: int, user_id: int, db: AsyncSession):
|
async def _verify_project_membership(project_id: int, user_id: int, db: AsyncSession):
|
||||||
"""Helper to verify user is a member of a project."""
|
"""Helper to verify user is a member of a project."""
|
||||||
project_repo = ProjectRepository(db)
|
project_repo = ProjectRepository(db)
|
||||||
@@ -473,10 +526,19 @@ def _build_requirement_response(req) -> RequirementResponse:
|
|||||||
"""Helper function to build RequirementResponse from a Requirement model."""
|
"""Helper function to build RequirementResponse from a Requirement model."""
|
||||||
# Determine validation status from latest validation
|
# Determine validation status from latest validation
|
||||||
validation_status = "Not Validated"
|
validation_status = "Not Validated"
|
||||||
|
validated_by = None
|
||||||
|
validated_at = None
|
||||||
|
validation_version = None
|
||||||
|
|
||||||
if req.validations:
|
if req.validations:
|
||||||
# Get the latest validation
|
# Get the latest validation
|
||||||
latest_validation = max(req.validations, key=lambda v: v.created_at or req.created_at)
|
latest_validation = max(req.validations, key=lambda v: v.created_at or req.created_at)
|
||||||
validation_status = latest_validation.status.status_name if latest_validation.status else "Not Validated"
|
validation_status = latest_validation.status.status_name if latest_validation.status else "Not Validated"
|
||||||
|
# Try to get username from user relationship
|
||||||
|
if latest_validation.user:
|
||||||
|
validated_by = latest_validation.user.sub
|
||||||
|
validated_at = latest_validation.created_at
|
||||||
|
validation_version = latest_validation.req_version_snapshot
|
||||||
|
|
||||||
return RequirementResponse(
|
return RequirementResponse(
|
||||||
id=req.id,
|
id=req.id,
|
||||||
@@ -490,6 +552,9 @@ def _build_requirement_response(req) -> RequirementResponse:
|
|||||||
priority=req.priority if req.priority else None,
|
priority=req.priority if req.priority else None,
|
||||||
groups=[GroupResponse.model_validate(g) for g in req.groups],
|
groups=[GroupResponse.model_validate(g) for g in req.groups],
|
||||||
validation_status=validation_status,
|
validation_status=validation_status,
|
||||||
|
validated_by=validated_by,
|
||||||
|
validated_at=validated_at,
|
||||||
|
validation_version=validation_version,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -611,6 +676,7 @@ async def create_requirement(
|
|||||||
"""
|
"""
|
||||||
Create a new requirement.
|
Create a new requirement.
|
||||||
User must be a member of the project.
|
User must be a member of the project.
|
||||||
|
Auditors (role_id=2) cannot create requirements.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
req_data: The requirement data (must include project_id)
|
req_data: The requirement data (must include project_id)
|
||||||
@@ -620,6 +686,9 @@ async def create_requirement(
|
|||||||
"""
|
"""
|
||||||
user = await _get_current_user_db(request, db)
|
user = await _get_current_user_db(request, db)
|
||||||
|
|
||||||
|
# Auditors (role_id=2) cannot create requirements
|
||||||
|
_require_role(user, [1, 3], "create requirements")
|
||||||
|
|
||||||
# Verify user is a member of the project
|
# Verify user is a member of the project
|
||||||
await _verify_project_membership(req_data.project_id, user.id, db)
|
await _verify_project_membership(req_data.project_id, user.id, db)
|
||||||
|
|
||||||
@@ -648,6 +717,7 @@ async def update_requirement(
|
|||||||
"""
|
"""
|
||||||
Update an existing requirement.
|
Update an existing requirement.
|
||||||
User must be a member of the requirement's project.
|
User must be a member of the requirement's project.
|
||||||
|
Auditors (role_id=2) cannot edit requirements.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
requirement_id: The requirement ID to update
|
requirement_id: The requirement ID to update
|
||||||
@@ -658,6 +728,9 @@ async def update_requirement(
|
|||||||
"""
|
"""
|
||||||
user = await _get_current_user_db(request, db)
|
user = await _get_current_user_db(request, db)
|
||||||
|
|
||||||
|
# Auditors (role_id=2) cannot edit requirements
|
||||||
|
_require_role(user, [1, 3], "edit requirements")
|
||||||
|
|
||||||
req_repo = RequirementRepository(db)
|
req_repo = RequirementRepository(db)
|
||||||
|
|
||||||
# First check if requirement exists
|
# First check if requirement exists
|
||||||
@@ -694,12 +767,16 @@ async def delete_requirement(
|
|||||||
"""
|
"""
|
||||||
Delete a requirement.
|
Delete a requirement.
|
||||||
User must be a member of the requirement's project.
|
User must be a member of the requirement's project.
|
||||||
|
Auditors (role_id=2) cannot delete requirements.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
requirement_id: The requirement ID to delete
|
requirement_id: The requirement ID to delete
|
||||||
"""
|
"""
|
||||||
user = await _get_current_user_db(request, db)
|
user = await _get_current_user_db(request, db)
|
||||||
|
|
||||||
|
# Auditors (role_id=2) cannot delete requirements
|
||||||
|
_require_role(user, [1, 3], "delete requirements")
|
||||||
|
|
||||||
req_repo = RequirementRepository(db)
|
req_repo = RequirementRepository(db)
|
||||||
|
|
||||||
# First check if requirement exists
|
# First check if requirement exists
|
||||||
@@ -715,3 +792,137 @@ async def delete_requirement(
|
|||||||
|
|
||||||
await req_repo.delete(requirement_id)
|
await req_repo.delete(requirement_id)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# Validation Endpoints
|
||||||
|
# ===========================================
|
||||||
|
|
||||||
|
@app.get("/api/validation-statuses", response_model=List[ValidationStatusResponse])
|
||||||
|
async def get_validation_statuses(db: AsyncSession = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Get all validation statuses.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of validation statuses (Approved, Denied, Partial, Not Validated).
|
||||||
|
"""
|
||||||
|
status_repo = ValidationStatusRepository(db)
|
||||||
|
statuses = await status_repo.get_all()
|
||||||
|
return [ValidationStatusResponse.model_validate(s) for s in statuses]
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/requirements/{requirement_id}/validations", response_model=ValidationHistoryResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_validation(
|
||||||
|
requirement_id: int,
|
||||||
|
request: Request,
|
||||||
|
validation_data: ValidationCreateRequest,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Create a new validation for a requirement.
|
||||||
|
Only auditors (role_id=2) and admins (role_id=1) can validate requirements.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
requirement_id: The requirement to validate
|
||||||
|
validation_data: The validation status and optional comment
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The created validation record.
|
||||||
|
"""
|
||||||
|
user = await _get_current_user_db(request, db)
|
||||||
|
|
||||||
|
# Only auditors (role_id=2) and admins (role_id=1) can validate
|
||||||
|
_require_role(user, [1, 2], "validate requirements")
|
||||||
|
|
||||||
|
# Check if requirement exists and user has access
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify user is a member of the requirement's project
|
||||||
|
await _verify_project_membership(requirement.project_id, user.id, db)
|
||||||
|
|
||||||
|
# Verify status exists
|
||||||
|
status_repo = ValidationStatusRepository(db)
|
||||||
|
validation_status = await status_repo.get_by_id(validation_data.status_id)
|
||||||
|
if not validation_status:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid validation status id {validation_data.status_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create the validation
|
||||||
|
validation_repo = ValidationRepository(db)
|
||||||
|
validation = await validation_repo.create(
|
||||||
|
requirement_id=requirement_id,
|
||||||
|
user_id=user.id,
|
||||||
|
status_id=validation_data.status_id,
|
||||||
|
req_version_snapshot=requirement.version,
|
||||||
|
comment=validation_data.comment
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return ValidationHistoryResponse(
|
||||||
|
id=validation.id,
|
||||||
|
status_name=validation_status.status_name,
|
||||||
|
status_id=validation.status_id,
|
||||||
|
req_version_snapshot=validation.req_version_snapshot,
|
||||||
|
comment=validation.comment,
|
||||||
|
created_at=validation.created_at,
|
||||||
|
validator_username=user.sub,
|
||||||
|
validator_id=user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/requirements/{requirement_id}/validations", response_model=List[ValidationHistoryResponse])
|
||||||
|
async def get_validation_history(
|
||||||
|
requirement_id: int,
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get the validation history for a requirement.
|
||||||
|
Returns all validations ordered by date (newest first).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
requirement_id: The requirement to get validation history for
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of validation records with validator info.
|
||||||
|
"""
|
||||||
|
user = await _get_current_user_db(request, db)
|
||||||
|
|
||||||
|
# Check if requirement exists
|
||||||
|
req_repo = RequirementRepository(db)
|
||||||
|
requirement = await req_repo.get_by_id(requirement_id)
|
||||||
|
if not requirement:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Requirement with id {requirement_id} not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify user is a member of the requirement's project
|
||||||
|
await _verify_project_membership(requirement.project_id, user.id, db)
|
||||||
|
|
||||||
|
# Get validation history
|
||||||
|
validation_repo = ValidationRepository(db)
|
||||||
|
validations = await validation_repo.get_by_requirement_id(requirement_id)
|
||||||
|
|
||||||
|
return [
|
||||||
|
ValidationHistoryResponse(
|
||||||
|
id=v.id,
|
||||||
|
status_name=v.status.status_name,
|
||||||
|
status_id=v.status_id,
|
||||||
|
req_version_snapshot=v.req_version_snapshot,
|
||||||
|
comment=v.comment,
|
||||||
|
created_at=v.created_at,
|
||||||
|
validator_username=v.user.sub,
|
||||||
|
validator_id=v.user_id
|
||||||
|
)
|
||||||
|
for v in validations
|
||||||
|
]
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class UserInfo(BaseModel):
|
|||||||
full_name: Optional[str] = None
|
full_name: Optional[str] = None
|
||||||
db_user_id: Optional[int] = None # Database user ID (populated after login)
|
db_user_id: Optional[int] = None # Database user ID (populated after login)
|
||||||
role: Optional[str] = None # User role name
|
role: Optional[str] = None # User role name
|
||||||
|
role_id: Optional[int] = None # User role ID (1=admin, 2=auditor, 3=user, etc.)
|
||||||
|
|
||||||
|
|
||||||
# Project schemas
|
# Project schemas
|
||||||
@@ -106,6 +107,15 @@ class PriorityResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
# Validation schemas
|
# Validation schemas
|
||||||
|
class ValidationStatusResponse(BaseModel):
|
||||||
|
"""Response schema for a validation status."""
|
||||||
|
id: int
|
||||||
|
status_name: str
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
class ValidationResponse(BaseModel):
|
class ValidationResponse(BaseModel):
|
||||||
"""Response schema for a validation."""
|
"""Response schema for a validation."""
|
||||||
id: int
|
id: int
|
||||||
@@ -118,6 +128,27 @@ class ValidationResponse(BaseModel):
|
|||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationHistoryResponse(BaseModel):
|
||||||
|
"""Response schema for validation history with validator info."""
|
||||||
|
id: int
|
||||||
|
status_name: str
|
||||||
|
status_id: int
|
||||||
|
req_version_snapshot: int
|
||||||
|
comment: Optional[str] = None
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
validator_username: str
|
||||||
|
validator_id: int
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationCreateRequest(BaseModel):
|
||||||
|
"""Request schema for creating a validation."""
|
||||||
|
status_id: int
|
||||||
|
comment: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
# Requirement schemas
|
# Requirement schemas
|
||||||
class RequirementResponse(BaseModel):
|
class RequirementResponse(BaseModel):
|
||||||
"""Response schema for a single requirement."""
|
"""Response schema for a single requirement."""
|
||||||
@@ -132,6 +163,9 @@ class RequirementResponse(BaseModel):
|
|||||||
priority: Optional[PriorityResponse] = None
|
priority: Optional[PriorityResponse] = None
|
||||||
groups: List[GroupResponse] = []
|
groups: List[GroupResponse] = []
|
||||||
validation_status: Optional[str] = None # Computed from latest validation
|
validation_status: Optional[str] = None # Computed from latest validation
|
||||||
|
validated_by: Optional[str] = None # Username of the validator
|
||||||
|
validated_at: Optional[datetime] = None # When the latest validation was made
|
||||||
|
validation_version: Optional[int] = None # Version at which requirement was validated
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ from src.repositories.tag_repository import TagRepository
|
|||||||
from src.repositories.requirement_repository import RequirementRepository
|
from src.repositories.requirement_repository import RequirementRepository
|
||||||
from src.repositories.priority_repository import PriorityRepository
|
from src.repositories.priority_repository import PriorityRepository
|
||||||
from src.repositories.project_repository import ProjectRepository
|
from src.repositories.project_repository import ProjectRepository
|
||||||
|
from src.repositories.validation_status_repository import ValidationStatusRepository
|
||||||
|
from src.repositories.validation_repository import ValidationRepository
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"UserRepository",
|
"UserRepository",
|
||||||
@@ -16,4 +18,6 @@ __all__ = [
|
|||||||
"RequirementRepository",
|
"RequirementRepository",
|
||||||
"PriorityRepository",
|
"PriorityRepository",
|
||||||
"ProjectRepository",
|
"ProjectRepository",
|
||||||
|
"ValidationStatusRepository",
|
||||||
|
"ValidationRepository",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class RequirementRepository:
|
|||||||
selectinload(Requirement.priority),
|
selectinload(Requirement.priority),
|
||||||
selectinload(Requirement.groups),
|
selectinload(Requirement.groups),
|
||||||
selectinload(Requirement.validations).selectinload(Validation.status),
|
selectinload(Requirement.validations).selectinload(Validation.status),
|
||||||
|
selectinload(Requirement.validations).selectinload(Validation.user),
|
||||||
)
|
)
|
||||||
.order_by(Requirement.created_at.desc())
|
.order_by(Requirement.created_at.desc())
|
||||||
)
|
)
|
||||||
@@ -53,6 +54,7 @@ class RequirementRepository:
|
|||||||
selectinload(Requirement.priority),
|
selectinload(Requirement.priority),
|
||||||
selectinload(Requirement.groups),
|
selectinload(Requirement.groups),
|
||||||
selectinload(Requirement.validations).selectinload(Validation.status),
|
selectinload(Requirement.validations).selectinload(Validation.status),
|
||||||
|
selectinload(Requirement.validations).selectinload(Validation.user),
|
||||||
)
|
)
|
||||||
.where(Requirement.project_id == project_id)
|
.where(Requirement.project_id == project_id)
|
||||||
.order_by(Requirement.created_at.desc())
|
.order_by(Requirement.created_at.desc())
|
||||||
@@ -76,6 +78,7 @@ class RequirementRepository:
|
|||||||
selectinload(Requirement.priority),
|
selectinload(Requirement.priority),
|
||||||
selectinload(Requirement.groups),
|
selectinload(Requirement.groups),
|
||||||
selectinload(Requirement.validations).selectinload(Validation.status),
|
selectinload(Requirement.validations).selectinload(Validation.status),
|
||||||
|
selectinload(Requirement.validations).selectinload(Validation.user),
|
||||||
)
|
)
|
||||||
.where(Requirement.user_id == user_id)
|
.where(Requirement.user_id == user_id)
|
||||||
.order_by(Requirement.created_at.desc())
|
.order_by(Requirement.created_at.desc())
|
||||||
@@ -99,6 +102,7 @@ class RequirementRepository:
|
|||||||
selectinload(Requirement.priority),
|
selectinload(Requirement.priority),
|
||||||
selectinload(Requirement.groups),
|
selectinload(Requirement.groups),
|
||||||
selectinload(Requirement.validations).selectinload(Validation.status),
|
selectinload(Requirement.validations).selectinload(Validation.status),
|
||||||
|
selectinload(Requirement.validations).selectinload(Validation.user),
|
||||||
selectinload(Requirement.user),
|
selectinload(Requirement.user),
|
||||||
selectinload(Requirement.last_editor),
|
selectinload(Requirement.last_editor),
|
||||||
)
|
)
|
||||||
@@ -125,6 +129,7 @@ class RequirementRepository:
|
|||||||
selectinload(Requirement.priority),
|
selectinload(Requirement.priority),
|
||||||
selectinload(Requirement.groups),
|
selectinload(Requirement.groups),
|
||||||
selectinload(Requirement.validations).selectinload(Validation.status),
|
selectinload(Requirement.validations).selectinload(Validation.status),
|
||||||
|
selectinload(Requirement.validations).selectinload(Validation.user),
|
||||||
)
|
)
|
||||||
.join(Requirement.groups)
|
.join(Requirement.groups)
|
||||||
.where(Group.id == group_id)
|
.where(Group.id == group_id)
|
||||||
@@ -159,6 +164,7 @@ class RequirementRepository:
|
|||||||
selectinload(Requirement.priority),
|
selectinload(Requirement.priority),
|
||||||
selectinload(Requirement.groups),
|
selectinload(Requirement.groups),
|
||||||
selectinload(Requirement.validations).selectinload(Validation.status),
|
selectinload(Requirement.validations).selectinload(Validation.status),
|
||||||
|
selectinload(Requirement.validations).selectinload(Validation.user),
|
||||||
)
|
)
|
||||||
.where(Requirement.tag_id == tag_id)
|
.where(Requirement.tag_id == tag_id)
|
||||||
)
|
)
|
||||||
|
|||||||
102
backend/src/repositories/validation_repository.py
Normal file
102
backend/src/repositories/validation_repository.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
"""
|
||||||
|
Repository for Validation database operations.
|
||||||
|
"""
|
||||||
|
from typing import List, Optional
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from src.db_models import Validation, ValidationStatus, User
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationRepository:
|
||||||
|
"""Repository for validation CRUD operations."""
|
||||||
|
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def create(
|
||||||
|
self,
|
||||||
|
requirement_id: int,
|
||||||
|
user_id: int,
|
||||||
|
status_id: int,
|
||||||
|
req_version_snapshot: int,
|
||||||
|
comment: Optional[str] = None
|
||||||
|
) -> Validation:
|
||||||
|
"""
|
||||||
|
Create a new validation record.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
requirement_id: The requirement being validated
|
||||||
|
user_id: The auditor performing the validation
|
||||||
|
status_id: The validation status (1=Approved, 2=Denied, 3=Partial, 4=Not Validated)
|
||||||
|
req_version_snapshot: The version of the requirement at validation time
|
||||||
|
comment: Optional comment explaining the validation decision
|
||||||
|
"""
|
||||||
|
validation = Validation(
|
||||||
|
requirement_id=requirement_id,
|
||||||
|
user_id=user_id,
|
||||||
|
status_id=status_id,
|
||||||
|
req_version_snapshot=req_version_snapshot,
|
||||||
|
comment=comment
|
||||||
|
)
|
||||||
|
self.db.add(validation)
|
||||||
|
await self.db.flush()
|
||||||
|
await self.db.refresh(validation)
|
||||||
|
return validation
|
||||||
|
|
||||||
|
async def get_by_id(self, validation_id: int) -> Optional[Validation]:
|
||||||
|
"""Get a validation by ID with related data."""
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(Validation)
|
||||||
|
.options(
|
||||||
|
selectinload(Validation.status),
|
||||||
|
selectinload(Validation.user)
|
||||||
|
)
|
||||||
|
.where(Validation.id == validation_id)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def get_by_requirement_id(self, requirement_id: int) -> List[Validation]:
|
||||||
|
"""
|
||||||
|
Get all validations for a requirement, ordered by creation date (newest first).
|
||||||
|
Includes related status and user data.
|
||||||
|
"""
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(Validation)
|
||||||
|
.options(
|
||||||
|
selectinload(Validation.status),
|
||||||
|
selectinload(Validation.user)
|
||||||
|
)
|
||||||
|
.where(Validation.requirement_id == requirement_id)
|
||||||
|
.order_by(Validation.created_at.desc())
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def get_latest_by_requirement_id(self, requirement_id: int) -> Optional[Validation]:
|
||||||
|
"""
|
||||||
|
Get the most recent validation for a requirement.
|
||||||
|
Returns None if no validations exist (requirement is "Not Validated").
|
||||||
|
"""
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(Validation)
|
||||||
|
.options(
|
||||||
|
selectinload(Validation.status),
|
||||||
|
selectinload(Validation.user)
|
||||||
|
)
|
||||||
|
.where(Validation.requirement_id == requirement_id)
|
||||||
|
.order_by(Validation.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def delete(self, validation_id: int) -> bool:
|
||||||
|
"""Delete a validation by ID. Returns True if deleted, False if not found."""
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(Validation).where(Validation.id == validation_id)
|
||||||
|
)
|
||||||
|
validation = result.scalar_one_or_none()
|
||||||
|
if validation:
|
||||||
|
await self.db.delete(validation)
|
||||||
|
await self.db.flush()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
80
backend/src/repositories/validation_status_repository.py
Normal file
80
backend/src/repositories/validation_status_repository.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""
|
||||||
|
Repository for ValidationStatus database operations.
|
||||||
|
"""
|
||||||
|
from typing import List, Optional
|
||||||
|
from sqlalchemy import select, text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from src.db_models import ValidationStatus
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationStatusRepository:
|
||||||
|
"""Repository for validation status CRUD operations."""
|
||||||
|
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def get_all(self) -> List[ValidationStatus]:
|
||||||
|
"""Get all validation statuses."""
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(ValidationStatus).order_by(ValidationStatus.id)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def get_by_id(self, status_id: int) -> Optional[ValidationStatus]:
|
||||||
|
"""Get a validation status by ID."""
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(ValidationStatus).where(ValidationStatus.id == status_id)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def get_by_name(self, status_name: str) -> Optional[ValidationStatus]:
|
||||||
|
"""Get a validation status by name."""
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(ValidationStatus).where(ValidationStatus.status_name == status_name)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def create(self, status_name: str) -> ValidationStatus:
|
||||||
|
"""Create a new validation status."""
|
||||||
|
status = ValidationStatus(status_name=status_name)
|
||||||
|
self.db.add(status)
|
||||||
|
await self.db.commit()
|
||||||
|
await self.db.refresh(status)
|
||||||
|
return status
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def seed_default_statuses(db) -> None:
|
||||||
|
"""
|
||||||
|
Seed default validation statuses if they don't exist.
|
||||||
|
Statuses:
|
||||||
|
1 - Approved: Requirement fully validated
|
||||||
|
2 - Denied: Requirement rejected, needs rework
|
||||||
|
3 - Partial: Part of requirement approved, needs more work
|
||||||
|
4 - Not Validated: Default status, awaiting validation
|
||||||
|
|
||||||
|
Note: This is a synchronous method that uses the sync engine on startup.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import text as sync_text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
default_statuses = [
|
||||||
|
(1, "Approved"),
|
||||||
|
(2, "Denied"),
|
||||||
|
(3, "Partial"),
|
||||||
|
(4, "Not Validated"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Check if db is async or sync session
|
||||||
|
if hasattr(db, 'execute'):
|
||||||
|
for status_id, status_name in default_statuses:
|
||||||
|
# Use raw SQL to insert with specific ID to maintain consistency
|
||||||
|
db.execute(
|
||||||
|
sync_text(
|
||||||
|
"INSERT INTO validation_statuses (id, status_name) "
|
||||||
|
"VALUES (:id, :name) "
|
||||||
|
"ON CONFLICT (id) DO NOTHING"
|
||||||
|
),
|
||||||
|
{"id": status_id, "name": status_name}
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
useEffect,
|
useEffect,
|
||||||
useCallback,
|
useCallback,
|
||||||
|
useMemo,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import type { User, AuthContextType } from '@/types'
|
import type { User, AuthContextType } from '@/types'
|
||||||
@@ -51,10 +52,14 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
|||||||
refreshUser()
|
refreshUser()
|
||||||
}, [refreshUser])
|
}, [refreshUser])
|
||||||
|
|
||||||
|
// Determine if user is an auditor (role_id = 2)
|
||||||
|
const isAuditor = useMemo(() => user?.role_id === 2, [user?.role_id])
|
||||||
|
|
||||||
const value: AuthContextType = {
|
const value: AuthContextType = {
|
||||||
user,
|
user,
|
||||||
isLoading,
|
isLoading,
|
||||||
isAuthenticated: !!user,
|
isAuthenticated: !!user,
|
||||||
|
isAuditor,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
refreshUser,
|
refreshUser,
|
||||||
|
|||||||
@@ -246,7 +246,7 @@ export default function DashboardPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
<span className="text-sm text-gray-700">
|
<span className="text-sm text-gray-700">
|
||||||
{user?.full_name || user?.preferred_username || 'User'}{' '}
|
{user?.full_name || user?.preferred_username || 'User'}{' '}
|
||||||
<span className="text-gray-500">(admin)</span>
|
<span className="text-gray-500">({user?.role || 'user'})</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useAuth, useProject } from '@/hooks'
|
import { useAuth, useProject } from '@/hooks'
|
||||||
import { useParams, Link } from 'react-router-dom'
|
import { useParams, Link } from 'react-router-dom'
|
||||||
import { requirementService } from '@/services'
|
import { requirementService, validationService } from '@/services'
|
||||||
import type { Requirement } from '@/services/requirementService'
|
import type { Requirement } from '@/services/requirementService'
|
||||||
|
import type { ValidationStatus, ValidationHistory } from '@/types'
|
||||||
|
|
||||||
// Tab types
|
// Tab types
|
||||||
type TabType = 'description' | 'sub-requirements' | 'co-requirements' | 'acceptance-criteria' | 'shared-comments' | 'validate'
|
type TabType = 'description' | 'sub-requirements' | 'co-requirements' | 'acceptance-criteria' | 'shared-comments' | 'validate'
|
||||||
|
|
||||||
export default function RequirementDetailPage() {
|
export default function RequirementDetailPage() {
|
||||||
const { user, logout } = useAuth()
|
const { user, logout, isAuditor } = useAuth()
|
||||||
const { currentProject } = useProject()
|
const { currentProject } = useProject()
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
const [activeTab, setActiveTab] = useState<TabType>('description')
|
const [activeTab, setActiveTab] = useState<TabType>('description')
|
||||||
@@ -16,6 +17,15 @@ export default function RequirementDetailPage() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Validation state
|
||||||
|
const [validationStatuses, setValidationStatuses] = useState<ValidationStatus[]>([])
|
||||||
|
const [validationHistory, setValidationHistory] = useState<ValidationHistory[]>([])
|
||||||
|
const [selectedStatusId, setSelectedStatusId] = useState<number | ''>('')
|
||||||
|
const [validationComment, setValidationComment] = useState('')
|
||||||
|
const [validationLoading, setValidationLoading] = useState(false)
|
||||||
|
const [validationError, setValidationError] = useState<string | null>(null)
|
||||||
|
const [historyLoading, setHistoryLoading] = useState(false)
|
||||||
|
|
||||||
// Fetch requirement data on mount
|
// Fetch requirement data on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchRequirement = async () => {
|
const fetchRequirement = async () => {
|
||||||
@@ -41,6 +51,78 @@ export default function RequirementDetailPage() {
|
|||||||
fetchRequirement()
|
fetchRequirement()
|
||||||
}, [id])
|
}, [id])
|
||||||
|
|
||||||
|
// Fetch validation statuses and history when validate tab is active
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchValidationData = async () => {
|
||||||
|
if (activeTab !== 'validate' || !id) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
setHistoryLoading(true)
|
||||||
|
const [statuses, history] = await Promise.all([
|
||||||
|
validationService.getStatuses(),
|
||||||
|
validationService.getValidationHistory(parseInt(id, 10))
|
||||||
|
])
|
||||||
|
setValidationStatuses(statuses.filter(s => s.id !== 4)) // Exclude "Not Validated" as option
|
||||||
|
setValidationHistory(history)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch validation data:', err)
|
||||||
|
} finally {
|
||||||
|
setHistoryLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchValidationData()
|
||||||
|
}, [activeTab, id])
|
||||||
|
|
||||||
|
// Handle validation submission
|
||||||
|
const handleSubmitValidation = async () => {
|
||||||
|
if (!selectedStatusId || !id) {
|
||||||
|
setValidationError('Please select a validation status')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setValidationLoading(true)
|
||||||
|
setValidationError(null)
|
||||||
|
|
||||||
|
const newValidation = await validationService.createValidation(parseInt(id, 10), {
|
||||||
|
status_id: selectedStatusId as number,
|
||||||
|
comment: validationComment.trim() || undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add to history and update requirement
|
||||||
|
setValidationHistory(prev => [newValidation, ...prev])
|
||||||
|
|
||||||
|
// Refresh requirement to get updated validation status
|
||||||
|
const updatedRequirement = await requirementService.getRequirement(parseInt(id, 10))
|
||||||
|
setRequirement(updatedRequirement)
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
setSelectedStatusId('')
|
||||||
|
setValidationComment('')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to submit validation:', err)
|
||||||
|
setValidationError('Failed to submit validation. Please try again.')
|
||||||
|
} finally {
|
||||||
|
setValidationLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get validation status style
|
||||||
|
const getValidationStatusStyle = (status: string): string => {
|
||||||
|
switch (status) {
|
||||||
|
case 'Approved':
|
||||||
|
return 'bg-green-100 text-green-800'
|
||||||
|
case 'Denied':
|
||||||
|
return 'bg-red-100 text-red-800'
|
||||||
|
case 'Partial':
|
||||||
|
return 'bg-yellow-100 text-yellow-800'
|
||||||
|
case 'Not Validated':
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-600'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white flex items-center justify-center">
|
<div className="min-h-screen bg-white flex items-center justify-center">
|
||||||
@@ -91,7 +173,13 @@ export default function RequirementDetailPage() {
|
|||||||
<span className="font-semibold">Version:</span> {requirement.version}
|
<span className="font-semibold">Version:</span> {requirement.version}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-700 mb-2">
|
<p className="text-sm text-gray-700 mb-2">
|
||||||
<span className="font-semibold">Validation Status:</span> {validationStatus}
|
<span className="font-semibold">Validation Status:</span>{' '}
|
||||||
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getValidationStatusStyle(validationStatus)}`}>
|
||||||
|
{validationStatus}
|
||||||
|
</span>
|
||||||
|
{requirement.validated_by && (
|
||||||
|
<span className="text-gray-500 ml-2">by @{requirement.validated_by}</span>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
{requirement.created_at && (
|
{requirement.created_at && (
|
||||||
<p className="text-sm text-gray-700 mb-2">
|
<p className="text-sm text-gray-700 mb-2">
|
||||||
@@ -106,11 +194,14 @@ export default function RequirementDetailPage() {
|
|||||||
<div className="bg-teal-50 border border-gray-300 rounded min-h-[300px] p-4">
|
<div className="bg-teal-50 border border-gray-300 rounded min-h-[300px] p-4">
|
||||||
<p className="text-gray-700">{requirement.req_desc || 'No description provided.'}</p>
|
<p className="text-gray-700">{requirement.req_desc || 'No description provided.'}</p>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Hide Edit button for auditors */}
|
||||||
|
{!isAuditor && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<button className="px-4 py-1.5 border border-gray-400 rounded text-sm text-gray-700 hover:bg-gray-50">
|
<button className="px-4 py-1.5 border border-gray-400 rounded text-sm text-gray-700 hover:bg-gray-50">
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -119,11 +210,13 @@ export default function RequirementDetailPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-xl font-bold text-gray-800 mb-4">Sub-Requirements</h3>
|
<h3 className="text-xl font-bold text-gray-800 mb-4">Sub-Requirements</h3>
|
||||||
<p className="text-gray-500">No sub-requirements defined yet.</p>
|
<p className="text-gray-500">No sub-requirements defined yet.</p>
|
||||||
|
{!isAuditor && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<button className="px-4 py-1.5 border border-gray-400 rounded text-sm text-gray-700 hover:bg-gray-50">
|
<button className="px-4 py-1.5 border border-gray-400 rounded text-sm text-gray-700 hover:bg-gray-50">
|
||||||
Add Sub-Requirement
|
Add Sub-Requirement
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -132,11 +225,13 @@ export default function RequirementDetailPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-xl font-bold text-gray-800 mb-4">Co-Requirements</h3>
|
<h3 className="text-xl font-bold text-gray-800 mb-4">Co-Requirements</h3>
|
||||||
<p className="text-gray-500">No co-requirements defined yet.</p>
|
<p className="text-gray-500">No co-requirements defined yet.</p>
|
||||||
|
{!isAuditor && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<button className="px-4 py-1.5 border border-gray-400 rounded text-sm text-gray-700 hover:bg-gray-50">
|
<button className="px-4 py-1.5 border border-gray-400 rounded text-sm text-gray-700 hover:bg-gray-50">
|
||||||
Add Co-Requirement
|
Add Co-Requirement
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -145,11 +240,13 @@ export default function RequirementDetailPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-xl font-bold text-gray-800 mb-4">Acceptance Criteria</h3>
|
<h3 className="text-xl font-bold text-gray-800 mb-4">Acceptance Criteria</h3>
|
||||||
<p className="text-gray-500">No acceptance criteria defined yet.</p>
|
<p className="text-gray-500">No acceptance criteria defined yet.</p>
|
||||||
|
{!isAuditor && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<button className="px-4 py-1.5 border border-gray-400 rounded text-sm text-gray-700 hover:bg-gray-50">
|
<button className="px-4 py-1.5 border border-gray-400 rounded text-sm text-gray-700 hover:bg-gray-50">
|
||||||
Add Criterion
|
Add Criterion
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -175,29 +272,161 @@ export default function RequirementDetailPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xl font-bold text-gray-800 mb-4">Validate Requirement</h3>
|
<h3 className="text-xl font-bold text-gray-800 mb-4">Validate Requirement</h3>
|
||||||
<div className="p-4 border border-gray-300 rounded bg-gray-50">
|
|
||||||
<p className="text-gray-700 mb-4">
|
{/* Current Status */}
|
||||||
Review all acceptance criteria and validate this requirement when ready.
|
<div className="mb-6 p-4 bg-gray-50 border border-gray-200 rounded">
|
||||||
</p>
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-2 mb-4">
|
<div>
|
||||||
<label className="flex items-center gap-2">
|
<p className="text-sm text-gray-600">Current Status:</p>
|
||||||
<input type="checkbox" className="w-4 h-4 rounded border-gray-300 text-teal-600" />
|
<span className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-medium ${getValidationStatusStyle(validationStatus)}`}>
|
||||||
<span className="text-sm">All acceptance criteria have been met</span>
|
{validationStatus}
|
||||||
</label>
|
</span>
|
||||||
<label className="flex items-center gap-2">
|
|
||||||
<input type="checkbox" className="w-4 h-4 rounded border-gray-300 text-teal-600" />
|
|
||||||
<span className="text-sm">Documentation is complete</span>
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-2">
|
|
||||||
<input type="checkbox" className="w-4 h-4 rounded border-gray-300 text-teal-600" />
|
|
||||||
<span className="text-sm">Stakeholders have approved</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
<button className="px-4 py-2 bg-teal-600 text-white rounded text-sm font-medium hover:bg-teal-700">
|
<div className="text-right">
|
||||||
Validate Requirement
|
<p className="text-sm text-gray-600">Requirement Version:</p>
|
||||||
|
<span className="text-lg font-semibold text-gray-800">{requirement.version}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{requirement.validated_by && (
|
||||||
|
<p className="text-sm text-gray-500 mt-2">
|
||||||
|
Last validated by @{requirement.validated_by}
|
||||||
|
{requirement.validated_at && ` on ${new Date(requirement.validated_at).toLocaleDateString()}`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{requirement.validation_version !== null && requirement.validation_version !== requirement.version && (
|
||||||
|
<div className="mt-3 p-2 bg-orange-100 border border-orange-300 rounded">
|
||||||
|
<p className="text-sm text-orange-800 flex items-center gap-2">
|
||||||
|
<span>⚠️</span>
|
||||||
|
<span>
|
||||||
|
This requirement was modified after the last validation (validated at version {requirement.validation_version}, current version {requirement.version}).
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Validation Form - Only for auditors and admins */}
|
||||||
|
{(isAuditor || user?.role_id === 1) && (
|
||||||
|
<div className="mb-6 p-4 border border-gray-300 rounded">
|
||||||
|
<h4 className="font-semibold text-gray-800 mb-4">Submit Validation</h4>
|
||||||
|
|
||||||
|
{validationError && (
|
||||||
|
<div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded text-sm">
|
||||||
|
{validationError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Status Selection */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Validation Status <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedStatusId}
|
||||||
|
onChange={(e) => setSelectedStatusId(e.target.value ? Number(e.target.value) : '')}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent"
|
||||||
|
disabled={validationLoading}
|
||||||
|
>
|
||||||
|
<option value="">Select a status...</option>
|
||||||
|
{validationStatuses.map((status) => (
|
||||||
|
<option key={status.id} value={status.id}>
|
||||||
|
{status.status_name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comment */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Comment
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={validationComment}
|
||||||
|
onChange={(e) => setValidationComment(e.target.value)}
|
||||||
|
placeholder="Add a comment explaining your decision (optional but recommended)"
|
||||||
|
rows={4}
|
||||||
|
className="w-full p-3 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 resize-none"
|
||||||
|
disabled={validationLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit Button */}
|
||||||
|
<button
|
||||||
|
onClick={handleSubmitValidation}
|
||||||
|
disabled={validationLoading || !selectedStatusId}
|
||||||
|
className="px-4 py-2 bg-teal-600 text-white rounded text-sm font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{validationLoading ? 'Submitting...' : 'Submit Validation'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Validation History */}
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-gray-800 mb-4">Validation History</h4>
|
||||||
|
|
||||||
|
{historyLoading ? (
|
||||||
|
<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">Loading history...</p>
|
||||||
|
</div>
|
||||||
|
) : validationHistory.length === 0 ? (
|
||||||
|
<p className="text-gray-500 text-center py-8">No validation history yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full border border-gray-200 rounded">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Version</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Validator</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Comment</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{validationHistory.map((validation) => {
|
||||||
|
const isStale = validation.req_version_snapshot !== requirement.version
|
||||||
|
return (
|
||||||
|
<tr key={validation.id} className={isStale ? 'bg-orange-50' : ''}>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-700">
|
||||||
|
{validation.created_at
|
||||||
|
? new Date(validation.created_at).toLocaleString()
|
||||||
|
: 'N/A'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getValidationStatusStyle(validation.status_name)}`}>
|
||||||
|
{validation.status_name}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-700">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
v{validation.req_version_snapshot}
|
||||||
|
{isStale && (
|
||||||
|
<span className="text-orange-600" title="Requirement was modified after this validation">
|
||||||
|
⚠️
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-700">
|
||||||
|
@{validation.validator_username}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-700">
|
||||||
|
{validation.comment || <span className="text-gray-400 italic">No comment</span>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -244,8 +473,8 @@ export default function RequirementDetailPage() {
|
|||||||
<path fillRule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clipRule="evenodd" />
|
<path fillRule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clipRule="evenodd" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-sm text-gray-700">
|
<span className="text-sm text-gray-700">
|
||||||
{user?.full_name || user?.preferred_username || 'Ricardo Belo'}{' '}
|
{user?.full_name || user?.preferred_username || 'User'}{' '}
|
||||||
<span className="text-gray-500">(admin)</span>
|
<span className="text-gray-500">({user?.role || 'user'})</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -7,6 +7,21 @@ import type { Tag } from '@/services/tagService'
|
|||||||
import type { Priority } from '@/services/priorityService'
|
import type { Priority } from '@/services/priorityService'
|
||||||
import type { Requirement, RequirementCreateRequest } from '@/services/requirementService'
|
import type { Requirement, RequirementCreateRequest } from '@/services/requirementService'
|
||||||
|
|
||||||
|
// Get validation status color
|
||||||
|
const getValidationStatusStyle = (status: string): { bgColor: string; textColor: string } => {
|
||||||
|
switch (status) {
|
||||||
|
case 'Approved':
|
||||||
|
return { bgColor: 'bg-green-100', textColor: 'text-green-800' }
|
||||||
|
case 'Denied':
|
||||||
|
return { bgColor: 'bg-red-100', textColor: 'text-red-800' }
|
||||||
|
case 'Partial':
|
||||||
|
return { bgColor: 'bg-yellow-100', textColor: 'text-yellow-800' }
|
||||||
|
case 'Not Validated':
|
||||||
|
default:
|
||||||
|
return { bgColor: 'bg-gray-100', textColor: 'text-gray-600' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Helper to lighten a hex color for backgrounds
|
// Helper to lighten a hex color for backgrounds
|
||||||
function lightenColor(hex: string, percent: number): string {
|
function lightenColor(hex: string, percent: number): string {
|
||||||
const num = parseInt(hex.replace('#', ''), 16)
|
const num = parseInt(hex.replace('#', ''), 16)
|
||||||
@@ -23,7 +38,7 @@ function lightenColor(hex: string, percent: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function RequirementsPage() {
|
export default function RequirementsPage() {
|
||||||
const { user, logout } = useAuth()
|
const { user, logout, isAuditor } = useAuth()
|
||||||
const { currentProject, isLoading: projectLoading } = useProject()
|
const { currentProject, isLoading: projectLoading } = useProject()
|
||||||
const [searchParams, setSearchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -335,8 +350,8 @@ export default function RequirementsPage() {
|
|||||||
<path fillRule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clipRule="evenodd" />
|
<path fillRule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clipRule="evenodd" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-sm text-gray-700">
|
<span className="text-sm text-gray-700">
|
||||||
{user?.full_name || user?.preferred_username || 'Ricardo Belo'}{' '}
|
{user?.full_name || user?.preferred_username || 'User'}{' '}
|
||||||
<span className="text-gray-500">(admin)</span>
|
<span className="text-gray-500">({user?.role || 'user'})</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -354,7 +369,8 @@ export default function RequirementsPage() {
|
|||||||
<div className="flex gap-8">
|
<div className="flex gap-8">
|
||||||
{/* Main Panel */}
|
{/* Main Panel */}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
{/* New Requirement Button */}
|
{/* New Requirement Button - Hidden for auditors */}
|
||||||
|
{!isAuditor && (
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<button
|
<button
|
||||||
onClick={openCreateModal}
|
onClick={openCreateModal}
|
||||||
@@ -363,6 +379,7 @@ export default function RequirementsPage() {
|
|||||||
New Requirement
|
New Requirement
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Search Bar */}
|
{/* Search Bar */}
|
||||||
<div className="flex gap-2 mb-6">
|
<div className="flex gap-2 mb-6">
|
||||||
@@ -432,6 +449,8 @@ export default function RequirementsPage() {
|
|||||||
const tagLabel = req.tag.tag_code
|
const tagLabel = req.tag.tag_code
|
||||||
const priorityName = req.priority?.priority_name ?? 'None'
|
const priorityName = req.priority?.priority_name ?? 'None'
|
||||||
const validationStatus = req.validation_status || 'Not Validated'
|
const validationStatus = req.validation_status || 'Not Validated'
|
||||||
|
const validationStyle = getValidationStatusStyle(validationStatus)
|
||||||
|
const isStale = req.validation_version !== null && req.validation_version !== req.version
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -451,9 +470,21 @@ export default function RequirementsPage() {
|
|||||||
|
|
||||||
{/* Validation status */}
|
{/* Validation status */}
|
||||||
<div className="flex-1 px-6 py-4 text-center">
|
<div className="flex-1 px-6 py-4 text-center">
|
||||||
<span className="text-sm text-gray-600">
|
<div className="flex items-center justify-center gap-2">
|
||||||
Validation: {validationStatus}
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${validationStyle.bgColor} ${validationStyle.textColor}`}>
|
||||||
|
{validationStatus}
|
||||||
</span>
|
</span>
|
||||||
|
{isStale && (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800" title="Requirement was modified after validation">
|
||||||
|
⚠ Stale
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{req.validated_by && (
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
by @{req.validated_by}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Priority and Version */}
|
{/* Priority and Version */}
|
||||||
@@ -470,12 +501,14 @@ export default function RequirementsPage() {
|
|||||||
>
|
>
|
||||||
Details
|
Details
|
||||||
</button>
|
</button>
|
||||||
|
{!isAuditor && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleRemove(req.id)}
|
onClick={() => handleRemove(req.id)}
|
||||||
className="px-3 py-1 border border-gray-400 rounded text-sm text-gray-700 hover:bg-gray-50"
|
className="px-3 py-1 border border-gray-400 rounded text-sm text-gray-700 hover:bg-gray-50"
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ export { requirementService } from './requirementService'
|
|||||||
export type { Requirement, RequirementCreateRequest, RequirementUpdateRequest } from './requirementService'
|
export type { Requirement, RequirementCreateRequest, RequirementUpdateRequest } from './requirementService'
|
||||||
export { projectService } from './projectService'
|
export { projectService } from './projectService'
|
||||||
export type { Project, ProjectCreateRequest, ProjectUpdateRequest } from './projectService'
|
export type { Project, ProjectCreateRequest, ProjectUpdateRequest } from './projectService'
|
||||||
|
export { validationService } from './validationService'
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ export interface Requirement {
|
|||||||
priority: Priority | null
|
priority: Priority | null
|
||||||
groups: Group[]
|
groups: Group[]
|
||||||
validation_status: string | null
|
validation_status: string | null
|
||||||
|
validated_by: string | null
|
||||||
|
validated_at: string | null
|
||||||
|
validation_version: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RequirementCreateRequest {
|
export interface RequirementCreateRequest {
|
||||||
|
|||||||
83
frontend/src/services/validationService.ts
Normal file
83
frontend/src/services/validationService.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import type { ValidationStatus, ValidationHistory, ValidationCreateRequest } from '@/types'
|
||||||
|
|
||||||
|
const API_BASE_URL = '/api'
|
||||||
|
|
||||||
|
class ValidationService {
|
||||||
|
/**
|
||||||
|
* Get all validation statuses from the API.
|
||||||
|
*/
|
||||||
|
async getStatuses(): Promise<ValidationStatus[]> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/validation-statuses`, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const statuses: ValidationStatus[] = await response.json()
|
||||||
|
return statuses
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch validation statuses:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a validation for a requirement.
|
||||||
|
*/
|
||||||
|
async createValidation(requirementId: number, data: ValidationCreateRequest): Promise<ValidationHistory> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/requirements/${requirementId}/validations`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const validation: ValidationHistory = await response.json()
|
||||||
|
return validation
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create validation:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get validation history for a requirement.
|
||||||
|
*/
|
||||||
|
async getValidationHistory(requirementId: number): Promise<ValidationHistory[]> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/requirements/${requirementId}/validations`, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const history: ValidationHistory[] = await response.json()
|
||||||
|
return history
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch validation history:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const validationService = new ValidationService()
|
||||||
@@ -2,12 +2,15 @@ export interface User {
|
|||||||
preferred_username: string
|
preferred_username: string
|
||||||
email: string | null
|
email: string | null
|
||||||
full_name: string | null
|
full_name: string | null
|
||||||
|
role: string | null
|
||||||
|
role_id: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthContextType {
|
export interface AuthContextType {
|
||||||
user: User | null
|
user: User | null
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
isAuthenticated: boolean
|
isAuthenticated: boolean
|
||||||
|
isAuditor: boolean
|
||||||
login: () => void
|
login: () => void
|
||||||
logout: () => Promise<void>
|
logout: () => Promise<void>
|
||||||
refreshUser: () => Promise<void>
|
refreshUser: () => Promise<void>
|
||||||
|
|||||||
@@ -1 +1,23 @@
|
|||||||
export * from './auth'
|
export * from './auth'
|
||||||
|
|
||||||
|
// Validation types
|
||||||
|
export interface ValidationStatus {
|
||||||
|
id: number
|
||||||
|
status_name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValidationHistory {
|
||||||
|
id: number
|
||||||
|
status_name: string
|
||||||
|
status_id: number
|
||||||
|
req_version_snapshot: number
|
||||||
|
comment: string | null
|
||||||
|
created_at: string | null
|
||||||
|
validator_username: string
|
||||||
|
validator_id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValidationCreateRequest {
|
||||||
|
status_id: number
|
||||||
|
comment?: string
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user