ruk·si

🍡 Django on Heroku

Updated at 2021-03-21 17:51

In this guide, we'll host an imaginary viggor Django project on Heroku.

Don't mind the v* commands, they are aliases I use to manage Python virtual envs.

Setup virtual environment and install low-level dependencies:

export APP_NAME=viggor
vmk $APP_NAME 3.8
v $APP_NAME
pip install -U pip wheel setuptools pip-tools
sudo apt-get install -y postgresql postgresql-contrib libpq-dev # for psycopg2

Define and install Python dependencies that I frequently use:

{ cat <<EOF > requirements.in
django
django-enumfields
django-filter
django-guardian
django-heroku
django-impersonate
django-jinja
djangorestframework
gunicorn
markdown
psycopg2
requests
whitenoise
EOF
} && { cat <<EOF > requirements-dev.in
-c requirements.txt
freezegun
pip-tools
pytest
pytest-cov
pytest-django
pytest-mock
requests-mock
EOF
} && pip-compile requirements.in \
  && pip-compile requirements-dev.in \
  && pip install -r requirements.txt -r requirements-dev.txt

Create and configure a new Djago project:

django-admin startproject $APP_NAME . \
  && cat <<EOF > .env
DATABASE_URL=postgres:///$APP_NAME
DEBUG=True
PYTHONUNBUFFERED=1
SECRET_KEY=`python -c "import secrets; print(secrets.token_urlsafe())"`
EOF

Mangle the settings file a bit:

# your milage may vary with new Django versions but this worked in Django 3.1
sed -i \
    -e "1s/^/import os\nimport django_heroku\nfrom django_jinja.builtins import DEFAULT_EXTENSIONS\n/" \
    -e "/# .*/d" \
    -e "s/SECRET_KEY = .*//" \
    -e "s/STATIC_URL = .*//" \
    -e "s/DATABASES = {/DELETE_ME = {/" \
    -e "s/DEBUG = True/DEBUG = os.getenv('DEBUG', False)/" \
    $APP_NAME/settings.py \
  && { cat <<EOF > $APP_NAME/session_serializer.py
import json
import uuid
from django.contrib.sessions.serializers import JSONSerializer

class SessionSerializer(JSONSerializer):

    def dumps(self, obj):
        return json.dumps(obj, separators=(',', ':'), cls=ExtendedJSONEncoder).encode('latin-1')

class ExtendedJSONEncoder(json.JSONEncoder):

    def default(self, obj):
        if isinstance(obj, uuid.UUID):
            return obj.hex
        return super().default(self, obj)
EOF
} && { cat <<EOF > $APP_NAME/permissions.py
import copy
from rest_framework import permissions

class ExtendedModelPermissions(permissions.DjangoModelPermissions):

    def __init__(self):
        self.perms_map = copy.deepcopy(self.perms_map)
        self.perms_map['GET'] = ['%(app_label)s.view_%(model_name)s']
        self.perms_map['OPTIONS'] = ['%(app_label)s.view_%(model_name)s']
        self.perms_map['HEAD'] = ['%(app_label)s.view_%(model_name)s']
        super().__init__()

class ExtendedObjectPermissions(permissions.DjangoObjectPermissions):

    def __init__(self):
        self.perms_map = copy.deepcopy(self.perms_map)
        self.perms_map['GET'] = ['%(app_label)s.view_%(model_name)s']
        self.perms_map['OPTIONS'] = ['%(app_label)s.view_%(model_name)s']
        self.perms_map['HEAD'] = ['%(app_label)s.view_%(model_name)s']
        super().__init__()

EOF
} && { cat <<EOF >> $APP_NAME/settings.py
AUTH_USER_MODEL = '$APP_NAME.User'
LOGIN_REDIRECT_URL = '/'
SESSION_SERIALIZER = '$APP_NAME.session_serializer.SessionSerializer'

AUTHENTICATION_BACKENDS = (
    'django.contrib.auth.backends.ModelBackend',
    'guardian.backends.ObjectPermissionBackend',
)

# https://django-guardian.readthedocs.io/en/stable/userguide/custom-user-model.html#custom-user-model
GUARDIAN_MONKEY_PATCH = False

INSTALLED_APPS.extend([
    '$APP_NAME',
    'django_jinja',
    'guardian',
    'impersonate',
    'rest_framework',
])

MIDDLEWARE.extend([
    'impersonate.middleware.ImpersonateMiddleware',
])

TEMPLATES.insert(0, {
    'BACKEND': 'django_jinja.backend.Jinja2',
    'APP_DIRS': True,
    'OPTIONS': {
        'extensions': DEFAULT_EXTENSIONS,
        'context_processors': TEMPLATES[0]['OPTIONS']['context_processors'],
        'match_extension': '.jinja',
        'debug': True,
    }
})

# https://www.django-rest-framework.org/api-guide/settings/
# https://www.django-rest-framework.org/api-guide/permissions/
REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': (
        'rest_framework.authentication.SessionAuthentication',
    ),
    'DEFAULT_PERMISSION_CLASSES': [
        '$APP_NAME.permissions.ExtendedObjectPermissions',
    ],
    # 'DEFAULT_FILTER_BACKENDS': [
    #     'rest_framework_guardian.filters.ObjectPermissionsFilter',
    # ],
    'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination',
    'PAGE_SIZE': 100,
}

