Skip to content

Commit

Permalink
Add a shortlinks Django app. (#2167)
Browse files Browse the repository at this point in the history
During our latest NYCx meeting we discussed the possibility of using a short URL service like bit.ly to provide branded short URLs, to make links to external resources provided on the textbot take up less precious characters.

Unfortunately, using bit.ly to create URLs with custom domains isn't very cheap (about $40 per month) and they don't have any kind of nonprofit discount.

As an alternative, I figured it shouldn't be too hard to roll-our-own extremely simplistic short URL service as a feature on the tenant platform.  Admins could log into our Django admin backend and use a basic UI to create urls that would be of the form `https://app.justfix.nyc/s/<some short id>`.

Aside from being useful to shorten URLs though, creating a layer of redirection has some other advantages:

* **We have the ability to update where our short URLs point.** This means, for example, if we send a text message that says "go here to learn more about rent reductions" and points to a URL on a government website, it that URL eventually 404's and the user is going through their SMS text history and taps the link, it will 404 too.  However, if the link points to a short URL we've provided, and we change the link's target once it 404's, then tapping that link won't 404 anymore.

* **We can change our external URLs in just one place.** For instance, if we have a single short URL that points to a government page on obtaining rent reductions, and we link to our short URL in our learning center articles and our tenant platform and our text bot, then if the target of that URL ever changes, we can just update the short URL target, instead of having to manually update the content of all our properties.

* **We could regularly sweep through our short URLs and be alerted of any that 404.**  To figure out which links have rotted, we can simply iterate through all the short URLs in our database and regularly check which ones 404.

This adds the feature via a new Django app called `shortlinks`, which Outreach Coordinators have the ability to create and modify.

## Limitations

* This doesn't currently record visits to shortlinks in any way, so we can't easily do metrics (unless we parse our log files, I guess).
* These links aren't _super_ short, e.g. `app.justfix.nyc/s/boop` could be further shortened to something like `jf.nyc/boop`, but that would take more work.
* There's no version history, so it's not easy to revert a change to a shortlink. We could add [django-reversion](https://django-reversion.readthedocs.io/) to get this, though.
  • Loading branch information
toolness authored Aug 9, 2021
1 parent 1de578d commit 02f2f60
Show file tree
Hide file tree
Showing 15 changed files with 140 additions and 0 deletions.
1 change: 1 addition & 0 deletions project/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@
"evictionfree.apps.EvictionfreeConfig",
"amplitude.apps.AmplitudeConfig",
"nycx",
"shortlinks",
]

MIDDLEWARE = [
Expand Down
1 change: 1 addition & 0 deletions project/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
path("mailchimp/", include("mailchimp.urls")),
path("p/", include("partnerships.urls")),
path("nycx/", include("nycx.urls")),
path("s/", include("shortlinks.urls")),
re_path(r"^en-(?:US|us)\/.*$", redirect_en_us),
re_path(r"^unsupported-locale\/.*$", frontend.views.react_rendered_view),
]
Expand Down
Empty file added shortlinks/__init__.py
Empty file.
23 changes: 23 additions & 0 deletions shortlinks/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from project.util.admin_util import admin_field
from django.contrib import admin

from project.util.site_util import absolute_reverse
from . import models


@admin.register(models.Link)
class LinkAdmin(admin.ModelAdmin):
list_display = ["title", "slug", "short_link", "url"]

fields = ["title", "url", "slug", "short_link", "description"]

readonly_fields = ["short_link"]

add_fields = ["title", "url", "slug", "description"]

@admin_field(admin_order_field="slug")
def short_link(self, obj):
if obj is not None and obj.pk:
return absolute_reverse("shortlinks:redirect", kwargs={"slug": obj.slug})

return "(This will be set once you save the link.)"
6 changes: 6 additions & 0 deletions shortlinks/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class ShortlinksConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "shortlinks"
26 changes: 26 additions & 0 deletions shortlinks/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Generated by Django 3.2.4 on 2021-07-21 10:53

from django.db import migrations, models


class Migration(migrations.Migration):

initial = True

dependencies = [
]

operations = [
migrations.CreateModel(
name='Link',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('url', models.URLField(help_text='The destination of the link.')),
('title', models.CharField(help_text='The title of the link.', max_length=200)),
('slug', models.SlugField(help_text='The slug of the link. This will be used in the short link, so try to keep it short yet (hopefully) memorable.', max_length=200, unique=True)),
('description', models.TextField(blank=True, help_text='A description of the link. Optional.')),
],
),
]
Empty file.
25 changes: 25 additions & 0 deletions shortlinks/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from django.db import models


class Link(models.Model):
created_at = models.DateTimeField(auto_now_add=True)

updated_at = models.DateTimeField(auto_now=True)

url = models.URLField(help_text="The destination of the link.")

title = models.CharField(max_length=200, help_text="The title of the link.")

slug = models.SlugField(
max_length=200,
help_text=(
"The slug of the link. This will be used in the short link, so "
"try to keep it short yet (hopefully) memorable."
),
unique=True,
)

description = models.TextField(help_text="A description of the link. Optional.", blank=True)

def __str__(self):
return self.title
Empty file added shortlinks/tests/__init__.py
Empty file.
16 changes: 16 additions & 0 deletions shortlinks/tests/factories.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import factory

from shortlinks.models import Link


class LinkFactory(factory.django.DjangoModelFactory):
class Meta:
model = Link

title = "Housing Court Answers"

url = "http://housingcourtanswers.org/"

slug = "hca"

description = "Our awesome partner's website."
7 changes: 7 additions & 0 deletions shortlinks/tests/test_models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from shortlinks.models import Link


class TestLink:
def test_str_works(self):
link = Link(title="Boop")
assert str(link) == "Boop"
16 changes: 16 additions & 0 deletions shortlinks/tests/test_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import pytest

from .factories import LinkFactory


@pytest.mark.parametrize("slug", ["hca", "hca-is_K00L"])
def test_redirect_works(db, client, slug):
LinkFactory(slug=slug)
res = client.get(f"/s/{slug}")
assert res.status_code == 302
assert res["Location"] == "http://housingcourtanswers.org/"


def test_redirect_404s_on_invalid_slug(db, client, disable_locale_middleware):
res = client.get("/s/hca")
assert res.status_code == 404
9 changes: 9 additions & 0 deletions shortlinks/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from django.urls import re_path

from . import views

app_name = "shortlinks"

urlpatterns = [
re_path(r"(?P<slug>[A-Za-z0-9\-_]+)", views.redirect_to_link, name="redirect"),
]
9 changes: 9 additions & 0 deletions shortlinks/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404

from .models import Link


def redirect_to_link(request, slug):
link = get_object_or_404(Link, slug=slug)
return HttpResponseRedirect(link.url)
1 change: 1 addition & 0 deletions users/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"evictionfree.change_evictionfreeuser",
IMPERSONATE_USERS_PERMISSION,
"django_sql_dashboard.execute_sql",
*ModelPermissions("shortlinks", "link").only(add=True, change=True),
]
)

Expand Down

0 comments on commit 02f2f60

Please sign in to comment.