realm_playgrounds: Replace url_prefix with url_template.

Dropping support for url_prefix for RealmPlayground, the server now uses
url_template instead only for playground creation, retrieval and audit
logging upon removal.

This does the necessary handling so that url_template is expanded with
the extracted code.

Fixes #25723.

Signed-off-by: Zixuan James Li <p359101898@gmail.com>
This commit is contained in:
Zixuan James Li 2023-05-26 23:04:50 -04:00 committed by Tim Abbott
parent c4bc0ad589
commit 000761ac0c
25 changed files with 142 additions and 92 deletions

View File

@ -20,6 +20,14 @@ format used by the Zulip server that they are interacting with.
## Changes in Zulip 8.0 ## Changes in Zulip 8.0
**Feature level 196**
* [`POST /realm/playgrounds`](/api/add-code-playground): `url_prefix` is
replaced by `url_template`, which only accepts [RFC 6570][rfc6570] compliant
URL templates. The old prefix format is no longer supported.
* [`GET /events`](/api/get-events), [`POST /register`](/api/register-queue):
`url_prefix` is replaced by `url_template` in `realm_playgrounds` events.
**Feature level 195** **Feature level 195**
* [`GET /events`](/api/get-events), [`POST /register`](/api/register-queue): * [`GET /events`](/api/get-events), [`POST /register`](/api/register-queue):

View File

