saved_snippets: Add backend for saved snippets.

Part of #31227.
This commit is contained in:
Vector73 2024-09-24 20:31:58 +05:30 committed by Tim Abbott
parent 90a4b4934a
commit 9e4e85e140
20 changed files with 657 additions and 5 deletions

View File

@ -20,6 +20,18 @@ format used by the Zulip server that they are interacting with.
## Changes in Zulip 10.0 ## Changes in Zulip 10.0
**Feature level 297**
* [`GET /events`](/api/get-events), [`POST /register`](/api/register-queue):
An event with `type: "saved_snippet"` is sent to the current user when a
saved snippet is created or deleted.
* [`GET /saved_snippets`](/api/get-saved-snippets): Added a new endpoint for
fetching saved snippets of the user.
* [`POST /saved_snippets`](/api/create-saved-snippet): Added a new endpoint for
creating a new saved snippet.
* [`DELETE /saved_snippets/{saved_snippet_id}`](/api/delete-saved-snippet): Added
a new endpoint for deleting saved snippets.
**Feature level 296**: **Feature level 296**:
* [`POST /register`](/api/register-queue), [`GET /events`](/api/get-events), * [`POST /register`](/api/register-queue), [`GET /events`](/api/get-events),

View File

@ -32,6 +32,9 @@
* [Create drafts](/api/create-drafts) * [Create drafts](/api/create-drafts)
* [Edit a draft](/api/edit-draft) * [Edit a draft](/api/edit-draft)
* [Delete a draft](/api/delete-draft) * [Delete a draft](/api/delete-draft)
* [Get all saved snippets](/api/get-saved-snippets)
* [Create a saved snippet](/api/create-saved-snippet)
* [Delete a saved snippet](/api/delete-saved-snippet)
#### Channels #### Channels

View File

@ -34,7 +34,7 @@ DESKTOP_WARNING_VERSION = "5.9.3"
# new level means in api_docs/changelog.md, as well as "**Changes**" # new level means in api_docs/changelog.md, as well as "**Changes**"
# entries in the endpoint's documentation in `zulip.yaml`. # entries in the endpoint's documentation in `zulip.yaml`.
API_FEATURE_LEVEL = 296 # Last bumped for `editable_by_user` custom profile field setting. API_FEATURE_LEVEL = 297 # Last bumped for saved_snippets
# Bump the minor PROVISION_VERSION to indicate that folks should provision # 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 # only when going from an old version of the code to a newer version. Bump

View File

@ -0,0 +1,62 @@
from typing import Any
from django.db import transaction
from django.utils.timezone import now as timezone_now
from django.utils.translation import gettext as _
from zerver.lib.exceptions import ResourceNotFoundError
from zerver.models import RealmAuditLog, SavedSnippet, UserProfile
from zerver.models.realm_audit_logs import AuditLogEventType
from zerver.tornado.django_api import send_event_on_commit
@transaction.atomic(durable=True)
def do_create_saved_snippet(
title: str,
content: str,
user_profile: UserProfile,
) -> SavedSnippet:
saved_snippet = SavedSnippet.objects.create(
realm=user_profile.realm,
user_profile=user_profile,
title=title,
content=content,
)
RealmAuditLog.objects.create(
realm=user_profile.realm,
acting_user=user_profile,
modified_user=user_profile,
event_type=AuditLogEventType.SAVED_SNIPPET_CREATED,
event_time=timezone_now(),
extra_data={"saved_snippet_id": saved_snippet.id},
)
event = {
"type": "saved_snippets",
"op": "add",
"saved_snippet": saved_snippet.to_api_dict(),
}
send_event_on_commit(user_profile.realm, event, [user_profile.id])
return saved_snippet
def do_get_saved_snippets(user_profile: UserProfile) -> list[dict[str, Any]]:
saved_snippets = SavedSnippet.objects.filter(user_profile=user_profile)
return [saved_snippet.to_api_dict() for saved_snippet in saved_snippets]
def do_delete_saved_snippet(
saved_snippet_id: int,
user_profile: UserProfile,
) -> None:
try:
saved_snippet = SavedSnippet.objects.get(id=saved_snippet_id, user_profile=user_profile)
except SavedSnippet.DoesNotExist:
raise ResourceNotFoundError(_("Saved snippet does not exist."))
saved_snippet.delete()
event = {"type": "saved_snippets", "op": "remove", "saved_snippet_id": saved_snippet_id}
send_event_on_commit(user_profile.realm, event, [user_profile.id])

