user_topics: Add a new endpoint to update visibility_policy.

This commit adds a new endpoint, 'POST /user_topics' which
is used to update the personal preferences for a topic.

Currently, it is used to update the visibility policy of
a user-topic row.
This commit is contained in:
Prakhar Pratyush 2023-04-01 00:48:12 +05:30 committed by Tim Abbott
parent 0b2fe5b163
commit f012d079c3
8 changed files with 511 additions and 6 deletions

View File

@ -20,6 +20,11 @@ format used by the Zulip server that they are interacting with.
## Changes in Zulip 7.0 ## Changes in Zulip 7.0
**Feature level 170**
* [`POST /user_topics`](/api/update-user-topic):
Added a new endpoint to update the personal preferences for a topic.
**Feature level 169** **Feature level 169**
* [`PATCH /users/me/subscriptions/muted_topics`](/api/mute-topic): * [`PATCH /users/me/subscriptions/muted_topics`](/api/mute-topic):

View File

@ -40,6 +40,7 @@
* [Archive a stream](/api/archive-stream) * [Archive a stream](/api/archive-stream)
* [Get topics in a stream](/api/get-stream-topics) * [Get topics in a stream](/api/get-stream-topics)
* [Topic muting](/api/mute-topic) * [Topic muting](/api/mute-topic)
* [Update personal preferences for a topic](/api/update-user-topic)
* [Delete a topic](/api/delete-topic) * [Delete a topic](/api/delete-topic)
* [Add a default stream](/api/add-default-stream) * [Add a default stream](/api/add-default-stream)
* [Remove a default stream](/api/remove-default-stream) * [Remove a default stream](/api/remove-default-stream)

View File

@ -33,7 +33,7 @@ DESKTOP_WARNING_VERSION = "5.4.3"
# Changes should be accompanied by documentation explaining what the # Changes should be accompanied by documentation explaining what the
# new level means in api_docs/changelog.md, as well as "**Changes**" # new level means in api_docs/changelog.md, as well as "**Changes**"
# entries in the endpoint's documentation in `zulip.yaml`. # entries in the endpoint's documentation in `zulip.yaml`.
API_FEATURE_LEVEL = 169 API_FEATURE_LEVEL = 170
# Bump the minor PROVISION_VERSION to indicate that folks should provision # Bump the minor PROVISION_VERSION to indicate that folks should provision
# only when going from an old version of the code to a newer version. Bump # only when going from an old version of the code to a newer version. Bump

View File

@ -728,6 +728,44 @@ def toggle_mute_topic(client: Client) -> None:
validate_against_openapi_schema(result, "/users/me/subscriptions/muted_topics", "patch", "200") validate_against_openapi_schema(result, "/users/me/subscriptions/muted_topics", "patch", "200")
@openapi_test_function("/user_topics:post")
def update_user_topic(client: Client) -> None:
stream_id = client.get_stream_id("Denmark")["stream_id"]
# {code_example|start}
# Mute the topic "dinner" in the stream having id 'stream_id'.
request = {
"stream_id": stream_id,
"topic": "dinner",
"visibility_policy": 1,
}
result = client.call_endpoint(
url="user_topics",
method="POST",
request=request,
)
# {code_example|end}
validate_against_openapi_schema(result, "/user_topics", "post", "200")
# {code_example|start}
# Remove mute from the topic "dinner" in the stream having id 'stream_id'.
request = {
"stream_id": stream_id,
"topic": "dinner",
"visibility_policy": 0,
}
result = client.call_endpoint(
url="user_topics",
method="POST",
request=request,
)
# {code_example|end}
validate_against_openapi_schema(result, "/user_topics", "post", "200")
@openapi_test_function("/users/me/muted_users/{muted_user_id}:post") @openapi_test_function("/users/me/muted_users/{muted_user_id}:post")
def add_user_mute(client: Client) -> None: def add_user_mute(client: Client) -> None:
ensure_users([10], ["hamlet"]) ensure_users([10], ["hamlet"])
@ -1537,6 +1575,7 @@ def test_streams(client: Client, nonadmin_client: Client) -> None:
get_subscribers(client) get_subscribers(client)
remove_subscriptions(client) remove_subscriptions(client)
toggle_mute_topic(client) toggle_mute_topic(client)
update_user_topic(client)
update_subscription_settings(client) update_subscription_settings(client)
get_stream_topics(client, 1) get_stream_topics(client, 1)
delete_topic(client, 1, "test") delete_topic(client, 1, "test")

