Repository: wagtail/wagtail

Issue:
Ability to add external pages in promoted search results
> ⓘ This feature request is based on an older PR https://github.com/wagtail/wagtail/pull/4560

### Is your proposal related to a problem?

At The University of Chicago Library we need the ability to add pages to our search results that do _not_ reside on our main Wagtail website. Though it might sound counter intuitive or weird to include "external pages" in site search, we have many web properties that use different content management systems. Some of those properties have the same branding as our main Wagtail website and are considered by our users to be part of our main Wagtail website even though they're hosted on different platforms.

Though we continue to look at more involved and comprehensive solutions using Elastic Search, we have noticed that **promoted search results** would actually solve the majority of our problems if they had the ability to include external pages. In fact, in some ways promoted search results could actually be better than indexing these pages for the following reasons:

1. Even though we want to add external pages to our search results, there is a lot of content on these pages we would want to keep out of the index. Though we could index these and fine tune the results, it seems heavy handed.
2. In-site searching is used by a very small fraction of our users. The large majority of them just use Google to search our site anyhow. We're most worried about a set of known searches.

### Describe the solution you'd like

When adding promoted searches, there should be an ability to add any external URL. This would behave similar to the redirects where you can redirect to a Page or an external URL.

We implemented a naive proof of concept in #4560 that you can see in this pull request. We'd like to use this as a starting point for getting feedback on the idea.  

