UserTopic: Set visibility_policy or delete UserTopic row without error.

This commit updates 'set_user_topic_visibility_policy_in_database'
to not raise an error when deleting a UserTopic row and the user
doesn't have a visibility_policy for the topic yet, or when setting
the visibility_policy to its current value.

Also, it includes the changes to not send unnecessary events
in such cases.
This commit is contained in:
Prakhar Pratyush 2023-03-14 19:39:12 +05:30 committed by Tim Abbott
parent f783e8b6ca
commit 042bbf2936
7 changed files with 88 additions and 78 deletions

View File

@ -20,6 +20,13 @@ format used by the Zulip server that they are interacting with.
## Changes in Zulip 7.0 ## Changes in Zulip 7.0
**Feature level 169**
* [`PATCH /users/me/subscriptions/muted_topics`](/api/mute-topic):
Trying to mute a topic that is already muted or unmute a topic
that was not previously muted now results in a success response
rather than an error.
**Feature level 168** **Feature level 168**
* [`PATCH /realm/user_settings_defaults`](/api/update-realm-user-settings-defaults), * [`PATCH /realm/user_settings_defaults`](/api/update-realm-user-settings-defaults),

View File

@ -809,7 +809,6 @@ def do_update_message(
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=UserTopic.VisibilityPolicy.MUTED,
ignore_duplicate=True,
) )
send_event(user_profile.realm, event, users_to_be_notified) send_event(user_profile.realm, event, users_to_be_notified)

View File

@ -19,22 +19,26 @@ def do_set_user_topic_visibility_policy(
*, *,
visibility_policy: int, visibility_policy: int,
last_updated: Optional[datetime.datetime] = None, last_updated: Optional[datetime.datetime] = None,
ignore_duplicate: bool = False,
skip_muted_topics_event: bool = False, skip_muted_topics_event: bool = False,
) -> None: ) -> None:
if last_updated is None: if last_updated is None:
last_updated = timezone_now() last_updated = timezone_now()
set_user_topic_visibility_policy_in_database( database_changed = set_user_topic_visibility_policy_in_database(
user_profile, user_profile,
stream.id, stream.id,
topic, topic,
visibility_policy=visibility_policy, visibility_policy=visibility_policy,
recipient_id=stream.recipient_id, recipient_id=stream.recipient_id,
last_updated=last_updated, last_updated=last_updated,
ignore_duplicate=ignore_duplicate,
) )
# Requests to set the visibility_policy to its current value
# or to delete a UserTopic row that doesn't exist shouldn't
# send an unnecessary event.
if not database_changed:
return
# This first muted_topics event is deprecated and will be removed # This first muted_topics event is deprecated and will be removed
# once clients are migrated to handle the user_topic event type # once clients are migrated to handle the user_topic event type
# instead. # instead.

View File

@ -1,14 +1,13 @@
import datetime import datetime
from typing import Callable, Dict, List, Optional, Tuple, TypedDict import logging
from typing import Callable, List, Optional, Tuple, TypedDict
from django.db import transaction from django.db import transaction
from django.db.models import QuerySet from django.db.models import QuerySet
from django.utils.timezone import now as timezone_now from django.utils.timezone import now as timezone_now
from django.utils.translation import gettext as _
from sqlalchemy.sql import ClauseElement, and_, column, not_, or_ from sqlalchemy.sql import ClauseElement, and_, column, not_, or_
from sqlalchemy.types import Integer from sqlalchemy.types import Integer
from zerver.lib.exceptions import JsonableError
from zerver.lib.timestamp import datetime_to_timestamp from zerver.lib.timestamp import datetime_to_timestamp
from zerver.lib.topic import topic_match_sa from zerver.lib.topic import topic_match_sa
from zerver.lib.types import UserTopicDict from zerver.lib.types import UserTopicDict
@ -117,20 +116,22 @@ def set_user_topic_visibility_policy_in_database(
visibility_policy: int, visibility_policy: int,
recipient_id: Optional[int] = None, recipient_id: Optional[int] = None,
last_updated: Optional[datetime.datetime] = None, last_updated: Optional[datetime.datetime] = None,
ignore_duplicate: bool = False, ) -> bool:
) -> None: # returns True if it modifies the database and False otherwise.
if visibility_policy == UserTopic.VisibilityPolicy.INHERIT: if visibility_policy == UserTopic.VisibilityPolicy.INHERIT:
try: (num_deleted, ignored) = UserTopic.objects.filter(
# Will throw UserTopic.DoesNotExist if the user doesn't user_profile=user_profile,
# already have a visibility policy for this topic. stream_id=stream_id,
UserTopic.objects.get( topic_name__iexact=topic_name,
user_profile=user_profile, ).delete()
stream_id=stream_id, if num_deleted == 0:
topic_name__iexact=topic_name, # If the user doesn't already have a visibility_policy for this topic
).delete() logging.info(
return "User %s tried to remove visibility_policy, which actually doesn't exist",
except UserTopic.DoesNotExist: user_profile.id,
raise JsonableError(_("Nothing to be done")) )
return False
return True
assert last_updated is not None assert last_updated is not None
assert recipient_id is not None assert recipient_id is not None
@ -147,26 +148,23 @@ def set_user_topic_visibility_policy_in_database(
) )
if created: if created:
return return True
duplicate_request: bool = row.visibility_policy == visibility_policy duplicate_request: bool = row.visibility_policy == visibility_policy
if duplicate_request and ignore_duplicate: if duplicate_request:
return logging.info(
"User %s tried to set visibility_policy to its current value of %s",
if duplicate_request and not ignore_duplicate: user_profile.id,
visibility_policy_string: Dict[int, str] = { visibility_policy,
1: "muted",
2: "unmuted",
3: "followed",
}
raise JsonableError(
_("Topic already {}").format(visibility_policy_string[visibility_policy])
) )
return False
# The request is to just 'update' the visibility policy of a topic # The request is to just 'update' the visibility policy of a topic
row.visibility_policy = visibility_policy row.visibility_policy = visibility_policy
row.last_updated = last_updated row.last_updated = last_updated
row.save(update_fields=["visibility_policy", "last_updated"]) row.save(update_fields=["visibility_policy", "last_updated"])
return True
def topic_is_muted(user_profile: UserProfile, stream_id: int, topic_name: str) -> bool: def topic_is_muted(user_profile: UserProfile, stream_id: int, topic_name: str) -> bool:

