Add social graph models: friendships, follows, blocks

Models + Alembic migration (0007, head) only, per docs/planning/social.md
US-1 -- the friends/follow API router is a separate sibling task.

- friendships: requester_id/addressee_id (FK users, CASCADE), status
  (pending/accepted native enum), unique pair constraint, indexed FKs.
- follows: follower_id/followed_id (FK users, CASCADE), unique pair.
- blocks: blocker_id/blocked_id (FK users, CASCADE), unique pair.
- Relationships wired onto User for both directions of each table.
- tests/test_social_models.py exercises request/accept, the unique-pair
  constraint, follow, block, and DB-level CASCADE delete against real
  Postgres. Full backend suite (152 tests) still green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2FESCaMXeJpC9gSctiEeD
This commit is contained in:
2026-09-15 00:10:15 +00:00
parent 4eb4476fc3
commit 9661b9a81c
6 changed files with 509 additions and 1 deletions
+2
View File
@@ -4,8 +4,10 @@ from app.models.world import World
from app.models.asset import Asset
from app.models.avatar_loadout import AvatarLoadout
from app.models.world_content import WorldRoom, WorldChunk, WorldChunkRevision
from app.models.social import Friendship, Follow, Block, FriendshipStatus
__all__ = [
"User", "Session", "World", "Asset", "AvatarLoadout",
"WorldRoom", "WorldChunk", "WorldChunkRevision",
"Friendship", "Follow", "Block", "FriendshipStatus",
]
+106
View File
@@ -0,0 +1,106 @@
"""Social graph: friendships, follows, blocks.
docs/planning/social.md §4/§5 (US-1). Friends = mutual (request -> accept),
Follow = one-way (no approval), Block = hard deny. This module is models +
schema only -- the request/accept/block business logic (idempotent
duplicate/self handling, removing an existing friendship/follow on block,
etc.) belongs to the API router task that depends on this one.
"""
import enum
from sqlalchemy import Column, Integer, ForeignKey, DateTime, UniqueConstraint
from sqlalchemy.dialects.postgresql import ENUM
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
class FriendshipStatus(str, enum.Enum):
PENDING = "pending"
ACCEPTED = "accepted"
class Friendship(Base):
"""A friend request, directional until accepted.
``requester_id`` sent the request to ``addressee_id``. Once accepted the
friendship is mutual, but the row stays directional (who asked whom) for
audit purposes; the API layer treats it as symmetric once accepted.
"""
__tablename__ = "friendships"
id = Column(Integer, primary_key=True, index=True)
requester_id = Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
addressee_id = Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
status = Column(
ENUM(
FriendshipStatus,
name="friendship_status",
create_type=False,
values_callable=lambda enum_cls: [e.value for e in enum_cls],
),
nullable=False,
default=FriendshipStatus.PENDING,
server_default=FriendshipStatus.PENDING.value,
)
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)
requester = relationship("User", foreign_keys=[requester_id], back_populates="sent_friend_requests")
addressee = relationship("User", foreign_keys=[addressee_id], back_populates="received_friend_requests")
__table_args__ = (
UniqueConstraint("requester_id", "addressee_id", name="uq_friendships_pair"),
)
class Follow(Base):
"""A one-way follow relationship: ``follower_id`` follows ``followed_id``."""
__tablename__ = "follows"
id = Column(Integer, primary_key=True, index=True)
follower_id = Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
followed_id = Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
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)
follower = relationship("User", foreign_keys=[follower_id], back_populates="following")
followed = relationship("User", foreign_keys=[followed_id], back_populates="followers")
__table_args__ = (
UniqueConstraint("follower_id", "followed_id", name="uq_follows_pair"),
)
class Block(Base):
"""A hard deny: ``blocker_id`` has blocked ``blocked_id``."""
__tablename__ = "blocks"
id = Column(Integer, primary_key=True, index=True)
blocker_id = Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
blocked_id = Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
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)
blocker = relationship("User", foreign_keys=[blocker_id], back_populates="blocks_initiated")
blocked = relationship("User", foreign_keys=[blocked_id], back_populates="blocked_by")
__table_args__ = (
UniqueConstraint("blocker_id", "blocked_id", name="uq_blocks_pair"),
)
+38
View File
@@ -35,6 +35,44 @@ class User(Base):
cascade="all, delete-orphan",
)
# Social graph (docs/planning/social.md US-1; app/models/social.py)
sent_friend_requests = relationship(
"Friendship",
foreign_keys="Friendship.requester_id",
back_populates="requester",
cascade="all, delete-orphan",
)
received_friend_requests = relationship(
"Friendship",
foreign_keys="Friendship.addressee_id",
back_populates="addressee",
cascade="all, delete-orphan",
)
following = relationship(
"Follow",
foreign_keys="Follow.follower_id",
back_populates="follower",
cascade="all, delete-orphan",
)
followers = relationship(
"Follow",
foreign_keys="Follow.followed_id",
back_populates="followed",
cascade="all, delete-orphan",
)
blocks_initiated = relationship(
"Block",
foreign_keys="Block.blocker_id",
back_populates="blocker",
cascade="all, delete-orphan",
)
blocked_by = relationship(
"Block",
foreign_keys="Block.blocked_id",
back_populates="blocked",
cascade="all, delete-orphan",
)
@property
def is_adult(self) -> bool:
"""Return True if the user is 18 or older based on date_of_birth."""