phase 15
All checks were successful
CI/CD Pipeline / VM Test - backend-integration (push) Successful in 11s
CI/CD Pipeline / VM Test - full-stack (push) Successful in 8s
CI/CD Pipeline / VM Test - performance (push) Successful in 8s
CI/CD Pipeline / VM Test - security (push) Successful in 8s
CI/CD Pipeline / Backend Linting (push) Successful in 3s
CI/CD Pipeline / Frontend Linting (push) Successful in 18s
CI/CD Pipeline / Nix Flake Check (push) Successful in 43s
CI/CD Pipeline / CI Summary (push) Successful in 0s
All checks were successful
CI/CD Pipeline / VM Test - backend-integration (push) Successful in 11s
CI/CD Pipeline / VM Test - full-stack (push) Successful in 8s
CI/CD Pipeline / VM Test - performance (push) Successful in 8s
CI/CD Pipeline / VM Test - security (push) Successful in 8s
CI/CD Pipeline / Backend Linting (push) Successful in 3s
CI/CD Pipeline / Frontend Linting (push) Successful in 18s
CI/CD Pipeline / Nix Flake Check (push) Successful in 43s
CI/CD Pipeline / CI Summary (push) Successful in 0s
This commit is contained in:
128
backend/app/api/export.py
Normal file
128
backend/app/api/export.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""Export API endpoints for downloading and exporting images."""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.deps import get_current_user, get_db
|
||||
from app.database.models.board import Board
|
||||
from app.database.models.board_image import BoardImage
|
||||
from app.database.models.image import Image
|
||||
from app.database.models.user import User
|
||||
from app.images.download import download_single_image
|
||||
from app.images.export_composite import create_composite_export
|
||||
from app.images.export_zip import create_zip_export
|
||||
|
||||
router = APIRouter(tags=["export"])
|
||||
|
||||
|
||||
@router.get("/images/{image_id}/download")
|
||||
async def download_image(
|
||||
image_id: UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> StreamingResponse:
|
||||
"""
|
||||
Download a single image.
|
||||
|
||||
Only the image owner can download it.
|
||||
"""
|
||||
# Verify image exists and user owns it
|
||||
image = db.query(Image).filter(Image.id == image_id, Image.user_id == current_user.id).first()
|
||||
|
||||
if image is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Image not found or access denied",
|
||||
)
|
||||
|
||||
return await download_single_image(image.storage_path, image.filename)
|
||||
|
||||
|
||||
@router.get("/boards/{board_id}/export/zip")
|
||||
def export_board_zip(
|
||||
board_id: UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> StreamingResponse:
|
||||
"""
|
||||
Export all images from a board as a ZIP file.
|
||||
|
||||
Only the board owner can export it.
|
||||
"""
|
||||
# Verify board exists and user owns it
|
||||
board = db.query(Board).filter(Board.id == board_id, Board.user_id == current_user.id).first()
|
||||
|
||||
if board is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Board not found or access denied",
|
||||
)
|
||||
|
||||
return create_zip_export(str(board_id), db)
|
||||
|
||||
|
||||
@router.get("/boards/{board_id}/export/composite")
|
||||
def export_board_composite(
|
||||
board_id: UUID,
|
||||
scale: float = Query(1.0, ge=0.5, le=4.0, description="Resolution scale (0.5x to 4x)"),
|
||||
format: str = Query("PNG", regex="^(PNG|JPEG)$", description="Output format (PNG or JPEG)"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> StreamingResponse:
|
||||
"""
|
||||
Export board as a single composite image showing the layout.
|
||||
|
||||
Only the board owner can export it.
|
||||
|
||||
Args:
|
||||
scale: Resolution multiplier (0.5x, 1x, 2x, 4x)
|
||||
format: Output format (PNG or JPEG)
|
||||
"""
|
||||
# Verify board exists and user owns it
|
||||
board = db.query(Board).filter(Board.id == board_id, Board.user_id == current_user.id).first()
|
||||
|
||||
if board is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Board not found or access denied",
|
||||
)
|
||||
|
||||
return create_composite_export(str(board_id), db, scale=scale, format=format)
|
||||
|
||||
|
||||
@router.get("/boards/{board_id}/export/info")
|
||||
def get_export_info(
|
||||
board_id: UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""
|
||||
Get information about board export (image count, estimated size).
|
||||
|
||||
Useful for showing progress estimates.
|
||||
"""
|
||||
# Verify board exists and user owns it
|
||||
board = db.query(Board).filter(Board.id == board_id, Board.user_id == current_user.id).first()
|
||||
|
||||
if board is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Board not found or access denied",
|
||||
)
|
||||
|
||||
# Count images and calculate estimated size
|
||||
images = (
|
||||
db.query(Image).join(BoardImage, BoardImage.image_id == Image.id).filter(BoardImage.board_id == board_id).all()
|
||||
)
|
||||
|
||||
total_size = sum(img.file_size for img in images)
|
||||
|
||||
return {
|
||||
"board_id": str(board_id),
|
||||
"image_count": len(images),
|
||||
"total_size_bytes": total_size,
|
||||
"estimated_zip_size_bytes": int(total_size * 0.95), # ZIP usually has small overhead
|
||||
}
|
||||
@@ -91,6 +91,27 @@ class StorageClient:
|
||||
logger.error(f"Failed to download file {object_name}: {e}")
|
||||
raise
|
||||
|
||||
def get_object(self, object_name: str) -> bytes | None:
|
||||
"""Get object as bytes from MinIO.
|
||||
|
||||
Args:
|
||||
object_name: S3 object name (path)
|
||||
|
||||
Returns:
|
||||
bytes: File data or None if not found
|
||||
|
||||
Raises:
|
||||
Exception: If download fails for reasons other than not found
|
||||
"""
|
||||
try:
|
||||
file_data = self.download_file(object_name)
|
||||
return file_data.read()
|
||||
except ClientError as e:
|
||||
if e.response["Error"]["Code"] == "404":
|
||||
return None
|
||||
logger.error(f"Failed to get object {object_name}: {e}")
|
||||
raise
|
||||
|
||||
def delete_file(self, object_name: str) -> None:
|
||||
"""Delete file from MinIO.
|
||||
|
||||
|
||||
62
backend/app/images/download.py
Normal file
62
backend/app/images/download.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Image download functionality."""
|
||||
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.core.storage import storage_client
|
||||
|
||||
|
||||
async def download_single_image(storage_path: str, filename: str) -> StreamingResponse:
|
||||
"""
|
||||
Download a single image from storage.
|
||||
|
||||
Args:
|
||||
storage_path: Path to image in MinIO
|
||||
filename: Original filename for download
|
||||
|
||||
Returns:
|
||||
StreamingResponse with image data
|
||||
|
||||
Raises:
|
||||
HTTPException: If image not found or download fails
|
||||
"""
|
||||
try:
|
||||
# Get image from storage
|
||||
image_data = storage_client.get_object(storage_path)
|
||||
|
||||
if image_data is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Image not found in storage",
|
||||
)
|
||||
|
||||
# Determine content type from file extension
|
||||
extension = Path(filename).suffix.lower()
|
||||
content_type_map = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".svg": "image/svg+xml",
|
||||
}
|
||||
content_type = content_type_map.get(extension, "application/octet-stream")
|
||||
|
||||
# Return streaming response
|
||||
return StreamingResponse(
|
||||
io.BytesIO(image_data),
|
||||
media_type=content_type,
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
"Cache-Control": "no-cache",
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to download image: {str(e)}",
|
||||
) from e
|
||||
228
backend/app/images/export_composite.py
Normal file
228
backend/app/images/export_composite.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""Composite image generation for board export."""
|
||||
|
||||
import io
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from PIL import Image as PILImage
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.storage import storage_client
|
||||
from app.database.models.board import Board
|
||||
from app.database.models.board_image import BoardImage
|
||||
from app.database.models.image import Image
|
||||
|
||||
|
||||
def create_composite_export(board_id: str, db: Session, scale: float = 1.0, format: str = "PNG") -> StreamingResponse:
|
||||
"""
|
||||
Create a composite image showing the entire board layout.
|
||||
|
||||
Args:
|
||||
board_id: Board UUID
|
||||
db: Database session
|
||||
scale: Resolution multiplier (1x, 2x, 4x)
|
||||
format: Output format (PNG or JPEG)
|
||||
|
||||
Returns:
|
||||
StreamingResponse with composite image
|
||||
|
||||
Raises:
|
||||
HTTPException: If export fails
|
||||
"""
|
||||
try:
|
||||
# Get board
|
||||
board = db.query(Board).filter(Board.id == board_id).first()
|
||||
if not board:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Board not found",
|
||||
)
|
||||
|
||||
# Get all images for the board with positions
|
||||
board_images = (
|
||||
db.query(BoardImage, Image)
|
||||
.join(Image, BoardImage.image_id == Image.id)
|
||||
.filter(BoardImage.board_id == board_id)
|
||||
.order_by(BoardImage.z_order)
|
||||
.all()
|
||||
)
|
||||
|
||||
if not board_images:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="No images found for this board",
|
||||
)
|
||||
|
||||
# Calculate canvas bounds
|
||||
bounds = _calculate_canvas_bounds(board_images)
|
||||
if not bounds:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Unable to calculate canvas bounds",
|
||||
)
|
||||
|
||||
min_x, min_y, max_x, max_y = bounds
|
||||
|
||||
# Calculate canvas size with padding
|
||||
padding = 50
|
||||
canvas_width = int((max_x - min_x + 2 * padding) * scale)
|
||||
canvas_height = int((max_y - min_y + 2 * padding) * scale)
|
||||
|
||||
# Limit canvas size to prevent memory issues
|
||||
max_dimension = 8192 # 8K resolution limit
|
||||
if canvas_width > max_dimension or canvas_height > max_dimension:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Composite image too large (max {max_dimension}x{max_dimension})",
|
||||
)
|
||||
|
||||
# Create blank canvas
|
||||
if format.upper() == "JPEG":
|
||||
canvas = PILImage.new("RGB", (canvas_width, canvas_height), color=(255, 255, 255))
|
||||
else:
|
||||
canvas = PILImage.new("RGBA", (canvas_width, canvas_height), color=(255, 255, 255, 255))
|
||||
|
||||
# Composite each image onto canvas
|
||||
for board_image, image in board_images:
|
||||
try:
|
||||
# Get image from storage
|
||||
image_data = storage_client.get_object(image.storage_path)
|
||||
if not image_data:
|
||||
continue
|
||||
|
||||
# Open image
|
||||
pil_image = PILImage.open(io.BytesIO(image_data))
|
||||
|
||||
# Apply transformations
|
||||
transformed_image = _apply_transformations(pil_image, board_image.transformations, scale)
|
||||
|
||||
# Calculate position on canvas
|
||||
pos = board_image.position
|
||||
x = int((pos["x"] - min_x + padding) * scale)
|
||||
y = int((pos["y"] - min_y + padding) * scale)
|
||||
|
||||
# Paste onto canvas
|
||||
if transformed_image.mode == "RGBA":
|
||||
canvas.paste(transformed_image, (x, y), transformed_image)
|
||||
else:
|
||||
canvas.paste(transformed_image, (x, y))
|
||||
|
||||
except Exception as e:
|
||||
# Log error but continue with other images
|
||||
print(f"Warning: Failed to composite {image.filename}: {str(e)}")
|
||||
continue
|
||||
|
||||
# Save to buffer
|
||||
output = io.BytesIO()
|
||||
if format.upper() == "JPEG":
|
||||
canvas = canvas.convert("RGB")
|
||||
canvas.save(output, format="JPEG", quality=95)
|
||||
media_type = "image/jpeg"
|
||||
extension = "jpg"
|
||||
else:
|
||||
canvas.save(output, format="PNG", optimize=True)
|
||||
media_type = "image/png"
|
||||
extension = "png"
|
||||
|
||||
output.seek(0)
|
||||
|
||||
# Return composite image
|
||||
return StreamingResponse(
|
||||
output,
|
||||
media_type=media_type,
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="board_composite.{extension}"',
|
||||
"Cache-Control": "no-cache",
|
||||
},
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to create composite export: {str(e)}",
|
||||
) from e
|
||||
|
||||
|
||||
def _calculate_canvas_bounds(board_images) -> tuple[float, float, float, float] | None:
|
||||
"""
|
||||
Calculate the bounding box for all images.
|
||||
|
||||
Args:
|
||||
board_images: List of (BoardImage, Image) tuples
|
||||
|
||||
Returns:
|
||||
Tuple of (min_x, min_y, max_x, max_y) or None
|
||||
"""
|
||||
if not board_images:
|
||||
return None
|
||||
|
||||
min_x = min_y = float("inf")
|
||||
max_x = max_y = float("-inf")
|
||||
|
||||
for board_image, image in board_images:
|
||||
pos = board_image.position
|
||||
transforms = board_image.transformations
|
||||
|
||||
x = pos["x"]
|
||||
y = pos["y"]
|
||||
width = image.width * transforms.get("scale", 1.0)
|
||||
height = image.height * transforms.get("scale", 1.0)
|
||||
|
||||
min_x = min(min_x, x)
|
||||
min_y = min(min_y, y)
|
||||
max_x = max(max_x, x + width)
|
||||
max_y = max(max_y, y + height)
|
||||
|
||||
return (min_x, min_y, max_x, max_y)
|
||||
|
||||
|
||||
def _apply_transformations(image: PILImage.Image, transformations: dict, scale: float) -> PILImage.Image:
|
||||
"""
|
||||
Apply transformations to an image.
|
||||
|
||||
Args:
|
||||
image: PIL Image
|
||||
transformations: Transformation dict
|
||||
scale: Resolution multiplier
|
||||
|
||||
Returns:
|
||||
Transformed PIL Image
|
||||
"""
|
||||
# Apply scale
|
||||
img_scale = transformations.get("scale", 1.0) * scale
|
||||
if img_scale != 1.0:
|
||||
new_width = int(image.width * img_scale)
|
||||
new_height = int(image.height * img_scale)
|
||||
image = image.resize((new_width, new_height), PILImage.Resampling.LANCZOS)
|
||||
|
||||
# Apply rotation
|
||||
rotation = transformations.get("rotation", 0)
|
||||
if rotation != 0:
|
||||
image = image.rotate(-rotation, expand=True, resample=PILImage.Resampling.BICUBIC)
|
||||
|
||||
# Apply flips
|
||||
if transformations.get("flipped_h", False):
|
||||
image = image.transpose(PILImage.Transpose.FLIP_LEFT_RIGHT)
|
||||
if transformations.get("flipped_v", False):
|
||||
image = image.transpose(PILImage.Transpose.FLIP_TOP_BOTTOM)
|
||||
|
||||
# Apply greyscale
|
||||
if transformations.get("greyscale", False):
|
||||
if image.mode == "RGBA":
|
||||
# Preserve alpha channel
|
||||
alpha = image.split()[-1]
|
||||
image = image.convert("L").convert("RGBA")
|
||||
image.putalpha(alpha)
|
||||
else:
|
||||
image = image.convert("L")
|
||||
|
||||
# Apply opacity
|
||||
opacity = transformations.get("opacity", 1.0)
|
||||
if opacity < 1.0 and image.mode in ("RGBA", "LA"):
|
||||
alpha = image.split()[-1]
|
||||
alpha = alpha.point(lambda p: int(p * opacity))
|
||||
image.putalpha(alpha)
|
||||
|
||||
return image
|
||||
103
backend/app/images/export_zip.py
Normal file
103
backend/app/images/export_zip.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""ZIP export functionality for multiple images."""
|
||||
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.storage import storage_client
|
||||
from app.database.models.board_image import BoardImage
|
||||
from app.database.models.image import Image
|
||||
|
||||
|
||||
def create_zip_export(board_id: str, db: Session) -> StreamingResponse:
|
||||
"""
|
||||
Create a ZIP file containing all images from a board.
|
||||
|
||||
Args:
|
||||
board_id: Board UUID
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
StreamingResponse with ZIP file
|
||||
|
||||
Raises:
|
||||
HTTPException: If export fails
|
||||
"""
|
||||
try:
|
||||
# Get all images for the board
|
||||
board_images = (
|
||||
db.query(BoardImage, Image)
|
||||
.join(Image, BoardImage.image_id == Image.id)
|
||||
.filter(BoardImage.board_id == board_id)
|
||||
.all()
|
||||
)
|
||||
|
||||
if not board_images:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="No images found for this board",
|
||||
)
|
||||
|
||||
# Create ZIP file in memory
|
||||
zip_buffer = io.BytesIO()
|
||||
|
||||
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
|
||||
for _board_image, image in board_images:
|
||||
try:
|
||||
# Get image data from storage
|
||||
image_data = storage_client.get_object(image.storage_path)
|
||||
|
||||
if image_data:
|
||||
# Add to ZIP with sanitized filename
|
||||
safe_filename = _sanitize_filename(image.filename)
|
||||
zip_file.writestr(safe_filename, image_data)
|
||||
|
||||
except Exception as e:
|
||||
# Log error but continue with other images
|
||||
print(f"Warning: Failed to add {image.filename} to ZIP: {str(e)}")
|
||||
continue
|
||||
|
||||
# Reset buffer position
|
||||
zip_buffer.seek(0)
|
||||
|
||||
# Return ZIP file
|
||||
return StreamingResponse(
|
||||
zip_buffer,
|
||||
media_type="application/zip",
|
||||
headers={
|
||||
"Content-Disposition": 'attachment; filename="board_export.zip"',
|
||||
"Cache-Control": "no-cache",
|
||||
},
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to create ZIP export: {str(e)}",
|
||||
) from e
|
||||
|
||||
|
||||
def _sanitize_filename(filename: str) -> str:
|
||||
"""
|
||||
Sanitize filename for safe inclusion in ZIP.
|
||||
|
||||
Args:
|
||||
filename: Original filename
|
||||
|
||||
Returns:
|
||||
Sanitized filename
|
||||
"""
|
||||
# Remove any path separators and dangerous characters
|
||||
safe_chars = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._- ")
|
||||
sanitized = "".join(c if c in safe_chars else "_" for c in filename)
|
||||
|
||||
# Ensure it's not empty and doesn't start with a dot
|
||||
if not sanitized or sanitized[0] == ".":
|
||||
sanitized = "file_" + sanitized
|
||||
|
||||
return sanitized
|
||||
@@ -5,7 +5,7 @@ import logging
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.api import auth, boards, groups, images, sharing
|
||||
from app.api import auth, boards, export, groups, images, sharing
|
||||
from app.core.config import settings
|
||||
from app.core.errors import WebRefException
|
||||
from app.core.logging import setup_logging
|
||||
@@ -87,6 +87,7 @@ app.include_router(boards.router, prefix=f"{settings.API_V1_PREFIX}")
|
||||
app.include_router(groups.router, prefix=f"{settings.API_V1_PREFIX}")
|
||||
app.include_router(images.router, prefix=f"{settings.API_V1_PREFIX}")
|
||||
app.include_router(sharing.router, prefix=f"{settings.API_V1_PREFIX}")
|
||||
app.include_router(export.router, prefix=f"{settings.API_V1_PREFIX}")
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
|
||||
Reference in New Issue
Block a user