"""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)