diff --git a/api_docs/changelog.md b/api_docs/changelog.md index 74e39a6c8b..5fdf340a35 100644 --- a/api_docs/changelog.md +++ b/api_docs/changelog.md @@ -20,6 +20,12 @@ format used by the Zulip server that they are interacting with. ## Changes in Zulip 7.0 +**Feature level 173**: + +* [`GET /scheduled_messages`](/api/get-scheduled-messages), [`DELETE + /scheduled_messages/`](/api/delete-scheduled-message): + Added new endpoints to fetch and delete scheduled messages. + **Feature level 172** * [`PATCH /messages/{message_id}`](/api/update-message): Topic editing diff --git a/version.py b/version.py index d37eff831d..a6e8e37cdd 100644 --- a/version.py +++ b/version.py @@ -33,7 +33,7 @@ DESKTOP_WARNING_VERSION = "5.4.3" # Changes should be accompanied by documentation explaining what the # new level means in api_docs/changelog.md, as well as "**Changes**" # entries in the endpoint's documentation in `zulip.yaml`. -API_FEATURE_LEVEL = 172 +API_FEATURE_LEVEL = 173 # 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/openapi/zulip.yaml b/zerver/openapi/zulip.yaml index 2ee5076eea..359165169c 100644 --- a/zerver/openapi/zulip.yaml +++ b/zerver/openapi/zulip.yaml @@ -4971,6 +4971,91 @@ paths: - description: | JSON response for when no draft exists with the provided ID. example: {"result": "error", "msg": "Draft does not exist"} + /scheduled_messages: + get: + operationId: get-scheduled-messages + tags: ["scheduled_messages"] + summary: Get scheduled messages + description: | + Fetch all scheduled messages for the current user. + + Scheduled messages are messages the user has scheduled to be + sent in the future via the send later feature. + + **Changes**: New in Zulip 7.0 (feature level 173). + responses: + "200": + description: Success. + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/JsonSuccessBase" + - $ref: "#/components/schemas/SuccessDescription" + - additionalProperties: false + properties: + result: {} + msg: {} + ignored_parameters_unsupported: {} + scheduled_messages: + type: array + description: | + Returns all of the current user's scheduled + messages, ordered by scheduled timestamp (ascending). + items: + $ref: "#/components/schemas/ScheduledMessage" + example: + { + "result": "success", + "msg": "", + "scheduled_messages": + [ + { + "message_id": 27, + "to": [14], + "type": "stream", + "content": "Hi", + "rendered_content": "

Hi

