phase 13
All checks were successful
CI/CD Pipeline / VM Test - security (push) Successful in 7s
CI/CD Pipeline / Backend Linting (push) Successful in 4s
CI/CD Pipeline / VM Test - backend-integration (push) Successful in 11s
CI/CD Pipeline / VM Test - full-stack (push) Successful in 8s
CI/CD Pipeline / VM Test - performance (push) Successful in 8s
CI/CD Pipeline / Nix Flake Check (push) Successful in 38s
CI/CD Pipeline / CI Summary (push) Successful in 0s
CI/CD Pipeline / Frontend Linting (push) Successful in 17s

This commit is contained in:
Danilo Reyes
2025-11-02 14:48:03 -06:00
parent e5abcced74
commit 948fe591dc
28 changed files with 2123 additions and 54 deletions

216
backend/app/api/groups.py Normal file
View File

@@ -0,0 +1,216 @@
"""Group management API endpoints."""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.boards.repository import BoardRepository
from app.boards.schemas import GroupCreate, GroupResponse, GroupUpdate
from app.core.deps import get_current_user, get_db
from app.database.models.user import User
router = APIRouter(prefix="/boards/{board_id}/groups", tags=["groups"])
@router.post("", response_model=GroupResponse, status_code=status.HTTP_201_CREATED)
def create_group(
board_id: UUID,
group_data: GroupCreate,
current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
):
"""
Create a new group on a board.
Assigns the specified images to the group.
"""
repo = BoardRepository(db)
# Verify board ownership
board = repo.get_board_by_id(board_id, current_user.id)
if not board:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Board not found",
)
# Create group
group = repo.create_group(
board_id=board_id,
name=group_data.name,
color=group_data.color,
annotation=group_data.annotation,
image_ids=group_data.image_ids,
)
# Calculate member count
response = GroupResponse.model_validate(group)
response.member_count = len(group_data.image_ids)
return response
@router.get("", response_model=list[GroupResponse])
def list_groups(
board_id: UUID,
current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
):
"""
List all groups on a board.
Returns groups with member counts.
"""
repo = BoardRepository(db)
# Verify board ownership
board = repo.get_board_by_id(board_id, current_user.id)
if not board:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Board not found",
)
# Get groups
groups = repo.get_board_groups(board_id)
# Convert to response with member counts
from sqlalchemy import func, select
from app.database.models.board_image import BoardImage
responses = []
for group in groups:
# Count members
count_stmt = select(func.count(BoardImage.id)).where(BoardImage.group_id == group.id)
member_count = db.execute(count_stmt).scalar_one()
response = GroupResponse.model_validate(group)
response.member_count = member_count
responses.append(response)
return responses
@router.get("/{group_id}", response_model=GroupResponse)
def get_group(
board_id: UUID,
group_id: UUID,
current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
):
"""
Get group details by ID.
"""
repo = BoardRepository(db)
# Verify board ownership
board = repo.get_board_by_id(board_id, current_user.id)
if not board:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Board not found",
)
# Get group
group = repo.get_group_by_id(group_id, board_id)
if not group:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Group not found",
)
# Count members
from sqlalchemy import func, select
from app.database.models.board_image import BoardImage
count_stmt = select(func.count(BoardImage.id)).where(BoardImage.group_id == group.id)
member_count = db.execute(count_stmt).scalar_one()
response = GroupResponse.model_validate(group)
response.member_count = member_count
return response
@router.patch("/{group_id}", response_model=GroupResponse)
def update_group(
board_id: UUID,
group_id: UUID,
group_data: GroupUpdate,
current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
):
"""
Update group metadata (name, color, annotation).
"""
repo = BoardRepository(db)
# Verify board ownership
board = repo.get_board_by_id(board_id, current_user.id)
if not board:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Board not found",
)
# Update group
group = repo.update_group(
group_id=group_id,
board_id=board_id,
name=group_data.name,
color=group_data.color,
annotation=group_data.annotation,
)
if not group:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Group not found",
)
# Count members
from sqlalchemy import func, select
from app.database.models.board_image import BoardImage
count_stmt = select(func.count(BoardImage.id)).where(BoardImage.group_id == group.id)
member_count = db.execute(count_stmt).scalar_one()
response = GroupResponse.model_validate(group)
response.member_count = member_count
return response
@router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_group(
board_id: UUID,
group_id: UUID,
current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
):
"""
Delete a group (ungroups all images).
"""
repo = BoardRepository(db)
# Verify board ownership
board = repo.get_board_by_id(board_id, current_user.id)
if not board:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Board not found",
)
# Delete group
success = repo.delete_group(group_id, board_id)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Group not found",
)

View File

