feat: Implement favorites functionality for photos with API and UI updates
This commit introduces a favorites feature for photos, allowing users to mark and manage their favorite images. The API has been updated with new endpoints for toggling favorite status, checking if a photo is a favorite, and bulk adding/removing favorites. The frontend has been enhanced to include favorite management in the PhotoViewer and Search components, with UI elements for adding/removing favorites and displaying favorite status. Documentation has been updated to reflect these changes.
This commit is contained in:
+248
-1
@@ -7,11 +7,13 @@ from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
||||
from fastapi.responses import JSONResponse, FileResponse
|
||||
from typing import Annotated
|
||||
from rq import Queue
|
||||
from redis import Redis
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.web.db.session import get_db
|
||||
from src.web.api.auth import get_current_user
|
||||
|
||||
# Redis connection for RQ
|
||||
redis_conn = Redis(host="localhost", port=6379, db=0, decode_responses=False)
|
||||
@@ -20,6 +22,10 @@ from src.web.schemas.photos import (
|
||||
PhotoImportRequest,
|
||||
PhotoImportResponse,
|
||||
PhotoResponse,
|
||||
BulkAddFavoritesRequest,
|
||||
BulkAddFavoritesResponse,
|
||||
BulkRemoveFavoritesRequest,
|
||||
BulkRemoveFavoritesResponse,
|
||||
)
|
||||
from src.web.schemas.search import (
|
||||
PhotoSearchResult,
|
||||
@@ -30,6 +36,7 @@ from src.web.services.photo_service import (
|
||||
import_photo_from_path,
|
||||
)
|
||||
from src.web.services.search_service import (
|
||||
get_favorite_photos,
|
||||
get_photo_face_count,
|
||||
get_photo_person,
|
||||
get_photo_tags,
|
||||
@@ -48,7 +55,8 @@ router = APIRouter(prefix="/photos", tags=["photos"])
|
||||
|
||||
@router.get("", response_model=SearchPhotosResponse)
|
||||
def search_photos(
|
||||
search_type: str = Query("name", description="Search type: name, date, tags, no_faces, no_tags, processed, unprocessed"),
|
||||
current_user: Annotated[dict, Depends(get_current_user)],
|
||||
search_type: str = Query("name", description="Search type: name, date, tags, no_faces, no_tags, processed, unprocessed, favorites"),
|
||||
person_name: Optional[str] = Query(None, description="Person name for name search"),
|
||||
tag_names: Optional[str] = Query(None, description="Comma-separated tag names for tag search"),
|
||||
match_all: bool = Query(False, description="Match all tags (for tag search)"),
|
||||
@@ -69,9 +77,23 @@ def search_photos(
|
||||
- Search no tags: returns photos without tags
|
||||
- Search processed: returns photos that have been processed for face detection
|
||||
- Search unprocessed: returns photos that have not been processed for face detection
|
||||
- Search favorites: returns photos favorited by current user
|
||||
"""
|
||||
from src.web.db.models import PhotoFavorite
|
||||
|
||||
items: List[PhotoSearchResult] = []
|
||||
total = 0
|
||||
username = current_user["username"] if current_user else None
|
||||
|
||||
# Helper function to check if photo is favorite
|
||||
def check_is_favorite(photo_id: int) -> bool:
|
||||
if not username:
|
||||
return False
|
||||
favorite = db.query(PhotoFavorite).filter(
|
||||
PhotoFavorite.username == username,
|
||||
PhotoFavorite.photo_id == photo_id
|
||||
).first()
|
||||
return favorite is not None
|
||||
|
||||
if search_type == "name":
|
||||
if not person_name:
|
||||
@@ -99,6 +121,7 @@ def search_photos(
|
||||
tags=tags,
|
||||
has_faces=face_count > 0,
|
||||
face_count=face_count,
|
||||
is_favorite=check_is_favorite(photo.id),
|
||||
)
|
||||
)
|
||||
elif search_type == "date":
|
||||
@@ -128,6 +151,7 @@ def search_photos(
|
||||
tags=tags,
|
||||
has_faces=face_count > 0,
|
||||
face_count=face_count,
|
||||
is_favorite=check_is_favorite(photo.id),
|
||||
)
|
||||
)
|
||||
elif search_type == "tags":
|
||||
@@ -163,6 +187,7 @@ def search_photos(
|
||||
tags=tags,
|
||||
has_faces=face_count > 0,
|
||||
face_count=face_count,
|
||||
is_favorite=check_is_favorite(photo.id),
|
||||
)
|
||||
)
|
||||
elif search_type == "no_faces":
|
||||
@@ -183,6 +208,7 @@ def search_photos(
|
||||
tags=tags,
|
||||
has_faces=False,
|
||||
face_count=0,
|
||||
is_favorite=check_is_favorite(photo.id),
|
||||
)
|
||||
)
|
||||
elif search_type == "no_tags":
|
||||
@@ -204,6 +230,7 @@ def search_photos(
|
||||
tags=[],
|
||||
has_faces=face_count > 0,
|
||||
face_count=face_count,
|
||||
is_favorite=check_is_favorite(photo.id),
|
||||
)
|
||||
)
|
||||
elif search_type == "processed":
|
||||
@@ -226,6 +253,7 @@ def search_photos(
|
||||
tags=tags,
|
||||
has_faces=face_count > 0,
|
||||
face_count=face_count,
|
||||
is_favorite=check_is_favorite(photo.id),
|
||||
)
|
||||
)
|
||||
elif search_type == "unprocessed":
|
||||
@@ -248,6 +276,35 @@ def search_photos(
|
||||
tags=tags,
|
||||
has_faces=face_count > 0,
|
||||
face_count=face_count,
|
||||
is_favorite=check_is_favorite(photo.id),
|
||||
)
|
||||
)
|
||||
elif search_type == "favorites":
|
||||
if not username:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Authentication required for favorites search",
|
||||
)
|
||||
results, total = get_favorite_photos(db, username, folder_path, page, page_size)
|
||||
for photo in results:
|
||||
tags = get_photo_tags(db, photo.id)
|
||||
face_count = get_photo_face_count(db, photo.id)
|
||||
person_name_val = get_photo_person(db, photo.id)
|
||||
# Convert datetime to date for date_added
|
||||
date_added = photo.date_added.date() if isinstance(photo.date_added, datetime) else photo.date_added
|
||||
items.append(
|
||||
PhotoSearchResult(
|
||||
id=photo.id,
|
||||
path=photo.path,
|
||||
filename=photo.filename,
|
||||
date_taken=photo.date_taken,
|
||||
date_added=date_added,
|
||||
processed=photo.processed,
|
||||
person_name=person_name_val,
|
||||
tags=tags,
|
||||
has_faces=face_count > 0,
|
||||
face_count=face_count,
|
||||
is_favorite=True, # All results are favorites
|
||||
)
|
||||
)
|
||||
else:
|
||||
@@ -481,6 +538,196 @@ def get_photo_image(photo_id: int, db: Session = Depends(get_db)) -> FileRespons
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/{photo_id}/toggle-favorite")
|
||||
def toggle_favorite(
|
||||
photo_id: int,
|
||||
current_user: Annotated[dict, Depends(get_current_user)],
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Toggle favorite status of a photo for current user."""
|
||||
from src.web.db.models import Photo, PhotoFavorite
|
||||
|
||||
username = current_user["username"]
|
||||
|
||||
# Verify photo exists
|
||||
photo = db.query(Photo).filter(Photo.id == photo_id).first()
|
||||
if not photo:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Photo {photo_id} not found",
|
||||
)
|
||||
|
||||
# Check if already favorited
|
||||
existing = db.query(PhotoFavorite).filter(
|
||||
PhotoFavorite.username == username,
|
||||
PhotoFavorite.photo_id == photo_id
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
# Remove favorite
|
||||
db.delete(existing)
|
||||
is_favorite = False
|
||||
else:
|
||||
# Add favorite
|
||||
favorite = PhotoFavorite(
|
||||
username=username,
|
||||
photo_id=photo_id
|
||||
)
|
||||
db.add(favorite)
|
||||
is_favorite = True
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"photo_id": photo_id,
|
||||
"is_favorite": is_favorite,
|
||||
"message": "Favorite status updated"
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{photo_id}/is-favorite")
|
||||
def check_favorite(
|
||||
photo_id: int,
|
||||
current_user: Annotated[dict, Depends(get_current_user)],
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Check if photo is favorited by current user."""
|
||||
from src.web.db.models import PhotoFavorite
|
||||
|
||||
username = current_user["username"]
|
||||
|
||||
favorite = db.query(PhotoFavorite).filter(
|
||||
PhotoFavorite.username == username,
|
||||
PhotoFavorite.photo_id == photo_id
|
||||
).first()
|
||||
|
||||
return {
|
||||
"photo_id": photo_id,
|
||||
"is_favorite": favorite is not None
|
||||
}
|
||||
|
||||
|
||||
@router.post("/bulk-add-favorites", response_model=BulkAddFavoritesResponse)
|
||||
def bulk_add_favorites(
|
||||
request: BulkAddFavoritesRequest,
|
||||
current_user: Annotated[dict, Depends(get_current_user)],
|
||||
db: Session = Depends(get_db),
|
||||
) -> BulkAddFavoritesResponse:
|
||||
"""Add multiple photos to favorites for current user.
|
||||
|
||||
Only adds favorites for photos that aren't already favorites.
|
||||
Uses a single database transaction for better performance.
|
||||
"""
|
||||
from src.web.db.models import Photo, PhotoFavorite
|
||||
|
||||
photo_ids = request.photo_ids
|
||||
if not photo_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="photo_ids list cannot be empty",
|
||||
)
|
||||
|
||||
username = current_user["username"]
|
||||
|
||||
# Verify all photos exist
|
||||
photos = db.query(Photo).filter(Photo.id.in_(photo_ids)).all()
|
||||
found_ids = {photo.id for photo in photos}
|
||||
missing_ids = set(photo_ids) - found_ids
|
||||
|
||||
if missing_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Photos not found: {sorted(missing_ids)}",
|
||||
)
|
||||
|
||||
# Get existing favorites in a single query
|
||||
existing_favorites = db.query(PhotoFavorite).filter(
|
||||
PhotoFavorite.username == username,
|
||||
PhotoFavorite.photo_id.in_(photo_ids)
|
||||
).all()
|
||||
existing_ids = {fav.photo_id for fav in existing_favorites}
|
||||
|
||||
# Only add favorites for photos that aren't already favorites
|
||||
photos_to_add = [photo_id for photo_id in photo_ids if photo_id not in existing_ids]
|
||||
|
||||
added_count = 0
|
||||
for photo_id in photos_to_add:
|
||||
favorite = PhotoFavorite(
|
||||
username=username,
|
||||
photo_id=photo_id
|
||||
)
|
||||
db.add(favorite)
|
||||
added_count += 1
|
||||
|
||||
db.commit()
|
||||
|
||||
return BulkAddFavoritesResponse(
|
||||
message=f"Added {added_count} photo(s) to favorites",
|
||||
added_count=added_count,
|
||||
already_favorite_count=len(existing_ids),
|
||||
total_requested=len(photo_ids),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/bulk-remove-favorites", response_model=BulkRemoveFavoritesResponse)
|
||||
def bulk_remove_favorites(
|
||||
request: BulkRemoveFavoritesRequest,
|
||||
current_user: Annotated[dict, Depends(get_current_user)],
|
||||
db: Session = Depends(get_db),
|
||||
) -> BulkRemoveFavoritesResponse:
|
||||
"""Remove multiple photos from favorites for current user.
|
||||
|
||||
Only removes favorites for photos that are currently favorites.
|
||||
Uses a single database transaction for better performance.
|
||||
"""
|
||||
from src.web.db.models import Photo, PhotoFavorite
|
||||
|
||||
photo_ids = request.photo_ids
|
||||
if not photo_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="photo_ids list cannot be empty",
|
||||
)
|
||||
|
||||
username = current_user["username"]
|
||||
|
||||
# Verify all photos exist
|
||||
photos = db.query(Photo).filter(Photo.id.in_(photo_ids)).all()
|
||||
found_ids = {photo.id for photo in photos}
|
||||
missing_ids = set(photo_ids) - found_ids
|
||||
|
||||
if missing_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Photos not found: {sorted(missing_ids)}",
|
||||
)
|
||||
|
||||
# Get existing favorites in a single query
|
||||
existing_favorites = db.query(PhotoFavorite).filter(
|
||||
PhotoFavorite.username == username,
|
||||
PhotoFavorite.photo_id.in_(photo_ids)
|
||||
).all()
|
||||
existing_ids = {fav.photo_id for fav in existing_favorites}
|
||||
|
||||
# Only remove favorites for photos that are currently favorites
|
||||
photos_to_remove = [photo_id for photo_id in photo_ids if photo_id in existing_ids]
|
||||
|
||||
removed_count = 0
|
||||
for favorite in existing_favorites:
|
||||
if favorite.photo_id in photos_to_remove:
|
||||
db.delete(favorite)
|
||||
removed_count += 1
|
||||
|
||||
db.commit()
|
||||
|
||||
return BulkRemoveFavoritesResponse(
|
||||
message=f"Removed {removed_count} photo(s) from favorites",
|
||||
removed_count=removed_count,
|
||||
not_favorite_count=len(photo_ids) - len(existing_ids),
|
||||
total_requested=len(photo_ids),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{photo_id}/open-folder")
|
||||
def open_photo_folder(photo_id: int, db: Session = Depends(get_db)) -> dict:
|
||||
"""Open the folder containing the photo in the system file manager and select the file.
|
||||
|
||||
+1
-1
@@ -103,7 +103,7 @@ async def lifespan(app: FastAPI):
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
# Check if required application tables exist (not just alembic_version)
|
||||
required_tables = {"photos", "people", "faces", "tags", "phototaglinkage", "person_encodings"}
|
||||
required_tables = {"photos", "people", "faces", "tags", "phototaglinkage", "person_encodings", "photo_favorites"}
|
||||
missing_tables = required_tables - existing_tables
|
||||
|
||||
if missing_tables:
|
||||
|
||||
@@ -43,6 +43,7 @@ class Photo(Base):
|
||||
photo_tags = relationship(
|
||||
"PhotoTagLinkage", back_populates="photo", cascade="all, delete-orphan"
|
||||
)
|
||||
favorites = relationship("PhotoFavorite", back_populates="photo", cascade="all, delete-orphan")
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_photos_processed", "processed"),
|
||||
@@ -175,3 +176,22 @@ class PhotoTagLinkage(Base):
|
||||
Index("idx_photo_tags_photo", "photo_id"),
|
||||
)
|
||||
|
||||
|
||||
class PhotoFavorite(Base):
|
||||
"""Photo favorites model - user-specific favorites."""
|
||||
|
||||
__tablename__ = "photo_favorites"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
username = Column(Text, nullable=False, index=True)
|
||||
photo_id = Column(Integer, ForeignKey("photos.id"), nullable=False, index=True)
|
||||
created_date = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
photo = relationship("Photo", back_populates="favorites")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("username", "photo_id", name="uq_user_photo_favorite"),
|
||||
Index("idx_favorites_username", "username"),
|
||||
Index("idx_favorites_photo", "photo_id"),
|
||||
)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -44,3 +44,33 @@ class PhotoImportResponse(BaseModel):
|
||||
folder_path: Optional[str] = None
|
||||
estimated_photos: Optional[int] = None
|
||||
|
||||
|
||||
class BulkAddFavoritesRequest(BaseModel):
|
||||
"""Request to add multiple photos to favorites."""
|
||||
|
||||
photo_ids: List[int] = Field(..., description="List of photo IDs to add to favorites")
|
||||
|
||||
|
||||
class BulkAddFavoritesResponse(BaseModel):
|
||||
"""Response for bulk add favorites operation."""
|
||||
|
||||
message: str
|
||||
added_count: int
|
||||
already_favorite_count: int
|
||||
total_requested: int
|
||||
|
||||
|
||||
class BulkRemoveFavoritesRequest(BaseModel):
|
||||
"""Request to remove multiple photos from favorites."""
|
||||
|
||||
photo_ids: List[int] = Field(..., description="List of photo IDs to remove from favorites")
|
||||
|
||||
|
||||
class BulkRemoveFavoritesResponse(BaseModel):
|
||||
"""Response for bulk remove favorites operation."""
|
||||
|
||||
message: str
|
||||
removed_count: int
|
||||
not_favorite_count: int
|
||||
total_requested: int
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ class PhotoSearchResult(BaseModel):
|
||||
tags: List[str] = Field(default_factory=list) # All tags for the photo
|
||||
has_faces: bool = False
|
||||
face_count: int = 0
|
||||
is_favorite: bool = False # Whether photo is favorited by current user
|
||||
|
||||
|
||||
class SearchPhotosResponse(BaseModel):
|
||||
|
||||
@@ -291,6 +291,41 @@ def get_processed_photos(
|
||||
return results, total
|
||||
|
||||
|
||||
def get_favorite_photos(
|
||||
db: Session,
|
||||
username: str,
|
||||
folder_path: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
) -> Tuple[List[Photo], int]:
|
||||
"""Get all favorite photos for a user with pagination."""
|
||||
from src.web.db.models import PhotoFavorite
|
||||
|
||||
# Join favorites with photos
|
||||
query = (
|
||||
db.query(Photo)
|
||||
.join(PhotoFavorite, Photo.id == PhotoFavorite.photo_id)
|
||||
.filter(PhotoFavorite.username == username)
|
||||
)
|
||||
|
||||
if folder_path:
|
||||
query = query.filter(Photo.path.like(f"{folder_path}%"))
|
||||
|
||||
total = query.count()
|
||||
|
||||
# Order by favorite date (most recent first), then date_taken
|
||||
results = (
|
||||
query.order_by(PhotoFavorite.created_date.desc())
|
||||
.order_by(Photo.date_taken.desc().nulls_last())
|
||||
.order_by(Photo.date_added.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
|
||||
return results, total
|
||||
|
||||
|
||||
def get_unprocessed_photos(
|
||||
db: Session,
|
||||
folder_path: Optional[str] = None,
|
||||
|
||||
Reference in New Issue
Block a user