![external-page-search-promotion](https://user-images.githubusercontent.com/7826429/40495754-af10623e-5f3d-11e8-8377-21f4e9db1c16.png) (note: old version of Wagtail shown)


### Describe alternatives you've considered

Leave this to custom implementations or layers on top of the Search system to solve.

### Additional context

* Original PR https://github.com/wagtail/wagtail/pull/4560

Ability to add external pages in promoted search results
> ⓘ This feature request is based on an older PR https://github.com/wagtail/wagtail/pull/4560

### Is your proposal related to a problem?

At The University of Chicago Library we need the ability to add pages to our search results that do _not_ reside on our main Wagtail website. Though it might sound counter intuitive or weird to include "external pages" in site search, we have many web properties that use different content management systems. Some of those properties have the same branding as our main Wagtail website and are considered by our users to be part of our main Wagtail website even though they're hosted on different platforms.

Though we continue to look at more involved and comprehensive solutions using Elastic Search, we have noticed that **promoted search results** would actually solve the majority of our problems if they had the ability to include external pages. In fact, in some ways promoted search results could actually be better than indexing these pages for the following reasons:

1. Even though we want to add external pages to our search results, there is a lot of content on these pages we would want to keep out of the index. Though we could index these and fine tune the results, it seems heavy handed.
2. In-site searching is used by a very small fraction of our users. The large majority of them just use Google to search our site anyhow. We're most worried about a set of known searches.

### Describe the solution you'd like

When adding promoted searches, there should be an ability to add any external URL. This would behave similar to the redirects where you can redirect to a Page or an external URL.

We implemented a naive proof of concept in #4560 that you can see in this pull request. We'd like to use this as a starting point for getting feedback on the idea.  

![external-page-search-promotion](https://user-images.githubusercontent.com/7826429/40495754-af10623e-5f3d-11e8-8377-21f4e9db1c16.png) (note: old version of Wagtail shown)


### Describe alternatives you've considered

Leave this to custom implementations or layers on top of the Search system to solve.

### Additional context

* Original PR https://github.com/wagtail/wagtail/pull/4560



Discussion:
I'll be happy to get this done.
Go for it @TopDevPros - please review the code and comments on the original PR for direction. Wagtail has changed a bit since the original PR but you should be able to pick up the intended changes and apply to the new code. 
I'll be happy to get this done.
Go for it @TopDevPros - please review the code and comments on the original PR for direction. Wagtail has changed a bit since the original PR but you should be able to pick up the intended changes and apply to the new code. 

Resolving patch:
diff --git a/wagtail/contrib/search_promotions/forms.py b/wagtail/contrib/search_promotions/forms.py
--- a/wagtail/contrib/search_promotions/forms.py
+++ b/wagtail/contrib/search_promotions/forms.py
@@ -27,7 +27,13 @@ def __init__(self, *args, **kwargs):
 
     class Meta:
         model = SearchPromotion
-        fields = ("query", "page", "description")
+        fields = (
+            "query",
+            "page",
+            "external_link_url",
+            "external_link_text",
+            "description",
+        )
 
         widgets = {
             "description": forms.Textarea(attrs={"rows": 3}),
@@ -67,6 +73,31 @@ def clean(self):
         non_empty_forms = 0
         for i in range(0, self.total_form_count()):
             form = self.forms[i]
+
+            page = form.cleaned_data["page"]
+            external_link_url = form.cleaned_data["external_link_url"]
+            external_link_text = form.cleaned_data["external_link_text"]
+
+            # only a page or external_link_url can be supplied
+            if page is None:
+                if external_link_url:
+                    # if an external_link_url then external_link_text is also required
+                    if not external_link_text:
+                        raise forms.ValidationError(
+                            _(
+                                "You must enter an external link text if you enter an external link URL."
+                            )
+                        )
+                else:
+                    raise forms.ValidationError(
+                        _("You must recommend a page OR an external link.")
+                    )
+            else:
+                if external_link_url:
+                    raise forms.ValidationError(
+                        _("Please only select a page OR enter an external link.")
+                    )
+
             if self.can_delete and self._should_delete_form(form):
                 non_deleted_forms -= 1
             if not (form.instance.id is None and not form.has_changed()):
diff --git a/wagtail/contrib/search_promotions/migrations/0007_searchpromotion_external_link_text_and_more.py b/wagtail/contrib/search_promotions/migrations/0007_searchpromotion_external_link_text_and_more.py
new file mode 100644
--- /dev/null
+++ b/wagtail/contrib/search_promotions/migrations/0007_searchpromotion_external_link_text_and_more.py
@@ -0,0 +1,54 @@
+# Generated by Django 4.0.10 on 2023-09-25 20:17
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("wagtailcore", "0089_log_entry_data_json_null_to_object"),
+        ("wagtailsearchpromotions", "0006_reset_query_sequence"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="searchpromotion",
+            name="external_link_text",
+            field=models.CharField(
+                blank=True,
+                max_length=200,
+                null=True,
+            ),
+        ),
+        migrations.AddField(
+            model_name="searchpromotion",
+            name="external_link_url",
+            field=models.URLField(
+                blank=True,
+                help_text="Alternatively, use an external link for this promotion",
+                verbose_name="External link URL",
+            ),
+        ),
+        migrations.AlterField(
+            model_name="searchpromotion",
+            name="description",
+            field=models.TextField(
+                blank=True,
+                help_text="Applies to internal page or external link",
+                verbose_name="description",
+            ),
+        ),
+        migrations.AlterField(
+            model_name="searchpromotion",
+            name="page",
+            field=models.ForeignKey(
+                blank=True,
+                help_text="Choose an internal page for this promotion",
+                null=True,
+                on_delete=django.db.models.deletion.CASCADE,
+                to="wagtailcore.page",
+                verbose_name="page",
+            ),
+        ),
+    ]
diff --git a/wagtail/contrib/search_promotions/models.py b/wagtail/contrib/search_promotions/models.py
--- a/wagtail/contrib/search_promotions/models.py
+++ b/wagtail/contrib/search_promotions/models.py
@@ -95,22 +95,62 @@ class SearchPromotion(models.Model):
         Query, db_index=True, related_name="editors_picks", on_delete=models.CASCADE
     )
     page = models.ForeignKey(
-        "wagtailcore.Page", verbose_name=_("page"), on_delete=models.CASCADE
+        "wagtailcore.Page",
+        verbose_name=_("page"),
+        help_text=_("Choose an internal page for this promotion"),
+        on_delete=models.CASCADE,
+        null=True,
+        blank=True,
+    )
+    external_link_url = models.URLField(
+        _("External link URL"),
+        help_text=_("Alternatively, use an external link for this promotion"),
+        blank=True,
+    )
+    external_link_text = models.CharField(
+        max_length=200,
+        blank=True,
+        null=True,
+    )
+    description = models.TextField(
+        verbose_name=_("description"),
+        help_text=_("Applies to internal page or external link"),
+        blank=True,
     )
     sort_order = models.IntegerField(null=True, blank=True, editable=False)
-    description = models.TextField(verbose_name=_("description"), blank=True)
+
+    @property
+    def title(self):
+        if self.page:
+            prop = self.page.title
+        else:
+            prop = self.external_link_text
+        return prop
+
+    @property
+    def link(self):
+        if self.page:
+            prop = self.page
+        else:
+            prop = self.external_link_url
+        return prop
 
     def __repr__(self):
+        if self.page:
+            label = "page"
+        else:
+            label = "external link"
+
         return (
             'SearchPromotion(query="'
             + self.query.query_string
-            + '", page="'
-            + self.page.title
+            + f'", {label}="'
+            + self.title
             + '")'
         )
 
     def __str__(self):
-        return f"{self.query.query_string} - {self.page.title}"
+        return f"{self.query.query_string} - {self.title}"
 
     class Meta:
         ordering = ("sort_order",)


Test patch:
diff --git a/wagtail/contrib/search_promotions/tests.py b/wagtail/contrib/search_promotions/tests.py
--- a/wagtail/contrib/search_promotions/tests.py
+++ b/wagtail/contrib/search_promotions/tests.py
@@ -31,9 +31,24 @@ def test_search_pick_create(self):
         self.assertEqual(Query.get("root page").editors_picks.count(), 1)
         self.assertEqual(Query.get("root page").editors_picks.first().page_id, 1)
 
-    def test_search_pick_ordering(self):
+    def test_search_pick_link_create(self):
         # Add 3 search picks in a different order to their sort_order values
         # They should be ordered by their sort order values and not their insertion order
+        SearchPromotion.objects.create(
+            query=Query.get("root page"),
+            external_link_url="https://wagtail.org",
+            sort_order=0,
+            description="First search promotion",
+        )
+
+        # Check
+        self.assertEqual(Query.get("root page").editors_picks.count(), 1)
+        self.assertEqual(
+            Query.get("root page").editors_picks.first().external_link_url,
+            "https://wagtail.org",
+        )
+
+    def test_search_pick_ordering(self):
         SearchPromotion.objects.create(
             query=Query.get("root page"),
             page_id=1,
@@ -48,7 +63,7 @@ def test_search_pick_ordering(self):
         )
         SearchPromotion.objects.create(
             query=Query.get("root page"),
-            page_id=1,
+            external_link_url="https://wagtail.org",
             sort_order=1,
             description="Middle search pick",
         )
@@ -106,6 +121,31 @@ def test_search(self):
         )
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.context["query_string"], "Hello")
+        self.assertContains(
+            response,
+            'Sorry, no promoted results match "<em>Hello</em>"',
+        )
+
+    def test_search_with_results(self):
+        SearchPromotion.objects.create(
+            query=Query.get("search promotion query"),
+            page_id=1,
+        )
+        SearchPromotion.objects.create(
+            query=Query.get("search promotion query"),
+            external_link_url="https://wagtail.org",
+        )
+
+        response = self.client.get(
+            reverse("wagtailsearchpromotions:index"), {"q": "search promotion query"}
+        )
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.context["query_string"], "search promotion query")
+        self.assertContains(response, '<a href="/admin/pages/1/edit/" class="nolink">')
+        self.assertContains(
+            response,
+            '<a href="https://wagtail.org" class="nolink" target="_blank" rel="noreferrer">',
+        )
 
     def make_search_picks(self):
         for i in range(50):
