generated from westfarn/web_django_template
Initial commit
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from blog.models import Post
|
||||
|
||||
|
||||
@admin.register(Post)
|
||||
class PostAdmin(admin.ModelAdmin):
|
||||
list_display = ("title", "is_published", "published_at", "author", "updated_at")
|
||||
list_filter = ("is_published",)
|
||||
prepopulated_fields = {"slug": ("title",)}
|
||||
search_fields = ("title", "excerpt", "body")
|
||||
@@ -0,0 +1,12 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class BlogConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "blog"
|
||||
verbose_name = "Blog"
|
||||
|
||||
def ready(self):
|
||||
from blog import hooks
|
||||
|
||||
hooks.register()
|
||||
@@ -0,0 +1,18 @@
|
||||
from core.registry import register_feature, register_portal_nav, register_public_nav
|
||||
|
||||
|
||||
def register() -> None:
|
||||
register_feature("blog")
|
||||
register_public_nav(
|
||||
section="blog",
|
||||
label="Blog",
|
||||
url_name="blog:list",
|
||||
order=40,
|
||||
)
|
||||
register_portal_nav(
|
||||
section="blog",
|
||||
label="Blog posts",
|
||||
url_name="blog_portal:portal_list",
|
||||
group="Content",
|
||||
order=10,
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
# Generated by Django 6.1 on 2026-08-26 11:38
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Post',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('title', models.CharField(max_length=200)),
|
||||
('slug', models.SlugField(max_length=220, unique=True)),
|
||||
('excerpt', models.TextField(blank=True)),
|
||||
('body', models.TextField()),
|
||||
('is_published', models.BooleanField(default=False)),
|
||||
('published_at', models.DateTimeField(blank=True, null=True)),
|
||||
('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='blog_posts', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-published_at', '-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,45 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.text import slugify
|
||||
|
||||
from core.models import TimeStampedModel, UUIDPrimaryKeyModel
|
||||
|
||||
|
||||
class Post(UUIDPrimaryKeyModel, TimeStampedModel):
|
||||
title = models.CharField(max_length=200)
|
||||
slug = models.SlugField(max_length=220, unique=True)
|
||||
excerpt = models.TextField(blank=True)
|
||||
body = models.TextField()
|
||||
is_published = models.BooleanField(default=False)
|
||||
published_at = models.DateTimeField(null=True, blank=True)
|
||||
author = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="blog_posts",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-published_at", "-created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.title
|
||||
|
||||
def get_absolute_url(self) -> str:
|
||||
return reverse("blog:detail", kwargs={"slug": self.slug})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.slug:
|
||||
base = slugify(self.title)[:200] or "post"
|
||||
slug = base
|
||||
n = 2
|
||||
while Post.objects.filter(slug=slug).exclude(pk=self.pk).exists():
|
||||
slug = f"{base}-{n}"
|
||||
n += 1
|
||||
self.slug = slug
|
||||
if self.is_published and self.published_at is None:
|
||||
self.published_at = timezone.now()
|
||||
super().save(*args, **kwargs)
|
||||
@@ -0,0 +1,12 @@
|
||||
from django.urls import path
|
||||
|
||||
from blog import views
|
||||
|
||||
app_name = "blog_portal"
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.portal_list, name="portal_list"),
|
||||
path("new/", views.portal_edit, name="portal_new"),
|
||||
path("<uuid:pk>/", views.portal_edit, name="portal_edit"),
|
||||
path("<uuid:pk>/delete/", views.portal_delete, name="portal_delete"),
|
||||
]
|
||||
@@ -0,0 +1,10 @@
|
||||
from django.urls import path
|
||||
|
||||
from blog import views
|
||||
|
||||
app_name = "blog"
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.post_list, name="list"),
|
||||
path("<slug:slug>/", views.post_detail, name="detail"),
|
||||
]
|
||||
@@ -0,0 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ post.title }} · {{ SITE_NAME }}{% endblock %}
|
||||
{% block content %}
|
||||
<section class="section section-lg">
|
||||
<div class="container">
|
||||
<p><a href="{% url 'blog:list' %}">← Blog</a></p>
|
||||
<h1>{{ post.title }}</h1>
|
||||
<p class="muted">{% if post.published_at %}{{ post.published_at|date:"F j, Y" }}{% endif %}</p>
|
||||
<div>{{ post.body|linebreaks }}</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,18 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Blog · {{ SITE_NAME }}{% endblock %}
|
||||
{% block content %}
|
||||
<section class="section section-lg">
|
||||
<div class="container">
|
||||
<h1 class="text-uppercase">Blog</h1>
|
||||
{% for post in posts %}
|
||||
<article style="margin:0 0 32px">
|
||||
<h2><a href="{{ post.get_absolute_url }}">{{ post.title }}</a></h2>
|
||||
<p class="muted">{% if post.published_at %}{{ post.published_at|date:"F j, Y" }}{% endif %}</p>
|
||||
<p>{{ post.excerpt|default:post.body|truncatewords:40 }}</p>
|
||||
</article>
|
||||
{% empty %}
|
||||
<p>No posts yet.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,20 @@
|
||||
{% extends "portal_base.html" %}
|
||||
{% block title %}{% if post %}Edit{% else %}New{% endif %} post · Portal{% endblock %}
|
||||
{% block topbar_title %}{% if post %}Edit post{% else %}New post{% endif %}{% endblock %}
|
||||
{% block portal_content %}
|
||||
<form method="post" class="form-grid">
|
||||
{% csrf_token %}
|
||||
<div class="field"><label>Title</label><input name="title" required value="{{ post.title|default:'' }}"></div>
|
||||
<div class="field"><label>Slug</label><input name="slug" value="{{ post.slug|default:'' }}" placeholder="auto from title"></div>
|
||||
<div class="field"><label>Excerpt</label><textarea name="excerpt" style="min-height:64px">{{ post.excerpt|default:'' }}</textarea></div>
|
||||
<div class="field"><label>Body</label><textarea name="body" required style="min-height:220px">{{ post.body|default:'' }}</textarea></div>
|
||||
<label><input type="checkbox" name="is_published" {% if post.is_published %}checked{% endif %}> Published</label>
|
||||
<button class="btn btn-primary" type="submit">Save</button>
|
||||
</form>
|
||||
{% if post %}
|
||||
<form method="post" action="{% url 'blog_portal:portal_delete' post.pk %}" style="margin-top:24px" onsubmit="return confirm('Delete this post?');">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-ghost" type="submit">Delete</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,20 @@
|
||||
{% extends "portal_base.html" %}
|
||||
{% block title %}Blog posts · Portal{% endblock %}
|
||||
{% block topbar_title %}Blog posts{% endblock %}
|
||||
{% block portal_content %}
|
||||
<p><a class="btn btn-primary" href="{% url 'blog_portal:portal_new' %}">New post</a></p>
|
||||
<table class="table">
|
||||
<thead><tr><th>Title</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for post in posts %}
|
||||
<tr>
|
||||
<td><a href="{% url 'blog_portal:portal_edit' post.pk %}">{{ post.title }}</a></td>
|
||||
<td>{% if post.is_published %}Published{% else %}Draft{% endif %}</td>
|
||||
<td><a href="{{ post.get_absolute_url }}">View</a></td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="3" class="empty-state">No posts yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,43 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import Client, TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from blog.models import Post
|
||||
|
||||
|
||||
class BlogPublicTests(TestCase):
|
||||
def test_list_hides_drafts(self):
|
||||
Post.objects.create(title="Draft", body="x", is_published=False)
|
||||
Post.objects.create(
|
||||
title="Live",
|
||||
body="hello",
|
||||
is_published=True,
|
||||
published_at=timezone.now(),
|
||||
)
|
||||
response = Client().get(reverse("blog:list"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Live")
|
||||
self.assertNotContains(response, "Draft")
|
||||
|
||||
def test_portal_requires_login(self):
|
||||
response = Client().get(reverse("blog_portal:portal_list"))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
|
||||
class BlogPortalTests(TestCase):
|
||||
def setUp(self):
|
||||
User = get_user_model()
|
||||
self.user = User.objects.create_user("editor", password="test-pass-123")
|
||||
self.client = Client()
|
||||
self.client.login(username="editor", password="test-pass-123")
|
||||
|
||||
def test_create_published_post(self):
|
||||
response = self.client.post(
|
||||
reverse("blog_portal:portal_new"),
|
||||
{"title": "Hello", "body": "World", "is_published": "on"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
post = Post.objects.get()
|
||||
self.assertTrue(post.is_published)
|
||||
self.assertEqual(post.slug, "hello")
|
||||
@@ -0,0 +1,64 @@
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.utils import timezone
|
||||
from django.utils.text import slugify
|
||||
from django.views.decorators.http import require_http_methods, require_POST
|
||||
|
||||
from blog.models import Post
|
||||
|
||||
|
||||
def post_list(request):
|
||||
posts = Post.objects.filter(is_published=True)
|
||||
return render(request, "blog/list.html", {"posts": posts})
|
||||
|
||||
|
||||
def post_detail(request, slug):
|
||||
post = get_object_or_404(Post, slug=slug, is_published=True)
|
||||
return render(request, "blog/detail.html", {"post": post})
|
||||
|
||||
|
||||
@login_required
|
||||
def portal_list(request):
|
||||
posts = Post.objects.all()
|
||||
return render(request, "blog/portal/list.html", {"posts": posts})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def portal_edit(request, pk=None):
|
||||
post = get_object_or_404(Post, pk=pk) if pk else None
|
||||
if request.method == "POST":
|
||||
title = (request.POST.get("title") or "").strip()
|
||||
body = (request.POST.get("body") or "").strip()
|
||||
excerpt = (request.POST.get("excerpt") or "").strip()
|
||||
slug = (request.POST.get("slug") or "").strip()
|
||||
is_published = request.POST.get("is_published") == "on"
|
||||
if not title or not body:
|
||||
messages.error(request, "Title and body are required.")
|
||||
else:
|
||||
if post is None:
|
||||
post = Post(author=request.user)
|
||||
post.title = title
|
||||
post.body = body
|
||||
post.excerpt = excerpt
|
||||
post.slug = slugify(slug)[:220] if slug else ""
|
||||
post.is_published = is_published
|
||||
if is_published and post.published_at is None:
|
||||
post.published_at = timezone.now()
|
||||
if not is_published:
|
||||
post.published_at = None
|
||||
post.save()
|
||||
messages.success(request, f'Saved “{post.title}”.')
|
||||
return redirect("blog_portal:portal_list")
|
||||
return render(request, "blog/portal/edit.html", {"post": post})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def portal_delete(request, pk):
|
||||
post = get_object_or_404(Post, pk=pk)
|
||||
title = post.title
|
||||
post.delete()
|
||||
messages.success(request, f'Deleted “{title}”.')
|
||||
return redirect("blog_portal:portal_list")
|
||||
Reference in New Issue
Block a user