From 60931816197450c018b67e8229190d193ed6a258 Mon Sep 17 00:00:00 2001 From: Stuart Maxwell Date: Wed, 9 Oct 2024 19:23:34 +1300 Subject: [PATCH 01/27] Fix lock file --- uv.lock | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/uv.lock b/uv.lock index d8e7f8f..97e36ef 100644 --- a/uv.lock +++ b/uv.lock @@ -116,15 +116,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/41/9307e4f5f9976bc8b7fea0b66367734e8faf3ec84bc0d412d8cfabbb66cd/distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784", size = 468850 }, ] -[[package]] -name = "distlib" -version = "0.3.8" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c4/91/e2df406fb4efacdf46871c25cde65d3c6ee5e173b7e5a4547a47bae91920/distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64", size = 609931 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8e/41/9307e4f5f9976bc8b7fea0b66367734e8faf3ec84bc0d412d8cfabbb66cd/distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784", size = 468850 }, -] - [[package]] name = "django" version = "5.1.2" @@ -386,17 +377,3 @@ sdist = { url = "https://files.pythonhosted.org/packages/3f/40/abc5a766da6b0b245 wheels = [ { url = "https://files.pythonhosted.org/packages/59/90/57b8ac0c8a231545adc7698c64c5a36fa7cd8e376c691b9bde877269f2eb/virtualenv-20.26.6-py3-none-any.whl", hash = "sha256:7345cc5b25405607a624d8418154577459c3e0277f5466dd79c49d5e492995f2", size = 5999862 }, ] - -[[package]] -name = "virtualenv" -version = "20.26.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "distlib" }, - { name = "filelock" }, - { name = "platformdirs" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3f/40/abc5a766da6b0b2457f819feab8e9203cbeae29327bd241359f866a3da9d/virtualenv-20.26.6.tar.gz", hash = "sha256:280aede09a2a5c317e409a00102e7077c6432c5a38f0ef938e643805a7ad2c48", size = 9372482 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/59/90/57b8ac0c8a231545adc7698c64c5a36fa7cd8e376c691b9bde877269f2eb/virtualenv-20.26.6-py3-none-any.whl", hash = "sha256:7345cc5b25405607a624d8418154577459c3e0277f5466dd79c49d5e492995f2", size = 5999862 }, -] From 5b6be8132600bd48f5d1b9742c9851961665f220 Mon Sep 17 00:00:00 2001 From: Stuart Maxwell Date: Wed, 9 Oct 2024 19:23:50 +1300 Subject: [PATCH 02/27] Add parent field to Post model --- src/djpress/migrations/0005_post_parent.py | 24 ++++++++++++++++++++++ src/djpress/models/post.py | 1 + 2 files changed, 25 insertions(+) create mode 100644 src/djpress/migrations/0005_post_parent.py diff --git a/src/djpress/migrations/0005_post_parent.py b/src/djpress/migrations/0005_post_parent.py new file mode 100644 index 0000000..7933f8f --- /dev/null +++ b/src/djpress/migrations/0005_post_parent.py @@ -0,0 +1,24 @@ +# Generated by Django 5.1.2 on 2024-10-09 06:22 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("djpress", "0004_rename_name_category_title_category_menu_order"), + ] + + operations = [ + migrations.AddField( + model_name="post", + name="parent", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="children", + to="djpress.post", + ), + ), + ] diff --git a/src/djpress/models/post.py b/src/djpress/models/post.py index 2557adc..552f271 100644 --- a/src/djpress/models/post.py +++ b/src/djpress/models/post.py @@ -242,6 +242,7 @@ class Post(models.Model): ) categories = models.ManyToManyField(Category, blank=True) menu_order = models.IntegerField(default=0) + parent = models.ForeignKey("self", null=True, blank=True, on_delete=models.SET_NULL, related_name="children") # Managers objects = models.Manager() From 858107ee96c5828e9a085827be0f8698dd850f28 Mon Sep 17 00:00:00 2001 From: Stuart Maxwell Date: Wed, 9 Oct 2024 20:03:01 +1300 Subject: [PATCH 03/27] Limit parent choices to just pages and validation to ensure a page can't be its own parent --- .../migrations/0006_alter_post_parent.py | 25 +++++++++++++ src/djpress/models/post.py | 36 +++++++++++++++---- 2 files changed, 55 insertions(+), 6 deletions(-) create mode 100644 src/djpress/migrations/0006_alter_post_parent.py diff --git a/src/djpress/migrations/0006_alter_post_parent.py b/src/djpress/migrations/0006_alter_post_parent.py new file mode 100644 index 0000000..a79b924 --- /dev/null +++ b/src/djpress/migrations/0006_alter_post_parent.py @@ -0,0 +1,25 @@ +# Generated by Django 5.1.2 on 2024-10-09 06:36 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("djpress", "0005_post_parent"), + ] + + operations = [ + migrations.AlterField( + model_name="post", + name="parent", + field=models.ForeignKey( + blank=True, + limit_choices_to={"post_type": "page"}, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="children", + to="djpress.post", + ), + ), + ] diff --git a/src/djpress/models/post.py b/src/djpress/models/post.py index 552f271..d9d1503 100644 --- a/src/djpress/models/post.py +++ b/src/djpress/models/post.py @@ -5,6 +5,7 @@ from django.contrib.auth.models import User from django.core.cache import cache +from django.core.exceptions import ValidationError from django.db import models from django.utils import timezone from django.utils.text import slugify @@ -39,6 +40,18 @@ def get_published_pages(self: "PagesManager") -> models.QuerySet: date__lte=timezone.now(), ) + def get_full_page_path(self) -> str: + """Return the full page path. + + This is the full path to the page, including any parent pages. + + Returns: + str: The full page path. + """ + if self.parent: + return f"{self.parent.get_full_path()}/{self.slug}" + return self.slug + def get_published_page_by_slug( self: "PagesManager", slug: str, @@ -235,14 +248,17 @@ class Post(models.Model): date = models.DateTimeField(default=timezone.now) modified_date = models.DateTimeField(auto_now=True) status = models.CharField(max_length=10, choices=STATUS_CHOICES, default="draft") - post_type = models.CharField( - max_length=10, - choices=CONTENT_TYPE_CHOICES, - default="post", - ) + post_type = models.CharField(max_length=10, choices=CONTENT_TYPE_CHOICES, default="post") categories = models.ManyToManyField(Category, blank=True) menu_order = models.IntegerField(default=0) - parent = models.ForeignKey("self", null=True, blank=True, on_delete=models.SET_NULL, related_name="children") + parent = models.ForeignKey( + "self", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="children", + limit_choices_to={"post_type": "page"}, + ) # Managers objects = models.Manager() @@ -266,8 +282,16 @@ def save(self: "Post", *args, **kwargs) -> None: # noqa: ANN002, ANN003 if not self.slug or self.slug.strip("-") == "": msg = "Invalid title. Unable to generate a valid slug." raise ValueError(msg) + self.full_clean() super().save(*args, **kwargs) + def clean(self) -> None: + """Custom validation for the Post model.""" + # A page cannot be its own parent + if self.parent and self.pk == self.parent.pk: + msg = "A page cannot be its own parent." + raise ValidationError(msg) + @property def content_markdown(self: "Post") -> str: """Return the content as HTML converted from Markdown.""" From d6fb46464630c170d689d366795e3e2f0c107451 Mon Sep 17 00:00:00 2001 From: Stuart Maxwell Date: Wed, 9 Oct 2024 20:05:06 +1300 Subject: [PATCH 04/27] Fix tests to avoid new validation errors --- tests/test_cache_published_posts.py | 10 +++++----- tests/test_models_post.py | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/test_cache_published_posts.py b/tests/test_cache_published_posts.py index 98741bf..720def6 100644 --- a/tests/test_cache_published_posts.py +++ b/tests/test_cache_published_posts.py @@ -140,9 +140,9 @@ def test_cache_get_recent_published_posts(user, settings): assert settings.DJPRESS_SETTINGS["CACHE_RECENT_PUBLISHED_POSTS"] is True # Create some published posts - post1 = Post.objects.create(title="Post 1", status="published", author=user) - post2 = Post.objects.create(title="Post 2", status="published", author=user) - post3 = Post.objects.create(title="Post 3", status="published", author=user) + post1 = Post.objects.create(title="Post 1", status="published", author=user, content="Test post") + post2 = Post.objects.create(title="Post 2", status="published", author=user, content="Test post") + post3 = Post.objects.create(title="Post 3", status="published", author=user, content="Test post") # Call the method being tested recent_posts = Post.post_objects.get_recent_published_posts() @@ -185,8 +185,8 @@ def test_cache_get_recent_published_posts_future_post(user, settings): assert settings.DJPRESS_SETTINGS["CACHE_RECENT_PUBLISHED_POSTS"] is True # Create some published posts - post1 = Post.objects.create(title="Post 1", status="published", author=user) - post2 = Post.objects.create(title="Post 2", status="published", author=user) + post1 = Post.objects.create(title="Post 1", status="published", author=user, content="Test post") + post2 = Post.objects.create(title="Post 2", status="published", author=user, content="Test post") # Call the method being tested recent_posts = Post.post_objects.get_recent_published_posts() diff --git a/tests/test_models_post.py b/tests/test_models_post.py index e894b0c..ebf934e 100644 --- a/tests/test_models_post.py +++ b/tests/test_models_post.py @@ -351,9 +351,9 @@ def test_get_recent_published_posts(user, settings): assert settings.DJPRESS_SETTINGS["RECENT_PUBLISHED_POSTS_COUNT"] == 3 # Create some published posts - post1 = Post.objects.create(title="Post 1", status="published", author=user) - post2 = Post.objects.create(title="Post 2", status="published", author=user) - post3 = Post.objects.create(title="Post 3", status="published", author=user) + post1 = Post.objects.create(title="Post 1", status="published", author=user, content="Test post") + post2 = Post.objects.create(title="Post 2", status="published", author=user, content="Test post") + post3 = Post.objects.create(title="Post 3", status="published", author=user, content="Test post") # Call the method being tested recent_posts = Post.post_objects.get_recent_published_posts() From cb166340d0b21fbc0690097d3e73dfa439269af4 Mon Sep 17 00:00:00 2001 From: Stuart Maxwell Date: Wed, 9 Oct 2024 23:02:48 +1300 Subject: [PATCH 05/27] get_full_page_path changed to full_page_path property. Improve validation. --- src/djpress/models/post.py | 60 ++++++++++++++++++++++++++++---------- 1 file changed, 44 insertions(+), 16 deletions(-) diff --git a/src/djpress/models/post.py b/src/djpress/models/post.py index d9d1503..e97a017 100644 --- a/src/djpress/models/post.py +++ b/src/djpress/models/post.py @@ -40,18 +40,6 @@ def get_published_pages(self: "PagesManager") -> models.QuerySet: date__lte=timezone.now(), ) - def get_full_page_path(self) -> str: - """Return the full page path. - - This is the full path to the page, including any parent pages. - - Returns: - str: The full page path. - """ - if self.parent: - return f"{self.parent.get_full_path()}/{self.slug}" - return self.slug - def get_published_page_by_slug( self: "PagesManager", slug: str, @@ -287,10 +275,37 @@ def save(self: "Post", *args, **kwargs) -> None: # noqa: ANN002, ANN003 def clean(self) -> None: """Custom validation for the Post model.""" - # A page cannot be its own parent - if self.parent and self.pk == self.parent.pk: - msg = "A page cannot be its own parent." - raise ValidationError(msg) + # Check for circular references in the page hierarchy + self._check_circular_reference() + + def _check_circular_reference(self) -> None: + """Check for circular references in the page hierarchy. + + This is a recursive function that checks if the current page is an ancestor of itself. This is needed to ensure + that we don't create a circular reference in the page hierarchy. This is called in the clean method. + + For example, we need to avoid the following page hierarchy from happening: + - Page A + - Page B + - Page C + - Page A + + Returns: + None + + Raises: + ValidationError: If a circular reference is detected. + """ + # If there's no parent, we don't need to check for circular references + if not self.parent: + return + + ancestor = self.parent + while ancestor: + if ancestor.pk == self.pk: + msg = "Circular reference detected in page hierarchy." + raise ValidationError(msg) + ancestor = ancestor.parent @property def content_markdown(self: "Post") -> str: @@ -354,3 +369,16 @@ def permalink(self: "Post") -> str: url_parts = [part for part in prefix.split("/") if part] + [self.slug] return "/".join(url_parts) + + @property + def full_page_path(self) -> str: + """Return the full page path. + + This is the full path to the page, including any parent pages. + + Returns: + str: The full page path. + """ + if self.parent: + return f"{self.parent.full_page_path}/{self.slug}" + return self.slug From 282c9f90085b4ed46808d205db89e2b8f43dc49f Mon Sep 17 00:00:00 2001 From: Stuart Maxwell Date: Wed, 9 Oct 2024 23:03:01 +1300 Subject: [PATCH 06/27] Add extra page fixture --- tests/conftest.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 74917e8..35604aa 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -146,3 +146,15 @@ def test_page2(user): status="published", post_type="page", ) + + +@pytest.fixture +def test_page3(user): + return Post.objects.create( + title="Test Page3", + slug="test-page3", + content="This is test page 3.", + author=user, + status="published", + post_type="page", + ) From c6782be98c8f08a70882082859ad90d9e9bd8efd Mon Sep 17 00:00:00 2001 From: Stuart Maxwell Date: Wed, 9 Oct 2024 23:03:25 +1300 Subject: [PATCH 07/27] Test page full path and clean method --- tests/test_models_post.py | 64 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/tests/test_models_post.py b/tests/test_models_post.py index ebf934e..911398b 100644 --- a/tests/test_models_post.py +++ b/tests/test_models_post.py @@ -3,6 +3,7 @@ from unittest.mock import Mock from djpress.models import Category, Post from django.core.cache import cache +from django.core.exceptions import ValidationError from djpress import urls as djpress_urls from djpress.models.post import PUBLISHED_POSTS_CACHE_KEY @@ -649,3 +650,66 @@ def test_get_cached_recent_published_posts_cache_hit_2_posts(mock_cache, setting # Verify cache.set is not called again assert not mock_cache_set.called + + +@pytest.mark.django_db +def test_post_clean_valid_parent(test_page1, test_page2): + test_page1.parent = test_page2 + test_page1.clean() + assert test_page1.parent == test_page2 + + +@pytest.mark.django_db +def test_post_clean_self_parent(test_page1): + test_page1.parent = test_page1 + with pytest.raises(ValidationError) as exc_info: + test_page1.clean() + assert "Circular reference detected in page hierarchy." in str(exc_info.value) + + +@pytest.mark.django_db +def test_post_clean_circular_reference(test_page1, test_page2): + test_page1.parent = test_page2 + test_page1.clean() + assert test_page1.parent == test_page2 + + # Create a circular reference + test_page2.parent = test_page1 + with pytest.raises(ValidationError) as exc_info: + test_page2.clean() + assert "Circular reference detected in page hierarchy." in str(exc_info.value) + + +@pytest.mark.django_db +def test_post_clean_circular_reference_extra_level(test_page1, test_page2, test_page3): + test_page1.parent = test_page2 + test_page1.clean() + assert test_page1.parent == test_page2 + + test_page2.parent = test_page3 + test_page2.clean() + assert test_page2.parent == test_page3 + + # Create a circular reference + test_page3.parent = test_page1 + with pytest.raises(ValidationError) as exc_info: + test_page3.clean() + assert "Circular reference detected in page hierarchy." in str(exc_info.value) + + +@pytest.mark.django_db +def test_full_page_path_no_parent(test_page1): + assert test_page1.full_page_path == "test-page1" + + +@pytest.mark.django_db +def test_get_full_page_path_with_parent(test_page1, test_page2): + test_page1.parent = test_page2 + assert test_page1.full_page_path == "test-page2/test-page1" + + +@pytest.mark.django_db +def test_get_full_page_path_with_grandparent(test_page1, test_page2, test_page3): + test_page1.parent = test_page2 + test_page2.parent = test_page3 + assert test_page1.full_page_path == "test-page3/test-page2/test-page1" From 912a9eff871563a0a7b1c57500729b5cdc4001e0 Mon Sep 17 00:00:00 2001 From: Stuart Maxwell Date: Wed, 9 Oct 2024 23:27:39 +1300 Subject: [PATCH 08/27] Update admin to better display models --- src/djpress/admin.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/djpress/admin.py b/src/djpress/admin.py index d950b69..755d096 100644 --- a/src/djpress/admin.py +++ b/src/djpress/admin.py @@ -1,9 +1,25 @@ """djpress admin configuration.""" +from typing import ClassVar + from django.contrib import admin # Register the models here. from djpress.models import Category, Post -admin.site.register(Category) -admin.site.register(Post) + +@admin.register(Category) +class CategoryAdmin(admin.ModelAdmin): + """Category admin configuration.""" + + list_display: ClassVar["str"] = ["title", "slug"] + + +@admin.register(Post) +class PostAdmin(admin.ModelAdmin): + """Post admin configuration.""" + + list_display: ClassVar["str"] = ["post_type", "title", "slug", "date", "author"] + list_display_links: ClassVar["str"] = ["title", "slug"] + ordering: ClassVar["str"] = ["post_type", "-date"] # Displays pages first, then sorted by date. + list_filter: ClassVar["str"] = ["post_type", "date", "author"] From 918171466bb0f42c42f3c95361b9e34ad65e49e8 Mon Sep 17 00:00:00 2001 From: Stuart Maxwell Date: Wed, 9 Oct 2024 23:46:31 +1300 Subject: [PATCH 09/27] Change get_page_url to use page.permalink --- src/djpress/url_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/djpress/url_utils.py b/src/djpress/url_utils.py index c05eae4..48483c4 100644 --- a/src/djpress/url_utils.py +++ b/src/djpress/url_utils.py @@ -214,7 +214,7 @@ def get_archives_url(year: int, month: int | None = None, day: int | None = None def get_page_url(page: Post) -> str: """Return the URL for the page.""" - url = f"/{page.slug}" + url = f"/{page.permalink}" if django_settings.APPEND_SLASH: return f"{url}/" From e90e26bbc0f6cbcf52d53dad3176e6fa155ab990 Mon Sep 17 00:00:00 2001 From: Stuart Maxwell Date: Wed, 9 Oct 2024 23:46:59 +1300 Subject: [PATCH 10/27] Change permalink property to return the full_page_path instead of the slug --- src/djpress/models/post.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/djpress/models/post.py b/src/djpress/models/post.py index e97a017..54bb418 100644 --- a/src/djpress/models/post.py +++ b/src/djpress/models/post.py @@ -348,10 +348,9 @@ def permalink(self: "Post") -> str: - The post slug - this is a unique identifier for the post. TODO: should this be a database unique constraint, or should we handle it in software instead? """ - # If the post type is a page, we return just the slug - # TODO: needs to support parent pages + # If the post type is a page, we return the full page path if self.post_type == "page": - return self.slug + return self.full_page_path prefix = djpress_settings.POST_PREFIX From b40c4a6d7959e642c8b049cde33cb1af66b383f8 Mon Sep 17 00:00:00 2001 From: Stuart Maxwell Date: Wed, 9 Oct 2024 23:47:05 +1300 Subject: [PATCH 11/27] Update tests --- tests/test_models_post.py | 18 +++++++----------- tests/test_url_utils.py | 12 ++++++++++++ 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/tests/test_models_post.py b/tests/test_models_post.py index 911398b..76ab8e6 100644 --- a/tests/test_models_post.py +++ b/tests/test_models_post.py @@ -330,18 +330,14 @@ def test_get_published_posts_by_author(user): @pytest.mark.django_db -def test_page_permalink(user): - page = Post( - title="Test Page", - slug="test-page", - content="This is a test page.", - author=user, - date=timezone.now(), - status="published", - post_type="page", - ) +def test_page_permalink(test_page1): + assert test_page1.permalink == "test-page1" - assert page.permalink == "test-page" + +@pytest.mark.django_db +def test_page_permalink_parent(test_page1, test_page2): + test_page1.parent = test_page2 + assert test_page1.permalink == "test-page2/test-page1" @pytest.mark.django_db diff --git a/tests/test_url_utils.py b/tests/test_url_utils.py index 78faa2d..5533c74 100644 --- a/tests/test_url_utils.py +++ b/tests/test_url_utils.py @@ -379,6 +379,18 @@ def test_get_page_url(settings, test_page1): assert url == expected_url +@pytest.mark.django_db +def test_get_page_url_parent(settings, test_page1, test_page2): + assert settings.APPEND_SLASH is True + expected_url = f"/{test_page2.slug}/{test_page1.slug}/" + + test_page1.parent = test_page2 + test_page1.save() + + url = get_page_url(test_page1) + assert url == expected_url + + @pytest.mark.django_db def test_get_post_url(settings, test_post1): assert settings.DJPRESS_SETTINGS["POST_PREFIX"] == "test-posts" From b4969fb6a0da2a1cc09a02e5875503becede622c Mon Sep 17 00:00:00 2001 From: Stuart Maxwell Date: Thu, 10 Oct 2024 00:39:33 +1300 Subject: [PATCH 12/27] Update get_published_page_by_path to handle parents --- src/djpress/models/post.py | 37 +++++++++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/src/djpress/models/post.py b/src/djpress/models/post.py index 54bb418..216861c 100644 --- a/src/djpress/models/post.py +++ b/src/djpress/models/post.py @@ -66,16 +66,37 @@ def get_published_page_by_path( ) -> "Post": """Return a single published page from a path. - For now, we'll only allow a top level path. + The path can consist of one or more pages, e.g. "about", "about/contact". - This will raise a ValueError if the path is invalid. - """ - # Check for a single item in the path - if path.count("/") > 0: - msg = "Invalid path" - raise ValueError(msg) + Args: + path (str): The path to the page. - return self.get_published_page_by_slug(path) + Returns: + Post: The published page. + + Raises: + PageNotFoundError: If the page cannot be found. + """ + # Strip leading and trailing slashes and split the path into parts + path_parts = path.strip("/").split("/") + + current_page = None + + for i, slug in enumerate(path_parts): + if i == 0: + try: + current_page = self.get(slug=slug, parent__isnull=True) + except Post.DoesNotExist as exc: + msg = "Page not found" + raise PageNotFoundError(msg) from exc + else: + try: + current_page = self.get(slug=slug, parent=current_page) + except Post.DoesNotExist as exc: + msg = "Page not found" + raise PageNotFoundError(msg) from exc + + return current_page class PostsManager(models.Manager): From 8652e8742137f542c760c103ce38d2d7c3daa05a Mon Sep 17 00:00:00 2001 From: Stuart Maxwell Date: Thu, 10 Oct 2024 00:41:46 +1300 Subject: [PATCH 13/27] Add tests for get_published_page_by_path --- tests/test_models_post.py | 107 +++++++++++++++++++++++++++++++++----- 1 file changed, 95 insertions(+), 12 deletions(-) diff --git a/tests/test_models_post.py b/tests/test_models_post.py index 76ab8e6..c44257f 100644 --- a/tests/test_models_post.py +++ b/tests/test_models_post.py @@ -388,22 +388,105 @@ def test_get_published_pages(test_page1, test_page2): @pytest.mark.django_db -def test_get_published_page_by_path(test_page1: Post): +def test_get_published_page_by_path_top_level(test_page1): """Test that the get_published_page_by_path method returns the correct page.""" - # Test case 1: pages can only be at the top level - page_path = f"test-pages/{test_page1.slug}" - with pytest.raises(expected_exception=ValueError): - Post.page_objects.get_published_page_by_path(page_path) + assert test_page1 == Post.page_objects.get_published_page_by_path(f"/test-page1") + assert test_page1 == Post.page_objects.get_published_page_by_path(f"test-page1/") + assert test_page1 == Post.page_objects.get_published_page_by_path(f"/test-page1/") + assert test_page1 == Post.page_objects.get_published_page_by_path(f"//////test-page1/////") - # Test case 2: pages at the top level - page_path: str = test_page1.slug - assert test_page1 == Post.page_objects.get_published_page_by_path(page_path) - # Test case 3: pages doesn't exist - page_path = "non-existent-page" - with pytest.raises(expected_exception=PageNotFoundError): - Post.page_objects.get_published_page_by_path(page_path) +@pytest.mark.django_db +def test_get_published_page_by_path_parent(test_page1, test_page2): + """Test that the get_published_page_by_path method returns the correct page.""" + test_page1.parent = test_page2 + test_page1.save() + + assert test_page1 == Post.page_objects.get_published_page_by_path(f"/test-page2/test-page1") + assert test_page1 == Post.page_objects.get_published_page_by_path(f"test-page2/test-page1/") + assert test_page1 == Post.page_objects.get_published_page_by_path(f"/test-page2/test-page1/") + assert test_page1 == Post.page_objects.get_published_page_by_path(f"//////test-page2/test-page1/////") + + +@pytest.mark.django_db +def test_get_published_page_by_path_grandparent(test_page1, test_page2, test_page3): + """Test that the get_published_page_by_path method returns the correct page.""" + test_page1.parent = test_page2 + test_page1.save() + test_page2.parent = test_page3 + test_page2.save() + + assert test_page1 == Post.page_objects.get_published_page_by_path(f"/test-page3/test-page2/test-page1") + assert test_page1 == Post.page_objects.get_published_page_by_path(f"test-page3/test-page2/test-page1/") + assert test_page1 == Post.page_objects.get_published_page_by_path(f"/test-page3/test-page2/test-page1/") + assert test_page1 == Post.page_objects.get_published_page_by_path(f"//////test-page3/test-page2/test-page1/////") + + +@pytest.mark.django_db +def test_get_non_existent_page_by_path(): + """Test that the get_published_page_by_path method raises a PageNotFoundError.""" + with pytest.raises(PageNotFoundError): + Post.page_objects.get_published_page_by_path("non-existent-page") + with pytest.raises(PageNotFoundError): + Post.page_objects.get_published_page_by_path("non-existint-parent/non-existent-page") + + +@pytest.mark.django_db +def test_get_non_existent_page_by_path_with_parent(test_page1): + """Test that the get_published_page_by_path method raises a PageNotFoundError.""" + with pytest.raises(PageNotFoundError): + Post.page_objects.get_published_page_by_path("test-page1/non-existent-page") + + +@pytest.mark.django_db +def test_get_valid_page_with_wrong_parent(test_page1, test_page2, test_page3): + """Test that the get_published_page_by_path method raises a PageNotFoundError.""" + test_page1.parent = test_page2 + test_page1.save() + + assert test_page1 == Post.page_objects.get_published_page_by_path("test-page2/test-page1") + assert test_page2 == Post.page_objects.get_published_page_by_path("test-page2") + assert test_page3 == Post.page_objects.get_published_page_by_path("test-page3") + + with pytest.raises(PageNotFoundError): + Post.page_objects.get_published_page_by_path("test-page3/test-page1") + with pytest.raises(PageNotFoundError): + Post.page_objects.get_published_page_by_path("test-page3/test-page2") + with pytest.raises(PageNotFoundError): + Post.page_objects.get_published_page_by_path("test-page3/test-page3") + with pytest.raises(PageNotFoundError): + Post.page_objects.get_published_page_by_path("test-page1/test-page1") + with pytest.raises(PageNotFoundError): + Post.page_objects.get_published_page_by_path("test-page1/test-page2") + with pytest.raises(PageNotFoundError): + Post.page_objects.get_published_page_by_path("test-page1/test-page3") + with pytest.raises(PageNotFoundError): + Post.page_objects.get_published_page_by_path("test-page2/test-page2") + with pytest.raises(PageNotFoundError): + Post.page_objects.get_published_page_by_path("test-page2/test-page3") + with pytest.raises(PageNotFoundError): + Post.page_objects.get_published_page_by_path("test-page2/test-page1/test-page3") + + +@pytest.mark.django_db +def test_get_valid_page_with_wrong_grandparent(test_page1, test_page2, test_page3): + """Test that the get_published_page_by_path method raises a PageNotFoundError.""" + test_page1.parent = test_page2 + test_page1.save() + test_page2.parent = test_page3 + test_page2.save() + + assert test_page1 == Post.page_objects.get_published_page_by_path("test-page3/test-page2/test-page1") + assert test_page2 == Post.page_objects.get_published_page_by_path("test-page3/test-page2") + assert test_page3 == Post.page_objects.get_published_page_by_path("test-page3") + + with pytest.raises(PageNotFoundError): + Post.page_objects.get_published_page_by_path("test-page3/test-page1/test-page2") + with pytest.raises(PageNotFoundError): + Post.page_objects.get_published_page_by_path("test-page3/test-page2/test-page2") + with pytest.raises(PageNotFoundError): + Post.page_objects.get_published_page_by_path("test-page1/test-page2/test-page3") @pytest.mark.django_db From a64ae0917a03390741230d4bcc1063c3e856a1e5 Mon Sep 17 00:00:00 2001 From: Stuart Maxwell Date: Thu, 10 Oct 2024 00:42:09 +1300 Subject: [PATCH 14/27] Add PageNotFoundError to single_page --- src/djpress/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/djpress/views.py b/src/djpress/views.py index debcafc..c06ce25 100644 --- a/src/djpress/views.py +++ b/src/djpress/views.py @@ -13,7 +13,7 @@ from django.shortcuts import render from djpress.conf import settings as djpress_settings -from djpress.exceptions import PostNotFoundError +from djpress.exceptions import PageNotFoundError, PostNotFoundError from djpress.feeds import PostFeed from djpress.models import Category, Post from djpress.url_utils import get_path_regex @@ -307,7 +307,7 @@ def single_page(request: HttpRequest, path: str) -> HttpResponse: try: post = Post.page_objects.get_published_page_by_path(path) context: dict = {"post": post} - except (PostNotFoundError, ValueError) as exc: + except (PageNotFoundError, ValueError) as exc: msg = "Page not found" raise Http404(msg) from exc From c74b0e6aa966f670e6450af572e936249aa59c23 Mon Sep 17 00:00:00 2001 From: Stuart Maxwell Date: Thu, 10 Oct 2024 00:42:22 +1300 Subject: [PATCH 15/27] Remove unnecessary tests --- tests/test_views.py | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/tests/test_views.py b/tests/test_views.py index f36bb8b..779ad04 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -100,15 +100,9 @@ def test_author_with_invalid_author(client, settings): assert response.status_code == 404 -def test_author_with_author_prefix_blank(client, settings): - assert settings.DJPRESS_SETTINGS["AUTHOR_PREFIX"] == "test-url-author" - settings.DJPRESS_SETTINGS["AUTHOR_PREFIX"] = "" - url = "/test-url-author/non-existent-author/" - response = client.get(url) - assert response.status_code == 404 - - +@pytest.mark.django_db def test_author_with_author_enabled_false(client, settings): + """This will try to get a page from the database that does not exist.""" assert settings.DJPRESS_SETTINGS["AUTHOR_ENABLED"] == True settings.DJPRESS_SETTINGS["AUTHOR_ENABLED"] = False url = "/test-url-author/non-existent-author/" @@ -147,15 +141,9 @@ def test_category_with_invalid_category(client, settings): assert response.status_code == 404 -def test_category_with_category_prefix_blank(client, settings): - assert settings.DJPRESS_SETTINGS["CATEGORY_PREFIX"] == "test-url-category" - settings.DJPRESS_SETTINGS["CATEGORY_PREFIX"] = "" - url = "/test-url-category/non-existent-category/" - response = client.get(url) - assert response.status_code == 404 - - +@pytest.mark.django_db def test_category_with_category_enabled_false(client, settings): + """This will try to get a page from the database that does not exist.""" assert settings.DJPRESS_SETTINGS["CATEGORY_ENABLED"] == True settings.DJPRESS_SETTINGS["CATEGORY_ENABLED"] = False url = "/test-url-category/non-existent-category/" From f1c6858ec1162adc29cf6a05613683e96ca60913 Mon Sep 17 00:00:00 2001 From: Stuart Maxwell Date: Thu, 10 Oct 2024 01:06:23 +1300 Subject: [PATCH 16/27] Fix test to save the post change --- tests/test_models_post.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_models_post.py b/tests/test_models_post.py index c44257f..1371a6b 100644 --- a/tests/test_models_post.py +++ b/tests/test_models_post.py @@ -337,6 +337,7 @@ def test_page_permalink(test_page1): @pytest.mark.django_db def test_page_permalink_parent(test_page1, test_page2): test_page1.parent = test_page2 + test_page1.save() assert test_page1.permalink == "test-page2/test-page1" From a3fbdebb6737022b6734289f0396f23cf5577fb7 Mon Sep 17 00:00:00 2001 From: Stuart Maxwell Date: Thu, 10 Oct 2024 01:06:40 +1300 Subject: [PATCH 17/27] Fix noxfile to install as editable --- noxfile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/noxfile.py b/noxfile.py index 9e05811..665c965 100644 --- a/noxfile.py +++ b/noxfile.py @@ -7,6 +7,6 @@ @nox.parametrize("django_ver", ["~=4.2.0", "~=5.0.0", "~=5.1.0"]) def test(session: nox.Session, django_ver: str) -> None: """Run the test suite.""" - session.install(".", "--all-extras", "-r", "pyproject.toml") + session.install("-e", ".", "--extra", "test", "-r", "pyproject.toml") session.install(f"django{django_ver}") session.run("pytest") From 9df38309bbcdb8f5f7263e68b170b72aad945397 Mon Sep 17 00:00:00 2001 From: Stuart Maxwell Date: Thu, 10 Oct 2024 23:04:40 +1300 Subject: [PATCH 18/27] Fix order_by on page queryset --- src/djpress/models/post.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/djpress/models/post.py b/src/djpress/models/post.py index 216861c..17657a7 100644 --- a/src/djpress/models/post.py +++ b/src/djpress/models/post.py @@ -26,7 +26,7 @@ class PagesManager(models.Manager): def get_queryset(self: "PagesManager") -> models.QuerySet: """Return the queryset for pages.""" - return super().get_queryset().filter(post_type="page").order_by("-date") + return super().get_queryset().filter(post_type="page") def get_published_pages(self: "PagesManager") -> models.QuerySet: """Return all published pages. From 7f4bb04fd0bdb3316a9e92ead0bb17ba933c0733 Mon Sep 17 00:00:00 2001 From: Stuart Maxwell Date: Thu, 10 Oct 2024 23:05:30 +1300 Subject: [PATCH 19/27] Add is_published property to Post model --- src/djpress/models/post.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/djpress/models/post.py b/src/djpress/models/post.py index 17657a7..cd3b18a 100644 --- a/src/djpress/models/post.py +++ b/src/djpress/models/post.py @@ -402,3 +402,22 @@ def full_page_path(self) -> str: if self.parent: return f"{self.parent.full_page_path}/{self.slug}" return self.slug + + @property + def is_published(self: "Post") -> bool: + """Return whether the post is published. + + For a post to be published, it must meet the following requirements: + - The status must be "published". + - The date must be less than or equal to the current date/time. + + This also checks if the parent page is published. + + Returns: + bool: Whether the post is published. + """ + if not (self.status == "published" and self.date <= timezone.now()): + return False + if self.parent: + return self.parent.is_published + return True From 2785dd7e4aca79db69645eb8c3b610bf66d5596d Mon Sep 17 00:00:00 2001 From: Stuart Maxwell Date: Thu, 10 Oct 2024 23:06:32 +1300 Subject: [PATCH 20/27] Update get_published_pages to check ancestors if published --- src/djpress/models/post.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/djpress/models/post.py b/src/djpress/models/post.py index cd3b18a..f0923c2 100644 --- a/src/djpress/models/post.py +++ b/src/djpress/models/post.py @@ -34,11 +34,11 @@ def get_published_pages(self: "PagesManager") -> models.QuerySet: For a page to be considered published, it must meet the following requirements: - The status must be "published". - The date must be less than or equal to the current date/time. + - All parent pages must also be published. """ - return self.get_queryset().filter( - status="published", - date__lte=timezone.now(), - ) + return Post.page_objects.filter( + pk__in=[page.pk for page in self.get_queryset().select_related("parent") if page.is_published], + ).order_by("menu_order", "title", "-date") def get_published_page_by_slug( self: "PagesManager", From 3d3de96adeab3c16c0c75904f9896d361b340061 Mon Sep 17 00:00:00 2001 From: Stuart Maxwell Date: Thu, 10 Oct 2024 23:08:15 +1300 Subject: [PATCH 21/27] Add get_page_tree method to PagesManager --- src/djpress/models/post.py | 45 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/djpress/models/post.py b/src/djpress/models/post.py index f0923c2..ce14085 100644 --- a/src/djpress/models/post.py +++ b/src/djpress/models/post.py @@ -98,6 +98,51 @@ def get_published_page_by_path( return current_page + def get_page_tree(self) -> list[dict["Post", list[dict]]]: + """Return the page tree. + + This returns a list of top-level pages. Each page is a dict containing the Post object and a list of children. + + Used to build the page hierarchy. + + ``` + root_pages = [ + { + 'page': , + 'children': [ + { + 'page': , + 'children': [ + { + 'page': , + 'children': [] + }, + # ... more grandchildren ... + ] + }, + # ... more children ... + ] + }, + # ... more root pages ... + ] + ``` + + Returns: + list[dict["Post", list[dict]]]: A list of top-level pages - each page is a dict containing the Post object + and a list of children. Each child is a dict containing the Post object and a list of children, and so on. + """ + pages = self.get_published_pages().select_related("parent") + page_dict = {page.id: {"page": page, "children": []} for page in pages} + root_pages = [] + for page_data in page_dict.values(): + page = page_data["page"] + if page.parent: + page_dict[page.parent.id]["children"].append(page_data) + + else: + root_pages.append(page_data) + return root_pages + class PostsManager(models.Manager): """Post custom manager.""" From 6455d44d6b990186cc4b5e5eef287e4f9873c1a0 Mon Sep 17 00:00:00 2001 From: Stuart Maxwell Date: Thu, 10 Oct 2024 23:08:36 +1300 Subject: [PATCH 22/27] More test page fixtures --- tests/conftest.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 35604aa..0faf261 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -158,3 +158,27 @@ def test_page3(user): status="published", post_type="page", ) + + +@pytest.fixture +def test_page4(user): + return Post.objects.create( + title="Test Page4", + slug="test-page4", + content="This is test page 4.", + author=user, + status="published", + post_type="page", + ) + + +@pytest.fixture +def test_page5(user): + return Post.objects.create( + title="Test Page5", + slug="test-page5", + content="This is test page 5.", + author=user, + status="published", + post_type="page", + ) From 872f8025194a4cfdb254225f5f8cc89163709736 Mon Sep 17 00:00:00 2001 From: Stuart Maxwell Date: Thu, 10 Oct 2024 23:12:48 +1300 Subject: [PATCH 23/27] New template tag to display nested list of blog pages blog_pages_list --- src/djpress/templatetags/djpress_tags.py | 75 +++++++++++++++++++----- src/djpress/templatetags/helpers.py | 39 ++++++++++++ 2 files changed, 98 insertions(+), 16 deletions(-) diff --git a/src/djpress/templatetags/djpress_tags.py b/src/djpress/templatetags/djpress_tags.py index b2a4835..184ce84 100644 --- a/src/djpress/templatetags/djpress_tags.py +++ b/src/djpress/templatetags/djpress_tags.py @@ -12,12 +12,7 @@ from djpress.conf import settings as djpress_settings from djpress.exceptions import PageNotFoundError from djpress.models import Category, Post -from djpress.templatetags.helpers import ( - categories_html, - category_link, - get_page_link, - post_read_more_link, -) +from djpress.templatetags import helpers from djpress.utils import get_author_display_name register = template.Library() @@ -57,7 +52,7 @@ def get_pages() -> models.QuerySet[Post]: Returns: models.QuerySet[Post]: All pages. """ - return Post.page_objects.get_published_pages().order_by("menu_order").order_by("title") + return Post.page_objects.get_published_pages() @register.simple_tag @@ -67,7 +62,7 @@ def get_categories() -> models.QuerySet[Category] | None: Returns: models.QuerySet[Category]: All categories. """ - return Category.objects.get_categories().order_by("menu_order").order_by("title") + return Category.objects.get_categories().order_by("menu_order", "title") @register.simple_tag @@ -90,7 +85,55 @@ def blog_categories( if not categories: return "" - return mark_safe(categories_html(categories, outer, outer_class, link_class)) + return mark_safe(helpers.categories_html(categories, outer, outer_class, link_class)) + + +@register.simple_tag +def blog_pages_list(ul_outer_class: str = "", li_class: str = "", a_class: str = "", ul_child_class: str = "") -> str: + """Returns an HTML list of the blog pages. + + The pages are sorted by menu order and then by title. Pages that have children have a nested list of children. + + The default output with no arguments is an unordered list with no classes. CSS classes can be added to the output by + specifying the arguments shown below. + + ``` + + ``` + """ + pages = Post.page_objects.get_page_tree() + + output = "" + + if not pages: + return output + + if ul_outer_class: + ul_outer_class = f' class="{ul_outer_class}"' + + output += f"" + output += helpers.get_blog_pages_list(pages, li_class=li_class, a_class=a_class, ul_child_class=ul_child_class) + output += "" + + return mark_safe(output) @register.simple_tag @@ -121,20 +164,20 @@ def blog_pages( if outer == "ul": output += f"" for page in pages: - output += f"
  • {get_page_link(page=page, link_class=link_class)}
  • " + output += f"
  • {helpers.get_page_link(page=page, link_class=link_class)}
  • " output += "" if outer == "div": output += f"" for page in pages: - output += f"{get_page_link(page=page, link_class=link_class)}, " + output += f"{helpers.get_page_link(page=page, link_class=link_class)}, " output = output[:-2] # Remove the trailing comma and space output += "" if outer == "span": output += f"" for page in pages: - output += f"{get_page_link(page=page, link_class=link_class)}, " + output += f"{helpers.get_page_link(page=page, link_class=link_class)}, " output = output[:-2] # Remove the trailing comma and space output += "" @@ -324,7 +367,7 @@ def post_category_link(category: Category, link_class: str = "") -> str: if not djpress_settings.CATEGORY_ENABLED: return category.title - return mark_safe(category_link(category, link_class)) + return mark_safe(helpers.category_link(category, link_class)) @register.simple_tag(takes_context=True) @@ -422,7 +465,7 @@ def post_content( if posts and post: content = mark_safe(post.truncated_content_markdown) if post.is_truncated: - content += post_read_more_link(post, read_more_link_class, read_more_text) + content += helpers.post_read_more_link(post, read_more_link_class, read_more_text) return mark_safe(content) if post: @@ -553,7 +596,7 @@ def post_categories_link( if not categories: return "" - return mark_safe(categories_html(categories, outer, outer_class, link_class)) + return mark_safe(helpers.categories_html(categories, outer, outer_class, link_class)) @register.simple_tag(takes_context=True) @@ -677,7 +720,7 @@ def page_link( except PageNotFoundError: return "" - output = get_page_link(page, link_class=link_class) + output = helpers.get_page_link(page, link_class=link_class) if outer == "li": return mark_safe(f"{output}") diff --git a/src/djpress/templatetags/helpers.py b/src/djpress/templatetags/helpers.py index a801905..9119820 100644 --- a/src/djpress/templatetags/helpers.py +++ b/src/djpress/templatetags/helpers.py @@ -109,3 +109,42 @@ def post_read_more_link( return f'

    {read_more_text}

    ' + +def get_blog_pages_list( + pages: list[dict[Post, list]], + li_class: str = "", + a_class: str = "", + ul_child_class: str = "", +) -> str: + """Return the HTML for the blog pages list. + + This expects to be passed the output of the get_page_tree method. + + Args: + pages: The pages from the get_page_tree method. + li_class: The CSS class(es) for the list item. + a_class: The CSS class(es) for the link. + ul_child_class: The CSS class(es) for the child ul + + Returns: + str: The HTML for the blog pages list. + """ + class_li = f' class="{li_class}"' if li_class else "" + class_ul = f' class="{ul_child_class}"' if ul_child_class else "" + + output = "" + + for page_data in pages: + page = page_data["page"] + children = page_data["children"] + + output += f"{get_page_link(page, link_class=a_class)}" + + if children: + output += f"" + output += get_blog_pages_list(children, li_class=li_class, a_class=a_class, ul_child_class=ul_child_class) + output += "" + + output += "" + + return output From d151bb4c345052b0b505fab77e63c1f5c9dee4d7 Mon Sep 17 00:00:00 2001 From: Stuart Maxwell Date: Thu, 10 Oct 2024 23:12:56 +1300 Subject: [PATCH 24/27] Updated tests --- tests/test_models_post.py | 210 +++++++++++++++- tests/test_templatetags_djpress_tags.py | 322 ++++++++++++++++++++++++ 2 files changed, 523 insertions(+), 9 deletions(-) diff --git a/tests/test_models_post.py b/tests/test_models_post.py index 1371a6b..04baee8 100644 --- a/tests/test_models_post.py +++ b/tests/test_models_post.py @@ -22,13 +22,6 @@ def test_post_model(test_post1, user, category1): assert str(test_post1) == "Test Post1" -@pytest.mark.django_db -def test_post_methods(test_post1, test_post2, category1, category2): - assert Post.post_objects.all().count() == 2 - assert Post.post_objects.get_published_post_by_slug("test-post1").title == "Test Post1" - assert Post.post_objects.get_published_posts_by_category(category1).count() == 1 - - @pytest.mark.django_db def test_get_published_content_with_future_date(user): Post.post_objects.create( @@ -383,9 +376,27 @@ def test_get_published_page_by_slug(test_page1): @pytest.mark.django_db -def test_get_published_pages(test_page1, test_page2): +def test_get_published_pages(test_page1, test_page2, test_page3, test_page4, test_page5): """Test that the get_published_pages method returns the correct pages.""" - assert list(Post.page_objects.get_published_pages()) == [test_page2, test_page1] + assert list(Post.page_objects.get_published_pages()) == [test_page1, test_page2, test_page3, test_page4, test_page5] + + test_page1.status = "draft" + test_page1.save() + assert list(Post.page_objects.get_published_pages()) == [test_page2, test_page3, test_page4, test_page5] + + test_page2.parent = test_page1 + test_page2.save() + assert list(Post.page_objects.get_published_pages()) == [test_page3, test_page4, test_page5] + + test_page3.date = timezone.now() + timezone.timedelta(days=1) + test_page3.save() + assert list(Post.page_objects.get_published_pages()) == [test_page4, test_page5] + + test_page4.parent = test_page3 + test_page4.save() + test_page5.parent = test_page4 + test_page5.save() + assert list(Post.page_objects.get_published_pages()) == [] @pytest.mark.django_db @@ -793,3 +804,184 @@ def test_get_full_page_path_with_grandparent(test_page1, test_page2, test_page3) test_page1.parent = test_page2 test_page2.parent = test_page3 assert test_page1.full_page_path == "test-page3/test-page2/test-page1" + + +@pytest.mark.django_db +def test_page_get_page_tree_no_children(test_page1, test_page2, test_page3, test_page4): + expected_tree = [ + {"page": test_page1, "children": []}, + {"page": test_page2, "children": []}, + {"page": test_page3, "children": []}, + {"page": test_page4, "children": []}, + ] + assert list(Post.page_objects.get_page_tree()) == expected_tree + + +@pytest.mark.django_db +def test_page_get_page_tree_with_children(test_page1, test_page2, test_page3, test_page4): + test_page1.parent = test_page2 + test_page1.save() + test_page3.parent = test_page2 + test_page3.save() + + expected_tree = [ + { + "page": test_page2, + "children": [ + {"page": test_page1, "children": []}, + {"page": test_page3, "children": []}, + ], + }, + {"page": test_page4, "children": []}, + ] + assert Post.page_objects.get_page_tree() == expected_tree + + +@pytest.mark.django_db +def test_page_get_page_tree_with_grandchildren(test_page1, test_page2, test_page3, test_page4, test_page5): + test_page1.parent = test_page2 + test_page1.save() + test_page3.parent = test_page2 + test_page3.save() + test_page2.parent = test_page5 + test_page2.save() + + expected_tree = [ + {"page": test_page4, "children": []}, + { + "page": test_page5, + "children": [ + { + "page": test_page2, + "children": [ + {"page": test_page1, "children": []}, + {"page": test_page3, "children": []}, + ], + }, + ], + }, + ] + assert Post.page_objects.get_page_tree() == expected_tree + + +@pytest.mark.django_db +def test_page_get_page_tree_with_grandchildren_parent_with_future_date( + test_page1, test_page2, test_page3, test_page4, test_page5 +): + test_page1.parent = test_page2 + test_page1.save() + test_page3.parent = test_page2 + test_page3.save() + test_page2.parent = test_page5 + test_page2.date = timezone.now() + timezone.timedelta(days=1) + test_page2.save() + + expected_tree = [ + {"page": test_page4, "children": []}, + { + "page": test_page5, + "children": [], + }, + ] + assert Post.page_objects.get_page_tree() == expected_tree + + +@pytest.mark.django_db +def test_page_get_page_tree_with_grandchildren_parent_with_status_draft( + test_page1, test_page2, test_page3, test_page4, test_page5 +): + test_page1.parent = test_page2 + test_page1.save() + test_page3.parent = test_page2 + test_page3.save() + test_page2.parent = test_page5 + test_page2.status = "draft" + test_page2.save() + + expected_tree = [ + {"page": test_page4, "children": []}, + { + "page": test_page5, + "children": [], + }, + ] + assert Post.page_objects.get_page_tree() == expected_tree + + +@pytest.mark.django_db +def test_page_order_menu_order(test_page1, test_page2, test_page3, test_page4, test_page5): + test_page1.menu_order = 1 + test_page1.save() + test_page2.menu_order = 2 + test_page2.save() + test_page3.menu_order = 3 + test_page3.save() + test_page4.menu_order = 4 + test_page4.save() + test_page5.menu_order = 5 + test_page5.save() + + expected_order = [test_page1, test_page2, test_page3, test_page4, test_page5] + + assert list(Post.page_objects.get_published_pages()) == expected_order + + +@pytest.mark.django_db +def test_page_order_title(test_page1, test_page2, test_page3, test_page4, test_page5): + test_page1.menu_order = 1 + test_page1.save() + test_page2.menu_order = 1 + test_page2.save() + test_page3.menu_order = 1 + test_page3.save() + test_page4.menu_order = 1 + test_page4.save() + test_page5.menu_order = 1 + test_page5.save() + + expected_order = [test_page1, test_page2, test_page3, test_page4, test_page5] + + assert list(Post.page_objects.get_published_pages()) == expected_order + + +@pytest.mark.django_db +def test_page_is_published(test_page1, test_page2, test_page3, test_page4, test_page5): + assert test_page1.is_published is True + assert test_page2.is_published is True + assert test_page3.is_published is True + + test_page1.status = "draft" + test_page1.save() + assert test_page1.is_published is False + + test_page2.parent = test_page1 + test_page2.save() + assert test_page2.is_published is False + + test_page2.parent = test_page3 + test_page2.save() + assert test_page2.is_published is True + + # change test_page3 to be in the future + test_page3.date = timezone.now() + timezone.timedelta(days=1) + test_page3.save() + assert test_page2.is_published is False + assert test_page3.is_published is False + + # Change test_page3 to be published again + test_page3.date = timezone.now() + test_page3.save() + test_page3.parent = test_page4 + test_page3.save() + assert test_page3.is_published is True + + test_page4.parent = test_page5 + test_page4.save() + assert test_page3.is_published is True + assert test_page4.is_published is True + + test_page5.status = "draft" + test_page5.save() + assert test_page3.is_published is False + assert test_page4.is_published is False + assert test_page5.is_published is False diff --git a/tests/test_templatetags_djpress_tags.py b/tests/test_templatetags_djpress_tags.py index 8328ded..56315a9 100644 --- a/tests/test_templatetags_djpress_tags.py +++ b/tests/test_templatetags_djpress_tags.py @@ -661,6 +661,328 @@ def test_blog_categories_no_categories(): assert djpress_tags.blog_categories() == "" +@pytest.mark.django_db +def test_blog_pages_list_no_pages(): + assert djpress_tags.blog_pages_list() == "" + + +@pytest.mark.django_db +def test_blog_pages_list_no_children(test_page1, test_page2, test_page3): + expected_output = ( + "
      " + f"
    • {get_page_link(page=test_page1)}
    • " + f"
    • {get_page_link(page=test_page2)}
    • " + f"
    • {get_page_link(page=test_page3)}
    • " + "
    " + ) + + assert djpress_tags.blog_pages_list() == expected_output + + +@pytest.mark.django_db +def test_blog_pages_list_no_children_with_classes(test_page1, test_page2, test_page3): + expected_output = ( + '
      ' + f'
    • {get_page_link(page=test_page1, link_class="a-class")}
    • ' + f'
    • {get_page_link(page=test_page2, link_class="a-class")}
    • ' + f'
    • {get_page_link(page=test_page3, link_class="a-class")}
    • ' + "
    " + ) + + assert ( + djpress_tags.blog_pages_list( + ul_outer_class="ul-outer-class", li_class="li-class", a_class="a-class", ul_child_class="ul-child-class" + ) + == expected_output + ) + + +@pytest.mark.django_db +def test_blog_pages_list_one_child(test_page1, test_page2, test_page3): + test_page2.parent = test_page1 + test_page2.save() + + expected_output = ( + "
      " + f"
    • {get_page_link(page=test_page1)}" + "
        " + f"
      • {get_page_link(page=test_page2)}
      • " + "
      " + "
    • " + f"
    • {get_page_link(page=test_page3)}
    • " + "
    " + ) + + assert djpress_tags.blog_pages_list() == expected_output + + +@pytest.mark.django_db +def test_blog_pages_list_one_child_with_classes(test_page1, test_page2, test_page3): + test_page2.parent = test_page1 + test_page2.save() + + expected_output = ( + '
      ' + f'
    • {get_page_link(page=test_page1, link_class="a-class")}' + '
        ' + f'
      • {get_page_link(page=test_page2, link_class="a-class")}
      • ' + "
      " + "
    • " + f'
    • {get_page_link(page=test_page3, link_class="a-class")}
    • ' + "
    " + ) + + assert ( + djpress_tags.blog_pages_list( + ul_outer_class="ul-outer-class", li_class="li-class", a_class="a-class", ul_child_class="ul-child-class" + ) + == expected_output + ) + + +@pytest.mark.django_db +def test_blog_pages_list_two_children(test_page1, test_page2, test_page3, test_page4): + test_page3.parent = test_page1 + test_page3.save() + test_page4.parent = test_page2 + test_page4.save() + + expected_output = ( + "
      " + f"
    • {get_page_link(page=test_page1)}" + "
        " + f"
      • {get_page_link(page=test_page3)}
      • " + "
      " + "
    • " + f"
    • {get_page_link(page=test_page2)}" + "
        " + f"
      • {get_page_link(page=test_page4)}
      • " + "
      " + "
    • " + "
    " + ) + + assert djpress_tags.blog_pages_list() == expected_output + + +@pytest.mark.django_db +def test_blog_pages_list_two_children_with_classes(test_page1, test_page2, test_page3, test_page4): + test_page3.parent = test_page1 + test_page3.save() + test_page4.parent = test_page2 + test_page4.save() + + expected_output = ( + '
      ' + f'
    • {get_page_link(page=test_page1, link_class="a-class")}' + '
        ' + f'
      • {get_page_link(page=test_page3, link_class="a-class")}
      • ' + "
      " + "
    • " + f'
    • {get_page_link(page=test_page2, link_class="a-class")}' + '
        ' + f'
      • {get_page_link(page=test_page4, link_class="a-class")}
      • ' + "
      " + "
    • " + "
    " + ) + + assert ( + djpress_tags.blog_pages_list( + ul_outer_class="ul-outer-class", li_class="li-class", a_class="a-class", ul_child_class="ul-child-class" + ) + == expected_output + ) + + +@pytest.mark.django_db +def test_blog_pages_list_child_grandchild(test_page1, test_page2, test_page3, test_page4): + test_page2.parent = test_page1 + test_page2.save() + test_page3.parent = test_page2 + test_page3.save() + + expected_output = ( + "
      " + f"
    • {get_page_link(page=test_page1)}" + "
        " + f"
      • {get_page_link(page=test_page2)}" + "
          " + f"
        • {get_page_link(page=test_page3)}
        • " + "
        " + "
      • " + "
      " + "
    • " + f"
    • {get_page_link(page=test_page4)}
    • " + "
    " + ) + + assert djpress_tags.blog_pages_list() == expected_output + + +@pytest.mark.django_db +def test_blog_pages_list_child_grandchild_with_classes(test_page1, test_page2, test_page3, test_page4): + test_page2.parent = test_page1 + test_page2.save() + test_page3.parent = test_page2 + test_page3.save() + + expected_output = ( + '
      ' + f'
    • {get_page_link(page=test_page1, link_class="a-class")}' + '
        ' + f'
      • {get_page_link(page=test_page2, link_class="a-class")}' + '
          ' + f'
        • {get_page_link(page=test_page3, link_class="a-class")}
        • ' + "
        " + "
      • " + "
      " + "
    • " + f'
    • {get_page_link(page=test_page4, link_class="a-class")}
    • ' + "
    " + ) + + assert ( + djpress_tags.blog_pages_list( + ul_outer_class="ul-outer-class", li_class="li-class", a_class="a-class", ul_child_class="ul-child-class" + ) + == expected_output + ) + + +@pytest.mark.django_db +def test_blog_pages_list_child_greatgrandchild(test_page1, test_page2, test_page3, test_page4): + test_page2.parent = test_page1 + test_page2.save() + test_page3.parent = test_page2 + test_page3.save() + test_page4.parent = test_page3 + test_page4.save() + + expected_output = ( + "
      " + f"
    • {get_page_link(page=test_page1)}" + "
        " + f"
      • {get_page_link(page=test_page2)}" + "
          " + f"
        • {get_page_link(page=test_page3)}" + "
            " + f"
          • {get_page_link(page=test_page4)}
          • " + "
          " + "
        • " + "
        " + "
      • " + "
      " + "
    • " + "
    " + ) + + assert djpress_tags.blog_pages_list() == expected_output + + +@pytest.mark.django_db +def test_blog_pages_list_child_greatgrandchild_with_classes(test_page1, test_page2, test_page3, test_page4): + test_page2.parent = test_page1 + test_page2.save() + test_page3.parent = test_page2 + test_page3.save() + test_page4.parent = test_page3 + test_page4.save() + + expected_output = ( + '
      ' + f'
    • {get_page_link(page=test_page1, link_class="a-class")}' + '
        ' + f'
      • {get_page_link(page=test_page2, link_class="a-class")}' + '
          ' + f'
        • {get_page_link(page=test_page3, link_class="a-class")}' + '
            ' + f'
          • {get_page_link(page=test_page4, link_class="a-class")}
          • ' + "
          " + "
        • " + "
        " + "
      • " + "
      " + "
    • " + "
    " + ) + + assert ( + djpress_tags.blog_pages_list( + ul_outer_class="ul-outer-class", li_class="li-class", a_class="a-class", ul_child_class="ul-child-class" + ) + == expected_output + ) + + +@pytest.mark.django_db +def test_blog_pages_list_child_change_order(test_page1, test_page2, test_page3, test_page4, test_page5): + test_page5.menu_order = 1 + test_page5.save() + test_page1.menu_order = 2 + test_page1.save() + test_page2.parent = test_page1 + test_page2.save() + test_page3.parent = test_page2 + test_page3.save() + test_page4.parent = test_page1 + test_page4.save() + + expected_output = ( + "
      " + f"
    • {get_page_link(page=test_page5)}
    • " + f"
    • {get_page_link(page=test_page1)}" + "
        " + f"
      • {get_page_link(page=test_page2)}" + "
          " + f"
        • {get_page_link(page=test_page3)}
        • " + "
        " + "
      • " + f"
      • {get_page_link(page=test_page4)}
      • " + "
      " + "
    • " + "
    " + ) + + assert djpress_tags.blog_pages_list() == expected_output + + +@pytest.mark.django_db +def test_blog_pages_list_child_greatgreatgrandchild(test_page1, test_page2, test_page3, test_page4, test_page5): + test_page2.parent = test_page1 + test_page2.save() + test_page3.parent = test_page2 + test_page3.save() + test_page4.parent = test_page3 + test_page4.save() + test_page5.parent = test_page4 + test_page5.save() + + expected_output = ( + "
      " + f"
    • {get_page_link(page=test_page1)}" + "
        " + f"
      • {get_page_link(page=test_page2)}" + "
          " + f"
        • {get_page_link(page=test_page3)}" + "
            " + f"
          • {get_page_link(page=test_page4)}" + "
              " + f"
            • {get_page_link(page=test_page5)}
            • " + "
            " + "
          • " + "
          " + "
        • " + "
        " + "
      • " + "
      " + "
    • " + "
    " + ) + + assert djpress_tags.blog_pages_list() == expected_output + + @pytest.mark.django_db def test_blog_pages_no_pages(): assert djpress_tags.blog_pages() == "" From 701cc797344bfc9d0acf2f0fe9dfb5bff8affc6a Mon Sep 17 00:00:00 2001 From: Stuart Maxwell Date: Thu, 10 Oct 2024 23:27:53 +1300 Subject: [PATCH 25/27] Add missing args to docstring --- src/djpress/templatetags/djpress_tags.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/djpress/templatetags/djpress_tags.py b/src/djpress/templatetags/djpress_tags.py index 184ce84..4c3998c 100644 --- a/src/djpress/templatetags/djpress_tags.py +++ b/src/djpress/templatetags/djpress_tags.py @@ -118,6 +118,15 @@ def blog_pages_list(ul_outer_class: str = "", li_class: str = "", a_class: str = ``` + + Args: + ul_outer_class (str): The CSS class(es) for the outer unordered list. + li_class (str): The CSS class(es) for the + a_class (str): The CSS class(es) for the anchor tags. + ul_child_class (str): The CSS class(es) for the nested unordered lists. + + Returns: + str: The HTML list of the blog pages. """ pages = Post.page_objects.get_page_tree() From 243c70c64c5824cd332cf974873ea138ae83c3dd Mon Sep 17 00:00:00 2001 From: Stuart Maxwell Date: Thu, 10 Oct 2024 23:28:00 +1300 Subject: [PATCH 26/27] Update docs --- docs/templatetags.md | 72 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/docs/templatetags.md b/docs/templatetags.md index 2cf52c5..9510e87 100644 --- a/docs/templatetags.md +++ b/docs/templatetags.md @@ -278,7 +278,7 @@ Outputs the same comma-separated list of categories, but wrapped in a `span` tag ## blog_pages -Get all blog pages as a list, wrapped in HTML that can be configured with optional arguments. +Get all blog pages as a single-level list, wrapped in HTML that can be configured with optional arguments. ### Arguments @@ -339,6 +339,76 @@ Outputs the same comma-separated list of pages, but wrapped in a `span` tag. ``` +## blog_pages_list + +Get all published blog pages and output as a nested list to support parent pages. + +### Arguments + +- `ul_outer_class` (optional): The CSS class(es) for the outer unordered list. +- `li_class` (optional): The CSS class(es) for the list item tags +- `a_class` (optional): The CSS class(es) for the anchor tags. +- `ul_child_class` (optional): The CSS class(es) for the nested unordered lists. + +The output from this tag is HTML which has been marked as safe. + +### Examples + +Just the tag, with no arguments. + +```django +{% blog_pages %} +``` + +This will output the following HTML: + +```html + +``` + +Or arguments can be used to build a Bootstrap-like navbar menu. + +```django +{% blog_pages ul_outer_class="navbar-nav" li_class="nav-item" a_class="nav-link" ul_child_class="dropdown-menu" %} +``` + +This will output the following HTML: + +```html + +``` + + ## have_posts Get a list of posts from the current context. This always returns a list, even if the context only contains a single post or page. From 2ec8b6c5cabe1c4354f448e366e77654fd7b4b97 Mon Sep 17 00:00:00 2001 From: Stuart Maxwell Date: Thu, 10 Oct 2024 23:30:45 +1300 Subject: [PATCH 27/27] Bump version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a658999..1d2c2d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "djpress" -version = "0.9.2" +version = "0.9.3" description = "A blog application for Django sites, inspired by classic WordPress." readme = "README.md" requires-python = ">=3.10"