Skip to content

Commit

Permalink
Merge pull request #211 from 2077-Collective/feat/add-related-article…
Browse files Browse the repository at this point in the history
…s-func

chore(feat): add relate articles func
  • Loading branch information
losndu authored Dec 13, 2024
2 parents 031fa6d + eb6c5a1 commit c5feea0
Show file tree
Hide file tree
Showing 4 changed files with 132 additions and 20 deletions.
43 changes: 32 additions & 11 deletions server/apps/research/admin/article_admin.py
Original file line number Diff line number Diff line change
@@ -1,44 +1,64 @@
from django.contrib import admin
from django import forms
from django.db.models import Q
from apps.research.models import Article, ArticleSlugHistory
from tinymce.widgets import TinyMCE
from .slug_history import current_slug_history



class ArticleForm(forms.ModelForm):
class Meta:
model = Article
fields = '__all__'

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['acknowledgement'].widget = TinyMCE(attrs={'cols': 80, 'rows': 30, 'id': "acknowledgement_richtext_field", 'placeholder': f"Enter Acknowledgement here"})
self.fields['content'].widget = TinyMCE(attrs={'cols': 80, 'rows': 30, 'id': "content_richtext_field", 'placeholder': f"Enter Article Content here"})

# Add filtering for related articles to exclude current article and drafts
if self.instance.pk:
self.fields['related_articles'].queryset = Article.objects.filter(
status='ready'
).exclude(
Q(pk=self.instance.pk) | Q(status='draft')
).order_by('-scheduled_publish_time')

self.fields['acknowledgement'].widget = TinyMCE(attrs={'cols': 80, 'rows': 30, 'id': "acknowledgement_richtext_field", 'placeholder': "Enter Acknowledgement here"})
self.fields['content'].widget = TinyMCE(attrs={'cols': 80, 'rows': 30, 'id': "content_richtext_field", 'placeholder': "Enter Article Content here"})

class ArticleAdmin(admin.ModelAdmin):
"""Admin interface for the Article model."""
form = ArticleForm

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

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']}),
('Article Details', {
'fields': [
'title', 'slug', 'authors', 'acknowledgement', 'categories',
'thumb', 'content', 'summary', 'status', 'scheduled_publish_time'
]
}),
('Related Content', {
'fields': ['related_articles'],
'description': 'Select up to 3 related articles that will appear at the end of this article'
}),
('Sponsorship Details', {
'fields': ['is_sponsored', 'sponsor_color', 'sponsor_text_color']
}),
('URL Management', {
'fields': ('current_slug_history',),
'classes': ('collapse',),
'description': 'History of URL changes for this article'
}),
]

list_display = ('title', 'display_authors', 'status', 'views', 'display_categories', 'min_read', 'created_at', 'scheduled_publish_time')
search_fields = ('title', 'authors__user__username', 'authors__twitter_username', 'content')
list_per_page = 25
list_filter = ('authors', 'status', 'categories', 'created_at', 'is_sponsored')
readonly_fields = ('views','current_slug_history',)
readonly_fields = ('views', 'current_slug_history',)
list_editable = ('status',)


def display_authors(self, obj):
"""Return a comma-separated list of authors for the article."""
Expand All @@ -50,8 +70,8 @@ def display_categories(self, obj):
return ", ".join(category.name for category in obj.categories.all())
display_categories.short_description = 'Categories'

def save_model(self, request, obj, form, change):
super().save_model(request, obj, form, change)
def save_model(self, request, obj, form, change):
super().save_model(request, obj, form, change)

def has_change_permission(self, request, obj=None):
"""Check if the user has permission to change the article."""
Expand All @@ -72,6 +92,7 @@ def has_delete_permission(self, request, obj=None):
if obj is not None and not obj.authors.filter(user=request.user).exists():
return False
return True

@admin.register(ArticleSlugHistory)
class ArticleSlugHistoryAdmin(admin.ModelAdmin):
"""Admin interface for the ArticleSlugHistory model."""
Expand All @@ -93,5 +114,5 @@ def has_add_permission(self, request):

def has_delete_permission(self, request, obj=None):
return False # Prevent deletion

admin.site.register(Article, ArticleAdmin)
18 changes: 18 additions & 0 deletions server/apps/research/migrations/0015_article_related_articles.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.0.8 on 2024-12-13 09:39

from django.db import migrations, models


class Migration(migrations.Migration):

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

operations = [
migrations.AddField(
model_name='article',
name='related_articles',
field=models.ManyToManyField(blank=True, help_text='Select up to 3 related articles', related_name='referenced_by', to='research.article', verbose_name='Related Articles'),
),
]
36 changes: 34 additions & 2 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 Down Expand Up @@ -38,13 +39,27 @@ class Article(BaseModel):
is_sponsored = models.BooleanField(default=False)
sponsor_color = models.CharField(max_length=7, default="#FF0420")
sponsor_text_color = models.CharField(max_length=7, default="#000000")
related_articles = models.ManyToManyField(
'self',
blank=True,
symmetrical=False,
related_name='referenced_by',
verbose_name='Related Articles',
help_text='Select up to 3 related articles'
)

objects = models.Manager()
post_objects = ArticleObjects()

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

def clean(self):
super().clean()
# Ensure no more than 3 related articles are selected
if self.related_articles.count() > 3:
raise ValidationError({'related_articles': 'You can select up to 3 related articles only.'})

def calculate_min_read(self):
word_count = len(self.content.split())
words_per_minute = 300 # Average reading speed (words per minute)
Expand Down Expand Up @@ -80,6 +95,24 @@ def build_table_of_contents(self):
self.table_of_contents = toc
self.content = str(soup)