View File

@ -8203,7 +8203,12 @@ paths:
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 **Changes**: Deprecated in Zulip 7.0 (feature level 170). Clients connecting
to the newer server should use the `/user_topics` endpoint instead.
It will be removed once clients have migrated to use the
`/user_topics` endpoint.
Before Zulip 7.0 (feature level 169), this endpoint
returned an error if asked to mute a topic that was already muted 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. or asked to unmute a topic that had not previously been muted.
x-curl-examples-parameters: x-curl-examples-parameters:
@ -8261,6 +8266,73 @@ paths:
responses: responses:
"200": "200":
$ref: "#/components/responses/SimpleSuccess" $ref: "#/components/responses/SimpleSuccess"
/user_topics:
post:
operationId: update-user-topic
summary: Update personal preferences for a topic
tags: ["streams"]
description: |
This endpoint is used to update the personal preferences for a topic,
such as the topic's visibility policy, which is used to implement
[mute a topic](/help/mute-a-topic) and related features.
This endpoint can be used to update the visibility policy for the single
stream and topic pair indicated by the parameters for a user.
**Changes**: New in Zulip 7.0 (feature level 170).
Previously, toggling the muting state for a topic was managed by the
`/users/me/subscriptions/muted_topics` endpoint, which this endpoint
is intended to replace.
parameters:
- name: stream_id
in: query
description: |
The ID of the stream to access.
schema:
type: integer
example: 1
required: true
- name: topic
in: query
description: |
The topic for which the personal preferences needs to be updated.
Note that the request will succeed regardless of whether
any messages have been sent to the specified topic.
schema:
type: string
example: dinner
required: true
- name: visibility_policy
in: query
description: |
Controls which visibility policy to set.
- 0 - INHERIT
- 1 - MUTED
- 2 - UNMUTED
The visibility policy, when set to MUTED, mutes a topic;
when set to UNMUTED, it unmutes a topic in a muted stream;
and INHERIT is used to remove the visibility policy already set.
MUTED topics are displayed faded in the Zulip UI, are not included
in the user's unread count totals, and the user doesn't receive any
notifications.
An UNMUTED topic will remain visible even if the stream is muted.
In a stream that is not muted, a policy of UNMUTED has the same effect
as INHERIT.
schema:
type: integer
enum:
- 0
- 1
- 2
example: 1
required: true
responses:
"200":
$ref: "#/components/responses/SimpleSuccess"
/users/me/muted_users/{muted_user_id}: /users/me/muted_users/{muted_user_id}:
post: post:
operationId: mute-user operationId: mute-user

View File

