groups: Allow setting anonymous group to settings while editing.

This commit adds support to set can_mention_group setting to
anonymous group while editing groups.
This commit is contained in:
Sahil Batra 2024-04-30 18:46:58 +05:30 committed by Tim Abbott
parent 23fdb73353
commit 3f80bc1b41
10 changed files with 291 additions and 41 deletions

View File

@ -26,7 +26,8 @@ format used by the Zulip server that they are interacting with.
/register`](/api/register-queue): `can_mention_group` field can now /register`](/api/register-queue): `can_mention_group` field can now
either be an ID of a named user group with the permission, or an either be an ID of a named user group with the permission, or an
object describing the set of users and groups with the permission. object describing the set of users and groups with the permission.
* [`POST /user_groups/create`](/api/create-user-group): The * [`POST /user_groups/create`](/api/create-user-group), [`PATCH
/user_groups/{user_group_id}`](/api/update-user-group): The
`can_mention_group` parameter can now either be an ID of a named `can_mention_group` parameter can now either be an ID of a named
user group or an object describing a set of users and groups. user group or an object describing a set of users and groups.

View File

@ -33,7 +33,7 @@ DESKTOP_WARNING_VERSION = "5.9.3"
# Changes should be accompanied by documentation explaining what the # Changes should be accompanied by documentation explaining what the
# new level means in api_docs/changelog.md, as well as "**Changes**" # new level means in api_docs/changelog.md, as well as "**Changes**"
# entries in the endpoint's documentation in `zulip.yaml`. # entries in the endpoint's documentation in `zulip.yaml`.
API_FEATURE_LEVEL = 257 API_FEATURE_LEVEL = 258
# 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

