Merge branch 'agent/166836d55365-marketplace-models' into main
checkpoint / checkpoint (push) Successful in 5m21s

This commit is contained in:
2026-09-15 19:58:37 +00:00
5 changed files with 484 additions and 1 deletions
@@ -0,0 +1,146 @@
"""add marketplace tables: catalog_items, marketplace_listings, purchases, creator_balances
Revision ID: 0013_add_marketplace_tables
Revises: 0012_add_wallet_and_transactions
Create Date: 2026-09-15 00:00:00.000000
docs/planning/economy.md US-3, US-4 (task 166836d55365).
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "0013_add_marketplace_tables"
down_revision: Union[str, None] = "0012_add_wallet_and_transactions"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
ITEM_TYPES = ("asset", "avatar_item", "experience_pass")
LISTING_STATUSES = ("active", "sold", "delisted", "removed_by_moderation")
PURCHASE_STATUSES = ("pending_escrow", "completed", "refunded", "reversed")
def upgrade() -> None:
bind = op.get_bind()
is_pg = bind.dialect.name == "postgresql"
# Enums
if is_pg:
item_type_enum = postgresql.ENUM(*ITEM_TYPES, name="catalog_item_type", create_type=False)
item_type_enum.create(bind, checkfirst=True)
item_type_col = postgresql.ENUM(*ITEM_TYPES, name="catalog_item_type", create_type=False)
listing_status_enum = postgresql.ENUM(*LISTING_STATUSES, name="marketplace_listing_status", create_type=False)
listing_status_enum.create(bind, checkfirst=True)
listing_status_col = postgresql.ENUM(*LISTING_STATUSES, name="marketplace_listing_status", create_type=False)
purchase_status_enum = postgresql.ENUM(*PURCHASE_STATUSES, name="purchase_status", create_type=False)
purchase_status_enum.create(bind, checkfirst=True)
purchase_status_col = postgresql.ENUM(*PURCHASE_STATUSES, name="purchase_status", create_type=False)
else:
item_type_col = sa.Enum(*ITEM_TYPES, name="catalog_item_type")
listing_status_col = sa.Enum(*LISTING_STATUSES, name="marketplace_listing_status")
purchase_status_col = sa.Enum(*PURCHASE_STATUSES, name="purchase_status")
# 1. catalog_items
op.create_table(
"catalog_items",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("owner_id", sa.Integer(), nullable=False),
sa.Column("asset_id", sa.Integer(), nullable=True),
sa.Column("item_type", item_type_col, nullable=False),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("description", sa.String(length=2000), nullable=True),
sa.Column("metadata_json", postgresql.JSONB(astext_type=sa.Text()) if is_pg else sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["owner_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["asset_id"], ["assets.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_catalog_items_owner_id"), "catalog_items", ["owner_id"], unique=False)
op.create_index(op.f("ix_catalog_items_asset_id"), "catalog_items", ["asset_id"], unique=False)
# 2. marketplace_listings
op.create_table(
"marketplace_listings",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("catalog_item_id", sa.BigInteger(), nullable=False),
sa.Column("seller_id", sa.Integer(), nullable=False),
sa.Column("price", sa.BigInteger(), nullable=False),
sa.Column("status", listing_status_col, server_default="active", nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.CheckConstraint("price > 0", name="check_listing_price_positive"),
sa.ForeignKeyConstraint(["catalog_item_id"], ["catalog_items.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["seller_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_marketplace_listings_catalog_item_id"), "marketplace_listings", ["catalog_item_id"], unique=False)
op.create_index(op.f("ix_marketplace_listings_seller_id"), "marketplace_listings", ["seller_id"], unique=False)
# 3. purchases
op.create_table(
"purchases",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("listing_id", sa.BigInteger(), nullable=True),
sa.Column("buyer_id", sa.Integer(), nullable=False),
sa.Column("seller_id", sa.Integer(), nullable=False),
sa.Column("price", sa.BigInteger(), nullable=False),
sa.Column("platform_fee", sa.BigInteger(), nullable=False),
sa.Column("seller_net", sa.BigInteger(), nullable=False),
sa.Column("status", purchase_status_col, server_default="pending_escrow", nullable=False),
sa.Column("escrow_hold_until", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["listing_id"], ["marketplace_listings.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["buyer_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["seller_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_purchases_listing_id"), "purchases", ["listing_id"], unique=False)
op.create_index(op.f("ix_purchases_buyer_id"), "purchases", ["buyer_id"], unique=False)
op.create_index(op.f("ix_purchases_seller_id"), "purchases", ["seller_id"], unique=False)
# 4. creator_balances
op.create_table(
"creator_balances",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("available", sa.BigInteger(), server_default="0", nullable=False),
sa.Column("pending", sa.BigInteger(), server_default="0", nullable=False),
sa.Column("lifetime_earned", sa.BigInteger(), server_default="0", nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.CheckConstraint("available >= 0", name="check_creator_available_non_negative"),
sa.CheckConstraint("pending >= 0", name="check_creator_pending_non_negative"),
sa.CheckConstraint("lifetime_earned >= 0", name="check_creator_lifetime_non_negative"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("user_id", name="uq_creator_balances_user_id"),
)
op.create_index(op.f("ix_creator_balances_user_id"), "creator_balances", ["user_id"], unique=True)
def downgrade() -> None:
op.drop_index(op.f("ix_creator_balances_user_id"), table_name="creator_balances")
op.drop_table("creator_balances")
op.drop_index(op.f("ix_purchases_seller_id"), table_name="purchases")
op.drop_index(op.f("ix_purchases_buyer_id"), table_name="purchases")
op.drop_index(op.f("ix_purchases_listing_id"), table_name="purchases")
op.drop_table("purchases")
op.drop_index(op.f("ix_marketplace_listings_seller_id"), table_name="marketplace_listings")
op.drop_index(op.f("ix_marketplace_listings_catalog_item_id"), table_name="marketplace_listings")
op.drop_table("marketplace_listings")
op.drop_index(op.f("ix_catalog_items_asset_id"), table_name="catalog_items")
op.drop_index(op.f("ix_catalog_items_owner_id"), table_name="catalog_items")
op.drop_table("catalog_items")
bind = op.get_bind()
if bind.dialect.name == "postgresql":
sa.Enum(*PURCHASE_STATUSES, name="purchase_status").drop(bind, checkfirst=True)
sa.Enum(*LISTING_STATUSES, name="marketplace_listing_status").drop(bind, checkfirst=True)
sa.Enum(*ITEM_TYPES, name="catalog_item_type").drop(bind, checkfirst=True)
+6
View File
@@ -11,6 +11,10 @@ from app.models.chat import (
from app.models.groups import Group, GroupMember, GroupRole
from app.models.profile import Profile, ProfileFavorite, ProfileVisibility
from app.models.wallet import Wallet, Transaction, TransactionType
from app.models.marketplace import (
CatalogItem, MarketplaceListing, Purchase, CreatorBalance,
ItemType, ListingStatus, PurchaseStatus,
)
__all__ = [
"User", "Session", "World", "Asset", "AvatarLoadout",
@@ -20,4 +24,6 @@ __all__ = [
"Group", "GroupMember", "GroupRole",
"Profile", "ProfileFavorite", "ProfileVisibility",
"Wallet", "Transaction", "TransactionType",
"CatalogItem", "MarketplaceListing", "Purchase", "CreatorBalance",
"ItemType", "ListingStatus", "PurchaseStatus",
]
+155
View File
@@ -0,0 +1,155 @@
"""Marketplace models: catalog items, listings, purchases, and creator balances.
docs/planning/economy.md US-3, US-4 (task 166836d55365).
"""
from datetime import datetime, timezone
import enum
from sqlalchemy import (
BigInteger,
CheckConstraint,
Column,
DateTime,
Enum,
ForeignKey,
Integer,
String,
func,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.types import JSON
from sqlalchemy.orm import relationship
from app.database import Base
class ItemType(str, enum.Enum):
asset = "asset"
avatar_item = "avatar_item"
experience_pass = "experience_pass"
class ListingStatus(str, enum.Enum):
active = "active"
sold = "sold"
delisted = "delisted"
removed_by_moderation = "removed_by_moderation"
class PurchaseStatus(str, enum.Enum):
pending_escrow = "pending_escrow"
completed = "completed"
refunded = "refunded"
reversed = "reversed"
class CatalogItem(Base):
__tablename__ = "catalog_items"
id = Column(BigInteger, primary_key=True, autoincrement=True)
owner_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
asset_id = Column(Integer, ForeignKey("assets.id", ondelete="SET NULL"), nullable=True, index=True)
item_type = Column(Enum(ItemType, name="catalog_item_type"), nullable=False)
name = Column(String(100), nullable=False)
description = Column(String(2000), nullable=True)
metadata_json = Column(JSONB().with_variant(JSON, "sqlite"), nullable=True)
created_at = Column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(timezone.utc),
server_default=func.now(),
)
owner = relationship("User", foreign_keys=[owner_id])
asset = relationship("Asset", foreign_keys=[asset_id])
listings = relationship("MarketplaceListing", back_populates="catalog_item", cascade="all, delete-orphan")
class MarketplaceListing(Base):
__tablename__ = "marketplace_listings"
id = Column(BigInteger, primary_key=True, autoincrement=True)
catalog_item_id = Column(BigInteger, ForeignKey("catalog_items.id", ondelete="CASCADE"), nullable=False, index=True)
seller_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
price = Column(BigInteger, nullable=False)
status = Column(
Enum(ListingStatus, name="marketplace_listing_status"),
nullable=False,
default=ListingStatus.active,
server_default=ListingStatus.active.value,
)
created_at = Column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(timezone.utc),
server_default=func.now(),
)
updated_at = Column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
server_default=func.now(),
)
__table_args__ = (
CheckConstraint("price > 0", name="check_listing_price_positive"),
)
catalog_item = relationship("CatalogItem", back_populates="listings")
seller = relationship("User", foreign_keys=[seller_id])
class Purchase(Base):
__tablename__ = "purchases"
id = Column(BigInteger, primary_key=True, autoincrement=True)
listing_id = Column(BigInteger, ForeignKey("marketplace_listings.id", ondelete="SET NULL"), nullable=True, index=True)
buyer_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
seller_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
price = Column(BigInteger, nullable=False)
platform_fee = Column(BigInteger, nullable=False)
seller_net = Column(BigInteger, nullable=False)
status = Column(
Enum(PurchaseStatus, name="purchase_status"),
nullable=False,
default=PurchaseStatus.pending_escrow,
server_default=PurchaseStatus.pending_escrow.value,
)
escrow_hold_until = Column(DateTime(timezone=True), nullable=True)
created_at = Column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(timezone.utc),
server_default=func.now(),
)
listing = relationship("MarketplaceListing", foreign_keys=[listing_id])
buyer = relationship("User", foreign_keys=[buyer_id])
seller = relationship("User", foreign_keys=[seller_id])
class CreatorBalance(Base):
__tablename__ = "creator_balances"
id = Column(BigInteger, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=False, index=True)
available = Column(BigInteger, nullable=False, default=0, server_default="0")
pending = Column(BigInteger, nullable=False, default=0, server_default="0")
lifetime_earned = Column(BigInteger, nullable=False, default=0, server_default="0")
updated_at = Column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
server_default=func.now(),
)
__table_args__ = (
CheckConstraint("available >= 0", name="check_creator_available_non_negative"),
CheckConstraint("pending >= 0", name="check_creator_pending_non_negative"),
CheckConstraint("lifetime_earned >= 0", name="check_creator_lifetime_non_negative"),
)
user = relationship("User", foreign_keys=[user_id])
+2 -1
View File
@@ -58,7 +58,8 @@ TEST_TABLES = (
# (not CASCADE, deliberately -- see groups.py), so it wouldn't be reached
# by cascading a truncate of users; list it explicitly for clarity too.
"group_members", "groups",
# Economy (app/models/wallet.py) -- transactions FK wallets FK users
# Economy (app/models/wallet.py, app/models/marketplace.py)
"purchases", "marketplace_listings", "catalog_items", "creator_balances",
"transactions", "wallets",
"sessions", "refresh_tokens", "users",
)
+175
View File
@@ -0,0 +1,175 @@
"""Model-level integration tests for Marketplace tables.
docs/planning/economy.md US-3, US-4 (task 166836d55365).
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import settings
from app.main import app
from app.models.marketplace import (
CatalogItem,
CreatorBalance,
ItemType,
ListingStatus,
MarketplaceListing,
Purchase,
PurchaseStatus,
)
def _register(client: TestClient, username: str) -> int:
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
headers = {"Authorization": f"Bearer {resp.json()['access_token']}"}
me = client.get("/api/users/me", headers=headers)
assert me.status_code == 200, me.text
return me.json()["id"]
def _session_factory():
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
return engine, async_sessionmaker(engine, expire_on_commit=False)
@pytest.mark.asyncio
async def test_catalog_item_and_listing_creation():
client = TestClient(app)
seller_id = _register(client, "market_seller_1")
engine, sf = _session_factory()
try:
async with sf() as session:
item = CatalogItem(
owner_id=seller_id,
item_type=ItemType.avatar_item,
name="Cyber Wings",
description="Glow in the dark wings",
metadata_json={"slot": "back"},
)
session.add(item)
await session.commit()
await session.refresh(item)
assert item.id is not None
assert item.name == "Cyber Wings"
listing = MarketplaceListing(
catalog_item_id=item.id,
seller_id=seller_id,
price=250,
status=ListingStatus.active,
)
session.add(listing)
await session.commit()
await session.refresh(listing)
assert listing.id is not None
assert listing.price == 250
assert listing.status == ListingStatus.active
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_listing_price_positive_constraint():
client = TestClient(app)
seller_id = _register(client, "market_seller_2")
engine, sf = _session_factory()
try:
async with sf() as session:
item = CatalogItem(
owner_id=seller_id,
item_type=ItemType.asset,
name="Free Prop",
)
session.add(item)
await session.commit()
await session.refresh(item)
bad_listing = MarketplaceListing(
catalog_item_id=item.id,
seller_id=seller_id,
price=0, # Must be > 0
)
session.add(bad_listing)
with pytest.raises(IntegrityError):
await session.commit()
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_creator_balance_uniqueness_and_constraints():
client = TestClient(app)
creator_id = _register(client, "market_creator_1")
engine, sf = _session_factory()
try:
async with sf() as session:
cb1 = CreatorBalance(user_id=creator_id, available=100, pending=50, lifetime_earned=150)
session.add(cb1)
await session.commit()
await session.refresh(cb1)
assert cb1.available == 100
assert cb1.pending == 50
assert cb1.lifetime_earned == 150
# Unique user_id constraint
async with sf() as session:
cb2 = CreatorBalance(user_id=creator_id)
session.add(cb2)
with pytest.raises(IntegrityError):
await session.commit()
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_purchase_record_creation():
client = TestClient(app)
seller_id = _register(client, "market_seller_3")
buyer_id = _register(client, "market_buyer_3")
engine, sf = _session_factory()
try:
async with sf() as session:
item = CatalogItem(owner_id=seller_id, item_type=ItemType.asset, name="Sword Prop")
session.add(item)
await session.commit()
await session.refresh(item)
listing = MarketplaceListing(catalog_item_id=item.id, seller_id=seller_id, price=100)
session.add(listing)
await session.commit()
await session.refresh(listing)
purchase = Purchase(
listing_id=listing.id,
buyer_id=buyer_id,
seller_id=seller_id,
price=100,
platform_fee=30,
seller_net=70,
status=PurchaseStatus.pending_escrow,
)
session.add(purchase)
await session.commit()
await session.refresh(purchase)
assert purchase.id is not None
assert purchase.seller_net == 70
assert purchase.status == PurchaseStatus.pending_escrow
finally:
await engine.dispose()