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
6 changes: 6 additions & 0 deletions app/backend/src/couchers/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,13 @@
"date_from_before_today": "From date must be today or later.",
"date_to_after_one_year": "You cannot request to stay with someone for longer than one year.",
"dev_apis_disabled": "Development APIs are not enabled on this server.",
"discussion_delete_permission_denied": "You are not allowed to delete this discussion.",
"discussion_deleted": "This discussion has been deleted.",
"discussion_edit_permission_denied": "You are not allowed to edit this discussion.",
"discussion_not_found": "Discussion not found.",
"reply_delete_permission_denied": "You are not allowed to delete this comment.",
"reply_deleted": "This comment has been deleted.",
"reply_edit_permission_denied": "You are not allowed to edit this comment.",
"discussions_not_enabled": "Discussions are not enabled in this group or community.",
"do_not_email_cannot_host": "You cannot enable hosting while you have emails turned off in your settings.",
"do_not_email_cannot_meet": "You cannot enable meeting up while you have emails turned off in your settings.",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"""Add edit/delete support to discussions, comments, and replies

Revision ID: 0165
Revises: 0164
Create Date: 2026-05-13 00:00:00.000000

"""

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision = "0165"
down_revision = "0164"
branch_labels = None
depends_on = None


def upgrade() -> None:
op.add_column(
"discussions",
sa.Column("deleted", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"discussions",
sa.Column("last_edited", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"comments",
sa.Column("last_edited", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"replies",
sa.Column("last_edited", sa.DateTime(timezone=True), nullable=True),
)

op.create_table(
"discussion_versions",
sa.Column("id", sa.BigInteger(), nullable=False),
sa.Column("discussion_id", sa.BigInteger(), nullable=False),
sa.Column("editor_user_id", sa.BigInteger(), nullable=False),
sa.Column("change_type", sa.Enum("edit", "delete", name="contentchangetype"), nullable=False),
sa.Column("old_title", sa.String(), nullable=True),
sa.Column("new_title", sa.String(), nullable=True),
sa.Column("old_content", sa.String(), nullable=True),
sa.Column("new_content", sa.String(), nullable=True),
sa.Column("created", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["discussion_id"], ["discussions.id"]),
sa.ForeignKeyConstraint(["editor_user_id"], ["users.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_discussion_versions_discussion_id", "discussion_versions", ["discussion_id"])
op.create_index("ix_discussion_versions_editor_user_id", "discussion_versions", ["editor_user_id"])

op.create_table(
"comment_versions",
sa.Column("id", sa.BigInteger(), nullable=False),
sa.Column("comment_id", sa.BigInteger(), nullable=False),
sa.Column("editor_user_id", sa.BigInteger(), nullable=False),
sa.Column(
"change_type", sa.Enum("edit", "delete", name="contentchangetype", create_type=False), nullable=False
),
sa.Column("old_content", sa.String(), nullable=True),
sa.Column("new_content", sa.String(), nullable=True),
sa.Column("created", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["comment_id"], ["comments.id"]),
sa.ForeignKeyConstraint(["editor_user_id"], ["users.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_comment_versions_comment_id", "comment_versions", ["comment_id"])
op.create_index("ix_comment_versions_editor_user_id", "comment_versions", ["editor_user_id"])

op.create_table(
"reply_versions",
sa.Column("id", sa.BigInteger(), nullable=False),
sa.Column("reply_id", sa.BigInteger(), nullable=False),
sa.Column("editor_user_id", sa.BigInteger(), nullable=False),
sa.Column(
"change_type", sa.Enum("edit", "delete", name="contentchangetype", create_type=False), nullable=False
),
sa.Column("old_content", sa.String(), nullable=True),
sa.Column("new_content", sa.String(), nullable=True),
sa.Column("created", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["reply_id"], ["replies.id"]),
sa.ForeignKeyConstraint(["editor_user_id"], ["users.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_reply_versions_reply_id", "reply_versions", ["reply_id"])
op.create_index("ix_reply_versions_editor_user_id", "reply_versions", ["editor_user_id"])


def downgrade() -> None:
op.drop_table("reply_versions")
op.drop_table("comment_versions")
op.drop_table("discussion_versions")
sa.Enum(name="contentchangetype").drop(op.get_bind(), checkfirst=True)

op.drop_column("replies", "last_edited")
op.drop_column("comments", "last_edited")
op.drop_column("discussions", "last_edited")
op.drop_column("discussions", "deleted")
80 changes: 79 additions & 1 deletion app/backend/src/couchers/models/discussions.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import enum
from datetime import datetime
from typing import TYPE_CHECKING

from sqlalchemy import BigInteger, DateTime, ForeignKey, String, UniqueConstraint, func
from sqlalchemy import BigInteger, DateTime, Enum, ForeignKey, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, column_property, mapped_column, relationship

from couchers.models.base import Base, communities_seq
Expand Down Expand Up @@ -29,6 +30,8 @@ class Discussion(Base, kw_only=True):
thread_id: Mapped[int] = mapped_column(ForeignKey("threads.id"), unique=True)
moderation_state_id: Mapped[int] = mapped_column(ForeignKey("moderation_states.id"), index=True)
created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
deleted: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
last_edited: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

non-nullable


creator_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
owner_cluster_id: Mapped[int] = mapped_column(ForeignKey("clusters.id"), index=True)
Expand Down Expand Up @@ -96,6 +99,7 @@ class Comment(Base, kw_only=True):
content: Mapped[str] = mapped_column(String) # CommonMark without images
created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
deleted: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
last_edited: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto. server default func.now


thread: Mapped[Thread] = relationship(init=False, backref="comments")

Expand All @@ -117,10 +121,84 @@ class Reply(Base, kw_only=True):
content: Mapped[str] = mapped_column(String) # CommonMark without images
created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
deleted: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
last_edited: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same


comment: Mapped[Comment] = relationship(init=False, backref="replies")


class ContentChangeType(enum.Enum):
edit = enum.auto()
delete = enum.auto()


class DiscussionVersion(Base, kw_only=True):
"""
audit log of edits and deletions to discussions
"""

__tablename__ = "discussion_versions"

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

discussion_id: Mapped[int] = mapped_column(ForeignKey("discussions.id"), index=True)
editor_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
change_type: Mapped[ContentChangeType] = mapped_column(Enum(ContentChangeType))
old_title: Mapped[str | None] = mapped_column(String, default=None)
new_title: Mapped[str | None] = mapped_column(String, default=None)
old_content: Mapped[str | None] = mapped_column(String, default=None)
new_content: Mapped[str | None] = mapped_column(String, default=None)
created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)

discussion: Mapped[Discussion] = relationship(init=False, backref="versions")
editor_user: Mapped[User] = relationship(
init=False, backref="edited_discussions", foreign_keys="DiscussionVersion.editor_user_id"
)


class CommentVersion(Base, kw_only=True):
"""
audit log of edits and deletions to comments
"""

__tablename__ = "comment_versions"

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

comment_id: Mapped[int] = mapped_column(ForeignKey("comments.id"), index=True)
editor_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
change_type: Mapped[ContentChangeType] = mapped_column(Enum(ContentChangeType))
old_content: Mapped[str | None] = mapped_column(String, default=None)
new_content: Mapped[str | None] = mapped_column(String, default=None)
created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)

comment: Mapped[Comment] = relationship(init=False, backref="versions")
editor_user: Mapped[User] = relationship(
init=False, backref="edited_comments", foreign_keys="CommentVersion.editor_user_id"
)


class ReplyVersion(Base, kw_only=True):
"""
audit log of edits and deletions to replies
"""

__tablename__ = "reply_versions"

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

reply_id: Mapped[int] = mapped_column(ForeignKey("replies.id"), index=True)
editor_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
change_type: Mapped[ContentChangeType] = mapped_column(Enum(ContentChangeType))
old_content: Mapped[str | None] = mapped_column(String, default=None)
new_content: Mapped[str | None] = mapped_column(String, default=None)
created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)

reply: Mapped[Reply] = relationship(init=False, backref="versions")
editor_user: Mapped[User] = relationship(
init=False, backref="edited_replies", foreign_keys="ReplyVersion.editor_user_id"
)


class ClusterDiscussionAssociation(Base, kw_only=True):
"""
discussions related to clusters
Expand Down
54 changes: 53 additions & 1 deletion app/backend/src/couchers/servicers/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@
UserAdminTag,
UserBadge,
)
from couchers.models.discussions import (
CommentVersion,
ContentChangeType,
DiscussionVersion,
ReplyVersion,
)
from couchers.models.notifications import NotificationTopicAction
from couchers.models.uploads import Upload, has_avatar_photo_expression
from couchers.notifications.notify import notify
Expand Down Expand Up @@ -987,6 +993,30 @@ def EditDiscussion(
discussion.content = request.new_content.strip()
return empty_pb2.Empty()

def DeleteDiscussion(
self, request: admin_pb2.AdminDeleteDiscussionReq, context: CouchersContext, session: Session
) -> empty_pb2.Empty:
discussion = session.execute(
select(Discussion).where(Discussion.id == request.discussion_id)
).scalar_one_or_none()
if not discussion:
context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "discussion_not_found")
if discussion.deleted is not None:
return empty_pb2.Empty()
session.add(
DiscussionVersion(
discussion_id=discussion.id,
editor_user_id=context.user_id,
change_type=ContentChangeType.delete,
old_title=discussion.title,
new_title=None,
old_content=discussion.content,
new_content=None,
)
)
discussion.deleted = now()
Comment thread
tristanlabelle marked this conversation as resolved.
return empty_pb2.Empty()

def EditReply(self, request: admin_pb2.EditReplyReq, context: CouchersContext, session: Session) -> empty_pb2.Empty:
database_id, depth = unpack_thread_id(request.reply_id)
if depth == 1:
Expand All @@ -1000,7 +1030,29 @@ def EditReply(self, request: admin_pb2.EditReplyReq, context: CouchersContext, s

if not obj:
context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "object_not_found")
obj.content = request.new_content.strip()
old_content = obj.content
new_content = request.new_content.strip()
if depth == 1:
session.add(
CommentVersion(
comment_id=database_id,
editor_user_id=context.user_id,
change_type=ContentChangeType.edit,
old_content=old_content,
new_content=new_content,
)
)
else:
session.add(
ReplyVersion(
reply_id=database_id,
editor_user_id=context.user_id,
change_type=ContentChangeType.edit,
old_content=old_content,
new_content=new_content,
)
)
obj.content = new_content
return empty_pb2.Empty()

def AddUsersToModerationUserList(
Expand Down
44 changes: 36 additions & 8 deletions app/backend/src/couchers/servicers/communities.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
Cluster,
ClusterRole,
ClusterSubscription,
Comment,
Discussion,
Event,
EventOccurrence,
Expand All @@ -32,7 +33,7 @@
from couchers.servicers.events import event_to_pb
from couchers.servicers.groups import group_to_pb
from couchers.servicers.pages import page_to_pb
from couchers.sql import to_bool, users_visible, where_moderated_content_visible
from couchers.sql import to_bool, users_visible, where_moderated_content_visible, where_users_column_visible
from couchers.utils import Timestamp_from_datetime, dt_from_millis, millis_from_dt, now

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -465,19 +466,46 @@ def ListDiscussions(
self, request: communities_pb2.ListDiscussionsReq, context: CouchersContext, session: Session
) -> communities_pb2.ListDiscussionsRes:
page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
next_page_id = int(request.page_token) if request.page_token else 0
next_page_id = int(request.page_token) if request.page_token else 2**63 - 1
node = session.execute(select(Node).where(Node.id == request.community_id)).scalar_one_or_none()
if not node:
context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "community_not_found")
if not node.official_cluster.small_community_features_enabled:
context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "discussions_not_enabled")
query = (
select(Discussion)
.where(Discussion.owner_cluster_id == node.official_cluster.id)
.where(or_(Discussion.id <= next_page_id, to_bool(next_page_id == 0)))
has_visible_comments = (
where_moderated_content_visible(
where_users_column_visible(
select(func.count())
.select_from(Comment)
.where(Comment.thread_id == Discussion.thread_id)
.where(Comment.deleted == None),
context,
Comment.author_user_id,
),
context,
Comment,
is_list_operation=True,
)
.correlate(Discussion)
.scalar_subquery()
)
discussions = (
session.execute(
where_moderated_content_visible(
select(Discussion)
.where(Discussion.owner_cluster_id == node.official_cluster.id)
.where((Discussion.deleted == None) | (has_visible_comments > 0))
.where(Discussion.id <= next_page_id)
.order_by(Discussion.id.desc())
.limit(page_size + 1),
context,
Discussion,
is_list_operation=True,
)
)
.scalars()
.all()
)
query = where_moderated_content_visible(query, context, Discussion, is_list_operation=True)
discussions = session.execute(query.order_by(Discussion.id.desc()).limit(page_size + 1)).scalars().all()
return communities_pb2.ListDiscussionsRes(
discussions=[discussion_to_pb(session, discussion, context) for discussion in discussions[:page_size]],
next_page_token=str(discussions[-1].id) if len(discussions) > page_size else None,
Expand Down
Loading
Loading