Skip to content

Commit

Permalink
chore(feat): add relatedArticles logic
Browse files Browse the repository at this point in the history
  • Loading branch information
ndu committed Dec 11, 2024
1 parent 41261fb commit faf84ee
Show file tree
Hide file tree
Showing 5 changed files with 217 additions and 41 deletions.
21 changes: 20 additions & 1 deletion server/apps/research/admin/article_admin.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,23 @@
from django.contrib import admin
from django import forms
from apps.research.models import Article, ArticleSlugHistory
from apps.research.models import Article, ArticleSlugHistory, RelatedArticle
from tinymce.widgets import TinyMCE
from .slug_history import current_slug_history



class RelatedArticleInline(admin.TabularInline):
model = RelatedArticle
fk_name = 'from_article'
extra = 1
max_num = 3
verbose_name = 'Related Article'
verbose_name_plural = 'Related Articles'

def formfield_for_foreignkey(self, db_field, request, **kwargs):
if db_field.name == 'to_article':
kwargs['queryset'] = Article.objects.filter(status='ready')
return super().formfield_for_foreignkey(db_field, request, **kwargs)
class ArticleForm(forms.ModelForm):
class Meta:
model = Article
Expand All @@ -19,10 +31,17 @@ def __init__(self, *args, **kwargs):
class ArticleAdmin(admin.ModelAdmin):
"""Admin interface for the Article model."""
form = ArticleForm
inlines = [RelatedArticleInline]

def current_slug_history(self, obj):
return current_slug_history(obj)
current_slug_history.short_description = 'Slug Change History'

def get_inlines(self, request, obj):
if obj is None:
return []
return super().get_inlines(request, obj)

fieldsets = [
('Article Details', {'fields': ['title', 'slug', 'authors', 'acknowledgement', 'categories', 'thumb', 'content', 'summary', 'status', 'scheduled_publish_time']}),
('Sponsorship Details', {'fields': ['is_sponsored', 'sponsor_color', 'sponsor_text_color']}),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Generated by Django 5.0.8 on 2024-12-11 05:54

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('research', '0014_alter_article_authors'),
]

