Creating custom model fields

Django comes with a complete collection of model fields that you can use to build your models. However, you can also create your own model fields to store custom data or alter the behavior of existing fields.

We need a field that allows us to specify an order for objects. If you think about an easy way to do this with a field provided by Django, you will probably think of adding a PositiveIntegerField to your models. This is a good starting point. We can create a custom field that inherits from PositiveIntegerField and provides additional behavior.

There are two relevant functionalities that we will build into our order field:

  • Automatically assign an order value when no specific order is provided. When no order is provided while storing an object, our field should automatically assign the next order based on the last existing ordered object. If there are two objects with order 1 and 2 respectively, when saving a third object, we should automatically assign the order 3 to it if no specific order is given.
  • Order objects with respect to other fields. Course modules will be ordered with respect to the course they belong to and module contents with respect to the module they belong to.

Create a new fields.py file inside the courses application directory and add the following code to it:

from django.db import models
from django.core.exceptions import ObjectDoesNotExist

class OrderField(models.PositiveIntegerField):

    def __init__(self, for_fields=None, *args, **kwargs):
        self.for_fields = for_fields
        super(OrderField, self).__init__(*args, **kwargs)

    def pre_save(self, model_instance, add):
        if getattr(model_instance, self.attname) is None:
            # no current value
            try:
                qs = self.model.objects.all()
                if self.for_fields:
                    # filter by objects with the same field values 
                    # for the fields in "for_fields"
                    query = {field: getattr(model_instance, field) for field in self.for_fields}
                    qs = qs.filter(**query)
                # get the order of the last item
                last_item = qs.latest(self.attname)
                value = last_item.order + 1
            except ObjectDoesNotExist:
                value = 0
            setattr(model_instance, self.attname, value)
            return value
        else:
            return super(OrderField, 
                         self).pre_save(model_instance, add)

This is our custom OrderField. It inherits from the PositiveIntegerField field provided by Django. Our OrderField field takes an optional for_fields parameter that allows us to indicate the fields that the order has to be calculated with respect to.

Our field overrides the pre_save() method of the PositiveIntegerField field, which is executed before saving the field into the database. In this method, we perform the following actions:

  1. We check if a value already exists for this field in the model instance. We use self.attname, which is the attribute name given to the field in the model. If the attribute's value is different than None, we calculate the order we should give it as follows:
    1. We build a Queryset to retrieve all objects for the field's model. We retrieve the model class the field belongs to by accessing self.model.
    2. We filter the QuerySet by the fields' current value for the model fields that are defined in the for_fields parameter of the field, if any. By doing so, we calculate the order with respect to the given fields.
    3. We retrieve the object with the highest order with last_item = qs.latest(self.attname) from the database. If no object is found, we assume this object is the first one and assign the order 0 to it.
    4. If an object is found, we add 1 to the highest order found.
    5. We assign the calculated order to the field's value in the model instance using setattr()and return it.
  2. If the model instance has a value for the current field, we don't do anything.

Note

When you create custom model fields, make them generic. Avoid hardcoding data that depends on a specific model or field. Your field should work in any model.

You can find more information about writing custom model fields at https://docs.djangoproject.com/en/1.8/howto/custom-model-fields/.

Let's add the new field to our models. Edit the models.py file of the courses application and import the new field as follows:

from .fields import OrderField

Then, add the following OrderField field to the Module model:

order = OrderField(blank=True, for_fields=['course'])

We name the new field order, and we specify that the ordering is calculated with respect to the course by setting for_fields=['course']. This means that the order for a new module will be assigned adding 1 to the last module of the same Course object. Now you can edit the __str__() method of the Module model to include its order as follows:

def __str__(self):
    return '{}. {}'.format(self.order, self.title)

Module contents also need to follow a particular order. Add an OrderField field to the Content model as follows:

order = OrderField(blank=True, for_fields=['module'])

This time, we specify that the order is calculated with respect to the module field.
Finally, let's add a default ordering for both models. Add the following Meta class to the Module and Content models:

class Meta:
    ordering = ['order']

