Browse Source

Related Pages and Page Miniviews (#568)

#### Description of change
Implements three changes as part of #567:
* Creates logic for "related pages", pages which are of a chosen type
and share classifier terms.
* Creates the ability to generate "miniviews" of pages, which are used
for previewing pages.
* Retrofit existing page preview and latest pages blocks to use new miniview
logic.

---------

Co-authored-by: Vince Salvino <salvino@coderedcorp.com>
Jeremy Childers 1 year ago
parent
commit
a130c53be5

+ 1 - 0
coderedcms/blocks/html_blocks.py

@@ -228,6 +228,7 @@ class PageListBlock(BaseBlock):
         label=_("Classified as"),
         help_text=_("Only show pages that are classified with this term."),
     )
+    # DEPRECATED: Remove in 3.0
     show_preview = blocks.BooleanBlock(
         required=False,
         default=False,

+ 40 - 0
coderedcms/migrations/0037_coderedpage_related_classifier_term_and_more.py

@@ -0,0 +1,40 @@
+# Generated by Django 4.1.8 on 2023-04-13 21:36
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("coderedcms", "0036_filmstrip_filmpanel"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="coderedpage",
+            name="related_classifier_term",
+            field=models.ForeignKey(
+                blank=True,
+                help_text="When getting related pages, pages with this term will be weighted over other classifier terms. By default, pages with the greatest number of classifiers in common are ranked highest.",
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name="+",
+                to="coderedcms.classifierterm",
+                verbose_name="Preferred related classifier term",
+            ),
+        ),
+        migrations.AddField(
+            model_name="coderedpage",
+            name="related_num",
+            field=models.PositiveIntegerField(
+                default=3, verbose_name="Number of related pages to show"
+            ),
+        ),
+        migrations.AddField(
+            model_name="coderedpage",
+            name="related_show",
+            field=models.BooleanField(
+                default=False, verbose_name="Show list of related pages"
+            ),
+        ),
+    ]

+ 111 - 0
coderedcms/models/page_models.py

@@ -146,6 +146,12 @@ class CoderedPage(WagtailCacheMixin, SeoMixin, Page, metaclass=CoderedPageMeta):
     # ajax_template = ''
     # search_template = ''
 
+    # Template used in site search results.
+    search_template = "coderedcms/pages/search_result.html"
+
+    # Template used for related pages, Latest Pages block, and Page Preview block.
+    miniview_template = "coderedcms/pages/page.mini.html"
+
     ###############
     # Content fields
     ###############
@@ -217,6 +223,41 @@ class CoderedPage(WagtailCacheMixin, SeoMixin, Page, metaclass=CoderedPageMeta):
         help_text=_("Enable filtering child pages by these classifiers."),
     )
 
+    #####################
+    # Related Page Fields
+    #####################
+
+    # Subclasses can override this to query on a specific
+    # page model. By default sibling pages are used.
+    related_query_pagemodel = None
+
+    # Subclasses can override this to enabled related pages by default.
+    related_show_default = False
+
+    related_show = models.BooleanField(
+        default=related_show_default,
+        verbose_name=_("Show list of related pages"),
+    )
+
+    related_num = models.PositiveIntegerField(
+        default=3,
+        verbose_name=_("Number of related pages to show"),
+    )
+
+    related_classifier_term = models.ForeignKey(
+        "coderedcms.ClassifierTerm",
+        blank=True,
+        null=True,
+        on_delete=models.SET_NULL,
+        related_name="+",
+        verbose_name=_("Preferred related classifier term"),
+        help_text=_(
+            "When getting related pages, pages with this term will be "
+            "weighted over other classifier terms. By default, pages with "
+            "the greatest number of classifiers in common are ranked highest."
+        ),
+    )
+
     ###############
     # Layout fields
     ###############
@@ -311,6 +352,14 @@ class CoderedPage(WagtailCacheMixin, SeoMixin, Page, metaclass=CoderedPageMeta):
             ],
             heading=_("Show Child Pages"),
         ),
+        MultiFieldPanel(
+            [
+                FieldPanel("related_show"),
+                FieldPanel("related_num"),
+                FieldPanel("related_classifier_term"),
+            ],
+            heading=_("Related Pages"),
+        ),
     ]
 
     promote_panels = SeoMixin.seo_meta_panels + SeoMixin.seo_struct_panels
