diff --git a/server/apps/research/admin/article_admin.py b/server/apps/research/admin/article_admin.py index 97e36fd..352ae59 100644 --- a/server/apps/research/admin/article_admin.py +++ b/server/apps/research/admin/article_admin.py @@ -1,8 +1,11 @@ from django.contrib import admin from django import forms -from apps.research.models import Article, Author +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 @@ -16,16 +19,26 @@ def __init__(self, *args, **kwargs): 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']}), + ('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',) + readonly_fields = ('views','current_slug_history',) list_editable = ('status',) + def display_authors(self, obj): """Return a comma-separated list of authors for the article.""" @@ -59,5 +72,26 @@ 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.""" + list_display = ('article_title', 'old_slug', 'current_slug', 'created_at') + list_filter = ('created_at', 'article__title') + search_fields = ('old_slug', 'article__title') + readonly_fields = ('article', 'old_slug', 'created_at') + + def article_title(self, obj): + return obj.article.title + article_title.short_description = 'Article' + + def current_slug(self, obj): + return obj.article.slug + current_slug.short_description = 'Current Slug' + def has_add_permission(self, request): + return False # Prevent manual addition + + 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/slug_history.py b/server/apps/research/admin/slug_history.py new file mode 100644 index 0000000..ce46dd7 --- /dev/null +++ b/server/apps/research/admin/slug_history.py @@ -0,0 +1,35 @@ +from django.utils.html import format_html, escape +from django.utils.safestring import mark_safe + +def get_slug_history_table(histories): + """Return the HTML table for the slug history.""" + html = [] + html.append('') + html.append('') + html.append('') + html.append('') + html.append('') + html.append('') + html.append('') + for history in histories: + html.append('') + html.append(f'') + html.append(f'') + html.append('') + html.append('') + html.append('
Slug History Table
Old SlugChanged At
{escape(history.old_slug)}{history.created_at}
') + return mark_safe(''.join(html)) + +def get_slug_history_html(obj): + """Return the HTML for the slug history.""" + histories = obj.slug_history.all().order_by('-created_at') + if not histories: + return "No slug changes recorded" + html = ['
'] + html.append(get_slug_history_table(histories)) + html.append('
') + return format_html(''.join(html)) + +def current_slug_history(obj): + """Display the history of URL changes for the article.""" + return get_slug_history_html(obj) \ No newline at end of file diff --git a/server/apps/research/migrations/0012_articleslughistory.py b/server/apps/research/migrations/0012_articleslughistory.py new file mode 100644 index 0000000..2a064e8 --- /dev/null +++ b/server/apps/research/migrations/0012_articleslughistory.py @@ -0,0 +1,28 @@ +# Generated by Django 5.0.8 on 2024-11-24 18:13 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('research', '0011_remove_article_sponsor_padding'), + ] + + operations = [ + migrations.CreateModel( + name='ArticleSlugHistory', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('old_slug', models.SlugField(max_length=255)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='slug_history', to='research.article')), + ], + options={ + 'db_table': 'research_articleslughistory', + 'indexes': [models.Index(fields=['old_slug'], name='research_ar_old_slu_12bba6_idx')], + 'unique_together': {('article', 'old_slug')}, + }, + ), + ] diff --git a/server/apps/research/migrations/0013_alter_article_authors.py b/server/apps/research/migrations/0013_alter_article_authors.py new file mode 100644 index 0000000..d9ac19f --- /dev/null +++ b/server/apps/research/migrations/0013_alter_article_authors.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.8 on 2024-11-29 17:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('research', '0012_articleslughistory'), + ] + + operations = [ + migrations.AlterField( + model_name='article', + name='authors', + field=models.ManyToManyField(related_name='articles', to='research.author'), + ), + ] diff --git a/server/apps/research/migrations/0014_alter_article_authors.py b/server/apps/research/migrations/0014_alter_article_authors.py new file mode 100644 index 0000000..493f07c --- /dev/null +++ b/server/apps/research/migrations/0014_alter_article_authors.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.8 on 2024-12-02 08:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('research', '0013_alter_article_authors'), + ] + + operations = [ + migrations.AlterField( + model_name='article', + name='authors', + field=models.ManyToManyField(blank=True, related_name='articles', to='research.author'), + ), + ] diff --git a/server/apps/research/models.py b/server/apps/research/models.py index c4e3730..1f9a46f 100644 --- a/server/apps/research/models.py +++ b/server/apps/research/models.py @@ -2,6 +2,6 @@ from .category import Category from .author import Author -from .article import Article +from .article import Article, ArticleSlugHistory -__all__ = ['Category', 'Author', 'Article'] +__all__ = ['Category', 'Author', 'Article', 'ArticleSlugHistory'] diff --git a/server/apps/research/models/__init__.py b/server/apps/research/models/__init__.py index aefa2a1..3737c10 100644 --- a/server/apps/research/models/__init__.py +++ b/server/apps/research/models/__init__.py @@ -1,3 +1,3 @@ from .category import Category from .author import Author -from .article import Article \ No newline at end of file +from .article import Article, ArticleSlugHistory \ No newline at end of file diff --git a/server/apps/research/models/article.py b/server/apps/research/models/article.py index 397fc9e..72cca9d 100644 --- a/server/apps/research/models/article.py +++ b/server/apps/research/models/article.py @@ -10,6 +10,7 @@ import json from bs4 import BeautifulSoup import uuid +from django.db import transaction def get_default_thumb(): return f"{settings.MEDIA_URL}images/2077-Collective.png" @@ -83,7 +84,26 @@ 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(): self.slug = self.generate_unique_slug() - + + """Override the save method to track slug changes.""" + if self.pk: # If this is an existing article + try: + old_instance = Article.objects.get(pk=self.pk) + # 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 + ) + except Article.DoesNotExist: + pass + if self.content: self.build_table_of_contents() @@ -110,3 +130,20 @@ def title_update(self): if original: return original.title != self.title return False + +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') + 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') + indexes = [ + models.Index(fields=['old_slug']), + ] + db_table = 'research_articleslughistory' # explicitly set table name + + def __str__(self): + return f"{self.old_slug} -> {self.article.slug}" \ No newline at end of file diff --git a/server/apps/research/views.py b/server/apps/research/views.py index 382b42c..eb355b9 100644 --- a/server/apps/research/views.py +++ b/server/apps/research/views.py @@ -1,12 +1,16 @@ from rest_framework.decorators import action from django.db.models import F -from django.shortcuts import render +from django.shortcuts import render, get_object_or_404 from rest_framework import viewsets, status from rest_framework.response import Response import uuid import logging +from django.db import transaction +from rest_framework import serializers +from urllib.parse import quote -from .models import Article + +from .models import Article, ArticleSlugHistory from .permissions import ArticleUserWritePermission from .serializers import ArticleSerializer, ArticleCreateUpdateSerializer, ArticleListSerializer @@ -42,7 +46,9 @@ def create(self, request, *args, **kwargs): return Response(serializer.data, status=status.HTTP_201_CREATED) except Exception as e: logger.error(f"Unexpected error during article creation: {e}") - return Response({'error': 'An unexpected error occurred'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + if isinstance(e, serializers.ValidationError): + return Response({'error': 'Invalid data provided'}, status=status.HTTP_400_BAD_REQUEST) + return Response({'error': 'Failed to create a new Article'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) def update(self, request, *args, **kwargs): """Handle article update.""" @@ -52,30 +58,48 @@ def update(self, request, *args, **kwargs): serializer.is_valid(raise_exception=True) self.perform_update(serializer) return Response(serializer.data, status=status.HTTP_200_OK) - except Exception as e: + except Exception as e: logger.error(f"Unexpected error during article update: {e}") - return Response({'error': 'An unexpected error occurred'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + if isinstance(e, serializers.ValidationError): + return Response({'error': 'Invalid data provided'}, status=status.HTTP_400_BAD_REQUEST) + return Response({'error': 'Error updating article'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) # Custom action to retrieve articles by slug or UUID @action(detail=False, methods=['get'], url_path=r'(?P[-\w0-9a-fA-F]+)') def retrieve_by_identifier(self, request, identifier=None): - """Retrieve an article by slug or UUID.""" + """Retrieve an article by slug or UUID, handling old slugs.""" try: - if self.is_valid_uuid(identifier): - instance = Article.objects.get(pk=identifier) - else: - instance = Article.objects.get(slug=identifier) - except Article.DoesNotExist: - return Response({'error': 'Article does not exist'}, status=status.HTTP_404_NOT_FOUND) + with transaction.atomic(): + if self.is_valid_uuid(identifier): + instance = Article.objects.get(pk=identifier) + else: + try: + instance = Article.objects.get(slug=identifier) + except Article.DoesNotExist: + # Check if this is an old slug + slug_history = get_object_or_404(ArticleSlugHistory, old_slug=identifier) + instance = slug_history.article + # Return a redirect response with the new URL + new_url = request.build_absolute_uri().replace( + f'/api/articles/{quote(identifier)}/', + f'/api/articles/{quote(instance.slug)}/' + ) + return Response({ + 'type': 'redirect', + 'new_url': new_url, + 'data': self.get_serializer(instance).data + }, status=status.HTTP_301_MOVED_PERMANENTLY) + + instance.views = F('views') + 1 + instance.save(update_fields=['views']) + instance.refresh_from_db(fields=['views']) + serializer = self.get_serializer(instance) + return Response({'success': True, 'data': serializer.data}) + except Exception as e: logger.error(f"Error retrieving article by identifier: {e}") - return Response({'error': 'An unexpected error occurred'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - - instance.views = F('views') + 1 - instance.save(update_fields=['views']) - instance.refresh_from_db(fields=['views']) - serializer = self.get_serializer(instance) - return Response({'success': True, 'data': serializer.data}) + return Response({'error': 'Article does not exist'}, + status=status.HTTP_404_NOT_FOUND) # Custom action to retrieve articles by category @action(detail=False, methods=['get'], url_path=r'category/(?P[-\w]+)') @@ -87,7 +111,7 @@ def retrieve_by_category(self, request, category=None): return Response({'success': True, 'data': serializer.data}) except Exception as e: logger.error(f"Error retrieving articles by category: {e}") - return Response({'error': 'An unexpected error occurred'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return Response({'error': 'Category does not exist'}, status=status.HTTP_404_NOT_FOUND) def is_valid_uuid(self, value): """Check if the value is a valid UUID.""" diff --git a/server/core/config/base.py b/server/core/config/base.py index 297b6ea..ae1ed98 100644 --- a/server/core/config/base.py +++ b/server/core/config/base.py @@ -14,6 +14,10 @@ from pathlib import Path from dotenv import load_dotenv +# third party imports +from .celery_config import (CELERY_BROKER_URL, CELERY_RESULT_BACKEND, CELERY_ACCEPT_CONTENT, CELERY_TASK_SERIALIZER, CELERY_RESULT_SERIALIZER, CELERY_TIMEZONE) +from .mail import (SITE_URL, EMAIL_HOST, EMAIL_HOST_USER, EMAIL_HOST_PASSWORD, DEFAULT_FROM_EMAIL, EMAIL_PORT, EMAIL_USE_TLS, EMAIL_USE_SSL, EMAIL_BACKEND) + load_dotenv() from decouple import config @@ -154,6 +158,7 @@ USE_I18N = True + USE_TZ = True # Static files (CSS, JavaScript, Images) @@ -191,7 +196,4 @@ SILENCED_SYSTEM_CHECKS = ["security.W019"] # Tinymce API Config -TINYMCE_API_KEY = os.getenv('TINYMCE_API_KEY') - -from .celery_config import * -from .mail import * \ No newline at end of file +TINYMCE_API_KEY = config('TINYMCE_API_KEY') \ No newline at end of file diff --git a/server/core/urls.py b/server/core/urls.py index c9eb82b..e1bbc1d 100644 --- a/server/core/urls.py +++ b/server/core/urls.py @@ -4,6 +4,7 @@ from django.urls import path, include from .token import csrf_token_view + urlpatterns = [ path('admin/', admin.site.urls), @@ -17,5 +18,6 @@ path('tinymce/', include('tinymce.urls')), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + if settings.DEBUG: urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/server/static/style.css b/server/static/style.css index 41b7c09..141be7a 100644 --- a/server/static/style.css +++ b/server/static/style.css @@ -93,3 +93,37 @@ body { background-color: var(--gray-8); } } + +.slug-history-table { + width: 100%; + border-collapse: collapse; +} + +.slug-history-table th, .slug-history-table td { + padding: 8px; + border: 1px solid #ddd; +} + +.slug-history-table th { + background-color: #f5f5f5; + background-color: var(--gray-1); +} + +@media (prefers-color-scheme: dark) { + .slug-history-table th { + background-color: var(--gray-7); + } + .slug-history-table th, .slug-history-table td { + border-color: var(--gray-6); + } +} + +.slug-history-table caption { + clip: rect(0 0 0 0); + clip-path: inset(50%); + height: 1px; + overflow: hidden; + position: absolute; + white-space: nowrap; + width: 1px; +} \ No newline at end of file