@ -1,3 +1,4 @@
from dataclasses import asdict
from datetime import datetime from datetime import datetime
from typing import Dict, List, Mapping, Optional, Sequence, TypedDict, Union from typing import Dict, List, Mapping, Optional, Sequence, TypedDict, Union
@ -8,6 +9,7 @@ from django.utils.translation import gettext as _
from zerver.lib.exceptions import JsonableError from zerver.lib.exceptions import JsonableError
from zerver.lib.user_groups import ( from zerver.lib.user_groups import (
AnonymousSettingGroupDict,
get_group_setting_value_for_api, get_group_setting_value_for_api,
get_role_based_system_groups_dict, get_role_based_system_groups_dict,
set_defaults_for_group_settings, set_defaults_for_group_settings,
@ -207,7 +209,7 @@ def check_add_user_group(
def do_send_user_group_update_event( def do_send_user_group_update_event(
user_group: NamedUserGroup, data: Dict[str, Union[str, int]] user_group: NamedUserGroup, data: Dict[str, Union[str, int, AnonymousSettingGroupDict]]
) -> None: ) -> None:
event = dict(type="user_group", op="update", group_id=user_group.id, data=data) event = dict(type="user_group", op="update", group_id=user_group.id, data=data)
send_event(user_group.realm, event, active_user_ids(user_group.realm_id)) send_event(user_group.realm, event, active_user_ids(user_group.realm_id))
@ -431,6 +433,15 @@ 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) do_send_delete_user_group_event(acting_user.realm, user_group_id, acting_user.realm.id)
def get_group_setting_value_for_audit_log_data(
setting_value: Union[int, AnonymousSettingGroupDict],
) -> Union[int, Dict[str, List[int]]]:
if isinstance(setting_value, int):
return setting_value
return asdict(setting_value)
@transaction.atomic(savepoint=False) @transaction.atomic(savepoint=False)
def do_change_user_group_permission_setting( def do_change_user_group_permission_setting(
user_group: NamedUserGroup, user_group: NamedUserGroup,
@ -442,6 +453,20 @@ def do_change_user_group_permission_setting(
old_value = getattr(user_group, setting_name) old_value = getattr(user_group, setting_name)
setattr(user_group, setting_name, setting_value_group) setattr(user_group, setting_name, setting_value_group)
user_group.save() user_group.save()
old_setting_api_value = get_group_setting_value_for_api(old_value)
new_setting_api_value = get_group_setting_value_for_api(setting_value_group)
if not hasattr(old_value, "named_user_group") and hasattr(
setting_value_group, "named_user_group"
):
# We delete the UserGroup which the setting was set to
# previously if it does not have any linked NamedUserGroup
# object, as it is not used anywhere else. A new UserGroup
# object would be created if the setting is later set to
# a combination of users and groups.
old_value.delete()
RealmAuditLog.objects.create( RealmAuditLog.objects.create(
realm=user_group.realm, realm=user_group.realm,
acting_user=acting_user, acting_user=acting_user,
@ -449,11 +474,17 @@ def do_change_user_group_permission_setting(
event_time=timezone_now(), event_time=timezone_now(),
modified_user_group=user_group, modified_user_group=user_group,
extra_data={ extra_data={
RealmAuditLog.OLD_VALUE: old_value.id, RealmAuditLog.OLD_VALUE: get_group_setting_value_for_audit_log_data(
RealmAuditLog.NEW_VALUE: setting_value_group.id, old_setting_api_value
),
RealmAuditLog.NEW_VALUE: get_group_setting_value_for_audit_log_data(
new_setting_api_value
),
"property": setting_name, "property": setting_name,
}, },
) )
event_data_dict: Dict[str, Union[str, int]] = {setting_name: setting_value_group.id} event_data_dict: Dict[str, Union[str, int, AnonymousSettingGroupDict]] = {
setting_name: new_setting_api_value
}
do_send_user_group_update_event(user_group, event_data_dict) do_send_user_group_update_event(user_group, event_data_dict)

View File

@ -1857,7 +1857,7 @@ user_group_data_type = DictType(
optional_keys=[ optional_keys=[
("name", str), ("name", str),
("description", str), ("description", str),
("can_mention_group", int), ("can_mention_group", group_setting_type),
], ],
) )

View File

@ -242,9 +242,7 @@ def update_or_create_user_group_for_setting(
direct_subgroups: List[int], direct_subgroups: List[int],
current_setting_value: Optional[UserGroup], current_setting_value: Optional[UserGroup],
) -> UserGroup: ) -> UserGroup:
if current_setting_value is not None and not hasattr( if current_setting_value is not None and not hasattr(current_setting_value, "named_user_group"):
current_setting_value, "named_user_group"
): # nocoverage
# We do not create a new group if the setting was already set # We do not create a new group if the setting was already set
# to an anonymous group. The memberships of existing group # to an anonymous group. The memberships of existing group
# itself are updated. # itself are updated.

View File

@ -3174,17 +3174,22 @@ paths:
The new description of the group. Only present if the description The new description of the group. Only present if the description
changed. changed.
can_mention_group: can_mention_group:
type: integer allOf:
description: | - $ref: "#/components/schemas/CanMentionGroup"
ID of the new user group whose members are allowed to mention the - description: |
group. Either the ID of a named user group that has permission
to mention the group, or an object describing the set of
users and groups who have permission mention the group.
**Changes**: Before Zulip 8.0 (feature level 198), **Changes**: Before Zulip 9.0 (feature level 258), the
the `can_mention_group` setting `can_mention_group` field was always an integer.
was named `can_mention_group_id`.
New in Zulip 8.0 (feature level 191). Previously, groups **Changes**: Before Zulip 8.0 (feature level 198),
could be mentioned if and only if they were not system groups. the `can_mention_group` setting
was named `can_mention_group_id`.
New in Zulip 8.0 (feature level 191). Previously, groups
could be mentioned if and only if they were not system groups.
example: example:
{ {
"type": "user_group", "type": "user_group",
@ -18544,19 +18549,24 @@ paths:
type: string type: string
example: The marketing team. example: The marketing team.
can_mention_group: can_mention_group:
description: | allOf:
ID of the new user group whose members are allowed to mention the - description: |
group. Either the ID of a named user group that has permission to
mention the group, or an object describing the set of users
and groups who have permission mention the group.
This setting cannot be set to `"role:internet"` and `"role:owners"` This setting cannot be set to `"role:internet"` and `"role:owners"`
system groups. system groups.
**Changes**: Before Zulip 8.0 (feature level 198), **Changes**: Before Zulip 9.0 (feature level 258), the
the `can_mention_group` setting was named `can_mention_group_id`. `can_mention_group` this field was always an integer.
New in Zulip 8.0 (feature level 191). Previously, groups **Changes**: Before Zulip 8.0 (feature level 198),
could be mentioned if and only if they were not system groups. the `can_mention_group` setting was named `can_mention_group_id`.
type: integer
New in Zulip 8.0 (feature level 191). Previously, groups
could be mentioned if and only if they were not system groups.
- $ref: "#/components/schemas/CanMentionGroup"
example: 12 example: 12
encoding: encoding:
can_mention_group: can_mention_group:

View File

@ -81,6 +81,7 @@ from zerver.models import (
RealmPlayground, RealmPlayground,
Recipient, Recipient,
Subscription, Subscription,
UserGroup,
UserProfile, UserProfile,
) )
from zerver.models.groups import SystemGroups from zerver.models.groups import SystemGroups
@ -1333,3 +1334,88 @@ class TestRealmAuditLog(ZulipTestCase):
"property": "can_mention_group", "property": "can_mention_group",
}, },
) )
moderators_group = NamedUserGroup.objects.get(
name=SystemGroups.MODERATORS, realm=user_group.realm, is_system_group=True
)
old_group = user_group.can_mention_group
new_group = UserGroup.objects.create(realm=user_group.realm)
new_group.direct_members.set([hamlet.id])
new_group.direct_subgroups.set([moderators_group.id])
now = timezone_now()
do_change_user_group_permission_setting(
user_group, "can_mention_group", new_group, acting_user=None
)
audit_log_entries = RealmAuditLog.objects.filter(
event_type=RealmAuditLog.USER_GROUP_GROUP_BASED_SETTING_CHANGED,
event_time__gte=now,
)
self.assert_length(audit_log_entries, 1)
self.assertIsNone(audit_log_entries[0].acting_user)
self.assertDictEqual(
audit_log_entries[0].extra_data,
{
RealmAuditLog.OLD_VALUE: old_group.id,
RealmAuditLog.NEW_VALUE: {
"direct_members": [hamlet.id],
"direct_subgroups": [moderators_group.id],
},
"property": "can_mention_group",
},
)
othello = self.example_user("othello")
new_group = UserGroup.objects.create(realm=user_group.realm)
new_group.direct_members.set([othello.id])
new_group.direct_subgroups.set([moderators_group.id])
now = timezone_now()
do_change_user_group_permission_setting(
user_group, "can_mention_group", new_group, acting_user=None
)
audit_log_entries = RealmAuditLog.objects.filter(
event_type=RealmAuditLog.USER_GROUP_GROUP_BASED_SETTING_CHANGED,
event_time__gte=now,
)
self.assert_length(audit_log_entries, 1)
self.assertIsNone(audit_log_entries[0].acting_user)
self.assertDictEqual(
audit_log_entries[0].extra_data,
{
RealmAuditLog.OLD_VALUE: {
"direct_members": [hamlet.id],
"direct_subgroups": [moderators_group.id],
},
RealmAuditLog.NEW_VALUE: {
"direct_members": [othello.id],
"direct_subgroups": [moderators_group.id],
},
"property": "can_mention_group",
},
)
new_group = NamedUserGroup.objects.get(
name=SystemGroups.EVERYONE, realm=user_group.realm, is_system_group=True
)
now = timezone_now()
do_change_user_group_permission_setting(
user_group, "can_mention_group", new_group, acting_user=None
)
audit_log_entries = RealmAuditLog.objects.filter(
event_type=RealmAuditLog.USER_GROUP_GROUP_BASED_SETTING_CHANGED,
event_time__gte=now,
)
self.assert_length(audit_log_entries, 1)
self.assertIsNone(audit_log_entries[0].acting_user)
self.assertDictEqual(
audit_log_entries[0].extra_data,
{
RealmAuditLog.OLD_VALUE: {
"direct_members": [othello.id],
"direct_subgroups": [moderators_group.id],
},
RealmAuditLog.NEW_VALUE: new_group.id,
"property": "can_mention_group",
},
)

View File

@ -1745,6 +1745,22 @@ class NormalActionsTest(BaseAction):
backend, "can_mention_group", moderators_group, acting_user=None backend, "can_mention_group", moderators_group, acting_user=None
) )
check_user_group_update("events[0]", events[0], "can_mention_group") check_user_group_update("events[0]", events[0], "can_mention_group")
self.assertEqual(events[0]["data"]["can_mention_group"], moderators_group.id)
setting_group = UserGroup.objects.create(realm=self.user_profile.realm)
setting_group.direct_members.set([othello.id])
setting_group.direct_subgroups.set([moderators_group.id])
with self.verify_action() as events:
do_change_user_group_permission_setting(
backend, "can_mention_group", setting_group, acting_user=None
)
check_user_group_update("events[0]", events[0], "can_mention_group")
self.assertEqual(
events[0]["data"]["can_mention_group"],
AnonymousSettingGroupDict(
direct_members=[othello.id], direct_subgroups=[moderators_group.id]
),
)
# Test add members # Test add members
hamlet = self.example_user("hamlet") hamlet = self.example_user("hamlet")

