diff --git a/templates/zerver/api/changelog.md b/templates/zerver/api/changelog.md index fc1ee2886f..9cbf60692d 100644 --- a/templates/zerver/api/changelog.md +++ b/templates/zerver/api/changelog.md @@ -10,6 +10,15 @@ below features are supported. ## Changes in Zulip 4.0 +**Feature level 48** + +* [`POST /users/me/muted_users/{muted_user_id}`](/api/mute-user), + [`DELETE /users/me/muted_users/{muted_user_id}`](/api/unmute-user): + New endpoints added to mute/unmute users. +* [`GET /events`](/api/get-events): Added new event type `muted_users` + which will be sent to a user when the set of users muted by them has + changed. + **Feature level 47** * [`POST /register`](/api/register-queue): Added a new `giphy_api_key` diff --git a/templates/zerver/api/mute-user.md b/templates/zerver/api/mute-user.md new file mode 100644 index 0000000000..5f28e9a9ad --- /dev/null +++ b/templates/zerver/api/mute-user.md @@ -0,0 +1,33 @@ +# Mute a user + +{generate_api_description(/users/me/muted_users/{muted_user_id}:post)} + +## Usage examples + +{start_tabs} +{tab|python} + +{generate_code_example(python)|/users/me/muted_users/{muted_user_id}:post|example} + +{tab|curl} + +{generate_code_example(curl)|/users/me/muted_users/{muted_user_id}:post|example} + +{end_tabs} + +## Parameters + +{generate_api_arguments_table|zulip.yaml|/users/me/muted_users/{muted_user_id}:post} + +## Response + +#### Example response + +A typical successful JSON response may look like: + +{generate_code_example|/users/me/muted_users/{muted_user_id}:post|fixture(200)} + + +An example JSON response for when a user is already muted: + +{generate_code_example|/users/me/muted_users/{muted_user_id}:post|fixture(400)} diff --git a/templates/zerver/api/unmute-user.md b/templates/zerver/api/unmute-user.md new file mode 100644 index 0000000000..7e0706e6c7 --- /dev/null +++ b/templates/zerver/api/unmute-user.md @@ -0,0 +1,32 @@ +# Unmute a user + +{generate_api_description(/users/me/muted_users/{muted_user_id}:delete)} + +## Usage examples + +{start_tabs} +{tab|python} + +{generate_code_example(python)|/users/me/muted_users/{muted_user_id}:delete|example} + +{tab|curl} + +{generate_code_example(curl)|/users/me/muted_users/{muted_user_id}:delete|example} + +{end_tabs} + +## Parameters + +{generate_api_arguments_table|zulip.yaml|/users/me/muted_users/{muted_user_id}:delete} + +## Response + +#### Example response + +A typical successful JSON response may look like: + +{generate_code_example|/users/me/muted_users/{muted_user_id}:delete|fixture(200)} + +An example JSON response for when a user is not previously muted: + +{generate_code_example|/users/me/muted_users/{muted_user_id}:delete|fixture(400)} diff --git a/templates/zerver/help/include/rest-endpoints.md b/templates/zerver/help/include/rest-endpoints.md index dbbf5e8972..c6c9df2f35 100644 --- a/templates/zerver/help/include/rest-endpoints.md +++ b/templates/zerver/help/include/rest-endpoints.md @@ -50,6 +50,8 @@ * [Update a user group](/api/update-user-group) * [Delete a user group](/api/remove-user-group) * [Update user group members](/api/update-user-group-members) +* [Mute a user](/api/mute-user) +* [Unmute a user](/api/unmute-user) #### Server & organizations diff --git a/version.py b/version.py index f6c09a7987..5b295da704 100644 --- a/version.py +++ b/version.py @@ -30,7 +30,7 @@ DESKTOP_WARNING_VERSION = "5.2.0" # # Changes should be accompanied by documentation explaining what the # new level means in templates/zerver/api/changelog.md. -API_FEATURE_LEVEL = 47 +API_FEATURE_LEVEL = 48 # 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 diff --git a/zerver/lib/actions.py b/zerver/lib/actions.py index d12057c8df..db1aa84feb 100644 --- a/zerver/lib/actions.py +++ b/zerver/lib/actions.py @@ -167,6 +167,7 @@ from zerver.lib.upload import ( upload_emoji_image, ) from zerver.lib.user_groups import access_user_group_by_id, create_user_group +from zerver.lib.user_mutes import add_user_mute, get_user_mutes, remove_user_mute from zerver.lib.user_status import update_user_status from zerver.lib.users import ( check_bot_name_available, @@ -6486,6 +6487,24 @@ def do_unmute_topic(user_profile: UserProfile, stream: Stream, topic: str) -> No send_event(user_profile.realm, event, [user_profile.id]) +def do_mute_user( + user_profile: UserProfile, + muted_user: UserProfile, + date_muted: Optional[datetime.datetime] = None, +) -> None: + if date_muted is None: + date_muted = timezone_now() + add_user_mute(user_profile, muted_user, date_muted) + event = dict(type="muted_users", muted_users=get_user_mutes(user_profile)) + send_event(user_profile.realm, event, [user_profile.id]) + + +def do_unmute_user(user_profile: UserProfile, muted_user: UserProfile) -> None: + remove_user_mute(user_profile, muted_user) + event = dict(type="muted_users", muted_users=get_user_mutes(user_profile)) + send_event(user_profile.realm, event, [user_profile.id]) + + def do_mark_hotspot_as_read(user: UserProfile, hotspot: str) -> None: UserHotspot.objects.get_or_create(user=user, hotspot=hotspot) event = dict(type="hotspots", hotspots=get_next_hotspots(user)) diff --git a/zerver/lib/event_schema.py b/zerver/lib/event_schema.py index 96f79c8dbc..1434b22333 100644 --- a/zerver/lib/event_schema.py +++ b/zerver/lib/event_schema.py @@ -337,6 +337,21 @@ muted_topics_event = event_dict_type( ) check_muted_topics = make_checker(muted_topics_event) +muted_user_type = DictType( + required_keys=[ + ("id", int), + ("timestamp", int), + ] +) + +muted_users_event = event_dict_type( + required_keys=[ + ("type", Equals("muted_users")), + ("muted_users", ListType(muted_user_type)), + ] +) +check_muted_users = make_checker(muted_users_event) + _check_topic_links = DictType( required_keys=[ ("text", str), diff --git a/zerver/lib/events.py b/zerver/lib/events.py index 83f6dfd647..cfaf7ac9a2 100644 --- a/zerver/lib/events.py +++ b/zerver/lib/events.py @@ -45,6 +45,7 @@ from zerver.lib.stream_subscription import handle_stream_notifications_compatibi from zerver.lib.topic import TOPIC_NAME from zerver.lib.topic_mutes import get_topic_mutes from zerver.lib.user_groups import user_groups_in_realm_serialized +from zerver.lib.user_mutes import get_user_mutes from zerver.lib.user_status import get_user_info_dict from zerver.lib.users import get_cross_realm_dicts, get_raw_user_data, is_administrator_role from zerver.models import ( @@ -159,6 +160,9 @@ def fetch_initial_state_data( if want("muted_topics"): state["muted_topics"] = [] if user_profile is None else get_topic_mutes(user_profile) + if want("muted_users"): + state["muted_users"] = [] if user_profile is None else get_user_mutes(user_profile) + if want("presence"): state["presences"] = ( {} if user_profile is None else get_presences_for_realm(realm, slim_presence) @@ -969,6 +973,8 @@ def apply_event( state["alert_words"] = event["alert_words"] elif event["type"] == "muted_topics": state["muted_topics"] = event["muted_topics"] + elif event["type"] == "muted_users": + state["muted_users"] = event["muted_users"] elif event["type"] == "realm_filters": state["realm_filters"] = event["realm_filters"] elif event["type"] == "update_display_settings": diff --git a/zerver/lib/export.py b/zerver/lib/export.py index 8b3be2d9fa..e00aaf7b7f 100644 --- a/zerver/lib/export.py +++ b/zerver/lib/export.py @@ -160,6 +160,7 @@ ALL_ZULIP_TABLES = { "zerver_userprofile_user_permissions", "zerver_userstatus", "zerver_mutedtopic", + "zerver_muteduser", } # This set contains those database tables that we expect to not be @@ -223,6 +224,7 @@ NON_EXPORTED_TABLES = { # export before they reach full production status. "zerver_defaultstreamgroup", "zerver_defaultstreamgroup_streams", + "zerver_muteduser", "zerver_submessage", # This is low priority, since users can easily just reset themselves to away. "zerver_userstatus", diff --git a/zerver/lib/import_realm.py b/zerver/lib/import_realm.py index 6d01f0be36..3ebf88ae7f 100644 --- a/zerver/lib/import_realm.py +++ b/zerver/lib/import_realm.py @@ -45,6 +45,7 @@ from zerver.models import ( Huddle, Message, MutedTopic, + MutedUser, Reaction, Realm, RealmAuditLog, @@ -111,6 +112,7 @@ ID_MAP: Dict[str, Dict[int, int]] = { "recipient_to_huddle_map": {}, "userhotspot": {}, "mutedtopic": {}, + "muteduser": {}, "service": {}, "usergroup": {}, "usergroupmembership": {}, @@ -1068,6 +1070,13 @@ def do_import_realm(import_dir: Path, subdomain: str, processes: int = 1) -> Rea update_model_ids(MutedTopic, data, "mutedtopic") bulk_import_model(data, MutedTopic) + if "zerver_muteduser" in data: + fix_datetime_fields(data, "zerver_muteduser") + re_map_foreign_keys(data, "zerver_muteduser", "user_profile", related_table="user_profile") + re_map_foreign_keys(data, "zerver_muteduser", "muted_user", related_table="user_profile") + update_model_ids(MutedUser, data, "muteduser") + bulk_import_model(data, MutedUser) + if "zerver_service" in data: re_map_foreign_keys(data, "zerver_service", "user_profile", related_table="user_profile") fix_service_tokens(data, "zerver_service") diff --git a/zerver/lib/user_mutes.py b/zerver/lib/user_mutes.py new file mode 100644 index 0000000000..d93555d20d --- /dev/null +++ b/zerver/lib/user_mutes.py @@ -0,0 +1,43 @@ +import datetime +from typing import Dict, List, Optional + +from django.utils.timezone import now as timezone_now + +from zerver.lib.timestamp import datetime_to_timestamp +from zerver.models import MutedUser, UserProfile + + +def get_user_mutes(user_profile: UserProfile) -> List[Dict[str, int]]: + rows = MutedUser.objects.filter(user_profile=user_profile).values( + "muted_user__id", + "date_muted", + ) + return [ + { + "id": row["muted_user__id"], + "timestamp": datetime_to_timestamp(row["date_muted"]), + } + for row in rows + ] + + +def add_user_mute( + user_profile: UserProfile, + muted_user: UserProfile, + date_muted: Optional[datetime.datetime] = None, +) -> None: + if date_muted is None: + date_muted = timezone_now() + MutedUser.objects.create( + user_profile=user_profile, + muted_user=muted_user, + date_muted=date_muted, + ) + + +def remove_user_mute(user_profile: UserProfile, muted_user: UserProfile) -> None: + MutedUser.objects.get(user_profile=user_profile, muted_user=muted_user).delete() + + +def user_is_muted(user_profile: UserProfile, muted_user: UserProfile) -> bool: + return MutedUser.objects.filter(user_profile=user_profile, muted_user=muted_user).exists() diff --git a/zerver/migrations/0314_muted_user.py b/zerver/migrations/0314_muted_user.py index 67048f6141..2b5cf68016 100644 --- a/zerver/migrations/0314_muted_user.py +++ b/zerver/migrations/0314_muted_user.py @@ -24,7 +24,7 @@ class Migration(migrations.Migration): ), ("date_muted", models.DateTimeField(default=django.utils.timezone.now)), ( - "muted_user_profile", + "muted_user", models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, related_name="+", @@ -41,7 +41,7 @@ class Migration(migrations.Migration): ), ], options={ - "unique_together": {("user_profile", "muted_user_profile")}, + "unique_together": {("user_profile", "muted_user")}, }, ), ] diff --git a/zerver/models.py b/zerver/models.py index ca57f32ca8..f12dae4a2b 100644 --- a/zerver/models.py +++ b/zerver/models.py @@ -1847,14 +1847,14 @@ class MutedTopic(models.Model): class MutedUser(models.Model): user_profile = models.ForeignKey(UserProfile, related_name="+", on_delete=CASCADE) - muted_user_profile = models.ForeignKey(UserProfile, related_name="+", on_delete=CASCADE) + muted_user = models.ForeignKey(UserProfile, related_name="+", on_delete=CASCADE) date_muted: datetime.datetime = models.DateTimeField(default=timezone_now) class Meta: - unique_together = ("user_profile", "muted_user_profile") + unique_together = ("user_profile", "muted_user") def __str__(self) -> str: - return f" {self.muted_user_profile.email}>" + return f" {self.muted_user.email}>" class Client(models.Model): diff --git a/zerver/openapi/python_examples.py b/zerver/openapi/python_examples.py index d27ff10755..126d363d57 100644 --- a/zerver/openapi/python_examples.py +++ b/zerver/openapi/python_examples.py @@ -601,6 +601,32 @@ def toggle_mute_topic(client: Client) -> None: validate_against_openapi_schema(result, "/users/me/subscriptions/muted_topics", "patch", "200") +@openapi_test_function("/users/me/muted_users/{muted_user_id}:post") +def add_user_mute(client: Client) -> None: + ensure_users([10], ["hamlet"]) + # {code_example|start} + # Mute user with ID 10 + muted_user_id = 10 + result = client.call_endpoint(url=f"/users/me/muted_users/{muted_user_id}", method="POST") + # {code_example|end} + + validate_against_openapi_schema(result, "/users/me/muted_users/{muted_user_id}", "post", "200") + + +@openapi_test_function("/users/me/muted_users/{muted_user_id}:delete") +def remove_user_mute(client: Client) -> None: + ensure_users([10], ["hamlet"]) + # {code_example|start} + # Unmute user with ID 10 + muted_user_id = 10 + result = client.call_endpoint(url=f"/users/me/muted_users/{muted_user_id}", method="DELETE") + # {code_example|end} + + validate_against_openapi_schema( + result, "/users/me/muted_users/{muted_user_id}", "delete", "200" + ) + + @openapi_test_function("/mark_all_as_read:post") def mark_all_as_read(client: Client) -> None: @@ -1352,6 +1378,8 @@ def test_users(client: Client, owner_client: Client) -> None: add_alert_words(client) remove_alert_words(client) deactivate_own_user(client, owner_client) + add_user_mute(client) + remove_user_mute(client) def test_streams(client: Client, nonadmin_client: Client) -> None: diff --git a/zerver/openapi/zulip.yaml b/zerver/openapi/zulip.yaml index 83cff00dfa..bb68e9809f 100644 --- a/zerver/openapi/zulip.yaml +++ b/zerver/openapi/zulip.yaml @@ -1696,6 +1696,50 @@ paths: [["Denmark", "topic", 1594825442]], "id": 0, } + - type: object + description: | + Event sent to a user's clients when that user's set of + configured muted users have changed. + + **Changes**: New in Zulip 4.0 (feature level 48). + properties: + id: + $ref: "#/components/schemas/EventIdSchema" + type: + allOf: + - $ref: "#/components/schemas/EventTypeSchema" + - enum: + - muted_users + muted_users: + type: array + description: | + A list of dictionaries where each dictionary describes + a muted user. + items: + type: object + additionalProperties: false + description: | + Object containing the user id and timestamp of a muted user. + properties: + id: + type: integer + description: | + The ID of the muted user. + timestamp: + type: integer + description: | + An integer UNIX timestamp representing when the user was muted. + additionalProperties: false + example: + { + "type": "muted_users", + "muted_users": + [ + {"id": 1, "timestamp": 1594825442}, + {"id": 22, "timestamp": 1654865392}, + ], + "id": 0, + } - type: object additionalProperties: false description: | @@ -5590,6 +5634,77 @@ paths: - $ref: "#/components/schemas/JsonError" - example: {"msg": "Topic is not muted", "result": "error"} + /users/me/muted_users/{muted_user_id}: + post: + operationId: mute_user + tags: ["users"] + description: | + This endpoint mutes a user. Messages sent by users you've muted will + be automatically marked as read and hidden. + + `POST {{ api_url }}/v1/users/me/muted_users/{muted_user_id}` + + **Changes**: New in Zulip 4.0 (feature level 48). + parameters: + - $ref: "#/components/parameters/MutedUserId" + responses: + "200": + description: Success. + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/JsonSuccess" + - example: {"msg": "", "result": "success"} + "400": + description: Bad request. + content: + application/json: + schema: + oneOf: + - allOf: + - $ref: "#/components/schemas/JsonError" + - example: {"msg": "Cannot mute self", "result": "error"} + - allOf: + - $ref: "#/components/schemas/JsonError" + - example: {"msg": "No such user", "result": "error"} + - allOf: + - $ref: "#/components/schemas/JsonError" + - example: + {"msg": "User already muted", "result": "error"} + delete: + operationId: unmute_user + tags: ["users"] + description: | + This endpoint unmutes a user. + + `DELETE {{ api_url }}/v1/users/me/muted_users/{muted_user_id}` + + **Changes**: New in Zulip 4.0 (feature level 48). + parameters: + - $ref: "#/components/parameters/MutedUserId" + responses: + "200": + description: Success. + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/JsonSuccess" + - example: {"msg": "", "result": "success"} + "400": + description: Bad request. + content: + application/json: + schema: + oneOf: + - allOf: + - $ref: "#/components/schemas/JsonError" + - example: {"msg": "No such user", "result": "error"} + - allOf: + - $ref: "#/components/schemas/JsonError" + - example: {"msg": "User is not muted", "result": "error"} + /users/{user_id}/subscriptions/{stream_id}: get: operationId: get_subscription_status @@ -6660,6 +6775,29 @@ paths: oneOf: - type: string - type: integer + muted_users: + type: array + description: | + Present if `muted_users` is present in `fetch_event_types`. + + A list of dictionaries where each dictionary describes + a muted user. + + **Changes**: New in Zulip 4.0 (feature level 48). + items: + type: object + additionalProperties: false + description: | + Object containing the user id and timestamp of a muted user. + properties: + id: + type: integer + description: | + The ID of the muted user. + timestamp: + type: integer + description: | + An integer UNIX timestamp representing when the user was muted. presences: type: object description: | @@ -11059,6 +11197,15 @@ components: type: integer example: 12 required: true + MutedUserId: + name: muted_user_id + in: path + description: | + The ID of the user to mute/un-mute. + schema: + type: integer + example: 10 + required: true StreamPostPolicy: name: stream_post_policy in: query diff --git a/zerver/tests/test_event_system.py b/zerver/tests/test_event_system.py index 4261f35918..74d164afbb 100644 --- a/zerver/tests/test_event_system.py +++ b/zerver/tests/test_event_system.py @@ -852,7 +852,7 @@ class FetchQueriesTest(ZulipTestCase): with mock.patch("zerver.lib.events.always_want") as want_mock: fetch_initial_state_data(user) - self.assert_length(queries, 29) + self.assert_length(queries, 30) expected_counts = dict( alert_words=1, @@ -862,6 +862,7 @@ class FetchQueriesTest(ZulipTestCase): hotspots=0, message=1, muted_topics=1, + muted_users=1, presence=1, realm=0, realm_bot=1, diff --git a/zerver/tests/test_events.py b/zerver/tests/test_events.py index a67ba4bbe2..a82437207a 100644 --- a/zerver/tests/test_events.py +++ b/zerver/tests/test_events.py @@ -59,6 +59,7 @@ from zerver.lib.actions import ( do_invite_users, do_mark_hotspot_as_read, do_mute_topic, + do_mute_user, do_reactivate_user, do_regenerate_api_key, do_remove_alert_words, @@ -81,6 +82,7 @@ from zerver.lib.actions import ( do_set_user_display_setting, do_set_zoom_token, do_unmute_topic, + do_unmute_user, do_update_embedded_data, do_update_message, do_update_message_flags, @@ -109,6 +111,7 @@ from zerver.lib.event_schema import ( check_invites_changed, check_message, check_muted_topics, + check_muted_users, check_presence, check_reaction_add, check_reaction_remove, @@ -1000,6 +1003,14 @@ class NormalActionsTest(BaseAction): events = self.verify_action(lambda: do_unmute_topic(self.user_profile, stream, "topic")) check_muted_topics("events[0]", events[0]) + def test_muted_users_events(self) -> None: + muted_user = self.example_user("othello") + events = self.verify_action(lambda: do_mute_user(self.user_profile, muted_user)) + check_muted_users("events[0]", events[0]) + + events = self.verify_action(lambda: do_unmute_user(self.user_profile, muted_user)) + check_muted_users("events[0]", events[0]) + def test_change_avatar_fields(self) -> None: events = self.verify_action( lambda: do_change_avatar_fields( diff --git a/zerver/tests/test_home.py b/zerver/tests/test_home.py index 02658b81ec..bc73f3fa5f 100644 --- a/zerver/tests/test_home.py +++ b/zerver/tests/test_home.py @@ -107,6 +107,7 @@ class HomeTest(ZulipTestCase): "max_message_id", "message_content_in_email_notifications", "muted_topics", + "muted_users", "narrow", "narrow_stream", "needs_tutorial", @@ -261,7 +262,7 @@ class HomeTest(ZulipTestCase): set(result["Cache-Control"].split(", ")), {"must-revalidate", "no-store", "no-cache"} ) - self.assert_length(queries, 39) + self.assert_length(queries, 40) self.assert_length(cache_mock.call_args_list, 5) html = result.content.decode("utf-8") @@ -341,7 +342,7 @@ class HomeTest(ZulipTestCase): result = self._get_home_page() self.check_rendered_logged_in_app(result) self.assert_length(cache_mock.call_args_list, 6) - self.assert_length(queries, 36) + self.assert_length(queries, 37) def test_num_queries_with_streams(self) -> None: main_user = self.example_user("hamlet") @@ -372,7 +373,7 @@ class HomeTest(ZulipTestCase): with queries_captured() as queries2: result = self._get_home_page() - self.assert_length(queries2, 34) + self.assert_length(queries2, 35) # Do a sanity check that our new streams were in the payload. html = result.content.decode("utf-8") diff --git a/zerver/tests/test_muting_users.py b/zerver/tests/test_muting_users.py new file mode 100644 index 0000000000..fbe28f50bc --- /dev/null +++ b/zerver/tests/test_muting_users.py @@ -0,0 +1,123 @@ +from datetime import datetime, timezone +from unittest import mock + +from zerver.lib.test_classes import ZulipTestCase +from zerver.lib.timestamp import datetime_to_timestamp +from zerver.lib.user_mutes import add_user_mute, get_user_mutes, user_is_muted + + +class MutedUsersTests(ZulipTestCase): + def test_get_user_mutes(self) -> None: + othello = self.example_user("othello") + cordelia = self.example_user("cordelia") + + muted_users = get_user_mutes(othello) + self.assertEqual(muted_users, []) + mute_time = datetime(2021, 1, 1, tzinfo=timezone.utc) + + with mock.patch( + "zerver.lib.user_mutes.timezone_now", + return_value=mute_time, + ): + add_user_mute(user_profile=othello, muted_user=cordelia) + + muted_users = get_user_mutes(othello) + self.assertEqual(len(muted_users), 1) + + self.assertDictEqual( + muted_users[0], + { + "id": cordelia.id, + "timestamp": datetime_to_timestamp(mute_time), + }, + ) + + def test_add_muted_user_mute_self(self) -> None: + user = self.example_user("hamlet") + self.login_user(user) + + url = "/api/v1/users/me/muted_users/{}".format(user.id) + result = self.api_post(user, url) + self.assert_json_error(result, "Cannot mute self") + + def test_add_muted_user_mute_bot(self) -> None: + user = self.example_user("hamlet") + self.login_user(user) + + bot_info = { + "full_name": "The Bot of Hamlet", + "short_name": "hambot", + "bot_type": "1", + } + result = self.client_post("/json/bots", bot_info) + self.assert_json_success(result) + muted_id = result.json()["user_id"] + + url = "/api/v1/users/me/muted_users/{}".format(muted_id) + result = self.api_post(user, url) + # Currently we do not allow muting bots. This is the error message + # from `access_user_by_id`. + self.assert_json_error(result, "No such user") + + def test_add_muted_user_mute_twice(self) -> None: + user = self.example_user("hamlet") + self.login_user(user) + muted_user = self.example_user("cordelia") + muted_id = muted_user.id + + add_user_mute( + user_profile=user, + muted_user=muted_user, + ) + + url = "/api/v1/users/me/muted_users/{}".format(muted_id) + result = self.api_post(user, url) + self.assert_json_error(result, "User already muted") + + def test_add_muted_user_valid_data(self) -> None: + user = self.example_user("hamlet") + self.login_user(user) + muted_user = self.example_user("cordelia") + muted_id = muted_user.id + + mock_date_muted = datetime(2021, 1, 1, tzinfo=timezone.utc).timestamp() + with mock.patch( + "zerver.views.muting.timezone_now", + return_value=datetime(2021, 1, 1, tzinfo=timezone.utc), + ): + url = "/api/v1/users/me/muted_users/{}".format(muted_id) + result = self.api_post(user, url) + self.assert_json_success(result) + + self.assertIn({"id": muted_id, "timestamp": mock_date_muted}, get_user_mutes(user)) + self.assertTrue(user_is_muted(user, muted_user)) + + def test_remove_muted_user_unmute_before_muting(self) -> None: + user = self.example_user("hamlet") + self.login_user(user) + muted_user = self.example_user("cordelia") + muted_id = muted_user.id + + url = "/api/v1/users/me/muted_users/{}".format(muted_id) + result = self.api_delete(user, url) + self.assert_json_error(result, "User is not muted") + + def test_remove_muted_user_valid_data(self) -> None: + user = self.example_user("hamlet") + self.login_user(user) + muted_user = self.example_user("cordelia") + muted_id = muted_user.id + + add_user_mute( + user_profile=user, + muted_user=muted_user, + date_muted=datetime(2021, 1, 1, tzinfo=timezone.utc), + ) + + url = "/api/v1/users/me/muted_users/{}".format(muted_id) + result = self.api_delete(user, url) + + mock_date_muted = datetime(2021, 1, 1, tzinfo=timezone.utc).timestamp() + self.assert_json_success(result) + self.assertNotIn({"id": muted_id, "timestamp": mock_date_muted}, get_user_mutes(user)) + self.assertFalse(user_is_muted(user, muted_user)) diff --git a/zerver/views/muting.py b/zerver/views/muting.py index 9832412336..0c62b3591e 100644 --- a/zerver/views/muting.py +++ b/zerver/views/muting.py @@ -5,7 +5,7 @@ from django.http import HttpRequest, HttpResponse from django.utils.timezone import now as timezone_now from django.utils.translation import ugettext as _ -from zerver.lib.actions import do_mute_topic, do_unmute_topic +from zerver.lib.actions import do_mute_topic, do_mute_user, do_unmute_topic, do_unmute_user from zerver.lib.request import REQ, has_request_variables from zerver.lib.response import json_error, json_success from zerver.lib.streams import ( @@ -16,6 +16,8 @@ from zerver.lib.streams import ( check_for_exactly_one_stream_arg, ) from zerver.lib.topic_mutes import topic_is_muted +from zerver.lib.user_mutes import user_is_muted +from zerver.lib.users import access_user_by_id from zerver.lib.validator import check_int from zerver.models import UserProfile @@ -85,3 +87,29 @@ def update_muted_topic( stream_name=stream, topic_name=topic, ) + + +def mute_user(request: HttpRequest, user_profile: UserProfile, muted_user_id: int) -> HttpResponse: + if user_profile.id == muted_user_id: + return json_error(_("Cannot mute self")) + + muted_user = access_user_by_id(user_profile, muted_user_id, allow_bots=False, for_admin=False) + date_muted = timezone_now() + + if user_is_muted(user_profile, muted_user): + return json_error(_("User already muted")) + + do_mute_user(user_profile, muted_user, date_muted) + return json_success() + + +def unmute_user( + request: HttpRequest, user_profile: UserProfile, muted_user_id: int +) -> HttpResponse: + muted_user = access_user_by_id(user_profile, muted_user_id, allow_bots=False, for_admin=False) + + if not user_is_muted(user_profile, muted_user): + return json_error(_("User is not muted")) + + do_unmute_user(user_profile, muted_user) + return json_success() diff --git a/zproject/urls.py b/zproject/urls.py index 3bdeebe7c0..78a6006190 100644 --- a/zproject/urls.py +++ b/zproject/urls.py @@ -80,7 +80,7 @@ from zerver.views.message_flags import ( update_message_flags, ) from zerver.views.message_send import render_message_backend, send_message_backend, zcommand_backend -from zerver.views.muting import update_muted_topic +from zerver.views.muting import mute_user, unmute_user, update_muted_topic from zerver.views.portico import ( app_download_link_redirect, apps_view, @@ -462,6 +462,7 @@ v1_api_and_json_patterns = [ ), # muting -> zerver.views.muting rest_path("users/me/subscriptions/muted_topics", PATCH=update_muted_topic), + rest_path("users/me/muted_users/", POST=mute_user, DELETE=unmute_user), # used to register for an event queue in tornado rest_path("register", POST=events_register_backend), # events -> zerver.tornado.views