Skip to main content

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 版本(业务数据其实没变)。建议按下列步骤消除:

  1. 备份历史表;
  2. 部署 v0.4.0 并执行 python manage.py migrate(历史表新增了索引);
  3. 如存在明文历史,先执行 python manage.py encrypt_data_history(幂等,见第 2 节);
  4. 用 python manage.py simplify_data_history --dry-run 评估将被改动的行;
  5. 确认后执行 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:为什么没有生成历史记录?

请依次检查:

  1. django_data_history 是否加入 INSTALLED_APPS,且位于 django.contrib.admin 之前;
  2. 是否设置了 SAVE_DATA_HISTORIES_FOR_ALL = True,或把目标模型加入 SAVE_DATA_HISTORIES_FOR;
  3. 目标模型是否被 DO_NOT_SAVE_DATA_HISTORIES_FOR 排除;
  4. 是否真的发生了数据变化(只改了被 django_data_history_excludes 忽略的字段时不会记录);
  5. 是否执行了 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)

Source distribution for django-data-history 0.4.0
File Size Uploaded
django_data_history-0.4.0.tar.gz 77.3 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for django-data-history 0.4.0
File Interpreter ABI Platform
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

Release history Release notifications | RSS feed

This release

0.4.0 This release

2 release files

0.3.0

2 release files

0.2.0

2 release files

0.1.9

2 release files

0.1.8

2 release files

0.1.7

2 release files

0.1.6

2 release files

0.1.5

2 release files

0.1.4

2 release files

0.1.3

2 release files

0.1.2

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page