feat(marketplace): add marketplace listings API and catalog items CRUD

This commit is contained in:
2026-09-15 20:09:07 +00:00
parent 18306c72e0
commit 90cc736d37
4 changed files with 527 additions and 1 deletions
+30
View File
@@ -53,3 +53,33 @@ async def get_current_user(
CurrentUser = Annotated[User, Depends(get_current_user)] CurrentUser = Annotated[User, Depends(get_current_user)]
oauth2_scheme_optional = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
async def get_optional_user(
token: Annotated[str | None, Depends(oauth2_scheme_optional)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> User | None:
"""Resolve the JWT bearer token to a User if present and valid, otherwise None."""
if not token:
return None
try:
payload = jwt.decode(
token, settings.public_pem, algorithms=[settings.ALGORITHM]
)
user_id: str | None = payload.get("sub")
if user_id is None:
return None
user_id_int = int(user_id)
except (JWTError, ValueError, TypeError):
return None
result = await db.execute(select(User).where(User.id == user_id_int))
user = result.scalar_one_or_none()
if user is None or not user.is_active:
return None
return user
OptionalUser = Annotated[User | None, Depends(get_optional_user)]
+2 -1
View File
@@ -9,7 +9,7 @@ from app.config import settings
from app.routers import ( from app.routers import (
auth, users, worlds, assets, avatar, world_content, internal, auth, users, worlds, assets, avatar, world_content, internal,
presence, groups, social_friends, profiles, chat_dm, chat_reports, presence, groups, social_friends, profiles, chat_dm, chat_reports,
chat_world, wallet, payments, chat_world, wallet, payments, marketplace,
) )
from app.gateway import ws from app.gateway import ws
@@ -60,6 +60,7 @@ app.include_router(chat_world.router, prefix="/api/chat", tags=["chat-world"])
app.include_router(chat_world.router, prefix="/v1/chat", tags=["chat-world"]) app.include_router(chat_world.router, prefix="/v1/chat", tags=["chat-world"])
app.include_router(wallet.router, prefix="/api/wallet", tags=["wallet"]) app.include_router(wallet.router, prefix="/api/wallet", tags=["wallet"])
app.include_router(payments.router, prefix="/api/payments", tags=["payments"]) app.include_router(payments.router, prefix="/api/payments", tags=["payments"])
app.include_router(marketplace.router, prefix="/api/marketplace", tags=["marketplace"])
app.include_router(ws.router, prefix="/api", tags=["websocket"]) app.include_router(ws.router, prefix="/api", tags=["websocket"])
app.include_router(ws.router, prefix="/v1", tags=["websocket"]) app.include_router(ws.router, prefix="/v1", tags=["websocket"])
+328
View File
@@ -0,0 +1,328 @@
"""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
+167
View File
@@ -0,0 +1,167 @@
"""Integration tests for Marketplace Listings API.
docs/planning/economy.md US-3 (task f51c7d95e237).
"""
import pytest
from fastapi.testclient import TestClient
from app.main import app
def _register(client: TestClient, username: str) -> tuple[int, dict[str, str]]:
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201, resp.text
token = resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
me = client.get("/api/users/me", headers=headers)
assert me.status_code == 200, me.text
return me.json()["id"], headers
def test_catalog_items_and_listings_crud():
client = TestClient(app)
uid1, h1 = _register(client, "mkt_user_1")
uid2, h2 = _register(client, "mkt_user_2")
# 1. Create catalog item by user 1
resp = client.post(
"/api/marketplace/items",
headers=h1,
json={
"name": "Wizard Hat",
"description": "A pointy blue hat with stars",
"item_type": "avatar_item",
"metadata_json": {"color": "blue", "rarity": "rare"},
},
)
assert resp.status_code == 201, resp.text
item_data = resp.json()
assert item_data["name"] == "Wizard Hat"
assert item_data["owner_id"] == uid1
item_id = item_data["id"]
# 2. List catalog items
resp = client.get("/api/marketplace/items", headers=h1, params={"mine": "true"})
assert resp.status_code == 200
my_items = resp.json()
assert len(my_items) >= 1
assert any(i["id"] == item_id for i in my_items)
resp = client.get("/api/marketplace/items", headers=h2, params={"mine": "true"})
assert resp.status_code == 200
assert not any(i["id"] == item_id for i in resp.json())
# 3. User 2 tries to list User 1's item -> 403
resp = client.post(
"/api/marketplace/listings",
headers=h2,
json={
"catalog_item_id": item_id,
"price": 100,
},
)
assert resp.status_code == 403, resp.text
# Invalid price <= 0 -> 422
resp = client.post(
"/api/marketplace/listings",
headers=h1,
json={
"catalog_item_id": item_id,
"price": 0,
},
)
assert resp.status_code == 422
# 4. User 1 successfully lists item
resp = client.post(
"/api/marketplace/listings",
headers=h1,
json={
"catalog_item_id": item_id,
"price": 250,
},
)
assert resp.status_code == 201, resp.text
listing = resp.json()
assert listing["price"] == 250
assert listing["seller_id"] == uid1
assert listing["status"] == "active"
assert listing["catalog_item"]["name"] == "Wizard Hat"
listing_id = listing["id"]
# 5. Search / browse listings
resp = client.get("/api/marketplace/listings", params={"q": "Wizard"})
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["total"] >= 1
assert any(l["id"] == listing_id for l in data["listings"])
# Filter by item_type
resp = client.get("/api/marketplace/listings", params={"item_type": "avatar_item"})
assert resp.status_code == 200
assert any(l["id"] == listing_id for l in resp.json()["listings"])
resp = client.get("/api/marketplace/listings", params={"item_type": "experience_pass"})
assert resp.status_code == 200
assert not any(l["id"] == listing_id for l in resp.json()["listings"])
# Price range filter
resp = client.get("/api/marketplace/listings", params={"min_price": 300})
assert resp.status_code == 200
assert not any(l["id"] == listing_id for l in resp.json()["listings"])
resp = client.get("/api/marketplace/listings", params={"max_price": 260})
assert resp.status_code == 200
assert any(l["id"] == listing_id for l in resp.json()["listings"])
# 6. Listing detail
resp = client.get(f"/api/marketplace/listings/{listing_id}")
assert resp.status_code == 200
assert resp.json()["id"] == listing_id
# 7. Update listing
# User 2 cannot update User 1's listing
resp = client.patch(
f"/api/marketplace/listings/{listing_id}",
headers=h2,
json={"price": 500},
)
assert resp.status_code == 403
# User 1 updates price
resp = client.patch(
f"/api/marketplace/listings/{listing_id}",
headers=h1,
json={"price": 350},
)
assert resp.status_code == 200
assert resp.json()["price"] == 350
# 8. Delist listing
# User 2 cannot delist
resp = client.delete(f"/api/marketplace/listings/{listing_id}", headers=h2)
assert resp.status_code == 403
# User 1 delists
resp = client.delete(f"/api/marketplace/listings/{listing_id}", headers=h1)
assert resp.status_code == 204
# Now detail shows delisted
resp = client.get(f"/api/marketplace/listings/{listing_id}")
assert resp.status_code == 200
assert resp.json()["status"] == "delisted"
# Default search only shows active
resp = client.get("/api/marketplace/listings")
assert resp.status_code == 200
assert not any(l["id"] == listing_id for l in resp.json()["listings"])