From 22b231ab6f8675627f44e4dcd4cd7627c602e9e2 Mon Sep 17 00:00:00 2001 From: Shubham Padia Date: Wed, 6 Aug 2025 08:04:39 +0000 Subject: [PATCH] channel-folders: Add PATCH method to reorder channel folders. The test cases are copied from ReorderCustomProfileFieldTest since we are imitating the reordering mechanism from custom profile fields to channel folders. --- api_docs/include/rest-endpoints.md | 1 + api_docs/unmerged.d/ZF-fcae8c.md | 3 + zerver/actions/channel_folders.py | 18 +++++ zerver/openapi/curl_param_value_generators.py | 24 +++++++ zerver/openapi/zulip.yaml | 46 +++++++++++++ zerver/tests/test_channel_folders.py | 68 ++++++++++++++++++- zerver/views/channel_folders.py | 13 ++++ zproject/urls.py | 3 +- 8 files changed, 174 insertions(+), 2 deletions(-) diff --git a/api_docs/include/rest-endpoints.md b/api_docs/include/rest-endpoints.md index 8bf227d31c..f76284b1f2 100644 --- a/api_docs/include/rest-endpoints.md +++ b/api_docs/include/rest-endpoints.md @@ -74,6 +74,7 @@ * [Remove a default channel](/api/remove-default-stream) * [Create a channel folder](/api/create-channel-folder) * [Get channel folders](/api/get-channel-folders) +* [Reorder channel folders](/api/patch-channel-folders) * [Update a channel folder](/api/update-channel-folder) #### Users diff --git a/api_docs/unmerged.d/ZF-fcae8c.md b/api_docs/unmerged.d/ZF-fcae8c.md index a905cfd6be..6747033685 100644 --- a/api_docs/unmerged.d/ZF-fcae8c.md +++ b/api_docs/unmerged.d/ZF-fcae8c.md @@ -4,3 +4,6 @@ Added a new field `order` to show in which order should channel folders be displayed. The list is 0-indexed and works similar to the `order` field of custom profile fields. +* [`PATCH /channel_folders`](/api/patch-channel-folders): Added a new + endpoint for reordering channel folders. It accepts an array of channel + folder IDs arranged in the order the user desires it to be in. diff --git a/zerver/actions/channel_folders.py b/zerver/actions/channel_folders.py index 6ac4025a67..b5e6b9b9fd 100644 --- a/zerver/actions/channel_folders.py +++ b/zerver/actions/channel_folders.py @@ -1,7 +1,11 @@ +from collections.abc import Iterable + from django.db import transaction from django.utils.timezone import now as timezone_now +from django.utils.translation import gettext as _ from zerver.lib.channel_folders import get_channel_folder_dict, render_channel_folder_description +from zerver.lib.exceptions import JsonableError from zerver.models import ChannelFolder, Realm, RealmAuditLog, UserProfile from zerver.models.realm_audit_logs import AuditLogEventType from zerver.models.users import active_user_ids @@ -22,6 +26,8 @@ def check_add_channel_folder( rendered_description=rendered_description, creator_id=acting_user.id, ) + channel_folder.order = channel_folder.id + channel_folder.save(update_fields=["order"]) creation_time = timezone_now() RealmAuditLog.objects.create( @@ -42,6 +48,18 @@ def check_add_channel_folder( return channel_folder +@transaction.atomic(durable=True) +def try_reorder_realm_channel_folders(realm: Realm, order: Iterable[int]) -> None: + order_mapping = {_[1]: _[0] for _ in enumerate(order)} + channel_folders = ChannelFolder.objects.filter(realm=realm) + for channel_folder in channel_folders: + if channel_folder.id not in order_mapping: + raise JsonableError(_("Invalid order mapping.")) + for channel_folder in channel_folders: + channel_folder.order = order_mapping[channel_folder.id] + channel_folder.save(update_fields=["order"]) + + def do_send_channel_folder_update_event( channel_folder: ChannelFolder, data: dict[str, str | bool] ) -> None: diff --git a/zerver/openapi/curl_param_value_generators.py b/zerver/openapi/curl_param_value_generators.py index f4ab3b5beb..0d47965007 100644 --- a/zerver/openapi/curl_param_value_generators.py +++ b/zerver/openapi/curl_param_value_generators.py @@ -11,6 +11,7 @@ from typing import Any from django.utils.timezone import now as timezone_now +from zerver.actions.channel_folders import check_add_channel_folder from zerver.actions.create_user import do_create_user from zerver.actions.presence import update_user_presence from zerver.actions.reactions import do_add_reaction @@ -21,6 +22,7 @@ from zerver.lib.initial_password import initial_password from zerver.lib.test_classes import ZulipTestCase from zerver.lib.upload import upload_message_attachment from zerver.models import Client, Message, NamedUserGroup, UserPresence +from zerver.models.channel_folders import ChannelFolder from zerver.models.realms import get_realm from zerver.models.users import UserProfile, get_user from zerver.openapi.openapi import Parameter @@ -389,3 +391,25 @@ def remove_attachment() -> dict[str, object]: attachment_id = url.replace("/user_uploads/", "").split("/")[0] return {"attachment_id": attachment_id} + + +@openapi_param_value_generator(["/channel_folders:patch"]) +def add_channel_folders() -> dict[str, object]: + user_profile = helpers.example_user("iago") + realm = user_profile.realm + check_add_channel_folder( + realm, + "General", + "Channel for general discussions", + acting_user=user_profile, + ) + check_add_channel_folder( + realm, + "Documentation", + "Channels for **documentation** discussions", + acting_user=user_profile, + ) + check_add_channel_folder(realm, "Memes", "Channels for sharing memes", acting_user=user_profile) + channel_folders = ChannelFolder.objects.filter(realm=realm) + + return {"order": [folder.id for folder in channel_folders]} diff --git a/zerver/openapi/zulip.yaml b/zerver/openapi/zulip.yaml index 355264a1eb..0f02a1547e 100644 --- a/zerver/openapi/zulip.yaml +++ b/zerver/openapi/zulip.yaml @@ -24516,6 +24516,52 @@ paths: }, ], } + patch: + operationId: patch-channel-folders + summary: Reorder channel folders + tags: ["channels"] + description: | + Given an array of channel folder IDs, this method will set the `order` + property of all of the channel folders in the organization according to + the order of the channel folder IDs specified in the request. + + **Changes**: New in Zulip 11.0 (feature level ZF-fcae8c). + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + order: + type: array + description: | + A list of channel folder IDs representing the new order. + items: + type: integer + encoding: + order: + contentType: application/json + responses: + "200": + $ref: "#/components/responses/SimpleSuccess" + + "400": + description: Bad request. + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/CodedError" + - example: + { + "code": "BAD_REQUEST", + "msg": "Invalid order mapping", + "result": "error", + } + description: | + An example JSON response when the order mapping is invalid: + /channel_folders/{channel_folder_id}: patch: operationId: update-channel-folder diff --git a/zerver/tests/test_channel_folders.py b/zerver/tests/test_channel_folders.py index e56a372f84..14991d6f56 100644 --- a/zerver/tests/test_channel_folders.py +++ b/zerver/tests/test_channel_folders.py @@ -21,7 +21,10 @@ class ChannelFoldersTestCase(ZulipTestCase): lear_user = self.lear_user("cordelia") check_add_channel_folder( - zulip_realm, "Frontend", "Channels for frontend discussions", acting_user=iago + zulip_realm, + "Frontend", + "Channels for frontend discussions", + acting_user=iago, ) check_add_channel_folder( zulip_realm, "Backend", "Channels for **backend** discussions", acting_user=iago @@ -50,6 +53,7 @@ class ChannelFolderCreationTest(ZulipTestCase): assert channel_folder is not None self.assertEqual(channel_folder.name, "Frontend") self.assertEqual(channel_folder.description, "") + self.assertEqual(channel_folder.id, channel_folder.order) response = orjson.loads(result.content) self.assertEqual(response["channel_folder_id"], channel_folder.id) @@ -349,3 +353,65 @@ class UpdateChannelFoldersTest(ChannelFoldersTestCase): self.assert_json_success(result) channel_folder = ChannelFolder.objects.get(id=channel_folder_id) self.assertTrue(channel_folder.is_archived) + + +class ReorderChannelFolderTest(ChannelFoldersTestCase): + def test_reorder(self) -> None: + self.login("iago") + realm = get_realm("zulip") + order = list( + ChannelFolder.objects.filter(realm=realm) + .order_by("-order") + .values_list("order", flat=True) + ) + result = self.client_patch( + "/json/channel_folders", info={"order": orjson.dumps(order).decode()} + ) + self.assert_json_success(result) + fields = ChannelFolder.objects.filter(realm=realm).order_by("order") + for field in fields: + self.assertEqual(field.id, order[field.order]) + + def test_reorder_duplicates(self) -> None: + self.login("iago") + realm = get_realm("zulip") + order = list( + ChannelFolder.objects.filter(realm=realm) + .order_by("-order") + .values_list("order", flat=True) + ) + frontend_folder = ChannelFolder.objects.get(name="Frontend", realm=realm) + order.append(frontend_folder.id) + result = self.client_patch( + "/json/channel_folders", info={"order": orjson.dumps(order).decode()} + ) + self.assert_json_success(result) + fields = ChannelFolder.objects.filter(realm=realm).order_by("order") + for field in fields: + self.assertEqual(field.id, order[field.order]) + + def test_reorder_unauthorized(self) -> None: + self.login("hamlet") + realm = get_realm("zulip") + order = list( + ChannelFolder.objects.filter(realm=realm) + .order_by("-order") + .values_list("order", flat=True) + ) + result = self.client_patch( + "/json/channel_folders", info={"order": orjson.dumps(order).decode()} + ) + self.assert_json_error(result, "Must be an organization administrator") + + def test_reorder_invalid(self) -> None: + self.login("iago") + order = [100, 200, 300] + result = self.client_patch( + "/json/channel_folders", info={"order": orjson.dumps(order).decode()} + ) + self.assert_json_error(result, "Invalid order mapping.") + order = [1, 2] + result = self.client_patch( + "/json/channel_folders", info={"order": orjson.dumps(order).decode()} + ) + self.assert_json_error(result, "Invalid order mapping.") diff --git a/zerver/views/channel_folders.py b/zerver/views/channel_folders.py index decde3d6dc..1de70a0ed9 100644 --- a/zerver/views/channel_folders.py +++ b/zerver/views/channel_folders.py @@ -10,6 +10,7 @@ from zerver.actions.channel_folders import ( do_change_channel_folder_description, do_change_channel_folder_name, do_unarchive_channel_folder, + try_reorder_realm_channel_folders, ) from zerver.decorator import require_realm_admin from zerver.lib.channel_folders import ( @@ -52,6 +53,18 @@ def get_channel_folders( return json_success(request, data={"channel_folders": channel_folders}) +@require_realm_admin +@typed_endpoint +def reorder_realm_channel_folders( + request: HttpRequest, + user_profile: UserProfile, + *, + order: Json[list[int]], +) -> HttpResponse: + try_reorder_realm_channel_folders(user_profile.realm, order) + return json_success(request) + + @require_realm_admin @typed_endpoint def update_channel_folder( diff --git a/zproject/urls.py b/zproject/urls.py index 620d47f241..c45776bb83 100644 --- a/zproject/urls.py +++ b/zproject/urls.py @@ -48,6 +48,7 @@ from zerver.views.auth import ( from zerver.views.channel_folders import ( create_channel_folder, get_channel_folders, + reorder_realm_channel_folders, update_channel_folder, ) from zerver.views.compatibility import check_global_compatibility @@ -559,7 +560,7 @@ v1_api_and_json_patterns = [ DELETE=remove_subscriptions_backend, ), rest_path("channel_folders/create", POST=create_channel_folder), - rest_path("channel_folders", GET=get_channel_folders), + rest_path("channel_folders", GET=get_channel_folders, PATCH=reorder_realm_channel_folders), rest_path("channel_folders/", PATCH=update_channel_folder), # topic-muting -> zerver.views.user_topics # (deprecated and will be removed once clients are migrated to use '/user_topics')