", + "topic": "Introduction", + "deliver_at": 1681662420000, + }, + ], + } + /scheduled_messages/{scheduled_message_id}: + delete: + operationId: delete-scheduled-message + tags: ["scheduled_messages"] + summary: Delete a scheduled message. + description: | + Delete/cancel a previously scheduled message. + + **Changes**: New in Zulip 7.0 (feature level 173). + parameters: + - name: scheduled_message_id + in: path + schema: + type: integer + description: | + The ID of the scheduled message you want to delete. + required: True + example: 1 + responses: + "200": + $ref: "#/components/responses/SimpleSuccess" + "404": + description: Not Found. + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/JsonError" + - description: | + Example response for when no scheduled message exists with the provided ID. + example: + { + "result": "error", + "msg": "Scheduled message does not exist", + } /default_streams: post: operationId: add-default-stream @@ -17079,6 +17164,64 @@ components: - to - topic - content + ScheduledMessage: + type: object + description: | + A dictionary for representing a scheduled message. + properties: + message_id: + type: integer + description: | + The unique ID of the scheduled message. It can be used to modify and + delete the scheduled message. + type: + type: string + description: | + The type of the scheduled message. Either unaddressed (empty string), "stream", + or "private" (for PMs and private group messages). + enum: + - "" + - stream + - private + to: + type: array + description: | + An array of the tentative target audience IDs. For "stream" + messages, this should contain exactly 1 ID, the ID of the + target stream. For private messages, this should be an array + of target user IDs. This cannot be an empty array since + scheduled messages are always addressed. + items: + type: integer + topic: + type: string + description: | + For stream scheduled message, the tentative topic name. For private messages, + this will be ignored and should ideally + be the empty string. Should not contain null bytes. + content: + type: string + description: | + The body of the scheduled message. Should not contain null bytes. + rendered_content: + type: string + description: | + The body of the scheduled message rendered in HTML. + deliver_at: + type: number + description: | + A Unix timestamp (seconds only) representing when the scheduled + message will be sent by the server. + example: 1595479019 + additionalProperties: false + required: + - message_id + - type + - to + - topic + - content + - rendered_content + - deliver_at User: allOf: - $ref: "#/components/schemas/UserBase" diff --git a/zerver/tests/test_message_send.py b/zerver/tests/test_message_send.py index 067566e74f..6aee993dc8 100644 --- a/zerver/tests/test_message_send.py +++ b/zerver/tests/test_message_send.py @@ -1520,6 +1520,74 @@ class ScheduledMessageTest(ZulipTestCase): self.assertEqual(message.scheduled_timestamp, convert_to_UTC(new_defer_until)) self.assertEqual(message.delivery_type, ScheduledMessage.SEND_LATER) + def test_fetch_scheduled_messages(self) -> None: + self.login("hamlet") + # No scheduled message + result = self.client_get("/json/scheduled_messages") + self.assert_json_success(result) + self.assert_length(orjson.loads(result.content)["scheduled_messages"], 0) + + content = "Test message" + defer_until = timezone_now().replace(tzinfo=None) + datetime.timedelta(days=1) + defer_until_str = str(defer_until) + self.do_schedule_message("stream", "Verona", content, defer_until_str) + + # Single scheduled message + result = self.client_get("/json/scheduled_messages") + self.assert_json_success(result) + scheduled_messages = orjson.loads(result.content)["scheduled_messages"] + + self.assert_length(scheduled_messages, 1) + self.assertEqual(scheduled_messages[0]["message_id"], self.last_scheduled_message().id) + self.assertEqual(scheduled_messages[0]["content"], content) + self.assertEqual(scheduled_messages[0]["to"], [self.get_stream_id("Verona")]) + self.assertEqual(scheduled_messages[0]["type"], "stream") + self.assertEqual(scheduled_messages[0]["topic"], "Test topic") + self.assertEqual( + scheduled_messages[0]["deliver_at"], int(convert_to_UTC(defer_until).timestamp() * 1000) + ) + + othello = self.example_user("othello") + result = self.do_schedule_message( + "private", [othello.email], content + " 3", defer_until_str + ) + + # Multiple scheduled messages + result = self.client_get("/json/scheduled_messages") + self.assert_json_success(result) + self.assert_length(orjson.loads(result.content)["scheduled_messages"], 2) + + # Check if another user can access these scheduled messages. + self.logout() + self.login("othello") + result = self.client_get("/json/scheduled_messages") + self.assert_json_success(result) + self.assert_length(orjson.loads(result.content)["scheduled_messages"], 0) + + def test_delete_scheduled_messages(self) -> None: + self.login("hamlet") + + content = "Test message" + defer_until = timezone_now().replace(tzinfo=None) + datetime.timedelta(days=1) + defer_until_str = str(defer_until) + self.do_schedule_message("stream", "Verona", content, defer_until_str) + message = self.last_scheduled_message() + self.logout() + + # Other user cannot delete it. + othello = self.example_user("othello") + result = self.api_delete(othello, f"/api/v1/scheduled_messages/{message.id}") + self.assert_json_error(result, "Scheduled message does not exist", 404) + + self.login("hamlet") + result = self.client_delete(f"/json/scheduled_messages/{message.id}") + self.assert_json_success(result) + + # Already deleted. + result = self.client_delete(f"/json/scheduled_messages/{message.id}") + self.assert_json_error(result, "Scheduled message does not exist", 404) + + class StreamMessagesTest(ZulipTestCase): def assert_stream_message( self, stream_name: str, topic_name: str = "test topic", content: str = "test content" diff --git a/zerver/tests/test_openapi.py b/zerver/tests/test_openapi.py index 82921013fa..81dd45a6e0 100644 --- a/zerver/tests/test_openapi.py +++ b/zerver/tests/test_openapi.py @@ -921,6 +921,7 @@ class OpenAPIAttributesTest(ZulipTestCase): "messages", "drafts", "webhooks", + "scheduled_messages", ] paths = OpenAPISpec(OPENAPI_SPEC_PATH).openapi()["paths"] for path, path_item in paths.items(): diff --git a/zerver/views/scheduled_messages.py b/zerver/views/scheduled_messages.py new file mode 100644 index 0000000000..489b3754c8 --- /dev/null +++ b/zerver/views/scheduled_messages.py @@ -0,0 +1,62 @@ +from typing import List, TypedDict + +from django.http import HttpRequest, HttpResponse + +from zerver.lib.request import has_request_variables +from zerver.lib.response import json_success +from zerver.lib.scheduled_messages import access_scheduled_message +from zerver.models import ScheduledMessage, UserProfile, get_recipient_ids +from zerver.tornado.django_api import send_event + + +class ScheduledMessageDict(TypedDict): + message_id: int + to: List[int] + type: str + content: str + rendered_content: str + topic: str + deliver_at: int + + +@has_request_variables +def fetch_scheduled_messages(request: HttpRequest, user_profile: UserProfile) -> HttpResponse: + scheduled_messages = ScheduledMessage.objects.filter( + sender=user_profile, delivered=False, delivery_type=ScheduledMessage.SEND_LATER + ).order_by("scheduled_timestamp") + scheduled_message_dicts: List[ScheduledMessageDict] = [] + + for scheduled_message in scheduled_messages: + recipient, recipient_type_str = get_recipient_ids( + scheduled_message.recipient, user_profile.id + ) + + msg_to_dict: ScheduledMessageDict = { + "message_id": scheduled_message.id, + "to": recipient, + "type": recipient_type_str, + "content": scheduled_message.content, + "rendered_content": scheduled_message.rendered_content, + "topic": scheduled_message.topic_name(), + "deliver_at": int(scheduled_message.scheduled_timestamp.timestamp() * 1000), + } + scheduled_message_dicts.append(msg_to_dict) + + return json_success(request, data={"scheduled_messages": scheduled_message_dicts}) + + +@has_request_variables +def delete_scheduled_messages( + request: HttpRequest, user_profile: UserProfile, scheduled_message_id: int +) -> HttpResponse: + scheduled_message_object = access_scheduled_message(user_profile, scheduled_message_id) + scheduled_message_id = scheduled_message_object.id + scheduled_message_object.delete() + + event = { + "type": "scheduled_message", + "op": "remove", + "scheduled_message_id": scheduled_message_id, + } + send_event(user_profile.realm, event, [user_profile.id]) + return json_success(request) diff --git a/zproject/urls.py b/zproject/urls.py index d3c2109a7a..84f4cf58c2 100644 --- a/zproject/urls.py +++ b/zproject/urls.py @@ -136,6 +136,7 @@ from zerver.views.report import ( report_send_times, report_unnarrow_times, ) +from zerver.views.scheduled_messages import delete_scheduled_messages, fetch_scheduled_messages from zerver.views.sentry import sentry_tunnel from zerver.views.storage import get_storage, remove_storage, update_storage from zerver.views.streams import ( @@ -321,6 +322,9 @@ v1_api_and_json_patterns = [ # Endpoints for syncing drafts. rest_path("drafts", GET=fetch_drafts, POST=create_drafts), rest_path("drafts/", PATCH=edit_draft, DELETE=delete_draft), + # New scheduled messages are created via send_message_backend. + rest_path("scheduled_messages", GET=fetch_scheduled_messages), + rest_path("scheduled_messages/", DELETE=delete_scheduled_messages), # messages -> zerver.views.message* # GET returns messages, possibly filtered, POST sends a message rest_path(