Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/backend/src/couchers/models/conversations.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from couchers.constants import DATETIME_INFINITY, DATETIME_MINUS_INFINITY
from couchers.models.base import Base
from couchers.models.host_requests import HostRequestStatus
from couchers.models.moderation import ModerationObjectType
from couchers.utils import now

if TYPE_CHECKING:
Expand Down Expand Up @@ -38,6 +39,7 @@ class GroupChat(Base, kw_only=True):

__tablename__ = "group_chats"
__moderation_author_column__ = "creator_id"
__moderation_object_type__ = ModerationObjectType.group_chat

conversation_id: Mapped[int] = mapped_column("id", ForeignKey("conversations.id"), primary_key=True)

Expand Down
4 changes: 4 additions & 0 deletions app/backend/src/couchers/models/discussions.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from sqlalchemy.orm import Mapped, column_property, mapped_column, relationship

from couchers.models.base import Base, communities_seq
from couchers.models.moderation import ModerationObjectType

if TYPE_CHECKING:
from couchers.models import Cluster, User
Expand All @@ -17,6 +18,7 @@ class Discussion(Base, kw_only=True):

__tablename__ = "discussions"
__moderation_author_column__ = "creator_user_id"
__moderation_object_type__ = ModerationObjectType.discussion

id: Mapped[int] = mapped_column(
BigInteger, communities_seq, primary_key=True, server_default=communities_seq.next_value(), init=False
Expand Down Expand Up @@ -84,6 +86,7 @@ class Comment(Base, kw_only=True):

__tablename__ = "comments"
__moderation_author_column__ = "author_user_id"
__moderation_object_type__ = ModerationObjectType.comment

id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)

Expand All @@ -104,6 +107,7 @@ class Reply(Base, kw_only=True):

__tablename__ = "replies"
__moderation_author_column__ = "author_user_id"
__moderation_object_type__ = ModerationObjectType.reply

id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)

Expand Down
2 changes: 2 additions & 0 deletions app/backend/src/couchers/models/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from sqlalchemy.sql.elements import ColumnElement

from couchers.models.base import Base, Geom, communities_seq
from couchers.models.moderation import ModerationObjectType
from couchers.utils import get_coordinates

if TYPE_CHECKING:
Expand Down Expand Up @@ -112,6 +113,7 @@ class Event(Base, kw_only=True):
class EventOccurrence(Base, kw_only=True):
__tablename__ = "event_occurrences"
__moderation_author_column__ = "creator_user_id"
__moderation_object_type__ = ModerationObjectType.event_occurrence

