From a65719b631a8e11e6347ec934d2fa36b6d595c5c Mon Sep 17 00:00:00 2001 From: gulimabr Date: Tue, 2 Dec 2025 15:05:34 -0300 Subject: [PATCH] fixed deletion of requirements and added modal to see deleted requirements --- backend/src/db_models.py | 15 +- backend/src/main.py | 31 ++- backend/src/models.py | 16 ++ .../repositories/requirement_repository.py | 70 +++++++ frontend/src/pages/RequirementsPage.tsx | 191 +++++++++++++++++- frontend/src/services/requirementService.ts | 28 ++- frontend/src/types/index.ts | 13 ++ 7 files changed, 358 insertions(+), 6 deletions(-) diff --git a/backend/src/db_models.py b/backend/src/db_models.py index 22b351d..78fcd5a 100644 --- a/backend/src/db_models.py +++ b/backend/src/db_models.py @@ -236,16 +236,25 @@ class Requirement(Base): secondary="requirements_groups", back_populates="requirements" ) - validations: Mapped[List["Validation"]] = relationship("Validation", back_populates="requirement") + validations: Mapped[List["Validation"]] = relationship( + "Validation", + back_populates="requirement", + cascade="all, delete-orphan", + passive_deletes=True + ) outgoing_links: Mapped[List["RequirementLink"]] = relationship( "RequirementLink", foreign_keys="RequirementLink.source_req_id", - back_populates="source_requirement" + back_populates="source_requirement", + cascade="all, delete-orphan", + passive_deletes=True ) incoming_links: Mapped[List["RequirementLink"]] = relationship( "RequirementLink", foreign_keys="RequirementLink.target_req_id", - back_populates="target_requirement" + back_populates="target_requirement", + cascade="all, delete-orphan", + passive_deletes=True ) comments: Mapped[List["RequirementComment"]] = relationship( "RequirementComment", diff --git a/backend/src/main.py b/backend/src/main.py index 867759d..94c2d6e 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -15,7 +15,7 @@ from src.models import ( RequirementLinkResponse, RequirementLinkCreateRequest, RequirementSearchResult, RoleResponse, ProjectMemberResponse, UserRoleUpdateRequest, ROLE_DISPLAY_NAMES, CommentResponse, CommentReplyResponse, CommentCreateRequest, ReplyCreateRequest, - RequirementStatusResponse + RequirementStatusResponse, DeletedRequirementResponse ) from src.controller import AuthController from src.config import get_openid, get_settings @@ -1130,6 +1130,35 @@ async def get_requirement_history( return [RequirementHistoryResponse(**h) for h in history] +@app.get("/api/projects/{project_id}/deleted-requirements", response_model=List[DeletedRequirementResponse]) +async def get_deleted_requirements( + project_id: int, + request: Request, + db: AsyncSession = Depends(get_db) +): + """ + Get all deleted requirements for a project. + Returns requirements that exist in history but have been deleted. + User must be a member of the project. + + Args: + project_id: The project to get deleted requirements for + + Returns: + List of deleted requirements with their last known state. + """ + user = await _get_current_user_db(request, db) + + # Verify user is a member of the project + await _verify_project_membership(project_id, user.id, db) + + # Get deleted requirements + req_repo = RequirementRepository(db) + deleted = await req_repo.get_deleted_requirements(project_id) + + return [DeletedRequirementResponse(**d) for d in deleted] + + # =========================================== # Validation Endpoints # =========================================== diff --git a/backend/src/models.py b/backend/src/models.py index d323271..390ecc7 100644 --- a/backend/src/models.py +++ b/backend/src/models.py @@ -275,6 +275,22 @@ class RequirementHistoryResponse(BaseModel): from_attributes = True +class DeletedRequirementResponse(BaseModel): + """Response schema for a deleted requirement from history.""" + history_id: int + original_req_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 + deleted_by_username: Optional[str] = None + deleted_at: 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 62dd902..66a029a 100644 --- a/backend/src/repositories/requirement_repository.py +++ b/backend/src/repositories/requirement_repository.py @@ -422,3 +422,73 @@ class RequirementRepository: } for row in rows ] + + async def get_deleted_requirements(self, project_id: int) -> List[Dict[str, Any]]: + """ + Get all deleted requirements for a project. + A requirement is considered deleted if it exists in requirements_history + but NOT in the requirements table. + + Args: + project_id: The project ID to get deleted requirements for + + Returns: + List of deleted requirements with their last known state + """ + # Find requirements that exist in history but not in the main table + # We get the latest version of each deleted requirement + query = text(""" + WITH deleted_req_ids AS ( + SELECT DISTINCT rh.original_req_id + FROM requirements_history rh + WHERE rh.project_id = :project_id + AND NOT EXISTS ( + SELECT 1 FROM requirements r + WHERE r.id = rh.original_req_id + ) + ), + latest_versions AS ( + SELECT rh.original_req_id, MAX(rh.version) as max_version + FROM requirements_history rh + INNER JOIN deleted_req_ids d ON rh.original_req_id = d.original_req_id + GROUP BY rh.original_req_id + ) + SELECT + rh.history_id, + rh.original_req_id, + rh.version, + rh.req_name, + rh.req_desc, + t.tag_code, + p.priority_name, + u.full_name as deleted_by_full_name, + u.username as deleted_by_username, + rh.valid_from, + rh.valid_to + FROM requirements_history rh + INNER JOIN latest_versions lv + ON rh.original_req_id = lv.original_req_id + AND rh.version = lv.max_version + 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 + ORDER BY rh.valid_to DESC + """) + + result = await self.session.execute(query, {"project_id": project_id}) + rows = result.fetchall() + + return [ + { + "history_id": row.history_id, + "original_req_id": row.original_req_id, + "version": row.version, + "req_name": row.req_name, + "req_desc": row.req_desc, + "tag_code": row.tag_code, + "priority_name": row.priority_name, + "deleted_by_username": row.deleted_by_full_name or row.deleted_by_username, + "deleted_at": row.valid_to, + } + for row in rows + ] diff --git a/frontend/src/pages/RequirementsPage.tsx b/frontend/src/pages/RequirementsPage.tsx index 4d04a78..0a93b45 100644 --- a/frontend/src/pages/RequirementsPage.tsx +++ b/frontend/src/pages/RequirementsPage.tsx @@ -6,6 +6,7 @@ import type { Group } from '@/services/groupService' import type { Tag } from '@/services/tagService' import type { Priority } from '@/services/priorityService' import type { Requirement, RequirementCreateRequest } from '@/services/requirementService' +import type { DeletedRequirement } from '@/types' // Get validation status color const getValidationStatusStyle = (status: string): { bgColor: string; textColor: string } => { @@ -58,6 +59,11 @@ export default function RequirementsPage() { const [newReqPriorityId, setNewReqPriorityId] = useState('') const [newReqGroupIds, setNewReqGroupIds] = useState([]) + // Deleted requirements state + const [showDeletedPanel, setShowDeletedPanel] = useState(false) + const [deletedRequirements, setDeletedRequirements] = useState([]) + const [deletedLoading, setDeletedLoading] = useState(false) + // Fetch data when project changes useEffect(() => { const fetchData = async () => { @@ -112,6 +118,29 @@ export default function RequirementsPage() { } }, [searchParams, groups]) + // Fetch deleted requirements when panel is opened + const fetchDeletedRequirements = async () => { + if (!currentProject) return + + try { + setDeletedLoading(true) + const deleted = await requirementService.getDeletedRequirements(currentProject.id) + setDeletedRequirements(deleted) + } catch (err) { + console.error('Failed to fetch deleted requirements:', err) + } finally { + setDeletedLoading(false) + } + } + + const toggleDeletedPanel = () => { + const newState = !showDeletedPanel + setShowDeletedPanel(newState) + if (newState) { + fetchDeletedRequirements() + } + } + // Filter requirements based on search and selected groups const filteredRequirements = requirements.filter(req => { const matchesSearch = searchQuery === '' || @@ -168,6 +197,10 @@ export default function RequirementsPage() { await requirementService.deleteRequirement(id) // Remove from local state setRequirements(prev => prev.filter(r => r.id !== id)) + // Refresh deleted requirements if panel is open + if (showDeletedPanel) { + fetchDeletedRequirements() + } } catch (err) { console.error('Failed to delete requirement:', err) alert('Failed to delete requirement. Please try again.') @@ -353,13 +386,53 @@ export default function RequirementsPage() {
{/* New Requirement Button - Hidden for auditors */} {!isAuditor && ( -
+
+ +
+ )} + {isAuditor && ( +
+
)} @@ -562,6 +635,122 @@ export default function RequirementsPage() {
+ {/* Deleted Requirements Side Panel */} + {showDeletedPanel && ( +
+ {/* Panel Header */} +
+
+ + + +

Deleted Requirements

+
+ +
+ + {/* Panel Content */} +
+ {deletedLoading ? ( +
+
+
+ ) : deletedRequirements.length === 0 ? ( +
+ + + +

No deleted requirements found.

+

Deleted requirements will appear here.

+
+ ) : ( +
+ {deletedRequirements.map((req) => ( +
+
+
+
+ + {req.tag_code || 'N/A'} + + + v{req.version || 1} + +
+

+ {req.req_name || 'Unnamed Requirement'} +

+ {req.req_desc && ( +

+ {req.req_desc} +

+ )} +
+
+ +
+ {req.priority_name && ( +

+ Priority:{' '} + {req.priority_name} +

+ )} +

+ Original ID:{' '} + #{req.original_req_id} +

+ {req.deleted_at && ( +

+ Deleted:{' '} + + {new Date(req.deleted_at).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + })} + +

+ )} + {req.deleted_by_username && ( +

+ Deleted by:{' '} + @{req.deleted_by_username} +

+ )} +
+
+ ))} +
+ )} +
+ + {/* Panel Footer */} +
+

💡 Deleted requirements are preserved in history for auditing purposes.

+
+
+ )} + + {/* Overlay when panel is open */} + {showDeletedPanel && ( +
setShowDeletedPanel(false)} + /> + )} + {/* Create Requirement Modal */} {showCreateModal && (
diff --git a/frontend/src/services/requirementService.ts b/frontend/src/services/requirementService.ts index b74874f..3990afa 100644 --- a/frontend/src/services/requirementService.ts +++ b/frontend/src/services/requirementService.ts @@ -1,7 +1,7 @@ import { Group } from './groupService' import { Tag } from './tagService' import { Priority } from './priorityService' -import type { RequirementHistory, RequirementStatus } from '@/types' +import type { RequirementHistory, RequirementStatus, DeletedRequirement } from '@/types' const API_BASE_URL = '/api' @@ -242,6 +242,32 @@ class RequirementService { throw error } } + + /** + * Get all deleted requirements for a project. + * Returns requirements that exist in history but have been deleted. + */ + async getDeletedRequirements(projectId: number): Promise { + try { + const response = await fetch(`${API_BASE_URL}/projects/${projectId}/deleted-requirements`, { + method: 'GET', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + }) + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + const deletedRequirements: DeletedRequirement[] = await response.json() + return deletedRequirements + } catch (error) { + console.error('Failed to fetch deleted requirements:', error) + throw error + } + } } export const requirementService = new RequirementService() diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index c4affeb..6bf45ba 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -72,3 +72,16 @@ export interface RequirementHistory { valid_from: string | null valid_to: string | null } + +// Deleted Requirement types +export interface DeletedRequirement { + history_id: number + original_req_id: number + version: number | null + req_name: string | null + req_desc: string | null + tag_code: string | null + priority_name: string | null + deleted_by_username: string | null + deleted_at: string | null +}