djpress 0.1__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """djpress module."""
djpress/admin.py ADDED
@@ -0,0 +1,9 @@
1
+ """djpress admin configuration."""
2
+
3
+ from django.contrib import admin
4
+
5
+ # Register the models here.
6
+ from djpress.models import Category, Post
7
+
8
+ admin.site.register(Category)
9
+ admin.site.register(Post)
djpress/apps.py ADDED
@@ -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
djpress/feeds.py ADDED
@@ -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,4 @@
1
+ """Models package for djpress app."""
2
+
3
+ from djpress.models.category import Category # noqa: F401
4
+ from djpress.models.post import Post # noqa: F401
@@ -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}"
djpress/models/post.py ADDED
@@ -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
djpress/signals.py ADDED
@@ -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)
@@ -0,0 +1,61 @@
1
+ body {
2
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Ubuntu,
3
+ sans-serif;
4
+ margin: 0;
5
+ padding: 0;
6
+ box-sizing: border-box;
7
+ background-color: #f4f4f4;
8
+ }
9
+
10
+ header {
11
+ background-color: #333;
12
+ color: #fff;
13
+ padding: 1em 0;
14
+ text-align: center;
15
+ }
16
+
17
+ header h1 a {
18
+ color: #fff;
19
+ }
20
+
21
+ main {
22
+ padding: 2em;
23
+ max-width: 992px;
24
+ margin: 0 auto;
25
+ }
26
+
27
+ article {
28
+ background-color: #fff;
29
+ padding: 1em;
30
+ margin-bottom: 1em;
31
+ border-radius: 5px;
32
+ }
33
+
34
+ article header h1 {
35
+ margin-top: 0;
36
+ }
37
+
38
+ article section {
39
+ margin-top: 1em;
40
+ }
41
+
42
+ article footer {
43
+ text-align: left;
44
+ padding: 1em;
45
+ position: static;
46
+ width: auto;
47
+ }
48
+
49
+ footer {
50
+ background-color: #333;
51
+ color: #fff;
52
+ text-align: center;
53
+ padding: 1em 0;
54
+ position: fixed;
55
+ bottom: 0;
56
+ width: 100%;
57
+ }
58
+
59
+ footer a {
60
+ color: #fff;
61
+ }
@@ -0,0 +1 @@
1
+ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Ubuntu,sans-serif;margin:0;padding:0;box-sizing:border-box;background-color:#f4f4f4}header{background-color:#333;color:#fff;padding:1em 0;text-align:center}header h1 a{color:#fff}main{padding:2em;max-width:992px;margin:0 auto}article{background-color:#fff;padding:1em;margin-bottom:1em;border-radius:5px}article header h1{margin-top:0}article section{margin-top:1em}article footer{text-align:left;padding:1em;position:static;width:auto}footer{background-color:#333;color:#fff;text-align:center;padding:1em 0;position:fixed;bottom:0;width:100%}footer a{color:#fff}