feat(blog): expand publishing and moderation APIs
This commit is contained in:
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user