from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from app.core.deps import get_db from app.models.spot import Spot from app.models.tag import SpotTag, Tag from app.schemas.common import PageResponse from app.schemas.spot import SpotBrief router = APIRouter() def _spot_to_brief(spot: Spot) -> SpotBrief: cover = next((img for img in spot.images if img.is_cover), None) if cover is None and spot.images: cover = spot.images[0] return SpotBrief( id=spot.id, title=spot.title, city=spot.city, longitude=spot.longitude, latitude=spot.latitude, cover_image_url=cover.image_url if cover else None, audit_status=spot.audit_status, avg_rating=spot.avg_rating, created_at=spot.created_at, ) @router.get("", response_model=PageResponse[SpotBrief]) async def search_spots( q: str = Query(..., min_length=1), page: int = Query(default=1, ge=1), page_size: int = Query(default=20, ge=1, le=100), db: AsyncSession = Depends(get_db), ): if not q.strip(): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Query cannot be empty") pattern = f"%{q}%" tag_spot_ids = ( select(SpotTag.spot_id) .join(Tag, SpotTag.tag_id == Tag.id) .where(Tag.name.ilike(pattern)) .distinct() .correlate(None) .scalar_subquery() ) filters = ( Spot.audit_status == "approved", or_( Spot.title.ilike(pattern), Spot.description.ilike(pattern), Spot.id.in_(tag_spot_ids), ), ) count_result = await db.execute(select(func.count(Spot.id)).where(*filters)) total = count_result.scalar() or 0 offset = (page - 1) * page_size result = await db.execute( select(Spot) .where(*filters) .order_by(Spot.created_at.desc()) .offset(offset) .limit(page_size) ) spots = result.scalars().all() return PageResponse(total=total, items=[_spot_to_brief(s) for s in spots])