1

Django - Allow duplicate user's emails in the grand User model, but unique in its extended models?

I'm working on a project in Django which have a User model extend from AbstractBaseUser; and 2 models extend from User (Customer and Technician). Its working all fine when creating a user or customer or technician.

However, whenever I register a new user as either customer or technician (/api/customer/register/ or /api/customer/register/) with the same email (because I define email as username USERNAME_FIELD), it returned me with "This email is already exists." Sure, I understand this since I define the UNIQUE constraint on username and email field in the User model. I want customer to be unique in its own model but not in User model, same for technician. Just like when you sign up for Uber or DoorDash, either you want to be a customer or you want to be a driver or you could be both. AND you could use the same email address to sign up for both. BUT, you cannot use the same email address to sign up again, if you are already signed up with that email as a customer or a driver

I have tried to tweak around this but none works so far! My question is, what would be the best approach to this problem? Should I create 2 different tables for Customer and Technician? If anyone knows, what architecture Uber or DoorDash or Lyft use to approach this problem?

Thank you so much for any help! I would post my models.py here for you fellows to see. Cheers!


import uuid
from typing import Any, Optional

from django.contrib.auth.models import (
    AbstractBaseUser,
    BaseUserManager,
    PermissionsMixin,
)
from django.db import models
from rest_framework_simplejwt.tokens import RefreshToken


class UserManager(BaseUserManager):  # type: ignore
    """UserManager class."""

    # type: ignore
    def create_user(self, username: str, email: str, password: Optional[str] = None) -> 'User':
        """Create and return a `User` with an email, username and password."""
        if username is None:
            raise TypeError('Users must have a username.')

        if email is None:
            raise TypeError('Users must have an email address.')

        user = self.model(username=username, email=self.normalize_email(email))
        user.set_password(password)
        user.save()

        return user

    def create_customer(self, username: str, email: str, password: Optional[str] = None) -> 'Customer':
        """Create and return a `User` with an email, username and password."""
        if username is None:
            raise TypeError('Users must have a username.')

        if email is None:
            raise TypeError('Users must have an email address.')

        user = user.model(username=username, email=self.normalize_email(email))
        user.set_password(password)
        user.save()

        return user

    """def create_customer(self, username, email, password=None, **extra_fields):
        user = self.create_user(username, email, password, **extra_fields)
        Customer.objects.create(user=user)
        return user"""

    def create_technician(self, username, email, password=None, **extra_fields):
        user = self.create_user(username, email, password, **extra_fields)
        Technician.objects.create(user=user)
        return user

    def create_superuser(self, username: str, email: str, password: str) -> 'User':  # type: ignore
        """Create and return a `User` with superuser (admin) permissions."""
        if password is None:
            raise TypeError('Superusers must have a password.')

        user = self.create_user(username, email, password)
        user.is_superuser = True
        user.is_staff = True
        user.is_active = True
        user.save()

        return user


class User(AbstractBaseUser, PermissionsMixin):

    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    username = models.CharField(db_index=True, max_length=255, unique=True)
    email = models.EmailField(db_index=True, unique=True)
    is_active = models.BooleanField(default=True)
    is_staff = models.BooleanField(default=False)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
    #bio = models.TextField(null=True)
    full_name = models.CharField(max_length=20000, null=True)
    birth_date = models.DateField(null=True)

    is_customer = models.BooleanField(default=False)
    is_technician = models.BooleanField(default=False)

    USERNAME_FIELD = 'email'
    REQUIRED_FIELDS = ['username']

    # Tells Django that the UserManager class defined above should manage
    # objects of this type.
    objects = UserManager()

    def __str__(self) -> str:
        """Return a string representation of this `User`."""
        string = self.email if self.email != '' else self.get_full_name()
        return f'{self.id} {string}'

    @property
    def tokens(self) -> dict[str, str]:
        """Allow us to get a user's token by calling `user.tokens`."""
        refresh = RefreshToken.for_user(self)
        return {'refresh': str(refresh), 'access': str(refresh.access_token)}

    def get_full_name(self) -> Optional[str]:
        """Return the full name of the user."""
        return self.full_name

    def get_short_name(self) -> str:
        """Return user username."""
        return self.username

class Customer(models.Model):
    user = models.OneToOneField(User, related_name="customer", on_delete=models.CASCADE, primary_key=True)

    objects = UserManager()

class Technician(models.Model):
    user = models.OneToOneField(User, related_name="technician", on_delete=models.CASCADE, primary_key=True)

My serializers.py

from django.contrib.auth import authenticate
from rest_framework import exceptions, serializers
from rest_framework_simplejwt.tokens import RefreshToken, TokenError

from .models import Customer, Technician
from django.contrib.auth import get_user_model
User = get_user_model()

from .utils import validate_email as email_is_valid