View File

@ -8202,6 +8202,10 @@ paths:
This endpoint mutes/unmutes a topic within a stream that the current This endpoint mutes/unmutes a topic within a stream that the current
user is subscribed to. Muted topics are displayed faded in the Zulip user is subscribed to. Muted topics are displayed faded in the Zulip
UI, and are not included in the user's unread count totals. UI, and are not included in the user's unread count totals.
**Changes**: Before Zulip 7.0 (feature level 169), this endpoint
returned an error if asked to mute a topic that was already muted
or asked to unmute a topic that had not previously been muted.
x-curl-examples-parameters: x-curl-examples-parameters:
oneOf: oneOf:
- type: exclude - type: exclude
@ -8257,26 +8261,6 @@ paths:
responses: responses:
"200": "200":
$ref: "#/components/responses/SimpleSuccess" $ref: "#/components/responses/SimpleSuccess"
"400":
description: Bad request.
content:
application/json:
schema:
oneOf:
- allOf:
- $ref: "#/components/schemas/JsonError"
- example:
{"msg": "Topic already muted", "result": "error"}
description: |
An example JSON response for when an `add` operation is requested for a topic
that has already been muted:
- allOf:
- $ref: "#/components/schemas/JsonError"
- example:
{"msg": "Topic is not muted", "result": "error"}
description: |
An example JSON response for when a `remove` operation is requested for a
topic that had not been previously muted:
/users/me/muted_users/{muted_user_id}: /users/me/muted_users/{muted_user_id}:
post: post:
operationId: mute-user operationId: mute-user

View File

