Browse Source

[WIP] New classifiers (#161)

* Initial working implementation

* Adding feature callout on empty state

* Adding classifier docs
Vince Salvino 6 years ago
parent
commit
41245bc032

+ 75 - 2
coderedcms/blocks/base_blocks.py

@@ -5,10 +5,12 @@ Bases, mixins, and utilites for blocks.
 from django import forms
 from django.template.loader import render_to_string
 from django.utils.encoding import force_text
+from django.utils.functional import cached_property
 from django.utils.safestring import mark_safe
 from django.utils.translation import ugettext_lazy as _
 from wagtail.core import blocks
 from wagtail.core.models import Collection
+from wagtail.core.utils import resolve_model_string
 from wagtail.documents.blocks import DocumentChooserBlock
 
 from coderedcms.settings import cr_settings
@@ -32,14 +34,85 @@ class MultiSelectBlock(blocks.FieldBlock):
         return [force_text(value)]
 
 
-class CollectionChooserBlock(blocks.ChooserBlock):
+class ClassifierTermChooserBlock(blocks.FieldBlock):
+    """
+    Enables choosing a ClassifierTerm in the streamfield.
+    Lazy loads the target_model from the string to avoid recursive imports.
+    """
+    widget = forms.Select
+
+    def __init__(self, required=False, label=None, help_text=None, *args, **kwargs):
+        self._required=required
+        self._help_text=help_text
+        self._label=label
+        super().__init__(*args, **kwargs)
+
+    @cached_property
+    def target_model(self):
+        return resolve_model_string('coderedcms.ClassifierTerm')
+
+    @cached_property
+    def field(self):
+        return forms.ModelChoiceField(
+            queryset=self.target_model.objects.all().order_by('classifier__name', 'name'),
+            widget=self.widget,
+            required=self._required,
+            label=self._label,
+            help_text=self._help_text,
+        )
+
+    def to_python(self, value):
+        """
+        Convert the serialized value back into a python object.
+        """
+        if isinstance(value, int):
+            return self.target_model.objects.get(pk=value)
+        return value
+
+    def get_prep_value(self, value):
+        """
+        Serialize the model in a form suitable for wagtail's JSON-ish streamfield
+        """
+        if isinstance(value, self.target_model):
+            return value.pk
+        return value
+
+
+class CollectionChooserBlock(blocks.FieldBlock):
     """
     Enables choosing a wagtail Collection in the streamfield.
     """
     target_model = Collection
     widget = forms.Select
 
-    def value_for_form(self, value):
+    def __init__(self, required=False, label=None, help_text=None, *args, **kwargs):
+        self._required=required
+        self._help_text=help_text
+        self._label=label
+        super().__init__(*args, **kwargs)
+
+    @cached_property
+    def field(self):
+        return forms.ModelChoiceField(
+            queryset=self.target_model.objects.all().order_by('name'),
+            widget=self.widget,
+            required=self._required,
+            label=self._label,
+            help_text=self._help_text,
+        )
+
+    def to_python(self, value):
+        """
+        Convert the serialized value back into a python object.
+        """
+        if isinstance(value, int):
+            return self.target_model.objects.get(pk=value)
+        return value
+
+    def get_prep_value(self, value):
+        """
+        Serialize the model in a form suitable for wagtail's JSON-ish streamfield
+        """
         if isinstance(value, self.target_model):
             return value.pk
         return value

+ 1 - 1
coderedcms/blocks/content_blocks.py

@@ -2,7 +2,6 @@
 Content blocks are for building complex, nested HTML structures that usually
 contain sub-blocks, and may require javascript to function properly.
 """
-
 from django.utils.translation import ugettext_lazy as _
 from wagtail.core import blocks
 from wagtail.documents.blocks import DocumentChooserBlock
@@ -67,6 +66,7 @@ class ImageGalleryBlock(BaseBlock):
     full size images in a modal.
     """
     collection = CollectionChooserBlock(
+        required=True,
         label=_('Image Collection'),
     )
 

+ 34 - 17
coderedcms/blocks/html_blocks.py

@@ -6,16 +6,25 @@ HTML blocks should NOT contain more sub-blocks or sub-streamfields.
 They must be safe to nest within more robust "content blocks" without
 creating recursion.
 """
-
+import logging
 from django.utils.translation import ugettext_lazy as _
 from wagtail.contrib.table_block.blocks import TableBlock as WagtailTableBlock
 from wagtail.core import blocks
-from wagtail.core.models import Page
 from wagtail.documents.blocks import DocumentChooserBlock
 from wagtail.embeds.blocks import EmbedBlock
 from wagtail.images.blocks import ImageChooserBlock
 
-from .base_blocks import BaseBlock, BaseLinkBlock, ButtonMixin, CoderedAdvTrackingSettings, LinkStructValue
+from .base_blocks import (
+    BaseBlock,
+    BaseLinkBlock,
+    ButtonMixin,
+    ClassifierTermChooserBlock,
+    CoderedAdvTrackingSettings,
+    LinkStructValue,
+)
+
+
+logger = logging.getLogger('coderedcms')
 
 
 class ButtonBlock(ButtonMixin, BaseLinkBlock):
@@ -187,6 +196,16 @@ class PageListBlock(BaseBlock):
     """
     Renders a preview of selected pages.
     """
+    indexed_by = blocks.PageChooserBlock(
+        required=True,
+        label=_('Parent page'),
+        help_text=_('Show a preview of pages that are children of the selected page. Uses ordering specified in the page’s LAYOUT tab.'),
+    )
+    classified_by = ClassifierTermChooserBlock(
+        required=False,
+        label=_('Classified as'),
+        help_text=_('Only show pages that are classified with this term.')
+    )
     show_preview = blocks.BooleanBlock(
         required=False,
         default=False,
@@ -196,11 +215,6 @@ class PageListBlock(BaseBlock):
         default=3,
         label=_('Number of pages to show'),
     )
-    indexed_by = blocks.PageChooserBlock(
-        required=False,
-        label=_('Limit to'),
-        help_text=_('Only show pages that are children of the selected page. Uses the subpage sorting as specified in the page’s LAYOUT tab.'),
-    )
 
     class Meta:
         template = 'coderedcms/blocks/pagelist_block.html'
@@ -211,16 +225,19 @@ class PageListBlock(BaseBlock):
 
         context = super().get_context(value, parent_context=parent_context)
 
-        if value['indexed_by']:
-            indexer = value['indexed_by'].specific
-            # try to use the CoderedPage `get_index_children()`,
-            # but fall back to get_children if this is a non-CoderedPage
-            try:
-                pages = indexer.get_index_children()
-            except AttributeError:
-                pages = indexer.get_children().live()
+        indexer = value['indexed_by'].specific
+        # try to use the CoderedPage `get_index_children()`,
+        # but fall back to get_children if this is a non-CoderedPage
+        if hasattr(indexer, 'get_index_children'):
+            pages = indexer.get_index_children()
+            if value['classified_by']:
+                try:
+                    pages = pages.filter(classifier_terms=value['classified_by'])
+                except:
+                    # `pages` is not a queryset, or is not a queryset of CoderedPage.
+                    logger.warning("Tried to filter by ClassifierTerm in PageListBlock, but <%s.%s ('%s')>.get_index_children() did not return a queryset or is not a queryset of CoderedPage models.", indexer._meta.app_label, indexer.__class__.__name__, indexer.title)
         else:
-            pages = Page.objects.live().order_by('-first_published_at')
+            pages = indexer.get_children().live()
 
         context['pages'] = pages[:value['num_posts']]
         return context

File diff suppressed because it is too large
+ 54 - 0
coderedcms/migrations/0014_classifiers.py


+ 74 - 19
coderedcms/models/page_models.py

@@ -3,10 +3,10 @@ Base and abstract pages used in CodeRed CMS.
 """
 
 import json
+import logging
 import os
-
 import geocoder
-
+from django import forms
 from django.conf import settings
 from django.core.files.uploadedfile import InMemoryUploadedFile, TemporaryUploadedFile
 from django.core.files.storage import FileSystemStorage
@@ -25,7 +25,7 @@ from django.utils.safestring import mark_safe
 from django.utils.translation import ugettext_lazy as _
 from eventtools.models import BaseEvent, BaseOccurrence
 from icalendar import Event as ICalEvent
-from modelcluster.fields import ParentalKey
+from modelcluster.fields import ParentalKey, ParentalManyToManyField
 from modelcluster.tags import ClusterTaggableManager
 from taggit.models import TaggedItemBase
 from wagtail.admin.edit_handlers import (
@@ -58,15 +58,21 @@ from coderedcms.blocks import (
     StructuredDataActionBlock)
 from coderedcms.fields import ColorField
 from coderedcms.forms import CoderedFormBuilder, CoderedSubmissionsListView
+from coderedcms.models.snippet_models import ClassifierTerm
 from coderedcms.models.wagtailsettings_models import GeneralSettings, LayoutSettings, SeoSettings, GoogleApiSettings
 from coderedcms.settings import cr_settings
+from coderedcms.widgets import ClassifierSelectWidget
 
-CODERED_PAGE_MODELS = []
+
+logger = logging.getLogger('coderedcms')
 
 
+CODERED_PAGE_MODELS = []
+
 def get_page_models():
     return CODERED_PAGE_MODELS
 
+
 class CoderedPageMeta(PageBase):
     def __init__(cls, name, bases, dct):
         super().__init__(name, bases, dct)
@@ -135,7 +141,7 @@ class CoderedPage(WagtailCacheMixin, Page, metaclass=CoderedPageMeta):
 
     # Subclasses can override this to query on a specific
     # page model, rather than the default wagtail Page.
-    index_query_pagemodel = 'wagtailcore.Page'
+    index_query_pagemodel = 'coderedcms.CoderedPage'
 
     # Subclasses can override these fields to enable custom
     # ordering based on specific subpage fields.
@@ -157,18 +163,18 @@ class CoderedPage(WagtailCacheMixin, Page, metaclass=CoderedPageMeta):
         max_length=255,
         choices=index_order_by_choices,
         default=index_order_by_default,
-        verbose_name=_('Order child pages by'),
         blank=True,
+        verbose_name=_('Order child pages by'),
     )
     index_num_per_page = models.PositiveIntegerField(
         default=10,
         verbose_name=_('Number per page'),
     )
-    tags = ClusterTaggableManager(
-        through=CoderedTag,
-        verbose_name='Tags',
+    index_classifiers = ParentalManyToManyField(
+        'coderedcms.Classifier',
         blank=True,
-        help_text=_('Used to categorize your pages.')
+        verbose_name=_('Filter child pages by'),
+        help_text=_('Enable filtering child pages by these classifiers.'),
     )
 
 
@@ -301,6 +307,24 @@ class CoderedPage(WagtailCacheMixin, Page, metaclass=CoderedPageMeta):
     )
 
 
+    ###############
+    # Classify
+    ###############
+
+    classifier_terms = ParentalManyToManyField(
+        'coderedcms.ClassifierTerm',
+        blank=True,
+        verbose_name=_('Classifiers'),
+        help_text=_('Categorize and group pages together with classifiers. Used to organize and filter pages across the site.'),
+    )
+    tags = ClusterTaggableManager(
+        through=CoderedTag,
+        blank=True,
+        verbose_name=_('Tags'),
+        help_text=_('Used to organize pages across the site.'),
+    )
+
+
     ###############
     # Settings
     ###############
@@ -313,6 +337,7 @@ class CoderedPage(WagtailCacheMixin, Page, metaclass=CoderedPageMeta):
         verbose_name=_('Content Walls')
     )
 
