"""Taxonomy API — folder and tag CRUD, sidebar data.""" from __future__ import annotations import uuid from datetime import datetime from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from sqlalchemy import select, func, update, delete from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_user_id, get_db_session from app.models.taxonomy import Folder, Tag, ItemTag, slugify, ensure_user_taxonomy from app.models.item import Item router = APIRouter(prefix="/api/taxonomy", tags=["taxonomy"]) # ── Schemas ── class FolderIn(BaseModel): name: str color: Optional[str] = None icon: Optional[str] = None class FolderOut(BaseModel): id: str name: str slug: str color: Optional[str] = None icon: Optional[str] = None is_active: bool sort_order: int item_count: int = 0 model_config = {"from_attributes": True} class TagIn(BaseModel): name: str color: Optional[str] = None icon: Optional[str] = None class TagOut(BaseModel): id: str name: str slug: str color: Optional[str] = None icon: Optional[str] = None is_active: bool sort_order: int item_count: int = 0 model_config = {"from_attributes": True} class SidebarOut(BaseModel): folders: list[FolderOut] tags: list[TagOut] total_items: int class MergeTagIn(BaseModel): source_tag_id: str target_tag_id: str # ── Sidebar data ── @router.get("/sidebar", response_model=SidebarOut) async def get_sidebar( user_id: str = Depends(get_user_id), db: AsyncSession = Depends(get_db_session), ): await ensure_user_taxonomy(db, user_id) # Folders with counts folder_rows = (await db.execute( select(Folder).where(Folder.user_id == user_id).order_by(Folder.sort_order, Folder.name) )).scalars().all() folder_counts = {} for row in (await db.execute( select(Item.folder_id, func.count()).where( Item.user_id == user_id, Item.folder_id.isnot(None) ).group_by(Item.folder_id) )).all(): folder_counts[row[0]] = row[1] folders = [FolderOut( id=f.id, name=f.name, slug=f.slug, color=f.color, icon=f.icon, is_active=f.is_active, sort_order=f.sort_order, item_count=folder_counts.get(f.id, 0) ) for f in folder_rows] # Tags with counts tag_rows = (await db.execute( select(Tag).where(Tag.user_id == user_id).order_by(Tag.sort_order, Tag.name) )).scalars().all() tag_counts = {} for row in (await db.execute( select(ItemTag.tag_id, func.count()).join(Item, Item.id == ItemTag.item_id).where( Item.user_id == user_id ).group_by(ItemTag.tag_id) )).all(): tag_counts[row[0]] = row[1] tags = [TagOut( id=t.id, name=t.name, slug=t.slug, color=t.color, icon=t.icon, is_active=t.is_active, sort_order=t.sort_order, item_count=tag_counts.get(t.id, 0) ) for t in tag_rows] # Total items total = (await db.execute( select(func.count()).where(Item.user_id == user_id) )).scalar() or 0 return SidebarOut(folders=folders, tags=tags, total_items=total) # ── Folder CRUD ── @router.get("/folders", response_model=list[FolderOut]) async def list_folders( user_id: str = Depends(get_user_id), db: AsyncSession = Depends(get_db_session), ): await ensure_user_taxonomy(db, user_id) rows = (await db.execute( select(Folder).where(Folder.user_id == user_id).order_by(Folder.sort_order, Folder.name) )).scalars().all() return [FolderOut(id=f.id, name=f.name, slug=f.slug, color=f.color, icon=f.icon, is_active=f.is_active, sort_order=f.sort_order) for f in rows] @router.post("/folders", response_model=FolderOut, status_code=201) async def create_folder( body: FolderIn, user_id: str = Depends(get_user_id), db: AsyncSession = Depends(get_db_session), ): slug = slugify(body.name) existing = (await db.execute( select(Folder).where(Folder.user_id == user_id, Folder.slug == slug) )).scalar_one_or_none() if existing: raise HTTPException(400, "Folder already exists") max_order = (await db.execute( select(func.max(Folder.sort_order)).where(Folder.user_id == user_id) )).scalar() or 0 folder = Folder(id=str(uuid.uuid4()), user_id=user_id, name=body.name.strip(), slug=slug, color=body.color, icon=body.icon, sort_order=max_order + 1) db.add(folder) await db.commit() return FolderOut(id=folder.id, name=folder.name, slug=folder.slug, is_active=folder.is_active, sort_order=folder.sort_order) @router.patch("/folders/{folder_id}", response_model=FolderOut) async def update_folder( folder_id: str, body: FolderIn, user_id: str = Depends(get_user_id), db: AsyncSession = Depends(get_db_session), ): folder = (await db.execute( select(Folder).where(Folder.id == folder_id, Folder.user_id == user_id) )).scalar_one_or_none() if not folder: raise HTTPException(404, "Folder not found") folder.name = body.name.strip() folder.slug = slugify(body.name) if body.color is not None: folder.color = body.color if body.icon is not None: folder.icon = body.icon folder.updated_at = datetime.utcnow() # Update denormalized folder name on items await db.execute( update(Item).where(Item.folder_id == folder_id).values(folder=folder.name) ) await db.commit() return FolderOut(id=folder.id, name=folder.name, slug=folder.slug, is_active=folder.is_active, sort_order=folder.sort_order) @router.delete("/folders/{folder_id}") async def delete_folder( folder_id: str, fallback_folder_id: Optional[str] = Query(None), user_id: str = Depends(get_user_id), db: AsyncSession = Depends(get_db_session), ): folder = (await db.execute( select(Folder).where(Folder.id == folder_id, Folder.user_id == user_id) )).scalar_one_or_none() if not folder: raise HTTPException(404, "Folder not found") # Move items to fallback folder or first available folder if fallback_folder_id: fallback = (await db.execute(select(Folder).where(Folder.id == fallback_folder_id))).scalar_one_or_none() else: fallback = (await db.execute( select(Folder).where(Folder.user_id == user_id, Folder.id != folder_id).order_by(Folder.sort_order).limit(1) )).scalar_one_or_none() if fallback: await db.execute( update(Item).where(Item.folder_id == folder_id).values(folder_id=fallback.id, folder=fallback.name) ) await db.execute(delete(Folder).where(Folder.id == folder_id)) await db.commit() return {"status": "deleted", "items_moved_to": fallback.name if fallback else None} # ── Tag CRUD ── @router.get("/tags", response_model=list[TagOut]) async def list_tags( user_id: str = Depends(get_user_id), db: AsyncSession = Depends(get_db_session), ): await ensure_user_taxonomy(db, user_id) rows = (await db.execute( select(Tag).where(Tag.user_id == user_id).order_by(Tag.sort_order, Tag.name) )).scalars().all() return [TagOut(id=t.id, name=t.name, slug=t.slug, color=t.color, icon=t.icon, is_active=t.is_active, sort_order=t.sort_order) for t in rows] @router.post("/tags", response_model=TagOut, status_code=201) async def create_tag( body: TagIn, user_id: str = Depends(get_user_id), db: AsyncSession = Depends(get_db_session), ): slug = slugify(body.name) existing = (await db.execute( select(Tag).where(Tag.user_id == user_id, Tag.slug == slug) )).scalar_one_or_none() if existing: raise HTTPException(400, "Tag already exists") max_order = (await db.execute( select(func.max(Tag.sort_order)).where(Tag.user_id == user_id) )).scalar() or 0 tag = Tag(id=str(uuid.uuid4()), user_id=user_id, name=body.name.strip(), slug=slug, color=body.color, icon=body.icon, sort_order=max_order + 1) db.add(tag) await db.commit() return TagOut(id=tag.id, name=tag.name, slug=tag.slug, is_active=tag.is_active, sort_order=tag.sort_order) @router.patch("/tags/{tag_id}", response_model=TagOut) async def update_tag( tag_id: str, body: TagIn, user_id: str = Depends(get_user_id), db: AsyncSession = Depends(get_db_session), ): tag = (await db.execute( select(Tag).where(Tag.id == tag_id, Tag.user_id == user_id) )).scalar_one_or_none() if not tag: raise HTTPException(404, "Tag not found") old_name = tag.name tag.name = body.name.strip() tag.slug = slugify(body.name) if body.color is not None: tag.color = body.color if body.icon is not None: tag.icon = body.icon tag.updated_at = datetime.utcnow() # Update denormalized tags array on items that had the old name items_with_tag = (await db.execute( select(Item).join(ItemTag, ItemTag.item_id == Item.id).where(ItemTag.tag_id == tag_id) )).scalars().all() for item in items_with_tag: if item.tags and old_name in item.tags: item.tags = [tag.name if t == old_name else t for t in item.tags] await db.commit() return TagOut(id=tag.id, name=tag.name, slug=tag.slug, is_active=tag.is_active, sort_order=tag.sort_order) @router.delete("/tags/{tag_id}") async def delete_tag( tag_id: str, user_id: str = Depends(get_user_id), db: AsyncSession = Depends(get_db_session), ): tag = (await db.execute( select(Tag).where(Tag.id == tag_id, Tag.user_id == user_id) )).scalar_one_or_none() if not tag: raise HTTPException(404, "Tag not found") # Remove tag from denormalized arrays items_with_tag = (await db.execute( select(Item).join(ItemTag, ItemTag.item_id == Item.id).where(ItemTag.tag_id == tag_id) )).scalars().all() for item in items_with_tag: if item.tags and tag.name in item.tags: item.tags = [t for t in item.tags if t != tag.name] # Delete join table entries and tag await db.execute(delete(ItemTag).where(ItemTag.tag_id == tag_id)) await db.execute(delete(Tag).where(Tag.id == tag_id)) await db.commit() return {"status": "deleted"} @router.post("/tags/merge") async def merge_tags( body: MergeTagIn, user_id: str = Depends(get_user_id), db: AsyncSession = Depends(get_db_session), ): """Merge source tag into target tag. All items with source get target instead.""" source = (await db.execute(select(Tag).where(Tag.id == body.source_tag_id, Tag.user_id == user_id))).scalar_one_or_none() target = (await db.execute(select(Tag).where(Tag.id == body.target_tag_id, Tag.user_id == user_id))).scalar_one_or_none() if not source or not target: raise HTTPException(404, "Tag not found") # Move item_tags from source to target (skip duplicates) source_items = (await db.execute( select(ItemTag.item_id).where(ItemTag.tag_id == source.id) )).scalars().all() target_items = set((await db.execute( select(ItemTag.item_id).where(ItemTag.tag_id == target.id) )).scalars().all()) for item_id in source_items: if item_id not in target_items: db.add(ItemTag(item_id=item_id, tag_id=target.id)) # Update denormalized tags items = (await db.execute( select(Item).join(ItemTag, ItemTag.item_id == Item.id).where(ItemTag.tag_id == source.id) )).scalars().all() for item in items: if item.tags: new_tags = [target.name if t == source.name else t for t in item.tags] item.tags = list(dict.fromkeys(new_tags)) # dedupe # Delete source await db.execute(delete(ItemTag).where(ItemTag.tag_id == source.id)) await db.execute(delete(Tag).where(Tag.id == source.id)) await db.commit() return {"status": "merged", "source": source.name, "target": target.name}