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