user_topics: Update UserTopic records regardless of the visibility_policy.

This commit updates the 'do_update_message' codepath to
update the UserTopic records regardless of visibility policy
during the "move-topic" operation.

This is required before offering new visibility policies
in the UI.

Previously, UserTopic records were moved or deleted only
for objects with a MUTED visibility policy.

Fixes: #24574
This commit is contained in:
Prakhar Pratyush 2023-03-17 19:39:50 +05:30 committed by Tim Abbott
parent 0377085f15
commit a890aaf34d
3 changed files with 165 additions and 28 deletions

View File

@ -50,7 +50,7 @@ from zerver.lib.topic import (
) )
from zerver.lib.types import EditHistoryEvent from zerver.lib.types import EditHistoryEvent
from zerver.lib.user_message import UserMessageLite, bulk_insert_ums from zerver.lib.user_message import UserMessageLite, bulk_insert_ums
from zerver.lib.user_topics import get_users_muting_topic from zerver.lib.user_topics import get_users_with_user_topic_visibility_policy
from zerver.lib.widget import is_widget_message from zerver.lib.widget import is_widget_message
from zerver.models import ( from zerver.models import (
ArchivedAttachment, ArchivedAttachment,
@ -749,7 +749,8 @@ def do_update_message(
) )
moved_all_visible_messages = len(visible_unmoved_messages) == 0 moved_all_visible_messages = len(visible_unmoved_messages) == 0
# Migrate muted topic configuration in the following circumstances: # Migrate 'topic with visibility_policy' configuration in the following
# circumstances:
# #
# * If propagate_mode is change_all, do so unconditionally. # * If propagate_mode is change_all, do so unconditionally.
# #
@ -765,50 +766,56 @@ def do_update_message(
assert stream_being_edited is not None assert stream_being_edited is not None
assert topic_name is not None or new_stream is not None assert topic_name is not None or new_stream is not None
for muting_user in get_users_muting_topic(stream_being_edited.id, orig_topic_name): for user_topic in get_users_with_user_topic_visibility_policy(
stream_being_edited.id, orig_topic_name
):
# TODO: Ideally, this would be a bulk update operation, # TODO: Ideally, this would be a bulk update operation,
# because we are doing database operations in a loop here. # because we are doing database operations in a loop here.
# #
# This loop is only acceptable in production because it is # This loop is only acceptable in production because it is
# rare for more than a few users to have muted an # rare for more than a few users to have visibility_policy
# individual topic that is being moved; as of this # set for an individual topic that is being moved; as of this
# writing, no individual topic in Zulip Cloud had been # writing, no individual topic in Zulip Cloud had been
# muted by more than 100 users. # muted by more than 100 users.
if new_stream is not None and muting_user.id in delete_event_notify_user_ids: if (
new_stream is not None
and user_topic.user_profile_id in delete_event_notify_user_ids
):
# If the messages are being moved to a stream the user # If the messages are being moved to a stream the user
# cannot access, then we treat this as the # cannot access, then we treat this as the
# messages/topic being deleted for this user. This is # messages/topic being deleted for this user. This is
# important for security reasons; we don't want to # important for security reasons; we don't want to
# give users a UserTopic row in a stream they cannot # give users a UserTopic row in a stream they cannot
# access. Unmute the topic for such users. # access. Remove the user topic rows for such users.
do_set_user_topic_visibility_policy( do_set_user_topic_visibility_policy(
muting_user, user_topic.user_profile,
stream_being_edited, stream_being_edited,
orig_topic_name, orig_topic_name,
visibility_policy=UserTopic.VisibilityPolicy.INHERIT, visibility_policy=UserTopic.VisibilityPolicy.INHERIT,
) )
else: else:
# Otherwise, we move the muted topic record for the # Otherwise, we move the user topic record for the
# user, but removing the old topic mute and then # user, but removing the old topic visibility_policy
# creating a new one. # and then creating a new one.
new_visibility_policy = user_topic.visibility_policy
do_set_user_topic_visibility_policy( do_set_user_topic_visibility_policy(
muting_user, user_topic.user_profile,
stream_being_edited, stream_being_edited,
orig_topic_name, orig_topic_name,
visibility_policy=UserTopic.VisibilityPolicy.INHERIT, visibility_policy=UserTopic.VisibilityPolicy.INHERIT,
# do_set_user_topic_visibility_policy with visibility_policy # do_set_user_topic_visibility_policy with visibility_policy
# set to UserTopic.VisibilityPolicy.MUTED will send an updated muted topic # set to 'new_visibility_policy' will send an updated muted topic
# event, which contains the full set of muted # event, which contains the full set of muted
# topics, just after this. # topics, just after this.
skip_muted_topics_event=True, skip_muted_topics_event=True,
) )
do_set_user_topic_visibility_policy( do_set_user_topic_visibility_policy(
muting_user, user_topic.user_profile,
new_stream if new_stream is not None else stream_being_edited, new_stream if new_stream is not None else stream_being_edited,
topic_name if topic_name is not None else orig_topic_name, topic_name if topic_name is not None else orig_topic_name,
visibility_policy=UserTopic.VisibilityPolicy.MUTED, visibility_policy=new_visibility_policy,
) )
send_event(user_profile.realm, event, users_to_be_notified) send_event(user_profile.realm, event, users_to_be_notified)

View File

@ -239,11 +239,9 @@ def build_topic_mute_checker(user_profile: UserProfile) -> Callable[[int, str],
return is_muted return is_muted
def get_users_muting_topic(stream_id: int, topic_name: str) -> QuerySet[UserProfile]: def get_users_with_user_topic_visibility_policy(
return UserProfile.objects.select_related("realm").filter( stream_id: int, topic_name: str
id__in=UserTopic.objects.filter( ) -> QuerySet[UserTopic]:
stream_id=stream_id, return UserTopic.objects.filter(
visibility_policy=UserTopic.VisibilityPolicy.MUTED, stream_id=stream_id, topic_name__iexact=topic_name
topic_name__iexact=topic_name, ).select_related("user_profile", "user_profile__realm")
).values("user_profile_id")
)

View File

@ -23,7 +23,7 @@ from zerver.lib.test_helpers import cache_tries_captured, queries_captured
from zerver.lib.topic import RESOLVED_TOPIC_PREFIX, TOPIC_NAME from zerver.lib.topic import RESOLVED_TOPIC_PREFIX, TOPIC_NAME
from zerver.lib.user_topics import ( from zerver.lib.user_topics import (
get_topic_mutes, get_topic_mutes,
get_users_muting_topic, get_users_with_user_topic_visibility_policy,
set_topic_visibility_policy, set_topic_visibility_policy,
topic_has_visibility_policy, topic_has_visibility_policy,
) )
@ -1371,10 +1371,12 @@ class EditMessageTest(EditMessageTestCase):
content=None, content=None,
) )
for muting_user in get_users_muting_topic(stream.id, change_all_topic_name): for user_topic in get_users_with_user_topic_visibility_policy(
stream.id, change_all_topic_name
):
for user in users_to_be_notified: for user in users_to_be_notified:
if muting_user.id == user["id"]: if user_topic.user_profile_id == user["id"]:
user["muted_topics"] = get_topic_mutes(muting_user) user["muted_topics"] = get_topic_mutes(user_topic.user_profile)
break break
assert_is_topic_muted(hamlet, stream.id, "Topic1", muted=False) assert_is_topic_muted(hamlet, stream.id, "Topic1", muted=False)
@ -1534,7 +1536,7 @@ class EditMessageTest(EditMessageTestCase):
assert_is_topic_muted(cordelia, new_public_stream.id, "changed topic name", muted=True) assert_is_topic_muted(cordelia, new_public_stream.id, "changed topic name", muted=True)
assert_is_topic_muted(aaron, new_public_stream.id, "changed topic name", muted=False) assert_is_topic_muted(aaron, new_public_stream.id, "changed topic name", muted=False)
# Moving only half the messages doesn't move MutedTopic records. # Moving only half the messages doesn't move UserTopic records.
second_message_id = self.send_stream_message( second_message_id = self.send_stream_message(
hamlet, stream_name, topic_name="changed topic name", content="Second message" hamlet, stream_name, topic_name="changed topic name", content="Second message"
) )
@ -1557,6 +1559,136 @@ class EditMessageTest(EditMessageTestCase):
assert_is_topic_muted(cordelia, new_public_stream.id, "final topic name", muted=False) assert_is_topic_muted(cordelia, new_public_stream.id, "final topic name", muted=False)
assert_is_topic_muted(aaron, new_public_stream.id, "final topic name", muted=False) assert_is_topic_muted(aaron, new_public_stream.id, "final topic name", muted=False)
@mock.patch("zerver.actions.user_topics.send_event")
def test_edit_unmuted_topic(self, mock_send_event: mock.MagicMock) -> None:
stream_name = "Stream 123"
stream = self.make_stream(stream_name)
hamlet = self.example_user("hamlet")
cordelia = self.example_user("cordelia")
aaron = self.example_user("aaron")
def assert_has_visibility_policy(
user_profile: UserProfile,
topic_name: str,
visibility_policy: int,
*,
expected: bool,
) -> None:
if expected:
self.assertTrue(
topic_has_visibility_policy(
user_profile, stream.id, topic_name, visibility_policy
)
)
else:
self.assertFalse(
topic_has_visibility_policy(
user_profile, stream.id, topic_name, visibility_policy
)
)
self.subscribe(hamlet, stream_name)
self.login_user(hamlet)
message_id = self.send_stream_message(
hamlet, stream_name, topic_name="Topic1", content="Hello World"
)
self.subscribe(cordelia, stream_name)
self.login_user(cordelia)
self.subscribe(aaron, stream_name)
self.login_user(aaron)
# Initially, hamlet sets visibility_policy as UNMUTED for 'Topic1' and 'Topic2',
# cordelia sets visibility_policy as MUTED for 'Topic1' and 'Topic2', while
# aaron doesn't have a visibility_policy set for 'Topic1' or 'Topic2'.
#
# After moving messages from 'Topic1' to 'Topic 1 edited', the expected behaviour is:
# hamlet has UNMUTED 'Topic 1 edited' and no visibility_policy set for 'Topic1'
# cordelia has MUTED 'Topic 1 edited' and no visibility_policy set for 'Topic1'
#
# There is no change in visibility_policy configurations for 'Topic2', i.e.
# hamlet has UNMUTED 'Topic2' + cordelia has MUTED 'Topic2'
# aaron still doesn't have visibility_policy set for any topic.
topics = [
[stream_name, "Topic1"],
[stream_name, "Topic2"],
]
set_topic_visibility_policy(hamlet, topics, UserTopic.VisibilityPolicy.UNMUTED)
set_topic_visibility_policy(cordelia, topics, UserTopic.VisibilityPolicy.MUTED)
# users that need to be notified by send_event in the case of change-topic-name operation.
users_to_be_notified_via_muted_topics_event: List[int] = []
users_to_be_notified_via_user_topic_event: List[int] = []
for user_topic in get_users_with_user_topic_visibility_policy(stream.id, "Topic1"):
# We are appending the same data twice because 'user_topic' event notifies
# the user during delete and create operation.
users_to_be_notified_via_user_topic_event.append(user_topic.user_profile_id)
users_to_be_notified_via_user_topic_event.append(user_topic.user_profile_id)
# 'muted_topics' event notifies the user of muted topics during create
# operation only.
users_to_be_notified_via_muted_topics_event.append(user_topic.user_profile_id)
change_all_topic_name = "Topic 1 edited"
with self.assert_database_query_count(19):
check_update_message(
user_profile=hamlet,
message_id=message_id,
stream_id=None,
topic_name=change_all_topic_name,
propagate_mode="change_all",
send_notification_to_old_thread=False,
send_notification_to_new_thread=False,
content=None,
)
# Extract the send_event call where event type is 'user_topic' or 'muted_topics.
# Here we assert that the expected users are notified properly.
users_notified_via_muted_topics_event: List[int] = []
users_notified_via_user_topic_event: List[int] = []
for call_args in mock_send_event.call_args_list:
(arg_realm, arg_event, arg_notified_users) = call_args[0]
if arg_event["type"] == "user_topic":
users_notified_via_user_topic_event.append(*arg_notified_users)
elif arg_event["type"] == "muted_topics":
users_notified_via_muted_topics_event.append(*arg_notified_users)
self.assertEqual(
sorted(users_notified_via_muted_topics_event),
sorted(users_to_be_notified_via_muted_topics_event),
)
self.assertEqual(
sorted(users_notified_via_user_topic_event),
sorted(users_to_be_notified_via_user_topic_event),
)
assert_has_visibility_policy(
hamlet, "Topic1", UserTopic.VisibilityPolicy.UNMUTED, expected=False
)
assert_has_visibility_policy(
cordelia, "Topic1", UserTopic.VisibilityPolicy.MUTED, expected=False
)
assert_has_visibility_policy(
aaron, "Topic1", UserTopic.VisibilityPolicy.UNMUTED, expected=False
)
assert_has_visibility_policy(
hamlet, "Topic2", UserTopic.VisibilityPolicy.UNMUTED, expected=True
)
assert_has_visibility_policy(
cordelia, "Topic2", UserTopic.VisibilityPolicy.MUTED, expected=True
)
assert_has_visibility_policy(
aaron, "Topic2", UserTopic.VisibilityPolicy.UNMUTED, expected=False
)
assert_has_visibility_policy(
hamlet, change_all_topic_name, UserTopic.VisibilityPolicy.UNMUTED, expected=True
)
assert_has_visibility_policy(
cordelia, change_all_topic_name, UserTopic.VisibilityPolicy.MUTED, expected=True
)
assert_has_visibility_policy(
aaron, change_all_topic_name, UserTopic.VisibilityPolicy.MUTED, expected=False
)
@mock.patch("zerver.actions.message_edit.send_event") @mock.patch("zerver.actions.message_edit.send_event")
def test_wildcard_mention(self, mock_send_event: mock.MagicMock) -> None: def test_wildcard_mention(self, mock_send_event: mock.MagicMock) -> None:
stream_name = "Macbeth" stream_name = "Macbeth"