Home > database >  Validate parent model foreign key by child class in Django
Validate parent model foreign key by child class in Django

Time:05-04

Let's say I have the following parent models in my Django application:

class Location(models.Model):
    name = models.CharField(max_length=100)

class Exit(models.Model):
    location = models.ForeignKey(Location, on_delete=models.CASCADE, related_name="exits")
    closed = models.BooleanField()

And two pairs of corresponding child models:

class Submarine(Location):
    size = models.FloatField()


class Hatch(Exit):
    diameter = models.FloatField()
class House(Location):
    height = models.FloatField()


class Door(Exit):
    width = models.FloatField()
    height = models.FloatField()

In this setup it is possible for a House to have a Hatch as one of its Exits, as well as for a Submarine to have a Door. Is there a way to explicitly prevent this from happening? Ideally, I would like an exception to be thrown on attempt to set an invalid foreign key.

Moving the location field from Exit to Hatch and Door is not an option, because I want to be able to use constructions like the following:

open_locations = Location.objects.filter(exits__closed=False)

and avoid duplication (i.e. writing separate functions for Houses and Submarines).

Maybe the limit_choices_to constraint could be helpful, but I didn't manage to figure out how to apply it here.

CodePudding user response:

You could use a CheckConstraint:

# models.py
from django.db import models

class Location(models.Model):
    name = models.CharField(max_length=100)

class Exit(models.Model):
    location = models.ForeignKey(Location, on_delete=models.CASCADE, related_name="exits")
    closed = models.BooleanField()

class Submarine(Location):
    size = models.FloatField()


class Hatch(Exit):
    diameter = models.FloatField()
    
    class Meta:
        constraints = [
            CheckConstraint(
                check=Q(location__submarine__is_null=False),
                name='hatches_must_be_submarine_exits'
            ),
        ]


class House(Location):
    height = models.FloatField()


class Door(Exit):
    width = models.FloatField()
    height = models.FloatField()

    class Meta:
        constraints = [
            CheckConstraint(
                check=Q(location__house__is_null=False),
                name='doors_must_be_house_exits'
            ),
        ]

EDIT: A more DRY way would be to do the validation at python level, rather than database level. You could use one clean-method like this:

# models.py
from django.db import models
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError


class Location(models.Model):
    name = models.CharField(max_length=100)

class Exit(models.Model):
    location = models.ForeignKey(Location, on_delete=models.CASCADE, related_name="exits")
    location_type = ContentType.objects.get_for_model(Location)
    closed = models.BooleanField()

    def clean(self):
        if self.location is not None:
            actual_type = ContentType.objects.get_for_model(self.location.__class__)
            expected_type = self.__class__.location_type
            if (
                actual_type
                is not expected_type
            ):
                raise ValidationError(
                    message=f'location must be a {expected_type.name}, not a {actual_type.name}'
                )

    

class Submarine(Location):
    size = models.FloatField()


class Hatch(Exit):
    location_type = ContentType.objects.get_for_model(Submarine)
    diameter = models.FloatField()


class House(Location):
    height = models.FloatField()


class Door(Exit):
    location_type = ContentType.objects.get_for_model(House)
    width = models.FloatField()
    height = models.FloatField()

Additionally, you can limit the displayed choices, for example, when instantiating a Form:

from django import forms

from my_app import models as my_models

class ExitForm(forms.ModelForm):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        loc_model = self._meta.model.location_type.model_class()
        self.fields['location'].choices = loc_model.objects.values_list('location__pk', 'name')


    class Meta:
        model = my_models.Exit
        fields = '__all__'

CodePudding user response:

Django is unfortunately not great with inheritance, because every child still needs its own database table. So even though you can make this work, it's not gonna look nice and won't help you down the road either. Easiest and most Djangesque way would be

class Location(models.Model):
    name = models.CharField(max_length=100)

    class Meta:
        abstract = True

class Exit(models.Model):
    closed = models.BooleanField()

    class Meta:
        abstract = True

class Submarine(Location):
    size = models.FloatField()

class Hatch(Exit):
    diameter = models.FloatField()
    location = models.ForeignKey(Submarine, on_delete=models.CASCADE, related_name="exits")

class House(Location):
    height = models.FloatField()

class Door(Exit):
    width = models.FloatField()
    height = models.FloatField()
    location = models.ForeignKey(House, on_delete=models.CASCADE, related_name="exits")

I've added the Meta with abstract = True because my intuition is you won't wanna have any plain Location and Exit objects in the database, but I might be wrong; Meta.abstract tells Django you don't need DB tables for the abstract parent models. The repeated Location line is unfortunate, but if there are many such models, you are better off using a factory than inheritance.

That would look like this:

class Exit(models.Model):
    closed = models.BooleanField()

    class Meta:
        abstract = True
    
    def location_field_factory(exit_type):
        assert isinstance(exit_type, Exit)
        return models.ForeignKey(exit_type, on_delete=models.CASCADE, related_name="exits")

class Barrel(Location):
    diameter = models.FloatField()
    height = models.FloatField()

class Lid(Exit):
    diameter = models.FloatField()
    location = Exit.location_field_factory(Barrel)
  • Related