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>
98 lines
3.2 KiB
Python
98 lines
3.2 KiB
Python
"""
|
|
Discard API router
|
|
"""
|
|
import os
|
|
import logging
|
|
from fastapi import APIRouter, Depends, HTTPException, Body
|
|
from sqlalchemy import select, and_
|
|
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), current_user: User = Depends(get_current_user)):
|
|
"""List discarded photos"""
|
|
result = await db.execute(
|
|
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), 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, Photo.user_id == current_user.id))
|
|
)
|
|
photos = result.scalars().all()
|
|
|
|
for photo in photos:
|
|
photo.is_discarded = False
|
|
photo.discarded_at = None
|
|
|
|
await db.commit()
|
|
return {"status": "success", "restored": len(photos)}
|
|
|
|
@router.delete("/empty")
|
|
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, Photo.user_id == current_user.id)
|
|
)
|
|
photos = result.scalars().all()
|
|
return await _permanently_delete(db, photos)
|
|
|
|
|
|
@router.delete("")
|
|
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
|
|
this can never bypass the soft-delete safety net.
|
|
"""
|
|
if not photo_ids:
|
|
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, Photo.user_id == current_user.id)
|
|
)
|
|
)
|
|
photos = result.scalars().all()
|
|
return await _permanently_delete(db, photos)
|
|
|
|
|
|
async def _permanently_delete(db: AsyncSession, photos: list[Photo]) -> dict:
|
|
"""Shared helper: unlink files for the given photos and delete their
|
|
rows. Per-file errors are counted but don't abort the batch.
|
|
"""
|
|
deleted = 0
|
|
file_errors = 0
|
|
for photo in photos:
|
|
try:
|
|
if photo.filepath and os.path.exists(photo.filepath):
|
|
os.unlink(photo.filepath)
|
|
except OSError as e:
|
|
file_errors += 1
|
|
logger.error(f"Failed to unlink {photo.filepath}: {e}")
|
|
await db.delete(photo)
|
|
deleted += 1
|
|
|
|
await db.commit()
|
|
return {
|
|
"status": "success",
|
|
"deleted": deleted,
|
|
"file_errors": file_errors,
|
|
}
|