phase 5
This commit is contained in:
344
backend/app/api/images.py
Normal file
344
backend/app/api/images.py
Normal 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)
|
||||
Reference in New Issue
Block a user