+
     ###############
     # Search
     ###############
@@ -335,8 +360,10 @@ class CoderedPage(WagtailCacheMixin, Page, metaclass=CoderedPageMeta):
         index.FilterField('index_show_subpages'),
         index.FilterField('index_order_by'),
         index.FilterField('custom_template'),
+        index.FilterField('classifier_terms'),
     ]
 
+
     ###############
     # Panels
     ###############
@@ -347,7 +374,10 @@ class CoderedPage(WagtailCacheMixin, Page, metaclass=CoderedPageMeta):
 
     body_content_panels = []
 
-    bottom_content_panels = [
+    bottom_content_panels = []
+
+    classify_panels = [
+        FieldPanel('classifier_terms', widget=ClassifierSelectWidget()),
         FieldPanel('tags'),
     ]
 
@@ -363,6 +393,7 @@ class CoderedPage(WagtailCacheMixin, Page, metaclass=CoderedPageMeta):
                 FieldPanel('index_show_subpages'),
                 FieldPanel('index_num_per_page'),
                 FieldPanel('index_order_by'),
+                FieldPanel('index_classifiers', widget=forms.CheckboxSelectMultiple()),
             ],
             heading=_('Show Child Pages')
         )
@@ -439,10 +470,11 @@ class CoderedPage(WagtailCacheMixin, Page, metaclass=CoderedPageMeta):
         Override to "lazy load" the panels overriden by subclasses.
         """
         panels = [
-            ObjectList(cls.content_panels + cls.body_content_panels + cls.bottom_content_panels, heading='Content'),
-            ObjectList(cls.layout_panels, heading='Layout'),
-            ObjectList(cls.promote_panels, heading='SEO', classname="seo"),
-            ObjectList(cls.settings_panels, heading='Settings', classname="settings"),
+            ObjectList(cls.content_panels + cls.body_content_panels + cls.bottom_content_panels, heading=_('Content')),
+            ObjectList(cls.classify_panels, heading=_('Classify')),
+            ObjectList(cls.layout_panels, heading=_('Layout')),
+            ObjectList(cls.promote_panels, heading=_('SEO'), classname="seo"),
+            ObjectList(cls.settings_panels, heading=_('Settings'), classname="settings"),
         ]
 
         if cls.integration_panels:
@@ -488,13 +520,13 @@ class CoderedPage(WagtailCacheMixin, Page, metaclass=CoderedPageMeta):
 
     def get_index_children(self):
         """
