events/tests/api: Send realm_playground events to clients.

We send the whole data set as a part of the event rather than
doing an add/remove operation for couple of reasons:
    * This would make the client logic simpler.
    * The playground data is small enough for us to not worry
      about performance.

Tweaked both `fetch_initial_state_data` and `apply_events` to
handle the new playground event.

Tests added to validate the event matches the expected schema.

Documented realm_playgrounds sections inside /events and
/register to support our openapi validation system in test_events.

Tweaked other tests like test_event_system.py and test_home.py
to account for the new event being generated.

Lastly, documented the changes to the API endpoints in
api/changelog.md and bumped API_FEATURE_LEVEL.

Tweaked by tabbott to add an `id` field in RealmPlayground objects
sent to clients, which is essential to sending the API request to
remove one.
This commit is contained in:
Sumanth V Rao 2020-10-28 08:30:46 +05:30 committed by Tim Abbott
parent d2e5b62dce
commit 1ac8fe7538
11 changed files with 149 additions and 9 deletions

View File

@ -10,6 +10,18 @@ below features are supported.
## Changes in Zulip 4.0 ## Changes in Zulip 4.0
**Feature level 49**
* Added new [`POST /realm/playground`](/api/add-playground) and
[`DELETE /realm/playground/{playground_id}`](/api/remove-playground)
endpoints for realm playgrounds.
* [`GET /events`](/api/get-events): A new `realm_playgrounds` events
is sent when changes are made to a set of configured playgrounds for
an organization.
* [`POST /register`](/api/register-queue): Added a new `realm_playgrounds`
field, which is required to fetch the set of configured playgrounds for
an organization.
**Feature level 48** **Feature level 48**
* [`POST /users/me/muted_users/{muted_user_id}`](/api/mute-user), * [`POST /users/me/muted_users/{muted_user_id}`](/api/mute-user),

View File

@ -30,7 +30,7 @@ DESKTOP_WARNING_VERSION = "5.2.0"
# #
# Changes should be accompanied by documentation explaining what the # Changes should be accompanied by documentation explaining what the
# new level means in templates/zerver/api/changelog.md. # new level means in templates/zerver/api/changelog.md.
API_FEATURE_LEVEL = 48 API_FEATURE_LEVEL = 49
# 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

