From 07005788ed104357e3c23dd1da8a3acdb3e7153c Mon Sep 17 00:00:00 2001 From: gulimabr Date: Mon, 1 Dec 2025 11:01:13 -0300 Subject: [PATCH] Added project separation logic --- backend/src/db_models.py | 57 +++ backend/src/main.py | 394 ++++++++++++++---- backend/src/models.py | 42 ++ backend/src/repositories/__init__.py | 2 + .../src/repositories/project_repository.py | 235 +++++++++++ .../repositories/requirement_repository.py | 46 +- frontend/src/context/ProjectContext.tsx | 120 ++++++ frontend/src/context/index.ts | 2 + frontend/src/hooks/index.ts | 1 + frontend/src/hooks/useProject.ts | 10 + frontend/src/main.tsx | 6 +- frontend/src/pages/DashboardPage.tsx | 240 ++++++++++- frontend/src/pages/RequirementDetailPage.tsx | 5 +- frontend/src/pages/RequirementsPage.tsx | 57 ++- frontend/src/services/index.ts | 2 + frontend/src/services/projectService.ts | 191 +++++++++ frontend/src/services/requirementService.ts | 42 +- 17 files changed, 1337 insertions(+), 115 deletions(-) create mode 100644 backend/src/repositories/project_repository.py create mode 100644 frontend/src/context/ProjectContext.tsx create mode 100644 frontend/src/hooks/useProject.ts create mode 100644 frontend/src/services/projectService.ts diff --git a/backend/src/db_models.py b/backend/src/db_models.py index 506d518..42ebc5e 100644 --- a/backend/src/db_models.py +++ b/backend/src/db_models.py @@ -52,6 +52,11 @@ class User(Base): foreign_keys="Requirement.last_editor_id" ) validations: Mapped[List["Validation"]] = relationship("Validation", back_populates="user") + projects: Mapped[List["Project"]] = relationship( + "Project", + secondary="project_members", + back_populates="members" + ) class Tag(Base): @@ -94,6 +99,54 @@ class Priority(Base): 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): """Validation status options.""" __tablename__ = "validation_statuses" @@ -110,6 +163,7 @@ class Requirement(Base): __tablename__ = "requirements" 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) tag_id: Mapped[int] = mapped_column(Integer, ForeignKey("tags.id"), nullable=False) last_editor_id: Mapped[Optional[int]] = mapped_column( @@ -138,6 +192,7 @@ class Requirement(Base): ) # Relationships + project: Mapped["Project"] = relationship("Project", back_populates="requirements") user: Mapped["User"] = relationship( "User", back_populates="requirements", @@ -159,6 +214,7 @@ class Requirement(Base): # Indexes __table_args__ = ( + Index("idx_req_project", "project_id"), Index("idx_req_tag", "tag_id"), Index("idx_req_priority", "priority_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) 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_desc: Mapped[Optional[str]] = mapped_column(Text, nullable=True) priority_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) diff --git a/backend/src/main.py b/backend/src/main.py index cacff0b..d2db5e7 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -8,12 +8,13 @@ from sqlalchemy.ext.asyncio import AsyncSession from src.models import ( TokenResponse, UserInfo, GroupResponse, TagResponse, RequirementResponse, PriorityResponse, - RequirementCreateRequest, RequirementUpdateRequest + RequirementCreateRequest, RequirementUpdateRequest, + ProjectResponse, ProjectCreateRequest, ProjectUpdateRequest, ProjectMemberRequest ) 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, TagRepository, RequirementRepository, PriorityRepository +from src.repositories import RoleRepository, GroupRepository, TagRepository, RequirementRepository, PriorityRepository, ProjectRepository import 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] +# =========================================== +# 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 # =========================================== @@ -252,6 +480,7 @@ def _build_requirement_response(req) -> RequirementResponse: return RequirementResponse( id=req.id, + project_id=req.project_id, req_name=req.req_name, req_desc=req.req_desc, version=req.version, @@ -264,44 +493,79 @@ def _build_requirement_response(req) -> RequirementResponse: ) -@app.get("/api/requirements", response_model=List[RequirementResponse]) -async def get_requirements( +@app.get("/api/projects/{project_id}/requirements", response_model=List[RequirementResponse]) +async def get_project_requirements( + project_id: int, request: Request, group_id: Optional[int] = None, tag_id: Optional[int] = None, 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: + project_id: The project ID group_id: Optional group ID to filter by tag_id: Optional tag ID to filter by Returns: - List of requirements owned by the authenticated user. + List of requirements in the project. """ - # 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" - ) + user = await _get_current_user_db(request, db) + 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, user_id=user.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, user_id=user.id) + requirements = await req_repo.get_by_tag_id(tag_id, project_id=project_id) 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] @@ -314,25 +578,15 @@ async def get_requirement( ): """ Get a specific requirement by ID. + User must be a member of the requirement's project. Args: requirement_id: The requirement ID 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_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" - ) + user = await _get_current_user_db(request, db) req_repo = RequirementRepository(db) 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" ) - # Verify user owns this requirement - if requirement.user_id != user.id: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="You do not have permission to access this requirement" - ) + # Verify user is a member of the requirement's project + await _verify_project_membership(requirement.project_id, user.id, db) return _build_requirement_response(requirement) @@ -360,28 +610,22 @@ async def create_requirement( ): """ Create a new requirement. + User must be a member of the project. Args: - req_data: The requirement data + req_data: The requirement data (must include project_id) Returns: The created requirement. """ - # Get the current user from cookie - user_info = AuthController.get_current_user(request) + user = await _get_current_user_db(request, 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" - ) + # Verify user is a member of the project + await _verify_project_membership(req_data.project_id, user.id, db) req_repo = RequirementRepository(db) requirement = await req_repo.create( + project_id=req_data.project_id, user_id=user.id, tag_id=req_data.tag_id, req_name=req_data.req_name, @@ -403,6 +647,7 @@ async def update_requirement( ): """ Update an existing requirement. + User must be a member of the requirement's project. Args: requirement_id: The requirement ID to update @@ -411,22 +656,11 @@ async def update_requirement( 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" - ) + user = await _get_current_user_db(request, 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) if not existing_req: raise HTTPException( @@ -434,11 +668,8 @@ async def update_requirement( detail=f"Requirement with id {requirement_id} not found" ) - if existing_req.user_id != user.id: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="You do not have permission to update this requirement" - ) + # Verify user is a member of the requirement's project + await _verify_project_membership(existing_req.project_id, user.id, db) requirement = await req_repo.update( requirement_id=requirement_id, @@ -462,26 +693,16 @@ async def delete_requirement( ): """ Delete a requirement. + User must be a member of the requirement's project. Args: requirement_id: The requirement ID to delete """ - # 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" - ) + user = await _get_current_user_db(request, 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) if not existing_req: raise HTTPException( @@ -489,11 +710,8 @@ async def delete_requirement( detail=f"Requirement with id {requirement_id} not found" ) - if existing_req.user_id != user.id: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="You do not have permission to delete this requirement" - ) + # Verify user is a member of the requirement's project + await _verify_project_membership(existing_req.project_id, user.id, db) await req_repo.delete(requirement_id) await db.commit() diff --git a/backend/src/models.py b/backend/src/models.py index dafd87c..675ff30 100644 --- a/backend/src/models.py +++ b/backend/src/models.py @@ -22,6 +22,46 @@ class UserInfo(BaseModel): 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 class GroupResponse(BaseModel): """Response schema for a single group.""" @@ -82,6 +122,7 @@ class ValidationResponse(BaseModel): class RequirementResponse(BaseModel): """Response schema for a single requirement.""" id: int + project_id: int req_name: str req_desc: Optional[str] = None version: int @@ -103,6 +144,7 @@ class RequirementListResponse(BaseModel): class RequirementCreateRequest(BaseModel): """Request schema for creating a requirement.""" + project_id: int tag_id: int req_name: str req_desc: Optional[str] = None diff --git a/backend/src/repositories/__init__.py b/backend/src/repositories/__init__.py index 32b73c1..07314ee 100644 --- a/backend/src/repositories/__init__.py +++ b/backend/src/repositories/__init__.py @@ -6,6 +6,7 @@ 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 +from src.repositories.project_repository import ProjectRepository __all__ = [ "UserRepository", @@ -14,4 +15,5 @@ __all__ = [ "TagRepository", "RequirementRepository", "PriorityRepository", + "ProjectRepository", ] diff --git a/backend/src/repositories/project_repository.py b/backend/src/repositories/project_repository.py new file mode 100644 index 0000000..e8eb344 --- /dev/null +++ b/backend/src/repositories/project_repository.py @@ -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()) diff --git a/backend/src/repositories/requirement_repository.py b/backend/src/repositories/requirement_repository.py index cd31ba1..1869e40 100644 --- a/backend/src/repositories/requirement_repository.py +++ b/backend/src/repositories/requirement_repository.py @@ -36,15 +36,38 @@ class RequirementRepository: ) 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]: """ - Get all requirements for a specific user. + Get all requirements created by a specific user. Args: user_id: The user's database ID Returns: - List of requirements owned by the user + List of requirements created by the user """ result = await self.session.execute( select(Requirement) @@ -83,13 +106,14 @@ class RequirementRepository: ) 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. Args: 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: List of requirements in the group @@ -106,6 +130,9 @@ class RequirementRepository: .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: query = query.where(Requirement.user_id == user_id) @@ -113,13 +140,14 @@ class RequirementRepository: result = await self.session.execute(query) 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. Args: 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: List of requirements with the tag @@ -135,6 +163,9 @@ class RequirementRepository: .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: query = query.where(Requirement.user_id == user_id) @@ -144,6 +175,7 @@ class RequirementRepository: async def create( self, + project_id: int, user_id: int, tag_id: int, req_name: str, @@ -155,6 +187,7 @@ class RequirementRepository: Create a new requirement. Args: + project_id: The project ID this requirement belongs to user_id: The creating user's ID tag_id: The tag ID req_name: The requirement name @@ -166,6 +199,7 @@ class RequirementRepository: The created Requirement """ requirement = Requirement( + project_id=project_id, user_id=user_id, tag_id=tag_id, req_name=req_name, diff --git a/frontend/src/context/ProjectContext.tsx b/frontend/src/context/ProjectContext.tsx new file mode 100644 index 0000000..620a869 --- /dev/null +++ b/frontend/src/context/ProjectContext.tsx @@ -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 + createProject: (name: string, description?: string) => Promise +} + +export const ProjectContext = createContext(undefined) + +const STORAGE_KEY = 'selectedProjectId' + +interface ProjectProviderProps { + children: ReactNode +} + +export function ProjectProvider({ children }: ProjectProviderProps) { + const { isAuthenticated, isLoading: authLoading } = useAuth() + const [projects, setProjects] = useState([]) + const [currentProject, setCurrentProjectState] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(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 => { + 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 ( + {children} + ) +} diff --git a/frontend/src/context/index.ts b/frontend/src/context/index.ts index 0df7eeb..a25e10e 100644 --- a/frontend/src/context/index.ts +++ b/frontend/src/context/index.ts @@ -1 +1,3 @@ export { AuthContext, AuthProvider } from './AuthContext' +export { ProjectContext, ProjectProvider } from './ProjectContext' +export type { ProjectContextType } from './ProjectContext' diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index aefa664..fde18f8 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -1 +1,2 @@ export { useAuth } from './useAuth' +export { useProject } from './useProject' diff --git a/frontend/src/hooks/useProject.ts b/frontend/src/hooks/useProject.ts new file mode 100644 index 0000000..e7519df --- /dev/null +++ b/frontend/src/hooks/useProject.ts @@ -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 +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 715b6cc..2349a4a 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,14 +2,16 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { BrowserRouter } from 'react-router-dom' import App from './App' -import { AuthProvider } from '@/context/AuthContext' +import { AuthProvider, ProjectProvider } from '@/context' import './index.css' createRoot(document.getElementById('root')!).render( - + + + , diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 087716b..61dd736 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import { useAuth } from '@/hooks' +import { useAuth, useProject } from '@/hooks' import { useNavigate } from 'react-router-dom' import { groupService, Group } from '@/services' @@ -63,11 +63,20 @@ function darkenColor(hex: string, percent: number): string { export default function DashboardPage() { const { user, logout } = useAuth() + const { projects, currentProject, setCurrentProject, isLoading: projectsLoading, createProject } = useProject() const navigate = useNavigate() const [groups, setGroups] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [hoveredGroup, setHoveredGroup] = useState(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(null) useEffect(() => { const fetchGroups = async () => { @@ -95,6 +104,45 @@ export default function DashboardPage() { 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 (
{/* Header */} @@ -107,11 +155,73 @@ export default function DashboardPage() { {/* Top Bar */}
- {/* Breadcrumb */} -
+ {/* Breadcrumb with Project Dropdown */} +
Projects » - PeTWIN + + {/* Project Dropdown */} +
+ + + {/* Dropdown Menu */} + {showProjectDropdown && ( +
+
+ {projects.length === 0 ? ( +
+ No projects available +
+ ) : ( + projects.map((project) => ( + + )) + )} +
+ +
+
+ )} +
{/* Language Toggle */} @@ -151,6 +261,30 @@ export default function DashboardPage() { {/* Main Content */}
+ {/* No Project Selected Warning */} + {!projectsLoading && !currentProject && ( +
+
+ + + +
+

No Project Selected

+

+ Please select a project from the dropdown above or{' '} + + {' '}to get started. +

+
+
+
+ )} +
{/* Left Sidebar */}
@@ -262,6 +396,104 @@ export default function DashboardPage() {
+ + {/* Click outside to close dropdown */} + {showProjectDropdown && ( +
setShowProjectDropdown(false)} + /> + )} + + {/* Create Project Modal */} + {showCreateProjectModal && ( +
+
+ {/* Modal Header */} +
+

Create New Project

+ +
+ + {/* Modal Body */} +
+
+ {/* Error message */} + {createProjectError && ( +
+ {createProjectError} +
+ )} + + {/* Project Name */} +
+ + 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 + /> +
+ + {/* Project Description */} +
+ +