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
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:
235
backend/app/api/library.py
Normal file
235
backend/app/api/library.py
Normal 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,
|
||||
}
|
||||
79
backend/app/api/quality.py
Normal file
79
backend/app/api/quality.py
Normal 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)
|
||||
74
backend/app/images/search.py
Normal file
74
backend/app/images/search.py
Normal 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
103
backend/app/images/serve.py
Normal 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"
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user