Django: parent model with several types of child models

advertisements

I've created a set of Django model for a CMS to show a series of Products.

Each page contains a series of rows, so I have a generic

class ProductRow(models.Model):
  slug = models.SlugField(max_length=100, null=False, blank=False, unique=True, primary_key=True)
  name = models.CharField(max_length=200,null=False,blank=False,unique=True)
  active = models.BooleanField(default=True, null=False, blank=False)

then I have a series of children of this model, for different types of row:

class ProductBanner(ProductRow):
  wide_image = models.ImageField(upload_to='product_images/banners/', max_length=100, null=False, blank=False)
  top_heading_text = models.CharField(max_length=100, null=False, blank=False)
  main_heading_text = models.CharField(max_length=200, null=False, blank=False)
  ...

class ProductMagazineRow(ProductRow):
  title = models.CharField(max_length=50, null=False, blank=False)
  show_descriptions = models.BooleanField(null=False, blank=False, default=False)
  panel_1_product = models.ForeignKey(Product, related_name='+', null=False, blank=False)
  panel_2_product = models.ForeignKey(Product, related_name='+', null=False, blank=False)
  panel_3_product = models.ForeignKey(Product, related_name='+', null=False, blank=False)
  ...

class ProductTextGridRow(ProductRow):
  title = models.CharField(max_length=50, null=False, blank=False)
  col1_title = models.CharField(max_length=50, null=False, blank=False)
  col1_product_1 = models.ForeignKey(Product, related_name='+', null=False, blank=False)
  col1_product_2 = models.ForeignKey(Product, related_name='+', null=False, blank=False)
  col1_product_3 = models.ForeignKey(Product, related_name='+', null=False, blank=False)
  ...

and so on.

Then in my ProductPage I have a series of ProductRows:

class ProductPage(models.Model):
  slug = models.SlugField(max_length=100, null=False, blank=False, unique=True, primary_key=True)
  name = models.CharField(max_length=200, null=False, blank=False, unique=True)
  title = models.CharField(max_length=80, null=False, blank=False)
  description = models.CharField(max_length=80, null=False, blank=False)
  row_1 = models.ForeignKey(ProductRow, related_name='+', null=False, blank=False)
  row_2 = models.ForeignKey(ProductRow, related_name='+', null=True, blank=True)
  row_3 = models.ForeignKey(ProductRow, related_name='+', null=True, blank=True)
  row_4 = models.ForeignKey(ProductRow, related_name='+', null=True, blank=True)
  row_5 = models.ForeignKey(ProductRow, related_name='+', null=True, blank=True)

The problem I have got, is that I want to allow those 5 rows in the ProductPage to be any of the different child types of ProductRow. However when I iterate over them such as

in views.py:

product_page_rows = [product_page.row_1,product_page.row_2,product_page.row_3,product_page.row_4,product_page.row_5]

and then in the template:

{% for row in product_page_rows %}
  <pre>{{ row.XXXX }}</pre>
{% endfor %}

I cannot reference any child field as XXXX.

I tried adding a "type()" method to both the parent and children, to try and distinguish which class each row is:

class ProductRow(models.Model):

  ...

  @classmethod
  def type(cls):
      return "generic"

and

class ProductTextGridRow(TourRow):

  ...

  @classmethod
  def type(cls):
      return "text-grid"