id: Mapped[int] = mapped_column(
BigInteger, communities_seq, primary_key=True, server_default=communities_seq.next_value(), init=False
Expand Down
2 changes: 2 additions & 0 deletions app/backend/src/couchers/models/host_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from sqlalchemy.sql import expression

from couchers.models.base import Base, Geom
from couchers.models.moderation import ModerationObjectType
from couchers.utils import date_in_timezone, now

if TYPE_CHECKING:
Expand Down Expand Up @@ -41,6 +42,7 @@ class HostRequest(Base, kw_only=True):

__tablename__ = "host_requests"
__moderation_author_column__ = "initiator_user_id"
__moderation_object_type__ = ModerationObjectType.host_request

conversation_id: Mapped[int] = mapped_column("id", ForeignKey("conversations.id"), primary_key=True)
initiator_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
Expand Down
48 changes: 46 additions & 2 deletions app/backend/src/couchers/models/moderation.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@
"""

import enum
from dataclasses import dataclass
from datetime import datetime
from typing import TYPE_CHECKING
from functools import cache
from typing import TYPE_CHECKING, Protocol

from sqlalchemy import BigInteger, DateTime, Enum, ForeignKey, Index, String, func
from sqlalchemy import BigInteger, ColumnElement, DateTime, Enum, ForeignKey, Index, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship

from couchers.models.base import Base, moderation_seq
Expand Down Expand Up @@ -186,3 +188,45 @@ class ModerationLog(Base, kw_only=True):

def __repr__(self) -> str:
return f"ModerationLog(id={self.id}, state_id={self.moderation_state_id}, action={self.action}, moderator={self.moderator_user_id}, time={self.time})"


class ModeratedContent(Protocol):
"""A model governed by the UMS, identified by the moderation metadata it declares as class attributes."""

__moderation_object_type__: ModerationObjectType
__moderation_author_column__: str


@dataclass(frozen=True)
class ModeratedModel:
"""A model governed by the UMS, with its moderation metadata resolved."""

object_type: ModerationObjectType
model: type[ModeratedContent]
author_column: ColumnElement[int]
object_id_column: ColumnElement[int]
moderation_state_id_column: ColumnElement[int]


@cache
def get_moderated_models() -> dict[ModerationObjectType, ModeratedModel]:
"""
Maps each ModerationObjectType to its model and resolved moderation metadata.

Discovered from every mapped model that declares __moderation_object_type__, so the moderation
metadata stays on the models themselves rather than in a separate hand-maintained list.
"""
models: dict[ModerationObjectType, ModeratedModel] = {}
for mapper in Base.registry.mappers:
cls = mapper.class_
if not hasattr(cls, "__moderation_object_type__"):
continue
model: type[ModeratedContent] = cls
models[model.__moderation_object_type__] = ModeratedModel(
object_type=model.__moderation_object_type__,
model=model,
author_column=mapper.columns[model.__moderation_author_column__],
object_id_column=mapper.primary_key[0],
moderation_state_id_column=mapper.columns["moderation_state_id"],
)
return models
2 changes: 2 additions & 0 deletions app/backend/src/couchers/models/rest.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@

from couchers.constants import GUIDELINES_VERSION
from couchers.models.base import Base, Geom
from couchers.models.moderation import ModerationObjectType
from couchers.models.users import HostingStatus
from couchers.utils import now

Expand Down Expand Up @@ -73,6 +74,7 @@ class FriendRelationship(Base, kw_only=True):

__tablename__ = "friend_relationships"
__moderation_author_column__ = "from_user_id"
__moderation_object_type__ = ModerationObjectType.friend_request

id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)

Expand Down
35 changes: 9 additions & 26 deletions app/backend/src/couchers/servicers/moderation.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import logging
from typing import TYPE_CHECKING

import grpc
from sqlalchemy import and_, exists, not_, or_, select
Expand Down Expand Up @@ -36,14 +35,12 @@
NotificationDelivery,
Reply,
User,
get_moderated_models,
)
from couchers.proto import moderation_pb2, moderation_pb2_grpc
from couchers.proto.internal import jobs_pb2
from couchers.utils import Timestamp_from_datetime, now

if TYPE_CHECKING:
from couchers.sql import _ModeratedContent

logger = logging.getLogger(__name__)

MAX_PAGINATION_LENGTH = 1_000
Expand Down Expand Up @@ -123,17 +120,6 @@
moderation_pb2.MODERATION_OBJECT_TYPE_DISCUSSION: ModerationObjectType.discussion,
}

# Mapping from ModerationObjectType to the SQLAlchemy model class
moderationobjecttype2model: dict[ModerationObjectType, _ModeratedContent] = {
ModerationObjectType.host_request: HostRequest,
ModerationObjectType.group_chat: GroupChat,
ModerationObjectType.friend_request: FriendRelationship,
ModerationObjectType.event_occurrence: EventOccurrence,
ModerationObjectType.comment: Comment,
ModerationObjectType.reply: Reply,
ModerationObjectType.discussion: Discussion,
}


def bulk_set_user_content_visibility(
session: Session,
Expand All @@ -147,10 +133,9 @@ def bulk_set_user_content_visibility(
final_reason = reason or f"Bulk visibility update for user {user.id} to {new_visibility.name}"

author_exists_clauses = []
for model in moderationobjecttype2model.values():
author_col = getattr(model, model.__moderation_author_column__)
for entry in get_moderated_models().values():
author_exists_clauses.append(
exists().where(and_(model.moderation_state_id == ModerationState.id, author_col == user.id))
exists().where(and_(entry.moderation_state_id_column == ModerationState.id, entry.author_column == user.id))
)

states = session.execute(select(ModerationState).where(or_(*author_exists_clauses))).scalars().all()
Expand Down Expand Up @@ -346,13 +331,12 @@ def GetModerationQueue(

# Use EXISTS for efficient author filtering
author_exists_clauses = []
for model in moderationobjecttype2model.values():
author_col = getattr(model, model.__moderation_author_column__)
for entry in get_moderated_models().values():
author_exists_clauses.append(
exists().where(
and_(
model.moderation_state_id == ModerationQueueItem.moderation_state_id,
author_col == author_user_id,
entry.moderation_state_id_column == ModerationQueueItem.moderation_state_id,
entry.author_column == author_user_id,
)
)
)
Expand Down Expand Up @@ -687,13 +671,12 @@ def ListModerationStates(

if request.author_user_id:
author_exists_clauses = []
for model in moderationobjecttype2model.values():
author_col = getattr(model, model.__moderation_author_column__)
for entry in get_moderated_models().values():
author_exists_clauses.append(
exists().where(
and_(
model.moderation_state_id == ModerationState.id,
author_col == request.author_user_id,
entry.moderation_state_id_column == ModerationState.id,
entry.author_column == request.author_user_id,
)
)
)
Expand Down
Loading
Loading