This commit is contained in:
Danilo Reyes
2025-11-02 01:47:25 -06:00
parent 48020b6f42
commit 010df31455
45 changed files with 8045 additions and 720 deletions

344
backend/app/api/images.py Normal file
View File

@@ -0,0 +1,344 @@
"""Image upload and management endpoints."""
from uuid import UUID
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.jwt import get_current_user
from app.core.deps import get_db
from app.database.models.board import Board
from app.database.models.user import User
from app.images.processing import generate_thumbnails
from app.images.repository import ImageRepository
from app.images.schemas import (
BoardImageCreate,
BoardImageResponse,
ImageListResponse,
ImageResponse,
ImageUploadResponse,
)
from app.images.upload import calculate_checksum, upload_image_to_storage
from app.images.validation import sanitize_filename, validate_image_file
from app.images.zip_handler import extract_images_from_zip
router = APIRouter(prefix="/images", tags=["images"])
@router.post("/upload", response_model=ImageUploadResponse, status_code=status.HTTP_201_CREATED)
async def upload_image(
file: UploadFile = File(...),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
Upload a single image.
- Validates file type and size
- Uploads to MinIO storage
- Generates thumbnails
- Creates database record
Returns image metadata including ID for adding to boards.
"""
# Validate file
contents = await validate_image_file(file)
# Sanitize filename
filename = sanitize_filename(file.filename or "image.jpg")
# Upload to storage and get dimensions
from uuid import uuid4
image_id = uuid4()
storage_path, width, height, mime_type = await upload_image_to_storage(
current_user.id, image_id, filename, contents
)
# Generate thumbnails
thumbnail_paths = generate_thumbnails(image_id, storage_path, contents)
# Calculate checksum
checksum = calculate_checksum(contents)
# Create metadata
metadata = {"format": mime_type.split("/")[1], "checksum": checksum, "thumbnails": thumbnail_paths}
# Create database record
repo = ImageRepository(db)
image = await repo.create_image(
user_id=current_user.id,
filename=filename,
storage_path=storage_path,
file_size=len(contents),
mime_type=mime_type,
width=width,
height=height,
metadata=metadata,
)
return image
@router.post("/upload-zip", response_model=list[ImageUploadResponse])
async def upload_zip(
file: UploadFile = File(...),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
Upload multiple images from a ZIP file.
- Extracts all valid images from ZIP
- Processes each image
- Returns list of uploaded images
Maximum ZIP size: 200MB
"""
uploaded_images = []
repo = ImageRepository(db)
async for filename, contents in extract_images_from_zip(file):
try:
# Sanitize filename
clean_filename = sanitize_filename(filename)
# Upload to storage
from uuid import uuid4
image_id = uuid4()
storage_path, width, height, mime_type = await upload_image_to_storage(
current_user.id, image_id, clean_filename, contents
)
# Generate thumbnails
thumbnail_paths = generate_thumbnails(image_id, storage_path, contents)
# Calculate checksum
checksum = calculate_checksum(contents)
# Create metadata
metadata = {
"format": mime_type.split("/")[1],
"checksum": checksum,
"thumbnails": thumbnail_paths,
}
# Create database record
image = await repo.create_image(
user_id=current_user.id,
filename=clean_filename,
storage_path=storage_path,
file_size=len(contents),
mime_type=mime_type,
width=width,
height=height,
metadata=metadata,
)
uploaded_images.append(image)
except Exception as e:
# Log error but continue with other images
print(f"Error processing {filename}: {e}")
continue
if not uploaded_images:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="No images could be processed from ZIP")
return uploaded_images
@router.get("/library", response_model=ImageListResponse)
async def get_image_library(
page: int = 1,
page_size: int = 50,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
Get user's image library with pagination.
Returns all images uploaded by the current user.
"""
repo = ImageRepository(db)
offset = (page - 1) * page_size
images, total = await repo.get_user_images(current_user.id, limit=page_size, offset=offset)
return ImageListResponse(images=list(images), total=total, page=page, page_size=page_size)
@router.get("/{image_id}", response_model=ImageResponse)
async def get_image(
image_id: UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Get image by ID."""
repo = ImageRepository(db)
image = await repo.get_image_by_id(image_id)
if not image:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Image not found")
# Verify ownership
if image.user_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
return image
@router.delete("/{image_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_image(
image_id: UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
Delete image permanently.
Only allowed if reference_count is 0 (not used on any boards).
"""
repo = ImageRepository(db)
image = await repo.get_image_by_id(image_id)
if not image:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Image not found")
# Verify ownership
if image.user_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
# Check if still in use
if image.reference_count > 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Image is still used on {image.reference_count} board(s). Remove from boards first.",
)
# Delete from storage
from app.images.processing import delete_thumbnails
from app.images.upload import delete_image_from_storage
await delete_image_from_storage(image.storage_path)
if "thumbnails" in image.metadata:
await delete_thumbnails(image.metadata["thumbnails"])
# Delete from database
await repo.delete_image(image_id)
@router.post("/boards/{board_id}/images", response_model=BoardImageResponse, status_code=status.HTTP_201_CREATED)
async def add_image_to_board(
board_id: UUID,
data: BoardImageCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
Add an existing image to a board.
The image must already be uploaded and owned by the current user.
"""
# Verify board ownership
from sqlalchemy import select
board_result = await db.execute(select(Board).where(Board.id == board_id))
board = board_result.scalar_one_or_none()
if not board:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Board not found")
if board.user_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
# Verify image ownership
repo = ImageRepository(db)
image = await repo.get_image_by_id(data.image_id)
if not image:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Image not found")
if image.user_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Image access denied")
# Add image to board
board_image = await repo.add_image_to_board(
board_id=board_id,
image_id=data.image_id,
position=data.position,
transformations=data.transformations,
z_order=data.z_order,
)
# Load image relationship for response
await db.refresh(board_image, ["image"])
return board_image
@router.delete("/boards/{board_id}/images/{image_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_image_from_board(
board_id: UUID,
image_id: UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
Remove image from board.
This doesn't delete the image, just removes it from this board.
The image remains in the user's library.
"""
# Verify board ownership
from sqlalchemy import select
board_result = await db.execute(select(Board).where(Board.id == board_id))
board = board_result.scalar_one_or_none()
if not board:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Board not found")
if board.user_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
# Remove image from board
repo = ImageRepository(db)
removed = await repo.remove_image_from_board(board_id, image_id)
if not removed:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Image not on this board")
@router.get("/boards/{board_id}/images", response_model=list[BoardImageResponse])
async def get_board_images(
board_id: UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
Get all images on a board, ordered by z-order.
Used for loading board contents in the canvas.
"""
# Verify board access (owner or shared link - for now just owner)
from sqlalchemy import select
board_result = await db.execute(select(Board).where(Board.id == board_id))
board = board_result.scalar_one_or_none()
if not board:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Board not found")
if board.user_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
# Get board images
repo = ImageRepository(db)
board_images = await repo.get_board_images(board_id)
# Load image relationships
for board_image in board_images:
await db.refresh(board_image, ["image"])
return list(board_images)