channel_folder: Add API to create a channel folder.

This commit also includes code to include channel_folders
data in register response.

Fixes part of #31972.
This commit is contained in:
Sahil Batra
2025-05-02 19:20:22 +05:30
committed by Tim Abbott
parent 350f6a1fa1
commit 332abd9e91
17 changed files with 471 additions and 4 deletions

View File

@@ -63,6 +63,7 @@
* [Delete a topic](/api/delete-topic)
* [Add a default channel](/api/add-default-stream)
* [Remove a default channel](/api/remove-default-stream)
* [Create a channel folder](/api/create-channel-folder)
#### Users

View File

@@ -0,0 +1,8 @@
* [`POST /channel_folders/create`](/api/create-channel-folder): Added
a new endpoint for creating a new channel folder.
* [`GET /events`](/api/get-events): An event with `type: "channel_folder"` is
sent to all users when a channel folder is created.
* [`POST /register`](/api/register-queue): Added `channel_folders` field to
response.

View File

@@ -0,0 +1,43 @@
from django.db import transaction
from django.utils.timezone import now as timezone_now
from zerver.lib.channel_folders import get_channel_folder_dict, render_channel_folder_description
from zerver.models import ChannelFolder, RealmAuditLog, UserProfile
from zerver.models.realm_audit_logs import AuditLogEventType
from zerver.models.users import active_user_ids
from zerver.tornado.django_api import send_event_on_commit
@transaction.atomic(durable=True)
def check_add_channel_folder(
name: str, description: str, *, acting_user: UserProfile
) -> ChannelFolder:
realm = acting_user.realm
rendered_description = render_channel_folder_description(
description, realm, acting_user=acting_user
)
channel_folder = ChannelFolder.objects.create(
realm=realm,
name=name,
description=description,
rendered_description=rendered_description,
creator_id=acting_user.id,
)
creation_time = timezone_now()
RealmAuditLog.objects.create(
realm=realm,
acting_user=acting_user,
event_type=AuditLogEventType.CHANNEL_FOLDER_CREATED,
event_time=creation_time,
modified_channel_folder=channel_folder,
)
event = dict(
type="channel_folder",
op="add",
channel_folder=get_channel_folder_dict(channel_folder),
)
send_event_on_commit(realm, event, active_user_ids(realm.id))
return channel_folder

View File

@@ -0,0 +1,60 @@
from typing import TypedDict
from django.utils.translation import gettext as _
from zerver.lib.exceptions import JsonableError
from zerver.lib.markdown import markdown_convert
from zerver.lib.string_validation import check_string_is_printable
from zerver.lib.timestamp import datetime_to_timestamp
from zerver.models import ChannelFolder, Realm, UserProfile
class ChannelFolderDict(TypedDict):
id: int
name: str
description: str
rendered_description: str
creator_id: int | None
date_created: int
is_archived: bool
def check_channel_folder_name(name: str, realm: Realm) -> None:
if name.strip() == "":
raise JsonableError(_("Channel folder name can't be empty."))
invalid_character_pos = check_string_is_printable(name)
if invalid_character_pos is not None:
raise JsonableError(
_("Invalid character in channel folder name, at position {position}.").format(
position=invalid_character_pos
)
)
if ChannelFolder.objects.filter(name__iexact=name, realm=realm).exists():
raise JsonableError(_("Channel folder '{name}' already exists").format(name=name))
def render_channel_folder_description(text: str, realm: Realm, *, acting_user: UserProfile) -> str:
return markdown_convert(
text, message_realm=realm, no_previews=True, acting_user=acting_user
).rendered_content
def get_channel_folder_dict(channel_folder: ChannelFolder) -> ChannelFolderDict:
date_created = datetime_to_timestamp(channel_folder.date_created)
return ChannelFolderDict(
id=channel_folder.id,
name=channel_folder.name,
description=channel_folder.description,
rendered_description=channel_folder.rendered_description,
date_created=date_created,
creator_id=channel_folder.creator_id,
is_archived=channel_folder.is_archived,
)
def get_channel_folders_in_realm(realm: Realm) -> list[ChannelFolderDict]:
folders = ChannelFolder.objects.filter(realm=realm)
channel_folders = [get_channel_folder_dict(channel_folder) for channel_folder in folders]
return sorted(channel_folders, key=lambda folder: folder["id"])

View File