but if i change XXXX for .type() in the template then it shows "generic" for every item in the list (I had defined a variety of row types in the data), so I guess everything is coming back as a ProductRow rather than the appropriate child type. I can find no way to get the children to be accessible as the correct child type rather than the parent type, or to determine which child type they actually are (I tried catching AttributeError as well, that didn't help).

Can someone advise how I can properly handle a list of varied model types all of which contain a common parent, and be able to access the fields of the appropriate child model type?


This is generally (read "always") a bad design to have something like this:

class MyModel(models.Model):
    ...
    row_1 = models.ForeignKey(...)
    row_2 = models.ForeignKey(...)
    row_3 = models.ForeignKey(...)
    row_4 = models.ForeignKey(...)
    row_5 = models.ForeignKey(...)

It is not scalable. If ever you want to allow 6 rows or 4 rows instead of 5, one day (who knows?), you will have to add/delete a new row and alter your database scheme (and handle existing objects that had 5 rows). And it's not DRY, your amount of code depends on the number of rows you handle and it involves a lot of copy-pasting.

This become clear that it is a bad design if you wonder how you would do it if you had to handle 100 rows instead of 5.

You have to use a ManyToManyField() and some custom logic to ensure there is at least one row, and at most five rows.

class ProductPage(models.Model):
    ...
    rows = models.ManyToManyField(ProductRow)

If you want your rows to be ordered, you can use an explicit intermediate model like this:

class ProductPageRow(models.Model):

    class Meta:
        order_with_respect_to = 'page'

    row = models.ForeignKey(ProductRow)
    page = models.ForeignKey(ProductPage)

class ProductPage(models.Model):
    ...
    rows = model.ManyToManyField(ProductRow, through=ProductPageRow)

I want to allow only N rows (let's say 5), you could implement your own order_with_respect_to logic:

from django.core.validators import MaxValueValidator

class ProductPageRow(models.Model):

    class Meta:
        unique_together = ('row', 'page', 'ordering')

    MAX_ROWS = 5

    row = models.ForeignKey(ProductRow)
    page = models.ForeignKey(ProductPage)
    ordering = models.PositiveSmallIntegerField(
        validators=[
            MaxValueValidator(MAX_ROWS - 1),
        ],
    )

The tuple ('row', 'page', 'ordering') uniqueness being enforced, and ordering being limited to five values (from 0 to 4), there can't be more than 5 occurrences of the couple ('row', 'page').

However, unless you have a very good reason to make 100% sure that there is no way to add more than N rows in the database by any mean (including direct SQL query input on your DBMS console), there is no need to "lock" it a this level.

It is very likely that all "untrusted" user will only be able to update your database through HTML form inputs. And you can use formsets to force both a minimum and a maximum number of rows when filling a form.

Note: This also applies to your other models. Any bunch of fields named foobar_N, where N is an incrementing integer, betrays a very bad database design.


Yet, this does not fix your issue.

The easiest (read "the first that comes to mind") way to get your child model instance back from the parent model instance is to loop over each possible child model until you get an instance that matches.

class ProductRow(models.Model):
    ...
    def get_actual_instance(self):
        if type(self) != ProductRow:
            # If it's not a ProductRow, its a child
            return self
        attr_name = '{}_ptr'.format(ProductRow._meta.model_name)
        for possible_class in self.__subclasses__():
            field_name = possible_class._meta.get_field(attr_name).related_query_name()
            try:
                return getattr(self, field_name)
            except possible_class.DoesNotExist:
                pass
         # If no child found, it was a ProductRow
         return self

But it involves to hit the database for each try. And it is still not very DRY. The most efficient way to get it is to add a field that will tell you the type of the child:

from django.contrib.contenttypes.models import ContentType

class ProductRow(models.Model):
    ...
    actual_type = models.ForeignKey(ContentType, editable=False)

    def save(self, *args, **kwargs):
        if self._state.adding:
            self.actual_type = ContentType.objects.get_for_model(type(self))
         super().save(*args, **kwargs)

    def get_actual_instance(self):
        my_info = (self._meta.app_label, self._meta.model_name)
        actual_info = (self.actual_type.app_label, self.actual_type.model)
        if type(self) != ProductRow or my_info == actual_info:
            # If this is already the actual instance
            return self
        # Otherwise
        attr_name = '{}_ptr_id'.format(ProductRow._meta.model_name)
        return self.actual_type.get_object_for_this_type(**{
            attr_name: self.pk,
        })