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:

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:

This keeps short links clean and shareable while admin views stay under /links/.

Templates

Create Bootstrap-flavored templates under links/templates/links/:

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:

  1. Resolve the user and link by username and slug.
  2. Create a Click record with request metadata (referrer, user agent, IP).
  3. 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:

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

Architecture Decisions

  1. URL structure: CRUD under /links/, public redirects at /<username>/<slug>/
  2. Per-user uniqueness: Slugs unique per user, not globally
  3. 302 redirects: Standard temporary redirect (not 301) for flexibility
  4. IP extraction: Supports X-Forwarded-For for proxy/load balancer deployments
  5. 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:

  1. Custom slugs: Allow users to specify memorable slugs (already supported, just needs UI polish)
  2. Link expiration: Add expires_at field and soft-delete expired links
  3. QR codes: Generate QR codes for each short link
  4. Export analytics: CSV/JSON export of click data
  5. Rate limiting: Prevent abuse of the redirect endpoint
  6. Link preview: Show target URL preview before redirect (optional confirmation page)
  7. 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.