phase 3.2 & 4.1

This commit is contained in:
Danilo Reyes
2025-11-02 00:36:32 -06:00
parent cac1db0ed7
commit d40139822d
21 changed files with 2230 additions and 123 deletions

View File

@@ -0,0 +1 @@
"""Boards module for board management."""

View File

@@ -0,0 +1,29 @@
"""Permission validation middleware for boards."""
from uuid import UUID
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from app.boards.repository import BoardRepository
def validate_board_ownership(board_id: UUID, user_id: UUID, db: Session) -> None:
"""
Validate that the user owns the board.
Args:
board_id: Board UUID
user_id: User UUID
db: Database session
Raises:
HTTPException: 404 if board not found or not owned by user
"""
repo = BoardRepository(db)
if not repo.board_exists(board_id, user_id):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Board {board_id} not found or access denied",
)

View File

@@ -0,0 +1,197 @@
"""Board repository for database operations."""
from collections.abc import Sequence
from uuid import UUID
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.database.models.board import Board
from app.database.models.board_image import BoardImage
class BoardRepository:
"""Repository for Board database operations."""
def __init__(self, db: Session):
"""
Initialize repository with database session.
Args:
db: SQLAlchemy database session
"""
self.db = db
def create_board(
self,
user_id: UUID,
title: str,
description: str | None = None,
viewport_state: dict | None = None,
) -> Board:
"""
Create a new board.
Args:
user_id: Owner's user ID
title: Board title
description: Optional board description
viewport_state: Optional custom viewport state
Returns:
Created Board instance
"""
if viewport_state is None:
viewport_state = {"x": 0, "y": 0, "zoom": 1.0, "rotation": 0}
board = Board(
user_id=user_id,
title=title,
description=description,
viewport_state=viewport_state,
)
self.db.add(board)
self.db.commit()
self.db.refresh(board)
return board
def get_board_by_id(self, board_id: UUID, user_id: UUID) -> Board | None:
"""
Get board by ID for a specific user.
Args:
board_id: Board UUID
user_id: User UUID (for ownership check)
Returns:
Board if found and owned by user, None otherwise
"""
stmt = select(Board).where(
Board.id == board_id,
Board.user_id == user_id,
Board.is_deleted == False, # noqa: E712
)
return self.db.execute(stmt).scalar_one_or_none()
def get_user_boards(
self,
user_id: UUID,
limit: int = 50,
offset: int = 0,
) -> tuple[Sequence[Board], int]:
"""
Get all boards for a user with pagination.
Args:
user_id: User UUID
limit: Maximum number of boards to return
offset: Number of boards to skip
Returns:
Tuple of (list of boards, total count)
"""
# Query for boards with image count
stmt = (
select(Board, func.count(BoardImage.id).label("image_count"))
.outerjoin(BoardImage, Board.id == BoardImage.board_id)
.where(Board.user_id == user_id, Board.is_deleted == False) # noqa: E712
.group_by(Board.id)
.order_by(Board.updated_at.desc())
.limit(limit)
.offset(offset)
)
results = self.db.execute(stmt).all()
boards = [row[0] for row in results]
# Get total count
count_stmt = select(func.count(Board.id)).where(Board.user_id == user_id, Board.is_deleted == False) # noqa: E712
total = self.db.execute(count_stmt).scalar_one()
return boards, total
def update_board(
self,
board_id: UUID,
user_id: UUID,
title: str | None = None,
description: str | None = None,
viewport_state: dict | None = None,
) -> Board | None:
"""
Update board metadata.
Args:
board_id: Board UUID
user_id: User UUID (for ownership check)
title: New title (if provided)
description: New description (if provided)
viewport_state: New viewport state (if provided)
Returns:
Updated Board if found and owned by user, None otherwise
"""
board = self.get_board_by_id(board_id, user_id)
if not board:
return None
if title is not None:
board.title = title
if description is not None:
board.description = description
if viewport_state is not None:
board.viewport_state = viewport_state
self.db.commit()
self.db.refresh(board)
return board
def delete_board(self, board_id: UUID, user_id: UUID) -> bool:
"""
Soft delete a board.
Args:
board_id: Board UUID
user_id: User UUID (for ownership check)
Returns:
True if deleted, False if not found or not owned
"""
board = self.get_board_by_id(board_id, user_id)
if not board:
return False
board.is_deleted = True
self.db.commit()
return True
def board_exists(self, board_id: UUID, user_id: UUID) -> bool:
"""
Check if board exists and is owned by user.
Args:
board_id: Board UUID
user_id: User UUID
Returns:
True if board exists and is owned by user
"""
stmt = select(func.count(Board.id)).where(
Board.id == board_id,
Board.user_id == user_id,
Board.is_deleted == False, # noqa: E712
)
count = self.db.execute(stmt).scalar_one()
return count > 0

View File

@@ -0,0 +1,67 @@
"""Board Pydantic schemas for request/response validation."""
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, field_validator
class ViewportState(BaseModel):
"""Viewport state for canvas position and zoom."""
x: float = Field(default=0, description="Horizontal pan position")
y: float = Field(default=0, description="Vertical pan position")
zoom: float = Field(default=1.0, ge=0.1, le=5.0, description="Zoom level (0.1 to 5.0)")
rotation: float = Field(default=0, ge=0, le=360, description="Canvas rotation in degrees (0 to 360)")
class BoardCreate(BaseModel):
"""Schema for creating a new board."""
title: str = Field(..., min_length=1, max_length=255, description="Board title")
description: str | None = Field(default=None, description="Optional board description")
class BoardUpdate(BaseModel):
"""Schema for updating board metadata."""
title: str | None = Field(None, min_length=1, max_length=255, description="Board title")
description: str | None = Field(None, description="Board description")
viewport_state: ViewportState | None = Field(None, description="Viewport state")
class BoardSummary(BaseModel):
"""Summary schema for board list view."""
model_config = ConfigDict(from_attributes=True)
id: UUID
title: str
description: str | None = None
image_count: int = Field(default=0, description="Number of images on board")
thumbnail_url: str | None = Field(default=None, description="URL to board thumbnail")
created_at: datetime
updated_at: datetime
class BoardDetail(BaseModel):
"""Detailed schema for single board view with all data."""
model_config = ConfigDict(from_attributes=True)
id: UUID
user_id: UUID
title: str
description: str | None = None
viewport_state: ViewportState
created_at: datetime
updated_at: datetime
is_deleted: bool = False
@field_validator("viewport_state", mode="before")
@classmethod
def convert_viewport_state(cls, v):
"""Convert dict to ViewportState if needed."""
if isinstance(v, dict):
return ViewportState(**v)
return v