Skip to content

Commit

Permalink
Merge pull request #193 from 2077-Collective/url-redirect
Browse files Browse the repository at this point in the history
feat: auto-redirect url when slug changes.
  • Loading branch information
losndu authored Dec 3, 2024
2 parents e866ed1 + b19151e commit 596988f
Show file tree
Hide file tree
Showing 12 changed files with 262 additions and 30 deletions.
38 changes: 36 additions & 2 deletions server/apps/research/admin/article_admin.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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."""
Expand Down Expand Up @@ -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)
35 changes: 35 additions & 0 deletions server/apps/research/admin/slug_history.py
Original file line number Diff line number Diff line change
@@ -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('<table class="slug-history-table" role="grid">')
html.append('<caption class="sr-only">Slug History Table</caption>')
html.append('<thead><tr>')
html.append('<th scope="col">Old Slug</th>')
html.append('<th scope="col">Changed At</th>')
html.append('</tr>')
html.append('</thead><tbody>')
for history in histories:
html.append('<tr>')
html.append(f'<td>{escape(history.old_slug)}</td>')
html.append(f'<td>{history.created_at}</td>')
html.append('</tr>')
html.append('</tbody>')
html.append('</table>')
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 = ['<div class="slug-history">']
html.append(get_slug_history_table(histories))
html.append('</div>')
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)
28 changes: 28 additions & 0 deletions server/apps/research/migrations/0012_articleslughistory.py
Original file line number Diff line number Diff line change
@@ -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')},
},
),
]
18 changes: 18 additions & 0 deletions server/apps/research/migrations/0013_alter_article_authors.py
Original file line number Diff line number Diff line change
@@ -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'),
),
]
18 changes: 18 additions & 0 deletions server/apps/research/migrations/0014_alter_article_authors.py
Original file line number Diff line number Diff line change
@@ -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'),
),
]
4 changes: 2 additions & 2 deletions server/apps/research/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']
2 changes: 1 addition & 1 deletion server/apps/research/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
from .category import Category
from .author import Author
from .article import Article
from .article import Article, ArticleSlugHistory
39 changes: 38 additions & 1 deletion server/apps/research/models/article.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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()

Expand All @@ -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}"
64 changes: 44 additions & 20 deletions server/apps/research/views.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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."""
Expand All @@ -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<identifier>[-\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<category>[-\w]+)')
Expand All @@ -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."""
Expand Down
10 changes: 6 additions & 4 deletions server/core/config/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -154,6 +158,7 @@

USE_I18N = True


USE_TZ = True

# Static files (CSS, JavaScript, Images)
Expand Down Expand Up @@ -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 *
TINYMCE_API_KEY = config('TINYMCE_API_KEY')
2 changes: 2 additions & 0 deletions server/core/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from django.urls import path, include
from .token import csrf_token_view


urlpatterns = [
path('admin/', admin.site.urls),

Expand All @@ -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)
Loading

0 comments on commit 596988f

Please sign in to comment.