# django-heroku will handle most of the default configuration
# https://github.com/heroku/django-heroku/blob/master/django_heroku/core.py#L49
django_heroku.settings(locals())
EOF
}

Add an index view and its template:

mkdir -p $APP_NAME/views \
  && echo 'from .index_view import IndexView' >> $APP_NAME/views/__init__.py \
  && { cat <<EOF > $APP_NAME/views/index_view.py
from django.views.generic import TemplateView

class IndexView(TemplateView):
    template_name = 'index.jinja'
EOF
} && mkdir -p $APP_NAME/templates \
  && { cat <<EOF > $APP_NAME/templates/index.jinja
<html>
    Hello {{ user }}!
</html>
EOF
}

Overwrite URL configuration:

cat <<EOF > $APP_NAME/urls.py
from django.contrib import admin
from django.urls import include, path

from $APP_NAME.api.router import router
from $APP_NAME.views import IndexView

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/', include(router.urls)),
    path('api-auth/', include('rest_framework.urls', namespace='rest_framework')),
    path('impersonate/', include('impersonate.urls')),
    path('', IndexView.as_view(), name='index'),
]
EOF

Customize the core user model:

mkdir -p $APP_NAME/mixins \
  && echo 'from .times_mixin import TimesMixin' >> $APP_NAME/mixins/__init__.py \
  && { cat <<EOF > $APP_NAME/mixins/times_mixin.py
from django.db import models

class TimesMixin(models.Model):
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        abstract = True
EOF
} && echo 'from .uuid_mixin import UUIDMixin' >> $APP_NAME/mixins/__init__.py \
  && { cat <<EOF > $APP_NAME/mixins/uuid_mixin.py
import uuid
from django.db import models

class UUIDMixin(models.Model):
    id = models.UUIDField(
        primary_key=True,
        default=uuid.uuid4,
        editable=False,
        verbose_name='ID'
    )

    class Meta:
        abstract = True
EOF
} && mkdir -p $APP_NAME/models \
  && echo 'from .user import User' >> $APP_NAME/models/__init__.py \
  && { cat <<EOF > $APP_NAME/models/user.py
from django.contrib.auth.models import AbstractUser
from guardian.mixins import GuardianUserMixin
from $APP_NAME.mixins import UUIDMixin
from $APP_NAME.mixins import TimesMixin

class User(UUIDMixin, TimesMixin, AbstractUser, GuardianUserMixin):
    pass
EOF
} && mkdir -p $APP_NAME/admin \
  && echo 'from .user_admin import UserAdmin' >> $APP_NAME/admin/__init__.py \
  && { cat <<EOF > $APP_NAME/admin/user_admin.py
from django.contrib import admin, auth
from django.utils.translation import gettext_lazy as _
from $APP_NAME.models import User

@admin.register(User)
class UserAdmin(auth.admin.UserAdmin):

    list_display = ('username', 'email', 'is_active', 'is_staff', 'is_superuser')
    readonly_fields = ('created_at', 'updated_at')

    def get_fieldsets(self, request, obj=None):
        fieldsets = super().get_fieldsets(request, obj)
        if obj:
            fieldsets += ((_('Custom'), {'fields': ('created_at', 'updated_at')}), )
        return fieldsets
EOF
}

Add a simple API for the user models:

mkdir -p $APP_NAME/api \
    && { cat <<EOF > $APP_NAME/api/user_serializer.py
from rest_framework import serializers
from $APP_NAME.models import User

class UserSerializer(serializers.HyperlinkedModelSerializer):
    class Meta:
        model = User
        fields = ['url', 'username', 'email']
EOF
} && { cat <<EOF > $APP_NAME/api/user_view_set.py
from rest_framework import viewsets
from $APP_NAME.api.user_serializer import UserSerializer
from $APP_NAME.models import User

class UserViewSet(viewsets.ModelViewSet):
    queryset = User.objects.all()
    serializer_class = UserSerializer
EOF
} && { cat <<EOF > $APP_NAME/api/router.py
from rest_framework import routers
from $APP_NAME.api.user_view_set import UserViewSet

router = routers.DefaultRouter()
router.register('users', UserViewSet)
EOF
}

