djpress 0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- djpress-0.1/LICENSE +21 -0
- djpress-0.1/MANIFEST.in +5 -0
- djpress-0.1/PKG-INFO +57 -0
- djpress-0.1/README.md +5 -0
- djpress-0.1/djpress/__init__.py +1 -0
- djpress-0.1/djpress/admin.py +9 -0
- djpress-0.1/djpress/apps.py +15 -0
- djpress-0.1/djpress/feeds.py +42 -0
- djpress-0.1/djpress/migrations/0001_initial.py +50 -0
- djpress-0.1/djpress/migrations/0002_rename_content_type_post_post_type.py +18 -0
- djpress-0.1/djpress/migrations/__init__.py +1 -0
- djpress-0.1/djpress/models/__init__.py +4 -0
- djpress-0.1/djpress/models/category.py +96 -0
- djpress-0.1/djpress/models/post.py +277 -0
- djpress-0.1/djpress/signals.py +28 -0
- djpress-0.1/djpress/static/djpress/css/style.css +61 -0
- djpress-0.1/djpress/static/djpress/css/style.min.css +1 -0
- djpress-0.1/djpress/templates/djpress/index.html +90 -0
- djpress-0.1/djpress/templatetags/__init__.py +1 -0
- djpress-0.1/djpress/templatetags/djpress_tags.py +319 -0
- djpress-0.1/djpress/tests/__init__.py +1 -0
- djpress-0.1/djpress/tests/test_category_cache.py +91 -0
- djpress-0.1/djpress/tests/test_djpress_tags.py +406 -0
- djpress-0.1/djpress/tests/test_feeds.py +64 -0
- djpress-0.1/djpress/tests/test_models.py +411 -0
- djpress-0.1/djpress/tests/test_post_authors.py +33 -0
- djpress-0.1/djpress/tests/test_published_posts_cache.py +124 -0
- djpress-0.1/djpress/tests/test_views.py +148 -0
- djpress-0.1/djpress/urls.py +69 -0
- djpress-0.1/djpress/utils.py +36 -0
- djpress-0.1/djpress/views.py +155 -0
- djpress-0.1/djpress.egg-info/PKG-INFO +57 -0
- djpress-0.1/djpress.egg-info/SOURCES.txt +38 -0
- djpress-0.1/djpress.egg-info/dependency_links.txt +1 -0
- djpress-0.1/djpress.egg-info/requires.txt +2 -0
- djpress-0.1/djpress.egg-info/top_level.txt +1 -0
- djpress-0.1/docs/index.md +1 -0
- djpress-0.1/pyproject.toml +36 -0
- djpress-0.1/setup.cfg +4 -0
- djpress-0.1/setup.py +3 -0
djpress-0.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Stuart Maxwell
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
djpress-0.1/MANIFEST.in
ADDED
djpress-0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: djpress
|
|
3
|
+
Version: 0.1
|
|
4
|
+
Summary: A blog application for Django sites, inspired by classic WordPress.
|
|
5
|
+
Author-email: Stuart Maxwell <stuart@amanzi.nz>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2024 Stuart Maxwell
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://github.com/stuartmaxwell/djpress
|
|
29
|
+
Project-URL: Repository, https://github.com/stuartmaxwell/djpress
|
|
30
|
+
Project-URL: Issues, https://github.com/stuartmaxwell/djpress/issues
|
|
31
|
+
Keywords: django,blog,cms,markdown,wordpress
|
|
32
|
+
Classifier: Development Status :: 3 - Alpha
|
|
33
|
+
Classifier: Programming Language :: Python :: 3
|
|
34
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
35
|
+
Classifier: Operating System :: OS Independent
|
|
36
|
+
Classifier: Environment :: Web Environment
|
|
37
|
+
Classifier: Framework :: Django
|
|
38
|
+
Classifier: Framework :: Django :: 5.0
|
|
39
|
+
Classifier: Intended Audience :: Developers
|
|
40
|
+
Classifier: Programming Language :: Python
|
|
41
|
+
Classifier: Programming Language :: Python :: 3
|
|
42
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
43
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
44
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
45
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
46
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
|
|
47
|
+
Requires-Python: >=3.11
|
|
48
|
+
Description-Content-Type: text/markdown
|
|
49
|
+
License-File: LICENSE
|
|
50
|
+
Requires-Dist: django
|
|
51
|
+
Requires-Dist: markdown
|
|
52
|
+
|
|
53
|
+
# DJ Press
|
|
54
|
+
|
|
55
|
+
A blog application for Django sites, inspired by classic WordPress.
|
|
56
|
+
|
|
57
|
+
> Warning - very alpha.
|
djpress-0.1/README.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""djpress module."""
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Djpress app configuration."""
|
|
2
|
+
|
|
3
|
+
from django.apps import AppConfig
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DjpressConfig(AppConfig):
|
|
7
|
+
"""Djpress app configuration."""
|
|
8
|
+
|
|
9
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
10
|
+
name = "djpress"
|
|
11
|
+
label = "djpress"
|
|
12
|
+
|
|
13
|
+
def ready(self: "DjpressConfig") -> None:
|
|
14
|
+
"""Import signals."""
|
|
15
|
+
import djpress.signals # noqa: F401
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""RSS feed for blog posts."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from django.contrib.syndication.views import Feed
|
|
6
|
+
from django.urls import reverse
|
|
7
|
+
|
|
8
|
+
from djpress.models import Post
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from django.db import models
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PostFeed(Feed):
|
|
15
|
+
"""RSS feed for blog posts."""
|
|
16
|
+
|
|
17
|
+
title = "stuartm.nz"
|
|
18
|
+
link = "/rss"
|
|
19
|
+
description = "stuartm.nz updates"
|
|
20
|
+
|
|
21
|
+
def items(self: "PostFeed") -> "models.QuerySet":
|
|
22
|
+
"""Return the most recent posts."""
|
|
23
|
+
return Post.post_objects.get_recent_published_posts()
|
|
24
|
+
|
|
25
|
+
def item_title(self: "PostFeed", item: Post) -> str:
|
|
26
|
+
"""Return the title of the post."""
|
|
27
|
+
return item.title
|
|
28
|
+
|
|
29
|
+
def item_description(self: "PostFeed", item: Post) -> str:
|
|
30
|
+
"""Return the description of the post.
|
|
31
|
+
|
|
32
|
+
This is taken from the truncated content of the post converted to HTML from
|
|
33
|
+
Markdown.
|
|
34
|
+
"""
|
|
35
|
+
description = item.truncated_content_markdown
|
|
36
|
+
if item.is_truncated:
|
|
37
|
+
description += f'<p><a href="{self.item_link(item)}">Read more</a></p>'
|
|
38
|
+
return description
|
|
39
|
+
|
|
40
|
+
def item_link(self: "PostFeed", item: Post) -> str:
|
|
41
|
+
"""Return the link to the post."""
|
|
42
|
+
return reverse("djpress:post_detail", args=[item.permalink])
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Generated by Django 5.0.4 on 2024-05-02 10:46
|
|
2
|
+
|
|
3
|
+
import django.db.models.deletion
|
|
4
|
+
import django.utils.timezone
|
|
5
|
+
from django.conf import settings
|
|
6
|
+
from django.db import migrations, models
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Migration(migrations.Migration):
|
|
10
|
+
|
|
11
|
+
initial = True
|
|
12
|
+
|
|
13
|
+
dependencies = [
|
|
14
|
+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
operations = [
|
|
18
|
+
migrations.CreateModel(
|
|
19
|
+
name='Category',
|
|
20
|
+
fields=[
|
|
21
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
22
|
+
('name', models.CharField(max_length=100)),
|
|
23
|
+
('slug', models.SlugField(blank=True, unique=True)),
|
|
24
|
+
('description', models.TextField(blank=True)),
|
|
25
|
+
],
|
|
26
|
+
options={
|
|
27
|
+
'verbose_name': 'category',
|
|
28
|
+
'verbose_name_plural': 'categories',
|
|
29
|
+
},
|
|
30
|
+
),
|
|
31
|
+
migrations.CreateModel(
|
|
32
|
+
name='Post',
|
|
33
|
+
fields=[
|
|
34
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
35
|
+
('title', models.CharField(max_length=200)),
|
|
36
|
+
('slug', models.SlugField(blank=True, unique=True)),
|
|
37
|
+
('content', models.TextField()),
|
|
38
|
+
('date', models.DateTimeField(default=django.utils.timezone.now)),
|
|
39
|
+
('modified_date', models.DateTimeField(auto_now=True)),
|
|
40
|
+
('status', models.CharField(choices=[('draft', 'Draft'), ('published', 'Published')], default='draft', max_length=10)),
|
|
41
|
+
('content_type', models.CharField(choices=[('post', 'Post'), ('page', 'Page')], default='post', max_length=10)),
|
|
42
|
+
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
|
43
|
+
('categories', models.ManyToManyField(blank=True, to='djpress.category')),
|
|
44
|
+
],
|
|
45
|
+
options={
|
|
46
|
+
'verbose_name': 'post',
|
|
47
|
+
'verbose_name_plural': 'posts',
|
|
48
|
+
},
|
|
49
|
+
),
|
|
50
|
+
]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Generated by Django 5.0.4 on 2024-05-03 11:04
|
|
2
|
+
|
|
3
|
+
from django.db import migrations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
|
|
8
|
+
dependencies = [
|
|
9
|
+
('djpress', '0001_initial'),
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
operations = [
|
|
13
|
+
migrations.RenameField(
|
|
14
|
+
model_name='post',
|
|
15
|
+
old_name='content_type',
|
|
16
|
+
new_name='post_type',
|
|
17
|
+
),
|
|
18
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Migrations for the djpress app."""
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Category model."""
|
|
2
|
+
|
|
3
|
+
from django.conf import settings
|
|
4
|
+
from django.core.cache import cache
|
|
5
|
+
from django.db import IntegrityError, models, transaction
|
|
6
|
+
from django.utils.text import slugify
|
|
7
|
+
|
|
8
|
+
CATEGORY_CACHE_KEY = "categories"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CategoryManager(models.Manager):
|
|
12
|
+
"""Category manager."""
|
|
13
|
+
|
|
14
|
+
def get_categories(self: "CategoryManager") -> models.QuerySet:
|
|
15
|
+
"""Return the queryset for categories.
|
|
16
|
+
|
|
17
|
+
If CACHE_CATEGORIES is set to True, we return the cached queryset.
|
|
18
|
+
"""
|
|
19
|
+
if settings.CACHE_CATEGORIES:
|
|
20
|
+
return self._get_cached_categories()
|
|
21
|
+
|
|
22
|
+
return self.all()
|
|
23
|
+
|
|
24
|
+
def _get_cached_categories(self: "CategoryManager") -> models.QuerySet:
|
|
25
|
+
"""Return the cached categories queryset."""
|
|
26
|
+
queryset = cache.get(CATEGORY_CACHE_KEY)
|
|
27
|
+
|
|
28
|
+
if queryset is None:
|
|
29
|
+
queryset = self.all()
|
|
30
|
+
cache.set(CATEGORY_CACHE_KEY, queryset, timeout=None)
|
|
31
|
+
|
|
32
|
+
return queryset
|
|
33
|
+
|
|
34
|
+
def get_category_by_slug(self: "CategoryManager", slug: str) -> "Category":
|
|
35
|
+
"""Return a single category by its slug."""
|
|
36
|
+
# First, try to get the category from the cache
|
|
37
|
+
categories = self.get_categories()
|
|
38
|
+
category = next(
|
|
39
|
+
(category for category in categories if category.slug == slug),
|
|
40
|
+
None,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# If the category is not found in the cache, fetch it from the database
|
|
44
|
+
if not category:
|
|
45
|
+
try:
|
|
46
|
+
category = self.get(slug=slug)
|
|
47
|
+
except Category.DoesNotExist as exc:
|
|
48
|
+
msg = "Category not found"
|
|
49
|
+
# Raise a 404 error
|
|
50
|
+
raise ValueError(msg) from exc
|
|
51
|
+
|
|
52
|
+
return category
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Category(models.Model):
|
|
56
|
+
"""Category model."""
|
|
57
|
+
|
|
58
|
+
name = models.CharField(max_length=100)
|
|
59
|
+
slug = models.SlugField(unique=True, blank=True)
|
|
60
|
+
description = models.TextField(blank=True)
|
|
61
|
+
|
|
62
|
+
# Custom Manager
|
|
63
|
+
objects: "CategoryManager" = CategoryManager()
|
|
64
|
+
|
|
65
|
+
class Meta:
|
|
66
|
+
"""Meta options for the category model."""
|
|
67
|
+
|
|
68
|
+
verbose_name = "category"
|
|
69
|
+
verbose_name_plural = "categories"
|
|
70
|
+
|
|
71
|
+
def __str__(self: "Category") -> str:
|
|
72
|
+
"""Return the string representation of the category."""
|
|
73
|
+
return self.name
|
|
74
|
+
|
|
75
|
+
def save(self: "Category", *args, **kwargs) -> None: # noqa: ANN002, ANN003
|
|
76
|
+
"""Override the save method to auto-generate the slug."""
|
|
77
|
+
if not self.slug:
|
|
78
|
+
self.slug = slugify(self.name)
|
|
79
|
+
if not self.slug or self.slug.strip("-") == "":
|
|
80
|
+
msg = "Invalid name. Unable to generate a valid slug."
|
|
81
|
+
raise ValueError(msg)
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
with transaction.atomic():
|
|
85
|
+
super().save(*args, **kwargs)
|
|
86
|
+
except IntegrityError as exc:
|
|
87
|
+
msg = f"A category with the slug {self.slug} already exists."
|
|
88
|
+
raise ValueError(msg) from exc
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def permalink(self: "Category") -> str:
|
|
92
|
+
"""Return the category's permalink."""
|
|
93
|
+
if settings.CATEGORY_PATH_ENABLED and settings.CATEGORY_PATH:
|
|
94
|
+
return f"{settings.CATEGORY_PATH}/{self.slug}"
|
|
95
|
+
|
|
96
|
+
return f"{self.slug}"
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Post model."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import ClassVar
|
|
5
|
+
|
|
6
|
+
from django.conf import settings
|
|
7
|
+
from django.contrib.auth.models import User
|
|
8
|
+
from django.core.cache import cache
|
|
9
|
+
from django.db import models
|
|
10
|
+
from django.utils import timezone
|
|
11
|
+
from django.utils.text import slugify
|
|
12
|
+
|
|
13
|
+
from djpress.models import Category
|
|
14
|
+
from djpress.utils import render_markdown
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
PUBLISHED_POSTS_CACHE_KEY = "published_posts"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PostsManager(models.Manager):
|
|
23
|
+
"""Post custom manager."""
|
|
24
|
+
|
|
25
|
+
def get_queryset(self: "PostsManager") -> models.QuerySet:
|
|
26
|
+
"""Return the queryset for published posts."""
|
|
27
|
+
return super().get_queryset().filter(post_type="post").order_by("-date")
|
|
28
|
+
|
|
29
|
+
def _get_published_posts(self: "PostsManager") -> models.QuerySet:
|
|
30
|
+
"""Returns all published posts.
|
|
31
|
+
|
|
32
|
+
For a post to be considered published, it must meet the following requirements:
|
|
33
|
+
- The status must be "published".
|
|
34
|
+
- The date must be less than or equal to the current date/time.
|
|
35
|
+
"""
|
|
36
|
+
return self.get_queryset().filter(
|
|
37
|
+
status="published",
|
|
38
|
+
date__lte=timezone.now(),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def get_recent_published_posts(self: "PostsManager") -> models.QuerySet:
|
|
42
|
+
"""Return recent published posts.
|
|
43
|
+
|
|
44
|
+
If CACHE_RECENT_PUBLISHED_POSTS is set to True, we return the cached queryset.
|
|
45
|
+
"""
|
|
46
|
+
if settings.CACHE_RECENT_PUBLISHED_POSTS:
|
|
47
|
+
return self._get_cached_recent_published_posts()
|
|
48
|
+
|
|
49
|
+
return self._get_published_posts().prefetch_related("categories", "author")[
|
|
50
|
+
: settings.RECENT_PUBLISHED_POSTS_COUNT
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
def _get_cached_recent_published_posts(self: "PostsManager") -> models.QuerySet:
|
|
54
|
+
"""Return the cached recent published posts queryset.
|
|
55
|
+
|
|
56
|
+
If there are any future posts, we calculate the seconds until that post, then we
|
|
57
|
+
set the timeout to that number of seconds.
|
|
58
|
+
"""
|
|
59
|
+
queryset = cache.get(PUBLISHED_POSTS_CACHE_KEY)
|
|
60
|
+
|
|
61
|
+
if queryset is None:
|
|
62
|
+
queryset = (
|
|
63
|
+
self.get_queryset()
|
|
64
|
+
.filter(
|
|
65
|
+
status="published",
|
|
66
|
+
)
|
|
67
|
+
.prefetch_related("categories", "author")
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
future_posts = queryset.filter(date__gt=timezone.now())
|
|
71
|
+
if future_posts.exists():
|
|
72
|
+
future_post = future_posts[0]
|
|
73
|
+
timeout = (future_post.date - timezone.now()).total_seconds()
|
|
74
|
+
else:
|
|
75
|
+
timeout = None
|
|
76
|
+
|
|
77
|
+
queryset = queryset.filter(date__lte=timezone.now())[
|
|
78
|
+
: settings.RECENT_PUBLISHED_POSTS_COUNT
|
|
79
|
+
]
|
|
80
|
+
cache.set(
|
|
81
|
+
PUBLISHED_POSTS_CACHE_KEY,
|
|
82
|
+
queryset,
|
|
83
|
+
timeout=timeout,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
return queryset
|
|
87
|
+
|
|
88
|
+
def get_published_post_by_slug(
|
|
89
|
+
self: "PostsManager",
|
|
90
|
+
slug: str,
|
|
91
|
+
) -> "Post":
|
|
92
|
+
"""Return a single published post.
|
|
93
|
+
|
|
94
|
+
Must have a date less than or equal to the current date/time based on its slug.
|
|
95
|
+
"""
|
|
96
|
+
# First, try to get the post from the cache
|
|
97
|
+
posts = self.get_recent_published_posts()
|
|
98
|
+
post = next((post for post in posts if post.slug == slug), None)
|
|
99
|
+
|
|
100
|
+
# If the post is not found in the cache, fetch it from the database
|
|
101
|
+
if not post:
|
|
102
|
+
try:
|
|
103
|
+
post = self._get_published_posts().get(slug=slug)
|
|
104
|
+
except Post.DoesNotExist as exc:
|
|
105
|
+
msg = "Post not found"
|
|
106
|
+
raise ValueError(msg) from exc
|
|
107
|
+
|
|
108
|
+
return post
|
|
109
|
+
|
|
110
|
+
def get_published_post_by_path(
|
|
111
|
+
self: "PostsManager",
|
|
112
|
+
path: str,
|
|
113
|
+
) -> "Post":
|
|
114
|
+
"""Return a single published post from a path.
|
|
115
|
+
|
|
116
|
+
This is a complex piece of logic...
|
|
117
|
+
|
|
118
|
+
Here are all the different valid paths that we need to check:
|
|
119
|
+
- /blog/2021/01/01/post-slug
|
|
120
|
+
- /blog/2021/01/post-slug
|
|
121
|
+
- /blog/2021/post-slug
|
|
122
|
+
- /blog/post-slug
|
|
123
|
+
- /2021/01/01/post-slug
|
|
124
|
+
- /2021/01/post-slug
|
|
125
|
+
- /2021/post-slug
|
|
126
|
+
- /post-slug
|
|
127
|
+
|
|
128
|
+
But we could be getting any invalid path too, e.g.
|
|
129
|
+
- /blog/2021/01/01/post-slug/extra
|
|
130
|
+
- /blog/2021/01/post-slug/extra
|
|
131
|
+
- etc.
|
|
132
|
+
|
|
133
|
+
This is what we need to do...
|
|
134
|
+
|
|
135
|
+
First, we need to know the following:
|
|
136
|
+
- Is there a POST_PREFIX defined?
|
|
137
|
+
- Are DATE_ARCHIVES enabled?
|
|
138
|
+
- If DATE_ARCHIVES are enabled, what is the POST_PERMALINK set to?
|
|
139
|
+
|
|
140
|
+
I don't think I can avoid regex matching here...
|
|
141
|
+
|
|
142
|
+
For now we'll just look at the POST_PREFIX.
|
|
143
|
+
"""
|
|
144
|
+
if settings.POST_PREFIX and path.startswith(settings.POST_PREFIX):
|
|
145
|
+
slug = path.split(settings.POST_PREFIX + "/")[1]
|
|
146
|
+
return self.get_published_post_by_slug(slug)
|
|
147
|
+
|
|
148
|
+
if settings.POST_PREFIX and not path.startswith(settings.POST_PREFIX):
|
|
149
|
+
msg = "Invalid path"
|
|
150
|
+
raise ValueError(msg)
|
|
151
|
+
|
|
152
|
+
return self.get_published_post_by_slug(path)
|
|
153
|
+
|
|
154
|
+
def get_published_posts_by_category(
|
|
155
|
+
self: "PostsManager",
|
|
156
|
+
category: Category,
|
|
157
|
+
) -> models.QuerySet:
|
|
158
|
+
"""Return all published posts for a given category.
|
|
159
|
+
|
|
160
|
+
Must have a date less than or equal to the current date/time for a specific
|
|
161
|
+
category, ordered by date in descending order.
|
|
162
|
+
"""
|
|
163
|
+
return (
|
|
164
|
+
self._get_published_posts()
|
|
165
|
+
.filter(categories=category)
|
|
166
|
+
.prefetch_related("categories", "author")
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
def get_published_posts_by_author(
|
|
170
|
+
self: "PostsManager",
|
|
171
|
+
author: User,
|
|
172
|
+
) -> models.QuerySet:
|
|
173
|
+
"""Return all published posts for a given author.
|
|
174
|
+
|
|
175
|
+
Must have a date less than or equal to the current date/time for a specific
|
|
176
|
+
author, ordered by date in descending order.
|
|
177
|
+
"""
|
|
178
|
+
return (
|
|
179
|
+
self._get_published_posts()
|
|
180
|
+
.filter(author=author)
|
|
181
|
+
.prefetch_related("categories", "author")
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class Post(models.Model):
|
|
186
|
+
"""Post model."""
|
|
187
|
+
|
|
188
|
+
STATUS_CHOICES: ClassVar = [("draft", "Draft"), ("published", "Published")]
|
|
189
|
+
CONTENT_TYPE_CHOICES: ClassVar = [("post", "Post"), ("page", "Page")]
|
|
190
|
+
|
|
191
|
+
title = models.CharField(max_length=200)
|
|
192
|
+
slug = models.SlugField(unique=True, blank=True)
|
|
193
|
+
content = models.TextField()
|
|
194
|
+
author = models.ForeignKey(User, on_delete=models.CASCADE)
|
|
195
|
+
date = models.DateTimeField(default=timezone.now)
|
|
196
|
+
modified_date = models.DateTimeField(auto_now=True)
|
|
197
|
+
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default="draft")
|
|
198
|
+
post_type = models.CharField(
|
|
199
|
+
max_length=10,
|
|
200
|
+
choices=CONTENT_TYPE_CHOICES,
|
|
201
|
+
default="post",
|
|
202
|
+
)
|
|
203
|
+
categories = models.ManyToManyField(Category, blank=True)
|
|
204
|
+
|
|
205
|
+
# Managers
|
|
206
|
+
objects = models.Manager()
|
|
207
|
+
post_objects: "PostsManager" = PostsManager()
|
|
208
|
+
|
|
209
|
+
class Meta:
|
|
210
|
+
"""Meta options for the Post model."""
|
|
211
|
+
|
|
212
|
+
verbose_name = "post"
|
|
213
|
+
verbose_name_plural = "posts"
|
|
214
|
+
|
|
215
|
+
def __str__(self: "Post") -> str:
|
|
216
|
+
"""Return the string representation of the post."""
|
|
217
|
+
return self.title
|
|
218
|
+
|
|
219
|
+
def save(self: "Post", *args, **kwargs) -> None: # noqa: ANN002, ANN003
|
|
220
|
+
"""Override the save method to auto-generate the slug."""
|
|
221
|
+
if not self.slug:
|
|
222
|
+
self.slug = slugify(self.title)
|
|
223
|
+
if not self.slug or self.slug.strip("-") == "":
|
|
224
|
+
msg = "Invalid title. Unable to generate a valid slug."
|
|
225
|
+
raise ValueError(msg)
|
|
226
|
+
super().save(*args, **kwargs)
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def content_markdown(self: "Post") -> str:
|
|
230
|
+
"""Return the content as HTML converted from Markdown."""
|
|
231
|
+
return render_markdown(self.content)
|
|
232
|
+
|
|
233
|
+
@property
|
|
234
|
+
def truncated_content_markdown(self: "Post") -> str:
|
|
235
|
+
"""Return the truncated content as HTML converted from Markdown."""
|
|
236
|
+
read_more_index = self.content.find(settings.TRUNCATE_TAG)
|
|
237
|
+
if read_more_index != -1:
|
|
238
|
+
truncated_content = self.content[:read_more_index]
|
|
239
|
+
else:
|
|
240
|
+
truncated_content = self.content
|
|
241
|
+
return render_markdown(truncated_content)
|
|
242
|
+
|
|
243
|
+
@property
|
|
244
|
+
def is_truncated(self: "Post") -> bool:
|
|
245
|
+
"""Return whether the content is truncated."""
|
|
246
|
+
return settings.TRUNCATE_TAG in self.content
|
|
247
|
+
|
|
248
|
+
@property
|
|
249
|
+
def permalink(self: "Post") -> str:
|
|
250
|
+
"""Return the post's permalink.
|
|
251
|
+
|
|
252
|
+
The posts permalink is constructed of the following elements:
|
|
253
|
+
- The post prefix - this is configured in POST_PREFIX and could be an empty
|
|
254
|
+
string or a custom string, e.g. `blog` or `posts`.
|
|
255
|
+
- The post date structure - this is configured in POST_PERMALINK and is a
|
|
256
|
+
`strftime` value, e.g. `%Y/%m/%d` or `%Y/%m`. Or it could be an empty string
|
|
257
|
+
to indicate that no date structure is used.
|
|
258
|
+
- The post slug - this is a unique identifier for the post. TODO: should this be
|
|
259
|
+
a database unique constraint, or should we handle it in software instead?
|
|
260
|
+
"""
|
|
261
|
+
# We start the permalink with just the slug
|
|
262
|
+
permalink = self.slug
|
|
263
|
+
|
|
264
|
+
# If the post type is a page, we return just the slug
|
|
265
|
+
if self.post_type == "page":
|
|
266
|
+
return permalink
|
|
267
|
+
|
|
268
|
+
# The only other post type is a post so we don't need to check for that
|
|
269
|
+
# If there's a permalink structure defined, we add that to the permalink
|
|
270
|
+
if settings.POST_PERMALINK:
|
|
271
|
+
permalink = f"{self.date.strftime(settings.POST_PERMALINK)}/{self.slug}"
|
|
272
|
+
|
|
273
|
+
# If there's a post prefix defined, we add that to the permalink
|
|
274
|
+
if settings.POST_PREFIX:
|
|
275
|
+
permalink = f"{settings.POST_PREFIX}/{permalink}"
|
|
276
|
+
|
|
277
|
+
return permalink
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Signals for djpress app."""
|
|
2
|
+
|
|
3
|
+
from django.core.cache import cache
|
|
4
|
+
from django.db.models.signals import post_delete, post_save
|
|
5
|
+
from django.dispatch import receiver
|
|
6
|
+
|
|
7
|
+
from djpress.models.category import (
|
|
8
|
+
CATEGORY_CACHE_KEY,
|
|
9
|
+
Category,
|
|
10
|
+
)
|
|
11
|
+
from djpress.models.post import (
|
|
12
|
+
PUBLISHED_POSTS_CACHE_KEY,
|
|
13
|
+
Post,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@receiver(post_save, sender=Category)
|
|
18
|
+
@receiver(post_delete, sender=Category)
|
|
19
|
+
def invalidate_category_cache(**kwargs) -> None: # noqa: ARG001, ANN003
|
|
20
|
+
"""Invalidate the category cache."""
|
|
21
|
+
cache.delete(CATEGORY_CACHE_KEY)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@receiver(post_save, sender=Post)
|
|
25
|
+
@receiver(post_delete, sender=Post)
|
|
26
|
+
def invalidate_published_content_cache(**kwargs) -> None: # noqa: ARG001, ANN003
|
|
27
|
+
"""Invalidate the published posts cache."""
|
|
28
|
+
cache.delete(PUBLISHED_POSTS_CACHE_KEY)
|