django-data-history
一个 Django 应用:自动把模型数据的变更明细记录下来,并在后台(admin)的对象历史页面中展示完整的变更信息。
- 自动记录模型的 新增 / 修改 / 删除 以及 多对多关系 的变化;
- 记录每次变更的 字段级差异(旧值 → 新值)、版本号、操作人、请求 ID、时间;
- 历史数据以 加密方式 存储(基于
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",
]
说明:历史记录模型自身(
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→ 记录多对多关系变化;- 关联对象(外键 / 反向关系)发生变化时,其历史也会被一并记录与更新。
- 加密存储:
data、userinfo字段使用SafeTextField加密保存。
四、使用说明
如何查看某个对象的历史?
方式一:通过 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)
被忽略的字段不会写入历史;如果一次保存只有被忽略的字段发生变化,则不会产生新的历史记录。
如何把历史保存到独立的表?
给模型类添加 DATA_HISTORY_STORAGE_CLASS 属性即可。该值必须是 django_data_history.models.DataHistoryBase 的子类,可以是类对象,也可以是类的绝对点分路径字符串。
from django_data_history.models import DataHistoryBase
class MyModelHistory(DataHistoryBase):
class Meta:
verbose_name = "我的模型变更记录"
verbose_name_plural = "我的模型变更记录"
class MyModel(models.Model):
DATA_HISTORY_STORAGE_CLASS = MyModelHistory
若不设置,则默认使用 django_data_history.models.DataHistory。
如何自定义「操作用户信息」?
默认记录的用户信息为:
{
"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,
}
五、注意事项与限制
1. 历史数据加密与 SECRET_KEY
历史记录的 data、userinfo 字段使用 SafeTextField 加密存储,默认使用 settings.SECRET_KEY 作为密钥。
- 请务必妥善保管
SECRET_KEY:一旦更换,旧的历史数据将无法解密(读取时会记录警告并回退为原始密文); - 对于历史遗留的明文数据,读取时会自动回退,仍可正常读取,不会报错;
- 如需自定义密钥/算法,可在自定义历史模型字段上通过
cipher/password等参数指定; - 若需要更换密钥又要保留旧数据可读,请见下一节「密钥轮换(
used_ciphers)」。
2. 密钥轮换(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会一直保留旧密钥的兼容读取能力;- 默认从未轮换过密钥时无需配置此项。
3. 请勿复用主键
历史记录通过 (app_label, model_name, item_id) 软引用原始对象,没有外键。
因此,如果一个对象被删除后,又用相同主键新建了另一个不同对象,两者会共用同一个 item_id,其历史会被追加到同一条时间线上(版本继续递增,新的 create 记录可能带上旧对象的 changes)。
- 请确保主键不被复用;
- 正常自增主键、UUID 主键都是安全的,多数业务系统不会遇到此问题;
- 若确实需要重置自增序列,请注意旧历史仍会挂在被复用的 id 上;
- 若无法避免复用,可通过「
create记录紧跟在delete记录之后」来判断新对象的起点。
4. 删除去重
同一个 item_id 若连续产生多次 delete 事件,只有第一条会被记录,重复的删除会被忽略(通过比对上一版记录的 action 判断)。
5. 只记录「有变化」的保存
默认情况下,保存时如果没有任何字段发生变化,则不会产生新的历史记录。可在调用时通过 ignore_unchanged_version=False 强制记录:
from django_data_history.models import DataHistoryBase
DataHistoryBase.add_data_history(
instance,
DataHistoryBase.CHANGE,
ignore_unchanged_version=False,
)
六、常见问题(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:历史记录会随原始对象删除而删除吗?
不会。历史与原始对象之间没有外键,原始对象删除后历史仍然保留。
七、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。 - 完善测试用例与中文文档。
八、许可证
本项目基于 Apache License, Version 2.0 开源。
Metadata
Release files for django-data-history 0.3.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.3.0.tar.gz | 29.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| django_data_history-0.3.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 55.1 kB
Release files / django_data_history-0.3.0.tar.gz
| Download URL | django_data_history-0.3.0.tar.gz |
|---|---|
| Size | 29.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
9461f7eccf7ac983dab047f18f7f1242b93e5c18d26772883eb83d1c4e7b9cb1
|
|
BLAKE2b-256 checksum How to use checksums |
7315ff29204752642f758eca046a69f33515111fa1d8b977a0b6f032078383e9
|
| 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.3.0-py3-none-any.whl
| Download URL | django_data_history-0.3.0-py3-none-any.whl |
|---|---|
| Size | 25.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
108b5364639c095c0f81b6d4ad3c577474ccf069414cef2448d331788d3df6ed
|
|
BLAKE2b-256 checksum How to use checksums |
37cc92a4165a401d3707c5388f147e34ef213ed9b2667e55b377d607bb44fc28
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.11.9
|