712d21e746
docs/planning/social.md US-5 (task 13). Models + Alembic migration only, per the sibling-dependency boundary with the Groups API task. - groups: id, name, description, owner_id (FK users, ON DELETE SET NULL -- a group is a durable multi-member community, so it and its full roster survive the owner deleting their account; the API task handles surfacing an ownerless group for reassignment). - group_members: group_id/user_id (both FK, ON DELETE CASCADE -- pure join-table rows with no meaning without either side), role (owner/admin/member native enum, default member), joined_at, unique constraint on (group_id, user_id). - User gets owned_groups / group_memberships relationships. - tests/conftest.py TEST_TABLES updated for truncation between tests. - New tests/test_group_models.py: role assignment, unique-membership rejection, group-delete cascade, member-delete cascade, and owner-delete SET NULL survival -- all verified against real Postgres. Verified: upgrade/downgrade/upgrade round-trip, \d-inspected schema matches spec (FKs, cascade behavior, unique constraint, indexes), full backend suite 166 passed / 0 regressions. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y2FESCaMXeJpC9gSctiEeD
98 lines
3.8 KiB
Python
98 lines
3.8 KiB
Python
"""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"),
|
|
)
|