Merge branch 'main' into verify-4daac3f1cfba
This commit is contained in:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user