feat(backend): add blog publishing platform
This commit is contained in:
@@ -1,156 +1,347 @@
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
from django.utils.text import slugify
|
||||
from django.utils import timezone
|
||||
from __future__ import annotations
|
||||
|
||||
import mimetypes
|
||||
import re
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import markdown
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.utils.text import slugify
|
||||
|
||||
from core.media import (
|
||||
BLUR_VARIANT,
|
||||
PREVIEW_VARIANT,
|
||||
THUMBNAIL_VARIANT,
|
||||
delete_image_derivatives_by_name,
|
||||
derivative_url,
|
||||
get_image_previous_name,
|
||||
safe_process_public_image,
|
||||
)
|
||||
from core.models import BaseModel
|
||||
|
||||
|
||||
def _plain_text_from_markdown(value: str) -> str:
|
||||
html = markdown.markdown(value or "", extensions=["markdown.extensions.extra"])
|
||||
return re.sub(r"<[^<]+?>", " ", html).replace("\n", " ").strip()
|
||||
|
||||
|
||||
def _unique_slug_for(instance, value: str) -> str:
|
||||
base = slugify(value, allow_unicode=True) or uuid4().hex[:10]
|
||||
slug = base[:180]
|
||||
counter = 2
|
||||
manager = getattr(instance.__class__, "all_objects", instance.__class__._default_manager)
|
||||
queryset = manager.filter(slug=slug)
|
||||
if instance.pk:
|
||||
queryset = queryset.exclude(pk=instance.pk)
|
||||
|
||||
while queryset.exists():
|
||||
suffix = f"-{counter}"
|
||||
slug = f"{base[: 200 - len(suffix)]}{suffix}"
|
||||
queryset = manager.filter(slug=slug)
|
||||
if instance.pk:
|
||||
queryset = queryset.exclude(pk=instance.pk)
|
||||
counter += 1
|
||||
|
||||
return slug
|
||||
|
||||
|
||||
def post_asset_upload_to(instance: "PostAsset", filename: str) -> str:
|
||||
suffix = Path(filename).suffix.lower()
|
||||
post_part = instance.post_id or "draft"
|
||||
return f"blog/posts/{post_part}/assets/{uuid4().hex}{suffix}"
|
||||
|
||||
|
||||
class Category(BaseModel):
|
||||
name = models.CharField(max_length=100, unique=True)
|
||||
slug = models.SlugField(max_length=100, unique=True, blank=True)
|
||||
slug = models.SlugField(max_length=100, unique=True, blank=True, allow_unicode=True)
|
||||
description = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name_plural = "Categories"
|
||||
ordering = ['name']
|
||||
ordering = ["name"]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.slug:
|
||||
self.slug = slugify(self.name)
|
||||
self.slug = _unique_slug_for(self, self.name)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class Tag(BaseModel):
|
||||
name = models.CharField(max_length=50, unique=True)
|
||||
slug = models.SlugField(max_length=50, unique=True, blank=True)
|
||||
slug = models.SlugField(max_length=50, unique=True, blank=True, allow_unicode=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
ordering = ["name"]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.slug:
|
||||
self.slug = slugify(self.name)
|
||||
self.slug = _unique_slug_for(self, self.name)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class Post(BaseModel):
|
||||
class StatusChoices(models.TextChoices):
|
||||
DRAFT = 'draft', 'Draft'
|
||||
PUBLISHED = 'published', 'Published'
|
||||
DRAFT = "draft", "Draft"
|
||||
SUBMITTED = "submitted", "Submitted for review"
|
||||
CHANGES_REQUESTED = "changes_requested", "Changes requested"
|
||||
PUBLISHED = "published", "Published"
|
||||
ARCHIVED = "archived", "Archived"
|
||||
|
||||
title = models.CharField(max_length=200)
|
||||
slug = models.SlugField(max_length=200, unique=True, blank=True)
|
||||
slug = models.SlugField(max_length=200, unique=True, blank=True, allow_unicode=True)
|
||||
content = models.TextField(help_text="Content in Markdown format")
|
||||
content_html = models.TextField(blank=True, editable=False)
|
||||
excerpt = models.TextField(max_length=300, blank=True)
|
||||
author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='posts')
|
||||
featured_image = models.ImageField(upload_to='blog/featured/', null=True, blank=True)
|
||||
status = models.CharField(max_length=10, choices=StatusChoices.choices, default=StatusChoices.DRAFT)
|
||||
author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="posts")
|
||||
featured_image = models.ImageField(upload_to="blog/featured/", null=True, blank=True)
|
||||
status = models.CharField(max_length=24, choices=StatusChoices.choices, default=StatusChoices.DRAFT)
|
||||
published_at = models.DateTimeField(null=True, blank=True)
|
||||
category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, blank=True, related_name='posts')
|
||||
tags = models.ManyToManyField(Tag, blank=True, related_name='posts')
|
||||
category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, blank=True, related_name="posts")
|
||||
tags = models.ManyToManyField(Tag, blank=True, related_name="posts")
|
||||
is_featured = models.BooleanField(default=False)
|
||||
|
||||
seo_title = models.CharField(max_length=70, blank=True)
|
||||
seo_description = models.CharField(max_length=170, blank=True)
|
||||
canonical_url = models.URLField(blank=True)
|
||||
og_title = models.CharField(max_length=95, blank=True)
|
||||
og_description = models.CharField(max_length=200, blank=True)
|
||||
og_image = models.ImageField(upload_to="blog/og/", null=True, blank=True)
|
||||
noindex = models.BooleanField(default=False)
|
||||
focus_keyword = models.CharField(max_length=120, blank=True)
|
||||
reading_time = models.PositiveIntegerField(default=1)
|
||||
|
||||
submitted_at = models.DateTimeField(null=True, blank=True)
|
||||
reviewed_at = models.DateTimeField(null=True, blank=True)
|
||||
reviewed_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="reviewed_blog_posts",
|
||||
)
|
||||
review_note = models.TextField(blank=True)
|
||||
published_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="published_blog_posts",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
ordering = ["-created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=['status', 'published_at']),
|
||||
models.Index(fields=['is_featured']),
|
||||
models.Index(fields=["status", "published_at"]),
|
||||
models.Index(fields=["is_featured"]),
|
||||
models.Index(fields=["author", "status"]),
|
||||
models.Index(fields=["slug", "status"]),
|
||||
]
|
||||
permissions = [
|
||||
("access_blog_admin", "Can access blog admin"),
|
||||
("review_blog_post", "Can review blog posts"),
|
||||
("publish_blog_post", "Can publish blog posts"),
|
||||
("moderate_blog_comment", "Can moderate blog comments"),
|
||||
("upload_blog_asset", "Can upload blog assets"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
previous_image_name = get_image_previous_name(self, "featured_image")
|
||||
current_image_name = self.featured_image.name if self.featured_image else None
|
||||
previous_featured_name = get_image_previous_name(self, "featured_image")
|
||||
current_featured_name = self.featured_image.name if self.featured_image else None
|
||||
previous_og_name = get_image_previous_name(self, "og_image")
|
||||
current_og_name = self.og_image.name if self.og_image else None
|
||||
|
||||
if not self.slug:
|
||||
self.slug = slugify(self.title)
|
||||
|
||||
# Auto-generate excerpt if not provided
|
||||
self.slug = _unique_slug_for(self, self.title)
|
||||
|
||||
if not self.excerpt and self.content:
|
||||
# Convert markdown to plain text for excerpt
|
||||
plain_text = markdown.markdown(self.content, extensions=['markdown.extensions.extra'])
|
||||
# Remove HTML tags and truncate
|
||||
import re
|
||||
plain_text = re.sub('<[^<]+?>', '', plain_text)
|
||||
self.excerpt = plain_text[:297] + '...' if len(plain_text) > 300 else plain_text
|
||||
plain_text = _plain_text_from_markdown(self.content)
|
||||
self.excerpt = f"{plain_text[:297]}..." if len(plain_text) > 300 else plain_text
|
||||
|
||||
self.content_html = markdown.markdown(
|
||||
self.content or "",
|
||||
extensions=[
|
||||
"markdown.extensions.extra",
|
||||
"markdown.extensions.codehilite",
|
||||
"markdown.extensions.toc",
|
||||
],
|
||||
)
|
||||
word_count = len((self.content or "").split())
|
||||
self.reading_time = max(1, (word_count + 199) // 200)
|
||||
|
||||
if self.status == Post.StatusChoices.PUBLISHED and not self.published_at:
|
||||
self.published_at = timezone.now()
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
if previous_image_name != current_image_name and previous_image_name:
|
||||
if previous_featured_name != current_featured_name and previous_featured_name:
|
||||
delete_image_derivatives_by_name(
|
||||
self.featured_image.storage if self.featured_image else None,
|
||||
previous_image_name,
|
||||
previous_featured_name,
|
||||
"blog_featured",
|
||||
delete_original=True,
|
||||
)
|
||||
|
||||
if previous_image_name != current_image_name and self.featured_image:
|
||||
if previous_featured_name != current_featured_name and self.featured_image:
|
||||
safe_process_public_image(self.featured_image, "blog_featured")
|
||||
|
||||
@property
|
||||
def content_html(self):
|
||||
"""Convert markdown content to HTML"""
|
||||
return markdown.markdown(
|
||||
self.content,
|
||||
extensions=[
|
||||
'markdown.extensions.extra',
|
||||
'markdown.extensions.codehilite',
|
||||
'markdown.extensions.toc',
|
||||
]
|
||||
)
|
||||
if previous_og_name != current_og_name and previous_og_name:
|
||||
delete_image_derivatives_by_name(
|
||||
self.og_image.storage if self.og_image else None,
|
||||
previous_og_name,
|
||||
"blog_featured",
|
||||
delete_original=True,
|
||||
)
|
||||
if previous_og_name != current_og_name and self.og_image:
|
||||
safe_process_public_image(self.og_image, "blog_featured")
|
||||
|
||||
@property
|
||||
def reading_time(self):
|
||||
"""Estimate reading time in minutes assuming 200 words per minute."""
|
||||
word_count = len(self.content.split())
|
||||
return max(1, word_count // 200)
|
||||
|
||||
class Comment(BaseModel):
|
||||
post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name='comments')
|
||||
author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='comments')
|
||||
content = models.TextField()
|
||||
parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='replies')
|
||||
is_approved = models.BooleanField(default=True)
|
||||
class PostAsset(BaseModel):
|
||||
class FileType(models.TextChoices):
|
||||
IMAGE = "image", "Image"
|
||||
VIDEO = "video", "Video"
|
||||
DOCUMENT = "document", "Document"
|
||||
ARCHIVE = "archive", "Archive"
|
||||
OTHER = "other", "Other"
|
||||
|
||||
post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name="assets")
|
||||
file = models.FileField(upload_to=post_asset_upload_to)
|
||||
file_type = models.CharField(max_length=16, choices=FileType.choices, default=FileType.OTHER)
|
||||
title = models.CharField(max_length=200, blank=True)
|
||||
alt_text = models.CharField(max_length=200, blank=True)
|
||||
caption = models.TextField(blank=True)
|
||||
size = models.PositiveBigIntegerField(default=0)
|
||||
mime_type = models.CharField(max_length=120, blank=True)
|
||||
uploaded_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="blog_assets",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['created_at']
|
||||
ordering = ["-created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=['post', 'is_approved']),
|
||||
models.Index(fields=["post", "file_type"]),
|
||||
models.Index(fields=["uploaded_by", "created_at"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f'Comment by {self.author.username} on {self.post.title}'
|
||||
return self.title or Path(self.file.name).name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
previous_file_name = get_image_previous_name(self, "file")
|
||||
current_file_name = self.file.name if self.file else None
|
||||
if self.file:
|
||||
self.size = self.file.size or self.size
|
||||
if not self.title:
|
||||
self.title = Path(self.file.name).name
|
||||
if not self.mime_type:
|
||||
self.mime_type = mimetypes.guess_type(self.file.name)[0] or ""
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
if previous_file_name != current_file_name and previous_file_name:
|
||||
delete_image_derivatives_by_name(
|
||||
self.file.storage if self.file else None,
|
||||
previous_file_name,
|
||||
"blog_asset",
|
||||
delete_original=True,
|
||||
)
|
||||
if previous_file_name != current_file_name and self.file_type == self.FileType.IMAGE and self.file:
|
||||
safe_process_public_image(self.file, "blog_asset")
|
||||
|
||||
@property
|
||||
def file_url(self):
|
||||
return self.file.url if self.file else None
|
||||
|
||||
@property
|
||||
def thumbnail_url(self):
|
||||
return derivative_url(self.file, THUMBNAIL_VARIANT) if self.file_type == self.FileType.IMAGE else None
|
||||
|
||||
@property
|
||||
def preview_url(self):
|
||||
return derivative_url(self.file, PREVIEW_VARIANT) if self.file_type == self.FileType.IMAGE else None
|
||||
|
||||
@property
|
||||
def blur_url(self):
|
||||
return derivative_url(self.file, BLUR_VARIANT) if self.file_type == self.FileType.IMAGE else None
|
||||
|
||||
|
||||
class Comment(BaseModel):
|
||||
post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name="comments")
|
||||
author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="comments")
|
||||
content = models.TextField()
|
||||
parent = models.ForeignKey("self", on_delete=models.CASCADE, null=True, blank=True, related_name="replies")
|
||||
is_approved = models.BooleanField(default=True)
|
||||
hidden_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="hidden_blog_comments",
|
||||
)
|
||||
hidden_at = models.DateTimeField(null=True, blank=True)
|
||||
moderation_note = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=["post", "is_approved"]),
|
||||
models.Index(fields=["author", "created_at"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"Comment by {self.author.username} on {self.post.title}"
|
||||
|
||||
@property
|
||||
def is_reply(self):
|
||||
return self.parent is not None
|
||||
|
||||
def hide(self, user, note: str = ""):
|
||||
self.is_approved = False
|
||||
self.hidden_by = user
|
||||
self.hidden_at = timezone.now()
|
||||
self.moderation_note = note
|
||||
self.save(update_fields=["is_approved", "hidden_by", "hidden_at", "moderation_note", "updated_at"])
|
||||
|
||||
|
||||
class Like(models.Model):
|
||||
post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name='likes')
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='likes')
|
||||
post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name="likes")
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="likes")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ['post', 'user']
|
||||
unique_together = ["post", "user"]
|
||||
indexes = [
|
||||
models.Index(fields=['post']),
|
||||
models.Index(fields=["post"]),
|
||||
models.Index(fields=["user", "created_at"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.user.username} likes {self.post.title}'
|
||||
return f"{self.user.username} likes {self.post.title}"
|
||||
|
||||
|
||||
class SavedPost(models.Model):
|
||||
post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name="saves")
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="saved_posts")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["post", "user"]
|
||||
indexes = [
|
||||
models.Index(fields=["post"]),
|
||||
models.Index(fields=["user", "created_at"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.username} saved {self.post.title}"
|
||||
|
||||
Reference in New Issue
Block a user