348 lines
13 KiB
Python
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}"
|