0a0dde3d50
Implements user profile data model and own-profile management endpoints: - Profile and ProfileFavorites models in backend/app/models/profile.py - Migration 0010_add_profile_tables.py (profiles, profile_favorites) - Endpoints in backend/app/routers/profiles.py: - GET /api/profile/me (own profile with default fallback) - PUT /api/profile/me (upsert bio, avatar_asset_id, privacy settings) - GET /api/profile/me/favorites (list favorite world IDs) - PUT /api/profile/me/favorites (replace favorite world IDs) - Fixes values_callable on Asset.asset_type ENUM in app/models/asset.py - Added test_profile.py covering auth, validation, favorites, privacy Independently re-verified: 19/19 new profile tests pass, 258/258 full backend regression suite passes with zero regressions.
130 lines
5.9 KiB
Python
130 lines
5.9 KiB
Python
"""User profile: bio, avatar reference, privacy settings, favorite worlds.
|
|
|
|
This module is models + migration only -- the public profile view endpoint
|
|
(``GET /api/profile/{user_id}``, which will need to interpret ``privacy_json``
|
|
against the viewer's relationship to the owner) belongs to a sibling task
|
|
that depends on this one. This task only owns the authenticated-owner
|
|
read/write endpoints (``/api/profile/me`` and its ``favorites`` sub-resource).
|
|
|
|
Design choices:
|
|
|
|
- ``profiles.user_id`` is both the primary key and the FK to ``users.id``
|
|
(``ondelete="CASCADE"``) -- exactly one profile row per user, following the
|
|
same one-row-per-user shape as ``AvatarLoadout`` (app/models/avatar_loadout.py).
|
|
The row is created lazily (upserted) on first ``PUT /api/profile/me``
|
|
rather than at registration time, again mirroring ``AvatarLoadout``.
|
|
- ``avatar_asset_id`` -> ``assets.id`` with ``ondelete="SET NULL"``. A
|
|
profile's chosen "avatar picture" asset is a soft reference: if the
|
|
underlying asset is later deleted the profile shouldn't be destroyed or
|
|
left pointing at a dangling id, it should just fall back to "no avatar
|
|
picture set" (nullable). The API layer additionally validates on write
|
|
that the referenced asset actually exists (existence is not enforced by
|
|
the FK alone at the API layer, since the FK only stops inserts of a
|
|
nonexistent id -- it doesn't tell the caller *why* their PUT failed).
|
|
- ``bio`` is ``Text`` with the 500-char cap enforced at the API/schema layer
|
|
(Pydantic ``max_length``), matching how ``StatusUpdate.status_message`` in
|
|
presence.py enforces its length cap in the router rather than the schema.
|
|
- ``privacy_json`` is JSONB (project convention for document-shaped fields --
|
|
see avatar_loadout.py, world_content.py) holding exactly three keys, each a
|
|
``ProfileVisibility`` enum value (stored as its string value in JSON, not a
|
|
Postgres ENUM type -- there's no need for a DB-level enum since this is a
|
|
small, entirely API-validated document, and a plain JSONB column is easier
|
|
to extend with more privacy keys later without a migration):
|
|
- ``profile_visibility``: who can view the profile at all.
|
|
- ``friends_list_visibility``: who can see the user's friends list.
|
|
- ``favorites_visibility``: who can see the user's favorited worlds.
|
|
Each value is one of "public", "friends", "private". Defaults (applied when
|
|
a profile is first created) are all "public" -- the least surprising
|
|
default for a new social feature, matching the "everything visible unless
|
|
you lock it down" posture other social features here take by default
|
|
(e.g. presence/status has no privacy gate at all yet).
|
|
- Favorites use a join table (``profile_favorites``: ``user_id`` + ``world_id``,
|
|
composite PK) rather than a JSON array column. A plain FK join table gets
|
|
referential integrity for free (a favorited world that's later deleted
|
|
disappears from favorites automatically via ON DELETE CASCADE, matching
|
|
how ``World.assets``/``World.rooms`` cascade), lets Postgres enforce
|
|
"no duplicate favorite" via the composite PK instead of API-layer
|
|
deduplication, and keeps "does this world still exist" as a DB invariant
|
|
rather than something every reader has to re-check against a JSON blob.
|
|
The tradeoff (an extra table/join vs. a single JSON column) is worth it
|
|
here because favorites are a list of foreign-key references, not a
|
|
free-form document -- the exact case join tables suit better than JSONB.
|
|
"""
|
|
import enum
|
|
|
|
from sqlalchemy import Column, Integer, ForeignKey, Text, DateTime
|
|
from sqlalchemy.dialects.postgresql import JSONB
|
|
from sqlalchemy.orm import relationship
|
|
from sqlalchemy.sql import func
|
|
|
|
from app.database import Base
|
|
|
|
|
|
class ProfileVisibility(str, enum.Enum):
|
|
PUBLIC = "public"
|
|
FRIENDS = "friends"
|
|
PRIVATE = "private"
|
|
|
|
|
|
# Applied when a profile is created for the first time (lazy upsert on the
|
|
# first PUT /api/profile/me). Keys match the three privacy_json fields
|
|
# documented above.
|
|
DEFAULT_PRIVACY: dict = {
|
|
"profile_visibility": ProfileVisibility.PUBLIC.value,
|
|
"friends_list_visibility": ProfileVisibility.PUBLIC.value,
|
|
"favorites_visibility": ProfileVisibility.PUBLIC.value,
|
|
}
|
|
|
|
BIO_MAX_LEN = 500
|
|
|
|
|
|
class Profile(Base):
|
|
"""One row per user: bio, avatar picture reference, privacy settings."""
|
|
|
|
__tablename__ = "profiles"
|
|
|
|
user_id = Column(
|
|
Integer,
|
|
ForeignKey("users.id", ondelete="CASCADE"),
|
|
primary_key=True,
|
|
)
|
|
avatar_asset_id = Column(
|
|
Integer,
|
|
ForeignKey("assets.id", ondelete="SET NULL"),
|
|
nullable=True,
|
|
)
|
|
bio = Column(Text, nullable=True)
|
|
privacy_json = Column(JSONB, nullable=False, server_default="{}")
|
|
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
|
|
|
|
user = relationship("User", back_populates="profile")
|
|
avatar_asset = relationship("Asset")
|
|
# Deliberately no ORM relationship to ProfileFavorite here: profiles and
|
|
# profile_favorites both independently FK users.id (no direct FK between
|
|
# the two tables), and the router only ever needs to query/replace a
|
|
# user's favorites by user_id directly -- not traverse it off a loaded
|
|
# Profile object. Keeping it a plain queryable table avoids a
|
|
# hand-spelled, easy-to-get-wrong primaryjoin for no real benefit.
|
|
|
|
|
|
class ProfileFavorite(Base):
|
|
"""A user's favorited world. Composite PK -- see module docstring for why
|
|
this is a join table rather than a JSON array column."""
|
|
|
|
__tablename__ = "profile_favorites"
|
|
|
|
user_id = Column(
|
|
Integer,
|
|
ForeignKey("users.id", ondelete="CASCADE"),
|
|
primary_key=True,
|
|
)
|
|
world_id = Column(
|
|
Integer,
|
|
ForeignKey("worlds.id", ondelete="CASCADE"),
|
|
primary_key=True,
|
|
)
|
|
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
|
|
|
world = relationship("World")
|