mirror of https://github.com/zulip/zulip.git
linkifier: Support reordering linkifiers.
This adds API support to reorder linkifiers and makes sure that the returned lists of linkifiers from `GET /events`, `POST /register`, and `GET /realm/linkifiers` are always sorted with the order that they should processed when rendering linkifiers. We set the new `order` field to the ID with the migration. This preserves the order of the existing linkifiers. New linkifiers added will always be ordered the last. When reordering, the `order` field of all linkifiers in the same realm is updated, in a manner similar to how we implement ordering for `custom_profile_fields`.
This commit is contained in:
parent
011b4c1f7a
commit
37660dd0e7
|
@ -20,6 +20,11 @@ format used by the Zulip server that they are interacting with.
|
||||||
|
|
||||||
## Changes in Zulip 8.0
|
## Changes in Zulip 8.0
|
||||||
|
|
||||||
|
**Feature level 202**
|
||||||
|
|
||||||
|
* [`PATCH /realm/linkifiers`](/api/reorder-linkifiers): Added new endpoint
|
||||||
|
to support changing the order in which linkifiers will be processed.
|
||||||
|
|
||||||
**Feature level 201**
|
**Feature level 201**
|
||||||
|
|
||||||
* [`POST /zulip-outgoing-webhook`]: Renamed the notification trigger
|
* [`POST /zulip-outgoing-webhook`]: Renamed the notification trigger
|
||||||
|
|
|
@ -92,6 +92,7 @@
|
||||||
* [Add a linkifier](/api/add-linkifier)
|
* [Add a linkifier](/api/add-linkifier)
|
||||||
* [Update a linkifier](/api/update-linkifier)
|
* [Update a linkifier](/api/update-linkifier)
|
||||||
* [Remove a linkifier](/api/remove-linkifier)
|
* [Remove a linkifier](/api/remove-linkifier)
|
||||||
|
* [Reorder linkifiers](/api/reorder-linkifiers)
|
||||||
* [Add a code playground](/api/add-code-playground)
|
* [Add a code playground](/api/add-code-playground)
|
||||||
* [Remove a code playground](/api/remove-code-playground)
|
* [Remove a code playground](/api/remove-code-playground)
|
||||||
* [Get all custom emoji](/api/get-custom-emoji)
|
* [Get all custom emoji](/api/get-custom-emoji)
|
||||||
|
|
|
@ -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 = 201
|
API_FEATURE_LEVEL = 202
|
||||||
|
|
||||||
# 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
|
||||||
|
|
|
@ -2,8 +2,11 @@ from typing import Dict, List, Optional
|
||||||
|
|
||||||
import orjson
|
import orjson
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
from django.db.models import Max
|
||||||
from django.utils.timezone import now as timezone_now
|
from django.utils.timezone import now as timezone_now
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
|
from zerver.lib.exceptions import JsonableError
|
||||||
from zerver.lib.types import LinkifierDict
|
from zerver.lib.types import LinkifierDict
|
||||||
from zerver.models import (
|
from zerver.models import (
|
||||||
Realm,
|
Realm,
|
||||||
|
@ -11,6 +14,7 @@ from zerver.models import (
|
||||||
RealmFilter,
|
RealmFilter,
|
||||||
UserProfile,
|
UserProfile,
|
||||||
active_user_ids,
|
active_user_ids,
|
||||||
|
flush_linkifiers,
|
||||||
linkifiers_for_realm,
|
linkifiers_for_realm,
|
||||||
)
|
)
|
||||||
from zerver.tornado.django_api import send_event_on_commit
|
from zerver.tornado.django_api import send_event_on_commit
|
||||||
|
@ -35,7 +39,15 @@ def do_add_linkifier(
|
||||||
) -> int:
|
) -> int:
|
||||||
pattern = pattern.strip()
|
pattern = pattern.strip()
|
||||||
url_template = url_template.strip()
|
url_template = url_template.strip()
|
||||||
linkifier = RealmFilter(realm=realm, pattern=pattern, url_template=url_template)
|
# This makes sure that the new linkifier is always ordered the last modulo
|
||||||
|
# the rare race condition.
|
||||||
|
max_order = RealmFilter.objects.aggregate(Max("order"))["order__max"]
|
||||||
|
if max_order is None:
|
||||||
|
linkifier = RealmFilter(realm=realm, pattern=pattern, url_template=url_template)
|
||||||
|
else:
|
||||||
|
linkifier = RealmFilter(
|
||||||
|
realm=realm, pattern=pattern, url_template=url_template, order=max_order + 1
|
||||||
|
)
|
||||||
linkifier.full_clean()
|
linkifier.full_clean()
|
||||||
linkifier.save()
|
linkifier.save()
|
||||||
|
|
||||||
|
@ -131,3 +143,54 @@ def do_update_linkifier(
|
||||||
)
|
)
|
||||||
|
|
||||||
notify_linkifiers(realm, realm_linkifiers)
|
notify_linkifiers(realm, realm_linkifiers)
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic(durable=True)
|
||||||
|
def check_reorder_linkifiers(
|
||||||
|
realm: Realm, ordered_linkifier_ids: List[int], *, acting_user: Optional[UserProfile]
|
||||||
|
) -> None:
|
||||||
|
"""ordered_linkifier_ids should contain ids of all existing linkifiers.
|
||||||
|
In the rare situation when any of the linkifier gets deleted that more ids
|
||||||
|
are passed, the checks below are sufficient to detect inconsistencies most of
|
||||||
|
the time."""
|
||||||
|
# Repeated IDs in the user request would collapse into the same key when
|
||||||
|
# constructing the set.
|
||||||
|
linkifier_id_set = set(ordered_linkifier_ids)
|
||||||
|
if len(linkifier_id_set) < len(ordered_linkifier_ids):
|
||||||
|
raise JsonableError(_("The ordered list must not contain duplicated linkifiers"))
|
||||||
|
|
||||||
|
linkifiers = RealmFilter.objects.filter(realm=realm)
|
||||||
|
if {linkifier.id for linkifier in linkifiers} != linkifier_id_set:
|
||||||
|
raise JsonableError(
|
||||||
|
_("The ordered list must enumerate all existing linkifiers exactly once")
|
||||||
|
)
|
||||||
|
|
||||||
|
# After the validation, we are sure that there is nothing to do. Return
|
||||||
|
# early to avoid flushing the cache and populating the audit logs.
|
||||||
|
if len(linkifiers) == 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
id_to_new_order = {
|
||||||
|
linkifier_id: order for order, linkifier_id in enumerate(ordered_linkifier_ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
for linkifier in linkifiers:
|
||||||
|
assert linkifier.id in id_to_new_order
|
||||||
|
linkifier.order = id_to_new_order[linkifier.id]
|
||||||
|
RealmFilter.objects.bulk_update(linkifiers, fields=["order"])
|
||||||
|
flush_linkifiers(instance=linkifiers[0])
|
||||||
|
|
||||||
|
# This roundtrip re-fetches the linkifiers sorted in the new order.
|
||||||
|
realm_linkifiers = linkifiers_for_realm(realm.id)
|
||||||
|
RealmAuditLog.objects.create(
|
||||||
|
realm=realm,
|
||||||
|
acting_user=acting_user,
|
||||||
|
event_type=RealmAuditLog.REALM_LINKIFIERS_REORDERED,
|
||||||
|
event_time=timezone_now(),
|
||||||
|
extra_data=orjson.dumps(
|
||||||
|
{
|
||||||
|
"realm_linkifiers": realm_linkifiers,
|
||||||
|
}
|
||||||
|
).decode(),
|
||||||
|
)
|
||||||
|
notify_linkifiers(realm, realm_linkifiers)
|
||||||
|
|
|
@ -0,0 +1,28 @@
|
||||||
|
# Generated by Django 4.2.3 on 2023-08-09 18:48
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||||
|
from django.db.migrations.state import StateApps
|
||||||
|
from django.db.models import F
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_set_order_value(apps: StateApps, schema_editor: BaseDatabaseSchemaEditor) -> None:
|
||||||
|
RealmFilter = apps.get_model("zerver", "RealmFilter")
|
||||||
|
RealmFilter.objects.all().update(order=F("id"))
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("zerver", "0465_backfill_scheduledmessagenotificationemail_trigger"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="realmfilter",
|
||||||
|
name="order",
|
||||||
|
field=models.IntegerField(default=0),
|
||||||
|
),
|
||||||
|
migrations.RunPython(
|
||||||
|
migrate_set_order_value, reverse_code=migrations.RunPython.noop, elidable=True
|
||||||
|
),
|
||||||
|
]
|
|
@ -1246,6 +1246,9 @@ class RealmFilter(models.Model):
|
||||||
realm = models.ForeignKey(Realm, on_delete=CASCADE)
|
realm = models.ForeignKey(Realm, on_delete=CASCADE)
|
||||||
pattern = models.TextField()
|
pattern = models.TextField()
|
||||||
url_template = models.TextField(validators=[url_template_validator])
|
url_template = models.TextField(validators=[url_template_validator])
|
||||||
|
# Linkifiers are applied in a message/topic in order; the processing order
|
||||||
|
# is important when there are overlapping patterns.
|
||||||
|
order = models.IntegerField(default=0)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = ("realm", "pattern")
|
unique_together = ("realm", "pattern")
|
||||||
|
@ -1309,7 +1312,7 @@ def linkifiers_for_realm(realm_id: int) -> List[LinkifierDict]:
|
||||||
url_template=linkifier.url_template,
|
url_template=linkifier.url_template,
|
||||||
id=linkifier.id,
|
id=linkifier.id,
|
||||||
)
|
)
|
||||||
for linkifier in RealmFilter.objects.filter(realm_id=realm_id).order_by("id")
|
for linkifier in RealmFilter.objects.filter(realm_id=realm_id).order_by("order")
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@ -4499,6 +4502,7 @@ class AbstractRealmAuditLog(models.Model):
|
||||||
REALM_LINKIFIER_REMOVED = 225
|
REALM_LINKIFIER_REMOVED = 225
|
||||||
REALM_EMOJI_ADDED = 226
|
REALM_EMOJI_ADDED = 226
|
||||||
REALM_EMOJI_REMOVED = 227
|
REALM_EMOJI_REMOVED = 227
|
||||||
|
REALM_LINKIFIERS_REORDERED = 228
|
||||||
|
|
||||||
SUBSCRIPTION_CREATED = 301
|
SUBSCRIPTION_CREATED = 301
|
||||||
SUBSCRIPTION_ACTIVATED = 302
|
SUBSCRIPTION_ACTIVATED = 302
|
||||||
|
|
|
@ -392,6 +392,18 @@ def get_realm_linkifiers(client: Client) -> None:
|
||||||
validate_against_openapi_schema(result, "/realm/linkifiers", "get", "200")
|
validate_against_openapi_schema(result, "/realm/linkifiers", "get", "200")
|
||||||
|
|
||||||
|
|
||||||
|
@openapi_test_function("/realm/linkifiers:patch")
|
||||||
|
def reorder_realm_linkifiers(client: Client) -> None:
|
||||||
|
# {code_example|start}
|
||||||
|
# Reorder the linkifiers in the user's organization.
|
||||||
|
order = [4, 3, 2, 1]
|
||||||
|
request = {"ordered_linkifier_ids": json.dumps(order)}
|
||||||
|
|
||||||
|
result = client.call_endpoint(url="/realm/linkifiers", method="PATCH", request=request)
|
||||||
|
# {code_example|end}
|
||||||
|
validate_against_openapi_schema(result, "/realm/linkifiers", "patch", "200")
|
||||||
|
|
||||||
|
|
||||||
@openapi_test_function("/realm/profile_fields:get")
|
@openapi_test_function("/realm/profile_fields:get")
|
||||||
def get_realm_profile_fields(client: Client) -> None:
|
def get_realm_profile_fields(client: Client) -> None:
|
||||||
# {code_example|start}
|
# {code_example|start}
|
||||||
|
@ -1629,6 +1641,7 @@ def test_server_organizations(client: Client) -> None:
|
||||||
update_realm_filter(client)
|
update_realm_filter(client)
|
||||||
add_realm_playground(client)
|
add_realm_playground(client)
|
||||||
get_server_settings(client)
|
get_server_settings(client)
|
||||||
|
reorder_realm_linkifiers(client)
|
||||||
remove_realm_filter(client)
|
remove_realm_filter(client)
|
||||||
remove_realm_playground(client)
|
remove_realm_playground(client)
|
||||||
get_realm_emoji(client)
|
get_realm_emoji(client)
|
||||||
|
|
|
@ -3205,8 +3205,16 @@ paths:
|
||||||
realm_linkifiers:
|
realm_linkifiers:
|
||||||
type: array
|
type: array
|
||||||
description: |
|
description: |
|
||||||
Array of dictionaries where each dictionary contains details about
|
An ordered array of dictionaries where each
|
||||||
a single realm linkifier.
|
dictionary contains details about a single
|
||||||
|
realm linkifier.
|
||||||
|
|
||||||
|
The order of the array reflects the order
|
||||||
|
that each linkifier should be processed when
|
||||||
|
linkifying messages and topics. By default,
|
||||||
|
new linkifiers are ordered last. This order
|
||||||
|
can be modified with [`PATCH
|
||||||
|
/realm/linkifiers`](/api/reorder-linkifiers).
|
||||||
items:
|
items:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
|
@ -10606,7 +10614,14 @@ paths:
|
||||||
linkifiers:
|
linkifiers:
|
||||||
type: array
|
type: array
|
||||||
description: |
|
description: |
|
||||||
An array of objects, where each object describes a linkifier.
|
An ordered array of objects, where each object
|
||||||
|
describes a linkifier.
|
||||||
|
|
||||||
|
The order of the array reflects the order that each
|
||||||
|
linkifier should be processed when linkifying messages
|
||||||
|
and topics. By default, new linkifiers are ordered
|
||||||
|
last. This order can be modified with [`PATCH
|
||||||
|
/realm/linkifiers`](/api/reorder-linkifiers).
|
||||||
items:
|
items:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
|
@ -10641,6 +10656,35 @@ paths:
|
||||||
],
|
],
|
||||||
"result": "success",
|
"result": "success",
|
||||||
}
|
}
|
||||||
|
patch:
|
||||||
|
operationId: reorder-linkifiers
|
||||||
|
summary: Reorder all linkifiers
|
||||||
|
tags: ["server_and_organizations"]
|
||||||
|
description: |
|
||||||
|
Change the order that the regular expression patterns in the organization's
|
||||||
|
[linkifiers](/help/add-a-custom-linkifier) are matched in messages and topics.
|
||||||
|
Useful when defining linkifiers with overlapping patterns.
|
||||||
|
|
||||||
|
**Changes**: New in Zulip 8.0 (feature level 202). Before this feature level,
|
||||||
|
linkifiers were always processed in order by ID, which meant users would
|
||||||
|
need to delete and recreate them to reorder the list of linkifiers.
|
||||||
|
parameters:
|
||||||
|
- name: ordered_linkifier_ids
|
||||||
|
in: query
|
||||||
|
description: |
|
||||||
|
A list of the IDs of all the linkifiers defined in this
|
||||||
|
organization, in the desired new order.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: integer
|
||||||
|
example: [3, 2, 1, 5]
|
||||||
|
required: true
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
$ref: "#/components/responses/SimpleSuccess"
|
||||||
/realm/filters:
|
/realm/filters:
|
||||||
post:
|
post:
|
||||||
operationId: add-linkifier
|
operationId: add-linkifier
|
||||||
|
@ -11382,9 +11426,15 @@ paths:
|
||||||
description: |
|
description: |
|
||||||
Present if `realm_linkifiers` is present in `fetch_event_types`.
|
Present if `realm_linkifiers` is present in `fetch_event_types`.
|
||||||
|
|
||||||
Array of objects where each object describes a single
|
An ordered array of objects where each object describes a single
|
||||||
[linkifier](/help/add-a-custom-linkifier).
|
[linkifier](/help/add-a-custom-linkifier).
|
||||||
|
|
||||||
|
The order of the array reflects the order that each
|
||||||
|
linkifier should be processed when linkifying messages
|
||||||
|
and topics. By default, new linkifiers are ordered
|
||||||
|
last. This order can be modified with [`PATCH
|
||||||
|
/realm/linkifiers`](/api/reorder-linkifiers).
|
||||||
|
|
||||||
Clients will receive an empty array unless the event queue is
|
Clients will receive an empty array unless the event queue is
|
||||||
registered with the client capability `{"linkifier_url_template": true}`.
|
registered with the client capability `{"linkifier_url_template": true}`.
|
||||||
See [`client_capabilities`](/api/register-queue#parameter-client_capabilities)
|
See [`client_capabilities`](/api/register-queue#parameter-client_capabilities)
|
||||||
|
|
|
@ -62,6 +62,7 @@ from zerver.actions.realm_domains import (
|
||||||
from zerver.actions.realm_emoji import check_add_realm_emoji, do_remove_realm_emoji
|
from zerver.actions.realm_emoji import check_add_realm_emoji, do_remove_realm_emoji
|
||||||
from zerver.actions.realm_icon import do_change_icon_source
|
from zerver.actions.realm_icon import do_change_icon_source
|
||||||
from zerver.actions.realm_linkifiers import (
|
from zerver.actions.realm_linkifiers import (
|
||||||
|
check_reorder_linkifiers,
|
||||||
do_add_linkifier,
|
do_add_linkifier,
|
||||||
do_remove_linkifier,
|
do_remove_linkifier,
|
||||||
do_update_linkifier,
|
do_update_linkifier,
|
||||||
|
@ -2179,6 +2180,17 @@ class NormalActionsTest(BaseAction):
|
||||||
)
|
)
|
||||||
check_realm_linkifiers("events[0]", events[0])
|
check_realm_linkifiers("events[0]", events[0])
|
||||||
|
|
||||||
|
linkifier_ids = list(
|
||||||
|
RealmFilter.objects.all().values_list("id", flat=True).order_by("order")
|
||||||
|
)
|
||||||
|
events = self.verify_action(
|
||||||
|
lambda: check_reorder_linkifiers(
|
||||||
|
self.user_profile.realm, [linkifier_ids[-1], *linkifier_ids[:-1]], acting_user=None
|
||||||
|
),
|
||||||
|
num_events=1,
|
||||||
|
)
|
||||||
|
check_realm_linkifiers("events[0]", events[0])
|
||||||
|
|
||||||
events = self.verify_action(
|
events = self.verify_action(
|
||||||
lambda: do_remove_linkifier(self.user_profile.realm, regex, acting_user=None),
|
lambda: do_remove_linkifier(self.user_profile.realm, regex, acting_user=None),
|
||||||
num_events=1,
|
num_events=1,
|
||||||
|
|
|
@ -1398,21 +1398,25 @@ class MarkdownTest(ZulipTestCase):
|
||||||
realm=realm,
|
realm=realm,
|
||||||
pattern="http",
|
pattern="http",
|
||||||
url_template="http://example.com/",
|
url_template="http://example.com/",
|
||||||
|
order=1,
|
||||||
),
|
),
|
||||||
RealmFilter(
|
RealmFilter(
|
||||||
realm=realm,
|
realm=realm,
|
||||||
pattern="b#(?P<id>[a-z]+)",
|
pattern="b#(?P<id>[a-z]+)",
|
||||||
url_template="http://example.com/b/{id}",
|
url_template="http://example.com/b/{id}",
|
||||||
|
order=2,
|
||||||
),
|
),
|
||||||
RealmFilter(
|
RealmFilter(
|
||||||
realm=realm,
|
realm=realm,
|
||||||
pattern="a#(?P<aid>[a-z]+) b#(?P<bid>[a-z]+)",
|
pattern="a#(?P<aid>[a-z]+) b#(?P<bid>[a-z]+)",
|
||||||
url_template="http://example.com/a/{aid}/b/{bid}",
|
url_template="http://example.com/a/{aid}/b/{bid}",
|
||||||
|
order=3,
|
||||||
),
|
),
|
||||||
RealmFilter(
|
RealmFilter(
|
||||||
realm=realm,
|
realm=realm,
|
||||||
pattern="a#(?P<id>[a-z]+)",
|
pattern="a#(?P<id>[a-z]+)",
|
||||||
url_template="http://example.com/a/{id}",
|
url_template="http://example.com/a/{id}",
|
||||||
|
order=4,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
@ -1454,20 +1458,22 @@ class MarkdownTest(ZulipTestCase):
|
||||||
def test_linkifier_precedence(self) -> None:
|
def test_linkifier_precedence(self) -> None:
|
||||||
realm = self.example_user("hamlet").realm
|
realm = self.example_user("hamlet").realm
|
||||||
RealmFilter.objects.filter(realm=realm).delete()
|
RealmFilter.objects.filter(realm=realm).delete()
|
||||||
# The insertion order should not affect the fact that the linkifiers are ordered by id.
|
# The insertion order should not affect the fact that the linkifiers are
|
||||||
# Note that we might later switch to a different field to order the linkifiers.
|
# ordered by the `order` field.
|
||||||
sequence = (10, 3, 11, 2, 4, 5, 6)
|
order_values = (10, 3, 11, 2, 4, 5, 6)
|
||||||
for cur_precedence in sequence:
|
order_to_id = {}
|
||||||
|
for cur_order in order_values:
|
||||||
linkifier = RealmFilter(
|
linkifier = RealmFilter(
|
||||||
id=cur_precedence,
|
|
||||||
realm=realm,
|
realm=realm,
|
||||||
pattern=f"abc{cur_precedence}",
|
pattern=f"abc{cur_order}",
|
||||||
url_template="http://foo.com",
|
url_template="http://foo.com",
|
||||||
|
order=cur_order,
|
||||||
)
|
)
|
||||||
linkifier.save()
|
linkifier.save()
|
||||||
|
order_to_id[cur_order] = linkifier.id
|
||||||
linkifiers = linkifiers_for_realm(realm.id)
|
linkifiers = linkifiers_for_realm(realm.id)
|
||||||
for index, cur_precedence in enumerate(sorted(sequence)):
|
for index, cur_order in enumerate(sorted(order_values)):
|
||||||
self.assertEqual(linkifiers[index]["id"], cur_precedence)
|
self.assertEqual(linkifiers[index]["id"], order_to_id[cur_order])
|
||||||
|
|
||||||
def test_realm_patterns_negative(self) -> None:
|
def test_realm_patterns_negative(self) -> None:
|
||||||
realm = get_realm("zulip")
|
realm = get_realm("zulip")
|
||||||
|
|
|
@ -1,9 +1,12 @@
|
||||||
import re
|
import re
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
import orjson
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
from zerver.lib.test_classes import ZulipTestCase
|
from zerver.lib.test_classes import ZulipTestCase
|
||||||
from zerver.models import RealmFilter, url_template_validator
|
from zerver.lib.utils import assert_is_not_none
|
||||||
|
from zerver.models import RealmAuditLog, RealmFilter, url_template_validator
|
||||||
|
|
||||||
|
|
||||||
class RealmFilterTest(ZulipTestCase):
|
class RealmFilterTest(ZulipTestCase):
|
||||||
|
@ -265,3 +268,118 @@ class RealmFilterTest(ZulipTestCase):
|
||||||
for url in invalid_urls:
|
for url in invalid_urls:
|
||||||
with self.assertRaises(ValidationError):
|
with self.assertRaises(ValidationError):
|
||||||
url_template_validator(url)
|
url_template_validator(url)
|
||||||
|
|
||||||
|
def test_reorder_linkifiers(self) -> None:
|
||||||
|
iago = self.example_user("iago")
|
||||||
|
self.login("iago")
|
||||||
|
|
||||||
|
def assert_linkifier_audit_logs(expected_id_order: List[int]) -> None:
|
||||||
|
"""Check if the audit log created orders the linkifiers correctly"""
|
||||||
|
extra_data = orjson.loads(
|
||||||
|
assert_is_not_none(
|
||||||
|
RealmAuditLog.objects.filter(
|
||||||
|
acting_user=iago,
|
||||||
|
event_type=RealmAuditLog.REALM_LINKIFIERS_REORDERED,
|
||||||
|
)
|
||||||
|
.latest("event_time")
|
||||||
|
.extra_data
|
||||||
|
)
|
||||||
|
)
|
||||||
|
audit_logged_ids = [
|
||||||
|
linkifier_dict["id"] for linkifier_dict in extra_data["realm_linkifiers"]
|
||||||
|
]
|
||||||
|
self.assertListEqual(expected_id_order, audit_logged_ids)
|
||||||
|
|
||||||
|
def assert_linkifier_order(expected_id_order: List[int]) -> None:
|
||||||
|
"""Verify that the realm audit log created matches the expected ordering"""
|
||||||
|
result = self.client_get("/json/realm/linkifiers")
|
||||||
|
actual_id_order = [
|
||||||
|
linkifier["id"] for linkifier in self.assert_json_success(result)["linkifiers"]
|
||||||
|
]
|
||||||
|
self.assertListEqual(expected_id_order, actual_id_order)
|
||||||
|
|
||||||
|
def reorder_verify_succeed(expected_id_order: List[int]) -> None:
|
||||||
|
"""Send a reorder request and verify that it succeeds"""
|
||||||
|
result = self.client_patch(
|
||||||
|
"/json/realm/linkifiers",
|
||||||
|
{"ordered_linkifier_ids": orjson.dumps(expected_id_order).decode()},
|
||||||
|
)
|
||||||
|
self.assert_json_success(result)
|
||||||
|
|
||||||
|
reorder_verify_succeed([])
|
||||||
|
self.assertEqual(
|
||||||
|
RealmAuditLog.objects.filter(
|
||||||
|
realm=iago.realm, event_type=RealmAuditLog.REALM_LINKIFIERS_REORDERED
|
||||||
|
).count(),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
|
||||||
|
linkifiers = [
|
||||||
|
{
|
||||||
|
"pattern": "1#(?P<id>[123])",
|
||||||
|
"url_template": "https://filter.com/foo/{id}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pattern": "2#(?P<id>[123])",
|
||||||
|
"url_template": "https://filter.com/bar/{id}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pattern": "3#(?P<id>[123])",
|
||||||
|
"url_template": "https://filter.com/baz/{id}",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
original_id_order = []
|
||||||
|
for linkifier in linkifiers:
|
||||||
|
result = self.client_post("/json/realm/filters", linkifier)
|
||||||
|
original_id_order.append(self.assert_json_success(result)["id"])
|
||||||
|
assert_linkifier_order(original_id_order)
|
||||||
|
self.assertListEqual([0, 1, 2], list(RealmFilter.objects.values_list("order", flat=True)))
|
||||||
|
|
||||||
|
# The creation order orders the linkifiers by default.
|
||||||
|
# When the order values are the same, fallback to order by ID.
|
||||||
|
RealmFilter.objects.all().update(order=0)
|
||||||
|
assert_linkifier_order(original_id_order)
|
||||||
|
|
||||||
|
# This should successfully reorder the linkifiers.
|
||||||
|
new_order = [original_id_order[2], original_id_order[1], original_id_order[0]]
|
||||||
|
reorder_verify_succeed(new_order)
|
||||||
|
assert_linkifier_audit_logs(new_order)
|
||||||
|
assert_linkifier_order(new_order)
|
||||||
|
|
||||||
|
# After reordering, newly created linkifier is ordered at the last, and
|
||||||
|
# the other linkifiers are unchanged.
|
||||||
|
result = self.client_post(
|
||||||
|
"/json/realm/filters", {"pattern": "3#123", "url_template": "https://example.com"}
|
||||||
|
)
|
||||||
|
new_linkifier_id = self.assert_json_success(result)["id"]
|
||||||
|
new_order = [*new_order, new_linkifier_id]
|
||||||
|
assert_linkifier_order(new_order)
|
||||||
|
|
||||||
|
# Deleting a linkifier should preserve the order.
|
||||||
|
deleted_linkifier_id = new_order[2]
|
||||||
|
result = self.client_delete(f"/json/realm/filters/{deleted_linkifier_id}")
|
||||||
|
self.assert_json_success(result)
|
||||||
|
new_order = [*new_order[:2], new_linkifier_id]
|
||||||
|
assert_linkifier_order(new_order)
|
||||||
|
|
||||||
|
# Extra non-existent ids are ignored.
|
||||||
|
new_order = [new_order[2], new_order[0], new_order[1]]
|
||||||
|
result = self.client_patch(
|
||||||
|
"/json/realm/linkifiers", {"ordered_linkifier_ids": [deleted_linkifier_id, *new_order]}
|
||||||
|
)
|
||||||
|
self.assert_json_error(
|
||||||
|
result, "The ordered list must enumerate all existing linkifiers exactly once"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Duplicated IDs are not allowed.
|
||||||
|
new_order = [*new_order, new_order[0]]
|
||||||
|
result = self.client_patch("/json/realm/linkifiers", {"ordered_linkifier_ids": new_order})
|
||||||
|
self.assert_json_error(result, "The ordered list must not contain duplicated linkifiers")
|
||||||
|
|
||||||
|
# Incomplete lists of linkifiers are not allowed.
|
||||||
|
result = self.client_patch(
|
||||||
|
"/json/realm/linkifiers", {"ordered_linkifier_ids": new_order[:2]}
|
||||||
|
)
|
||||||
|
self.assert_json_error(
|
||||||
|
result, "The ordered list must enumerate all existing linkifiers exactly once"
|
||||||
|
)
|
||||||
|
|
|
@ -1,8 +1,11 @@
|
||||||
|
from typing import List
|
||||||
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
from zerver.actions.realm_linkifiers import (
|
from zerver.actions.realm_linkifiers import (
|
||||||
|
check_reorder_linkifiers,
|
||||||
do_add_linkifier,
|
do_add_linkifier,
|
||||||
do_remove_linkifier,
|
do_remove_linkifier,
|
||||||
do_update_linkifier,
|
do_update_linkifier,
|
||||||
|
@ -11,6 +14,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_int, check_list
|
||||||
from zerver.models import RealmFilter, UserProfile, linkifiers_for_realm
|
from zerver.models import RealmFilter, UserProfile, linkifiers_for_realm
|
||||||
|
|
||||||
|
|
||||||
|
@ -73,3 +77,14 @@ def update_linkifier(
|
||||||
raise JsonableError(_("Linkifier not found."))
|
raise JsonableError(_("Linkifier not found."))
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
raise ValidationFailureError(e)
|
raise ValidationFailureError(e)
|
||||||
|
|
||||||
|
|
||||||
|
@require_realm_admin
|
||||||
|
@has_request_variables
|
||||||
|
def reorder_linkifiers(
|
||||||
|
request: HttpRequest,
|
||||||
|
user_profile: UserProfile,
|
||||||
|
ordered_linkifier_ids: List[int] = REQ(json_validator=check_list(check_int)),
|
||||||
|
) -> HttpResponse:
|
||||||
|
check_reorder_linkifiers(user_profile.realm, ordered_linkifier_ids, acting_user=user_profile)
|
||||||
|
return json_success(request)
|
||||||
|
|
|
@ -114,6 +114,7 @@ from zerver.views.realm_linkifiers import (
|
||||||
create_linkifier,
|
create_linkifier,
|
||||||
delete_linkifier,
|
delete_linkifier,
|
||||||
list_linkifiers,
|
list_linkifiers,
|
||||||
|
reorder_linkifiers,
|
||||||
update_linkifier,
|
update_linkifier,
|
||||||
)
|
)
|
||||||
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
|
||||||
|
@ -270,7 +271,7 @@ v1_api_and_json_patterns = [
|
||||||
# realm/logo -> zerver.views.realm_logo
|
# realm/logo -> zerver.views.realm_logo
|
||||||
rest_path("realm/logo", POST=upload_logo, DELETE=delete_logo_backend, GET=get_logo_backend),
|
rest_path("realm/logo", POST=upload_logo, DELETE=delete_logo_backend, GET=get_logo_backend),
|
||||||
# realm/filters and realm/linkifiers -> zerver.views.realm_linkifiers
|
# realm/filters and realm/linkifiers -> zerver.views.realm_linkifiers
|
||||||
rest_path("realm/linkifiers", GET=list_linkifiers),
|
rest_path("realm/linkifiers", GET=list_linkifiers, PATCH=reorder_linkifiers),
|
||||||
rest_path("realm/filters", POST=create_linkifier),
|
rest_path("realm/filters", POST=create_linkifier),
|
||||||
rest_path("realm/filters/<int:filter_id>", DELETE=delete_linkifier, PATCH=update_linkifier),
|
rest_path("realm/filters/<int:filter_id>", DELETE=delete_linkifier, PATCH=update_linkifier),
|
||||||
# realm/playgrounds -> zerver.views.realm_playgrounds
|
# realm/playgrounds -> zerver.views.realm_playgrounds
|
||||||
|
|
Loading…
Reference in New Issue