feat(blog): expand publishing and moderation APIs

This commit is contained in:
2026-06-11 21:20:44 +03:30
parent 4039be0187
commit 5045f8da47
7 changed files with 600 additions and 42 deletions

View File

@@ -54,10 +54,22 @@ def post_asset_upload_to(instance: "PostAsset", filename: str) -> str:
return f"blog/posts/{post_part}/assets/{uuid4().hex}{suffix}"
def blog_banner_upload_to(instance: "BlogBanner", filename: str) -> str:
suffix = Path(filename).suffix.lower()
return f"blog/banners/{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)
parent = models.ForeignKey(
"self",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="children",
)
class Meta:
verbose_name_plural = "Categories"
@@ -71,6 +83,17 @@ class Category(BaseModel):
self.slug = _unique_slug_for(self, self.name)
super().save(*args, **kwargs)
@property
def path(self):
path = []
current = self
seen = set()
while current and current.pk not in seen:
seen.add(current.pk)
path.append(current)
current = current.parent
return list(reversed(path))
class Tag(BaseModel):
name = models.CharField(max_length=50, unique=True)
@@ -136,6 +159,11 @@ class Post(BaseModel):
blank=True,
related_name="published_blog_posts",
)
writers = models.ManyToManyField(
settings.AUTH_USER_MODEL,
blank=True,
related_name="written_blog_posts",
)
class Meta:
ordering = ["-created_at"]
@@ -177,8 +205,8 @@ class Post(BaseModel):
"markdown.extensions.toc",
],
)
word_count = len((self.content or "").split())
self.reading_time = max(1, (word_count + 199) // 200)
character_count = len(_plain_text_from_markdown(self.content or ""))
self.reading_time = max(1, (character_count + 999) // 1000)
if self.status == Post.StatusChoices.PUBLISHED and not self.published_at:
self.published_at = timezone.now()
@@ -206,6 +234,24 @@ class Post(BaseModel):
safe_process_public_image(self.og_image, "blog_featured")
class BlogBanner(BaseModel):
title = models.CharField(max_length=160, blank=True)
alt_text = models.CharField(max_length=200, blank=True)
image = models.ImageField(upload_to=blog_banner_upload_to)
url = models.URLField()
is_active = models.BooleanField(default=True)
sort_order = models.PositiveIntegerField(default=0)
class Meta:
ordering = ["sort_order", "-created_at"]
indexes = [
models.Index(fields=["is_active", "sort_order"]),
]
def __str__(self):
return self.title or self.url
class PostAsset(BaseModel):
class FileType(models.TextChoices):
IMAGE = "image", "Image"
@@ -283,6 +329,7 @@ class Comment(BaseModel):
content = models.TextField()
parent = models.ForeignKey("self", on_delete=models.CASCADE, null=True, blank=True, related_name="replies")
is_approved = models.BooleanField(default=True)
is_hidden = models.BooleanField(default=False)
hidden_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
@@ -292,12 +339,21 @@ class Comment(BaseModel):
)
hidden_at = models.DateTimeField(null=True, blank=True)
moderation_note = models.TextField(blank=True)
deleted_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="deleted_blog_comments",
)
delete_note = models.TextField(blank=True)
class Meta:
ordering = ["created_at"]
indexes = [
models.Index(fields=["post", "is_approved"]),
models.Index(fields=["post", "is_approved", "is_hidden"]),
models.Index(fields=["author", "created_at"]),
models.Index(fields=["parent", "is_deleted", "is_hidden"]),
]
def __str__(self):
@@ -308,11 +364,50 @@ class Comment(BaseModel):
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"])
now = timezone.now()
ids = [self.id, *self.descendant_ids()]
self.__class__.all_objects.filter(id__in=ids, is_deleted=False).update(
is_hidden=True,
is_approved=False,
hidden_by=user,
hidden_at=now,
moderation_note=note,
updated_at=now,
)
def unhide(self):
now = timezone.now()
ids = [self.id, *self.descendant_ids()]
self.__class__.all_objects.filter(id__in=ids, is_deleted=False).update(
is_hidden=False,
is_approved=True,
hidden_by=None,
hidden_at=None,
moderation_note="",
updated_at=now,
)
def descendant_ids(self) -> list[int]:
pending = [self.id]
descendants: list[int] = []
while pending:
child_ids = list(
self.__class__.all_objects.filter(parent_id__in=pending).values_list("id", flat=True)
)
descendants.extend(child_ids)
pending = child_ids
return descendants
def soft_delete_tree(self, user, note: str = ""):
now = timezone.now()
ids = [self.id, *self.descendant_ids()]
self.__class__.all_objects.filter(id__in=ids).update(
is_deleted=True,
deleted_at=now,
deleted_by=user,
delete_note=note,
updated_at=now,
)
class Like(models.Model):