From 80d6e670827db62be28267c31aa05d608ebf6eb0 Mon Sep 17 00:00:00 2001 From: ndu Date: Fri, 13 Dec 2024 12:26:13 +0100 Subject: [PATCH 1/3] chore(feat): add relate articles func --- server/apps/research/admin/article_admin.py | 43 +++++++++++---- .../0015_article_related_articles.py | 18 ++++++ server/apps/research/models/article.py | 36 +++++++++++- .../serializers/article_serializer.py | 55 ++++++++++++++++--- 4 files changed, 132 insertions(+), 20 deletions(-) create mode 100644 server/apps/research/migrations/0015_article_related_articles.py diff --git a/server/apps/research/admin/article_admin.py b/server/apps/research/admin/article_admin.py index 352ae59..7dc624c 100644 --- a/server/apps/research/admin/article_admin.py +++ b/server/apps/research/admin/article_admin.py @@ -1,11 +1,10 @@ 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 @@ -13,32 +12,53 @@ class Meta: 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.""" @@ -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.""" @@ -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.""" @@ -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) \ No newline at end of file diff --git a/server/apps/research/migrations/0015_article_related_articles.py b/server/apps/research/migrations/0015_article_related_articles.py new file mode 100644 index 0000000..e458dd1 --- /dev/null +++ b/server/apps/research/migrations/0015_article_related_articles.py @@ -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'), + ), + ] diff --git a/server/apps/research/models/article.py b/server/apps/research/models/article.py index 72cca9d..2c3f4f8 100644 --- a/server/apps/research/models/article.py +++ b/server/apps/research/models/article.py @@ -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 @@ -38,6 +39,14 @@ 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() @@ -45,6 +54,12 @@ class Article(BaseModel): 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) @@ -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(): @@ -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 @@ -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}" \ No newline at end of file diff --git a/server/apps/research/serializers/article_serializer.py b/server/apps/research/serializers/article_serializer.py index 2bf8989..43147c7 100644 --- a/server/apps/research/serializers/article_serializer.py +++ b/server/apps/research/serializers/article_serializer.py @@ -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' @@ -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'): @@ -59,16 +95,19 @@ def create(self, validated_data: dict) -> Article: article.authors.set(authors) if categories: article.categories.set(categories) + if related_articles: + 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) @@ -76,7 +115,9 @@ def update(self, instance: Article, validated_data: dict) -> Article: instance.authors.set(authors) if categories: instance.categories.set(categories) + if related_articles: + 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)}") \ No newline at end of file From f8417af15c76bd4b1cfecb01686c789e2f3f7bda Mon Sep 17 00:00:00 2001 From: ndu Date: Fri, 13 Dec 2024 12:34:46 +0100 Subject: [PATCH 2/3] chore(feat): add fix to related articles func --- server/apps/research/serializers/article_serializer.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/apps/research/serializers/article_serializer.py b/server/apps/research/serializers/article_serializer.py index 43147c7..2a19dbd 100644 --- a/server/apps/research/serializers/article_serializer.py +++ b/server/apps/research/serializers/article_serializer.py @@ -95,12 +95,12 @@ def create(self, validated_data: dict) -> Article: article.authors.set(authors) if categories: article.categories.set(categories) - if related_articles: + 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)}") + raise serializers.ValidationError(f"Error creating article: {str(e)}") from e def update(self, instance: Article, validated_data: dict) -> Article: """Update an existing article instance.""" @@ -115,7 +115,7 @@ def update(self, instance: Article, validated_data: dict) -> Article: instance.authors.set(authors) if categories: instance.categories.set(categories) - if related_articles: + if related_articles is not None: instance.related_articles.set(related_articles) return instance From eb6c5a11a6f038ee5f33aa56a32be15b50b1eb6c Mon Sep 17 00:00:00 2001 From: ndu Date: Fri, 13 Dec 2024 12:37:28 +0100 Subject: [PATCH 3/3] chore(feat): add fix to related articles func --- server/apps/research/serializers/article_serializer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/apps/research/serializers/article_serializer.py b/server/apps/research/serializers/article_serializer.py index 2a19dbd..f8b02b2 100644 --- a/server/apps/research/serializers/article_serializer.py +++ b/server/apps/research/serializers/article_serializer.py @@ -100,7 +100,7 @@ def create(self, validated_data: dict) -> Article: return article except Exception as e: - raise serializers.ValidationError(f"Error creating article: {str(e)}") from e + raise serializers.ValidationError(f"Error creating article: {str(e)}") def update(self, instance: Article, validated_data: dict) -> Article: """Update an existing article instance."""