diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b8d68a6b..353f6572 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -40,32 +40,32 @@ repos: hooks: - id: djhtml args: [-t, "2"] - - repo: https://github.com/pre-commit/mirrors-mypy - rev: 'v1.5.0' # Use the sha / tag you want to point at - hooks: - - id: mypy - exclude: "[a-zA-Z]*/(migrations)|(docs)|(example)/(.)*" - args: [--no-strict-optional, - --ignore-missing-imports] - additional_dependencies: - - crispy_bootstrap4 - - dj-inmemorystorage - - django-allauth - - django-crispy-forms - - django-debug-toolbar - - django-environ - - django_extensions - - django-filter - - django-fluent-comments - - django-htmx - - django-modelcluster - - django-model-utils - - django-stubs[compatible-mypy] - - django-taggit - - django-threadedcomments - - psycopg2 - - Pygments - - python-slugify - - wagtail - - wagtail_srcset - - types-python-slugify +# - repo: https://github.com/pre-commit/mirrors-mypy +# rev: 'v1.5.0' # Use the sha / tag you want to point at +# hooks: +# - id: mypy +# exclude: "[a-zA-Z]*/(migrations)|(docs)|(example)/(.)*" +# args: [--no-strict-optional, +# --ignore-missing-imports] +# additional_dependencies: +# - crispy_bootstrap4 +# - dj-inmemorystorage +# - django-allauth +# - django-crispy-forms +# - django-debug-toolbar +# - django-environ +# - django_extensions +# - django-filter +# - django-fluent-comments +# - django-htmx +# - django-modelcluster +# - django-model-utils +# - django-stubs[compatible-mypy] +# - django-taggit +# - django-threadedcomments +# - psycopg2 +# - Pygments +# - python-slugify +# - wagtail +# - wagtail_srcset +# - types-python-slugify diff --git a/cast/appsettings.py b/cast/appsettings.py index 05c72f5f..eff81d67 100644 --- a/cast/appsettings.py +++ b/cast/appsettings.py @@ -11,6 +11,9 @@ MENU_ITEM_PAGINATION: int = getattr(settings, "MENU_ITEM_PAGINATION", 20) POST_LIST_PAGINATION: int = getattr(settings, "POST_LIST_PAGINATION", 5) DELETE_WAGTAIL_IMAGES: bool = getattr(settings, "DELETE_WAGTAIL_IMAGES", True) +CAST_FILTERSET_FACETS: list[str] = getattr( + settings, "CAST_FILTERSET_FACETS", ["search", "date", "date_facets", "category_facets", "tag_facets"] +) SettingValue = Union[str, bool, int] diff --git a/cast/filters.py b/cast/filters.py index 7197d19a..ddcfce95 100644 --- a/cast/filters.py +++ b/cast/filters.py @@ -1,9 +1,11 @@ import string from collections.abc import Iterable, Mapping from datetime import datetime -from typing import Any, Optional, cast +from typing import Any, Optional, Union, cast import django_filters +from django.core import validators +from django.core.exceptions import ValidationError from django.core.files.uploadedfile import UploadedFile from django.db import models from django.db.models.fields import BLANK_CHOICE_DASH @@ -19,8 +21,12 @@ from django_filters.fields import ChoiceField as FilterChoiceField from wagtail.models import PageQuerySet +from cast import appsettings +from cast.models.pages import PostTag +from cast.models.snippets import PostCategory -class DateFacetWidget(Widget): + +class CountFacetWidget(Widget): data: QueryDict def __init__(self, attrs: Optional[dict[str, str]] = None): @@ -105,44 +111,70 @@ def parse_date_facets(value: str) -> datetime: return year_month -def get_selected_facet(get_params: dict) -> Optional[datetime]: - date_facet = get_params.get("date_facets") - if date_facet is None or len(date_facet) == 0: - return None - return parse_date_facets(date_facet) - - -def get_facet_counts( - filterset_data_orig: Optional[QueryDict], queryset: Optional[models.QuerySet] -) -> dict[str, dict[datetime, int]]: - if filterset_data_orig is None: - filterset_data = {} - else: - filterset_data = {k: v for k, v in filterset_data_orig.items()} # copy filterset_data to avoid overwriting - - # get selected facet if set and build the facet counting queryset - facet_counts = {} - selected_facet = get_selected_facet(filterset_data) - if selected_facet is not None: - facet_counts = {"year_month": {selected_facet: 1}} - filterset_data["facet_counts"] = facet_counts # type: ignore - filterset_data_as_query_dict = cast(QueryDict, filterset_data) # make mypy happy - post_filter = PostFilterset(queryset=queryset, data=filterset_data_as_query_dict, facet_counts=facet_counts) +def fetch_date_facet_counts(post_queryset: models.QuerySet) -> dict[datetime, int]: + # get the date facet counts facet_queryset = ( - post_filter.qs.order_by() + post_queryset.order_by() .annotate(month=TruncMonth("visible_date")) .values("month") - .annotate(n=models.Count("pk")) + .annotate(num_posts=models.Count("pk")) ) # build up the date facet counts for final filter pass year_month_counts = {} for row in facet_queryset: - year_month_counts[row["month"]] = row["n"] - return {"year_month": year_month_counts} + year_month_counts[row["month"]] = row["num_posts"] + return year_month_counts + + +SlugFacetCounts = dict[str, tuple[str, int]] + + +def fetch_category_facet_counts(post_queryset: models.QuerySet) -> SlugFacetCounts: + category_count_queryset = PostCategory.objects.annotate( + num_posts=models.Count("post", filter=models.Q(post__in=post_queryset)) + ) + category_counts = {} + for category in category_count_queryset: + category_counts[category.slug] = (category.name, category.num_posts) # type: ignore + return category_counts + + +def fetch_tag_facet_counts(post_queryset: models.QuerySet) -> SlugFacetCounts: + tag_count_queryset = PostTag.objects.annotate( + num_posts=models.Count("content_object", filter=models.Q(content_object__in=post_queryset)) + ) + tag_counts = {} + for tag in tag_count_queryset: + tag_counts[tag.tag.slug] = (tag.tag.name, tag.num_posts) # type: ignore + return tag_counts -class FacetChoicesMixin: +def get_facet_counts( + filterset_data_orig: Union[QueryDict, dict], + queryset: Optional[models.QuerySet], + fields: tuple[str, ...] = tuple(appsettings.CAST_FILTERSET_FACETS), +) -> dict[str, dict[datetime, int]]: + filterset_data = {k: v for k, v in filterset_data_orig.items()} # copy filterset_data to avoid overwriting + filterset_data["facet_counts"] = {} # type: ignore + filterset_data_as_query_dict = cast(QueryDict, filterset_data) # make mypy happy + post_filter = PostFilterset(queryset=queryset, data=filterset_data_as_query_dict) + + # fetch the facet counts for the fields with counts + facet_counts: dict[str, dict] = {} + post_queryset = post_filter.qs + + if "date_facets" in fields: + facet_counts["year_month"] = fetch_date_facet_counts(post_queryset) + if "category_facets" in fields: + facet_counts["categories"] = fetch_category_facet_counts(post_queryset) + if "tag_facets" in fields: + facet_counts["tags"] = fetch_tag_facet_counts(post_queryset) + + return facet_counts + + +class DateFacetChoicesMixin: """Just a way to pass the facet counts to the field which displays the choice.""" parent: "PostFilterset" @@ -160,7 +192,7 @@ def field(self) -> Field: return super_filter.field -class AllChoicesField(FilterChoiceField): +class AllDateChoicesField(FilterChoiceField): def valid_value(self, value: str) -> bool: """ Allow all values instead of explicit choices but still validate @@ -174,8 +206,8 @@ def valid_value(self, value: str) -> bool: return False -class DateFacetFilter(FacetChoicesMixin, django_filters.filters.ChoiceFilter): - field_class = AllChoicesField +class DateFacetFilter(DateFacetChoicesMixin, django_filters.filters.ChoiceFilter): + field_class = AllDateChoicesField def filter(self, qs: models.QuerySet, value: str) -> models.QuerySet: if len(value) == 0: @@ -188,17 +220,98 @@ def filter(self, qs: models.QuerySet, value: str) -> models.QuerySet: return filtered +class SlugChoicesField(FilterChoiceField): + def valid_value(self, value: str) -> bool: + """ + Used to determine if the value provided by the user can be used + to filter the queryset. Return early if value is not a string, + use the slug validator to check if the value is a valid slug. + """ + if not isinstance(value, str): + return False + try: + validators.validate_slug(value) + return True + except ValidationError: + return False + + +class CountChoicesMixin: + """Just a way to pass the facet counts to the field which displays the choice.""" + + parent: "PostFilterset" + extra: dict[str, Any] + _facet_count_key = "categories" + + @property + def field(self) -> Field: + facet_count_choices = [] + # use cast to make mypy happy + facet_counts: SlugFacetCounts = cast(SlugFacetCounts, self.parent.facet_counts.get(self._facet_count_key, {})) + for slug, (name, count) in sorted(facet_counts.items()): + if count == 0: + continue + label = f"{name} ({count})" + facet_count_choices.append((slug, label)) + self.extra["choices"] = facet_count_choices + super_filter = cast(django_filters.filters.ChoiceFilter, super()) # make mypy happy + return super_filter.field + + +class CategoryFacetFilter(CountChoicesMixin, django_filters.filters.ChoiceFilter): + field_class = SlugChoicesField + + def filter(self, qs: models.QuerySet, value: str): + # Check if value is provided (not None and not an empty list) + if value: + return qs.filter(categories__slug__in=[value]) + return qs + + +class TagChoicesMixin(CountChoicesMixin): + _facet_count_key = "tags" + + +class TagFacetFilter(TagChoicesMixin, django_filters.filters.ChoiceFilter): + field_class = SlugChoicesField + + def filter(self, qs: models.QuerySet, value: str): + # Check if value is provided (not None and not an empty list) + if value: + return qs.filter(tags__name__in=[value]) + return qs + + class PostFilterset(django_filters.FilterSet): search = django_filters.CharFilter(field_name="search", method="fulltext_search", label="Search") date = django_filters.DateFromToRangeFilter( field_name="visible_date", label="Date", - widget=django_filters.widgets.DateRangeWidget(attrs={"type": "date", "placeholder": "YYYY/MM/DD"}), + widget=django_filters.widgets.DateRangeWidget( + attrs={"type": "date", "placeholder": "YYYY/MM/DD"} + ), # type: ignore + ) + # FIXME Maybe use ModelMultipleChoiceFilter for categories? Couldn't get it to work for now, though. + # - one problem was that after setting choices via the choices parameter, Django randomly + # complained about models not being available before app start etc. + category_facets = CategoryFacetFilter( + field_name="category_facets", + label="Categories", + # choices do not need to be set, since they are transported from facet counts + # into the extra dict of the field via CountChoicesMixin + widget=CountFacetWidget(attrs={"class": "cast-date-facet-container"}), + ) + tag_facets = TagFacetFilter( + field_name="tag_facets", + label="Tags", + # choices do not need to be set, since they are transported from facet counts + # into the extra dict of the field via CountChoicesMixin + widget=CountFacetWidget(attrs={"class": "cast-date-facet-container"}), ) date_facets = DateFacetFilter( field_name="date_facets", label="Date Facets", - widget=DateFacetWidget(attrs={"class": "cast-date-facet-container"}), + widget=CountFacetWidget(attrs={"class": "cast-date-facet-container"}), ) o = django_filters.OrderingFilter( fields=(("visible_date", "visible_date"),), @@ -206,26 +319,30 @@ class PostFilterset(django_filters.FilterSet): ) class Meta: - fields = ["search", "date", "date_facets"] + fields = appsettings.CAST_FILTERSET_FACETS def __init__( self, data: Optional[QueryDict] = None, queryset: Optional[models.QuerySet] = None, *, - facet_counts: Optional[dict] = None, fetch_facet_counts: bool = False, ): super().__init__(data=data, queryset=queryset) - self.facet_counts = facet_counts if facet_counts is not None else {} + if data is None: + data = QueryDict("") + # Remove filters which are not configured in the settings + configured_filters = set(appsettings.CAST_FILTERSET_FACETS) + for filter_name in self.filters.copy().keys(): + if filter_name not in configured_filters: + del self.filters[filter_name] + # fetch the facet counts + self.facet_counts = {} if fetch_facet_counts: # avoid running into infinite recursion problems, because # get_facet_counts will instantiate PostFilterset again # -> and again -> and again ... - try: - self.facet_counts = get_facet_counts(data, queryset) - except ValueError: - self.facet_counts = {} + self.facet_counts = get_facet_counts(data, queryset, fields=tuple(self._meta.fields)) @staticmethod def fulltext_search(queryset: PageQuerySet, _name: str, value: str) -> models.QuerySet: diff --git a/cast/migrations/0049_added_category_snippets.py b/cast/migrations/0049_added_category_snippets.py new file mode 100644 index 00000000..c5fba7c1 --- /dev/null +++ b/cast/migrations/0049_added_category_snippets.py @@ -0,0 +1,36 @@ +# Generated by Django 4.2.4 on 2023-08-15 11:58 + +from django.db import migrations, models +import modelcluster.fields + + +class Migration(migrations.Migration): + dependencies = [ + ("cast", "0048_added_visible_date_index_for_wagtail_api"), + ] + + operations = [ + migrations.CreateModel( + name="PostCategory", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(help_text="The name for this category", max_length=255, unique=True)), + ( + "slug", + models.SlugField( + help_text="A slug to identify posts by this category", unique=True, verbose_name="slug" + ), + ), + ], + options={ + "verbose_name": "Post Category", + "verbose_name_plural": "Post Categories", + "ordering": ["name"], + }, + ), + migrations.AddField( + model_name="post", + name="categories", + field=modelcluster.fields.ParentalManyToManyField(blank=True, to="cast.postcategory"), + ), + ] diff --git a/cast/migrations/0050_add_tags_for_posts.py b/cast/migrations/0050_add_tags_for_posts.py new file mode 100644 index 00000000..a57f4dfa --- /dev/null +++ b/cast/migrations/0050_add_tags_for_posts.py @@ -0,0 +1,50 @@ +# Generated by Django 4.2.4 on 2023-08-20 12:57 + +from django.db import migrations, models +import django.db.models.deletion +import modelcluster.contrib.taggit +import modelcluster.fields + + +class Migration(migrations.Migration): + dependencies = [ + ("taggit", "0005_auto_20220424_2025"), + ("cast", "0049_added_category_snippets"), + ] + + operations = [ + migrations.CreateModel( + name="PostTag", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "content_object", + modelcluster.fields.ParentalKey( + on_delete=django.db.models.deletion.CASCADE, related_name="tagged_items", to="cast.post" + ), + ), + ( + "tag", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="%(app_label)s_%(class)s_items", + to="taggit.tag", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.AddField( + model_name="post", + name="tags", + field=modelcluster.contrib.taggit.ClusterTaggableManager( + blank=True, + help_text="A comma-separated list of tags.", + through="cast.PostTag", + to="taggit.Tag", + verbose_name="tags", + ), + ), + ] diff --git a/cast/models/__init__.py b/cast/models/__init__.py index babd9e80..a0c3b4a4 100644 --- a/cast/models/__init__.py +++ b/cast/models/__init__.py @@ -5,6 +5,7 @@ from .itunes import ItunesArtWork from .moderation import SpamFilter from .pages import Episode, HomePage, Post, sync_media_ids +from .snippets import PostCategory from .theme import TemplateBaseDirectory from .video import Video, get_video_dimensions @@ -19,6 +20,7 @@ "HomePage", "ItunesArtWork", "Post", + "PostCategory", "Episode", "sync_media_ids", "SpamFilter", diff --git a/cast/models/pages.py b/cast/models/pages.py index f9b15730..a01d0899 100644 --- a/cast/models/pages.py +++ b/cast/models/pages.py @@ -15,10 +15,13 @@ from django.utils.safestring import SafeText from django.utils.translation import gettext_lazy as _ from django_comments import get_model as get_comment_model +from modelcluster.contrib.taggit import ClusterTaggableManager +from modelcluster.fields import ParentalKey, ParentalManyToManyField from slugify import slugify +from taggit.models import TaggedItemBase from wagtail import blocks from wagtail.admin.forms import WagtailAdminPageForm -from wagtail.admin.panels import FieldPanel +from wagtail.admin.panels import FieldPanel, MultiFieldPanel from wagtail.api import APIField from wagtail.embeds.blocks import EmbedBlock from wagtail.fields import StreamField @@ -90,6 +93,10 @@ def get_port(self) -> int: return self.port +class PostTag(TaggedItemBase): + content_object = ParentalKey("Post", related_name="tagged_items", on_delete=models.CASCADE) + + class Post(Page): uuid = models.UUIDField(default=uuid.uuid4, editable=False) visible_date = models.DateTimeField( @@ -106,6 +113,11 @@ class Post(Page): videos = models.ManyToManyField("cast.Video", blank=True) galleries = models.ManyToManyField("cast.Gallery", blank=True) audios = models.ManyToManyField("cast.Audio", blank=True) + categories = ParentalManyToManyField("cast.PostCategory", blank=True) + + # managers + objects: PageManager = PageManager() + tags = ClusterTaggableManager(through=PostTag, blank=True, verbose_name=_("tags")) _local_template_name: Optional[str] = None @@ -136,13 +148,16 @@ class Post(Page): content_panels = Page.content_panels + [ FieldPanel("visible_date"), + MultiFieldPanel( + [FieldPanel("categories", widget=forms.CheckboxSelectMultiple)], + heading="Categories", + classname="collapsed", + ), + FieldPanel("tags"), FieldPanel("body"), ] parent_page_types = ["cast.Blog", "cast.Podcast"] - # managers - objects: PageManager = PageManager() - @property def media_model_lookup(self) -> dict[str, type[models.Model]]: from .audio import Audio diff --git a/cast/models/snippets.py b/cast/models/snippets.py new file mode 100644 index 00000000..f9fad705 --- /dev/null +++ b/cast/models/snippets.py @@ -0,0 +1,18 @@ +from django.db import models +from wagtail.snippets.models import register_snippet + + +@register_snippet +class PostCategory(models.Model): + """Post category snippet for grouping posts.""" + + name = models.CharField(max_length=255, unique=True, help_text="The name for this category") + slug = models.SlugField(verbose_name="slug", unique=True, help_text="A slug to identify posts by this category") + + class Meta: + verbose_name = "Post Category" + verbose_name_plural = "Post Categories" + ordering = ["name"] + + def __str__(self) -> str: + return self.name diff --git a/docs/features.rst b/docs/features.rst index d594faca..1a5aafb3 100644 --- a/docs/features.rst +++ b/docs/features.rst @@ -7,6 +7,7 @@ Features .. include:: social-media.rst .. include:: comments.rst .. include:: blog.rst +.. include:: tags.rst .. include:: video.rst .. include:: audio.rst .. include:: themes.rst diff --git a/docs/settings.rst b/docs/settings.rst index 60dd0d80..4b1f54fd 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -103,3 +103,27 @@ like this: .. important:: This will only work if you are using Django >= 4.2. + + +****************** +Faceted Navigation +****************** + +You can configure the facets that are available in the search UI by +setting the ``CAST_FILTERSET_FACETS`` variable in your settings file. +The default value is: + +.. code-block:: python + + CAST_FILTERSET_FACETS = [ + "search", "date", "date_facets", "category_facets", "tag_facets" + ] + +But if you want to remove the ``tag_facets`` facet, because you don't +use tags, you can do it like this: + +.. code-block:: python + + CAST_FILTERSET_FACETS = [ + "search", "date", "date_facets", "category_facets" + ] diff --git a/docs/tags.rst b/docs/tags.rst new file mode 100644 index 00000000..dcb39037 --- /dev/null +++ b/docs/tags.rst @@ -0,0 +1,32 @@ +***************** +Categories / Tags +***************** + +This is a beta feature. It is not yet fully implemented. Since I don't +know yet if I will go with tags or categories, I added both and wait +which one sticks 😄. + +Categories +========== + +Categories are one way to group posts. They come with their own snippet +model so you can add them via the admin interface by clicking on one +of the categories. A blog post can have multiple categories and a category +can have multiple blog posts. If you want to add a new category, you have +to add it using the wagtail admin interface. + +Categories might be the right thing if you do not have too many of +them and they rarely change. + + +Tags +==== + +Tags are another way to group posts. They come with their own link to +the `taggit` tag model. You can add tags to a blog post by using the +standard wagtail `tag` interface. A blog post can have multiple tags +and a tag can have multiple blog posts. If you want to add a new tag, +there's a text field with auto completion in the wagtail admin interface. + +Tags might be the right thing if you have a lot of them and they change +often and you don't mind having to type them in the admin interface. diff --git a/tests/filter_test.py b/tests/filter_test.py index 1aea04c5..ed638bc2 100644 --- a/tests/filter_test.py +++ b/tests/filter_test.py @@ -1,42 +1,213 @@ +from datetime import datetime + +import pytest from django.http import QueryDict +from django.utils.timezone import make_aware -from cast.filters import DateFacetWidget, get_facet_counts +from cast import appsettings +from cast.filters import ( + CategoryFacetFilter, + CountFacetWidget, + PostFilterset, + SlugChoicesField, + get_facet_counts, +) +from cast.models import Post, PostCategory +from tests.factories import PostFactory -def test_date_facet_widget_render(): - dfw = DateFacetWidget() - dfw.choices = [("foo", ("bar", "baz"))] - dfw.data = {} - html = dfw.render("foo", "bar") +def test_count_facet_widget_render(): + cfw = CountFacetWidget() + cfw.choices = [("foo", ("bar", "baz"))] + cfw.data = {} + html = cfw.render("foo", "bar") # noqa pycharm warning about template not found assert "foo" in html assert "bar" in html assert "baz" in html -def test_date_facet_widget_if_options(mocker): - mocker.patch("cast.filters.DateFacetWidget.render_options", return_value=False) - dfw = DateFacetWidget() - html = dfw.render("foo", "bar") +def test_count_facet_widget_if_options(mocker): + mocker.patch("cast.filters.CountFacetWidget.render_options", return_value=False) + cfw = CountFacetWidget() + html = cfw.render("foo", "bar") # noqa pycharm warning about template not found assert "foo" not in html -def test_get_facet_counts(mocker): - get_selected_facet = mocker.patch("cast.filters.get_selected_facet") - mocker.patch("cast.filters.PostFilterset") - _ = get_facet_counts(None, [mocker.MagicMock()]) - # only isinstance because the initial filterset_data dict is modified - assert isinstance(get_selected_facet.call_args[0][0], dict) - - -def test_active_pagination_is_removed_from_date_facet_filter(): - dfw = DateFacetWidget() - dfw.data = QueryDict("page=3") - option = dfw.render_option("name", set(), "value", "label") +def test_active_pagination_is_removed_from_count_facet_filter(): + cfw = CountFacetWidget() + cfw.data = QueryDict("page=3") + option = cfw.render_option("name", set(), "value", "label") assert "page=3" not in option -def test_selected_date_facet_is_in_hidden_input(): - dfw = DateFacetWidget() - dfw.data = QueryDict("date_facets=2018-12") - option = dfw.render_option("date_facets", {"2018-12"}, "2018-12", "2018-12 (3)") +def test_selected_count_facet_is_in_hidden_input(): + cfw = CountFacetWidget() + cfw.data = QueryDict("date_facets=2018-12") + option = cfw.render_option("date_facets", {"2018-12"}, "2018-12", "2018-12 (3)") assert '' in option + + +@pytest.mark.parametrize( + "value, is_valid", + [ + (None, False), + ("", False), + ("foo", True), # happy path + ("foo bar", False), # no spaces + ], +) +def test_validate_category_facet_choice(value, is_valid): + field = SlugChoicesField() + assert field.valid_value(value) == is_valid + + +def test_category_choices_mixin_side_effects(): + class Parent: + def __init__(self, facet_counts): + self.facet_counts = facet_counts + + ccm = CategoryFacetFilter() # use Filter instead of Mixin to make super and self.extra work + ccm.parent = Parent({"categories": {"count1": ("count one", 1), "count0": ("count 0", 0)}}) + _ = ccm.field + category_slugs = {slug for slug, label in ccm.extra["choices"]} + assert category_slugs == {"count1"} + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "field_name, facet_count_key", + [ + ("date_facets", "year_month"), + ("category_facets", "categories"), + ("tag_facets", "tags"), + ], +) +def test_only_filter_specified_fields(field_name, facet_count_key): + # given an empty queryset and a facet that is usually in the facet counts + queryset = Post.objects.none() + assert facet_count_key in get_facet_counts({}, queryset) + + # when the field is removed from the list of fields + fields = set(appsettings.CAST_FILTERSET_FACETS) + fields.remove(field_name) + facet_counts = get_facet_counts({}, queryset, fields=tuple(fields)) + + # then the facet is not in the facet counts + assert facet_count_key not in facet_counts + + +@pytest.mark.django_db +class TestPostFilterset: + def test_no_posts_no_date_facets(self): + # given a filterset with no posts + queryset = Post.objects.none() + # when the facet counts are fetched + filterset = PostFilterset(QueryDict(), queryset=queryset, fetch_facet_counts=True) + # then there are no date facets + assert filterset.facet_counts["year_month"] == {} + + def test_post_is_counted_in_date_facets(self, post): + # given a queryset with a post + queryset = post.blog.unfiltered_published_posts + # when the facet counts are fetched + filterset = PostFilterset(QueryDict(), queryset=queryset, fetch_facet_counts=True) + # then the post is counted in the date facets + date_facets = filterset.facet_counts["year_month"] + date_month_post = make_aware(datetime(post.visible_date.year, post.visible_date.month, 1)) + assert date_facets[date_month_post] == 1 + + def test_post_is_counted_in_date_facets_when_in_search_result(self, post): + # given a queryset with a post + queryset = post.blog.unfiltered_published_posts + # when the queryset is filtered by the posts title + querydict = QueryDict(f"search={post.title}") + filterset = PostFilterset(querydict, queryset=queryset, fetch_facet_counts=True) + # then the post is in the queryset + assert post in filterset.qs + # and the post is counted in the date facets + date_facets = filterset.facet_counts["year_month"] + date_month_post = make_aware(datetime(post.visible_date.year, post.visible_date.month, 1)) + assert date_facets[date_month_post] == 1 + + def test_post_is_counted_in_date_facets_when_not_in_search_result(self, post): + # given a queryset with a post + queryset = post.blog.unfiltered_published_posts + # when the queryset is filtered by a query that does not match the post + querydict = QueryDict("search=not_in_title") + filterset = PostFilterset(querydict, queryset=queryset, fetch_facet_counts=True) + # then the post is not in the queryset + assert post not in filterset.qs + # and the post is not counted in the date facets + date_facets = filterset.facet_counts["year_month"] + assert date_facets == {} + + def test_post_is_counted_in_category_facets(self, post): + # given a queryset with a post in a category + category = PostCategory.objects.create(name="Today I Learned", slug="til") + post.categories.add(category) + post.save() # yes, this is required + queryset = post.blog.unfiltered_published_posts + # when the facet counts are fetched + filterset = PostFilterset(QueryDict(), queryset=queryset, fetch_facet_counts=True) + # then the post is counted in the category facets + category_facets = filterset.facet_counts["categories"] + assert category_facets[category.slug] == ("Today I Learned", 1) + + def test_posts_are_filtered_by_category_facet(self, post, body): + # given a queryset with a post in a category and another post without a category + category = PostCategory.objects.create(name="Today I Learned", slug="til") + post.categories.add(category) + post.save() + blog = post.blog + another_post = PostFactory(owner=blog.owner, parent=blog, title="another post", slug="another-post", body=body) + another_post.save() + # when the posts are filtered by the category + queryset = blog.unfiltered_published_posts + querydict = QueryDict("category_facets=til") + filterset = PostFilterset(querydict, queryset=queryset) + # then the post without a category is not in the queryset + assert another_post not in filterset.qs + assert filterset.qs.count() == 1 + + def test_posts_are_filtered_and_wise_by_multiple_categories(self, post, body): + # given a queryset containing two posts being in one category and one of the posts is + # in an additional category + + # first post + category = PostCategory.objects.create(name="Today I Learned", slug="til") + post.categories.add(category) + another_category = PostCategory.objects.create(name="Additional Category", slug="additional_category") + post.categories.add(another_category) + post.save() + + # second post + blog = post.blog + another_post = PostFactory(owner=blog.owner, parent=blog, title="another post", slug="another-post", body=body) + another_post.categories.add(category) + another_post.save() + queryset = blog.unfiltered_published_posts + + # when the posts are filtered by both categories + querydict = QueryDict("category_facets=til&category_facets=additional_category") + filterset = PostFilterset(querydict, queryset=queryset) + + # then the post without the additional category is not in the queryset + # but the post with both categories is in the queryset + assert post in filterset.qs + assert another_post not in filterset.qs + assert filterset.qs.count() == 1 + + def test_posts_are_filtered_by_tag_facet(self, post, body): + # given a queryset with a tagged post and another post without this tag + post.tags.add("tag") + post.save() + blog = post.blog + another_post = PostFactory(owner=blog.owner, parent=blog, title="another post", slug="another-post", body=body) + another_post.save() + # when the posts are filtered by the tag + queryset = blog.unfiltered_published_posts + querydict = QueryDict("tag_facets=tag") + filterset = PostFilterset(querydict, queryset=queryset, fetch_facet_counts=True) + # then the untagged post is not in the queryset + assert another_post not in filterset.qs + assert filterset.qs.count() == 1 diff --git a/tests/snippets_test.py b/tests/snippets_test.py new file mode 100644 index 00000000..ed8c6eb5 --- /dev/null +++ b/tests/snippets_test.py @@ -0,0 +1,6 @@ +from cast.models import PostCategory + + +def test_post_category_name(): + category = PostCategory(name="Test Category", slug="test-category") + assert str(category) == "Test Category"