diff --git a/backend/src/main.py b/backend/src/main.py
index 2320390..df8f89b 100644
--- a/backend/src/main.py
+++ b/backend/src/main.py
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.models import (
TokenResponse, UserInfo, GroupResponse,
TagResponse, RequirementResponse, PriorityResponse,
- RequirementCreateRequest, RequirementUpdateRequest,
+ RequirementCreateRequest, RequirementUpdateRequest, RequirementHistoryResponse,
ProjectResponse, ProjectCreateRequest, ProjectUpdateRequest, ProjectMemberRequest,
ValidationStatusResponse, ValidationHistoryResponse, ValidationCreateRequest,
RelationshipTypeResponse, RelationshipTypeCreateRequest, RelationshipTypeUpdateRequest,
@@ -793,6 +793,16 @@ def _build_requirement_response(req) -> RequirementResponse:
validated_at = latest_validation.created_at
validation_version = latest_validation.req_version_snapshot
+ # Get author (creator) display name
+ author_username = None
+ if req.user:
+ author_username = _get_display_name(req.user)
+
+ # Get last editor display name
+ last_editor_username = None
+ if req.last_editor:
+ last_editor_username = _get_display_name(req.last_editor)
+
return RequirementResponse(
id=req.id,
project_id=req.project_id,
@@ -808,6 +818,8 @@ def _build_requirement_response(req) -> RequirementResponse:
validated_by=validated_by,
validated_at=validated_at,
validation_version=validation_version,
+ author_username=author_username,
+ last_editor_username=last_editor_username,
)
@@ -1047,6 +1059,43 @@ async def delete_requirement(
await db.commit()
+@app.get("/api/requirements/{requirement_id}/history", response_model=List[RequirementHistoryResponse])
+async def get_requirement_history(
+ requirement_id: int,
+ request: Request,
+ db: AsyncSession = Depends(get_db)
+):
+ """
+ Get the version history for a requirement.
+ Returns all previous versions ordered by version (newest first).
+ Note: Group changes are not tracked in history.
+
+ Args:
+ requirement_id: The requirement to get history for
+
+ Returns:
+ List of historical versions with tag, priority, and editor 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 history
+ history = await req_repo.get_history(requirement_id)
+
+ return [RequirementHistoryResponse(**h) for h in history]
+
+
# ===========================================
# Validation Endpoints
# ===========================================
@@ -1073,7 +1122,7 @@ async def create_validation(
):
"""
Create a new validation for a requirement.
- Only auditors (role_id=2) and admins (role_id=1) can validate requirements.
+ Only auditors (role_id=2) can validate requirements.
Args:
requirement_id: The requirement to validate
@@ -1084,8 +1133,8 @@ async def create_validation(
"""
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")
+ # Only auditors (role_id=2) can validate
+ _require_role(user, [2], "validate requirements")
# Check if requirement exists and user has access
req_repo = RequirementRepository(db)
diff --git a/backend/src/models.py b/backend/src/models.py
index 4c13dd5..b186399 100644
--- a/backend/src/models.py
+++ b/backend/src/models.py
@@ -211,6 +211,8 @@ class RequirementResponse(BaseModel):
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
+ author_username: Optional[str] = None # Display name of who created the requirement
+ last_editor_username: Optional[str] = None # Display name of who last edited the requirement
class Config:
from_attributes = True
@@ -240,6 +242,22 @@ class RequirementUpdateRequest(BaseModel):
group_ids: Optional[List[int]] = None
+class RequirementHistoryResponse(BaseModel):
+ """Response schema for requirement history (previous versions)."""
+ history_id: int
+ version: Optional[int] = None
+ req_name: Optional[str] = None
+ req_desc: Optional[str] = None
+ tag_code: Optional[str] = None
+ priority_name: Optional[str] = None
+ edited_by_username: Optional[str] = None
+ valid_from: Optional[datetime] = None
+ valid_to: Optional[datetime] = None
+
+ class Config:
+ from_attributes = True
+
+
# Relationship Type schemas
class RelationshipTypeResponse(BaseModel):
"""Response schema for a relationship type."""
diff --git a/backend/src/repositories/requirement_repository.py b/backend/src/repositories/requirement_repository.py
index 4b44261..f899da8 100644
--- a/backend/src/repositories/requirement_repository.py
+++ b/backend/src/repositories/requirement_repository.py
@@ -1,11 +1,11 @@
"""
Repository layer for Requirement database operations.
"""
-from typing import List, Optional
-from sqlalchemy import select
+from typing import List, Optional, Dict, Any
+from sqlalchemy import select, text
from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession
-from src.db_models import Requirement, Group, Tag, Priority, Validation
+from src.db_models import Requirement, Group, Tag, Priority, Validation, RequirementHistory
import logging
logger = logging.getLogger(__name__)
@@ -32,6 +32,8 @@ class RequirementRepository:
selectinload(Requirement.groups),
selectinload(Requirement.validations).selectinload(Validation.status),
selectinload(Requirement.validations).selectinload(Validation.user),
+ selectinload(Requirement.user),
+ selectinload(Requirement.last_editor),
)
.order_by(Requirement.created_at.desc())
)
@@ -55,6 +57,8 @@ class RequirementRepository:
selectinload(Requirement.groups),
selectinload(Requirement.validations).selectinload(Validation.status),
selectinload(Requirement.validations).selectinload(Validation.user),
+ selectinload(Requirement.user),
+ selectinload(Requirement.last_editor),
)
.where(Requirement.project_id == project_id)
.order_by(Requirement.created_at.desc())
@@ -79,6 +83,8 @@ class RequirementRepository:
selectinload(Requirement.groups),
selectinload(Requirement.validations).selectinload(Validation.status),
selectinload(Requirement.validations).selectinload(Validation.user),
+ selectinload(Requirement.user),
+ selectinload(Requirement.last_editor),
)
.where(Requirement.user_id == user_id)
.order_by(Requirement.created_at.desc())
@@ -130,6 +136,8 @@ class RequirementRepository:
selectinload(Requirement.groups),
selectinload(Requirement.validations).selectinload(Validation.status),
selectinload(Requirement.validations).selectinload(Validation.user),
+ selectinload(Requirement.user),
+ selectinload(Requirement.last_editor),
)
.join(Requirement.groups)
.where(Group.id == group_id)
@@ -165,6 +173,8 @@ class RequirementRepository:
selectinload(Requirement.groups),
selectinload(Requirement.validations).selectinload(Validation.status),
selectinload(Requirement.validations).selectinload(Validation.user),
+ selectinload(Requirement.user),
+ selectinload(Requirement.last_editor),
)
.where(Requirement.tag_id == tag_id)
)
@@ -349,3 +359,53 @@ class RequirementRepository:
result = await self.session.execute(stmt)
return list(result.scalars().all())
+
+ async def get_history(self, requirement_id: int) -> List[Dict[str, Any]]:
+ """
+ Get the version history for a requirement.
+ This data is populated by a database trigger on UPDATE.
+
+ Args:
+ requirement_id: The requirement ID to get history for
+
+ Returns:
+ List of historical versions with resolved tag/priority/editor names
+ """
+ # Use raw SQL with LEFT JOINs to resolve foreign keys to display names
+ query = text("""
+ SELECT
+ rh.history_id,
+ rh.version,
+ rh.req_name,
+ rh.req_desc,
+ t.tag_code,
+ p.priority_name,
+ u.full_name as edited_by_full_name,
+ u.username as edited_by_username,
+ rh.valid_from,
+ rh.valid_to
+ FROM requirements_history rh
+ LEFT JOIN tags t ON rh.tag_id = t.id
+ LEFT JOIN priorities p ON rh.priority_id = p.id
+ LEFT JOIN users u ON rh.edited_by = u.id
+ WHERE rh.original_req_id = :requirement_id
+ ORDER BY rh.version DESC
+ """)
+
+ result = await self.session.execute(query, {"requirement_id": requirement_id})
+ rows = result.fetchall()
+
+ return [
+ {
+ "history_id": row.history_id,
+ "version": row.version,
+ "req_name": row.req_name,
+ "req_desc": row.req_desc,
+ "tag_code": row.tag_code,
+ "priority_name": row.priority_name,
+ "edited_by_username": row.edited_by_full_name or row.edited_by_username,
+ "valid_from": row.valid_from,
+ "valid_to": row.valid_to,
+ }
+ for row in rows
+ ]
diff --git a/frontend/src/pages/RequirementDetailPage.tsx b/frontend/src/pages/RequirementDetailPage.tsx
index 49fc406..7eb1c4b 100644
--- a/frontend/src/pages/RequirementDetailPage.tsx
+++ b/frontend/src/pages/RequirementDetailPage.tsx
@@ -1,13 +1,16 @@
import { useState, useEffect } from 'react'
import { useAuth, useProject } from '@/hooks'
import { useParams, Link } from 'react-router-dom'
-import { requirementService, validationService, relationshipService, commentService } from '@/services'
+import { requirementService, validationService, relationshipService, commentService, tagService, priorityService, groupService } from '@/services'
import type { Requirement } from '@/services/requirementService'
import type { RelationshipType, RequirementLink, RequirementSearchResult } from '@/services/relationshipService'
-import type { ValidationStatus, ValidationHistory, Comment } from '@/types'
+import type { Tag } from '@/services/tagService'
+import type { Priority } from '@/services/priorityService'
+import type { Group } from '@/services/groupService'
+import type { ValidationStatus, ValidationHistory, Comment, RequirementHistory } from '@/types'
// Tab types
-type TabType = 'description' | 'relationships' | 'acceptance-criteria' | 'shared-comments' | 'validate'
+type TabType = 'description' | 'relationships' | 'acceptance-criteria' | 'shared-comments' | 'validate' | 'history'
export default function RequirementDetailPage() {
const { user, logout, isAuditor } = useAuth()
@@ -52,6 +55,25 @@ export default function RequirementDetailPage() {
const [deletingCommentId, setDeletingCommentId] = useState
+ Author:{' '} + {requirement.author_username ? ( + @{requirement.author_username} + ) : ( + Unknown + )} +
+ {requirement.last_editor_username && ( ++ Last Edited By:{' '} + @{requirement.last_editor_username} +
+ )} {requirement.created_at && (Created: {new Date(requirement.created_at).toLocaleDateString()} @@ -466,7 +603,10 @@ export default function RequirementDetailPage() { {/* Hide Edit button for auditors */} {!isAuditor && (
Loading history...
+No version history yet. History is recorded when the requirement is edited.
+ ) : ( +Description:
++ {historyItem.req_desc || No description} +
+Loading options...
+No groups available
+ ) : ( +