"""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"), )