mirror of
https://github.com/zulip/zulip.git
synced 2025-11-04 14:03:30 +00:00
drafts: Add an API endpoint for creating drafts.
This endpoint will allow a user to create drafts in bulk. Signed-off-by: Hemanth V. Alluri <hdrive1999@gmail.com>
This commit is contained in:
committed by
Tim Abbott
parent
0e893b9045
commit
a0f71b7458
303
zerver/tests/test_drafts.py
Normal file
303
zerver/tests/test_drafts.py
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
import time
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
import ujson
|
||||||
|
|
||||||
|
from zerver.lib.test_classes import ZulipTestCase
|
||||||
|
from zerver.models import Draft
|
||||||
|
|
||||||
|
|
||||||
|
class DraftCreationTests(ZulipTestCase):
|
||||||
|
def create_and_check_drafts_for_success(self, draft_dicts: List[Dict[str, Any]],
|
||||||
|
expected_draft_dicts: List[Dict[str, Any]]) -> None:
|
||||||
|
hamlet = self.example_user("hamlet")
|
||||||
|
|
||||||
|
# Make sure that there are no drafts in the database before
|
||||||
|
# the test begins.
|
||||||
|
self.assertEqual(Draft.objects.count(), 0)
|
||||||
|
|
||||||
|
# Now send a POST request to the API endpoint.
|
||||||
|
payload = {"drafts": ujson.dumps(draft_dicts)}
|
||||||
|
resp = self.api_post(hamlet, "/api/v1/drafts", payload)
|
||||||
|
self.assert_json_success(resp)
|
||||||
|
|
||||||
|
# Finally check to make sure that the drafts were actually created properly.
|
||||||
|
new_draft_dicts = [d.to_dict() for d in Draft.objects.order_by("last_edit_time")]
|
||||||
|
self.assertEqual(new_draft_dicts, expected_draft_dicts)
|
||||||
|
|
||||||
|
def create_and_check_drafts_for_error(self, draft_dicts: List[Dict[str, Any]],
|
||||||
|
expected_message: str) -> None:
|
||||||
|
hamlet = self.example_user("hamlet")
|
||||||
|
|
||||||
|
# Make sure that there are no drafts in the database before
|
||||||
|
# the test begins.
|
||||||
|
self.assertEqual(Draft.objects.count(), 0)
|
||||||
|
|
||||||
|
# Now send a POST request to the API endpoint.
|
||||||
|
payload = {"drafts": ujson.dumps(draft_dicts)}
|
||||||
|
resp = self.api_post(hamlet, "/api/v1/drafts", payload)
|
||||||
|
self.assert_json_error(resp, expected_message)
|
||||||
|
|
||||||
|
# Make sure that there are no drafts in the database at the
|
||||||
|
# end of the test. Drafts should never be created in error
|
||||||
|
# conditions.
|
||||||
|
self.assertEqual(Draft.objects.count(), 0)
|
||||||
|
|
||||||
|
def test_create_one_stream_draft_properly(self) -> None:
|
||||||
|
hamlet = self.example_user("hamlet")
|
||||||
|
visible_stream_name = self.get_streams(hamlet)[0]
|
||||||
|
visible_stream_id = self.get_stream_id(visible_stream_name)
|
||||||
|
draft_dicts = [{
|
||||||
|
"type": "stream",
|
||||||
|
"to": [visible_stream_id],
|
||||||
|
"topic": "sync drafts",
|
||||||
|
"content": "Let's add backend support for syncing drafts.",
|
||||||
|
"timestamp": 1595479019.4391587,
|
||||||
|
}]
|
||||||
|
expected_draft_dicts = [{
|
||||||
|
"type": "stream",
|
||||||
|
"to": [visible_stream_id],
|
||||||
|
"topic": "sync drafts",
|
||||||
|
"content": "Let's add backend support for syncing drafts.",
|
||||||
|
"timestamp": 1595479019.439159, # We only go as far microseconds.
|
||||||
|
}]
|
||||||
|
self.create_and_check_drafts_for_success(draft_dicts, expected_draft_dicts)
|
||||||
|
|
||||||
|
def test_create_one_personal_message_draft_properly(self) -> None:
|
||||||
|
zoe = self.example_user("ZOE")
|
||||||
|
draft_dicts = [{
|
||||||
|
"type": "private",
|
||||||
|
"to": [zoe.id],
|
||||||
|
"topic": "This topic should be ignored.",
|
||||||
|
"content": "What if we made it possible to sync drafts in Zulip?",
|
||||||
|
"timestamp": 1595479019.43915,
|
||||||
|
}]
|
||||||
|
expected_draft_dicts = [{
|
||||||
|
"type": "private",
|
||||||
|
"to": [zoe.id],
|
||||||
|
"topic": "", # For private messages the topic should be ignored.
|
||||||
|
"content": "What if we made it possible to sync drafts in Zulip?",
|
||||||
|
"timestamp": 1595479019.43915,
|
||||||
|
}]
|
||||||
|
self.create_and_check_drafts_for_success(draft_dicts, expected_draft_dicts)
|
||||||
|
|
||||||
|
def test_create_one_group_personal_message_draft_properly(self) -> None:
|
||||||
|
zoe = self.example_user("ZOE")
|
||||||
|
othello = self.example_user("othello")
|
||||||
|
draft_dicts = [{
|
||||||
|
"type": "private",
|
||||||
|
"to": [zoe.id, othello.id],
|
||||||
|
"topic": "This topic should be ignored.",
|
||||||
|
"content": "What if we made it possible to sync drafts in Zulip?",
|
||||||
|
"timestamp": 1595479019,
|
||||||
|
}]
|
||||||
|
expected_draft_dicts = [{
|
||||||
|
"type": "private",
|
||||||
|
"to": [zoe.id, othello.id],
|
||||||
|
"topic": "", # For private messages the topic should be ignored.
|
||||||
|
"content": "What if we made it possible to sync drafts in Zulip?",
|
||||||
|
"timestamp": 1595479019.0,
|
||||||
|
}]
|
||||||
|
self.create_and_check_drafts_for_success(draft_dicts, expected_draft_dicts)
|
||||||
|
|
||||||
|
def test_create_batch_of_drafts_properly(self) -> None:
|
||||||
|
hamlet = self.example_user("hamlet")
|
||||||
|
visible_stream_name = self.get_streams(hamlet)[0]
|
||||||
|
visible_stream_id = self.get_stream_id(visible_stream_name)
|
||||||
|
zoe = self.example_user("ZOE")
|
||||||
|
othello = self.example_user("othello")
|
||||||
|
draft_dicts = [
|
||||||
|
{
|
||||||
|
"type": "stream",
|
||||||
|
"to": [visible_stream_id],
|
||||||
|
"topic": "sync drafts",
|
||||||
|
"content": "Let's add backend support for syncing drafts.",
|
||||||
|
"timestamp": 1595479019.43915,
|
||||||
|
}, # Stream message draft
|
||||||
|
{
|
||||||
|
"type": "private",
|
||||||
|
"to": [zoe.id],
|
||||||
|
"topic": "This topic should be ignored.",
|
||||||
|
"content": "What if we made it possible to sync drafts in Zulip?",
|
||||||
|
"timestamp": 1595479020.43916,
|
||||||
|
}, # Private message draft
|
||||||
|
{
|
||||||
|
"type": "private",
|
||||||
|
"to": [zoe.id, othello.id],
|
||||||
|
"topic": "",
|
||||||
|
"content": "What if we made it possible to sync drafts in Zulip?",
|
||||||
|
"timestamp": 1595479021.43916,
|
||||||
|
}, # Private group message draft
|
||||||
|
]
|
||||||
|
expected_draft_dicts = [
|
||||||
|
{
|
||||||
|
"type": "stream",
|
||||||
|
"to": [visible_stream_id],
|
||||||
|
"topic": "sync drafts",
|
||||||
|
"content": "Let's add backend support for syncing drafts.",
|
||||||
|
"timestamp": 1595479019.43915,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "private",
|
||||||
|
"to": [zoe.id],
|
||||||
|
"topic": "",
|
||||||
|
"content": "What if we made it possible to sync drafts in Zulip?",
|
||||||
|
"timestamp": 1595479020.43916,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "private",
|
||||||
|
"to": [zoe.id, othello.id],
|
||||||
|
"topic": "",
|
||||||
|
"content": "What if we made it possible to sync drafts in Zulip?",
|
||||||
|
"timestamp": 1595479021.43916,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
self.create_and_check_drafts_for_success(draft_dicts, expected_draft_dicts)
|
||||||
|
|
||||||
|
def test_missing_timestamps(self) -> None:
|
||||||
|
""" If a timestamp is not provided for a draft dict then it should be automatically
|
||||||
|
filled in. """
|
||||||
|
hamlet = self.example_user("hamlet")
|
||||||
|
visible_stream_name = self.get_streams(hamlet)[0]
|
||||||
|
visible_stream_id = self.get_stream_id(visible_stream_name)
|
||||||
|
|
||||||
|
draft_dicts = [{
|
||||||
|
"type": "stream",
|
||||||
|
"to": [visible_stream_id],
|
||||||
|
"topic": "sync drafts",
|
||||||
|
"content": "Let's add backend support for syncing drafts.",
|
||||||
|
}]
|
||||||
|
|
||||||
|
self.assertEqual(Draft.objects.count(), 0)
|
||||||
|
|
||||||
|
current_time = round(time.time(), 6)
|
||||||
|
payload = {"drafts": ujson.dumps(draft_dicts)}
|
||||||
|
resp = self.api_post(hamlet, "/api/v1/drafts", payload)
|
||||||
|
self.assert_json_success(resp)
|
||||||
|
|
||||||
|
new_drafts = Draft.objects.all()
|
||||||
|
self.assertEqual(Draft.objects.count(), 1)
|
||||||
|
new_draft = new_drafts[0].to_dict()
|
||||||
|
self.assertTrue(isinstance(new_draft["timestamp"], float))
|
||||||
|
# Since it would be too tricky to get the same times, perform
|
||||||
|
# a relative check.
|
||||||
|
self.assertTrue(new_draft["timestamp"] > current_time)
|
||||||
|
|
||||||
|
def test_invalid_timestamp(self) -> None:
|
||||||
|
draft_dicts = [{
|
||||||
|
"type": "stream",
|
||||||
|
"to": [],
|
||||||
|
"topic": "sync drafts",
|
||||||
|
"content": "Let's add backend support for syncing drafts.",
|
||||||
|
"timestamp": -10.10,
|
||||||
|
}]
|
||||||
|
self.create_and_check_drafts_for_error(
|
||||||
|
draft_dicts,
|
||||||
|
"Timestamp must not be negative."
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_create_non_stream_draft_with_no_recipient(self) -> None:
|
||||||
|
""" When "to" is an empty list, the type should become "" as well. """
|
||||||
|
draft_dicts = [
|
||||||
|
{
|
||||||
|
"type": "private",
|
||||||
|
"to": [],
|
||||||
|
"topic": "sync drafts",
|
||||||
|
"content": "Let's add backend support for syncing drafts.",
|
||||||
|
"timestamp": 1595479019.43915,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "",
|
||||||
|
"to": [],
|
||||||
|
"topic": "sync drafts",
|
||||||
|
"content": "Let's add backend support for syncing drafts.",
|
||||||
|
"timestamp": 1595479019.43915,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
expected_draft_dicts = [
|
||||||
|
{
|
||||||
|
"type": "",
|
||||||
|
"to": [],
|
||||||
|
"topic": "",
|
||||||
|
"content": "Let's add backend support for syncing drafts.",
|
||||||
|
"timestamp": 1595479019.43915,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "",
|
||||||
|
"to": [],
|
||||||
|
"topic": "",
|
||||||
|
"content": "Let's add backend support for syncing drafts.",
|
||||||
|
"timestamp": 1595479019.43915,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
self.create_and_check_drafts_for_success(draft_dicts, expected_draft_dicts)
|
||||||
|
|
||||||
|
def test_create_stream_draft_with_no_recipient(self) -> None:
|
||||||
|
draft_dicts = [{
|
||||||
|
"type": "stream",
|
||||||
|
"to": [],
|
||||||
|
"topic": "sync drafts",
|
||||||
|
"content": "Let's add backend support for syncing drafts.",
|
||||||
|
"timestamp": 1595479019.439159,
|
||||||
|
}]
|
||||||
|
self.create_and_check_drafts_for_error(
|
||||||
|
draft_dicts,
|
||||||
|
"Must specify exactly 1 stream ID for stream messages"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_create_stream_draft_for_inaccessible_stream(self) -> None:
|
||||||
|
# When the user does not have permission to access the stream:
|
||||||
|
stream = self.make_stream("Secret Society", invite_only=True)
|
||||||
|
draft_dicts = [{
|
||||||
|
"type": "stream",
|
||||||
|
"to": [stream.id], # This can't be accessed by hamlet.
|
||||||
|
"topic": "sync drafts",
|
||||||
|
"content": "Let's add backend support for syncing drafts.",
|
||||||
|
"timestamp": 1595479019.43915,
|
||||||
|
}]
|
||||||
|
self.create_and_check_drafts_for_error(draft_dicts, "Invalid stream id")
|
||||||
|
|
||||||
|
# When the stream itself does not exist:
|
||||||
|
draft_dicts = [{
|
||||||
|
"type": "stream",
|
||||||
|
"to": [99999999999999], # Hopefully, this doesn't exist.
|
||||||
|
"topic": "sync drafts",
|
||||||
|
"content": "Let's add backend support for syncing drafts.",
|
||||||
|
"timestamp": 1595479019.43915,
|
||||||
|
}]
|
||||||
|
self.create_and_check_drafts_for_error(draft_dicts, "Invalid stream id")
|
||||||
|
|
||||||
|
def test_create_personal_message_draft_for_non_existing_user(self) -> None:
|
||||||
|
draft_dicts = [{
|
||||||
|
"type": "private",
|
||||||
|
"to": [99999999999999], # Hopefully, this doesn't exist either.
|
||||||
|
"topic": "This topic should be ignored.",
|
||||||
|
"content": "What if we made it possible to sync drafts in Zulip?",
|
||||||
|
"timestamp": 1595479019.43915,
|
||||||
|
}]
|
||||||
|
self.create_and_check_drafts_for_error(draft_dicts, "Invalid user ID 99999999999999")
|
||||||
|
|
||||||
|
def test_create_draft_with_null_bytes(self) -> None:
|
||||||
|
draft_dicts = [{
|
||||||
|
"type": "",
|
||||||
|
"to": [],
|
||||||
|
"topic": "sync drafts.",
|
||||||
|
"content": "Some regular \x00 content here",
|
||||||
|
"timestamp": 1595479019.439159,
|
||||||
|
}]
|
||||||
|
self.create_and_check_drafts_for_error(
|
||||||
|
draft_dicts,
|
||||||
|
"Content must not contain null bytes"
|
||||||
|
)
|
||||||
|
|
||||||
|
draft_dicts = [{
|
||||||
|
"type": "stream",
|
||||||
|
"to": [10],
|
||||||
|
"topic": "thinking about \x00",
|
||||||
|
"content": "Let's add backend support for syncing drafts.",
|
||||||
|
"timestamp": 1595479019.439159,
|
||||||
|
}]
|
||||||
|
self.create_and_check_drafts_for_error(
|
||||||
|
draft_dicts,
|
||||||
|
"Topic must not contain null bytes"
|
||||||
|
)
|
||||||
105
zerver/views/drafts.py
Normal file
105
zerver/views/drafts.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import time
|
||||||
|
from typing import Any, Dict, List, Set
|
||||||
|
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.http import HttpRequest, HttpResponse
|
||||||
|
from django.utils.translation import ugettext as _
|
||||||
|
|
||||||
|
from zerver.lib.actions import recipient_for_user_profiles
|
||||||
|
from zerver.lib.addressee import get_user_profiles_by_ids
|
||||||
|
from zerver.lib.exceptions import JsonableError
|
||||||
|
from zerver.lib.message import truncate_body, truncate_topic
|
||||||
|
from zerver.lib.request import REQ, has_request_variables
|
||||||
|
from zerver.lib.response import json_success
|
||||||
|
from zerver.lib.streams import access_stream_by_id
|
||||||
|
from zerver.lib.timestamp import timestamp_to_datetime
|
||||||
|
from zerver.lib.validator import (
|
||||||
|
check_dict_only,
|
||||||
|
check_float,
|
||||||
|
check_int,
|
||||||
|
check_list,
|
||||||
|
check_required_string,
|
||||||
|
check_string,
|
||||||
|
check_string_in,
|
||||||
|
check_union,
|
||||||
|
)
|
||||||
|
from zerver.models import Draft, UserProfile
|
||||||
|
|
||||||
|
VALID_DRAFT_TYPES: Set[str] = {"", "private", "stream"}
|
||||||
|
|
||||||
|
# A validator to verify if the structure (syntax) of a dictionary
|
||||||
|
# meets the requirements to be a draft dictionary:
|
||||||
|
draft_dict_validator = check_dict_only(
|
||||||
|
required_keys=[
|
||||||
|
("type", check_string_in(VALID_DRAFT_TYPES)),
|
||||||
|
("to", check_list(check_int)), # The ID of the stream to send to, or a list of user IDs.
|
||||||
|
("topic", check_string), # This string can simply be empty for private type messages.
|
||||||
|
("content", check_required_string),
|
||||||
|
],
|
||||||
|
optional_keys=[
|
||||||
|
("timestamp", check_union([check_int, check_float])), # A Unix timestamp.
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def further_validated_draft_dict(draft_dict: Dict[str, Any],
|
||||||
|
user_profile: UserProfile) -> Dict[str, Any]:
|
||||||
|
""" Take a draft_dict that was already validated by draft_dict_validator then
|
||||||
|
further sanitize, validate, and transform it. Ultimately return this "further
|
||||||
|
validated" draft dict. It will have a slightly different set of keys the values
|
||||||
|
for which can be used to directly create a Draft object. """
|
||||||
|
|
||||||
|
content = truncate_body(draft_dict["content"])
|
||||||
|
if "\x00" in content:
|
||||||
|
raise JsonableError(_("Content must not contain null bytes"))
|
||||||
|
|
||||||
|
timestamp = draft_dict.get("timestamp", time.time())
|
||||||
|
timestamp = round(timestamp, 6)
|
||||||
|
if timestamp < 0:
|
||||||
|
# While it's not exactly an invalid timestamp, it's not something
|
||||||
|
# we want to allow either.
|
||||||
|
raise JsonableError(_("Timestamp must not be negative."))
|
||||||
|
last_edit_time = timestamp_to_datetime(timestamp)
|
||||||
|
|
||||||
|
topic = ""
|
||||||
|
recipient = None
|
||||||
|
to = draft_dict["to"]
|
||||||
|
if draft_dict["type"] == "stream":
|
||||||
|
topic = truncate_topic(draft_dict["topic"])
|
||||||
|
if "\x00" in topic:
|
||||||
|
raise JsonableError(_("Topic must not contain null bytes"))
|
||||||
|
if len(to) != 1:
|
||||||
|
raise JsonableError(_("Must specify exactly 1 stream ID for stream messages"))
|
||||||
|
stream, recipient, sub = access_stream_by_id(user_profile, to[0])
|
||||||
|
elif draft_dict["type"] == "private" and len(to) != 0:
|
||||||
|
to_users = get_user_profiles_by_ids(set(to), user_profile.realm)
|
||||||
|
try:
|
||||||
|
recipient = recipient_for_user_profiles(to_users, False, None, user_profile)
|
||||||
|
except ValidationError as e: # nocoverage
|
||||||
|
raise JsonableError(e.messages[0])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"recipient": recipient,
|
||||||
|
"topic": topic,
|
||||||
|
"content": content,
|
||||||
|
"last_edit_time": last_edit_time,
|
||||||
|
}
|
||||||
|
|
||||||
|
@has_request_variables
|
||||||
|
def create_drafts(request: HttpRequest, user_profile: UserProfile,
|
||||||
|
draft_dicts: List[Dict[str, Any]]=REQ("drafts",
|
||||||
|
validator=check_list(draft_dict_validator)),
|
||||||
|
) -> HttpResponse:
|
||||||
|
draft_objects = []
|
||||||
|
for draft_dict in draft_dicts:
|
||||||
|
valid_draft_dict = further_validated_draft_dict(draft_dict, user_profile)
|
||||||
|
draft_objects.append(Draft(
|
||||||
|
user_profile=user_profile,
|
||||||
|
recipient=valid_draft_dict["recipient"],
|
||||||
|
topic=valid_draft_dict["topic"],
|
||||||
|
content=valid_draft_dict["content"],
|
||||||
|
last_edit_time=valid_draft_dict["last_edit_time"],
|
||||||
|
))
|
||||||
|
|
||||||
|
created_draft_objects = Draft.objects.bulk_create(draft_objects)
|
||||||
|
draft_ids = [draft_object.id for draft_object in created_draft_objects]
|
||||||
|
return json_success({"ids": draft_ids})
|
||||||
@@ -193,6 +193,11 @@ v1_api_and_json_patterns = [
|
|||||||
path('zcommand', rest_dispatch,
|
path('zcommand', rest_dispatch,
|
||||||
{'POST': 'zerver.views.message_send.zcommand_backend'}),
|
{'POST': 'zerver.views.message_send.zcommand_backend'}),
|
||||||
|
|
||||||
|
# Endpoints for syncing drafts.
|
||||||
|
path('drafts', rest_dispatch,
|
||||||
|
{'POST': ('zerver.views.drafts.create_drafts',
|
||||||
|
{'intentionally_undocumented'})}),
|
||||||
|
|
||||||
# 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
|
||||||
path('messages', rest_dispatch,
|
path('messages', rest_dispatch,
|
||||||
|
|||||||
Reference in New Issue
Block a user