feat: multi-user auth with per-user media isolation
Introduce username/password authentication with admin and user roles.
Each user gets their own media directory under /photos/{username}/ with
isolated photos, folders, heaps, and tags. Admins manage users and
observe the full library from a dedicated Settings page.
Backend:
- User model with bcrypt passwords and JWT access/refresh tokens
- Auth router (login, refresh, setup, change-password, status)
- Admin router (user CRUD with last-admin protection)
- user_id FK added to photos, folders, source_roots, heaps, tags
- All data routers scoped by authenticated user
- Scanner inherits user_id from source root owner
- Thumbnails stored under user-prefixed paths for isolation
- Library endpoints accept ?scope=global for admin cross-user view
- Alembic migration 0009 with data migration for existing installs
- Defensive bootstrap.py handles fresh vs existing DB startup
Frontend:
- AuthContext with token lifecycle, auto-refresh, login/logout
- Login page, first-run setup page, auth gate in App.tsx
- Bearer token interceptor on all API requests
- User identity + logout in left sidebar
- Admin-only Settings page with Library Management and Users tabs
- UserManagement panel (add, edit role, reset password, deactivate)
- Settings shows global stats across all users for admin
- Filter bar, right sidebar, keyboard hints hidden on settings page
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
260
backend/app/routers/admin.py
Normal file
260
backend/app/routers/admin.py
Normal file
@@ -0,0 +1,260 @@
|
||||
"""
|
||||
Admin router — user management and app configuration.
|
||||
All endpoints require admin role.
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
from typing import Optional, List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, func as sa_func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import hash_password
|
||||
from app.database import get_db
|
||||
from app.dependencies import require_admin
|
||||
from app.models.user import User
|
||||
from app.models.photos import Photo
|
||||
from app.models.folders import SourceRoot
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class CreateUserRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
role: str = "user" # 'admin' | 'user'
|
||||
|
||||
class UpdateUserRequest(BaseModel):
|
||||
role: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
new_password: Optional[str] = None
|
||||
|
||||
class UserDetailResponse(BaseModel):
|
||||
id: str
|
||||
username: str
|
||||
email: Optional[str]
|
||||
role: str
|
||||
is_active: bool
|
||||
media_path: str
|
||||
created_at: Optional[str]
|
||||
photo_count: int = 0
|
||||
|
||||
class UserListResponse(BaseModel):
|
||||
users: List[UserDetailResponse]
|
||||
total: int
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# User CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/users", response_model=UserListResponse)
|
||||
async def list_users(
|
||||
admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List all users with their photo counts."""
|
||||
result = await db.execute(select(User).order_by(User.created_at))
|
||||
users = result.scalars().all()
|
||||
|
||||
user_list = []
|
||||
for u in users:
|
||||
count_result = await db.execute(
|
||||
select(sa_func.count(Photo.id)).where(Photo.user_id == u.id)
|
||||
)
|
||||
photo_count = count_result.scalar() or 0
|
||||
|
||||
user_list.append(UserDetailResponse(
|
||||
id=u.id,
|
||||
username=u.username,
|
||||
email=u.email,
|
||||
role=u.role,
|
||||
is_active=u.is_active,
|
||||
media_path=u.media_path,
|
||||
created_at=u.created_at.isoformat() if u.created_at else None,
|
||||
photo_count=photo_count,
|
||||
))
|
||||
|
||||
return UserListResponse(users=user_list, total=len(user_list))
|
||||
|
||||
|
||||
@router.post("/users", status_code=201, response_model=UserDetailResponse)
|
||||
async def create_user(
|
||||
body: CreateUserRequest,
|
||||
admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create a new user. Creates their media directory and source root."""
|
||||
if body.role not in ("admin", "user"):
|
||||
raise HTTPException(status_code=400, detail="Role must be 'admin' or 'user'")
|
||||
if len(body.username.strip()) < 2:
|
||||
raise HTTPException(status_code=400, detail="Username must be at least 2 characters")
|
||||
if len(body.password) < 6:
|
||||
raise HTTPException(status_code=400, detail="Password must be at least 6 characters")
|
||||
|
||||
# Check for duplicate username
|
||||
existing = await db.execute(
|
||||
select(User).where(User.username == body.username.strip())
|
||||
)
|
||||
if existing.scalar_one_or_none() is not None:
|
||||
raise HTTPException(status_code=409, detail="Username already taken")
|
||||
|
||||
media_path = os.path.join(settings.photo_dirs, body.username.strip())
|
||||
os.makedirs(media_path, exist_ok=True)
|
||||
|
||||
user = User(
|
||||
username=body.username.strip(),
|
||||
hashed_password=hash_password(body.password),
|
||||
role=body.role,
|
||||
media_path=media_path,
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush() # get user.id before creating source root
|
||||
|
||||
source_root = SourceRoot(
|
||||
name=f"{user.username}'s Library",
|
||||
path=media_path,
|
||||
user_id=user.id,
|
||||
)
|
||||
db.add(source_root)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"Admin '{admin.username}' created user '{user.username}' (role={user.role})")
|
||||
|
||||
return UserDetailResponse(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
media_path=user.media_path,
|
||||
created_at=user.created_at.isoformat() if user.created_at else None,
|
||||
photo_count=0,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/users/{user_id}", response_model=UserDetailResponse)
|
||||
async def get_user(
|
||||
user_id: str,
|
||||
admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get a single user's details."""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
count_result = await db.execute(
|
||||
select(sa_func.count(Photo.id)).where(Photo.user_id == user.id)
|
||||
)
|
||||
photo_count = count_result.scalar() or 0
|
||||
|
||||
return UserDetailResponse(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
media_path=user.media_path,
|
||||
created_at=user.created_at.isoformat() if user.created_at else None,
|
||||
photo_count=photo_count,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/users/{user_id}", response_model=UserDetailResponse)
|
||||
async def update_user(
|
||||
user_id: str,
|
||||
body: UpdateUserRequest,
|
||||
admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update a user's role, active status, or password."""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
if body.role is not None:
|
||||
if body.role not in ("admin", "user"):
|
||||
raise HTTPException(status_code=400, detail="Role must be 'admin' or 'user'")
|
||||
# Prevent demoting the last admin
|
||||
if user.role == "admin" and body.role == "user":
|
||||
admin_count = (await db.execute(
|
||||
select(sa_func.count(User.id)).where(User.role == "admin", User.is_active == True)
|
||||
)).scalar()
|
||||
if admin_count <= 1:
|
||||
raise HTTPException(status_code=400, detail="Cannot demote the last admin")
|
||||
user.role = body.role
|
||||
|
||||
if body.is_active is not None:
|
||||
# Prevent deactivating the last admin
|
||||
if user.role == "admin" and not body.is_active:
|
||||
admin_count = (await db.execute(
|
||||
select(sa_func.count(User.id)).where(User.role == "admin", User.is_active == True)
|
||||
)).scalar()
|
||||
if admin_count <= 1:
|
||||
raise HTTPException(status_code=400, detail="Cannot deactivate the last admin")
|
||||
user.is_active = body.is_active
|
||||
|
||||
if body.new_password is not None:
|
||||
if len(body.new_password) < 6:
|
||||
raise HTTPException(status_code=400, detail="Password must be at least 6 characters")
|
||||
user.hashed_password = hash_password(body.new_password)
|
||||
|
||||
await db.commit()
|
||||
|
||||
count_result = await db.execute(
|
||||
select(sa_func.count(Photo.id)).where(Photo.user_id == user.id)
|
||||
)
|
||||
photo_count = count_result.scalar() or 0
|
||||
|
||||
return UserDetailResponse(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
media_path=user.media_path,
|
||||
created_at=user.created_at.isoformat() if user.created_at else None,
|
||||
photo_count=photo_count,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}")
|
||||
async def delete_user(
|
||||
user_id: str,
|
||||
admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Soft-delete a user by deactivating them. Media is preserved."""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
if user.id == admin.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete yourself")
|
||||
|
||||
# Prevent deleting the last admin
|
||||
if user.role == "admin":
|
||||
admin_count = (await db.execute(
|
||||
select(sa_func.count(User.id)).where(User.role == "admin", User.is_active == True)
|
||||
)).scalar()
|
||||
if admin_count <= 1:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete the last admin")
|
||||
|
||||
user.is_active = False
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"Admin '{admin.username}' deactivated user '{user.username}'")
|
||||
return {"status": "ok", "detail": f"User '{user.username}' deactivated"}
|
||||
200
backend/app/routers/auth.py
Normal file
200
backend/app/routers/auth.py
Normal file
@@ -0,0 +1,200 @@
|
||||
"""
|
||||
Authentication router — login, token refresh, profile, first-run setup.
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, func as sa_func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import hash_password, verify_password, create_access_token, create_refresh_token, decode_token
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.folders import SourceRoot
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request / response schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: str
|
||||
username: str
|
||||
email: Optional[str]
|
||||
role: str
|
||||
is_active: bool
|
||||
created_at: Optional[str]
|
||||
|
||||
class SetupRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""Authenticate with username + password, receive JWT tokens."""
|
||||
result = await db.execute(
|
||||
select(User).where(User.username == body.username)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user is None or not verify_password(body.password, user.hashed_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid username or password",
|
||||
)
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Account is deactivated",
|
||||
)
|
||||
|
||||
return TokenResponse(
|
||||
access_token=create_access_token(user.id, user.role),
|
||||
refresh_token=create_refresh_token(user.id),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse)
|
||||
async def refresh_token(body: RefreshRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""Exchange a valid refresh token for a new access + refresh pair."""
|
||||
try:
|
||||
payload = decode_token(body.refresh_token)
|
||||
if payload.get("type") != "refresh":
|
||||
raise ValueError("not a refresh token")
|
||||
user_id = payload["sub"]
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired refresh token",
|
||||
)
|
||||
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None or not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found or deactivated",
|
||||
)
|
||||
|
||||
return TokenResponse(
|
||||
access_token=create_access_token(user.id, user.role),
|
||||
refresh_token=create_refresh_token(user.id),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def get_me(current_user: User = Depends(get_current_user)):
|
||||
"""Return the authenticated user's profile."""
|
||||
return UserResponse(
|
||||
id=current_user.id,
|
||||
username=current_user.username,
|
||||
email=current_user.email,
|
||||
role=current_user.role,
|
||||
is_active=current_user.is_active,
|
||||
created_at=current_user.created_at.isoformat() if current_user.created_at else None,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/change-password")
|
||||
async def change_password(
|
||||
body: ChangePasswordRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Change the authenticated user's password."""
|
||||
if not verify_password(body.current_password, current_user.hashed_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Current password is incorrect",
|
||||
)
|
||||
current_user.hashed_password = hash_password(body.new_password)
|
||||
await db.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/setup", response_model=TokenResponse, status_code=201)
|
||||
async def setup(body: SetupRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""First-run only: create the initial admin account.
|
||||
|
||||
Returns 409 if any user already exists. This endpoint is
|
||||
unauthenticated by design — it can only run once.
|
||||
"""
|
||||
count = (await db.execute(select(sa_func.count(User.id)))).scalar()
|
||||
if count > 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Setup already completed — users exist",
|
||||
)
|
||||
|
||||
if len(body.username.strip()) < 2:
|
||||
raise HTTPException(status_code=400, detail="Username must be at least 2 characters")
|
||||
if len(body.password) < 6:
|
||||
raise HTTPException(status_code=400, detail="Password must be at least 6 characters")
|
||||
|
||||
media_path = os.path.join(settings.photo_dirs, body.username.strip())
|
||||
os.makedirs(media_path, exist_ok=True)
|
||||
|
||||
user = User(
|
||||
username=body.username.strip(),
|
||||
hashed_password=hash_password(body.password),
|
||||
role="admin",
|
||||
media_path=media_path,
|
||||
)
|
||||
db.add(user)
|
||||
|
||||
# Create a source root for the new admin's media directory
|
||||
source_root = SourceRoot(
|
||||
name=f"{user.username}'s Library",
|
||||
path=media_path,
|
||||
user_id=user.id,
|
||||
)
|
||||
db.add(source_root)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"Initial admin account created: {user.username}")
|
||||
|
||||
return TokenResponse(
|
||||
access_token=create_access_token(user.id, user.role),
|
||||
refresh_token=create_refresh_token(user.id),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def auth_status(db: AsyncSession = Depends(get_db)):
|
||||
"""Public endpoint: returns whether setup has been completed.
|
||||
|
||||
The frontend calls this to decide whether to show the setup page
|
||||
or the login page.
|
||||
"""
|
||||
count = (await db.execute(select(sa_func.count(User.id)))).scalar()
|
||||
return {"setup_completed": count > 0}
|
||||
@@ -9,25 +9,27 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Photo
|
||||
from app.models.user import User
|
||||
from app.dependencies import get_current_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("")
|
||||
async def list_discarded(db: AsyncSession = Depends(get_db)):
|
||||
async def list_discarded(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""List discarded photos"""
|
||||
result = await db.execute(
|
||||
select(Photo).where(Photo.is_discarded == True)
|
||||
select(Photo).where(Photo.is_discarded == True, Photo.user_id == current_user.id)
|
||||
)
|
||||
photos = result.scalars().all()
|
||||
return photos
|
||||
|
||||
@router.post("/restore")
|
||||
async def restore_photos(photo_ids: list[str], db: AsyncSession = Depends(get_db)):
|
||||
async def restore_photos(photo_ids: list[str], db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""Restore photos from the discard pile"""
|
||||
result = await db.execute(
|
||||
select(Photo).where(and_(Photo.id.in_(photo_ids), Photo.is_discarded == True))
|
||||
select(Photo).where(and_(Photo.id.in_(photo_ids), Photo.is_discarded == True, Photo.user_id == current_user.id))
|
||||
)
|
||||
photos = result.scalars().all()
|
||||
|
||||
@@ -39,12 +41,12 @@ async def restore_photos(photo_ids: list[str], db: AsyncSession = Depends(get_db
|
||||
return {"status": "success", "restored": len(photos)}
|
||||
|
||||
@router.delete("/empty")
|
||||
async def empty_discard(db: AsyncSession = Depends(get_db)):
|
||||
async def empty_discard(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""Permanently delete all discarded photos and unlink their files from
|
||||
disk. Failures on individual files are logged but don't abort the batch.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Photo).where(Photo.is_discarded == True)
|
||||
select(Photo).where(Photo.is_discarded == True, Photo.user_id == current_user.id)
|
||||
)
|
||||
photos = result.scalars().all()
|
||||
return await _permanently_delete(db, photos)
|
||||
@@ -54,6 +56,7 @@ async def empty_discard(db: AsyncSession = Depends(get_db)):
|
||||
async def delete_discarded(
|
||||
photo_ids: list[str] = Body(..., embed=True),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Permanently delete a specific subset of discarded photos. The photos
|
||||
must already be in the discard pile — non-discarded ids are skipped so
|
||||
@@ -63,7 +66,7 @@ async def delete_discarded(
|
||||
return {"status": "success", "deleted": 0, "file_errors": 0}
|
||||
result = await db.execute(
|
||||
select(Photo).where(
|
||||
and_(Photo.id.in_(photo_ids), Photo.is_discarded == True)
|
||||
and_(Photo.id.in_(photo_ids), Photo.is_discarded == True, Photo.user_id == current_user.id)
|
||||
)
|
||||
)
|
||||
photos = result.scalars().all()
|
||||
|
||||
@@ -16,6 +16,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Folder, SourceRoot, Photo
|
||||
from app.models.user import User
|
||||
from app.dependencies import get_current_user, get_user_folder
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -48,10 +50,10 @@ def _validate_folder_name(name: str) -> str:
|
||||
return name
|
||||
|
||||
@router.get("")
|
||||
async def get_folders(db: AsyncSession = Depends(get_db)):
|
||||
async def get_folders(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""Get all source folders"""
|
||||
# Get source roots instead of regular folders
|
||||
result = await db.execute(select(SourceRoot).where(SourceRoot.is_active == True))
|
||||
result = await db.execute(select(SourceRoot).where(SourceRoot.is_active == True, SourceRoot.user_id == current_user.id))
|
||||
source_roots = result.scalars().all()
|
||||
|
||||
folders_list = []
|
||||
@@ -73,7 +75,7 @@ async def get_folders(db: AsyncSession = Depends(get_db)):
|
||||
return {"folders": folders_list}
|
||||
|
||||
@router.get("/tree")
|
||||
async def get_folder_tree(db: AsyncSession = Depends(get_db)):
|
||||
async def get_folder_tree(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""Recursive folder tree, one root per active SourceRoot. The tree
|
||||
starts at the Folder row matching the SourceRoot.path (the scanner
|
||||
creates one for every walked directory), with the SourceRoot's
|
||||
@@ -97,7 +99,7 @@ async def get_folder_tree(db: AsyncSession = Depends(get_db)):
|
||||
creates as a parent walk) are skipped via path-prefix filtering.
|
||||
"""
|
||||
sr_result = await db.execute(
|
||||
select(SourceRoot).where(SourceRoot.is_active == True) # noqa: E712
|
||||
select(SourceRoot).where(SourceRoot.is_active == True, SourceRoot.user_id == current_user.id) # noqa: E712
|
||||
)
|
||||
source_roots = sr_result.scalars().all()
|
||||
|
||||
@@ -196,6 +198,7 @@ async def rename_folder(
|
||||
folder_id: str,
|
||||
body: FolderRename,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Rename a folder. Two cases:
|
||||
|
||||
@@ -212,7 +215,7 @@ async def rename_folder(
|
||||
|
||||
# Try SourceRoot first (display-only rename).
|
||||
sr_result = await db.execute(
|
||||
select(SourceRoot).where(SourceRoot.id == folder_id)
|
||||
select(SourceRoot).where(SourceRoot.id == folder_id, SourceRoot.user_id == current_user.id)
|
||||
)
|
||||
source_root = sr_result.scalar_one_or_none()
|
||||
if source_root:
|
||||
@@ -225,14 +228,11 @@ async def rename_folder(
|
||||
}
|
||||
|
||||
# Otherwise it's a Folder row.
|
||||
folder_result = await db.execute(select(Folder).where(Folder.id == folder_id))
|
||||
folder = folder_result.scalar_one_or_none()
|
||||
if not folder:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
folder = await get_user_folder(folder_id, current_user, db)
|
||||
|
||||
# Refuse to rename the bare source root mount through here.
|
||||
sr_check = await db.execute(
|
||||
select(SourceRoot).where(SourceRoot.id == folder.source_root_id)
|
||||
select(SourceRoot).where(SourceRoot.id == folder.source_root_id, SourceRoot.user_id == current_user.id)
|
||||
)
|
||||
sr = sr_check.scalar_one_or_none()
|
||||
if sr and os.path.normpath(folder.path) == os.path.normpath(sr.path):
|
||||
@@ -291,7 +291,7 @@ async def rename_folder(
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_folder(body: FolderCreate, db: AsyncSession = Depends(get_db)):
|
||||
async def create_folder(body: FolderCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""Create a new sub-folder under an existing Folder. Mirrors the
|
||||
create to disk so the next scan sees it. Body: { name, parent_id }.
|
||||
parent_id MUST be an existing Folder row id (any descendant of a
|
||||
@@ -300,12 +300,7 @@ async def create_folder(body: FolderCreate, db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
name = _validate_folder_name(body.name)
|
||||
|
||||
parent_result = await db.execute(
|
||||
select(Folder).where(Folder.id == body.parent_id)
|
||||
)
|
||||
parent = parent_result.scalar_one_or_none()
|
||||
if not parent:
|
||||
raise HTTPException(status_code=404, detail="Parent folder not found")
|
||||
parent = await get_user_folder(body.parent_id, current_user, db)
|
||||
|
||||
new_path = os.path.join(parent.path, name)
|
||||
if os.path.exists(new_path):
|
||||
@@ -323,6 +318,7 @@ async def create_folder(body: FolderCreate, db: AsyncSession = Depends(get_db)):
|
||||
name=name,
|
||||
path=new_path,
|
||||
source_root_id=parent.source_root_id,
|
||||
user_id=current_user.id,
|
||||
photo_count=0,
|
||||
)
|
||||
db.add(new_folder)
|
||||
@@ -341,6 +337,7 @@ async def delete_folder(
|
||||
folder_id: str,
|
||||
mode: Literal['discard', 'permanent'] = Query('discard'),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Delete a folder. Behavior depends on mode:
|
||||
|
||||
@@ -357,13 +354,10 @@ async def delete_folder(
|
||||
Refuses to delete the bare source-root mount in either mode (deleting
|
||||
the docker mount through the UI would be a footgun).
|
||||
"""
|
||||
folder_result = await db.execute(select(Folder).where(Folder.id == folder_id))
|
||||
folder = folder_result.scalar_one_or_none()
|
||||
if not folder:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
folder = await get_user_folder(folder_id, current_user, db)
|
||||
|
||||
sr_check = await db.execute(
|
||||
select(SourceRoot).where(SourceRoot.id == folder.source_root_id)
|
||||
select(SourceRoot).where(SourceRoot.id == folder.source_root_id, SourceRoot.user_id == current_user.id)
|
||||
)
|
||||
sr = sr_check.scalar_one_or_none()
|
||||
if sr and os.path.normpath(folder.path) == os.path.normpath(sr.path):
|
||||
@@ -484,6 +478,7 @@ async def set_folder_hidden(
|
||||
folder_id: str,
|
||||
body: FolderHide,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Toggle the "hide from views" flag on a folder or source root.
|
||||
|
||||
@@ -502,7 +497,7 @@ async def set_folder_hidden(
|
||||
"""
|
||||
# SourceRoot path — resolve to the Folder row at the mount point.
|
||||
sr_result = await db.execute(
|
||||
select(SourceRoot).where(SourceRoot.id == folder_id)
|
||||
select(SourceRoot).where(SourceRoot.id == folder_id, SourceRoot.user_id == current_user.id)
|
||||
)
|
||||
source_root = sr_result.scalar_one_or_none()
|
||||
|
||||
@@ -511,6 +506,7 @@ async def set_folder_hidden(
|
||||
root_folder_result = await db.execute(
|
||||
select(Folder).where(
|
||||
Folder.source_root_id == source_root.id,
|
||||
Folder.user_id == current_user.id,
|
||||
Folder.path == os.path.normpath(source_root.path),
|
||||
)
|
||||
)
|
||||
@@ -521,12 +517,7 @@ async def set_folder_hidden(
|
||||
detail="Source root has no indexed Folder row yet; scan first.",
|
||||
)
|
||||
else:
|
||||
folder_result = await db.execute(
|
||||
select(Folder).where(Folder.id == folder_id)
|
||||
)
|
||||
folder = folder_result.scalar_one_or_none()
|
||||
if folder is None:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
folder = await get_user_folder(folder_id, current_user, db)
|
||||
|
||||
folder.is_hidden = bool(body.hidden)
|
||||
await db.flush()
|
||||
@@ -547,11 +538,11 @@ async def set_folder_hidden(
|
||||
|
||||
|
||||
@router.post("/{folder_id}/scan")
|
||||
async def scan_folder(folder_id: str, db: AsyncSession = Depends(get_db)):
|
||||
async def scan_folder(folder_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""Trigger manual re-scan of source root folder"""
|
||||
from app.tasks.celery import celery_app
|
||||
|
||||
result = await db.execute(select(SourceRoot).where(SourceRoot.id == folder_id))
|
||||
|
||||
result = await db.execute(select(SourceRoot).where(SourceRoot.id == folder_id, SourceRoot.user_id == current_user.id))
|
||||
source_root = result.scalar_one_or_none()
|
||||
|
||||
if not source_root:
|
||||
|
||||
@@ -14,6 +14,8 @@ from app.database import get_db
|
||||
from app.models import Heap, Photo, Folder
|
||||
from app.models.folders import SourceRoot
|
||||
from app.models.heaps import heap_photos
|
||||
from app.models.user import User
|
||||
from app.dependencies import get_current_user, get_user_heap
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -48,7 +50,10 @@ class HeapConvertBody(BaseModel):
|
||||
# ── Endpoints ─────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("")
|
||||
async def list_heaps(db: AsyncSession = Depends(get_db)):
|
||||
async def list_heaps(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List all heaps with photo counts."""
|
||||
# LEFT JOIN heap_photos and group so we can return counts in one query.
|
||||
count_subq = (
|
||||
@@ -63,6 +68,7 @@ async def list_heaps(db: AsyncSession = Depends(get_db)):
|
||||
stmt = (
|
||||
select(Heap, count_subq.c.photo_count)
|
||||
.outerjoin(count_subq, Heap.id == count_subq.c.heap_id)
|
||||
.where(Heap.user_id == current_user.id)
|
||||
.order_by(Heap.created_at.asc())
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
@@ -82,12 +88,16 @@ async def list_heaps(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_heap(body: HeapCreate, db: AsyncSession = Depends(get_db)):
|
||||
async def create_heap(
|
||||
body: HeapCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Create a new heap."""
|
||||
name = (body.name or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="Heap name is required")
|
||||
heap = Heap(name=name)
|
||||
heap = Heap(name=name, user_id=current_user.id)
|
||||
db.add(heap)
|
||||
await db.commit()
|
||||
await db.refresh(heap)
|
||||
@@ -103,14 +113,14 @@ async def create_heap(body: HeapCreate, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
@router.patch("/{heap_id}")
|
||||
async def update_heap(
|
||||
heap_id: str, body: HeapUpdate, db: AsyncSession = Depends(get_db)
|
||||
heap_id: str,
|
||||
body: HeapUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Rename a heap and/or toggle active state. Setting is_active=true on
|
||||
one heap deactivates all others (single-active invariant)."""
|
||||
result = await db.execute(select(Heap).where(Heap.id == heap_id))
|
||||
heap = result.scalar_one_or_none()
|
||||
if not heap:
|
||||
raise HTTPException(status_code=404, detail="Heap not found")
|
||||
heap = await get_user_heap(heap_id, current_user, db)
|
||||
|
||||
if body.name is not None:
|
||||
name = body.name.strip()
|
||||
@@ -120,8 +130,12 @@ async def update_heap(
|
||||
|
||||
if body.is_active is not None:
|
||||
if body.is_active:
|
||||
# Clear active flag on all other heaps in one statement
|
||||
await db.execute(update(Heap).values(is_active=False))
|
||||
# Clear active flag on all other heaps for this user
|
||||
await db.execute(
|
||||
update(Heap)
|
||||
.where(Heap.user_id == current_user.id)
|
||||
.values(is_active=False)
|
||||
)
|
||||
heap.is_active = True
|
||||
else:
|
||||
heap.is_active = False
|
||||
@@ -138,17 +152,18 @@ async def update_heap(
|
||||
|
||||
|
||||
@router.post("/{heap_id}/duplicate", status_code=201)
|
||||
async def duplicate_heap(heap_id: str, db: AsyncSession = Depends(get_db)):
|
||||
async def duplicate_heap(
|
||||
heap_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Create a new heap with the same membership as an existing one. The
|
||||
new heap is named "{original} (copy)" and is never the active target —
|
||||
duplicating shouldn't quietly steal the user's T-key destination.
|
||||
"""
|
||||
result = await db.execute(select(Heap).where(Heap.id == heap_id))
|
||||
source = result.scalar_one_or_none()
|
||||
if not source:
|
||||
raise HTTPException(status_code=404, detail="Heap not found")
|
||||
source = await get_user_heap(heap_id, current_user, db)
|
||||
|
||||
new_heap = Heap(name=f"{source.name} (copy)", is_active=False)
|
||||
new_heap = Heap(name=f"{source.name} (copy)", is_active=False, user_id=current_user.id)
|
||||
db.add(new_heap)
|
||||
await db.flush() # populate new_heap.id without committing yet
|
||||
|
||||
@@ -178,24 +193,30 @@ async def duplicate_heap(heap_id: str, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.delete("/{heap_id}", status_code=204)
|
||||
async def delete_heap(heap_id: str, db: AsyncSession = Depends(get_db)):
|
||||
async def delete_heap(
|
||||
heap_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Delete a heap. Photos themselves are unaffected — only the membership
|
||||
rows in heap_photos cascade-delete."""
|
||||
result = await db.execute(select(Heap).where(Heap.id == heap_id))
|
||||
heap = result.scalar_one_or_none()
|
||||
if not heap:
|
||||
raise HTTPException(status_code=404, detail="Heap not found")
|
||||
heap = await get_user_heap(heap_id, current_user, db)
|
||||
await db.delete(heap)
|
||||
await db.commit()
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/{heap_id}/photo_ids")
|
||||
async def get_heap_photo_ids(heap_id: str, db: AsyncSession = Depends(get_db)):
|
||||
async def get_heap_photo_ids(
|
||||
heap_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return just the photo ids belonging to a heap. Used by the frontend
|
||||
to maintain a fast client-side membership lookup for the active heap
|
||||
(for the basket affordance on thumbnails) without fetching full photo
|
||||
records."""
|
||||
await get_user_heap(heap_id, current_user, db)
|
||||
result = await db.execute(
|
||||
select(heap_photos.c.photo_id).where(heap_photos.c.heap_id == heap_id)
|
||||
)
|
||||
@@ -204,14 +225,14 @@ async def get_heap_photo_ids(heap_id: str, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
@router.post("/{heap_id}/photos")
|
||||
async def add_photos_to_heap(
|
||||
heap_id: str, body: HeapPhotosBody, db: AsyncSession = Depends(get_db)
|
||||
heap_id: str,
|
||||
body: HeapPhotosBody,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Add photos to a heap. Idempotent: re-adding existing members is a
|
||||
no-op (handled by an INSERT OR IGNORE-style filter on duplicates)."""
|
||||
result = await db.execute(select(Heap).where(Heap.id == heap_id))
|
||||
heap = result.scalar_one_or_none()
|
||||
if not heap:
|
||||
raise HTTPException(status_code=404, detail="Heap not found")
|
||||
await get_user_heap(heap_id, current_user, db)
|
||||
|
||||
if not body.photo_ids:
|
||||
return {"status": "success", "added": 0}
|
||||
@@ -241,6 +262,7 @@ async def convert_heap_to_folder(
|
||||
heap_id: str,
|
||||
body: HeapConvertBody,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Convert a heap into a folder by moving (or copying) every member
|
||||
photo into the target directory. Optionally deletes the heap row at
|
||||
@@ -249,10 +271,7 @@ async def convert_heap_to_folder(
|
||||
target_id may be a Folder id or a SourceRoot id (matches the
|
||||
/photos/move convention so the same dropdown can populate it).
|
||||
"""
|
||||
heap_result = await db.execute(select(Heap).where(Heap.id == heap_id))
|
||||
heap = heap_result.scalar_one_or_none()
|
||||
if not heap:
|
||||
raise HTTPException(status_code=404, detail="Heap not found")
|
||||
heap = await get_user_heap(heap_id, current_user, db)
|
||||
|
||||
# Resolve target_id → (target_dir, target_folder)
|
||||
sr_check = await db.execute(
|
||||
@@ -402,13 +421,13 @@ async def convert_heap_to_folder(
|
||||
|
||||
@router.delete("/{heap_id}/photos")
|
||||
async def remove_photos_from_heap(
|
||||
heap_id: str, body: HeapPhotosBody, db: AsyncSession = Depends(get_db)
|
||||
heap_id: str,
|
||||
body: HeapPhotosBody,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Remove photos from a heap. Removing a non-member is a no-op."""
|
||||
result = await db.execute(select(Heap).where(Heap.id == heap_id))
|
||||
heap = result.scalar_one_or_none()
|
||||
if not heap:
|
||||
raise HTTPException(status_code=404, detail="Heap not found")
|
||||
await get_user_heap(heap_id, current_user, db)
|
||||
|
||||
if not body.photo_ids:
|
||||
return {"status": "success", "removed": 0}
|
||||
|
||||
@@ -11,42 +11,42 @@ import os
|
||||
import shutil
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select, func, update
|
||||
from sqlalchemy import select, func, update, true as sa_true
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Photo
|
||||
from app.models.user import User
|
||||
from app.dependencies import get_current_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _owner_filter(user: User, scope: str | None):
|
||||
"""Return a column expression scoping photos to the current user,
|
||||
or a pass-through true() when an admin requests global scope."""
|
||||
if scope == "global" and user.role == "admin":
|
||||
return sa_true()
|
||||
return Photo.user_id == user.id
|
||||
|
||||
# Media types we accept in the regenerate-thumbnails request body. Mirrors
|
||||
# the values produced by `app.tasks.scan.get_media_type`.
|
||||
_VALID_MEDIA_TYPES = {'photo', 'raw', 'heic', 'video'}
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_library_stats(db: AsyncSession = Depends(get_db)):
|
||||
"""Get library statistics + per-section counts. Each section count
|
||||
matches the filter the sidebar applies when you click it, so the
|
||||
sidebar badges and the timeline below them stay in sync.
|
||||
|
||||
- all_photos: non-discarded photos + videos (matches the All
|
||||
Photos section's default filter)
|
||||
- rated: non-discarded with rating >= 1
|
||||
- colored: non-discarded with a color_label set (matches the
|
||||
Colors grouped view's labeled buckets)
|
||||
- duplicates: non-discarded with is_duplicate = true
|
||||
- discarded: is_discarded = true
|
||||
- total_size: raw bytes across every row, including discarded
|
||||
"""
|
||||
# Every sidebar badge runs against this filter. `not_visible` is the
|
||||
# inverse: a photo is visible iff it's neither discarded nor hidden
|
||||
# (marked hidden-from-views via a folder toggle). Kept as a single
|
||||
# expression so every sub-count below applies it identically.
|
||||
visible = (Photo.is_discarded.is_(False)) & (Photo.is_hidden.is_(False))
|
||||
async def get_library_stats(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
scope: str | None = Query(None),
|
||||
):
|
||||
"""Get library statistics. Pass ?scope=global (admin only) for
|
||||
cross-user totals (used by the Settings page)."""
|
||||
owner = _owner_filter(current_user, scope)
|
||||
visible = owner & (Photo.is_discarded.is_(False)) & (Photo.is_hidden.is_(False))
|
||||
|
||||
all_photos_count = (
|
||||
await db.execute(select(func.count(Photo.id)).where(visible))
|
||||
@@ -84,7 +84,7 @@ async def get_library_stats(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
discarded_count = (
|
||||
await db.execute(
|
||||
select(func.count(Photo.id)).where(Photo.is_discarded.is_(True))
|
||||
select(func.count(Photo.id)).where(owner, Photo.is_discarded.is_(True))
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
@@ -92,17 +92,18 @@ async def get_library_stats(db: AsyncSession = Depends(get_db)):
|
||||
photo_count = (
|
||||
await db.execute(
|
||||
select(func.count(Photo.id)).where(
|
||||
owner,
|
||||
Photo.media_type.in_(['photo', 'heic', 'raw'])
|
||||
)
|
||||
)
|
||||
).scalar() or 0
|
||||
video_count = (
|
||||
await db.execute(
|
||||
select(func.count(Photo.id)).where(Photo.media_type == 'video')
|
||||
select(func.count(Photo.id)).where(owner, Photo.media_type == 'video')
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
size = (await db.execute(select(func.sum(Photo.file_size)))).scalar() or 0
|
||||
size = (await db.execute(select(func.sum(Photo.file_size)).where(owner))).scalar() or 0
|
||||
|
||||
return {
|
||||
"all_photos": all_photos_count,
|
||||
@@ -118,7 +119,7 @@ async def get_library_stats(db: AsyncSession = Depends(get_db)):
|
||||
}
|
||||
|
||||
@router.post("/scan")
|
||||
async def trigger_scan():
|
||||
async def trigger_scan(current_user: User = Depends(get_current_user)):
|
||||
"""Trigger full library re-scan"""
|
||||
from app.tasks.scan import scan_all_source_roots
|
||||
|
||||
@@ -128,7 +129,7 @@ async def trigger_scan():
|
||||
|
||||
|
||||
@router.post("/backfill-gps")
|
||||
async def trigger_backfill_gps():
|
||||
async def trigger_backfill_gps(current_user: User = Depends(get_current_user)):
|
||||
"""Re-run EXIF metadata extraction on every photo that's still missing
|
||||
GPS coordinates. Useful after fixing the EXIF parser, or any time the
|
||||
Map view looks emptier than expected. Returns immediately — work runs
|
||||
@@ -139,7 +140,7 @@ async def trigger_backfill_gps():
|
||||
return {"status": "success", "message": "GPS backfill queued"}
|
||||
|
||||
@router.get("/scan/status")
|
||||
async def get_scan_status(db: AsyncSession = Depends(get_db)):
|
||||
async def get_scan_status(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""Get current scan status"""
|
||||
import redis
|
||||
from app.config import settings
|
||||
@@ -187,20 +188,27 @@ class RegenerateThumbnailsRequest(BaseModel):
|
||||
|
||||
|
||||
@router.get("/maintenance/thumbnail-stats")
|
||||
async def get_thumbnail_stats(db: AsyncSession = Depends(get_db)):
|
||||
async def get_thumbnail_stats(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
scope: str | None = Query(None),
|
||||
):
|
||||
"""Counts of photos by processing_status, plus a media-type breakdown
|
||||
so the Settings panel can show the user what's outstanding."""
|
||||
owner = _owner_filter(current_user, scope)
|
||||
status_rows = (
|
||||
await db.execute(
|
||||
select(Photo.processing_status, func.count(Photo.id)).group_by(
|
||||
Photo.processing_status
|
||||
)
|
||||
select(Photo.processing_status, func.count(Photo.id))
|
||||
.where(owner)
|
||||
.group_by(Photo.processing_status)
|
||||
)
|
||||
).all()
|
||||
|
||||
media_rows = (
|
||||
await db.execute(
|
||||
select(Photo.media_type, func.count(Photo.id)).group_by(Photo.media_type)
|
||||
select(Photo.media_type, func.count(Photo.id))
|
||||
.where(owner)
|
||||
.group_by(Photo.media_type)
|
||||
)
|
||||
).all()
|
||||
|
||||
@@ -222,6 +230,8 @@ async def get_thumbnail_stats(db: AsyncSession = Depends(get_db)):
|
||||
async def regenerate_thumbnails(
|
||||
body: RegenerateThumbnailsRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
scope: str | None = Query(None),
|
||||
):
|
||||
"""Reset matching photos' on-disk thumbnail directories and re-queue
|
||||
Celery thumbnail generation. Used by the Settings panel for the
|
||||
@@ -233,6 +243,8 @@ async def regenerate_thumbnails(
|
||||
"""
|
||||
from app.tasks.thumbs import generate_thumbnails
|
||||
|
||||
owner = _owner_filter(current_user, scope)
|
||||
|
||||
# Validate media_types early so a typo can't silently match nothing.
|
||||
media_types = body.media_types
|
||||
if media_types is not None:
|
||||
@@ -244,7 +256,7 @@ async def regenerate_thumbnails(
|
||||
f"Allowed: {sorted(_VALID_MEDIA_TYPES)}",
|
||||
}
|
||||
|
||||
query = select(Photo)
|
||||
query = select(Photo).where(owner)
|
||||
if media_types:
|
||||
query = query.where(Photo.media_type.in_(media_types))
|
||||
if body.only_failed:
|
||||
@@ -297,7 +309,11 @@ async def regenerate_thumbnails(
|
||||
|
||||
|
||||
@router.get("/maintenance/worker-status")
|
||||
async def get_worker_status(db: AsyncSession = Depends(get_db)):
|
||||
async def get_worker_status(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
scope: str | None = Query(None),
|
||||
):
|
||||
"""Diagnostics for the Celery worker fleet + recent task failures.
|
||||
|
||||
Surfaced in the Settings panel so the user can spot a stuck queue or
|
||||
@@ -316,6 +332,7 @@ async def get_worker_status(db: AsyncSession = Depends(get_db)):
|
||||
things failed without opening the DB.
|
||||
- broker_ok: bool — could we even reach Redis?
|
||||
"""
|
||||
owner = _owner_filter(current_user, scope)
|
||||
from app.tasks.celery import celery_app
|
||||
from app.config import settings
|
||||
import redis as _redis
|
||||
@@ -398,7 +415,7 @@ async def get_worker_status(db: AsyncSession = Depends(get_db)):
|
||||
# ----- Recent task failures from the photos table ----------------------
|
||||
failed_total = (
|
||||
await db.execute(
|
||||
select(func.count(Photo.id)).where(Photo.processing_status == 'failed')
|
||||
select(func.count(Photo.id)).where(owner, Photo.processing_status == 'failed')
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
@@ -411,7 +428,7 @@ async def get_worker_status(db: AsyncSession = Depends(get_db)):
|
||||
Photo.processing_error,
|
||||
Photo.updated_at,
|
||||
)
|
||||
.where(Photo.processing_status == 'failed')
|
||||
.where(owner, Photo.processing_status == 'failed')
|
||||
.order_by(Photo.updated_at.desc().nullslast())
|
||||
.limit(20)
|
||||
)
|
||||
@@ -454,7 +471,11 @@ async def get_worker_status(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.get("/maintenance/pipeline-stats")
|
||||
async def get_pipeline_stats(db: AsyncSession = Depends(get_db)):
|
||||
async def get_pipeline_stats(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
scope: str | None = Query(None),
|
||||
):
|
||||
"""Per-stage progress across the ingestion pipeline.
|
||||
|
||||
Returns a `{stage_key: {done, total, label}}` map so the Settings
|
||||
@@ -470,7 +491,8 @@ async def get_pipeline_stats(db: AsyncSession = Depends(get_db)):
|
||||
from app.models import Embedding, FaceEmbedding, OCRText
|
||||
from app.models.tags import photo_tags # association Table, not a model
|
||||
|
||||
not_discarded = Photo.is_discarded.is_(False)
|
||||
owner = _owner_filter(current_user, scope)
|
||||
not_discarded = owner & Photo.is_discarded.is_(False)
|
||||
|
||||
async def scalar_count(query):
|
||||
return (await db.execute(query)).scalar() or 0
|
||||
@@ -654,7 +676,7 @@ async def get_pipeline_stats(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.get("/maintenance/missing-stats")
|
||||
async def get_missing_stats():
|
||||
async def get_missing_stats(current_user: User = Depends(get_current_user)):
|
||||
"""Count photos whose files no longer exist on disk under a mounted
|
||||
source root. Surfaced in Settings so the user can see a number before
|
||||
pulling the trigger on prune-missing. Cheap enough to call freely."""
|
||||
@@ -663,7 +685,7 @@ async def get_missing_stats():
|
||||
|
||||
|
||||
@router.post("/maintenance/prune-missing")
|
||||
async def run_prune_missing():
|
||||
async def run_prune_missing(current_user: User = Depends(get_current_user)):
|
||||
"""Actually delete the orphaned photo rows reported by /missing-stats.
|
||||
Common cause: PHOTO_DIRS in .env was repointed at a different library
|
||||
leaving every old row dangling. Skips any photo whose source root
|
||||
@@ -677,7 +699,7 @@ async def run_prune_missing():
|
||||
|
||||
|
||||
@router.post("/maintenance/cleanup")
|
||||
async def run_data_integrity_cleanup():
|
||||
async def run_data_integrity_cleanup(current_user: User = Depends(get_current_user)):
|
||||
"""Re-run the source-roots / folders / photos data-integrity cleanup
|
||||
that normally only runs on backend startup. Idempotent."""
|
||||
from app.services.cleanup import cleanup_data_integrity
|
||||
@@ -695,7 +717,11 @@ async def run_data_integrity_cleanup():
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/duplicates/groups")
|
||||
async def get_duplicate_groups(db: AsyncSession = Depends(get_db)):
|
||||
async def get_duplicate_groups(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
scope: str | None = Query(None),
|
||||
):
|
||||
"""Return every duplicate group with its members.
|
||||
|
||||
Drives the frontend grouped grid view in the Duplicates section. One
|
||||
@@ -708,6 +734,7 @@ async def get_duplicate_groups(db: AsyncSession = Depends(get_db)):
|
||||
duplicates that the perceptual hash trivially caught)
|
||||
* "similar" — members differ at the byte level but match perceptually
|
||||
"""
|
||||
owner = _owner_filter(current_user, scope)
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(
|
||||
@@ -723,6 +750,7 @@ async def get_duplicate_groups(db: AsyncSession = Depends(get_db)):
|
||||
Photo.media_type,
|
||||
Photo.duplicate_group_id,
|
||||
)
|
||||
.where(owner)
|
||||
.where(Photo.duplicate_group_id.is_not(None))
|
||||
.where(Photo.is_discarded.is_(False))
|
||||
.where(Photo.is_hidden.is_(False))
|
||||
@@ -784,7 +812,7 @@ async def get_duplicate_groups(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/maintenance/regroup-duplicates")
|
||||
async def trigger_regroup_duplicates():
|
||||
async def trigger_regroup_duplicates(current_user: User = Depends(get_current_user)):
|
||||
"""Recompute duplicate groups from current perceptual hashes.
|
||||
|
||||
Fires the celery `regroup_duplicates` task which walks every photo's
|
||||
@@ -800,7 +828,7 @@ async def trigger_regroup_duplicates():
|
||||
|
||||
|
||||
@router.post("/maintenance/backfill-phashes")
|
||||
async def trigger_backfill_phashes():
|
||||
async def trigger_backfill_phashes(current_user: User = Depends(get_current_user)):
|
||||
"""Compute perceptual hashes for every photo currently missing one.
|
||||
|
||||
One-shot recovery path for libraries that existed before the phash
|
||||
|
||||
@@ -7,9 +7,10 @@ from pathlib import Path
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, and_, or_, func
|
||||
from sqlalchemy import select, and_, or_, func, tuple_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
@@ -19,11 +20,13 @@ logger = logging.getLogger(__name__)
|
||||
from app.database import get_db
|
||||
from app.models import Photo, Folder, Tag
|
||||
from app.models.folders import SourceRoot
|
||||
from app.models.user import User
|
||||
from app.models.heaps import heap_photos
|
||||
from app.models.tags import photo_tags
|
||||
from app.schemas.photos import PhotoResponse, PhotoUpdate, PhotoListResponse, BulkAction
|
||||
from app.services.exif_writer import ExifWriteError, write_taken_at
|
||||
from app.services.date_guess import has_date_warning as compute_date_warning
|
||||
from app.dependencies import get_current_user, get_user_photo
|
||||
from app.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
@@ -47,13 +50,15 @@ async def list_photos(
|
||||
order: str = "desc",
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(100, ge=1, le=500),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
cursor: Optional[str] = Query(None, description="Opaque cursor for keyset pagination"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List photos with filters and pagination"""
|
||||
|
||||
|
||||
# Build query — eager-load tags so the response can include them
|
||||
# without an N+1 round-trip per photo.
|
||||
query = select(Photo).options(selectinload(Photo.tags))
|
||||
# without an N+1 round-trip per photo. Scoped to the current user.
|
||||
query = select(Photo).options(selectinload(Photo.tags)).where(Photo.user_id == current_user.id)
|
||||
|
||||
# Apply filters
|
||||
filters = []
|
||||
@@ -206,24 +211,92 @@ async def list_photos(
|
||||
"rating": Photo.rating,
|
||||
}
|
||||
sort_column = SORT_WHITELIST.get(sort, Photo.taken_at)
|
||||
if order == "desc":
|
||||
query = query.order_by(sort_column.desc())
|
||||
desc = order == "desc"
|
||||
|
||||
# Keyset / cursor pagination — O(1) regardless of page depth.
|
||||
# The cursor encodes the last-seen (sort_value, id) pair so the DB
|
||||
# can seek directly to the next slice via an indexed range scan
|
||||
# instead of skipping N rows with OFFSET.
|
||||
if cursor:
|
||||
try:
|
||||
decoded = json.loads(base64.urlsafe_b64decode(cursor))
|
||||
cursor_val = decoded["v"]
|
||||
cursor_id = decoded["id"]
|
||||
# For datetime columns, parse the ISO string back.
|
||||
if sort in ("taken_at", "added_at") and cursor_val is not None:
|
||||
cursor_val = datetime.fromisoformat(cursor_val)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid cursor")
|
||||
|
||||
# Keyset condition: for DESC we want rows "less than" the cursor,
|
||||
# for ASC rows "greater than". We use (sort_col, id) tuple
|
||||
# comparison which handles NULLs and ties correctly.
|
||||
if desc:
|
||||
if cursor_val is None:
|
||||
# NULL sorts last in DESC with NULLS LAST — seek past it by id
|
||||
query = query.where(
|
||||
or_(
|
||||
sort_column.is_(None) & (Photo.id < cursor_id),
|
||||
)
|
||||
)
|
||||
else:
|
||||
query = query.where(
|
||||
or_(
|
||||
sort_column < cursor_val,
|
||||
and_(sort_column == cursor_val, Photo.id < cursor_id),
|
||||
sort_column.is_(None),
|
||||
)
|
||||
)
|
||||
else:
|
||||
if cursor_val is None:
|
||||
query = query.where(
|
||||
or_(
|
||||
sort_column.is_(None) & (Photo.id > cursor_id),
|
||||
)
|
||||
)
|
||||
else:
|
||||
query = query.where(
|
||||
or_(
|
||||
sort_column > cursor_val,
|
||||
and_(sort_column == cursor_val, Photo.id > cursor_id),
|
||||
)
|
||||
)
|
||||
|
||||
if desc:
|
||||
query = query.order_by(sort_column.desc().nulls_last(), Photo.id.desc())
|
||||
else:
|
||||
query = query.order_by(sort_column.asc())
|
||||
|
||||
# Count total results
|
||||
count_query = select(func.count()).select_from(query.subquery())
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar()
|
||||
|
||||
# Apply pagination
|
||||
offset = (page - 1) * per_page
|
||||
query = query.offset(offset).limit(per_page)
|
||||
|
||||
query = query.order_by(sort_column.asc().nulls_last(), Photo.id.asc())
|
||||
|
||||
# Count total results (only when no cursor — first page needs it;
|
||||
# subsequent pages reuse the total from the first response).
|
||||
total = None
|
||||
if not cursor:
|
||||
count_query = select(func.count()).select_from(query.subquery())
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar()
|
||||
|
||||
# Fallback to offset pagination when no cursor is provided and page > 1
|
||||
# (backward compat for any callers not yet using cursors).
|
||||
if not cursor and page > 1:
|
||||
offset = (page - 1) * per_page
|
||||
query = query.offset(offset)
|
||||
|
||||
query = query.limit(per_page)
|
||||
|
||||
# Execute query
|
||||
result = await db.execute(query)
|
||||
photos = result.scalars().all()
|
||||
|
||||
|
||||
# Build next_cursor from the last row in this batch.
|
||||
next_cursor = None
|
||||
if photos and len(photos) == per_page:
|
||||
last = photos[-1]
|
||||
sort_val = getattr(last, sort if sort in SORT_WHITELIST else "taken_at")
|
||||
if isinstance(sort_val, datetime):
|
||||
sort_val = sort_val.isoformat()
|
||||
cursor_payload = json.dumps({"v": sort_val, "id": last.id})
|
||||
next_cursor = base64.urlsafe_b64encode(cursor_payload.encode()).decode()
|
||||
|
||||
# Convert to response, attaching tags inline so the frontend can group
|
||||
# client-side without a second round-trip.
|
||||
photo_dicts = []
|
||||
@@ -235,21 +308,25 @@ async def list_photos(
|
||||
]
|
||||
photo_dicts.append(d)
|
||||
|
||||
return {
|
||||
response = {
|
||||
"photos": photo_dicts,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"pages": (total + per_page - 1) // per_page if total else 0,
|
||||
"next_cursor": next_cursor,
|
||||
}
|
||||
# Include total + legacy page fields on first page / non-cursor requests
|
||||
if total is not None:
|
||||
response["total"] = total
|
||||
response["page"] = page
|
||||
response["pages"] = (total + per_page - 1) // per_page if total else 0
|
||||
return response
|
||||
|
||||
@router.get("/map")
|
||||
async def list_photos_with_gps(db: AsyncSession = Depends(get_db)):
|
||||
async def list_photos_with_gps(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Lightweight listing of every non-discarded photo that has GPS
|
||||
coordinates, used by the Map view. Intentionally returns a flat list
|
||||
(no pagination) with only the fields the map renderer needs, so even
|
||||
large libraries serialize to a few MB at most. Declared *before*
|
||||
``/{photo_id}`` so the literal path wins the FastAPI route match."""
|
||||
coordinates, used by the Map view."""
|
||||
result = await db.execute(
|
||||
select(
|
||||
Photo.id,
|
||||
@@ -257,6 +334,7 @@ async def list_photos_with_gps(db: AsyncSession = Depends(get_db)):
|
||||
Photo.longitude,
|
||||
Photo.taken_at,
|
||||
).where(
|
||||
Photo.user_id == current_user.id,
|
||||
Photo.is_discarded.is_(False),
|
||||
Photo.is_hidden.is_(False),
|
||||
Photo.latitude.is_not(None),
|
||||
@@ -277,16 +355,11 @@ async def list_photos_with_gps(db: AsyncSession = Depends(get_db)):
|
||||
@router.get("/{photo_id}")
|
||||
async def get_photo(
|
||||
photo_id: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Get single photo with full EXIF and its tags."""
|
||||
result = await db.execute(
|
||||
select(Photo).where(Photo.id == photo_id)
|
||||
)
|
||||
photo = result.scalar_one_or_none()
|
||||
|
||||
if not photo:
|
||||
raise HTTPException(status_code=404, detail="Photo not found")
|
||||
photo = await get_user_photo(photo_id, current_user, db)
|
||||
|
||||
# Fetch tags via the join table so we don't need to declare a
|
||||
# relationship on the Photo model side.
|
||||
@@ -310,12 +383,11 @@ async def add_photo_tags(
|
||||
photo_id: str,
|
||||
body: dict,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Add one or more tags to a photo. Body: { tag_ids: [str, ...] }.
|
||||
Idempotent: re-adding existing members is a no-op."""
|
||||
photo_result = await db.execute(select(Photo).where(Photo.id == photo_id))
|
||||
if photo_result.scalar_one_or_none() is None:
|
||||
raise HTTPException(status_code=404, detail="Photo not found")
|
||||
await get_user_photo(photo_id, current_user, db)
|
||||
|
||||
tag_ids = body.get("tag_ids") or []
|
||||
if not isinstance(tag_ids, list) or not tag_ids:
|
||||
@@ -346,8 +418,10 @@ async def remove_photo_tag(
|
||||
photo_id: str,
|
||||
tag_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Remove a tag from a photo. Removing a non-member is a no-op."""
|
||||
await get_user_photo(photo_id, current_user, db)
|
||||
from sqlalchemy import delete as sql_delete
|
||||
await db.execute(
|
||||
sql_delete(photo_tags).where(
|
||||
@@ -363,22 +437,21 @@ async def get_thumbnail(
|
||||
photo_id: str,
|
||||
size: str,
|
||||
response: Response,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Serve thumbnail (with Nginx X-Accel-Redirect support)"""
|
||||
if size not in ['small', 'medium', 'large']:
|
||||
raise HTTPException(status_code=400, detail="Invalid thumbnail size")
|
||||
|
||||
result = await db.execute(
|
||||
select(Photo).where(Photo.id == photo_id)
|
||||
)
|
||||
photo = result.scalar_one_or_none()
|
||||
|
||||
if not photo:
|
||||
raise HTTPException(status_code=404, detail="Photo not found")
|
||||
|
||||
# Check if thumbnail exists, generate if not
|
||||
thumb_dir = f"/data/thumbs/{photo_id}"
|
||||
|
||||
photo = await get_user_photo(photo_id, current_user, db)
|
||||
|
||||
# Check if thumbnail exists, generate if not.
|
||||
# User-prefixed path for isolation.
|
||||
if photo.user_id:
|
||||
thumb_dir = f"/data/thumbs/{photo.user_id}/{photo_id}"
|
||||
else:
|
||||
thumb_dir = f"/data/thumbs/{photo_id}"
|
||||
thumb_path = f"{thumb_dir}/{size}.webp"
|
||||
|
||||
if not os.path.exists(thumb_path):
|
||||
@@ -451,16 +524,11 @@ async def get_thumbnail(
|
||||
@router.get("/{photo_id}/original")
|
||||
async def get_original(
|
||||
photo_id: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Serve original file (download for RAW, inline for web-safe formats)"""
|
||||
result = await db.execute(
|
||||
select(Photo).where(Photo.id == photo_id)
|
||||
)
|
||||
photo = result.scalar_one_or_none()
|
||||
|
||||
if not photo:
|
||||
raise HTTPException(status_code=404, detail="Photo not found")
|
||||
photo = await get_user_photo(photo_id, current_user, db)
|
||||
|
||||
if not os.path.exists(photo.filepath):
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
@@ -560,19 +628,10 @@ async def get_proxy(
|
||||
photo_id: str,
|
||||
response: Response,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Serve a full-resolution WebP proxy for non-web-safe formats (RAW, HEIC,
|
||||
TIFF) so the loupe view can display them inline. Web-safe formats are
|
||||
redirected to /original to avoid pointless transcoding.
|
||||
|
||||
Cached at /data/proxies/{photo_id}.webp; subsequent requests serve the
|
||||
cached file (with optional X-Accel-Redirect for production).
|
||||
"""
|
||||
result = await db.execute(select(Photo).where(Photo.id == photo_id))
|
||||
photo = result.scalar_one_or_none()
|
||||
|
||||
if not photo:
|
||||
raise HTTPException(status_code=404, detail="Photo not found")
|
||||
"""Serve a full-resolution WebP proxy for non-web-safe formats."""
|
||||
photo = await get_user_photo(photo_id, current_user, db)
|
||||
|
||||
if not os.path.exists(photo.filepath):
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
@@ -613,19 +672,11 @@ async def get_proxy(
|
||||
async def update_photo(
|
||||
photo_id: str,
|
||||
update: PhotoUpdate,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Update photo metadata. If `filename` is included, also rename the
|
||||
file on disk in its current directory (no cross-folder moves through
|
||||
this endpoint).
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Photo).where(Photo.id == photo_id)
|
||||
)
|
||||
photo = result.scalar_one_or_none()
|
||||
|
||||
if not photo:
|
||||
raise HTTPException(status_code=404, detail="Photo not found")
|
||||
"""Update photo metadata."""
|
||||
photo = await get_user_photo(photo_id, current_user, db)
|
||||
|
||||
update_data = update.dict(exclude_unset=True)
|
||||
|
||||
@@ -692,19 +743,11 @@ async def update_photo(
|
||||
@router.delete("/{photo_id}")
|
||||
async def discard_photo(
|
||||
photo_id: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Soft-discard a photo: sets is_discarded=true. The file stays on disk so
|
||||
restore is just a flag flip. Permanent deletion happens via DELETE
|
||||
/discard/{id} or DELETE /discard/empty.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Photo).where(Photo.id == photo_id)
|
||||
)
|
||||
photo = result.scalar_one_or_none()
|
||||
|
||||
if not photo:
|
||||
raise HTTPException(status_code=404, detail="Photo not found")
|
||||
"""Soft-discard a photo."""
|
||||
photo = await get_user_photo(photo_id, current_user, db)
|
||||
|
||||
photo.is_discarded = True
|
||||
photo.discarded_at = datetime.utcnow()
|
||||
@@ -726,6 +769,7 @@ class CopyRequest(BaseModel):
|
||||
async def copy_photos(
|
||||
body: CopyRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Copy photos into a target folder. Same target resolution as /move
|
||||
(folder id or source root id), but uses shutil.copy2 and creates new
|
||||
@@ -767,7 +811,7 @@ async def copy_photos(
|
||||
return {"status": "success", "copied": 0, "errors": []}
|
||||
|
||||
photos_result = await db.execute(
|
||||
select(Photo).where(Photo.id.in_(body.photo_ids))
|
||||
select(Photo).where(Photo.id.in_(body.photo_ids), Photo.user_id == current_user.id)
|
||||
)
|
||||
photos_to_copy = photos_result.scalars().all()
|
||||
|
||||
@@ -820,6 +864,7 @@ async def copy_photos(
|
||||
file_size=photo.file_size,
|
||||
taken_at=photo.taken_at,
|
||||
taken_at_source=photo.taken_at_source,
|
||||
user_id=current_user.id,
|
||||
user_title=photo.user_title,
|
||||
user_notes=photo.user_notes,
|
||||
rating=photo.rating,
|
||||
@@ -844,6 +889,7 @@ async def copy_photos(
|
||||
async def move_photos(
|
||||
body: MoveRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Move photos into a target folder. The target can be either a Folder
|
||||
id or a SourceRoot id (since the LeftSidebar only exposes source roots
|
||||
@@ -887,9 +933,9 @@ async def move_photos(
|
||||
if not body.photo_ids:
|
||||
return {"status": "success", "moved": 0, "errors": []}
|
||||
|
||||
# Fetch the photo rows
|
||||
# Fetch the photo rows, scoped to user
|
||||
photos_result = await db.execute(
|
||||
select(Photo).where(Photo.id.in_(body.photo_ids))
|
||||
select(Photo).where(Photo.id.in_(body.photo_ids), Photo.user_id == current_user.id)
|
||||
)
|
||||
photos_to_move = photos_result.scalars().all()
|
||||
|
||||
@@ -932,12 +978,13 @@ async def move_photos(
|
||||
@router.post("/bulk")
|
||||
async def bulk_action(
|
||||
action: BulkAction,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Perform bulk actions on multiple photos"""
|
||||
# Get photos
|
||||
# Get photos, scoped to user
|
||||
result = await db.execute(
|
||||
select(Photo).where(Photo.id.in_(action.ids))
|
||||
select(Photo).where(Photo.id.in_(action.ids), Photo.user_id == current_user.id)
|
||||
)
|
||||
photos = result.scalars().all()
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ from sqlalchemy import select
|
||||
from app.database import get_db
|
||||
from app.models import Photo
|
||||
from app.services.search import hybrid_search
|
||||
from app.models.user import User
|
||||
from app.dependencies import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -22,7 +24,7 @@ class SearchRequest(BaseModel):
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def search_photos(body: SearchRequest, db: AsyncSession = Depends(get_db)):
|
||||
async def search_photos(body: SearchRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""Unified search endpoint. Every query runs hybrid (FTS + semantic)
|
||||
by default — the user never picks a mode.
|
||||
|
||||
@@ -47,7 +49,7 @@ async def search_photos(body: SearchRequest, db: AsyncSession = Depends(get_db))
|
||||
|
||||
# Hydrate with photo data
|
||||
photo_ids = [r["photo_id"] for r in results]
|
||||
stmt = select(Photo).where(Photo.id.in_(photo_ids))
|
||||
stmt = select(Photo).where(Photo.id.in_(photo_ids), Photo.user_id == current_user.id)
|
||||
rows = (await db.execute(stmt)).scalars().all()
|
||||
photo_map = {p.id: p for p in rows}
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database import get_db
|
||||
from app.models import Photo, Tag
|
||||
from app.models.tags import photo_tags
|
||||
from app.models.user import User
|
||||
from app.dependencies import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -42,6 +44,7 @@ class TagMerge(BaseModel):
|
||||
async def list_tags(
|
||||
kind: Optional[str] = Query(None, description="Filter by kind: user, object, scene, face_cluster"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List all tags with their photo counts, optionally filtered by kind.
|
||||
|
||||
@@ -61,6 +64,7 @@ async def list_tags(
|
||||
photo_tags.join(Photo, Photo.id == photo_tags.c.photo_id)
|
||||
)
|
||||
.where(
|
||||
Photo.user_id == current_user.id,
|
||||
Photo.is_discarded.is_(False),
|
||||
Photo.is_hidden.is_(False),
|
||||
)
|
||||
@@ -70,6 +74,7 @@ async def list_tags(
|
||||
stmt = (
|
||||
select(Tag, count_subq.c.photo_count, count_subq.c.first_photo_id)
|
||||
.outerjoin(count_subq, Tag.id == count_subq.c.tag_id)
|
||||
.where(Tag.user_id == current_user.id)
|
||||
)
|
||||
if kind:
|
||||
stmt = stmt.where(Tag.kind == kind)
|
||||
@@ -93,7 +98,7 @@ async def list_tags(
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_tag(body: TagCreate, db: AsyncSession = Depends(get_db)):
|
||||
async def create_tag(body: TagCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""Create a new tag. The (name, kind) pair is unique — re-creating an
|
||||
existing pair returns the existing row (idempotent for autocomplete)."""
|
||||
name = (body.name or "").strip()
|
||||
@@ -101,7 +106,7 @@ async def create_tag(body: TagCreate, db: AsyncSession = Depends(get_db)):
|
||||
raise HTTPException(status_code=400, detail="Tag name is required")
|
||||
|
||||
existing = await db.execute(
|
||||
select(Tag).where(Tag.name == name, Tag.kind == body.kind)
|
||||
select(Tag).where(Tag.name == name, Tag.kind == body.kind, Tag.user_id == current_user.id)
|
||||
)
|
||||
found = existing.scalar_one_or_none()
|
||||
if found:
|
||||
@@ -110,7 +115,7 @@ async def create_tag(body: TagCreate, db: AsyncSession = Depends(get_db)):
|
||||
"kind": found.kind, "photo_count": 0,
|
||||
}
|
||||
|
||||
tag = Tag(name=name, color=body.color, kind=body.kind)
|
||||
tag = Tag(name=name, color=body.color, kind=body.kind, user_id=current_user.id)
|
||||
db.add(tag)
|
||||
await db.commit()
|
||||
await db.refresh(tag)
|
||||
@@ -122,10 +127,11 @@ async def create_tag(body: TagCreate, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
@router.patch("/{tag_id}")
|
||||
async def update_tag(
|
||||
tag_id: str, body: TagUpdate, db: AsyncSession = Depends(get_db)
|
||||
tag_id: str, body: TagUpdate, db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Rename or recolor a tag (works for any kind — user, object, face_cluster)."""
|
||||
result = await db.execute(select(Tag).where(Tag.id == tag_id))
|
||||
result = await db.execute(select(Tag).where(Tag.id == tag_id, Tag.user_id == current_user.id))
|
||||
tag = result.scalar_one_or_none()
|
||||
if not tag:
|
||||
raise HTTPException(status_code=404, detail="Tag not found")
|
||||
@@ -145,7 +151,8 @@ async def update_tag(
|
||||
|
||||
@router.post("/{tag_id}/merge")
|
||||
async def merge_tag(
|
||||
tag_id: str, body: TagMerge, db: AsyncSession = Depends(get_db)
|
||||
tag_id: str, body: TagMerge, db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Merge tag_id INTO target_id. All photo associations from the source
|
||||
tag are moved to the target, then the source tag is deleted.
|
||||
@@ -155,8 +162,8 @@ async def merge_tag(
|
||||
if tag_id == body.target_id:
|
||||
raise HTTPException(status_code=400, detail="Cannot merge a tag into itself")
|
||||
|
||||
source = (await db.execute(select(Tag).where(Tag.id == tag_id))).scalar_one_or_none()
|
||||
target = (await db.execute(select(Tag).where(Tag.id == body.target_id))).scalar_one_or_none()
|
||||
source = (await db.execute(select(Tag).where(Tag.id == tag_id, Tag.user_id == current_user.id))).scalar_one_or_none()
|
||||
target = (await db.execute(select(Tag).where(Tag.id == body.target_id, Tag.user_id == current_user.id))).scalar_one_or_none()
|
||||
if not source:
|
||||
raise HTTPException(status_code=404, detail="Source tag not found")
|
||||
if not target:
|
||||
@@ -189,9 +196,9 @@ async def merge_tag(
|
||||
|
||||
|
||||
@router.delete("/{tag_id}", status_code=204)
|
||||
async def delete_tag(tag_id: str, db: AsyncSession = Depends(get_db)):
|
||||
async def delete_tag(tag_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""Delete a tag. Photo associations cascade-delete via the FK."""
|
||||
result = await db.execute(select(Tag).where(Tag.id == tag_id))
|
||||
result = await db.execute(select(Tag).where(Tag.id == tag_id, Tag.user_id == current_user.id))
|
||||
tag = result.scalar_one_or_none()
|
||||
if not tag:
|
||||
raise HTTPException(status_code=404, detail="Tag not found")
|
||||
|
||||
Reference in New Issue
Block a user