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
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)
|