-        Override to return query of subpages as defined by `index_` variables.
+        Returns query of subpages as defined by `index_` variables.
         """
         if self.index_query_pagemodel:
             querymodel = resolve_model_string(self.index_query_pagemodel, self._meta.app_label)
             query = querymodel.objects.child_of(self).live()
         else:
-            query = super().get_children().live()
+            query = self.get_children().live()
         if self.index_order_by:
             return query.order_by(self.index_order_by)
         return query
@@ -520,11 +552,34 @@ class CoderedPage(WagtailCacheMixin, Page, metaclass=CoderedPageMeta):
         context = super().get_context(request)
 
         if self.index_show_subpages:
+            # Get child pages
             all_children = self.get_index_children()
+            # Filter by classifier terms if applicable
+            if len(request.GET) > 0 and self.index_classifiers.exists():
+                # Look up comma separated ClassifierTerm slugs i.e. `/?c=term1-slug,term2-slug`
+                terms = []
+                get_c = request.GET.get('c', None)
+                if get_c:
+                    terms = get_c.split(',')
+                # Else look up individual querystrings i.e. `/?classifier-slug=term1-slug`
+                else:
+                    for classifier in self.index_classifiers.all().only('slug'):
+                        get_term = request.GET.get(classifier.slug, None)
+                        if get_term:
+                            terms.append(get_term)
+                if len(terms) > 0:
+                    selected_terms = ClassifierTerm.objects.filter(slug__in=terms)
+                    context['selected_terms'] = selected_terms
+                    if len(selected_terms) > 0:
+                        try:
+                            for term in selected_terms:
+                                all_children = all_children.filter(classifier_terms=term)
+                        except:
+                            logger.warning("Tried to filter by ClassifierTerm, but <%s.%s ('%s')>.get_index_children() did not return a queryset or is not a queryset of CoderedPage models.", self._meta.app_label, self.__class__.__name__, self.title)
             paginator = Paginator(all_children, self.index_num_per_page)
-            page = request.GET.get('p', 1)
+            pagenum = request.GET.get('p', 1)
             try:
-                paged_children = paginator.page(page)
+                paged_children = paginator.page(pagenum)
             except:
                 paged_children = paginator.page(1)
 

+ 86 - 0
coderedcms/models/snippet_models.py

@@ -3,6 +3,7 @@ Snippets are for content that is re-usable in nature.
 """
 
 from django.db import models
