From 459ceaa16209f37482aaec04268e068670e5f95b Mon Sep 17 00:00:00 2001
From: gulimabr
Date: Tue, 2 Dec 2025 11:21:03 -0300
Subject: [PATCH] Added history track
---
backend/src/main.py | 57 ++-
backend/src/models.py | 18 +
.../repositories/requirement_repository.py | 66 ++-
frontend/src/pages/RequirementDetailPage.tsx | 423 +++++++++++++++++-
frontend/src/services/requirementService.ts | 29 ++
frontend/src/types/index.ts | 13 +
6 files changed, 593 insertions(+), 13 deletions(-)
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(null)
const [deletingReplyId, setDeletingReplyId] = useState(null)
+ // Edit modal state
+ const [showEditModal, setShowEditModal] = useState(false)
+ const [editLoading, setEditLoading] = useState(false)
+ const [editError, setEditError] = useState(null)
+ const [editReqName, setEditReqName] = useState('')
+ const [editReqDesc, setEditReqDesc] = useState('')
+ const [editTagId, setEditTagId] = useState('')
+ const [editPriorityId, setEditPriorityId] = useState('')
+ const [editGroupIds, setEditGroupIds] = useState([])
+ const [availableTags, setAvailableTags] = useState([])
+ const [availablePriorities, setAvailablePriorities] = useState([])
+ const [availableGroups, setAvailableGroups] = useState([])
+ const [editOptionsLoading, setEditOptionsLoading] = useState(false)
+
+ // Requirement history state
+ const [reqHistory, setReqHistory] = useState([])
+ const [reqHistoryLoading, setReqHistoryLoading] = useState(false)
+ const [expandedHistoryId, setExpandedHistoryId] = useState(null)
+
// Fetch requirement data on mount
useEffect(() => {
const fetchRequirement = async () => {
@@ -142,6 +164,25 @@ export default function RequirementDetailPage() {
fetchCommentsData()
}, [activeTab, id])
+ // Fetch requirement history when history tab is active
+ useEffect(() => {
+ const fetchHistoryData = async () => {
+ if (activeTab !== 'history' || !id) return
+
+ try {
+ setReqHistoryLoading(true)
+ const history = await requirementService.getRequirementHistory(parseInt(id, 10))
+ setReqHistory(history)
+ } catch (err) {
+ console.error('Failed to fetch requirement history:', err)
+ } finally {
+ setReqHistoryLoading(false)
+ }
+ }
+
+ fetchHistoryData()
+ }, [activeTab, id])
+
// Debounced search for target requirements
useEffect(() => {
if (!showAddRelationshipModal || !currentProject || !id) return
@@ -378,6 +419,87 @@ export default function RequirementDetailPage() {
}
}
+ // Open edit modal and load options
+ const openEditModal = async () => {
+ if (!requirement) return
+
+ // Set initial form values from current requirement
+ setEditReqName(requirement.req_name)
+ setEditReqDesc(requirement.req_desc || '')
+ setEditTagId(requirement.tag.id)
+ setEditPriorityId(requirement.priority?.id || '')
+ setEditGroupIds(requirement.groups.map(g => g.id))
+ setEditError(null)
+ setShowEditModal(true)
+
+ // Fetch options if not already loaded
+ if (availableTags.length === 0 || availablePriorities.length === 0 || availableGroups.length === 0) {
+ try {
+ setEditOptionsLoading(true)
+ const [tags, priorities, groups] = await Promise.all([
+ tagService.getTags(),
+ priorityService.getPriorities(),
+ groupService.getGroups()
+ ])
+ setAvailableTags(tags)
+ setAvailablePriorities(priorities)
+ setAvailableGroups(groups)
+ } catch (err) {
+ console.error('Failed to fetch edit options:', err)
+ setEditError('Failed to load options. Please try again.')
+ } finally {
+ setEditOptionsLoading(false)
+ }
+ }
+ }
+
+ // Close edit modal
+ const closeEditModal = () => {
+ setShowEditModal(false)
+ setEditError(null)
+ }
+
+ // Handle saving edits
+ const handleSaveEdit = async () => {
+ if (!id || !editReqName.trim() || !editTagId) {
+ setEditError('Name and Tag are required')
+ return
+ }
+
+ try {
+ setEditLoading(true)
+ setEditError(null)
+
+ await requirementService.updateRequirement(parseInt(id, 10), {
+ req_name: editReqName.trim(),
+ req_desc: editReqDesc.trim() || undefined,
+ tag_id: editTagId as number,
+ priority_id: editPriorityId ? editPriorityId as number : undefined,
+ group_ids: editGroupIds
+ })
+
+ // Refetch to ensure consistency (trigger updates version)
+ const updatedRequirement = await requirementService.getRequirement(parseInt(id, 10))
+ setRequirement(updatedRequirement)
+
+ closeEditModal()
+ } catch (err) {
+ console.error('Failed to update requirement:', err)
+ setEditError('Failed to save changes. Please try again.')
+ } finally {
+ setEditLoading(false)
+ }
+ }
+
+ // Toggle group selection in edit modal
+ const toggleGroupSelection = (groupId: number) => {
+ setEditGroupIds(prev =>
+ prev.includes(groupId)
+ ? prev.filter(id => id !== groupId)
+ : [...prev, groupId]
+ )
+ }
+
// Get validation status style
const getValidationStatusStyle = (status: string): string => {
switch (status) {
@@ -425,6 +547,7 @@ export default function RequirementDetailPage() {
{ id: 'acceptance-criteria', label: 'Acceptance Criteria' },
{ id: 'shared-comments', label: 'Shared Comments' },
{ id: 'validate', label: 'Validate' },
+ { id: 'history', label: 'History' },
]
// Get display values from the requirement data
@@ -450,6 +573,20 @@ export default function RequirementDetailPage() {
by @{requirement.validated_by}
)}
+
+ 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 && (
-
@@ -783,8 +923,8 @@ export default function RequirementDetailPage() {
)}
- {/* Validation Form - Only for auditors and admins */}
- {(isAuditor || user?.role_id === 1) && (
+ {/* Validation Form - Only for auditors */}
+ {isAuditor && (
Submit Validation
@@ -907,6 +1047,117 @@ export default function RequirementDetailPage() {
)
+ case 'history':
+ return (
+
+
Version History
+
+ {/* Author info */}
+
+ Original Author:{' '}
+ {requirement.author_username ? (
+ @{requirement.author_username}
+ ) : (
+ Unknown
+ )}
+ {requirement.created_at && (
+
+ (created {new Date(requirement.created_at).toLocaleDateString()})
+
+ )}
+
+
+
+ Note: Group changes are not tracked in the version history.
+
+
+ {reqHistoryLoading ? (
+
+ ) : reqHistory.length === 0 ? (
+
No version history yet. History is recorded when the requirement is edited.
+ ) : (
+
+ {reqHistory.map((historyItem) => (
+
+ {/* History Row Header */}
+
setExpandedHistoryId(
+ expandedHistoryId === historyItem.history_id ? null : historyItem.history_id
+ )}
+ >
+
+
+ v{historyItem.version}
+
+
+ {historyItem.req_name || No name}
+
+ {historyItem.tag_code && (
+
+ {historyItem.tag_code}
+
+ )}
+ {historyItem.priority_name && (
+
+ Priority: {historyItem.priority_name}
+
+ )}
+
+
+ {historyItem.edited_by_username && (
+ by @{historyItem.edited_by_username}
+ )}
+ {historyItem.valid_from && historyItem.valid_to && (
+
+ {new Date(historyItem.valid_from).toLocaleDateString()} - {new Date(historyItem.valid_to).toLocaleDateString()}
+
+ )}
+
+ {expandedHistoryId === historyItem.history_id ? '▼' : '▶'}
+
+
+
+
+ {/* Expanded Description */}
+ {expandedHistoryId === historyItem.history_id && (
+
+
Description:
+
+
+ {historyItem.req_desc || No description}
+
+
+
+
+ Valid From:{' '}
+
+ {historyItem.valid_from
+ ? new Date(historyItem.valid_from).toLocaleString()
+ : 'N/A'}
+
+
+
+ Valid To:{' '}
+
+ {historyItem.valid_to
+ ? new Date(historyItem.valid_to).toLocaleString()
+ : 'N/A'}
+
+
+
+
+ )}
+
+ ))}
+
+ )}
+
+ )
+
default:
return null
}
@@ -1155,6 +1406,166 @@ export default function RequirementDetailPage() {
)}
+
+ {/* Edit Requirement Modal */}
+ {showEditModal && (
+
+
+ {/* Modal Header */}
+
+
Edit Requirement
+
+ ✕
+
+
+
+ {/* Modal Body */}
+
+ {editError && (
+
+ {editError}
+
+ )}
+
+ {editOptionsLoading ? (
+
+ ) : (
+ <>
+ {/* Name */}
+
+
+ setEditReqName(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={editLoading}
+ />
+
+
+ {/* Tag */}
+
+
+
+
+
+ {/* Priority */}
+
+
+
+
+
+ {/* Description */}
+
+
+
+
+ {/* Groups */}
+
+
+ {availableGroups.length === 0 ? (
+
No groups available
+ ) : (
+
+ {availableGroups.map((group) => (
+
+ ))}
+
+ )}
+
+ >
+ )}
+
+
+ {/* Modal Footer */}
+
+
+ Cancel
+
+
+ {editLoading ? 'Saving...' : 'Save Changes'}
+
+
+
+
+ )}
)
}
diff --git a/frontend/src/services/requirementService.ts b/frontend/src/services/requirementService.ts
index 968c0f7..0463bd3 100644
--- a/frontend/src/services/requirementService.ts
+++ b/frontend/src/services/requirementService.ts
@@ -1,6 +1,7 @@
import { Group } from './groupService'
import { Tag } from './tagService'
import { Priority } from './priorityService'
+import type { RequirementHistory } from '@/types'
const API_BASE_URL = '/api'
@@ -19,6 +20,8 @@ export interface Requirement {
validated_by: string | null
validated_at: string | null
validation_version: number | null
+ author_username: string | null
+ last_editor_username: string | null
}
export interface RequirementCreateRequest {
@@ -208,6 +211,32 @@ class RequirementService {
throw error
}
}
+
+ /**
+ * Get the version history for a requirement.
+ * Returns all previous versions ordered by version (newest first).
+ */
+ async getRequirementHistory(requirementId: number): Promise {
+ try {
+ const response = await fetch(`${API_BASE_URL}/requirements/${requirementId}/history`, {
+ method: 'GET',
+ credentials: 'include',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ })
+
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`)
+ }
+
+ const history: RequirementHistory[] = await response.json()
+ return history
+ } catch (error) {
+ console.error('Failed to fetch requirement history:', error)
+ throw error
+ }
+ }
}
export const requirementService = new RequirementService()
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index 04449af..39273bf 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -51,3 +51,16 @@ export interface CommentCreateRequest {
export interface ReplyCreateRequest {
reply_text: string
}
+
+// Requirement History types
+export interface RequirementHistory {
+ history_id: number
+ version: number | null
+ req_name: string | null
+ req_desc: string | null
+ tag_code: string | null
+ priority_name: string | null
+ edited_by_username: string | null
+ valid_from: string | null
+ valid_to: string | null
+}