diff --git a/backend/src/db_models.py b/backend/src/db_models.py index 392c449..22b351d 100644 --- a/backend/src/db_models.py +++ b/backend/src/db_models.py @@ -104,6 +104,19 @@ class Priority(Base): requirements: Mapped[List["Requirement"]] = relationship("Requirement", back_populates="priority") +class RequirementStatus(Base): + """Requirement lifecycle statuses (Draft, Regular, etc.).""" + __tablename__ = "requirement_statuses" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + status_code: Mapped[str] = mapped_column(String(20), nullable=False, unique=True) + status_name: Mapped[str] = mapped_column(Text, nullable=False) + description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + + # Relationships + requirements: Mapped[List["Requirement"]] = relationship("Requirement", back_populates="status") + + class Project(Base): """Projects - containers for requirements that can have multiple members.""" __tablename__ = "projects" @@ -172,6 +185,12 @@ class Requirement(Base): project_id: Mapped[int] = mapped_column(Integer, ForeignKey("projects.id", ondelete="CASCADE"), nullable=False) user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False) tag_id: Mapped[int] = mapped_column(Integer, ForeignKey("tags.id"), nullable=False) + status_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("requirement_statuses.id"), + nullable=False, + default=1 # Default to 'Draft' status + ) last_editor_id: Mapped[Optional[int]] = mapped_column( Integer, ForeignKey("users.id"), @@ -210,6 +229,7 @@ class Requirement(Base): foreign_keys=[last_editor_id] ) tag: Mapped["Tag"] = relationship("Tag", back_populates="requirements") + status: Mapped["RequirementStatus"] = relationship("RequirementStatus", back_populates="requirements") priority: Mapped[Optional["Priority"]] = relationship("Priority", back_populates="requirements") groups: Mapped[List["Group"]] = relationship( "Group", @@ -239,6 +259,7 @@ class Requirement(Base): Index("idx_req_tag", "tag_id"), Index("idx_req_priority", "priority_id"), Index("idx_req_user", "user_id"), + Index("idx_req_status", "status_id"), ) diff --git a/backend/src/main.py b/backend/src/main.py index df8f89b..867759d 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -14,7 +14,8 @@ from src.models import ( RelationshipTypeResponse, RelationshipTypeCreateRequest, RelationshipTypeUpdateRequest, RequirementLinkResponse, RequirementLinkCreateRequest, RequirementSearchResult, RoleResponse, ProjectMemberResponse, UserRoleUpdateRequest, ROLE_DISPLAY_NAMES, - CommentResponse, CommentReplyResponse, CommentCreateRequest, ReplyCreateRequest + CommentResponse, CommentReplyResponse, CommentCreateRequest, ReplyCreateRequest, + RequirementStatusResponse ) from src.controller import AuthController from src.config import get_openid, get_settings @@ -22,7 +23,8 @@ from src.database import init_db, close_db, get_db from src.repositories import ( RoleRepository, GroupRepository, TagRepository, RequirementRepository, PriorityRepository, ProjectRepository, ValidationStatusRepository, ValidationRepository, - RelationshipTypeRepository, RequirementLinkRepository, CommentRepository, ReplyRepository + RelationshipTypeRepository, RequirementLinkRepository, CommentRepository, ReplyRepository, + RequirementStatusRepository ) import logging @@ -54,6 +56,13 @@ async def lifespan(app: FastAPI): await session.commit() logger.info("Default roles ensured") + # Ensure default requirement statuses exist + async with AsyncSessionLocal() as session: + req_status_repo = RequirementStatusRepository(session) + await req_status_repo.ensure_default_statuses_exist() + await session.commit() + logger.info("Default requirement statuses ensured") + # Ensure default validation statuses exist async with AsyncSessionLocal() as session: await session.execute( @@ -277,6 +286,23 @@ async def get_priorities(db: AsyncSession = Depends(get_db)): return [PriorityResponse.model_validate(p) for p in priorities] +# =========================================== +# Requirement Statuses Endpoints +# =========================================== + +@app.get("/api/requirement-statuses", response_model=List[RequirementStatusResponse]) +async def get_requirement_statuses(db: AsyncSession = Depends(get_db)): + """ + Get all requirement lifecycle statuses (Draft, Regular, etc.). + + Returns: + List of all requirement statuses. + """ + status_repo = RequirementStatusRepository(db) + statuses = await status_repo.get_all() + return [RequirementStatusResponse.model_validate(s) for s in statuses] + + # =========================================== # Projects Endpoints # =========================================== @@ -803,6 +829,11 @@ def _build_requirement_response(req) -> RequirementResponse: if req.last_editor: last_editor_username = _get_display_name(req.last_editor) + # Get requirement lifecycle status + status_response = None + if req.status: + status_response = RequirementStatusResponse.model_validate(req.status) + return RequirementResponse( id=req.id, project_id=req.project_id, @@ -814,6 +845,7 @@ def _build_requirement_response(req) -> RequirementResponse: tag=TagResponse.model_validate(req.tag), priority=req.priority if req.priority else None, groups=[GroupResponse.model_validate(g) for g in req.groups], + status=status_response, validation_status=validation_status, validated_by=validated_by, validated_at=validated_at, @@ -966,6 +998,7 @@ async def create_requirement( req_desc=req_data.req_desc, priority_id=req_data.priority_id, group_ids=req_data.group_ids, + status_id=req_data.status_id, ) await db.commit() @@ -1017,6 +1050,7 @@ async def update_requirement( tag_id=req_data.tag_id, priority_id=req_data.priority_id, group_ids=req_data.group_ids, + status_id=req_data.status_id, ) await db.commit() diff --git a/backend/src/models.py b/backend/src/models.py index b186399..d323271 100644 --- a/backend/src/models.py +++ b/backend/src/models.py @@ -151,6 +151,18 @@ class PriorityResponse(BaseModel): from_attributes = True +# Requirement Status schemas +class RequirementStatusResponse(BaseModel): + """Response schema for a requirement lifecycle status.""" + id: int + status_code: str + status_name: str + description: Optional[str] = None + + class Config: + from_attributes = True + + # Validation schemas class ValidationStatusResponse(BaseModel): """Response schema for a validation status.""" @@ -207,6 +219,9 @@ class RequirementResponse(BaseModel): tag: TagResponse priority: Optional[PriorityResponse] = None groups: List[GroupResponse] = [] + # Requirement lifecycle status (Draft, Regular) + status: Optional[RequirementStatusResponse] = None + # Validation status (Approved, Denied, etc.) 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 @@ -231,6 +246,7 @@ class RequirementCreateRequest(BaseModel): req_desc: Optional[str] = None priority_id: Optional[int] = None group_ids: Optional[List[int]] = None + status_id: Optional[int] = None # Defaults to Draft (1) if not provided class RequirementUpdateRequest(BaseModel): @@ -240,6 +256,7 @@ class RequirementUpdateRequest(BaseModel): tag_id: Optional[int] = None priority_id: Optional[int] = None group_ids: Optional[List[int]] = None + status_id: Optional[int] = None class RequirementHistoryResponse(BaseModel): diff --git a/backend/src/repositories/__init__.py b/backend/src/repositories/__init__.py index 0fb222c..b388257 100644 --- a/backend/src/repositories/__init__.py +++ b/backend/src/repositories/__init__.py @@ -12,6 +12,7 @@ from src.repositories.validation_repository import ValidationRepository from src.repositories.relationship_type_repository import RelationshipTypeRepository from src.repositories.requirement_link_repository import RequirementLinkRepository from src.repositories.comment_repository import CommentRepository, ReplyRepository +from src.repositories.requirement_status_repository import RequirementStatusRepository __all__ = [ "UserRepository", @@ -27,4 +28,5 @@ __all__ = [ "RequirementLinkRepository", "CommentRepository", "ReplyRepository", + "RequirementStatusRepository", ] diff --git a/backend/src/repositories/requirement_repository.py b/backend/src/repositories/requirement_repository.py index f899da8..62dd902 100644 --- a/backend/src/repositories/requirement_repository.py +++ b/backend/src/repositories/requirement_repository.py @@ -5,7 +5,7 @@ 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, RequirementHistory +from src.db_models import Requirement, Group, Tag, Priority, Validation, RequirementHistory, RequirementStatus import logging logger = logging.getLogger(__name__) @@ -19,7 +19,7 @@ class RequirementRepository: async def get_all(self) -> List[Requirement]: """ - Get all requirements with their related data (tag, priority, groups, validations). + Get all requirements with their related data (tag, priority, groups, validations, status). Returns: List of all requirements with eager-loaded relationships @@ -30,6 +30,7 @@ class RequirementRepository: selectinload(Requirement.tag), selectinload(Requirement.priority), selectinload(Requirement.groups), + selectinload(Requirement.status), selectinload(Requirement.validations).selectinload(Validation.status), selectinload(Requirement.validations).selectinload(Validation.user), selectinload(Requirement.user), @@ -55,6 +56,7 @@ class RequirementRepository: selectinload(Requirement.tag), selectinload(Requirement.priority), selectinload(Requirement.groups), + selectinload(Requirement.status), selectinload(Requirement.validations).selectinload(Validation.status), selectinload(Requirement.validations).selectinload(Validation.user), selectinload(Requirement.user), @@ -81,6 +83,7 @@ class RequirementRepository: selectinload(Requirement.tag), selectinload(Requirement.priority), selectinload(Requirement.groups), + selectinload(Requirement.status), selectinload(Requirement.validations).selectinload(Validation.status), selectinload(Requirement.validations).selectinload(Validation.user), selectinload(Requirement.user), @@ -107,6 +110,7 @@ class RequirementRepository: selectinload(Requirement.tag), selectinload(Requirement.priority), selectinload(Requirement.groups), + selectinload(Requirement.status), selectinload(Requirement.validations).selectinload(Validation.status), selectinload(Requirement.validations).selectinload(Validation.user), selectinload(Requirement.user), @@ -134,6 +138,7 @@ class RequirementRepository: selectinload(Requirement.tag), selectinload(Requirement.priority), selectinload(Requirement.groups), + selectinload(Requirement.status), selectinload(Requirement.validations).selectinload(Validation.status), selectinload(Requirement.validations).selectinload(Validation.user), selectinload(Requirement.user), @@ -171,6 +176,7 @@ class RequirementRepository: selectinload(Requirement.tag), selectinload(Requirement.priority), selectinload(Requirement.groups), + selectinload(Requirement.status), selectinload(Requirement.validations).selectinload(Validation.status), selectinload(Requirement.validations).selectinload(Validation.user), selectinload(Requirement.user), @@ -198,6 +204,7 @@ class RequirementRepository: req_desc: Optional[str] = None, priority_id: Optional[int] = None, group_ids: Optional[List[int]] = None, + status_id: Optional[int] = None, ) -> Requirement: """ Create a new requirement. @@ -210,6 +217,7 @@ class RequirementRepository: req_desc: The requirement description (optional) priority_id: The priority ID (optional) group_ids: List of group IDs to associate (optional) + status_id: The requirement status ID (optional, defaults to 1=Draft) Returns: The created Requirement @@ -221,6 +229,7 @@ class RequirementRepository: req_name=req_name, req_desc=req_desc, priority_id=priority_id, + status_id=status_id if status_id else 1, # Default to Draft ) # Add groups if provided @@ -247,6 +256,7 @@ class RequirementRepository: tag_id: Optional[int] = None, priority_id: Optional[int] = None, group_ids: Optional[List[int]] = None, + status_id: Optional[int] = None, ) -> Optional[Requirement]: """ Update an existing requirement. @@ -259,6 +269,7 @@ class RequirementRepository: tag_id: New tag ID (optional) priority_id: New priority ID (optional) group_ids: New list of group IDs (optional) + status_id: New status ID (optional) Returns: The updated Requirement, or None if not found @@ -276,6 +287,8 @@ class RequirementRepository: requirement.tag_id = tag_id if priority_id is not None: requirement.priority_id = priority_id + if status_id is not None: + requirement.status_id = status_id # Set the last editor requirement.last_editor_id = editor_id diff --git a/backend/src/repositories/requirement_status_repository.py b/backend/src/repositories/requirement_status_repository.py new file mode 100644 index 0000000..a7ce0f9 --- /dev/null +++ b/backend/src/repositories/requirement_status_repository.py @@ -0,0 +1,102 @@ +""" +Repository layer for RequirementStatus database operations. +""" +from typing import List, Optional +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from src.db_models import RequirementStatus +import logging + +logger = logging.getLogger(__name__) + + +class RequirementStatusRepository: + """Repository for RequirementStatus-related database operations.""" + + def __init__(self, session: AsyncSession): + self.session = session + + async def get_all(self) -> List[RequirementStatus]: + """ + Get all requirement statuses. + + Returns: + List of all requirement statuses + """ + result = await self.session.execute( + select(RequirementStatus).order_by(RequirementStatus.id) + ) + return list(result.scalars().all()) + + async def get_by_id(self, status_id: int) -> Optional[RequirementStatus]: + """ + Get a requirement status by ID. + + Args: + status_id: The status ID + + Returns: + RequirementStatus if found, None otherwise + """ + result = await self.session.execute( + select(RequirementStatus).where(RequirementStatus.id == status_id) + ) + return result.scalar_one_or_none() + + async def get_by_code(self, status_code: str) -> Optional[RequirementStatus]: + """ + Get a requirement status by code. + + Args: + status_code: The status code (e.g., 'DRAFT', 'REGULAR') + + Returns: + RequirementStatus if found, None otherwise + """ + result = await self.session.execute( + select(RequirementStatus).where(RequirementStatus.status_code == status_code) + ) + return result.scalar_one_or_none() + + async def create( + self, + status_code: str, + status_name: str, + description: Optional[str] = None + ) -> RequirementStatus: + """ + Create a new requirement status. + + Args: + status_code: The unique status code + status_name: The display name + description: Optional description + + Returns: + The created RequirementStatus + """ + status = RequirementStatus( + status_code=status_code, + status_name=status_name, + description=description + ) + self.session.add(status) + await self.session.flush() + await self.session.refresh(status) + return status + + async def ensure_default_statuses_exist(self) -> None: + """ + Ensure default requirement statuses exist in the database. + Called during application startup. + """ + default_statuses = [ + ("DRAFT", "Draft", "Initial version, not ready for review"), + ("REGULAR", "Regular", "Active requirement"), + ] + + for status_code, status_name, description in default_statuses: + existing = await self.get_by_code(status_code) + if existing is None: + logger.info(f"Creating default requirement status: {status_code}") + await self.create(status_code, status_name, description) diff --git a/frontend/src/pages/RequirementDetailPage.tsx b/frontend/src/pages/RequirementDetailPage.tsx index 7eb1c4b..09fd9f7 100644 --- a/frontend/src/pages/RequirementDetailPage.tsx +++ b/frontend/src/pages/RequirementDetailPage.tsx @@ -1,13 +1,13 @@ import { useState, useEffect } from 'react' import { useAuth, useProject } from '@/hooks' import { useParams, Link } from 'react-router-dom' -import { requirementService, validationService, relationshipService, commentService, tagService, priorityService, groupService } from '@/services' +import { requirementService, validationService, relationshipService, commentService, tagService, priorityService, groupService, requirementStatusService } from '@/services' import type { Requirement } from '@/services/requirementService' import type { RelationshipType, RequirementLink, RequirementSearchResult } from '@/services/relationshipService' 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' +import type { ValidationStatus, ValidationHistory, Comment, RequirementHistory, RequirementStatus } from '@/types' // Tab types type TabType = 'description' | 'relationships' | 'acceptance-criteria' | 'shared-comments' | 'validate' | 'history' @@ -64,9 +64,11 @@ export default function RequirementDetailPage() { const [editTagId, setEditTagId] = useState('') const [editPriorityId, setEditPriorityId] = useState('') const [editGroupIds, setEditGroupIds] = useState([]) + const [editStatusId, setEditStatusId] = useState(1) const [availableTags, setAvailableTags] = useState([]) const [availablePriorities, setAvailablePriorities] = useState([]) const [availableGroups, setAvailableGroups] = useState([]) + const [availableStatuses, setAvailableStatuses] = useState([]) const [editOptionsLoading, setEditOptionsLoading] = useState(false) // Requirement history state @@ -429,21 +431,24 @@ export default function RequirementDetailPage() { setEditTagId(requirement.tag.id) setEditPriorityId(requirement.priority?.id || '') setEditGroupIds(requirement.groups.map(g => g.id)) + setEditStatusId(requirement.status?.id || 1) setEditError(null) setShowEditModal(true) // Fetch options if not already loaded - if (availableTags.length === 0 || availablePriorities.length === 0 || availableGroups.length === 0) { + if (availableTags.length === 0 || availablePriorities.length === 0 || availableGroups.length === 0 || availableStatuses.length === 0) { try { setEditOptionsLoading(true) - const [tags, priorities, groups] = await Promise.all([ + const [tags, priorities, groups, statuses] = await Promise.all([ tagService.getTags(), priorityService.getPriorities(), - groupService.getGroups() + groupService.getGroups(), + requirementStatusService.getStatuses() ]) setAvailableTags(tags) setAvailablePriorities(priorities) setAvailableGroups(groups) + setAvailableStatuses(statuses) } catch (err) { console.error('Failed to fetch edit options:', err) setEditError('Failed to load options. Please try again.') @@ -475,7 +480,8 @@ export default function RequirementDetailPage() { req_desc: editReqDesc.trim() || undefined, tag_id: editTagId as number, priority_id: editPriorityId ? editPriorityId as number : undefined, - group_ids: editGroupIds + group_ids: editGroupIds, + status_id: editStatusId ? editStatusId as number : undefined, }) // Refetch to ensure consistency (trigger updates version) @@ -515,6 +521,9 @@ export default function RequirementDetailPage() { } } + // Check if requirement is in draft status + const isDraftStatus = requirement?.status?.status_code === 'DRAFT' + if (loading) { return (
@@ -561,9 +570,33 @@ export default function RequirementDetailPage() { return (

Description

+ + {/* Draft indicator banner */} + {isDraftStatus && ( +
+
+ 📝 +
+

Draft Requirement

+

This requirement is still in draft status and is not finalized. It may be subject to changes.

+
+
+
+ )} +

Version: {requirement.version}

+

+ Status:{' '} + + {requirement.status?.status_name || 'Unknown'} + +

Validation Status:{' '} @@ -1220,6 +1253,14 @@ export default function RequirementDetailPage() {

{/* Requirement Header */}
+ {/* Draft Status Indicator */} + {isDraftStatus && ( +
+ 📝 + Draft - Not Finalized +
+ )} + {/* Tag */}

{tagCode}

@@ -1491,6 +1532,28 @@ export default function RequirementDetailPage() {
+ {/* Status */} +
+ + +

+ Draft requirements are not finalized and marked with a visual indicator. +

+
+ {/* Description */}