View File

@ -671,6 +671,62 @@ class UserGroupAPITestCase(UserGroupTestCase):
support_group = NamedUserGroup.objects.get(name="support", realm=hamlet.realm) support_group = NamedUserGroup.objects.get(name="support", realm=hamlet.realm)
self.assertEqual(support_group.can_mention_group, nobody_group.usergroup_ptr) self.assertEqual(support_group.can_mention_group, nobody_group.usergroup_ptr)
othello = self.example_user("othello")
params = {
"can_mention_group": orjson.dumps(
{
"direct_members": [othello.id],
"direct_subgroups": [moderators_group.id, marketing_group.id],
}
).decode()
}
result = self.client_patch(f"/json/user_groups/{support_group.id}", info=params)
self.assert_json_success(result)
support_group = NamedUserGroup.objects.get(name="support", realm=hamlet.realm)
self.assertCountEqual(
list(support_group.can_mention_group.direct_members.all()),
[othello],
)
self.assertCountEqual(
list(support_group.can_mention_group.direct_subgroups.all()),
[marketing_group, moderators_group],
)
prospero = self.example_user("prospero")
params = {
"can_mention_group": orjson.dumps(
{
"direct_members": [othello.id, prospero.id],
"direct_subgroups": [moderators_group.id, marketing_group.id],
}
).decode()
}
previous_can_mention_group_id = support_group.can_mention_group_id
result = self.client_patch(f"/json/user_groups/{support_group.id}", info=params)
self.assert_json_success(result)
support_group = NamedUserGroup.objects.get(name="support", realm=hamlet.realm)
# Test that the existing UserGroup object is updated.
self.assertEqual(support_group.can_mention_group_id, previous_can_mention_group_id)
self.assertCountEqual(
list(support_group.can_mention_group.direct_members.all()),
[othello, prospero],
)
self.assertCountEqual(
list(support_group.can_mention_group.direct_subgroups.all()),
[marketing_group, moderators_group],
)
params = {"can_mention_group": orjson.dumps(marketing_group.id).decode()}
previous_can_mention_group_id = support_group.can_mention_group_id
result = self.client_patch(f"/json/user_groups/{support_group.id}", info=params)
self.assert_json_success(result)
support_group = NamedUserGroup.objects.get(name="support", realm=hamlet.realm)
# Test that the previous UserGroup object is deleted.
self.assertFalse(UserGroup.objects.filter(id=previous_can_mention_group_id).exists())
self.assertEqual(support_group.can_mention_group_id, marketing_group.id)
owners_group = NamedUserGroup.objects.get( owners_group = NamedUserGroup.objects.get(
name="role:owners", realm=hamlet.realm, is_system_group=True name="role:owners", realm=hamlet.realm, is_system_group=True
) )
@ -699,6 +755,28 @@ class UserGroupAPITestCase(UserGroupTestCase):
result = self.client_patch(f"/json/user_groups/{support_group.id}", info=params) result = self.client_patch(f"/json/user_groups/{support_group.id}", info=params)
self.assert_json_error(result, "Invalid user group") self.assert_json_error(result, "Invalid user group")
params = {
"can_mention_group": orjson.dumps(
{
"direct_members": [1111, othello.id],
"direct_subgroups": [moderators_group.id, marketing_group.id],
}
).decode()
}
result = self.client_patch(f"/json/user_groups/{support_group.id}", info=params)
self.assert_json_error(result, "Invalid user ID: 1111")
params = {
"can_mention_group": orjson.dumps(
{
"direct_members": [prospero.id, othello.id],
"direct_subgroups": [1111, marketing_group.id],
}
).decode()
}
result = self.client_patch(f"/json/user_groups/{support_group.id}", info=params)
self.assert_json_error(result, "Invalid user group ID: 1111")
def test_user_group_update_to_already_existing_name(self) -> None: def test_user_group_update_to_already_existing_name(self) -> None:
hamlet = self.example_user("hamlet") hamlet = self.example_user("hamlet")
self.login_user(hamlet) self.login_user(hamlet)