operations = [
migrations.CreateModel(
name='RelatedArticle',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('from_article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='related_from', to='research.article')),
('to_article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='related_to', to='research.article')),
],
),
migrations.AddField(
model_name='article',
name='related_articles',
field=models.ManyToManyField(blank=True, related_name='referenced_by', through='research.RelatedArticle', to='research.article'),
),
migrations.AddConstraint(
model_name='relatedarticle',
constraint=models.CheckConstraint(check=models.Q(('from_article', models.F('to_article')), _negated=True), name='prevent_self_reference'),
),
migrations.AlterUniqueTogether(
name='relatedarticle',
unique_together={('from_article', 'to_article')},
),
]
2 changes: 1 addition & 1 deletion server/apps/research/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
from .category import Category
from .author import Author
from .article import Article, ArticleSlugHistory
from .article import Article, ArticleSlugHistory, RelatedArticle
149 changes: 114 additions & 35 deletions server/apps/research/models/article.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from django.db import models
from django.utils.text import slugify
from django.core.exceptions import ValidationError
from apps.common.models import BaseModel
from apps.research.managers import ArticleObjects
from .category import Category
Expand All @@ -12,28 +13,41 @@
import uuid
from django.db import transaction


def get_default_thumb():
return f"{settings.MEDIA_URL}images/2077-Collective.png"


class Article(BaseModel):
"""Model for articles."""

options = (
('draft', 'Draft'),
('ready', 'Ready'),
("draft", "Draft"),
("ready", "Ready"),
)

title = models.TextField()
content = HTMLField(blank=True, null=True)
summary = models.TextField(blank=True)
acknowledgement = HTMLField(blank=True, null=True)
authors = models.ManyToManyField(Author, blank=True, related_name='articles')
authors = models.ManyToManyField(Author, blank=True, related_name="articles")
slug = models.SlugField(max_length=255, blank=True, db_index=True)
categories = models.ManyToManyField(Category, blank=True, related_name='articles')
thumb = models.ImageField(upload_to='images/', default=get_default_thumb, blank=True)
categories = models.ManyToManyField(Category, blank=True, related_name="articles")
related_articles = models.ManyToManyField(
"self",
blank=True,
symmetrical=False,
related_name="referenced_by",
through="RelatedArticle",
)
thumb = models.ImageField(
upload_to="images/", default=get_default_thumb, blank=True
)
views = models.PositiveBigIntegerField(default=0)
status = models.CharField(max_length=10, choices=options, default='draft', db_index=True)
scheduled_publish_time = models.DateTimeField(null=True, blank=True, db_index=True)
status = models.CharField(
max_length=10, choices=options, default="draft", db_index=True
)
scheduled_publish_time = models.DateTimeField(null=True, blank=True, db_index=True)
table_of_contents = models.JSONField(default=list, blank=True)
is_sponsored = models.BooleanField(default=False)
sponsor_color = models.CharField(max_length=7, default="#FF0420")
Expand All @@ -43,8 +57,31 @@ class Article(BaseModel):
post_objects = ArticleObjects()

class Meta:
ordering = ('-scheduled_publish_time',)

ordering = ("-scheduled_publish_time",)

def get_related_articles(self):
"""
Get manually selected related articles for this article.
If none exist, fallback to default frontend logic
"""

manual_related_articles = self.related_articles.filter(status="ready").order_by(
"-scheduled_publish_time"
)[:3]

if manual_related_articles.exists():
return manual_related_articles

# Fallback to last 3 articles in the same category

category_articles = (
Article.objects.filter(categories__in=self.categories.all(), status="ready")
.exclude(id=self.id)
.order_by("-scheduled_publish_time")
.distinct()[:3]
)
return category_articles

def calculate_min_read(self):
word_count = len(self.content.split())
words_per_minute = 300 # Average reading speed (words per minute)
Expand All @@ -58,24 +95,24 @@ def __str__(self):

def build_table_of_contents(self):
"""Build the table of contents from the article content."""
soup = BeautifulSoup(self.content, 'html.parser')
headers = soup.find_all(['h1', 'h2', 'h3'])
soup = BeautifulSoup(self.content, "html.parser")
headers = soup.find_all(["h1", "h2", "h3"])

toc = []
stack = [{'level': 0, 'children': toc}]
stack = [{"level": 0, "children": toc}]

for header in headers:
level = int(header.name[1])
title = header.get_text()
header['id'] = slugify(title)
header["id"] = slugify(title)

while level <= stack[-1]['level']:
while level <= stack[-1]["level"]:
stack.pop()

new_item = {'title': title, 'id': header['id'], 'children': []}
stack[-1]['children'].append(new_item)
stack.append({'level': level, 'children': new_item['children']})
new_item = {"title": title, "id": header["id"], "children": []}

stack[-1]["children"].append(new_item)
stack.append({"level": level, "children": new_item["children"]})

self.table_of_contents = toc
self.content = str(soup)
Expand All @@ -92,23 +129,26 @@ def save(self, *args, **kwargs):
# Generate new slug first
if not self.slug or self.title_update():
self.slug = self.generate_unique_slug()

# Then check if we need to create slug history
if old_instance.slug and old_instance.slug != self.slug:
# Only create history if the slug actually changed and isn't empty
with transaction.atomic():
ArticleSlugHistory.objects.create(
article=self,
old_slug=old_instance.slug
article=self, old_slug=old_instance.slug
)
except Article.DoesNotExist:
pass
pass

if self.content:
self.build_table_of_contents()

if self.scheduled_publish_time and self.status == 'draft' and timezone.now() >= self.scheduled_publish_time:
self.status = 'ready'

if (
self.scheduled_publish_time
and self.status == "draft"
and timezone.now() >= self.scheduled_publish_time
):
self.status = "ready"

super().save(*args, **kwargs)

Expand All @@ -122,28 +162,67 @@ def generate_unique_slug(self):
slug = f"{base_slug}-{num}"
num += 1
return slug

def title_update(self):
"""Check if the title has changed."""
if self.pk: # Only check if the article exists
original = Article.objects.filter(pk=self.pk).only('title').first()
original = Article.objects.filter(pk=self.pk).only("title").first()
if original:
return original.title != self.title
return False


class RelatedArticle(models.Model):
"""Through model for related articles to prevent circular references."""

from_article = models.ForeignKey(
Article, on_delete=models.CASCADE, related_name="related_from"
)
to_article = models.ForeignKey(
Article, on_delete=models.CASCADE, related_name="related_to"
)
created_at = models.DateTimeField(auto_now_add=True)

class Meta:
unique_together = ("from_article", "to_article")
constraints = [
models.CheckConstraint(
check=~models.Q(from_article=models.F("to_article")),
name="prevent_self_reference",
)
]

def clean(self):
# Prevent direct circular references
if RelatedArticle.objects.filter(
from_article=self.to_article, to_article=self.from_article
).exists():
raise ValidationError("Circular references detected.")

# Maximum of 3 related articles
if RelatedArticle.objects.filter(from_article=self.from_article).count() >= 3:
raise ValidationError("Maximum of 3 related articles allowed.")

def save(self, *args, **kwargs):
self.clean()
super().save(*args, **kwargs)

class ArticleSlugHistory(models.Model):
"""Model to track historical slugs for articles."""

id = models.AutoField(primary_key=True)
article = models.ForeignKey('Article', on_delete=models.CASCADE, related_name='slug_history')
article = models.ForeignKey(
"Article", on_delete=models.CASCADE, related_name="slug_history"
)
old_slug = models.SlugField(max_length=255, db_index=True)
created_at = models.DateTimeField(auto_now_add=True)

class Meta:
unique_together = ('article', 'old_slug')
unique_together = ("article", "old_slug")
indexes = [
models.Index(fields=['old_slug']),
models.Index(fields=["old_slug"]),
]
db_table = 'research_articleslughistory' # explicitly set table name
db_table = "research_articleslughistory" # explicitly set table name

def __str__(self):
return f"{self.old_slug} -> {self.article.slug}"
return f"{self.old_slug} -> {self.article.slug}"
Loading

0 comments on commit faf84ee

Please sign in to comment.