Added project separation logic

This commit is contained in:
gulimabr
2025-12-01 11:01:13 -03:00
parent 6d02736cba
commit 07005788ed
17 changed files with 1337 additions and 115 deletions

View File

@@ -52,6 +52,11 @@ class User(Base):
foreign_keys="Requirement.last_editor_id" foreign_keys="Requirement.last_editor_id"
) )
validations: Mapped[List["Validation"]] = relationship("Validation", back_populates="user") validations: Mapped[List["Validation"]] = relationship("Validation", back_populates="user")
projects: Mapped[List["Project"]] = relationship(
"Project",
secondary="project_members",
back_populates="members"
)
class Tag(Base): class Tag(Base):
@@ -94,6 +99,54 @@ class Priority(Base):
requirements: Mapped[List["Requirement"]] = relationship("Requirement", back_populates="priority") requirements: Mapped[List["Requirement"]] = relationship("Requirement", back_populates="priority")
class Project(Base):
"""Projects - containers for requirements that can have multiple members."""
__tablename__ = "projects"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
project_name: Mapped[str] = mapped_column(Text, nullable=False)
project_desc: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=datetime.utcnow,
nullable=True
)
# Relationships
members: Mapped[List["User"]] = relationship(
"User",
secondary="project_members",
back_populates="projects"
)
requirements: Mapped[List["Requirement"]] = relationship("Requirement", back_populates="project")
class ProjectMember(Base):
"""Join table for many-to-many relationship between projects and users."""
__tablename__ = "project_members"
project_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("projects.id", ondelete="CASCADE"),
primary_key=True
)
user_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("users.id", ondelete="CASCADE"),
primary_key=True
)
joined_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=datetime.utcnow,
nullable=True
)
# Indexes
__table_args__ = (
Index("idx_pm_user", "user_id"),
)
class ValidationStatus(Base): class ValidationStatus(Base):
"""Validation status options.""" """Validation status options."""
__tablename__ = "validation_statuses" __tablename__ = "validation_statuses"
@@ -110,6 +163,7 @@ class Requirement(Base):
__tablename__ = "requirements" __tablename__ = "requirements"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
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) user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
tag_id: Mapped[int] = mapped_column(Integer, ForeignKey("tags.id"), nullable=False) tag_id: Mapped[int] = mapped_column(Integer, ForeignKey("tags.id"), nullable=False)
last_editor_id: Mapped[Optional[int]] = mapped_column( last_editor_id: Mapped[Optional[int]] = mapped_column(
@@ -138,6 +192,7 @@ class Requirement(Base):
) )
# Relationships # Relationships
project: Mapped["Project"] = relationship("Project", back_populates="requirements")
user: Mapped["User"] = relationship( user: Mapped["User"] = relationship(
"User", "User",
back_populates="requirements", back_populates="requirements",
@@ -159,6 +214,7 @@ class Requirement(Base):
# Indexes # Indexes
__table_args__ = ( __table_args__ = (
Index("idx_req_project", "project_id"),
Index("idx_req_tag", "tag_id"), Index("idx_req_tag", "tag_id"),
Index("idx_req_priority", "priority_id"), Index("idx_req_priority", "priority_id"),
Index("idx_req_user", "user_id"), Index("idx_req_user", "user_id"),
@@ -220,6 +276,7 @@ class RequirementHistory(Base):
history_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) history_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
original_req_id: Mapped[int] = mapped_column(Integer, nullable=False) original_req_id: Mapped[int] = mapped_column(Integer, nullable=False)
project_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
req_name: Mapped[Optional[str]] = mapped_column(Text, nullable=True) req_name: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
req_desc: Mapped[Optional[str]] = mapped_column(Text, nullable=True) req_desc: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
priority_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) priority_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)

View File

@@ -8,12 +8,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.models import ( from src.models import (
TokenResponse, UserInfo, GroupResponse, TokenResponse, UserInfo, GroupResponse,
TagResponse, RequirementResponse, PriorityResponse, TagResponse, RequirementResponse, PriorityResponse,
RequirementCreateRequest, RequirementUpdateRequest RequirementCreateRequest, RequirementUpdateRequest,
ProjectResponse, ProjectCreateRequest, ProjectUpdateRequest, ProjectMemberRequest
) )
from src.controller import AuthController from src.controller import AuthController
from src.config import get_openid, get_settings from src.config import get_openid, get_settings
from src.database import init_db, close_db, get_db from src.database import init_db, close_db, get_db
from src.repositories import RoleRepository, GroupRepository, TagRepository, RequirementRepository, PriorityRepository from src.repositories import RoleRepository, GroupRepository, TagRepository, RequirementRepository, PriorityRepository, ProjectRepository
import logging import logging
# Configure logging # Configure logging
@@ -237,6 +238,233 @@ async def get_priorities(db: AsyncSession = Depends(get_db)):
return [PriorityResponse.model_validate(p) for p in priorities] return [PriorityResponse.model_validate(p) for p in priorities]
# ===========================================
# Projects Endpoints
# ===========================================
async def _get_current_user_db(request: Request, db: AsyncSession):
"""Helper to get the current authenticated user from the database."""
user_info = AuthController.get_current_user(request)
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"
)
return user
async def _verify_project_membership(project_id: int, user_id: int, db: AsyncSession):
"""Helper to verify user is a member of a project."""
project_repo = ProjectRepository(db)
# Check if project exists
project = await project_repo.get_by_id(project_id)
if not project:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Project with id {project_id} not found"
)
# Check if user is a member
is_member = await project_repo.is_member(project_id, user_id)
if not is_member:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You are not a member of this project"
)
return project
@app.get("/api/projects", response_model=List[ProjectResponse])
async def get_my_projects(
request: Request,
db: AsyncSession = Depends(get_db)
):
"""
Get all projects the authenticated user is a member of.
Returns:
List of projects the user belongs to.
"""
user = await _get_current_user_db(request, db)
project_repo = ProjectRepository(db)
projects = await project_repo.get_by_user_id(user.id)
return [ProjectResponse.model_validate(p) for p in projects]
@app.get("/api/projects/{project_id}", response_model=ProjectResponse)
async def get_project(
project_id: int,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""
Get a specific project by ID.
User must be a member of the project.
Args:
project_id: The project ID
Returns:
The project if found and user is a member.
"""
user = await _get_current_user_db(request, db)
project = await _verify_project_membership(project_id, user.id, db)
return ProjectResponse.model_validate(project)
@app.post("/api/projects", response_model=ProjectResponse, status_code=status.HTTP_201_CREATED)
async def create_project(
request: Request,
project_data: ProjectCreateRequest,
db: AsyncSession = Depends(get_db)
):
"""
Create a new project.
The creating user will automatically be added as a member.
Args:
project_data: The project data
Returns:
The created project.
"""
user = await _get_current_user_db(request, db)
project_repo = ProjectRepository(db)
project = await project_repo.create(
project_name=project_data.project_name,
project_desc=project_data.project_desc,
creator_id=user.id,
)
await db.commit()
return ProjectResponse.model_validate(project)
@app.put("/api/projects/{project_id}", response_model=ProjectResponse)
async def update_project(
project_id: int,
request: Request,
project_data: ProjectUpdateRequest,
db: AsyncSession = Depends(get_db)
):
"""
Update an existing project.
User must be a member of the project.
Args:
project_id: The project ID to update
project_data: The updated project data
Returns:
The updated project.
"""
user = await _get_current_user_db(request, db)
await _verify_project_membership(project_id, user.id, db)
project_repo = ProjectRepository(db)
project = await project_repo.update(
project_id=project_id,
project_name=project_data.project_name,
project_desc=project_data.project_desc,
)
await db.commit()
return ProjectResponse.model_validate(project)
@app.delete("/api/projects/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_project(
project_id: int,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""
Delete a project.
User must be a member of the project.
Args:
project_id: The project ID to delete
"""
user = await _get_current_user_db(request, db)
await _verify_project_membership(project_id, user.id, db)
project_repo = ProjectRepository(db)
await project_repo.delete(project_id)
await db.commit()
@app.post("/api/projects/{project_id}/members", status_code=status.HTTP_201_CREATED)
async def add_project_member(
project_id: int,
request: Request,
member_data: ProjectMemberRequest,
db: AsyncSession = Depends(get_db)
):
"""
Add a member to a project.
User must be a member of the project.
Args:
project_id: The project ID
member_data: The user to add
Returns:
Success message.
"""
user = await _get_current_user_db(request, db)
await _verify_project_membership(project_id, user.id, db)
project_repo = ProjectRepository(db)
added = await project_repo.add_member(project_id, member_data.user_id)
if not added:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User is already a member of this project"
)
await db.commit()
return {"message": "Member added successfully"}
@app.delete("/api/projects/{project_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_project_member(
project_id: int,
user_id: int,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""
Remove a member from a project.
User must be a member of the project.
Args:
project_id: The project ID
user_id: The user ID to remove
"""
current_user = await _get_current_user_db(request, db)
await _verify_project_membership(project_id, current_user.id, db)
project_repo = ProjectRepository(db)
removed = await project_repo.remove_member(project_id, user_id)
if not removed:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User is not a member of this project"
)
await db.commit()
# =========================================== # ===========================================
# Requirements Endpoints # Requirements Endpoints
# =========================================== # ===========================================
@@ -252,6 +480,7 @@ def _build_requirement_response(req) -> RequirementResponse:
return RequirementResponse( return RequirementResponse(
id=req.id, id=req.id,
project_id=req.project_id,
req_name=req.req_name, req_name=req.req_name,
req_desc=req.req_desc, req_desc=req.req_desc,
version=req.version, version=req.version,
@@ -264,44 +493,79 @@ def _build_requirement_response(req) -> RequirementResponse:
) )
@app.get("/api/requirements", response_model=List[RequirementResponse]) @app.get("/api/projects/{project_id}/requirements", response_model=List[RequirementResponse])
async def get_requirements( async def get_project_requirements(
project_id: int,
request: Request, request: Request,
group_id: Optional[int] = None, group_id: Optional[int] = None,
tag_id: Optional[int] = None, tag_id: Optional[int] = None,
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
""" """
Get all requirements for the authenticated user, optionally filtered by group or tag. Get all requirements for a specific project, optionally filtered by group or tag.
User must be a member of the project.
Args: Args:
project_id: The project ID
group_id: Optional group ID to filter by group_id: Optional group ID to filter by
tag_id: Optional tag ID to filter by tag_id: Optional tag ID to filter by
Returns: Returns:
List of requirements owned by the authenticated user. List of requirements in the project.
""" """
# Get the current user from cookie user = await _get_current_user_db(request, db)
user_info = AuthController.get_current_user(request) await _verify_project_membership(project_id, user.id, db)
# 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) req_repo = RequirementRepository(db)
if group_id: if group_id:
requirements = await req_repo.get_by_group_id(group_id, user_id=user.id) requirements = await req_repo.get_by_group_id(group_id, project_id=project_id)
elif tag_id: elif tag_id:
requirements = await req_repo.get_by_tag_id(tag_id, user_id=user.id) requirements = await req_repo.get_by_tag_id(tag_id, project_id=project_id)
else: else:
requirements = await req_repo.get_by_user_id(user.id) requirements = await req_repo.get_by_project_id(project_id)
return [_build_requirement_response(req) for req in requirements]
@app.get("/api/requirements", response_model=List[RequirementResponse])
async def get_requirements(
request: Request,
project_id: Optional[int] = None,
group_id: Optional[int] = None,
tag_id: Optional[int] = None,
db: AsyncSession = Depends(get_db)
):
"""
Get requirements. If project_id is provided, returns requirements for that project.
User must be a member of the project.
Args:
project_id: Required project ID to filter by
group_id: Optional group ID to filter by
tag_id: Optional tag ID to filter by
Returns:
List of requirements.
"""
user = await _get_current_user_db(request, db)
if not project_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="project_id is required"
)
await _verify_project_membership(project_id, user.id, db)
req_repo = RequirementRepository(db)
if group_id:
requirements = await req_repo.get_by_group_id(group_id, project_id=project_id)
elif tag_id:
requirements = await req_repo.get_by_tag_id(tag_id, project_id=project_id)
else:
requirements = await req_repo.get_by_project_id(project_id)
return [_build_requirement_response(req) for req in requirements] return [_build_requirement_response(req) for req in requirements]
@@ -314,25 +578,15 @@ async def get_requirement(
): ):
""" """
Get a specific requirement by ID. Get a specific requirement by ID.
User must be a member of the requirement's project.
Args: Args:
requirement_id: The requirement ID requirement_id: The requirement ID
Returns: Returns:
The requirement if found and owned by the authenticated user. The requirement if found and user has access.
""" """
# Get the current user from cookie user = await _get_current_user_db(request, db)
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) req_repo = RequirementRepository(db)
requirement = await req_repo.get_by_id(requirement_id) requirement = await req_repo.get_by_id(requirement_id)
@@ -342,12 +596,8 @@ async def get_requirement(
detail=f"Requirement with id {requirement_id} not found" detail=f"Requirement with id {requirement_id} not found"
) )
# Verify user owns this requirement # Verify user is a member of the requirement's project
if requirement.user_id != user.id: await _verify_project_membership(requirement.project_id, user.id, db)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You do not have permission to access this requirement"
)
return _build_requirement_response(requirement) return _build_requirement_response(requirement)
@@ -360,28 +610,22 @@ async def create_requirement(
): ):
""" """
Create a new requirement. Create a new requirement.
User must be a member of the project.
Args: Args:
req_data: The requirement data req_data: The requirement data (must include project_id)
Returns: Returns:
The created requirement. The created requirement.
""" """
# Get the current user from cookie user = await _get_current_user_db(request, db)
user_info = AuthController.get_current_user(request)
# Get the user's database ID # Verify user is a member of the project
from src.repositories import UserRepository await _verify_project_membership(req_data.project_id, user.id, db)
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) req_repo = RequirementRepository(db)
requirement = await req_repo.create( requirement = await req_repo.create(
project_id=req_data.project_id,
user_id=user.id, user_id=user.id,
tag_id=req_data.tag_id, tag_id=req_data.tag_id,
req_name=req_data.req_name, req_name=req_data.req_name,
@@ -403,6 +647,7 @@ async def update_requirement(
): ):
""" """
Update an existing requirement. Update an existing requirement.
User must be a member of the requirement's project.
Args: Args:
requirement_id: The requirement ID to update requirement_id: The requirement ID to update
@@ -411,22 +656,11 @@ async def update_requirement(
Returns: Returns:
The updated requirement. The updated requirement.
""" """
# Get the current user from cookie user = await _get_current_user_db(request, db)
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) req_repo = RequirementRepository(db)
# First check if requirement exists and user owns it # First check if requirement exists
existing_req = await req_repo.get_by_id(requirement_id) existing_req = await req_repo.get_by_id(requirement_id)
if not existing_req: if not existing_req:
raise HTTPException( raise HTTPException(
@@ -434,11 +668,8 @@ async def update_requirement(
detail=f"Requirement with id {requirement_id} not found" detail=f"Requirement with id {requirement_id} not found"
) )
if existing_req.user_id != user.id: # Verify user is a member of the requirement's project
raise HTTPException( await _verify_project_membership(existing_req.project_id, user.id, db)
status_code=status.HTTP_403_FORBIDDEN,
detail="You do not have permission to update this requirement"
)
requirement = await req_repo.update( requirement = await req_repo.update(
requirement_id=requirement_id, requirement_id=requirement_id,
@@ -462,26 +693,16 @@ async def delete_requirement(
): ):
""" """
Delete a requirement. Delete a requirement.
User must be a member of the requirement's project.
Args: Args:
requirement_id: The requirement ID to delete requirement_id: The requirement ID to delete
""" """
# Get the current user from cookie user = await _get_current_user_db(request, db)
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) req_repo = RequirementRepository(db)
# First check if requirement exists and user owns it # First check if requirement exists
existing_req = await req_repo.get_by_id(requirement_id) existing_req = await req_repo.get_by_id(requirement_id)
if not existing_req: if not existing_req:
raise HTTPException( raise HTTPException(
@@ -489,11 +710,8 @@ async def delete_requirement(
detail=f"Requirement with id {requirement_id} not found" detail=f"Requirement with id {requirement_id} not found"
) )
if existing_req.user_id != user.id: # Verify user is a member of the requirement's project
raise HTTPException( await _verify_project_membership(existing_req.project_id, user.id, db)
status_code=status.HTTP_403_FORBIDDEN,
detail="You do not have permission to delete this requirement"
)
await req_repo.delete(requirement_id) await req_repo.delete(requirement_id)
await db.commit() await db.commit()

View File

@@ -22,6 +22,46 @@ class UserInfo(BaseModel):
role: Optional[str] = None # User role name role: Optional[str] = None # User role name
# Project schemas
class ProjectBase(BaseModel):
"""Base schema for projects."""
project_name: str
project_desc: Optional[str] = None
class ProjectResponse(BaseModel):
"""Response schema for a single project."""
id: int
project_name: str
project_desc: Optional[str] = None
created_at: Optional[datetime] = None
class Config:
from_attributes = True
class ProjectCreateRequest(BaseModel):
"""Request schema for creating a project."""
project_name: str
project_desc: Optional[str] = None
class ProjectUpdateRequest(BaseModel):
"""Request schema for updating a project."""
project_name: Optional[str] = None
project_desc: Optional[str] = None
class ProjectListResponse(BaseModel):
"""Response schema for list of projects."""
projects: List[ProjectResponse]
class ProjectMemberRequest(BaseModel):
"""Request schema for adding/removing project members."""
user_id: int
# Group schemas # Group schemas
class GroupResponse(BaseModel): class GroupResponse(BaseModel):
"""Response schema for a single group.""" """Response schema for a single group."""
@@ -82,6 +122,7 @@ class ValidationResponse(BaseModel):
class RequirementResponse(BaseModel): class RequirementResponse(BaseModel):
"""Response schema for a single requirement.""" """Response schema for a single requirement."""
id: int id: int
project_id: int
req_name: str req_name: str
req_desc: Optional[str] = None req_desc: Optional[str] = None
version: int version: int
@@ -103,6 +144,7 @@ class RequirementListResponse(BaseModel):
class RequirementCreateRequest(BaseModel): class RequirementCreateRequest(BaseModel):
"""Request schema for creating a requirement.""" """Request schema for creating a requirement."""
project_id: int
tag_id: int tag_id: int
req_name: str req_name: str
req_desc: Optional[str] = None req_desc: Optional[str] = None

View File

@@ -6,6 +6,7 @@ from src.repositories.group_repository import GroupRepository
from src.repositories.tag_repository import TagRepository from src.repositories.tag_repository import TagRepository
from src.repositories.requirement_repository import RequirementRepository from src.repositories.requirement_repository import RequirementRepository
from src.repositories.priority_repository import PriorityRepository from src.repositories.priority_repository import PriorityRepository
from src.repositories.project_repository import ProjectRepository
__all__ = [ __all__ = [
"UserRepository", "UserRepository",
@@ -14,4 +15,5 @@ __all__ = [
"TagRepository", "TagRepository",
"RequirementRepository", "RequirementRepository",
"PriorityRepository", "PriorityRepository",
"ProjectRepository",
] ]

View File

@@ -0,0 +1,235 @@
"""
Repository layer for Project database operations.
"""
from typing import List, Optional
from sqlalchemy import select, exists
from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession
from src.db_models import Project, ProjectMember, User
import logging
logger = logging.getLogger(__name__)
class ProjectRepository:
"""Repository for Project-related database operations."""
def __init__(self, session: AsyncSession):
self.session = session
async def get_all(self) -> List[Project]:
"""
Get all projects.
Returns:
List of all projects
"""
result = await self.session.execute(
select(Project)
.options(selectinload(Project.members))
.order_by(Project.created_at.desc())
)
return list(result.scalars().all())
async def get_by_id(self, project_id: int) -> Optional[Project]:
"""
Get a project by ID with its members.
Args:
project_id: The project ID
Returns:
Project if found, None otherwise
"""
result = await self.session.execute(
select(Project)
.options(selectinload(Project.members))
.where(Project.id == project_id)
)
return result.scalar_one_or_none()
async def get_by_user_id(self, user_id: int) -> List[Project]:
"""
Get all projects that a user is a member of.
Args:
user_id: The user's database ID
Returns:
List of projects the user belongs to
"""
result = await self.session.execute(
select(Project)
.options(selectinload(Project.members))
.join(ProjectMember, Project.id == ProjectMember.project_id)
.where(ProjectMember.user_id == user_id)
.order_by(Project.created_at.desc())
)
return list(result.scalars().all())
async def is_member(self, project_id: int, user_id: int) -> bool:
"""
Check if a user is a member of a project.
Args:
project_id: The project ID
user_id: The user's database ID
Returns:
True if user is a member, False otherwise
"""
result = await self.session.execute(
select(
exists()
.where(ProjectMember.project_id == project_id)
.where(ProjectMember.user_id == user_id)
)
)
return result.scalar()
async def create(
self,
project_name: str,
project_desc: Optional[str] = None,
creator_id: Optional[int] = None,
) -> Project:
"""
Create a new project.
Optionally add the creator as the first member.
Args:
project_name: The project name
project_desc: The project description (optional)
creator_id: The user ID of the creator to add as first member (optional)
Returns:
The created Project
"""
project = Project(
project_name=project_name,
project_desc=project_desc,
)
self.session.add(project)
await self.session.flush()
# Add creator as first member if provided
if creator_id:
await self.add_member(project.id, creator_id)
await self.session.refresh(project)
return await self.get_by_id(project.id)
async def update(
self,
project_id: int,
project_name: Optional[str] = None,
project_desc: Optional[str] = None,
) -> Optional[Project]:
"""
Update an existing project.
Args:
project_id: The project ID to update
project_name: New project name (optional)
project_desc: New project description (optional)
Returns:
The updated Project, or None if not found
"""
project = await self.get_by_id(project_id)
if not project:
return None
if project_name is not None:
project.project_name = project_name
if project_desc is not None:
project.project_desc = project_desc
await self.session.flush()
await self.session.refresh(project)
return await self.get_by_id(project_id)
async def delete(self, project_id: int) -> bool:
"""
Delete a project (cascades to requirements and memberships).
Args:
project_id: The project ID to delete
Returns:
True if deleted, False if not found
"""
project = await self.get_by_id(project_id)
if not project:
return False
await self.session.delete(project)
await self.session.flush()
return True
async def add_member(self, project_id: int, user_id: int) -> bool:
"""
Add a user as a member of a project.
Args:
project_id: The project ID
user_id: The user ID to add
Returns:
True if added, False if already a member
"""
# Check if already a member
if await self.is_member(project_id, user_id):
return False
member = ProjectMember(
project_id=project_id,
user_id=user_id,
)
self.session.add(member)
await self.session.flush()
return True
async def remove_member(self, project_id: int, user_id: int) -> bool:
"""
Remove a user from a project.
Args:
project_id: The project ID
user_id: The user ID to remove
Returns:
True if removed, False if not a member
"""
result = await self.session.execute(
select(ProjectMember)
.where(ProjectMember.project_id == project_id)
.where(ProjectMember.user_id == user_id)
)
member = result.scalar_one_or_none()
if not member:
return False
await self.session.delete(member)
await self.session.flush()
return True
async def get_members(self, project_id: int) -> List[User]:
"""
Get all members of a project.
Args:
project_id: The project ID
Returns:
List of users who are members of the project
"""
result = await self.session.execute(
select(User)
.join(ProjectMember, User.id == ProjectMember.user_id)
.where(ProjectMember.project_id == project_id)
)
return list(result.scalars().all())

View File

@@ -36,15 +36,38 @@ class RequirementRepository:
) )
return list(result.scalars().all()) return list(result.scalars().all())
async def get_by_project_id(self, project_id: int) -> List[Requirement]:
"""
Get all requirements for a specific project.
Args:
project_id: The project's database ID
Returns:
List of requirements belonging to the project
"""
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.project_id == project_id)
.order_by(Requirement.created_at.desc())
)
return list(result.scalars().all())
async def get_by_user_id(self, user_id: int) -> List[Requirement]: async def get_by_user_id(self, user_id: int) -> List[Requirement]:
""" """
Get all requirements for a specific user. Get all requirements created by a specific user.
Args: Args:
user_id: The user's database ID user_id: The user's database ID
Returns: Returns:
List of requirements owned by the user List of requirements created by the user
""" """
result = await self.session.execute( result = await self.session.execute(
select(Requirement) select(Requirement)
@@ -83,13 +106,14 @@ class RequirementRepository:
) )
return result.scalar_one_or_none() return result.scalar_one_or_none()
async def get_by_group_id(self, group_id: int, user_id: Optional[int] = None) -> List[Requirement]: async def get_by_group_id(self, group_id: int, project_id: Optional[int] = None, user_id: Optional[int] = None) -> List[Requirement]:
""" """
Get all requirements belonging to a specific group. Get all requirements belonging to a specific group.
Args: Args:
group_id: The group ID group_id: The group ID
user_id: Optional user ID to filter by project_id: Optional project ID to filter by
user_id: Optional user ID to filter by (deprecated, use project_id)
Returns: Returns:
List of requirements in the group List of requirements in the group
@@ -106,6 +130,9 @@ class RequirementRepository:
.where(Group.id == group_id) .where(Group.id == group_id)
) )
if project_id is not None:
query = query.where(Requirement.project_id == project_id)
if user_id is not None: if user_id is not None:
query = query.where(Requirement.user_id == user_id) query = query.where(Requirement.user_id == user_id)
@@ -113,13 +140,14 @@ class RequirementRepository:
result = await self.session.execute(query) result = await self.session.execute(query)
return list(result.scalars().all()) return list(result.scalars().all())
async def get_by_tag_id(self, tag_id: int, user_id: Optional[int] = None) -> List[Requirement]: async def get_by_tag_id(self, tag_id: int, project_id: Optional[int] = None, user_id: Optional[int] = None) -> List[Requirement]:
""" """
Get all requirements with a specific tag. Get all requirements with a specific tag.
Args: Args:
tag_id: The tag ID tag_id: The tag ID
user_id: Optional user ID to filter by project_id: Optional project ID to filter by
user_id: Optional user ID to filter by (deprecated, use project_id)
Returns: Returns:
List of requirements with the tag List of requirements with the tag
@@ -135,6 +163,9 @@ class RequirementRepository:
.where(Requirement.tag_id == tag_id) .where(Requirement.tag_id == tag_id)
) )
if project_id is not None:
query = query.where(Requirement.project_id == project_id)
if user_id is not None: if user_id is not None:
query = query.where(Requirement.user_id == user_id) query = query.where(Requirement.user_id == user_id)
@@ -144,6 +175,7 @@ class RequirementRepository:
async def create( async def create(
self, self,
project_id: int,
user_id: int, user_id: int,
tag_id: int, tag_id: int,
req_name: str, req_name: str,
@@ -155,6 +187,7 @@ class RequirementRepository:
Create a new requirement. Create a new requirement.
Args: Args:
project_id: The project ID this requirement belongs to
user_id: The creating user's ID user_id: The creating user's ID
tag_id: The tag ID tag_id: The tag ID
req_name: The requirement name req_name: The requirement name
@@ -166,6 +199,7 @@ class RequirementRepository:
The created Requirement The created Requirement
""" """
requirement = Requirement( requirement = Requirement(
project_id=project_id,
user_id=user_id, user_id=user_id,
tag_id=tag_id, tag_id=tag_id,
req_name=req_name, req_name=req_name,

View File

@@ -0,0 +1,120 @@
import {
createContext,
useState,
useEffect,
useCallback,
type ReactNode,
} from 'react'
import { projectService, type Project } from '@/services'
import { useAuth } from '@/hooks'
export interface ProjectContextType {
projects: Project[]
currentProject: Project | null
isLoading: boolean
error: string | null
setCurrentProject: (project: Project | null) => void
refreshProjects: () => Promise<void>
createProject: (name: string, description?: string) => Promise<Project>
}
export const ProjectContext = createContext<ProjectContextType | undefined>(undefined)
const STORAGE_KEY = 'selectedProjectId'
interface ProjectProviderProps {
children: ReactNode
}
export function ProjectProvider({ children }: ProjectProviderProps) {
const { isAuthenticated, isLoading: authLoading } = useAuth()
const [projects, setProjects] = useState<Project[]>([])
const [currentProject, setCurrentProjectState] = useState<Project | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const setCurrentProject = useCallback((project: Project | null) => {
setCurrentProjectState(project)
if (project) {
localStorage.setItem(STORAGE_KEY, project.id.toString())
} else {
localStorage.removeItem(STORAGE_KEY)
}
}, [])
const refreshProjects = useCallback(async () => {
if (!isAuthenticated) {
setProjects([])
setCurrentProjectState(null)
setIsLoading(false)
return
}
try {
setIsLoading(true)
setError(null)
const fetchedProjects = await projectService.getMyProjects()
setProjects(fetchedProjects)
// Restore selected project from localStorage or select first available
const savedProjectId = localStorage.getItem(STORAGE_KEY)
if (savedProjectId) {
const savedProject = fetchedProjects.find(
(p) => p.id === parseInt(savedProjectId, 10)
)
if (savedProject) {
setCurrentProjectState(savedProject)
} else if (fetchedProjects.length > 0) {
// Saved project no longer available, select first one
setCurrentProject(fetchedProjects[0])
}
} else if (fetchedProjects.length > 0) {
// No saved project, select first one
setCurrentProject(fetchedProjects[0])
}
} catch (err) {
console.error('Failed to fetch projects:', err)
setError('Failed to load projects')
} finally {
setIsLoading(false)
}
}, [isAuthenticated, setCurrentProject])
const createProject = useCallback(async (name: string, description?: string): Promise<Project> => {
const newProject = await projectService.createProject({
project_name: name,
project_desc: description,
})
// Add to projects list
setProjects((prev: Project[]) => [newProject, ...prev])
// If no current project, set this as current
if (!currentProject) {
setCurrentProject(newProject)
}
return newProject
}, [currentProject, setCurrentProject])
// Fetch projects when authentication status changes
useEffect(() => {
if (!authLoading) {
refreshProjects()
}
}, [authLoading, isAuthenticated, refreshProjects])
const value: ProjectContextType = {
projects,
currentProject,
isLoading,
error,
setCurrentProject,
refreshProjects,
createProject,
}
return (
<ProjectContext.Provider value={value}>{children}</ProjectContext.Provider>
)
}

View File

@@ -1 +1,3 @@
export { AuthContext, AuthProvider } from './AuthContext' export { AuthContext, AuthProvider } from './AuthContext'
export { ProjectContext, ProjectProvider } from './ProjectContext'
export type { ProjectContextType } from './ProjectContext'

View File

@@ -1 +1,2 @@
export { useAuth } from './useAuth' export { useAuth } from './useAuth'
export { useProject } from './useProject'

View File

@@ -0,0 +1,10 @@
import { useContext } from 'react'
import { ProjectContext, type ProjectContextType } from '@/context'
export function useProject(): ProjectContextType {
const context = useContext(ProjectContext)
if (context === undefined) {
throw new Error('useProject must be used within a ProjectProvider')
}
return context
}

View File

@@ -2,14 +2,16 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom' import { BrowserRouter } from 'react-router-dom'
import App from './App' import App from './App'
import { AuthProvider } from '@/context/AuthContext' import { AuthProvider, ProjectProvider } from '@/context'
import './index.css' import './index.css'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<BrowserRouter> <BrowserRouter>
<AuthProvider> <AuthProvider>
<ProjectProvider>
<App /> <App />
</ProjectProvider>
</AuthProvider> </AuthProvider>
</BrowserRouter> </BrowserRouter>
</StrictMode>, </StrictMode>,

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useAuth } from '@/hooks' import { useAuth, useProject } from '@/hooks'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { groupService, Group } from '@/services' import { groupService, Group } from '@/services'
@@ -63,12 +63,21 @@ function darkenColor(hex: string, percent: number): string {
export default function DashboardPage() { export default function DashboardPage() {
const { user, logout } = useAuth() const { user, logout } = useAuth()
const { projects, currentProject, setCurrentProject, isLoading: projectsLoading, createProject } = useProject()
const navigate = useNavigate() const navigate = useNavigate()
const [groups, setGroups] = useState<Group[]>([]) const [groups, setGroups] = useState<Group[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [hoveredGroup, setHoveredGroup] = useState<number | null>(null) const [hoveredGroup, setHoveredGroup] = useState<number | null>(null)
// Project dropdown state
const [showProjectDropdown, setShowProjectDropdown] = useState(false)
const [showCreateProjectModal, setShowCreateProjectModal] = useState(false)
const [newProjectName, setNewProjectName] = useState('')
const [newProjectDesc, setNewProjectDesc] = useState('')
const [createProjectLoading, setCreateProjectLoading] = useState(false)
const [createProjectError, setCreateProjectError] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
const fetchGroups = async () => { const fetchGroups = async () => {
try { try {
@@ -95,6 +104,45 @@ export default function DashboardPage() {
navigate('/requirements') navigate('/requirements')
} }
const handleProjectSelect = (project: typeof currentProject) => {
if (project) {
setCurrentProject(project)
}
setShowProjectDropdown(false)
}
const handleCreateProject = async (e: React.FormEvent) => {
e.preventDefault()
if (!newProjectName.trim()) {
setCreateProjectError('Project name is required')
return
}
try {
setCreateProjectLoading(true)
setCreateProjectError(null)
const newProject = await createProject(
newProjectName.trim(),
newProjectDesc.trim() || undefined
)
// Select the newly created project
setCurrentProject(newProject)
// Close modal and reset form
setShowCreateProjectModal(false)
setNewProjectName('')
setNewProjectDesc('')
} catch (err) {
console.error('Failed to create project:', err)
setCreateProjectError('Failed to create project. Please try again.')
} finally {
setCreateProjectLoading(false)
}
}
return ( return (
<div className="min-h-screen bg-white"> <div className="min-h-screen bg-white">
{/* Header */} {/* Header */}
@@ -107,11 +155,73 @@ export default function DashboardPage() {
{/* Top Bar */} {/* Top Bar */}
<div className="border-y border-gray-200 py-3 px-8"> <div className="border-y border-gray-200 py-3 px-8">
<div className="flex items-center justify-between max-w-7xl mx-auto"> <div className="flex items-center justify-between max-w-7xl mx-auto">
{/* Breadcrumb */} {/* Breadcrumb with Project Dropdown */}
<div className="text-sm"> <div className="text-sm flex items-center gap-2">
<span className="text-gray-600">Projects</span> <span className="text-gray-600">Projects</span>
<span className="mx-2 text-gray-400">»</span> <span className="mx-2 text-gray-400">»</span>
<span className="font-semibold text-gray-900">PeTWIN</span>
{/* Project Dropdown */}
<div className="relative">
<button
onClick={() => setShowProjectDropdown(!showProjectDropdown)}
className="flex items-center gap-2 font-semibold text-gray-900 hover:text-teal-700 focus:outline-none"
>
{projectsLoading ? (
<span className="text-gray-500">Loading...</span>
) : currentProject ? (
<>
{currentProject.project_name}
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</>
) : (
<span className="text-gray-500 italic">No project selected</span>
)}
</button>
{/* Dropdown Menu */}
{showProjectDropdown && (
<div className="absolute top-full left-0 mt-1 w-64 bg-white border border-gray-200 rounded-lg shadow-lg z-50">
<div className="py-1">
{projects.length === 0 ? (
<div className="px-4 py-2 text-sm text-gray-500">
No projects available
</div>
) : (
projects.map((project) => (
<button
key={project.id}
onClick={() => handleProjectSelect(project)}
className={`w-full text-left px-4 py-2 text-sm hover:bg-gray-100 ${
currentProject?.id === project.id
? 'bg-teal-50 text-teal-700 font-medium'
: 'text-gray-700'
}`}
>
<div className="font-medium">{project.project_name}</div>
{project.project_desc && (
<div className="text-xs text-gray-500 truncate">
{project.project_desc}
</div>
)}
</button>
))
)}
<hr className="my-1 border-gray-200" />
<button
onClick={() => {
setShowProjectDropdown(false)
setShowCreateProjectModal(true)
}}
className="w-full text-left px-4 py-2 text-sm text-teal-600 hover:bg-gray-100 font-medium"
>
+ Create New Project
</button>
</div>
</div>
)}
</div>
</div> </div>
{/* Language Toggle */} {/* Language Toggle */}
@@ -151,6 +261,30 @@ export default function DashboardPage() {
{/* Main Content */} {/* Main Content */}
<div className="max-w-7xl mx-auto px-8 py-8"> <div className="max-w-7xl mx-auto px-8 py-8">
{/* No Project Selected Warning */}
{!projectsLoading && !currentProject && (
<div className="mb-8 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<div className="flex items-center gap-3">
<svg className="w-6 h-6 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div>
<h3 className="font-semibold text-yellow-800">No Project Selected</h3>
<p className="text-sm text-yellow-700">
Please select a project from the dropdown above or{' '}
<button
onClick={() => setShowCreateProjectModal(true)}
className="underline font-medium hover:text-yellow-900"
>
create a new project
</button>
{' '}to get started.
</p>
</div>
</div>
</div>
)}
<div className="flex gap-12"> <div className="flex gap-12">
{/* Left Sidebar */} {/* Left Sidebar */}
<div className="w-64 flex-shrink-0"> <div className="w-64 flex-shrink-0">
@@ -262,6 +396,104 @@ export default function DashboardPage() {
</div> </div>
</div> </div>
</div> </div>
{/* Click outside to close dropdown */}
{showProjectDropdown && (
<div
className="fixed inset-0 z-40"
onClick={() => setShowProjectDropdown(false)}
/>
)}
{/* Create Project Modal */}
{showCreateProjectModal && (
<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-md 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">Create New Project</h2>
<button
onClick={() => {
setShowCreateProjectModal(false)
setCreateProjectError(null)
setNewProjectName('')
setNewProjectDesc('')
}}
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={handleCreateProject}>
<div className="px-6 py-4 space-y-4">
{/* Error message */}
{createProjectError && (
<div className="p-3 bg-red-100 border border-red-400 text-red-700 rounded text-sm">
{createProjectError}
</div>
)}
{/* Project Name */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Project Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={newProjectName}
onChange={(e) => setNewProjectName(e.target.value)}
placeholder="Enter project 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>
{/* Project Description */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<textarea
value={newProjectDesc}
onChange={(e) => setNewProjectDesc(e.target.value)}
placeholder="Enter project 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>
</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={() => {
setShowCreateProjectModal(false)
setCreateProjectError(null)
setNewProjectName('')
setNewProjectDesc('')
}}
className="px-4 py-2 border border-gray-400 rounded text-sm font-medium text-gray-700 hover:bg-gray-100"
disabled={createProjectLoading}
>
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={createProjectLoading}
>
{createProjectLoading ? 'Creating...' : 'Create Project'}
</button>
</div>
</form>
</div>
</div>
)}
</div> </div>
) )
} }

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useAuth } from '@/hooks' import { useAuth, useProject } from '@/hooks'
import { useParams, Link } from 'react-router-dom' import { useParams, Link } from 'react-router-dom'
import { requirementService } from '@/services' import { requirementService } from '@/services'
import type { Requirement } from '@/services/requirementService' import type { Requirement } from '@/services/requirementService'
@@ -9,6 +9,7 @@ type TabType = 'description' | 'sub-requirements' | 'co-requirements' | 'accepta
export default function RequirementDetailPage() { export default function RequirementDetailPage() {
const { user, logout } = useAuth() const { user, logout } = useAuth()
const { currentProject } = useProject()
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const [activeTab, setActiveTab] = useState<TabType>('description') const [activeTab, setActiveTab] = useState<TabType>('description')
const [requirement, setRequirement] = useState<Requirement | null>(null) const [requirement, setRequirement] = useState<Requirement | null>(null)
@@ -220,7 +221,7 @@ export default function RequirementDetailPage() {
<div className="text-sm"> <div className="text-sm">
<Link to="/dashboard" className="text-gray-600 hover:underline">Projects</Link> <Link to="/dashboard" className="text-gray-600 hover:underline">Projects</Link>
<span className="mx-2 text-gray-400">»</span> <span className="mx-2 text-gray-400">»</span>
<Link to="/dashboard" className="text-gray-600 hover:underline">PeTWIN</Link> <Link to="/dashboard" className="text-gray-600 hover:underline">{currentProject?.project_name || 'Project'}</Link>
<span className="mx-2 text-gray-400">»</span> <span className="mx-2 text-gray-400">»</span>
<Link to="/requirements" className="text-gray-600 hover:underline">Search</Link> <Link to="/requirements" className="text-gray-600 hover:underline">Search</Link>
<span className="mx-2 text-gray-400">»</span> <span className="mx-2 text-gray-400">»</span>

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { useAuth } from '@/hooks' import { useAuth, useProject } from '@/hooks'
import { useSearchParams, Link, useNavigate } from 'react-router-dom' import { useSearchParams, Link, useNavigate } from 'react-router-dom'
import { groupService, tagService, requirementService, priorityService } from '@/services' import { groupService, tagService, requirementService, priorityService } from '@/services'
import type { Group } from '@/services/groupService' import type { Group } from '@/services/groupService'
@@ -24,6 +24,7 @@ function lightenColor(hex: string, percent: number): string {
export default function RequirementsPage() { export default function RequirementsPage() {
const { user, logout } = useAuth() const { user, logout } = useAuth()
const { currentProject, isLoading: projectLoading } = useProject()
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
const navigate = useNavigate() const navigate = useNavigate()
@@ -52,24 +53,35 @@ export default function RequirementsPage() {
const [newReqPriorityId, setNewReqPriorityId] = useState<number | ''>('') const [newReqPriorityId, setNewReqPriorityId] = useState<number | ''>('')
const [newReqGroupIds, setNewReqGroupIds] = useState<number[]>([]) const [newReqGroupIds, setNewReqGroupIds] = useState<number[]>([])
// Fetch data on mount // Fetch data when project changes
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
// Don't fetch if no project is selected
if (!currentProject) {
setRequirements([])
setLoading(false)
return
}
try { try {
setLoading(true) setLoading(true)
setError(null) setError(null)
// Fetch groups, tags, priorities, and requirements in parallel // Fetch groups, tags, and priorities in parallel
const [groupsData, tagsData, prioritiesData, requirementsData] = await Promise.all([ const [groupsData, tagsData, prioritiesData] = await Promise.all([
groupService.getGroups(), groupService.getGroups(),
tagService.getTags(), tagService.getTags(),
priorityService.getPriorities(), priorityService.getPriorities(),
requirementService.getRequirements(),
]) ])
setGroups(groupsData) setGroups(groupsData)
setTags(tagsData) setTags(tagsData)
setPriorities(prioritiesData) setPriorities(prioritiesData)
// Fetch requirements for the current project
const requirementsData = await requirementService.getRequirements({
project_id: currentProject.id,
})
setRequirements(requirementsData) setRequirements(requirementsData)
} catch (err) { } catch (err) {
console.error('Failed to fetch data:', err) console.error('Failed to fetch data:', err)
@@ -79,8 +91,10 @@ export default function RequirementsPage() {
} }
} }
if (!projectLoading) {
fetchData() fetchData()
}, []) }
}, [currentProject, projectLoading])
// Initialize filters from URL params // Initialize filters from URL params
useEffect(() => { useEffect(() => {
@@ -204,12 +218,17 @@ export default function RequirementsPage() {
setCreateError('Please select a tag') setCreateError('Please select a tag')
return return
} }
if (!currentProject) {
setCreateError('No project selected')
return
}
try { try {
setCreateLoading(true) setCreateLoading(true)
setCreateError(null) setCreateError(null)
const data: RequirementCreateRequest = { const data: RequirementCreateRequest = {
project_id: currentProject.id,
tag_id: newReqTagId as number, tag_id: newReqTagId as number,
req_name: newReqName.trim(), req_name: newReqName.trim(),
req_desc: newReqDesc.trim() || undefined, req_desc: newReqDesc.trim() || undefined,
@@ -232,7 +251,7 @@ export default function RequirementsPage() {
} }
} }
if (loading) { if (loading || projectLoading) {
return ( return (
<div className="min-h-screen bg-white flex items-center justify-center"> <div className="min-h-screen bg-white flex items-center justify-center">
<div className="text-center"> <div className="text-center">
@@ -243,6 +262,26 @@ export default function RequirementsPage() {
) )
} }
if (!currentProject) {
return (
<div className="min-h-screen bg-white flex items-center justify-center">
<div className="text-center">
<svg className="w-16 h-16 text-gray-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
<h2 className="text-xl font-semibold text-gray-700 mb-2">No Project Selected</h2>
<p className="text-gray-500 mb-4">Please select a project from the dashboard to view requirements.</p>
<button
onClick={() => navigate('/dashboard')}
className="px-4 py-2 bg-teal-600 text-white rounded hover:bg-teal-700"
>
Go to Dashboard
</button>
</div>
</div>
)
}
if (error) { if (error) {
return ( return (
<div className="min-h-screen bg-white flex items-center justify-center"> <div className="min-h-screen bg-white flex items-center justify-center">
@@ -275,7 +314,7 @@ export default function RequirementsPage() {
<div className="text-sm"> <div className="text-sm">
<Link to="/dashboard" className="text-gray-600 hover:underline">Projects</Link> <Link to="/dashboard" className="text-gray-600 hover:underline">Projects</Link>
<span className="mx-2 text-gray-400">»</span> <span className="mx-2 text-gray-400">»</span>
<Link to="/dashboard" className="text-gray-600 hover:underline">PeTWIN</Link> <Link to="/dashboard" className="text-gray-600 hover:underline">{currentProject.project_name}</Link>
<span className="mx-2 text-gray-400">»</span> <span className="mx-2 text-gray-400">»</span>
<span className="font-semibold text-gray-900">Search Requirements</span> <span className="font-semibold text-gray-900">Search Requirements</span>
</div> </div>

View File

@@ -7,3 +7,5 @@ export { priorityService } from './priorityService'
export type { Priority } from './priorityService' export type { Priority } from './priorityService'
export { requirementService } from './requirementService' export { requirementService } from './requirementService'
export type { Requirement, RequirementCreateRequest, RequirementUpdateRequest } from './requirementService' export type { Requirement, RequirementCreateRequest, RequirementUpdateRequest } from './requirementService'
export { projectService } from './projectService'
export type { Project, ProjectCreateRequest, ProjectUpdateRequest } from './projectService'

View File

@@ -0,0 +1,191 @@
const API_BASE_URL = '/api'
export interface Project {
id: number
project_name: string
project_desc: string | null
created_at: string | null
}
export interface ProjectCreateRequest {
project_name: string
project_desc?: string
}
export interface ProjectUpdateRequest {
project_name?: string
project_desc?: string
}
class ProjectService {
/**
* Get all projects the current user is a member of.
*/
async getMyProjects(): Promise<Project[]> {
try {
const response = await fetch(`${API_BASE_URL}/projects`, {
method: 'GET',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const projects: Project[] = await response.json()
return projects
} catch (error) {
console.error('Failed to fetch projects:', error)
throw error
}
}
/**
* Get a specific project by ID.
*/
async getProject(projectId: number): Promise<Project> {
try {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}`, {
method: 'GET',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const project: Project = await response.json()
return project
} catch (error) {
console.error('Failed to fetch project:', error)
throw error
}
}
/**
* Create a new project.
*/
async createProject(data: ProjectCreateRequest): Promise<Project> {
try {
const response = await fetch(`${API_BASE_URL}/projects`, {
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 project: Project = await response.json()
return project
} catch (error) {
console.error('Failed to create project:', error)
throw error
}
}
/**
* Update an existing project.
*/
async updateProject(projectId: number, data: ProjectUpdateRequest): Promise<Project> {
try {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}`, {
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 project: Project = await response.json()
return project
} catch (error) {
console.error('Failed to update project:', error)
throw error
}
}
/**
* Delete a project.
*/
async deleteProject(projectId: number): Promise<void> {
try {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}`, {
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 project:', error)
throw error
}
}
/**
* Add a member to a project.
*/
async addMember(projectId: number, userId: number): Promise<void> {
try {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/members`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ user_id: userId }),
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
} catch (error) {
console.error('Failed to add member:', error)
throw error
}
}
/**
* Remove a member from a project.
*/
async removeMember(projectId: number, userId: number): Promise<void> {
try {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/members/${userId}`, {
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 remove member:', error)
throw error
}
}
}
export const projectService = new ProjectService()

View File

@@ -6,6 +6,7 @@ const API_BASE_URL = '/api'
export interface Requirement { export interface Requirement {
id: number id: number
project_id: number
req_name: string req_name: string
req_desc: string | null req_desc: string | null
version: number version: number
@@ -18,6 +19,7 @@ export interface Requirement {
} }
export interface RequirementCreateRequest { export interface RequirementCreateRequest {
project_id: number
tag_id: number tag_id: number
req_name: string req_name: string
req_desc?: string req_desc?: string
@@ -36,11 +38,43 @@ export interface RequirementUpdateRequest {
class RequirementService { class RequirementService {
/** /**
* Get all requirements from the API. * Get all requirements from the API.
* Optionally filter by group_id or tag_id. * Requires project_id, optionally filter by group_id or tag_id.
*/ */
async getRequirements(params?: { group_id?: number; tag_id?: number }): Promise<Requirement[]> { async getRequirements(params: { project_id: number; group_id?: number; tag_id?: number }): Promise<Requirement[]> {
try { try {
let url = `${API_BASE_URL}/requirements` const queryParams = new URLSearchParams()
queryParams.append('project_id', params.project_id.toString())
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 url = `${API_BASE_URL}/requirements?${queryParams.toString()}`
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 requirements for a specific project.
*/
async getProjectRequirements(projectId: number, params?: { group_id?: number; tag_id?: number }): Promise<Requirement[]> {
try {
let url = `${API_BASE_URL}/projects/${projectId}/requirements`
if (params) { if (params) {
const queryParams = new URLSearchParams() const queryParams = new URLSearchParams()
@@ -68,7 +102,7 @@ class RequirementService {
const requirements: Requirement[] = await response.json() const requirements: Requirement[] = await response.json()
return requirements return requirements
} catch (error) { } catch (error) {
console.error('Failed to fetch requirements:', error) console.error('Failed to fetch project requirements:', error)
throw error throw error
} }
} }