@@ -8,6 +8,7 @@ from sqlalchemy.orm import Session
from app.database.models.board import Board
from app.database.models.board_image import BoardImage
from app.database.models.group import Group
class BoardRepository:
@@ -195,3 +196,213 @@ class BoardRepository:
count = self.db.execute(stmt).scalar_one()
return count > 0
# Group operations
def create_group(
self,
board_id: UUID,
name: str,
color: str,
annotation: str | None,
image_ids: list[UUID],
) -> Group:
"""
Create a new group and assign images to it.
Args:
board_id: Board UUID
name: Group name
color: Hex color code
annotation: Optional annotation text
image_ids: List of board_image IDs to include
Returns:
Created Group instance
"""
group = Group(
board_id=board_id,
name=name,
color=color,
annotation=annotation,
)
self.db.add(group)
self.db.flush() # Get group ID
# Assign images to group
for image_id in image_ids:
stmt = select(BoardImage).where(BoardImage.board_id == board_id, BoardImage.image_id == image_id)
board_image = self.db.execute(stmt).scalar_one_or_none()
if board_image:
board_image.group_id = group.id
self.db.commit()
self.db.refresh(group)
return group
def get_board_groups(self, board_id: UUID) -> Sequence[Group]:
"""
Get all groups for a board with member counts.
Args:
board_id: Board UUID
Returns:
List of groups
"""
stmt = (
select(Group, func.count(BoardImage.id).label("member_count"))
.outerjoin(BoardImage, Group.id == BoardImage.group_id)
.where(Group.board_id == board_id)
.group_by(Group.id)
.order_by(Group.created_at.desc())
)
results = self.db.execute(stmt).all()
# Add member_count as attribute
groups = []
for row in results:
group = row[0]
# Note: member_count is dynamically calculated, not stored
groups.append(group)
return groups
def get_group_by_id(self, group_id: UUID, board_id: UUID) -> Group | None:
"""
Get group by ID.
Args:
group_id: Group UUID
board_id: Board UUID (for verification)
Returns:
Group if found, None otherwise
"""
stmt = select(Group).where(Group.id == group_id, Group.board_id == board_id)
return self.db.execute(stmt).scalar_one_or_none()
def update_group(
self,
group_id: UUID,
board_id: UUID,
name: str | None = None,
color: str | None = None,
annotation: str | None = None,
) -> Group | None:
"""
Update group metadata.
Args:
group_id: Group UUID
board_id: Board UUID
name: New name (if provided)
color: New color (if provided)
annotation: New annotation (if provided)
Returns:
Updated Group if found, None otherwise
"""
group = self.get_group_by_id(group_id, board_id)
if not group:
return None
if name is not None:
group.name = name
if color is not None:
group.color = color
if annotation is not None:
group.annotation = annotation
self.db.commit()
self.db.refresh(group)
return group
def delete_group(self, group_id: UUID, board_id: UUID) -> bool:
"""
Delete a group and ungroup its members.
Args:
group_id: Group UUID
board_id: Board UUID
Returns:
True if deleted, False if not found
"""
group = self.get_group_by_id(group_id, board_id)
if not group:
return False
# Ungroup all members (set group_id to None)
stmt = select(BoardImage).where(BoardImage.group_id == group_id)
members = self.db.execute(stmt).scalars().all()
for member in members:
member.group_id = None
# Delete the group
self.db.delete(group)
self.db.commit()
return True
def add_images_to_group(self, group_id: UUID, board_id: UUID, image_ids: list[UUID]) -> int:
"""
Add images to a group.
Args:
group_id: Group UUID
board_id: Board UUID
image_ids: List of image IDs to add
Returns:
Number of images added
"""
count = 0
for image_id in image_ids:
stmt = select(BoardImage).where(BoardImage.board_id == board_id, BoardImage.image_id == image_id)
board_image = self.db.execute(stmt).scalar_one_or_none()
if board_image:
board_image.group_id = group_id
count += 1
self.db.commit()
return count
def remove_images_from_group(self, group_id: UUID, image_ids: list[UUID]) -> int:
"""
Remove images from a group.
Args:
group_id: Group UUID
image_ids: List of image IDs to remove
Returns:
Number of images removed
"""
count = 0
for image_id in image_ids:
stmt = select(BoardImage).where(BoardImage.group_id == group_id, BoardImage.image_id == image_id)
board_image = self.db.execute(stmt).scalar_one_or_none()
if board_image:
board_image.group_id = None
count += 1
self.db.commit()
return count

View File

@@ -74,3 +74,35 @@ class BoardDetail(BaseModel):
if isinstance(v, dict):
return ViewportState(**v)
return v
class GroupCreate(BaseModel):
"""Schema for creating a new group."""
name: str = Field(..., min_length=1, max_length=255, description="Group name")
color: str = Field(..., pattern=r"^#[0-9A-Fa-f]{6}$", description="Hex color code (#RRGGBB)")
annotation: str | None = Field(None, max_length=10000, description="Optional text annotation")
image_ids: list[UUID] = Field(..., min_items=1, description="List of image IDs to include in group")
class GroupUpdate(BaseModel):
"""Schema for updating group metadata."""
name: str | None = Field(None, min_length=1, max_length=255, description="Group name")
color: str | None = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$", description="Hex color code")
annotation: str | None = Field(None, max_length=10000, description="Text annotation")
class GroupResponse(BaseModel):
"""Response schema for group with member count."""
model_config = ConfigDict(from_attributes=True)
id: UUID
board_id: UUID
name: str
color: str
annotation: str | None = None
member_count: int = Field(default=0, description="Number of images in group")
created_at: datetime
updated_at: datetime

View File

@@ -5,7 +5,7 @@ import logging
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from app.api import auth, boards, images
from app.api import auth, boards, groups, images
from app.core.config import settings
from app.core.errors import WebRefException
from app.core.logging import setup_logging
@@ -84,6 +84,7 @@ async def root():
# API routers
app.include_router(auth.router, prefix=f"{settings.API_V1_PREFIX}")
app.include_router(boards.router, prefix=f"{settings.API_V1_PREFIX}")
app.include_router(groups.router, prefix=f"{settings.API_V1_PREFIX}")
app.include_router(images.router, prefix=f"{settings.API_V1_PREFIX}")