"""Marketplace listings API router. docs/planning/economy.md US-3 (task f51c7d95e237). """ from datetime import datetime from typing import Annotated, Any from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel, Field from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db from app.dependencies import CurrentUser, OptionalUser from app.models.marketplace import ( CatalogItem, ItemType, ListingStatus, MarketplaceListing, ) from app.models.user import User router = APIRouter() DbDep = Annotated[AsyncSession, Depends(get_db)] # ── Schemas ── class CatalogItemCreate(BaseModel): name: str = Field(..., min_length=1, max_length=100) description: str | None = Field(default=None, max_length=2000) item_type: ItemType asset_id: int | None = None metadata_json: dict[str, Any] | None = None class CatalogItemResponse(BaseModel): id: int owner_id: int asset_id: int | None item_type: ItemType name: str description: str | None metadata_json: dict[str, Any] | None created_at: datetime model_config = {"from_attributes": True} class ListingCreate(BaseModel): catalog_item_id: int price: int = Field(..., gt=0, description="Price in whole Credits (must be positive)") class ListingUpdate(BaseModel): price: int | None = Field(default=None, gt=0) status: ListingStatus | None = None class ListingResponse(BaseModel): id: int catalog_item_id: int seller_id: int price: int status: ListingStatus created_at: datetime updated_at: datetime catalog_item: CatalogItemResponse | None = None seller_username: str | None = None model_config = {"from_attributes": True} class ListingListResponse(BaseModel): listings: list[ListingResponse] total: int limit: int offset: int # ── Catalog Items ── @router.post("/items", response_model=CatalogItemResponse, status_code=status.HTTP_201_CREATED) async def create_catalog_item( payload: CatalogItemCreate, current_user: CurrentUser, db: DbDep, ): """Create a new catalog item owned by the authenticated user.""" item = CatalogItem( owner_id=current_user.id, asset_id=payload.asset_id, item_type=payload.item_type, name=payload.name.strip(), description=payload.description.strip() if payload.description else None, metadata_json=payload.metadata_json, ) db.add(item) await db.commit() await db.refresh(item) return item @router.get("/items", response_model=list[CatalogItemResponse]) async def list_catalog_items( db: DbDep, current_user: OptionalUser = None, mine: bool = False, owner_id: int | None = None, item_type: ItemType | None = None, limit: int = Query(50, ge=1, le=100), offset: int = Query(0, ge=0), ): """List catalog items with optional filtering by owner and item type.""" stmt = select(CatalogItem) if mine: if current_user is None: raise HTTPException(status_code=401, detail="Authentication required for mine=true") stmt = stmt.where(CatalogItem.owner_id == current_user.id) elif owner_id is not None: stmt = stmt.where(CatalogItem.owner_id == owner_id) if item_type is not None: stmt = stmt.where(CatalogItem.item_type == item_type) stmt = stmt.order_by(CatalogItem.created_at.desc(), CatalogItem.id.desc()).offset(offset).limit(limit) res = await db.execute(stmt) return list(res.scalars().all()) # ── Listings ── @router.post("/listings", response_model=ListingResponse, status_code=status.HTTP_201_CREATED) async def create_listing( payload: ListingCreate, current_user: CurrentUser, db: DbDep, ): """Create a new marketplace listing for an owned catalog item.""" item = await db.get(CatalogItem, payload.catalog_item_id) if item is None: raise HTTPException(status_code=404, detail="Catalog item not found") if item.owner_id != current_user.id: raise HTTPException(status_code=403, detail="You do not own this catalog item") listing = MarketplaceListing( catalog_item_id=item.id, seller_id=current_user.id, price=payload.price, status=ListingStatus.active, ) db.add(listing) await db.commit() await db.refresh(listing) return ListingResponse( id=listing.id, catalog_item_id=listing.catalog_item_id, seller_id=listing.seller_id, price=listing.price, status=listing.status, created_at=listing.created_at, updated_at=listing.updated_at, catalog_item=CatalogItemResponse.model_validate(item), seller_username=current_user.username, ) @router.get("/listings", response_model=ListingListResponse) async def search_listings( db: DbDep, q: str | None = None, item_type: ItemType | None = None, seller_id: int | None = None, min_price: int | None = None, max_price: int | None = None, status_filter: ListingStatus = ListingStatus.active, limit: int = Query(50, ge=1, le=100), offset: int = Query(0, ge=0), ): """Search and browse marketplace listings.""" stmt = ( select(MarketplaceListing, CatalogItem, User.username) .join(CatalogItem, MarketplaceListing.catalog_item_id == CatalogItem.id) .join(User, MarketplaceListing.seller_id == User.id) ) if status_filter: stmt = stmt.where(MarketplaceListing.status == status_filter) if seller_id is not None: stmt = stmt.where(MarketplaceListing.seller_id == seller_id) if item_type is not None: stmt = stmt.where(CatalogItem.item_type == item_type) if min_price is not None: stmt = stmt.where(MarketplaceListing.price >= min_price) if max_price is not None: stmt = stmt.where(MarketplaceListing.price <= max_price) if q: search_pattern = f"%{q.strip()}%" stmt = stmt.where( or_( CatalogItem.name.ilike(search_pattern), CatalogItem.description.ilike(search_pattern), ) ) # Count count_stmt = select(func.count()).select_from(stmt.subquery()) total = (await db.execute(count_stmt)).scalar() or 0 stmt = stmt.order_by(MarketplaceListing.created_at.desc(), MarketplaceListing.id.desc()).offset(offset).limit(limit) rows = (await db.execute(stmt)).all() listings = [ ListingResponse( id=listing.id, catalog_item_id=listing.catalog_item_id, seller_id=listing.seller_id, price=listing.price, status=listing.status, created_at=listing.created_at, updated_at=listing.updated_at, catalog_item=CatalogItemResponse.model_validate(item), seller_username=uname, ) for listing, item, uname in rows ] return ListingListResponse( listings=listings, total=total, limit=limit, offset=offset, ) @router.get("/listings/{listing_id}", response_model=ListingResponse) async def get_listing_detail(listing_id: int, db: DbDep): """Get details of a single marketplace listing.""" stmt = ( select(MarketplaceListing, CatalogItem, User.username) .join(CatalogItem, MarketplaceListing.catalog_item_id == CatalogItem.id) .join(User, MarketplaceListing.seller_id == User.id) .where(MarketplaceListing.id == listing_id) ) res = (await db.execute(stmt)).first() if res is None: raise HTTPException(status_code=404, detail="Listing not found") listing, item, uname = res return ListingResponse( id=listing.id, catalog_item_id=listing.catalog_item_id, seller_id=listing.seller_id, price=listing.price, status=listing.status, created_at=listing.created_at, updated_at=listing.updated_at, catalog_item=CatalogItemResponse.model_validate(item), seller_username=uname, ) @router.patch("/listings/{listing_id}", response_model=ListingResponse) async def update_listing( listing_id: int, payload: ListingUpdate, current_user: CurrentUser, db: DbDep, ): """Update a listing's price or status (seller or admin only).""" stmt = ( select(MarketplaceListing, CatalogItem, User.username) .join(CatalogItem, MarketplaceListing.catalog_item_id == CatalogItem.id) .join(User, MarketplaceListing.seller_id == User.id) .where(MarketplaceListing.id == listing_id) ) res = (await db.execute(stmt)).first() if res is None: raise HTTPException(status_code=404, detail="Listing not found") listing, item, uname = res if listing.seller_id != current_user.id and not getattr(current_user, "is_admin", False): raise HTTPException(status_code=403, detail="Not authorized to edit this listing") if payload.price is not None: listing.price = payload.price if payload.status is not None: listing.status = payload.status listing.updated_at = datetime.now() await db.commit() await db.refresh(listing) return ListingResponse( id=listing.id, catalog_item_id=listing.catalog_item_id, seller_id=listing.seller_id, price=listing.price, status=listing.status, created_at=listing.created_at, updated_at=listing.updated_at, catalog_item=CatalogItemResponse.model_validate(item), seller_username=uname, ) @router.delete("/listings/{listing_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_or_delist_listing( listing_id: int, current_user: CurrentUser, db: DbDep, ): """Delist or delete a listing (seller or admin only).""" listing = await db.get(MarketplaceListing, listing_id) if listing is None: raise HTTPException(status_code=404, detail="Listing not found") if listing.seller_id != current_user.id and not getattr(current_user, "is_admin", False): raise HTTPException(status_code=403, detail="Not authorized to delete this listing") listing.status = ListingStatus.delisted listing.updated_at = datetime.now() await db.commit() return None