From ca06b459ecb55d0eeb01dc20598c578442fe0ba0 Mon Sep 17 00:00:00 2001 From: architect-bot Date: Tue, 18 Aug 2026 15:50:36 +0000 Subject: [PATCH] WIP checkpoint: Friends/follow/block models + Alembic migration --- backend/alembic/env.py | 2 +- backend/alembic/versions/0002_social_graph.py | 126 ++++++++++++++++++ backend/app/models/__init__.py | 14 +- backend/app/models/block.py | 48 +++++++ backend/app/models/follow.py | 48 +++++++ backend/app/models/friendship.py | 74 ++++++++++ backend/app/models/user.py | 38 ++++++ 7 files changed, 348 insertions(+), 2 deletions(-) create mode 100644 backend/alembic/versions/0002_social_graph.py create mode 100644 backend/app/models/block.py create mode 100644 backend/app/models/follow.py create mode 100644 backend/app/models/friendship.py diff --git a/backend/alembic/env.py b/backend/alembic/env.py index d9411ca..e36019a 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import create_async_engine from app.config import settings from app.database import Base -from app.models import user, session, world, asset # noqa: F401 +from app.models import user, session, world, asset, friendship, follow, block # noqa: F401 # this is the Alembic Config object, which provides # access to the values within the .ini file in use. diff --git a/backend/alembic/versions/0002_social_graph.py b/backend/alembic/versions/0002_social_graph.py new file mode 100644 index 0000000..4c5d543 --- /dev/null +++ b/backend/alembic/versions/0002_social_graph.py @@ -0,0 +1,126 @@ +"""social graph: friendships, follows, blocks + +Revision ID: 0002_social_graph +Revises: 0001_initial +Create Date: 2026-08-18 00:00:00.000000 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "0002_social_graph" +down_revision: Union[str, None] = "0001_initial" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Create friendships, follows, and blocks tables.""" + op.create_table( + "friendships", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("requester_id", sa.Integer(), nullable=False), + sa.Column("addressee_id", sa.Integer(), nullable=False), + sa.Column("status", sa.String(length=20), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.CheckConstraint( + "status IN ('pending', 'accepted')", + name="ck_friendships_status", + ), + sa.CheckConstraint( + "requester_id <> addressee_id", + name="ck_friendships_no_self", + ), + sa.ForeignKeyConstraint( + ["addressee_id"], + ["users.id"], + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["requester_id"], + ["users.id"], + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "requester_id", + "addressee_id", + name="uq_friendships_pair", + ), + ) + op.create_index(op.f("ix_friendships_id"), "friendships", ["id"], unique=False) + op.create_index(op.f("ix_friendships_addressee_id"), "friendships", ["addressee_id"], unique=False) + op.create_index(op.f("ix_friendships_requester_id"), "friendships", ["requester_id"], unique=False) + + op.create_table( + "follows", + sa.Column("follower_id", sa.Integer(), nullable=False), + sa.Column("followed_id", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.CheckConstraint( + "follower_id <> followed_id", + name="ck_follows_no_self", + ), + sa.ForeignKeyConstraint( + ["followed_id"], + ["users.id"], + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["follower_id"], + ["users.id"], + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("follower_id", "followed_id"), + sa.UniqueConstraint( + "follower_id", + "followed_id", + name="uq_follows_pair", + ), + ) + op.create_index(op.f("ix_follows_followed_id"), "follows", ["followed_id"], unique=False) + + op.create_table( + "blocks", + sa.Column("blocker_id", sa.Integer(), nullable=False), + sa.Column("blocked_id", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.CheckConstraint( + "blocker_id <> blocked_id", + name="ck_blocks_no_self", + ), + sa.ForeignKeyConstraint( + ["blocked_id"], + ["users.id"], + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["blocker_id"], + ["users.id"], + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("blocker_id", "blocked_id"), + sa.UniqueConstraint( + "blocker_id", + "blocked_id", + name="uq_blocks_pair", + ), + ) + op.create_index(op.f("ix_blocks_blocked_id"), "blocks", ["blocked_id"], unique=False) + + +def downgrade() -> None: + """Drop friendships, follows, and blocks tables.""" + op.drop_index(op.f("ix_blocks_blocked_id"), table_name="blocks") + op.drop_table("blocks") + + op.drop_index(op.f("ix_follows_followed_id"), table_name="follows") + op.drop_table("follows") + + op.drop_index(op.f("ix_friendships_requester_id"), table_name="friendships") + op.drop_index(op.f("ix_friendships_addressee_id"), table_name="friendships") + op.drop_index(op.f("ix_friendships_id"), table_name="friendships") + op.drop_table("friendships") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 96e2dc3..2b77930 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -2,5 +2,17 @@ from app.models.user import User from app.models.session import Session from app.models.world import World from app.models.asset import Asset +from app.models.friendship import Friendship, FriendshipStatus +from app.models.follow import Follow +from app.models.block import Block -__all__ = ["User", "Session", "World", "Asset"] +__all__ = [ + "User", + "Session", + "World", + "Asset", + "Friendship", + "FriendshipStatus", + "Follow", + "Block", +] diff --git a/backend/app/models/block.py b/backend/app/models/block.py new file mode 100644 index 0000000..f92a54c --- /dev/null +++ b/backend/app/models/block.py @@ -0,0 +1,48 @@ +"""Block model: a hard deny that also hides content and prevents DMs.""" + +from sqlalchemy import CheckConstraint, Column, DateTime, ForeignKey, Integer, UniqueConstraint +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +from app.database import Base + + +class Block(Base): + __tablename__ = "blocks" + __table_args__ = ( + UniqueConstraint( + "blocker_id", + "blocked_id", + name="uq_blocks_blocker_blocked", + ), + CheckConstraint( + "blocker_id <> blocked_id", + name="ck_blocks_no_self", + ), + ) + + 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()) + + blocker = relationship( + "User", + foreign_keys=[blocker_id], + back_populates="blocks_given", + ) + blocked = relationship( + "User", + foreign_keys=[blocked_id], + back_populates="blocks_received", + ) diff --git a/backend/app/models/follow.py b/backend/app/models/follow.py new file mode 100644 index 0000000..f0af635 --- /dev/null +++ b/backend/app/models/follow.py @@ -0,0 +1,48 @@ +"""Follow model: a one-way relationship requiring no approval.""" + +from sqlalchemy import CheckConstraint, Column, DateTime, ForeignKey, Integer, UniqueConstraint +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +from app.database import Base + + +class Follow(Base): + __tablename__ = "follows" + __table_args__ = ( + UniqueConstraint( + "follower_id", + "followed_id", + name="uq_follows_follower_followed", + ), + CheckConstraint( + "follower_id <> followed_id", + name="ck_follows_no_self", + ), + ) + + 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()) + + follower = relationship( + "User", + foreign_keys=[follower_id], + back_populates="follows_given", + ) + followed = relationship( + "User", + foreign_keys=[followed_id], + back_populates="followers", + ) diff --git a/backend/app/models/friendship.py b/backend/app/models/friendship.py new file mode 100644 index 0000000..c8f72fc --- /dev/null +++ b/backend/app/models/friendship.py @@ -0,0 +1,74 @@ +"""Friendship model: a one-directional request that becomes mutual on accept.""" + +from sqlalchemy import ( + CheckConstraint, + Column, + DateTime, + ForeignKey, + Integer, + String, + UniqueConstraint, +) +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +from app.database import Base + + +class FriendshipStatus: + """String values for the friendship.status column.""" + + PENDING = "pending" + ACCEPTED = "accepted" + + +class Friendship(Base): + __tablename__ = "friendships" + __table_args__ = ( + UniqueConstraint( + "requester_id", + "addressee_id", + name="uq_friendships_requester_addressee", + ), + CheckConstraint( + "requester_id <> addressee_id", + name="ck_friendships_no_self", + ), + CheckConstraint( + "status IN ('pending', 'accepted')", + name="ck_friendships_status", + ), + ) + + 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( + String(20), + nullable=False, + default=FriendshipStatus.PENDING, + server_default=FriendshipStatus.PENDING, + ) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + requester = relationship( + "User", + foreign_keys=[requester_id], + back_populates="friendships_sent", + ) + addressee = relationship( + "User", + foreign_keys=[addressee_id], + back_populates="friendships_received", + ) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index ff48caa..e471061 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -19,3 +19,41 @@ class User(Base): sessions = relationship("Session", back_populates="user", cascade="all, delete-orphan") worlds = relationship("World", back_populates="owner") assets = relationship("Asset", back_populates="owner") + + # Social graph + 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_issued = relationship( + "Block", + foreign_keys="Block.blocker_id", + back_populates="blocker", + cascade="all, delete-orphan", + ) + blocks_received = relationship( + "Block", + foreign_keys="Block.blocked_id", + back_populates="blocked", + cascade="all, delete-orphan", + )