Files
guilan-ace-backend/apps/blog/models.py
Amirhossein Khalili 954e78d0cb
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled
feat(backend): add blog publishing platform
2026-06-08 21:31:06 +03:30

348 lines
13 KiB
Python

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, allow_unicode=True)
description = models.TextField(blank=True)
class Meta:
verbose_name_plural = "Categories"
ordering = ["name"]
def __str__(self):
return self.name
def save(self, *args, **kwargs):
if not self.slug:
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, allow_unicode=True)
class Meta:
ordering = ["name"]
def __str__(self):
return self.name
def save(self, *args, **kwargs):
if not self.slug:
self.slug = _unique_slug_for(self, self.name)
super().save(*args, **kwargs)
class Post(BaseModel):
class StatusChoices(models.TextChoices):
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, 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=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")
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"]
indexes = [
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_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 = _unique_slug_for(self, self.title)
if not self.excerpt and self.content:
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_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_featured_name,
"blog_featured",
delete_original=True,
)
if previous_featured_name != current_featured_name and self.featured_image:
safe_process_public_image(self.featured_image, "blog_featured")
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")
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"]
indexes = [
models.Index(fields=["post", "file_type"]),
models.Index(fields=["uploaded_by", "created_at"]),
]
def __str__(self):
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")
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} 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}"