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.
This commit is contained in:
Abhijeet Prasad Bodas 2021-03-27 16:53:32 +05:30 committed by Tim Abbott
parent 89f6139505
commit 3bfcaa3968
21 changed files with 522 additions and 12 deletions

View File

@ -10,6 +10,15 @@ below features are supported.
## Changes in Zulip 4.0 ## 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** **Feature level 47**
* [`POST /register`](/api/register-queue): Added a new `giphy_api_key` * [`POST /register`](/api/register-queue): Added a new `giphy_api_key`

View File

@ -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)}

View File

@ -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)}

View File

@ -50,6 +50,8 @@
* [Update a user group](/api/update-user-group) * [Update a user group](/api/update-user-group)
* [Delete a user group](/api/remove-user-group) * [Delete a user group](/api/remove-user-group)
* [Update user group members](/api/update-user-group-members) * [Update user group members](/api/update-user-group-members)
* [Mute a user](/api/mute-user)
* [Unmute a user](/api/unmute-user)
#### Server & organizations #### Server & organizations

View File

@ -30,7 +30,7 @@ DESKTOP_WARNING_VERSION = "5.2.0"
# #
# Changes should be accompanied by documentation explaining what the # Changes should be accompanied by documentation explaining what the
# new level means in templates/zerver/api/changelog.md. # 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 # 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

@ -167,6 +167,7 @@ from zerver.lib.upload import (
upload_emoji_image, upload_emoji_image,
) )
from zerver.lib.user_groups import access_user_group_by_id, create_user_group 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.user_status import update_user_status
from zerver.lib.users import ( from zerver.lib.users import (
check_bot_name_available, 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]) 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: def do_mark_hotspot_as_read(user: UserProfile, hotspot: str) -> None:
UserHotspot.objects.get_or_create(user=user, hotspot=hotspot) UserHotspot.objects.get_or_create(user=user, hotspot=hotspot)
event = dict(type="hotspots", hotspots=get_next_hotspots(user)) event = dict(type="hotspots", hotspots=get_next_hotspots(user))

View File

