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 Exit
s, 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 House
s and Submarine
s).
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)