Files
mule-image/backend/app/routers/heaps.py
dtoro 351ccd7bb4 refactor: unify Pick with active heap membership
Pick and add-to-heap were two ways of saying "I want to keep this
one". Merging them: P now toggles the selection's membership in the
active heap. The is_picked flag goes away (orphaned in the DB the
same way is_trashed was).

Backend
- Drop is_picked from PhotoBase / PhotoUpdate / PhotoResponse and
  from the photos list filter param.
- Drop the is_picked Column from the Photo model (DB column stays
  on legacy installs but is no longer read or written).
- Drop the bulk action 'pick' branch.
- New GET /heaps/{id}/photo_ids returns just the flat string list.
  Used by the frontend for fast client-side membership lookups
  without fetching full photo records.

Frontend
- New hooks/useActiveHeapMembersQuery.ts → returns
  { activeHeap, memberIds: Set<string> }. Subscribes once at the
  Timeline level and passes a derived isInActiveHeap bool down to
  each PhotoThumbnail (avoids hundreds of thumbnails subscribing
  to the same query).
- PhotoThumbnail: replaces the old check-icon Pick affordance with
  a clear basket badge in the bottom-right corner — a small filled
  pick-colour pill containing a ShoppingBasket icon — visible only
  when the photo belongs to the active heap.
- P shortcut (useKeyboardShortcuts) now toggles membership: if every
  selected photo is already a member, it removes them; otherwise it
  adds the missing ones. T binding removed (P fully replaces it).
- RightSidebar Pick button is now a Pick / Picked toggle bound to
  the active heap. Disabled with a hint when no heap is active.
  Shows the heap name in its title attr.
- filterStore drops 'picked' and 'unflagged' from FlagFilter.
  FilterBar's flag dropdown is now just Any / Discarded.
- LeftSidebar drops the "Flagged" virtual node (it just set
  flag=picked, which no longer exists).
- KeyboardHints: P → "Pick → heap".
- Photo TS type drops is_picked.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 23:20:31 +02:00

204 lines
6.5 KiB
Python

"""
Heaps API router
"""
from typing import Optional
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
from app.models.heaps import heap_photos
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]
# ── Endpoints ─────────────────────────────────────────────────────────────
@router.get("")
async def list_heaps(db: AsyncSession = Depends(get_db)):
"""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)
.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)):
"""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)
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)
):
"""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")
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 in one statement
await db.execute(update(Heap).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.delete("/{heap_id}", status_code=204)
async def delete_heap(heap_id: str, db: AsyncSession = Depends(get_db)):
"""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")
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)):
"""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."""
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)
):
"""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")
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.delete("/{heap_id}/photos")
async def remove_photos_from_heap(
heap_id: str, body: HeapPhotosBody, db: AsyncSession = Depends(get_db)
):
"""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")
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}