feat(discovery): browse and search endpoint with name/tag/category filters, sorting, and categories listing

This commit is contained in:
2026-09-15 20:58:25 +00:00
parent bddee1b96d
commit dd4edecf11
2 changed files with 300 additions and 0 deletions
+112
View File
@@ -10,6 +10,7 @@ from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, s
from pydantic import BaseModel
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.dependencies import OptionalUser
@@ -21,7 +22,35 @@ router = APIRouter()
DbDep = Annotated[AsyncSession, Depends(get_db)]
class WorldOwnerSummary(BaseModel):
id: int
username: str
model_config = {"from_attributes": True}
class DiscoveredWorld(BaseModel):
id: int
name: str
description: str | None = None
category: str | None = None
tags: list[str] = []
play_count: int = 0
created_at: datetime | None = None
owner: WorldOwnerSummary
model_config = {"from_attributes": True}
class DiscoveryListResponse(BaseModel):
items: list[DiscoveredWorld]
total: int
skip: int
limit: int
class VisitRequest(BaseModel):
anonymous_token: str | None = None
idempotency_key: str | None = None
@@ -132,3 +161,86 @@ async def record_world_visit(
play_count=world.play_count,
last_played_at=world.last_played_at,
)
@router.get("/categories", response_model=list[str])
async def list_categories(
db: DbDep,
):
"""List distinct categories of public worlds."""
res = await db.execute(
select(World.category)
.where(World.category.is_not(None), World.visibility == WorldVisibility.PUBLIC)
.distinct()
.order_by(World.category)
)
categories = [c for c in res.scalars().all() if c]
return categories
@router.get("/worlds", response_model=DiscoveryListResponse)
async def list_discovery_worlds(
db: DbDep,
current_user: OptionalUser = None,
q: str | None = Query(None, description="Substring search on world name"),
tags: list[str] | None = Query(None, description="Filter by tags"),
category: str | None = Query(None, description="Exact category filter"),
sort: str = Query("newest", description="Sort order (newest, most_played)"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
):
"""List public worlds for discovery with search, category/tag filters, and pagination."""
base_query = select(World).where(World.visibility == WorldVisibility.PUBLIC)
if q:
base_query = base_query.where(World.name.ilike(f"%{q}%"))
if category:
base_query = base_query.where(func.lower(World.category) == category.lower())
if tags:
parsed_tags = [t.strip() for item in tags for t in item.split(",") if t.strip()]
if parsed_tags:
base_query = base_query.where(World.tags.overlap(parsed_tags))
# Count total matching worlds
count_query = select(func.count()).select_from(base_query.subquery())
total = (await db.execute(count_query)).scalar_one()
# Sort
if sort == "most_played":
base_query = base_query.order_by(World.play_count.desc(), World.created_at.desc())
else: # default: newest
base_query = base_query.order_by(World.created_at.desc())
# Pagination & load owner
query = (
base_query
.offset(skip)
.limit(limit)
.options(selectinload(World.owner))
)
result = await db.execute(query)
worlds = result.scalars().all()
items = [
DiscoveredWorld(
id=w.id,
name=w.name,
description=w.description,
category=w.category,
tags=w.tags or [],
play_count=w.play_count,
created_at=w.created_at,
owner=WorldOwnerSummary(id=w.owner.id, username=w.owner.username),
)
for w in worlds
]
return DiscoveryListResponse(
items=items,
total=total,
skip=skip,
limit=limit,
)