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
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
"""Backend/moderation: UMS coverage for thread comments and replies

Revision ID: 0152
Revises: 0151
Create Date: 2026-05-10 00:57:29.125757

"""

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision = "0152"
down_revision = "0151"
branch_labels = None
depends_on = None


def upgrade() -> None:
# Add 'comment' and 'reply' to the ModerationObjectType enum.
# Must use rename/recreate pattern instead of ADD VALUE, because ADD VALUE
# cannot be used in the same transaction as DML that references the new value.
op.execute("ALTER TYPE moderationobjecttype RENAME TO moderationobjecttype_old")
op.execute(
"CREATE TYPE moderationobjecttype AS ENUM "
"('host_request', 'group_chat', 'friend_request', 'event_occurrence', 'comment', 'reply')"
)
op.execute("""
ALTER TABLE moderation_states
ALTER COLUMN object_type TYPE moderationobjecttype
USING object_type::text::moderationobjecttype
""")
op.execute("DROP TYPE moderationobjecttype_old")

# Add moderation_state_id columns as nullable so we can backfill before enforcing NOT NULL.
op.add_column("comments", sa.Column("moderation_state_id", sa.BigInteger(), nullable=True))
op.add_column("replies", sa.Column("moderation_state_id", sa.BigInteger(), nullable=True))

# Backfill moderation states for existing comments (using the < 2000000 ID range reserved for backfills)
op.execute("""
INSERT INTO moderation_states (id, object_type, object_id, visibility)
SELECT
(SELECT COALESCE(MAX(id), 0) FROM moderation_states WHERE id < 2000000) + ROW_NUMBER() OVER (ORDER BY id),
'comment',
id,
'visible'
FROM comments
""")
op.execute("""
INSERT INTO moderation_log (id, moderation_state_id, action, moderator_user_id, new_visibility, reason)
Comment thread
aapeliv marked this conversation as resolved.
SELECT
(SELECT GREATEST(
COALESCE((SELECT MAX(id) FROM moderation_states WHERE id < 2000000), 0),
COALESCE((SELECT MAX(id) FROM moderation_log WHERE id < 2000000), 0)
)) + ROW_NUMBER() OVER (ORDER BY c.id),
ms.id,
'create',
c.author_user_id,
'visible',
'Migration: existing comment'
FROM moderation_states ms
JOIN comments c ON ms.object_id = c.id AND ms.object_type = 'comment'
""")
op.execute("""
UPDATE comments
SET moderation_state_id = moderation_states.id
FROM moderation_states
WHERE moderation_states.object_type = 'comment'
AND moderation_states.object_id = comments.id
""")

# Backfill moderation states for existing replies (using the < 2000000 ID range reserved for backfills)
op.execute("""
INSERT INTO moderation_states (id, object_type, object_id, visibility)
SELECT
(SELECT COALESCE(MAX(id), 0) FROM moderation_states WHERE id < 2000000) + ROW_NUMBER() OVER (ORDER BY id),
'reply',
id,
'visible'
FROM replies
""")
op.execute("""
INSERT INTO moderation_log (id, moderation_state_id, action, moderator_user_id, new_visibility, reason)
SELECT
(SELECT GREATEST(
COALESCE((SELECT MAX(id) FROM moderation_states WHERE id < 2000000), 0),
COALESCE((SELECT MAX(id) FROM moderation_log WHERE id < 2000000), 0)
)) + ROW_NUMBER() OVER (ORDER BY r.id),
ms.id,
'create',
r.author_user_id,
'visible',
'Migration: existing reply'
FROM moderation_states ms
JOIN replies r ON ms.object_id = r.id AND ms.object_type = 'reply'
""")
op.execute("""
UPDATE replies
SET moderation_state_id = moderation_states.id
FROM moderation_states
WHERE moderation_states.object_type = 'reply'
AND moderation_states.object_id = replies.id
""")

# Now enforce NOT NULL and add the index + FK constraints.
op.alter_column("comments", "moderation_state_id", nullable=False)
op.create_index(op.f("ix_comments_moderation_state_id"), "comments", ["moderation_state_id"], unique=False)
op.create_foreign_key(
op.f("fk_comments_moderation_state_id_moderation_states"),
"comments",
"moderation_states",
["moderation_state_id"],
["id"],
)

op.alter_column("replies", "moderation_state_id", nullable=False)
op.create_index(op.f("ix_replies_moderation_state_id"), "replies", ["moderation_state_id"], unique=False)
op.create_foreign_key(
op.f("fk_replies_moderation_state_id_moderation_states"),
"replies",
"moderation_states",
["moderation_state_id"],
["id"],
)


def downgrade() -> None:
op.drop_constraint(op.f("fk_replies_moderation_state_id_moderation_states"), "replies", type_="foreignkey")
op.drop_index(op.f("ix_replies_moderation_state_id"), table_name="replies")
op.drop_column("replies", "moderation_state_id")
op.drop_constraint(op.f("fk_comments_moderation_state_id_moderation_states"), "comments", type_="foreignkey")
op.drop_index(op.f("ix_comments_moderation_state_id"), table_name="comments")
op.drop_column("comments", "moderation_state_id")

# Clean up moderation data for comments and replies.
op.execute("""
DELETE FROM moderation_log
WHERE moderation_state_id IN (
SELECT id FROM moderation_states WHERE object_type IN ('comment', 'reply')
)
""")
op.execute("DELETE FROM moderation_states WHERE object_type IN ('comment', 'reply')")

