feat(backend): add blog publishing platform
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled

This commit is contained in:
2026-06-08 21:31:06 +03:30
parent b7b21a6cc6
commit 954e78d0cb
14 changed files with 1334 additions and 278 deletions

View File

@@ -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}"