@@ -260,6 +300,31 @@ def test_post(self):
         # Check that the search pick was created
         self.assertTrue(Query.get("test").editors_picks.filter(page_id=1).exists())
 
+    def test_post_with_external_link(self):
+        # Submit
+        post_data = {
+            "query_string": "test",
+            "editors_picks-TOTAL_FORMS": 1,
+            "editors_picks-INITIAL_FORMS": 0,
+            "editors_picks-MAX_NUM_FORMS": 1000,
+            "editors_picks-0-DELETE": "",
+            "editors_picks-0-ORDER": 0,
+            "editors_picks-0-external_link_url": "https://wagtail.org",
+            "editors_picks-0-external_link_text": "Wagtail",
+            "editors_picks-0-description": "Hello",
+        }
+        response = self.client.post(reverse("wagtailsearchpromotions:add"), post_data)
+
+        # User should be redirected back to the index
+        self.assertRedirects(response, reverse("wagtailsearchpromotions:index"))
+
+        # Check that the search pick was created
+        self.assertTrue(
+            Query.get("test")
+            .editors_picks.filter(external_link_url="https://wagtail.org")
+            .exists()
+        )
+
     def test_post_without_recommendations(self):
         # Submit
         post_data = {
@@ -280,6 +345,75 @@ def test_post_without_recommendations(self):
             "Please specify at least one recommendation for this search term.",
         )
 
