From 3bfcaa39689c9996cb7eb0ec65bd37f727074f6e Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Bodas Date: Sat, 27 Mar 2021 16:53:32 +0530 Subject: [PATCH] mute user: Add backend infrastructure code. Adds backend code for the mute users feature. This is just infrastructure work (database interactions, helpers, tests, events, API docs etc) and does not involve any behavioral/semantic aspects of muted users. Adds POST and DELETE endpoints, to keep the URL scheme mostly consistent in terms of `users/me`. TODOs: 1. Add tests for exporting `zulip_muteduser` database table. 2. Add dedicated methods to python-zulip-api to be used in place of the current `client.call_endpoint` implementation. --- templates/zerver/api/changelog.md | 9 ++ templates/zerver/api/mute-user.md | 33 ++++ templates/zerver/api/unmute-user.md | 32 ++++ .../zerver/help/include/rest-endpoints.md | 2 + version.py | 2 +- zerver/lib/actions.py | 19 +++ zerver/lib/event_schema.py | 15 ++ zerver/lib/events.py | 6 + zerver/lib/export.py | 2 + zerver/lib/import_realm.py | 9 ++ zerver/lib/user_mutes.py | 43 +++++ zerver/migrations/0314_muted_user.py | 4 +- zerver/models.py | 6 +- zerver/openapi/python_examples.py | 28 ++++ zerver/openapi/zulip.yaml | 147 ++++++++++++++++++ zerver/tests/test_event_system.py | 3 +- zerver/tests/test_events.py | 11 ++ zerver/tests/test_home.py | 7 +- zerver/tests/test_muting_users.py | 123 +++++++++++++++ zerver/views/muting.py | 30 +++- zproject/urls.py | 3 +- 21 files changed, 522 insertions(+), 12 deletions(-) create mode 100644 templates/zerver/api/mute-user.md create mode 100644 templates/zerver/api/unmute-user.md create mode 100644 zerver/lib/user_mutes.py create mode 100644 zerver/tests/test_muting_users.py 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