realm/playground: Add API endpoint for creating playground entry.

This endpoint will allow clients to create a playground entry
containing the name, pygments language and url_prefix for the
playground of their choice.

Introduced the `do_*` function in-charge of creating the entry in
the model. Handling the process of sending events which will be
done in a follow up commit.

Added the openAPI format data to zulip.yaml for POST
/realm/playgrounds. Also added python and curl examples for using
the endpoint in its markdown documented (add-playground.md).

Tests added.
This commit is contained in:
Sumanth V Rao
2020-10-27 06:44:56 +05:30
parent 40228972b9
commit 251b415987
9 changed files with 269 additions and 1 deletions

View File

@@ -0,0 +1,32 @@
# Add a playground
{generate_api_description(/realm/playgrounds:post)}
## Usage examples
{start_tabs}
{tab|python}
{generate_code_example(python)|/realm/playgrounds:post|example}
{tab|curl}
{generate_code_example(curl)|/realm/playgrounds:post|example}
{end_tabs}
## Parameters
{generate_api_arguments_table|zulip.yaml|/realm/playgrounds:post}
## Response
#### Return values
{generate_return_values_table|zulip.yaml|/realm/playgrounds:post}
#### Example response
A typical successful JSON response may look like:
{generate_code_example|/realm/playgrounds:post|fixture(200)}

View File

@@ -59,6 +59,7 @@
* [Get linkifiers](/api/get-linkifiers) * [Get linkifiers](/api/get-linkifiers)
* [Add a linkifier](/api/add-linkifier) * [Add a linkifier](/api/add-linkifier)
* [Remove a linkifier](/api/remove-linkifier) * [Remove a linkifier](/api/remove-linkifier)
* [Add a playground](/api/add-playground)
* [Get all custom emoji](/api/get-custom-emoji) * [Get all custom emoji](/api/get-custom-emoji)
* [Upload custom emoji](/api/upload-custom-emoji) * [Upload custom emoji](/api/upload-custom-emoji)
* [Get all custom profile fields](/api/get-custom-profile-fields) * [Get all custom profile fields](/api/get-custom-profile-fields)

View File

