diff --git a/api_docs/changelog.md b/api_docs/changelog.md index ce6bf3c2ff..bf6bb327fa 100644 --- a/api_docs/changelog.md +++ b/api_docs/changelog.md @@ -20,6 +20,15 @@ format used by the Zulip server that they are interacting with. ## Changes in Zulip 10.0 +**Feature level 301** + +* [`POST /user_groups/create`](/api/create-user-group): Added `can_join_group` + parameter to support setting the user group whose members can join the user + group. +* [`PATCH /user_groups/{user_group_id}`](/api/update-user-group): Added + `can_join_group` parameter to support changing the user group whose + members can join the specified user group. + **Feature level 300** * [`GET /messages`](/api/get-message): Added a new message_ids parameter, diff --git a/zerver/lib/import_realm.py b/zerver/lib/import_realm.py index d6c25c85b3..c49f4ac670 100644 --- a/zerver/lib/import_realm.py +++ b/zerver/lib/import_realm.py @@ -757,6 +757,7 @@ def bulk_import_named_user_groups(data: TableData) -> None: group["name"], group["description"], group["is_system_group"], + group["can_join_group_id"], group["can_manage_group_id"], group["can_mention_group_id"], group["deactivated"], @@ -767,7 +768,7 @@ def bulk_import_named_user_groups(data: TableData) -> None: query = SQL( """ - INSERT INTO zerver_namedusergroup (usergroup_ptr_id, realm_id, name, description, is_system_group, can_manage_group_id, can_mention_group_id, deactivated, date_created) + INSERT INTO zerver_namedusergroup (usergroup_ptr_id, realm_id, name, description, is_system_group, can_join_group_id, can_manage_group_id, can_mention_group_id, deactivated, date_created) VALUES %s """ ) diff --git a/zerver/lib/user_groups.py b/zerver/lib/user_groups.py index f50a909533..0e6feb93ad 100644 --- a/zerver/lib/user_groups.py +++ b/zerver/lib/user_groups.py @@ -819,7 +819,7 @@ def bulk_create_system_user_groups(groups: list[dict[str, str]], realm: Realm) - user_group_ids = [id for (id,) in cursor.fetchall()] rows = [ - SQL("({},{},{},{},{},{},{},{})").format( + SQL("({},{},{},{},{},{},{},{},{})").format( Literal(user_group_ids[idx]), Literal(realm.id), Literal(group["name"]), @@ -827,13 +827,14 @@ def bulk_create_system_user_groups(groups: list[dict[str, str]], realm: Realm) - Literal(True), Literal(initial_group_setting_value), Literal(initial_group_setting_value), + Literal(initial_group_setting_value), Literal(False), ) for idx, group in enumerate(groups) ] query = SQL( """ - INSERT INTO zerver_namedusergroup (usergroup_ptr_id, realm_id, name, description, is_system_group, can_manage_group_id, can_mention_group_id, deactivated) + INSERT INTO zerver_namedusergroup (usergroup_ptr_id, realm_id, name, description, is_system_group, can_join_group_id, can_manage_group_id, can_mention_group_id, deactivated) VALUES {rows} """ ).format(rows=SQL(", ").join(rows)) @@ -915,7 +916,7 @@ def create_system_user_groups_for_realm(realm: Realm) -> dict[int, NamedUserGrou user_group = set_defaults_for_group_settings(group, {}, system_groups_name_dict) groups_with_updated_settings.append(user_group) NamedUserGroup.objects.bulk_update( - groups_with_updated_settings, ["can_manage_group", "can_mention_group"] + groups_with_updated_settings, ["can_join_group", "can_manage_group", "can_mention_group"] ) subgroup_objects: list[GroupGroupMembership] = [] diff --git a/zerver/migrations/0596_namedusergroup_can_join_group.py b/zerver/migrations/0596_namedusergroup_can_join_group.py new file mode 100644 index 0000000000..72dab89229 --- /dev/null +++ b/zerver/migrations/0596_namedusergroup_can_join_group.py @@ -0,0 +1,23 @@ +# Generated by Django 5.0.8 on 2024-09-19 10:34 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("zerver", "0595_add_realmexport_table_and_backfill"), + ] + + operations = [ + migrations.AddField( + model_name="namedusergroup", + name="can_join_group", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.RESTRICT, + related_name="+", + to="zerver.usergroup", + ), + ), + ] diff --git a/zerver/migrations/0597_set_default_value_for_can_join_group.py b/zerver/migrations/0597_set_default_value_for_can_join_group.py new file mode 100644 index 0000000000..d5a0ae0477 --- /dev/null +++ b/zerver/migrations/0597_set_default_value_for_can_join_group.py @@ -0,0 +1,56 @@ +# Generated by Django 5.0.8 on 2024-09-19 10:34 + +from django.db import migrations, transaction +from django.db.backends.base.schema import BaseDatabaseSchemaEditor +from django.db.migrations.state import StateApps +from django.db.models import Max, Min, OuterRef + + +def set_default_value_for_can_join_group( + apps: StateApps, schema_editor: BaseDatabaseSchemaEditor +) -> None: + NamedUserGroup = apps.get_model("zerver", "NamedUserGroup") + BATCH_SIZE = 1000 + + max_id = NamedUserGroup.objects.filter(can_join_group=None).aggregate(Max("id"))["id__max"] + if max_id is None: + # Do nothing if there are no user groups on the server. + return + + lower_bound = NamedUserGroup.objects.filter(can_join_group=None).aggregate(Min("id"))["id__min"] + while lower_bound <= max_id + BATCH_SIZE / 2: + upper_bound = lower_bound + BATCH_SIZE - 1 + print(f"Processing batch {lower_bound} to {upper_bound} for NamedUserGroup") + + with transaction.atomic(): + # Owners will naturally have the permission to join the + # group via their permission to manage all groups or add + # anyone to this group. + NamedUserGroup.objects.filter( + id__range=(lower_bound, upper_bound), + can_join_group=None, + ).update( + can_join_group=NamedUserGroup.objects.filter( + name="role:nobody", + realm_for_sharding=OuterRef("realm_for_sharding"), + is_system_group=True, + ).values("pk") + ) + + lower_bound += BATCH_SIZE + + +class Migration(migrations.Migration): + atomic = False + + dependencies = [ + ("zerver", "0596_namedusergroup_can_join_group"), + ] + + operations = [ + migrations.RunPython( + set_default_value_for_can_join_group, + elidable=True, + reverse_code=migrations.RunPython.noop, + ) + ] diff --git a/zerver/migrations/0598_alter_namedusergroup_can_join_group.py b/zerver/migrations/0598_alter_namedusergroup_can_join_group.py new file mode 100644 index 0000000000..60893b8512 --- /dev/null +++ b/zerver/migrations/0598_alter_namedusergroup_can_join_group.py @@ -0,0 +1,22 @@ +# Generated by Django 5.0.8 on 2024-09-19 10:40 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("zerver", "0597_set_default_value_for_can_join_group"), + ] + + operations = [ + migrations.AlterField( + model_name="namedusergroup", + name="can_join_group", + field=models.ForeignKey( + on_delete=django.db.models.deletion.RESTRICT, + related_name="+", + to="zerver.usergroup", + ), + ), + ] diff --git a/zerver/models/groups.py b/zerver/models/groups.py index 0703501d90..c37f4ac7ef 100644 --- a/zerver/models/groups.py +++ b/zerver/models/groups.py @@ -59,6 +59,7 @@ class NamedUserGroup(UserGroup): # type: ignore[django-manager-missing] # djang ) is_system_group = models.BooleanField(default=False, db_column="is_system_group") + can_join_group = models.ForeignKey(UserGroup, on_delete=models.RESTRICT, related_name="+") can_manage_group = models.ForeignKey(UserGroup, on_delete=models.RESTRICT, related_name="+") can_mention_group = models.ForeignKey( UserGroup, on_delete=models.RESTRICT, db_column="can_mention_group_id" @@ -94,6 +95,16 @@ class NamedUserGroup(UserGroup): # type: ignore[django-manager-missing] # djang } GROUP_PERMISSION_SETTINGS = { + "can_join_group": GroupPermissionSetting( + require_system_group=False, + allow_internet_group=False, + allow_owners_group=True, + allow_nobody_group=True, + allow_everyone_group=False, + default_group_name=SystemGroups.NOBODY, + default_for_system_groups=SystemGroups.NOBODY, + id_field_name="can_join_group_id", + ), "can_manage_group": GroupPermissionSetting( require_system_group=False, allow_internet_group=False, diff --git a/zerver/openapi/curl_param_value_generators.py b/zerver/openapi/curl_param_value_generators.py index 1e4136db0d..f356da4798 100644 --- a/zerver/openapi/curl_param_value_generators.py +++ b/zerver/openapi/curl_param_value_generators.py @@ -264,6 +264,7 @@ def get_temp_user_group_id() -> dict[str, object]: user_group, _ = NamedUserGroup.objects.get_or_create( name="temp", realm=get_realm("zulip"), + can_join_group_id=11, can_manage_group_id=11, can_mention_group_id=11, realm_for_sharding=get_realm("zulip"), @@ -278,6 +279,7 @@ 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_join_group_id=11, can_manage_group_id=11, can_mention_group_id=11, realm_for_sharding=get_realm("zulip"), diff --git a/zerver/openapi/zulip.yaml b/zerver/openapi/zulip.yaml index ad1ed2d12f..5eedae434f 100644 --- a/zerver/openapi/zulip.yaml +++ b/zerver/openapi/zulip.yaml @@ -20081,6 +20081,18 @@ paths: items: type: integer example: [1, 2, 3, 4] + can_join_group: + allOf: + - description: | + A [group-setting value][setting-values] defining the set of users who + have permission to join this user group. + + **Changes**: New in Zulip 10.0 (feature level 301). + + [setting-values]: /api/group-setting-values + [system-groups]: /api/group-setting-values#system-groups + - $ref: "#/components/schemas/GroupSettingValue" + example: 11 can_manage_group: allOf: - description: | @@ -20127,6 +20139,8 @@ paths: encoding: members: contentType: application/json + can_join_group: + contentType: application/json can_manage_group: contentType: application/json can_mention_group: @@ -20257,6 +20271,37 @@ paths: a required field. type: string example: The marketing team. + can_join_group: + description: | + The set of users who have permission to join this user group + expressed as an [update to a group-setting value][update-group-setting]. + + **Changes**: New in Zulip 10.0 (feature level 301). + + [update-group-setting]: /api/group-setting-values#updating-group-setting-values + [system-groups]: /api/group-setting-values#system-groups + type: object + additionalProperties: false + properties: + new: + allOf: + - description: | + The new [group-setting value](/api/group-setting-values) for who would + have the permission to join the group. + - $ref: "#/components/schemas/GroupSettingValue" + old: + allOf: + - description: | + The expected current [group-setting value](/api/group-setting-values) + for who has the permission to join the group. + - $ref: "#/components/schemas/GroupSettingValue" + required: + - new + example: + { + "new": {"direct_members": [10], "direct_subgroups": [11]}, + "old": 11, + } can_manage_group: description: | The set of users who have permission to [manage this user group][manage-user-groups] @@ -20341,6 +20386,8 @@ paths: "old": 11, } encoding: + can_join_group: + contentType: application/json can_manage_group: contentType: application/json can_mention_group: diff --git a/zerver/views/user_groups.py b/zerver/views/user_groups.py index b35da4de35..0a03832af9 100644 --- a/zerver/views/user_groups.py +++ b/zerver/views/user_groups.py @@ -57,6 +57,7 @@ def add_user_group( name: str, members: Json[list[int]], description: str, + can_join_group: Json[int | AnonymousSettingGroupDict] | None = None, can_manage_group: Json[int | AnonymousSettingGroupDict] | None = None, can_mention_group: Json[int | AnonymousSettingGroupDict] | None = None, ) -> HttpResponse: @@ -116,12 +117,14 @@ def edit_user_group( user_group_id: PathOnly[int], name: str | None = None, description: str | None = None, + can_join_group: Json[GroupSettingChangeRequest] | None = None, can_manage_group: Json[GroupSettingChangeRequest] | None = None, can_mention_group: Json[GroupSettingChangeRequest] | None = None, ) -> HttpResponse: if ( name is None and description is None + and can_join_group is None and can_manage_group is None and can_mention_group is None ): @@ -132,7 +135,10 @@ def edit_user_group( ) if user_group.deactivated and ( - description is not None or can_mention_group is not None or can_manage_group is not None + description is not None + or can_join_group is not None + or can_mention_group is not None + or can_manage_group is not None ): raise JsonableError(_("You can only change name of deactivated user groups"))