user_groups: Add API support for deactivating user groups.

This commit is contained in:
Sahil Batra 2024-05-15 19:14:18 +05:30 committed by Tim Abbott
parent bef7cfe00f
commit e1cfe61452
14 changed files with 459 additions and 2 deletions

View File

@ -20,6 +20,16 @@ format used by the Zulip server that they are interacting with.
## Changes in Zulip 10.0
**Feature level 290**
* [`POST /user_groups/{user_group_id}/deactivate`](/api/deactivate-user-group):
Added new API endpoint to deactivate a user group.
* [`POST /register`](/api/register-queue), [`GET
/user_groups`](/api/get-user-groups): Added `deactivated` field in
the user group objects to identify deactivated user groups.
* [`GET /events`](/api/get-events): When a user group is deactivated,
a `user_group` event with `op=update` is sent to clients.
**Feature level 289**
* [`POST /users/{user_id}/subscription`](/api/subscribe): In the response,

View File

@ -79,6 +79,7 @@
* [Create a user group](/api/create-user-group)
* [Update a user group](/api/update-user-group)
* [Delete a user group](/api/remove-user-group)
* [Deactivate a user group](/api/deactivate-user-group)
* [Update user group members](/api/update-user-group-members)
* [Update subgroups of a user group](/api/update-user-group-subgroups)
* [Get user group membership status](/api/get-is-user-group-member)

View File

@ -815,6 +815,7 @@ exports.fixtures = {
direct_subgroup_ids: [2],
can_manage_group: 16,
can_mention_group: 11,
deactivated: false,
},
},

View File

@ -182,6 +182,7 @@ def do_send_create_user_group_event(
direct_subgroup_ids=[direct_subgroup.id for direct_subgroup in direct_subgroups],
can_manage_group=get_group_setting_value_for_api(user_group.can_manage_group),
can_mention_group=get_group_setting_value_for_api(user_group.can_mention_group),
deactivated=False,
),
)
send_event_on_commit(user_group.realm, event, active_user_ids(user_group.realm_id))
@ -436,6 +437,25 @@ def check_delete_user_group(user_group: NamedUserGroup, *, acting_user: UserProf
do_send_delete_user_group_event(acting_user.realm, user_group_id, acting_user.realm.id)
@transaction.atomic(savepoint=False)
def do_deactivate_user_group(
user_group: NamedUserGroup, *, acting_user: UserProfile | None
) -> None:
user_group.deactivated = True
user_group.save(update_fields=["deactivated"])
now = timezone_now()
RealmAuditLog.objects.create(
realm=user_group.realm,
modified_user_group_id=user_group.id,
event_type=AuditLogEventType.USER_GROUP_DEACTIVATED,
event_time=now,
acting_user=acting_user,
)
do_send_user_group_update_event(user_group, dict(deactivated=True))
@transaction.atomic(savepoint=False)
def do_change_user_group_permission_setting(
user_group: NamedUserGroup,

View File

@ -1817,6 +1817,7 @@ group_type = DictType(
("is_system_group", bool),
("can_manage_group", group_setting_type),
("can_mention_group", group_setting_type),
("deactivated", bool),
]
)
@ -1865,6 +1866,7 @@ user_group_data_type = DictType(
("description", str),
("can_manage_group", group_setting_type),
("can_mention_group", group_setting_type),
("deactivated", bool),
],
)

View File