@@ -197,6 +197,7 @@ from zerver.models import (
RealmDomain, RealmDomain,
RealmEmoji, RealmEmoji,
RealmFilter, RealmFilter,
RealmPlayground,
Recipient, Recipient,
ScheduledEmail, ScheduledEmail,
ScheduledMessage, ScheduledMessage,
@@ -6591,6 +6592,16 @@ def do_remove_realm_domain(
send_event(realm, event, active_user_ids(realm.id)) send_event(realm, event, active_user_ids(realm.id))
def do_add_realm_playground(realm: Realm, **kwargs: Any) -> int:
realm_playground = RealmPlayground(realm=realm, **kwargs)
# We expect full_clean to always pass since a thorough input validation
# is performed in the view (using check_url, check_pygments_language, etc)
# before calling this function.
realm_playground.full_clean()
realm_playground.save()
return realm_playground.id
def get_occupied_streams(realm: Realm) -> QuerySet: def get_occupied_streams(realm: Realm) -> QuerySet:
# TODO: Make a generic stub for QuerySet # TODO: Make a generic stub for QuerySet
""" Get streams with subscribers """ """ Get streams with subscribers """

View File

@@ -5,7 +5,7 @@
# based on Zulip's OpenAPI definitions, as well as test setup and # based on Zulip's OpenAPI definitions, as well as test setup and
# fetching of appropriate parameter values to use when running the # fetching of appropriate parameter values to use when running the
# cURL examples as part of the tools/test-api test suite. # cURL examples as part of the tools/test-api test suite.
import json
from functools import wraps from functools import wraps
from typing import Any, Callable, Dict, List, Optional, Set, Tuple from typing import Any, Callable, Dict, List, Optional, Set, Tuple
@@ -268,6 +268,15 @@ def upload_custom_emoji() -> Dict[str, object]:
} }
@openapi_param_value_generator(["/realm/playgrounds:post"])
def add_realm_playground() -> Dict[str, object]:
return {
"name": "Python2 playground",
"pygments_language": json.dumps("Python2"),
"url_prefix": json.dumps("https://python2.example.com"),
}
@openapi_param_value_generator(["/users/{user_id}:delete"]) @openapi_param_value_generator(["/users/{user_id}:delete"])
def deactivate_user() -> Dict[str, object]: def deactivate_user() -> Dict[str, object]:
user_profile = do_create_user( user_profile = do_create_user(

View File

@@ -380,6 +380,22 @@ def remove_realm_filter(client: Client) -> None:
validate_against_openapi_schema(result, "/realm/filters/{filter_id}", "delete", "200") validate_against_openapi_schema(result, "/realm/filters/{filter_id}", "delete", "200")
@openapi_test_function("/realm/playgrounds:post")
def add_realm_playground(client: Client) -> None:
# {code_example|start}
# Add a realm playground for Python
request = {
"name": "Python playground",
"pygments_language": json.dumps("Python"),
"url_prefix": json.dumps("https://python.example.com"),
}
result = client.call_endpoint(url="/realm/playgrounds", method="POST", request=request)
# {code_example|end}
validate_against_openapi_schema(result, "/realm/playgrounds", "post", "200")
@openapi_test_function("/users/me:get") @openapi_test_function("/users/me:get")
def get_profile(client: Client) -> None: def get_profile(client: Client) -> None:
@@ -1417,6 +1433,7 @@ def test_server_organizations(client: Client) -> None:
get_realm_filters(client) get_realm_filters(client)
add_realm_filter(client) add_realm_filter(client)
add_realm_playground(client)
get_server_settings(client) get_server_settings(client)
remove_realm_filter(client) remove_realm_filter(client)
get_realm_emoji(client) get_realm_emoji(client)

View File

@@ -6567,6 +6567,60 @@ paths:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/JsonSuccess" $ref: "#/components/schemas/JsonSuccess"
/realm/playgrounds:
post:
operationId: add_realm_playground
tags: ["server_and_organizations"]
description: |
Configure realm playgrounds options to run code snippets occuring
in a code block using playgrounds which supports that language.
`POST {{ api_url }}/v1/realm/playgrounds`
parameters:
- name: name
in: query
description: |
The user-visible display name of the playground which can be
used to pick the target playground, especially when multiple
playground options exist for that programming language.
schema:
type: string
example: "Python playground"
required: true
- name: pygments_language
in: query
description: |
The name of the Pygments language lexer for that
programming language.
schema:
type: string
example: "Python"
required: true
- name: url_prefix
in: query
description: |
The url prefix for the playground.
schema:
type: string
example: https://python.example.com
required: true
responses:
"200":
description: Success.
content:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/JsonSuccessBase"
- additionalProperties: false
properties:
result: {}
msg: {}
id:
type: integer
description: |
The numeric ID assigned to this playground.
example: {"id": 1, "result": "success", "msg": ""}
/register: /register:
post: post:
operationId: register_queue operationId: register_queue

View File

@@ -0,0 +1,95 @@
from typing import Dict
import orjson
from zerver.lib.test_classes import ZulipTestCase
from zerver.models import RealmPlayground, get_realm
class RealmPlaygroundTests(ZulipTestCase):
def json_serialize(self, payload: Dict[str, str]) -> Dict[str, str]:
payload["url_prefix"] = orjson.dumps(payload["url_prefix"]).decode()
payload["pygments_language"] = orjson.dumps(payload["pygments_language"]).decode()
return payload
def test_create_one_playground_entry(self) -> None:
iago = self.example_user("iago")
payload = {
"name": "Python playground",
"pygments_language": "Python",
"url_prefix": "https://python.example.com",
}
# Now send a POST request to the API endpoint.
resp = self.api_post(iago, "/json/realm/playgrounds", self.json_serialize(payload))
self.assert_json_success(resp)
# Check if the actual object exists
realm = get_realm("zulip")
self.assertTrue(
RealmPlayground.objects.filter(realm=realm, name="Python playground").exists()
)
def test_create_multiple_playgrounds_for_same_language(self) -> None:
iago = self.example_user("iago")
data = [
{
"name": "Python playground 1",
"pygments_language": "Python",
"url_prefix": "https://python.example.com",
},
{
"name": "Python playground 2",
"pygments_language": "Python",
"url_prefix": "https://python2.example.com",
},
]
for payload in data:
resp = self.api_post(iago, "/json/realm/playgrounds", self.json_serialize(payload))
self.assert_json_success(resp)
realm = get_realm("zulip")
self.assertTrue(
RealmPlayground.objects.filter(realm=realm, name="Python playground 1").exists()
)
self.assertTrue(
RealmPlayground.objects.filter(realm=realm, name="Python playground 2").exists()
)
def test_invalid_params(self) -> None:
iago = self.example_user("iago")
payload = {
"name": "Invalid URL",
"pygments_language": "Python",
"url_prefix": "https://invalid-url",
}
resp = self.api_post(iago, "/json/realm/playgrounds", self.json_serialize(payload))
self.assert_json_error(resp, "url_prefix is not a URL")
payload["url_prefix"] = "https://python.example.com"
payload["pygments_language"] = "a$b$c"
resp = self.api_post(iago, "/json/realm/playgrounds", self.json_serialize(payload))
self.assert_json_error(resp, "Invalid characters in pygments language")
def test_create_already_existing_playground(self) -> None:
iago = self.example_user("iago")
payload = {
"name": "Python playground",
"pygments_language": "Python",
"url_prefix": "https://python.example.com",
}
serialized_payload = self.json_serialize(payload)
resp = self.api_post(iago, "/json/realm/playgrounds", serialized_payload)
self.assert_json_success(resp)
resp = self.api_post(iago, "/json/realm/playgrounds", serialized_payload)
self.assert_json_error(resp, "Realm playground with this Realm and Name already exists.")
def test_not_realm_admin(self) -> None:
hamlet = self.example_user("hamlet")
resp = self.api_post(hamlet, "/json/realm/playgrounds")
self.assert_json_error(resp, "Must be an organization administrator")

View File

@@ -0,0 +1,46 @@
import re
from django.core.exceptions import ValidationError
from django.http import HttpRequest, HttpResponse
from django.utils.translation import ugettext as _
from zerver.decorator import require_realm_admin
from zerver.lib.actions import do_add_realm_playground
from zerver.lib.request import REQ, JsonableError, has_request_variables
from zerver.lib.response import json_error, json_success
from zerver.lib.validator import check_capped_string, check_url
from zerver.models import RealmPlayground, UserProfile
def check_pygments_language(var_name: str, val: object) -> str:
s = check_capped_string(RealmPlayground.MAX_PYGMENTS_LANGUAGE_LENGTH)(var_name, val)
# We don't want to restrict the language here to be only from the list of valid
# Pygments languages. Keeping it open would allow us to hook up a "playground"
# for custom "languages" that aren't known to Pygments. We use a similar strategy
# even in our fenced_code markdown processor.
valid_pygments_language = re.compile(r"^[ a-zA-Z0-9_+-./#]*$")
matched_results = valid_pygments_language.match(s)
if not matched_results:
raise JsonableError(_("Invalid characters in pygments language"))
return s
@require_realm_admin
@has_request_variables
def add_realm_playground(
request: HttpRequest,
user_profile: UserProfile,
name: str = REQ(),
url_prefix: str = REQ(validator=check_url),
pygments_language: str = REQ(validator=check_pygments_language),
) -> HttpResponse:
try:
playground_id = do_add_realm_playground(
realm=user_profile.realm,
name=name.strip(),
pygments_language=pygments_language.strip(),
url_prefix=url_prefix.strip(),
)
except ValidationError as e:
return json_error(e.messages[0], data={"errors": dict(e)})
return json_success({"id": playground_id})

View File

@@ -121,6 +121,7 @@ from zerver.views.realm_export import delete_realm_export, export_realm, get_rea
from zerver.views.realm_icon import delete_icon_backend, get_icon_backend, upload_icon from zerver.views.realm_icon import delete_icon_backend, get_icon_backend, upload_icon
from zerver.views.realm_linkifiers import create_linkifier, delete_linkifier, list_linkifiers from zerver.views.realm_linkifiers import create_linkifier, delete_linkifier, list_linkifiers
from zerver.views.realm_logo import delete_logo_backend, get_logo_backend, upload_logo from zerver.views.realm_logo import delete_logo_backend, get_logo_backend, upload_logo
from zerver.views.realm_playgrounds import add_realm_playground
from zerver.views.registration import ( from zerver.views.registration import (
accounts_home, accounts_home,
accounts_home_from_multiuse_invite, accounts_home_from_multiuse_invite,
@@ -268,6 +269,8 @@ v1_api_and_json_patterns = [
# realm/filters -> zerver.views.realm_linkifiers # realm/filters -> zerver.views.realm_linkifiers
rest_path("realm/filters", GET=list_linkifiers, POST=create_linkifier), rest_path("realm/filters", GET=list_linkifiers, POST=create_linkifier),
rest_path("realm/filters/<int:filter_id>", DELETE=delete_linkifier), rest_path("realm/filters/<int:filter_id>", DELETE=delete_linkifier),
# realm/playgrounds -> zerver.views.realm_playgrounds
rest_path("realm/playgrounds", POST=add_realm_playground),
# realm/profile_fields -> zerver.views.custom_profile_fields # realm/profile_fields -> zerver.views.custom_profile_fields
rest_path( rest_path(
"realm/profile_fields", "realm/profile_fields",