View File

@ -301,6 +301,32 @@ drafts_remove_event = event_dict_type(
) )
check_draft_remove = make_checker(drafts_remove_event) check_draft_remove = make_checker(drafts_remove_event)
saved_snippet_fields = DictType(
required_keys=[
("id", int),
("title", str),
("content", str),
("date_created", int),
],
)
saved_snippet_add_event = event_dict_type(
required_keys=[
("type", Equals("saved_snippets")),
("op", Equals("add")),
("saved_snippet", saved_snippet_fields),
]
)
check_saved_snippet_add = make_checker(saved_snippet_add_event)
saved_snippet_remove_event = event_dict_type(
required_keys=[
("type", Equals("saved_snippets")),
("op", Equals("remove")),
("saved_snippet_id", int),
]
)
check_saved_snippet_remove = make_checker(saved_snippet_remove_event)
has_zoom_token_event = event_dict_type( has_zoom_token_event = event_dict_type(
required_keys=[ required_keys=[

View File

@ -13,6 +13,7 @@ from typing_extensions import NotRequired, TypedDict
from version import API_FEATURE_LEVEL, ZULIP_MERGE_BASE, ZULIP_VERSION from version import API_FEATURE_LEVEL, ZULIP_MERGE_BASE, ZULIP_VERSION
from zerver.actions.default_streams import default_stream_groups_to_dicts_sorted from zerver.actions.default_streams import default_stream_groups_to_dicts_sorted
from zerver.actions.realm_settings import get_realm_authentication_methods_for_page_params_api from zerver.actions.realm_settings import get_realm_authentication_methods_for_page_params_api
from zerver.actions.saved_snippets import do_get_saved_snippets
from zerver.actions.users import get_owned_bot_dicts from zerver.actions.users import get_owned_bot_dicts
from zerver.lib import emoji from zerver.lib import emoji
from zerver.lib.alert_words import user_alert_words from zerver.lib.alert_words import user_alert_words
@ -205,6 +206,12 @@ def fetch_initial_state_data(
# remove this parameter from the API. # remove this parameter from the API.
state["max_message_id"] = max_message_id_for_user(user_profile) state["max_message_id"] = max_message_id_for_user(user_profile)
if want("saved_snippets"):
if user_profile is None:
state["saved_snippets"] = []
else:
state["saved_snippets"] = do_get_saved_snippets(user_profile)
if want("drafts"): if want("drafts"):
if user_profile is None: if user_profile is None:
state["drafts"] = [] state["drafts"] = []
@ -871,6 +878,15 @@ def apply_event(
# this code path. But in any case, they're noops. # this code path. But in any case, they're noops.
pass pass
elif event["type"] == "saved_snippets":
if event["op"] == "add":
state["saved_snippets"].append(event["saved_snippet"])
elif event["op"] == "remove":
for idx, saved_snippet in enumerate(state["saved_snippets"]):
if saved_snippet["id"] == event["saved_snippet_id"]:
del state["saved_snippets"][idx]
break
elif event["type"] == "drafts": elif event["type"] == "drafts":
if event["op"] == "add": if event["op"] == "add":
state["drafts"].extend(event["drafts"]) state["drafts"].extend(event["drafts"])

View File

@ -161,6 +161,7 @@ ALL_ZULIP_TABLES = {
"zerver_realmreactivationstatus", "zerver_realmreactivationstatus",
"zerver_realmuserdefault", "zerver_realmuserdefault",
"zerver_recipient", "zerver_recipient",
"zerver_savedsnippet",
"zerver_scheduledemail", "zerver_scheduledemail",
"zerver_scheduledemail_users", "zerver_scheduledemail_users",
"zerver_scheduledmessage", "zerver_scheduledmessage",

View File

@ -69,6 +69,7 @@ from zerver.models import (
RealmPlayground, RealmPlayground,
RealmUserDefault, RealmUserDefault,
Recipient, Recipient,
SavedSnippet,
ScheduledMessage, ScheduledMessage,
Service, Service,
Stream, Stream,
@ -1425,6 +1426,14 @@ def do_import_realm(import_dir: Path, subdomain: str, processes: int = 1) -> Rea
update_model_ids(AlertWord, data, "alertword") update_model_ids(AlertWord, data, "alertword")
bulk_import_model(data, AlertWord) bulk_import_model(data, AlertWord)
if "zerver_savedsnippet" in data:
re_map_foreign_keys(
data, "zerver_savedsnippet", "user_profile", related_table="user_profile"
)
re_map_foreign_keys(data, "zerver_savedsnippet", "realm", related_table="realm")
update_model_ids(SavedSnippet, data, "savedsnippet")
bulk_import_model(data, SavedSnippet)
if "zerver_onboardingstep" in data: if "zerver_onboardingstep" in data:
fix_datetime_fields(data, "zerver_onboardingstep") fix_datetime_fields(data, "zerver_onboardingstep")
re_map_foreign_keys(data, "zerver_onboardingstep", "user", related_table="user_profile") re_map_foreign_keys(data, "zerver_onboardingstep", "user", related_table="user_profile")

View File

@ -0,0 +1,41 @@
# Generated by Django 5.0.8 on 2024-09-24 14:51
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("zerver", "0586_customprofilefield_editable_by_user"),
]
operations = [
migrations.CreateModel(
name="SavedSnippet",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("title", models.TextField(max_length=60)),
("content", models.TextField(max_length=10000)),
("date_created", models.DateTimeField(default=django.utils.timezone.now)),
(
"realm",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="zerver.realm"
),
),
(
"user_profile",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
),
),
],
),
]

View File

@ -51,6 +51,7 @@ from zerver.models.realms import RealmAuthenticationMethod as RealmAuthenticatio
from zerver.models.realms import RealmDomain as RealmDomain from zerver.models.realms import RealmDomain as RealmDomain
from zerver.models.recipients import DirectMessageGroup as DirectMessageGroup from zerver.models.recipients import DirectMessageGroup as DirectMessageGroup
from zerver.models.recipients import Recipient as Recipient from zerver.models.recipients import Recipient as Recipient
from zerver.models.saved_snippets import SavedSnippet as SavedSnippet
from zerver.models.scheduled_jobs import AbstractScheduledJob as AbstractScheduledJob from zerver.models.scheduled_jobs import AbstractScheduledJob as AbstractScheduledJob
from zerver.models.scheduled_jobs import MissedMessageEmailAddress as MissedMessageEmailAddress from zerver.models.scheduled_jobs import MissedMessageEmailAddress as MissedMessageEmailAddress
from zerver.models.scheduled_jobs import ScheduledEmail as ScheduledEmail from zerver.models.scheduled_jobs import ScheduledEmail as ScheduledEmail

View File

@ -108,6 +108,8 @@ class AuditLogEventType(IntEnum):
USER_GROUP_GROUP_BASED_SETTING_CHANGED = 722 USER_GROUP_GROUP_BASED_SETTING_CHANGED = 722
USER_GROUP_DEACTIVATED = 723 USER_GROUP_DEACTIVATED = 723
SAVED_SNIPPET_CREATED = 800
# The following values are only for remote server/realm logs. # The following values are only for remote server/realm logs.
# Values should be exactly 10000 greater than the corresponding # Values should be exactly 10000 greater than the corresponding
# value used for the same purpose in realm audit logs (e.g., # value used for the same purpose in realm audit logs (e.g.,

View File

@ -0,0 +1,26 @@
from typing import Any
from django.conf import settings
from django.db import models
from django.utils.timezone import now as timezone_now
from zerver.models.realms import Realm
from zerver.models.users import UserProfile
class SavedSnippet(models.Model):
MAX_TITLE_LENGTH = 60
realm = models.ForeignKey(Realm, on_delete=models.CASCADE)
user_profile = models.ForeignKey(UserProfile, on_delete=models.CASCADE)
title = models.TextField(max_length=MAX_TITLE_LENGTH)
content = models.TextField(max_length=settings.MAX_MESSAGE_LENGTH)
date_created = models.DateTimeField(default=timezone_now)
def to_api_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"title": self.title,
"content": self.content,
"date_created": int(self.date_created.timestamp()),
}

View File

@ -1143,6 +1143,50 @@ def remove_attachment(client: Client, attachment_id: int) -> None:
validate_against_openapi_schema(result, "/attachments/{attachment_id}", "delete", "200") validate_against_openapi_schema(result, "/attachments/{attachment_id}", "delete", "200")
@openapi_test_function("/saved_snippets:post")
def create_saved_snippet(client: Client) -> None:
# {code_example|start}
# Create a saved snippet.
request = {"title": "Welcome message", "content": "**Welcome** to the organization."}
result = client.call_endpoint(
request=request,
url="/saved_snippets",
method="POST",
)
# {code_example|end}
assert_success_response(result)
validate_against_openapi_schema(result, "/saved_snippets", "post", "200")
@openapi_test_function("/saved_snippets:get")
def get_saved_snippets(client: Client) -> None:
# {code_example|start}
# Get all the saved snippets.
result = client.call_endpoint(
url="/saved_snippets",
method="GET",
)
# {code_example|end}
assert_success_response(result)
validate_against_openapi_schema(result, "/saved_snippets", "get", "200")
@openapi_test_function("/saved_snippets/{saved_snippet_id}:delete")
def delete_saved_snippet(client: Client) -> None:
saved_snippet_id = client.call_endpoint(url="/saved_snippets", method="GET")["saved_snippets"][
0
]["id"]
# {code_example|start}
# Delete a saved snippet.
result = client.call_endpoint(
url=f"/saved_snippets/{saved_snippet_id}",
method="DELETE",
)
# {code_example|end}
assert_success_response(result)
validate_against_openapi_schema(result, "/saved_snippets/{saved_snippet_id}", "delete", "200")
@openapi_test_function("/messages:post") @openapi_test_function("/messages:post")
def send_message(client: Client) -> int: def send_message(client: Client) -> int:
request: dict[str, Any] = {} request: dict[str, Any] = {}
@ -1770,6 +1814,9 @@ def test_users(client: Client, owner_client: Client) -> None:
remove_user_mute(client) remove_user_mute(client)
get_alert_words(client) get_alert_words(client)
add_alert_words(client) add_alert_words(client)
create_saved_snippet(client)
get_saved_snippets(client)
delete_saved_snippet(client)
remove_alert_words(client) remove_alert_words(client)
add_apns_token(client) add_apns_token(client)
remove_apns_token(client) remove_apns_token(client)

View File

@ -5010,6 +5010,68 @@ paths:
"op": "remove", "op": "remove",
"draft_id": 17, "draft_id": 17,
} }
- type: object
additionalProperties: false
description: |
Event containing details of a newly created saved snippet.
**Changes**: New in Zulip 10.0 (feature level 297).
properties:
id:
$ref: "#/components/schemas/EventIdSchema"
type:
allOf:
- $ref: "#/components/schemas/EventTypeSchema"
- enum:
- saved_snippets
op:
type: string
enum:
- add
saved_snippet:
$ref: "#/components/schemas/SavedSnippet"
example:
{
"type": "saved_snippets",
"op": "add",
"saved_snippet":
{
"id": 1,
"title": "Example",
"content": "Welcome to the organization.",
"date_created": 1681662420,
},
}
- type: object
additionalProperties: false
description: |
Event containing the ID of a deleted saved snippet.
**Changes**: New in Zulip 10.0 (feature level 297).
properties:
id:
$ref: "#/components/schemas/EventIdSchema"
type:
allOf:
- $ref: "#/components/schemas/EventTypeSchema"
- enum:
- saved_snippets
op:
type: string
enum:
- remove
saved_snippet_id:
type: integer
description: |
The ID of the saved snippet that was just deleted.
**Changes**: New in Zulip 10.0 (feature level 297).
example:
{
"type": "saved_snippets",
"op": "remove",
"saved_snippet_id": 17,
}
- type: object - type: object
additionalProperties: false additionalProperties: false
description: | description: |
@ -5754,6 +5816,152 @@ paths:
"result": "error", "result": "error",
"msg": "Draft does not exist", "msg": "Draft does not exist",
} }
/saved_snippets:
get:
operationId: get-saved-snippets
tags: ["drafts"]
summary: Get all saved snippets
description: |
Fetch all the saved snippets for the current user.
**Changes**: New in Zulip 10.0 (feature level 297).
responses:
"200":
description: Success.
content:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/JsonSuccessBase"
- additionalProperties: false
properties:
result: {}
msg: {}
ignored_parameters_unsupported: {}
saved_snippets:
type: array
description: |
An array of dictionaries containing data on all of the current user's
saved snippets.
items:
$ref: "#/components/schemas/SavedSnippet"
example:
{
"result": "success",
"msg": "",
"saved_snippets":
[
{
"id": 1,
"title": "Example",
"content": "Welcome to the organization.",
"date_created": 1681662420,
},
],
}
post:
operationId: create-saved-snippet
tags: ["drafts"]
summary: Create a saved snippet
description: |
Create a new saved snippet for the current user.
**Changes**: New in Zulip 10.0 (feature level 297).
requestBody:
required: true
content:
application/x-www-form-urlencoded:
schema:
type: object
properties:
title:
type: string
description: |
The title of the saved snippet.
example: Example title
content:
type: string
description: |
The content of the saved snippet in text/markdown format.
Clients should insert this content into a message when using
a saved snippet.
example: Welcome to the organization.
required:
- title
- content
responses:
"200":
description: Success.
content:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/JsonSuccessBase"
- additionalProperties: false
properties:
result: {}
msg: {}
ignored_parameters_unsupported: {}
saved_snippet_id:
type: integer
description: |
The unique ID of the saved snippet created.
example:
{"result": "success", "msg": "", "saved_snippet_id": 1}
"400":
description: Bad request.
content:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/CodedError"
- example:
{
"code": "BAD_REQUEST",
"msg": "Title cannot be empty.",
"result": "error",
}
description: |
A typical failed JSON response for when either title or content is
empty:
/saved_snippets/{saved_snippet_id}:
delete:
operationId: delete-saved-snippet
tags: ["drafts"]
summary: Delete a saved snippet
description: |
Delete a saved snippet.
**Changes**: New in Zulip 10.0 (feature level 297).
parameters:
- name: saved_snippet_id
in: path
schema:
type: integer
description: |
The ID of the saved snippet to delete.
required: true
example: 2
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 saved snippet exists
with the provided ID:
example:
{
"code": "BAD_REQUEST",
"result": "error",
"msg": "Saved snippet does not exist.",
}
/scheduled_messages: /scheduled_messages:
get: get:
operationId: get-scheduled-messages operationId: get-scheduled-messages
@ -14246,6 +14454,17 @@ paths:
The list of users other than the current user in the direct message The list of users other than the current user in the direct message
conversation. This will be an empty list for direct messages sent to conversation. This will be an empty list for direct messages sent to
oneself. oneself.
saved_snippets:
type: array
items:
$ref: "#/components/schemas/SavedSnippet"
description: |
Present if `saved_snippets` is present in `fetch_event_types`.
An array of dictionaries containing data on all of the current user's
saved snippets.
**Changes**: New in Zulip 10.0 (feature level 297).
subscriptions: subscriptions:
type: array type: array
items: items:
@ -22573,6 +22792,32 @@ components:
- to - to
- topic - topic
- content - content
SavedSnippet:
type: object
description: |
Object containing the details of the saved snippet.
additionalProperties: false
properties:
id:
type: integer
description: |
The unique ID of the saved snippet.
title:
type: string
description: |
The title of the saved snippet.
content:
type: string
description: |
The content of the saved snippet in text/markdown format.
Clients should insert this content into a message when using
a saved snippet.
date_created:
type: integer
description: |
The UNIX timestamp for when the saved snippet was created, in
UTC seconds.
ScheduledMessage: ScheduledMessage:
type: object type: object
description: | description: |

