Programming • Jan 14, 2026 • Cliff
Building A Django SaaS Application (Part 3)
Building a Django SaaS App
Or The Django SaaS Mega-Tutorial
From Scratch to Subscription-Ready (Part 3)
TL;DR
We will add a user-scoped link shortening feature:
- Model
Linkholds a target URL and a per-user slug, exposed as/<username>/<slug>/. - Model
Clickrecords each visit (referrer, user agent, IP) before redirecting with a 302. - We follow TDD: write tests first, implement models, run migrations with
manage.py. - Future tutorials will add freemium/paid gating; this tutorial keeps it open for all users.
Estimated time: 45–75 minutes Prerequisites: Completed Tutorials 001 and 002 (project runs, tests green).
What We Are Building
A minimal link shortener that lives under each user’s namespace:
https://localhost:8000/<username>/<slug>/
This avoids cross-user slug collisions. Each hit records a Click and then issues a 302 redirect to the target URL. Users can create, list, update, and delete their own links; later we will gate quotas by plan.
Development Workflow (Red → Green → Refactor)
1) Red: Write tests describing the behavior. 2) Green: Implement the smallest code to satisfy the tests. 3) Refactor: Clean up while keeping tests green.
We will start with models and migrations, then proceed to CRUD views/templates.
Step 0: Ensure the links App Exists
Create the app
python manage.py startapp links
Add it to INSTALLED_APPS if it is not already present:
# config/settings.py
INSTALLED_APPS = [
# ...
"links",
]
Step 1: Design the Data Model
Link
- user: owner (FK to custom user)
- target_url: destination URL
- slug: short id (per-user unique, generated if blank)
- created_at / updated_at
- Derived public_path: /<username>/<slug>/
Click
- link: FK to Link
- created_at: timestamp of the visit
- referrer: HTTP referrer (optional)
- user_agent: raw user-agent string (optional)
- ip_address: visitor IP (optional, stored as GenericIPAddressField)
Why per-user slug uniqueness?
We avoid global slug clashes by namespacing under the username. Two users can both own /<username>/promo/ without conflict.
Step 2: Write Model Tests (Red)
Open links/tests.py and add tests that express the expected behaviors:
from django.contrib.auth import get_user_model
from django.db import IntegrityError
from django.test import TestCase
from .models import Click, Link
class LinkModelTests(TestCase):
def setUp(self) -> None:
User = get_user_model()
self.alice = User.objects.create_user(
username="alice", email="alice@example.com", password="password123"
)
self.bob = User.objects.create_user(
username="bob", email="bob@example.com", password="password123"
)
def test_slug_autogenerates_and_public_path(self) -> None:
link = Link.objects.create(user=self.alice, target_url="https://example.com")
self.assertTrue(link.slug)
self.assertEqual(len(link.slug), 8)
self.assertEqual(link.public_path, f"/{self.alice.username}/{link.slug}/")
def test_slug_unique_per_user(self) -> None:
Link.objects.create(
user=self.alice,
target_url="https://example.com/one",
slug="customslug",
)
with self.assertRaises(IntegrityError):
Link.objects.create(
user=self.alice,
target_url="https://example.com/two",
slug="customslug",
)
def test_same_slug_allowed_for_different_users(self) -> None:
first = Link.objects.create(
user=self.alice,
target_url="https://example.com/one",
slug="sharedslug",
)
second = Link.objects.create(
user=self.bob,
target_url="https://example.com/two",
slug="sharedslug",
)
self.assertNotEqual(first.pk, second.pk)
class ClickModelTests(TestCase):
def setUp(self) -> None:
User = get_user_model()
self.alice = User.objects.create_user(
username="alice", email="alice@example.com", password="password123"
)
self.link = Link.objects.create(user=self.alice, target_url="https://example.com")
def test_click_records_metadata(self) -> None:
click = Click.objects.create(
link=self.link,
referrer="https://referrer.test",
user_agent="FakeBrowser/1.0",
ip_address="203.0.113.1",
)
self.assertEqual(click.link, self.link)
self.assertEqual(click.referrer, "https://referrer.test")
self.assertEqual(click.user_agent, "FakeBrowser/1.0")
self.assertEqual(click.ip_address, "203.0.113.1")
Run tests to confirm they fail (Red):
python manage.py test links
Expected: failures, because models are not implemented yet.
Step 3: Implement the Models (Green)
Edit links/models.py to satisfy the tests and generate slugs per user:
import secrets
import string
from django.conf import settings
from django.db import models
SLUG_ALPHABET = string.ascii_lowercase + string.digits
SLUG_LENGTH = 8
def generate_slug(length: int = SLUG_LENGTH) -> str:
return "".join(secrets.choice(SLUG_ALPHABET) for _ in range(length))
class Link(models.Model):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="links",
)
target_url = models.URLField(max_length=500)
slug = models.SlugField(max_length=32, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["user", "slug"], name="unique_link_slug_per_user"
)
]
ordering = ["-created_at"]
def __str__(self) -> str: # pragma: no cover - repr helper
return f"{self.user.username}/{self.slug} -> {self.target_url}"
@property
def public_path(self) -> str:
return f"/{self.user.username}/{self.slug}/"
def save(self, *args, **kwargs) -> None:
if not self.slug:
self.slug = self._generate_unique_slug()
super().save(*args, **kwargs)
def _generate_unique_slug(self) -> str:
# Simple loop because collisions are rare; retries are inexpensive at this scale.
while True:
candidate = generate_slug()
if not Link.objects.filter(user=self.user, slug=candidate).exists():
return candidate
class Click(models.Model):
link = models.ForeignKey(Link, on_delete=models.CASCADE, related_name="clicks")
created_at = models.DateTimeField(auto_now_add=True)
referrer = models.URLField(max_length=500, blank=True)
user_agent = models.TextField(blank=True)
ip_address = models.GenericIPAddressField(blank=True, null=True)
class Meta:
ordering = ["-created_at"]
def __str__(self) -> str: # pragma: no cover - repr helper
return f"Click on {self.link} at {self.created_at.isoformat()}"
Key points:
- Slugs are generated lazily in save() when missing.
- Uniqueness is enforced per user via UniqueConstraint(["user", "slug"]).
- public_path is a helper to render /<username>/<slug>/ for redirects and UI.
- Click stores basic analytics we can aggregate later.
Step 4: Run Migrations
Create and apply migrations using manage.py (do not hand-write migration files):
python manage.py makemigrations links
python manage.py migrate
Step 5: Verify Tests (Green)
Re-run the tests to confirm the models now satisfy expectations:
python manage.py test links
All tests should pass. If they fail, re-check slug generation and the unique constraint.
Step 6: CRUD for Links (Red)
Write the view tests
Open links/tests.py and add CRUD tests (list, create, detail ownership, update, delete):
from django.urls import reverse
# ...existing tests...
class LinkCRUDTests(TestCase):
def setUp(self) -> None:
User = get_user_model()
self.alice = User.objects.create_user(
username="alice", email="alice@example.com", password="password123"
)
self.bob = User.objects.create_user(
username="bob", email="bob@example.com", password="password123"
)
self.alice_link = Link.objects.create(
user=self.alice, target_url="https://example.com/alice", slug="alice1"
)
self.bob_link = Link.objects.create(
user=self.bob, target_url="https://example.com/bob", slug="bob1"
)
def test_list_shows_only_user_links(self) -> None:
self.client.login(email="alice@example.com", password="password123")
response = self.client.get(reverse("link_list"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "alice1")
self.assertNotContains(response, "bob1")
def test_create_link_sets_user_and_redirects(self) -> None:
self.client.login(email="alice@example.com", password="password123")
response = self.client.post(
reverse("link_create"),
{"target_url": "https://new.example.com", "slug": "newslug"},
follow=True,
)
self.assertEqual(response.status_code, 200)
created = Link.objects.get(slug="newslug", user=self.alice)
self.assertEqual(created.target_url, "https://new.example.com")
def test_detail_requires_owner(self) -> None:
self.client.login(email="alice@example.com", password="password123")
ok = self.client.get(reverse("link_detail", args=[self.alice_link.pk]))
self.assertEqual(ok.status_code, 200)
forbidden = self.client.get(reverse("link_detail", args=[self.bob_link.pk]))
self.assertEqual(forbidden.status_code, 404)
def test_update_allows_owner(self) -> None:
self.client.login(email="alice@example.com", password="password123")
response = self.client.post(
reverse("link_update", args=[self.alice_link.pk]),
{"target_url": "https://updated.example.com", "slug": "alice1"},
follow=True,
)
self.assertEqual(response.status_code, 200)
self.alice_link.refresh_from_db()
self.assertEqual(self.alice_link.target_url, "https://updated.example.com")
def test_delete_allows_owner(self) -> None:
self.client.login(email="alice@example.com", password="password123")
response = self.client.post(
reverse("link_delete", args=[self.alice_link.pk]), follow=True
)
self.assertEqual(response.status_code, 200)
self.assertFalse(Link.objects.filter(pk=self.alice_link.pk).exists())
Run tests (expect failures):
python manage.py test links
Step 7: Implement CRUD (Green)
Form
Create links/forms.py:
from django import forms
from .models import Link
class LinkForm(forms.ModelForm):
class Meta:
model = Link
fields = ["target_url", "slug"]
widgets = {
"target_url": forms.URLInput(attrs={"class": "form-control"}),
"slug": forms.TextInput(attrs={"class": "form-control"}),
}
help_texts = {"slug": "Optional. Leave blank to auto-generate. Per-user unique."}
def __init__(self, *args, **kwargs):
self.user = kwargs.pop("user", None)
super().__init__(*args, **kwargs)
def clean_slug(self):
slug = self.cleaned_data.get("slug")
if not slug or not self.user:
return slug
qs = Link.objects.filter(user=self.user, slug=slug)
if self.instance.pk:
qs = qs.exclude(pk=self.instance.pk)
if qs.exists():
raise forms.ValidationError("You already have a link with this slug.")
return slug
Views
Update links/views.py with login-protected CRUD views:
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse_lazy
from django.views.generic import CreateView, DeleteView, DetailView, ListView, UpdateView
from .forms import LinkForm
from .models import Link
class LinkListView(LoginRequiredMixin, ListView):
model = Link
template_name = "links/link_list.html"
context_object_name = "links"
def get_queryset(self):
return Link.objects.filter(user=self.request.user)
class LinkDetailView(LoginRequiredMixin, DetailView):
model = Link
template_name = "links/link_detail.html"
context_object_name = "link"
def get_queryset(self):
return Link.objects.filter(user=self.request.user)
class LinkCreateView(LoginRequiredMixin, CreateView):
model = Link
form_class = LinkForm
template_name = "links/link_form.html"
success_url = reverse_lazy("link_list")
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs["user"] = self.request.user
return kwargs
def form_valid(self, form):
form.instance.user = self.request.user
return super().form_valid(form)
class LinkUpdateView(LoginRequiredMixin, UpdateView):
model = Link
form_class = LinkForm
template_name = "links/link_form.html"
success_url = reverse_lazy("link_list")
def get_queryset(self):
return Link.objects.filter(user=self.request.user)
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs["user"] = self.request.user
return kwargs
class LinkDeleteView(LoginRequiredMixin, DeleteView):
model = Link
template_name = "links/link_confirm_delete.html"
success_url = reverse_lazy("link_list")
def get_queryset(self):
return Link.objects.filter(user=self.request.user)
URLs and URL Design
Add links/urls.py:
# links/urls.py
from django.urls import path
from .views import (
LinkCreateView,
LinkDeleteView,
LinkDetailView,
LinkListView,
LinkUpdateView,
)
urlpatterns = [
path("links/", LinkListView.as_view(), name="link_list"),
path("links/new/", LinkCreateView.as_view(), name="link_create"),
path("links/<int:pk>/", LinkDetailView.as_view(), name="link_detail"),
path("links/<int:pk>/edit/", LinkUpdateView.as_view(), name="link_update"),
path("links/<int:pk>/delete/", LinkDeleteView.as_view(), name="link_delete"),
]
URL Design Decision: CRUD views for managing links live under /links/ (e.g., /links/, /links/new/, /links/123/edit/). However, the links app is included without a prefix in config/urls.py:
# config/urls.py
urlpatterns = [
path("admin/", admin.site.urls),
path("accounts/", include("accounts.urls")),
path("", include("core.urls")),
path("", include("links.urls")), # No prefix
]
Why? In the next section, we'll add a public redirect endpoint at /<username>/<slug>/ that lives in the same app. By including links without a prefix, both URL patterns coexist:
- CRUD:
/links/(dashboard, create, edit, delete) - Public:
/<username>/<slug>/(redirect + click tracking)
This keeps short links clean and shareable while admin views stay under /links/.
Templates
Create Bootstrap-flavored templates under links/templates/links/:
link_list.html: list the user’s links with actions (View/Edit/Delete) and click counts.link_detail.html: show slug, target URL, and basic click count placeholder for future stats.link_form.html: shared create/update form.link_confirm_delete.html: delete confirmation.
Navigation (optional but helpful)
Add a “Links” nav item (authenticated only) in the base template to reach the list quickly.
Step 8: Re-run Tests (Green)
python manage.py test links
All tests should now pass. If not, check the ownership filtering in get_queryset() and the form’s clean_slug uniqueness check.
Step 9: Public Redirect Endpoint (Red)
Design Overview
The public redirect endpoint serves shortened links at /<username>/<slug>/. When visited:
- Resolve the user and link by username and slug.
- Create a
Clickrecord with request metadata (referrer, user agent, IP). - Issue a 302 redirect to the target URL.
This is public (no authentication required), which is the whole point of a shortened link.
Write Tests
Add tests to links/tests.py that verify redirect behavior and click tracking:
class LinkPublicRedirectTests(TestCase):
def setUp(self) -> None:
User = get_user_model()
self.alice = User.objects.create_user(
username="alice", email="alice@example.com", password="password123"
)
self.link = Link.objects.create(
user=self.alice,
target_url="https://example.com/target",
slug="testslug",
)
def test_redirect_creates_click_and_redirects(self) -> None:
response = self.client.get(
reverse("link_redirect", args=[self.alice.username, self.link.slug]),
HTTP_REFERER="https://social.example.com",
HTTP_USER_AGENT="TestBrowser/1.0",
)
self.assertEqual(response.status_code, 302)
self.assertEqual(response["Location"], self.link.target_url)
click = Click.objects.get(link=self.link)
self.assertEqual(click.referrer, "https://social.example.com")
self.assertEqual(click.user_agent, "TestBrowser/1.0")
def test_redirect_captures_ip_address(self) -> None:
response = self.client.get(
reverse("link_redirect", args=[self.alice.username, self.link.slug]),
REMOTE_ADDR="192.0.2.1",
)
self.assertEqual(response.status_code, 302)
click = Click.objects.get(link=self.link)
self.assertEqual(click.ip_address, "192.0.2.1")
def test_redirect_nonexistent_slug_returns_404(self) -> None:
response = self.client.get(
reverse("link_redirect", args=[self.alice.username, "nonexistent"])
)
self.assertEqual(response.status_code, 404)
self.assertEqual(Click.objects.count(), 0)
def test_redirect_wrong_username_returns_404(self) -> None:
response = self.client.get(
reverse("link_redirect", args=["wronguser", self.link.slug])
)
self.assertEqual(response.status_code, 404)
self.assertEqual(Click.objects.count(), 0)
Run tests to confirm they fail (Red):
python manage.py test links.tests.LinkPublicRedirectTests
Step 10: Implement the Redirect Endpoint (Green)
View
Add to links/views.py:
from django.http import Http404
from django.shortcuts import redirect
from django.views import View
class LinkPublicRedirectView(View):
def get(self, request, username, slug):
User = get_user_model()
try:
user = User.objects.get(username=username)
link = Link.objects.get(user=user, slug=slug)
except (User.DoesNotExist, Link.DoesNotExist):
raise Http404()
Click.objects.create(
link=link,
referrer=request.META.get("HTTP_REFERER", ""),
user_agent=request.META.get("HTTP_USER_AGENT", ""),
ip_address=self._get_client_ip(request),
)
return redirect(link.target_url)
def _get_client_ip(self, request):
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
if x_forwarded_for:
ip = x_forwarded_for.split(",")[0]
else:
ip = request.META.get("REMOTE_ADDR")
return ip if ip else None
Key Details:
- Uses a simple function-based
View(no template needed). - Resolves user and link atomically; raises
Http404if either doesn't exist or the link doesn't belong to the user. - Extracts HTTP metadata from
request.META: HTTP_REFERER: where the click came from.HTTP_USER_AGENT: browser/client info.REMOTE_ADDRandHTTP_X_FORWARDED_FOR: IP address (with proxy support).- Returns a
302 Foundredirect via Django'sredirect()helper.
URL
Add to links/urls.py:
from .views import LinkPublicRedirectView
urlpatterns = [
# ... existing paths ...
path("<str:username>/<str:slug>/", LinkPublicRedirectView.as_view(), name="link_redirect"),
]
URL Ordering: This pattern is placed at the end of the urlpatterns list to avoid shadowing earlier patterns (e.g., /links/new/).
Step 11: Re-run All Tests (Green)
python manage.py test links
All tests, including the new redirect tests, should now pass.
Step 12: Add Click Analytics and Full URLs
Display Full URLs for Easy Sharing
Users need to easily copy and share their shortened links. Update the templates to show the complete URL.
Link List Template
Update links/templates/links/link_list.html to show the full URL instead of just the slug:
<table class="table align-middle">
<thead>
<tr>
<th scope="col">Short URL</th>
<th scope="col">Target</th>
<th scope="col">Clicks</th>
<th scope="col" class="text-end">Actions</th>
</tr>
</thead>
<tbody>
{% for link in links %}
<tr>
<td>
<code class="user-select-all">{{ request.scheme }}://{{ request.get_host }}{{ link.public_path }}</code>
</td>
<td><a href="{{ link.target_url }}" target="_blank" rel="noopener">{{ link.target_url|truncatechars:50 }}</a></td>
<td>{{ link.clicks.count }}</td>
<td class="text-end">
<a class="btn btn-sm btn-outline-secondary" href="{% url 'link_detail' link.pk %}">View</a>
<a class="btn btn-sm btn-outline-primary" href="{% url 'link_update' link.pk %}">Edit</a>
<a class="btn btn-sm btn-outline-danger" href="{% url 'link_delete' link.pk %}">Delete</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
The user-select-all class makes the URL easily selectable for copy/paste.
Enhance Detail View with Analytics
Update links/views.py to aggregate click statistics:
class LinkDetailView(LoginRequiredMixin, DetailView):
model = Link
template_name = "links/link_detail.html"
context_object_name = "link"
def get_queryset(self):
return Link.objects.filter(user=self.request.user)
def get_context_data(self, **kwargs):
from django.db.models import Count
context = super().get_context_data(**kwargs)
link = self.object
# Build full URL
context["full_url"] = self.request.build_absolute_uri(link.public_path)
# Aggregate click statistics
clicks = link.clicks.all()
context["total_clicks"] = clicks.count()
# Top referrers
referrer_stats = (
clicks.exclude(referrer="")
.values("referrer")
.annotate(count=Count("id"))
.order_by("-count")[:5]
)
context["top_referrers"] = referrer_stats
# Top user agents
user_agent_stats = (
clicks.exclude(user_agent="")
.values("user_agent")
.annotate(count=Count("id"))
.order_by("-count")[:5]
)
context["top_user_agents"] = user_agent_stats
# Recent clicks
context["recent_clicks"] = clicks[:10]
return context
Detail Template with Analytics
Update links/templates/links/link_detail.html:
{% extends "core/base.html" %}
{% block title %}Link Details{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h1 class="h4">{{ link.slug }}</h1>
<p class="mb-0 text-muted">Created {{ link.created_at|date:"M d, Y" }}</p>
</div>
<div>
<a class="btn btn-outline-primary" href="{% url 'link_update' link.pk %}">Edit</a>
<a class="btn btn-outline-danger" href="{% url 'link_delete' link.pk %}">Delete</a>
</div>
</div>
<div class="card mb-3">
<div class="card-body">
<h2 class="h5">Short URL</h2>
<div class="input-group">
<input type="text" class="form-control font-monospace" value="{{ full_url }}" readonly id="short-url">
<button class="btn btn-outline-secondary" type="button" onclick="copyToClipboard()">
<span id="copy-icon">📋 Copy</span>
</button>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-body">
<h2 class="h5">Target URL</h2>
<p class="mb-0"><a href="{{ link.target_url }}" target="_blank" rel="noopener">{{ link.target_url }}</a></p>
</div>
</div>
<div class="card mb-3">
<div class="card-body">
<h2 class="h5">Click Statistics</h2>
<p class="h3 mb-3">{{ total_clicks }} <small class="text-muted">total clicks</small></p>
<div class="row">
<div class="col-md-6 mb-3">
<h3 class="h6">Top Referrers</h3>
{% if top_referrers %}
<ul class="list-unstyled">
{% for stat in top_referrers %}
<li class="mb-1">
<span class="badge bg-secondary">{{ stat.count }}</span>
<small>{{ stat.referrer|truncatechars:40 }}</small>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-muted small mb-0">No referrer data yet.</p>
{% endif %}
</div>
<div class="col-md-6 mb-3">
<h3 class="h6">Top User Agents</h3>
{% if top_user_agents %}
<ul class="list-unstyled">
{% for stat in top_user_agents %}
<li class="mb-1">
<span class="badge bg-secondary">{{ stat.count }}</span>
<small>{{ stat.user_agent|truncatechars:40 }}</small>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-muted small mb-0">No user agent data yet.</p>
{% endif %}
</div>
</div>
{% if recent_clicks %}
<h3 class="h6 mt-3">Recent Clicks</h3>
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>Time</th>
<th>Referrer</th>
<th>IP</th>
</tr>
</thead>
<tbody>
{% for click in recent_clicks %}
<tr>
<td><small>{{ click.created_at|date:"M d, H:i" }}</small></td>
<td><small>{{ click.referrer|default:"Direct"|truncatechars:30 }}</small></td>
<td><small>{{ click.ip_address|default:"-" }}</small></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
function copyToClipboard() {
const input = document.getElementById('short-url');
const icon = document.getElementById('copy-icon');
input.select();
navigator.clipboard.writeText(input.value);
icon.textContent = '✅ Copied!';
setTimeout(() => {
icon.textContent = '📋 Copy';
}, 2000);
}
</script>
{% endblock %}
The detail page now shows: - Full URL in a copyable input with one-click copy button - Total clicks prominently displayed - Top 5 referrers with click counts - Top 5 user agents with click counts - Recent 10 clicks with timestamp, referrer, and IP
Summary
Tutorial 003 has implemented a complete, production-ready link shortening service:
| Feature | Status |
|---|---|
| Models (Link, Click) | ✅ Complete |
| Migrations | ✅ Complete |
| CRUD views and forms | ✅ Complete |
| Public redirect + click tracking | ✅ Complete |
| Click analytics display | ✅ Complete |
| Full URL display and copy | ✅ Complete |
What We Built
- Per-user namespaced short links:
/<username>/<slug>/avoids global slug collisions - Automatic slug generation: 8-character alphanumeric slugs with collision detection
- Click tracking: Captures referrer, user agent, and IP address on every redirect
- Analytics dashboard: Aggregated statistics on referrers, user agents, and recent activity
- Bootstrap UI: Clean, responsive interface with theme support
- Comprehensive tests: 52+ tests covering models, CRUD, redirects, and edge cases
- Owner-scoped access: Users can only view and manage their own links
Architecture Decisions
- URL structure: CRUD under
/links/, public redirects at/<username>/<slug>/ - Per-user uniqueness: Slugs unique per user, not globally
- 302 redirects: Standard temporary redirect (not 301) for flexibility
- IP extraction: Supports
X-Forwarded-Forfor proxy/load balancer deployments - Lazy slug generation: Slugs created on first save if not provided
Next Steps
This link shortener is now ready for real-world use. Future enhancements could include:
- Custom slugs: Allow users to specify memorable slugs (already supported, just needs UI polish)
- Link expiration: Add
expires_atfield and soft-delete expired links - QR codes: Generate QR codes for each short link
- Export analytics: CSV/JSON export of click data
- Rate limiting: Prevent abuse of the redirect endpoint
- Link preview: Show target URL preview before redirect (optional confirmation page)
- Freemium gating (Tutorial 004): Limit free users to X links, offer paid unlimited plans
Running Quality Checks
Before merging, run all quality gates:
# Tests
python manage.py test
# Code formatting
black .
# Type checking
mypy .
# Security scan
bandit -r . -ll -c .bandit.yaml
All checks should pass. The codebase is ready for production deployment (covered in a future tutorial).
Tutorial 003 Complete 🎉
The link shortening feature is fully functional, tested, and documented. Commit your changes and merge the feature branch into main.
We build software the same way we write about it: Robust. Tested. Correct.
At McIndi Solutions, we specialize in mission-critical modernization and high-security platforms for healthcare and finance. Whether you need a fractional CTO to guide your architecture or a senior engineering team to unblock a complex automation challenge, we are available for advisory and hands-on engagements.
Email us at sales@mcindi.com to discuss your project.