Django-Allauth: Add Action after Authentication + test behaviour.
Gabriel Soler
Posted on September 25, 2024
Allauth is a powerful third-party Django app that is highly regarded in the community. It's a popular choice in many tutorials and books thanks to its ability to solve many authentication problems and keep Django up to date with current practices, such as two-factor authentication and Social authentication. Its stats show 987,075 downloads a month.
Even though it is excellent to use a third-party app like Django-Allauth, it carries two big problems and questions for a beginner like me:
- One of the key challenges is understanding how to add functionalities before and after its actions.
- How do I test the new behaviours when I do not know how things were made?
Why these two are so crucial?
In my case, I was motivated to make something happen after authentication. I wanted my app to save the test taken and link it to whoever was visiting, even without being a user. This was important to me because it would allow a new user to try the app a little and then decide to sign up or a previous user to use the app and then keep the changes.
These Django actions were not part of the book I followed to learn, nor any other tutorial I have used before. They made me go on a quest to understand a few more advanced areas of Django: Sessions, Signals and Testings.
Sessions:
Django erases all user information after they sign up or log in. Everything is saved in an AnonimusUser; there is no communication when the system moves to the Authenticated user. This means that all the work that happens without being authenticated gets lost.
So we need to find a way around it. The solution I found is that every time Django starts, it creates a Session (activated by default, but check if it is not).
Sessions are a model that holds information every time someone uses Django and has a dictionary to remember things between user actions. The session is maintained before and after authentication and, therefore, can hold, for instance, a Model's primary key.
(I like using functions views because I feel they let me see what is happening)
# views.py
def take_profile_test(request):
if request.method !='POST': # no post gives you the base Form
#no data submitted; create a blank form
form = StyleForm()
else:
#POST data submitted; process data
form = StyleForm(data=request.POST)
if form.is_valid():
new = form.save(commit=False) # so we make changes after
if request.user.is_authenticated:
user = request.user
new.user = user
new.save()
pk = new.pk # here, I start saving the pk of this form in the Session
request.session['form_pk'] = str(pk) # I moved it to s string for it to work
request.session['linked'] = "false" # For testing, I added another information point, I will change it after things are working post-authentication
return redirect('between_app:results',pk)
#display a blank or invalid form
context = {'form':form}
return render(request,'between_app/personal_style/profile_test.html',context)
You can see here that I am calling a Form, and having two paths, with or without a 'post' request. If the post gives me a valid form I save it, first without commit, so I can add a user to it. But, if there is no user, I am in troubles. Normally you have your Form pages locked to be authenticated, but keeping them open makes the site more attractive.
To be able to link the form to the session I create new entries in the dictionary. Using the request.session is the way of calling it. You can see that you can add new entries to it to hold some information. In this case, I added the 'pk' of the form(' form_pk') and also a 'linked', to help me track if the next function made the change and therefore it is working.
Signals:
Django-Allauth as many other third party apps gives you some signals of what they are doing. Like with JavaScript's event listener, you can have a function waiting for the signal so it can trigger an action.
So it is great to know that Allauth has the next signals:
allauth.account.signals.user_logged_in(request, user)
allauth.account.signals.user_logged_out(request, user)
allauth.account.signals.user_signed_up(request, user)
It has many other ones, like passwords changes, or specific for social accounts, etc. Most of the behaviours of the app have a signal you can hook to.
But, how to you do that?
# apps.py
class AccountsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'accounts'
def ready(self):
from allauth.account.signals import user_signed_up, user_logged_in
from django.dispatch.dispatcher import receiver
from between_app.models import PersonalStyle
import uuid
def save_form_after_log(request,user,model,**kwargs):
"""Base function for adding pk to form after auth" ""
try:
form_pk = request.session["form_pk"]
form_pk = uuid.UUID(form_pk)
form = model.objects.get(pk=form_pk)
form.user = user
form.save()
print("form linked to user") # For checking your logs
request.session['linked'] = "true" #for testing
except Exception as e:
print(f"failed to link user because: {e}") # for checking your logs
@receiver(user_signed_up)
def user_signed_up(request, user, **kwargs):
"""function to be applied after signup so if there is a test made before it is attached to the user"""
save_form_after_log(request,user,PersonalStyle,**kwargs)
@receiver(user_logged_in)
def user_logged_in(request, user, **kwargs):
"""function to be applied after login so if there is a test made before it is attached to the user"""
save_form_after_log(request,user,PersonalStyle,**kwargs)
You are seeing the file of accounts/apps.py, which is the best place to place your functions.
Here we have a few things:
- a ready function where to put your new actions
- a receiver to be listening to the signals.
The ready function needs to be placed inside the app function. After doing some research in Stack overflow, I found out that there are a few places you an put it, but that this is the best one (and the one that works).
This function activates when the third party app starts and then it allows you to be listening to it. If you place this in other file (models seems to be an option) you are in risk of it to not be initiated well and not pick up the signals.
The receiver it is a great thing to know about, as it is the one listening for signals, and does the trick quite easlily, you put it on top with the @ (that will wrap you function inside the receiver) and put the name of the signal to be waiting for. Then you need to give it a few parameters. Allauth asks you to have a request and a user. In the kwargs we may have some extra information, so I kept it there as used by others.
In this case, after things where working well I made one function and used it for both cases, that is why I passed the model to it. In this case I am using the same both times, but I felt useful to pass it.
Testing
Now this is working, but one of my new learning landmarks is to add tests to my code. In this case, testing a third party app was another challenge. As I did not know how the app works I needed to dig into their source code to figure out how to test it.
I found one test that they were using that was close to what I needed and adapted it.
from django.test import TestCase,Client
from django.urls import reverse, reverse_lazy
from django.contrib.auth import get_user_model
from .forms import StyleForm
from django.conf import settings
from django.test.utils import override_settings
from allauth.account import app_settings
from allauth.account.models import EmailAddress
from allauth.account import app_settings
@override_settings(
ACCOUNT_DEFAULT_HTTP_PROTOCOL= "https",
ACCOUNT_EMAIL_VERIFICATION=app_settings.EmailVerificationMethod.MANDATORY,
ACCOUNT_AUTHENTICATION_METHOD=app_settings.AuthenticationMethod.USERNAME,
ACCOUNT_SIGNUP_FORM_CLASS=None,
ACCOUNT_EMAIL_SUBJECT_PREFIX=None,
LOGIN_REDIRECT_URL="/accounts/profile/",
ACCOUNT_SIGNUP_REDIRECT_URL="/accounts/welcome/",
ACCOUNT_ADAPTER= "allauth.account.adapter.DefaultAccountAdapter",
ACCOUNT_USERNAME_REQUIRED=True,
)
class ProfileLinkUserAutenticate(TestCase):
"""to test if the open profile test saves after login and signup """
fixtures = ['between_app/fixtures/PersonalStyleGroup.yaml',
'between_app/fixtures/PersonalStyleSection.yaml'
]
def setup(self):
self.client = Client() #to explore templates in request
@classmethod
def setUpTestData(cls):
cls.user = get_user_model().objects.create(username="@usertest")
cls.user.set_password("test123%%HH")
cls.user.save()
EmailAddress.objects.create(
user=cls.user,
email="test@test.com",
primary=True,
verified=True,
)
cls.login_data = {"login":"@usertest","password":"test123%%HH"}
cls.data = {
'follower_1':90,
'propositive_1':2,
'challenger_1':6,
'acceptant_1':10,
'intensive_1':20,
'extensive_1':6,
'divider_1':3,
'containment_1':30,
'becoming_1':1,
'development_1':60,
'individuation_1':10,
'belonging_1':3,
}
def test_client_login(self):
self.client.login(
username = 'usertest',
email = 'test@test.com',
password = 'test123',
)
self.assertTrue(self.user.is_authenticated)
self.client.logout()
def test_form_valid(self):
form_invalid = StyleForm(data={"name": "Computer", "price": 400.1234})
self.assertFalse(form_invalid.is_valid())
form_valid = StyleForm(data=self.data)
self.assertTrue(form_valid.is_valid())
def test_post_form(self):
response = self.client.post('/profile_test/',self.data, follow=True)
self.assertEqual(self.client.session['linked'],"false")
self.assertContains(response,"Compassionate")
def test_login_after_test(self):
self.client.logout()
response = self.client.post('/profile_test/',self.data, follow=True)
self.assertEqual(self.client.session['linked'],"false")
response = self.client.post(
reverse("account_login"),
self.login_data,
)
self.assertRedirects(
response, settings.LOGIN_REDIRECT_URL, fetch_redirect_response=False
)
response = self.client.get('')
self.assertEqual(self.client.session['linked'],"true")
For testing here there are quite a few things to look at:
- Fixtures
- override settings
- client
- create user
I think it is a bit out of the scope to explain everything, but I will try to do it shortly.
Fixtures: allow you to give information to the database so your app can start with some important parts. As when we do tests we start a new database each time, I needed to give it the fixture.
Override settings: I am not sure what they are doing, but the source code were using it and I did too. Sorry but I am happy not knowing (for now). There are many checks in the background that may be too much and unnecesary to keep in testing.
Client: the client allows you to simulate someone visiting your site. You place it inside a TestCase, which also creates a custom database, only for testing. It is useful to test behaviour and keep things clean and contained.
Create user: when you create an user Django hashes the data, and therefore it is not enough to add them as a normal model. That is why it is useful to use the 'set_password'. I also added the email in this way because they did so in the source code. I must say I spend weeks trying to send a dictionary in the sign up and I never hit the mark. So, finding how they where doing it seems the safest option.
Conclusions
- Working with a third-party app simplifies your work but adds new complexities, as you need to know your code better and dig into theirs for things to plug in nicely. I have needed to step up my game to use Allauth flexibly and do what I needed it to do. Learning to use Sessions and Signals will help me use and fully take advantage of other third-party apps.
- I hope my rabbit hole search will be helpful and save you some time.
- Please leave any questions or suggestions in the comments :)
Posted on September 25, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.