+from django.utils.text import slugify
 from django.utils.translation import ugettext_lazy as _
 from modelcluster.fields import ParentalKey
 from modelcluster.models import ClusterableModel
@@ -122,10 +123,95 @@ class CarouselSlide(Orderable, models.Model):
         ]
     )
 
+
+@register_snippet
+class Classifier(ClusterableModel):
+    """
+    Simple and generic model to organize/categorize/group pages.
+    """
+    class Meta:
+        verbose_name = _('Classifier')
+        verbose_name_plural = _('Classifiers')
+        ordering = ['name']
+
+    slug = models.SlugField(
+        allow_unicode=True,
+        unique=True,
+        verbose_name=_('Slug'),
+    )
+    name = models.CharField(
+        max_length=255,
+        verbose_name=_('Name'),
+    )
+
+    panels = [
+        FieldPanel('name'),
+        InlinePanel('terms', label=_('Classifier Terms'))
+    ]
+
+    def save(self, *args, **kwargs):
+        if not self.slug:
+            # Make a slug and suffix a number if it already exists to ensure uniqueness
+            newslug = slugify(self.name, allow_unicode=True)
+            tmpslug = newslug
+            suffix = 1
+            while True:
+                if not Classifier.objects.filter(slug=tmpslug).exists():
+                    self.slug = tmpslug
+                    break
+                tmpslug = newslug + "-" + str(suffix)
+                suffix += 1
+        return super().save(*args, **kwargs)
+
     def __str__(self):
         return self.name
 
 