def get_related_articles(self):
"""
Returns manually selected related articles if they exist,
otherwise falls back to automatic recommendations
"""
manual_related = self.related_articles.filter(status='ready').order_by('-scheduled_publish_time')[:3]

if manual_related.exists():
return manual_related

# Fallback logic - articles with matching categories
return Article.objects.filter(
status='ready',
categories__in=self.categories.all()
).exclude(
id=self.id
).distinct().order_by('-scheduled_publish_time')[:3]

def save(self, *args, **kwargs):
"""Override the save method to generate a unique slug and build table of contents."""
if not self.slug or self.title_update():
Expand Down Expand Up @@ -114,7 +147,6 @@ def save(self, *args, **kwargs):

def generate_unique_slug(self):
"""Generate a unique slug for the article."""

base_slug = slugify(self.title)
slug = base_slug
num = 1
Expand Down Expand Up @@ -143,7 +175,7 @@ class Meta:
indexes = [
models.Index(fields=['old_slug']),
]
db_table = 'research_articleslughistory' # explicitly set table name
db_table = 'research_articleslughistory'

def __str__(self):
return f"{self.old_slug} -> {self.article.slug}"
55 changes: 48 additions & 7 deletions server/apps/research/serializers/article_serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,29 @@
from .author_serializer import AuthorSerializer
from .category_serializer import CategorySerializer

class RelatedArticleSerializer(serializers.ModelSerializer):
"""Simplified serializer for related articles to prevent recursion"""
authors = AuthorSerializer(many=True)
categories = CategorySerializer(many=True)

class Meta:
model = Article
fields = [
'id', 'slug', 'title', 'authors', 'thumb', 'categories',
'summary', 'min_read', 'created_at'
]

class ArticleListSerializer(serializers.ModelSerializer):
categories = CategorySerializer(many=True)
authors = AuthorSerializer(many=True)
related_articles = serializers.SerializerMethodField()

def get_related_articles(self, obj):
related = obj.get_related_articles()
return RelatedArticleSerializer(related, many=True, context=self.context).data

class Meta:
model = Article
include = ['categories' 'authors']
exclude = [
'content', 'scheduled_publish_time', 'acknowledgement',
'status', 'views', 'created_at', 'updated_at', 'table_of_contents'
Expand All @@ -18,33 +34,53 @@ class Meta:
class ArticleSerializer(serializers.ModelSerializer):
"""Serializer for the Article model."""
authors = AuthorSerializer(many=True, read_only=True)
categories = CategorySerializer(many=True)
categories = CategorySerializer(many=True)
views = serializers.ReadOnlyField()
min_read = serializers.ReadOnlyField()

related_articles = serializers.SerializerMethodField()

def get_related_articles(self, obj):
related = obj.get_related_articles()
return RelatedArticleSerializer(related, many=True, context=self.context).data

class Meta:
model = Article
fields = [
'id', 'slug', 'title', 'authors', 'thumb', 'categories', 'summary',
'acknowledgement', 'content', 'min_read', 'status', 'views',
'created_at', 'updated_at', 'scheduled_publish_time', 'table_of_contents',
'is_sponsored', 'sponsor_color', 'sponsor_text_color'
'is_sponsored', 'sponsor_color', 'sponsor_text_color', 'related_articles'
]

class ArticleCreateUpdateSerializer(serializers.ModelSerializer):
"""Serializer for creating and updating articles."""
authors = serializers.PrimaryKeyRelatedField(queryset=Author.objects.all(), many=True, required=False)
categories = serializers.PrimaryKeyRelatedField(queryset=Category.objects.all(), many=True, required=False)
related_articles = serializers.PrimaryKeyRelatedField(
queryset=Article.objects.filter(status='ready'),
many=True,
required=False
)

class Meta:
model = Article
fields = ['title', 'slug', 'categories', 'thumb', 'content', 'summary', 'acknowledgement', 'status', 'authors', 'scheduled_publish_time', 'is_sponsored', 'sponsor_color', 'sponsor_text_color']
fields = [
'title', 'slug', 'categories', 'thumb', 'content', 'summary',
'acknowledgement', 'status', 'authors', 'scheduled_publish_time',
'is_sponsored', 'sponsor_color', 'sponsor_text_color', 'related_articles'
]

def validate_related_articles(self, value):
if len(value) > 3:
raise serializers.ValidationError("You can select up to 3 related articles only.")
return value

def create(self, validated_data: dict) -> Article:
"""Create a new article instance."""
request = self.context.get('request')
authors = validated_data.pop('authors', [])
categories = validated_data.pop('categories', [])
related_articles = validated_data.pop('related_articles', [])

try:
if not authors and request and hasattr(request, 'user'):
Expand All @@ -59,24 +95,29 @@ def create(self, validated_data: dict) -> Article:
article.authors.set(authors)
if categories:
article.categories.set(categories)
if related_articles is not None:
article.related_articles.set(related_articles)

return article
except Exception as e:
raise serializers.ValidationError(f"Error creating article: {str(e)}")


def update(self, instance: Article, validated_data: dict) -> Article:
"""Update an existing article instance."""
authors = validated_data.pop('authors', [])
categories = validated_data.pop('categories', [])
related_articles = validated_data.pop('related_articles', [])

try:
instance = super().update(instance, validated_data)

if authors:
instance.authors.set(authors)
if categories:
instance.categories.set(categories)
if related_articles is not None:
instance.related_articles.set(related_articles)

return instance
except Exception as e:
raise serializers.ValidationError(f"Error updating article: {str(e)}")
raise serializers.ValidationError(f"Error updating article: {str(e)}")

0 comments on commit c5feea0

Please sign in to comment.