Merge branch 'main' into verify-4daac3f1cfba

This commit is contained in:
2026-09-15 00:37:58 +00:00
8 changed files with 580 additions and 4 deletions
+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."""
+42 -1
View File
@@ -54,6 +54,12 @@ def _client_key(request: Request) -> str:
# brute-forcer.
_auth_limiter = SlidingWindowLimiter(max_hits=10, window_seconds=60)
# Every limiter in the process -- named limits built via `rate_limit()`
# below register themselves here too, so `reset_all()` (the test hook) and
# any future process-wide accounting stay correct without each caller
# having to remember to wire it up.
_registered_limiters: list[SlidingWindowLimiter] = [_auth_limiter]
async def auth_rate_limit(request: Request) -> None:
"""FastAPI dependency: rate-limit auth attempts per client IP.
@@ -68,6 +74,41 @@ async def auth_rate_limit(request: Request) -> None:
_auth_limiter.check(f"auth:{_client_key(request)}")
def rate_limit(name: str, *, max_hits: int, window_seconds: float):
"""Build a named, per-client-IP FastAPI rate-limit dependency.
Generalizes the ``auth_rate_limit`` pattern above for other endpoints
(e.g. chat send, message report) that want their own independent cap:
report_rate_limit = rate_limit("message_report", max_hits=5, window_seconds=60)
@router.post("/report", dependencies=[Depends(report_rate_limit)])
...
Each call gets its own ``SlidingWindowLimiter`` -- counters for one
named limit never bleed into another, even for the same client IP --
and reuses ``SlidingWindowLimiter.check`` for the actual sliding-window
accounting and the 429 + ``Retry-After`` response. Like
``auth_rate_limit``, the returned dependency is a no-op when
``settings.RATE_LIMIT_ENABLED`` is false, so the checkpoint smoke suite
(one client IP for the whole run) doesn't trip on it.
``name`` only needs to be unique within the process; it namespaces the
per-IP key so it doesn't collide with ``auth_rate_limit`` or any other
named limit.
"""
limiter = SlidingWindowLimiter(max_hits=max_hits, window_seconds=window_seconds)
_registered_limiters.append(limiter)
async def _dependency(request: Request) -> None:
if not settings.RATE_LIMIT_ENABLED:
return
limiter.check(f"{name}:{_client_key(request)}")
return _dependency
def reset_all() -> None:
"""Test hook."""
_auth_limiter.reset()
for limiter in _registered_limiters:
limiter.reset()