mirror of https://github.com/zulip/zulip.git
urls: Add new endpoint to create scheduled messages.
This will help us remove scheduled message and reminder logic from `/messages` code path. Removes `deliver_at`/`defer_until` and `tz_guess` parameters. And adds the `scheduled_delivery_timestamp` instead. Also updates the scheduled message dicts to return `scheduled_delivery_timestamp`. Also, revises some text in `/delete-scheduled-message` endpoint and in the `ScheduledMessage` schema in the API documentation.
This commit is contained in:
parent
7739703111
commit
d60d6e9115
|
@ -4288,7 +4288,7 @@ class StreamScheduledMessageAPI(TypedDict):
|
||||||
content: str
|
content: str
|
||||||
rendered_content: str
|
rendered_content: str
|
||||||
topic: str
|
topic: str
|
||||||
deliver_at: int
|
scheduled_delivery_timestamp: int
|
||||||
|
|
||||||
|
|
||||||
class DirectScheduledMessageAPI(TypedDict):
|
class DirectScheduledMessageAPI(TypedDict):
|
||||||
|
@ -4297,7 +4297,7 @@ class DirectScheduledMessageAPI(TypedDict):
|
||||||
type: str
|
type: str
|
||||||
content: str
|
content: str
|
||||||
rendered_content: str
|
rendered_content: str
|
||||||
deliver_at: int
|
scheduled_delivery_timestamp: int
|
||||||
|
|
||||||
|
|
||||||
class ScheduledMessage(models.Model):
|
class ScheduledMessage(models.Model):
|
||||||
|
@ -4348,7 +4348,7 @@ class ScheduledMessage(models.Model):
|
||||||
type=recipient_type_str,
|
type=recipient_type_str,
|
||||||
content=self.content,
|
content=self.content,
|
||||||
rendered_content=self.rendered_content,
|
rendered_content=self.rendered_content,
|
||||||
deliver_at=int(self.scheduled_timestamp.timestamp() * 1000),
|
scheduled_delivery_timestamp=datetime_to_timestamp(self.scheduled_timestamp),
|
||||||
)
|
)
|
||||||
|
|
||||||
# The recipient for stream messages should always just be the unique stream ID.
|
# The recipient for stream messages should always just be the unique stream ID.
|
||||||
|
@ -4361,7 +4361,7 @@ class ScheduledMessage(models.Model):
|
||||||
content=self.content,
|
content=self.content,
|
||||||
rendered_content=self.rendered_content,
|
rendered_content=self.rendered_content,
|
||||||
topic=self.topic_name(),
|
topic=self.topic_name(),
|
||||||
deliver_at=int(self.scheduled_timestamp.timestamp() * 1000),
|
scheduled_delivery_timestamp=datetime_to_timestamp(self.scheduled_timestamp),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -5021,8 +5021,9 @@ paths:
|
||||||
scheduled_messages:
|
scheduled_messages:
|
||||||
type: array
|
type: array
|
||||||
description: |
|
description: |
|
||||||
Returns all of the current user's scheduled
|
Returns all of the current user's undelivered scheduled
|
||||||
messages, ordered by scheduled timestamp (ascending).
|
messages, ordered by `scheduled_delivery_timestamp`
|
||||||
|
(ascending).
|
||||||
items:
|
items:
|
||||||
$ref: "#/components/schemas/ScheduledMessage"
|
$ref: "#/components/schemas/ScheduledMessage"
|
||||||
example:
|
example:
|
||||||
|
@ -5033,7 +5034,7 @@ paths:
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"scheduled_message_id": 27,
|
"scheduled_message_id": 27,
|
||||||
"to": [14],
|
"to": 14,
|
||||||
"type": "stream",
|
"type": "stream",
|
||||||
"content": "Hi",
|
"content": "Hi",
|
||||||
"rendered_content": "<p>Hi</p>",
|
"rendered_content": "<p>Hi</p>",
|
||||||
|
@ -5042,13 +5043,160 @@ paths:
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
post:
|
||||||
|
operationId: create-or-update-scheduled-messages
|
||||||
|
tags: ["scheduled_messages"]
|
||||||
|
summary: Create or edit scheduled messages
|
||||||
|
description: |
|
||||||
|
Create a new scheduled message or edit an existing scheduled message.
|
||||||
|
|
||||||
|
The `scheduled_message_id` parameter determines whether a scheduled
|
||||||
|
message is created or updated. When it is omitted, a new scheduled
|
||||||
|
message is created. When it is specified, the existing scheduled
|
||||||
|
message with that unique ID is updated for the values passed to the
|
||||||
|
other endpoint parameters.
|
||||||
|
parameters:
|
||||||
|
- name: type
|
||||||
|
in: query
|
||||||
|
description: |
|
||||||
|
The type of scheduled message to be sent. `"direct"` for a direct
|
||||||
|
message and `"stream"` for a stream message.
|
||||||
|
|
||||||
|
In Zulip 7.0 (feature level 174), `"direct"` was added as the
|
||||||
|
preferred way to indicate the type of a direct message, deprecating
|
||||||
|
the original `"private"`. While `"private"` is supported for
|
||||||
|
scheduling direct messages, clients are encouraged to use to the
|
||||||
|
modern convention because support for `"private"` may eventually
|
||||||
|
be removed.
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- direct
|
||||||
|
- stream
|
||||||
|
- private
|
||||||
|
example: direct
|
||||||
|
required: true
|
||||||
|
- name: to
|
||||||
|
in: query
|
||||||
|
description: |
|
||||||
|
The scheduled message's tentative target audience.
|
||||||
|
|
||||||
|
For stream messages, integer ID of the stream. For direct messages,
|
||||||
|
either a list containing integer user IDs or a list containing string
|
||||||
|
email addresses.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
oneOf:
|
||||||
|
- type: integer
|
||||||
|
- type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
- type: array
|
||||||
|
items:
|
||||||
|
type: integer
|
||||||
|
minLength: 1
|
||||||
|
example: [9, 10]
|
||||||
|
required: true
|
||||||
|
- $ref: "#/components/parameters/RequiredContent"
|
||||||
|
- name: topic
|
||||||
|
in: query
|
||||||
|
description: |
|
||||||
|
The topic of the message. Only required for stream messages
|
||||||
|
(`type="stream"`).
|
||||||
|
|
||||||
|
Clients should use the `max_topic_length` returned by the
|
||||||
|
[`POST /register`](/api/register-queue) endpoint to determine
|
||||||
|
the maximum topic length.
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: Castle
|
||||||
|
allowEmptyValue: true
|
||||||
|
- name: scheduled_message_id
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
description: |
|
||||||
|
The unique ID of the scheduled message to be updated.
|
||||||
|
|
||||||
|
If omitted, a new scheduled message will be created. Otherwise,
|
||||||
|
the existing scheduled message with this unique ID will be updated.
|
||||||
|
example: 1
|
||||||
|
- name: scheduled_delivery_timestamp
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
description: |
|
||||||
|
The UNIX timestamp for when the message will be sent,
|
||||||
|
in UTC seconds.
|
||||||
|
required: true
|
||||||
|
example: 3165826990
|
||||||
|
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_message_id:
|
||||||
|
type: integer
|
||||||
|
description: |
|
||||||
|
The unique ID of the scheduled message.
|
||||||
|
|
||||||
|
This is different from the unique ID that the message will have
|
||||||
|
after it is sent.
|
||||||
|
example:
|
||||||
|
{
|
||||||
|
"msg": "",
|
||||||
|
"scheduled_message_id": 42,
|
||||||
|
"result": "success",
|
||||||
|
}
|
||||||
|
"400":
|
||||||
|
description: Bad request.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
oneOf:
|
||||||
|
- allOf:
|
||||||
|
- $ref: "#/components/schemas/NonExistingStreamError"
|
||||||
|
- description: |
|
||||||
|
A typical failed JSON response for when a stream message is sent to a stream
|
||||||
|
that does not exist:
|
||||||
|
- allOf:
|
||||||
|
- $ref: "#/components/schemas/CodedError"
|
||||||
|
- example:
|
||||||
|
{
|
||||||
|
"code": "BAD_REQUEST",
|
||||||
|
"msg": "Invalid email 'eeshan@zulip.com'",
|
||||||
|
"result": "error",
|
||||||
|
}
|
||||||
|
description: |
|
||||||
|
A typical failed JSON response for when a private message is sent to a user
|
||||||
|
that does not exist:
|
||||||
|
- allOf:
|
||||||
|
- $ref: "#/components/schemas/JsonError"
|
||||||
|
- description: |
|
||||||
|
Example response for when no scheduled message exists with the provided ID.
|
||||||
|
example:
|
||||||
|
{
|
||||||
|
"code": "BAD_REQUEST",
|
||||||
|
"result": "error",
|
||||||
|
"msg": "Scheduled message does not exist",
|
||||||
|
}
|
||||||
/scheduled_messages/{scheduled_message_id}:
|
/scheduled_messages/{scheduled_message_id}:
|
||||||
delete:
|
delete:
|
||||||
operationId: delete-scheduled-message
|
operationId: delete-scheduled-message
|
||||||
tags: ["scheduled_messages"]
|
tags: ["scheduled_messages"]
|
||||||
summary: Delete a scheduled message.
|
summary: Delete a scheduled message
|
||||||
description: |
|
description: |
|
||||||
Delete/cancel a previously scheduled message.
|
Delete, and therefore cancel sending, a previously scheduled message.
|
||||||
|
|
||||||
**Changes**: New in Zulip 7.0 (feature level 173).
|
**Changes**: New in Zulip 7.0 (feature level 173).
|
||||||
parameters:
|
parameters:
|
||||||
|
@ -5057,7 +5205,10 @@ paths:
|
||||||
schema:
|
schema:
|
||||||
type: integer
|
type: integer
|
||||||
description: |
|
description: |
|
||||||
The ID of the scheduled message you want to delete.
|
The ID of the scheduled message to delete.
|
||||||
|
|
||||||
|
This is different from the unique ID that a message would have
|
||||||
|
after it was sent.
|
||||||
required: True
|
required: True
|
||||||
example: 1
|
example: 1
|
||||||
responses:
|
responses:
|
||||||
|
@ -5074,6 +5225,7 @@ paths:
|
||||||
Example response for when no scheduled message exists with the provided ID.
|
Example response for when no scheduled message exists with the provided ID.
|
||||||
example:
|
example:
|
||||||
{
|
{
|
||||||
|
"code": "BAD_REQUEST",
|
||||||
"result": "error",
|
"result": "error",
|
||||||
"msg": "Scheduled message does not exist",
|
"msg": "Scheduled message does not exist",
|
||||||
}
|
}
|
||||||
|
@ -17340,21 +17492,21 @@ components:
|
||||||
ScheduledMessage:
|
ScheduledMessage:
|
||||||
type: object
|
type: object
|
||||||
description: |
|
description: |
|
||||||
A dictionary for representing a scheduled message.
|
Object containing details of the scheduled message.
|
||||||
properties:
|
properties:
|
||||||
scheduled_message_id:
|
scheduled_message_id:
|
||||||
type: integer
|
type: integer
|
||||||
description: |
|
description: |
|
||||||
The unique ID of the scheduled message. It can be used to modify and
|
The unique ID of the scheduled message, which can be used to
|
||||||
delete the scheduled message. This ID is different than the ID of the
|
modify or delete the scheduled message.
|
||||||
message that will actually be sent.
|
|
||||||
|
This is different from the unique ID that the message will have
|
||||||
|
after it is sent.
|
||||||
type:
|
type:
|
||||||
type: string
|
type: string
|
||||||
description: |
|
description: |
|
||||||
The type of the scheduled message. Either unaddressed (empty string), "stream",
|
The type of the scheduled message. Either `"stream"` or `"private"`.
|
||||||
or "private" (for PMs and private group messages).
|
|
||||||
enum:
|
enum:
|
||||||
- ""
|
|
||||||
- stream
|
- stream
|
||||||
- private
|
- private
|
||||||
to:
|
to:
|
||||||
|
@ -17378,16 +17530,16 @@ components:
|
||||||
content:
|
content:
|
||||||
type: string
|
type: string
|
||||||
description: |
|
description: |
|
||||||
The body of the scheduled message. Should not contain null bytes.
|
The content/body of the scheduled message, in text/markdown format.
|
||||||
rendered_content:
|
rendered_content:
|
||||||
type: string
|
type: string
|
||||||
description: |
|
description: |
|
||||||
The body of the scheduled message rendered in HTML.
|
The content/body of the scheduled message rendered in HTML.
|
||||||
deliver_at:
|
scheduled_delivery_timestamp:
|
||||||
type: number
|
type: integer
|
||||||
description: |
|
description: |
|
||||||
A Unix timestamp (seconds only) representing when the scheduled
|
The UNIX timestamp for when the message will be sent
|
||||||
message will be sent by the server.
|
by the server, in UTC seconds.
|
||||||
example: 1595479019
|
example: 1595479019
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
required:
|
required:
|
||||||
|
@ -17396,7 +17548,7 @@ components:
|
||||||
- to
|
- to
|
||||||
- content
|
- content
|
||||||
- rendered_content
|
- rendered_content
|
||||||
- deliver_at
|
- scheduled_delivery_timestamp
|
||||||
User:
|
User:
|
||||||
allOf:
|
allOf:
|
||||||
- $ref: "#/components/schemas/UserBase"
|
- $ref: "#/components/schemas/UserBase"
|
||||||
|
|
|
@ -1,19 +1,12 @@
|
||||||
import datetime
|
import time
|
||||||
import sys
|
|
||||||
from typing import TYPE_CHECKING, List, Union
|
from typing import TYPE_CHECKING, List, Union
|
||||||
|
|
||||||
import orjson
|
import orjson
|
||||||
from django.utils.timezone import now as timezone_now
|
|
||||||
|
|
||||||
from zerver.lib.test_classes import ZulipTestCase
|
from zerver.lib.test_classes import ZulipTestCase
|
||||||
from zerver.lib.timestamp import convert_to_UTC
|
from zerver.lib.timestamp import timestamp_to_datetime
|
||||||
from zerver.models import ScheduledMessage
|
from zerver.models import ScheduledMessage
|
||||||
|
|
||||||
if sys.version_info < (3, 9): # nocoverage
|
|
||||||
from backports import zoneinfo
|
|
||||||
else: # nocoverage
|
|
||||||
import zoneinfo
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from django.test.client import _MonkeyPatchedWSGIResponse as TestHttpResponse
|
from django.test.client import _MonkeyPatchedWSGIResponse as TestHttpResponse
|
||||||
|
|
||||||
|
@ -28,11 +21,9 @@ class ScheduledMessageTest(ZulipTestCase):
|
||||||
def do_schedule_message(
|
def do_schedule_message(
|
||||||
self,
|
self,
|
||||||
msg_type: str,
|
msg_type: str,
|
||||||
to: Union[str, int, List[str], List[int]],
|
to: Union[int, List[str], List[int]],
|
||||||
msg: str,
|
msg: str,
|
||||||
defer_until: str = "",
|
scheduled_delivery_timestamp: int,
|
||||||
tz_guess: str = "",
|
|
||||||
delivery_type: str = "send_later",
|
|
||||||
scheduled_message_id: str = "",
|
scheduled_message_id: str = "",
|
||||||
) -> "TestHttpResponse":
|
) -> "TestHttpResponse":
|
||||||
self.login("hamlet")
|
self.login("hamlet")
|
||||||
|
@ -46,158 +37,98 @@ class ScheduledMessageTest(ZulipTestCase):
|
||||||
"to": orjson.dumps(to).decode(),
|
"to": orjson.dumps(to).decode(),
|
||||||
"content": msg,
|
"content": msg,
|
||||||
"topic": topic_name,
|
"topic": topic_name,
|
||||||
"delivery_type": delivery_type,
|
"scheduled_delivery_timestamp": scheduled_delivery_timestamp,
|
||||||
"tz_guess": tz_guess,
|
|
||||||
}
|
}
|
||||||
if defer_until:
|
|
||||||
payload["deliver_at"] = defer_until
|
|
||||||
|
|
||||||
if scheduled_message_id:
|
if scheduled_message_id:
|
||||||
payload["scheduled_message_id"] = scheduled_message_id
|
payload["scheduled_message_id"] = scheduled_message_id
|
||||||
|
|
||||||
# `Topic` cannot be empty according to OpenAPI specification.
|
result = self.client_post("/json/scheduled_messages", payload)
|
||||||
intentionally_undocumented: bool = topic_name == ""
|
|
||||||
result = self.client_post(
|
|
||||||
"/json/messages", payload, intentionally_undocumented=intentionally_undocumented
|
|
||||||
)
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def test_schedule_message(self) -> None:
|
def test_schedule_message(self) -> None:
|
||||||
content = "Test message"
|
content = "Test message"
|
||||||
defer_until = timezone_now().replace(tzinfo=None) + datetime.timedelta(days=1)
|
scheduled_delivery_timestamp = int(time.time() + 86400)
|
||||||
defer_until_str = str(defer_until)
|
verona_stream_id = self.get_stream_id("Verona")
|
||||||
|
|
||||||
# Scheduling a message to a stream you are subscribed is successful.
|
# Scheduling a message to a stream you are subscribed is successful.
|
||||||
result = self.do_schedule_message("stream", "Verona", content + " 1", defer_until_str)
|
|
||||||
message = self.last_scheduled_message()
|
|
||||||
self.assert_json_success(result)
|
|
||||||
self.assertEqual(message.content, "Test message 1")
|
|
||||||
self.assertEqual(message.rendered_content, "<p>Test message 1</p>")
|
|
||||||
self.assertEqual(message.topic_name(), "Test topic")
|
|
||||||
self.assertEqual(message.scheduled_timestamp, convert_to_UTC(defer_until))
|
|
||||||
self.assertEqual(message.delivery_type, ScheduledMessage.SEND_LATER)
|
|
||||||
# Scheduling a message for reminders.
|
|
||||||
result = self.do_schedule_message(
|
result = self.do_schedule_message(
|
||||||
"stream", "Verona", content + " 2", defer_until_str, delivery_type="remind"
|
"stream", verona_stream_id, content + " 1", scheduled_delivery_timestamp
|
||||||
)
|
)
|
||||||
message = self.last_scheduled_message()
|
scheduled_message = self.last_scheduled_message()
|
||||||
self.assert_json_success(result)
|
self.assert_json_success(result)
|
||||||
self.assertEqual(message.delivery_type, ScheduledMessage.REMIND)
|
self.assertEqual(scheduled_message.content, "Test message 1")
|
||||||
|
self.assertEqual(scheduled_message.rendered_content, "<p>Test message 1</p>")
|
||||||
|
self.assertEqual(scheduled_message.topic_name(), "Test topic")
|
||||||
|
self.assertEqual(
|
||||||
|
scheduled_message.scheduled_timestamp,
|
||||||
|
timestamp_to_datetime(scheduled_delivery_timestamp),
|
||||||
|
)
|
||||||
|
|
||||||
# Scheduling a private message is successful.
|
# Scheduling a private message is successful.
|
||||||
othello = self.example_user("othello")
|
othello = self.example_user("othello")
|
||||||
hamlet = self.example_user("hamlet")
|
|
||||||
result = self.do_schedule_message(
|
result = self.do_schedule_message(
|
||||||
"private", [othello.email], content + " 3", defer_until_str
|
"direct", [othello.email], content + " 3", scheduled_delivery_timestamp
|
||||||
)
|
)
|
||||||
message = self.last_scheduled_message()
|
scheduled_message = self.last_scheduled_message()
|
||||||
self.assert_json_success(result)
|
self.assert_json_success(result)
|
||||||
self.assertEqual(message.content, "Test message 3")
|
self.assertEqual(scheduled_message.content, "Test message 3")
|
||||||
self.assertEqual(message.rendered_content, "<p>Test message 3</p>")
|
self.assertEqual(scheduled_message.rendered_content, "<p>Test message 3</p>")
|
||||||
self.assertEqual(message.scheduled_timestamp, convert_to_UTC(defer_until))
|
self.assertEqual(
|
||||||
self.assertEqual(message.delivery_type, ScheduledMessage.SEND_LATER)
|
scheduled_message.scheduled_timestamp,
|
||||||
|
timestamp_to_datetime(scheduled_delivery_timestamp),
|
||||||
# Setting a reminder in PM's to other users causes a error.
|
|
||||||
result = self.do_schedule_message(
|
|
||||||
"private", [othello.email], content + " 4", defer_until_str, delivery_type="remind"
|
|
||||||
)
|
)
|
||||||
self.assert_json_error(result, "Reminders can only be set for streams.")
|
|
||||||
|
|
||||||
# Setting a reminder in PM's to ourself is successful.
|
|
||||||
# Required by reminders from message actions popover caret feature.
|
|
||||||
result = self.do_schedule_message(
|
|
||||||
"private", [hamlet.email], content + " 5", defer_until_str, delivery_type="remind"
|
|
||||||
)
|
|
||||||
message = self.last_scheduled_message()
|
|
||||||
self.assert_json_success(result)
|
|
||||||
self.assertEqual(message.content, "Test message 5")
|
|
||||||
self.assertEqual(message.delivery_type, ScheduledMessage.REMIND)
|
|
||||||
|
|
||||||
# Scheduling a message while guessing time zone.
|
|
||||||
tz_guess = "Asia/Kolkata"
|
|
||||||
result = self.do_schedule_message(
|
|
||||||
"stream", "Verona", content + " 6", defer_until_str, tz_guess=tz_guess
|
|
||||||
)
|
|
||||||
message = self.last_scheduled_message()
|
|
||||||
self.assert_json_success(result)
|
|
||||||
self.assertEqual(message.content, "Test message 6")
|
|
||||||
local_tz = zoneinfo.ZoneInfo(tz_guess)
|
|
||||||
utz_defer_until = defer_until.replace(tzinfo=local_tz)
|
|
||||||
self.assertEqual(message.scheduled_timestamp, convert_to_UTC(utz_defer_until))
|
|
||||||
self.assertEqual(message.delivery_type, ScheduledMessage.SEND_LATER)
|
|
||||||
|
|
||||||
# Test with users time zone setting as set to some time zone rather than
|
|
||||||
# empty. This will help interpret timestamp in users local time zone.
|
|
||||||
user = self.example_user("hamlet")
|
|
||||||
user.timezone = "US/Pacific"
|
|
||||||
user.save(update_fields=["timezone"])
|
|
||||||
result = self.do_schedule_message("stream", "Verona", content + " 7", defer_until_str)
|
|
||||||
message = self.last_scheduled_message()
|
|
||||||
self.assert_json_success(result)
|
|
||||||
self.assertEqual(message.content, "Test message 7")
|
|
||||||
local_tz = zoneinfo.ZoneInfo(user.timezone)
|
|
||||||
utz_defer_until = defer_until.replace(tzinfo=local_tz)
|
|
||||||
self.assertEqual(message.scheduled_timestamp, convert_to_UTC(utz_defer_until))
|
|
||||||
self.assertEqual(message.delivery_type, ScheduledMessage.SEND_LATER)
|
|
||||||
|
|
||||||
def test_scheduling_in_past(self) -> None:
|
def test_scheduling_in_past(self) -> None:
|
||||||
# Scheduling a message in past should fail.
|
# Scheduling a message in past should fail.
|
||||||
content = "Test message"
|
content = "Test message"
|
||||||
defer_until = timezone_now()
|
verona_stream_id = self.get_stream_id("Verona")
|
||||||
defer_until_str = str(defer_until)
|
scheduled_delivery_timestamp = int(time.time() - 86400)
|
||||||
|
|
||||||
result = self.do_schedule_message("stream", "Verona", content + " 1", defer_until_str)
|
result = self.do_schedule_message(
|
||||||
self.assert_json_error(result, "Time must be in the future.")
|
"stream", verona_stream_id, content + " 1", scheduled_delivery_timestamp
|
||||||
|
|
||||||
def test_invalid_timestamp(self) -> None:
|
|
||||||
# Scheduling a message from which timestamp couldn't be parsed
|
|
||||||
# successfully should fail.
|
|
||||||
content = "Test message"
|
|
||||||
defer_until = "Missed the timestamp"
|
|
||||||
|
|
||||||
result = self.do_schedule_message("stream", "Verona", content + " 1", defer_until)
|
|
||||||
self.assert_json_error(result, "Invalid time format")
|
|
||||||
|
|
||||||
def test_missing_deliver_at(self) -> None:
|
|
||||||
content = "Test message"
|
|
||||||
|
|
||||||
result = self.do_schedule_message("stream", "Verona", content + " 1")
|
|
||||||
self.assert_json_error(
|
|
||||||
result, "Missing deliver_at in a request for delayed message delivery"
|
|
||||||
)
|
)
|
||||||
|
self.assert_json_error(result, "Scheduled delivery time must be in the future.")
|
||||||
|
|
||||||
def test_edit_schedule_message(self) -> None:
|
def test_edit_schedule_message(self) -> None:
|
||||||
content = "Original test message"
|
content = "Original test message"
|
||||||
defer_until = timezone_now().replace(tzinfo=None) + datetime.timedelta(days=1)
|
scheduled_delivery_timestamp = int(time.time() + 86400)
|
||||||
defer_until_str = str(defer_until)
|
verona_stream_id = self.get_stream_id("Verona")
|
||||||
|
|
||||||
# Scheduling a message to a stream you are subscribed is successful.
|
# Scheduling a message to a stream you are subscribed is successful.
|
||||||
result = self.do_schedule_message("stream", "Verona", content, defer_until_str)
|
result = self.do_schedule_message(
|
||||||
message = self.last_scheduled_message()
|
"stream", verona_stream_id, content, scheduled_delivery_timestamp
|
||||||
|
)
|
||||||
|
scheduled_message = self.last_scheduled_message()
|
||||||
self.assert_json_success(result)
|
self.assert_json_success(result)
|
||||||
self.assertEqual(message.content, "Original test message")
|
self.assertEqual(scheduled_message.content, "Original test message")
|
||||||
self.assertEqual(message.topic_name(), "Test topic")
|
self.assertEqual(scheduled_message.topic_name(), "Test topic")
|
||||||
self.assertEqual(message.scheduled_timestamp, convert_to_UTC(defer_until))
|
self.assertEqual(
|
||||||
self.assertEqual(message.delivery_type, ScheduledMessage.SEND_LATER)
|
scheduled_message.scheduled_timestamp,
|
||||||
|
timestamp_to_datetime(scheduled_delivery_timestamp),
|
||||||
|
)
|
||||||
|
|
||||||
# Edit content and time of scheduled message.
|
# Edit content and time of scheduled message.
|
||||||
edited_content = "Edited test message"
|
edited_content = "Edited test message"
|
||||||
new_defer_until = defer_until + datetime.timedelta(days=3)
|
new_scheduled_delivery_timestamp = scheduled_delivery_timestamp + int(
|
||||||
new_defer_until_str = str(new_defer_until)
|
time.time() + (3 * 86400)
|
||||||
|
)
|
||||||
|
|
||||||
result = self.do_schedule_message(
|
result = self.do_schedule_message(
|
||||||
"stream",
|
"stream",
|
||||||
"Verona",
|
verona_stream_id,
|
||||||
edited_content,
|
edited_content,
|
||||||
new_defer_until_str,
|
new_scheduled_delivery_timestamp,
|
||||||
scheduled_message_id=str(message.id),
|
scheduled_message_id=str(scheduled_message.id),
|
||||||
)
|
)
|
||||||
message = self.get_scheduled_message(str(message.id))
|
scheduled_message = self.get_scheduled_message(str(scheduled_message.id))
|
||||||
self.assert_json_success(result)
|
self.assert_json_success(result)
|
||||||
self.assertEqual(message.content, edited_content)
|
self.assertEqual(scheduled_message.content, edited_content)
|
||||||
self.assertEqual(message.topic_name(), "Test topic")
|
self.assertEqual(scheduled_message.topic_name(), "Test topic")
|
||||||
self.assertEqual(message.scheduled_timestamp, convert_to_UTC(new_defer_until))
|
self.assertEqual(
|
||||||
self.assertEqual(message.delivery_type, ScheduledMessage.SEND_LATER)
|
scheduled_message.scheduled_timestamp,
|
||||||
|
timestamp_to_datetime(new_scheduled_delivery_timestamp),
|
||||||
|
)
|
||||||
|
|
||||||
def test_fetch_scheduled_messages(self) -> None:
|
def test_fetch_scheduled_messages(self) -> None:
|
||||||
self.login("hamlet")
|
self.login("hamlet")
|
||||||
|
@ -206,10 +137,10 @@ class ScheduledMessageTest(ZulipTestCase):
|
||||||
self.assert_json_success(result)
|
self.assert_json_success(result)
|
||||||
self.assert_length(orjson.loads(result.content)["scheduled_messages"], 0)
|
self.assert_length(orjson.loads(result.content)["scheduled_messages"], 0)
|
||||||
|
|
||||||
|
verona_stream_id = self.get_stream_id("Verona")
|
||||||
content = "Test message"
|
content = "Test message"
|
||||||
defer_until = timezone_now().replace(tzinfo=None) + datetime.timedelta(days=1)
|
scheduled_delivery_timestamp = int(time.time() + 86400)
|
||||||
defer_until_str = str(defer_until)
|
self.do_schedule_message("stream", verona_stream_id, content, scheduled_delivery_timestamp)
|
||||||
self.do_schedule_message("stream", "Verona", content, defer_until_str)
|
|
||||||
|
|
||||||
# Single scheduled message
|
# Single scheduled message
|
||||||
result = self.client_get("/json/scheduled_messages")
|
result = self.client_get("/json/scheduled_messages")
|
||||||
|
@ -221,16 +152,16 @@ class ScheduledMessageTest(ZulipTestCase):
|
||||||
scheduled_messages[0]["scheduled_message_id"], self.last_scheduled_message().id
|
scheduled_messages[0]["scheduled_message_id"], self.last_scheduled_message().id
|
||||||
)
|
)
|
||||||
self.assertEqual(scheduled_messages[0]["content"], content)
|
self.assertEqual(scheduled_messages[0]["content"], content)
|
||||||
self.assertEqual(scheduled_messages[0]["to"], self.get_stream_id("Verona"))
|
self.assertEqual(scheduled_messages[0]["to"], verona_stream_id)
|
||||||
self.assertEqual(scheduled_messages[0]["type"], "stream")
|
self.assertEqual(scheduled_messages[0]["type"], "stream")
|
||||||
self.assertEqual(scheduled_messages[0]["topic"], "Test topic")
|
self.assertEqual(scheduled_messages[0]["topic"], "Test topic")
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
scheduled_messages[0]["deliver_at"], int(convert_to_UTC(defer_until).timestamp() * 1000)
|
scheduled_messages[0]["scheduled_delivery_timestamp"], scheduled_delivery_timestamp
|
||||||
)
|
)
|
||||||
|
|
||||||
othello = self.example_user("othello")
|
othello = self.example_user("othello")
|
||||||
result = self.do_schedule_message(
|
result = self.do_schedule_message(
|
||||||
"private", [othello.email], content + " 3", defer_until_str
|
"direct", [othello.email], content + " 3", scheduled_delivery_timestamp
|
||||||
)
|
)
|
||||||
|
|
||||||
# Multiple scheduled messages
|
# Multiple scheduled messages
|
||||||
|
@ -249,21 +180,22 @@ class ScheduledMessageTest(ZulipTestCase):
|
||||||
self.login("hamlet")
|
self.login("hamlet")
|
||||||
|
|
||||||
content = "Test message"
|
content = "Test message"
|
||||||
defer_until = timezone_now().replace(tzinfo=None) + datetime.timedelta(days=1)
|
verona_stream_id = self.get_stream_id("Verona")
|
||||||
defer_until_str = str(defer_until)
|
scheduled_delivery_timestamp = int(time.time() + 86400)
|
||||||
self.do_schedule_message("stream", "Verona", content, defer_until_str)
|
|
||||||
message = self.last_scheduled_message()
|
self.do_schedule_message("stream", verona_stream_id, content, scheduled_delivery_timestamp)
|
||||||
|
scheduled_message = self.last_scheduled_message()
|
||||||
self.logout()
|
self.logout()
|
||||||
|
|
||||||
# Other user cannot delete it.
|
# Other user cannot delete it.
|
||||||
othello = self.example_user("othello")
|
othello = self.example_user("othello")
|
||||||
result = self.api_delete(othello, f"/api/v1/scheduled_messages/{message.id}")
|
result = self.api_delete(othello, f"/api/v1/scheduled_messages/{scheduled_message.id}")
|
||||||
self.assert_json_error(result, "Scheduled message does not exist", 404)
|
self.assert_json_error(result, "Scheduled message does not exist", 404)
|
||||||
|
|
||||||
self.login("hamlet")
|
self.login("hamlet")
|
||||||
result = self.client_delete(f"/json/scheduled_messages/{message.id}")
|
result = self.client_delete(f"/json/scheduled_messages/{scheduled_message.id}")
|
||||||
self.assert_json_success(result)
|
self.assert_json_success(result)
|
||||||
|
|
||||||
# Already deleted.
|
# Already deleted.
|
||||||
result = self.client_delete(f"/json/scheduled_messages/{message.id}")
|
result = self.client_delete(f"/json/scheduled_messages/{scheduled_message.id}")
|
||||||
self.assert_json_error(result, "Scheduled message does not exist", 404)
|
self.assert_json_error(result, "Scheduled message does not exist", 404)
|
||||||
|
|
|
@ -1,12 +1,9 @@
|
||||||
import sys
|
|
||||||
from email.headerregistry import Address
|
from email.headerregistry import Address
|
||||||
from typing import Iterable, Optional, Sequence, Union, cast
|
from typing import Iterable, Optional, Sequence, Union, cast
|
||||||
|
|
||||||
from dateutil.parser import parse as dateparser
|
|
||||||
from django.core import validators
|
from django.core import validators
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
from django.utils.timezone import now as timezone_now
|
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
from zerver.actions.message_send import (
|
from zerver.actions.message_send import (
|
||||||
|
@ -17,30 +14,22 @@ from zerver.actions.message_send import (
|
||||||
extract_private_recipients,
|
extract_private_recipients,
|
||||||
extract_stream_indicator,
|
extract_stream_indicator,
|
||||||
)
|
)
|
||||||
from zerver.actions.scheduled_messages import check_schedule_message
|
|
||||||
from zerver.lib.exceptions import JsonableError
|
from zerver.lib.exceptions import JsonableError
|
||||||
from zerver.lib.message import render_markdown
|
from zerver.lib.message import render_markdown
|
||||||
from zerver.lib.request import REQ, RequestNotes, has_request_variables
|
from zerver.lib.request import REQ, RequestNotes, has_request_variables
|
||||||
from zerver.lib.response import json_success
|
from zerver.lib.response import json_success
|
||||||
from zerver.lib.timestamp import convert_to_UTC
|
|
||||||
from zerver.lib.topic import REQ_topic
|
from zerver.lib.topic import REQ_topic
|
||||||
from zerver.lib.validator import check_int, check_string_in, to_float
|
from zerver.lib.validator import check_string_in, to_float
|
||||||
from zerver.lib.zcommand import process_zcommands
|
from zerver.lib.zcommand import process_zcommands
|
||||||
from zerver.lib.zephyr import compute_mit_user_fullname
|
from zerver.lib.zephyr import compute_mit_user_fullname
|
||||||
from zerver.models import (
|
from zerver.models import (
|
||||||
Client,
|
Client,
|
||||||
Message,
|
Message,
|
||||||
Realm,
|
|
||||||
RealmDomain,
|
RealmDomain,
|
||||||
UserProfile,
|
UserProfile,
|
||||||
get_user_including_cross_realm,
|
get_user_including_cross_realm,
|
||||||
)
|
)
|
||||||
|
|
||||||
if sys.version_info < (3, 9): # nocoverage
|
|
||||||
from backports import zoneinfo
|
|
||||||
else: # nocoverage
|
|
||||||
import zoneinfo
|
|
||||||
|
|
||||||
|
|
||||||
class InvalidMirrorInputError(Exception):
|
class InvalidMirrorInputError(Exception):
|
||||||
pass
|
pass
|
||||||
|
@ -139,56 +128,6 @@ def same_realm_jabber_user(user_profile: UserProfile, email: str) -> bool:
|
||||||
return RealmDomain.objects.filter(realm=user_profile.realm, domain=domain).exists()
|
return RealmDomain.objects.filter(realm=user_profile.realm, domain=domain).exists()
|
||||||
|
|
||||||
|
|
||||||
def handle_deferred_message(
|
|
||||||
sender: UserProfile,
|
|
||||||
client: Client,
|
|
||||||
recipient_type_name: str,
|
|
||||||
message_to: Union[Sequence[str], Sequence[int]],
|
|
||||||
topic_name: Optional[str],
|
|
||||||
message_content: str,
|
|
||||||
scheduled_message_id: Optional[int],
|
|
||||||
delivery_type: str,
|
|
||||||
defer_until: str,
|
|
||||||
tz_guess: Optional[str],
|
|
||||||
forwarder_user_profile: UserProfile,
|
|
||||||
realm: Optional[Realm],
|
|
||||||
) -> str:
|
|
||||||
deliver_at = None
|
|
||||||
local_tz = "UTC"
|
|
||||||
if tz_guess:
|
|
||||||
local_tz = tz_guess
|
|
||||||
elif sender.timezone:
|
|
||||||
local_tz = sender.timezone
|
|
||||||
try:
|
|
||||||
deliver_at = dateparser(defer_until)
|
|
||||||
except ValueError:
|
|
||||||
raise JsonableError(_("Invalid time format"))
|
|
||||||
|
|
||||||
deliver_at_usertz = deliver_at
|
|
||||||
if deliver_at_usertz.tzinfo is None:
|
|
||||||
user_tz = zoneinfo.ZoneInfo(local_tz)
|
|
||||||
deliver_at_usertz = deliver_at.replace(tzinfo=user_tz)
|
|
||||||
deliver_at = convert_to_UTC(deliver_at_usertz)
|
|
||||||
|
|
||||||
if deliver_at <= timezone_now():
|
|
||||||
raise JsonableError(_("Time must be in the future."))
|
|
||||||
|
|
||||||
check_schedule_message(
|
|
||||||
sender,
|
|
||||||
client,
|
|
||||||
recipient_type_name,
|
|
||||||
message_to,
|
|
||||||
topic_name,
|
|
||||||
message_content,
|
|
||||||
scheduled_message_id,
|
|
||||||
delivery_type,
|
|
||||||
deliver_at,
|
|
||||||
realm=realm,
|
|
||||||
forwarder_user_profile=forwarder_user_profile,
|
|
||||||
)
|
|
||||||
return str(deliver_at_usertz)
|
|
||||||
|
|
||||||
|
|
||||||
@has_request_variables
|
@has_request_variables
|
||||||
def send_message_backend(
|
def send_message_backend(
|
||||||
request: HttpRequest,
|
request: HttpRequest,
|
||||||
|
@ -202,12 +141,6 @@ def send_message_backend(
|
||||||
widget_content: Optional[str] = REQ(default=None, documentation_pending=True),
|
widget_content: Optional[str] = REQ(default=None, documentation_pending=True),
|
||||||
local_id: Optional[str] = REQ(default=None),
|
local_id: Optional[str] = REQ(default=None),
|
||||||
queue_id: Optional[str] = REQ(default=None),
|
queue_id: Optional[str] = REQ(default=None),
|
||||||
scheduled_message_id: Optional[int] = REQ(
|
|
||||||
default=None, json_validator=check_int, documentation_pending=True
|
|
||||||
),
|
|
||||||
delivery_type: str = REQ("delivery_type", default="send_now", documentation_pending=True),
|
|
||||||
defer_until: Optional[str] = REQ("deliver_at", default=None, documentation_pending=True),
|
|
||||||
tz_guess: Optional[str] = REQ("tz_guess", default=None, documentation_pending=True),
|
|
||||||
time: Optional[float] = REQ(default=None, converter=to_float, documentation_pending=True),
|
time: Optional[float] = REQ(default=None, converter=to_float, documentation_pending=True),
|
||||||
) -> HttpResponse:
|
) -> HttpResponse:
|
||||||
recipient_type_name = req_type
|
recipient_type_name = req_type
|
||||||
|
@ -294,26 +227,6 @@ def send_message_backend(
|
||||||
raise JsonableError(_("Invalid mirrored message"))
|
raise JsonableError(_("Invalid mirrored message"))
|
||||||
sender = user_profile
|
sender = user_profile
|
||||||
|
|
||||||
if (delivery_type == "send_later" or delivery_type == "remind") and defer_until is None:
|
|
||||||
raise JsonableError(_("Missing deliver_at in a request for delayed message delivery"))
|
|
||||||
|
|
||||||
if (delivery_type == "send_later" or delivery_type == "remind") and defer_until is not None:
|
|
||||||
deliver_at = handle_deferred_message(
|
|
||||||
sender,
|
|
||||||
client,
|
|
||||||
recipient_type_name,
|
|
||||||
message_to,
|
|
||||||
topic_name,
|
|
||||||
message_content,
|
|
||||||
scheduled_message_id,
|
|
||||||
delivery_type,
|
|
||||||
defer_until,
|
|
||||||
tz_guess,
|
|
||||||
forwarder_user_profile=user_profile,
|
|
||||||
realm=realm,
|
|
||||||
)
|
|
||||||
return json_success(request, data={"deliver_at": deliver_at})
|
|
||||||
|
|
||||||
ret = check_send_message(
|
ret = check_send_message(
|
||||||
sender,
|
sender,
|
||||||
client,
|
client,
|
||||||
|
|
|
@ -1,10 +1,22 @@
|
||||||
from django.http import HttpRequest, HttpResponse
|
from typing import Optional, Sequence, Union
|
||||||
|
|
||||||
from zerver.actions.scheduled_messages import delete_scheduled_message
|
from django.http import HttpRequest, HttpResponse
|
||||||
from zerver.lib.request import has_request_variables
|
from django.utils.timezone import now as timezone_now
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
|
from zerver.actions.message_send import extract_private_recipients
|
||||||
|
from zerver.actions.scheduled_messages import (
|
||||||
|
check_schedule_message,
|
||||||
|
delete_scheduled_message,
|
||||||
|
)
|
||||||
|
from zerver.lib.exceptions import JsonableError
|
||||||
|
from zerver.lib.request import REQ, RequestNotes, has_request_variables
|
||||||
from zerver.lib.response import json_success
|
from zerver.lib.response import json_success
|
||||||
from zerver.lib.scheduled_messages import get_undelivered_scheduled_messages
|
from zerver.lib.scheduled_messages import get_undelivered_scheduled_messages
|
||||||
from zerver.models import UserProfile
|
from zerver.lib.timestamp import timestamp_to_datetime
|
||||||
|
from zerver.lib.topic import REQ_topic
|
||||||
|
from zerver.lib.validator import check_int, check_string_in
|
||||||
|
from zerver.models import Message, UserProfile
|
||||||
|
|
||||||
|
|
||||||
@has_request_variables
|
@has_request_variables
|
||||||
|
@ -20,3 +32,51 @@ def delete_scheduled_messages(
|
||||||
) -> HttpResponse:
|
) -> HttpResponse:
|
||||||
delete_scheduled_message(user_profile, scheduled_message_id)
|
delete_scheduled_message(user_profile, scheduled_message_id)
|
||||||
return json_success(request)
|
return json_success(request)
|
||||||
|
|
||||||
|
|
||||||
|
@has_request_variables
|
||||||
|
def scheduled_messages_backend(
|
||||||
|
request: HttpRequest,
|
||||||
|
user_profile: UserProfile,
|
||||||
|
req_type: str = REQ("type", str_validator=check_string_in(Message.API_RECIPIENT_TYPES)),
|
||||||
|
req_to: str = REQ("to"),
|
||||||
|
topic_name: Optional[str] = REQ_topic(),
|
||||||
|
message_content: str = REQ("content"),
|
||||||
|
scheduled_message_id: Optional[int] = REQ(default=None, json_validator=check_int),
|
||||||
|
scheduled_delivery_timestamp: int = REQ(json_validator=check_int),
|
||||||
|
) -> HttpResponse:
|
||||||
|
recipient_type_name = req_type
|
||||||
|
if recipient_type_name == "direct":
|
||||||
|
# For now, use "private" from Message.API_RECIPIENT_TYPES.
|
||||||
|
# TODO: Use "direct" here, as well as in events and
|
||||||
|
# scheduled message objects/dicts.
|
||||||
|
recipient_type_name = "private"
|
||||||
|
|
||||||
|
deliver_at = timestamp_to_datetime(scheduled_delivery_timestamp)
|
||||||
|
if deliver_at <= timezone_now():
|
||||||
|
raise JsonableError(_("Scheduled delivery time must be in the future."))
|
||||||
|
|
||||||
|
sender = user_profile
|
||||||
|
client = RequestNotes.get_notes(request).client
|
||||||
|
assert client is not None
|
||||||
|
|
||||||
|
if recipient_type_name == "stream":
|
||||||
|
# req_to is ID of the recipient stream.
|
||||||
|
message_to: Union[Sequence[str], Sequence[int]] = [int(req_to)]
|
||||||
|
else:
|
||||||
|
message_to = extract_private_recipients(req_to)
|
||||||
|
|
||||||
|
scheduled_message_id = check_schedule_message(
|
||||||
|
sender,
|
||||||
|
client,
|
||||||
|
recipient_type_name,
|
||||||
|
message_to,
|
||||||
|
topic_name,
|
||||||
|
message_content,
|
||||||
|
scheduled_message_id,
|
||||||
|
"send_later",
|
||||||
|
deliver_at,
|
||||||
|
realm=user_profile.realm,
|
||||||
|
forwarder_user_profile=user_profile,
|
||||||
|
)
|
||||||
|
return json_success(request, data={"scheduled_message_id": scheduled_message_id})
|
||||||
|
|
|
@ -136,7 +136,11 @@ from zerver.views.report import (
|
||||||
report_send_times,
|
report_send_times,
|
||||||
report_unnarrow_times,
|
report_unnarrow_times,
|
||||||
)
|
)
|
||||||
from zerver.views.scheduled_messages import delete_scheduled_messages, fetch_scheduled_messages
|
from zerver.views.scheduled_messages import (
|
||||||
|
delete_scheduled_messages,
|
||||||
|
fetch_scheduled_messages,
|
||||||
|
scheduled_messages_backend,
|
||||||
|
)
|
||||||
from zerver.views.sentry import sentry_tunnel
|
from zerver.views.sentry import sentry_tunnel
|
||||||
from zerver.views.storage import get_storage, remove_storage, update_storage
|
from zerver.views.storage import get_storage, remove_storage, update_storage
|
||||||
from zerver.views.streams import (
|
from zerver.views.streams import (
|
||||||
|
@ -323,7 +327,7 @@ v1_api_and_json_patterns = [
|
||||||
rest_path("drafts", GET=fetch_drafts, POST=create_drafts),
|
rest_path("drafts", GET=fetch_drafts, POST=create_drafts),
|
||||||
rest_path("drafts/<int:draft_id>", PATCH=edit_draft, DELETE=delete_draft),
|
rest_path("drafts/<int:draft_id>", PATCH=edit_draft, DELETE=delete_draft),
|
||||||
# New scheduled messages are created via send_message_backend.
|
# New scheduled messages are created via send_message_backend.
|
||||||
rest_path("scheduled_messages", GET=fetch_scheduled_messages),
|
rest_path("scheduled_messages", GET=fetch_scheduled_messages, POST=scheduled_messages_backend),
|
||||||
rest_path("scheduled_messages/<int:scheduled_message_id>", DELETE=delete_scheduled_messages),
|
rest_path("scheduled_messages/<int:scheduled_message_id>", DELETE=delete_scheduled_messages),
|
||||||
# messages -> zerver.views.message*
|
# messages -> zerver.views.message*
|
||||||
# GET returns messages, possibly filtered, POST sends a message
|
# GET returns messages, possibly filtered, POST sends a message
|
||||||
|
|
Loading…
Reference in New Issue