Dev Journal #7 - Extending the User Model



Wrapper Model

Currently, I am using the Django’s built-in user model from the django.contrib.auth library. As I discussed earlier in my Dev Journal #4, this library provides a lot of tools for handling common authentication practices.

The default User table already handles storing basic information about the user account. However, I will eventually need to save additional user information that are specific to my application as I continue developing. I can technically achieve this by extending the same User table to carry the extra columns for each additional user fields. However, this approach could further complicate the default behaviour and clutter the model. Instead, I will create a wrapper model called UserProfile that will have an One-to-One relationship with User model. In addition to having access to User model fields, UserProfile will have its own fields to handle any additional custom attributes. Below are the two entities from the previously proposed ERD diagram that I will be referring to in this post.

UserProfile

The declaration of this new model class would look something like this:

class UserProfile(models.Model):

    METRIC = 'METRIC'
    IMPERIAL = 'IMPERIAL'
    UNIT_CHOICES = [(METRIC, 'Metric'), (IMPERIAL, 'Imperial')]

    user = models.OneToOneField(User, on_delete=models.CASCADE)
    bio = models.TextField(max_length=200, null=True, blank=True)
    birth_date = models.DateField(null=True, blank=True)
    weight = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True)
    height = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True)
    unit = models.CharField(max_length=8, choices=UNIT_CHOICES, default=METRIC)
    is_private = models.BooleanField(default=False)

    @property
    def owner(self):
        return self.user

The user attribute is defined as OneToOneField with User model, which means that for every UserProfile model, there will exist a link to the User model that holds the account information. The rest of the attributes are additional custom fields I wish to capture about the user. Notice how the attribute unit is a CharField but it behaves like an enum when provided with th list of tuples as the choices parameter. Each tuple has two items where the first element is the actual value and the second is the display name. If the choices are given, the field will be enforced with the built-in model validation which raises errors if the user tries to input an undefined choice value. Lastly, the @property decorater is used to mark the owner of the object which is helpful to know during delete requests.

Signal Dispatchers

Finally, I want the UserProfile to be instantiated every time a new User instance is created. A signal receiver can be used to get notified every time a user account is created. Then the receiver runs a function that creates a corresponding UserProfile which links back to that particular user account. Another receiver listening for delete events from the UserProfile can be used to cascade the deletion down to the associated User model.

from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver


@receiver(post_save, sender=User)
def create_user_profile(sender, instance, created, **kwargs):
    if created:
        UserProfile.objects.create(user=instance)


@receiver(post_save, sender=User)
def save_user_profile(sender, instance, **kwargs):
    instance.userprofile.save()


@receiver(post_delete, sender=UserProfile)
def post_delete_user(sender, instance, *args, **kwargs):
    if instance.user:
        instance.user.delete()

Serializer

Serializers allow querysets and model instances to be rendered into a JSON data format. It can also deserialize parsed data to be converted back to model instances. They are also used for validating all incoming data. Below is my example of UserProfileSerializer, which contains fields combined from both the User model and the UserProfile model.

class UserProfileSerializer(serializers.ModelSerializer):
    username = serializers.CharField(source='user.username', read_only=True)
    email = serializers.CharField(source='user.email', read_only=True)
    first_name = serializers.CharField(source='user.first_name', required=False)
    last_name = serializers.CharField(source='user.last_name', required=False)
    last_login = serializers.DateTimeField(source='user.last_login', read_only=True)
    date_joined = serializers.DateTimeField(source='user.date_joined', read_only=True)

    class Meta:
        model = UserProfile
        fields = [
            'username',
            'email',
            'first_name',
            'last_name',
            'last_login',
            'date_joined',
            'bio',
            'birth_date',
            'weight',
            'height',
            'unit',
            'is_private'
        ]

I only allow updates on first_name and last_name for the fields from the User model. All fields that belong to UserProfile are allowed to be updated as well. Django provides basic validation based on the data type defined in its model class. However, it’s also important to provide some additional semantic validations on top of the basic validations to provide better user experience.

class UserProfileSerializer(serializers.ModelSerializer):

    ...

    # Custom validations
    @staticmethod
    def validate_weight(value):
        if value < 0:
            raise serializers.ValidationError('Weight cannot be negative.')
        return value

    @staticmethod
    def validate_height(value):
        if value < 0:
            raise serializers.ValidationError('Height cannot be negative.')
        return value

    @staticmethod
    def validate_birth_date(value):
        if value > datetime.date.today():
            raise serializers.ValidationError('Date of birth cannot be in the future.')
        return value

    ...

Since I am updating two different models through a single serializer, I need to override the update function of the serializers.ModelSerializer to change its default behaviour. The first_name and last_name fields are provided as the user object’s attributes, and the user object is nested under the original instance. In the new update function, I unstitch the given instance into two separate instances for each models and save the validated data accordingly. I also call the UserDetailSerializer with the partial=True parameter because I only want to update the first_name and the last_name, but the serializer will be expecting values from all fields belonging to UserDetailSerializer by default.

from django.contrib.auth import get_user_model

User = get_user_model()


class UserProfileSerializer(serializers.ModelSerializer):
    
    ...

    def update(self, instance, validated_data):
        # Update User instance
        user_data = validated_data.pop('user', {})
        user_serializer = UserDetailSerializer(instance.user, data=user_data, partial=True)
        user_serializer.is_valid(raise_exception=True)
        user_serializer.update(instance.user, user_data)

        # Update UserProfile instance
        super(UserProfileSerializer, self).update(instance, validated_data)
        return instance

    ...


