In this tutorial, we will setup a dockerized Nuxt app with PWA and SSR capabilities. This app will consume a python REST API built with Django.
This is the part 1 of the tutorial where we will setup the python REST API
You can find the source code of this article on my github
To setup our project, we will use Docker. So the only dependency needed is docker itself!
If you do not have Docker installed on your machine, you can get it here.
let's start by creating our api directory and the following files:
mkdir api
touch api/requirements.txt
touch api/Dockerfile
touch docker-compose.yml
requirements.txt
will be use by docker to install the dependency we need.
### api/requirements.txt
Django==3.2
psycopg2-binary==2.9.1
djangorestframework==3.12.2
Dockerfile
will be use by docker to build the image which our API will run in.
### api/Dockerfile
FROM python:3.8
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED=1
WORKDIR /code
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
docker-compose.yml
will be use during our development to orchestrate our containers.
For now, we are going to use 2 containers:
### docker-compose.yml
version: "3.9"
services:
db:
image: postgres:13.4
volumes:
- ./db:/var/lib/postgresql/data
environment:
- POSTGRES_DB=postgres
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
api:
restart: always
build:
context: api
dockerfile: Dockerfile
command: python manage.py runserver 0.0.0.0:8000
volumes:
- ./api:/code
ports:
- "8000:8000"
depends_on:
- db
Using these files and Docker, we simply run the following command to create our Django project
docker-compose run api django-admin startproject _project .
Our project is now setup! We should now have Django files creaated inside the api
directory thanks to the bind mount we defined in the docker-compose.yml
file.
We can make sure our API is working well by spinning up our container using the following command.
docker-compose up
If we go to http://localhost:8000/, we should see our Django app up and running:
Currently, our app is using the default sqlite
database Django provides. We could use this database for development. However, I like to use a postgreSQL database during development to mimic our production environement as much as possible and avoid potential headache later on.
Thanksfully, the official postgresql
docker image make things extrimely easy for us!
A db
directory should also have been created. This is the files used by the postgres official image that run our postgreSQL database.
We only need to modify our settings.py
like below to connect to the dockerized postgreSQL database.
### api/_project/settings.py
...
impost os
...
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'postgres',
'USER': 'postgres',
'PASSWORD': 'postgres',
'HOST': 'db',
'PORT': 5432,
}
}
...
If your containers are still running, Django should have picked up that change and have reloaded.
The api
service now uses the the dockerized posgreSQL database.
We are going to implement email login as this is more convinient for users than username.
For this, we create a users
app using the following command.
docker-compose run api python manage.py startapp users
We do the following changes to implement email login.
We start by overiding Django's user model and create our own.
### api/users/models.py
from django.db import models
from django.contrib.auth.base_user import BaseUserManager
from django.contrib.auth.models import AbstractUser
class CustomUserManager(BaseUserManager):
def create_user(self, email, password, **extra_fields):
if not email:
raise ValueError('email must be set')
email = self.normalize_email(email)
user = self.model(email = email, **extra_fields)
user.set_password(password)
user.save()
return user
def create_superuser(self, email, password, **extra_fields):
extra_fields.setdefault('is_staff', True)
extra_fields.setdefault('is_superuser', True)
extra_fields.setdefault('is_active', True)
if extra_fields.get('is_staff') is not True:
raise ValueError('superuser must have is_staff = True')
if extra_fields.get('is_superuser') is not True:
raise ValueError('superuser must have is_superuser = True')
return self.create_user(email, password, **extra_fields)
class CustomUser(AbstractUser):
username = None
first_name = None
last_name = None
email = models.EmailField(max_length=50, unique = True)
REQUIRED_FIELDS = []
USERNAME_FIELD = 'email'
objects = CustomUserManager()
def __str__(self):
return self.email
Then, we create custom forms to overide Django's.
touch api/users/forms.py
### api/users/forms.py
from django.contrib.auth.forms import UserCreationForm, UserChangeForm
from .models import CustomUser
class CustomUserCreationForm(UserCreationForm):
class Meta:
model = CustomUser
fields = ('email',)
class CustomUserChangeForm(UserChangeForm):
class Meta:
model = CustomUser
fields = ('email',)
We also, modify admin.py
so our user model is easily accessible from the admin page.
### api/users/admin.py
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from .forms import CustomUserCreationForm, CustomUserChangeForm
from .models import CustomUser
class CustomUserAdmin(UserAdmin):
add_form = CustomUserCreationForm
form = CustomUserChangeForm
model = CustomUser
list_display = ('email', 'is_staff', 'is_active',)
list_filter = ('email', 'is_staff', 'is_active',)
fieldsets = (
('Credentials', {'fields': ('email', 'password')}),
('Permissions', {'fields': ('is_staff', 'is_active', 'groups')}),
)
add_fieldsets = (
(None, {
'classes': ('wide',),
'fields': ('email', 'password1', 'password2', 'is_staff', 'is_active')}
),
)
search_fields = ('email',)
ordering = ('email',)
admin.site.register(CustomUser, CustomUserAdmin)
Finaly, we register our user app in our installed app and point AUTH_USER_MODEL
to our new custom user model.
### api/_project/settings.py
...
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
#local
'users',
]
AUTH_USER_MODEL = 'users.CustomUser'
...
For our change to take effect, we need to migrate our database. We do this using the following 2 commands.
docker-compose run api python manage.py makemigrations users
docker-compose run api python manage.py migrate
Then, we create our superuser to be able to access the admin page.
docker-compose run api python manage.py createsuperuser
Finally we spin up our docker containers using docker-compose.
docker-compose up
We should now be able to login to the admin page (http://localhost:8000/admin) using email instead of username!
We also can create, modify and delete users from the admin page (http://localhost:8000/admin/users/).
For authentication, we will use the djangorestframework-simplejwt
library. This library provide out of the box JWT authentication.
To do this, we just have to modify our requirements.txt
file.
### requirements.txt
Django==3.2
psycopg2-binary==2.9.1
djangorestframework==3.12.4
djangorestframework-simplejwt==4.7.2
Then, we need to rebuild our image. We can do this by using docker-compose up --build
.
The new dependencies have now been installed and our Django api is up and running again!
To use and configure djangorestframework
and djangorestframework-simplejwt
, we add the following code to you settings.py
.
### api/_project/settings.py
...
from datetime import timedelta
...
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.sites',
#local
'users',
#3rd party
'rest_framework',
]
...
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=5),
'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
'ROTATE_REFRESH_TOKENS': True,
}
...
REST_FRAMEWORK = {
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticated',
],
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework_simplejwt.authentication.JWTAuthentication',
],
}
...
We also add djangorestframework-simplejwt
routes to our project in order to get access token and request refresh token to our API.
### api/_project/urls.py
from django.contrib import admin
from django.urls import path
from rest_framework_simplejwt.views import (
TokenObtainPairView,
TokenRefreshView,
)
urlpatterns = [
path('admin/', admin.site.urls),
path('token/', TokenObtainPairView.as_view()),
path('token/refresh/', TokenRefreshView.as_view()),
]
We are now able to sign in user using JWT and refresh them so the user stays loggged in.
Let's now create some API view for our user to:
We start by creating our serializers.
touch api/users/serializers.py
### api/serializers.py
from rest_framework import serializers
from django.contrib.auth import get_user_model
from rest_framework.validators import UniqueValidator
from django.contrib.auth.password_validation import validate_password
class SignUpSerializer(serializers.ModelSerializer):
email = serializers.EmailField(required=True, validators=[UniqueValidator(queryset=get_user_model().objects.all())])
password = serializers.CharField(required=True, write_only=True, validators=[validate_password])
password2 = serializers.CharField(required=True, write_only=True)
class Meta:
model = get_user_model()
fields = ('email', 'password', 'password2',)
def validate(self, value):
if value['password'] != value['password2']:
raise serializers.ValidationError({"password2": "Password fields did not match"})
return value
def create(self, validated_data):
user = get_user_model().objects.create(email=validated_data['email'])
user.set_password(validated_data['password'])
user.save()
return user
class EmailSerializer(serializers.ModelSerializer):
class Meta:
model = get_user_model()
depth = 1
fields = ('id', 'email',)
class PasswordChangeSerializer(serializers.ModelSerializer):
password = serializers.CharField(write_only=True, required=True, validators=[validate_password])
password2 = serializers.CharField(write_only=True, required=True)
old_password = serializers.CharField(write_only=True, required=True)
class Meta:
model = get_user_model()
fields = ('old_password', 'password', 'password2')
def validate_old_password(self, value):
user = self.context['request'].user
if not user.check_password(value):
raise serializers.ValidationError({'old_password': 'Old password is incorrect'})
return value
def validate(seld, value):
if value['password'] != value['password2']:
raise serializers.ValidationError({'password2': 'Password fields did not match'})
return value
def update(self, instance, validated_data):
instance.set_password(validated_data['password'])
return instance
Then, we create our view. Using djangorestframework's generics view make things extremely easy for us!
### api/users/views.py
from rest_framework import generics
from rest_framework.permissions import AllowAny
from django.contrib.auth import get_user_model
from .serializers import SignUpSerializer, EmailSerializer, PasswordChangeSerializer
class SignUp(generics.CreateAPIView):
permission_classes = [AllowAny]
serializer_class = SignUpSerializer
class Email(generics.RetrieveUpdateAPIView):
queryset = get_user_model()
serializer_class = EmailSerializer
def get_object(self):
return self.request.user
class PasswordChange(generics.UpdateAPIView):
queryset = get_user_model()
serializer_class = PasswordChangeSerializer
def get_object(self):
return self.request.user
Finally, we add the crreated view to our urls.py
.
### api/_project/urls.py
from django.contrib import admin
from django.urls import path
from rest_framework_simplejwt.views import (
TokenObtainPairView,
TokenRefreshView,
)
from users.views import SignUp, Email, PasswordChange
urlpatterns = [
path('admin/', admin.site.urls),
path('token/', TokenObtainPairView.as_view()),
path('token/refresh/', TokenRefreshView.as_view()),
path('signup/', SignUp.as_view()),
path('user/', Email.as_view()),
path('password/change/', PasswordChange.as_view())
]
Users are now able to signup at http://localhost:8000/signup/.
Change their email at http://localhost:8000/user/.
And change their password at http://localhost:8000/password/change/.
For our user to properly use our app, one more feature is nice (if not mandatory) to have. Password reset by email.
Password reset is pretty hard to implement by our own. Fortunately, the django-rest-passwordreset
library makes such implementation a breeze.
To use this library, will need to implement sending email as well. I will use a gmail account for this. However, steps are similar for all email providers.
We start by installing django-rest-passwordreset
by adding it into our requirements.txt
.
### api/requirements.txt
Django==3.2
psycopg2-binary==2.9.1
djangorestframework==3.12.2
djangorestframework-simplejwt==4.7.2
django-rest-passwordreset==1.2.0
We add django_rest_passwordreset
and setup our email provider in our settings.py
.
### api/_project/settings.py
...
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
#local
'users',
'recipes',
#3rd parties
'rest_framework',
'django_rest_passwordreset',
]
...
EMAIL_BACKENDS = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_HOST = 'smtp.gmail.com'
EMAIL_HOST_USER = <Your email here>
EMAIL_HOST_PASSWORD = <Your password here>
EMAIL_PORT = 587 #for gmail only
EMAIL_USE_TLS = True
DEFAULT_FROM_EMAIL = <Your email here>
...
We also add django-rest-passwordreset
view to our urls.py
file.
### api/_project.urls.py
from django.contrib import admin
from django.urls import path, include
from rest_framework_simplejwt.views import (
TokenObtainPairView,
TokenRefreshView,
)
from users.views import SignUp, Email, PasswordChange
urlpatterns = [
path('admin/', admin.site.urls),
path('token/', TokenObtainPairView.as_view()),
path('token/refresh/', TokenRefreshView.as_view()),
path('signup/', SignUp.as_view()),
path('user/', Email.as_view()),
path('password/change/', PasswordChange.as_view()),
path('password/reset/', include('django_rest_passwordreset.urls')),
]
By default, django-rest-passwordreset
will not send email to user. We are going to use a signal for this.
First, we can start by creating a the template of the email sent to our users. In these template, we will inject the user's email as well as the password reset url.
touch api/users/templates/email/user_reset_password.html
touch api/users/templates/email/user_reset_password.txt
### api/users/templates/email/user_reset_password.html
<html>
<p>Hi {{email}}</p>
<p>
You are receiving this email because you have requested a password reset.
</p>
<p>
Please click on the link below to reset your password.
<br />
<a href="{{reset_password_url}}">{{reset_password_url}}</a>
</p>
<p>
If you did not requested this password reset, please change your password
ASAP
</p>
<p>
Regards
<br />
Awesome Recipes
</p>
</html>
### api/users/templates/email/user_reset_password.txt
Hi {{email}},
You are receiving this email because you have requested a password reset.
Please click on the link below to reset your password.
{{reset_password_url}}
If you did not requested this password reset, please change your password ASAP
Regards
Awesome Recipes
Inside our users/models.py
, will create a function to send the eamail to the users that will be fired everytime a password reset token is created.
### api/users/models.py
from django.db import models
from django.contrib.auth.base_user import BaseUserManager
from django.contrib.auth.models import AbstractUser
from django.core.mail import EmailMultiAlternatives
from django.dispatch import receiver
from django.template.loader import render_to_string
from django_rest_passwordreset.signals import reset_password_token_created
...
@receiver(reset_password_token_created)
def password_reset_token_created(sender, instance, reset_password_token, *args, **kwargs):
context = {
'email': reset_password_token.user.email,
'reset_password_url': f"http://localhost:3000/password/reset/confirm/?token={reset_password_token.key}"
}
email_html_message = render_to_string('email/user_reset_password.html', context)
email_plaintext_message = render_to_string('email/user_reset_password.txt', context)
msg = EmailMultiAlternatives("Password Reset", email_plaintext_message, [], [reset_password_token.user.email])
msg.attach_alternative(email_html_message, "text/html")
msg.send()
We now can now re build our image and migrate our database to include our changes
docker-compose build api
docker-compose run api python manage.py migrate
Password reset is now implemented!
We now can spin up our containers and try it out!
docker-compose up
We can request password reset to http://localhost:8000/password/reset/
Verify token validity to http://localhost:8000/password/reset/verify_token/
And reset password to http://localhost:8000/password/reset/confirm/
We are now going to create the API views that are going to deliver the main content of our app.
First, we create the recipes
app.
docker-compose run api python manage.py startapp recipes
Then we create the following models.
### api/recipes/models.py
from django.db import models
from django.contrib.auth import get_user_model
class Recipe(models.Model):
name = models.CharField(max_length=50)
image = models.ImageField(upload_to='recipes/%Y/%m/%d')
instruction = models.TextField(max_length=10000)
user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.name
We also edit admin.py
to be able to manage this recipe model from the admin page.
### api/recipes/admin.py
from django.contrib import admin
from .models import Recipe
@admin.register(Recipe)
class RecipeAdmin(admin.ModelAdmin):
list_display = ['id', 'name', 'created_at', 'updated_at',]
We create the serializers.
touch api/recipes/serializers.py
### api/recipes/serializers.py
from rest_framework import serializers
from .models import Recipe
from django.contrib.auth import get_user_model
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = get_user_model()
fields = ['id', 'email']
class RecipeSerializer(serializers.ModelSerializer):
user = UserSerializer(read_only=True)
class Meta:
model = Recipe
fields = '__all__'
def create(self, validated_data):
validated_data['user'] = self.context['request'].user
return Recipe.objects.create(**validated_data)
And then the views.
### api/recipes/views.py
from rest_framework import generics
from rest_framework import permissions
from django.contrib.auth import get_user_model
from .models import Recipe
from .serializers import RecipeSerializer
class OwnerOrReadOnly(permissions.BasePermission):
def has_object_permission(self, request, view, obj):
if request.method in permissions.SAFE_METHODS or obj.user == request.user:
return True
class RecipeList(generics.ListCreateAPIView):
queryset = Recipe.objects.all()
serializer_class = RecipeSerializer
class RecipeDetail(generics.RetrieveUpdateDestroyAPIView):
permission_classes = [permissions.IsAuthenticated, OwnerOrReadOnly]
queryset = Recipe.objects.all()
serializer_class = RecipeSerializer
Finally, we add the views to our urls.py
. Here, we also add a static route to serve our media files that are uploaded by users.
### api/_project/urls.py
from django.contrib import admin
from django.urls import path, include
from django.conf.urls.static import static
from django.conf import settings
from rest_framework_simplejwt.views import (
TokenObtainPairView,
TokenRefreshView,
)
from users.views import SignUp, Email, PasswordChange
from recipes.views import RecipeList, RecipeDetail
urlpatterns = [
path('admin/', admin.site.urls),
path('token/', TokenObtainPairView.as_view()),
path('token/refresh/', TokenRefreshView.as_view()),
path('signup/', SignUp.as_view()),
path('user/', Email.as_view()),
path('password/change/', PasswordChange.as_view()),
path('password/reset/', include('django_rest_passwordreset.urls')),
path('recipes/', RecipeList.as_view()),
path('recipes/<int:pk>/', RecipeDetail.as_view()),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
We also need to add this new app to our installed app. We also setup our MEDIA_URL
and MEDIA_ROOT
to serve our media files.
### api/_project/settings.py
...
from datetime import timedelta
import os
...
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.sites',
#local
'users',
'recipes',
#3rd party
'rest_framework',
'django_rest_passwordreset',
]
...
MEDIA_URL = '/api/media/'
MEDIA_ROOT = os.path.join(BASE_DIR.parent, 'media').replace('\\', '/')
...
Images will get uploaded to the media
folder we defined above.
However, on image edit or deletion, the image will remain in the media
folder.
Fortunately, we can install the django_cleanup
library. This library will delete media files on instance modification and deletion using Django signals.
We also need to install the Pillow
library to use Django ImageField
in our recipe model.
Our API will be hosted from 2 different host. From localhost
externaly and from api
whitin docker. We need to add these two host to the ALLOWED_HOSTS
.
Finally, we need to install django-cors-headers
as our frontend will not be served using the same host as the Django API.
### api/requirements.txt
Django==3.2
psycopg2-binary==2.9.1
djangorestframework==3.12.2
djangorestframework-simplejwt==4.7.2
django-cleanup==5.2.0
Pillow==8.3.1
django-cors-headers==3.8.0
We also need to modify our settings.py
to include these library
### api/_project/settings.py
...
ALLOWED_HOSTS = ['localhost', 'api']
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.sites',
#local
'users',
'recipes',
#3rd party
'rest_framework',
'django_rest_passwordreset',
'corsheaders',
'django_cleanup', #need to be last
]
...
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware', #need to be first
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
...
CORS_ALLOWED_ORIGINS = ['http://localhost:3000']
...
At the project level, we create the media
folder that we bind mount to our docker container using volumes in order to persist data.
mkdir media
### docker-compose.yml
version: "3.9"
services:
db:
image: postgres:13.4
volumes:
- ./db:/var/lib/postgresql/data
environment:
- POSTGRES_DB=postgres
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
api:
restart: always
build:
context: api
dockerfile: Dockerfile
command: python manage.py runserver 0.0.0.0:8000
volumes:
- ./api:/code
- ./media:/media
ports:
- "8000:8000"
depends_on:
- db
We now rebuild our image and migrate our database for our change to take effect:
docker-compose build api
docker-compose run api python manage.py makemigrations recipes
docker-compose run api python manage.py migrate
And spin up our containers:
docker-compose up
We can now create to http://localhost:8000/recipes/
Modify and delete existing recipes to http://localhost:8000/recipes/id/
Our REST API is now setup!
In the Part 2 of this tutorial, we will see how to set up our frontend using Nuxt.
You can find the source code of this article on my github
If you have any question or just want to chat, feel free to email me florian.bigot321@gmail.com