The Module and Content models should now look as follows:

class Module(models.Model):
    course = models.ForeignKey(Course, related_name='modules')
    title = models.CharField(max_length=200)
    description = models.TextField(blank=True)
    order = OrderField(blank=True, for_fields=['course'])

    class Meta:
        ordering = ['order']

    def __str__(self):
        return '{}. {}'.format(self.order, self.title)


class Content(models.Model):
    module = models.ForeignKey(Module, related_name='contents')
    content_type = models.ForeignKey(ContentType,
                    limit_choices_to={'model__in':('text',
                                                      'video',
                                                   'file')})
    object_id = models.PositiveIntegerField()
    item = GenericForeignKey('content_type', 'object_id')
    order = OrderField(blank=True, for_fields=['module'])

    class Meta:
        ordering = ['order']

Let's create a new model migration that reflects the new order fields. Open the shell and run the following command:

python manage.py makemigrations courses

You will see the following output:

You are trying to add a non-nullable field 'order' to content without a default; we can't do that (the database needs something to populate existing rows).
Please select a fix:
 1) Provide a one-off default now (will be set on all existing rows)
 2) Quit, and let me add a default in models.py
Select an option:

Django is telling us that since we added a new field for an existing model, we have to provide a default value for existing rows in the database. If the field had null=True, it would accept null values and Django would create the migration without asking for a default value. We can specify a default value or cancel the migration and add a default attribute to the order field in the models.py file before creating the migration.

Enter 1 and press Enter to provide a default value for existing records. You will see the following output:

Please enter the default value now, as valid Python
The datetime and django.utils.timezone modules are available, so you can do e.g. timezone.now()
>>> 

Enter 0 so that this is the default value for existing records and press Enter. Django will ask you for a default value for the Module model too. Choose the first option and enter 0 as default value again. Finally, you will see an output similar to the following one:

Migrations for 'courses':
  0003_auto_20150701_1851.py:
    - Change Meta options on content
    - Change Meta options on module
    - Add field order to content
    - Add field order to module

Then, apply the new migrations with the following command:

python manage.py migrate

The output of the command will inform you that the migration was successfully applied as follows:

Applying courses.0003_auto_20150701_1851... OK

Let's test our new field. Open the shell using python manage.py shell and create a new course as follows:

>>> from django.contrib.auth.models import User
>>> from courses.models import Subject, Course, Module
>>> user = User.objects.latest('id')
>>> subject = Subject.objects.latest('id')
>>> c1 = Course.objects.create(subject=subject, owner=user, title='Course 1', slug='course1')

We have created a course in the database. Now, let's add modules to the course and see how the modules' order is automatically calculated. We create an initial module and check its order:

>>> m1 = Module.objects.create(course=c1, title='Module 1')
>>> m1.order
0

OrderField sets its value to 0, since this is the first Module object created for the given course. Now we create a second module for the same course:

>>> m2 = Module.objects.create(course=c1, title='Module 2')
>>> m2.order
1

OrderField calculates the next order value adding 1 to the highest order for existing objects. Let's create a third module forcing a specific order:

>>> m3 = Module.objects.create(course=c1, title='Module 3', order=5)
>>> m3.order
5

If we specify a custom order, the OrderField field does not interfere and the value given to the order is used.

Let's add a fourth module:

>>> m4 = Module.objects.create(course=c1, title='Module 4')
>>> m4.order
6

The order for this module has been automatically set. Our OrderField field does not guarantee that all order values are consecutive. However, it respects existing order values and always assigns the next order based on the highest existing order.

Let's create a second course and add a module to it:

>>> c2 = Course.objects.create(subject=subject, title='Course 2', slug='course2', owner=user)
>>> m5 = Module.objects.create(course=c2, title='Module 1')
>>> m5.order
0

To calculate the new module's order, the field only takes into consideration existing modules that belong to the same course. Since this is the first module of the second course, the resulting order is 0. This is because we specified for_fields=['course'] in the order field of the Module model.

Congratulations! You have successfully created your first custom model field.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset