zerver: Add endpoints and events for reminders.

There are similar to what exists for scheduled messages expect
the PATCH requests which will be added later when the
functionality is implemented.
This commit is contained in:
Aman Agrawal
2025-06-28 02:38:58 +05:30
committed by Tim Abbott
parent 0b4da0ae35
commit 25731859b6
19 changed files with 475 additions and 16 deletions

View File

@@ -20,6 +20,17 @@ format used by the Zulip server that they are interacting with.
## Changes in Zulip 11.0
**Feature level 399**
* [`GET /events`](/api/get-events):
Added `reminders` events sent to clients when a user creates
or deletes scheduled messages.
* [`GET /reminders`](/api/get-reminders):
Clients can now request `/reminders` endpoint to fetch all
scheduled reminders.
* [`DELETE /reminders/{reminder_id}`](/api/delete-reminder):
Clients can now delete a scheduled reminder.
**Feature level 398**
* [`POST /register`](/api/register-queue), [`PATCH /settings`](/api/update-settings),

View File

@@ -30,6 +30,8 @@
#### Message reminders
* [Create a message reminder](/api/create-message-reminder)
* [Get reminders](/api/get-reminders)
* [Delete a reminder](/api/delete-reminder)
#### Drafts

View File

@@ -34,7 +34,7 @@ DESKTOP_WARNING_VERSION = "5.9.3"
# new level means in api_docs/changelog.md, as well as "**Changes**"
# entries in the endpoint's documentation in `zulip.yaml`.
API_FEATURE_LEVEL = 398
API_FEATURE_LEVEL = 399
# 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

View File

