phase 22
All checks were successful
CI/CD Pipeline / VM Test - backend-integration (push) Successful in 12s
CI/CD Pipeline / VM Test - full-stack (push) Successful in 9s
CI/CD Pipeline / VM Test - performance (push) Successful in 9s
CI/CD Pipeline / VM Test - security (push) Successful in 9s
CI/CD Pipeline / Backend Linting (push) Successful in 3s
CI/CD Pipeline / Frontend Linting (push) Successful in 24s
CI/CD Pipeline / Nix Flake Check (push) Successful in 53s
CI/CD Pipeline / CI Summary (push) Successful in 1s
CI/CD Pipeline / VM Test - backend-integration (pull_request) Successful in 2s
CI/CD Pipeline / VM Test - full-stack (pull_request) Successful in 2s
CI/CD Pipeline / VM Test - performance (pull_request) Successful in 2s
CI/CD Pipeline / VM Test - security (pull_request) Successful in 2s
CI/CD Pipeline / Backend Linting (pull_request) Successful in 2s
CI/CD Pipeline / Frontend Linting (pull_request) Successful in 16s
CI/CD Pipeline / Nix Flake Check (pull_request) Successful in 38s
CI/CD Pipeline / CI Summary (pull_request) Successful in 0s

This commit is contained in:
Danilo Reyes
2025-11-02 15:50:30 -06:00
parent d4fbdf9273
commit ce353f8b49
23 changed files with 2524 additions and 103 deletions

235
backend/app/api/library.py Normal file
View File