@ -6,7 +6,7 @@ from typing import TypedDict
from django.conf import settings
from django.db import connection, transaction
from django.db.models import F, QuerySet
from django.db.models import F, Q, QuerySet
from django.utils.timezone import now as timezone_now
from django.utils.translation import gettext as _
from django_cte import With
@ -53,6 +53,7 @@ class UserGroupDict(TypedDict):
is_system_group: bool
can_manage_group: int | AnonymousSettingGroupDict
can_mention_group: int | AnonymousSettingGroupDict
deactivated: bool
@dataclass
@ -126,6 +127,70 @@ def access_user_group_by_id(
return user_group
def access_user_group_for_deactivation(
user_group_id: int, user_profile: UserProfile
) -> NamedUserGroup:
user_group = access_user_group_by_id(user_group_id, user_profile, for_read=False)
if (
user_group.direct_supergroups.exclude(named_user_group=None)
.filter(named_user_group__deactivated=False)
.exists()
):
raise JsonableError(
_("You cannot deactivate a user group that is subgroup of any user group.")
)
anonymous_supergroup_ids = user_group.direct_supergroups.filter(
named_user_group=None
).values_list("id", flat=True)
# We check both the cases - whether the group is being directly used
# as the value of a setting or as a subgroup of an anonymous group
# used for a setting.
setting_group_ids_using_deactivating_user_group = [
*list(anonymous_supergroup_ids),
user_group.id,
]
stream_setting_query = Q()
for setting_name in Stream.stream_permission_group_settings:
stream_setting_query |= Q(
**{f"{setting_name}__in": setting_group_ids_using_deactivating_user_group}
)
if (
Stream.objects.filter(realm_id=user_group.realm_id, deactivated=False)
.filter(stream_setting_query)
.exists()
):
raise JsonableError(_("You cannot deactivate a user group which is used for setting."))
group_setting_query = Q()
for setting_name in NamedUserGroup.GROUP_PERMISSION_SETTINGS:
group_setting_query |= Q(
**{f"{setting_name}__in": setting_group_ids_using_deactivating_user_group}
)
if (
NamedUserGroup.objects.filter(realm_id=user_group.realm_id, deactivated=False)
.filter(group_setting_query)
.exists()
):
raise JsonableError(_("You cannot deactivate a user group which is used for setting."))
realm_setting_query = Q()
for setting_name in Realm.REALM_PERMISSION_GROUP_SETTINGS:
realm_setting_query |= Q(
**{f"{setting_name}__in": setting_group_ids_using_deactivating_user_group}
)
if Realm.objects.filter(id=user_group.realm_id).filter(realm_setting_query).exists():
raise JsonableError(_("You cannot deactivate a user group which is used for setting."))
return user_group
@contextmanager
def lock_subgroups_with_respect_to_supergroup(
potential_subgroup_ids: Collection[int], potential_supergroup_id: int, acting_user: UserProfile
@ -430,6 +495,7 @@ def user_groups_in_realm_serialized(realm: Realm) -> list[UserGroupDict]:
can_mention_group=get_setting_value_for_user_group_object(
user_group.can_mention_group, group_members, group_subgroups
),
deactivated=user_group.deactivated,
)
for group_dict in group_dicts.values():

View File

@ -106,6 +106,7 @@ class AuditLogEventType(IntEnum):
USER_GROUP_NAME_CHANGED = 720
USER_GROUP_DESCRIPTION_CHANGED = 721
USER_GROUP_GROUP_BASED_SETTING_CHANGED = 722
USER_GROUP_DEACTIVATED = 723
# The following values are only for remote server/realm logs.
# Values should be exactly 10000 greater than the corresponding

View File

@ -275,6 +275,20 @@ def get_temp_user_group_id() -> dict[str, object]:
}
@openapi_param_value_generator(["/user_groups/{user_group_id}/deactivate:post"])
def get_temp_user_group_id_for_deactivation() -> dict[str, object]:
user_group, _ = NamedUserGroup.objects.get_or_create(
name="temp-deactivation",
realm=get_realm("zulip"),
can_manage_group_id=11,
can_mention_group_id=11,
realm_for_sharding=get_realm("zulip"),
)
return {
"user_group_id": user_group.id,
}
@openapi_param_value_generator(["/realm/filters/{filter_id}:delete"])
def remove_realm_filters() -> dict[str, object]:
filter_id = do_add_linkifier(

View File

@ -3228,6 +3228,14 @@ paths:
[setting-values]: /api/group-setting-values
[system-groups]: /api/group-setting-values#system-groups
[mentions]: /help/mention-a-user-or-group
deactivated:
type: boolean
description: |
Whether the user group is deactivated. Deactivated groups
cannot be used as a subgroup of another group or used for
any other purpose.
**Changes**: New in Zulip 10.0 (feature level 290).
example:
{
"type": "user_group",
@ -20048,6 +20056,14 @@ paths:
[setting-values]: /api/group-setting-values
[system-groups]: /api/group-setting-values#system-groups
[mentions]: /help/mention-a-user-or-group
deactivated:
type: boolean
description: |
Whether the user group is deactivated. Deactivated groups
cannot be used as a subgroup of another group or used for
any other purpose.
**Changes**: New in Zulip 10.0 (feature level 290).
description: |
A list of `user_group` objects.
example:
@ -20211,6 +20227,27 @@ paths:
"result": "success",
"is_user_group_member": false,
}
/user_groups/{user_group_id}/deactivate:
post:
operationId: deactivate-user-group
summary: Deactivate a user group
tags: ["users"]
description: |
Deactivate a user group. Deactivated user groups cannot be
used for mentions, permissions, or any other purpose, but can
be reactivated or renamed.
Deactivating user groups is preferable to deleting them from
the database, since the deactivation model allows audit logs
of changes to sensitive group-valued permissions to be
maintained.
**Changes**: New in Zulip 10.0 (feature level 290).
parameters:
- $ref: "#/components/parameters/UserGroupId"
responses:
"200":
$ref: "#/components/responses/SimpleSuccess"
/real-time:
# This entry is a hack; it exists to give us a place to put the text
# documenting the parameters for call_on_each_event and friends.
@ -21268,6 +21305,14 @@ components:
[setting-values]: /api/group-setting-values
[system-groups]: /api/group-setting-values#system-groups
[mentions]: /help/mention-a-user-or-group
deactivated:
type: boolean
description: |
Whether the user group is deactivated. Deactivated groups
cannot be used as a subgroup of another group or used for
any other purpose.
**Changes**: New in Zulip 10.0 (feature level 290).
GroupSettingValue:
oneOf:
- type: integer

View File

@ -52,6 +52,7 @@ from zerver.actions.user_groups import (
bulk_remove_members_from_user_groups,
check_add_user_group,
do_change_user_group_permission_setting,
do_deactivate_user_group,
do_update_user_group_description,
do_update_user_group_name,
remove_subgroups_from_user_group,
@ -1454,3 +1455,25 @@ class TestRealmAuditLog(ZulipTestCase):
"property": "can_mention_group",
},
)
def test_user_group_deactivation(self) -> None:
hamlet = self.example_user("hamlet")
cordelia = self.example_user("cordelia")
user_group = check_add_user_group(
hamlet.realm,
"test",
[hamlet, cordelia],
acting_user=hamlet,
)
now = timezone_now()
do_deactivate_user_group(user_group, acting_user=hamlet)
audit_log_entries = RealmAuditLog.objects.filter(
acting_user=hamlet,
realm=hamlet.realm,
event_time__gte=now,
event_type=AuditLogEventType.USER_GROUP_DEACTIVATED,
)
self.assert_length(audit_log_entries, 1)
self.assertIsNone(audit_log_entries[0].modified_user)
self.assertEqual(audit_log_entries[0].modified_user_group, user_group)