@ -226,6 +226,7 @@ from zerver.models import (
get_huddle_recipient, get_huddle_recipient,
get_huddle_user_ids, get_huddle_user_ids,
get_old_unclaimed_attachments, get_old_unclaimed_attachments,
get_realm_playgrounds,
get_stream, get_stream,
get_stream_by_id_in_realm, get_stream_by_id_in_realm,
get_stream_cache_key, get_stream_cache_key,
@ -6592,6 +6593,11 @@ def do_remove_realm_domain(
send_event(realm, event, active_user_ids(realm.id)) send_event(realm, event, active_user_ids(realm.id))
def notify_realm_playgrounds(realm: Realm) -> None:
event = dict(type="realm_playgrounds", realm_playgrounds=get_realm_playgrounds(realm))
send_event(realm, event, active_user_ids(realm.id))
def do_add_realm_playground(realm: Realm, **kwargs: Any) -> int: def do_add_realm_playground(realm: Realm, **kwargs: Any) -> int:
realm_playground = RealmPlayground(realm=realm, **kwargs) realm_playground = RealmPlayground(realm=realm, **kwargs)
# We expect full_clean to always pass since a thorough input validation # We expect full_clean to always pass since a thorough input validation
@ -6599,11 +6605,13 @@ def do_add_realm_playground(realm: Realm, **kwargs: Any) -> int:
# before calling this function. # before calling this function.
realm_playground.full_clean() realm_playground.full_clean()
realm_playground.save() realm_playground.save()
notify_realm_playgrounds(realm)
return realm_playground.id return realm_playground.id
def do_remove_realm_playground(realm_playground: RealmPlayground) -> None: def do_remove_realm_playground(realm: Realm, realm_playground: RealmPlayground) -> None:
realm_playground.delete() realm_playground.delete()
notify_realm_playgrounds(realm)
def get_occupied_streams(realm: Realm) -> QuerySet: def get_occupied_streams(realm: Realm) -> QuerySet:

View File

@ -680,6 +680,24 @@ realm_domains_remove_event = event_dict_type(
) )
check_realm_domains_remove = make_checker(realm_domains_remove_event) check_realm_domains_remove = make_checker(realm_domains_remove_event)
realm_playground_type = DictType(
required_keys=[("id", int), ("name", str), ("pygments_language", str), ("url_prefix", str)]
)
realm_playgrounds_event = event_dict_type(
required_keys=[
("type", Equals("realm_playgrounds")),
("realm_playgrounds", ListType(realm_playground_type)),
]
)
_check_realm_playgrounds = make_checker(realm_playgrounds_event)
def check_realm_playgrounds(var_name: str, event: Dict[str, object]) -> None:
_check_realm_playgrounds(var_name, event)
assert isinstance(event["realm_playgrounds"], list)
realm_emoji_type = DictType( realm_emoji_type = DictType(
required_keys=[ required_keys=[
("id", str), ("id", str),

View File

@ -59,6 +59,7 @@ from zerver.models import (
custom_profile_fields_for_realm, custom_profile_fields_for_realm,
get_default_stream_groups, get_default_stream_groups,
get_realm_domains, get_realm_domains,
get_realm_playgrounds,
realm_filters_for_realm, realm_filters_for_realm,
) )
from zerver.tornado.django_api import get_user_events, request_event_queue from zerver.tornado.django_api import get_user_events, request_event_queue
@ -258,6 +259,9 @@ def fetch_initial_state_data(
if want("realm_filters"): if want("realm_filters"):
state["realm_filters"] = realm_filters_for_realm(realm.id) state["realm_filters"] = realm_filters_for_realm(realm.id)
if want("realm_playgrounds"):
state["realm_playgrounds"] = get_realm_playgrounds(realm)
if want("realm_user_groups"): if want("realm_user_groups"):
state["realm_user_groups"] = user_groups_in_realm_serialized(realm) state["realm_user_groups"] = user_groups_in_realm_serialized(realm)
@ -981,6 +985,8 @@ def apply_event(
state["muted_users"] = event["muted_users"] state["muted_users"] = event["muted_users"]
elif event["type"] == "realm_filters": elif event["type"] == "realm_filters":
state["realm_filters"] = event["realm_filters"] state["realm_filters"] = event["realm_filters"]
elif event["type"] == "realm_playgrounds":
state["realm_playgrounds"] = event["realm_playgrounds"]
elif event["type"] == "update_display_settings": elif event["type"] == "update_display_settings":
assert event["setting_name"] in UserProfile.property_types assert event["setting_name"] in UserProfile.property_types
state[event["setting_name"]] = event["setting"] state[event["setting_name"]] = event["setting"]

View File

@ -1001,11 +1001,12 @@ class RealmPlayground(models.Model):
return f"<RealmPlayground({self.realm.string_id}): {self.pygments_language} {self.name}>" return f"<RealmPlayground({self.realm.string_id}): {self.pygments_language} {self.name}>"
def get_realm_playgrounds(realm: Realm) -> List[Dict[str, str]]: def get_realm_playgrounds(realm: Realm) -> List[Dict[str, Union[int, str]]]:
playgrounds: List[Dict[str, str]] = [] playgrounds: List[Dict[str, Union[int, str]]] = []
for playground in RealmPlayground.objects.filter(realm=realm).all(): for playground in RealmPlayground.objects.filter(realm=realm).all():
playgrounds.append( playgrounds.append(
dict( dict(
id=playground.id,
name=playground.name, name=playground.name,
pygments_language=playground.pygments_language, pygments_language=playground.pygments_language,
url_prefix=playground.url_prefix, url_prefix=playground.url_prefix,

View File

@ -2413,6 +2413,40 @@ paths:
], ],
"id": 0, "id": 0,
} }
- type: object
additionalProperties: false
description: |
Event sent to all users in a Zulip organization when the
set of configured playgrounds for the organization has changed.
properties:
id:
$ref: "#/components/schemas/EventIdSchema"
type:
allOf:
- $ref: "#/components/schemas/EventTypeSchema"
- enum:
- realm_playgrounds
realm_playgrounds:
type: array
description: |
An array of dictionaries where each dictionary contains
data about a single playground entry.
items:
$ref: "#/components/schemas/RealmPlayground"
example:
{
"type": "realm_playgrounds",
"realm_playgrounds":
[
{
"id": 1,
"name": "Python playground",
"pygments_language": "Python",
"url_prefix": "https://python.example.com",
},
],
"id": 0,
}
- type: object - type: object
additionalProperties: false additionalProperties: false
description: | description: |
@ -6935,6 +6969,15 @@ paths:
The second element is the URL with which the The second element is the URL with which the
pattern matching string should be linkified with and the third element pattern matching string should be linkified with and the third element
is the id of the realm filter. is the id of the realm filter.
realm_playgrounds:
type: array
items:
$ref: "#/components/schemas/RealmPlayground"
description: |
Present if `realm_playgrounds` is present in `fetch_event_types`.
An array of dictionaries where each dictionary describes a playground entry
in this Zulip organization.
realm_user_groups: realm_user_groups:
type: array type: array
items: items:
@ -10097,6 +10140,33 @@ components:
type: boolean type: boolean
description: | description: |
Whether subdomains are allowed for this domain. Whether subdomains are allowed for this domain.
RealmPlayground:
type: object
additionalProperties: false
description: |
Object containing details about a realm playground.
properties:
id:
type: integer
description: |
The unique ID for the realm playground.
name:
type: string
description: |
The user-visible display name of the playground. Clients
should display this in UI for picking which playground to
open a code block in, to differentiate between multiple
configured playground options for a given pygments
language.
pygments_language:
type: string
description: |
The name of the Pygments language lexer for that
programming language.
url_prefix:
type: string
description: |
The url prefix for the playground.
RealmExport: RealmExport:
type: object type: object
additionalProperties: false additionalProperties: false

View File

@ -852,7 +852,7 @@ class FetchQueriesTest(ZulipTestCase):
with mock.patch("zerver.lib.events.always_want") as want_mock: with mock.patch("zerver.lib.events.always_want") as want_mock:
fetch_initial_state_data(user) fetch_initial_state_data(user)
self.assert_length(queries, 30) self.assert_length(queries, 31)
expected_counts = dict( expected_counts = dict(
alert_words=1, alert_words=1,
@ -871,6 +871,7 @@ class FetchQueriesTest(ZulipTestCase):
realm_incoming_webhook_bots=0, realm_incoming_webhook_bots=0,
realm_emoji=1, realm_emoji=1,
realm_filters=1, realm_filters=1,
realm_playgrounds=1,
realm_user=3, realm_user=3,
realm_user_groups=2, realm_user_groups=2,
recent_private_conversations=1, recent_private_conversations=1,

View File

@ -27,6 +27,7 @@ from zerver.lib.actions import (
do_add_linkifier, do_add_linkifier,
do_add_reaction, do_add_reaction,
do_add_realm_domain, do_add_realm_domain,
do_add_realm_playground,
do_add_streams_to_default_stream_group, do_add_streams_to_default_stream_group,
do_add_submessage, do_add_submessage,
do_change_avatar_fields, do_change_avatar_fields,
@ -70,6 +71,7 @@ from zerver.lib.actions import (
do_remove_realm_custom_profile_field, do_remove_realm_custom_profile_field,
do_remove_realm_domain, do_remove_realm_domain,
do_remove_realm_emoji, do_remove_realm_emoji,
do_remove_realm_playground,
do_remove_streams_from_default_stream_group, do_remove_streams_from_default_stream_group,
do_rename_stream, do_rename_stream,
do_revoke_multi_use_invite, do_revoke_multi_use_invite,
@ -126,6 +128,7 @@ from zerver.lib.event_schema import (
check_realm_emoji_update, check_realm_emoji_update,
check_realm_export, check_realm_export,
check_realm_filters, check_realm_filters,
check_realm_playgrounds,
check_realm_update, check_realm_update,
check_realm_update_dict, check_realm_update_dict,
check_realm_user_add, check_realm_user_add,
@ -176,6 +179,7 @@ from zerver.models import (
Realm, Realm,
RealmAuditLog, RealmAuditLog,
RealmDomain, RealmDomain,
RealmPlayground,
Service, Service,
Stream, Stream,
UserGroup, UserGroup,
@ -191,6 +195,7 @@ from zerver.tornado.event_queue import (
allocate_client_descriptor, allocate_client_descriptor,
clear_client_event_queues_for_testing, clear_client_event_queues_for_testing,
) )
from zerver.views.realm_playgrounds import access_playground_by_id
class BaseAction(ZulipTestCase): class BaseAction(ZulipTestCase):
@ -1358,6 +1363,24 @@ class NormalActionsTest(BaseAction):
check_realm_domains_remove("events[0]", events[0]) check_realm_domains_remove("events[0]", events[0])
self.assertEqual(events[0]["domain"], "zulip.org") self.assertEqual(events[0]["domain"], "zulip.org")
def test_realm_playground_events(self) -> None:
playground_info = dict(
name="Python playground",
pygments_language="Python",
url_prefix="https://python.example.com",
)
events = self.verify_action(
lambda: do_add_realm_playground(self.user_profile.realm, **playground_info)
)
check_realm_playgrounds("events[0]", events[0])
last_id = RealmPlayground.objects.last().id
realm_playground = access_playground_by_id(self.user_profile.realm, last_id)
events = self.verify_action(
lambda: do_remove_realm_playground(self.user_profile.realm, realm_playground)
)
check_realm_playgrounds("events[0]", events[0])
def test_create_bot(self) -> None: def test_create_bot(self) -> None:
action = lambda: self.create_bot("test") action = lambda: self.create_bot("test")
events = self.verify_action(action, num_events=2) events = self.verify_action(action, num_events=2)

View File

@ -178,6 +178,7 @@ class HomeTest(ZulipTestCase):
"realm_notifications_stream_id", "realm_notifications_stream_id",
"realm_password_auth_enabled", "realm_password_auth_enabled",
"realm_plan_type", "realm_plan_type",
"realm_playgrounds",
"realm_presence_disabled", "realm_presence_disabled",
"realm_private_message_policy", "realm_private_message_policy",
"realm_push_notifications_enabled", "realm_push_notifications_enabled",
@ -262,7 +263,7 @@ class HomeTest(ZulipTestCase):
set(result["Cache-Control"].split(", ")), {"must-revalidate", "no-store", "no-cache"} set(result["Cache-Control"].split(", ")), {"must-revalidate", "no-store", "no-cache"}
) )
self.assert_length(queries, 40) self.assert_length(queries, 41)
self.assert_length(cache_mock.call_args_list, 5) self.assert_length(cache_mock.call_args_list, 5)
html = result.content.decode("utf-8") html = result.content.decode("utf-8")
@ -342,7 +343,7 @@ class HomeTest(ZulipTestCase):
result = self._get_home_page() result = self._get_home_page()
self.check_rendered_logged_in_app(result) self.check_rendered_logged_in_app(result)
self.assert_length(cache_mock.call_args_list, 6) self.assert_length(cache_mock.call_args_list, 6)
self.assert_length(queries, 37) self.assert_length(queries, 38)
def test_num_queries_with_streams(self) -> None: def test_num_queries_with_streams(self) -> None:
main_user = self.example_user("hamlet") main_user = self.example_user("hamlet")
@ -373,7 +374,7 @@ class HomeTest(ZulipTestCase):
with queries_captured() as queries2: with queries_captured() as queries2:
result = self._get_home_page() result = self._get_home_page()
self.assert_length(queries2, 35) self.assert_length(queries2, 36)
# Do a sanity check that our new streams were in the payload. # Do a sanity check that our new streams were in the payload.
html = result.content.decode("utf-8") html = result.content.decode("utf-8")

View File

@ -60,5 +60,5 @@ def delete_realm_playground(
request: HttpRequest, user_profile: UserProfile, playground_id: int request: HttpRequest, user_profile: UserProfile, playground_id: int
) -> HttpResponse: ) -> HttpResponse:
realm_playground = access_playground_by_id(user_profile.realm, playground_id) realm_playground = access_playground_by_id(user_profile.realm, playground_id)
do_remove_realm_playground(realm_playground) do_remove_realm_playground(user_profile.realm, realm_playground)
return json_success() return json_success()