@@ -339,6 +388,7 @@ class CoderedPage(WagtailCacheMixin, SeoMixin, Page, metaclass=CoderedPageMeta):
         if not self.id:
             self.index_order_by = self.index_order_by_default
             self.index_show_subpages = self.index_show_subpages_default
+            self.related_show = self.related_show_default
 
     @cached_classmethod
     def get_edit_handler(cls):
@@ -455,6 +505,55 @@ class CoderedPage(WagtailCacheMixin, SeoMixin, Page, metaclass=CoderedPageMeta):
 
         return query
 
+    def get_related_pages(self) -> models.QuerySet:
+        """
+        Returns a queryset of sibling pages, or the model type
+        defined by `self.related_query_pagemodel`. Ordered by number
+        of shared classifier terms.
+        """
+
+        # Get our related query model, and queryset.
+        if self.related_query_pagemodel:
+            if isinstance(
+                self.related_query_pagemodel, Union[str, models.Model]
+            ):
+                querymodel = resolve_model_string(
+                    self.related_query_pagemodel, self._meta.app_label
+                )
+                r_qs = querymodel.objects.all().live()
+            else:
+                raise AttributeError(
+                    f"The related_querymodel should be a model or str."
+                    f" The related_querymodel of {self} is {type(self.related_querymodel)}"
+                )
+        else:
+            r_qs = self.get_parent().specific.get_index_children()
+
+        # Exclude self to avoid infinite recursion.
+        r_qs = r_qs.exclude(pk=self.pk)
+
+        order_by = []
+
+        # If we have a preferred classifier term, order by that.
+        if self.related_classifier_term:
+            p_ct_q = models.Q(classifier_terms=self.related_classifier_term)
+            r_qs = r_qs.annotate(p_ct=p_ct_q)
+            order_by.append("-p_ct")
+
+        # If this page has a classifier, then order by number of
+        # shared classifier terms.
+        if self.classifier_terms.exists():
+            r_ct_q = models.Q(classifier_terms__in=self.classifier_terms.all())
+            r_qs = r_qs.annotate(r_ct=models.Count("classifier_terms", r_ct_q))
+            order_by.append("-r_ct")
+
+        # Order the related pages, then add distinct to deal with
+        # annotating based on a many to many.
+        if order_by:
+            r_qs = r_qs.order_by(*order_by).distinct()
+
+        return r_qs[: self.related_num]
+
     def get_content_walls(self, check_child_setting=True):
         current_content_walls = []
         if check_child_setting:
