Added project separation logic
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
235
backend/src/repositories/project_repository.py
Normal file
235
backend/src/repositories/project_repository.py
Normal 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())
|
||||||
@@ -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,
|
||||||
|
|||||||
120
frontend/src/context/ProjectContext.tsx
Normal file
120
frontend/src/context/ProjectContext.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1 +1,3 @@
|
|||||||
export { AuthContext, AuthProvider } from './AuthContext'
|
export { AuthContext, AuthProvider } from './AuthContext'
|
||||||
|
export { ProjectContext, ProjectProvider } from './ProjectContext'
|
||||||
|
export type { ProjectContextType } from './ProjectContext'
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export { useAuth } from './useAuth'
|
export { useAuth } from './useAuth'
|
||||||
|
export { useProject } from './useProject'
|
||||||
|
|||||||
10
frontend/src/hooks/useProject.ts
Normal file
10
frontend/src/hooks/useProject.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
<App />
|
<ProjectProvider>
|
||||||
|
<App />
|
||||||
|
</ProjectProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchData()
|
if (!projectLoading) {
|
||||||
}, [])
|
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>
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
191
frontend/src/services/projectService.ts
Normal file
191
frontend/src/services/projectService.ts
Normal 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()
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user