@ -1,5 +1,5 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Dict, List from typing import Any, Dict, List, Mapping
import time_machine import time_machine
from django.utils.timezone import now as timezone_now from django.utils.timezone import now as timezone_now
@ -14,7 +14,10 @@ from zerver.lib.user_topics import (
from zerver.models import UserProfile, UserTopic, get_stream from zerver.models import UserProfile, UserTopic, get_stream
class MutedTopicsTests(ZulipTestCase): class MutedTopicsTestsDeprecated(ZulipTestCase):
# Tests the deprecated URL: "/api/v1/users/me/subscriptions/muted_topics".
# It exists for backward compatibility and should be removed once
# we remove the deprecated URL.
def test_get_deactivated_muted_topic(self) -> None: def test_get_deactivated_muted_topic(self) -> None:
user = self.example_user("hamlet") user = self.example_user("hamlet")
self.login_user(user) self.login_user(user)
@ -229,6 +232,225 @@ class MutedTopicsTests(ZulipTestCase):
self.assert_json_error(result, "Please choose one: 'stream' or 'stream_id'.") self.assert_json_error(result, "Please choose one: 'stream' or 'stream_id'.")
class MutedTopicsTests(ZulipTestCase):
def test_get_deactivated_muted_topic(self) -> None:
user = self.example_user("hamlet")
self.login_user(user)
stream = get_stream("Verona", user.realm)
url = "/api/v1/user_topics"
data = {
"stream_id": stream.id,
"topic": "Verona3",
"visibility_policy": UserTopic.VisibilityPolicy.MUTED,
}
mock_date_muted = datetime(2020, 1, 1, tzinfo=timezone.utc).timestamp()
with time_machine.travel(datetime(2020, 1, 1, tzinfo=timezone.utc), tick=False):
result = self.api_post(user, url, data)
self.assert_json_success(result)
stream.deactivated = True
stream.save()
self.assertNotIn((stream.name, "Verona3", mock_date_muted), get_topic_mutes(user))
self.assertIn((stream.name, "Verona3", mock_date_muted), get_topic_mutes(user, True))
def test_user_ids_muting_topic(self) -> None:
hamlet = self.example_user("hamlet")
cordelia = self.example_user("cordelia")
realm = hamlet.realm
stream = get_stream("Verona", realm)
topic_name = "teST topic"
date_muted = datetime(2020, 1, 1, tzinfo=timezone.utc)
stream_topic_target = StreamTopicTarget(
stream_id=stream.id,
topic_name=topic_name,
)
user_ids = stream_topic_target.user_ids_with_visibility_policy(
UserTopic.VisibilityPolicy.MUTED
)
self.assertEqual(user_ids, set())
url = "/api/v1/user_topics"
def set_topic_visibility_for_user(user: UserProfile, visibility_policy: int) -> None:
data = {
"stream_id": stream.id,
"topic": "test TOPIC",
"visibility_policy": visibility_policy,
}
with time_machine.travel(date_muted, tick=False):
result = self.api_post(user, url, data)
self.assert_json_success(result)
set_topic_visibility_for_user(hamlet, UserTopic.VisibilityPolicy.MUTED)
set_topic_visibility_for_user(cordelia, UserTopic.VisibilityPolicy.UNMUTED)
user_ids = stream_topic_target.user_ids_with_visibility_policy(
UserTopic.VisibilityPolicy.MUTED
)
self.assertEqual(user_ids, {hamlet.id})
hamlet_date_muted = UserTopic.objects.filter(
user_profile=hamlet, visibility_policy=UserTopic.VisibilityPolicy.MUTED
)[0].last_updated
self.assertEqual(hamlet_date_muted, date_muted)
set_topic_visibility_for_user(cordelia, UserTopic.VisibilityPolicy.MUTED)
user_ids = stream_topic_target.user_ids_with_visibility_policy(
UserTopic.VisibilityPolicy.MUTED
)
self.assertEqual(user_ids, {hamlet.id, cordelia.id})
cordelia_date_muted = UserTopic.objects.filter(
user_profile=cordelia, visibility_policy=UserTopic.VisibilityPolicy.MUTED
)[0].last_updated
self.assertEqual(cordelia_date_muted, date_muted)
def test_add_muted_topic(self) -> None:
user = self.example_user("hamlet")
self.login_user(user)
stream = get_stream("Verona", user.realm)
url = "/api/v1/user_topics"
data = {
"stream_id": stream.id,
"topic": "Verona3",
"visibility_policy": UserTopic.VisibilityPolicy.MUTED,
}
mock_date_muted = datetime(2020, 1, 1, tzinfo=timezone.utc).timestamp()
events: List[Mapping[str, Any]] = []
with self.tornado_redirected_to_list(events, expected_num_events=2):
with time_machine.travel(datetime(2020, 1, 1, tzinfo=timezone.utc), tick=False):
result = self.api_post(user, url, data)
self.assert_json_success(result)
self.assertTrue(
topic_has_visibility_policy(
user, stream.id, "verona3", UserTopic.VisibilityPolicy.MUTED
)
)
# Verify if events are sent properly
user_topic_event: Dict[str, Any] = {
"type": "user_topic",
"stream_id": stream.id,
"topic_name": "Verona3",
"last_updated": mock_date_muted,
"visibility_policy": UserTopic.VisibilityPolicy.MUTED,
}
muted_topics_event = dict(type="muted_topics", muted_topics=get_topic_mutes(user))
self.assertEqual(events[0]["event"], muted_topics_event)
self.assertEqual(events[1]["event"], user_topic_event)
# Now check that no error is raised when attempted to mute
# an already muted topic. This should be case-insensitive.
user_topic_count = UserTopic.objects.count()
data["topic"] = "VERONA3"
with self.assertLogs(level="INFO") as info_logs:
result = self.api_post(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:
user = self.example_user("hamlet")
realm = user.realm
self.login_user(user)
stream = get_stream("Verona", realm)
do_set_user_topic_visibility_policy(
user,
stream,
"Verona3",
visibility_policy=UserTopic.VisibilityPolicy.MUTED,
last_updated=datetime(2020, 1, 1, tzinfo=timezone.utc),
)
self.assertTrue(
topic_has_visibility_policy(
user, stream.id, "verona3", UserTopic.VisibilityPolicy.MUTED
)
)
url = "/api/v1/user_topics"
data = {
"stream_id": stream.id,
"topic": "Verona3",
"visibility_policy": UserTopic.VisibilityPolicy.INHERIT,
}
mock_date_mute_removed = datetime(2020, 1, 1, tzinfo=timezone.utc).timestamp()
events: List[Mapping[str, Any]] = []
with self.tornado_redirected_to_list(events, expected_num_events=2):
with time_machine.travel(datetime(2020, 1, 1, tzinfo=timezone.utc), tick=False):
result = self.api_post(user, url, data)
self.assert_json_success(result)
self.assertFalse(
topic_has_visibility_policy(
user, stream.id, "verona3", UserTopic.VisibilityPolicy.MUTED
)
)
# Verify if events are sent properly
user_topic_event: Dict[str, Any] = {
"type": "user_topic",
"stream_id": stream.id,
"topic_name": data["topic"],
"last_updated": mock_date_mute_removed,
"visibility_policy": UserTopic.VisibilityPolicy.INHERIT,
}
muted_topics_event = dict(type="muted_topics", muted_topics=get_topic_mutes(user))
self.assertEqual(events[0]["event"], muted_topics_event)
self.assertEqual(events[1]["event"], user_topic_event)
# Check that removing mute from a topic for which the user
# doesn't already have a visibility_policy doesn't cause an error.
with self.assertLogs(level="INFO") as info_logs:
result = self.api_post(user, url, data)
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",
)
def test_muted_topic_add_invalid(self) -> None:
user = self.example_user("hamlet")
self.login_user(user)
url = "/api/v1/user_topics"
data = {
"stream_id": 999999999,
"topic": "Verona3",
"visibility_policy": UserTopic.VisibilityPolicy.MUTED,
}
result = self.api_post(user, url, data)
self.assert_json_error(result, "Invalid stream ID")
def test_muted_topic_remove_invalid(self) -> None:
user = self.example_user("hamlet")
self.login_user(user)
url = "/api/v1/user_topics"
data = {
"stream_id": 999999999,
"topic": "Verona3",
"visibility_policy": UserTopic.VisibilityPolicy.INHERIT,
}
result = self.api_post(user, url, data)
self.assert_json_error(result, "Invalid stream ID")
class UnmutedTopicsTests(ZulipTestCase): class UnmutedTopicsTests(ZulipTestCase):
def test_user_ids_unmuting_topic(self) -> None: def test_user_ids_unmuting_topic(self) -> None:
hamlet = self.example_user("hamlet") hamlet = self.example_user("hamlet")
@ -277,3 +499,146 @@ class UnmutedTopicsTests(ZulipTestCase):
user_profile=cordelia, visibility_policy=UserTopic.VisibilityPolicy.UNMUTED user_profile=cordelia, visibility_policy=UserTopic.VisibilityPolicy.UNMUTED
)[0].last_updated )[0].last_updated
self.assertEqual(cordelia_date_unmuted, date_unmuted) self.assertEqual(cordelia_date_unmuted, date_unmuted)
def test_add_unmuted_topic(self) -> None:
user = self.example_user("hamlet")
self.login_user(user)
stream = get_stream("Verona", user.realm)
url = "/api/v1/user_topics"
data = {
"stream_id": stream.id,
"topic": "Verona3",
"visibility_policy": UserTopic.VisibilityPolicy.UNMUTED,
}
mock_date_unmuted = datetime(2020, 1, 1, tzinfo=timezone.utc).timestamp()
events: List[Mapping[str, Any]] = []
with self.tornado_redirected_to_list(events, expected_num_events=2):
with time_machine.travel(datetime(2020, 1, 1, tzinfo=timezone.utc), tick=False):
result = self.api_post(user, url, data)
self.assert_json_success(result)
self.assertTrue(
topic_has_visibility_policy(
user, stream.id, "verona3", UserTopic.VisibilityPolicy.UNMUTED
)
)
# Verify if events are sent properly
user_topic_event: Dict[str, Any] = {
"type": "user_topic",
"stream_id": stream.id,
"topic_name": "Verona3",
"last_updated": mock_date_unmuted,
"visibility_policy": UserTopic.VisibilityPolicy.UNMUTED,
}
muted_topics_event = dict(type="muted_topics", muted_topics=get_topic_mutes(user))
self.assertEqual(events[0]["event"], muted_topics_event)
self.assertEqual(events[1]["event"], user_topic_event)
# Now check that no error is raised when attempted to UNMUTE
# an already UNMUTED topic. This should be case-insensitive.
user_topic_count = UserTopic.objects.count()
data["topic"] = "VERONA3"
with self.assertLogs(level="INFO") as info_logs:
result = self.api_post(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.UNMUTED}",
)
# 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_unmuted_topic(self) -> None:
user = self.example_user("hamlet")
realm = user.realm
self.login_user(user)
stream = get_stream("Verona", realm)
do_set_user_topic_visibility_policy(
user,
stream,
"Verona3",
visibility_policy=UserTopic.VisibilityPolicy.UNMUTED,
last_updated=datetime(2020, 1, 1, tzinfo=timezone.utc),
)
self.assertTrue(
topic_has_visibility_policy(
user, stream.id, "verona3", UserTopic.VisibilityPolicy.UNMUTED
)
)
url = "/api/v1/user_topics"
data = {
"stream_id": stream.id,
"topic": "vEroNA3",
"visibility_policy": UserTopic.VisibilityPolicy.INHERIT,
}
mock_date_unmute_removed = datetime(2020, 1, 1, tzinfo=timezone.utc).timestamp()
events: List[Mapping[str, Any]] = []
with self.tornado_redirected_to_list(events, expected_num_events=2):
with time_machine.travel(datetime(2020, 1, 1, tzinfo=timezone.utc), tick=False):
result = self.api_post(user, url, data)
self.assert_json_success(result)
self.assertFalse(
topic_has_visibility_policy(
user, stream.id, "verona3", UserTopic.VisibilityPolicy.UNMUTED
)
)
# Verify if events are sent properly
user_topic_event: Dict[str, Any] = {
"type": "user_topic",
"stream_id": stream.id,
"topic_name": data["topic"],
"last_updated": mock_date_unmute_removed,
"visibility_policy": UserTopic.VisibilityPolicy.INHERIT,
}
muted_topics_event = dict(type="muted_topics", muted_topics=get_topic_mutes(user))
self.assertEqual(events[0]["event"], muted_topics_event)
self.assertEqual(events[1]["event"], user_topic_event)
# Check that removing UNMUTE from a topic for which the user
# doesn't already have a visibility_policy doesn't cause an error.
with self.assertLogs(level="INFO") as info_logs:
result = self.api_post(user, url, data)
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",
)
def test_unmuted_topic_add_invalid(self) -> None:
user = self.example_user("hamlet")
self.login_user(user)
url = "/api/v1/user_topics"
data = {
"stream_id": 999999999,
"topic": "Verona3",
"visibility_policy": UserTopic.VisibilityPolicy.UNMUTED,
}
result = self.api_post(user, url, data)
self.assert_json_error(result, "Invalid stream ID")
def test_unmuted_topic_remove_invalid(self) -> None:
user = self.example_user("hamlet")
self.login_user(user)
url = "/api/v1/user_topics"
data = {
"stream_id": 999999999,
"topic": "Verona3",
"visibility_policy": UserTopic.VisibilityPolicy.INHERIT,
}
result = self.api_post(user, url, data)
self.assert_json_error(result, "Invalid stream ID")

