You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

573 lines
16 KiB

import math
import random
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.utils import timezone
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
from apps.profiles.models.institution import Institution
class PostCategory(models.Model):
name = models.CharField(
max_length=255,
verbose_name=_('Category Name')
)
slug = models.SlugField(
max_length=255,
unique=True,
allow_unicode=True,
verbose_name=_('Slug / URL Identifier')
)
language = models.CharField(
max_length=10,
default='en',
choices=settings.LANGUAGES,
verbose_name=_('Language')
)
description = models.TextField(
blank=True,
null=True,
verbose_name=_('Category Description')
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name=_('Created At')
)
class Meta:
ordering = ('name',)
verbose_name = _('Post Category')
verbose_name_plural = _('Post Categories')
def __str__(self):
return f"{self.name} ({self.language})"
def save(self, *args, **kwargs):
if not self.slug:
base_slug = slugify(self.name, allow_unicode=True) or f"category-{random.randint(1000, 9999)}"
slug = base_slug
counter = 1
while PostCategory.objects.filter(slug=slug).exclude(pk=self.pk).exists():
slug = f"{base_slug}-{counter}"
counter += 1
self.slug = slug
super().save(*args, **kwargs)
class PostTag(models.Model):
name = models.CharField(
max_length=100,
unique=True,
verbose_name=_('Tag Name')
)
slug = models.SlugField(
max_length=100,
unique=True,
allow_unicode=True,
verbose_name=_('Slug')
)
class Meta:
ordering = ('name',)
verbose_name = _('Post Tag')
verbose_name_plural = _('Post Tags')
def __str__(self):
return self.name
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.name, allow_unicode=True) or f"tag-{random.randint(1000, 9999)}"
super().save(*args, **kwargs)
class Post(models.Model):
class PostType(models.TextChoices):
NEWS = 'news', _('News & Announcements')
ARTICLE = 'article', _('Analytical Article')
REPORT = 'report', _('Field & Diplomatic Report')
INTERVIEW = 'interview', _('Expert Interview')
class AuthorType(models.TextChoices):
USER = 'user', _('Individual User')
INSTITUTION = 'institution', _('Institution')
class Status(models.TextChoices):
DRAFT = 'draft', _('Draft')
PENDING_REVIEW = 'pending_review', _('Pending Review')
PUBLISHED = 'published', _('Published')
REJECTED = 'rejected', _('Rejected')
ARCHIVED = 'archived', _('Archived')
Status.REVIEW = Status.PENDING_REVIEW
class LanguageChoices(models.TextChoices):
FA = 'fa', _('Persian')
AR = 'ar', _('Arabic')
EN = 'en', _('English')
UR = 'ur', _('Urdu')
FR = 'fr', _('French')
title = models.CharField(
max_length=255,
verbose_name=_('Post Title')
)
slug = models.SlugField(
max_length=255,
unique=True,
allow_unicode=True,
blank=True,
null=True,
verbose_name=_('Slug / URL Identifier')
)
post_type = models.CharField(
max_length=50,
choices=PostType.choices,
default=PostType.ARTICLE,
verbose_name=_('Post Type')
)
author_type = models.CharField(
max_length=20,
choices=AuthorType.choices,
default=AuthorType.USER,
verbose_name=_('Author Type'),
help_text=_('Indicates whether the post is authored as an individual user or on behalf of an institution.')
)
category = models.ForeignKey(
PostCategory,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='posts',
verbose_name=_('Category')
)
language = models.CharField(
max_length=10,
choices=LanguageChoices.choices,
default=LanguageChoices.EN,
verbose_name=_('Language')
)
status = models.CharField(
max_length=20,
choices=Status.choices,
default=Status.DRAFT,
verbose_name=_('Publication Status')
)
rejection_reason = models.TextField(
blank=True,
default='',
verbose_name=_('Rejection Reason'),
help_text=_('Reason provided by editor when rejecting a post.')
)
reviewed_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='reviewed_cms_posts',
verbose_name=_('Reviewed By')
)
reviewed_at = models.DateTimeField(
null=True,
blank=True,
verbose_name=_('Reviewed At')
)
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='cms_posts',
verbose_name=_('Author'),
help_text=_('User account that created or submitted the post.')
)
institution = models.ForeignKey(
Institution,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='cms_posts',
verbose_name=_('Associated Institution'),
help_text=_('Institution on whose behalf the post is published if author_type is institution.')
)
author_role_label = models.CharField(
max_length=150,
blank=True,
null=True,
verbose_name=_('Author Role Label'),
help_text=_('e.g. Senior Researcher, Center Director, Diplomatic Envoy')
)
author_custom_avatar = models.ImageField(
upload_to='cms/avatars/%Y/%m/',
blank=True,
null=True,
verbose_name=_('Custom Author Avatar')
)
featured_image = models.ImageField(
upload_to='cms/featured/%Y/%m/',
blank=True,
null=True,
verbose_name=_('Featured Cover Image')
)
excerpt = models.TextField(
blank=True,
null=True,
verbose_name=_('Excerpt / Summary'),
help_text=_('Brief summary for search results and cards.')
)
content = models.TextField(
verbose_name=_('Rich Text / Markdown Content')
)
tags = models.JSONField(
default=list,
blank=True,
verbose_name=_('Tags')
)
reading_time_minutes = models.PositiveIntegerField(
default=3,
verbose_name=_('Reading Time (Minutes)')
)
views_count = models.PositiveIntegerField(
default=0,
verbose_name=_('Views Count')
)
likes_count = models.PositiveIntegerField(
default=0,
verbose_name=_('Likes Count')
)
is_featured = models.BooleanField(
default=False,
verbose_name=_('Is Featured')
)
publish_date = models.DateTimeField(
default=timezone.now,
verbose_name=_('Publish Date')
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name=_('Created At')
)
updated_at = models.DateTimeField(
auto_now=True,
verbose_name=_('Updated At')
)
class Meta:
ordering = ('-is_featured', '-publish_date')
verbose_name = _('Post')
verbose_name_plural = _('Posts')
def __str__(self):
return f"{self.title} ({self.get_post_type_display()} - {self.language})"
def save(self, *args, **kwargs):
if not self.slug:
base_slug = slugify(self.title, allow_unicode=True) or f"post-{random.randint(1000, 9999)}"
slug = base_slug
counter = 1
while Post.objects.filter(slug=slug).exclude(pk=self.pk).exists():
slug = f"{base_slug}-{counter}"
counter += 1
self.slug = slug
# Calculate reading time based on word count (~200 words/min)
if self.content:
word_count = len(self.content.split())
self.reading_time_minutes = max(1, math.ceil(word_count / 200))
super().save(*args, **kwargs)
def clean(self):
super().clean()
if self.author_type == self.AuthorType.INSTITUTION and not self.institution_id:
raise ValidationError({
'institution': _('An institution is required when author type is Institution.')
})
@property
def is_institution_authored(self) -> bool:
return self.author_type == self.AuthorType.INSTITUTION and self.institution_id is not None
@property
def author_display_name(self) -> str:
if self.is_institution_authored and self.institution:
return self.institution.name
if self.author:
return getattr(self.author, 'fullname', None) or self.author.get_full_name() or self.author.email
return ""
def is_liked_by(self, user):
if not user or not user.is_authenticated:
return False
return self.likes.filter(user=user).exists()
@property
def has_pending_revision(self) -> bool:
return self.revisions.filter(status=PostRevision.Status.PENDING).exists()
def get_pending_revision(self):
return self.revisions.filter(status=PostRevision.Status.PENDING).first()
class PostRevision(models.Model):
class Status(models.TextChoices):
PENDING = 'pending', _('Pending Review')
APPROVED = 'approved', _('Approved & Merged')
REJECTED = 'rejected', _('Rejected')
post = models.ForeignKey(
Post,
on_delete=models.CASCADE,
related_name='revisions',
verbose_name=_('Original Post')
)
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='cms_post_revisions',
verbose_name=_('Author')
)
title = models.CharField(
max_length=255,
verbose_name=_('Revised Title')
)
excerpt = models.TextField(
blank=True,
default='',
verbose_name=_('Revised Excerpt')
)
content = models.TextField(
verbose_name=_('Revised Content')
)
tags = models.JSONField(
default=list,
blank=True,
verbose_name=_('Revised Tags')
)
featured_image = models.ImageField(
upload_to='cms/revisions/%Y/%m/',
blank=True,
null=True,
verbose_name=_('Revised Featured Image')
)
category = models.ForeignKey(
PostCategory,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='revisions',
verbose_name=_('Revised Category')
)
status = models.CharField(
max_length=20,
choices=Status.choices,
default=Status.PENDING,
verbose_name=_('Revision Status')
)
rejection_reason = models.TextField(
blank=True,
default='',
verbose_name=_('Rejection Reason')
)
reviewed_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='reviewed_cms_revisions',
verbose_name=_('Reviewed By')
)
reviewed_at = models.DateTimeField(
null=True,
blank=True,
verbose_name=_('Reviewed At')
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name=_('Submitted At')
)
updated_at = models.DateTimeField(
auto_now=True,
verbose_name=_('Updated At')
)
class Meta:
ordering = ('-created_at',)
verbose_name = _('Post Revision')
verbose_name_plural = _('Post Revisions')
def __str__(self):
return f"Revision #{self.id} for Post #{self.post_id} ({self.get_status_display()})"
def apply_to_post(self, editor=None):
"""
Merges this revision into the live Post seamlessly with zero downtime.
"""
post = self.post
post.title = self.title
post.content = self.content
if self.excerpt:
post.excerpt = self.excerpt
if self.tags:
post.tags = self.tags
if self.category:
post.category = self.category
if self.featured_image:
post.featured_image = self.featured_image
post.reviewed_by = editor
post.reviewed_at = timezone.now()
post.rejection_reason = ''
post.save()
self.status = PostRevision.Status.APPROVED
self.reviewed_by = editor
self.reviewed_at = timezone.now()
self.rejection_reason = ''
self.save(update_fields=['status', 'reviewed_by', 'reviewed_at', 'rejection_reason', 'updated_at'])
class PostComment(models.Model):
class Status(models.TextChoices):
PENDING = 'pending', _('Pending Moderation')
APPROVED = 'approved', _('Approved')
SPAM = 'spam', _('Spam / Rejected')
post = models.ForeignKey(
Post,
on_delete=models.CASCADE,
related_name='comments',
verbose_name=_('Post')
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='post_comments',
verbose_name=_('User')
)
author_name = models.CharField(
max_length=255,
blank=True,
null=True,
verbose_name=_('Author Name')
)
author_email = models.EmailField(
blank=True,
null=True,
verbose_name=_('Author Email')
)
content = models.TextField(
verbose_name=_('Comment Content')
)
status = models.CharField(
max_length=20,
choices=Status.choices,
default=Status.APPROVED,
verbose_name=_('Moderation Status')
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name=_('Created At')
)
class Meta:
ordering = ('-created_at',)
verbose_name = _('Post Comment')
verbose_name_plural = _('Post Comments')
def __str__(self):
author = self.author_name or (self.user.fullname if self.user else "Anonymous")
return f"Comment by {author} on {self.post.title}"
class PostLike(models.Model):
post = models.ForeignKey(
Post,
on_delete=models.CASCADE,
related_name='likes',
verbose_name=_('Post')
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='cms_post_likes',
verbose_name=_('User')
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name=_('Created At')
)
class Meta:
unique_together = ('post', 'user')
ordering = ('-created_at',)
verbose_name = _('Post Like')
verbose_name_plural = _('Post Likes')
def __str__(self):
return f"{self.user} likes {self.post.title}"
class MediaAsset(models.Model):
class MediaType(models.TextChoices):
IMAGE = 'image', _('Image')
VIDEO = 'video', _('Video')
AUDIO = 'audio', _('Audio')
DOCUMENT = 'document', _('Document / PDF')
file = models.FileField(
upload_to='cms/media/%Y/%m/',
verbose_name=_('Media File')
)
title = models.CharField(
max_length=255,
blank=True,
null=True,
verbose_name=_('Title / Description')
)
media_type = models.CharField(
max_length=20,
choices=MediaType.choices,
default=MediaType.IMAGE,
verbose_name=_('Media Type')
)
file_size = models.PositiveIntegerField(
default=0,
verbose_name=_('File Size (Bytes)')
)
dimensions = models.CharField(
max_length=50,
blank=True,
null=True,
verbose_name=_('Dimensions (WxH)')
)
uploaded_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='uploaded_cms_media',
verbose_name=_('Uploaded By')
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name=_('Created At')
)
class Meta:
ordering = ('-created_at',)
verbose_name = _('Media Asset')
verbose_name_plural = _('Media Asset Library')
def __str__(self):
return self.title or f"Media Asset #{self.id}"
def save(self, *args, **kwargs):
if self.file and not self.file_size:
try:
self.file_size = self.file.size
except Exception:
pass
super().save(*args, **kwargs)