Compare commits

...

2 Commits

Author SHA1 Message Date
daddy 712d21e746 Add groups + group_members models and migration
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
2026-09-15 00:28:46 +00:00
daddy f40940a491 Chat data models + migration (conversations, messages, reports)
checkpoint / checkpoint (push) Successful in 2m47s
conversations (denormalized ordered user_a/user_b pair + check constraint
for cheap uniqueness, plus a real conversation_participants join table
for future >2-party support), messages (nullable conversation_id/world_id/
room_id, sender SET NULL to survive account deletion, room_id correctly
FKs into world_rooms), message_reports (SET NULL on message/reporter/
moderator -- audit trail survives deletion, matching the existing
WorldChunkRevision precedent). Models/migration only, no API endpoints.

Independently re-verified: migration round-trip, full schema inspected
directly (FKs/cascade/constraints/indexes all match spec), 160/160
backend tests via the hermetic bootstrap.
2026-09-15 00:22:12 +00:00
6 changed files with 452 additions and 0 deletions
@@ -0,0 +1,80 @@
"""add group tables (groups, group_members)
Revision ID: 0009_add_group_tables
Revises: 0008_add_chat_tables
Create Date: 2026-09-15 00:00:00.000000
docs/planning/social.md US-5 / app/models/groups.py. Models + migration only
-- create/join/leave, role authorization, and the group page endpoint live
in a sibling task ("Groups API") that depends on this one.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "0009_add_group_tables"
down_revision: Union[str, None] = "0008_add_chat_tables"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
group_role = postgresql.ENUM("owner", "admin", "member", name="group_role")
group_role.create(op.get_bind(), checkfirst=True)
op.create_table(
"groups",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column(
"owner_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_groups_owner_id", "groups", ["owner_id"])
op.create_table(
"group_members",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"group_id",
sa.Integer(),
sa.ForeignKey("groups.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"role",
postgresql.ENUM("owner", "admin", "member", name="group_role", create_type=False),
nullable=False,
server_default="member",
),
sa.Column("joined_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.UniqueConstraint("group_id", "user_id", name="uq_group_members_pair"),
)
op.create_index("ix_group_members_group_id", "group_members", ["group_id"])
op.create_index("ix_group_members_user_id", "group_members", ["user_id"])
def downgrade() -> None:
op.drop_index("ix_group_members_user_id", table_name="group_members")
op.drop_index("ix_group_members_group_id", table_name="group_members")
op.drop_table("group_members")
op.drop_index("ix_groups_owner_id", table_name="groups")
op.drop_table("groups")
postgresql.ENUM(name="group_role").drop(op.get_bind(), checkfirst=True)
+2
View File
@@ -8,10 +8,12 @@ from app.models.social import Friendship, Follow, Block, FriendshipStatus
from app.models.chat import (
Conversation, ConversationParticipant, Message, MessageReport, ReportStatus,
)
from app.models.groups import Group, GroupMember, GroupRole
__all__ = [
"User", "Session", "World", "Asset", "AvatarLoadout",
"WorldRoom", "WorldChunk", "WorldChunkRevision",
"Friendship", "Follow", "Block", "FriendshipStatus",
"Conversation", "ConversationParticipant", "Message", "MessageReport", "ReportStatus",
"Group", "GroupMember", "GroupRole",
]
+97
View File
@@ -0,0 +1,97 @@
"""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"),
)
+11
View File
@@ -73,6 +73,17 @@ class User(Base):
cascade="all, delete-orphan",
)
# Groups (docs/planning/social.md US-5; app/models/groups.py)
owned_groups = relationship(
"Group",
back_populates="owner",
)
group_memberships = relationship(
"GroupMember",
back_populates="user",
cascade="all, delete-orphan",
)
@property
def is_adult(self) -> bool:
"""Return True if the user is 18 or older based on date_of_birth."""
+4
View File
@@ -50,6 +50,10 @@ TEST_TABLES = (
# reached by cascading a truncate of messages/conversations; list it
# explicitly along with the rest for clarity/consistency.
"message_reports", "messages", "conversation_participants", "conversations",
# Groups (app/models/groups.py). groups.owner_id is ON DELETE SET NULL
# (not CASCADE, deliberately -- see groups.py), so it wouldn't be reached
# by cascading a truncate of users; list it explicitly for clarity too.
"group_members", "groups",
"sessions", "refresh_tokens", "users",
)
+258
View File
@@ -0,0 +1,258 @@
"""Model-level tests for groups: groups and group_members.
app/models/groups.py / alembic/versions/0009_add_group_tables.py
(docs/planning/social.md US-5). This is the models+migration task only --
there are no API endpoints yet (a sibling task, "Groups API", owns
create/join/leave/role authorization and the group page endpoint), so these
tests exercise the ORM models and the real Postgres constraints directly
rather than going through HTTP.
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import delete, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import settings
from app.main import app
from app.models.groups import Group, GroupMember, GroupRole
from app.models.user import User
def _register(client: TestClient, username: str) -> int:
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201, resp.text
headers = {"Authorization": f"Bearer {resp.json()['access_token']}"}
me = client.get("/api/users/me", headers=headers)
assert me.status_code == 200, me.text
return me.json()["id"]
def _session_factory():
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
return engine, async_sessionmaker(engine, expire_on_commit=False)
class TestGroupCreationAndRoles:
async def test_create_group_with_members_of_different_roles(self):
client = TestClient(app)
owner = _register(client, "grp_role_owner")
admin = _register(client, "grp_role_admin")
member = _register(client, "grp_role_member")
engine, factory = _session_factory()
try:
async with factory() as session:
group = Group(name="Explorers", description="We explore worlds", owner_id=owner)
session.add(group)
await session.flush()
session.add_all(
[
GroupMember(group_id=group.id, user_id=owner, role=GroupRole.OWNER),
GroupMember(group_id=group.id, user_id=admin, role=GroupRole.ADMIN),
GroupMember(group_id=group.id, user_id=member, role=GroupRole.MEMBER),
]
)
await session.commit()
group_id = group.id
async with factory() as session:
fetched = await session.get(Group, group_id)
members = (
await session.execute(
select(GroupMember).where(GroupMember.group_id == group_id)
)
).scalars().all()
by_user = {m.user_id: m.role for m in members}
assert fetched.owner_id == owner
assert len(members) == 3
assert by_user[owner] == GroupRole.OWNER
assert by_user[admin] == GroupRole.ADMIN
assert by_user[member] == GroupRole.MEMBER
finally:
await engine.dispose()
async def test_membership_role_defaults_to_member(self):
client = TestClient(app)
owner = _register(client, "grp_default_owner")
joiner = _register(client, "grp_default_joiner")
engine, factory = _session_factory()
try:
async with factory() as session:
group = Group(name="Defaults", owner_id=owner)
session.add(group)
await session.flush()
session.add(GroupMember(group_id=group.id, user_id=joiner))
await session.commit()
group_id = group.id
async with factory() as session:
gm = (
await session.execute(
select(GroupMember).where(
GroupMember.group_id == group_id, GroupMember.user_id == joiner
)
)
).scalar_one()
assert gm.role == GroupRole.MEMBER
finally:
await engine.dispose()
class TestUniqueMembershipConstraint:
async def test_duplicate_membership_rejected(self):
client = TestClient(app)
owner = _register(client, "grp_uq_owner")
member = _register(client, "grp_uq_member")
engine, factory = _session_factory()
try:
async with factory() as session:
group = Group(name="Uniqueness", owner_id=owner)
session.add(group)
await session.flush()
session.add(GroupMember(group_id=group.id, user_id=member))
await session.commit()
group_id = group.id
async with factory() as session:
session.add(GroupMember(group_id=group_id, user_id=member))
with pytest.raises(IntegrityError):
await session.commit()
finally:
await engine.dispose()
class TestCascadeDelete:
async def test_deleting_group_deletes_members(self):
client = TestClient(app)
owner = _register(client, "grp_cd_group_owner")
member = _register(client, "grp_cd_group_member")
engine, factory = _session_factory()
try:
async with factory() as session:
group = Group(name="To Delete", owner_id=owner)
session.add(group)
await session.flush()
session.add_all(
[
GroupMember(group_id=group.id, user_id=owner, role=GroupRole.OWNER),
GroupMember(group_id=group.id, user_id=member),
]
)
await session.commit()
group_id = group.id
async with factory() as session:
# Core delete against groups directly, so the real ON DELETE
# CASCADE does the work (not the ORM).
await session.execute(delete(Group).where(Group.id == group_id))
await session.commit()
async with factory() as session:
members = (
await session.execute(
select(GroupMember).where(GroupMember.group_id == group_id)
)
).scalars().all()
assert members == []
finally:
await engine.dispose()
async def test_deleting_member_user_removes_only_their_membership(self):
client = TestClient(app)
owner = _register(client, "grp_cd_user_owner")
member = _register(client, "grp_cd_user_member")
engine, factory = _session_factory()
try:
async with factory() as session:
group = Group(name="Membership Cascade", owner_id=owner)
session.add(group)
await session.flush()
session.add_all(
[
GroupMember(group_id=group.id, user_id=owner, role=GroupRole.OWNER),
GroupMember(group_id=group.id, user_id=member),
]
)
await session.commit()
group_id = group.id
async with factory() as session:
await session.execute(delete(User).where(User.id == member))
await session.commit()
async with factory() as session:
remaining = (
await session.execute(
select(GroupMember).where(GroupMember.group_id == group_id)
)
).scalars().all()
# Only the deleted member's row is gone; the group and the
# owner's membership survive.
assert len(remaining) == 1
assert remaining[0].user_id == owner
group = await session.get(Group, group_id)
assert group is not None
assert group.owner_id == owner
finally:
await engine.dispose()
async def test_deleting_owner_survives_with_owner_id_null(self):
"""Deleting the owner's account must not delete the group or its
other members -- only ``owner_id`` is nulled out (SET NULL), per the
module docstring in app/models/groups.py."""
client = TestClient(app)
owner = _register(client, "grp_cd_owner_owner")
member = _register(client, "grp_cd_owner_member")
engine, factory = _session_factory()
try:
async with factory() as session:
group = Group(name="Survives Owner Deletion", owner_id=owner)
session.add(group)
await session.flush()
session.add_all(
[
GroupMember(group_id=group.id, user_id=owner, role=GroupRole.OWNER),
GroupMember(group_id=group.id, user_id=member),
]
)
await session.commit()
group_id = group.id
async with factory() as session:
await session.execute(delete(User).where(User.id == owner))
await session.commit()
async with factory() as session:
group = await session.get(Group, group_id)
assert group is not None
assert group.owner_id is None
assert group.name == "Survives Owner Deletion"
# The owner's own membership row is gone (user_id CASCADE),
# but the other member's row survives untouched.
members = (
await session.execute(
select(GroupMember).where(GroupMember.group_id == group_id)
)
).scalars().all()
assert len(members) == 1
assert members[0].user_id == member
finally:
await engine.dispose()