class UserDetailSerializer(serializers.ModelSerializer):

    class Meta:
        model = User
        fields = [
            'id',
            'username',
            'email',
            'date_joined',
            'last_login',
            'first_name',
            'last_name'
        ]

Here is a sample error response body returned from the server when the data fails validations during the PUT requests

Input

{
    "first_name": "Adolph Blaine Charles David Earl Frederick Gerald Hubert Irvin John Kenneth Lloyd Martin Nero Oliver Paul Quincy Randolph Sherman Thomas Uncas Victor William Xerxes Yancy Zeus",
    "last_name": "Wolfe­schlegel­stein­hausen­berger­dorff­welche­vor­altern­waren­gewissen­haft­schafers­wessen­schafe­waren­wohl­gepflege­und­sorg­faltig­keit­be­schutzen­vor­an­greifen­durch­ihr­raub­gierig­feinde­welche­vor­altern­zwolf­hundert­tausend­jah­res­voran­die­er­scheinen­von­der­erste­erde­mensch­der­raum­schiff­genacht­mit­tung­stein­und­sieben­iridium­elek­trisch­motors­ge­brauch­licht­als­sein­ur­sprung­von­kraft­ge­start­sein­lange­fahrt­hin­zwischen­stern­artig­raum­auf­der­suchen­nach­bar­schaft­der­stern­welche­ge­habt­be­wohn­bar­planeten­kreise­drehen­sich­und­wo­hin­der­neue­rasse­von­ver­stand­ig­mensch­lich­keit­konnte­fort­pflanzen­und­sicher­freuen­an­lebens­lang­lich­freude­und­ru­he­mit­nicht­ein­furcht­vor­an­greifen­vor­anderer­intelligent­ge­schopfs­von­hin­zwischen­stern­art­ig­raum",
    "bio": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.",
    "birth_date": "2029-01-01",
    "weight": "-100",
    "height": "-100",
    "unit": "FFF",
    "is_private": "depends"
}

Output

{
    "first_name": [
        "Ensure this field has no more than 30 characters."
    ],
    "last_name": [
        "Ensure this field has no more than 150 characters."
    ],
    "bio": [
        "Ensure this field has no more than 200 characters."
    ],
    "birth_date": [
        "Date of birth cannot be in the future."
    ],
    "weight": [
        "Weight cannot be negative."
    ],
    "height": [
        "Height cannot be negative."
    ],
    "unit": [
        "\"FFF\" is not a valid choice."
    ],
    "is_private": [
        "Must be a valid boolean."
    ]
}

Lastly, I run a conditional check on the is_private field to see if I need to hide any sensitive user data from the public. Later on, the same field wil be used to determine whether to hide the user’s followers and following lists, user’s achievements as well as the user’s exercise logs. For now, I will hide the email, first_name, last_name, last_login, date_joined, birth_date, weight, height and unit fields if the account is set to private mode. To hide the data, I simply return a different represenation of the serialized field by overriding the default to_representation method. In my code below, I return a dictionary comprehension which loops through the sensitive field list and excludes them from the original serialization. However, I shouldn’t be hiding these information if the API request is made by the owner of the profile, so I added an additional check to exclude those cases.

class UserProfileSerializer(serializers.ModelSerializer):

    ...

    # Exclude sensitive data to other users if is_private=True
    def to_representation(self, instance):
        ret = super().to_representation(instance)
        if self.context['request'].user != self.instance.user and ret.get('is_private'):
            sensitive_fields = ['email', 'first_name', 'last_name',
                                'last_login', 'date_joined', 'birth_date', 'weight', 'height', 'unit']
            return {key: ret[key] for key in ret if key not in sensitive_fields}

        return ret

Create an API View

The last step is to provide an access point for users to communicate to the backend. POST API is not needed because the UserProfile should only be instantiated when a new user account is created via the signal dispatcher. The GET API is required to allow users to view user profiles. But I don’t want to give everyone an access to view everyone else’s profile! Only those who have been authenticated through the login system should be able to view other users. And not all other users but only the ones who have been successfully activated through the email confirmation and is recognized as active users. The PUT API is also required to allow users to update their own profile. Similarily, I should only let the owner of the profile to be able to update their own profile and nobody else’s. These permission rules can be enforced by the IsOwnerOrReadOnly permission which is defined as below. Finally, the DELETE API is required to delete the profile and close the user accounts completely. Since deleting an account is a very serious action, I will handle this in a separate API that requires email confirmation.

class IsOwnerOrReadOnly(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 requests.
        if request.method in permissions.SAFE_METHODS:
            return True

        # Instance must have an attribute named `owner`.
        return obj.owner == request.user

In the urls.py under the user module, I included the path to the API as path('<str:user__username>/', UserProfileAPIView.as_view(), name='profile-detail'). I use the username as my lookup_field in the url instead of the user’s id because I prefer working with human readable APIs.

class UserProfileAPIView(generics.RetrieveUpdateAPIView):
    permission_classes = [permissions.IsAuthenticated, IsOwnerOrReadOnly]
    serializer_class = UserProfileSerializer
    lookup_field = 'user__username'

    def get_queryset(self):
        return UserProfile.objects.filter(user__is_active=True)

    def put(self, request, *args, **kwargs):
        return self.update(request, *args, **kwargs)

There are still some additional work that needs to be done. For example, I am planning on building an email change API that sends email confirmation before updating the email address. I think user delete requests should be handled in the same way as well.

Django has been surprisingly fun to work with despite the initial learning curve. I just wish I had more time to code… yaaaaaawn

Good night 🌙

< Previous Post Next Post >

Comments