Skip to content

Commit

Permalink
Re ic-labs#284 WIP URL shortener
Browse files Browse the repository at this point in the history
  • Loading branch information
Greg Turner committed Aug 15, 2017
1 parent bdacba6 commit 913d02b
Show file tree
Hide file tree
Showing 12 changed files with 262 additions and 3 deletions.
4 changes: 4 additions & 0 deletions icekit/admin_tools/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 _
Expand Down Expand Up @@ -70,6 +71,9 @@ class HeroMixinAdmin(RawIdPreviewAdminMixin):


class ListableMixinAdmin(admin.ModelAdmin):

# inlines = [RedirectInline]

FIELDSETS = (
('Advanced listing options', {
'classes': ('collapse',),
Expand Down
4 changes: 2 additions & 2 deletions icekit/project/settings/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ################################################################
Expand Down
2 changes: 1 addition & 1 deletion icekit/project/settings/calculated.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions icekit/redirects/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
default_app_config = '%s.apps.AppConfig' % __name__
19 changes: 19 additions & 0 deletions icekit/redirects/admin.py
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions icekit/redirects/apps.py
Original file line number Diff line number Diff line change
@@ -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")
55 changes: 55 additions & 0 deletions icekit/redirects/middleware.py
Original file line number Diff line number Diff line change
@@ -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
31 changes: 31 additions & 0 deletions icekit/redirects/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -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,),
),
]
40 changes: 40 additions & 0 deletions icekit/redirects/migrations/0002_auto_20170815_1519.py
Original file line number Diff line number Diff line change
@@ -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),
),
]
Empty file.
61 changes: 61 additions & 0 deletions icekit/redirects/models.py
Original file line number Diff line number Diff line change
@@ -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)
41 changes: 41 additions & 0 deletions icekit/redirects/utils.py
Original file line number Diff line number Diff line change
@@ -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)

0 comments on commit 913d02b

Please sign in to comment.