View File

@ -15,7 +15,7 @@ from zerver.lib.streams import (
access_stream_to_remove_visibility_policy_by_name, access_stream_to_remove_visibility_policy_by_name,
check_for_exactly_one_stream_arg, check_for_exactly_one_stream_arg,
) )
from zerver.lib.validator import check_int, check_string_in from zerver.lib.validator import check_int, check_int_in, check_string_in
from zerver.models import UserProfile, UserTopic from zerver.models import UserProfile, UserTopic
@ -87,3 +87,23 @@ def update_muted_topic(
topic_name=topic, topic_name=topic,
) )
return json_success(request) return json_success(request)
@has_request_variables
def update_user_topic(
request: HttpRequest,
user_profile: UserProfile,
stream_id: int = REQ(json_validator=check_int),
topic: str = REQ(),
visibility_policy: int = REQ(json_validator=check_int_in(UserTopic.VisibilityPolicy.values)),
) -> HttpResponse:
if visibility_policy == UserTopic.VisibilityPolicy.INHERIT:
error = _("Invalid stream ID")
stream = access_stream_to_remove_visibility_policy_by_id(user_profile, stream_id, error)
else:
(stream, sub) = access_stream_by_id(user_profile, stream_id)
do_set_user_topic_visibility_policy(
user_profile, stream, topic, visibility_policy=visibility_policy
)
return json_success(request)

