"""Groups: named groups with owner/admin/member roles. docs/planning/social.md §4/§5 (US-5). This module is models + schema only -- create/join/leave, role authorization (only the owner may transfer ownership or promote/demote admins), and the group page aggregate endpoint belong to the API router task ("Groups API") that depends on this one. Delete-behavior choices: - ``groups.owner_id`` -> ``SET NULL`` (nullable). A group is a durable, multi-member community, not content solely attributable to one person like a ``World``/``Asset``. Cascading the group away (or hard-blocking the owner's account deletion) would punish every other member for the owner's choice to leave the platform. Instead the group and its full membership list survive with ``owner_id`` nulled out; the sibling API task is responsible for surfacing an ownerless group (e.g. prompting an admin to claim ownership) the same way ``Message.sender_id`` / ``WorldChunk``'s actor columns already treat "the actor is gone" as a survivable state elsewhere in this schema. - ``group_members.group_id`` -> ``CASCADE``. A membership row is meaningless without the group it belongs to; deleting a group deletes its roster, mirroring ``ConversationParticipant.conversation_id`` in chat.py. - ``group_members.user_id`` -> ``CASCADE``. Deleting a user removes their own membership rows wherever they appear, matching the join-table convention used by ``ConversationParticipant.user_id`` and the social-graph tables (``Friendship``/``Follow``/``Block``) for plain per-user references. """ import enum from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint from sqlalchemy.dialects.postgresql import ENUM from sqlalchemy.orm import relationship from sqlalchemy.sql import func from app.database import Base class GroupRole(str, enum.Enum): OWNER = "owner" ADMIN = "admin" MEMBER = "member" class Group(Base): """A named group. ``owner_id`` is nullable -- see module docstring for why the group survives its owner's account deletion.""" __tablename__ = "groups" id = Column(Integer, primary_key=True, index=True) name = Column(String(100), nullable=False) description = Column(Text, nullable=True) owner_id = Column( Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, 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 ) owner = relationship("User", back_populates="owned_groups") members = relationship( "GroupMember", back_populates="group", cascade="all, delete-orphan" ) class GroupMember(Base): """A group's roster: one row per (group, user) with a role.""" __tablename__ = "group_members" id = Column(Integer, primary_key=True, index=True) group_id = Column( Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=False, index=True ) user_id = Column( Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True ) role = Column( ENUM( GroupRole, name="group_role", create_type=False, values_callable=lambda enum_cls: [e.value for e in enum_cls], ), nullable=False, default=GroupRole.MEMBER, server_default=GroupRole.MEMBER.value, ) joined_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) group = relationship("Group", back_populates="members") user = relationship("User", back_populates="group_memberships") __table_args__ = ( UniqueConstraint("group_id", "user_id", name="uq_group_members_pair"), )