在 Django 中通过子 class 验证父模型外键

Validate parent model foreign key by child class in Django

假设我的 Django 应用程序中有以下父模型:

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 House(Location):
    height = models.FloatField()


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

在此设置中,House 可以将 Hatch 作为其 Exit 之一,并且 Submarine 可以将Door。有没有办法明确防止这种情况发生?理想情况下,我希望在尝试设置无效外键时抛出异常。

location 字段从 Exit 移动到 HatchDoor 不是一个选项,因为我希望能够使用如下结构:

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

并避免重复(即为 HouseSubmarine 编写单独的函数)。

也许 limit_choices_to 约束可能有用,但我没弄清楚如何在此处应用它。

您可以使用 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'
            ),
        ]

编辑: 一种更 DRY 的方法是在 python 级别而不是数据库级别进行验证。您可以像这样使用一个 clean-method:

# 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()

此外,您还可以限制显示的选项,例如,在实例化 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__'

不幸的是,Django 在继承方面不是很好,因为每个 child 仍然需要自己的数据库 table。所以即使你可以完成这项工作,它看起来也不会很好,也不会帮助你走下去。最简单和最 Djangesque 的方式是

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")

我添加了 Metaabstract = True 因为我的直觉是你不会想要任何普通的 LocationExit objects数据库,但我可能错了; Meta.abstract 告诉 Django 你不需要 DB tables 用于抽象 parent 模型。重复的 Location 行很不幸,但如果有很多这样的模型,你最好使用工厂而不是继承。

看起来像这样:

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)