diff --git a/api_docs/unmerged.d/ZF-2f0879.md b/api_docs/unmerged.d/ZF-2f0879.md new file mode 100644 index 0000000000..7ef97a7d9f --- /dev/null +++ b/api_docs/unmerged.d/ZF-2f0879.md @@ -0,0 +1,4 @@ +* [`GET /users/me/subscriptions`](/api/get-subscriptions), + [`GET /streams`](/api/get-streams), [`GET /events`](/api/get-events), + [`POST /register`](/api/register-queue): Added `folder_id` field + to Stream and Subscription objects. diff --git a/zerver/actions/streams.py b/zerver/actions/streams.py index f9a58a83af..0491db4f18 100644 --- a/zerver/actions/streams.py +++ b/zerver/actions/streams.py @@ -462,6 +462,7 @@ def send_subscription_add_events( date_created=stream_dict["date_created"], description=stream_dict["description"], first_message_id=stream_dict["first_message_id"], + folder_id=stream_dict["folder_id"], is_recently_active=stream_dict["is_recently_active"], history_public_to_subscribers=stream_dict["history_public_to_subscribers"], invite_only=stream_dict["invite_only"], diff --git a/zerver/lib/import_realm.py b/zerver/lib/import_realm.py index 7b3ae3bd7d..e49d1d65f5 100644 --- a/zerver/lib/import_realm.py +++ b/zerver/lib/import_realm.py @@ -1313,12 +1313,33 @@ def do_import_realm(import_dir: Path, subdomain: str, processes: int = 1) -> Rea if "zerver_usergroup" not in data: system_groups_name_dict = create_system_user_groups_for_realm(realm) + channel_folder_id_to_creator_id = {} + if "zerver_channelfolder" in data: + fix_datetime_fields(data, "zerver_channelfolder") + re_map_foreign_keys(data, "zerver_channelfolder", "realm", related_table="realm") + re_map_foreign_keys( + data, "zerver_channelfolder", "creator", related_table="user_profile" + ) + + # To correctly set .folder attribute for streams, we + # would need to create ChannelFolder objects before + # creating Stream objects. So we retain the .creator + # attribute data in a mapping, so that we can update it + # once the UserProfile objects are created. + for channel_folder in data["zerver_channelfolder"]: + creator_id = channel_folder.pop("creator_id", None) + channel_folder_id_to_creator_id[channel_folder["id"]] = creator_id + + bulk_import_model(data, ChannelFolder) + # Email tokens will automatically be randomly generated when the # Stream objects are created by Django. fix_datetime_fields(data, "zerver_stream") re_map_foreign_keys(data, "zerver_stream", "realm", related_table="realm") re_map_foreign_keys(data, "zerver_stream", "creator", related_table="user_profile") + re_map_foreign_keys(data, "zerver_stream", "folder", related_table="channelfolder") + # There's a circular dependency between Stream and UserProfile due to # the .creator attribute. We untangle it by first remembering the creator_id # for all the streams and then removing those fields from the data. @@ -1390,11 +1411,10 @@ def do_import_realm(import_dir: Path, subdomain: str, processes: int = 1) -> Rea stream.creator_id = stream_id_to_creator_id[stream.id] Stream.objects.bulk_update(streams, ["creator_id"]) - if "zerver_channelfolder" in data: - fix_datetime_fields(data, "zerver_channelfolder") - re_map_foreign_keys(data, "zerver_channelfolder", "realm", related_table="realm") - re_map_foreign_keys(data, "zerver_channelfolder", "creator", related_table="user_profile") - bulk_import_model(data, ChannelFolder) + channel_folders = ChannelFolder.objects.filter(id__in=channel_folder_id_to_creator_id.keys()) + for channel_folder in channel_folders: + channel_folder.creator_id = channel_folder_id_to_creator_id[channel_folder.id] + ChannelFolder.objects.bulk_update(channel_folders, ["creator_id"]) if "zerver_namedusergroup" in data: # UserProfiles have been loaded, so now we're ready to set .creator_id diff --git a/zerver/lib/streams.py b/zerver/lib/streams.py index 6cd6e7eafc..725b224f59 100644 --- a/zerver/lib/streams.py +++ b/zerver/lib/streams.py @@ -1507,6 +1507,7 @@ def stream_to_dict( date_created=datetime_to_timestamp(stream.date_created), description=stream.description, first_message_id=stream.first_message_id, + folder_id=stream.folder_id, is_recently_active=stream.is_recently_active, history_public_to_subscribers=stream.history_public_to_subscribers, invite_only=stream.invite_only, diff --git a/zerver/lib/subscription_info.py b/zerver/lib/subscription_info.py index 31520f166c..0b10ffb9b9 100644 --- a/zerver/lib/subscription_info.py +++ b/zerver/lib/subscription_info.py @@ -81,6 +81,7 @@ def get_web_public_subs( date_created = datetime_to_timestamp(stream.date_created) description = stream.description first_message_id = stream.first_message_id + folder_id = stream.folder_id is_recently_active = stream.is_recently_active history_public_to_subscribers = stream.history_public_to_subscribers invite_only = stream.invite_only @@ -124,6 +125,7 @@ def get_web_public_subs( desktop_notifications=desktop_notifications, email_notifications=email_notifications, first_message_id=first_message_id, + folder_id=folder_id, is_recently_active=is_recently_active, history_public_to_subscribers=history_public_to_subscribers, in_home_view=in_home_view, @@ -203,6 +205,7 @@ def build_stream_api_dict( date_created=datetime_to_timestamp(raw_stream_dict["date_created"]), description=raw_stream_dict["description"], first_message_id=raw_stream_dict["first_message_id"], + folder_id=raw_stream_dict["folder_id"], history_public_to_subscribers=raw_stream_dict["history_public_to_subscribers"], invite_only=raw_stream_dict["invite_only"], is_web_public=raw_stream_dict["is_web_public"], @@ -233,6 +236,7 @@ def build_stream_dict_for_sub( date_created = stream_dict["date_created"] description = stream_dict["description"] first_message_id = stream_dict["first_message_id"] + folder_id = stream_dict["folder_id"] history_public_to_subscribers = stream_dict["history_public_to_subscribers"] invite_only = stream_dict["invite_only"] is_web_public = stream_dict["is_web_public"] @@ -275,6 +279,7 @@ def build_stream_dict_for_sub( desktop_notifications=desktop_notifications, email_notifications=email_notifications, first_message_id=first_message_id, + folder_id=folder_id, is_recently_active=is_recently_active, history_public_to_subscribers=history_public_to_subscribers, in_home_view=in_home_view, @@ -304,6 +309,7 @@ def build_stream_dict_for_never_sub( date_created = datetime_to_timestamp(raw_stream_dict["date_created"]) description = raw_stream_dict["description"] first_message_id = raw_stream_dict["first_message_id"] + folder_id = raw_stream_dict["folder_id"] is_recently_active = raw_stream_dict["is_recently_active"] history_public_to_subscribers = raw_stream_dict["history_public_to_subscribers"] invite_only = raw_stream_dict["invite_only"] @@ -352,6 +358,7 @@ def build_stream_dict_for_never_sub( date_created=date_created, description=description, first_message_id=first_message_id, + folder_id=folder_id, is_recently_active=is_recently_active, history_public_to_subscribers=history_public_to_subscribers, invite_only=invite_only, diff --git a/zerver/lib/types.py b/zerver/lib/types.py index 29e2f566bf..4b1c447111 100644 --- a/zerver/lib/types.py +++ b/zerver/lib/types.py @@ -168,6 +168,7 @@ class RawStreamDict(TypedDict): deactivated: bool description: str first_message_id: int | None + folder_id: int | None is_recently_active: bool history_public_to_subscribers: bool id: int @@ -216,6 +217,7 @@ class SubscriptionStreamDict(TypedDict): desktop_notifications: bool | None email_notifications: bool | None first_message_id: int | None + folder_id: int | None is_recently_active: bool history_public_to_subscribers: bool in_home_view: bool @@ -248,6 +250,7 @@ class NeverSubscribedStreamDict(TypedDict): date_created: int description: str first_message_id: int | None + folder_id: int | None is_recently_active: bool history_public_to_subscribers: bool invite_only: bool @@ -279,6 +282,7 @@ class DefaultStreamDict(TypedDict): date_created: int description: str first_message_id: int | None + folder_id: int | None is_recently_active: bool history_public_to_subscribers: bool invite_only: bool diff --git a/zerver/migrations/0708_stream_folder.py b/zerver/migrations/0708_stream_folder.py new file mode 100644 index 0000000000..c5a025ad02 --- /dev/null +++ b/zerver/migrations/0708_stream_folder.py @@ -0,0 +1,20 @@ +# Generated by Django 5.1.8 on 2025-05-16 07:41 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("zerver", "0707_realmauditlog_modified_channel_folder"), + ] + + operations = [ + migrations.AddField( + model_name="stream", + name="folder", + field=models.ForeignKey( + null=True, on_delete=django.db.models.deletion.SET_NULL, to="zerver.channelfolder" + ), + ), + ] diff --git a/zerver/models/streams.py b/zerver/models/streams.py index 038a61be8e..343b75f18f 100644 --- a/zerver/models/streams.py +++ b/zerver/models/streams.py @@ -11,6 +11,7 @@ from typing_extensions import override from zerver.lib.cache import flush_stream from zerver.lib.types import GroupPermissionSetting +from zerver.models.channel_folders import ChannelFolder from zerver.models.groups import SystemGroups, UserGroup from zerver.models.realms import Realm from zerver.models.recipients import Recipient @@ -41,6 +42,8 @@ class Stream(models.Model): # Foreign key to the Recipient object for STREAM type messages to this stream. recipient = models.ForeignKey(Recipient, null=True, on_delete=models.SET_NULL) + folder = models.ForeignKey(ChannelFolder, null=True, on_delete=models.SET_NULL) + # Various permission policy configurations PERMISSION_POLICIES: dict[str, dict[str, Any]] = { "web_public": { @@ -238,6 +241,7 @@ class Stream(models.Model): "deactivated", "description", "first_message_id", + "folder_id", "history_public_to_subscribers", "id", "invite_only", diff --git a/zerver/openapi/zulip.yaml b/zerver/openapi/zulip.yaml index dc50ad1693..a55d1ff960 100644 --- a/zerver/openapi/zulip.yaml +++ b/zerver/openapi/zulip.yaml @@ -692,6 +692,7 @@ paths: "stream_post_policy": 1, "history_public_to_subscribers": true, "first_message_id": null, + "folder_id": 1, "is_recently_active": true, "message_retention_days": null, "is_announcement_only": false, @@ -1358,6 +1359,7 @@ paths: "stream_post_policy": 1, "history_public_to_subscribers": false, "first_message_id": null, + "folder_id": 1, "is_recently_active": true, "message_retention_days": null, "is_announcement_only": false, @@ -15837,6 +15839,8 @@ paths: history_public_to_subscribers: {} first_message_id: nullable: true + folder_id: + nullable: true is_recently_active: {} is_announcement_only: {} can_add_subscribers_group: {} @@ -21036,6 +21040,8 @@ paths: history_public_to_subscribers: {} first_message_id: nullable: true + folder_id: + nullable: true is_recently_active: {} is_announcement_only: {} can_add_subscribers_group: {} @@ -21082,6 +21088,7 @@ paths: - message_retention_days - history_public_to_subscribers - first_message_id + - folder_id - is_announcement_only - can_remove_subscribers_group - can_subscribe_group @@ -21101,6 +21108,7 @@ paths: "date_created": 1691057093, "description": "A private channel", "first_message_id": 18, + "folder_id": 1, "is_recently_active": true, "history_public_to_subscribers": false, "invite_only": true, @@ -21123,6 +21131,7 @@ paths: "date_created": 1691057093, "description": "A default public channel", "first_message_id": 21, + "folder_id": null, "is_recently_active": true, "history_public_to_subscribers": true, "invite_only": false, @@ -21173,6 +21182,7 @@ paths: { "description": "A Scandinavian country", "first_message_id": 1, + "folder_id": 1, "is_recently_active": true, "history_public_to_subscribers": true, "date_created": 1691057093, @@ -23477,6 +23487,8 @@ components: history_public_to_subscribers: {} first_message_id: nullable: true + folder_id: + nullable: true is_recently_active: {} is_announcement_only: {} can_add_subscribers_group: {} @@ -23513,6 +23525,7 @@ components: - message_retention_days - history_public_to_subscribers - first_message_id + - folder_id - is_recently_active - is_announcement_only - can_remove_subscribers_group @@ -23646,6 +23659,8 @@ components: Is `null` for channels with no message history. **Changes**: New in Zulip 2.1.0. + folder_id: + $ref: "#/components/schemas/FolderId" is_recently_active: type: boolean description: | @@ -24712,6 +24727,8 @@ components: has older history that can be accessed. Is `null` for channels with no message history. + folder_id: + $ref: "#/components/schemas/FolderId" is_recently_active: type: boolean description: | @@ -26578,6 +26595,15 @@ components: named groups' content could be inserted with `%(name_of_group)s`. type: string example: https://github.com/zulip/zulip/issues/{id} + FolderId: + type: integer + nullable: true + description: | + The ID of the folder to which the channel belongs. + + Is `null` if channel does not belong to any folder. + + **Changes**: New in Zulip 11.0 (feature level ZF-2f0879). ################### # Shared responses diff --git a/zerver/tests/test_import_export.py b/zerver/tests/test_import_export.py index 66391907fd..812d0d0e8b 100644 --- a/zerver/tests/test_import_export.py +++ b/zerver/tests/test_import_export.py @@ -1619,12 +1619,14 @@ class RealmImportExportTest(ExportFile): flags=OnboardingUserMessage.flags.starred, ) - ChannelFolder.objects.create( + channel_folder = ChannelFolder.objects.create( realm=original_realm, name="Frontend", description="Frontend channels", creator=self.example_user("iago"), ) + stream.folder = channel_folder + stream.save() # We want to have an extra, malformed RealmEmoji with no .author # to test that upon import that gets fixed. @@ -1759,6 +1761,15 @@ class RealmImportExportTest(ExportFile): Recipient.objects.get(type=Recipient.STREAM, type_id=stream.id).id, ) + # Check folder field for imported streams + for stream in Stream.objects.filter(realm=imported_realm): + if stream.name == "Verona": + # Folder was only set for "Verona" stream in original realm. + assert stream.folder is not None + self.assertEqual(stream.folder.name, "Frontend") + else: + self.assertIsNone(stream.folder_id) + for dm_group in DirectMessageGroup.objects.all(): # Direct Message groups don't have a realm column, so we just test all # Direct Message groups for simplicity.