329 lines
10 KiB
Python
329 lines
10 KiB
Python
"""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
|