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('Slug History Table')
+ html.append('')
+ html.append('Old Slug | ')
+ html.append('Changed At | ')
+ html.append('
')
+ html.append('')
+ for history in histories:
+ html.append('')
+ html.append(f'{escape(history.old_slug)} | ')
+ html.append(f'{history.created_at} | ')
+ html.append('
')
+ html.append('')
+ html.append('
')
+ 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