diff --git a/server/apps/research/admin/article_admin.py b/server/apps/research/admin/article_admin.py index 1a8fa17..1a29c07 100644 --- a/server/apps/research/admin/article_admin.py +++ b/server/apps/research/admin/article_admin.py @@ -1,5 +1,6 @@ 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 @@ -16,9 +17,34 @@ 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"}) - self.fields['gpt_summary'].widget = TinyMCE(attrs={'cols': 80, 'rows': 15, 'id': "gpt_summary_richtext_field", 'placeholder': f"GPT-generated summary will appear 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') + + # Configure TinyMCE widgets + 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" + }) + self.fields['gpt_summary'].widget = TinyMCE(attrs={ + 'cols': 80, + 'rows': 15, + 'id': "gpt_summary_richtext_field", + 'placeholder': "GPT-generated summary will appear here" + }) class ArticleAdmin(admin.ModelAdmin): """Admin interface for the Article model.""" @@ -50,7 +76,6 @@ def generate_summary_view(self, request): if request.method == 'POST': content = request.POST.get('content') try: - # Run the async function in the sync view gpt_summary = asyncio.run(self._generate_summary(content)) return JsonResponse({'summary': gpt_summary}) except Exception as e: @@ -64,19 +89,31 @@ def current_slug_history(self, obj): current_slug_history.short_description = 'Slug Change History' fieldsets = [ - ('Article Details', {'fields': ['title', 'slug', 'authors', 'acknowledgement', 'categories', 'thumb', 'content', 'summary', 'gpt_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', 'gpt_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',) class Media: @@ -95,8 +132,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.""" @@ -117,6 +154,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.""" @@ -138,5 +176,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/admin/category_admin.py b/server/apps/research/admin/category_admin.py index 0edf8ef..01d742d 100644 --- a/server/apps/research/admin/category_admin.py +++ b/server/apps/research/admin/category_admin.py @@ -10,4 +10,4 @@ class CategoryAdmin(admin.ModelAdmin): search_fields = ('name',) list_filter = ('created_at',) ordering = ('name',) - readonly_fields = ('slug',) + readonly_fields = ('slug',) \ No newline at end of file diff --git a/server/apps/research/migrations/0015_category_slug.py b/server/apps/research/migrations/0015_category_slug.py index 4ee94d1..0ac0a71 100644 --- a/server/apps/research/migrations/0015_category_slug.py +++ b/server/apps/research/migrations/0015_category_slug.py @@ -1,7 +1,19 @@ -# Generated by Django 5.0.8 on 2024-12-09 11:07 +# Generated by Django 5.0.8 on 2024-12-13 07:34 from django.db import migrations, models +def remove_duplicate_slugs(apps, schema_editor): + Category = apps.get_model('research', 'Category') + seen_slugs = {} + + for category in Category.objects.order_by('id'): + base_slug = category.slug + counter = 1 + while category.slug in seen_slugs: + category.slug = f"{base_slug}-{counter}" + counter += 1 + seen_slugs[category.slug] = True + category.save() class Migration(migrations.Migration): @@ -15,4 +27,5 @@ class Migration(migrations.Migration): name='slug', field=models.SlugField(blank=True, max_length=255), ), + migrations.RunPython(remove_duplicate_slugs), ] diff --git a/server/apps/research/migrations/0016_alter_category_slug.py b/server/apps/research/migrations/0016_alter_category_slug.py deleted file mode 100644 index 6df3e9e..0000000 --- a/server/apps/research/migrations/0016_alter_category_slug.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.0.8 on 2024-12-09 11:19 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('research', '0015_category_slug'), - ] - - operations = [ - migrations.AlterField( - model_name='category', - name='slug', - field=models.SlugField(blank=True, max_length=255, unique=True), - ), - ] diff --git a/server/apps/research/migrations/0016_article_related_articles.py b/server/apps/research/migrations/0016_article_related_articles.py new file mode 100644 index 0000000..074bacc --- /dev/null +++ b/server/apps/research/migrations/0016_article_related_articles.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.8 on 2024-12-13 12:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('research', '0015_category_slug'), + ] + + 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/migrations/0017_article_gpt_summary_alter_article_summary.py b/server/apps/research/migrations/0017_article_gpt_summary_alter_article_summary.py index 9d2b209..939a188 100644 --- a/server/apps/research/migrations/0017_article_gpt_summary_alter_article_summary.py +++ b/server/apps/research/migrations/0017_article_gpt_summary_alter_article_summary.py @@ -6,7 +6,7 @@ class Migration(migrations.Migration): dependencies = [ - ('research', '0016_alter_category_slug'), + ('research', '0016_article_related_articles'), ] operations = [ diff --git a/server/apps/research/models/article.py b/server/apps/research/models/article.py index 5d8883e..f490205 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 @@ -39,6 +40,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() @@ -46,6 +55,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) @@ -81,6 +96,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(): @@ -115,7 +148,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 @@ -144,7 +176,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/models/category.py b/server/apps/research/models/category.py index 080d673..5f8da82 100644 --- a/server/apps/research/models/category.py +++ b/server/apps/research/models/category.py @@ -6,15 +6,21 @@ class Category(BaseModel): """Model for categories.""" name = models.CharField(max_length=255) - slug = models.SlugField(max_length=255, unique=True, blank=True) + slug = models.SlugField(max_length=255, blank=True) class Meta: verbose_name_plural = 'Categories' def save(self, *args, **kwargs): - if not self.slug: - self.slug = self.generate_slug() - super().save(*args, **kwargs) + with transaction.atomic(): + try: + if not self.slug: + self.slug = self.generate_slug() + if len(self.slug) > 255: + raise ValueError("Generated slug exceeds maximum length") + except ValueError as e: + raise ValueError(f"Failed to generate valid slug") from e + super().save(*args, **kwargs) def __str__(self): return self.name diff --git a/server/apps/research/serializers/article_serializer.py b/server/apps/research/serializers/article_serializer.py index 16dc7a1..ff04ebf 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,54 @@ 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', 'gpt_summary', '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', 'gpt_summary', 'acknowledgement', 'status', 'authors', 'scheduled_publish_time', 'is_sponsored', 'sponsor_color', 'sponsor_text_color'] + fields = [ + 'title', 'slug', 'categories', 'thumb', 'content', 'summary', + 'gpt_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 +96,19 @@ 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) @@ -76,7 +116,9 @@ def update(self, instance: Article, validated_data: dict) -> Article: 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)}") \ No newline at end of file