+class ClassifierTerm(Orderable, models.Model):
+    """
+    Term used to categorize a page.
+    """
+    class Meta:
+        verbose_name = _('Classifier Term')
+        verbose_name_plural = _('Classifier Terms')
+
+    classifier = ParentalKey(
+        Classifier,
+        related_name='terms',
+        verbose_name=_('Classifier'),
+    )
+    slug = models.SlugField(
+        allow_unicode=True,
+        unique=True,
+        verbose_name=_('Slug'),
+    )
+    name = models.CharField(
+        max_length=255,
+        verbose_name=_('Name'),
+    )
+
+    panels = [
+        FieldPanel('name'),
+    ]
+
+    def save(self, *args, **kwargs):
+        if not self.slug:
+            # Make a slug and suffix a number if it already exists to ensure uniqueness
+            newslug = slugify(self.name, allow_unicode=True)
+            tmpslug = newslug
+            suffix = 1
+            while True:
+                if not ClassifierTerm.objects.filter(slug=tmpslug).exists():
+                    self.slug = tmpslug
+                    break
+                tmpslug = newslug + "-" + str(suffix)
+                suffix += 1
+        return super().save(*args, **kwargs)
+
+    def __str__(self):
+        return "{0} > {1}".format(self.classifier.name, self.name)
+
+
 @register_snippet
 class Navbar(models.Model):
     """

File diff suppressed because it is too large
+ 2 - 2
coderedcms/project_template/website/migrations/0001_initial.py


+ 39 - 0
coderedcms/static/css/codered-editor.css

@@ -215,4 +215,43 @@ li.sequence-member li > .field .Draftail-Editor {
 
 .codered-collapsible fieldset {
     padding: 10px 0 0;
+}
+
+.codered-checkbox-group {
+    margin-bottom: 1em;
+}
+.codered-checkbox-group ul {
+    margin-top: 0.5em;
+}
+.codered-checkbox-group label {
+    padding-bottom: 0.25em;
+}
+.codered-checkbox-group label input {
+    margin-right: 0.25em;
+}
+
+.codered-callout {
+    background-color: #f0f1f2;
+    border-radius: 6px;
+    display: inline-block;
+    padding: 2em 3em;
+    text-align: center;
+}
+.codered-callout .codered-big-icon {
+    font-size: 10rem;
+    color: rgba(0,0,0,0.1);
+}
+.codered-callout h3 {
+    font-size: 1.2rem;
+    font-weight: 600;
+}
+.codered-callout p {
+    font-size: 1rem;
+}
+.codered-callout .codered-new {
+    border-radius: 4px;
+    background-color: rgba(0,0,0,0.1);
+    font-size: 0.6em;
+    padding: 0.3em 0.6em;
+    text-transform: uppercase;
 }

+ 1 - 1
coderedcms/templates/coderedcms/blocks/pagelist_block.html

@@ -6,7 +6,7 @@
 <ul>
     {% for page in pages %}
     {% with page=page.specific %}
-    <li><a href="{% pageurl page.url %}">
+    <li><a href="{% pageurl page %}">
         {{page.title}} {% if self.show_preview %}<small class="text-muted">– {{page.body_preview}}</small>{% endif %}
     </a></li>
     {% endwith %}

+ 14 - 0
coderedcms/templates/coderedcms/includes/classifier_dropdowns.html

@@ -0,0 +1,14 @@
+{% load i18n wagtailcore_tags %}
+
+<form method="GET" action="{% pageurl page %}" class="{{formclass}}">
+    {% for classifier in page.index_classifiers.all %}
+    <select class="form-control m-1" name="{{classifier.slug}}">
+        <option value="">{% trans "Filter by" %} {{classifier.name}}</option>
+        {% for term in classifier.terms.all %}
+        <option value="{{term.slug}}" {% if term in selected_terms %}selected{% endif %}>{{term.name}}</option>
+        {% endfor %}
+    </select>
+    {% endfor %}
+    <button type="submit" class="btn btn-primary m-1">{% trans "Filter" %}</button>
+    <a href="{% pageurl page %}" class="btn btn-secondary m-1">{% trans "All" %}</a>
+</form>

+ 18 - 0
coderedcms/templates/coderedcms/includes/classifier_nav.html

@@ -0,0 +1,18 @@
+{% load i18n wagtailcore_tags %}
+
+{% for classifier in page.index_classifiers.all %}
+<h4>{{classifier.name}}</h4>
+<ul class="nav {{navclass}}">
+    <li class="nav-item">
+        <a class="nav-link {% if not selected_terms %}disabled{% endif %}"
+            href="{% pageurl page %}">{% trans "All" %}</a>
+    </li>
+    {% for term in classifier.terms.all %}
+    <li class="nav-item">
+        <a class="nav-link {% if term in selected_terms %}active{% endif %}"
+           href="{% pageurl page %}?c={{term.slug}}">{{term.name}}</a>
+    </li>
+    {% endfor %}
+</ul>
+{% endfor %}
+

+ 38 - 18
coderedcms/templates/coderedcms/pages/article_index_page.html

@@ -2,32 +2,52 @@
 
 {% load wagtailcore_tags wagtailimages_tags coderedcms_tags %}
 
+{% block index_filters %}{% endblock %}
+
 {% block index_content %}
-<div class="container">
+{% if page.index_show_subpages %}
+    <div class="container">
+        {% if page.index_classifiers.exists %}
+        <div class="row">
+            <div class="col-md-9">
+        {% endif %}
 
-    {% for article in index_paginated %}
-    <div class="row">
-        {% if self.show_images %}
-        <div class="col-md">
-            {% if article.cover_image %}
-                {% image article.specific.cover_image fill-1000x500 as cover_image %}
-                <a href="{{article.specific.url}}" title="{{article.title}}"><img src="{{cover_image.url}}" class="w-100" alt="{{article.title}}" /></a>
+        {% for article in index_paginated %}
+        <div class="row">
+            {% if self.show_images %}
+            <div class="col-md">
+                <a href="{% pageurl article %}" title="{{article.title}}">
+                {% if article.cover_image %}
+                    {% image article.specific.cover_image fill-1000x500 as cover_image %}
+                    <img src="{{cover_image.url}}" class="w-100" alt="{{article.title}}" />
+                {% else %}
+                    <p class="p-5 lead text-center bg-secondary text-white-50">{{article.title}}</p>
+                {% endif %}
+                </a>
+            </div>
             {% endif %}
+            <div class="col-md">
+                <h3><a href="{% pageurl article %}">{{article.title}}</a></h3>
+                {% if self.show_captions and article.specific.caption %}<p class="lead">{{article.specific.caption}}</p>{% endif %}
+                {% if self.show_meta %}<p>{{article.specific.get_pub_date}}{% if article.specific.get_author_name %} &bull; {{article.specific.get_author_name}}{% endif %}</p>{% endif %}
+                {% if self.show_preview_text %}<p>{{article.specific.body_preview}}</p>{% endif %}
+            </div>
         </div>
+        {% if not forloop.last %}
+        <hr>
         {% endif %}
-        <div class="col-md">
-            <h3><a href="{{article.specific.url}}">{{article.title}}</a></h3>
-            {% if self.show_captions and article.specific.caption %}<p class="lead">{{article.specific.caption}}</p>{% endif %}
-            {% if self.show_meta %}<p>{{article.specific.get_pub_date}}{% if article.specific.get_author_name %} &bull; {{article.specific.get_author_name}}{% endif %}</p>{% endif %}
-            {% if self.show_preview_text %}<p>{{article.specific.body_preview}}</p>{% endif %}
+        {% endfor %}
+
+        {% if page.index_classifiers.exists %}
+            </div>
+            <div class="col-md-3">
+                {% include "coderedcms/includes/classifier_nav.html" with navclass="nav-pills flex-column" %}
+            </div>
         </div>
-    </div>
-    {% if not forloop.last %}
-    <hr>
-    {% endif %}
-    {% endfor %}
+        {% endif %}
 
     {% include "coderedcms/includes/pagination.html" with items=index_paginated %}
 
 </div>
+{% endif %}
 {% endblock %}

+ 17 - 11
coderedcms/templates/coderedcms/pages/base.html

@@ -1,4 +1,4 @@
-{% load static wagtailuserbar wagtailcore_tags wagtailsettings_tags wagtailimages_tags coderedcms_tags %}
+{% load static coderedcms_tags i18n wagtailcore_tags wagtailimages_tags wagtailsettings_tags wagtailuserbar %}
 {% get_settings %}
 
 <!doctype html>
@@ -124,17 +124,23 @@
 
                 {% block content_post_body %}{% endblock %}
 
-            {% endblock %}
+                {% block index_filters %}
+                {% if page.index_show_subpages and page.index_classifiers.exists %}
+                    {% include "coderedcms/includes/classifier_dropdowns.html" with formclass="form-inline" %}
+                {% endif %}
+                {% 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>
+                    {% include "coderedcms/includes/pagination.html" with items=index_paginated %}
+                {% endif %}
+                {% endblock %}
 
-            {% block index_content %}
-            {% if page.index_show_subpages %}
-                <ul>
-                {% for child in index_paginated %}
-                    <li><a href="{{child.url}}">{{child.title}}</a></li>
-                {% endfor %}
-                </ul>
-                {% include "coderedcms/includes/pagination.html" with items=index_paginated %}
-            {% endif %}
             {% endblock %}
         </div>
 

+ 15 - 7
coderedcms/templates/coderedcms/pages/web_page_notitle.html

@@ -4,14 +4,22 @@
     {% 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="form-inline" %}
+    </div>
+    {% endif %}
+{% endblock %}
+
 {% block index_content %}
-  {% if page.index_show_subpages %}
-  <div class="container">
-    {{block.super}}
-  </div>
-  {% endif %}
+    {% if page.index_show_subpages %}
+    <div class="container">
+        {{block.super}}
+    </div>
+    {% endif %}
 {% endblock %}
 
 {% block footer %}
-  {% include 'coderedcms/snippets/footer.html' %}
-{% endblock %}
+{% include 'coderedcms/snippets/footer.html' %}
+{% endblock %}

+ 39 - 0
coderedcms/templates/coderedcms/widgets/checkbox_classifiers.html

@@ -0,0 +1,39 @@
+{% if widget.optgroups %}
+    {% with id=widget.attrs.id %}
+    <div class="field-row {{ widget.attrs.class }}" {% if id %}id="{{ id }}"{% endif %}>
+    {% for group, options, index in widget.optgroups %}
+        {% if group %}
+        <div class="field-col col4">
+        <ul class="codered-checkbox-group">
+            <li><strong>{{ group }}</strong>
+        {% endif %}
+        <ul{% if id %} id="{{ id }}_{{ index }}"{% endif %}>
+        {% for option in options %}
+        <li>{% include option.template_name with widget=option %}</li>
+        {% endfor %}
+        </ul>
+        {% if group %}
+        </li></ul></div>
+        {% endif %}
+        {% if forloop.counter|divisibleby:3 %}
+        <div class="clearfix"></div>
+        {% endif %}
+    {% endfor %}
+    </div>
+    {% endwith %}
+{% else %}
+    {% load i18n %}
+    <div class="codered-callout">
+        <div class="codered-big-icon icon icon-snippet"></div>
+        <h3>
+            <span class="codered-new">{% trans "New!" %}</span>
+            {% trans "Organize Pages with Classifiers" %}
+        </h3>
+        <p>
+            {% trans "Use Classifiers to create custom categories and filters."%}
+        </p>
+        <div>
+            <a class="button bicolor icon icon-plus" href="{% url 'wagtailsnippets:add' 'coderedcms' 'classifier' %}">{% trans "Add Classifier" %}</a>
+        </div>
+    </div>
+{% endif %}

+ 37 - 0
coderedcms/widgets.py

@@ -1,4 +1,41 @@
 from django import forms
 
+
 class ColorPickerWidget(forms.TextInput):
     input_type = 'color'
+
+
+class ClassifierSelectWidget(forms.CheckboxSelectMultiple):
+    template_name = 'coderedcms/widgets/checkbox_classifiers.html'
+
+    def optgroups(self, name, value, attrs=None):
+        from coderedcms.models.snippet_models import Classifier
+        classifiers = Classifier.objects.all().select_related()
+
+        groups = []
+        has_selected = False
+
+        for index, classifier in enumerate(classifiers):
+            subgroup = []
+            group_name = classifier.name
+            subindex = 0
+            choices = []
+
+            for term in classifier.terms.all():
+                choices.append((term.pk, term.name))
+
+            groups.append((group_name, subgroup, index))
+
+            for subvalue, sublabel in choices:
+                selected = (
+                    str(subvalue) in value and
+                    (not has_selected or self.allow_multiple_selected)
+                )
+                has_selected |= selected
+                subgroup.append(self.create_option(
+                    name, subvalue, sublabel, selected, index,
+                    subindex=subindex, attrs=attrs,
+                ))
+                if subindex is not None:
+                    subindex += 1
+        return groups

+ 100 - 0
docs/features/classifiers.rst

@@ -0,0 +1,100 @@
+Classifiers
+===========
+
+Classifiers provide a way to create custom categories or groups to organize and filter pages.
+
+
+Usage
+-----
+
+Let's say you want to create a custom category: "Blog Category". Blog Category will be used
+to filter article/blog pages on the site.
+
+First, create a new Classifier under **Snippets > Classifiers** called "Blog Category"
+and add several Terms underneath the Classifier, for example: "News", "Opinion", and "Press Releases".
+These terms will function as categories. Create and reorder these Terms as needed.
+Save the Classifier when finished.
+
+Second, classify various Article pages by Blog Category terms:
+
+* Edit an Article page.
+* Open the **Classify** tab, and select the appropriate terms.
+* Publish the page when finished.
+
+To enable filtering Article pages by "Blog Category":
+
+* Edit your Article Landing Page (may be named differently on your project - it should be the
+  parent page of your Article Pages).
+* Open the **Layout** tab, enable **Show child pages**, and then select "Blog Category"
+  under **Filter child pages by** .
+* Publish or preview the page, and you'll now see filtering options for every term under
+  Blog Category.
+
+Going a bit further, let's show a preview of the top 3 newest blog pages classified as "News"
+automatically on the home page:
+
+* Edit the home page.
+* In the **Content** tab anywhere in the **Body** add a Responsive Grid Row, and then add a
+  **Latest Pages** block.
+* Set the **Parent page** to your Article landing page, and **Classified as** to
+  "Blog Category > News".
+* Publish or preview the page, and you'll now see the latest 3 articles classified as "News"
+  on the home page.
+
+Classifiers are not just limited to Article pages, they work an every page on the site.
+Classifiers can be used to create product types, portfolios, categories, and any other
+organizational structures your content may need.
+
+
+Implementation
+--------------
+
+Classifiers are enabled by default on all ``CoderedPage`` models. The filtering HTML UI
+is rendered in the ``{% block index_filters %}`` block on the page template, which originates
+in ``base.html`` but is overridden in various other templates such as ``web_page_notitle.html``
+and ``article_index_page.html``.
+
+CodeRed CMS provides two filtering templates by default, a Bootstrap nav in
+``coderedcms/includes/classifier_nav.html`` and a simple select/dropdown form in
+``coderedcms/includes/classifier_dropdowns.html``. Most likely, you will want to implement your
+own filtering UI based on your own website needs, but you can follow the example in these two
+templates.
+
+Classifiers are not limited to just Pages though - they can be used on Snippets or any other
+model (Snippet example below)::
+
+    from django.db import models
+    from modelcluster.fields import ParentalManyToManyField
+    from wagtail.admin.edit_handlers import FieldPanel
+    from wagtail.snippets.models import register_snippet
+
+    @register_snippet
+    class MySnippet(models.Model):
+        name = models.CharField(
+            max_length=255,
+        )
+        classifier_terms = ParentalManyToManyField(
+            'coderedcms.ClassifierTerm',
+            blank=True,
+        )
+        panels = [
+            FieldPanel('name')
+            FieldPanel('classifier_terms'),
+        ]
+
+
+This will create a default list of checkboxes or a multi-select in the Wagtail UI
+to select classifier terms. However, if you prefer to have the checkboxes grouped
+by the Classifier they belong to (same UI as the **Classify** tab in the page editor),
+use the built-in ``ClassifierSelectWidget``::
+
+        from coderedcms.widgets import ClassifierSelectWidget
+
+        panels = [
+            FieldPanel('name')
+            FieldPanel('classifier_terms', widget=ClassifierSelectWidget()),
+        ]
+
+
+Finally run ``python manage.py makemigrations website`` and ``python manage.py migrate`` to
+create the new models in your project.

+ 1 - 0
docs/features/index.rst

@@ -4,6 +4,7 @@ Features
 .. toctree::
     :maxdepth: 1
 
+    classifiers
     events
     import_export
     mailchimp

Some files were not shown because too many files changed in this diff