View File

@ -1176,7 +1176,7 @@ class FetchQueriesTest(ZulipTestCase):
realm = get_realm_with_settings(realm_id=user.realm_id) realm = get_realm_with_settings(realm_id=user.realm_id)
with ( with (
self.assert_database_query_count(39), self.assert_database_query_count(40),
mock.patch("zerver.lib.events.always_want") as want_mock, mock.patch("zerver.lib.events.always_want") as want_mock,
): ):
fetch_initial_state_data(user, realm=realm) fetch_initial_state_data(user, realm=realm)
@ -1205,6 +1205,7 @@ class FetchQueriesTest(ZulipTestCase):
realm_user_groups=3, realm_user_groups=3,
realm_user_settings_defaults=1, realm_user_settings_defaults=1,
recent_private_conversations=1, recent_private_conversations=1,
saved_snippets=1,
scheduled_messages=1, scheduled_messages=1,
starred_messages=1, starred_messages=1,
stream=3, stream=3,

View File

@ -85,6 +85,7 @@ from zerver.actions.realm_settings import (
do_set_realm_user_default_setting, do_set_realm_user_default_setting,
do_set_realm_zulip_update_announcements_stream, do_set_realm_zulip_update_announcements_stream,
) )
from zerver.actions.saved_snippets import do_create_saved_snippet, do_delete_saved_snippet
from zerver.actions.scheduled_messages import ( from zerver.actions.scheduled_messages import (
check_schedule_message, check_schedule_message,
delete_scheduled_message, delete_scheduled_message,
@ -172,6 +173,8 @@ from zerver.lib.event_schema import (
check_realm_user_add, check_realm_user_add,
check_realm_user_remove, check_realm_user_remove,
check_realm_user_update, check_realm_user_update,
check_saved_snippet_add,
check_saved_snippet_remove,
check_scheduled_message_add, check_scheduled_message_add,
check_scheduled_message_remove, check_scheduled_message_remove,
check_scheduled_message_update, check_scheduled_message_update,
@ -238,6 +241,7 @@ from zerver.models import (
RealmFilter, RealmFilter,
RealmPlayground, RealmPlayground,
RealmUserDefault, RealmUserDefault,
SavedSnippet,
Service, Service,
Stream, Stream,
UserMessage, UserMessage,
@ -1662,6 +1666,18 @@ class NormalActionsTest(BaseAction):
do_remove_alert_words(self.user_profile, ["alert_word"]) do_remove_alert_words(self.user_profile, ["alert_word"])
check_alert_words("events[0]", events[0]) check_alert_words("events[0]", events[0])
def test_saved_replies_events(self) -> None:
with self.verify_action() as events:
do_create_saved_snippet("Welcome message", "Welcome", self.user_profile)
check_saved_snippet_add("events[0]", events[0])
saved_snippet_id = (
SavedSnippet.objects.filter(user_profile=self.user_profile).order_by("id")[0].id
)
with self.verify_action() as events:
do_delete_saved_snippet(saved_snippet_id, self.user_profile)
check_saved_snippet_remove("events[0]", events[0])
def test_away_events(self) -> None: def test_away_events(self) -> None:
client = get_client("website") client = get_client("website")

View File

@ -213,6 +213,7 @@ class HomeTest(ZulipTestCase):
"realm_wildcard_mention_policy", "realm_wildcard_mention_policy",
"realm_zulip_update_announcements_stream_id", "realm_zulip_update_announcements_stream_id",
"recent_private_conversations", "recent_private_conversations",
"saved_snippets",
"scheduled_messages", "scheduled_messages",
"server_avatar_changes_disabled", "server_avatar_changes_disabled",
"server_emoji_data_url", "server_emoji_data_url",
@ -269,7 +270,7 @@ class HomeTest(ZulipTestCase):
# Verify succeeds once logged-in # Verify succeeds once logged-in
with ( with (
self.assert_database_query_count(50), self.assert_database_query_count(51),
patch("zerver.lib.cache.cache_set") as cache_mock, patch("zerver.lib.cache.cache_set") as cache_mock,
): ):
result = self._get_home_page(stream="Denmark") result = self._get_home_page(stream="Denmark")
@ -574,7 +575,7 @@ class HomeTest(ZulipTestCase):
# Verify number of queries for Realm admin isn't much higher than for normal users. # Verify number of queries for Realm admin isn't much higher than for normal users.
self.login("iago") self.login("iago")
with ( with (
self.assert_database_query_count(50), self.assert_database_query_count(51),
patch("zerver.lib.cache.cache_set") as cache_mock, patch("zerver.lib.cache.cache_set") as cache_mock,
): ):
result = self._get_home_page() result = self._get_home_page()
@ -606,7 +607,7 @@ class HomeTest(ZulipTestCase):
self._get_home_page() self._get_home_page()
# Then for the second page load, measure the number of queries. # Then for the second page load, measure the number of queries.
with self.assert_database_query_count(45): with self.assert_database_query_count(46):
result = self._get_home_page() result = self._get_home_page()
# Do a sanity check that our new streams were in the payload. # Do a sanity check that our new streams were in the payload.

View File

@ -0,0 +1,80 @@
from zerver.actions.saved_snippets import do_create_saved_snippet
from zerver.lib.test_classes import ZulipTestCase
from zerver.models import SavedSnippet, UserProfile
class SavedSnippetTests(ZulipTestCase):
def create_example_saved_snippet(self, user: UserProfile) -> int:
saved_snippet = do_create_saved_snippet(
"Welcome message", "**Welcome** to the organization.", user
)
return saved_snippet.id
def test_create_saved_snippet(self) -> None:
"""Tests creation of saved snippets."""
user = self.example_user("hamlet")
self.login_user(user)
result = self.client_get(
"/json/saved_snippets",
)
response_dict = self.assert_json_success(result)
self.assert_length(response_dict["saved_snippets"], 0)
result = self.client_post(
"/json/saved_snippets",
{"title": "Welcome message", "content": "**Welcome** to the organization."},
)
response_dict = self.assert_json_success(result)
saved_snippet_id = response_dict["saved_snippet_id"]
result = self.client_get(
"/json/saved_snippets",
)
response_dict = self.assert_json_success(result)
self.assert_length(response_dict["saved_snippets"], 1)
self.assertEqual(saved_snippet_id, response_dict["saved_snippets"][0]["id"])
result = self.client_post(
"/json/saved_snippets",
{
"title": "A" * (SavedSnippet.MAX_TITLE_LENGTH + 60),
"content": "**Welcome** to the organization.",
},
)
self.assert_json_error(
result,
status_code=400,
msg=f"title is too long (limit: {SavedSnippet.MAX_TITLE_LENGTH} characters)",
)
def test_delete_saved_snippet(self) -> None:
"""Tests deletion of saved snippets."""
user = self.example_user("hamlet")
self.login_user(user)
saved_snippet_id = self.create_example_saved_snippet(user)
result = self.client_get(
"/json/saved_snippets",
)
response_dict = self.assert_json_success(result)
self.assert_length(response_dict["saved_snippets"], 1)
result = self.client_delete(
f"/json/saved_snippets/{saved_snippet_id}",
)
self.assert_json_success(result)
result = self.client_get(
"/json/saved_snippets",
)
response_dict = self.assert_json_success(result)
self.assert_length(response_dict["saved_snippets"], 0)
# Tests if error is thrown when the provided ID does not exist.
result = self.client_delete(
"/json/saved_snippets/10",
)
self.assert_json_error(result, "Saved snippet does not exist.", status_code=404)

View File

@ -0,0 +1,55 @@
from typing import Annotated
from django.conf import settings
from django.http import HttpRequest, HttpResponse
from pydantic import StringConstraints
from zerver.actions.saved_snippets import (
do_create_saved_snippet,
do_delete_saved_snippet,
do_get_saved_snippets,
)
from zerver.lib.response import json_success
from zerver.lib.typed_endpoint import typed_endpoint
from zerver.models import SavedSnippet, UserProfile
def get_saved_snippets(
request: HttpRequest,
user_profile: UserProfile,
) -> HttpResponse:
return json_success(request, data={"saved_snippets": do_get_saved_snippets(user_profile)})
@typed_endpoint
def create_saved_snippet(
request: HttpRequest,
user_profile: UserProfile,
*,
title: Annotated[
str,
StringConstraints(
min_length=1, max_length=SavedSnippet.MAX_TITLE_LENGTH, strip_whitespace=True
),
],
content: Annotated[
str,
StringConstraints(
min_length=1, max_length=settings.MAX_MESSAGE_LENGTH, strip_whitespace=True
),
],
) -> HttpResponse:
title = title.strip()
content = content.strip()
saved_snippet = do_create_saved_snippet(title, content, user_profile)
return json_success(request, data={"saved_snippet_id": saved_snippet.id})
def delete_saved_snippet(
request: HttpRequest,
user_profile: UserProfile,
*,
saved_snippet_id: int,
) -> HttpResponse:
do_delete_saved_snippet(saved_snippet_id, user_profile)
return json_success(request)

View File

@ -148,6 +148,11 @@ from zerver.views.registration import (
signup_send_confirm, signup_send_confirm,
) )
from zerver.views.report import report_csp_violations from zerver.views.report import report_csp_violations
from zerver.views.saved_snippets import (
create_saved_snippet,
delete_saved_snippet,
get_saved_snippets,
)
from zerver.views.scheduled_messages import ( from zerver.views.scheduled_messages import (
create_scheduled_message_backend, create_scheduled_message_backend,
delete_scheduled_messages, delete_scheduled_messages,
@ -335,6 +340,9 @@ v1_api_and_json_patterns = [
# Endpoints for syncing drafts. # Endpoints for syncing drafts.
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),
# saved_snippets -> zerver.views.saved_snippets
rest_path("saved_snippets", GET=get_saved_snippets, POST=create_saved_snippet),
rest_path("saved_snippets/<int:saved_snippet_id>", DELETE=delete_saved_snippet),
# New scheduled messages are created via send_message_backend. # New scheduled messages are created via send_message_backend.
rest_path( rest_path(
"scheduled_messages", GET=fetch_scheduled_messages, POST=create_scheduled_message_backend "scheduled_messages", GET=fetch_scheduled_messages, POST=create_scheduled_message_backend