Sharing:
- New HeapShare and FolderShare models with read/write permissions
- Sharing API router (CRUD for heap and folder shares)
- Heap endpoints accept shared access (photo_ids, add/remove with write)
- Photo list drops user_id filter in shared context, adds owner_username
- Media serving (thumb/original/proxy) falls back to share check on 404
- ShareDialog component for managing shares from kebab menus
- HeapsPanel shows "Shared with me" section for shared heaps
- LeftSidebar shows "Shared with me" section for shared folders
- Owner badge on PhotoThumbnail for photos from other users
Auth:
- Access token default bumped to 1 year, refresh to 10 years
- Refresh token persisted in localStorage (survives page reload)
- Timer-based refresh replaced with 401 axios interceptor
Vision pipeline fixes:
- Bootstrap sets Redis ready key even on partial export failure
- Export functions run conditionally (only for actually missing models)
- _load_thumb handles multi-user path (/data/thumbs/{user_id}/{photo_id}/)
- can_access_photo_via_share uses single subquery instead of N+1 loop
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
449 lines
15 KiB
Python
449 lines
15 KiB
Python
"""
|
|
Heaps API router
|
|
"""
|
|
import os
|
|
import shutil
|
|
import logging
|
|
from typing import Optional, Literal
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import select, func, update, insert, delete
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
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, get_user_or_shared_heap
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
# ── Schemas ───────────────────────────────────────────────────────────────
|
|
|
|
class HeapCreate(BaseModel):
|
|
name: str
|
|
|
|
|
|
class HeapUpdate(BaseModel):
|
|
name: Optional[str] = None
|
|
is_active: Optional[bool] = None
|
|
|
|
|
|
class HeapPhotosBody(BaseModel):
|
|
photo_ids: list[str]
|
|
|
|
|
|
class HeapConvertBody(BaseModel):
|
|
target_id: str # folder id OR source root id
|
|
mode: Literal['move', 'copy'] = 'move'
|
|
delete_heap: bool = False
|
|
# Optional subfolder name to create inside the target. If provided, the
|
|
# actual destination is target_dir/subfolder_name (created if missing).
|
|
# Path separators and dot-segments are rejected.
|
|
subfolder_name: Optional[str] = None
|
|
|
|
|
|
# ── Endpoints ─────────────────────────────────────────────────────────────
|
|
|
|
@router.get("")
|
|
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 = (
|
|
select(
|
|
heap_photos.c.heap_id,
|
|
func.count(heap_photos.c.photo_id).label("photo_count"),
|
|
)
|
|
.group_by(heap_photos.c.heap_id)
|
|
.subquery()
|
|
)
|
|
|
|
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)
|
|
rows = result.all()
|
|
|
|
return [
|
|
{
|
|
"id": h.id,
|
|
"name": h.name,
|
|
"is_active": bool(h.is_active),
|
|
"created_at": h.created_at,
|
|
"updated_at": h.updated_at,
|
|
"photo_count": int(count or 0),
|
|
}
|
|
for h, count in rows
|
|
]
|
|
|
|
|
|
@router.post("", status_code=201)
|
|
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, user_id=current_user.id)
|
|
db.add(heap)
|
|
await db.commit()
|
|
await db.refresh(heap)
|
|
return {
|
|
"id": heap.id,
|
|
"name": heap.name,
|
|
"is_active": bool(heap.is_active),
|
|
"created_at": heap.created_at,
|
|
"updated_at": heap.updated_at,
|
|
"photo_count": 0,
|
|
}
|
|
|
|
|
|
@router.patch("/{heap_id}")
|
|
async def update_heap(
|
|
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)."""
|
|
heap = await get_user_heap(heap_id, current_user, db)
|
|
|
|
if body.name is not None:
|
|
name = body.name.strip()
|
|
if not name:
|
|
raise HTTPException(status_code=400, detail="Heap name is required")
|
|
heap.name = name
|
|
|
|
if body.is_active is not None:
|
|
if body.is_active:
|
|
# 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
|
|
|
|
await db.commit()
|
|
await db.refresh(heap)
|
|
return {
|
|
"id": heap.id,
|
|
"name": heap.name,
|
|
"is_active": bool(heap.is_active),
|
|
"created_at": heap.created_at,
|
|
"updated_at": heap.updated_at,
|
|
}
|
|
|
|
|
|
@router.post("/{heap_id}/duplicate", status_code=201)
|
|
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.
|
|
"""
|
|
source = await get_user_heap(heap_id, current_user, db)
|
|
|
|
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
|
|
|
|
# Bulk-copy the membership rows. SELECT photo_id FROM heap_photos WHERE
|
|
# heap_id = :src — INSERT each into the new heap. Done as a single
|
|
# INSERT...SELECT to avoid round-tripping ids through Python.
|
|
member_rows = await db.execute(
|
|
select(heap_photos.c.photo_id).where(heap_photos.c.heap_id == heap_id)
|
|
)
|
|
photo_ids = [row[0] for row in member_rows.all()]
|
|
if photo_ids:
|
|
await db.execute(
|
|
insert(heap_photos),
|
|
[{"heap_id": new_heap.id, "photo_id": pid} for pid in photo_ids],
|
|
)
|
|
|
|
await db.commit()
|
|
await db.refresh(new_heap)
|
|
return {
|
|
"id": new_heap.id,
|
|
"name": new_heap.name,
|
|
"is_active": False,
|
|
"photo_count": len(photo_ids),
|
|
"created_at": new_heap.created_at,
|
|
"updated_at": new_heap.updated_at,
|
|
}
|
|
|
|
|
|
@router.delete("/{heap_id}", status_code=204)
|
|
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."""
|
|
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),
|
|
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_or_shared_heap(heap_id, current_user, db)
|
|
result = await db.execute(
|
|
select(heap_photos.c.photo_id).where(heap_photos.c.heap_id == heap_id)
|
|
)
|
|
return [row[0] for row in result.all()]
|
|
|
|
|
|
@router.post("/{heap_id}/photos")
|
|
async def add_photos_to_heap(
|
|
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).
|
|
Shared users with write permission can add their own photos."""
|
|
_heap, permission = await get_user_or_shared_heap(heap_id, current_user, db)
|
|
if permission == "read":
|
|
raise HTTPException(status_code=403, detail="Read-only access to this heap")
|
|
|
|
if not body.photo_ids:
|
|
return {"status": "success", "added": 0}
|
|
|
|
# Find which ids are already members so we don't violate the PK.
|
|
existing = await db.execute(
|
|
select(heap_photos.c.photo_id).where(
|
|
heap_photos.c.heap_id == heap_id,
|
|
heap_photos.c.photo_id.in_(body.photo_ids),
|
|
)
|
|
)
|
|
existing_ids = {row[0] for row in existing.all()}
|
|
new_ids = [pid for pid in body.photo_ids if pid not in existing_ids]
|
|
|
|
if new_ids:
|
|
await db.execute(
|
|
insert(heap_photos),
|
|
[{"heap_id": heap_id, "photo_id": pid} for pid in new_ids],
|
|
)
|
|
await db.commit()
|
|
|
|
return {"status": "success", "added": len(new_ids), "already_present": len(existing_ids)}
|
|
|
|
|
|
@router.post("/{heap_id}/convert")
|
|
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
|
|
the end.
|
|
|
|
target_id may be a Folder id or a SourceRoot id (matches the
|
|
/photos/move convention so the same dropdown can populate it).
|
|
"""
|
|
heap = await get_user_heap(heap_id, current_user, db)
|
|
|
|
# Resolve target_id → (target_dir, target_folder)
|
|
sr_check = await db.execute(
|
|
select(SourceRoot).where(SourceRoot.id == body.target_id)
|
|
)
|
|
source_root = sr_check.scalar_one_or_none()
|
|
|
|
if source_root is not None:
|
|
parent_dir = source_root.path
|
|
parent_source_root_id = source_root.id
|
|
else:
|
|
folder_check = await db.execute(
|
|
select(Folder).where(Folder.id == body.target_id)
|
|
)
|
|
parent_folder = folder_check.scalar_one_or_none()
|
|
if parent_folder is None:
|
|
raise HTTPException(status_code=404, detail="Target folder not found")
|
|
parent_dir = parent_folder.path
|
|
parent_source_root_id = parent_folder.source_root_id
|
|
|
|
if not os.path.isdir(parent_dir):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Target parent does not exist: {parent_dir}",
|
|
)
|
|
|
|
# Resolve target_dir, creating an optional subfolder if requested.
|
|
if body.subfolder_name is not None:
|
|
sub = body.subfolder_name.strip()
|
|
if not sub:
|
|
raise HTTPException(status_code=400, detail="Subfolder name cannot be empty")
|
|
if '/' in sub or '\\' in sub or sub in ('.', '..'):
|
|
raise HTTPException(status_code=400, detail="Invalid subfolder name")
|
|
target_dir = os.path.join(parent_dir, sub)
|
|
if not os.path.exists(target_dir):
|
|
try:
|
|
os.makedirs(target_dir)
|
|
except OSError as e:
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=f"Failed to create subfolder: {e}",
|
|
)
|
|
elif not os.path.isdir(target_dir):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"{target_dir} exists but is not a directory",
|
|
)
|
|
else:
|
|
target_dir = parent_dir
|
|
|
|
# Ensure a Folder row for the target, reusing the scanner helper so
|
|
# path normalization + dedupe stay consistent.
|
|
from app.tasks.scan import get_or_create_folder
|
|
target_folder = await get_or_create_folder(db, target_dir, parent_source_root_id)
|
|
|
|
# Fetch the heap's photos via the join table.
|
|
photo_result = await db.execute(
|
|
select(Photo)
|
|
.join(heap_photos, Photo.id == heap_photos.c.photo_id)
|
|
.where(heap_photos.c.heap_id == heap_id)
|
|
)
|
|
photos = photo_result.scalars().all()
|
|
|
|
moved = 0
|
|
copied = 0
|
|
errors: list[dict] = []
|
|
|
|
def _unique_target_name(directory: str, filename: str) -> Optional[str]:
|
|
if not os.path.exists(os.path.join(directory, filename)):
|
|
return filename
|
|
stem, ext = os.path.splitext(filename)
|
|
for i in range(1, 100):
|
|
suffix = '' if i == 1 else f' {i}'
|
|
candidate = f"{stem} (copy{suffix}){ext}"
|
|
if not os.path.exists(os.path.join(directory, candidate)):
|
|
return candidate
|
|
return None
|
|
|
|
for photo in photos:
|
|
if not os.path.exists(photo.filepath):
|
|
errors.append({"id": photo.id, "error": "source file missing"})
|
|
continue
|
|
|
|
if body.mode == 'move':
|
|
if photo.folder_id == target_folder.id:
|
|
continue # already there
|
|
new_path = os.path.join(target_dir, photo.filename)
|
|
if os.path.exists(new_path):
|
|
errors.append({"id": photo.id, "error": f"name collision: {photo.filename}"})
|
|
continue
|
|
try:
|
|
shutil.move(photo.filepath, new_path)
|
|
except OSError as e:
|
|
errors.append({"id": photo.id, "error": str(e)})
|
|
continue
|
|
photo.filepath = new_path
|
|
photo.folder_id = target_folder.id
|
|
moved += 1
|
|
else: # copy
|
|
new_name = _unique_target_name(target_dir, photo.filename)
|
|
if new_name is None:
|
|
errors.append({"id": photo.id, "error": "too many name collisions"})
|
|
continue
|
|
new_path = os.path.join(target_dir, new_name)
|
|
try:
|
|
shutil.copy2(photo.filepath, new_path)
|
|
except OSError as e:
|
|
errors.append({"id": photo.id, "error": str(e)})
|
|
continue
|
|
new_photo = Photo(
|
|
filepath=new_path,
|
|
filename=new_name,
|
|
folder_id=target_folder.id,
|
|
file_hash=photo.file_hash,
|
|
media_type=photo.media_type,
|
|
original_format=photo.original_format,
|
|
width=photo.width,
|
|
height=photo.height,
|
|
file_size=photo.file_size,
|
|
taken_at=photo.taken_at,
|
|
taken_at_source=photo.taken_at_source,
|
|
user_title=photo.user_title,
|
|
user_notes=photo.user_notes,
|
|
rating=photo.rating,
|
|
color_label=photo.color_label,
|
|
exif_json=photo.exif_json,
|
|
is_duplicate=True,
|
|
processing_status='pending',
|
|
)
|
|
db.add(new_photo)
|
|
copied += 1
|
|
|
|
if body.delete_heap:
|
|
await db.delete(heap)
|
|
|
|
await db.commit()
|
|
|
|
return {
|
|
"status": "success",
|
|
"mode": body.mode,
|
|
"moved": moved,
|
|
"copied": copied,
|
|
"errors": errors,
|
|
"heap_deleted": body.delete_heap,
|
|
}
|
|
|
|
|
|
@router.delete("/{heap_id}/photos")
|
|
async def remove_photos_from_heap(
|
|
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.
|
|
Shared users with write permission can remove photos."""
|
|
_heap, permission = await get_user_or_shared_heap(heap_id, current_user, db)
|
|
if permission == "read":
|
|
raise HTTPException(status_code=403, detail="Read-only access to this heap")
|
|
|
|
if not body.photo_ids:
|
|
return {"status": "success", "removed": 0}
|
|
|
|
res = await db.execute(
|
|
delete(heap_photos).where(
|
|
heap_photos.c.heap_id == heap_id,
|
|
heap_photos.c.photo_id.in_(body.photo_ids),
|
|
)
|
|
)
|
|
await db.commit()
|
|
return {"status": "success", "removed": res.rowcount or 0}
|