Django-Simple-History:避免冗余历史记录并跟踪通用关系
问题描述
我们正在使用django-simple-history来跟踪模型中的变更,包括多对多(m2m)关系和通用关系。
Model 代码
class BandProfile(BaseModel): class Meta: db_table = "band_profiles" name = models.CharField(max_length=255) types = models.ManyToManyField( Choices, related_name="types", limit_choices_to={"category": "type"}, blank=True, ) description = models.TextField(null=True, blank=True) history = HistoricalRecords( table_name="history_band_profiles", history_user_setter=set_historical_user_from_request, history_user_getter=get_historical_user_from_request, history_user_id_field=models.CharField(max_length=36, null=True), m2m_fields=[types], # Many-to-many tracking ) def __str__(self): return f"Band Profile for {self.name}"
Serializer 代码(create方法)
def create(self, validated_data): """ Create method for BandProfile with handling for related fields. """ types = validated_data.pop("type_ids", []) band_profile = BandProfile.objects.create(**validated_data) band_profile.types.set(types)
遇到的问题
在创建或更新资源时,django-simple-history会生成多条冗余的历史记录,导致历史跟踪信息不正确。
期望的响应格式
我们需要结构化的响应,包含资源的历史记录、其多对多关系和通用关系的历史,示例如下:
[ { "id": 101, "name": "Resource A", "history_date": "2025-03-20T10:00:00Z", "change_type": "update", "types": [ { "id": 201, "name": "Sector X", "history_date": "2025-03-19T15:00:00Z", "change_type": "create" }, { "id": 202, "name": "Sector Y", "history_date": "2025-03-20T16:00:00Z", "change_type": "update" } ], "generic_relation": [ { "id": 301, "type": "Document", "title": "Policy Document A", "history_date": "2025-03-18T11:00:00Z", "change_type": "create" }, { "id": 302, "type": "Document", "title": "Policy Document B", "history_date": "2025-03-20T14:00:00Z", "change_type": "delete" } ] } ]
我们的疑问
如何在创建和更新多对多关系时避免冗余的历史记录?我们的配置是否有遗漏?
备注:我们尝试过多对多关系使用through模型,但历史记录无法维护主模型和关联对象之间的链接。
解决方案
咱们先拆解问题:冗余记录的根源和通用关系跟踪的实现,分别给你实用的解决思路:
一、解决冗余历史记录的问题
你现在遇到的多条记录,核心原因是:创建主对象和设置m2m字段是两个独立的操作,django-simple-history会分别为每个操作生成历史条目。比如你先create主对象(触发第一条记录),再调用types.set()(触发第二条记录,因为你配置了m2m_fields)。下面是具体的修复方法:
1. 用原子事务+临时禁用自动记录,手动生成统一历史条目
把主对象创建和m2m关联的操作放在同一个原子事务里,临时禁用自动历史记录,完成所有操作后手动生成一条包含所有初始状态的历史记录。这样就能确保只生成一条记录,包含主对象和m2m的完整初始状态。
修改你的Serializer的create方法:
from django.db import transaction from simple_history.utils import disable_history def create(self, validated_data): """ Create method for BandProfile with single history entry for all changes. """ type_ids = validated_data.pop("type_ids", []) request = self.context.get("request") with transaction.atomic(), disable_history(BandProfile): # 在禁用历史的上下文里执行所有操作,不会触发自动记录 band_profile = BandProfile.objects.create(**validated_data) band_profile.types.set(type_ids) # 手动创建一条包含完整状态的历史记录 historical_entry = band_profile.history.create( history_type="+", # "+"表示创建操作,更新用"~",删除用"-" history_user_id=get_historical_user_from_request(request) if request else None, # 其他历史字段会自动从当前对象同步 ) # 如果你的自定义setter需要额外处理,这里可以调用set_historical_user_from_request set_historical_user_from_request(historical_entry, request) return band_profile
同样,更新操作也可以用这个思路:把主对象更新和m2m变更放在disable_history上下文里,之后手动生成一条更新的历史记录。
2. 检查自定义历史用户配置是否重复触发
你的HistoricalRecords配置里用了自定义的history_user_setter和getter,要确保这些函数不会在m2m变更时重复执行。比如,当调用types.set()时,是否会再次触发set_historical_user_from_request,导致生成额外的记录?可以在这些函数里加日志排查,或者确保它们只在主对象的save()操作时执行。
3. 关于through模型的正确用法(可选)
如果你一定要用through模型,需要给through模型也添加HistoricalRecords,并且关联到主模型的历史记录。比如:
class BandProfileTypeThrough(models.Model): band_profile = models.ForeignKey(BandProfile, on_delete=models.CASCADE) type = models.ForeignKey(Choices, on_delete=models.CASCADE) history = HistoricalRecords(table_name="history_band_profile_types") # 然后在BandProfile的m2m配置里指定through: types = models.ManyToManyField( Choices, related_name="types", limit_choices_to={"category": "type"}, blank=True, through=BandProfileTypeThrough, )
不过这种方式会生成through模型的历史记录,需要额外处理主模型和through模型的历史关联,不如第一种方法直接。
二、实现通用关系的历史跟踪
django-simple-history默认不支持直接跟踪GenericForeignKey,需要我们手动处理,核心思路是:给通用关系指向的所有模型添加历史记录,然后在序列化主模型的历史数据时,嵌套查询对应的通用对象历史。
1. 给通用模型添加历史记录
比如,如果你的通用关系指向Document模型,给它加上HistoricalRecords:
class Document(BaseModel): title = models.CharField(max_length=255) # 其他字段... history = HistoricalRecords(table_name="history_documents")
2. 自定义历史序列化器,嵌套通用关系的历史
创建一个主模型的历史序列化器,在里面手动查询对应时间点的通用对象历史,嵌套返回,符合你期望的格式:
from simple_history.models import HistoricalRecords class HistoricalBandProfileSerializer(serializers.ModelSerializer): types = serializers.SerializerMethodField() generic_relation = serializers.SerializerMethodField() class Meta: model = BandProfile.history.model fields = ["id", "name", "history_date", "change_type", "types", "generic_relation"] def get_types(self, historical_obj): # 获取该历史记录对应的types关联的当前历史状态 # 这里假设Choices模型也有HistoricalRecords return HistoricalChoicesSerializer( historical_obj.instance.types.all(), many=True ).data def get_generic_relation(self, historical_obj): # 获取关联的通用对象在该历史时间点的状态 generic_obj = historical_obj.instance.generic_obj # 你的通用关系字段名 if not generic_obj: return [] # 获取该通用对象在历史记录生成时间点的快照 generic_history = generic_obj.history.as_of(historical_obj.history_date) # 用对应的历史序列化器返回数据 return HistoricalDocumentSerializer(generic_history).data
这样,当你返回主模型的历史数据时,就能看到嵌套的m2m和通用关系的历史记录了。
三、额外的注意事项
- 所有操作尽量放在原子事务里,避免部分操作失败导致历史记录不一致
- 手动生成历史记录时,要确保
history_user和其他自定义字段的设置和自动生成的逻辑一致 - 测试更新操作时,同样要把主对象字段更新和m2m变更放在同一个
disable_history上下文里,避免多条记录
备注:内容来源于stack exchange,提问作者user30040602