View File

@ -111,6 +111,7 @@ from zerver.actions.user_groups import (
check_add_user_group,
check_delete_user_group,
do_change_user_group_permission_setting,
do_deactivate_user_group,
do_update_user_group_description,
do_update_user_group_name,
remove_subgroups_from_user_group,
@ -1920,6 +1921,11 @@ class NormalActionsTest(BaseAction):
remove_subgroups_from_user_group(backend, [api_design], acting_user=None)
check_user_group_remove_subgroups("events[0]", events[0])
# Test deactivate event
with self.verify_action() as events:
do_deactivate_user_group(backend, acting_user=None)
check_user_group_update("events[0]", events[0], "deactivated")
# Test remove event
with self.verify_action() as events:
check_delete_user_group(backend, acting_user=othello)

View File

@ -8,7 +8,15 @@ from django.db import transaction
from django.utils.timezone import now as timezone_now
from zerver.actions.create_realm import do_create_realm
from zerver.actions.realm_settings import do_set_realm_property
from zerver.actions.realm_settings import (
do_change_realm_permission_group_setting,
do_set_realm_property,
)
from zerver.actions.streams import (
do_change_stream_group_based_setting,
do_deactivate_stream,
do_unarchive_stream,
)
from zerver.actions.user_groups import (
add_subgroups_to_user_group,
bulk_add_members_to_user_groups,
@ -16,6 +24,7 @@ from zerver.actions.user_groups import (
check_add_user_group,
create_user_group_in_database,
do_change_user_group_permission_setting,
do_deactivate_user_group,
promote_new_full_members,
)
from zerver.actions.users import do_deactivate_user
@ -43,6 +52,7 @@ from zerver.models import (
GroupGroupMembership,
NamedUserGroup,
Realm,
Stream,
UserGroup,
UserGroupMembership,
UserProfile,
@ -83,6 +93,7 @@ class UserGroupTestCase(ZulipTestCase):
self.assertEqual(user_groups[0]["direct_subgroup_ids"], [])
self.assertEqual(user_groups[0]["can_manage_group"], user_group.id)
self.assertEqual(user_groups[0]["can_mention_group"], user_group.id)
self.assertFalse(user_groups[0]["deactivated"])
owners_system_group = NamedUserGroup.objects.get(name=SystemGroups.OWNERS, realm=realm)
membership = UserGroupMembership.objects.filter(user_group=owners_system_group).values_list(
@ -95,6 +106,7 @@ class UserGroupTestCase(ZulipTestCase):
self.assertEqual(user_groups[1]["direct_subgroup_ids"], [])
self.assertEqual(user_groups[1]["can_manage_group"], user_group.id)
self.assertEqual(user_groups[1]["can_mention_group"], user_group.id)
self.assertFalse(user_groups[0]["deactivated"])
admins_system_group = NamedUserGroup.objects.get(
name=SystemGroups.ADMINISTRATORS, realm=realm
@ -112,6 +124,7 @@ class UserGroupTestCase(ZulipTestCase):
self.assertEqual(user_groups[9]["members"], [])
self.assertEqual(user_groups[9]["can_manage_group"], user_group.id)
self.assertEqual(user_groups[9]["can_mention_group"], everyone_group.id)
self.assertFalse(user_groups[0]["deactivated"])
othello = self.example_user("othello")
hamletcharacters_group = NamedUserGroup.objects.get(name="hamletcharacters", realm=realm)
@ -147,6 +160,12 @@ class UserGroupTestCase(ZulipTestCase):
user_groups[10]["can_mention_group"].direct_subgroups,
[admins_system_group.id, hamletcharacters_group.id],
)
self.assertFalse(user_groups[0]["deactivated"])
do_deactivate_user_group(new_user_group, acting_user=None)
user_groups = user_groups_in_realm_serialized(realm)
self.assertEqual(user_groups[10]["id"], new_user_group.id)
self.assertTrue(user_groups[10]["deactivated"])
def test_get_direct_user_groups(self) -> None:
othello = self.example_user("othello")
@ -1149,6 +1168,238 @@ class UserGroupAPITestCase(UserGroupTestCase):
result = self.client_delete(f"/json/user_groups/{lear_test_group.id}")
self.assert_json_error(result, "Invalid user group")
def test_user_group_deactivation(self) -> None:
support_group = self.create_user_group_for_test("support")
leadership_group = self.create_user_group_for_test("leadership")
add_subgroups_to_user_group(support_group, [leadership_group], acting_user=None)
realm = get_realm("zulip")
do_set_realm_property(
realm, "user_group_edit_policy", CommonPolicyEnum.ADMINS_ONLY, acting_user=None
)
self.login("othello")
result = self.client_post(f"/json/user_groups/{support_group.id}/deactivate")
self.assert_json_error(result, "Insufficient permission")
do_set_realm_property(
realm, "user_group_edit_policy", CommonPolicyEnum.MEMBERS_ONLY, acting_user=None
)
self.login("hamlet")
result = self.client_post(f"/json/user_groups/{support_group.id}/deactivate")
self.assert_json_error(result, "Insufficient permission")
self.login("othello")
result = self.client_post(f"/json/user_groups/{support_group.id}/deactivate")
self.assert_json_success(result)
support_group = NamedUserGroup.objects.get(name="support", realm=realm)
self.assertTrue(support_group.deactivated)
support_group.deactivated = False
support_group.save()
# Check admins can deactivate groups even if they are not members
# of the group.
self.login("iago")
result = self.client_post(f"/json/user_groups/{support_group.id}/deactivate")
self.assert_json_success(result)
support_group = NamedUserGroup.objects.get(name="support", realm=realm)
self.assertTrue(support_group.deactivated)
support_group.deactivated = False
support_group.save()
# Check moderators can deactivate groups if they are allowed by
# user_group_edit_policy even when they are not members of the group.
do_set_realm_property(
realm, "user_group_edit_policy", CommonPolicyEnum.ADMINS_ONLY, acting_user=None
)
self.login("shiva")
result = self.client_post(f"/json/user_groups/{support_group.id}/deactivate")
self.assert_json_error(result, "Insufficient permission")
do_set_realm_property(
realm, "user_group_edit_policy", CommonPolicyEnum.MODERATORS_ONLY, acting_user=None
)
result = self.client_post(f"/json/user_groups/{support_group.id}/deactivate")
self.assert_json_success(result)
support_group = NamedUserGroup.objects.get(name="support", realm=realm)
self.assertTrue(support_group.deactivated)
support_group.deactivated = False
support_group.save()
# Check that group that is subgroup of another group cannot be deactivated.
result = self.client_post(f"/json/user_groups/{leadership_group.id}/deactivate")
self.assert_json_error(
result, "You cannot deactivate a user group that is subgroup of any user group."
)
# If the supergroup is itself deactivated, then subgroup can be deactivated.
result = self.client_post(f"/json/user_groups/{support_group.id}/deactivate")
self.assert_json_success(result)
result = self.client_post(f"/json/user_groups/{leadership_group.id}/deactivate")
self.assert_json_success(result)
leadership_group = NamedUserGroup.objects.get(name="leadership", realm=realm)
self.assertTrue(leadership_group.deactivated)
# Check that system groups cannot be deactivated at all.
self.login("desdemona")
members_system_group = NamedUserGroup.objects.get(
name=SystemGroups.MEMBERS, realm=realm, is_system_group=True
)
result = self.client_post(f"/json/user_groups/{members_system_group.id}/deactivate")
self.assert_json_error(result, "Insufficient permission")
def test_user_group_deactivation_with_group_used_for_settings(self) -> None:
support_group = self.create_user_group_for_test("support")
realm = get_realm("zulip")
moderators_group = NamedUserGroup.objects.get(
name=SystemGroups.MODERATORS, realm=realm, is_system_group=True
)
hamlet = self.example_user("hamlet")
self.login("desdemona")
for setting_name in Realm.REALM_PERMISSION_GROUP_SETTINGS:
anonymous_setting_group = self.create_or_update_anonymous_group_for_setting(
[hamlet], [moderators_group, support_group]
)
do_change_realm_permission_group_setting(
realm, setting_name, anonymous_setting_group, acting_user=None
)
result = self.client_post(f"/json/user_groups/{support_group.id}/deactivate")
self.assert_json_error(
result, "You cannot deactivate a user group which is used for setting."
)
do_change_realm_permission_group_setting(
realm, setting_name, support_group, acting_user=None
)
result = self.client_post(f"/json/user_groups/{support_group.id}/deactivate")
self.assert_json_error(
result, "You cannot deactivate a user group which is used for setting."
)
# Reset the realm setting to one of the system group so this setting
# does not interfere when testing for another setting.
do_change_realm_permission_group_setting(
realm, setting_name, moderators_group, acting_user=None
)
stream = ensure_stream(realm, "support", acting_user=None)
for setting_name in Stream.stream_permission_group_settings:
do_change_stream_group_based_setting(
stream, setting_name, support_group, acting_user=None
)
result = self.client_post(f"/json/user_groups/{support_group.id}/deactivate")
self.assert_json_error(
result, "You cannot deactivate a user group which is used for setting."
)
# Test the group can be deactivated, if the stream which uses
# this group for a setting is deactivated.
do_deactivate_stream(stream, acting_user=None)
result = self.client_post(f"/json/user_groups/{support_group.id}/deactivate")
self.assert_json_success(result)
support_group = NamedUserGroup.objects.get(name="support", realm=realm)
self.assertTrue(support_group.deactivated)
support_group.deactivated = False
support_group.save()
do_unarchive_stream(stream, "support", acting_user=None)
anonymous_setting_group = self.create_or_update_anonymous_group_for_setting(
[hamlet], [moderators_group, support_group]
)
do_change_stream_group_based_setting(
stream, setting_name, anonymous_setting_group, acting_user=None
)
result = self.client_post(f"/json/user_groups/{support_group.id}/deactivate")
self.assert_json_error(
result, "You cannot deactivate a user group which is used for setting."
)
# Test the group can be deactivated, if the stream which uses
# this group for a setting is deactivated.
do_deactivate_stream(stream, acting_user=None)
result = self.client_post(f"/json/user_groups/{support_group.id}/deactivate")
self.assert_json_success(result)
support_group = NamedUserGroup.objects.get(name="support", realm=realm)
self.assertTrue(support_group.deactivated)
# Reactivate the group again for further testing.
support_group.deactivated = False
support_group.save()
# Reset the stream setting to one of the system group so this setting
# does not interfere when testing for another setting.
do_change_stream_group_based_setting(
stream, setting_name, moderators_group, acting_user=None
)
leadership_group = self.create_user_group_for_test("leadership")
for setting_name in NamedUserGroup.GROUP_PERMISSION_SETTINGS:
do_change_user_group_permission_setting(
leadership_group, setting_name, support_group, acting_user=None
)
result = self.client_post(f"/json/user_groups/{support_group.id}/deactivate")
self.assert_json_error(
result, "You cannot deactivate a user group which is used for setting."
)
# Test the group can be deactivated, if the user group which uses
# this group for a setting is deactivated.
do_deactivate_user_group(leadership_group, acting_user=None)
result = self.client_post(f"/json/user_groups/{support_group.id}/deactivate")
self.assert_json_success(result)
support_group = NamedUserGroup.objects.get(name="support", realm=realm)
self.assertTrue(support_group.deactivated)
support_group.deactivated = False
support_group.save()
leadership_group.deactivated = False
leadership_group.save()
anonymous_setting_group = self.create_or_update_anonymous_group_for_setting(
[hamlet], [moderators_group, support_group]
)
do_change_user_group_permission_setting(
leadership_group, setting_name, anonymous_setting_group, acting_user=None
)
result = self.client_post(f"/json/user_groups/{support_group.id}/deactivate")
self.assert_json_error(
result, "You cannot deactivate a user group which is used for setting."
)
# Test the group can be deactivated, if the user group which uses
# this group for a setting is deactivated.
do_deactivate_user_group(leadership_group, acting_user=None)
result = self.client_post(f"/json/user_groups/{support_group.id}/deactivate")
self.assert_json_success(result)
support_group = NamedUserGroup.objects.get(name="support", realm=realm)
self.assertTrue(support_group.deactivated)
# Reactivate the group again for further testing.
support_group.deactivated = False
support_group.save()
leadership_group.deactivated = False
leadership_group.save()
# Reset the group setting to one of the system group so this setting
# does not interfere when testing for another setting.
do_change_user_group_permission_setting(
leadership_group, setting_name, moderators_group, acting_user=None
)
def test_query_counts(self) -> None:
hamlet = self.example_user("hamlet")
cordelia = self.example_user("cordelia")

View File

@ -13,6 +13,7 @@ from zerver.actions.user_groups import (
check_add_user_group,
check_delete_user_group,
do_change_user_group_permission_setting,
do_deactivate_user_group,
do_update_user_group_description,
do_update_user_group_name,
remove_subgroups_from_user_group,
@ -26,6 +27,7 @@ from zerver.lib.user_groups import (
AnonymousSettingGroupDict,
GroupSettingChangeRequest,
access_user_group_by_id,
access_user_group_for_deactivation,
access_user_group_for_setting,
check_user_group_name,
get_direct_memberships_of_users,
@ -183,6 +185,19 @@ def delete_user_group(
return json_success(request)
@typed_endpoint
@transaction.atomic
def deactivate_user_group(
request: HttpRequest,
user_profile: UserProfile,
*,
user_group_id: PathOnly[Json[int]],
) -> HttpResponse:
user_group = access_user_group_for_deactivation(user_group_id, user_profile)
do_deactivate_user_group(user_group, acting_user=user_profile)
return json_success(request)
@require_member_or_admin
@typed_endpoint
def update_user_group_backend(

View File

@ -188,6 +188,7 @@ from zerver.views.upload import (
)
from zerver.views.user_groups import (
add_user_group,
deactivate_user_group,
delete_user_group,
edit_user_group,
get_is_user_group_member,
@ -413,6 +414,7 @@ v1_api_and_json_patterns = [
rest_path(
"user_groups/<int:user_group_id>/members/<int:user_id>", GET=get_is_user_group_member
),
rest_path("user_groups/<int:user_group_id>/deactivate", POST=deactivate_user_group),
# users/me -> zerver.views.user_settings
rest_path("users/me/avatar", POST=set_avatar_backend, DELETE=delete_avatar_backend),
# users/me/onboarding_steps -> zerver.views.onboarding_steps