This is a howto that might come in handy to some people, but mostly I just want to document how I poked around some very angry django dragons and created something marvelous. There are also people on twitter who were wondering what the fuck I was doing.
So let's start by describing the problem. We have a base user model named pUser (yes stupid naming convention) that is tied to a cookie, which holds an id. These users are then tied to a number of different API accounts. In my case it is Delicious, Twitter and Facebook. The user_id is also used to tie a bunch of meta data in different other models to them.
The problem is that we do not want to trouble users with a special login for our service. But they are using different computers and browsers, so the same physical user can have multiple user id's.
However through their Delicious et al. credentials we can tie them back together into a single entity. But we do not want to trouble the rest of the code with this detail, it should just work seamlessly because otherwise we'd be forced to introduce checking for this stuff at about 50 different places in the project.
My approach to solving this goes as follows; at the end will be the three tests that indicate that the solution is valid. A hardcore test through the actual UI also confirmed that everything works.
First we introduce a model that connects different user id's to the main user (i.e. the first id said user was given)
class UserNormalisation(models.Model):main_id = models.IntegerField()sub_id = models.IntegerField()class Meta:unique_together = ("main_id", "sub_id")
Then we give our Delicious model a ModelManager that will perform duplicity checking and tie different users together as needed.
class DeliciousManager(models.Manager):def create(self, **kwargs):try:old = Delicious.objects.get(username=kwargs['username'])new = super(DeliciousManager, self).create(**kwargs)try:UserNormalisation(main_id = old.user.id,sub_id = new.user.id).save()except IntegrityError:passnew.delete()return oldexcept Delicious.DoesNotExist:return super(DeliciousManager, self).create(**kwargs)class Delicious(models.Model):user = models.ForeignKey( pUser )username = models.CharField( max_length=255 )password = models.CharField( max_length=255 )isScrobbled = models.BooleanField( default=False )objects = DeliciousManager()
Basically when the _create_function is called it checks whether a Delicious model with the same username already exists and if it does, then a row is added to the UserNormalisation table to tie the two user id's together.
And here's the real magic, the changes we did to the pUser model.
class pUserManager(models.Manager):def get(self, **kwargs):user = super(pUserManager, self).get(**kwargs)try:id = UserNormalisation.objects.get(sub_id=user.id).main_iduser = super(pUserManager, self).get(id=id)except UserNormalisation.DoesNotExist:passreturn userclass pUser(models.Model):username = models.CharField( max_length=50 )password = models.CharField( max_length=255 )creation = models.DateTimeField( auto_now=True )objects = pUserManager()def __init__(self, *args, **kwargs):super(pUser, self).__init__(*args, **kwargs)try:id = UserNormalisation.objects.get(sub_id=self.id).main_idself.id = idexcept UserNormalisation.DoesNotExist:pass
The pUserManager should have a few more functions that do essentially the same thing for other operations (filter comes to mind). Essentially whenever a pUser is fetched from the db the manager will return the real user as per the UserNormalisation model.
Another trick that makes this work seamlessly even when used as a connecting model (primary key for instance) in a different table is that __init__ function. What I've discovered is that there it's enough to just change the user's id in place and everything will work.
Here are the tests that confirm all of this funky stuff actually performs as expected
def test_normalisation(self):user = pUser(username="test", password="test")user.save()user2 = pUser(username="test2", password="test")user2.save()norm = UserNormalisation(main_id=user.id, sub_id=user2.id)norm.save()fixture = pUser.objects.get(id=user2.id)self.assertEquals( fixture.id, user.id )def test_normalisation2(self):user = pUser()user.save()user2 = pUser()user2.save()user.delicious_set.create(username="test", password="test")fixture = user2.delicious_set.create(username="test", password="test")self.assertEquals( fixture.user.id, user.id )self.assertEquals( UserNormalisation.objects.get(sub_id=user2.id).main_id, user.id )self.assertEquals( fixture.user, user )def test_normalisation3(self):user = pUser()user.save()user2 = pUser()user2.save()user.delicious_set.create(username="test", password="test")fixture = user2.delicious_set.create(username="test", password="test")norm = UserNormalisation.objects.all()Concepts.relate(user=user2, concept1="tag1", concept2="tag2")relation = ConceptRelation.objects.filter(user=user2, concept1="tag1")self.assertEquals( relation.user.id, user.id )self.assertEquals( relation.user, user )
Take special note to the latter two examples. In test_normalisation2 you can see that when a delicious_set is created for user2, the two users become the same thing because both we're using the same delicious username both times. Something similar happens in test_normalisation3, but there we see that creating a ConceptRelation for user2 actually creates it for the first user because they both behave as if they were the original user.
I write articles with real insight into the career and skills of a modern software engineer. "Raw and honest from the heart!" as one reader described them. Fueled by lessons learned over 20 years of building production code for side-projects, small businesses, and hyper growth startups. Both successful and not.
Subscribe below 👇
Join Swizec's Newsletter and get insightful emails 💌 on mindsets, tactics, and technical skills for your career. Real lessons from building production software. No bullshit.
"Man, love your simple writing! Yours is the only newsletter I open and only blog that I give a fuck to read & scroll till the end. And wow always take away lessons with me. Inspiring! And very relatable. 👌"
Have a burning question that you think I can answer? Hit me up on twitter and I'll do my best.
Who am I and who do I help? I'm Swizec Teller and I turn coders into engineers with "Raw and honest from the heart!" writing. No bullshit. Real insights into the career and skills of a modern software engineer.
Want to become a true senior engineer? Take ownership, have autonomy, and be a force multiplier on your team. The Senior Engineer Mindset ebook can help 👉 swizec.com/senior-mindset. These are the shifts in mindset that unlocked my career.
Curious about Serverless and the modern backend? Check out Serverless Handbook, for frontend engineers 👉 ServerlessHandbook.dev
Want to Stop copy pasting D3 examples and create data visualizations of your own? Learn how to build scalable dataviz React components your whole team can understand with React for Data Visualization
Did someone amazing share this letter with you? Wonderful! You can sign up for my weekly letters for software engineers on their path to greatness, here: swizec.com/blog
By the way, just in case no one has told you it yet today: I love and appreciate you for who you are ❤️