django-data-history
一个 Django 应用:自动把模型数据的变更明细记录下来,并在后台(admin)的对象历史页面中展示完整的变更信息。
- 自动记录模型的 新增 / 修改 / 删除 以及 多对多关系 的变化;
- 记录每次变更的 字段级差异(旧值 → 新值)、版本号、操作人、请求 ID、时间;
- 统一规范化 日期/时间/JSON/浮点 等字段的序列化,杜绝「无实际变化却记版本」的假记录;
- 历史数据以 加密方式 存储(基于
django-safe-fields); - 可在 admin 的对象历史页面中直观查看;
- 支持把不同模型的历史记录写到 独立的历史表 中;
- 提供 加密遗留明文 与 简化遗留历史 的管理命令,并可选配 文件字段比较/记录钩子。
一、安装
pip install django-data-history
本应用依赖以下包(安装本应用时会自动安装):
django>=3.2
django-safe-fields>=0.2.3
django-middleware-global-request>=0.3.1
django-middleware-request-id>=0.1.2
django-static-jquery-ui
zenutils
要求 Django ≥ 3.2(应用配置
AppConfig依赖 Django 3.2+ 的自动识别)。 内置zh_Hans中文翻译(django.mo随包发布),设置LANGUAGE_CODE = "zh-hans"后自动生效。
二、快速开始
1. 配置 INSTALLED_APPS
把下面几个应用加入 INSTALLED_APPS,并确保 django_data_history 排在 django.contrib.admin 之前:
INSTALLED_APPS = [
...
"django_middleware_global_request",
"django_middleware_request_id",
"django_static_jquery_ui",
"django_data_history",
"django_safe_fields", # 历史数据加密所依赖
...
"django.contrib.admin",
...
]
2. 配置 MIDDLEWARE
MIDDLEWARE = [
...
"django_middleware_global_request.middleware.GlobalRequestMiddleware",
"django_middleware_request_id.middlewares.DjangoMiddlewareRequestId",
...
]
GlobalRequestMiddleware:让应用能在信号中拿到当前请求,从而记录操作用户;DjangoMiddlewareRequestId:为每个请求生成request_id,用于串联同一次请求产生的多条记录。
3. 配置记录范围
# 默认 False。设为 True 时,自动为「所有模型」开启历史记录。
SAVE_DATA_HISTORIES_FOR_ALL = True
# 当 SAVE_DATA_HISTORIES_FOR_ALL = False 时,只有这里列出的模型会开启记录。
# 默认空列表。格式为 "app_label.model_name"。
SAVE_DATA_HISTORIES_FOR = [
"your_app1.model_name1",
]
# 当 SAVE_DATA_HISTORIES_FOR_ALL = True 时,这里列出的模型「不」记录。
# 会在默认值的基础上追加:
# [
# "sessions.session",
# "contenttypes.contenttype",
# "admin.logentry",
# "auth.permission",
# ]
DO_NOT_SAVE_DATA_HISTORIES_FOR = [
"your_app2.model_name2",
]
# 上面「默认排除的系统模型」列表可通过该设置整体覆盖(默认即 sessions/contenttypes/admin/auth)。
# SYSTEM_MODELS_DO_NOT_SAVE_DATA_HISTORIES = [...]
说明:历史记录模型自身(
DataHistoryBase的子类)不会被再次记录,避免递归。
4. 执行迁移
python manage.py migrate
完成后即可使用。默认情况下,所有被记录模型的增删改都会自动写入 django_data_history 里的 DataHistory 表。
三、核心概念
- 软引用:一条历史记录通过
(app_label, model_name, item_id)三元组关联到被记录的原始对象,没有真正的外键。因此一张历史表可以同时追踪多种模型。 - 动作
action:create(新增)、change(修改)、delete(删除)。 - 版本
version:同一个item_id的记录从1开始递增。 - 触发时机:应用通过 Django 信号自动记录:
post_save→ 记录create/change;post_delete→ 记录delete;m2m_changed→ 记录多对多关系变化;- 关联对象(外键 / 反向关系)发生变化时,其历史也会被一并记录与更新。反向关联默认只记录主键列表,可通过
django_data_history_related_fields白名单改为记录完整对象(详见「四、使用说明」)。
- 加密存储:
data、userinfo字段使用SafeTextField加密保存。
四、使用说明
完整使用案例(端到端)
下面是一个从配置到查询历史的最小可运行示例。
1)settings.py
INSTALLED_APPS = [
# ...
"django_middleware_global_request",
"django_middleware_request_id",
"django_static_jquery_ui",
"django_data_history", # 必须排在 django.contrib.admin 之前
"django_safe_fields",
# ...
"django.contrib.admin",
"myapp",
]
MIDDLEWARE = [
# ...
"django_middleware_global_request.middleware.GlobalRequestMiddleware",
"django_middleware_request_id.middlewares.DjangoMiddlewareRequestId",
# ...
]
SAVE_DATA_HISTORIES_FOR_ALL = True # 自动为所有模型开启记录
2)myapp/models.py
from django.db import models
class Tag(models.Model):
name = models.CharField(max_length=32)
class Article(models.Model):
title = models.CharField(max_length=128)
body = models.TextField(blank=True)
is_published = models.BooleanField(default=False)
tags = models.ManyToManyField(Tag, blank=True, related_name="articles")
3)myapp/admin.py —— 无需额外配置
from django.contrib import admin
from .models import Article, Tag
admin.site.register(Article)
admin.site.register(Tag)
# 开启记录后,本应用会自动把历史查看能力注入这些 ModelAdmin;
# 「历史」页面会显示每个版本的字段级差异、操作人、时间与复制按钮。
4)执行迁移并使用
python manage.py makemigrations && python manage.py migrate
from django_data_history.models import get_histories, get_deleted_instance_histories
article = Article.objects.create(title="Hello")
article.title = "Hello, world"
article.save()
article.tags.add(Tag.objects.create(name="django"))
# 查看历史(按 version 倒序)
for history in get_histories(article):
info = history.get_history_info()
print(history.version, history.action, info["changes"])
# 1 create {'title': [None, 'Hello'], 'id': [None, 1], 'tags': [None, []]}
# 2 change {'title': ['Hello', 'Hello, world']}
# 3 change {'tags': [[], [1]]}
# 对象删除后,仍可通过模型类 + 主键查询历史
pk = article.pk
article.delete()
deleted_histories = get_deleted_instance_histories(Article, pk)
历史记录的数据结构
history.get_history_info() 返回 data 字段(加密存储)解析后的字典:
{
"app_label": "myapp",
"model_name": "article",
"item_id": 1,
"add_item": "2026-01-02 03:04:05", # 记录时间(本地时间字符串)
"data": { # 本次保存后的完整字段快照
"id": 1,
"title": "Hello, world",
"body": "",
"is_published": False,
"tags": [1],
},
"changes": { # 仅本次发生变化的字段:{字段: [旧值, 新值]}
"title": ["Hello", "Hello, world"],
},
"username": "admin",
"userinfo": {"username": "admin", "name": "...", "email": "..."},
"request_id": "…",
"action": "change", # create / change / delete
"version": 2,
}
data是完整快照;changes只包含变化的字段;- 关联字段(反向关联 / 多对多)默认是主键列表;
FileField是存储相对路径。
如何查看某个对象的历史?
方式一:通过 admin
被开启记录的模型,其 admin 会自动注入历史查看能力。进入对象的「历史」页面即可看到所有版本(含字段级差异、操作人、时间等)。
方式二:通过代码
from django_data_history.models import get_histories, get_deleted_instance_histories
# 某个实例的全部历史(按版本倒序)
for history in get_histories(instance):
print(history.action, history.version, history.get_history_info())
# 某个已删除实例的历史(传入模型类和主键)
histories = get_deleted_instance_histories(MyModel, pk)
历史记录对象上的常用方法:
| 方法 | 说明 |
|---|---|
get_history_info() |
返回 data 字段解析后的字典(含 data、changes 等) |
get_userinfo() |
返回操作人信息字典 |
changes_count() |
变更字段数量 |
get_all_changes() |
全部变更(field / old_value / new_value) |
get_first_changes() |
第一条变更 |
get_more_changes() |
除第一条外的其余变更 |
userinfo_text() |
操作人信息的纯文本形式 |
userinfo_display() |
操作人信息的 HTML 表格形式 |
如何忽略某些字段的变化?
若某些字段(如自动维护的 mod_time)无需关注,可在模型上声明 django_data_history_excludes:
class TestModel(models.Model):
django_data_history_excludes = ["mod_time"]
mod_time = models.DateTimeField(auto_now=True)
被忽略的字段不会写入历史;如果一次保存只有被忽略的字段发生变化,则不会产生新的历史记录。
文件字段(FileField / ImageField)只记录路径
历史只记录文件在存储中的路径字符串(字段值),不读取、不校验、也不保存文件内容:
- 记录值 =
str(fieldfile),即upload_to之后的存储相对路径(如"files/a.txt");空文件与None都会序列化为""; - 变化判定只看路径:路径变化才产生新版本,
changes记录「旧路径 → 新路径」; - 同路径覆盖内容不会被记录:若
upload_to生成固定文件名、内容原地替换,路径不变,则历史认为无变化; - 文件本体由 Django 的存储后端(
STORAGES["default"]+upload_to)负责,本库既不复制也不删除。
因此若希望「内容变化」也能被审计,推荐让 upload_to 生成不可变路径(内容寻址或用 UUID),使路径随内容变化:
import uuid
def upload_to(instance, filename):
return f"files/{uuid.uuid4().hex}/{filename}"
class Doc(models.Model):
file = models.FileField(upload_to=upload_to)
文件字段默认就会被记录(只记路径,开销极小);若确实完全不需要,用 django_data_history_excludes = ["file"] 排除。
如何自定义文件字段的比较与保存?(业务钩子)
若希望「同路径覆盖内容」也能进入数据历史,或想把文件内容指纹落到业务自己的表里,可通过两个可选钩子实现。按模型配置(模型类属性,推荐),也支持全局设置作为兜底;同一模型两者都配置时模型类属性优先。值可以是可调用对象、点分路径字符串,或按字段名分别配置的字典({字段名: 钩子, "*": 兜底钩子}):
| 钩子 | 按模型配置(优先) | 全局兜底 | 签名 |
|---|---|---|---|
| 文件比较器 | django_data_history_file_comparator |
DATA_HISTORY_FILE_COMPARATOR |
(instance, field_name, old_value, new_value) -> bool |
| 文件记录器 | django_data_history_file_recorder |
DATA_HISTORY_FILE_RECORDER |
(instance, field_name, old_value, new_value, changed) -> None |
- 比较器:对每个
FileField调用。路径未变(old_value == new_value)时,若返回True,则强制记一次变更(用于「同路径内容变化」);路径变化时无需额外处理(本来就会记录)。 - 记录器:当一条历史记录被保存后对每个
FileField调用(DELETE动作不调用),业务可在此把「文件名/大小/哈希」等写入自己的模型。changed表示本次该文件是否被判为变化。
按模型配置示例(Doc 用业务表 DocFileHistory 记录 sha256,同路径内容变化即可被审计):
class Doc(models.Model):
django_data_history_file_comparator = "myapp.file_hooks.compare_file_content"
django_data_history_file_recorder = "myapp.file_hooks.record_file_content"
django_data_history_excludes = ["file_histories"] # 不把业务文件表的反向关联纳入历史
file = models.FileField(upload_to="docs/")
按字段分别配置(同一模型里的不同 FileField 用不同钩子;未命中的字段回退到 "*",都没有则视为未配置):
class Doc(models.Model):
django_data_history_file_comparator = {
"file": "myapp.file_hooks.compare_file_content",
"thumb": "myapp.file_hooks.compare_thumb",
"*": "myapp.file_hooks.compare_any_file",
}
file = models.FileField(upload_to="docs/")
thumb = models.ImageField(upload_to="thumbs/", null=True, blank=True)
全局兜底示例(对所有未单独配置的模型生效):
# settings.py
DATA_HISTORY_FILE_COMPARATOR = "myapp.file_hooks.compare_file_content"
DATA_HISTORY_FILE_RECORDER = "myapp.file_hooks.record_file_content"
模型属性会覆盖全局设置;模型属性为
None/未定义时才回退到全局设置。
比较器的保守原则:当没有可参照的历史记录时,路径未变应返回
False(不制造变更)。否则在「级联删除子记录」等场景会误判,进而写入一个指向已删除对象的孤儿记录。完整可运行参考实现见django_data_history_example/file_hooks.py。
为什么不在库里内置 FileDataHistoryBase / FileDataHistory 模型? 文件内容策略差异极大(对象存储 / 内容寻址 / 加密 / 保留与清理周期 / 合规要求),内置模型无法普适,还会带来迁移与升级负担,且与「历史 data 加密、文件本体由存储后端负责」的边界冲突。因此本库只提供钩子,由业务自行决定是否落库、落到哪张表;即“文件内容历史”属于业务,数据历史只负责路径变更 + 通知钩子。
如何控制关联对象(反向关联)的记录方式?
默认情况下,反向关联(如 Category.books)会被记录,但只记录主键列表(如 "books": [1, 2, 3]),与多对多字段一致。这样既能检测关联的新增/删除,又避免把整个子对象序列化进历史,显著降低查询次数与存储体积(N 个子对象由 N+1 次查询降为 1 次,体积也随之缩小)。
如需把某个反向关联记录为完整对象(包含子对象的全部字段),在关联所在的模型上声明 django_data_history_related_fields 白名单:
class Category(models.Model):
django_data_history_related_fields = ["books"]
注意:
- 只有白名单里的反向关联会以「完整对象」记录;未列入的仍是主键列表;
- 以完整对象记录时,子对象自身字段的变化也会让父对象产生新版本;而主键列表模式下,子对象字段变化不会影响父对象(只记录在子对象自己的历史里),仅新增/删除子对象会改变父对象的主键列表;
- 若想全局恢复「所有反向关联都记录完整对象」的旧行为:
# 默认 False:反向关联记录为主键列表
# True:所有反向关联都记录为完整对象(旧行为,开销较大)
DATA_HISTORY_RELATED_FULL_OBJECTS = True
django_data_history_excludes会在查询之前生效:被排除的字段(含反向关联与多对多)不会触发任何数据库查询。
该默认行为相对早期版本有变化:升级后反向关联不再以完整对象记录。注意升级副作用:旧历史里反向关联存的是「完整对象」,新版本存的是「主键列表」,形状不同会被判定为变化,因此每个对象在升级后的第一次保存可能额外产生一条
change版本(即使业务数据没有变化)。如需保持旧数据形状、避免这次形状变化,请在升级前设置DATA_HISTORY_RELATED_FULL_OBJECTS = True或使用django_data_history_related_fields白名单;也可在升级后用内置命令python manage.py simplify_data_history把旧历史原地简化为主键列表(详见「五、注意事项与限制」第 3 节)。
如何控制外键级联记录的范围?
保存一个对象时,应用会为其外键指向的对象补记历史(保证内联编辑等场景下父对象历史完整)。默认只对已开启数据历史(SAVE_DATA_HISTORIES_FOR_ALL = True 或列入 SAVE_DATA_HISTORIES_FOR)的模型级联,避免把整个关联图卷入记录。
为降低无关写入的开销,级联按需触发:
- 新建 / 删除子对象时,级联其全部外键目标(父对象的主键列表确实变化);
- 更新子对象时,只级联外键字段确实变化的目标——只改普通字段不会再触发父对象查询;外键改指时会同时更新新旧两个父对象的历史;
- 若父对象用
django_data_history_related_fields或DATA_HISTORY_RELATED_FULL_OBJECTS把该反向关联记录为完整对象,则子对象任意字段变化仍会级联父对象(因为父对象快照包含子对象字段)。
若需要恢复「对所有外键目标都级联」的旧行为:
# 默认 False:只对已开启数据历史的模型级联
# True:对所有外键目标都级联(旧行为)
DATA_HISTORY_CASCADE_FK_FOR_UNTRACKED_MODELS = True
如何把历史保存到独立的表?
给模型类添加 DATA_HISTORY_STORAGE_CLASS 属性即可。该值必须是 django_data_history.models.DataHistoryBase 的子类,可以是类对象,也可以是类的绝对点分路径字符串。
from django_data_history.models import DataHistoryBase
class MyModelHistory(DataHistoryBase):
class Meta(DataHistoryBase.Meta): # 必须继承,才能带上历史查询所需的索引
verbose_name = "我的模型变更记录"
verbose_name_plural = "我的模型变更记录"
class MyModel(models.Model):
DATA_HISTORY_STORAGE_CLASS = MyModelHistory
若不设置,则默认使用 django_data_history.models.DataHistory。
注意:历史查询(
get_data_history/get_data_histories,以及历史页)依赖DataHistoryBase.Meta中声明的索引(app_label, model_name, item_id, version)与(username)。Django 不会把抽象父类的Meta自动合并进自定义了Meta的子类, 因此子类的Meta必须写成class Meta(DataHistoryBase.Meta):,否则历史表将 缺少索引、查询退化为全表扫描。改完记得执行makemigrations与migrate。
如何自定义「操作用户信息」?
默认记录的用户信息为:
{
"username": user.username,
"name": user.last_name + user.first_name,
"email": user.email,
}
可通过 settings.GET_USER_INFORMATION 指定一个函数的绝对点分路径来覆盖:
GET_USER_INFORMATION = "your_project.utils.get_user_information"
def get_user_information(user):
return {
"username": user.username,
"name": user.get_full_name(),
"email": user.email,
}
如何手动记录一条历史?
多数场景由信号自动记录;特殊流程(绕过 save()、离线修复、外部系统同步)可手动补记:
from django_data_history.models import DataHistoryBase
DataHistoryBase.add_data_history(
instance,
DataHistoryBase.CHANGE, # CREATE / CHANGE / DELETE
username="system", # 可选:覆盖操作人
userinfo={"username": "system"}, # 可选:覆盖操作人信息
save=True, # False 只构造记录对象、不落库
ignore_unchanged_version=False, # False:即使无字段变化也记录一条
)
- 会自动使用该模型的
DATA_HISTORY_STORAGE_CLASS(未配置则用默认DataHistory); - 默认(
ignore_unchanged_version=True)在没有任何字段变化时不产生新记录。
也可在运行时为某个模型开启记录(等价于把它加入记录范围):
from django_data_history.models import save_data_histories_for
save_data_histories_for(MyModel)
如何读取最新 / 指定版本的历史?
from django_data_history.models import get_data_history_storage_class
DataHistoryModel = get_data_history_storage_class(instance)
# 最新一条(版本号最大)
latest = DataHistoryModel.get_data_history(
instance._meta.app_label, instance._meta.model_name, instance.pk
)
# 某个对象的全部历史(按 version 倒序)
all_versions = DataHistoryModel.get_data_histories(
instance._meta.app_label, instance._meta.model_name, instance.pk
)
for h in all_versions:
print(h.version, h.action, h.get_history_info()["changes"])
admin 中的历史表与自定义
- 默认历史表
DataHistory已自动注册到 admin(DataHistoryAdmin):支持按app_label/model_name/action/add_time过滤,按item_id搜索; - 业务模型的历史由应用自动注入到其
ModelAdmin,无需手写get_data_histories; - 若使用自定义历史表(
DATA_HISTORY_STORAGE_CLASS),可自行注册查看,例如:
from django.contrib import admin
from .models import MyModelHistory
admin.site.register(MyModelHistory)
字段类型与序列化约定(速查)
| 字段类型 | 记录形态 |
|---|---|
CharField / TextField / SlugField / EmailField / URLField / FilePathField / GenericIPAddressField |
字符串 |
IntegerField 及其变体 / BooleanField / DecimalField(字符串) |
原样(Decimal 形如 "1.10") |
FloatField |
数字;NaN / Infinity / -Infinity 归一为字符串,-0.0 → 0 |
DateField |
"YYYY-MM-DD" |
TimeField |
"HH:MM:SS[.ffffff]"(完整微秒) |
DateTimeField |
UTC + 完整微秒,UTC 以 Z 结尾(如 "2026-01-02T03:04:05.123456Z") |
DurationField |
"D HH:MM:SS.ffffff"(如 "1 00:00:02.123456") |
UUIDField |
字符串 |
BinaryField |
base64 字符串 |
JSONField |
递归键排序、数字归一后的结构 |
FileField / ImageField |
存储相对路径字符串(空文件与 None 均为 "") |
详细边界与升级副作用见「注意事项与限制」第 8 节。
脚本 / 后台任务 / 无请求上下文
- 信号在管理命令、Celery 任务、
manage.py shell中同样触发并记录; - 由于没有请求上下文,
request_id/username可能为空; - 需要时可手动调用
add_data_history(..., username=..., userinfo=...)补上操作人。
多数据库
- 历史随业务对象一起通过 ORM 写入(默认
default,或由你的数据库路由决定使用哪个库); encrypt_data_history与simplify_data_history均支持--database指定库别名(默认default)。
五、注意事项与限制
1. 历史数据加密与 SECRET_KEY
历史记录的 data、userinfo 字段使用 SafeTextField 加密存储,默认使用 settings.SECRET_KEY 作为密钥。
- 请务必妥善保管
SECRET_KEY:一旦更换,旧的历史数据将无法解密(读取时会记录警告并回退为原始密文); - 对于历史遗留的明文数据,读取时会自动回退,仍可正常读取,不会报错;
- 如需自定义密钥/算法,可在自定义历史模型字段上通过
cipher/password等参数指定; - 若需要更换密钥又要保留旧数据可读,请见下一节「密钥轮换(
used_ciphers)」。
2. 加密历史遗留的明文数据(管理命令)
早期版本或迁移前的历史记录,其 data / userinfo 可能是未加密的明文(直接就是 JSON 字符串)。读取时虽然能自动回退到明文,但明文落库仍有泄露风险,建议用内置命令把它们原地加密:
# 处理所有历史表
python manage.py encrypt_data_history
# 只处理某一张历史表
python manage.py encrypt_data_history --model myapp.MyDataHistory
# 只统计不写入
python manage.py encrypt_data_history --dry-run
# 调整每批处理的行数(默认 1000)
python manage.py encrypt_data_history --batch-size 5000
# 指定数据库别名(默认 default)
python manage.py encrypt_data_history --database other
要点:
- 经 ORM 读取时字段总会解密,明文与密文都会被还原成同样的明文,无法据此判断底层是否已加密;因此命令读取数据库原始列判断是否为明文:历史表存的是 JSON 对象,而密文是 hex/base64 编码,因此「能解析为 JSON 对象」即为明文;已加密的行会被跳过,命令幂等,可重复执行;
- 写入统一走 ORM(
QuerySet.update()),由字段自身的 cipher 完成加密(默认即SECRET_KEY),与正常写入行为一致,不会重复加密,也避免手工调用 cipher 导致行为不一致; - 字段级增量:同一行中只有「仍是明文」的字段会被重新保存,已加密字段(如
userinfo)不会被改写;单行写入失败会记录 warning 并继续处理其余行; - 命令不改变业务语义:加密后再读出来仍是原来的明文 JSON;
- 通过
--model app_label.ModelName可限定单张历史表,省略则处理所有DataHistoryBase子类; --batch-size为每次扫描的行数(默认 1000);小于等于 0 时按 1 处理(逐行扫描);--database指定要操作的数据库别名(默认default);原始列读取与 ORM 写回使用同一别名;- 原始列名取自字段的
db_column(而非字段名),兼容用db_column自定义列名的加密字段; userinfo为NULL的行会被自动跳过。
3. 简化历史遗留的「完整对象」数据(管理命令)
早期版本的反向关联默认以完整对象记录(如 "books": [{"id": 1, "title": ...}]),当前版本默认改为主键列表(如 "books": [1])。形状不同会被判定为变化,导致对象在升级后的第一次保存额外产生一条 change 版本。用内置命令可把已有历史原地简化为当前形态,消除这次假变更:
# 处理所有历史表
python manage.py simplify_data_history
# 只处理某一张历史表
python manage.py simplify_data_history --model myapp.MyDataHistory
# 只统计不写入
python manage.py simplify_data_history --dry-run
# 调整每批处理的行数(默认 1000)
python manage.py simplify_data_history --batch-size 5000
# 指定数据库别名(默认 default)
python manage.py simplify_data_history --database other
# 同时删除冗余的重复版本(不可逆,建议先 --dry-run)
python manage.py simplify_data_history --dedupe
python manage.py simplify_data_history --dedupe --dry-run
要点:
- 只改写
data与changes里反向关联的取值:完整对象列表压成主键列表,多对多与主键保持主键列表,并统一按主键排序; - 会修复损坏的非列表关联值:反向一对一的空值/异常值会被移除(当前序列化器对空的一对一不写该键),其余反向关联与多对多的非列表值会修正为空列表;
- 是否简化遵循当前配置:
django_data_history_related_fields白名单内的关联、或全局DATA_HISTORY_RELATED_FULL_OBJECTS = True,仍保持完整对象(仅做排序); - 当前
django_data_history_excludes排除的字段会从历史data/changes中一并移除,保证与后续写入的形状一致; - 命令幂等:已是目标形态、无法解析或找不到对应业务模型的行会被跳过,可重复执行;
- 命令结束会按业务模型列出被改动取值的字段及涉及的历史行数(
Fields changed:;--dry-run时为Fields that would change:),便于确认到底简化了哪些字段; - 写入统一走 ORM(
QuerySet.update()),由字段自身的 cipher 完成加密,与正常写入行为一致,且不改变app_label/version/add_time/action等其它列; - 单行写入失败会记录 warning 并继续处理其余行;
--model app_label.ModelName限定单张历史表,省略则处理所有DataHistoryBase子类;--batch-size为每次扫描的行数(默认 1000,小于等于 0 时按 1 处理);--database指定要操作的数据库别名(默认default)。
用 --dedupe 删除冗余版本
早期实现(关联顺序抖动、子对象字段变化级联父对象、升级形状不一致)会在历史里留下冗余行:同一对象相邻版本的快照 data 完全相同。简化命令只规整内容、不删行,需要用 --dedupe 才能清理:
- 判定:在同一
(app_label, model_name, item_id)分组内按version排序,某行的快照与上一条保留行相同、且其changes为「无实质变化」(缺失/空/旧==新)时才视为冗余并删除;changes无法解析时保守保留,避免误删仍带审计信息的行; - 删除事件保留:
action == delete的行永不删除(删除本身是有意义的事件,即使快照与前一行相同); - 版本重排:删除后把剩余行按
1..N连续重排version,并同步更新行内 JSON 的version字段; - 先简化后去重:
--dedupe在简化之后执行,因此「简化后才变得相同」的行也能被识别; - 大表友好(分批):扫描按
(app_label, model_name, item_id, version, pk)键集分页,每批--batch-size行,并按分组即时提交,内存只占当前批次加跨批的单个未闭合分组;重排用批量 UPDATE(bulk_update)按--batch-size提交,不是逐行 UPDATE;删除也按--batch-size分块; - 与简化共用
--model/--batch-size/--database/--dry-run;--dry-run只统计不删除、不重排; - 不可逆:删除后无法恢复,建议先
--dry-run确认数量。
注意:
--dedupe以data快照判重,并额外要求changes无实质变化,但不比较request_id、username等元信息;若你的场景需要保留「谁在何时做了一次无数据变化的保存」这类审计痕迹,请勿使用。
4. 密钥轮换(used_ciphers)
更换加密密钥后,旧数据是用旧密钥加密的,无法用新密钥解出。为此可以在字段上通过 used_ciphers 传入「旧密钥对应的 cipher 列表」。读取时会依次尝试 [当前 cipher] + used_ciphers:
- 写入:始终使用当前 cipher(默认即
SECRET_KEY)加密; - 读取:先用当前 cipher 解密,**失败(抛异常)**时再依次用
used_ciphers中的旧 cipher 解密。
重要:回退依赖「解密失败时抛异常」,业务必须自行对解密后的明文做正确性检查。
默认的
MysqlAesCipher在密钥错误时不会抛异常,而是返回空串或乱码;读取会直接返回该结果而不再尝试used_ciphers,密钥轮换因此失效。所以使用
used_ciphers时,必须让 cipher 在「明文不符合业务预期」时抛异常。注意:不能用「结果能否解码为文本」来判断——错误密钥解出的结果也可能恰好是文本,而且这里并没有可信的参照明文可对比。正确做法是由业务按明文字段的业务结构/格式来校验,例如「历史记录必须是合法 JSON」「手机号字段必须符合手机号格式」。
先定义一个「带业务校验」的 cipher,校验函数由业务提供:
from fastutils import cipherutils
class CipherWithCheck(cipherutils.MysqlAesCipher):
"""解密后用业务校验函数检查明文;不通过则抛异常,使 used_ciphers 能继续回退。"""
def __init__(self, *args, validator=None, **kwargs):
self.validator = validator
super().__init__(*args, **kwargs)
def decrypt(self, text, **kwargs):
if text is None:
return None
result = super().decrypt(text, **kwargs)
if not self.validator(result):
raise ValueError(
"decrypt result does not match the field's business format"
)
return result
历史表的字段存的是 JSON,因此用「是否为合法 JSON」作为业务校验:
import json
def is_history_json(value):
try:
json.loads(value)
return True
except Exception:
return False
再把它同时作为当前 cipher 与旧 cipher 使用(旧密钥放在环境变量 OLD_SECRET_KEY 中):
import os
from fastutils import cipherutils
from django_safe_fields.fields import SafeTextField
from django_data_history.models import DataHistoryBase
encoder = cipherutils.HexlifyEncoder()
# 旧密钥对应的 cipher:encoder / force_text / 算法必须与当初写入时保持一致。
old_ciphers = [
CipherWithCheck(
password=os.environ["OLD_SECRET_KEY"],
validator=is_history_json,
result_encoder=encoder,
force_text=True,
),
]
class MyDataHistory(DataHistoryBase):
data = SafeTextField(
cipher_class=CipherWithCheck,
cipher_kwargs={"validator": is_history_json},
used_ciphers=old_ciphers,
)
userinfo = SafeTextField(
null=True,
blank=True,
cipher_class=CipherWithCheck,
cipher_kwargs={"validator": is_history_json},
used_ciphers=old_ciphers,
)
class Meta:
verbose_name = "数据变更记录"
verbose_name_plural = "数据变更记录"
然后让业务模型使用这张历史表:
class MyModel(models.Model):
DATA_HISTORY_STORAGE_CLASS = MyDataHistory
要点:
- 使用
used_ciphers时,当前 cipher 与旧 cipher 都必须带「业务正确性检查」,检查不通过就抛异常,否则回退不会生效; - 校验函数由业务按明文的业务结构提供:历史表字段用「合法 JSON」,普通字段用各自的格式(如手机号
^1\d{10}$、身份证、邮箱等); - 不要用「能否解码为文本」来判断:错误密钥解出的结果也可能是文本,且没有参照明文可比;
used_ciphers只影响读取解密,不影响写入,无需迁移(它不是数据库字段属性);- 可以传入多个旧 cipher,按顺序依次尝试,支持先后多次密钥轮换;
- 若旧数据当初使用了自定义的
cipher/password/ encoder / force_text,构造旧 cipher 时必须与当时的配置完全一致,否则解密会失败; - 如需完全自定义当前 cipher 的算法/编码/密钥,可在同一字段上通过
cipher/cipher_class/cipher_kwargs/password/result_encoder/force_text指定; django_safe_fields没有内置的「重新加密」命令,used_ciphers会一直保留旧密钥的兼容读取能力;- 默认从未轮换过密钥时无需配置此项。
5. 请勿复用主键
历史记录通过 (app_label, model_name, item_id) 软引用原始对象,没有外键。
因此,如果一个对象被删除后,又用相同主键新建了另一个不同对象,两者会共用同一个 item_id,其历史会被追加到同一条时间线上(版本继续递增,新的 create 记录可能带上旧对象的 changes)。
- 请确保主键不被复用;
- 正常自增主键、UUID 主键都是安全的,多数业务系统不会遇到此问题;
- 若确实需要重置自增序列,请注意旧历史仍会挂在被复用的 id 上;
- 若无法避免复用,可通过「
create记录紧跟在delete记录之后」来判断新对象的起点。
6. 删除去重
同一个 item_id 若连续产生多次 delete 事件,只有第一条会被记录,重复的删除会被忽略(通过比对上一版记录的 action 判断)。
7. 只记录「有变化」的保存
默认情况下,保存时如果没有任何字段发生变化,则不会产生新的历史记录。可在调用时通过 ignore_unchanged_version=False 强制记录:
from django_data_history.models import DataHistoryBase
DataHistoryBase.add_data_history(
instance,
DataHistoryBase.CHANGE,
ignore_unchanged_version=False,
)
8. 字段序列化的边界与已知限制
- 数据库后端相关:测试只在 SQLite 上运行。Postgres 专有字段(
ArrayField/HStoreField/CITextField等)未做一致性保证与测试,其取值由 Django 序列化器决定; - 自增主键变体:
BigAutoField/SmallAutoField作为主键时只以id记录,不单独比较; - 延迟加载字段:
get_item_data会对时间/JSON/浮点字段取getattr(instance, attname)以覆盖 Django 序列化器的截断;若对象来自.only(...)(字段被延迟),这次访问会触发一次额外查询(影响极小); - 表示归一:DateTime/Time 统一为 UTC + 完整微秒,JSON 键排序且
1.0→1、-0.0→0,Float 的NaN/Infinity存为字符串;这些归一会改变既有历史的表示形态,升级后建议用simplify_data_history原地对齐(详见第 3 节); - 测试严格性:
pytest.ini设置filterwarnings = error,任何未显式豁免的告警都会让对应用例失败;仅 5 个读取django_safe_fields旧密文/明文的用例以方法级标记豁免该第三方弃用告警; - CI / 版本矩阵:
.github/workflows/tests.yml在 Python 3.9–3.13 上运行ruff check与pytest。声明requires-python >=3.7,但 3.7 / 3.8 未纳入矩阵。
9. 从 v0.3.0 升级到 v0.4.0
v0.4.0 累积了自 v0.3.0 以来的全部变更,其中有几处会改变已存历史的表示形态,升级后对象的首次保存可能额外产生一条 change 版本(业务数据其实没变)。建议按下列步骤消除:
- 备份历史表;
- 部署 v0.4.0 并执行
python manage.py migrate(历史表新增了索引); - 如存在明文历史,先执行
python manage.py encrypt_data_history(幂等,见第 2 节); - 用
python manage.py simplify_data_history --dry-run评估将被改动的行; - 确认后执行
python manage.py simplify_data_history(必要时加--dedupe,见第 3 节)。
涉及的表示变化:
- 反向关联默认由「完整对象」改为「主键列表」;
- DateTime / Time 统一为 UTC + 完整微秒;
- JSON 递归键排序、数字归一(
1.0→1、-0.0→0); - Float 的
NaN/Infinity/-Infinity归一为字符串。
若希望保持反向关联的旧形态,可在升级前设置
DATA_HISTORY_RELATED_FULL_OBJECTS = True。
六、常见问题(FAQ)
Q:为什么没有生成历史记录?
请依次检查:
django_data_history是否加入INSTALLED_APPS,且位于django.contrib.admin之前;- 是否设置了
SAVE_DATA_HISTORIES_FOR_ALL = True,或把目标模型加入SAVE_DATA_HISTORIES_FOR; - 目标模型是否被
DO_NOT_SAVE_DATA_HISTORIES_FOR排除; - 是否真的发生了数据变化(只改了被
django_data_history_excludes忽略的字段时不会记录); - 是否执行了
python manage.py migrate。
Q:更换了 SECRET_KEY 之后,历史数据读不出来了怎么办?
加密密钥来自 SECRET_KEY,更换后旧数据无法解密。请恢复原 SECRET_KEY,或按「五、注意事项与限制」中的「密钥轮换(used_ciphers)」一节,用旧密钥对应的 cipher 进行兼容读取。
Q:删除了父对象后,子对象的删除也会损坏历史记录吗?
不会。删除父对象导致级联删除子对象时,会正常为各对象记录 delete,并忽略已不存在的关联目标。
Q:历史记录会随原始对象删除而删除吗?
不会。历史与原始对象之间没有外键,原始对象删除后历史仍然保留。
Q:为什么升级后对象第一次保存会多出一条 change 版本?
这是反向关联形态或字段表示归一造成的「形状变化」。按「五、注意事项与限制」第 9 节的升级指引,用 simplify_data_history 原地对齐即可消除。
Q:为什么把 JSON 里的 1.0 改成 1(或 -0.0 改成 0)没有生成记录?
JSON 数字会归一:1.0 与 1 视为同一值,-0.0 归一为 0,因此不构成变化;改为不同数值(如 1 → 2)仍会正常记录。
Q:文件内容变了,但历史没有记录?
历史只比较路径。同路径原地覆盖内容时不会记录,需配置文件比较器钩子(见「如何自定义文件字段的比较与保存?」),或让 upload_to 生成不可变路径。
七、Release 记录
v0.1.0
- 首个版本。
v0.1.1
- 修复
ugettext_lazy相关问题。
v0.1.2
- 新增
save_data_histories_for_fk_instance,修复内联编辑时历史缺失的问题。
v0.1.3
- 修复
fields_map中字段名带+的问题。
v0.1.5
- 使用
django_middleware_request_id替代应用内部的request_id实现。
v0.1.6
- 修复
setup.py中的依赖声明。
v0.1.7
- 修复
DataHistoryModelAdmin.get_data_histories的问题。
v0.1.8
- 修复与
django-import-export协作时的问题; - 新增
django_data_history_excludes支持。
v0.1.9
- 修复
OneToOneField相关的问题。
v0.2.0
- 使用 Apache License, Version 2.0 开源协议。
v0.3.0
- 历史数据改用
django-safe-fields加密存储。 - 改用
pyproject.toml打包。 - 编译并随包发布
zh_Hans翻译(django.mo)。 - 移除空占位模块
tests.py、views.py。 - 修复级联删除时访问已删除外键对象报
DoesNotExist的问题。 - 删除去重改为依据
action判断,不再依赖从未写入的delete_flag。 - 完善测试用例与中文文档。
v0.4.0
生产发布版本为 v0.3.0;本节累积记录自 v0.3.0 以来的全部变更。
新增功能
- 新增
encrypt_data_history管理命令:把历史遗留的明文data/userinfo原地加密为SafeTextField密文,命令幂等;支持--model/--batch-size/--dry-run/--database,并按字段db_column读取原始列。 - 新增
simplify_data_history管理命令:把旧版「完整对象」反向关联原地简化为主键列表,消除升级后首次保存的假变更;支持--model/--batch-size/--dry-run/--database,并输出被改动的字段清单;--dedupe可删除冗余版本并连续重排version(键集分批扫描、bulk_update提交、保留delete事件)。 - 新增文件字段业务钩子
django_data_history_file_comparator/django_data_history_file_recorder(全局兜底DATA_HISTORY_FILE_COMPARATOR/DATA_HISTORY_FILE_RECORDER):用于「同路径覆盖内容」的变更判定,以及业务把文件指纹(哈希/大小等)落到自己的模型;支持可调用对象 / 点分路径字符串 / 按字段名字典(含"*"兜底)。
修复
- 统一日期/时间/JSON/浮点字段的序列化,消除假版本与键序差异:
- DateTime / Time 统一为 UTC + 完整微秒(消除同一时刻不同时区表示的假变更,以及 Django 序列化器的毫秒截断);
- JSON 字段递归键排序,数字归一(
1.0→1、-0.0→0、NaN/Infinity→字符串); - Float 字段
NaN/Infinity/-Infinity归一并处理-0.0,修复NaN != NaN导致每次保存都产生假版本的问题。
- 反向关联默认改存主键列表(不再序列化完整子对象),缩窄外键级联范围,消除 N+1 查询与历史体积膨胀;
django_data_history_related_fields白名单或DATA_HISTORY_RELATED_FULL_OBJECTS = True可恢复完整对象。 django_data_history_excludes改为在查询之前生效,被排除字段不再触发数据库查询;并兼容损坏的历史数据。- 关联记录按主键稳定排序(含完整对象内的嵌套多对多),消除顺序抖动导致的假变更。
- 修复损坏的非列表反向关联值导致保存 / 命令崩溃的问题;简化命令可修复该类数据。
- 外键级联改为按需触发,并修复外键改指时旧父对象历史未更新的问题;仅对已开启数据历史的模型级联(
DATA_HISTORY_CASCADE_FK_FOR_UNTRACKED_MODELS = True恢复旧行为)。 - 修复自定义历史模型未继承索引导致查询全表扫描的问题。
- 修复历史页复制按钮在无 JS 时误跳转,并增加剪贴板特性检测与降级。
其它
- 编译并随包发布
zh_Hans翻译(django.mo);完善打包元数据、依赖约束与构建脚本。 - 完善
used_ciphers密钥轮换文档(要求业务按明文业务结构校验)。 - 工程:新增 Ruff 静态检查配置(
pyproject.toml);新增 GitHub Actions(Python 3.9–3.13 矩阵)运行ruff check与pytest;测试全局开启filterwarnings = error,仅按方法豁免 5 个已知第三方告警用例。 - 测试:大幅补充字段序列化一致性(时间/JSON/浮点/UUID/Duration/Binary 等)、特殊字段取值变化、简化 / 加密命令、查询性能(索引命中)、外键级联优化等正反向用例;覆盖率保持 100%。
八、许可证
本项目基于 Apache License, Version 2.0 开源。
Metadata
Release files for django-data-history 0.4.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| django_data_history-0.4.0.tar.gz | 77.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| django_data_history-0.4.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 127.7 kB
Release files / django_data_history-0.4.0.tar.gz
| Download URL | django_data_history-0.4.0.tar.gz |
|---|---|
| Size | 77.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
e71f3df0d47fdb4e04168a9abd7ceeb04e37b904b837f2db08f54fe6214419ab
|
|
BLAKE2b-256 checksum How to use checksums |
ca8040ad458650b4f8274c1d638c0147cc0a7b86faff6a2ab1daa7ccbee15220
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.11.9
|
Release files / django_data_history-0.4.0-py3-none-any.whl
| Download URL | django_data_history-0.4.0-py3-none-any.whl |
|---|---|
| Size | 50.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
7df9e26a4b7fe644a7fac37dadee06bb1a6ac2e37f6daa9049d8dbb77357a3b1
|
|
BLAKE2b-256 checksum How to use checksums |
fde9c5af3e71bf09ea53af5d87effc87c307c537859f920f837f636ebbd022fc
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.11.9
|