View File

@ -192,7 +192,7 @@ from zerver.views.user_settings import (
regenerate_api_key, regenerate_api_key,
set_avatar_backend, set_avatar_backend,
) )
from zerver.views.user_topics import update_muted_topic from zerver.views.user_topics import update_muted_topic, update_user_topic
from zerver.views.users import ( from zerver.views.users import (
add_bot_backend, add_bot_backend,
avatar, avatar,
@ -476,7 +476,10 @@ v1_api_and_json_patterns = [
DELETE=remove_subscriptions_backend, DELETE=remove_subscriptions_backend,
), ),
# topic-muting -> zerver.views.user_topics # topic-muting -> zerver.views.user_topics
# (deprecated and will be removed once clients are migrated to use '/user_topics')
rest_path("users/me/subscriptions/muted_topics", PATCH=update_muted_topic), rest_path("users/me/subscriptions/muted_topics", PATCH=update_muted_topic),
# used to update the personal preferences for a topic -> zerver.views.user_topics
rest_path("user_topics", POST=update_user_topic),
# user-muting -> zerver.views.user_mutes # user-muting -> zerver.views.user_mutes
rest_path("users/me/muted_users/<int:muted_user_id>", POST=mute_user, DELETE=unmute_user), rest_path("users/me/muted_users/<int:muted_user_id>", POST=mute_user, DELETE=unmute_user),
# used to register for an event queue in tornado # used to register for an event queue in tornado