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:
parent
cd72913cd5
commit
5ca130f8bd
@ -73,7 +73,7 @@ export const photosApi = {
|
||||
},
|
||||
|
||||
searchPhotos: async (params: {
|
||||
search_type: 'name' | 'date' | 'tags' | 'no_faces' | 'no_tags' | 'processed' | 'unprocessed'
|
||||
search_type: 'name' | 'date' | 'tags' | 'no_faces' | 'no_tags' | 'processed' | 'unprocessed' | 'favorites'
|
||||
person_name?: string
|
||||
tag_names?: string
|
||||
match_all?: boolean
|
||||
@ -89,6 +89,36 @@ export const photosApi = {
|
||||
return data
|
||||
},
|
||||
|
||||
toggleFavorite: async (photoId: number): Promise<{ photo_id: number; is_favorite: boolean; message: string }> => {
|
||||
const { data } = await apiClient.post(
|
||||
`/api/v1/photos/${photoId}/toggle-favorite`
|
||||
)
|
||||
return data
|
||||
},
|
||||
|
||||
checkFavorite: async (photoId: number): Promise<{ photo_id: number; is_favorite: boolean }> => {
|
||||
const { data } = await apiClient.get(
|
||||
`/api/v1/photos/${photoId}/is-favorite`
|
||||
)
|
||||
return data
|
||||
},
|
||||
|
||||
bulkAddFavorites: async (photoIds: number[]): Promise<{ message: string; added_count: number; already_favorite_count: number; total_requested: number }> => {
|
||||
const { data } = await apiClient.post(
|
||||
'/api/v1/photos/bulk-add-favorites',
|
||||
{ photo_ids: photoIds }
|
||||
)
|
||||
return data
|
||||
},
|
||||
|
||||
bulkRemoveFavorites: async (photoIds: number[]): Promise<{ message: string; removed_count: number; not_favorite_count: number; total_requested: number }> => {
|
||||
const { data } = await apiClient.post(
|
||||
'/api/v1/photos/bulk-remove-favorites',
|
||||
{ photo_ids: photoIds }
|
||||
)
|
||||
return data
|
||||
},
|
||||
|
||||
openFolder: async (photoId: number): Promise<{ message: string; folder: string }> => {
|
||||
const { data } = await apiClient.post<{ message: string; folder: string }>(
|
||||
`/api/v1/photos/${photoId}/open-folder`
|
||||
@ -115,6 +145,7 @@ export interface PhotoSearchResult {
|
||||
tags: string[]
|
||||
has_faces: boolean
|
||||
face_count: number
|
||||
is_favorite?: boolean
|
||||
}
|
||||
|
||||
export interface SearchPhotosResponse {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { PhotoSearchResult } from '../api/photos'
|
||||
import { PhotoSearchResult, photosApi } from '../api/photos'
|
||||
import { apiClient } from '../api/client'
|
||||
|
||||
interface PhotoViewerProps {
|
||||
@ -37,6 +37,10 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [slideshowInterval, setSlideshowInterval] = useState(3) // seconds
|
||||
const slideshowTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
// Favorite state
|
||||
const [isFavorite, setIsFavorite] = useState(false)
|
||||
const [loadingFavorite, setLoadingFavorite] = useState(false)
|
||||
|
||||
const currentPhoto = photos[currentIndex]
|
||||
const canGoPrev = currentIndex > 0
|
||||
@ -180,10 +184,34 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView
|
||||
setPanX(0)
|
||||
setPanY(0)
|
||||
|
||||
// Load favorite status when photo changes
|
||||
photosApi.checkFavorite(currentPhoto.id)
|
||||
.then(result => setIsFavorite(result.is_favorite))
|
||||
.catch(err => {
|
||||
console.error('Error checking favorite:', err)
|
||||
setIsFavorite(false)
|
||||
})
|
||||
|
||||
// Preload adjacent images when current photo changes
|
||||
preloadAdjacent(currentIndex)
|
||||
}, [currentIndex, currentPhoto, photos.length])
|
||||
|
||||
// Toggle favorite
|
||||
const toggleFavorite = async () => {
|
||||
if (loadingFavorite || !currentPhoto) return
|
||||
|
||||
setLoadingFavorite(true)
|
||||
try {
|
||||
const result = await photosApi.toggleFavorite(currentPhoto.id)
|
||||
setIsFavorite(result.is_favorite)
|
||||
} catch (error) {
|
||||
console.error('Error toggling favorite:', error)
|
||||
alert('Error updating favorite status')
|
||||
} finally {
|
||||
setLoadingFavorite(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard navigation
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@ -255,9 +283,24 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Right Play Button */}
|
||||
{/* Top Right Controls */}
|
||||
<div className="absolute top-0 right-0 z-10 bg-black bg-opacity-70 text-white p-2 rounded-bl-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Favorite button */}
|
||||
<button
|
||||
onClick={toggleFavorite}
|
||||
disabled={loadingFavorite}
|
||||
className={`px-3 py-1 rounded text-xs ${
|
||||
isFavorite
|
||||
? 'bg-yellow-600 hover:bg-yellow-700'
|
||||
: 'bg-gray-700 hover:bg-gray-600'
|
||||
} disabled:opacity-50`}
|
||||
title={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
|
||||
>
|
||||
{isFavorite ? '⭐' : '☆'}
|
||||
</button>
|
||||
|
||||
{/* Slideshow controls */}
|
||||
{isPlaying && (
|
||||
<select
|
||||
value={slideshowInterval}
|
||||
|
||||
@ -4,7 +4,7 @@ import tagsApi, { TagResponse, PhotoTagItem } from '../api/tags'
|
||||
import { apiClient } from '../api/client'
|
||||
import PhotoViewer from '../components/PhotoViewer'
|
||||
|
||||
type SearchType = 'name' | 'date' | 'tags' | 'no_faces' | 'no_tags' | 'processed' | 'unprocessed'
|
||||
type SearchType = 'name' | 'date' | 'tags' | 'no_faces' | 'no_tags' | 'processed' | 'unprocessed' | 'favorites'
|
||||
|
||||
const SEARCH_TYPES: { value: SearchType; label: string }[] = [
|
||||
{ value: 'name', label: 'Search photos by name' },
|
||||
@ -14,6 +14,7 @@ const SEARCH_TYPES: { value: SearchType; label: string }[] = [
|
||||
{ value: 'no_tags', label: 'Photos without tags' },
|
||||
{ value: 'processed', label: 'Search processed photos' },
|
||||
{ value: 'unprocessed', label: 'Search un-processed photos' },
|
||||
{ value: 'favorites', label: '⭐ Favorite photos' },
|
||||
]
|
||||
|
||||
type SortColumn = 'person' | 'tags' | 'processed' | 'path' | 'date_taken'
|
||||
@ -57,6 +58,7 @@ export default function Search() {
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<Set<number>>(new Set())
|
||||
const [loadingTags, setLoadingTags] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [loadingFavorites, setLoadingFavorites] = useState(false)
|
||||
|
||||
// Photo viewer
|
||||
const [showPhotoViewer, setShowPhotoViewer] = useState(false)
|
||||
@ -133,7 +135,13 @@ export default function Search() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (searchType === 'no_faces' || searchType === 'no_tags' || searchType === 'processed' || searchType === 'unprocessed') {
|
||||
// Clear results from previous search when search type changes
|
||||
setResults([])
|
||||
setTotal(0)
|
||||
setPage(1)
|
||||
setSelectedPhotos(new Set())
|
||||
|
||||
if (searchType === 'no_faces' || searchType === 'no_tags' || searchType === 'processed' || searchType === 'unprocessed' || searchType === 'favorites') {
|
||||
handleSearch()
|
||||
}
|
||||
// Clear selected tags when switching away from tag search
|
||||
@ -408,6 +416,66 @@ export default function Search() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddToFavorites = async () => {
|
||||
if (selectedPhotos.size === 0) {
|
||||
alert('Please select photos to add to favorites.')
|
||||
return
|
||||
}
|
||||
|
||||
setLoadingFavorites(true)
|
||||
try {
|
||||
const photoIds = Array.from(selectedPhotos)
|
||||
|
||||
// Use bulk endpoint for better performance
|
||||
await photosApi.bulkAddFavorites(photoIds)
|
||||
|
||||
// Refresh search results to update favorite status
|
||||
await performSearch(page)
|
||||
|
||||
// Clear selection after successful operation
|
||||
setSelectedPhotos(new Set())
|
||||
} catch (error) {
|
||||
console.error('Error adding photos to favorites:', error)
|
||||
} finally {
|
||||
setLoadingFavorites(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveFromFavorites = async () => {
|
||||
if (selectedPhotos.size === 0) {
|
||||
alert('Please select photos to remove from favorites.')
|
||||
return
|
||||
}
|
||||
|
||||
setLoadingFavorites(true)
|
||||
try {
|
||||
const photoIds = Array.from(selectedPhotos)
|
||||
|
||||
// Use bulk endpoint for better performance
|
||||
await photosApi.bulkRemoveFavorites(photoIds)
|
||||
|
||||
// Refresh search results to update favorite status
|
||||
await performSearch(page)
|
||||
|
||||
// Clear selection after successful operation
|
||||
setSelectedPhotos(new Set())
|
||||
} catch (error) {
|
||||
console.error('Error removing photos from favorites:', error)
|
||||
} finally {
|
||||
setLoadingFavorites(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleFavorite = async (photoId: number) => {
|
||||
try {
|
||||
await photosApi.toggleFavorite(photoId)
|
||||
// Refresh search results to update favorite status
|
||||
await performSearch(page)
|
||||
} catch (error) {
|
||||
console.error(`Error toggling favorite for photo ${photoId}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
// Load all photos for photo viewer (all pages)
|
||||
const loadAllPhotos = async () => {
|
||||
if (total === 0) {
|
||||
@ -676,6 +744,14 @@ export default function Search() {
|
||||
>
|
||||
{loadingAllPhotos ? 'Loading...' : '▶ Play photos'}
|
||||
</button>
|
||||
<button
|
||||
onClick={searchType === 'favorites' ? handleRemoveFromFavorites : handleAddToFavorites}
|
||||
disabled={selectedPhotos.size === 0 || loadingFavorites}
|
||||
className="px-3 py-1 text-sm border rounded hover:bg-gray-50 disabled:bg-gray-100 disabled:text-gray-400"
|
||||
title={searchType === 'favorites' ? 'Remove selected photos from favorites' : 'Add selected photos to favorites'}
|
||||
>
|
||||
{loadingFavorites ? '...' : '⭐'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowTagModal(true)}
|
||||
disabled={selectedPhotos.size === 0}
|
||||
@ -729,6 +805,7 @@ export default function Search() {
|
||||
Processed {sortColumn === 'processed' && (sortDir === 'asc' ? '↑' : '↓')}
|
||||
</th>
|
||||
)}
|
||||
<th className="text-left p-2 w-12">⭐</th>
|
||||
<th className="text-left p-2 w-12">📁</th>
|
||||
{searchType !== 'no_faces' && (
|
||||
<th className="text-left p-2 w-12">👤</th>
|
||||
@ -767,6 +844,21 @@ export default function Search() {
|
||||
{searchType !== 'name' && (
|
||||
<td className="p-2 text-center">{photo.processed ? 'Yes' : 'No'}</td>
|
||||
)}
|
||||
<td className="p-2 text-center">
|
||||
{photo.is_favorite && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleToggleFavorite(photo.id)
|
||||
}}
|
||||
className="cursor-pointer hover:text-yellow-600 text-lg"
|
||||
title="Remove from favorites"
|
||||
>
|
||||
⭐
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user