Introduction to Backend Development With Django
Welcome to the (Django)l !: Introduction to Backend Programming with Django
Prerequisites to this Workshop
You need the following installed:
The starting repo for this workshop: https://github.com/CodersforLearning/django-csf
What you will be building as part of this workshop?
You will be building a backend for an event management system. Below is the schematics
What are APIs and REST-APIs?
Application Programming Interface
Analogy
Who interacts with the user interface? - the user
Who interacts with the application programming interface ? - the application program (eg. the browser)
Representational State Transfer Application Programming Interface
Swagger/ Open API specification
What is Django?
Django
- Python web framework for creating server-side application
Follows MVC:
- Model - database
- View – Interface (API or User Interface)
- Controller – URLs + routes
See Documentation
What is Django REST Framework (DRF)?
- library for creating REST-API
- just makes it easier develop REST-API
In:
- Authentication + Permission
- Generic API Views
- Serialisers (payload validation and format)
See Documentation
Interactive Workshop Time!!!
Firstly, open your IDE (VSCode) and open the terminal.
- Clone the repo:
git clone https://github.com/CodersforLearning/django-csf.git
- Go to the directory:
cd django-csf
- Run the setup script:
./setup.sh
What does the setup script do?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23 | #!/bin/bash
# Create a Python virtual environment
python3 -m venv venv
# Activate Python environment
source venv/bin/activate
# Install Django
pip3 install Django
# Bootstraps the django codebase
django-admin startproject csfevents
# As per https://www.django-rest-framework.org/#installation
pip3 install djangorestframework
pip3 install markdown # Markdown support for the browsable API.
pip3 install django-filter # Filtering support
pip3 install pytz # Timezone support
# Freeze the requirements
cd csfevents
pip3 freeze > requirements.txt
|
Initial files
manage.py
- the entrypoint of the Django application
csfevents
- the main Django application
csfevents/settings.py
- the configuration file of the Django application
csfevents/urls.py
- the URL routes of the Django application
csfevents/wsgi.py
and csfevents/asgi.py
- used as the script to run production django application
Let's start the Django application
- Perform the initial migration:
python3 manage.py migrate
. Notice that when you run this command, it will create a db.sqlite3
file.
- Run the Django application:
python3 manage.py runserver
- Check out the Django application: http://localhost:8000
- Check out the Django admin: http://localhost:8000/admin
- Create a superuser:
python3 manage.py createsuperuser
. Login and look around the Django admin.
Additional info
Django ships default "django apps" defined settings.py
file. You can see the list of apps in the INSTALLED_APPS
variable.
Django apps are plugins that can be used to extend the functionality of the Django application. It's the core method of developing with this backend framework.
For the db.sqlite3
file, it's the default database that Django uses. You can change this to other databases like MySQL, PostgreSQL, etc.
Let's create our first Django app: event
Run this command python3 manage.py startapp event
.
What did this command do?
Initial files that it created:
event/admin.py
- the admin interface
event/apps.py
- the configuration
event/models.py
- the database schema
event/tests.py
- the test cases
event/views.py
- the views
Some files you want to create later are:
event/serializers.py
- the serializers
event/urls.py
- the URL routes
event/permissions.py
- the permissions
Creation of the database schema
Quick Reference: ERD
WIP Answer
1
2
3
4
5
6
7
8
9
10
11
12 | class Event(models.Model):
# UUID id
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
name = models.CharField(max_length=100)
date = models.DateField()
organizer = models.ForeignKey(
User, on_delete=models.SET_NULL, null=True, blank=True
)
def __str__(self):
return self.name
|
After creating this, you'll need to add it in the INSTALLED_APPS
variable in the settings.py
file.
Then run python3 manage.py makemigrations
and python3 manage.py migrate
.
What did these commands do?
makemigrations
- creates the migration files
These are scripts that defines how the database schema changed.
migrate
- applies the migration files to the database
Similar to how we performed our initial migration
When you have created that, check out db.sqlite3
and you'll see that there's a new table called event_event
.
Creation of the admin interface
WIP Answer
| from django.contrib import admin
from .models import Event
admin.site.register(Event)
|
Now visit the admin interface and you'll see the Event model there.
A cooler example
| @admin.register(Event)
class EventAdmin(admin.ModelAdmin):
list_display = ("name", "date", "organizer")
list_filter = ("organizer",)
search_fields = ("name",)
ordering = ("date",)
date_hierarchy = "date"
|
Do the same thing again for Attendance
Quick Reference: ERD
WIP Answer
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23 | # models.py
class Attendance(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
event = models.ForeignKey(
Event, on_delete=models.CASCADE, related_name="attendances"
)
# String field with only the valid choices "going", "maybe", "not_going"
status = models.CharField(
max_length=10,
choices=[("Going", "going"), ("Maybe", "maybe"), ("Not Going", "not_going")],
)
def __str__(self):
return f"Event '{self.event.name}' - Attendance '{self.user.username}'"
# admin.py
@admin.register(Attendance)
class AttendanceAdmin(admin.ModelAdmin):
list_display = ("user", "event", "status")
list_filter = ("user", "event", "status")
search_fields = ("user", "event", "status")
ordering = ("user", "event", "status")
date_hierarchy = "event__date"
|
Creation Views: the Interface in API
serializers.py
Serialisers are a way to convert Python models to JSON, XML or any other format you wish.
WIP Answer
| # serializers.py
class EventSerializer(serializers.ModelSerializer):
class Meta:
model = Event
fields = ("id", "name", "date", "organizer")
|
views.py
and urls.py
Here's our goal:
| // Class based - programming
GET events in the system - /events
POST create events - /events
PUT/PATCH update events - /events/<id>
DELETE delete events - /events/<id>
Special actions
POST Change attendee status to an event - /events/<id>/change_attendee_status
|
Firstly, we need to set the urls.py
to import the views from event/views.py
.
| # urls.py
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path("admin/", admin.site.urls),
path("api/events/", include("event.urls")) # --HERE--
]
|
There's 2 main ways to create views
Class-based Views
This is the easiest way - full of magic. If you need flexibility, you can override the methods.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20 | # event/views.py
class EventViewSet(viewsets.ModelViewSet):
queryset = Event.objects.all()
serializer_class = EventSerializer
def perform_create(self, serializer):
# Set whoever is creating the event as the organizer:
serializer.save(organizer=self.request.user)
# Note: This is a way to override the default methods
def destroy(self, request, *args, **kwargs):
return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED)
# event/urls.py
router = DefaultRouter()
router.register(r"", EventViewSet, basename="events")
urlpatterns = [
path("", include(router.urls)),
]
|
Important to note that the basename
is used to generate the URL name. In this case, it will be events-list
and events-detail
. (you will need this automated testing)
Functional Views
This is the more flexible way. This is currently the way CSF does it.
Example
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43 | # event/views.py
@api_view(["GET"])
def event_list(request):
events = Event.objects.all()
serializer = EventSerializer(events, many=True)
return Response(serializer.data)
@api_view(["GET"])
def event_detail(request, pk):
event = get_object_or_404(Event, pk=pk)
serializer = EventSerializer(event)
return Response(serializer.data)
@api_view(["POST"])
def event_create(request):
# Set whoever is creating the event as the organizer:
serializer = EventSerializer(data=request.data)
if serializer.is_valid():
serializer.save(organizer=request.user)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@api_view(["PUT"])
def event_update(request, pk):
event = get_object_or_404(Event, pk=pk)
serializer = EventSerializer(event, data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@api_view(["DELETE"])
def event_delete(request, pk):
return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED)
# urls.py
urlpatterns = [
path("", views.event_list),
path("<int:pk>/", views.event_detail),
path("create/", views.event_create),
path("<int:pk>/update/", views.event_update),
path("<int:pk>/delete/", views.event_delete),
]
|
Reuse Same Path with different HTTP Methods
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32 | @api_view(["GET", "POST"])
def event_list(request):
if request.method == "GET":
queryset = Event.objects.all()
serializer = EventSerializer(queryset, many=True)
return Response(serializer.data)
elif request.method == "POST":
serializer = EventSerializer(data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=201)
return Response(serializer.errors, status=400)
@api_view(["GET", "PUT", "PATCH", "DELETE"])
def event_detail(request, pk):
try:
event = Event.objects.get(pk=pk)
except Event.DoesNotExist:
return Response(status=404)
if request.method == "GET":
serializer = EventSerializer(event)
return Response(serializer.data)
elif request.method in ["PUT", "PATCH"]:
serializer = EventSerializer(event, data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data)
return Response(serializer.errors, status=400)
elif request.method == "DELETE":
event.delete()
return Response(status=204)
|
Another Example Class-based Views
This is the easiest way - full of magic. If you need flexibility, you can override the methods.
| # views.py
class EventViewSet(viewsets.ModelViewSet):
queryset = Event.objects.all()
serializer_class = EventSerializer
def perform_destroy(self, instance):
if not instance.is_public: # Assuming `is_public` is a boolean field in the `Event` model
raise serializers.ValidationError("Cannot delete a non-public event.")
super().perform_destroy(instance)
|
After that long discussion, let's create the views and play around.
Going back to Serializer for Organizer
1
2
3
4
5
6
7
8
9
10
11
12
13 | + class UserSerializer(serializers.ModelSerializer):
+ # read only serializer
+ class Meta:
+ model = User
+ fields = ("id", "username", "email")
class EventSerializer(serializers.ModelSerializer):
+ organizer = UserSerializer(read_only=True)
class Meta:
model = Event
fields = ("id", "name", "date", "organizer")
|
What about if we also want to include attendees?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16 | + class AttendanceSerializer(serializers.ModelSerializer):
+ user = UserSerializer(read_only=True)
+
+ class Meta:
+ model = Attendance
+ fields = ("user", "status")
class EventSerializerWithAttendees(serializers.ModelSerializer):
organizer = UserSerializer(read_only=True)
+ attendances = AttendanceSerializer(many=True, read_only=True)
class Meta:
model = Event
- fields = ("id", "name", "date", "organizer")
+ fields = ("id", "name", "date", "organizer", "attendances")
|
Bonus: Filters and Search (Will not be covered in this workshop)
https://www.django-rest-framework.org/api-guide/filtering/
/events/<id>/change_attendee_status/
Docs:
- Viewset Actions
eg. /events/1/change_attendee_status/
Payload body looks like
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22 | # serializers.py
class AttendanceChangeStatusSerializer(serializers.ModelSerializer):
class Meta:
model = Attendance
fields = ("status")
# views.py
class EventViewSet(viewsets.ModelViewSet):
...
# Attendance
@action(detail=True, methods=["post"])
def change_attendee_status(self, request, pk=None):
event = self.get_object()
serializer = AttendanceChangeStatusSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = request.user
status = serializer.validated_data["status"]
Attendance.objects.update_or_create(
user=user, event=event, defaults={"status": status}
)
return Response(status=status.HTTP_200_OK)
|
Automated Testing
https://www.django-rest-framework.org/api-guide/testing/
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42 | from rest_framework.test import APITestCase
from django.contrib.auth.models import User
from event.models import Event
# Test to test all the HTTP methods of `EventViewSet`
class EventTest(APITestCase):
def setUp(self):
# Create a user
self.user = User.objects.create_user(
username="test_user", email="[email protected]", password="test_password"
)
# Create an event
self.event = Event.objects.create(
name="test_event", date="2021-01-01", organizer=self.user
)
def test_get(self):
# Test GET method
url = reverse("events-list")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data[0]["name"], "test_event")
self.assertEqual(response.data[0]["date"], "2021-01-01")
self.assertEqual(response.data[0]["organizer"]["username"], "test_user")
def test_post(self):
# Test POST method
self.client.login(username="test_user", password="test_password")
url = reverse("events-list")
response = self.client.post(url, {"name": "test_event2", "date": "2021-01-02"})
self.assertEqual(response.status_code, 201)
event_data = response.data
self.assertEqual(event_data["name"], "test_event2")
self.assertEqual(event_data["date"], "2021-01-02")
self.assertEqual(event_data["organizer"]["username"], "test_user")
# Make sure the event by checking the database
event = Event.objects.get(name="test_event2")
self.assertEqual(str(event.id), event_data["id"])
# ... All other methods are left to the reader to implement
|
To run this test, run python manage.py test
Authentication and Authorization
What if we only want the PUT/PATCH/DELETE
methods to be accessible by the organizer?
Docs:
- Authentication https://www.django-rest-framework.org/api-guide/authentication/
- Authorization/Permissions https://www.django-rest-framework.org/api-guide/permissions/
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19 | # permissions.py
from rest_framework import permissions
# The organizer is the only one allowed to update or delete an event.
class IsOrganizerOrReadOnly(permissions.BasePermission):
def has_object_permission(self, request, view, obj):
# Read permissions are allowed to any request,
# so we'll always allow GET, HEAD or OPTIONS POST.
if request.method in [permissions.SAFE_METHODS, 'POST']:
return True
# Write permissions are only allowed to the organiser of the event.
return obj.organizer == request.user
# views.py
class EventViewSet(viewsets.ModelViewSet):
...
permission_classes = [IsOrganizerOrReadOnly]
...
|
TDD: Test-Driven Development With Multiple Accounts
This is a perfect showcase how it's very difficult to manage manually testing with multiple accounts.
1
2
3
4
5
6
7
8
9
10
11
12 | class EventTest(APITestCase):
...
def test_not_organizer_cannot_modify(self):
# Test that a user who is not the organizer cannot modify the event
# Create another user
self.user2 = User.objects.create_user(
username="test_user2", email="[email protected]", password="test_password"
)
self.client.login(username="test_user2", password="test_password")
url = reverse("events-detail", args=[self.event.id])
response = self.client.put(url, {"name": "test_event2", "date": "2021-01-02"})
self.assertEqual(response.status_code, 403)
|
How to manually test this?
- Login as the organiser
- Create an event
- Create a new user
- Login as the new user
- Try to modify the event
- Check that it returns a 403 Forbidden
Summary of Django Simple JWT
The only key bit to know is
install pip install djangorestframework-simplejwt
in settings.py
| REST_FRAMEWORK = {
...
'DEFAULT_AUTHENTICATION_CLASSES': (
...
'rest_framework_simplejwt.authentication.JWTAuthentication',
)
...
}
|
in urls.py
| from rest_framework_simplejwt.views import (
TokenObtainPairView,
TokenRefreshView,
)
urlpatterns = [
...
path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'), # Login
path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), # Refresh tokens
...
]
|
If you login via /api/token
, you will send something like this
| {
"username":"test_user",
"password":"test_password"
}
|
you're going to get something like this back
| {
"access":"eyJh...",
"refresh":"eyJhb..."
}
|
And if you are using an authenticated endpoint, you need to add the Authorization
header with the token.
"Authorization: Bearer eyJhbEXAMPLEOFJWTOKEN..."
Bonus" Sending Emails
Run docker-compose up
to start the mail server.
Your mail server will have this UI at http://localhost:8025/.
It communicates via SMTP on port 1025.
Add these in your settings.py
| EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_HOST = 'localhost'
EMAIL_PORT = 1025
EMAIL_HOST_USER = ''
EMAIL_HOST_PASSWORD = ''
EMAIL_USE_TLS = False
EMAIL_ADDRESS_FROM = '[email protected];
|
Where are these values coming from?
If you check the docker-compose.yml
, you will understand that we are just configuring the values to send via SMTP to the mail server.
In actual production services, you will change this to your actual SMTP server like AWS SES, Sendgrid, Mailgun, etc.
New file called utils.py
| from django.core.mail import send_mail
from django.conf import settings
def send_email(subject, message, recipient_list):
send_mail(
subject=subject,
message=message,
from_email=settings.EMAIL_ADDRESS_FROM,
recipient_list=recipient_list,
fail_silently=False,
)
|
Now let's change it so that when a new event is created, an email is sent to the organizer.
1
2
3
4
5
6
7
8
9
10
11
12
13 | # views.py
from event.utils import send_email
class EventViewSet(viewsets.ModelViewSet):
...
def perform_create(self, serializer):
# Set whoever is creating the event as the organizer:
serializer.save(organizer=self.request.user)
send_email(
subject="New event created",
message="A new event has been created",
recipient_list=[self.request.user.email],
)
|