""" Sharing API router — manage cross-user access to heaps and folders. """ import logging from typing import Literal, Optional from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db from app.models.heaps import Heap, heap_photos from app.models.folders import Folder, SourceRoot from app.models.photos import Photo from app.models.sharing import HeapShare, FolderShare from app.models.user import User from app.dependencies import ( get_current_user, get_user_heap, get_user_folder, resolve_username, ) logger = logging.getLogger(__name__) router = APIRouter(prefix="/sharing", tags=["sharing"]) # ── Schemas ────────────────────────────────────────────────────────────── class ShareCreate(BaseModel): username: str permission: Literal["read", "write"] = "read" class ShareResponse(BaseModel): id: str shared_with_id: str shared_with_username: str permission: str created_at: str class SharedHeapResponse(BaseModel): id: str name: str owner_username: str permission: str photo_count: int class SharedFolderResponse(BaseModel): id: str name: str folder_type: str owner_username: str permission: str photo_count: int # ── Heap sharing ───────────────────────────────────────────────────────── @router.get("/heaps/shared-with-me") async def list_shared_heaps( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """List all heaps that have been shared with the current user.""" result = await db.execute( select(HeapShare, Heap, User) .join(Heap, HeapShare.heap_id == Heap.id) .join(User, HeapShare.owner_id == User.id) .where(HeapShare.shared_with_id == current_user.id) ) rows = result.all() items = [] for share, heap, owner in rows: # Count photos in this heap. count_result = await db.execute( select(func.count()).select_from(heap_photos).where( heap_photos.c.heap_id == heap.id ) ) count = count_result.scalar() or 0 items.append(SharedHeapResponse( id=heap.id, name=heap.name, owner_username=owner.username, permission=share.permission, photo_count=count, )) return items @router.get("/heaps/{heap_id}") async def list_heap_shares( heap_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """List all shares for a heap (owner only).""" heap = await get_user_heap(heap_id, current_user, db) result = await db.execute( select(HeapShare, User) .join(User, HeapShare.shared_with_id == User.id) .where(HeapShare.heap_id == heap.id) ) return [ ShareResponse( id=share.id, shared_with_id=user.id, shared_with_username=user.username, permission=share.permission, created_at=share.created_at.isoformat() if share.created_at else "", ) for share, user in result.all() ] @router.post("/heaps/{heap_id}", status_code=201) async def share_heap( heap_id: str, body: ShareCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """Share a heap with another user (owner only).""" heap = await get_user_heap(heap_id, current_user, db) target_user = await resolve_username(body.username, db) if target_user.id == current_user.id: raise HTTPException(status_code=400, detail="Cannot share with yourself") # Check for existing share. existing = await db.execute( select(HeapShare).where( HeapShare.heap_id == heap.id, HeapShare.shared_with_id == target_user.id, ) ) if existing.scalar_one_or_none(): raise HTTPException(status_code=409, detail="Already shared with this user") share = HeapShare( heap_id=heap.id, owner_id=current_user.id, shared_with_id=target_user.id, permission=body.permission, ) db.add(share) await db.commit() logger.info("Heap %s shared with %s (%s)", heap.name, target_user.username, body.permission) return {"status": "shared", "share_id": share.id} @router.delete("/heaps/{heap_id}/{share_id}", status_code=204) async def revoke_heap_share( heap_id: str, share_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """Revoke a heap share. The owner can revoke any share; a recipient can revoke their own share (i.e. leave).""" result = await db.execute( select(HeapShare).where(HeapShare.id == share_id, HeapShare.heap_id == heap_id) ) share = result.scalar_one_or_none() if share is None: raise HTTPException(status_code=404, detail="Share not found") # Must be the owner or the recipient themselves. if share.owner_id != current_user.id and share.shared_with_id != current_user.id: raise HTTPException(status_code=403, detail="Not authorized") await db.delete(share) await db.commit() # ── Folder sharing ─────────────────────────────────────────────────────── @router.get("/folders/shared-with-me") async def list_shared_folders( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """List all folders/source roots shared with the current user.""" result = await db.execute( select(FolderShare, User) .join(User, FolderShare.owner_id == User.id) .where(FolderShare.shared_with_id == current_user.id) ) rows = result.all() items = [] for share, owner in rows: # Resolve the folder/source root name and photo count. if share.folder_type == "source_root": sr_result = await db.execute( select(SourceRoot).where(SourceRoot.id == share.folder_id) ) entity = sr_result.scalar_one_or_none() if not entity: continue name = entity.name # Count all photos under this source root's folders. count_result = await db.execute( select(func.count()).select_from(Photo).where( Photo.folder_id.in_( select(Folder.id).where(Folder.source_root_id == entity.id) ), Photo.is_discarded.is_(False), ) ) else: folder_result = await db.execute( select(Folder).where(Folder.id == share.folder_id) ) entity = folder_result.scalar_one_or_none() if not entity: continue name = entity.name import os target_path = os.path.normpath(entity.path).rstrip(os.sep) count_result = await db.execute( select(func.count()).select_from(Photo).where( Photo.folder_id.in_( select(Folder.id).where( (Folder.path == target_path) | (Folder.path.like(target_path + os.sep + "%")) ) ), Photo.is_discarded.is_(False), ) ) count = count_result.scalar() or 0 items.append(SharedFolderResponse( id=share.folder_id, name=name, folder_type=share.folder_type, owner_username=owner.username, permission=share.permission, photo_count=count, )) return items @router.get("/folders/{folder_id}") async def list_folder_shares( folder_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """List all shares for a folder (owner only).""" # Verify ownership — try folder then source root. owned = False result = await db.execute( select(Folder).where(Folder.id == folder_id, Folder.user_id == current_user.id) ) if result.scalar_one_or_none(): owned = True else: result = await db.execute( select(SourceRoot).where(SourceRoot.id == folder_id, SourceRoot.user_id == current_user.id) ) if result.scalar_one_or_none(): owned = True if not owned: raise HTTPException(status_code=404, detail="Folder not found") result = await db.execute( select(FolderShare, User) .join(User, FolderShare.shared_with_id == User.id) .where(FolderShare.folder_id == folder_id) ) return [ ShareResponse( id=share.id, shared_with_id=user.id, shared_with_username=user.username, permission=share.permission, created_at=share.created_at.isoformat() if share.created_at else "", ) for share, user in result.all() ] @router.post("/folders/{folder_id}", status_code=201) async def share_folder( folder_id: str, body: ShareCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """Share a folder or source root with another user (owner only).""" # Determine folder_type and verify ownership. folder_type = "folder" result = await db.execute( select(Folder).where(Folder.id == folder_id, Folder.user_id == current_user.id) ) entity = result.scalar_one_or_none() if entity is None: result = await db.execute( select(SourceRoot).where(SourceRoot.id == folder_id, SourceRoot.user_id == current_user.id) ) entity = result.scalar_one_or_none() if entity is None: raise HTTPException(status_code=404, detail="Folder not found") folder_type = "source_root" target_user = await resolve_username(body.username, db) if target_user.id == current_user.id: raise HTTPException(status_code=400, detail="Cannot share with yourself") existing = await db.execute( select(FolderShare).where( FolderShare.folder_id == folder_id, FolderShare.shared_with_id == target_user.id, ) ) if existing.scalar_one_or_none(): raise HTTPException(status_code=409, detail="Already shared with this user") share = FolderShare( folder_id=folder_id, folder_type=folder_type, owner_id=current_user.id, shared_with_id=target_user.id, permission=body.permission, ) db.add(share) await db.commit() logger.info("Folder %s shared with %s (%s)", entity.name, target_user.username, body.permission) return {"status": "shared", "share_id": share.id} @router.delete("/folders/{folder_id}/{share_id}", status_code=204) async def revoke_folder_share( folder_id: str, share_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """Revoke a folder share (owner or self-remove).""" result = await db.execute( select(FolderShare).where(FolderShare.id == share_id, FolderShare.folder_id == folder_id) ) share = result.scalar_one_or_none() if share is None: raise HTTPException(status_code=404, detail="Share not found") if share.owner_id != current_user.id and share.shared_with_id != current_user.id: raise HTTPException(status_code=403, detail="Not authorized") await db.delete(share) await db.commit()