9661b9a81c
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
107 lines
4.0 KiB
Python
107 lines
4.0 KiB
Python
"""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"),
|
|
)
|