M main/feeds.py => main/feeds.py +6 -0
@@ 107,6 107,12 @@ class RSSBlogFeed(Feed):
def item_pubdate(self, item):
# set time to 00:00 because we don't store time for published_at field
return datetime.combine(item.published_at, datetime.min.time())
+
+ def item_categories(self, item):
+ """
+ Return a list of tags for this item, to render <category> elements.
+ """
+ return item.tag_list # uses your Post.tag_list property
def item_extra_kwargs(self, item):
"""
M main/forms.py => main/forms.py +1 -0
@@ 73,3 73,4 @@ class APIPost(forms.Form):
slug = forms.SlugField(max_length=300, required=False)
body = forms.CharField(widget=forms.Textarea, required=False)
published_at = forms.DateField(required=False)
+ tags = forms.CharField(max_length=300, required=False)
A main/migrations/0121_post_tags.py => main/migrations/0121_post_tags.py +18 -0
@@ 0,0 1,18 @@
+# Generated by Django 5.2.5 on 2025-09-20 20:52
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('main', '0120_user_number_of_posts_feed'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='post',
+ name='tags',
+ field=models.CharField(blank=True, default=None, max_length=300, null=True),
+ ),
+ ]
M main/models.py => main/models.py +7 -0
@@ 264,6 264,7 @@ class Post(models.Model):
help_text="Leave blank to keep as draft/unpublished. Use a future date for auto-posting.",
)
broadcasted_at = models.DateTimeField(blank=True, null=True, default=None)
+ tags = models.CharField(max_length=300, blank=True, null=True, default=None)
class Meta:
ordering = ["-published_at", "-created_at"]
@@ 277,6 278,12 @@ class Post(models.Model):
def body_as_text(self):
as_html = util.md_to_html(self.body)
return bleach.clean(as_html, strip=True, tags=[])
+
+ @property
+ def tag_list(self):
+ if self.tags:
+ return [t.strip() for t in self.tags.split(",") if t.strip()]
+ return []
@property
def is_draft(self):
M main/templates/main/api_docs.html => main/templates/main/api_docs.html +2 -0
@@ 54,6 54,7 @@
<li><code>title</code>: string [required]</li>
<li><code>body</code>: string [optional]</li>
<li><code>published_at</code>: string (ISO date eg. 2006-01-31) [optional]</li>
+ <li><code>tags</code>: string [optional]</li>
</ul>
<strong>Request</strong>
@@ 61,6 62,7 @@
"title": "New blog",
"body": "## Why?\n\nEveryone wants a blog, right?",
"published_at": "2020-09-21"
+ "tags": "life, tech, coding",
}</code></pre>
<strong>Response</strong>
M main/templates/main/blog_posts.html => main/templates/main/blog_posts.html +16 -0
@@ 43,6 43,12 @@
<h2 itemprop="name">{% if blog_user.posts_page_title %}{{ blog_user.posts_page_title }}{% else %}Posts{% endif %}</h2>
+ {% if filter_tag %}
+ <p>
+ Active tag filter: <strong>{{ filter_tag }}</strong> (<a href="{% url 'post_list' %}">clear filter</a>)
+ </p>
+ {% endif %}
+
{% if request.user.is_authenticated and request.subdomain == request.user.username and drafts %}
<div class="drafts">
<strong>
@@ 63,8 69,18 @@
{% if p.published_at %}
<li>
<a href="{% url 'post_detail' p.slug %}">{{ p.title }}</a>
+ {% if p.tag_list %}
+ <small>
+ —
+ <b>Tags</b>:
+ {% for tag in p.tag_list %}
+ <a href="{% url 'post_list_filter' tag %}" class="tag">{{ tag }}</a>{% if not forloop.last %}, {% endif %}
+ {% endfor %}
+ </small>
+ {% endif %}
<small>
—
+ <b>Published on</b>:
<time datetime="{{ p.published_at|date:'Y-m-d' }}" itemprop="datePublished">
{{ p.published_at|date:'F j, Y' }}
</time>
M main/templates/main/post_detail.html => main/templates/main/post_detail.html +9 -0
@@ 63,6 63,15 @@
<div class="posts-item-body" itemprop="articleBody">
{{ post.body_as_html|safe }}
</div>
+
+ {% if post.tag_list %}
+ <hr>
+ <p><b>Tags</b>:
+ {% for tag in post.tag_list %}
+ <a href="{% url 'post_list_filter' tag %}" class="tag">{{ tag }}</a>{% if not forloop.last %}, {% endif %}
+ {% endfor %}
+ </p>
+ {% endif %}
</article>
</main>
M main/templates/main/post_form.html => main/templates/main/post_form.html +11 -0
@@ 62,6 62,17 @@
</p>
<p>
+ <label for="id_tags">Tags</label>
+ {% if form.tags.errors %}
+ {% for error in form.tags.errors %}
+ <span class="form-error">{{ error|escape }}</span><br>
+ {% endfor %}
+ {% endif %}
+ <input type="text" name="tags" id="id_tags" maxlength="300" required value="{{ form.tags.value|default_if_none:'' }}">
+ <span class="helptext">{{ form.tags.help_text }}</span>
+ </p>
+
+ <p>
<label for="id_body">Content (<a href="{% url 'guides_markdown' %}">supports markdown</a>) <span id="js-status"></span></label>
{% if form.body.errors %}
{% for error in form.body.errors %}
M main/templates/main/post_list.html => main/templates/main/post_list.html +16 -0
@@ 9,6 9,11 @@
<a href="{% url 'post_create' %}">Create a new post »</a>
</p>
{% if post_list %}
+ {% if filter_tag %}
+ <p>
+ Active tag filter: <strong>{{ filter_tag }}</strong> (<a href="{% url 'post_list' %}">clear filter</a>)
+ </p>
+ {% endif %}
<p>
List of posts:
</p>
@@ 18,7 23,18 @@
<a href="{% url 'post_detail' post.slug %}">
{{ post.title }}
</a>
+ {% if p.tag_list %}
+ <small>
+ —
+ <b>Tags</b>:
+ {% for tag in p.tag_list %}
+ <a href="{% url 'post_list_filter' tag %}" class="tag">{{ tag }}</a>{% if not forloop.last %}, {% endif %}
+ {% endfor %}
+ </small>
+ {% endif %}
<small>
+ —
+ <b>Published on</b>:
{% if post.is_published %}
<time datetime="{{ post.published_at|date:'Y-m-d' }}" itemprop="datePublished">
— {{ post.published_at|date:'F j, Y' }}
M main/urls.py => main/urls.py +1 -0
@@ 92,6 92,7 @@ urlpatterns += [
),
path("new/post/", general.PostCreate.as_view(), name="post_create"),
path("posts/", general.post_list, name="post_list"),
+ path("posts/tag/<slug:tag>/", general.post_list_filter, name="post_list_filter"),
path("blog/<slug:slug>/", general.PostDetail.as_view(), name="post_detail"),
path("posts/<slug:slug>/", general.post_detail_redir, name="post_detail_redir_a"),
path("post/<slug:slug>/", general.post_detail_redir, name="post_detail_redir_b"),
M main/views/general.py => main/views/general.py +63 -2
@@ 5,6 5,7 @@ from datetime import datetime, timedelta
from urllib.parse import urlparse
import stripe
+from django.db.models import Q
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import authenticate, login
@@ 167,6 168,66 @@ def post_list(request):
return redirect("post_list_dashboard")
return render(request, "404.html")
+
+def post_list_filter(request, tag = None):
+ if hasattr(request, "subdomain"):
+ if models.User.objects.filter(username=request.subdomain).exists():
+ drafts = []
+ if request.user.is_authenticated and request.user == request.blog_user:
+ posts = models.Post.objects.filter(Q(tags__iexact=tag) |
+ Q(tags__startswith=f"{tag},") |
+ Q(tags__endswith=f",{tag}") |
+ Q(tags__contains=f",{tag},"),owner=request.blog_user).defer(
+ "body",
+ )
+ drafts = models.Post.objects.filter(
+ owner=request.blog_user,
+ published_at__isnull=True,
+ tags__icontains=tag,
+ ).defer("body")
+ else:
+ models.AnalyticPage.objects.create(user=request.blog_user, path="index")
+ posts = models.Post.objects.filter(
+ Q(tags__iexact=tag) |
+ Q(tags__startswith=f"{tag},") |
+ Q(tags__endswith=f",{tag}") |
+ Q(tags__contains=f",{tag},"),
+ owner=request.blog_user,
+ published_at__isnull=False,
+ published_at__lte=timezone.now().date(),
+ ).defer("body")
+
+ license_url = request.build_absolute_uri(reverse('rsl_license'))
+
+ response = render(
+ request,
+ "main/blog_posts.html",
+ {
+ "subdomain": request.subdomain,
+ "blog_user": request.blog_user,
+ "posts": posts,
+ "drafts": drafts,
+ "pages": models.Page.objects.filter(
+ owner=request.blog_user, is_hidden=False
+ ).defer("body"),
+ "license_url": license_url,
+ "filter_tag": tag,
+ },
+ )
+
+ # add Link header for license if applicable
+ obj, created = models.ReallySimpleLicensing.objects.get_or_create(user=request.blog_user)
+ if request.blog_user.reallysimplelicensing.license and request.blog_user.reallysimplelicensing.show_http:
+ response["Link"] = f'<{license_url}>; rel="license"; type="application/rsl+xml"'
+
+ return response
+ else:
+ return redirect("//" + settings.CANONICAL_HOST + reverse("index"))
+ else:
+ if request.user.is_authenticated:
+ return redirect("post_list_dashboard")
+
+ return render(request, "404.html")
class PostList(LoginRequiredMixin, ListView):
model = models.Post
@@ 423,7 484,7 @@ class PostDetail(DetailView):
class PostCreate(LoginRequiredMixin, SuccessMessageMixin, CreateView):
model = models.Post
- fields = ["title", "published_at", "body"]
+ fields = ["title", "published_at", "body", "tags"]
success_message = "'%(title)s' was created"
def form_valid(self, form):
@@ 443,7 504,7 @@ class PostCreate(LoginRequiredMixin, SuccessMessageMixin, CreateView):
class PostUpdate(LoginRequiredMixin, SuccessMessageMixin, UpdateView):
model = models.Post
- fields = ["title", "slug", "published_at", "body"]
+ fields = ["title", "slug", "published_at", "body", "tags"]
success_message = "post updated"
def get_queryset(self):