@ -35,6 +35,7 @@ from zerver.models import (
Stream, Stream,
UserMessage, UserMessage,
UserProfile, UserProfile,
UserTopic,
get_realm, get_realm,
get_stream, get_stream,
) )
@ -1338,7 +1339,7 @@ class EditMessageTest(EditMessageTestCase):
# This code path adds 9 (1 + 4/user with muted topics) + 1 to # This code path adds 9 (1 + 4/user with muted topics) + 1 to
# the number of database queries for moving a topic. # the number of database queries for moving a topic.
with self.assert_database_query_count(21): with self.assert_database_query_count(19):
check_update_message( check_update_message(
user_profile=hamlet, user_profile=hamlet,
message_id=message_id, message_id=message_id,
@ -1381,15 +1382,23 @@ class EditMessageTest(EditMessageTestCase):
self.assertTrue(topic_is_muted(hamlet, stream.id, change_later_topic_name)) self.assertTrue(topic_is_muted(hamlet, stream.id, change_later_topic_name))
# Make sure we safely handle the case of the new topic being already muted. # Make sure we safely handle the case of the new topic being already muted.
check_update_message( with self.assertLogs(level="INFO") as info_logs:
user_profile=hamlet, check_update_message(
message_id=message_id, user_profile=hamlet,
stream_id=None, message_id=message_id,
topic_name=already_muted_topic, stream_id=None,
propagate_mode="change_all", topic_name=already_muted_topic,
send_notification_to_old_thread=False, propagate_mode="change_all",
send_notification_to_new_thread=False, send_notification_to_old_thread=False,
content=None, send_notification_to_new_thread=False,
content=None,
)
self.assertEqual(
set(info_logs.output),
{
f"INFO:root:User {hamlet.id} tried to set visibility_policy to its current value of {UserTopic.VisibilityPolicy.MUTED}",
f"INFO:root:User {cordelia.id} tried to set visibility_policy to its current value of {UserTopic.VisibilityPolicy.MUTED}",
},
) )
self.assertFalse(topic_is_muted(hamlet, stream.id, change_later_topic_name)) self.assertFalse(topic_is_muted(hamlet, stream.id, change_later_topic_name))
self.assertTrue(topic_is_muted(hamlet, stream.id, already_muted_topic)) self.assertTrue(topic_is_muted(hamlet, stream.id, already_muted_topic))
@ -1422,7 +1431,7 @@ class EditMessageTest(EditMessageTestCase):
set_topic_mutes(desdemona, muted_topics) set_topic_mutes(desdemona, muted_topics)
set_topic_mutes(cordelia, muted_topics) set_topic_mutes(cordelia, muted_topics)
with self.assert_database_query_count(32): with self.assert_database_query_count(30):
check_update_message( check_update_message(
user_profile=desdemona, user_profile=desdemona,
message_id=message_id, message_id=message_id,
@ -1453,7 +1462,7 @@ class EditMessageTest(EditMessageTestCase):
set_topic_mutes(desdemona, muted_topics) set_topic_mutes(desdemona, muted_topics)
set_topic_mutes(cordelia, muted_topics) set_topic_mutes(cordelia, muted_topics)
with self.assert_database_query_count(33): with self.assert_database_query_count(31):
check_update_message( check_update_message(
user_profile=desdemona, user_profile=desdemona,
message_id=message_id, message_id=message_id,
@ -1486,7 +1495,7 @@ class EditMessageTest(EditMessageTestCase):
set_topic_mutes(desdemona, muted_topics) set_topic_mutes(desdemona, muted_topics)
set_topic_mutes(cordelia, muted_topics) set_topic_mutes(cordelia, muted_topics)
with self.assert_database_query_count(32): with self.assert_database_query_count(30):
check_update_message( check_update_message(
user_profile=desdemona, user_profile=desdemona,
message_id=message_id, message_id=message_id,

View File

@ -2,7 +2,6 @@ from datetime import datetime, timezone
from typing import Any, Dict, List from typing import Any, Dict, List
from unittest import mock from unittest import mock
from django.db import transaction
from django.utils.timezone import now as timezone_now from django.utils.timezone import now as timezone_now
from zerver.actions.user_topics import do_set_user_topic_visibility_policy from zerver.actions.user_topics import do_set_user_topic_visibility_policy
@ -120,11 +119,20 @@ class MutedTopicsTests(ZulipTestCase):
assert stream.recipient is not None assert stream.recipient is not None
result = self.api_patch(user, url, data) result = self.api_patch(user, url, data)
# Now check that error is raised when attempted to mute an already # Now check that no error is raised when attempted to mute
# muted topic. This should be case-insensitive. # an already muted topic. This should be case-insensitive.
user_topic_count = UserTopic.objects.count()
data["topic"] = "VERONA3" data["topic"] = "VERONA3"
result = self.api_patch(user, url, data) with self.assertLogs(level="INFO") as info_logs:
self.assert_json_error(result, "Topic already muted") result = self.api_patch(user, url, data)
self.assert_json_success(result)
self.assertEqual(
info_logs.output[0],
f"INFO:root:User {user.id} tried to set visibility_policy to its current value of {UserTopic.VisibilityPolicy.MUTED}",
)
# Verify that we didn't end up with duplicate UserTopic rows
# with the two different cases after the previous API call.
self.assertEqual(UserTopic.objects.count() - user_topic_count, 0)
def test_remove_muted_topic(self) -> None: def test_remove_muted_topic(self) -> None:
user = self.example_user("hamlet") user = self.example_user("hamlet")
@ -195,15 +203,16 @@ class MutedTopicsTests(ZulipTestCase):
result = self.api_patch(user, url, data) result = self.api_patch(user, url, data)
self.assert_json_error(result, "Topic is not muted") self.assert_json_error(result, "Topic is not muted")
with transaction.atomic(): # Check that removing mute from a topic for which the user
# This API call needs a new nested transaction with 'savepoint=True', # doesn't already have a visibility_policy doesn't cause an error.
# because it calls 'set_user_topic_visibility_policy_in_database', data = {"stream": stream.name, "topic": "BOGUS", "op": "remove"}
# which on failure rollbacks the test-transaction. with self.assertLogs(level="INFO") as info_logs:
# If it is not used, the test-transaction will be rolled back during this API call,
# and the next API call will result in a "TransactionManagementError."
data = {"stream": stream.name, "topic": "BOGUS", "op": "remove"}
result = self.api_patch(user, url, data) result = self.api_patch(user, url, data)
self.assert_json_error(result, "Nothing to be done") self.assert_json_success(result)
self.assertEqual(
info_logs.output[0],
f"INFO:root:User {user.id} tried to remove visibility_policy, which actually doesn't exist",
)
data = {"stream_id": 999999999, "topic": "BOGUS", "op": "remove"} data = {"stream_id": 999999999, "topic": "BOGUS", "op": "remove"}
result = self.api_patch(user, url, data) result = self.api_patch(user, url, data)