f34a4806ef
Recovers a real August feature that was never merged (3 WIP checkpoints + fix commit47ab492, 225 commits behind main): a Redis-backed heartbeat presence store plus /api/presence/{heartbeat,presence,status} endpoints. Ported onto current main's conventions rather than rebuilt: - app/models/user.py: last_seen_at/status_message columns (unchanged from the orphaned branch; still compatible with the current model). - alembic/versions/0006_add_presence_columns.py: same column migration, rebased as the new head after 0005_add_world_content_tables (was 2a9b9873244a off 0001_initial). - app/routers/presence.py: same three endpoints, updated to the DbDep pattern other routers (avatar.py) now use; retains the47ab492fix (is_online_batch via MGET, correct StatusUpdate Field annotation). - app/services/presence.py: real adaptation beyond a mechanical port -- the original PresenceStore required an explicit connect() that nothing ever called (main.py's lifespan never wired it up), so every real request would have raised "not connected". Made the Redis client connect lazily on first use instead, and cache it per running event loop rather than globally: this repo's TestClient(app) pattern (no `with` block) can dispatch different requests onto different event loops, which broke a single cached connection the same way NullPool's comment in conftest.py describes for asyncpg. Dropped the unused get_presence()/ update_status_message() methods and the never-wired init/shutdown singleton helpers -- dead code even in the original. - tests/conftest.py: extended the existing ephemeral-Postgres bootstrap to also bring up ephemeral Redis (docker-compose.yml already defined the service) when REDIS_URL isn't already reachable, so `pytest` stays hermetic. - tests/test_presence.py: heartbeat sets online, batch presence over mixed online/offline/unknown ids (the exact47ab492regression), status message update/strip/length-limit, and TTL expiry via a short-TTL PresenceStore instance (real Redis SET/EXISTS/MGET, no 60s sleep needed). Verified: full backend pytest suite (144 tests) passes against a fresh Postgres+Redis. Also drove the real endpoints end-to-end against a running uvicorn + docker-compose Postgres/Redis: heartbeat -> online, batch query, forced TTL expiry -> offline with last_seen_at retained, status message trim + 422 over 140 chars. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y2FESCaMXeJpC9gSctiEeD
147 lines
4.5 KiB
Python
147 lines
4.5 KiB
Python
"""Presence & status router.
|
|
|
|
Endpoints:
|
|
- POST /api/presence/heartbeat -- record a heartbeat (sets Redis TTL key)
|
|
- GET /api/presence/presence -- query presence for a list of user ids
|
|
- PATCH /api/presence/status -- update the authenticated user's status message
|
|
"""
|
|
|
|
from datetime import datetime, timezone
|
|
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, HTTPException, status, Depends
|
|
from pydantic import BaseModel, Field, field_validator
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.database import get_db
|
|
from app.dependencies import CurrentUser
|
|
from app.models.user import User
|
|
from app.services.presence import presence_store
|
|
|
|
router = APIRouter()
|
|
|
|
DbDep = Annotated[AsyncSession, Depends(get_db)]
|
|
|
|
# ──────────────────────────── Pydantic schemas ────────────────────────────────
|
|
|
|
_STATUS_MAX_LEN = 140
|
|
|
|
|
|
class HeartbeatRequest(BaseModel):
|
|
"""Empty body -- the caller's identity comes from the JWT."""
|
|
|
|
|
|
class PresenceUser(BaseModel):
|
|
user_id: int
|
|
is_online: bool
|
|
last_seen_at: datetime | None
|
|
status_message: str | None
|
|
|
|
|
|
class PresenceResponse(BaseModel):
|
|
users: list[PresenceUser]
|
|
|
|
|
|
class StatusUpdate(BaseModel):
|
|
status_message: str | None = Field(default=None, max_length=_STATUS_MAX_LEN)
|
|
|
|
@field_validator("status_message")
|
|
@classmethod
|
|
def strip_message(cls, v: str | None) -> str | None:
|
|
if v is not None:
|
|
v = v.strip()
|
|
return v
|
|
|
|
|
|
# ──────────────────────────────── Endpoints ───────────────────────────────────
|
|
|
|
|
|
@router.post("/heartbeat", response_model=dict)
|
|
async def heartbeat(
|
|
_req: HeartbeatRequest,
|
|
current_user: CurrentUser,
|
|
db: DbDep,
|
|
):
|
|
"""Record a heartbeat for the authenticated user.
|
|
|
|
Sets a Redis key with a TTL so the server can derive online/offline
|
|
status without trusting client claims. Also persists last_seen_at to
|
|
the database.
|
|
"""
|
|
# Update Redis TTL (presence heartbeat)
|
|
await presence_store.heartbeat(current_user.id)
|
|
|
|
# Persist last_seen_at to the database
|
|
now = datetime.now(timezone.utc)
|
|
result = await db.execute(select(User).where(User.id == current_user.id))
|
|
user = result.scalar_one_or_none()
|
|
if user:
|
|
user.last_seen_at = now
|
|
await db.commit()
|
|
|
|
return {"status": "ok", "user_id": current_user.id}
|
|
|
|
|
|
@router.get("/presence", response_model=PresenceResponse)
|
|
async def get_presence(
|
|
user_ids: str,
|
|
current_user: CurrentUser,
|
|
db: DbDep,
|
|
):
|
|
"""Return presence info for a comma-separated list of user ids.
|
|
|
|
Online/offline is derived from the Redis heartbeat TTL.
|
|
last_seen_at and status_message come from the database.
|
|
"""
|
|
ids = [int(uid.strip()) for uid in user_ids.split(",") if uid.strip()]
|
|
if not ids:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="user_ids must contain at least one integer",
|
|
)
|
|
|
|
# Check Redis for online status (single round trip via MGET).
|
|
online_ids = await presence_store.is_online_batch(ids)
|
|
|
|
# Fetch DB records for last_seen_at and status_message
|
|
result = await db.execute(select(User).where(User.id.in_(ids)))
|
|
users = {u.id: u for u in result.scalars().all()}
|
|
|
|
presence_users = []
|
|
for uid in ids:
|
|
user = users.get(uid)
|
|
if user is None:
|
|
continue
|
|
presence_users.append(
|
|
PresenceUser(
|
|
user_id=uid,
|
|
is_online=uid in online_ids,
|
|
last_seen_at=user.last_seen_at,
|
|
status_message=user.status_message,
|
|
)
|
|
)
|
|
|
|
return PresenceResponse(users=presence_users)
|
|
|
|
|
|
@router.patch("/status", response_model=dict)
|
|
async def update_status(
|
|
body: StatusUpdate,
|
|
current_user: CurrentUser,
|
|
db: DbDep,
|
|
):
|
|
"""Update the authenticated user's status message (length-limited)."""
|
|
result = await db.execute(select(User).where(User.id == current_user.id))
|
|
user = result.scalar_one_or_none()
|
|
if not user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="User not found",
|
|
)
|
|
|
|
user.status_message = body.status_message
|
|
await db.commit()
|
|
|
|
return {"status": "ok", "status_message": body.status_message}
|