@ -67,15 +67,24 @@ prefix**.
{end_tabs} {end_tabs}
For example, to configure code playgrounds for languages like Python or For example, to configure code playgrounds for languages like Python or
JavaScript, you could specify the language and URL prefix fields as: JavaScript, you could specify the language and URL templates as:
* `Python` and `https://replit.com/languages/python3/?code=` * `Python` and `https://replit.com/languages/python3/code={code}`
* `JavaScript` and `https://replit.com/languages/javascript/?code=` * `JavaScript` and `https://replit.com/languages/javascript/code={code}`
When a code block is labeled as Python or JavaScript (either explicitly or by When a code block is labeled as Python or JavaScript (either explicitly or by
organization default), users would get a on-hover option to open the code block organization default), users would get a on-hover option to open the code block
in the specified code playground. in the specified code playground.
!!! tip ""
Code playgrounds use [RFC 6570](https://www.rfc-editor.org/rfc/rfc6570.html)
compliant URL templates to describe how links should be generated. Zulip's
rendering engine will pass the URL-encoded code from the code block as the
`code` parameter, denoted as `{code}` in this URL template, in order to
generate the URL. You can refer to parts of the documentation on URL
templates from [adding a custom linkifier](/help/add-a-custom-linkifier).
### Technical details ### Technical details
* You can configure multiple playgrounds for a given language; if you do that, * You can configure multiple playgrounds for a given language; if you do that,
@ -87,8 +96,9 @@ to these human-readable Pygments names; e.g., `py3` and `py` are mapped to
`Python`. One can use the typeahead (which appears when you type something `Python`. One can use the typeahead (which appears when you type something
or just click on the language field) to look up the Pygments name. or just click on the language field) to look up the Pygments name.
* The links for opening code playgrounds are always constructed by concatenating * The links for opening code playgrounds are always constructed by substituting
the provided URL prefix with the URL-encoded contents of the code block. the URL-encoded contents of the code block into `code` variable in the URL template.
The URL template is required to contain exactly one variable named `code`.
* Code playground sites do not always clearly document their URL format; often * Code playground sites do not always clearly document their URL format; often
you can just get the prefix from your browser's URL bar. you can just get the prefix from your browser's URL bar.

View File

@ -539,7 +539,7 @@ html_rules: List["Rule"] = [
}, },
"exclude": { "exclude": {
"templates/analytics/support.html", "templates/analytics/support.html",
# We have URL prefix and Pygments language name as placeholders # We have URL template and Pygments language name as placeholders
# in the below template which we don't want to be translatable. # in the below template which we don't want to be translatable.
"web/templates/settings/playground_settings_admin.hbs", "web/templates/settings/playground_settings_admin.hbs",
}, },
@ -553,6 +553,8 @@ html_rules: List["Rule"] = [
"description": "Likely missing quoting in HTML attribute", "description": "Likely missing quoting in HTML attribute",
"good_lines": ['<a href="{{variable}}">'], "good_lines": ['<a href="{{variable}}">'],
"bad_lines": ["<a href={{variable}}>"], "bad_lines": ["<a href={{variable}}>"],
# Exclude the use of URL templates from this check.
"exclude_pattern": "={code}",
}, },
{ {
"pattern": " '}}", "pattern": " '}}",

View File

@ -33,7 +33,7 @@ DESKTOP_WARNING_VERSION = "5.9.3"
# Changes should be accompanied by documentation explaining what the # Changes should be accompanied by documentation explaining what the
# 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 = 195 API_FEATURE_LEVEL = 196
# 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

@ -7,7 +7,7 @@ import * as common from "./lib/common";
type Playground = { type Playground = {
playground_name: string; playground_name: string;
pygments_language: string; pygments_language: string;
url_prefix: string; url_template: string;
}; };
async function _add_playground_and_return_status(page: Page, payload: Playground): Promise<string> { async function _add_playground_and_return_status(page: Page, payload: Playground): Promise<string> {
@ -35,7 +35,7 @@ async function test_successful_playground_creation(page: Page): Promise<void> {
const payload = { const payload = {
pygments_language: "Python", pygments_language: "Python",
playground_name: "Python3 playground", playground_name: "Python3 playground",
url_prefix: "https://python.example.com", url_template: "https://python.example.com?code={code}",
}; };
const status = await _add_playground_and_return_status(page, payload); const status = await _add_playground_and_return_status(page, payload);
assert.strictEqual(status, "Custom playground added!"); assert.strictEqual(status, "Custom playground added!");
@ -52,8 +52,8 @@ async function test_successful_playground_creation(page: Page): Promise<void> {
"Python3 playground", "Python3 playground",
); );
assert.strictEqual( assert.strictEqual(
await common.get_text_from_selector(page, ".playground_row span.playground_url_prefix"), await common.get_text_from_selector(page, ".playground_row span.playground_url_template"),
"https://python.example.com", "https://python.example.com?code={code}",
); );
} }
@ -61,12 +61,12 @@ async function test_invalid_playground_parameters(page: Page): Promise<void> {
const payload = { const payload = {
pygments_language: "Python", pygments_language: "Python",
playground_name: "Python3 playground", playground_name: "Python3 playground",
url_prefix: "not_a_url", url_template: "not_a_url_template{",
}; };
let status = await _add_playground_and_return_status(page, payload); let status = await _add_playground_and_return_status(page, payload);
assert.strictEqual(status, "Failed: url_prefix is not a URL"); assert.strictEqual(status, "Failed: Invalid URL template.");
payload.url_prefix = "https://python.example.com"; payload.url_template = "https://python.example.com?code={code}";
payload.pygments_language = "py!@%&"; payload.pygments_language = "py!@%&";
status = await _add_playground_and_return_status(page, payload); status = await _add_playground_and_return_status(page, payload);
assert.strictEqual(status, "Failed: Invalid characters in pygments language"); assert.strictEqual(status, "Failed: Invalid characters in pygments language");

View File

@ -2,6 +2,7 @@ import ClipboardJS from "clipboard";
import {parseISO} from "date-fns"; import {parseISO} from "date-fns";
import $ from "jquery"; import $ from "jquery";
import tippy, {hideAll} from "tippy.js"; import tippy, {hideAll} from "tippy.js";
import url_template_lib from "url-template";
import render_no_arrow_popover from "../templates/no_arrow_popover.hbs"; import render_no_arrow_popover from "../templates/no_arrow_popover.hbs";
import render_playground_links_popover_content from "../templates/playground_links_popover_content.hbs"; import render_playground_links_popover_content from "../templates/playground_links_popover_content.hbs";
@ -783,20 +784,21 @@ export function register_click_handlers() {
const playground_info = realm_playground.get_playground_info_for_languages( const playground_info = realm_playground.get_playground_info_for_languages(
$codehilite_div.data("code-language"), $codehilite_div.data("code-language"),
); );
// We do the code extraction here and set the target href combining the url_prefix // We do the code extraction here and set the target href expanding
// and the extracted code. Depending on whether the language has multiple playground // the url_template with the extracted code. Depending on whether
// links configured, a popover is show. // the language has multiple playground links configured, a popover
// is shown.
const extracted_code = $codehilite_div.find("code").text(); const extracted_code = $codehilite_div.find("code").text();
if (playground_info.length === 1) { if (playground_info.length === 1) {
const url_prefix = playground_info[0].url_prefix; const url_template = url_template_lib.parse(playground_info[0].url_template);
$view_in_playground_button.attr( $view_in_playground_button.attr(
"href", "href",
url_prefix + encodeURIComponent(extracted_code), url_template.expand({code: extracted_code}),
); );
} else { } else {
for (const $playground of playground_info) { for (const $playground of playground_info) {
$playground.playground_url = const url_template = url_template_lib.parse($playground.url_template);
$playground.url_prefix + encodeURIComponent(extracted_code); $playground.playground_url = url_template.expand({code: extracted_code});
} }
toggle_playground_link_popover(this, playground_info); toggle_playground_link_popover(this, playground_info);
} }

View File

@ -23,7 +23,7 @@ export function update_playgrounds(playgrounds_data: RealmPlayground[]): void {
const element_to_push: Omit<RealmPlayground, "pygments_language"> = { const element_to_push: Omit<RealmPlayground, "pygments_language"> = {
id: data.id, id: data.id,
name: data.name, name: data.name,
url_prefix: data.url_prefix, url_template: data.url_template,
}; };
if (map_language_to_playground_info.has(data.pygments_language)) { if (map_language_to_playground_info.has(data.pygments_language)) {
map_language_to_playground_info.get(data.pygments_language)!.push(element_to_push); map_language_to_playground_info.get(data.pygments_language)!.push(element_to_push);

View File

@ -41,7 +41,7 @@ export function populate_playgrounds(playgrounds_data) {
playground: { playground: {
playground_name: playground.name, playground_name: playground.name,
pygments_language: playground.pygments_language, pygments_language: playground.pygments_language,
url_prefix: playground.url_prefix, url_template: playground.url_template,
id: playground.id, id: playground.id,
}, },
can_modify: page_params.is_admin, can_modify: page_params.is_admin,
@ -65,7 +65,7 @@ export function populate_playgrounds(playgrounds_data) {
...ListWidget.generic_sort_functions("alphabetic", [ ...ListWidget.generic_sort_functions("alphabetic", [
"pygments_language", "pygments_language",
"name", "name",
"url_prefix", "url_template",
]), ]),
}, },
$simplebar_container: $("#playground-settings .progressive-table-wrapper"), $simplebar_container: $("#playground-settings .progressive-table-wrapper"),
@ -110,7 +110,7 @@ function build_page() {
const data = { const data = {
name: $("#playground_name").val(), name: $("#playground_name").val(),
pygments_language: $("#playground_pygments_language").val(), pygments_language: $("#playground_pygments_language").val(),
url_prefix: $("#playground_url_prefix").val(), url_template: $("#playground_url_template").val(),
}; };
channel.post({ channel.post({
url: "/json/realm/playgrounds", url: "/json/realm/playgrounds",
@ -118,7 +118,7 @@ function build_page() {
success() { success() {
$("#playground_pygments_language").val(""); $("#playground_pygments_language").val("");
$("#playground_name").val(""); $("#playground_name").val("");
$("#playground_url_prefix").val(""); $("#playground_url_template").val("");
$add_playground_button.prop("disabled", false); $add_playground_button.prop("disabled", false);
ui_report.success( ui_report.success(
$t_html({defaultMessage: "Custom playground added!"}), $t_html({defaultMessage: "Custom playground added!"}),

View File

@ -250,7 +250,7 @@ h3,
#playground-settings { #playground-settings {
#playground_pygments_language, #playground_pygments_language,
#playground_name, #playground_name,
#playground_url_prefix { #playground_url_template {
width: calc(100% - 10em - 6em); width: calc(100% - 10em - 6em);
} }
} }

View File

@ -7,7 +7,7 @@
<span class="playground_name">{{playground_name}}</span> <span class="playground_name">{{playground_name}}</span>
</td> </td>
<td> <td>
<span class="playground_url_prefix">{{url_prefix}}</span> <span class="playground_url_template">{{url_template}}</span>
</td> </td>
{{#if ../can_modify}} {{#if ../can_modify}}
<td class="no-select actions"> <td class="no-select actions">

View File

@ -23,7 +23,7 @@
{{t "Name" }}: <span class="rendered_markdown"><code>Python3 playground</code></span> {{t "Name" }}: <span class="rendered_markdown"><code>Python3 playground</code></span>
</li> </li>
<li> <li>
{{t "URL prefix" }}: <span class="rendered_markdown"><code>https://replit.com/languages/python3/?code=</code></span> {{t "URL template" }}: <span class="rendered_markdown"><code>https://replit.com/languages/python3/?code={code}</code></span>
</li> </li>
</ul> </ul>
<p> <p>
@ -52,8 +52,8 @@
<input type="text" id="playground_name" class="settings_text_input" name="playground_name" autocomplete="off" placeholder="Python3 playground" /> <input type="text" id="playground_name" class="settings_text_input" name="playground_name" autocomplete="off" placeholder="Python3 playground" />
</div> </div>
<div class="input-group"> <div class="input-group">
<label for="playground_url_prefix"> {{t "URL prefix" }}</label> <label for="playground_url_template"> {{t "URL template" }}</label>
<input type="text" id="playground_url_prefix" class="settings_text_input" name="url_prefix" placeholder="https://replit.com/languages/python3/?code=" /> <input type="text" id="playground_url_template" class="settings_text_input" name="url_template" placeholder="https://replit.com/languages/python3/?code={code}" />
</div> </div>
<button type="submit" id="submit_playground_button" class="button rounded sea-green"> <button type="submit" id="submit_playground_button" class="button rounded sea-green">
{{t 'Add code playground' }} {{t 'Add code playground' }}
@ -73,7 +73,7 @@
<thead class="table-sticky-headers"> <thead class="table-sticky-headers">
<th class="active" data-sort="alphabetic" data-sort-prop="pygments_language">{{t "Language" }}</th> <th class="active" data-sort="alphabetic" data-sort-prop="pygments_language">{{t "Language" }}</th>
<th data-sort="alphabetic" data-sort-prop="name">{{t "Name" }}</th> <th data-sort="alphabetic" data-sort-prop="name">{{t "Name" }}</th>
<th data-sort="alphabetic" data-sort-prop="url_prefix">{{t "URL prefix" }}</th> <th data-sort="alphabetic" data-sort-prop="url_template">{{t "URL template" }}</th>
{{#if is_admin}} {{#if is_admin}}
<th class="actions">{{t "Actions" }}</th> <th class="actions">{{t "Actions" }}</th>
{{/if}} {{/if}}

View File

@ -518,7 +518,7 @@ exports.fixtures = {
id: 1, id: 1,
name: "Lean playground", name: "Lean playground",
pygments_language: "Lean", pygments_language: "Lean",
url_prefix: "https://leanprover.github.io/live/latest/#code=", url_template: "https://leanprover.github.io/live/latest/{#code}",
}, },
], ],
}, },

View File

@ -25,7 +25,7 @@ run_test("get_pygments_typeahead_list_for_composebox", () => {
id: 2, id: 2,
name: "Custom Lang", name: "Custom Lang",
pygments_language: custom_pygment_language, pygments_language: custom_pygment_language,
url_prefix: "https://example.com/?q=", url_template: "https://example.com/?q={code}",
}, },
]; ];
realm_playground.initialize({ realm_playground.initialize({
@ -52,19 +52,19 @@ run_test("get_pygments_typeahead_list_for_settings", () => {
id: 1, id: 1,
name: "Custom Lang #1", name: "Custom Lang #1",
pygments_language: custom_pygment_language, pygments_language: custom_pygment_language,
url_prefix: "https://example.com/?q=", url_template: "https://example.com/?q={code}",
}, },
{ {
id: 2, id: 2,
name: "Custom Lang #2", name: "Custom Lang #2",
pygments_language: custom_pygment_language, pygments_language: custom_pygment_language,
url_prefix: "https://example.com/?q=", url_template: "https://example.com/?q={code}",
}, },
{ {
id: 3, id: 3,
name: "Invent a Language", name: "Invent a Language",
pygments_language: "invent_a_lang", pygments_language: "invent_a_lang",
url_prefix: "https://example.com/?q=", url_template: "https://example.com/?q={code}",
}, },
]; ];
realm_playground.initialize({ realm_playground.initialize({

View File

@ -28,18 +28,14 @@ def do_add_realm_playground(
acting_user: Optional[UserProfile], acting_user: Optional[UserProfile],
name: str, name: str,
pygments_language: str, pygments_language: str,
url_prefix: str, url_template: str,
) -> int: ) -> int:
realm_playground = RealmPlayground( realm_playground = RealmPlayground(
realm=realm, realm=realm,
name=name, name=name,
pygments_language=pygments_language, pygments_language=pygments_language,
url_prefix=url_prefix, url_template=url_template,
url_template=url_prefix + "{code}",
) )
# 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.full_clean()
realm_playground.save() realm_playground.save()
realm_playgrounds = get_realm_playgrounds(realm) realm_playgrounds = get_realm_playgrounds(realm)
@ -55,7 +51,7 @@ def do_add_realm_playground(
id=realm_playground.id, id=realm_playground.id,
name=realm_playground.name, name=realm_playground.name,
pygments_language=realm_playground.pygments_language, pygments_language=realm_playground.pygments_language,
url_prefix=realm_playground.url_prefix, url_template=realm_playground.url_template,
), ),
} }
).decode(), ).decode(),
@ -71,7 +67,6 @@ def do_remove_realm_playground(
removed_playground = { removed_playground = {
"name": realm_playground.name, "name": realm_playground.name,
"pygments_language": realm_playground.pygments_language, "pygments_language": realm_playground.pygments_language,
"url_prefix": realm_playground.url_prefix,
"url_template": realm_playground.url_template, "url_template": realm_playground.url_template,
} }

View File

@ -702,7 +702,7 @@ 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( realm_playground_type = DictType(
required_keys=[("id", int), ("name", str), ("pygments_language", str), ("url_prefix", str)] required_keys=[("id", int), ("name", str), ("pygments_language", str), ("url_template", str)]
) )
realm_playgrounds_event = event_dict_type( realm_playgrounds_event = event_dict_type(

View File

@ -271,7 +271,7 @@ class RealmPlaygroundDict(TypedDict):
id: int id: int
name: str name: str
pygments_language: str pygments_language: str
url_prefix: str url_template: str
@dataclass @dataclass

View File

@ -0,0 +1,23 @@
# Generated by Django 4.2.1 on 2023-05-27 03:13
from django.db import migrations, models
from zerver.models import url_template_validator
class Migration(migrations.Migration):
dependencies = [
("zerver", "0463_backfill_realmplayground_url_template"),
]
operations = [
migrations.RemoveField(
model_name="realmplayground",
name="url_prefix",
),
migrations.AlterField(
model_name="realmplayground",
name="url_template",
field=models.TextField(validators=[url_template_validator], null=False),
),
]

View File

@ -42,7 +42,7 @@ from django.contrib.postgres.indexes import GinIndex
from django.contrib.postgres.search import SearchVectorField from django.contrib.postgres.search import SearchVectorField
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.serializers.json import DjangoJSONEncoder from django.core.serializers.json import DjangoJSONEncoder
from django.core.validators import MinLengthValidator, RegexValidator, URLValidator, validate_email from django.core.validators import MinLengthValidator, RegexValidator, validate_email
from django.db import models, transaction from django.db import models, transaction
from django.db.backends.base.base import BaseDatabaseWrapper from django.db.backends.base.base import BaseDatabaseWrapper
from django.db.models import CASCADE, Exists, F, OuterRef, Q, QuerySet, Sum from django.db.models import CASCADE, Exists, F, OuterRef, Q, QuerySet, Sum
@ -1365,8 +1365,7 @@ class RealmPlayground(models.Model):
MAX_PYGMENTS_LANGUAGE_LENGTH = 40 MAX_PYGMENTS_LANGUAGE_LENGTH = 40
realm = models.ForeignKey(Realm, on_delete=CASCADE) realm = models.ForeignKey(Realm, on_delete=CASCADE)
url_prefix = models.TextField(validators=[URLValidator()]) url_template = models.TextField(validators=[url_template_validator])
url_template = models.TextField(validators=[url_template_validator], null=True)
# User-visible display name used when configuring playgrounds in the settings page and # User-visible display name used when configuring playgrounds in the settings page and
# when displaying them in the playground links popover. # when displaying them in the playground links popover.
@ -1399,22 +1398,17 @@ class RealmPlayground(models.Model):
and stores all ValidationErrors from all stages to return as JSON. and stores all ValidationErrors from all stages to return as JSON.
""" """
# Prior to the completion of this migration, we make url_template nullable, # Do not continue the check if the url template is invalid to begin
# while ensuring that no code path will create a RealmPlayground without populating it. # with. The ValidationError for invalid template will only be raised by
assert self.url_template is not None # the validator set on the url_template field instead of here to avoid
# duplicates.
# Do not continue the check if the url template is invalid to begin with.
# The ValidationError for invalid template will only be raised by the validator
# set on the url_template field instead of here to avoid duplicates.
if not uri_template.validate(self.url_template): if not uri_template.validate(self.url_template):
return return
# Extract variables used in the URL template. # Extract variables used in the URL template.
template_variables = set(uri_template.URITemplate(self.url_template).variable_names) template_variables = set(uri_template.URITemplate(self.url_template).variable_names)
if ( if "code" not in template_variables:
"code" not in template_variables
): # nocoverage: prior to the completion of the migration, it is impossible to generate a URL template without the "code" variable
raise ValidationError(_('Missing the required variable "code" in the URL template')) raise ValidationError(_('Missing the required variable "code" in the URL template'))
# The URL template should only contain a single variable, which is "code". # The URL template should only contain a single variable, which is "code".
@ -1432,7 +1426,7 @@ def get_realm_playgrounds(realm: Realm) -> List[RealmPlaygroundDict]:
id=playground.id, 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_template=playground.url_template,
) )
) )
return playgrounds return playgrounds

View File

@ -296,7 +296,7 @@ def add_realm_playground() -> Dict[str, object]:
return { return {
"name": "Python2 playground", "name": "Python2 playground",
"pygments_language": "Python2", "pygments_language": "Python2",
"url_prefix": "https://python2.example.com", "url_template": "https://python2.example.com?code={code}",
} }
@ -307,7 +307,7 @@ def remove_realm_playground() -> Dict[str, object]:
acting_user=None, acting_user=None,
name="Python playground", name="Python playground",
pygments_language="Python", pygments_language="Python",
url_prefix="https://python.example.com", url_template="https://python.example.com?code={code}",
) )
return { return {
"playground_id": playground_id, "playground_id": playground_id,

View File

@ -481,7 +481,7 @@ def add_realm_playground(client: Client) -> None:
request = { request = {
"name": "Python playground", "name": "Python playground",
"pygments_language": "Python", "pygments_language": "Python",
"url_prefix": "https://python.example.com", "url_template": "https://python.example.com?code={code}",
} }
result = client.call_endpoint(url="/realm/playgrounds", method="POST", request=request) result = client.call_endpoint(url="/realm/playgrounds", method="POST", request=request)
# {code_example|end} # {code_example|end}

View File

@ -3305,7 +3305,7 @@ paths:
"id": 1, "id": 1,
"name": "Python playground", "name": "Python playground",
"pygments_language": "Python", "pygments_language": "Python",
"url_prefix": "https://python.example.com", "url_template": "https://python.example.com",
}, },
], ],
"id": 0, "id": 0,
@ -10716,13 +10716,20 @@ paths:
type: string type: string
example: Python example: Python
required: true required: true
- name: url_prefix - name: url_template
in: query in: query
description: | description: |
The url prefix for the playground. The [RFC 6570](https://www.rfc-editor.org/rfc/rfc6570.html)
compliant URL template for the playground. The template should
contain exactly one variable named `code`, which determines how the
extracted code should be substituted in the playground URL.
**Changes**: New in Zulip 8.0 (feature level 196). This replaced the
`url_prefix` parameter, which was used to construct URLs by just
concatenating `url_prefix` and `code`.
schema: schema:
type: string type: string
example: https://python.example.com example: https://python.example.com?code={code}
required: true required: true
responses: responses:
"200": "200":
@ -17408,10 +17415,17 @@ components:
description: | description: |
The name of the Pygments language lexer for that The name of the Pygments language lexer for that
programming language. programming language.
url_prefix: url_template:
type: string type: string
description: | description: |
The url prefix for the playground. The [RFC 6570](https://www.rfc-editor.org/rfc/rfc6570.html)
compliant URL template for the playground. The template contains
exactly one variable named `code`, which determines how the
extracted code should be substituted in the playground URL.
**Changes**: New in Zulip 8.0 (feature level 196). This replaced the
`url_prefix` parameter, which was used to construct URLs by just
concatenating url_prefix and code.
RealmExport: RealmExport:
type: object type: object
additionalProperties: false additionalProperties: false

View File

@ -866,13 +866,13 @@ class TestRealmAuditLog(ZulipTestCase):
acting_user=user, acting_user=user,
name="Python playground", name="Python playground",
pygments_language="Python", pygments_language="Python",
url_prefix="https://python.example.com", url_template="https://python.example.com{code}",
) )
added_playground = RealmPlaygroundDict( added_playground = RealmPlaygroundDict(
id=playground_id, id=playground_id,
name="Python playground", name="Python playground",
pygments_language="Python", pygments_language="Python",
url_prefix="https://python.example.com", url_template="https://python.example.com{code}",
) )
expected_extra_data = { expected_extra_data = {
"realm_playgrounds": [*initial_playgrounds, added_playground], "realm_playgrounds": [*initial_playgrounds, added_playground],
@ -899,7 +899,6 @@ class TestRealmAuditLog(ZulipTestCase):
removed_playground = { removed_playground = {
"name": "Python playground", "name": "Python playground",
"pygments_language": "Python", "pygments_language": "Python",
"url_prefix": "https://python.example.com",
"url_template": "https://python.example.com{code}", "url_template": "https://python.example.com{code}",
} }
expected_extra_data = { expected_extra_data = {

View File

@ -2228,7 +2228,7 @@ class NormalActionsTest(BaseAction):
acting_user=None, acting_user=None,
name="Python playground", name="Python playground",
pygments_language="Python", pygments_language="Python",
url_prefix="https://python.example.com", url_template="https://python.example.com{code}",
) )
) )
check_realm_playgrounds("events[0]", events[0]) check_realm_playgrounds("events[0]", events[0])

View File

@ -10,7 +10,7 @@ class RealmPlaygroundTests(ZulipTestCase):
payload = { payload = {
"name": "Python playground", "name": "Python playground",
"pygments_language": "Python", "pygments_language": "Python",
"url_prefix": "https://python.example.com", "url_template": "https://python.example.com{code}",
} }
# Now send a POST request to the API endpoint. # Now send a POST request to the API endpoint.
resp = self.api_post(iago, "/api/v1/realm/playgrounds", payload) resp = self.api_post(iago, "/api/v1/realm/playgrounds", payload)
@ -29,12 +29,12 @@ class RealmPlaygroundTests(ZulipTestCase):
{ {
"name": "Python playground 1", "name": "Python playground 1",
"pygments_language": "Python", "pygments_language": "Python",
"url_prefix": "https://python.example.com", "url_template": "https://python.example.com{code}",
}, },
{ {
"name": "Python playground 2", "name": "Python playground 2",
"pygments_language": "Python", "pygments_language": "Python",
"url_prefix": "https://python2.example.com", "url_template": "https://python2.example.com{code}",
}, },
] ]
for payload in data: for payload in data:
@ -53,22 +53,17 @@ class RealmPlaygroundTests(ZulipTestCase):
iago = self.example_user("iago") iago = self.example_user("iago")
payload = { payload = {
"name": "Invalid URL", "name": "Invalid characters in pygments language",
"pygments_language": "Python", "pygments_language": "a$b$c",
"url_prefix": "https://invalid-url", "url_template": "https://template.com{code}",
} }
resp = self.api_post(iago, "/api/v1/realm/playgrounds", payload) resp = self.api_post(iago, "/api/v1/realm/playgrounds", 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, "/api/v1/realm/playgrounds", payload)
self.assert_json_error(resp, "Invalid characters in pygments language") self.assert_json_error(resp, "Invalid characters in pygments language")
payload = { payload = {
"name": "Template with an unexpected variable", "name": "Template with an unexpected variable",
"pygments_language": "Python", "pygments_language": "Python",
"url_prefix": "https://template.com?test={test}", "url_template": "https://template.com{?test,code}",
} }
resp = self.api_post(iago, "/api/v1/realm/playgrounds", payload) resp = self.api_post(iago, "/api/v1/realm/playgrounds", payload)
self.assert_json_error( self.assert_json_error(
@ -78,18 +73,26 @@ class RealmPlaygroundTests(ZulipTestCase):
payload = { payload = {
"name": "Invalid URL template", "name": "Invalid URL template",
"pygments_language": "Python", "pygments_language": "Python",
"url_prefix": "https://template.com?test={test", "url_template": "https://template.com?test={test",
} }
resp = self.api_post(iago, "/api/v1/realm/playgrounds", payload) resp = self.api_post(iago, "/api/v1/realm/playgrounds", payload)
self.assert_json_error(resp, "Invalid URL template.") self.assert_json_error(resp, "Invalid URL template.")
payload = {
"name": "Template without the required variable",
"pygments_language": "Python",
"url_template": "https://template.com{?test}",
}
resp = self.api_post(iago, "/api/v1/realm/playgrounds", payload)
self.assert_json_error(resp, 'Missing the required variable "code" in the URL template')
def test_create_already_existing_playground(self) -> None: def test_create_already_existing_playground(self) -> None:
iago = self.example_user("iago") iago = self.example_user("iago")
payload = { payload = {
"name": "Python playground", "name": "Python playground",
"pygments_language": "Python", "pygments_language": "Python",
"url_prefix": "https://python.example.com", "url_template": "https://python.example.com{code}",
} }
resp = self.api_post(iago, "/api/v1/realm/playgrounds", payload) resp = self.api_post(iago, "/api/v1/realm/playgrounds", payload)
self.assert_json_success(resp) self.assert_json_success(resp)
@ -117,7 +120,7 @@ class RealmPlaygroundTests(ZulipTestCase):
acting_user=iago, acting_user=iago,
name="Python playground", name="Python playground",
pygments_language="Python", pygments_language="Python",
url_prefix="https://python.example.com", url_template="https://python.example.com{code}",
) )
self.assertTrue(RealmPlayground.objects.filter(name="Python playground").exists()) self.assertTrue(RealmPlayground.objects.filter(name="Python playground").exists())

View File

@ -9,7 +9,7 @@ from zerver.decorator import require_realm_admin
from zerver.lib.exceptions import JsonableError, ValidationFailureError from zerver.lib.exceptions import JsonableError, ValidationFailureError
from zerver.lib.request import REQ, has_request_variables from zerver.lib.request import REQ, has_request_variables
from zerver.lib.response import json_success from zerver.lib.response import json_success
from zerver.lib.validator import check_capped_string, check_url from zerver.lib.validator import check_capped_string
from zerver.models import Realm, RealmPlayground, UserProfile from zerver.models import Realm, RealmPlayground, UserProfile
@ -40,7 +40,7 @@ def add_realm_playground(
request: HttpRequest, request: HttpRequest,
user_profile: UserProfile, user_profile: UserProfile,
name: str = REQ(), name: str = REQ(),
url_prefix: str = REQ(str_validator=check_url), url_template: str = REQ(),
pygments_language: str = REQ(str_validator=check_pygments_language), pygments_language: str = REQ(str_validator=check_pygments_language),
) -> HttpResponse: ) -> HttpResponse:
try: try:
@ -49,7 +49,7 @@ def add_realm_playground(
acting_user=user_profile, acting_user=user_profile,
name=name.strip(), name=name.strip(),
pygments_language=pygments_language.strip(), pygments_language=pygments_language.strip(),
url_prefix=url_prefix.strip(), url_template=url_template.strip(),
) )
except ValidationError as e: except ValidationError as e:
raise ValidationFailureError(e) raise ValidationFailureError(e)