Django Model 如何引用多个 Foreign Key 的子属性
结论
ForeignKey 的目标必须是 Model 类或模型名称,不能是 Customer.payments.payment_mode 这样的反向关系属性。
Customer.payments 是反向关系管理器,表示与该客户关联的多个 Payment 对象,并不是某个具体的 Payment 实例,因此不能直接访问 payment_mode。
可以让 Invoice 直接关联选中的 Payment:
class Invoice(models.Model):
customer = models.ForeignKey(
Customer,
on_delete=models.RESTRICT,
related_name='invoices',
)
payment = models.ForeignKey(
Payment,
on_delete=models.RESTRICT,
related_name='invoices',
)
付款方式可通过下面的写法取得:
invoice.payment.payment_mode
为什么会出现这个错误
在下面的定义中:
class Payment(models.Model):
customer = models.ForeignKey(
Customer,
on_delete=models.RESTRICT,
related_name='payments',
)
related_name='payments' 会在 Customer 上创建反向关系。对于某个具体客户,可以这样查询其付款记录:
customer.payments.all()
但如果从模型类访问:
Customer.payments
得到的是 ReverseManyToOneDescriptor。它描述的是“一名客户对应多个 Payment”的关系,并不是一条具体的付款记录。因此,下面的写法无效:
Customer.payments.payment_mode
原来的两个外键都使用了 related_name='payments',还会造成反向关系名称混乱。发票关系应使用单独的名称,例如 invoices。
推荐的模型设计
如果一张发票需要同时保存客户和所选的付款记录,可以这样定义:
from django.core.exceptions import ValidationError
from django.db import models
class Customer(models.Model):
# some customer details
pass
class Payment(models.Model):
customer = models.ForeignKey(
Customer,
on_delete=models.RESTRICT,
related_name='payments',
)
payment_mode = models.CharField(
max_length=255,
blank=True,
null=True,
)
def __str__(self):
return self.payment_mode or f'Payment #{self.pk}'
class Invoice(models.Model):
customer = models.ForeignKey(
Customer,
on_delete=models.RESTRICT,
related_name='invoices',
)
payment = models.ForeignKey(
Payment,
on_delete=models.RESTRICT,
related_name='invoices',
)
def clean(self):
super().clean()
if (
self.customer_id
and self.payment_id
and self.payment.customer_id != self.customer_id
):
raise ValidationError({
'payment': '所选付款方式不属于当前客户。',
})
创建发票时:
customer = Customer.objects.get(pk=1)
payment = customer.payments.get(pk=3)
invoice = Invoice(
customer=customer,
payment=payment,
)
invoice.full_clean()
invoice.save()
读取付款方式:
invoice.payment.payment_mode
跨关系查询可以这样写:
Invoice.objects.filter(
customer=customer,
payment__payment_mode='cash',
)
在表单中只显示当前客户的付款方式
模型中的 ForeignKey 不会根据另一个字段自动过滤选项。使用 ModelForm 时,可以按当前客户限制 payment 字段的 queryset:
from django import forms
class InvoiceForm(forms.ModelForm):
class Meta:
model = Invoice
fields = ['customer', 'payment']
def __init__(self, *args, customer=None, **kwargs):
super().__init__(*args, **kwargs)
if customer is not None:
self.fields['customer'].initial = customer
self.fields['payment'].queryset = customer.payments.all()
elif self.instance.pk and self.instance.customer_id:
self.fields['payment'].queryset = self.instance.customer.payments.all()
else:
self.fields['payment'].queryset = Payment.objects.none()
调用表单:
form = InvoiceForm(customer=customer)
服务端仍要保留 clean() 中的校验。过滤表单选项只能改善界面体验,无法阻止用户绕过表单提交不匹配的数据。
可以进一步简化关系
如果发票关联的 Payment 一定能唯一确定客户,那么 Invoice.customer 就属于重复数据,可以只保存 payment:
class Invoice(models.Model):
payment = models.ForeignKey(
Payment,
on_delete=models.RESTRICT,
related_name='invoices',
)
@property
def customer(self):
return self.payment.customer
此时可以直接使用:
invoice.customer
invoice.payment.payment_mode
这种结构不会出现“发票客户与付款记录所属客户不一致”的情况。查询某个客户的发票时,可以写成:
Invoice.objects.filter(payment__customer=customer)
是否保留独立的 customer 字段,要看具体业务需求。如果客户只是从 Payment 推导出来,通常不需要重复保存。如果发票必须单独记录客户,则可以保留两个外键,但要校验两者是否一致。
如果发票还需要保留开票时的付款方式文本,并且后续修改 Payment.payment_mode 不应影响历史发票,可以增加 payment_mode_snapshot 这样的快照字段,在创建发票时复制当时的值。
备注:内容仅供参考。
