diff --git a/api_docs/changelog.md b/api_docs/changelog.md index 1c76a04024..73db3879ea 100644 --- a/api_docs/changelog.md +++ b/api_docs/changelog.md @@ -20,6 +20,17 @@ format used by the Zulip server that they are interacting with. ## Changes in Zulip 10.0 +**Feature level 357** + +* [`GET /users/me/subscriptions`](/api/get-subscriptions), + [`GET /streams`](/api/get-streams), [`GET /events`](/api/get-events), + [`POST /register`](/api/register-queue): Added `can_subscribe_group` + field to Stream and Subscription objects. +* [`POST /users/me/subscriptions`](/api/subscribe), + [`PATCH /streams/{stream_id}`](/api/update-stream): Added + `can_subscribe_group` parameter to support setting and changing the + user group whose members can subscribe to the specified stream. + **Feature level 356** * [`GET /streams`](/api/get-streams): The new parameter diff --git a/version.py b/version.py index 416f3d2235..4ca00cdc1a 100644 --- a/version.py +++ b/version.py @@ -34,7 +34,7 @@ DESKTOP_WARNING_VERSION = "5.9.3" # new level means in api_docs/changelog.md, as well as "**Changes**" # entries in the endpoint's documentation in `zulip.yaml`. -API_FEATURE_LEVEL = 356 # Last bumped for adding `include_can_access_content` to get-streams. +API_FEATURE_LEVEL = 357 # Last bumped for adding 'can_subscribe_group' # 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/actions/streams.py b/zerver/actions/streams.py index 403fa657fb..e2085f3eb9 100644 --- a/zerver/actions/streams.py +++ b/zerver/actions/streams.py @@ -425,6 +425,7 @@ def send_subscription_add_events( can_administer_channel_group=stream_dict["can_administer_channel_group"], can_send_message_group=stream_dict["can_send_message_group"], can_remove_subscribers_group=stream_dict["can_remove_subscribers_group"], + can_subscribe_group=stream_dict["can_subscribe_group"], creator_id=stream_dict["creator_id"], date_created=stream_dict["date_created"], description=stream_dict["description"], diff --git a/zerver/lib/streams.py b/zerver/lib/streams.py index 9877dbbe67..e884605b01 100644 --- a/zerver/lib/streams.py +++ b/zerver/lib/streams.py @@ -86,6 +86,7 @@ class StreamDict(TypedDict, total=False): can_administer_channel_group: UserGroup | None can_send_message_group: UserGroup | None can_remove_subscribers_group: UserGroup | None + can_subscribe_group: UserGroup | None def get_stream_permission_policy_name( @@ -256,6 +257,7 @@ def create_stream_if_needed( can_administer_channel_group: UserGroup | None = None, can_send_message_group: UserGroup | None = None, can_remove_subscribers_group: UserGroup | None = None, + can_subscribe_group: UserGroup | None = None, acting_user: UserProfile | None = None, setting_groups_dict: dict[int, int | AnonymousSettingGroupDict] | None = None, ) -> tuple[Stream, bool]: @@ -370,6 +372,7 @@ def create_streams_if_needed( can_administer_channel_group=stream_dict.get("can_administer_channel_group", None), can_send_message_group=stream_dict.get("can_send_message_group", None), can_remove_subscribers_group=stream_dict.get("can_remove_subscribers_group", None), + can_subscribe_group=stream_dict.get("can_subscribe_group", None), acting_user=acting_user, setting_groups_dict=setting_groups_dict, ) @@ -1398,6 +1401,7 @@ def stream_to_dict( can_administer_channel_group = setting_groups_dict[stream.can_administer_channel_group_id] can_send_message_group = setting_groups_dict[stream.can_send_message_group_id] can_remove_subscribers_group = setting_groups_dict[stream.can_remove_subscribers_group_id] + can_subscribe_group = setting_groups_dict[stream.can_subscribe_group_id] stream_post_policy = get_stream_post_policy_value_based_on_group_setting( stream.can_send_message_group @@ -1409,6 +1413,7 @@ def stream_to_dict( can_administer_channel_group=can_administer_channel_group, can_send_message_group=can_send_message_group, can_remove_subscribers_group=can_remove_subscribers_group, + can_subscribe_group=can_subscribe_group, creator_id=stream.creator_id, date_created=datetime_to_timestamp(stream.date_created), description=stream.description, diff --git a/zerver/lib/subscription_info.py b/zerver/lib/subscription_info.py index 4df0e84171..0b0aa0a221 100644 --- a/zerver/lib/subscription_info.py +++ b/zerver/lib/subscription_info.py @@ -62,6 +62,7 @@ def get_web_public_subs(realm: Realm) -> SubscriptionInfo: can_administer_channel_group = setting_groups_dict[stream.can_administer_channel_group_id] can_send_message_group = setting_groups_dict[stream.can_send_message_group_id] can_remove_subscribers_group = setting_groups_dict[stream.can_remove_subscribers_group_id] + can_subscribe_group = setting_groups_dict[stream.can_subscribe_group_id] creator_id = stream.creator_id date_created = datetime_to_timestamp(stream.date_created) description = stream.description @@ -101,6 +102,7 @@ def get_web_public_subs(realm: Realm) -> SubscriptionInfo: can_administer_channel_group=can_administer_channel_group, can_send_message_group=can_send_message_group, can_remove_subscribers_group=can_remove_subscribers_group, + can_subscribe_group=can_subscribe_group, color=color, creator_id=creator_id, date_created=date_created, @@ -168,6 +170,7 @@ def build_stream_api_dict( can_remove_subscribers_group = setting_groups_dict[ raw_stream_dict["can_remove_subscribers_group_id"] ] + can_subscribe_group = setting_groups_dict[raw_stream_dict["can_subscribe_group_id"]] return APIStreamDict( is_archived=raw_stream_dict["deactivated"], @@ -175,6 +178,7 @@ def build_stream_api_dict( can_administer_channel_group=can_administer_channel_group, can_send_message_group=can_send_message_group, can_remove_subscribers_group=can_remove_subscribers_group, + can_subscribe_group=can_subscribe_group, creator_id=raw_stream_dict["creator_id"], date_created=datetime_to_timestamp(raw_stream_dict["date_created"]), description=raw_stream_dict["description"], @@ -204,6 +208,7 @@ def build_stream_dict_for_sub( can_administer_channel_group = stream_dict["can_administer_channel_group"] can_send_message_group = stream_dict["can_send_message_group"] can_remove_subscribers_group = stream_dict["can_remove_subscribers_group"] + can_subscribe_group = stream_dict["can_subscribe_group"] creator_id = stream_dict["creator_id"] date_created = stream_dict["date_created"] description = stream_dict["description"] @@ -242,6 +247,7 @@ def build_stream_dict_for_sub( can_administer_channel_group=can_administer_channel_group, can_send_message_group=can_send_message_group, can_remove_subscribers_group=can_remove_subscribers_group, + can_subscribe_group=can_subscribe_group, color=color, creator_id=creator_id, date_created=date_created, @@ -305,6 +311,7 @@ def build_stream_dict_for_never_sub( can_remove_subscribers_group_value = setting_groups_dict[ raw_stream_dict["can_remove_subscribers_group_id"] ] + can_subscribe_group_value = setting_groups_dict[raw_stream_dict["can_subscribe_group_id"]] # Backwards-compatibility addition of removed field. is_announcement_only = raw_stream_dict["stream_post_policy"] == Stream.STREAM_POST_POLICY_ADMINS @@ -316,6 +323,7 @@ def build_stream_dict_for_never_sub( can_administer_channel_group=can_administer_channel_group_value, can_send_message_group=can_send_message_group_value, can_remove_subscribers_group=can_remove_subscribers_group_value, + can_subscribe_group=can_subscribe_group_value, creator_id=creator_id, date_created=date_created, description=description, diff --git a/zerver/lib/types.py b/zerver/lib/types.py index 9e727b0e22..ceb75bc74a 100644 --- a/zerver/lib/types.py +++ b/zerver/lib/types.py @@ -157,6 +157,7 @@ class RawStreamDict(TypedDict): can_administer_channel_group_id: int can_send_message_group_id: int can_remove_subscribers_group_id: int + can_subscribe_group_id: int creator_id: int | None date_created: datetime deactivated: bool @@ -202,6 +203,7 @@ class SubscriptionStreamDict(TypedDict): can_administer_channel_group: int | AnonymousSettingGroupDict can_send_message_group: int | AnonymousSettingGroupDict can_remove_subscribers_group: int | AnonymousSettingGroupDict + can_subscribe_group: int | AnonymousSettingGroupDict color: str creator_id: int | None date_created: int @@ -235,6 +237,7 @@ class NeverSubscribedStreamDict(TypedDict): can_administer_channel_group: int | AnonymousSettingGroupDict can_send_message_group: int | AnonymousSettingGroupDict can_remove_subscribers_group: int | AnonymousSettingGroupDict + can_subscribe_group: int | AnonymousSettingGroupDict creator_id: int | None date_created: int description: str @@ -264,6 +267,7 @@ class DefaultStreamDict(TypedDict): can_administer_channel_group: int | AnonymousSettingGroupDict can_send_message_group: int | AnonymousSettingGroupDict can_remove_subscribers_group: int | AnonymousSettingGroupDict + can_subscribe_group: int | AnonymousSettingGroupDict creator_id: int | None date_created: int description: str diff --git a/zerver/migrations/0673_stream_can_subscribe_group.py b/zerver/migrations/0673_stream_can_subscribe_group.py new file mode 100644 index 0000000000..5568a4ef15 --- /dev/null +++ b/zerver/migrations/0673_stream_can_subscribe_group.py @@ -0,0 +1,23 @@ +# Generated by Django 5.0.10 on 2025-02-17 09:00 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("zerver", "0672_fix_attachment_realm"), + ] + + operations = [ + migrations.AddField( + model_name="stream", + name="can_subscribe_group", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.RESTRICT, + related_name="+", + to="zerver.usergroup", + ), + ), + ] diff --git a/zerver/migrations/0674_set_default_for_stream_can_subscribe_group.py b/zerver/migrations/0674_set_default_for_stream_can_subscribe_group.py new file mode 100644 index 0000000000..36114c2dfa --- /dev/null +++ b/zerver/migrations/0674_set_default_for_stream_can_subscribe_group.py @@ -0,0 +1,55 @@ +# Generated by Django 5.0.10 on 2025-02-17 09:18 + +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_subscribe_group( + apps: StateApps, schema_editor: BaseDatabaseSchemaEditor +) -> None: + Stream = apps.get_model("zerver", "Stream") + NamedUserGroup = apps.get_model("zerver", "NamedUserGroup") + + BATCH_SIZE = 1000 + + max_id = Stream.objects.filter(can_subscribe_group=None).aggregate(Max("id"))["id__max"] + if max_id is None: + # Do nothing if there are no channels on the server. + return + + lower_bound = Stream.objects.filter(can_subscribe_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 Stream") + + with transaction.atomic(): + Stream.objects.filter( + id__range=(lower_bound, upper_bound), + can_subscribe_group=None, + ).update( + can_subscribe_group=NamedUserGroup.objects.filter( + name="role:nobody", + realm_for_sharding=OuterRef("realm_id"), + is_system_group=True, + ).values("pk") + ) + + lower_bound += BATCH_SIZE + + +class Migration(migrations.Migration): + atomic = False + + dependencies = [ + ("zerver", "0673_stream_can_subscribe_group"), + ] + + operations = [ + migrations.RunPython( + set_default_value_for_can_subscribe_group, + elidable=True, + reverse_code=migrations.RunPython.noop, + ), + ] diff --git a/zerver/migrations/0675_alter_stream_can_subscribe_group.py b/zerver/migrations/0675_alter_stream_can_subscribe_group.py new file mode 100644 index 0000000000..e12ecda918 --- /dev/null +++ b/zerver/migrations/0675_alter_stream_can_subscribe_group.py @@ -0,0 +1,22 @@ +# Generated by Django 5.0.10 on 2025-02-17 09:32 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("zerver", "0674_set_default_for_stream_can_subscribe_group"), + ] + + operations = [ + migrations.AlterField( + model_name="stream", + name="can_subscribe_group", + field=models.ForeignKey( + on_delete=django.db.models.deletion.RESTRICT, + related_name="+", + to="zerver.usergroup", + ), + ), + ] diff --git a/zerver/models/streams.py b/zerver/models/streams.py index 15b9ac049b..4c4c82d7a9 100644 --- a/zerver/models/streams.py +++ b/zerver/models/streams.py @@ -135,6 +135,7 @@ class Stream(models.Model): can_send_message_group = models.ForeignKey( UserGroup, on_delete=models.RESTRICT, related_name="+" ) + can_subscribe_group = models.ForeignKey(UserGroup, on_delete=models.RESTRICT, related_name="+") # The very first message ID in the stream. Used to help clients # determine whether they might need to display "show all topics" for a @@ -175,6 +176,13 @@ class Stream(models.Model): allow_everyone_group=True, default_group_name=SystemGroups.EVERYONE, ), + "can_subscribe_group": GroupPermissionSetting( + require_system_group=False, + allow_internet_group=False, + allow_nobody_group=True, + allow_everyone_group=False, + default_group_name=SystemGroups.NOBODY, + ), } stream_permission_group_settings_requiring_content_access = [ @@ -234,6 +242,7 @@ class Stream(models.Model): "can_administer_channel_group_id", "can_send_message_group_id", "can_remove_subscribers_group_id", + "can_subscribe_group_id", "is_recently_active", ] diff --git a/zerver/openapi/zulip.yaml b/zerver/openapi/zulip.yaml index ce57ebd202..74837255cf 100644 --- a/zerver/openapi/zulip.yaml +++ b/zerver/openapi/zulip.yaml @@ -718,6 +718,7 @@ paths: "stream_weekly_traffic": null, "can_add_subscribers_group": 2, "can_remove_subscribers_group": 2, + "can_subscribe_group": 2, "subscribers": [10], }, ], @@ -1355,6 +1356,7 @@ paths: "is_announcement_only": false, "can_add_subscribers_group": 2, "can_remove_subscribers_group": 2, + "can_subscribe_group": 2, "stream_weekly_traffic": null, }, ], @@ -10642,12 +10644,16 @@ paths: created. The initial [channel settings](/api/update-stream) will be determined by the optional parameters, like `invite_only`, detailed below. - Note that the ability to subscribe oneself and/or other users to a specified - channel depends on the [channel's type: private, public, or - web-public](/help/channel-permissions). + Note that the ability to subscribe oneself and/or other users + to a specified channel depends on the [channel's permissions + settings](/help/channel-permissions). - **Changes**: Before Zulip 10.0 (feature level 349), a user cannot - subscribe other users to a private channel without being subscribed + **Changes**: Before Zulip 10.0 (feature level 357), the + `can_subscribe_group` permission, which allows members of the + group to subscribe themselves to the channel, did not exist. + + Before Zulip 10.0 (feature level 349), a user cannot subscribe + other users to a private channel without being subscribed to that channel themselves. Now, If a user is part of `can_add_subscribers_group`, they can subscribe themselves or other users to a private channel without being subscribed to that channel. @@ -10798,6 +10804,8 @@ paths: $ref: "#/components/schemas/CanAdministerChannelGroup" can_send_message_group: $ref: "#/components/schemas/CanSendMessageGroup" + can_subscribe_group: + $ref: "#/components/schemas/CanSubscribeGroup" required: - subscriptions encoding: @@ -10825,6 +10833,8 @@ paths: contentType: application/json can_send_message_group: contentType: application/json + can_subscribe_group: + contentType: application/json responses: "200": description: Success. @@ -15374,6 +15384,7 @@ paths: can_remove_subscribers_group: {} can_administer_channel_group: {} can_send_message_group: {} + can_subscribe_group: {} stream_weekly_traffic: type: integer nullable: true @@ -20440,6 +20451,7 @@ paths: can_remove_subscribers_group: {} can_administer_channel_group: {} can_send_message_group: {} + can_subscribe_group: {} stream_weekly_traffic: type: integer nullable: true @@ -20481,6 +20493,7 @@ paths: - first_message_id - is_announcement_only - can_remove_subscribers_group + - can_subscribe_group - stream_weekly_traffic - is_recently_active example: @@ -20492,6 +20505,7 @@ paths: { "can_add_subscribers_group": 10, "can_remove_subscribers_group": 10, + "can_subscribe_group": 10, "creator_id": null, "date_created": 1691057093, "description": "A private channel", @@ -20513,6 +20527,7 @@ paths: { "can_add_subscribers_group": 9, "can_remove_subscribers_group": 9, + "can_subscribe_group": 10, "creator_id": 12, "date_created": 1691057093, "description": "A default public channel", @@ -20599,6 +20614,7 @@ paths: "stream_post_policy": 1, "can_add_subscribers_group": 2, "can_remove_subscribers_group": 2, + "can_subscribe_group": 2, "stream_weekly_traffic": null, }, } @@ -20861,6 +20877,32 @@ paths: } - $ref: "#/components/schemas/GroupSettingValueUpdate" + can_subscribe_group: + allOf: + - description: | + The set of users who have permission to subscribe themselves to this channel + expressed as an [update to a group-setting value][update-group-setting]. + + [update-group-setting]: /api/group-setting-values#updating-group-setting-values + + Everyone, excluding guests, can subscribe to any public channel + irrespective of this setting. + + Users in this group can subscribe to a private channel as well. + + Note that a user must [have content access](/help/channel-permissions) + to a channel and permission to administer the channel in order to + modify this setting. + + **Changes**: New in Zulip 10.0 (feature level 357). + example: + { + "new": + {"direct_members": [10], "direct_subgroups": [11]}, + "old": 15, + } + - $ref: "#/components/schemas/GroupSettingValueUpdate" + encoding: is_private: contentType: application/json @@ -20878,6 +20920,8 @@ paths: contentType: application/json can_send_message_group: contentType: application/json + can_subscribe_group: + contentType: application/json responses: "200": $ref: "#/components/responses/SimpleSuccess" @@ -22622,6 +22666,7 @@ components: can_remove_subscribers_group: {} can_administer_channel_group: {} can_send_message_group: {} + can_subscribe_group: {} stream_weekly_traffic: type: integer nullable: true @@ -22655,6 +22700,7 @@ components: - is_announcement_only - can_remove_subscribers_group - stream_weekly_traffic + - can_subscribe_group BasicChannelBase: type: object description: | @@ -22809,6 +22855,8 @@ components: $ref: "#/components/schemas/CanAdministerChannelGroup" can_send_message_group: $ref: "#/components/schemas/CanSendMessageGroup" + can_subscribe_group: + $ref: "#/components/schemas/CanSubscribeGroup" BasicBot: allOf: - $ref: "#/components/schemas/BasicBotBase" @@ -23873,6 +23921,8 @@ components: $ref: "#/components/schemas/CanAdministerChannelGroup" can_send_message_group: $ref: "#/components/schemas/CanSendMessageGroup" + can_subscribe_group: + $ref: "#/components/schemas/CanSubscribeGroup" is_archived: type: boolean description: | @@ -25607,6 +25657,25 @@ components: `stream_post_policy` field used to control the permission to post in the channel. + [setting-values]: /api/group-setting-values + CanSubscribeGroup: + allOf: + - $ref: "#/components/schemas/GroupSettingValue" + - description: | + A [group-setting value][setting-values] defining the set of users + who have permission to subscribe themselves to this channel. + + Everyone, excluding guests, can subscribe to any public channel + irrespective of this setting. + + Users in this group can subscribe to a private channel as well. + + Note that a user must [have content access](/help/channel-permissions) + to a channel and permission to administer the channel in order to + modify this setting. + + **Changes**: New in Zulip 10.0 (feature level 357). + [setting-values]: /api/group-setting-values LinkifierPattern: description: | diff --git a/zerver/views/streams.py b/zerver/views/streams.py index 0f3663e8a1..68b969835a 100644 --- a/zerver/views/streams.py +++ b/zerver/views/streams.py @@ -271,6 +271,7 @@ def update_stream_backend( can_administer_channel_group: Json[GroupSettingChangeRequest] | None = None, can_send_message_group: Json[GroupSettingChangeRequest] | None = None, can_remove_subscribers_group: Json[GroupSettingChangeRequest] | None = None, + can_subscribe_group: Json[GroupSettingChangeRequest] | None = None, ) -> HttpResponse: # Most settings updates only require metadata access, not content # access. We will check for content access further when and where @@ -602,6 +603,7 @@ def add_subscriptions_backend( can_administer_channel_group: Json[int | AnonymousSettingGroupDict] | None = None, can_send_message_group: Json[int | AnonymousSettingGroupDict] | None = None, can_remove_subscribers_group: Json[int | AnonymousSettingGroupDict] | None = None, + can_subscribe_group: Json[int | AnonymousSettingGroupDict] | None = None, announce: Json[bool] = False, principals: Json[list[str] | list[int]] | None = None, authorization_errors_fatal: Json[bool] = True, @@ -681,6 +683,7 @@ def add_subscriptions_backend( stream_dict_copy["can_remove_subscribers_group"] = group_settings_map[ "can_remove_subscribers_group" ] + stream_dict_copy["can_subscribe_group"] = group_settings_map["can_subscribe_group"] stream_dicts.append(stream_dict_copy)