From 913d02bd2e0e26f984e9b6084fdd53589acb788a Mon Sep 17 00:00:00 2001 From: Greg Turner Date: Tue, 15 Aug 2017 15:46:14 +1000 Subject: [PATCH] Re #284 WIP URL shortener --- icekit/admin_tools/mixins.py | 4 ++ icekit/project/settings/_base.py | 4 +- icekit/project/settings/calculated.py | 2 +- icekit/redirects/__init__.py | 1 + icekit/redirects/admin.py | 19 ++++++ icekit/redirects/apps.py | 7 +++ icekit/redirects/middleware.py | 55 +++++++++++++++++ icekit/redirects/migrations/0001_initial.py | 31 ++++++++++ .../migrations/0002_auto_20170815_1519.py | 40 ++++++++++++ icekit/redirects/migrations/__init__.py | 0 icekit/redirects/models.py | 61 +++++++++++++++++++ icekit/redirects/utils.py | 41 +++++++++++++ 12 files changed, 262 insertions(+), 3 deletions(-) create mode 100644 icekit/redirects/__init__.py create mode 100644 icekit/redirects/admin.py create mode 100644 icekit/redirects/apps.py create mode 100644 icekit/redirects/middleware.py create mode 100644 icekit/redirects/migrations/0001_initial.py create mode 100644 icekit/redirects/migrations/0002_auto_20170815_1519.py create mode 100644 icekit/redirects/migrations/__init__.py create mode 100644 icekit/redirects/models.py create mode 100644 icekit/redirects/utils.py diff --git a/icekit/admin_tools/mixins.py b/icekit/admin_tools/mixins.py index b8e7aa30..8f9a04fe 100644 --- a/icekit/admin_tools/mixins.py +++ b/icekit/admin_tools/mixins.py @@ -10,6 +10,7 @@ from icekit.admin_tools.previews import RawIdPreviewAdminMixin from icekit.admin_tools.utils import admin_link +from icekit.redirects.admin import RedirectInline from icekit.utils.attributes import resolve from django.utils.translation import ugettext_lazy as _ @@ -70,6 +71,9 @@ class HeroMixinAdmin(RawIdPreviewAdminMixin): class ListableMixinAdmin(admin.ModelAdmin): + + # inlines = [RedirectInline] + FIELDSETS = ( ('Advanced listing options', { 'classes': ('collapse',), diff --git a/icekit/project/settings/_base.py b/icekit/project/settings/_base.py index 36a45bd3..b9bff56e 100644 --- a/icekit/project/settings/_base.py +++ b/icekit/project/settings/_base.py @@ -343,10 +343,10 @@ # Requires: django.contrib.sites -INSTALLED_APPS += ('django.contrib.redirects', ) +INSTALLED_APPS += ('icekit.redirects', ) MIDDLEWARE_CLASSES += ( - 'django.contrib.redirects.middleware.RedirectFallbackMiddleware', + 'icekit.redirects.middleware.RedirectFallbackMiddleware', ) # DJANGO SITES ################################################################ diff --git a/icekit/project/settings/calculated.py b/icekit/project/settings/calculated.py index c27a0755..7517f105 100644 --- a/icekit/project/settings/calculated.py +++ b/icekit/project/settings/calculated.py @@ -77,7 +77,7 @@ 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.cache.FetchFromCacheMiddleware', 'django.contrib.flatpages.middleware.FlatpageFallbackMiddleware', - 'django.contrib.redirects.middleware.RedirectFallbackMiddleware', + 'icekit.redirects.middleware.RedirectFallbackMiddleware', ]) # Get the secret key from a file that should never be committed to version diff --git a/icekit/redirects/__init__.py b/icekit/redirects/__init__.py new file mode 100644 index 00000000..dd48ee45 --- /dev/null +++ b/icekit/redirects/__init__.py @@ -0,0 +1 @@ +default_app_config = '%s.apps.AppConfig' % __name__ diff --git a/icekit/redirects/admin.py b/icekit/redirects/admin.py new file mode 100644 index 00000000..38f1fe3d --- /dev/null +++ b/icekit/redirects/admin.py @@ -0,0 +1,19 @@ +from django.contrib import admin +from django.contrib.contenttypes.admin import GenericTabularInline + +from .models import Redirect + +class RedirectAdmin(admin.ModelAdmin): + """ + A copy from django.contrib.redirects + """ + list_display = ('old_path', 'new_path') + list_filter = ('site',) + search_fields = ('old_path', 'new_path') + radio_fields = {'site': admin.VERTICAL} + +admin.site.register(Redirect, RedirectAdmin) + + +class RedirectInline(GenericTabularInline): + model = Redirect diff --git a/icekit/redirects/apps.py b/icekit/redirects/apps.py new file mode 100644 index 00000000..73e07ffa --- /dev/null +++ b/icekit/redirects/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig +from django.utils.translation import ugettext_lazy as _ + +class AppConfig(AppConfig): + name = '.'.join(__name__.split('.')[:-1]) + label = 'redirects' + verbose_name = _("Redirects") diff --git a/icekit/redirects/middleware.py b/icekit/redirects/middleware.py new file mode 100644 index 00000000..4986b981 --- /dev/null +++ b/icekit/redirects/middleware.py @@ -0,0 +1,55 @@ +""" +A nearly exact copy from django.contrib.redirects +""" + +from __future__ import unicode_literals + +from django import http +from django.apps import apps +from django.conf import settings +from .models import Redirect +from django.contrib.sites.shortcuts import get_current_site +from django.core.exceptions import ImproperlyConfigured + + +class RedirectFallbackMiddleware(object): + + # Defined as class-level attributes to be subclassing-friendly. + response_gone_class = http.HttpResponseGone + response_redirect_class = http.HttpResponsePermanentRedirect + + def __init__(self): + if not apps.is_installed('django.contrib.sites'): + raise ImproperlyConfigured( + "You cannot use RedirectFallbackMiddleware when " + "django.contrib.sites is not installed." + ) + + def process_response(self, request, response): + # No need to check for a redirect for non-404 responses. + if response.status_code != 404: + return response + + full_path = request.get_full_path() + current_site = get_current_site(request) + + r = None + try: + r = Redirect.objects.get(site=current_site, old_path=full_path) + except Redirect.DoesNotExist: + pass + if settings.APPEND_SLASH and not request.path.endswith('/'): + # Try appending a trailing slash. + path_len = len(request.path) + full_path = full_path[:path_len] + '/' + full_path[path_len:] + try: + r = Redirect.objects.get(site=current_site, old_path=full_path) + except Redirect.DoesNotExist: + pass + if r is not None: + if r.new_path == '': + return self.response_gone_class() + return self.response_redirect_class(r.new_path) + + # No redirect was found. Return the response. + return response diff --git a/icekit/redirects/migrations/0001_initial.py b/icekit/redirects/migrations/0001_initial.py new file mode 100644 index 00000000..49906348 --- /dev/null +++ b/icekit/redirects/migrations/0001_initial.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('sites', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Redirect', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('site', models.ForeignKey(to='sites.Site', to_field='id')), + ('old_path', models.CharField(help_text="This should be an absolute path, excluding the domain name. Example: '/events/search/'.", max_length=200, verbose_name='redirect from', db_index=True)), + ('new_path', models.CharField(help_text="This can be either an absolute path (as above) or a full URL starting with 'http://'.", max_length=200, verbose_name='redirect to', blank=True)), + ], + options={ + 'ordering': ('old_path',), + 'unique_together': set([('site', 'old_path')]), + 'db_table': 'django_redirect', + 'verbose_name': 'redirect', + 'verbose_name_plural': 'redirects', + }, + bases=(models.Model,), + ), + ] diff --git a/icekit/redirects/migrations/0002_auto_20170815_1519.py b/icekit/redirects/migrations/0002_auto_20170815_1519.py new file mode 100644 index 00000000..c0bd9aee --- /dev/null +++ b/icekit/redirects/migrations/0002_auto_20170815_1519.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('redirects', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='redirect', + name='content_type', + field=models.ForeignKey(to='contenttypes.ContentType', null=True, blank=True), + ), + migrations.AddField( + model_name='redirect', + name='object_id', + field=models.PositiveIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='redirect', + name='new_path', + field=models.CharField(blank=True, verbose_name='URL override', max_length=200, help_text="This can be either an absolute path (as above) or a full URL. Example: '/events/search/?q=kids'."), + ), + migrations.AlterField( + model_name='redirect', + name='old_path', + field=models.CharField(db_index=True, blank=True, verbose_name='redirect from', max_length=200, help_text="This can be any unused path, excluding the domain name. Example: '/kids'. A short URL will be generated if this is left blank."), + ), + migrations.AlterField( + model_name='redirect', + name='site', + field=models.ForeignKey(to='sites.Site', default=1), + ), + ] diff --git a/icekit/redirects/migrations/__init__.py b/icekit/redirects/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/icekit/redirects/models.py b/icekit/redirects/models.py new file mode 100644 index 00000000..0c4eee3e --- /dev/null +++ b/icekit/redirects/models.py @@ -0,0 +1,61 @@ +from random import randint + +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.contrib.sites.models import Site +from django.db import models +from django.utils.encoding import python_2_unicode_compatible +from django.utils.translation import ugettext_lazy as _ +from icekit.fields import ICEkitURLField + +from .utils import short_code_from_id +from django.conf import settings + + +@python_2_unicode_compatible +class Redirect(models.Model): + """ + A simple model, database-superset of django.contrib.redirect, for sending one URL to another. + + We customise this model to generate short URLs if old_path isn't given, and to ensure slashes. + """ + + # redirect fields + site = models.ForeignKey(Site, default=settings.SITE_ID) + old_path = models.CharField(_('redirect from'), max_length=200, db_index=True, blank=True, + help_text=_("This can be any unused path, excluding the domain name. Example: '/kids'. " + "A short URL will be generated if this is left blank.")) + # this contrib.redirect field is now used as an 'override'. + new_path = models.CharField(_('URL override'), max_length=200, blank=True, + help_text=_("This can be either an absolute path (as above) or a full URL. Example: '/events/search/?q=kids'.")) + + + # GFK to a piece of content + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, null=True, blank=True) + object_id = models.PositiveIntegerField(null=True, blank=True) + content_object = GenericForeignKey('content_type', 'object_id') + + + class Meta: + verbose_name = _('redirect') + verbose_name_plural = _('redirects') + db_table = 'django_redirect' + unique_together = (('site', 'old_path'),) + ordering = ('old_path',) + + def __str__(self): + return "%s ---> %s" % (self.old_path, self.new_path) + + def save(self, *args, **kwargs): + if not self.old_path: + # we'll need to generate a short code. We want to use the DB ID. If we don't already have one, we + # fake a value, save, and use that ID to generate the code. + if not self.id: + self.old_path = "__TEMPPATH_%s__" % randint(0,32000) + super(Redirect, self).save(*args, **kwargs) + self.old_path = short_code_from_id(self.id) + + if not self.old_path.startswith('/'): + self.old_path = "/%s" % self.old_path + + super(Redirect, self).save(*args, **kwargs) diff --git a/icekit/redirects/utils.py b/icekit/redirects/utils.py new file mode 100644 index 00000000..4c0da4f1 --- /dev/null +++ b/icekit/redirects/utils.py @@ -0,0 +1,41 @@ +import string + +ALPHABET = string.digits + string.lowercase + +def base36encode(number): + """Converts a positive integer to a base36 string.""" + if not isinstance(number, (int, long)): + raise TypeError('`number` must be an integer') + + base36 = '' + + if 0 <= number < len(ALPHABET): + return ALPHABET[number] + + while number != 0: + number, i = divmod(number, len(ALPHABET)) + base36 = ALPHABET[i] + base36 + + return base36 + + +def base36decode(number): + return int(number, len(ALPHABET)) + + +def short_code_from_id(integer, min_length=2): + """ + :param input: a positive integer (ID) representing the URL to be shortened. + :return: a string comprising the letters 0-9 and a-z that maps to that ID + + Assuming a DB ID is passed, the result should be unique. + + We will also ensure that the returned string is at least n characters long, so that the single-character namespace + is reserved for future. We do this by adding len(ALPHABET) ^ min_length to the integer. + + It's not necessary that this function be reversible - as we're using a database lookup - just unique. (If we needed + reversibility we'd either need to fix min_length or would need to encode it in the output.) + """ + + minimum_number = len(ALPHABET) ** (min_length - 1) + return base36encode(integer + minimum_number)