Create development database:

# sudo -u postgres createuser -s $USER  # here, $USER is the sudoing user
createdb $APP_NAME
# psql $APP_NAME -c 'SELECT 1;'  # just a test
heroku local:run python manage.py makemigrations $APP_NAME

Create Procfile for Heroku:

cat <<EOF > Procfile
release: python manage.py migrate --noinput
web: gunicorn $APP_NAME.wsgi --log-file=-
EOF

Setup testing for development:

{ cat <<EOF > pytest.ini
[pytest]
DJANGO_SETTINGS_MODULE=$APP_NAME.settings
python_files = *_tests.py
EOF
} && { cat <<EOF > conftest.py
import pytest
from django.contrib.auth.models import Group
from rest_framework.test import APIClient

@pytest.fixture
def random_user(django_user_model):
    User = django_user_model
    return User.objects.create_user('john', 'john@example.com', 'johndoe123')

@pytest.fixture
def random_group():
    return Group.objects.create(name='some-people')

@pytest.fixture
def admin_user(admin_user):
    return admin_user

@pytest.fixture
def anonymous_client(client):
    return client

@pytest.fixture
def random_client(client, random_user):
    client.force_login(random_user)
    return client

@pytest.fixture
def admin_client(admin_client):
    return admin_client

@pytest.fixture
def anonymous_api_client():
    return APIClient()

@pytest.fixture
def admin_api_client(anonymous_api_client, admin_user):
    anonymous_api_client.force_authenticate(user=admin_user)
    return anonymous_api_client
EOF
} && { cat <<EOF > $APP_NAME/api/user_api_tests.py
import pytest
from guardian.shortcuts import get_perms, assign_perm, remove_perm
from rest_framework.reverse import reverse

# https://www.django-rest-framework.org/api-guide/routers/#usage
# https://developer.mozilla.org/en-US/docs/Learn/Server-side/Django/Authentication

@pytest.mark.django_db
def test_user_api_as_anonymous(anonymous_api_client):
    user_list_url = reverse('user-list')
    response = anonymous_api_client.get(user_list_url)
    assert response.status_code == 403  # Forbidden

# TODO: # user_detail_url = reverse('user-detail', args=[response.wsgi_request.user.pk])
# TODO: https://github.com/rpkilby/django-rest-framework-guardian/blob/master/src/rest_framework_guardian/filters.py

@pytest.mark.django_db
def test_user_api_as_random(random_client, random_group):
    user_list_url = reverse('user-list')
    response = random_client.get(user_list_url)
    user = response.wsgi_request.user
    assert response.status_code == 403  # Forbidden

    assign_perm('$APP_NAME.view_user', response.wsgi_request.user)
    response = random_client.get(user_list_url)
    assert response.status_code == 200  # OK

    remove_perm('$APP_NAME.view_user', response.wsgi_request.user)
    response = random_client.get(user_list_url)
    assert response.status_code == 403  # Forbidden

@pytest.mark.django_db
def test_user_api_as_admin(admin_api_client):
    user_list_url = reverse('user-list')
    response = admin_api_client.get(user_list_url)
    assert response.status_code == 200  # OK

@pytest.mark.django_db
def test_permissions_configuration(random_user, random_group):
    common_permissions = ['add_group', 'change_group', 'delete_group', 'view_group']
    for perm in common_permissions:
        assert not random_user.has_perm(perm, random_group)
    for perm in common_permissions:
        assign_perm(perm, random_user, random_group)
        assert random_user.has_perm(perm, random_group)
EOF
} && { cat <<EOF > $APP_NAME/views/index_view_tests.py
def test_index_view_as_anonymous(anonymous_client):
    response = anonymous_client.get('/')
    assert 'Hello AnonymousUser!' in str(response.content)

def test_index_view_as_admin(admin_client):
    response = admin_client.get('/')
    assert 'Hello admin!' in str(response.content)
EOF
} && { cat <<EOF > $APP_NAME/models/user_tests.py
import uuid
import pytest
from $APP_NAME.models import User

@pytest.mark.django_db
def test_user_creation():
    User.objects.create_user('john', 'john@example.com', 'johndoe123')
    user = User.objects.get(username='john')
    assert type(user.id) == uuid.UUID
    assert user.email == 'john@example.com'
    assert user.password != 'johndoe123'  # it wil be properly hashed
    assert not user.is_staff
    assert not user.is_superuser
EOF
} && heroku local:run pytest

TODO: from django.core.exceptions import PermissionDenied

def users_list_view(request): if not request.user.has_perm('auth.view_user'): raise PermissionDenied()

See that it works:

heroku local:run python manage.py migrate
heroku local:run python manage.py createsuperuser
heroku local:start web -p 4000
# http://127.0.0.1:4000/
# http://127.0.0.1:4000/admin and try to login

