diff --git a/templates/zerver/api/changelog.md b/templates/zerver/api/changelog.md index de05652d57..d8fd7eb5b3 100644 --- a/templates/zerver/api/changelog.md +++ b/templates/zerver/api/changelog.md @@ -10,6 +10,11 @@ below features are supported. ## Changes in Zulip 4.0 +**Feature level 57** + +* [`PATCH /realm/filters/{filter_id}`](/api/update-linkifier): New + endpoint added to update a realm linkifier. + **Feature level 56** * [`POST /register`](/api/register-queue): Added a new setting diff --git a/templates/zerver/api/update-linkifier.md b/templates/zerver/api/update-linkifier.md new file mode 100644 index 0000000000..2a77d80e34 --- /dev/null +++ b/templates/zerver/api/update-linkifier.md @@ -0,0 +1,32 @@ +# Update a linkifier + +{generate_api_description(/realm/filters/{filter_id}:patch)} + +## Usage examples + +{start_tabs} +{tab|python} + +{generate_code_example(python)|/realm/filters/{filter_id}:patch|example} + +{tab|curl} + +{generate_code_example(curl)|/realm/filters/{filter_id}:patch|example} + +{end_tabs} + +## Parameters + +{generate_api_arguments_table|zulip.yaml|/realm/filters/{filter_id}:patch} + +## Response + +#### Return values + +{generate_return_values_table|zulip.yaml|/realm/filters/{filter_id}:patch} + +#### Example response + +A typical successful JSON response may look like: + +{generate_code_example|/realm/filters/{filter_id}:patch|fixture(200)} diff --git a/templates/zerver/help/include/rest-endpoints.md b/templates/zerver/help/include/rest-endpoints.md index b3831efd28..22159b6cdc 100644 --- a/templates/zerver/help/include/rest-endpoints.md +++ b/templates/zerver/help/include/rest-endpoints.md @@ -59,6 +59,7 @@ * [Get server settings](/api/get-server-settings) * [Get linkifiers](/api/get-linkifiers) * [Add a linkifier](/api/add-linkifier) +* [Update a linkifier](/api/update-linkifier) * [Remove a linkifier](/api/remove-linkifier) * [Add a playground](/api/add-playground) * [Remove a playground](/api/remove-playground) diff --git a/version.py b/version.py index 0098969532..50c18c3653 100644 --- a/version.py +++ b/version.py @@ -30,7 +30,7 @@ DESKTOP_WARNING_VERSION = "5.2.0" # # Changes should be accompanied by documentation explaining what the # new level means in templates/zerver/api/changelog.md. -API_FEATURE_LEVEL = 56 +API_FEATURE_LEVEL = 57 # 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/lib/actions.py b/zerver/lib/actions.py index a8ba2eb3d1..7e44653498 100644 --- a/zerver/lib/actions.py +++ b/zerver/lib/actions.py @@ -6647,6 +6647,17 @@ def do_remove_linkifier( notify_linkifiers(realm) +def do_update_linkifier(realm: Realm, id: int, pattern: str, url_format_string: str) -> None: + pattern = pattern.strip() + url_format_string = url_format_string.strip() + linkifier = RealmFilter.objects.get(realm=realm, id=id) + linkifier.pattern = pattern + linkifier.url_format_string = url_format_string + linkifier.full_clean() + linkifier.save(update_fields=["pattern", "url_format_string"]) + notify_linkifiers(realm) + + def get_emails_from_user_ids(user_ids: Sequence[int]) -> Dict[int, str]: # We may eventually use memcached to speed this up, but the DB is fast. return UserProfile.emails_from_ids(user_ids) diff --git a/zerver/openapi/python_examples.py b/zerver/openapi/python_examples.py index 9b2757d8b2..9e42f450af 100644 --- a/zerver/openapi/python_examples.py +++ b/zerver/openapi/python_examples.py @@ -372,6 +372,25 @@ def add_realm_filter(client: Client) -> None: validate_against_openapi_schema(result, "/realm/filters", "post", "200") +@openapi_test_function("/realm/filters/{filter_id}:patch") +def update_realm_filter(client: Client) -> None: + + # {code_example|start} + # Update the linkifier (realm_filter) with ID 1 + filter_id = 1 + request = { + "pattern": "#(?P[0-9]+)", + "url_format_string": "https://github.com/zulip/zulip/issues/%(id)s", + } + + result = client.call_endpoint( + url=f"/realm/filters/{filter_id}", method="PATCH", request=request + ) + # {code_example|end} + + validate_against_openapi_schema(result, "/realm/filters/{filter_id}", "patch", "200") + + @openapi_test_function("/realm/filters/{filter_id}:delete") def remove_realm_filter(client: Client) -> None: @@ -1464,6 +1483,7 @@ def test_server_organizations(client: Client) -> None: get_realm_linkifiers(client) add_realm_filter(client) + update_realm_filter(client) add_realm_playground(client) get_server_settings(client) remove_realm_filter(client) diff --git a/zerver/openapi/zulip.yaml b/zerver/openapi/zulip.yaml index cb67fe536d..59ea45fa37 100644 --- a/zerver/openapi/zulip.yaml +++ b/zerver/openapi/zulip.yaml @@ -6682,6 +6682,35 @@ paths: application/json: schema: $ref: "#/components/schemas/JsonSuccess" + patch: + operationId: update_linkifier + tags: ["server_and_organizations"] + description: | + Update a [linkifier](/help/add-a-custom-linkifier), regular + expression patterns that are automatically linkified when they appear + in messages and topics. + + `PATCH {{ api_url }}/v1/realm/filters/{filter_id}` + + **Changes**: New in Zulip 4.0 (feature level 57). + parameters: + - name: filter_id + in: path + description: | + The ID of the linkifier that you want to update. + schema: + type: integer + example: 2 + required: true + - $ref: "#/components/parameters/LinkifierPattern" + - $ref: "#/components/parameters/LinkifierURLFormatString" + responses: + "200": + description: Success. + content: + application/json: + schema: + $ref: "#/components/schemas/JsonSuccess" /realm/playgrounds: post: operationId: add_realm_playground diff --git a/zerver/tests/test_events.py b/zerver/tests/test_events.py index 0ed471398f..a8d0edef1c 100644 --- a/zerver/tests/test_events.py +++ b/zerver/tests/test_events.py @@ -86,6 +86,7 @@ from zerver.lib.actions import ( do_unmute_topic, do_unmute_user, do_update_embedded_data, + do_update_linkifier, do_update_message, do_update_message_flags, do_update_outgoing_webhook_service, @@ -1356,13 +1357,21 @@ class NormalActionsTest(BaseAction): check_realm_linkifiers("events[0]", events[0]) check_realm_filters("events[1]", events[1]) + regex = "#(?P[0-9]+)" + linkifier_id = events[0]["realm_linkifiers"][0]["id"] events = self.verify_action( - lambda: do_remove_linkifier(self.user_profile.realm, "#(?P[123])"), + lambda: do_update_linkifier(self.user_profile.realm, linkifier_id, regex, url), num_events=2, ) check_realm_linkifiers("events[0]", events[0]) check_realm_filters("events[1]", events[1]) + events = self.verify_action( + lambda: do_remove_linkifier(self.user_profile.realm, regex), num_events=2 + ) + check_realm_linkifiers("events[0]", events[0]) + check_realm_filters("events[1]", events[1]) + def test_realm_domain_events(self) -> None: events = self.verify_action( lambda: do_add_realm_domain(self.user_profile.realm, "zulip.org", False) diff --git a/zerver/tests/test_realm_linkifiers.py b/zerver/tests/test_realm_linkifiers.py index 944eb06fc1..2b0874f321 100644 --- a/zerver/tests/test_realm_linkifiers.py +++ b/zerver/tests/test_realm_linkifiers.py @@ -124,3 +124,50 @@ class RealmFilterTest(ZulipTestCase): result = self.client_delete(f"/json/realm/filters/{linkifier_id}") self.assert_json_success(result) self.assertEqual(RealmFilter.objects.count(), linkifiers_count - 1) + + def test_update(self) -> None: + self.login("iago") + data = { + "pattern": "#(?P[123])", + "url_format_string": "https://realm.com/my_realm_filter/%(id)s", + } + result = self.client_post("/json/realm/filters", info=data) + self.assert_json_success(result) + + linkifier_id = result.json()["id"] + data = { + "pattern": "#(?P[0-9]+)", + "url_format_string": "https://realm.com/my_realm_filter/issues/%(id)s", + } + result = self.client_patch(f"/json/realm/filters/{linkifier_id}", info=data) + self.assert_json_success(result) + self.assertIsNotNone(re.match(data["pattern"], "#1234")) + + # Verify that the linkifier is updated accordingly. + result = self.client_get("/json/realm/linkifiers") + self.assert_json_success(result) + linkifier = result.json()["linkifiers"] + self.assertEqual(len(linkifier), 1) + self.assertEqual(linkifier[0]["pattern"], "#(?P[0-9]+)") + self.assertEqual( + linkifier[0]["url_format"], "https://realm.com/my_realm_filter/issues/%(id)s" + ) + + data = { + "pattern": r"ZUL-(?P\d++)", + "url_format_string": "https://realm.com/my_realm_filter/%(id)s", + } + result = self.client_patch(f"/json/realm/filters/{linkifier_id}", info=data) + self.assert_json_error( + result, "Invalid linkifier pattern. Valid characters are [ a-zA-Z_#=/:+!-]." + ) + + data["pattern"] = r"ZUL-(?P\d+)" + data["url_format_string"] = "$fgfg" + result = self.client_patch(f"/json/realm/filters/{linkifier_id}", info=data) + self.assert_json_error(result, "Enter a valid URL.") + + data["pattern"] = r"#(?P[123])" + data["url_format_string"] = "https://realm.com/my_realm_filter/%(id)s" + result = self.client_patch(f"/json/realm/filters/{linkifier_id + 1}", info=data) + self.assert_json_error(result, "Linkifier not found.") diff --git a/zerver/views/realm_linkifiers.py b/zerver/views/realm_linkifiers.py index 746d004846..b45c5768c6 100644 --- a/zerver/views/realm_linkifiers.py +++ b/zerver/views/realm_linkifiers.py @@ -3,7 +3,7 @@ from django.http import HttpRequest, HttpResponse from django.utils.translation import gettext as _ from zerver.decorator import require_realm_admin -from zerver.lib.actions import do_add_linkifier, do_remove_linkifier +from zerver.lib.actions import do_add_linkifier, do_remove_linkifier, do_update_linkifier from zerver.lib.request import REQ, has_request_variables from zerver.lib.response import json_error, json_success from zerver.models import RealmFilter, UserProfile, linkifiers_for_realm @@ -43,3 +43,26 @@ def delete_linkifier( except RealmFilter.DoesNotExist: return json_error(_("Linkifier not found.")) return json_success() + + +@require_realm_admin +@has_request_variables +def update_linkifier( + request: HttpRequest, + user_profile: UserProfile, + filter_id: int, + pattern: str = REQ(), + url_format_string: str = REQ(), +) -> HttpResponse: + try: + do_update_linkifier( + realm=user_profile.realm, + id=filter_id, + pattern=pattern, + url_format_string=url_format_string, + ) + return json_success() + except RealmFilter.DoesNotExist: + return json_error(_("Linkifier not found.")) + except ValidationError as e: + return json_error(e.messages[0], data={"errors": dict(e)}) diff --git a/zproject/urls.py b/zproject/urls.py index 6a18411315..c27dfa2b64 100644 --- a/zproject/urls.py +++ b/zproject/urls.py @@ -118,7 +118,12 @@ from zerver.views.realm_domains import ( from zerver.views.realm_emoji import delete_emoji, list_emoji, upload_emoji from zerver.views.realm_export import delete_realm_export, export_realm, get_realm_exports 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, + update_linkifier, +) from zerver.views.realm_logo import delete_logo_backend, get_logo_backend, upload_logo from zerver.views.realm_playgrounds import add_realm_playground, delete_realm_playground from zerver.views.registration import ( @@ -265,7 +270,7 @@ v1_api_and_json_patterns = [ # realm/filters and realm/linkifiers -> zerver.views.realm_linkifiers rest_path("realm/linkifiers", GET=list_linkifiers), rest_path("realm/filters", POST=create_linkifier), - rest_path("realm/filters/", DELETE=delete_linkifier), + rest_path("realm/filters/", DELETE=delete_linkifier, PATCH=update_linkifier), # realm/playgrounds -> zerver.views.realm_playgrounds rest_path("realm/playgrounds", POST=add_realm_playground), rest_path("realm/playgrounds/", DELETE=delete_realm_playground),