streams: Add folder foreign key to Stream table.

This commit also adds "folder_id" field in stream and subscription
objects.

Fixes part of #31972.
This commit is contained in:
Sahil Batra
2025-05-15 18:42:27 +05:30
committed by Tim Abbott
parent 5de0f265bd
commit 202bebda89
10 changed files with 104 additions and 6 deletions

View File

@@ -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.

View File

@@ -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"],

View File

@@ -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

View File

@@ -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,

View File

@@ -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,

View File

@@ -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

View File

@@ -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"
),
),
]

View File

@@ -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",

View File

@@ -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

View File

@@ -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.