Added functions to fetch requirements from db.
Added functionality to create requirements on page
This commit is contained in:
@@ -1,15 +1,19 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import List
|
||||
from fastapi import FastAPI, Depends, Request
|
||||
from typing import List, Optional
|
||||
from fastapi import FastAPI, Depends, Request, HTTPException, status
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from src.models import TokenResponse, UserInfo, GroupResponse
|
||||
from src.models import (
|
||||
TokenResponse, UserInfo, GroupResponse,
|
||||
TagResponse, RequirementResponse, PriorityResponse,
|
||||
RequirementCreateRequest, RequirementUpdateRequest
|
||||
)
|
||||
from src.controller import AuthController
|
||||
from src.config import get_openid, get_settings
|
||||
from src.database import init_db, close_db, get_db
|
||||
from src.repositories import RoleRepository, GroupRepository
|
||||
from src.repositories import RoleRepository, GroupRepository, TagRepository, RequirementRepository, PriorityRepository
|
||||
import logging
|
||||
|
||||
# Configure logging
|
||||
@@ -176,3 +180,254 @@ async def get_groups(db: AsyncSession = Depends(get_db)):
|
||||
group_repo = GroupRepository(db)
|
||||
groups = await group_repo.get_all()
|
||||
return [GroupResponse.model_validate(g) for g in groups]
|
||||
|
||||
|
||||
# ===========================================
|
||||
# Tags Endpoints
|
||||
# ===========================================
|
||||
|
||||
@app.get("/api/tags", response_model=List[TagResponse])
|
||||
async def get_tags(db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
Get all tags.
|
||||
|
||||
Returns:
|
||||
List of all tags with their codes and descriptions.
|
||||
"""
|
||||
tag_repo = TagRepository(db)
|
||||
tags = await tag_repo.get_all()
|
||||
return [TagResponse.model_validate(t) for t in tags]
|
||||
|
||||
|
||||
@app.get("/api/tags/{tag_id}", response_model=TagResponse)
|
||||
async def get_tag(tag_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
Get a specific tag by ID.
|
||||
|
||||
Args:
|
||||
tag_id: The tag ID
|
||||
|
||||
Returns:
|
||||
The tag if found.
|
||||
"""
|
||||
tag_repo = TagRepository(db)
|
||||
tag = await tag_repo.get_by_id(tag_id)
|
||||
if not tag:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Tag with id {tag_id} not found"
|
||||
)
|
||||
return TagResponse.model_validate(tag)
|
||||
|
||||
|
||||
# ===========================================
|
||||
# Priorities Endpoints
|
||||
# ===========================================
|
||||
|
||||
@app.get("/api/priorities", response_model=List[PriorityResponse])
|
||||
async def get_priorities(db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
Get all priorities.
|
||||
|
||||
Returns:
|
||||
List of all priorities ordered by priority_num.
|
||||
"""
|
||||
priority_repo = PriorityRepository(db)
|
||||
priorities = await priority_repo.get_all()
|
||||
return [PriorityResponse.model_validate(p) for p in priorities]
|
||||
|
||||
|
||||
# ===========================================
|
||||
# Requirements Endpoints
|
||||
# ===========================================
|
||||
|
||||
def _build_requirement_response(req) -> RequirementResponse:
|
||||
"""Helper function to build RequirementResponse from a Requirement model."""
|
||||
# Determine validation status from latest validation
|
||||
validation_status = "Not Validated"
|
||||
if req.validations:
|
||||
# Get the latest validation
|
||||
latest_validation = max(req.validations, key=lambda v: v.created_at or req.created_at)
|
||||
validation_status = latest_validation.status.status_name if latest_validation.status else "Not Validated"
|
||||
|
||||
return RequirementResponse(
|
||||
id=req.id,
|
||||
req_name=req.req_name,
|
||||
req_desc=req.req_desc,
|
||||
version=req.version,
|
||||
created_at=req.created_at,
|
||||
updated_at=req.updated_at,
|
||||
tag=TagResponse.model_validate(req.tag),
|
||||
priority=req.priority if req.priority else None,
|
||||
groups=[GroupResponse.model_validate(g) for g in req.groups],
|
||||
validation_status=validation_status,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/requirements", response_model=List[RequirementResponse])
|
||||
async def get_requirements(
|
||||
group_id: Optional[int] = None,
|
||||
tag_id: Optional[int] = None,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get all requirements, optionally filtered by group or tag.
|
||||
|
||||
Args:
|
||||
group_id: Optional group ID to filter by
|
||||
tag_id: Optional tag ID to filter by
|
||||
|
||||
Returns:
|
||||
List of all requirements with their related data.
|
||||
"""
|
||||
req_repo = RequirementRepository(db)
|
||||
|
||||
if group_id:
|
||||
requirements = await req_repo.get_by_group_id(group_id)
|
||||
elif tag_id:
|
||||
requirements = await req_repo.get_by_tag_id(tag_id)
|
||||
else:
|
||||
requirements = await req_repo.get_all()
|
||||
|
||||
return [_build_requirement_response(req) for req in requirements]
|
||||
|
||||
|
||||
@app.get("/api/requirements/{requirement_id}", response_model=RequirementResponse)
|
||||
async def get_requirement(requirement_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
Get a specific requirement by ID.
|
||||
|
||||
Args:
|
||||
requirement_id: The requirement ID
|
||||
|
||||
Returns:
|
||||
The requirement if found.
|
||||
"""
|
||||
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"
|
||||
)
|
||||
return _build_requirement_response(requirement)
|
||||
|
||||
|
||||
@app.post("/api/requirements", response_model=RequirementResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_requirement(
|
||||
request: Request,
|
||||
req_data: RequirementCreateRequest,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Create a new requirement.
|
||||
|
||||
Args:
|
||||
req_data: The requirement data
|
||||
|
||||
Returns:
|
||||
The created requirement.
|
||||
"""
|
||||
# Get the current user from cookie
|
||||
user_info = AuthController.get_current_user(request)
|
||||
|
||||
# Get the user's database ID
|
||||
from src.repositories import UserRepository
|
||||
user_repo = UserRepository(db)
|
||||
user = await user_repo.get_by_sub(user_info.sub)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found in database"
|
||||
)
|
||||
|
||||
req_repo = RequirementRepository(db)
|
||||
requirement = await req_repo.create(
|
||||
user_id=user.id,
|
||||
tag_id=req_data.tag_id,
|
||||
req_name=req_data.req_name,
|
||||
req_desc=req_data.req_desc,
|
||||
priority_id=req_data.priority_id,
|
||||
group_ids=req_data.group_ids,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return _build_requirement_response(requirement)
|
||||
|
||||
|
||||
@app.put("/api/requirements/{requirement_id}", response_model=RequirementResponse)
|
||||
async def update_requirement(
|
||||
requirement_id: int,
|
||||
request: Request,
|
||||
req_data: RequirementUpdateRequest,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Update an existing requirement.
|
||||
|
||||
Args:
|
||||
requirement_id: The requirement ID to update
|
||||
req_data: The updated requirement data
|
||||
|
||||
Returns:
|
||||
The updated requirement.
|
||||
"""
|
||||
# Get the current user from cookie
|
||||
user_info = AuthController.get_current_user(request)
|
||||
|
||||
# Get the user's database ID
|
||||
from src.repositories import UserRepository
|
||||
user_repo = UserRepository(db)
|
||||
user = await user_repo.get_by_sub(user_info.sub)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found in database"
|
||||
)
|
||||
|
||||
req_repo = RequirementRepository(db)
|
||||
requirement = await req_repo.update(
|
||||
requirement_id=requirement_id,
|
||||
editor_id=user.id,
|
||||
req_name=req_data.req_name,
|
||||
req_desc=req_data.req_desc,
|
||||
tag_id=req_data.tag_id,
|
||||
priority_id=req_data.priority_id,
|
||||
group_ids=req_data.group_ids,
|
||||
)
|
||||
|
||||
if not requirement:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Requirement with id {requirement_id} not found"
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return _build_requirement_response(requirement)
|
||||
|
||||
|
||||
@app.delete("/api/requirements/{requirement_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_requirement(
|
||||
requirement_id: int,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Delete a requirement.
|
||||
|
||||
Args:
|
||||
requirement_id: The requirement ID to delete
|
||||
"""
|
||||
# Verify user is authenticated
|
||||
AuthController.get_current_user(request)
|
||||
|
||||
req_repo = RequirementRepository(db)
|
||||
deleted = await req_repo.delete(requirement_id)
|
||||
|
||||
if not deleted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Requirement with id {requirement_id} not found"
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, SecretStr
|
||||
|
||||
|
||||
@@ -35,3 +36,84 @@ class GroupResponse(BaseModel):
|
||||
class GroupListResponse(BaseModel):
|
||||
"""Response schema for list of groups."""
|
||||
groups: List[GroupResponse]
|
||||
|
||||
|
||||
# Tag schemas
|
||||
class TagResponse(BaseModel):
|
||||
"""Response schema for a single tag."""
|
||||
id: int
|
||||
tag_code: str
|
||||
tag_description: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TagListResponse(BaseModel):
|
||||
"""Response schema for list of tags."""
|
||||
tags: List[TagResponse]
|
||||
|
||||
|
||||
# Priority schemas
|
||||
class PriorityResponse(BaseModel):
|
||||
"""Response schema for a priority."""
|
||||
id: int
|
||||
priority_name: str
|
||||
priority_num: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# Validation schemas
|
||||
class ValidationResponse(BaseModel):
|
||||
"""Response schema for a validation."""
|
||||
id: int
|
||||
status_name: str
|
||||
req_version_snapshot: int
|
||||
comment: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# Requirement schemas
|
||||
class RequirementResponse(BaseModel):
|
||||
"""Response schema for a single requirement."""
|
||||
id: int
|
||||
req_name: str
|
||||
req_desc: Optional[str] = None
|
||||
version: int
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
tag: TagResponse
|
||||
priority: Optional[PriorityResponse] = None
|
||||
groups: List[GroupResponse] = []
|
||||
validation_status: Optional[str] = None # Computed from latest validation
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class RequirementListResponse(BaseModel):
|
||||
"""Response schema for list of requirements."""
|
||||
requirements: List[RequirementResponse]
|
||||
|
||||
|
||||
class RequirementCreateRequest(BaseModel):
|
||||
"""Request schema for creating a requirement."""
|
||||
tag_id: int
|
||||
req_name: str
|
||||
req_desc: Optional[str] = None
|
||||
priority_id: Optional[int] = None
|
||||
group_ids: Optional[List[int]] = None
|
||||
|
||||
|
||||
class RequirementUpdateRequest(BaseModel):
|
||||
"""Request schema for updating a requirement."""
|
||||
req_name: Optional[str] = None
|
||||
req_desc: Optional[str] = None
|
||||
tag_id: Optional[int] = None
|
||||
priority_id: Optional[int] = None
|
||||
group_ids: Optional[List[int]] = None
|
||||
|
||||
@@ -3,9 +3,15 @@ Repository layer for database operations.
|
||||
"""
|
||||
from src.repositories.user_repository import UserRepository, RoleRepository
|
||||
from src.repositories.group_repository import GroupRepository
|
||||
from src.repositories.tag_repository import TagRepository
|
||||
from src.repositories.requirement_repository import RequirementRepository
|
||||
from src.repositories.priority_repository import PriorityRepository
|
||||
|
||||
__all__ = [
|
||||
"UserRepository",
|
||||
"RoleRepository",
|
||||
"GroupRepository",
|
||||
"TagRepository",
|
||||
"RequirementRepository",
|
||||
"PriorityRepository",
|
||||
]
|
||||
|
||||
76
backend/src/repositories/priority_repository.py
Normal file
76
backend/src/repositories/priority_repository.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
Repository layer for Priority database operations.
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from src.db_models import Priority
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PriorityRepository:
|
||||
"""Repository for Priority-related database operations."""
|
||||
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
async def get_all(self) -> List[Priority]:
|
||||
"""
|
||||
Get all priorities ordered by priority_num.
|
||||
|
||||
Returns:
|
||||
List of all priorities
|
||||
"""
|
||||
result = await self.session.execute(
|
||||
select(Priority).order_by(Priority.priority_num)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_by_id(self, priority_id: int) -> Optional[Priority]:
|
||||
"""
|
||||
Get a priority by ID.
|
||||
|
||||
Args:
|
||||
priority_id: The priority ID
|
||||
|
||||
Returns:
|
||||
Priority if found, None otherwise
|
||||
"""
|
||||
result = await self.session.execute(
|
||||
select(Priority).where(Priority.id == priority_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_by_name(self, priority_name: str) -> Optional[Priority]:
|
||||
"""
|
||||
Get a priority by name.
|
||||
|
||||
Args:
|
||||
priority_name: The priority name
|
||||
|
||||
Returns:
|
||||
Priority if found, None otherwise
|
||||
"""
|
||||
result = await self.session.execute(
|
||||
select(Priority).where(Priority.priority_name == priority_name)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def create(self, priority_name: str, priority_num: int) -> Priority:
|
||||
"""
|
||||
Create a new priority.
|
||||
|
||||
Args:
|
||||
priority_name: The priority name
|
||||
priority_num: The priority number for ordering
|
||||
|
||||
Returns:
|
||||
The created Priority
|
||||
"""
|
||||
priority = Priority(priority_name=priority_name, priority_num=priority_num)
|
||||
self.session.add(priority)
|
||||
await self.session.flush()
|
||||
await self.session.refresh(priority)
|
||||
return priority
|
||||
227
backend/src/repositories/requirement_repository.py
Normal file
227
backend/src/repositories/requirement_repository.py
Normal file
@@ -0,0 +1,227 @@
|
||||
"""
|
||||
Repository layer for Requirement database operations.
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from src.db_models import Requirement, Group, Tag, Priority, Validation
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RequirementRepository:
|
||||
"""Repository for Requirement-related database operations."""
|
||||
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
async def get_all(self) -> List[Requirement]:
|
||||
"""
|
||||
Get all requirements with their related data (tag, priority, groups, validations).
|
||||
|
||||
Returns:
|
||||
List of all requirements with eager-loaded relationships
|
||||
"""
|
||||
result = await self.session.execute(
|
||||
select(Requirement)
|
||||
.options(
|
||||
selectinload(Requirement.tag),
|
||||
selectinload(Requirement.priority),
|
||||
selectinload(Requirement.groups),
|
||||
selectinload(Requirement.validations).selectinload(Validation.status),
|
||||
)
|
||||
.order_by(Requirement.created_at.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_by_id(self, requirement_id: int) -> Optional[Requirement]:
|
||||
"""
|
||||
Get a requirement by ID with its related data.
|
||||
|
||||
Args:
|
||||
requirement_id: The requirement ID
|
||||
|
||||
Returns:
|
||||
Requirement if found, None otherwise
|
||||
"""
|
||||
result = await self.session.execute(
|
||||
select(Requirement)
|
||||
.options(
|
||||
selectinload(Requirement.tag),
|
||||
selectinload(Requirement.priority),
|
||||
selectinload(Requirement.groups),
|
||||
selectinload(Requirement.validations).selectinload(Validation.status),
|
||||
selectinload(Requirement.user),
|
||||
selectinload(Requirement.last_editor),
|
||||
)
|
||||
.where(Requirement.id == requirement_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_by_group_id(self, group_id: int) -> List[Requirement]:
|
||||
"""
|
||||
Get all requirements belonging to a specific group.
|
||||
|
||||
Args:
|
||||
group_id: The group ID
|
||||
|
||||
Returns:
|
||||
List of requirements in the group
|
||||
"""
|
||||
result = await self.session.execute(
|
||||
select(Requirement)
|
||||
.options(
|
||||
selectinload(Requirement.tag),
|
||||
selectinload(Requirement.priority),
|
||||
selectinload(Requirement.groups),
|
||||
selectinload(Requirement.validations).selectinload(Validation.status),
|
||||
)
|
||||
.join(Requirement.groups)
|
||||
.where(Group.id == group_id)
|
||||
.order_by(Requirement.created_at.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_by_tag_id(self, tag_id: int) -> List[Requirement]:
|
||||
"""
|
||||
Get all requirements with a specific tag.
|
||||
|
||||
Args:
|
||||
tag_id: The tag ID
|
||||
|
||||
Returns:
|
||||
List of requirements with the tag
|
||||
"""
|
||||
result = await self.session.execute(
|
||||
select(Requirement)
|
||||
.options(
|
||||
selectinload(Requirement.tag),
|
||||
selectinload(Requirement.priority),
|
||||
selectinload(Requirement.groups),
|
||||
selectinload(Requirement.validations).selectinload(Validation.status),
|
||||
)
|
||||
.where(Requirement.tag_id == tag_id)
|
||||
.order_by(Requirement.created_at.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def create(
|
||||
self,
|
||||
user_id: int,
|
||||
tag_id: int,
|
||||
req_name: str,
|
||||
req_desc: Optional[str] = None,
|
||||
priority_id: Optional[int] = None,
|
||||
group_ids: Optional[List[int]] = None,
|
||||
) -> Requirement:
|
||||
"""
|
||||
Create a new requirement.
|
||||
|
||||
Args:
|
||||
user_id: The creating user's ID
|
||||
tag_id: The tag ID
|
||||
req_name: The requirement name
|
||||
req_desc: The requirement description (optional)
|
||||
priority_id: The priority ID (optional)
|
||||
group_ids: List of group IDs to associate (optional)
|
||||
|
||||
Returns:
|
||||
The created Requirement
|
||||
"""
|
||||
requirement = Requirement(
|
||||
user_id=user_id,
|
||||
tag_id=tag_id,
|
||||
req_name=req_name,
|
||||
req_desc=req_desc,
|
||||
priority_id=priority_id,
|
||||
)
|
||||
|
||||
# Add groups if provided
|
||||
if group_ids:
|
||||
groups_result = await self.session.execute(
|
||||
select(Group).where(Group.id.in_(group_ids))
|
||||
)
|
||||
groups = list(groups_result.scalars().all())
|
||||
requirement.groups = groups
|
||||
|
||||
self.session.add(requirement)
|
||||
await self.session.flush()
|
||||
await self.session.refresh(requirement)
|
||||
|
||||
# Reload with relationships
|
||||
return await self.get_by_id(requirement.id)
|
||||
|
||||
async def update(
|
||||
self,
|
||||
requirement_id: int,
|
||||
editor_id: int,
|
||||
req_name: Optional[str] = None,
|
||||
req_desc: Optional[str] = None,
|
||||
tag_id: Optional[int] = None,
|
||||
priority_id: Optional[int] = None,
|
||||
group_ids: Optional[List[int]] = None,
|
||||
) -> Optional[Requirement]:
|
||||
"""
|
||||
Update an existing requirement.
|
||||
|
||||
Args:
|
||||
requirement_id: The requirement ID to update
|
||||
editor_id: The ID of the user making the edit
|
||||
req_name: New requirement name (optional)
|
||||
req_desc: New requirement description (optional)
|
||||
tag_id: New tag ID (optional)
|
||||
priority_id: New priority ID (optional)
|
||||
group_ids: New list of group IDs (optional)
|
||||
|
||||
Returns:
|
||||
The updated Requirement, or None if not found
|
||||
"""
|
||||
requirement = await self.get_by_id(requirement_id)
|
||||
if not requirement:
|
||||
return None
|
||||
|
||||
# Update fields if provided
|
||||
if req_name is not None:
|
||||
requirement.req_name = req_name
|
||||
if req_desc is not None:
|
||||
requirement.req_desc = req_desc
|
||||
if tag_id is not None:
|
||||
requirement.tag_id = tag_id
|
||||
if priority_id is not None:
|
||||
requirement.priority_id = priority_id
|
||||
|
||||
# Set the last editor
|
||||
requirement.last_editor_id = editor_id
|
||||
|
||||
# Update groups if provided
|
||||
if group_ids is not None:
|
||||
groups_result = await self.session.execute(
|
||||
select(Group).where(Group.id.in_(group_ids))
|
||||
)
|
||||
groups = list(groups_result.scalars().all())
|
||||
requirement.groups = groups
|
||||
|
||||
await self.session.flush()
|
||||
await self.session.refresh(requirement)
|
||||
|
||||
return await self.get_by_id(requirement_id)
|
||||
|
||||
async def delete(self, requirement_id: int) -> bool:
|
||||
"""
|
||||
Delete a requirement.
|
||||
|
||||
Args:
|
||||
requirement_id: The requirement ID to delete
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found
|
||||
"""
|
||||
requirement = await self.get_by_id(requirement_id)
|
||||
if not requirement:
|
||||
return False
|
||||
|
||||
await self.session.delete(requirement)
|
||||
await self.session.flush()
|
||||
return True
|
||||
76
backend/src/repositories/tag_repository.py
Normal file
76
backend/src/repositories/tag_repository.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
Repository layer for Tag database operations.
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from src.db_models import Tag
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TagRepository:
|
||||
"""Repository for Tag-related database operations."""
|
||||
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
async def get_all(self) -> List[Tag]:
|
||||
"""
|
||||
Get all tags.
|
||||
|
||||
Returns:
|
||||
List of all tags
|
||||
"""
|
||||
result = await self.session.execute(
|
||||
select(Tag).order_by(Tag.tag_code)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_by_id(self, tag_id: int) -> Optional[Tag]:
|
||||
"""
|
||||
Get a tag by ID.
|
||||
|
||||
Args:
|
||||
tag_id: The tag ID
|
||||
|
||||
Returns:
|
||||
Tag if found, None otherwise
|
||||
"""
|
||||
result = await self.session.execute(
|
||||
select(Tag).where(Tag.id == tag_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_by_code(self, tag_code: str) -> Optional[Tag]:
|
||||
"""
|
||||
Get a tag by code.
|
||||
|
||||
Args:
|
||||
tag_code: The tag code (e.g., GSR, SFR)
|
||||
|
||||
Returns:
|
||||
Tag if found, None otherwise
|
||||
"""
|
||||
result = await self.session.execute(
|
||||
select(Tag).where(Tag.tag_code == tag_code)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def create(self, tag_code: str, tag_description: str) -> Tag:
|
||||
"""
|
||||
Create a new tag.
|
||||
|
||||
Args:
|
||||
tag_code: The tag code (e.g., GSR, SFR)
|
||||
tag_description: The tag description
|
||||
|
||||
Returns:
|
||||
The created Tag
|
||||
"""
|
||||
tag = Tag(tag_code=tag_code, tag_description=tag_description)
|
||||
self.session.add(tag)
|
||||
await self.session.flush()
|
||||
await self.session.refresh(tag)
|
||||
return tag
|
||||
@@ -1,140 +1,108 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useAuth } from '@/hooks'
|
||||
import { useSearchParams, Link, useNavigate } from 'react-router-dom'
|
||||
import { groupService, tagService, requirementService, priorityService } from '@/services'
|
||||
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'
|
||||
|
||||
// Types for requirements
|
||||
interface Requirement {
|
||||
id: string
|
||||
tag: string
|
||||
title: string
|
||||
validation: string
|
||||
priority: number
|
||||
progress: number
|
||||
group: RequirementGroup
|
||||
// Helper to lighten a hex color for backgrounds
|
||||
function lightenColor(hex: string, percent: number): string {
|
||||
const num = parseInt(hex.replace('#', ''), 16)
|
||||
const amt = Math.round(2.55 * percent)
|
||||
const R = (num >> 16) + amt
|
||||
const G = (num >> 8 & 0x00FF) + amt
|
||||
const B = (num & 0x0000FF) + amt
|
||||
return '#' + (
|
||||
0x1000000 +
|
||||
(R < 255 ? (R < 1 ? 0 : R) : 255) * 0x10000 +
|
||||
(G < 255 ? (G < 1 ? 0 : G) : 255) * 0x100 +
|
||||
(B < 255 ? (B < 1 ? 0 : B) : 255)
|
||||
).toString(16).slice(1)
|
||||
}
|
||||
|
||||
type RequirementGroup =
|
||||
| 'Data Services'
|
||||
| 'Integration'
|
||||
| 'Intelligence'
|
||||
| 'User Experience'
|
||||
| 'Management'
|
||||
| 'Trustworthiness'
|
||||
|
||||
// Color mapping for requirement groups
|
||||
const groupColors: Record<RequirementGroup, { bg: string; border: string }> = {
|
||||
'Data Services': { bg: 'bg-blue-200', border: 'border-blue-300' },
|
||||
'Integration': { bg: 'bg-amber-200', border: 'border-amber-300' },
|
||||
'Intelligence': { bg: 'bg-purple-200', border: 'border-purple-300' },
|
||||
'User Experience': { bg: 'bg-green-200', border: 'border-green-300' },
|
||||
'Management': { bg: 'bg-red-200', border: 'border-red-300' },
|
||||
'Trustworthiness': { bg: 'bg-teal-600', border: 'border-teal-700' },
|
||||
}
|
||||
|
||||
// Static mock data
|
||||
const mockRequirements: Requirement[] = [
|
||||
{
|
||||
id: '1',
|
||||
tag: 'GSR#7',
|
||||
title: 'Controle e monitoramento em right-time',
|
||||
validation: 'Not Validated',
|
||||
priority: 0,
|
||||
progress: 0,
|
||||
group: 'Trustworthiness',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
tag: 'GSR#10',
|
||||
title: 'Interface de software DT-externo bem definida.',
|
||||
validation: 'Not Validated',
|
||||
priority: 1,
|
||||
progress: 0,
|
||||
group: 'Intelligence',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
tag: 'GSR#12',
|
||||
title: 'Visualizacao',
|
||||
validation: 'Not Validated',
|
||||
priority: 1,
|
||||
progress: 0,
|
||||
group: 'User Experience',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
tag: 'GSR#1',
|
||||
title: 'Estado corrente atualizado',
|
||||
validation: 'Not Validated',
|
||||
priority: 1,
|
||||
progress: 0,
|
||||
group: 'Data Services',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
tag: 'GSR#5',
|
||||
title: 'Sincronização de dados em tempo real',
|
||||
validation: 'Not Validated',
|
||||
priority: 2,
|
||||
progress: 25,
|
||||
group: 'Data Services',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
tag: 'GSR#8',
|
||||
title: 'Integração com sistemas legados',
|
||||
validation: 'Not Validated',
|
||||
priority: 1,
|
||||
progress: 50,
|
||||
group: 'Integration',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
tag: 'GSR#15',
|
||||
title: 'Dashboard de gestão',
|
||||
validation: 'Not Validated',
|
||||
priority: 3,
|
||||
progress: 0,
|
||||
group: 'Management',
|
||||
},
|
||||
]
|
||||
|
||||
// Caption types
|
||||
const captionItems = [
|
||||
{ abbr: 'SFR', description: 'Specific Functional Requirement', underline: true },
|
||||
{ abbr: 'SNFR', description: 'Specific Non-Functional Requirement', underline: true },
|
||||
{ abbr: 'GNFR', description: 'Generic Non-Functional Requirement', underline: true },
|
||||
{ abbr: 'GSR', description: 'Generic Service Requirement', underline: true },
|
||||
{ abbr: 'GDR', description: 'Generic Data Requirement', underline: true },
|
||||
{ abbr: 'GCR', description: 'Generic Connection Requirement', underline: true },
|
||||
]
|
||||
|
||||
export default function RequirementsPage() {
|
||||
const { user, logout } = useAuth()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
|
||||
// State
|
||||
// Data state
|
||||
const [groups, setGroups] = useState<Group[]>([])
|
||||
const [tags, setTags] = useState<Tag[]>([])
|
||||
const [priorities, setPriorities] = useState<Priority[]>([])
|
||||
const [requirements, setRequirements] = useState<Requirement[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Filter state
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedGroups, setSelectedGroups] = useState<RequirementGroup[]>([])
|
||||
const [selectedGroups, setSelectedGroups] = useState<number[]>([])
|
||||
const [orderBy, setOrderBy] = useState<'Date' | 'Priority' | 'Name'>('Date')
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('list')
|
||||
|
||||
// Modal state
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [createLoading, setCreateLoading] = useState(false)
|
||||
const [createError, setCreateError] = useState<string | null>(null)
|
||||
|
||||
// Form state for new requirement
|
||||
const [newReqName, setNewReqName] = useState('')
|
||||
const [newReqDesc, setNewReqDesc] = useState('')
|
||||
const [newReqTagId, setNewReqTagId] = useState<number | ''>('')
|
||||
const [newReqPriorityId, setNewReqPriorityId] = useState<number | ''>('')
|
||||
const [newReqGroupIds, setNewReqGroupIds] = useState<number[]>([])
|
||||
|
||||
// Fetch data on mount
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
// Fetch groups, tags, priorities, and requirements in parallel
|
||||
const [groupsData, tagsData, prioritiesData, requirementsData] = await Promise.all([
|
||||
groupService.getGroups(),
|
||||
tagService.getTags(),
|
||||
priorityService.getPriorities(),
|
||||
requirementService.getRequirements(),
|
||||
])
|
||||
|
||||
setGroups(groupsData)
|
||||
setTags(tagsData)
|
||||
setPriorities(prioritiesData)
|
||||
setRequirements(requirementsData)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch data:', err)
|
||||
setError('Failed to load data. Please try again.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
// Initialize filters from URL params
|
||||
useEffect(() => {
|
||||
const groupParam = searchParams.get('group')
|
||||
if (groupParam) {
|
||||
setSelectedGroups([groupParam as RequirementGroup])
|
||||
if (groupParam && groups.length > 0) {
|
||||
const group = groups.find(g => g.group_name === groupParam)
|
||||
if (group) {
|
||||
setSelectedGroups([group.id])
|
||||
}
|
||||
}
|
||||
}, [searchParams])
|
||||
}, [searchParams, groups])
|
||||
|
||||
// Filter requirements based on search and selected groups
|
||||
const filteredRequirements = mockRequirements.filter(req => {
|
||||
const filteredRequirements = requirements.filter(req => {
|
||||
const tagLabel = `${req.tag.tag_code}#${req.id}`
|
||||
const matchesSearch = searchQuery === '' ||
|
||||
req.tag.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
req.title.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
tagLabel.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
req.req_name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
|
||||
const matchesGroup = selectedGroups.length === 0 ||
|
||||
selectedGroups.includes(req.group)
|
||||
req.groups.some(g => selectedGroups.includes(g.id))
|
||||
|
||||
return matchesSearch && matchesGroup
|
||||
})
|
||||
@@ -143,19 +111,24 @@ export default function RequirementsPage() {
|
||||
const sortedRequirements = [...filteredRequirements].sort((a, b) => {
|
||||
switch (orderBy) {
|
||||
case 'Priority':
|
||||
return b.priority - a.priority
|
||||
const priorityA = a.priority?.priority_num ?? 0
|
||||
const priorityB = b.priority?.priority_num ?? 0
|
||||
return priorityB - priorityA
|
||||
case 'Name':
|
||||
return a.title.localeCompare(b.title)
|
||||
return a.req_name.localeCompare(b.req_name)
|
||||
case 'Date':
|
||||
default:
|
||||
return 0
|
||||
const dateA = a.created_at ? new Date(a.created_at).getTime() : 0
|
||||
const dateB = b.created_at ? new Date(b.created_at).getTime() : 0
|
||||
return dateB - dateA
|
||||
}
|
||||
})
|
||||
|
||||
const handleGroupToggle = (group: RequirementGroup) => {
|
||||
const handleGroupToggle = (groupId: number) => {
|
||||
setSelectedGroups(prev =>
|
||||
prev.includes(group)
|
||||
? prev.filter(g => g !== group)
|
||||
: [...prev, group]
|
||||
prev.includes(groupId)
|
||||
? prev.filter(id => id !== groupId)
|
||||
: [...prev, groupId]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -166,18 +139,128 @@ export default function RequirementsPage() {
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
// For now, filtering is automatic - this could trigger an API call later
|
||||
// Filtering is automatic, but this could trigger a fresh API call
|
||||
}
|
||||
|
||||
const handleRemove = (id: string) => {
|
||||
// For now, just a placeholder - will connect to backend later
|
||||
console.log('Remove requirement:', id)
|
||||
const handleRemove = async (id: number) => {
|
||||
if (!confirm('Are you sure you want to delete this requirement?')) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await requirementService.deleteRequirement(id)
|
||||
// Remove from local state
|
||||
setRequirements(prev => prev.filter(r => r.id !== id))
|
||||
} catch (err) {
|
||||
console.error('Failed to delete requirement:', err)
|
||||
alert('Failed to delete requirement. Please try again.')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDetails = (id: string) => {
|
||||
const handleDetails = (id: number) => {
|
||||
navigate(`/requirements/${id}`)
|
||||
}
|
||||
|
||||
// Get the primary group color for a requirement (first group or default)
|
||||
const getRequirementColor = (req: Requirement): string => {
|
||||
if (req.groups.length > 0) {
|
||||
return req.groups[0].hex_color
|
||||
}
|
||||
return '#6B7280' // default gray
|
||||
}
|
||||
|
||||
// Modal functions
|
||||
const openCreateModal = () => {
|
||||
setShowCreateModal(true)
|
||||
setCreateError(null)
|
||||
// Reset form
|
||||
setNewReqName('')
|
||||
setNewReqDesc('')
|
||||
setNewReqTagId('')
|
||||
setNewReqPriorityId('')
|
||||
setNewReqGroupIds([])
|
||||
}
|
||||
|
||||
const closeCreateModal = () => {
|
||||
setShowCreateModal(false)
|
||||
setCreateError(null)
|
||||
}
|
||||
|
||||
const handleCreateGroupToggle = (groupId: number) => {
|
||||
setNewReqGroupIds(prev =>
|
||||
prev.includes(groupId)
|
||||
? prev.filter(id => id !== groupId)
|
||||
: [...prev, groupId]
|
||||
)
|
||||
}
|
||||
|
||||
const handleCreateRequirement = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
// Validation
|
||||
if (!newReqName.trim()) {
|
||||
setCreateError('Requirement name is required')
|
||||
return
|
||||
}
|
||||
if (!newReqTagId) {
|
||||
setCreateError('Please select a tag')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setCreateLoading(true)
|
||||
setCreateError(null)
|
||||
|
||||
const data: RequirementCreateRequest = {
|
||||
tag_id: newReqTagId as number,
|
||||
req_name: newReqName.trim(),
|
||||
req_desc: newReqDesc.trim() || undefined,
|
||||
priority_id: newReqPriorityId ? (newReqPriorityId as number) : undefined,
|
||||
group_ids: newReqGroupIds.length > 0 ? newReqGroupIds : undefined,
|
||||
}
|
||||
|
||||
const newRequirement = await requirementService.createRequirement(data)
|
||||
|
||||
// Add to local state
|
||||
setRequirements(prev => [newRequirement, ...prev])
|
||||
|
||||
// Close modal
|
||||
closeCreateModal()
|
||||
} catch (err) {
|
||||
console.error('Failed to create requirement:', err)
|
||||
setCreateError('Failed to create requirement. Please try again.')
|
||||
} finally {
|
||||
setCreateLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-white flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-teal-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Loading requirements...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-white flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-red-600 mb-4">{error}</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-teal-600 text-white rounded hover:bg-teal-700"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
{/* Header */}
|
||||
@@ -236,7 +319,10 @@ export default function RequirementsPage() {
|
||||
<div className="flex-1">
|
||||
{/* New Requirement Button */}
|
||||
<div className="mb-6">
|
||||
<button className="px-4 py-2 border border-gray-400 rounded text-sm font-medium text-gray-700 hover:bg-gray-50">
|
||||
<button
|
||||
onClick={openCreateModal}
|
||||
className="px-4 py-2 border border-gray-400 rounded text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
New Requirement
|
||||
</button>
|
||||
</div>
|
||||
@@ -268,15 +354,15 @@ export default function RequirementsPage() {
|
||||
<div className="mb-6">
|
||||
<p className="text-sm text-gray-600 mb-3">Filter Group</p>
|
||||
<div className="grid grid-cols-3 gap-x-8 gap-y-2">
|
||||
{(['Data Services', 'Intelligence', 'Management', 'Integration', 'User Experience', 'Trustworthiness'] as RequirementGroup[]).map((group) => (
|
||||
<label key={group} className="flex items-center gap-2 cursor-pointer">
|
||||
{groups.map((group) => (
|
||||
<label key={group.id} className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedGroups.includes(group)}
|
||||
onChange={() => handleGroupToggle(group)}
|
||||
checked={selectedGroups.includes(group.id)}
|
||||
onChange={() => handleGroupToggle(group.id)}
|
||||
className="w-4 h-4 rounded border-gray-300 text-teal-600 focus:ring-teal-500"
|
||||
/>
|
||||
<span className="text-sm text-blue-600 underline">{group}</span>
|
||||
<span className="text-sm text-blue-600 underline">{group.group_name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
@@ -327,30 +413,39 @@ export default function RequirementsPage() {
|
||||
{/* Requirements List */}
|
||||
<div className="space-y-4">
|
||||
{sortedRequirements.map((req) => {
|
||||
const colors = groupColors[req.group]
|
||||
const primaryColor = getRequirementColor(req)
|
||||
const bgColor = lightenColor(primaryColor, 60)
|
||||
const tagLabel = `${req.tag.tag_code}#${req.id}`
|
||||
const priorityNum = req.priority?.priority_num ?? 0
|
||||
const validationStatus = req.validation_status || 'Not Validated'
|
||||
|
||||
return (
|
||||
<div
|
||||
key={req.id}
|
||||
className={`flex items-center border ${colors.border} rounded overflow-hidden`}
|
||||
className="flex items-center rounded overflow-hidden"
|
||||
style={{ borderColor: primaryColor, borderWidth: '1px', borderStyle: 'solid' }}
|
||||
>
|
||||
{/* Colored tag section */}
|
||||
<div className={`${colors.bg} px-4 py-4 min-w-[320px]`}>
|
||||
<div
|
||||
className="px-4 py-4 min-w-[320px]"
|
||||
style={{ backgroundColor: bgColor }}
|
||||
>
|
||||
<span className="font-bold text-gray-800">
|
||||
{req.tag} - {req.title}
|
||||
{tagLabel} - {req.req_name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Validation status */}
|
||||
<div className="flex-1 px-6 py-4 text-center">
|
||||
<span className="text-sm text-gray-600">
|
||||
Validation: {req.validation}
|
||||
Validation: {validationStatus}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Priority and Progress */}
|
||||
{/* Priority and Version */}
|
||||
<div className="px-6 py-4 text-right">
|
||||
<p className="text-sm text-gray-700">Priority: {req.priority}</p>
|
||||
<p className="text-sm text-gray-600">{req.progress.toFixed(2)}% Done</p>
|
||||
<p className="text-sm text-gray-700">Priority: {priorityNum}</p>
|
||||
<p className="text-sm text-gray-600">Version: {req.version}</p>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
@@ -385,10 +480,10 @@ export default function RequirementsPage() {
|
||||
<div className="border-l border-gray-200 pl-6">
|
||||
<h3 className="font-semibold text-gray-800 mb-4">Caption:</h3>
|
||||
<div className="space-y-2">
|
||||
{captionItems.map((item) => (
|
||||
<p key={item.abbr} className="text-sm">
|
||||
<span className="text-blue-600 underline">{item.abbr}</span>
|
||||
<span className="text-gray-600">: {item.description}</span>
|
||||
{tags.map((tag) => (
|
||||
<p key={tag.id} className="text-sm">
|
||||
<span className="text-blue-600 underline">{tag.tag_code}</span>
|
||||
<span className="text-gray-600">: {tag.tag_description}</span>
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
@@ -397,6 +492,145 @@ export default function RequirementsPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Requirement Modal */}
|
||||
{showCreateModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-lg mx-4">
|
||||
{/* Modal Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-xl font-semibold text-gray-800">New Requirement</h2>
|
||||
<button
|
||||
onClick={closeCreateModal}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Body */}
|
||||
<form onSubmit={handleCreateRequirement}>
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
{/* Error message */}
|
||||
{createError && (
|
||||
<div className="p-3 bg-red-100 border border-red-400 text-red-700 rounded text-sm">
|
||||
{createError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tag Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Tag <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={newReqTagId}
|
||||
onChange={(e) => setNewReqTagId(e.target.value ? Number(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"
|
||||
required
|
||||
>
|
||||
<option value="">Select a tag...</option>
|
||||
{tags.map((tag) => (
|
||||
<option key={tag.id} value={tag.id}>
|
||||
{tag.tag_code} - {tag.tag_description}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Requirement Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newReqName}
|
||||
onChange={(e) => setNewReqName(e.target.value)}
|
||||
placeholder="Enter requirement name"
|
||||
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"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={newReqDesc}
|
||||
onChange={(e) => setNewReqDesc(e.target.value)}
|
||||
placeholder="Enter requirement description (optional)"
|
||||
rows={3}
|
||||
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 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Priority Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Priority
|
||||
</label>
|
||||
<select
|
||||
value={newReqPriorityId}
|
||||
onChange={(e) => setNewReqPriorityId(e.target.value ? Number(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"
|
||||
>
|
||||
<option value="">Select a priority (optional)...</option>
|
||||
{priorities.map((priority) => (
|
||||
<option key={priority.id} value={priority.id}>
|
||||
{priority.priority_name} ({priority.priority_num})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Groups Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Groups
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2 max-h-40 overflow-y-auto border border-gray-200 rounded p-3">
|
||||
{groups.map((group) => (
|
||||
<label key={group.id} className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newReqGroupIds.includes(group.id)}
|
||||
onChange={() => handleCreateGroupToggle(group.id)}
|
||||
className="w-4 h-4 rounded border-gray-300 text-teal-600 focus:ring-teal-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">{group.group_name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="flex justify-end gap-3 px-6 py-4 border-t border-gray-200 bg-gray-50 rounded-b-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeCreateModal}
|
||||
className="px-4 py-2 border border-gray-400 rounded text-sm font-medium text-gray-700 hover:bg-gray-100"
|
||||
disabled={createLoading}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-teal-600 text-white rounded text-sm font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={createLoading}
|
||||
>
|
||||
{createLoading ? 'Creating...' : 'Create Requirement'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
export { authService } from './authService'
|
||||
export { groupService } from './groupService'
|
||||
export type { Group } from './groupService'
|
||||
export { tagService } from './tagService'
|
||||
export type { Tag } from './tagService'
|
||||
export { priorityService } from './priorityService'
|
||||
export type { Priority } from './priorityService'
|
||||
export { requirementService } from './requirementService'
|
||||
export type { Requirement, RequirementCreateRequest, RequirementUpdateRequest } from './requirementService'
|
||||
|
||||
36
frontend/src/services/priorityService.ts
Normal file
36
frontend/src/services/priorityService.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
const API_BASE_URL = '/api'
|
||||
|
||||
export interface Priority {
|
||||
id: number
|
||||
priority_name: string
|
||||
priority_num: number
|
||||
}
|
||||
|
||||
class PriorityService {
|
||||
/**
|
||||
* Get all priorities from the API.
|
||||
*/
|
||||
async getPriorities(): Promise<Priority[]> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/priorities`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const priorities: Priority[] = await response.json()
|
||||
return priorities
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch priorities:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const priorityService = new PriorityService()
|
||||
176
frontend/src/services/requirementService.ts
Normal file
176
frontend/src/services/requirementService.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { Group } from './groupService'
|
||||
import { Tag } from './tagService'
|
||||
import { Priority } from './priorityService'
|
||||
|
||||
const API_BASE_URL = '/api'
|
||||
|
||||
export interface Requirement {
|
||||
id: number
|
||||
req_name: string
|
||||
req_desc: string | null
|
||||
version: number
|
||||
created_at: string | null
|
||||
updated_at: string | null
|
||||
tag: Tag
|
||||
priority: Priority | null
|
||||
groups: Group[]
|
||||
validation_status: string | null
|
||||
}
|
||||
|
||||
export interface RequirementCreateRequest {
|
||||
tag_id: number
|
||||
req_name: string
|
||||
req_desc?: string
|
||||
priority_id?: number
|
||||
group_ids?: number[]
|
||||
}
|
||||
|
||||
export interface RequirementUpdateRequest {
|
||||
req_name?: string
|
||||
req_desc?: string
|
||||
tag_id?: number
|
||||
priority_id?: number
|
||||
group_ids?: number[]
|
||||
}
|
||||
|
||||
class RequirementService {
|
||||
/**
|
||||
* Get all requirements from the API.
|
||||
* Optionally filter by group_id or tag_id.
|
||||
*/
|
||||
async getRequirements(params?: { group_id?: number; tag_id?: number }): Promise<Requirement[]> {
|
||||
try {
|
||||
let url = `${API_BASE_URL}/requirements`
|
||||
|
||||
if (params) {
|
||||
const queryParams = new URLSearchParams()
|
||||
if (params.group_id) queryParams.append('group_id', params.group_id.toString())
|
||||
if (params.tag_id) queryParams.append('tag_id', params.tag_id.toString())
|
||||
|
||||
const queryString = queryParams.toString()
|
||||
if (queryString) {
|
||||
url += `?${queryString}`
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const requirements: Requirement[] = await response.json()
|
||||
return requirements
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch requirements:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific requirement by ID.
|
||||
*/
|
||||
async getRequirement(requirementId: number): Promise<Requirement> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/requirements/${requirementId}`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const requirement: Requirement = await response.json()
|
||||
return requirement
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch requirement:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new requirement.
|
||||
*/
|
||||
async createRequirement(data: RequirementCreateRequest): Promise<Requirement> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/requirements`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const requirement: Requirement = await response.json()
|
||||
return requirement
|
||||
} catch (error) {
|
||||
console.error('Failed to create requirement:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing requirement.
|
||||
*/
|
||||
async updateRequirement(requirementId: number, data: RequirementUpdateRequest): Promise<Requirement> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/requirements/${requirementId}`, {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const requirement: Requirement = await response.json()
|
||||
return requirement
|
||||
} catch (error) {
|
||||
console.error('Failed to update requirement:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a requirement.
|
||||
*/
|
||||
async deleteRequirement(requirementId: number): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/requirements/${requirementId}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete requirement:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const requirementService = new RequirementService()
|
||||
61
frontend/src/services/tagService.ts
Normal file
61
frontend/src/services/tagService.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
const API_BASE_URL = '/api'
|
||||
|
||||
export interface Tag {
|
||||
id: number
|
||||
tag_code: string
|
||||
tag_description: string
|
||||
}
|
||||
|
||||
class TagService {
|
||||
/**
|
||||
* Get all tags from the API.
|
||||
*/
|
||||
async getTags(): Promise<Tag[]> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/tags`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const tags: Tag[] = await response.json()
|
||||
return tags
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch tags:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific tag by ID.
|
||||
*/
|
||||
async getTag(tagId: number): Promise<Tag> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/tags/${tagId}`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const tag: Tag = await response.json()
|
||||
return tag
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch tag:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const tagService = new TagService()
|
||||
Reference in New Issue
Block a user