Skip to content

Commit

Permalink
Merge pull request #204 from 2077-Collective/main
Browse files Browse the repository at this point in the history
chore(feat): add relatedArticles logic
  • Loading branch information
losndu authored Dec 12, 2024
2 parents 11bb528 + 5f42eb2 commit f3e2561
Show file tree
Hide file tree
Showing 5 changed files with 45 additions and 237 deletions.
39 changes: 1 addition & 38 deletions server/apps/research/admin/article_admin.py
Original file line number Diff line number Diff line change
@@ -1,41 +1,11 @@
from django.contrib import admin
from django import forms
from apps.research.models import Article, ArticleSlugHistory, RelatedArticle
from apps.research.models import Article, ArticleSlugHistory
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':
# Get the parent object (Article) from the request
obj_id = request.resolver_match.kwargs.get('object_id')
parent_obj = None

if obj_id:
try:
parent_obj = Article.objects.get(pk=obj_id)
except Article.DoesNotExist:
pass

base_queryset = Article.objects.filter(status='ready')
if parent_obj:
# Exclude self-reference and articles that already have a relationship
base_queryset = base_queryset.exclude(
id=parent_obj.id
).exclude(
related_from__to_article=parent_obj
)
kwargs['queryset'] = base_queryset
return super().formfield_for_foreignkey(db_field, request, **kwargs)
class ArticleForm(forms.ModelForm):
class Meta:
model = Article
Expand All @@ -49,17 +19,10 @@ 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

This file was deleted.

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, RelatedArticle
from .article import Article, ArticleSlugHistory
151 changes: 35 additions & 116 deletions server/apps/research/models/article.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
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 @@ -13,41 +12,28 @@
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")
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
)
categories = models.ManyToManyField(Category, blank=True, related_name='articles')
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 @@ -57,31 +43,8 @@ class Article(BaseModel):
post_objects = ArticleObjects()

class Meta:
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

ordering = ('-scheduled_publish_time',)

def calculate_min_read(self):
word_count = len(self.content.split())
words_per_minute = 300 # Average reading speed (words per minute)
Expand All @@ -95,24 +58,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 @@ -129,26 +92,23 @@ 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 @@ -162,69 +122,28 @@ 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):
# Acquire a lock on related articles for this from_article
RelatedArticle.objects.select_for_update().filter(from_article=self.from_article)
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 f3e2561

Please sign in to comment.