Added project separation logic
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
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())
|
||||
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user