# Note: we cannot remove enum values in PostgreSQL easily, so we leave 'comment'/'reply'
# in the moderationobjecttype enum.
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 @@ -81,10 +81,12 @@ class Comment(Base, kw_only=True):
"""

__tablename__ = "comments"
__moderation_author_column__ = "author_user_id"

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

thread_id: Mapped[int] = mapped_column(ForeignKey("threads.id"), index=True)
moderation_state_id: Mapped[int] = mapped_column(ForeignKey("moderation_states.id"), index=True)
author_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
content: Mapped[str] = mapped_column(String) # CommonMark without images
created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
Expand All @@ -99,10 +101,12 @@ class Reply(Base, kw_only=True):
"""

__tablename__ = "replies"
__moderation_author_column__ = "author_user_id"

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

comment_id: Mapped[int] = mapped_column(ForeignKey("comments.id"), index=True)
moderation_state_id: Mapped[int] = mapped_column(ForeignKey("moderation_states.id"), index=True)
author_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
content: Mapped[str] = mapped_column(String) # CommonMark without images
created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
Expand Down
2 changes: 2 additions & 0 deletions app/backend/src/couchers/models/moderation.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ class ModerationObjectType(enum.Enum):
group_chat = enum.auto()
friend_request = enum.auto()
event_occurrence = enum.auto()
comment = enum.auto()
reply = enum.auto()


class ModerationState(Base, kw_only=True):
Expand Down
2 changes: 1 addition & 1 deletion app/backend/src/couchers/servicers/discussions.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ def discussion_to_pb(session: Session, discussion: Discussion, context: Couchers
owner_title=discussion.owner_cluster.name,
title=discussion.title,
content=discussion.content,
thread=thread_to_pb(session, discussion.thread_id),
thread=thread_to_pb(session, context, discussion.thread_id),
can_moderate=can_moderate,
)

Expand Down
2 changes: 1 addition & 1 deletion app/backend/src/couchers/servicers/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ def event_to_pb(session: Session, occurrence: EventOccurrence, context: Couchers
owner_user_id=event.owner_user_id,
owner_community_id=owner_community_id,
owner_group_id=owner_group_id,
thread=thread_to_pb(session, event.thread_id),
thread=thread_to_pb(session, context, event.thread_id),
can_edit=can_edit,
can_moderate=can_moderate,
)
Expand Down
16 changes: 16 additions & 0 deletions app/backend/src/couchers/servicers/moderation.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
)
from couchers.models import (
AdminActionLevel,
Comment,
Event,
EventOccurrence,
FriendRelationship,
Expand All @@ -32,6 +33,7 @@
ModerationVisibility,
Notification,
NotificationDelivery,
Reply,
User,
)
from couchers.proto import moderation_pb2, moderation_pb2_grpc
Expand Down Expand Up @@ -104,6 +106,8 @@
ModerationObjectType.group_chat: moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT,
ModerationObjectType.friend_request: moderation_pb2.MODERATION_OBJECT_TYPE_FRIEND_REQUEST,
ModerationObjectType.event_occurrence: moderation_pb2.MODERATION_OBJECT_TYPE_EVENT_OCCURRENCE,
ModerationObjectType.comment: moderation_pb2.MODERATION_OBJECT_TYPE_COMMENT,
ModerationObjectType.reply: moderation_pb2.MODERATION_OBJECT_TYPE_REPLY,
}

moderationobjecttype2sql = {
Expand All @@ -112,6 +116,8 @@
moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT: ModerationObjectType.group_chat,
moderation_pb2.MODERATION_OBJECT_TYPE_FRIEND_REQUEST: ModerationObjectType.friend_request,
moderation_pb2.MODERATION_OBJECT_TYPE_EVENT_OCCURRENCE: ModerationObjectType.event_occurrence,
moderation_pb2.MODERATION_OBJECT_TYPE_COMMENT: ModerationObjectType.comment,
moderation_pb2.MODERATION_OBJECT_TYPE_REPLY: ModerationObjectType.reply,
}

# Mapping from ModerationObjectType to the SQLAlchemy model class
Expand All @@ -120,6 +126,8 @@
ModerationObjectType.group_chat: GroupChat,
ModerationObjectType.friend_request: FriendRelationship,
ModerationObjectType.event_occurrence: EventOccurrence,
ModerationObjectType.comment: Comment,
ModerationObjectType.reply: Reply,
}


Expand Down Expand Up @@ -251,6 +259,14 @@ def moderation_state_to_pb(state: ModerationState, session: Session) -> moderati
.where(EventOccurrence.id == object_id)
).one()
content = f"{title}\n\n{description}"
elif object_type == ModerationObjectType.comment:
author_user_id, content = session.execute(
select(Comment.author_user_id, Comment.content).where(Comment.id == object_id)
).one()
elif object_type == ModerationObjectType.reply:
author_user_id, content = session.execute(
select(Reply.author_user_id, Reply.content).where(Reply.id == object_id)
).one()
else:
raise ValueError(f"Unsupported moderation object type: {object_type}")

Expand Down
2 changes: 1 addition & 1 deletion app/backend/src/couchers/servicers/pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ def page_to_pb(session: Session, page: Page, context: CouchersContext) -> pages_
owner_user_id=page.owner_user_id,
owner_community_id=owner_community_id,
owner_group_id=owner_group_id,
thread=thread_to_pb(session, page.thread_id),
thread=thread_to_pb(session, context, page.thread_id),
title=current_version.title,
content=current_version.content,
photo_url=current_version.photo.full_url if current_version.photo_key else None,
Expand Down
Loading
Loading