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 ? ( +
+
+

Loading history...

+
+ ) : 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 ? ( +
+
+

Loading options...

+
+ ) : ( + <> + {/* 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 */} +
+ +