from django.db import models from django.conf import settings from django.utils.text import slugify from django.utils import timezone import markdown from core.models import BaseModel class Category(BaseModel): name = models.CharField(max_length=100, unique=True) slug = models.SlugField(max_length=100, unique=True, blank=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 = slugify(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) class Meta: ordering = ['name'] def __str__(self): return self.name def save(self, *args, **kwargs): if not self.slug: self.slug = slugify(self.name) super().save(*args, **kwargs) class Post(BaseModel): class StatusChoices(models.TextChoices): DRAFT = 'draft', 'Draft' PUBLISHED = 'published', 'Published' title = models.CharField(max_length=200) slug = models.SlugField(max_length=200, unique=True, blank=True) content = models.TextField(help_text="Content in Markdown format") 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=10, 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) class Meta: ordering = ['-created_at'] indexes = [ models.Index(fields=['status', 'published_at']), models.Index(fields=['is_featured']), ] def __str__(self): return self.title def save(self, *args, **kwargs): if not self.slug: self.slug = slugify(self.title) # Auto-generate excerpt if not provided if not self.excerpt and self.content: # Convert markdown to plain text for excerpt plain_text = markdown.markdown(self.content, extensions=['markdown.extensions.extra']) # Remove HTML tags and truncate import re plain_text = re.sub('<[^<]+?>', '', plain_text) self.excerpt = plain_text[:297] + '...' if len(plain_text) > 300 else plain_text if self.status == Post.StatusChoices.PUBLISHED and not self.published_at: self.published_at = timezone.now() super().save(*args, **kwargs) @property def content_html(self): """Convert markdown content to HTML""" return markdown.markdown( self.content, extensions=[ 'markdown.extensions.extra', 'markdown.extensions.codehilite', 'markdown.extensions.toc', ] ) @property def reading_time(self): """Estimate reading time in minutes assuming 200 words per minute.""" word_count = len(self.content.split()) return max(1, word_count // 200) 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) class Meta: ordering = ['created_at'] indexes = [ models.Index(fields=['post', 'is_approved']), ] 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 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']), ] def __str__(self): return f'{self.user.username} likes {self.post.title}'