View File

@ -30,6 +30,7 @@ from zerver.lib.user_groups import (
access_user_group_for_setting, access_user_group_for_setting,
check_user_group_name, check_user_group_name,
get_direct_memberships_of_users, get_direct_memberships_of_users,
get_group_setting_value_for_api,
get_subgroup_ids, get_subgroup_ids,
get_user_group_direct_member_ids, get_user_group_direct_member_ids,
get_user_group_member_ids, get_user_group_member_ids,
@ -39,7 +40,7 @@ from zerver.lib.user_groups import (
) )
from zerver.lib.users import access_user_by_id, user_ids_to_users from zerver.lib.users import access_user_by_id, user_ids_to_users
from zerver.lib.validator import check_bool, check_dict_only, check_int, check_list, check_union from zerver.lib.validator import check_bool, check_dict_only, check_int, check_list, check_union
from zerver.models import NamedUserGroup, UserProfile from zerver.models import NamedUserGroup, UserGroup, UserProfile
from zerver.models.users import get_system_bot from zerver.models.users import get_system_bot
from zerver.views.streams import compose_views from zerver.views.streams import compose_views
@ -121,6 +122,34 @@ def get_user_group(request: HttpRequest, user_profile: UserProfile) -> HttpRespo
return json_success(request, data={"user_groups": user_groups}) return json_success(request, data={"user_groups": user_groups})
def are_both_setting_values_equal(
first_setting_value: Union[int, AnonymousSettingGroupDict],
second_setting_value: Union[int, AnonymousSettingGroupDict],
) -> bool:
if isinstance(first_setting_value, int) and isinstance(second_setting_value, int):
return first_setting_value == second_setting_value
if isinstance(first_setting_value, AnonymousSettingGroupDict) and isinstance(
second_setting_value, AnonymousSettingGroupDict
):
return set(first_setting_value.direct_members) == set(
second_setting_value.direct_members
) and set(first_setting_value.direct_subgroups) == set(
second_setting_value.direct_subgroups
)
return False
def check_setting_value_changed(
current_value: UserGroup,
new_setting_value: Union[int, AnonymousSettingGroupDict],
) -> bool:
current_setting_api_value = get_group_setting_value_for_api(current_value)
return not are_both_setting_values_equal(current_setting_api_value, new_setting_value)
@transaction.atomic @transaction.atomic
@require_user_group_edit_permission @require_user_group_edit_permission
@has_request_variables @has_request_variables
@ -130,11 +159,11 @@ def edit_user_group(
user_group_id: int = REQ(json_validator=check_int, path_only=True), user_group_id: int = REQ(json_validator=check_int, path_only=True),
name: Optional[str] = REQ(default=None), name: Optional[str] = REQ(default=None),
description: Optional[str] = REQ(default=None), description: Optional[str] = REQ(default=None),
can_mention_group_id: Optional[int] = REQ( can_mention_group: Optional[Union[Dict[str, List[int]], int]] = REQ(
"can_mention_group", json_validator=check_int, default=None json_validator=check_group_setting, default=None
), ),
) -> HttpResponse: ) -> HttpResponse:
if name is None and description is None and can_mention_group_id is None: if name is None and description is None and can_mention_group is None:
raise JsonableError(_("No new data supplied")) raise JsonableError(_("No new data supplied"))
user_group = access_user_group_by_id(user_group_id, user_profile, for_read=False) user_group = access_user_group_by_id(user_group_id, user_profile, for_read=False)
@ -148,20 +177,21 @@ def edit_user_group(
request_settings_dict = locals() request_settings_dict = locals()
for setting_name, permission_config in NamedUserGroup.GROUP_PERMISSION_SETTINGS.items(): for setting_name, permission_config in NamedUserGroup.GROUP_PERMISSION_SETTINGS.items():
setting_group_id_name = permission_config.id_field_name if setting_name not in request_settings_dict: # nocoverage
if setting_group_id_name not in request_settings_dict: # nocoverage
continue continue
if request_settings_dict[setting_group_id_name] is not None and request_settings_dict[ if request_settings_dict[setting_name] is None:
setting_group_id_name continue
] != getattr(user_group, setting_group_id_name):
setting_value_group_id = request_settings_dict[setting_group_id_name] current_value = getattr(user_group, setting_name)
new_setting_value = parse_group_setting_value(request_settings_dict[setting_name])
if check_setting_value_changed(current_value, new_setting_value):
setting_value_group = access_user_group_for_setting( setting_value_group = access_user_group_for_setting(
setting_value_group_id, new_setting_value,
user_profile, user_profile,
setting_name=setting_name, setting_name=setting_name,
permission_configuration=permission_config, permission_configuration=permission_config,
current_setting_value=current_value,
) )
do_change_user_group_permission_setting( do_change_user_group_permission_setting(
user_group, setting_name, setting_value_group, acting_user=user_profile user_group, setting_name, setting_value_group, acting_user=user_profile