All checks were successful
CI/CD Pipeline / VM Test - backend-integration (push) Successful in 7s
CI/CD Pipeline / VM Test - full-stack (push) Successful in 7s
CI/CD Pipeline / VM Test - performance (push) Successful in 7s
CI/CD Pipeline / VM Test - security (push) Successful in 7s
CI/CD Pipeline / Backend Linting (push) Successful in 2s
CI/CD Pipeline / Frontend Linting (push) Successful in 15s
CI/CD Pipeline / Nix Flake Check (push) Successful in 41s
CI/CD Pipeline / CI Summary (push) Successful in 1s
471 lines
15 KiB
Python
471 lines
15 KiB
Python
"""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,
|
|
BoardImageUpdate,
|
|
BulkImageUpdate,
|
|
BulkUpdateResponse,
|
|
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.patch("/boards/{board_id}/images/{image_id}", response_model=BoardImageResponse)
|
|
async def update_board_image(
|
|
board_id: UUID,
|
|
image_id: UUID,
|
|
data: BoardImageUpdate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""
|
|
Update board image position, transformations, z-order, or group.
|
|
|
|
This endpoint is optimized for frequent position updates (debounced from frontend).
|
|
Only provided fields are updated.
|
|
"""
|
|
# 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")
|
|
|
|
# Update board image
|
|
repo = ImageRepository(db)
|
|
board_image = await repo.update_board_image(
|
|
board_id=board_id,
|
|
image_id=image_id,
|
|
position=data.position,
|
|
transformations=data.transformations,
|
|
z_order=data.z_order,
|
|
group_id=data.group_id,
|
|
)
|
|
|
|
if not board_image:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Image not on this board")
|
|
|
|
# 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.patch("/boards/{board_id}/images/bulk", response_model=BulkUpdateResponse)
|
|
async def bulk_update_board_images(
|
|
board_id: UUID,
|
|
data: BulkImageUpdate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""
|
|
Bulk update multiple images on a board.
|
|
|
|
Applies the same changes to all specified images. Useful for multi-selection operations.
|
|
"""
|
|
# 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")
|
|
|
|
# Update each image
|
|
repo = ImageRepository(db)
|
|
updated_ids = []
|
|
failed_count = 0
|
|
|
|
for image_id in data.image_ids:
|
|
try:
|
|
# Calculate new position if delta provided
|
|
position = None
|
|
if data.position_delta:
|
|
# Get current position
|
|
board_image = await repo.get_board_image(board_id, image_id)
|
|
if board_image and board_image.position:
|
|
current_pos = board_image.position
|
|
position = {
|
|
"x": current_pos.get("x", 0) + data.position_delta["dx"],
|
|
"y": current_pos.get("y", 0) + data.position_delta["dy"],
|
|
}
|
|
|
|
# Calculate new z-order if delta provided
|
|
z_order = None
|
|
if data.z_order_delta is not None:
|
|
board_image = await repo.get_board_image(board_id, image_id)
|
|
if board_image:
|
|
z_order = board_image.z_order + data.z_order_delta
|
|
|
|
# Update the image
|
|
updated = await repo.update_board_image(
|
|
board_id=board_id,
|
|
image_id=image_id,
|
|
position=position,
|
|
transformations=data.transformations,
|
|
z_order=z_order,
|
|
group_id=None, # Bulk operations don't change groups
|
|
)
|
|
|
|
if updated:
|
|
updated_ids.append(image_id)
|
|
else:
|
|
failed_count += 1
|
|
|
|
except Exception as e:
|
|
print(f"Error updating image {image_id}: {e}")
|
|
failed_count += 1
|
|
continue
|
|
|
|
return BulkUpdateResponse(
|
|
updated_count=len(updated_ids),
|
|
failed_count=failed_count,
|
|
image_ids=updated_ids,
|
|
)
|
|
|
|
|
|
@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)
|