Run on Heroku

Tell Heroku which Python version to use:

# https://devcenter.heroku.com/articles/python-support#supported-python-runtimes
echo python-3.8.8 > runtime.txt
cat <<EOF > .gitignore
.idea/
.pytest_cache/
__pycache__/
staticfiles/
.env
EOF
git init
git add -A
git commit -m 'Initial commit'
heroku auth:whoami  # heroku login

export HEROKU_APP=$APP_NAME
heroku apps:create --region us --remote $HEROKU_APP
heroku config:set SECRET_KEY=`python -c "import secrets;print(secrets.token_urlsafe())"`
heroku addons:create heroku-postgresql:hobby-dev
heroku config  # you should see DATABASE_URL in there
git push heroku master
heroku run python manage.py createsuperuser
heroku open

Debugging Heroku:

# see service logs...
heroku logs --tail

# open interactive debug shell...
heroku run python manage.py shell

Add error reporting

export APP_NAME=viggor
export HEROKU_APP=$APP_NAME && echo $HEROKU_APP
echo 'sentry-sdk' >> requirements.in
pip-compile requirements.in && pip install -r requirements.txt

# https://docs.sentry.io/platforms/python/guides/django/
# https://devcenter.heroku.com/articles/sentry#integrating-with-python-or-django
heroku addons:create sentry
# you will recieve a message from Sentry, recover password to set it

# settings.py...
import os

if os.getenv('SENTRY_DSN'):
    import sentry_sdk
    from sentry_sdk.integrations.django import DjangoIntegration

    sentry_sdk.init(
        dsn=os.environ['SENTRY_DSN'],
        integrations=[DjangoIntegration()],
        traces_sample_rate=1.0,
        send_default_pii=True,
    )


import sentry_sdk
try:
    a_potentially_failing_function()
except Exception as e:
    # Alternatively the argument can be omitted
    sentry_sdk.capture_exception(e)


# reporting about a non-exception issue like a 404 or a warning:
import sentry_sdk
sentry_sdk.capture_message('Something went wrong')
sentry_sdk.capture_message('Something went wrong', level='error')

Add caching

export APP_NAME=viggor
export HEROKU_APP=$APP_NAME && echo $HEROKU_APP
echo 'django-redis' >> requirements.in
pip-compile requirements.in && pip install -r requirements.txt

cat <<EOF >> $APP_NAME/settings.py
# https://devcenter.heroku.com/articles/heroku-redis#connecting-in-django
CACHES = {
    'default': {
        'BACKEND': 'django_redis.cache.RedisCache',
        'LOCATION': os.getenv('REDIS_TLS_URL', os.getenv('REDIS_URL', 'redis://127.0.0.1:6379/1')),
        'OPTIONS': {
            'CLIENT_CLASS': 'django_redis.client.DefaultClient',
            'CONNECTION_POOL_KWARGS': {'ssl_cert_reqs': False}
        }
    }
}
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
SESSION_CACHE_ALIAS = "default"
EOF

heroku local:start web -p 4000  # and try to login

# now you can use the cache backend in your code:
# from django.core.cache import cache
# cache.ttl('foo')
# cache.set('foo', 'value', timeout=5)
# cache.get('foo')
# with cache.lock('somekey'):
#     do_some_thing()
# and all libraries that support Django cache backend will use it

heroku addons:create heroku-redis:hobby-dev
heroku addons:wait heroku-redis
heroku config  # there should be REDIS_TLS_URL and/or REDIS_URL
git add -A
git commit -m 'Add Redis for cache and sessions'
git push heroku master
heroku open  # and try to login

Adding scheduled jobs

If you need something ran every 10 mininutes, every hour or once per day, Heroku Scheduler is an easy free option.

export APP_NAME=viggor
export HEROKU_APP=$APP_NAME && echo $HEROKU_APP
heroku addons:create scheduler:standard
heroku addons:open scheduler
# and configure it to run a command e.g. echo hello or python manage.py something

For more complex jobs: https://devcenter.heroku.com/articles/clock-processes-python

Better Auth

echo 'django-allauth' >> requirements.in
pip-compile requirements.in && pip install -r requirements.txt

Frontend

https://www.valentinog.com/blog/drf/#setting-up-react-and-webpack

Pipelines

https://devcenter.heroku.com/articles/pipelines https://devcenter.heroku.com/articles/github-integration-review-apps

Reverting all changes; local and remote

export APP_NAME=viggor
export HEROKU_APP=$APP_NAME && echo $HEROKU_APP
heroku apps:destroy --confirm $HEROKU_APP
cd ..
dropdb $APP_NAME
vrm $APP_NAME
rm -rf $APP_NAME