Depending on the project, Django and Djoser can go really well together. Django provides such an enormous feature set as a foundation, and such a modular platform, that tools like Djoser can provide enormous value while still staying out of the way of the rest of your application. At the same time, the whole solution (Django, Djoser, and any other reusable Django app I’ve ever seen), top to bottom, is Just Pythonâ„¢. That means you can almost always find the right place to hook in your own code, without having to take over responsibility for an entire solution.
Djoser is a library that integrates nicely into a Django REST Framework project to provide API endpoints for things like user registration, login and logout, password resets, etc. It also integrates pretty seamlessly with Django-SimpleJWT to enable JWT support, and will expose JWT create, refresh, and verify endpoints if support is turned on. It’s pretty sweet.
Well…. most of the time.
The only real issues I’ve had with Djoser always root from one of two assumptions the project makes:
- That you’re puritanical in your adherence to REST principles at every turn, and
- That you’re building a Single Page Application (SPA)
That first one is easily forgivable: if you’re going to be an opinionated solution, it’s best to be consistent, and strict. The minute you fall off of that wagon, everything starts to devolve into murkiness. It doesn’t seem like a big leap to say that a lot of developers prefer an API that is clear and consistent over one that is vague and inconsistent.
As for the second assumption, it honestly doesn’t get in the way very often, but on a recent project, it bit me pretty hard. On this project, I had to leverage Djoser in my Django project’s user activation flow.
User Registration and User Activation
User activation happens as part of the user registration process in my case (and, I suspect, most cases). At a high level, the registration flow goes like this:
- a POST is sent to the server requesting that a given username or email be given a user account, using the given password.
- the server generates a token of some kind, uses that token to generate a verification link, and sends an account activation email containing the link.
- the end user opens the email and clicks the link
- magic
- the user is activated, and may or may not get an email confirming that their account is ready to go
All of this is straightforward until you get to step 4: ‘magic’. Big surprise, right? Also perhaps unsurprising is that this is where Djoser’s assumptions make life difficult if you’re not building a Single Page Application, and/or are not a REST purist.
Djoser User Registration
Before getting to activation, you have to register. For completeness, it’s worth pointing out that Djoser provides an endpoint that takes a POST request with the desired username, email, and password to kick things off. It integrates nicely with the rest of your application without any real work to do other than adding a urlpattern that’s given to you to your urls.py file.
In my case, I’m using a custom user model and I changed the USERNAME_FIELD to ’email’, and as a result, Djoser accepts just the email and password fields by default, because it’s leaning on the base Django functionality for as much as possible, which is smart and makes everyones’ lives easier.
When this POST request comes in, password validators and any other things you have set up to happen at user creation time will happen, including the creation of a user record. However, the is_active
flag will be False
for that record. Then it generates an encoded uid (from the record it created) and a verification token, uses them along with the value of ACTIVATION_URL to form a confirmation link, puts that in an email to the email address used to register, and sends it. And that leads us to…
Djoser User Activation
First, let’s have a look at the default value for Djoser’s ACTIVATION_URL setting. This setting determines the URL that will be emailed to the person who is trying to register a new account. The default value is `
'#/activate/{uid}/{token}'`
This is a front end URL with placeholders for the uid and token values. It gets assembled into an account registration verification link that looks like this:
http://localhost:8000/#/activate/Mw/am6c7b-85f2acbaf4691e9cc6c891bbc4fd7754
Up to this point in my project, I was gleefully following along with what Djoser seems to be making easy for me. Then I clicked the above link and everything crashed. Why? Because Djoser does not have a back end view to handle the front end URL that is the default ACTIVATION_URL.
Here’s the explanation from one of the project maintainers: https://github.com/sunscrapers/djoser/issues/14
I suppose you directly use an url to activation view which expects POST request. When you open a link to this view in browser it makes a GET request which is simply not working.
Our assumption is that GET requests should not change the state of application. That’s why the activation view expects POST in order to affect user model. Moreover it’s REST API so if you open one of the endpoints in your browser it displays JSON response which is not something for regular user.
If you’re working on single page application you need to create a new screen with separate url that generates POST request to your REST API.
If you really want to have view that activates user on GET request then you need to implement your own view, but remember to provide reasonable html response.
To boil this down, my understanding from this is that:
- Djoser devs know that the ACTIVATION_URL is going to be used to create a link that is sent to someone via email.
- Djoser devs know that, when you click a link in an email, the result is a GET request to the back end.
- Djoser devs have provided a view for user activation that only supports POST requests in spite of this fact.
This is immensely frustrating. Their implementation seems to sacrifice a product that actually works at all for the sake of REST purity and maybe an assumption that all developers are only creating SPAs. What’s more, searching around for solutions turns up lots of confused people. The solution that I found trending was one where you write your own view that does accept a GET request, and then, inside the view, in the back end code, make a POST request to run the code in Djoser’s UserActivationView!
This all just felt way too… wrong for me. The back end should not make an HTTP request to itself. Perhaps what I did wasn’t 100% perfect either, but I’d be interested in a dialog that could shed more light on why things are the way they are, and how to properly and effectively deal with it.
My Workaround
First, if you’re just here for the code, here’s the view I created and the urlpattern that maps to it.
from djoser.views import UserViewSet
from rest_framework.response import Response
class ActivateUser(UserViewSet):
def get_serializer(self, *args, **kwargs):
serializer_class = self.get_serializer_class()
kwargs.setdefault('context', self.get_serializer_context())
# this line is the only change from the base implementation.
kwargs['data'] = {"uid": self.kwargs['uid'], "token": self.kwargs['token']}
return serializer_class(*args, **kwargs)
def activation(self, request, uid, token, *args, **kwargs):
super().activation(request, *args, **kwargs)
return Response(status=status.HTTP_204_NO_CONTENT)
My Djoser ACTIVATION_URL in settings.py is `
'accounts/activate/{uid}/{token}'
And then the urlpattern used to map requests to that url looks like this:
path('accounts/activate/<uid>/<token>', ActivateUser.as_view({'get': 'activation'}), name='activation'),
What’s Actually Happening In My Workaround
Djoser leans on Django and Django Rest Framework for a lot of its functionality. In order to support a large number of URLs while duplicating the least amount of code, Djoser utilizes Django Rest Framework’s ‘ViewSet’ concept, which lets you map an ‘action’ to a method in a single class. So, instead of having separate views for “UserRegistration”, “UserActivation”, “UserPasswordChange”, and all of the other things that can happen to a user, Djoser just has one class called “UserViewSet” (at djoser.views.UserViewSet
).
UserViewSet.activation is a method that takes a POST request containing the UID and token values, validates them, and (assuming validation passes) sends a signal that, in my application, sets the is_active
flag on the user to True
and sends the new user an email letting them know their account is now active. “It’s all perfect except for the POST!” I thought. But I wasn’t happy with solutions that have code in the back end going back out to the internet to trigger other code on the back end. I wasn’t going to accept sending a POST request to trigger another view.
So, step one was to create my own view, inheriting from UserViewSet, and then allowing that view to accept a GET request, because you’ll recall that our mission is to handle the user clicking the link in their email to activate their account. Aside from accepting a GET request, I don’t really want my code to do anything at all. Just call super().activation and get out of the way!
Now, the base implementation forces POST-only by decorating it with an @action
decorator. The first argument to the decorator is a list of HTTP methods supported, and only post
is listed. Great! So just don’t decorate that method, map it to a GET in urls.py
, and you’re all set!
Sadly, it was not quite that easy. Since UserViewSet.activation only supports a POST request, it also assumes that what it needs is already in request.data
. But when our GET request comes in, the uid
and token
values will be in kwargs
. Making things more difficult, the request
object here is Django Rest Framework’s Request
object, and its data
attribute is not settable (I think it’s a property defined with no setter, but don’t quote me). So, I can’t just overwrite request.data
and move on. Now what?
So, we need to find a way to get data into request.data
so that when I call super().activation()
, it can act on that data. In looking at the code for UserViewSet.activation
I found this:
@action(["post"], detail=False)
def activation(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
That’s not the whole method, but for the whole method, the only time request.data
is referenced is on the very first line of the code. Since we already said I can’t just shim in a line and overwrite request.data
, let’s instead have a look at this get_serializer
method!
def get_serializer(self, *args, **kwargs):
"""
Return the serializer instance that should be used for validating and
deserializing input, and for serializing output.
"""
serializer_class = self.get_serializer_class()
kwargs.setdefault('context', self.get_serializer_context())
return serializer_class(*args, **kwargs)
Notice that it doesn’t explicitly have a data
parameter defined in the method’s signature. That means any reference to data
would have to be in kwargs
. That means we can set kwargs['data']
inside of this method to whatever we want. The updated method to make that happen only adds a single line:
def get_serializer(self, *args, **kwargs):
serializer_class = self.get_serializer_class()
kwargs.setdefault('context', self.get_serializer_context())
kwargs['data'] = {"uid": self.kwargs['uid'], "token": self.kwargs['token']}
return serializer_class(*args, **kwargs)
That’s it. You just effectively replaced request.data
.
One More Time
This is a lot. Let’s review what happened.
First, the mission:
- Support a GET request that happens when the user clicks the account activation link in their email.
Next, the problem:
- There’s code to handle user activation, but it doesn’t support a GET request.
Then, the workaround:
- Create our own view that inherits from the Djoser UserViewSet to handle the incoming GET request.
- Override the
activation
method to accept theuid
andtoken
parameters coming in on the URL and remove the@action
decorator that only allowed HTTP POST. - Override the
get_serializer
method to insert theuid
andtoken
values into itskwargs['data']
. - Define a urlpattern that maps a get request to our ACTIVATION_URL to our newly-created view.
- Profit
If you don’t recall any of the above steps from the discussion, scroll back up to see my code in the My Workaround section.
But Maybe I’m Wrong!
I’m wrong a lot. Maybe you know better. I’d be happy to see a better alternative solution. I’d also love to have a better understanding of the logic Djoser is using, because I admittedly just don’t get that. As a developer, I’m far more comfortable moving from APIs back towards the operating system and infrastructure services than I am moving into front end frameworks (though I do have to do that sometimes). So, if you understand how a ‘front end url’ is sent via email and then expected to somehow be intercepted by a front end that then sends a POST to the back end, please point me to some docs!