@@ -0,0 +1,235 @@
"""Image library API endpoints."""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.core.deps import get_current_user, get_db
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.search import count_images, search_images
router = APIRouter(tags=["library"])
class ImageLibraryResponse(BaseModel):
"""Response schema for library image."""
id: str
filename: str
file_size: int
mime_type: str
width: int
height: int
reference_count: int
created_at: str
thumbnail_url: str | None = None
class ImageLibraryListResponse(BaseModel):
"""Response schema for library listing."""
images: list[ImageLibraryResponse]
total: int
limit: int
offset: int
class AddToBoardRequest(BaseModel):
"""Request schema for adding library image to board."""
board_id: str
position: dict = {"x": 0, "y": 0}
@router.get("/library/images", response_model=ImageLibraryListResponse)
def list_library_images(
query: str | None = Query(None, description="Search query"),
limit: int = Query(50, ge=1, le=100, description="Results per page"),
offset: int = Query(0, ge=0, description="Pagination offset"),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> ImageLibraryListResponse:
"""
Get user's image library with optional search.
Returns all images owned by the user, regardless of board usage.
"""
# Search images
images = search_images(str(current_user.id), db, query=query, limit=limit, offset=offset)
# Count total
total = count_images(str(current_user.id), db, query=query)
# Convert to response format
image_responses = []
for img in images:
thumbnails = img.image_metadata.get("thumbnails", {})
image_responses.append(
ImageLibraryResponse(
id=str(img.id),
filename=img.filename,
file_size=img.file_size,
mime_type=img.mime_type,
width=img.width,
height=img.height,
reference_count=img.reference_count,
created_at=img.created_at.isoformat(),
thumbnail_url=thumbnails.get("medium"),
)
)
return ImageLibraryListResponse(images=image_responses, total=total, limit=limit, offset=offset)
@router.post("/library/images/{image_id}/add-to-board", status_code=status.HTTP_201_CREATED)
def add_library_image_to_board(
image_id: UUID,
request: AddToBoardRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict:
"""
Add an existing library image to a board.
Creates a new BoardImage reference without duplicating the file.
Increments reference count on the image.
"""
# 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 in library",
)
# Verify board exists and user owns it
from app.database.models.board import Board
board = db.query(Board).filter(Board.id == request.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",
)
# Check if image already on this board
existing = (
db.query(BoardImage).filter(BoardImage.board_id == request.board_id, BoardImage.image_id == image_id).first()
)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Image already exists on this board",
)
# Get max z_order for board
max_z = (
db.query(BoardImage.z_order)
.filter(BoardImage.board_id == request.board_id)
.order_by(BoardImage.z_order.desc())
.first()
)
next_z = (max_z[0] + 1) if max_z else 0
# Create BoardImage reference
board_image = BoardImage(
board_id=UUID(request.board_id),
image_id=image_id,
position=request.position,
transformations={
"scale": 1.0,
"rotation": 0,
"opacity": 1.0,
"flipped_h": False,
"flipped_v": False,
"greyscale": False,
},
z_order=next_z,
)
db.add(board_image)
# Increment reference count
image.reference_count += 1
db.commit()
db.refresh(board_image)
return {"id": str(board_image.id), "message": "Image added to board successfully"}
@router.delete("/library/images/{image_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_library_image(
image_id: UUID,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> None:
"""
Permanently delete an image from library.
Removes image from all boards and deletes from storage.
Only allowed if user owns the image.
"""
from app.core.storage import storage_client
# Get image
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 in library",
)
# Delete all BoardImage references
db.query(BoardImage).filter(BoardImage.image_id == image_id).delete()
# Delete from storage
import contextlib
try:
storage_client.delete_file(image.storage_path)
# Also delete thumbnails if they exist
thumbnails = image.image_metadata.get("thumbnails", {})
for thumb_path in thumbnails.values():
if thumb_path:
with contextlib.suppress(Exception):
storage_client.delete_file(thumb_path)
except Exception as e:
# Log error but continue with database deletion
print(f"Warning: Failed to delete image from storage: {str(e)}")
# Delete database record
db.delete(image)
db.commit()
@router.get("/library/stats")
def get_library_stats(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict:
"""
Get statistics about user's image library.
Returns total images, total size, and usage across boards.
"""
images = db.query(Image).filter(Image.user_id == current_user.id).all()
total_images = len(images)
total_size = sum(img.file_size for img in images)
total_references = sum(img.reference_count for img in images)
return {
"total_images": total_images,
"total_size_bytes": total_size,
"total_board_references": total_references,
"average_references_per_image": total_references / total_images if total_images > 0 else 0,
}

View File

@@ -0,0 +1,79 @@
"""Connection quality detection and testing endpoints."""
import time
from fastapi import APIRouter
from pydantic import BaseModel
router = APIRouter(tags=["quality"])
class ConnectionTestRequest(BaseModel):
"""Request schema for connection test."""
test_size_bytes: int = 100000 # 100KB default test size
class ConnectionTestResponse(BaseModel):
"""Response schema for connection test results."""
speed_mbps: float
latency_ms: float
quality_tier: str # 'low', 'medium', 'high'
recommended_thumbnail: str # 'low', 'medium', 'high'
@router.post("/connection/test", response_model=ConnectionTestResponse)
async def test_connection_speed(request: ConnectionTestRequest) -> ConnectionTestResponse:
"""
Test connection speed and return quality recommendation.
This endpoint helps determine appropriate thumbnail quality.
The client measures download time of test data to calculate speed.
Args:
request: Test configuration
Returns:
Connection quality information and recommendations
"""
# Record start time for latency measurement
start_time = time.time()
# Simulate latency measurement (in real implementation, client measures this)
latency_ms = (time.time() - start_time) * 1000
# Client will measure actual download time
# Here we just provide the test data size for calculation
# The client calculates: speed_mbps = (test_size_bytes * 8) / (download_time_seconds * 1_000_000)
# For now, we return a standard response
# In practice, the client does the speed calculation
return ConnectionTestResponse(
speed_mbps=0.0, # Client calculates this
latency_ms=latency_ms,
quality_tier="medium",
recommended_thumbnail="medium",
)
@router.get("/connection/test-data")
async def get_test_data(size: int = 100000) -> bytes:
"""
Serve test data for connection speed measurement.
Client downloads this and measures time to calculate speed.
Args:
size: Size of test data in bytes (max 500KB)
Returns:
Random bytes for speed testing
"""
import secrets
# Cap size at 500KB to prevent abuse
size = min(size, 500000)
# Generate random bytes
return secrets.token_bytes(size)

View File

@@ -0,0 +1,74 @@
"""Image search and filtering functionality."""
from sqlalchemy import or_
from sqlalchemy.orm import Session
from app.database.models.image import Image
def search_images(
user_id: str,
db: Session,
query: str | None = None,
limit: int = 50,
offset: int = 0,
) -> list[Image]:
"""
Search user's image library with optional filters.
Args:
user_id: User UUID
db: Database session
query: Search query (searches filename)
limit: Maximum results (default 50)
offset: Pagination offset (default 0)
Returns:
List of matching images
"""
# Base query - get user's images
stmt = db.query(Image).filter(Image.user_id == user_id)
# Add search filter if query provided
if query:
search_term = f"%{query}%"
stmt = stmt.filter(
or_(
Image.filename.ilike(search_term),
Image.image_metadata["format"].astext.ilike(search_term),
)
)
# Order by most recently uploaded
stmt = stmt.order_by(Image.created_at.desc())
# Apply pagination
stmt = stmt.limit(limit).offset(offset)
return stmt.all()
def count_images(user_id: str, db: Session, query: str | None = None) -> int:
"""
Count images matching search criteria.
Args:
user_id: User UUID
db: Database session
query: Search query (optional)
Returns:
Count of matching images
"""
stmt = db.query(Image).filter(Image.user_id == user_id)
if query:
search_term = f"%{query}%"
stmt = stmt.filter(
or_(
Image.filename.ilike(search_term),
Image.image_metadata["format"].astext.ilike(search_term),
)
)
return stmt.count()

103
backend/app/images/serve.py Normal file
View File

@@ -0,0 +1,103 @@
"""Image serving with quality-based thumbnail selection."""
from fastapi import HTTPException, status
from fastapi.responses import StreamingResponse
from app.database.models.image import Image
def get_thumbnail_path(image: Image, quality: str) -> str:
"""
Get thumbnail path for specified quality level.
Args:
image: Image model instance
quality: Quality level ('low', 'medium', 'high', 'original')
Returns:
Storage path to thumbnail
Raises:
ValueError: If quality level is invalid
"""
if quality == "original":
return image.storage_path
# Get thumbnail paths from metadata
thumbnails = image.image_metadata.get("thumbnails", {})
# Map quality to thumbnail size
if quality == "low":
thumbnail_path = thumbnails.get("low")
elif quality == "medium":
thumbnail_path = thumbnails.get("medium")
elif quality == "high":
thumbnail_path = thumbnails.get("high")
else:
raise ValueError(f"Invalid quality level: {quality}")
# Fall back to original if thumbnail doesn't exist
if not thumbnail_path:
return image.storage_path
return thumbnail_path
async def serve_image_with_quality(
image: Image, quality: str = "medium", filename: str | None = None
) -> StreamingResponse:
"""
Serve image with specified quality level.
Args:
image: Image model instance
quality: Quality level ('low', 'medium', 'high', 'original')
filename: Optional custom filename for download
Returns:
StreamingResponse with image data
Raises:
HTTPException: If image cannot be served
"""
from app.images.download import download_single_image
try:
# Get appropriate thumbnail path
storage_path = get_thumbnail_path(image, quality)
# Use original filename if not specified
if filename is None:
filename = image.filename
# Serve the image
return await download_single_image(storage_path, filename)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from e
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to serve image: {str(e)}",
) from e
def determine_quality_from_speed(speed_mbps: float) -> str:
"""
Determine appropriate quality level based on connection speed.
Args:
speed_mbps: Connection speed in Mbps
Returns:
Quality level string
"""
if speed_mbps < 1.0:
return "low"
elif speed_mbps < 5.0:
return "medium"
else:
return "high"

View File

@@ -5,7 +5,7 @@ import logging
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from app.api import auth, boards, export, groups, images, sharing
from app.api import auth, boards, export, groups, images, library, quality, sharing
from app.core.config import settings
from app.core.errors import WebRefException
from app.core.logging import setup_logging
@@ -88,6 +88,8 @@ 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.include_router(library.router, prefix=f"{settings.API_V1_PREFIX}")
app.include_router(quality.router, prefix=f"{settings.API_V1_PREFIX}")
@app.on_event("startup")