diff --git a/api_docs/changelog.md b/api_docs/changelog.md index c11ad6d6da..f633f747d2 100644 --- a/api_docs/changelog.md +++ b/api_docs/changelog.md @@ -20,6 +20,18 @@ format used by the Zulip server that they are interacting with. ## 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**: * [`POST /register`](/api/register-queue), [`GET /events`](/api/get-events), diff --git a/api_docs/include/rest-endpoints.md b/api_docs/include/rest-endpoints.md index e5538ec957..7d1c039bd9 100644 --- a/api_docs/include/rest-endpoints.md +++ b/api_docs/include/rest-endpoints.md @@ -32,6 +32,9 @@ * [Create drafts](/api/create-drafts) * [Edit a draft](/api/edit-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 diff --git a/version.py b/version.py index bbc05dbd0a..307eff8559 100644 --- a/version.py +++ b/version.py @@ -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 = 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 # only when going from an old version of the code to a newer version. Bump diff --git a/zerver/actions/saved_snippets.py b/zerver/actions/saved_snippets.py new file mode 100644 index 0000000000..59ca88467f --- /dev/null +++ b/zerver/actions/saved_snippets.py @@ -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]) diff --git a/zerver/lib/event_schema.py b/zerver/lib/event_schema.py index 13b8ecae62..f9f6a06e61 100644 --- a/zerver/lib/event_schema.py +++ b/zerver/lib/event_schema.py @@ -301,6 +301,32 @@ drafts_remove_event = event_dict_type( ) 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( required_keys=[ diff --git a/zerver/lib/events.py b/zerver/lib/events.py index cef6528e38..144f742c1e 100644 --- a/zerver/lib/events.py +++ b/zerver/lib/events.py @@ -13,6 +13,7 @@ from typing_extensions import NotRequired, TypedDict 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.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.lib import emoji from zerver.lib.alert_words import user_alert_words @@ -205,6 +206,12 @@ def fetch_initial_state_data( # remove this parameter from the API. 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 user_profile is None: state["drafts"] = [] @@ -871,6 +878,15 @@ def apply_event( # this code path. But in any case, they're noops. 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": if event["op"] == "add": state["drafts"].extend(event["drafts"]) diff --git a/zerver/lib/export.py b/zerver/lib/export.py index a9068bf8c0..89ca690d71 100644 --- a/zerver/lib/export.py +++ b/zerver/lib/export.py @@ -161,6 +161,7 @@ ALL_ZULIP_TABLES = { "zerver_realmreactivationstatus", "zerver_realmuserdefault", "zerver_recipient", + "zerver_savedsnippet", "zerver_scheduledemail", "zerver_scheduledemail_users", "zerver_scheduledmessage", diff --git a/zerver/lib/import_realm.py b/zerver/lib/import_realm.py index 59476e862b..7eb3cecaa6 100644 --- a/zerver/lib/import_realm.py +++ b/zerver/lib/import_realm.py @@ -69,6 +69,7 @@ from zerver.models import ( RealmPlayground, RealmUserDefault, Recipient, + SavedSnippet, ScheduledMessage, Service, Stream, @@ -1425,6 +1426,14 @@ def do_import_realm(import_dir: Path, subdomain: str, processes: int = 1) -> Rea update_model_ids(AlertWord, 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: fix_datetime_fields(data, "zerver_onboardingstep") re_map_foreign_keys(data, "zerver_onboardingstep", "user", related_table="user_profile") diff --git a/zerver/migrations/0587_savedsnippet.py b/zerver/migrations/0587_savedsnippet.py new file mode 100644 index 0000000000..538b43b9f5 --- /dev/null +++ b/zerver/migrations/0587_savedsnippet.py @@ -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 + ), + ), + ], + ), + ] diff --git a/zerver/models/__init__.py b/zerver/models/__init__.py index 6d5074880e..f86ae2b4bf 100644 --- a/zerver/models/__init__.py +++ b/zerver/models/__init__.py @@ -51,6 +51,7 @@ from zerver.models.realms import RealmAuthenticationMethod as RealmAuthenticatio from zerver.models.realms import RealmDomain as RealmDomain from zerver.models.recipients import DirectMessageGroup as DirectMessageGroup 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 MissedMessageEmailAddress as MissedMessageEmailAddress from zerver.models.scheduled_jobs import ScheduledEmail as ScheduledEmail diff --git a/zerver/models/realm_audit_logs.py b/zerver/models/realm_audit_logs.py index 426cbd1ea6..f6c6c534a3 100644 --- a/zerver/models/realm_audit_logs.py +++ b/zerver/models/realm_audit_logs.py @@ -108,6 +108,8 @@ class AuditLogEventType(IntEnum): USER_GROUP_GROUP_BASED_SETTING_CHANGED = 722 USER_GROUP_DEACTIVATED = 723 + SAVED_SNIPPET_CREATED = 800 + # The following values are only for remote server/realm logs. # Values should be exactly 10000 greater than the corresponding # value used for the same purpose in realm audit logs (e.g., diff --git a/zerver/models/saved_snippets.py b/zerver/models/saved_snippets.py new file mode 100644 index 0000000000..942f759004 --- /dev/null +++ b/zerver/models/saved_snippets.py @@ -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()), + } diff --git a/zerver/openapi/python_examples.py b/zerver/openapi/python_examples.py index e09921196a..348e1096b6 100644 --- a/zerver/openapi/python_examples.py +++ b/zerver/openapi/python_examples.py @@ -1143,6 +1143,50 @@ def remove_attachment(client: Client, attachment_id: int) -> None: 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") def send_message(client: Client) -> int: request: dict[str, Any] = {} @@ -1770,6 +1814,9 @@ def test_users(client: Client, owner_client: Client) -> None: remove_user_mute(client) get_alert_words(client) add_alert_words(client) + create_saved_snippet(client) + get_saved_snippets(client) + delete_saved_snippet(client) remove_alert_words(client) add_apns_token(client) remove_apns_token(client) diff --git a/zerver/openapi/zulip.yaml b/zerver/openapi/zulip.yaml index 524dc385f5..9627989c65 100644 --- a/zerver/openapi/zulip.yaml +++ b/zerver/openapi/zulip.yaml @@ -5010,6 +5010,68 @@ paths: "op": "remove", "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 additionalProperties: false description: | @@ -5754,6 +5816,152 @@ paths: "result": "error", "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: get: operationId: get-scheduled-messages @@ -14246,6 +14454,17 @@ paths: 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 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: type: array items: @@ -22573,6 +22792,32 @@ components: - to - topic - 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: type: object description: | diff --git a/zerver/tests/test_event_system.py b/zerver/tests/test_event_system.py index 2110151980..f78eb99a82 100644 --- a/zerver/tests/test_event_system.py +++ b/zerver/tests/test_event_system.py @@ -1176,7 +1176,7 @@ class FetchQueriesTest(ZulipTestCase): realm = get_realm_with_settings(realm_id=user.realm_id) with ( - self.assert_database_query_count(39), + self.assert_database_query_count(40), mock.patch("zerver.lib.events.always_want") as want_mock, ): fetch_initial_state_data(user, realm=realm) @@ -1205,6 +1205,7 @@ class FetchQueriesTest(ZulipTestCase): realm_user_groups=3, realm_user_settings_defaults=1, recent_private_conversations=1, + saved_snippets=1, scheduled_messages=1, starred_messages=1, stream=3, diff --git a/zerver/tests/test_events.py b/zerver/tests/test_events.py index 1b124dc0c8..0f2ce8cb47 100644 --- a/zerver/tests/test_events.py +++ b/zerver/tests/test_events.py @@ -85,6 +85,7 @@ from zerver.actions.realm_settings import ( do_set_realm_user_default_setting, 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 ( check_schedule_message, delete_scheduled_message, @@ -172,6 +173,8 @@ from zerver.lib.event_schema import ( check_realm_user_add, check_realm_user_remove, check_realm_user_update, + check_saved_snippet_add, + check_saved_snippet_remove, check_scheduled_message_add, check_scheduled_message_remove, check_scheduled_message_update, @@ -238,6 +241,7 @@ from zerver.models import ( RealmFilter, RealmPlayground, RealmUserDefault, + SavedSnippet, Service, Stream, UserMessage, @@ -1662,6 +1666,18 @@ class NormalActionsTest(BaseAction): do_remove_alert_words(self.user_profile, ["alert_word"]) 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: client = get_client("website") diff --git a/zerver/tests/test_home.py b/zerver/tests/test_home.py index 1448db27a7..2cf8041a5f 100644 --- a/zerver/tests/test_home.py +++ b/zerver/tests/test_home.py @@ -213,6 +213,7 @@ class HomeTest(ZulipTestCase): "realm_wildcard_mention_policy", "realm_zulip_update_announcements_stream_id", "recent_private_conversations", + "saved_snippets", "scheduled_messages", "server_avatar_changes_disabled", "server_emoji_data_url", @@ -269,7 +270,7 @@ class HomeTest(ZulipTestCase): # Verify succeeds once logged-in with ( - self.assert_database_query_count(50), + self.assert_database_query_count(51), patch("zerver.lib.cache.cache_set") as cache_mock, ): 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. self.login("iago") with ( - self.assert_database_query_count(50), + self.assert_database_query_count(51), patch("zerver.lib.cache.cache_set") as cache_mock, ): result = self._get_home_page() @@ -606,7 +607,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(45): + with self.assert_database_query_count(46): result = self._get_home_page() # Do a sanity check that our new streams were in the payload. diff --git a/zerver/tests/test_saved_snippets.py b/zerver/tests/test_saved_snippets.py new file mode 100644 index 0000000000..1d9d741fab --- /dev/null +++ b/zerver/tests/test_saved_snippets.py @@ -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) diff --git a/zerver/views/saved_snippets.py b/zerver/views/saved_snippets.py new file mode 100644 index 0000000000..e103c7a16a --- /dev/null +++ b/zerver/views/saved_snippets.py @@ -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) diff --git a/zproject/urls.py b/zproject/urls.py index 3a5f6450fd..49f1c9710b 100644 --- a/zproject/urls.py +++ b/zproject/urls.py @@ -148,6 +148,11 @@ from zerver.views.registration import ( signup_send_confirm, ) 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 ( create_scheduled_message_backend, delete_scheduled_messages, @@ -335,6 +340,9 @@ v1_api_and_json_patterns = [ # Endpoints for syncing drafts. rest_path("drafts", GET=fetch_drafts, POST=create_drafts), rest_path("drafts/", PATCH=edit_draft, DELETE=delete_draft), + # saved_snippets -> zerver.views.saved_snippets + rest_path("saved_snippets", GET=get_saved_snippets, POST=create_saved_snippet), + rest_path("saved_snippets/", DELETE=delete_saved_snippet), # New scheduled messages are created via send_message_backend. rest_path( "scheduled_messages", GET=fetch_scheduled_messages, POST=create_scheduled_message_backend