@@ -478,6 +577,7 @@ class CoderedPage(WagtailCacheMixin, SeoMixin, Page, metaclass=CoderedPageMeta):
         """
         context = super().get_context(request)
 
+        # Show list of child pages.
         if self.index_show_subpages:
             # Get child pages
             all_children = self.get_index_children()
@@ -528,9 +628,16 @@ class CoderedPage(WagtailCacheMixin, SeoMixin, Page, metaclass=CoderedPageMeta):
 
             context["index_paginated"] = paged_children
             context["index_children"] = all_children
+
+        # Show a list of related pages.
+        if self.related_show:
+            context["related_pages"] = self.get_related_pages()
+
+        # Content walls.
         context["content_walls"] = self.get_content_walls(
             check_child_setting=False
         )
+
         return context
 
 
@@ -593,6 +700,9 @@ class CoderedArticlePage(CoderedWebPage):
         abstract = True
 
     template = "coderedcms/pages/article_page.html"
+    search_template = "coderedcms/pages/article_page.search.html"
+
+    related_show_default = True
 
     # Override body to provide simpler content
     body = StreamField(
@@ -1593,6 +1703,7 @@ class CoderedFormPage(CoderedFormMixin, CoderedWebPage):
         abstract = True
 
     template = "coderedcms/pages/form_page.html"
+    miniview_template = "coderedcms/pages/form_page.mini.html"
     landing_page_template = "coderedcms/pages/form_page_landing.html"
 
     base_form_class = WagtailAdminFormPageForm

+ 2 - 0
coderedcms/settings.py

@@ -183,6 +183,7 @@ class _DefaultSettings:
                 "Card masonry - fluid brick pattern",
             ),
         ],
+        # DEPRECATED: Remove in 3.0.
         "pagelistblock": [
             (
                 "coderedcms/blocks/pagelist_block.html",
@@ -209,6 +210,7 @@ class _DefaultSettings:
                 "Article, card masonry - fluid brick pattern",
             ),
         ],
+        # DEPRECATED: Remove in 3.0
         "pagepreviewblock": [
             (
                 "coderedcms/blocks/pagepreview_card.html",

+ 7 - 9
coderedcms/templates/coderedcms/blocks/pagelist_block.html

@@ -1,15 +1,13 @@
 {% extends "coderedcms/blocks/base_block.html" %}
 {% load wagtailcore_tags %}
 {% block block_render %}
-<ul>
+<div class="row">
   {% for page in pages %}
-  {% with page=page.specific %}
-  <li>
-    <a href="{% pageurl page %}">
-      {{page.title}} {% if self.show_preview %}<small class="text-muted">– {{page.body_preview}}</small>{% endif %}
-    </a>
-  </li>
-  {% endwith %}
+  <div class="col-sm-6 col-lg-4">
+    {% with page=page.specific %}
+    {% include page.miniview_template %}
+    {% endwith %}
+  </div>
   {% endfor %}
-</ul>
+</div>
 {% endblock %}

+ 1 - 3
coderedcms/templates/coderedcms/blocks/pagepreview_block.html

@@ -2,8 +2,6 @@
 {% load wagtailcore_tags %}
 {% block block_render %}
 {% with page=self.page.specific %}
-<a href="{% pageurl page %}">
-  {{page.title}} <small class="text-muted">– {{page.body_preview}}</small>
-</a>
+{% include page.miniview_template %}
 {% endwith %}
 {% endblock %}

+ 4 - 0
coderedcms/templates/coderedcms/pages/article_page.html

@@ -37,5 +37,9 @@
     {% endfor %}
   </div>
   {% endblock %}
+
+  {% block related_content %}
+  {{ block.super }}
+  {% endblock %}
 </article>
 {% endblock %}

+ 29 - 8
coderedcms/templates/coderedcms/pages/base.html

@@ -126,22 +126,43 @@
     {% block content_post_body %}{% endblock %}
 
     {% block index_filters %}
-    {% if page.index_show_subpages and page.index_classifiers.exists %}
-    {% include "coderedcms/includes/classifier_dropdowns.html" with formclass="d-flex" formid="filter" %}
-    {% endif %}
+    <div class="container">
+      {% if page.index_show_subpages and page.index_classifiers.exists %}
+      {% include "coderedcms/includes/classifier_dropdowns.html" with formclass="d-flex" formid="filter" %}
+      {% endif %}
+    </div>
     {% endblock %}
 
     {% block index_content %}
     {% if page.index_show_subpages %}
-    <ul>
-      {% for child in index_paginated %}
-      <li><a href="{% pageurl child %}">{{child.title}}</a></li>
-      {% endfor %}
-    </ul>
+    <div class="container">
+      <div class="row">
+        {% for child in index_paginated %}
+        <div class="col-sm-6 col-lg-4">
+          {% include child.miniview_template with page=child %}
+        </div>
+        {% endfor %}
+      </div>
+    </div>
     {% include "coderedcms/includes/pagination.html" with items=index_paginated %}
     {% endif %}
     {% endblock %}
 
+    {% block related_content %}
+    {% if page.related_show %}
+    <div class="container">
+      <h2 class="text-center my-5">{% trans "Related" %}</h2>
+      <div class="row">
+        {% for rp in related_pages %}
+        <div class="col-sm-6 col-lg-4">
+          {% include rp.miniview_template with page=rp %}
+        </div>
+        {% endfor %}
+      </div>
+    </div>
+    {% endif %}
+    {% endblock %}
+
     {% endblock %}
   </div>
 

+ 24 - 0
coderedcms/templates/coderedcms/pages/form_page.mini.html

@@ -0,0 +1,24 @@
+{% load django_bootstrap5 coderedcms_tags wagtailcore_tags %}
+{% with page=self.page.specific %}
+{% if page.form_live %}
+{% get_pageform page request as form %}
+<form class="{{ page.form_css_class }}" id="{{ page.form_id }}" action="{% pageurl page %}" method="POST"
+  {% if form|is_file_form %}enctype="multipart/form-data" {% endif %}>
+  {% csrf_token %}
+  {% bootstrap_form form layout="horizontal" %}
+  {% block captcha %}
+  {% if page.spam_protection %}
+  {% include "coderedcms/includes/form_honeypot.html" %}
+  {% endif %}
+  {% endblock %}
+  <div class="mt-5 row">
+    <div class="{{'horizontal_label_class'|bootstrap_settings}}"></div>
+    <div class="{{'horizontal_field_class'|bootstrap_settings}}">
+      <button type="submit" class="btn {{page.button_size}} {{page.button_style}} {{page.button_css_class}}">
+        {{ page.button_text }}
+      </button>
+    </div>
+  </div>
+</form>
+{% endif %}
+{% endwith %}

+ 12 - 0
coderedcms/templates/coderedcms/pages/page.mini.html

@@ -0,0 +1,12 @@
+{% load wagtailcore_tags wagtailimages_tags %}
+<div class="card mb-3">
+  {% if page.cover_image %}
+  {% image page.cover_image fill-800x450 format-webp as card_img %}
+  <img class="card-img-top w-100" src="{{card_img.url}}" alt="{{card_img.title}}">
+  {% endif %}
+  <div class="card-body">
+    <h5 class="card-title">{{page.title}}</h5>
+    <p class="card-text">{{page.body_preview}}</p>
+    <a class="card-link" href="{% pageurl page %}" title="{{page.title}}">Read more</a>
+  </div>
+</div>

+ 0 - 4
coderedcms/templates/coderedcms/pages/search.html

@@ -63,11 +63,7 @@
   {% for page in results_paginated %}
   <div class="mb-5">
     {% with page=page.specific %}
-    {% if page.search_template %}
     {% include page.search_template %}
-    {% else %}
-    {% include 'coderedcms/pages/search_result.html' %}
-    {% endif %}
     {% endwith %}
   </div>
   {% endfor %}

+ 0 - 16
coderedcms/templates/coderedcms/pages/web_page_notitle.html

@@ -4,22 +4,6 @@
 {% include "coderedcms/snippets/navbar.html" %}
 {% endblock %}
 
-{% block index_filters %}
-{% if page.index_show_subpages and page.index_classifiers.exists %}
-<div class="container">
-  {% include "coderedcms/includes/classifier_dropdowns.html" with formclass="d-flex" formid="filter" %}
-</div>
-{% endif %}
-{% endblock %}
-
-{% block index_content %}
-{% if page.index_show_subpages %}
-<div class="container">
-  {{ block.super }}
-</div>
-{% endif %}
-{% endblock %}
-
 {% block footer %}
 {% include "coderedcms/snippets/footer.html" %}
 {% endblock %}

+ 13 - 11
docs/features/blocks/contentblocks/latestpages.rst

@@ -1,7 +1,7 @@
 Latest Pages Block
 ==================
 
-Creates a list of the most recently published pages with a specified length. 
+Creates a list of the most recently published pages with a specified length.
 
 Field Reference
 ---------------
@@ -12,21 +12,23 @@ Fields and purposes:
 
 * **Classified By** - Filters which pages are displayed by the classifier that you selected
 
-* **Show Body Preview** - If selected, shows a preview of what the page contains 
-
 * **Number of Pages to Show** - Limits how many pages are displayed to the number that you selected
 
-.. note::
-    There are also a few built-in templates available for this block under the **Advanced Settings** section.
-
-The pages are displayed as links with a line or so of text if the preview option is selected.
+Each page is rendered using the page model's "miniview" template.
+The template can be overridden per model with the ``miniview_template`` attribute, the default of which is `coderedcms/pages/page.mini.html <https://github.com/coderedcorp/coderedcms/blob/dev/coderedcms/templates/coderedcms/pages/pages.mini.html>`_.
 
 .. figure:: img/latestpages1.png
-    :alt: The Latest Pages block and its settings
+    :alt: The Latest Pages block and its settings.
 
-    The Latest Pages block and its settings
+    The Latest Pages block and its settings.
 
 .. figure:: img/latestpages2.png
-    :alt: The Latest Pages block as displayed on the website
+    :alt: The Latest Pages block as displayed on the website.
+
+    The Latest Pages block as displayed on the website.
+
+.. deprecated:: 2.1
+
+   * "Show Body Preview" field was deprecated in 2.1 and will be removed in 3.0.
 
-    The Latest Pages block as displayed on the website
+   * The additional built-in templates under this block's **Advanced Settings** are deprecated as of 2.1 and will be removed in 3.0. These have been replaced with identical miniview templates for Article and Form pages.

+ 5 - 5
docs/features/blocks/contentblocks/pagepreview.rst

@@ -1,7 +1,7 @@
 Page Preview Block
 ==================
 
-Shows a preview of a selected Page
+Shows a miniview (a condensed version) of a selected Page.
 
 Field Reference
 ---------------
@@ -11,9 +11,9 @@ There is only one field.
 **Page to Preview** - Select the page that you want to display a preview
 
 .. figure:: img/pagepreview_edit.png
-    :alt: A Page Preview block and its settings
+    :alt: A Page Preview block and its settings.
 
-    A Page Preview block and its settings
+    A Page Preview block and its settings.
 
-It looks very similar in design to the Latest Pages block but only displays the one selected page.
-It shows a link to the page and a few lines of preview text.
+The selected page is rendered using the page model's "miniview" template.
+The template can be overridden per model with the ``miniview_template`` attribute, the default of which is `coderedcms/pages/page.mini.html <https://github.com/coderedcorp/coderedcms/blob/dev/coderedcms/templates/coderedcms/pages/pages.mini.html>`_.

BIN
docs/features/img/related_pages.png


+ 1 - 0
docs/features/index.rst

@@ -8,5 +8,6 @@ Features
     import_export
     mailchimp
     page_types/index
+    related_pages
     searching
     snippets/index

+ 63 - 0
docs/features/related_pages.rst

@@ -0,0 +1,63 @@
+Related Pages
+=============
+
+.. versionadded:: 2.1
+
+    Added related pages system. You must be on Wagtail CRX version 2.1 or higher in order to follow this guide.
+
+Using the power of :doc:`/features/snippets/classifiers`, pages can automatically show a list of similarly classified pages. By default, this is enabled on :doc:`/features/page_types/article_pages`, but can be enabled on any page via the Wagtail Admin, or a 1-line code change on the page model.
+
+.. figure:: img/related_pages.png
+   :alt:  Related pages showing similarly classified articles.
+
+   Related pages showing similarly classified articles.
+
+
+Related page formatting
+------------------------
+
+Each related page is rendered using the page model's "miniview" template.
+The template can be overridden per model with the ``miniview_template`` attribute, the default of which is `coderedcms/pages/page.mini.html <https://github.com/coderedcorp/coderedcms/blob/dev/coderedcms/templates/coderedcms/pages/pages.mini.html>`_.
+
+If related pages are enabled, a ``QuerySet`` of pages is added to the context as ``related_pages``. This ``QuerySet`` can also be retrieved by calling ``page.get_related_pages()``.
+
+
+Related page customization
+--------------------------
+
+Each page can have related pages turned on or off via a toggle in the page editor, under the **Layout** tab. Pages based on ``CoderedArticlePage`` have this setting enabled by default when new pages are created. To toggle the default value when creating new pages, set ``related_show_default`` to ``True`` or ``False``. To retroactively toggle this setting on existing pages, set the related ``related_show`` field using a manual query or migration.
+
+By default, sibling pages are queried and ordered based on the number of Classifier Terms in common. If you wish to query a different model --- for example to have Article pages show related Product pages instead --- set the ``related_query_pagemodel`` attribute to the desired content type.
+
+.. code-block:: python
+
+   class ProductPage(CoderedPage):
+
+       # Custom template that will be used when a Product
+       # is shown on an Article page (below).
+       miniview_template = "website/pages/product.mini.html"
+
+   class ArticlePage(CoderedPage):
+
+       # Show related pages by default when creating new Articles.
+       related_show_default = True
+
+       # By default, related sibling Articles will be shown.
+       # Show related Products instead.
+       related_query_pagemodel = "website.ProductPage"
+
+If you'd instead prefer to totally customize the related algorithm, override the ``get_related_pages()`` function. Just be sure to return a ``QuerySet`` of pages.
+
+.. code-block:: python
+
+   class ProductPage(CoderedPage):
+       price = models.DecimalField(max_digits=9, decimal_places=2)
+
+   class ArticlePage(CoderedPage):
+
+       # Show related pages by default when creating new Articles.
+       related_show_default = True
+
+       def get_related_pages(self) -> models.QuerySet:
+           """Show most expensive products first."""
+           return ProductPage.objects.live().order_by("-price")