Skip to content

Commit

Permalink
Change slug detection to work with permalinks, but dates currenly ign…
Browse files Browse the repository at this point in the history
…ored
  • Loading branch information
stuartmaxwell committed Sep 25, 2024
1 parent 645e513 commit 27c6df7
Show file tree
Hide file tree
Showing 5 changed files with 354 additions and 57 deletions.
47 changes: 19 additions & 28 deletions src/djpress/models/post.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

from djpress.conf import settings
from djpress.models import Category
from djpress.utils import render_markdown
from djpress.utils import extract_parts_from_path, render_markdown

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -65,6 +65,8 @@ def get_published_page_by_path(
"""Return a single published post from a path.
For now, we'll only allow a top level path.
This will raise a ValueError if the path is invalid.
"""
# Check for a single item in the path
if path.count("/") > 0:
Expand Down Expand Up @@ -127,9 +129,7 @@ def _get_cached_recent_published_posts(self: "PostsManager") -> models.QuerySet:

timeout = self._get_cache_timeout(queryset)

queryset = queryset.filter(date__lte=timezone.now())[
: settings.RECENT_PUBLISHED_POSTS_COUNT
]
queryset = queryset.filter(date__lte=timezone.now())[: settings.RECENT_PUBLISHED_POSTS_COUNT]
cache.set(
PUBLISHED_POSTS_CACHE_KEY,
queryset,
Expand Down Expand Up @@ -189,9 +189,16 @@ def get_published_post_by_path(
) -> "Post":
"""Return a single published post from a path.
This is a complex piece of logic...
This is a complex piece of logic that needs to extract the slug from the following path structures:
`/{POST_PREFIX}/{POST_PERMALINK}/{slug}`
- POST_PREFIX is optional and could be an empty string or a custom string.
- POST_PERMALINK is optional and could be an empty string or a custom date format string.
- slug is everything after the slash
Here are all the different valid paths that we need to check:
- /blog/2021/01/01/post-slug
- /blog/2021/01/post-slug
- /blog/2021/post-slug
Expand All @@ -210,22 +217,17 @@ def get_published_post_by_path(
First, we need to know the following:
- Is there a POST_PREFIX defined?
- Is there a POST_PERMALINK defined?
- Are DATE_ARCHIVES enabled?
- If DATE_ARCHIVES are enabled, what is the POST_PERMALINK set to?
I don't think I can avoid regex matching here...
For now, we'll just look at the POST_PREFIX.
"""
if settings.POST_PREFIX and path.startswith(settings.POST_PREFIX):
slug = path.split(settings.POST_PREFIX + "/")[1]
return self.get_published_post_by_slug(slug)
# This will raise a SlugNotFoundError exception if the path is invalid
path_parts = extract_parts_from_path(path)

if settings.POST_PREFIX and not path.startswith(settings.POST_PREFIX):
msg = "Invalid path"
raise ValueError(msg)

return self.get_published_post_by_slug(path)
return self.get_published_post_by_slug(path_parts.slug)

def get_published_posts_by_category(
self: "PostsManager",
Expand All @@ -236,11 +238,7 @@ def get_published_posts_by_category(
Must have a date less than or equal to the current date/time for a specific
category, ordered by date in descending order.
"""
return (
self.get_published_posts()
.filter(categories=category)
.prefetch_related("categories", "author")
)
return self.get_published_posts().filter(categories=category).prefetch_related("categories", "author")

def get_published_posts_by_author(
self: "PostsManager",
Expand All @@ -251,11 +249,7 @@ def get_published_posts_by_author(
Must have a date less than or equal to the current date/time for a specific
author, ordered by date in descending order.
"""
return (
self.get_published_posts()
.filter(author=author)
.prefetch_related("categories", "author")
)
return self.get_published_posts().filter(author=author).prefetch_related("categories", "author")


class Post(models.Model):
Expand Down Expand Up @@ -312,10 +306,7 @@ def content_markdown(self: "Post") -> str:
def truncated_content_markdown(self: "Post") -> str:
"""Return the truncated content as HTML converted from Markdown."""
read_more_index = self.content.find(settings.TRUNCATE_TAG)
if read_more_index != -1:
truncated_content = self.content[:read_more_index]
else:
truncated_content = self.content
truncated_content = self.content[:read_more_index] if read_more_index != -1 else self.content
return render_markdown(truncated_content)

@property
Expand Down
91 changes: 88 additions & 3 deletions src/djpress/utils.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
"""Utility functions that are used in the project."""

import re
from typing import NamedTuple

import markdown
from django.contrib.auth.models import User
from django.template.loader import TemplateDoesNotExist, select_template
from django.utils import timezone

from djpress.conf import settings
from djpress.exceptions import SlugNotFoundError

md = markdown.Markdown(
extensions=settings.MARKDOWN_EXTENSIONS,
Expand Down Expand Up @@ -74,13 +78,13 @@ def validate_date(year: str, month: str, day: str) -> None:

try:
if int_month and int_day:
timezone.datetime(int_year, int_month, int_day)
timezone.make_aware(timezone.datetime(int_year, int_month, int_day))

elif int_month:
timezone.datetime(int_year, int_month, 1)
timezone.make_aware(timezone.datetime(int_year, int_month, 1))

else:
timezone.datetime(int_year, 1, 1)
timezone.make_aware(timezone.datetime(int_year, 1, 1))

except ValueError as exc:
msg = "Invalid date"
Expand All @@ -103,3 +107,84 @@ def get_template_name(templates: list[str]) -> str:
raise TemplateDoesNotExist(msg) from exc

return template


class PathParts(NamedTuple):
"""Named tuple for the path parts.
These are extracted by the extract_parts_from_path function.
Attributes:
year (int | None): The year.
month (int | None): The month.
day (int | None): The day.
slug (str): The slug.
"""

year: int | None
month: int | None
day: int | None
slug: str


def extract_parts_from_path(path: str) -> PathParts:
"""Extract the parts from the path.
Args:
path (str): The path.
Returns:
PathParts: The parts extracted from the path.
"""
# Remove leading and trailing slashes
path = path.strip("/")

# Build the regex pattern
pattern_parts = []

post_prefix = settings.POST_PREFIX
post_permalink = settings.POST_PERMALINK

# Add the post prefix to the pattern, if it exists
if post_prefix:
pattern_parts.append(re.escape(post_prefix))

# Add the date parts based on the permalink structure
if post_permalink:
if "%Y" in post_permalink:
post_permalink = post_permalink.replace("%Y", r"(?P<year>\d{4})")
if "%m" in post_permalink:
post_permalink = post_permalink.replace("%m", r"(?P<month>\d{2})")
if "%d" in post_permalink:
post_permalink = post_permalink.replace("%d", r"(?P<day>\d{2})")
pattern_parts.append(post_permalink)

# Add the slug capture group
pattern_parts.append(r"(?P<slug>[0-9A-Za-z_/-]+)") # TODO: repeated code - urls.py

# Join patterns with optional slashes
pattern = "^" + "/".join(f"(?:{part})" for part in pattern_parts) + "$"

# Attempt to match the pattern
match = re.match(pattern, path)

if not match:
msg = "Slug could not be found in the provided path."
raise SlugNotFoundError(msg)

# Extract the date parts and slug
year = match.group("year") if "year" in match.groupdict() else None
month = match.group("month") if "month" in match.groupdict() else None
day = match.group("day") if "day" in match.groupdict() else None
slug = match.group("slug") if "slug" in match.groupdict() else None

if not slug:
msg = "Slug could not be found in the provided path."
raise SlugNotFoundError(msg)

# Convert year, month, day to integers (or None if not present)
year = int(year) if year else None
month = int(month) if month else None
day = int(day) if day else None

return PathParts(year=year, month=month, day=day, slug=slug)
6 changes: 6 additions & 0 deletions src/djpress/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from django.shortcuts import render

from djpress.conf import settings
from djpress.exceptions import SlugNotFoundError
from djpress.models import Category, Post
from djpress.utils import get_template_name, validate_date

Expand Down Expand Up @@ -223,9 +224,14 @@ def post_detail(request: HttpRequest, path: str) -> HttpResponse:
# If the page is found, use the page template
template_names.insert(0, "djpress/page.html")
except ValueError:
# A ValueError means we were able to parse the path, but the page was not found
try:
post = Post.post_objects.get_published_post_by_path(path)
context: dict = {"post": post}
except SlugNotFoundError as exc:
# A SlugNotFoundError means we were not able to parse the path
msg = "Post not found"
raise Http404(msg) from exc
except ValueError as exc:
msg = "Post not found"
raise Http404(msg) from exc
Expand Down
34 changes: 22 additions & 12 deletions tests/test_models_post.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from unittest.mock import patch

from djpress.models.post import PUBLISHED_POSTS_CACHE_KEY
from djpress.exceptions import SlugNotFoundError


@pytest.fixture
Expand Down Expand Up @@ -94,9 +95,7 @@ def test_post_methods(test_post1, test_post2, category1, category2):
test_post1.categories.add(category1)

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_post_by_slug("test-post1").title == "Test Post1"
assert Post.post_objects.get_published_posts_by_category(category1).count() == 1
assert Post.post_objects.get_published_posts_by_category(category2).count() == 0

Expand Down Expand Up @@ -319,7 +318,7 @@ def test_post_permalink(user):
slug="test-post",
content="This is a test post.",
author=user,
date=timezone.datetime(2024, 1, 1),
date=timezone.make_aware(timezone.datetime(2024, 1, 1)),
status="published",
post_type="post",
)
Expand Down Expand Up @@ -451,27 +450,40 @@ def test_get_published_post_by_path(user):

# Confirm settings are set according to settings_testing.py
assert settings.POST_PREFIX == "test-posts"
assert settings.POST_PERMALINK == ""

# Create a post
post = Post.objects.create(title="Test Post", status="published", author=user)
post = Post.objects.create(
title="Test Post",
status="published",
author=user,
date=timezone.make_aware(timezone.datetime(2024, 1, 1)),
)

# Test case 1: POST_PREFIX is set and path starts with POST_PREFIX
# Test case 1: POST_PREFIX is set and no POST_PERMALINK
post_path = f"test-posts/{post.slug}"
assert post == Post.post_objects.get_published_post_by_path(post_path)

# Test case 2: POST_PREFIX is set but path does not start with POST_PREFIX
post_path = f"/incorrect-path/{post.slug}"
# Should raise a ValueError
with pytest.raises(ValueError):
# Should raise a SlugNotFoundError since we can't parse the path to get the slug
with pytest.raises(SlugNotFoundError):
Post.post_objects.get_published_post_by_path(post_path)

# Test case 3: POST_PREFIX is not set but path starts with POST_PREFIX
settings.set("POST_PREFIX", "")
post_path = f"test-posts/non-existent-slug"
# Should raise a ValueError
# Should raise a ValueError since we can parse the path but the slug doesn't exist
with pytest.raises(ValueError):
Post.post_objects.get_published_post_by_path(post_path)

# # Test case 4: POST_PREFIX is set and POST_PERMALINK is set
# settings.set("POST_PERMALINK", "%Y/%m/%d")
# assert settings.POST_PREFIX == ""
# assert settings.POST_PERMALINK == "%Y/%m/%d"
# post_path = f"2024/01/01/{post.slug}"
# # assert post == Post.post_objects.get_published_post_by_path(post_path)

# Set back to default
settings.set("POST_PREFIX", "test-posts")

Expand Down Expand Up @@ -561,9 +573,7 @@ def test_get_cached_recent_published_posts(user, mock_timezone_now, monkeypatch)

# Check if the timeout is correct (should be close to 2 hours)
expected_timeout = 7200 # 2 hours in seconds
actual_timeout = (
kwargs.get("timeout") or args[2]
) # timeout might be a kwarg or the third positional arg
actual_timeout = kwargs.get("timeout") or args[2] # timeout might be a kwarg or the third positional arg
assert abs(actual_timeout - expected_timeout) < 5 # Allow a small margin of error

settings.set("CACHE_RECENT_PUBLISHED_POSTS", False)
Expand Down
Loading

0 comments on commit 27c6df7

Please sign in to comment.