@ -337,6 +337,21 @@ muted_topics_event = event_dict_type(
) )
check_muted_topics = make_checker(muted_topics_event) 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( _check_topic_links = DictType(
required_keys=[ required_keys=[
("text", str), ("text", str),

View File

@ -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 import TOPIC_NAME
from zerver.lib.topic_mutes import get_topic_mutes from zerver.lib.topic_mutes import get_topic_mutes
from zerver.lib.user_groups import user_groups_in_realm_serialized 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.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.lib.users import get_cross_realm_dicts, get_raw_user_data, is_administrator_role
from zerver.models import ( from zerver.models import (
@ -159,6 +160,9 @@ def fetch_initial_state_data(
if want("muted_topics"): if want("muted_topics"):
state["muted_topics"] = [] if user_profile is None else get_topic_mutes(user_profile) 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"): if want("presence"):
state["presences"] = ( state["presences"] = (
{} if user_profile is None else get_presences_for_realm(realm, slim_presence) {} 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"] state["alert_words"] = event["alert_words"]
elif event["type"] == "muted_topics": elif event["type"] == "muted_topics":
state["muted_topics"] = event["muted_topics"] state["muted_topics"] = event["muted_topics"]
elif event["type"] == "muted_users":
state["muted_users"] = event["muted_users"]
elif event["type"] == "realm_filters": elif event["type"] == "realm_filters":
state["realm_filters"] = event["realm_filters"] state["realm_filters"] = event["realm_filters"]
elif event["type"] == "update_display_settings": elif event["type"] == "update_display_settings":

View File

@ -160,6 +160,7 @@ ALL_ZULIP_TABLES = {
"zerver_userprofile_user_permissions", "zerver_userprofile_user_permissions",
"zerver_userstatus", "zerver_userstatus",
"zerver_mutedtopic", "zerver_mutedtopic",
"zerver_muteduser",
} }
# This set contains those database tables that we expect to not be # 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. # export before they reach full production status.
"zerver_defaultstreamgroup", "zerver_defaultstreamgroup",
"zerver_defaultstreamgroup_streams", "zerver_defaultstreamgroup_streams",
"zerver_muteduser",
"zerver_submessage", "zerver_submessage",
# This is low priority, since users can easily just reset themselves to away. # This is low priority, since users can easily just reset themselves to away.
"zerver_userstatus", "zerver_userstatus",

View File

@ -45,6 +45,7 @@ from zerver.models import (
Huddle, Huddle,
Message, Message,
MutedTopic, MutedTopic,
MutedUser,
Reaction, Reaction,
Realm, Realm,
RealmAuditLog, RealmAuditLog,
@ -111,6 +112,7 @@ ID_MAP: Dict[str, Dict[int, int]] = {
"recipient_to_huddle_map": {}, "recipient_to_huddle_map": {},
"userhotspot": {}, "userhotspot": {},
"mutedtopic": {}, "mutedtopic": {},
"muteduser": {},
"service": {}, "service": {},
"usergroup": {}, "usergroup": {},
"usergroupmembership": {}, "usergroupmembership": {},
@ -1068,6 +1070,13 @@ def do_import_realm(import_dir: Path, subdomain: str, processes: int = 1) -> Rea
update_model_ids(MutedTopic, data, "mutedtopic") update_model_ids(MutedTopic, data, "mutedtopic")
bulk_import_model(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: if "zerver_service" in data:
re_map_foreign_keys(data, "zerver_service", "user_profile", related_table="user_profile") re_map_foreign_keys(data, "zerver_service", "user_profile", related_table="user_profile")
fix_service_tokens(data, "zerver_service") fix_service_tokens(data, "zerver_service")

43
zerver/lib/user_mutes.py Normal file
View File

@ -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()

View File

@ -24,7 +24,7 @@ class Migration(migrations.Migration):
), ),
("date_muted", models.DateTimeField(default=django.utils.timezone.now)), ("date_muted", models.DateTimeField(default=django.utils.timezone.now)),
( (
"muted_user_profile", "muted_user",
models.ForeignKey( models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, on_delete=django.db.models.deletion.CASCADE,
related_name="+", related_name="+",
@ -41,7 +41,7 @@ class Migration(migrations.Migration):
), ),
], ],
options={ options={
"unique_together": {("user_profile", "muted_user_profile")}, "unique_together": {("user_profile", "muted_user")},
}, },
), ),
] ]

View File

@ -1847,14 +1847,14 @@ class MutedTopic(models.Model):
class MutedUser(models.Model): class MutedUser(models.Model):
user_profile = models.ForeignKey(UserProfile, related_name="+", on_delete=CASCADE) 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) date_muted: datetime.datetime = models.DateTimeField(default=timezone_now)
class Meta: class Meta:
unique_together = ("user_profile", "muted_user_profile") unique_together = ("user_profile", "muted_user")
def __str__(self) -> str: def __str__(self) -> str:
return f"<MutedUser: {self.user_profile.email} -> {self.muted_user_profile.email}>" return f"<MutedUser: {self.user_profile.email} -> {self.muted_user.email}>"
class Client(models.Model): class Client(models.Model):

View File

@ -601,6 +601,32 @@ 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("/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") @openapi_test_function("/mark_all_as_read:post")
def mark_all_as_read(client: Client) -> None: 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) add_alert_words(client)
remove_alert_words(client) remove_alert_words(client)
deactivate_own_user(client, owner_client) deactivate_own_user(client, owner_client)
add_user_mute(client)
remove_user_mute(client)
def test_streams(client: Client, nonadmin_client: Client) -> None: def test_streams(client: Client, nonadmin_client: Client) -> None:

View File

@ -1696,6 +1696,50 @@ paths:
[["Denmark", "topic", 1594825442]], [["Denmark", "topic", 1594825442]],
"id": 0, "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 - type: object
additionalProperties: false additionalProperties: false
description: | description: |
@ -5590,6 +5634,77 @@ paths:
- $ref: "#/components/schemas/JsonError" - $ref: "#/components/schemas/JsonError"
- example: - example:
{"msg": "Topic is not muted", "result": "error"} {"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}: /users/{user_id}/subscriptions/{stream_id}:
get: get:
operationId: get_subscription_status operationId: get_subscription_status
@ -6660,6 +6775,29 @@ paths:
oneOf: oneOf:
- type: string - type: string
- type: integer - 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: presences:
type: object type: object
description: | description: |
@ -11059,6 +11197,15 @@ components:
type: integer type: integer
example: 12 example: 12
required: true 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: StreamPostPolicy:
name: stream_post_policy name: stream_post_policy
in: query in: query

View File

@ -852,7 +852,7 @@ class FetchQueriesTest(ZulipTestCase):
with mock.patch("zerver.lib.events.always_want") as want_mock: with mock.patch("zerver.lib.events.always_want") as want_mock:
fetch_initial_state_data(user) fetch_initial_state_data(user)
self.assert_length(queries, 29) self.assert_length(queries, 30)
expected_counts = dict( expected_counts = dict(
alert_words=1, alert_words=1,
@ -862,6 +862,7 @@ class FetchQueriesTest(ZulipTestCase):
hotspots=0, hotspots=0,
message=1, message=1,
muted_topics=1, muted_topics=1,
muted_users=1,
presence=1, presence=1,
realm=0, realm=0,
realm_bot=1, realm_bot=1,

View File

@ -59,6 +59,7 @@ from zerver.lib.actions import (
do_invite_users, do_invite_users,
do_mark_hotspot_as_read, do_mark_hotspot_as_read,
do_mute_topic, do_mute_topic,
do_mute_user,
do_reactivate_user, do_reactivate_user,
do_regenerate_api_key, do_regenerate_api_key,
do_remove_alert_words, do_remove_alert_words,
@ -81,6 +82,7 @@ from zerver.lib.actions import (
do_set_user_display_setting, do_set_user_display_setting,
do_set_zoom_token, do_set_zoom_token,
do_unmute_topic, do_unmute_topic,
do_unmute_user,
do_update_embedded_data, do_update_embedded_data,
do_update_message, do_update_message,
do_update_message_flags, do_update_message_flags,
@ -109,6 +111,7 @@ from zerver.lib.event_schema import (
check_invites_changed, check_invites_changed,
check_message, check_message,
check_muted_topics, check_muted_topics,
check_muted_users,
check_presence, check_presence,
check_reaction_add, check_reaction_add,
check_reaction_remove, check_reaction_remove,
@ -1000,6 +1003,14 @@ class NormalActionsTest(BaseAction):
events = self.verify_action(lambda: do_unmute_topic(self.user_profile, stream, "topic")) events = self.verify_action(lambda: do_unmute_topic(self.user_profile, stream, "topic"))
check_muted_topics("events[0]", events[0]) 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: def test_change_avatar_fields(self) -> None:
events = self.verify_action( events = self.verify_action(
lambda: do_change_avatar_fields( lambda: do_change_avatar_fields(

View File

@ -107,6 +107,7 @@ class HomeTest(ZulipTestCase):
"max_message_id", "max_message_id",
"message_content_in_email_notifications", "message_content_in_email_notifications",
"muted_topics", "muted_topics",
"muted_users",
"narrow", "narrow",
"narrow_stream", "narrow_stream",
"needs_tutorial", "needs_tutorial",
@ -261,7 +262,7 @@ class HomeTest(ZulipTestCase):
set(result["Cache-Control"].split(", ")), {"must-revalidate", "no-store", "no-cache"} 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) self.assert_length(cache_mock.call_args_list, 5)
html = result.content.decode("utf-8") html = result.content.decode("utf-8")
@ -341,7 +342,7 @@ class HomeTest(ZulipTestCase):
result = self._get_home_page() result = self._get_home_page()
self.check_rendered_logged_in_app(result) self.check_rendered_logged_in_app(result)
self.assert_length(cache_mock.call_args_list, 6) 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: def test_num_queries_with_streams(self) -> None:
main_user = self.example_user("hamlet") main_user = self.example_user("hamlet")
@ -372,7 +373,7 @@ class HomeTest(ZulipTestCase):
with queries_captured() as queries2: with queries_captured() as queries2:
result = self._get_home_page() 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. # Do a sanity check that our new streams were in the payload.
html = result.content.decode("utf-8") html = result.content.decode("utf-8")

View File

@ -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))

View File

@ -5,7 +5,7 @@ from django.http import HttpRequest, HttpResponse
from django.utils.timezone import now as timezone_now from django.utils.timezone import now as timezone_now
from django.utils.translation import ugettext as _ 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.request import REQ, has_request_variables
from zerver.lib.response import json_error, json_success from zerver.lib.response import json_error, json_success
from zerver.lib.streams import ( from zerver.lib.streams import (
@ -16,6 +16,8 @@ from zerver.lib.streams import (
check_for_exactly_one_stream_arg, check_for_exactly_one_stream_arg,
) )
from zerver.lib.topic_mutes import topic_is_muted 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.lib.validator import check_int
from zerver.models import UserProfile from zerver.models import UserProfile
@ -85,3 +87,29 @@ def update_muted_topic(
stream_name=stream, stream_name=stream,
topic_name=topic, 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()

View File

@ -80,7 +80,7 @@ from zerver.views.message_flags import (
update_message_flags, update_message_flags,
) )
from zerver.views.message_send import render_message_backend, send_message_backend, zcommand_backend 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 ( from zerver.views.portico import (
app_download_link_redirect, app_download_link_redirect,
apps_view, apps_view,
@ -462,6 +462,7 @@ v1_api_and_json_patterns = [
), ),
# muting -> zerver.views.muting # muting -> zerver.views.muting
rest_path("users/me/subscriptions/muted_topics", PATCH=update_muted_topic), rest_path("users/me/subscriptions/muted_topics", PATCH=update_muted_topic),
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
rest_path("register", POST=events_register_backend), rest_path("register", POST=events_register_backend),
# events -> zerver.tornado.views # events -> zerver.tornado.views