@@ -1,11 +1,14 @@
import datetime
from django.db import transaction
from zerver.actions.message_send import check_message
from zerver.actions.scheduled_messages import do_schedule_messages
from zerver.lib.addressee import Addressee
from zerver.lib.message import access_message
from zerver.lib.reminders import get_reminder_formatted_content
from zerver.models import Client, ScheduledMessage, UserProfile
from zerver.tornado.django_api import send_event_on_commit
def schedule_reminder_for_message(
@@ -34,6 +37,22 @@ def schedule_reminder_for_message(
[send_request],
current_user,
read_by_sender=False,
skip_events=True,
delivery_type=ScheduledMessage.REMIND,
)[0]
def notify_remove_reminder(user_profile: UserProfile, reminder_id: int) -> None:
event = {
"type": "reminders",
"op": "remove",
"reminder_id": reminder_id,
}
send_event_on_commit(user_profile.realm, event, [user_profile.id])
@transaction.atomic(durable=True)
def do_delete_reminder(user_profile: UserProfile, reminder: ScheduledMessage) -> None:
assert reminder.delivery_type == ScheduledMessage.REMIND
reminder_id = reminder.id
reminder.delete()
notify_remove_reminder(user_profile, reminder_id)

View File

@@ -90,6 +90,15 @@ def notify_new_scheduled_message(
send_event_on_commit(user_profile.realm, event, [user_profile.id])
def notify_new_reminder(user_profile: UserProfile, reminders: list[ScheduledMessage]) -> None:
event = {
"type": "reminders",
"op": "add",
"reminders": [reminder.to_reminder_dict() for reminder in reminders],
}
send_event_on_commit(user_profile.realm, event, [user_profile.id])
def do_schedule_messages(
send_message_requests: Sequence[SendMessageRequest],
sender: UserProfile,
@@ -137,7 +146,10 @@ def do_schedule_messages(
scheduled_message.save(update_fields=["has_attachment"])
if not skip_events:
notify_new_scheduled_message(sender, scheduled_message_objects)
if delivery_type == ScheduledMessage.REMIND:
notify_new_reminder(sender, scheduled_message_objects)
else:
notify_new_scheduled_message(sender, scheduled_message_objects)
return [scheduled_message.id for scheduled_message, ignored in scheduled_messages]
@@ -286,7 +298,6 @@ def delete_scheduled_message(user_profile: UserProfile, scheduled_message_id: in
scheduled_message_object = access_scheduled_message(user_profile, scheduled_message_id)
scheduled_message_id = scheduled_message_object.id
scheduled_message_object.delete()
notify_remove_scheduled_message(user_profile, scheduled_message_id)

View File

@@ -65,6 +65,8 @@ from zerver.lib.event_types import (
EventRealmUserRemove,
EventRealmUserSettingsDefaultsUpdate,
EventRealmUserUpdate,
EventRemindersAdd,
EventRemindersRemove,
EventRestart,
EventSavedSnippetsAdd,
EventSavedSnippetsRemove,
@@ -197,6 +199,8 @@ check_realm_linkifiers = make_checker(EventRealmLinkifiers)
check_realm_playgrounds = make_checker(EventRealmPlaygrounds)
check_realm_user_add = make_checker(EventRealmUserAdd)
check_realm_user_remove = make_checker(EventRealmUserRemove)
check_reminder_add = make_checker(EventRemindersAdd)
check_reminder_remove = make_checker(EventRemindersRemove)
check_restart = make_checker(EventRestart)
check_saved_snippets_add = make_checker(EventSavedSnippetsAdd)
check_saved_snippets_remove = make_checker(EventSavedSnippetsRemove)

View File

@@ -821,6 +821,29 @@ class EventScheduledMessagesUpdate(BaseEvent):
scheduled_message: ScheduledMessageFields
class ReminderFields(BaseModel):
reminder_id: int
type: Literal["private"]
to: list[int]
content: str
rendered_content: str
scheduled_delivery_timestamp: int
failed: bool
reminder_target_message_id: int
class EventRemindersAdd(BaseEvent):
type: Literal["reminders"]
op: Literal["add"]
reminders: list[ReminderFields]
class EventRemindersRemove(BaseEvent):
type: Literal["reminders"]
op: Literal["remove"]
reminder_id: int
class BasicStreamFields(BaseModel):
is_archived: bool
can_administer_channel_group: int | UserGroupMembersDict

View File

@@ -51,7 +51,10 @@ from zerver.lib.onboarding_steps import get_next_onboarding_steps
from zerver.lib.presence import get_presence_for_user, get_presences_for_realm
from zerver.lib.realm_icon import realm_icon_url
from zerver.lib.realm_logo import get_realm_logo_source, get_realm_logo_url
from zerver.lib.scheduled_messages import get_undelivered_scheduled_messages
from zerver.lib.scheduled_messages import (
get_undelivered_reminders,
get_undelivered_scheduled_messages,
)
from zerver.lib.soft_deactivation import reactivate_user_if_soft_deactivated
from zerver.lib.sounds import get_available_notification_sounds
from zerver.lib.stream_subscription import handle_stream_notifications_compatibility
@@ -328,6 +331,9 @@ def fetch_initial_state_data(
[] if user_profile is None else get_undelivered_scheduled_messages(user_profile)
)
if want("reminders"):
state["reminders"] = [] if user_profile is None else get_undelivered_reminders(user_profile)
if want("muted_topics") and (
# Suppress muted_topics data for clients that explicitly
# support user_topic. This allows clients to request both the

View File

@@ -1,6 +1,7 @@
from django.conf import settings
from django.utils.translation import gettext as _
from zerver.lib.exceptions import ResourceNotFoundError
from zerver.lib.markdown.fenced_code import get_unused_fence
from zerver.lib.mention import silent_mention_syntax_for_user
from zerver.lib.message import truncate_content
@@ -8,6 +9,7 @@ from zerver.lib.message_cache import MessageDict
from zerver.lib.topic_link_util import get_message_link_syntax
from zerver.lib.url_encoding import message_link_url
from zerver.models import Message, Stream, UserProfile
from zerver.models.scheduled_jobs import ScheduledMessage
def get_reminder_formatted_content(message: Message, current_user: UserProfile) -> str:
@@ -46,3 +48,12 @@ def get_reminder_formatted_content(message: Message, current_user: UserProfile)
fence=fence,
msg_content=msg_content,
)
def access_reminder(user_profile: UserProfile, reminder_id: int) -> ScheduledMessage:
try:
return ScheduledMessage.objects.get(
id=reminder_id, sender=user_profile, delivery_type=ScheduledMessage.REMIND
)
except ScheduledMessage.DoesNotExist:
raise ResourceNotFoundError(_("Reminder does not exist"))

View File

@@ -3,6 +3,7 @@ from django.utils.translation import gettext as _
from zerver.lib.exceptions import ResourceNotFoundError
from zerver.models import ScheduledMessage, UserProfile
from zerver.models.scheduled_jobs import (
APIReminderDirectMessageDict,
APIScheduledDirectMessageDict,
APIScheduledStreamMessageDict,
)
@@ -12,7 +13,9 @@ def access_scheduled_message(
user_profile: UserProfile, scheduled_message_id: int
) -> ScheduledMessage:
try:
return ScheduledMessage.objects.get(id=scheduled_message_id, sender=user_profile)
return ScheduledMessage.objects.get(
id=scheduled_message_id, sender=user_profile, delivery_type=ScheduledMessage.SEND_LATER
)
except ScheduledMessage.DoesNotExist:
raise ResourceNotFoundError(_("Scheduled message does not exist"))
@@ -32,3 +35,20 @@ def get_undelivered_scheduled_messages(
scheduled_message.to_dict() for scheduled_message in scheduled_messages
]
return scheduled_message_dicts
def get_undelivered_reminders(
user_profile: UserProfile,
) -> list[APIReminderDirectMessageDict]:
reminders = ScheduledMessage.objects.filter(
realm_id=user_profile.realm_id,
sender=user_profile,
# Notably, we don't require failed=False, since we will want
# to display those to users.
delivered=False,
delivery_type=ScheduledMessage.REMIND,
).order_by("scheduled_timestamp")
reminder_dicts: list[APIReminderDirectMessageDict] = [
reminder.to_reminder_dict() for reminder in reminders
]
return reminder_dicts

View File

@@ -138,6 +138,17 @@ class APIScheduledDirectMessageDict(TypedDict):
failed: bool
class APIReminderDirectMessageDict(TypedDict):
reminder_id: int
to: list[int]
type: str
content: str
rendered_content: str
scheduled_delivery_timestamp: int
failed: bool
reminder_target_message_id: int
class ScheduledMessage(models.Model):
sender = models.ForeignKey(UserProfile, on_delete=CASCADE)
recipient = models.ForeignKey(Recipient, on_delete=CASCADE)
@@ -247,6 +258,21 @@ class ScheduledMessage(models.Model):
failed=self.failed,
)
def to_reminder_dict(self) -> APIReminderDirectMessageDict:
assert self.reminder_target_message_id is not None
recipient, recipient_type_str = get_recipient_ids(self.recipient, self.sender.id)
assert recipient_type_str == "private"
return APIReminderDirectMessageDict(
reminder_id=self.id,
to=recipient,
type=recipient_type_str,
content=self.content,
rendered_content=self.rendered_content,
scheduled_delivery_timestamp=datetime_to_timestamp(self.scheduled_timestamp),
failed=self.failed,
reminder_target_message_id=self.reminder_target_message_id,
)
EMAIL_TYPES = {
"account_registered": ScheduledEmail.WELCOME,

View File

@@ -28,6 +28,9 @@ UNTESTED_GENERATED_CURL_EXAMPLES = {
# Would need push notification bouncer set up to test the
# generated curl example for this endpoint.
"test-notify",
# Having a message for a specific user available to test this endpoint
# is tricky for testing.
"delete-reminder",
}

View File

@@ -5765,6 +5765,78 @@ paths:
"op": "remove",
"saved_snippet_id": 17,
}
- type: object
additionalProperties: false
description: |
Event sent to a user's clients when a reminder is scheduled.
**Changes**: New in Zulip 11.0 (feature level 399).
properties:
id:
$ref: "#/components/schemas/EventIdSchema"
type:
allOf:
- $ref: "#/components/schemas/EventTypeSchema"
- enum:
- reminders
op:
type: string
enum:
- add
reminders:
type: array
description: |
An array of objects containing details of the newly created
reminders.
items:
$ref: "#/components/schemas/Reminder"
example:
{
"type": "reminders",
"op": "add",
"reminders":
[
{
"reminder_id": 17,
"type": "private",
"to": [6],
"content": "Hello there!",
"rendered_content": "<p>Hello there!</p>",
"scheduled_delivery_timestamp": 1681662420,
"failed": false,
"reminder_target_message_id": 42,
},
],
}
- type: object
additionalProperties: false
description: |
Event sent to a user's clients when a reminder
is deleted.
**Changes**: New in Zulip 11.0 (feature level 399).
properties:
id:
$ref: "#/components/schemas/EventIdSchema"
type:
allOf:
- $ref: "#/components/schemas/EventTypeSchema"
- enum:
- reminders
op:
type: string
enum:
- remove
reminder_id:
type: integer
description: |
The ID of the reminder that was deleted.
example:
{
"type": "reminders",
"op": "remove",
"reminder_id": 17,
}
- type: object
additionalProperties: false
description: |
@@ -7020,6 +7092,56 @@ paths:
"msg": "Saved snippet does not exist.",
}
/reminders:
get:
operationId: get-reminders
tags: ["reminders"]
summary: Get reminders
description: |
Fetch all [reminders](/help/schedule-a-reminder) for the
current user.
Reminders are messages the user has scheduled to be sent in the
future to themself.
**Changes**: New in Zulip 11.0 (feature level 399).
responses:
"200":
description: Success.
content:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/JsonSuccessBase"
- additionalProperties: false
properties:
result: {}
msg: {}
ignored_parameters_unsupported: {}
reminders:
type: array
description: |
Returns all of the current user's undelivered reminders,
ordered by `scheduled_delivery_timestamp` (ascending).
items:
$ref: "#/components/schemas/Reminder"
example:
{
"result": "success",
"msg": "",
"reminders":
[
{
"reminder_id": 27,
"to": [6],
"type": "private",
"content": "Hi",
"rendered_content": "<p>Hi</p>",
"scheduled_delivery_timestamp": 1681662420,
"failed": false,
"reminder_target_message_id": 42,
},
],
}
post:
operationId: create-message-reminder
tags: ["reminders"]
@@ -7064,6 +7186,48 @@ paths:
description: |
Unique ID of the scheduled message reminder.
example: {"msg": "", "reminder_id": 42, "result": "success"}
/reminders/{reminder_id}:
delete:
operationId: delete-reminder
tags: ["reminders"]
summary: Delete a reminder
description: |
Delete, and therefore cancel sending, a previously [scheduled
reminder](/help/schedule-a-reminder).
**Changes**: New in Zulip 11.0 (feature level 399).
parameters:
- name: reminder_id
in: path
schema:
type: integer
description: |
The ID of the reminder to delete.
This is different from the unique ID that the message would have
after being sent.
required: true
example: 1
responses:
"200":
$ref: "#/components/responses/SimpleSuccess"
"404":
description: Not Found.
content:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/CodedError"
- description: |
A typical failed JSON response for when no reminder exists
with the provided ID:
example:
{
"code": "BAD_REQUEST",
"result": "error",
"msg": "Reminder does not exist",
}
/scheduled_messages:
get:
operationId: get-scheduled-messages
@@ -15875,6 +16039,17 @@ paths:
**Changes**: New in Zulip 7.0 (feature level 179).
items:
$ref: "#/components/schemas/ScheduledMessage"
reminders:
type: array
description: |
Present if `reminders` is present in `fetch_event_types`.
An array of all undelivered reminders scheduled by the user.
**Changes**: New in Zulip 11.0 (feature level 399).
items:
$ref: "#/components/schemas/ScheduledMessage"
muted_topics:
type: array
deprecated: true
@@ -26272,6 +26447,71 @@ components:
rendered_content: {}
scheduled_delivery_timestamp: {}
failed: {}
Reminder:
type: object
additionalProperties: false
description: |
Object containing details of the scheduled message.
properties:
reminder_id:
type: integer
description: |
The unique ID of the reminder, which can be used to
delete the reminder.
This is different from the unique ID that the message would have
after being sent.
type:
type: string
description: |
The type of the reminder. Always set to `"private"`.
enum:
- private
to:
type: array
items:
type: integer
description: |
Contains the ID of the user who scheduled the reminder,
and to which the reminder will be sent.
content:
type: string
description: |
The content/body of the reminder, in text/markdown format.
rendered_content:
type: string
description: |
The content/body of the reminder rendered in HTML.
scheduled_delivery_timestamp:
type: integer
description: |
The UNIX timestamp for when the message will be sent
by the server, in UTC seconds.
example: 1595479019
failed:
type: boolean
description: |
Whether the server has tried to send the reminder
and it failed to successfully send.
Clients that support unscheduling reminders
should display scheduled messages with `"failed": true` with an
indicator that the server failed to send the message at the
scheduled time, so that the user is aware of the failure and can
get the content of the scheduled message.
reminder_target_message_id:
type: integer
description: |
The ID of the message that the reminder is created for.
required:
- reminder_id
- type
- to
- content
- rendered_content
- scheduled_delivery_timestamp
- failed
- reminder_target_message_id
GroupPermissionSetting:
description: |
Configuration for a group permission setting specifying the groups

View File

@@ -1241,7 +1241,7 @@ class FetchQueriesTest(ZulipTestCase):
self.login_user(user)
with (
self.assert_database_query_count(46),
self.assert_database_query_count(47),
mock.patch("zerver.lib.events.always_want") as want_mock,
):
fetch_initial_state_data(user, realm=user.realm)
@@ -1277,6 +1277,7 @@ class FetchQueriesTest(ZulipTestCase):
realm_user_groups=2,
realm_user_settings_defaults=1,
recent_private_conversations=1,
reminders=1,
saved_snippets=1,
scheduled_messages=1,
starred_messages=1,

View File

@@ -223,6 +223,7 @@ class HomeTest(ZulipTestCase):
"realm_zulip_update_announcements_stream_id",
"realm_moderation_request_channel_id",
"recent_private_conversations",
"reminders",
"saved_snippets",
"scheduled_messages",
"server_avatar_changes_disabled",
@@ -284,7 +285,7 @@ class HomeTest(ZulipTestCase):
# Verify succeeds once logged-in
with (
self.assert_database_query_count(56),
self.assert_database_query_count(57),
patch("zerver.lib.cache.cache_set") as cache_mock,
):
result = self._get_home_page(stream="Denmark")
@@ -592,7 +593,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(55),
self.assert_database_query_count(56),
patch("zerver.lib.cache.cache_set") as cache_mock,
):
result = self._get_home_page()
@@ -624,7 +625,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(51):
with self.assert_database_query_count(52):
result = self._get_home_page()
# Do a sanity check that our new streams were in the payload.

View File

@@ -289,3 +289,58 @@ class RemindersTest(ZulipTestCase):
self.get_dm_reminder_content(content, reminder.reminder_target_message_id),
)
self.assertEqual(delivered_message.date_sent, more_than_scheduled_delivery_datetime)
def test_delete_reminder(self) -> None:
hamlet = self.example_user("hamlet")
cordelia = self.example_user("cordelia")
response = self.api_get(hamlet, "/api/v1/reminders")
self.assert_json_success(response)
response_data = response.json()
self.assertEqual(response_data["reminders"], [])
# Create a test message to schedule a reminder for.
message_id = self.send_stream_message(
hamlet,
"Denmark",
)
# Schedule a reminder for the created message.
deliver_at = int(time.time() + 86400)
response = self.do_schedule_reminder(
message_id=message_id,
scheduled_delivery_timestamp=deliver_at,
)
self.assert_json_success(response)
response_data = response.json()
self.assertIn("reminder_id", response_data)
reminder_id = response_data["reminder_id"]
# Verify that the reminder was scheduled correctly.
reminders_response = self.api_get(hamlet, "/api/v1/reminders")
self.assert_json_success(reminders_response)
reminders_data = reminders_response.json()
self.assert_length(reminders_data["reminders"], 1)
reminder = reminders_data["reminders"][0]
self.assertEqual(reminder["reminder_id"], reminder_id)
self.assertEqual(reminder["reminder_target_message_id"], message_id)
# Test deleting the reminder with the wrong user.
result = self.api_delete(cordelia, f"/api/v1/reminders/{reminder_id}")
self.assert_json_error(result, "Reminder does not exist", status_code=404)
# Test deleting the reminder.
result = self.client_delete(f"/json/reminders/{reminder_id}")
self.assert_json_success(result)
# Verify that the reminder was deleted.
self.assertEqual(response.status_code, 200)
reminders_response = self.api_get(hamlet, "/api/v1/reminders")
self.assert_json_success(reminders_response)
reminders_data = reminders_response.json()
self.assert_length(reminders_data["reminders"], 0)
# Try deleting again to trigger failure.
result = self.client_delete(f"/json/reminders/{reminder_id}")
self.assert_json_error(result, "Reminder does not exist", status_code=404)

View File

@@ -1,13 +1,14 @@
from django.http import HttpRequest, HttpResponse
from django.utils.timezone import now as timezone_now
from pydantic import Json
from pydantic import Json, NonNegativeInt
from zerver.actions.reminders import schedule_reminder_for_message
from zerver.actions.reminders import do_delete_reminder, schedule_reminder_for_message
from zerver.lib.exceptions import DeliveryTimeNotInFutureError
from zerver.lib.reminders import access_reminder
from zerver.lib.request import RequestNotes
from zerver.lib.response import json_success
from zerver.lib.timestamp import timestamp_to_datetime
from zerver.lib.typed_endpoint import typed_endpoint
from zerver.lib.typed_endpoint import PathOnly, typed_endpoint
from zerver.models import UserProfile
@@ -33,3 +34,15 @@ def create_reminders_message_backend(
deliver_at,
)
return json_success(request, data={"reminder_id": reminder_id})
@typed_endpoint
def delete_reminder(
request: HttpRequest,
user_profile: UserProfile,
*,
reminder_id: PathOnly[NonNegativeInt],
) -> HttpResponse:
reminder = access_reminder(user_profile, reminder_id)
do_delete_reminder(user_profile, reminder)
return json_success(request)

View File

@@ -14,7 +14,10 @@ from zerver.lib.exceptions import DeliveryTimeNotInFutureError, JsonableError
from zerver.lib.recipient_parsing import extract_direct_message_recipient_ids, extract_stream_id
from zerver.lib.request import RequestNotes
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_reminders,
get_undelivered_scheduled_messages,
)
from zerver.lib.timestamp import timestamp_to_datetime
from zerver.lib.typed_endpoint import (
ApiParamConfig,
@@ -34,6 +37,11 @@ def fetch_scheduled_messages(request: HttpRequest, user_profile: UserProfile) ->
)
@typed_endpoint_without_parameters
def fetch_reminders(request: HttpRequest, user_profile: UserProfile) -> HttpResponse:
return json_success(request, data={"reminders": get_undelivered_reminders(user_profile)})
@typed_endpoint
def delete_scheduled_messages(
request: HttpRequest,

View File

@@ -164,7 +164,7 @@ from zerver.views.registration import (
realm_register,
signup_send_confirm,
)
from zerver.views.reminders import create_reminders_message_backend
from zerver.views.reminders import create_reminders_message_backend, delete_reminder
from zerver.views.report import report_csp_violations
from zerver.views.saved_snippets import (
create_saved_snippet,
@@ -175,6 +175,7 @@ from zerver.views.saved_snippets import (
from zerver.views.scheduled_messages import (
create_scheduled_message_backend,
delete_scheduled_messages,
fetch_reminders,
fetch_scheduled_messages,
update_scheduled_message_backend,
)
@@ -378,7 +379,11 @@ v1_api_and_json_patterns = [
DELETE=delete_saved_snippet,
PATCH=edit_saved_snippet,
),
rest_path("reminders", POST=create_reminders_message_backend),
rest_path("reminders", GET=fetch_reminders, POST=create_reminders_message_backend),
rest_path(
"reminders/<int:reminder_id>",
DELETE=delete_reminder,
),
rest_path(
"scheduled_messages", GET=fetch_scheduled_messages, POST=create_scheduled_message_backend
),