class RegistrationSerializer(serializers.ModelSerializer[User]):
    """Serializers registration requests and creates a new user."""

    password = serializers.CharField(max_length=128, min_length=8, write_only=True)

    class Meta:
        model = User
        fields = [
            'email',
            'username',
            'password',
            'full_name',
        ]

    def validate_email(self, value: str) -> str:
        """Normalize and validate email address."""
        valid, error_text = email_is_valid(value)
        if not valid:
            raise serializers.ValidationError(error_text)
        try:
            email_name, domain_part = value.strip().rsplit('@', 1)
        except ValueError:
            pass
        else:
            value = '@'.join([email_name, domain_part.lower()])

        return value

    def create(self, validated_data):  # type: ignore
        """Return user after creation."""
        user = User.objects.create_user(
            username=validated_data['username'], email=validated_data['email'], password=validated_data['password']
        )
        user.full_name = validated_data.get('full_name', '')
        user.save(update_fields=['full_name'])
        return user

class CustomerRegistrationSerializer(serializers.ModelSerializer[User]):
    """Serializers registration requests and creates a new user."""

    password = serializers.CharField(max_length=128, min_length=8, write_only=True)

    class Meta:
        model = User
        fields = [
            'email',
            'username',
            'password',
            'full_name',
        ]

    def validate_email(self, value: str) -> str:
        """Normalize and validate email address."""
        valid, error_text = email_is_valid(value)
        if not valid:
            raise serializers.ValidationError(error_text)
        try:
            email_name, domain_part = value.strip().rsplit('@', 1)
        except ValueError:
            pass
        else:
            value = '@'.join([email_name, domain_part.lower()])

        return value

    def create(self, validated_data):  # type: ignore
        """Return user after creation."""
        customer = User.objects.create_customer(username=validated_data['username'], email=validated_data['email'], password=validated_data['password'])
        customer.full_name = validated_data.get('full_name', '')
        customer.is_customer = True
        customer.save(update_fields=['full_name','is_customer'])
        return customer

class TechnicianRegistrationSerializer(serializers.ModelSerializer[User]):
    """Serializers registration requests and creates a new user."""

    password = serializers.CharField(max_length=128, min_length=8, write_only=True)

    class Meta:
        model = User
        fields = [
            'email',
            'username',
            'password',
            'full_name',
        ]

    def validate_email(self, value: str) -> str:
        """Normalize and validate email address."""
        valid, error_text = email_is_valid(value)
        if not valid:
            raise serializers.ValidationError(error_text)
        try:
            email_name, domain_part = value.strip().rsplit('@', 1)
        except ValueError:
            pass
        else:
            value = '@'.join([email_name, domain_part.lower()])

        return value

    def create(self, validated_data):  # type: ignore
        """Return user after creation."""
        technician = User.objects.create_technician(username=validated_data['username'], email=validated_data['email'], password=validated_data['password'])
        technician.full_name = validated_data.get('full_name', '')
        technician.is_technician = True
        technician.save(update_fields=['full_name','is_technician'])
        print(technician.tokens)
        return technician

I have tried to tweak around with the create function or customize username or email for Customer and Technician, but none of that works for me.

jeology
  • 11
  • 2

1 Answers1

1

What you have implemented so far is almost Djangos implementation of multi-table inheritance. You need to move the username to the extended class (and actually extend it in the code not just conceptually).

You can read up on how this is implemented in the documentation.

Your code should look somewhat like this:

class Customer(User):
    username = models.CharField(db_index=True, max_length=255, unique=True)

    objects = UserManager()

class Technician(User):
    username = models.CharField(db_index=True, max_length=255, unique=True)

Under the hood django implements a one to one field on these models which provide some neat syntax simplifications, but fair warning this can create some challenges in further implementation.

As to what the best solutions is, there is none, there's only one that fits your needs best. It's best not to think too far ahead but face challenges as they come. Who knows you might end up preffering the two table systems better. On the other hand the user superclass sharing some properties may prove useful.

For more options look at this

Blye
  • 619
  • 4
  • 20
  • Thanks! By this way of implementation, would the Customer and Technician class inherit the authenticate function from the User model? – jeology Aug 01 '23 at 22:28
  • If this is part of the Abstract base user you're already extending it should, not sure how that would affect syntax though – Blye Aug 02 '23 at 14:29
  • could you tell me how exactly different this implementation is compared to OneToOne? – jeology Aug 02 '23 at 15:26
  • It's barely differen't it's simply a native way of doing things in django. I guess the only somewhat significant thing would be syntax of queries. You can also access the extended objects as a superset of the inherited object and not have to query for the connected object. That being said this is all desicribed in 4 paragraphs in the docs (1st link) so if that's unsufficiend I'd go around and test it yourself. – Blye Aug 02 '23 at 19:58
  • Gotta admit this a very well phrased question though. Hats off to you – Blye Aug 02 '23 at 19:59
  • 1
    Thank you so much! Cheers to you code fellow! – jeology Aug 03 '23 at 01:27