Added auditor logic

This commit is contained in:
gulimabr
2025-12-01 11:36:43 -03:00
parent 07005788ed
commit f7bb62ea99
15 changed files with 888 additions and 72 deletions

View File

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

View File

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

View File

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

View File

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

View 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

View 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()

View File

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

View File

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

View File

@@ -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>
<div className="mt-4"> {/* Hide Edit button for auditors */}
<button className="px-4 py-1.5 border border-gray-400 rounded text-sm text-gray-700 hover:bg-gray-50"> {!isAuditor && (
Edit <div className="mt-4">
</button> <button className="px-4 py-1.5 border border-gray-400 rounded text-sm text-gray-700 hover:bg-gray-50">
</div> Edit
</button>
</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>
<div className="mt-4"> {!isAuditor && (
<button className="px-4 py-1.5 border border-gray-400 rounded text-sm text-gray-700 hover:bg-gray-50"> <div className="mt-4">
Add Sub-Requirement <button className="px-4 py-1.5 border border-gray-400 rounded text-sm text-gray-700 hover:bg-gray-50">
</button> Add Sub-Requirement
</div> </button>
</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>
<div className="mt-4"> {!isAuditor && (
<button className="px-4 py-1.5 border border-gray-400 rounded text-sm text-gray-700 hover:bg-gray-50"> <div className="mt-4">
Add Co-Requirement <button className="px-4 py-1.5 border border-gray-400 rounded text-sm text-gray-700 hover:bg-gray-50">
</button> Add Co-Requirement
</div> </button>
</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>
<div className="mt-4"> {!isAuditor && (
<button className="px-4 py-1.5 border border-gray-400 rounded text-sm text-gray-700 hover:bg-gray-50"> <div className="mt-4">
Add Criterion <button className="px-4 py-1.5 border border-gray-400 rounded text-sm text-gray-700 hover:bg-gray-50">
</button> Add Criterion
</div> </button>
</div>
)}
</div> </div>
) )
@@ -175,27 +272,159 @@ 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"> </div>
<input type="checkbox" className="w-4 h-4 rounded border-gray-300 text-teal-600" /> <div className="text-right">
<span className="text-sm">Documentation is complete</span> <p className="text-sm text-gray-600">Requirement Version:</p>
</label> <span className="text-lg font-semibold text-gray-800">{requirement.version}</span>
<label className="flex items-center gap-2"> </div>
<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"> {requirement.validated_by && (
Validate Requirement <p className="text-sm text-gray-500 mt-2">
</button> 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>
</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>
</div> </div>
) )
@@ -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

View File

@@ -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,15 +369,17 @@ 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 */}
<div className="mb-6"> {!isAuditor && (
<button <div className="mb-6">
onClick={openCreateModal} <button
className="px-4 py-2 border border-gray-400 rounded text-sm font-medium text-gray-700 hover:bg-gray-50" onClick={openCreateModal}
> className="px-4 py-2 border border-gray-400 rounded text-sm font-medium text-gray-700 hover:bg-gray-50"
New Requirement >
</button> New Requirement
</div> </button>
</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}`}>
</span> {validationStatus}
</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>
<button {!isAuditor && (
onClick={() => handleRemove(req.id)} <button
className="px-3 py-1 border border-gray-400 rounded text-sm text-gray-700 hover:bg-gray-50" onClick={() => handleRemove(req.id)}
> className="px-3 py-1 border border-gray-400 rounded text-sm text-gray-700 hover:bg-gray-50"
Remove >
</button> Remove
</button>
)}
</div> </div>
</div> </div>
) )

View File

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

View File

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

View 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()

View File

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

View File

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