+    def test_post_with_page_and_external_link(self):
+        post_data = {
+            "query_string": "test",
+            "editors_picks-TOTAL_FORMS": 1,
+            "editors_picks-INITIAL_FORMS": 0,
+            "editors_picks-MAX_NUM_FORMS": 1000,
+            "editors_picks-0-DELETE": "",
+            "editors_picks-0-ORDER": 0,
+            "editors_picks-0-page": 1,
+            "editors_picks-0-external_link_url": "https://wagtail.org",
+            "editors_picks-0-external_link_text": "Wagtail",
+            "editors_picks-0-description": "Hello",
+        }
+        response = self.client.post(reverse("wagtailsearchpromotions:add"), post_data)
+
+        # User should be given an error
+        self.assertEqual(response.status_code, 200)
+        self.assertFormsetError(
+            response,
+            "searchpicks_formset",
+            None,
+            None,
+            "Please only select a page OR enter an external link.",
+        )
+
+    def test_post_missing_recommendation(self):
+        post_data = {
+            "query_string": "test",
+            "editors_picks-TOTAL_FORMS": 1,
+            "editors_picks-INITIAL_FORMS": 0,
+            "editors_picks-MAX_NUM_FORMS": 1000,
+            "editors_picks-0-DELETE": "",
+            "editors_picks-0-ORDER": 0,
+            "editors_picks-0-description": "Hello",
+        }
+        response = self.client.post(reverse("wagtailsearchpromotions:add"), post_data)
+
+        # User should be given an error
+        self.assertEqual(response.status_code, 200)
+        self.assertFormsetError(
+            response,
+            "searchpicks_formset",
+            None,
+            None,
+            "You must recommend a page OR an external link.",
+        )
+
+    def test_post_missing_external_text(self):
+        post_data = {
+            "query_string": "test",
+            "editors_picks-TOTAL_FORMS": 1,
+            "editors_picks-INITIAL_FORMS": 0,
+            "editors_picks-MAX_NUM_FORMS": 1000,
+            "editors_picks-0-DELETE": "",
+            "editors_picks-0-ORDER": 0,
+            "editors_picks-0-external_link_url": "https://wagtail.org",
+        }
+        response = self.client.post(reverse("wagtailsearchpromotions:add"), post_data)
+
+        # User should be given an error
+        self.assertEqual(response.status_code, 200)
+        self.assertFormsetError(
+            response,
+            "searchpicks_formset",
+            None,
+            None,
+            "You must enter an external link text if you enter an external link URL.",
+        )
+
 
 class TestSearchPromotionsEditView(WagtailTestUtils, TestCase):
     def setUp(self):
@@ -288,10 +422,10 @@ def setUp(self):
         # Create an search pick to edit
         self.query = Query.get("Hello")
         self.search_pick = self.query.editors_picks.create(
-            page_id=1, description="Root page"
+            page_id=1, sort_order=0, description="Root page"
         )
         self.search_pick_2 = self.query.editors_picks.create(
-            page_id=2, description="Homepage"
+            page_id=2, sort_order=1, description="Homepage"
         )
 
     def test_simple(self):