247 lines
7.4 KiB
Python
247 lines
7.4 KiB
Python
"""Discovery router - visit tracking and world browsing.
|
|
|
|
docs/planning/discovery.md (tasks 42726e60fe10, 300da1dcddf3, 33706f401ad9).
|
|
"""
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, status
|
|
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
|
|
from app.models.world import World, WorldVisibility
|
|
from app.models.world_visit import WorldVisit
|
|
|
|
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
|
|
|
|
|
|
class VisitResponse(BaseModel):
|
|
play_count: int
|
|
last_played_at: datetime | None = None
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
DEDUP_WINDOW_SECONDS = 60
|
|
|
|
|
|
@router.post("/worlds/{world_id}/visit", response_model=VisitResponse)
|
|
async def record_world_visit(
|
|
world_id: int,
|
|
request: Request,
|
|
db: DbDep,
|
|
current_user: OptionalUser = None,
|
|
visit_data: VisitRequest | None = None,
|
|
x_idempotency_key: Annotated[str | None, Header(alias="X-Idempotency-Key")] = None,
|
|
idempotency_key: str | None = Query(None),
|
|
):
|
|
"""Record a visit to a world.
|
|
|
|
- Increments worlds.play_count atomically (World.play_count + 1 in UPDATE).
|
|
- Updates worlds.last_played_at to func.now().
|
|
- Records row in world_visits (with world_id, user_id nullable, visited_at).
|
|
- Checks visibility: 404 if world not found or not PUBLIC and not owned by user.
|
|
- Idempotent: repeated calls within deduplication window or with same token/key do not duplicate.
|
|
"""
|
|
# 1. Fetch world
|
|
res = await db.execute(select(World).where(World.id == world_id))
|
|
world = res.scalar_one_or_none()
|
|
if not world:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="World not found")
|
|
|
|
# 2. Check visibility: public or owned by requesting user
|
|
if world.visibility != WorldVisibility.PUBLIC:
|
|
if not current_user or world.owner_id != current_user.id:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="World not found")
|
|
|
|
# Determine token / idempotency key
|
|
explicit_key = (
|
|
(visit_data.idempotency_key if visit_data else None)
|
|
or (visit_data.anonymous_token if visit_data else None)
|
|
or x_idempotency_key
|
|
or idempotency_key
|
|
)
|
|
|
|
fallback_host = request.client.host if request.client else "unknown_client"
|
|
dedup_key = explicit_key or (f"client_{fallback_host}" if not current_user else None)
|
|
|
|
# 3. Deduplication check
|
|
cutoff = datetime.now(timezone.utc) - timedelta(seconds=DEDUP_WINDOW_SECONDS)
|
|
is_duplicate = False
|
|
|
|
if current_user:
|
|
dedup_res = await db.execute(
|
|
select(WorldVisit).where(
|
|
WorldVisit.world_id == world_id,
|
|
WorldVisit.user_id == current_user.id,
|
|
WorldVisit.visited_at >= cutoff,
|
|
).limit(1)
|
|
)
|
|
if dedup_res.scalar_one_or_none():
|
|
is_duplicate = True
|
|
elif dedup_key:
|
|
dedup_res = await db.execute(
|
|
select(WorldVisit).where(
|
|
WorldVisit.world_id == world_id,
|
|
WorldVisit.anonymous_token == dedup_key,
|
|
WorldVisit.visited_at >= cutoff,
|
|
).limit(1)
|
|
)
|
|
if dedup_res.scalar_one_or_none():
|
|
is_duplicate = True
|
|
|
|
if is_duplicate:
|
|
return VisitResponse(
|
|
play_count=world.play_count,
|
|
last_played_at=world.last_played_at,
|
|
)
|
|
|
|
# 4. Atomic increment and visit log in single transaction
|
|
await db.execute(
|
|
update(World)
|
|
.where(World.id == world_id)
|
|
.values(
|
|
play_count=World.play_count + 1,
|
|
last_played_at=func.now(),
|
|
)
|
|
)
|
|
|
|
visit = WorldVisit(
|
|
world_id=world_id,
|
|
user_id=current_user.id if current_user else None,
|
|
anonymous_token=dedup_key if not current_user else None,
|
|
)
|
|
db.add(visit)
|
|
await db.commit()
|
|
|
|
# Re-fetch or refresh to return updated fields
|
|
await db.refresh(world)
|
|
|
|
return VisitResponse(
|
|
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,
|
|
)
|
|
|