@@ -23,6 +23,7 @@ from zerver.lib.event_types import (
EventAttachmentAdd,
EventAttachmentRemove,
EventAttachmentUpdate,
EventChannelFolderAdd,
EventCustomProfileFields,
EventDefaultStreamGroups,
EventDefaultStreams,
@@ -161,6 +162,7 @@ check_alert_words = make_checker(EventAlertWords)
check_attachment_add = make_checker(EventAttachmentAdd)
check_attachment_remove = make_checker(EventAttachmentRemove)
check_attachment_update = make_checker(EventAttachmentUpdate)
check_channel_folder_add = make_checker(EventChannelFolderAdd)
check_custom_profile_fields = make_checker(EventCustomProfileFields)
check_default_stream_groups = make_checker(EventDefaultStreamGroups)
check_default_streams = make_checker(EventDefaultStreams)

View File

@@ -66,6 +66,22 @@ class EventAttachmentUpdate(BaseEvent):
upload_space_used: int
class ChannelFolderForEventChannelFolderAdd(BaseModel):
id: int
name: str
description: str
rendered_description: str
date_created: int
creator_id: int
is_archived: bool
class EventChannelFolderAdd(BaseEvent):
type: Literal["channel_folder"]
op: Literal["add"]
channel_folder: ChannelFolderForEventChannelFolderAdd
class DetailedCustomProfileCore(BaseModel):
id: int
type: int

View File

@@ -19,6 +19,7 @@ from zerver.lib import emoji
from zerver.lib.alert_words import user_alert_words
from zerver.lib.avatar import avatar_url
from zerver.lib.bot_config import load_bot_config_template
from zerver.lib.channel_folders import get_channel_folders_in_realm
from zerver.lib.compatibility import is_outdated_server
from zerver.lib.default_streams import get_default_stream_ids_for_realm
from zerver.lib.exceptions import JsonableError
@@ -764,6 +765,11 @@ def fetch_initial_state_data(
state["unsubscribed"] = sub_info.unsubscribed
state["never_subscribed"] = sub_info.never_subscribed
if want("channel_folders") and user_profile is not None:
# TODO: Spectators should get the channel folders that
# contain atleast one web-public channel.
state["channel_folders"] = get_channel_folders_in_realm(user_profile.realm)
if want("update_message_flags") and want("message"):
# Keeping unread_msgs updated requires both message flag updates and
# message updates. This is due to the fact that new messages will not
@@ -1870,6 +1876,12 @@ def apply_event(
else:
fields = ["stream_id", "topic_name", "visibility_policy", "last_updated"]
state["user_topics"].append({x: event[x] for x in fields})
elif event["type"] == "channel_folder":
if event["op"] == "add":
state["channel_folders"].append(event["channel_folder"])
state["channel_folders"].sort(key=lambda folder: folder["id"])
else:
raise AssertionError("Unexpected event type {type}/{op}".format(**event))
elif event["type"] == "has_zoom_token":
state["has_zoom_token"] = event["value"]
elif event["type"] == "web_reload_client":

View File

@@ -0,0 +1,20 @@
# Generated by Django 5.1.8 on 2025-05-05 13:27
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("zerver", "0706_channelfolder"),
]
operations = [
migrations.AddField(
model_name="realmauditlog",
name="modified_channel_folder",
field=models.ForeignKey(
null=True, on_delete=django.db.models.deletion.CASCADE, to="zerver.channelfolder"
),
),
]

View File

@@ -5,6 +5,7 @@ from django.db import models
from django.db.models import CASCADE, Q
from typing_extensions import override
from zerver.models.channel_folders import ChannelFolder
from zerver.models.groups import NamedUserGroup
from zerver.models.realms import Realm
from zerver.models.streams import Stream
@@ -113,6 +114,8 @@ class AuditLogEventType(IntEnum):
SAVED_SNIPPET_CREATED = 800
CHANNEL_FOLDER_CREATED = 901
# The following values are only for remote server/realm logs.
# Values should be exactly 10000 greater than the corresponding
# value used for the same purpose in realm audit logs (e.g.,
@@ -233,6 +236,11 @@ class RealmAuditLog(AbstractRealmAuditLog):
null=True,
on_delete=CASCADE,
)
modified_channel_folder = models.ForeignKey(
ChannelFolder,
null=True,
on_delete=CASCADE,
)
event_last_message_id = models.IntegerField(null=True)
class Meta:

View File

@@ -5716,6 +5716,42 @@ paths:
"op": "remove",
"scheduled_message_id": 17,
}
- type: object
additionalProperties: false
description: |
Event sent to users in an organization when a channel folder is created.
**Changes**: New in Zulip 11.0 (feature level ZF-392de9).
properties:
id:
$ref: "#/components/schemas/EventIdSchema"
type:
allOf:
- $ref: "#/components/schemas/EventTypeSchema"
- enum:
- channel_folder
op:
type: string
enum:
- add
channel_folder:
$ref: "#/components/schemas/ChannelFolder"
example:
{
"type": "channel_folder",
"op": "add",
"channel_folder":
{
"name": "fronted",
"creator_id": 9,
"date_created": 1717484476,
"description": "Channels for frontend discussions",
"rendered_description": "<p>Channels for frontend discussions</p>",
"id": 2,
"is_archived": false,
},
"id": 0,
}
queue_id:
type: string
description: |
@@ -15793,6 +15829,17 @@ paths:
in `can_administer_channel_group` of a channel that they never
subscribed to, but not an organization administrator, the channel
in question would not be part of this array.
channel_folders:
type: array
items:
$ref: "#/components/schemas/ChannelFolder"
description: |
Present if `channel_folders` is present in `fetch_event_types`.
An array of dictionaries where each dictionary describes one
of the channel folders in the organization.
**Changes**: New in Zulip 11.0 (feature level ZF-392de9).
unread_msgs:
type: object
description: |
@@ -22819,6 +22866,76 @@ paths:
**Changes**: New in Zulip 10.0 (feature level 298). Previously,
this error returned the `"BAD_REQUEST"` code.
/channel_folders/create:
post:
operationId: create-channel-folder
summary: Create a channel folder
tags: ["channels"]
description: |
Create a new channel folder, that will be used to organize
channels in left sidebar.
Only organization administrators can create a new channel
folder.
**Changes**: New in Zulip 11.0 (feature level ZF-392de9).
requestBody:
required: true
content:
application/x-www-form-urlencoded:
schema:
type: object
properties:
name:
description: |
The name of the channel folder.
type: string
example: marketing
description:
description: |
The description of the channel folder.
type: string
example: Channels for marketing.
responses:
"200":
description: |
A success response containing the unique ID of the channel folder.
This field provides a straightforward way to reference the
newly created channel folder.
content:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/JsonSuccessBase"
- required:
- channel_folder_id
additionalProperties: false
properties:
result: {}
msg: {}
ignored_parameters_unsupported: {}
channel_folder_id:
type: integer
description: |
The unique ID of the created channel folder.
example:
{"msg": "", "result": "success", "channel_folder_id": 123}
"400":
description: Bad request.
content:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/CodedError"
- example:
{
"result": "error",
"msg": "Must be an organization administrator",
"code": "UNAUTHORIZED_PRINCIPAL",
}
description: |
Error when the user does not have permission
to create a channel folder:
/real-time:
# This entry is a hack; it exists to give us a place to put the text
# documenting the parameters for call_on_each_event and friends.
@@ -25490,6 +25607,51 @@ components:
This user-generated HTML content should be rendered
using the same CSS and client-side security protections
as are used for message content.
ChannelFolder:
type: object
additionalProperties: false
description: |
Object containing the channel folder's attributes.
properties:
name:
type: string
description: |
The name of the channel folder.
date_created:
type: integer
nullable: true
description: |
The UNIX timestamp for when the channel folder was created,
in UTC seconds.
creator_id:
type: integer
nullable: true
description: |
The ID of the user who created this channel folder.
description:
type: string
description: |
The description of the channel folder.
rendered_description:
type: string
description: |
The description of the channel folder rendered as HTML,
intended to be used when displaying the channel folder
description in a UI.
One should use the standard Zulip rendered_markdown CSS when
displaying this content so that emoji, LaTeX, and other syntax
work correctly. And any client-side security logic for
user-generated message content should be applied when displaying
this HTML as though it were the body of a Zulip message.
id:
type: integer
description: |
The ID of the channel folder.
is_archived:
type: boolean
description: |
Whether the channel folder is archived or not.
JsonResponseBase:
type: object
properties:

View File

@@ -12,6 +12,7 @@ from zerver.actions.bots import (
do_change_default_events_register_stream,
do_change_default_sending_stream,
)
from zerver.actions.channel_folders import check_add_channel_folder
from zerver.actions.create_realm import do_create_realm
from zerver.actions.create_user import (
do_activate_mirror_dummy_user,
@@ -1537,3 +1538,23 @@ class TestRealmAuditLog(ZulipTestCase):
self.assert_length(audit_log_entries, 1)
self.assertIsNone(audit_log_entries[0].modified_user)
self.assertEqual(audit_log_entries[0].modified_user_group, user_group)
def test_channel_folders(self) -> None:
iago = self.example_user("iago")
now = timezone_now()
channel_folder = check_add_channel_folder(
"Frontend",
"Channels for frontend discussions",
acting_user=iago,
)
audit_log_entries = RealmAuditLog.objects.filter(
acting_user=iago,
realm=iago.realm,
event_time__gte=now,
event_type=AuditLogEventType.CHANNEL_FOLDER_CREATED,
)
self.assert_length(audit_log_entries, 1)
self.assertIsNone(audit_log_entries[0].modified_user)
self.assertIsNone(audit_log_entries[0].modified_user_group)
self.assertEqual(audit_log_entries[0].modified_channel_folder, channel_folder)

View File

@@ -0,0 +1,72 @@
import orjson
from zerver.lib.test_classes import ZulipTestCase
from zerver.models import ChannelFolder
from zerver.models.realms import get_realm
class ChannelFolderCreationTest(ZulipTestCase):
def test_creating_channel_folder(self) -> None:
self.login("shiva")
realm = get_realm("zulip")
params = {"name": "Frontend", "description": ""}
result = self.client_post("/json/channel_folders/create", params)
self.assert_json_error(result, "Must be an organization administrator")
self.login("iago")
result = self.client_post("/json/channel_folders/create", params)
self.assert_json_success(result)
channel_folder = ChannelFolder.objects.filter(realm=realm).last()
assert channel_folder is not None
self.assertEqual(channel_folder.name, "Frontend")
self.assertEqual(channel_folder.description, "")
response = orjson.loads(result.content)
self.assertEqual(response["channel_folder_id"], channel_folder.id)
def test_creating_channel_folder_with_duplicate_name(self) -> None:
self.login("iago")
realm = get_realm("zulip")
params = {"name": "Frontend", "description": ""}
result = self.client_post("/json/channel_folders/create", params)
self.assert_json_success(result)
self.assertTrue(ChannelFolder.objects.filter(realm=realm, name="Frontend").exists())
result = self.client_post("/json/channel_folders/create", params)
self.assert_json_error(result, "Channel folder 'Frontend' already exists")
def test_rendered_description_for_channel_folder(self) -> None:
self.login("iago")
realm = get_realm("zulip")
params = {"name": "Frontend", "description": "Channels for frontend discussions"}
result = self.client_post("/json/channel_folders/create", params)
self.assert_json_success(result)
channel_folder = ChannelFolder.objects.get(realm=realm, name="Frontend")
self.assertEqual(channel_folder.description, "Channels for frontend discussions")
self.assertEqual(
channel_folder.rendered_description, "<p>Channels for frontend discussions</p>"
)
params = {"name": "Backend", "description": "Channels for **backend** discussions"}
result = self.client_post("/json/channel_folders/create", params)
self.assert_json_success(result)
channel_folder = ChannelFolder.objects.get(realm=realm, name="Backend")
self.assertEqual(channel_folder.description, "Channels for **backend** discussions")
self.assertEqual(
channel_folder.rendered_description,
"<p>Channels for <strong>backend</strong> discussions</p>",
)
def test_invalid_names_for_channel_folder(self) -> None:
self.login("iago")
params = {"name": "", "description": "Channels for frontend discussions"}
result = self.client_post("/json/channel_folders/create", params)
self.assert_json_error(result, "Channel folder name can't be empty.")
invalid_name = "abc\000"
params = {"name": invalid_name, "description": "Channels for frontend discussions"}
result = self.client_post("/json/channel_folders/create", params)
self.assert_json_error(result, "Invalid character in channel folder name, at position 4.")

View File

@@ -1213,13 +1213,14 @@ class FetchQueriesTest(ZulipTestCase):
self.login_user(user)
with (
self.assert_database_query_count(44),
self.assert_database_query_count(45),
mock.patch("zerver.lib.events.always_want") as want_mock,
):
fetch_initial_state_data(user, realm=user.realm)
expected_counts = dict(
alert_words=1,
channel_folders=1,
custom_profile_fields=1,
default_streams=1,
default_stream_groups=1,

View File

@@ -27,6 +27,7 @@ from zerver.actions.bots import (
do_change_default_events_register_stream,
do_change_default_sending_stream,
)
from zerver.actions.channel_folders import check_add_channel_folder
from zerver.actions.create_user import do_create_user, do_reactivate_user
from zerver.actions.custom_profile_fields import (
check_remove_custom_profile_field_value,
@@ -154,6 +155,7 @@ from zerver.lib.event_schema import (
check_attachment_add,
check_attachment_remove,
check_attachment_update,
check_channel_folder_add,
check_custom_profile_fields,
check_default_stream_groups,
check_default_streams,
@@ -5433,3 +5435,12 @@ class ScheduledMessagesEventsTest(BaseAction):
with self.verify_action() as events:
delete_scheduled_message(self.user_profile, scheduled_message_id)
check_scheduled_message_remove("events[0]", events[0])
class ChannelFolderActionTest(BaseAction):
def test_channel_folder_creation_event(self) -> None:
folder_name = "Frontend"
folder_description = "Channels for **frontend** discussions"
with self.verify_action() as events:
check_add_channel_folder(folder_name, folder_description, acting_user=self.user_profile)
check_channel_folder_add("events[0]", events[0])

View File

@@ -77,6 +77,7 @@ class HomeTest(ZulipTestCase):
"can_create_streams",
"can_create_web_public_streams",
"can_invite_others_to_realm",
"channel_folders",
"cross_realm_bots",
"custom_profile_field_types",
"custom_profile_fields",
@@ -279,7 +280,7 @@ class HomeTest(ZulipTestCase):
# Verify succeeds once logged-in
with (
self.assert_database_query_count(54),
self.assert_database_query_count(55),
patch("zerver.lib.cache.cache_set") as cache_mock,
):
result = self._get_home_page(stream="Denmark")
@@ -587,7 +588,7 @@ class HomeTest(ZulipTestCase):
# Verify number of queries for Realm admin isn't much higher than for normal users.
self.login("iago")
with (
self.assert_database_query_count(53),
self.assert_database_query_count(54),
patch("zerver.lib.cache.cache_set") as cache_mock,
):
result = self._get_home_page()
@@ -619,7 +620,7 @@ class HomeTest(ZulipTestCase):
self._get_home_page()
# Then for the second page load, measure the number of queries.
with self.assert_database_query_count(49):
with self.assert_database_query_count(50):
result = self._get_home_page()
# Do a sanity check that our new streams were in the payload.

View File

@@ -0,0 +1,27 @@
from typing import Annotated
from django.http import HttpRequest, HttpResponse
from pydantic import StringConstraints
from zerver.actions.channel_folders import check_add_channel_folder
from zerver.decorator import require_realm_admin
from zerver.lib.channel_folders import check_channel_folder_name
from zerver.lib.response import json_success
from zerver.lib.typed_endpoint import typed_endpoint
from zerver.models.channel_folders import ChannelFolder
from zerver.models.users import UserProfile
@require_realm_admin
@typed_endpoint
def create_channel_folder(
request: HttpRequest,
user_profile: UserProfile,
*,
name: Annotated[str, StringConstraints(max_length=ChannelFolder.MAX_NAME_LENGTH)],
description: Annotated[str, StringConstraints(max_length=ChannelFolder.MAX_DESCRIPTION_LENGTH)],
) -> HttpResponse:
check_channel_folder_name(name, user_profile.realm)
channel_folder = check_add_channel_folder(name, description, acting_user=user_profile)
return json_success(request, data={"channel_folder_id": channel_folder.id})

View File

@@ -45,6 +45,7 @@ from zerver.views.auth import (
start_social_login,
start_social_signup,
)
from zerver.views.channel_folders import create_channel_folder
from zerver.views.compatibility import check_global_compatibility
from zerver.views.custom_profile_fields import (
create_realm_custom_profile_field,
@@ -530,6 +531,7 @@ v1_api_and_json_patterns = [
PATCH=update_subscriptions_backend,
DELETE=remove_subscriptions_backend,
),
rest_path("channel_folders/create", POST=create_channel_folder),
# topic-muting -> zerver.views.user_topics
# (deprecated and will be removed once clients are migrated to use '/user_topics')
rest_path("users/me/subscriptions/muted_topics", PATCH=update_muted_topic),