Skip to main content

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:为什么没有生成历史记录?

请依次检查:

  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:历史记录会随原始对象删除而删除吗?

不会。历史与原始对象之间没有外键,原始对象删除后历史仍然保留。


七、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)

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

Built distribution (wheel)

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

Release history Release notifications | RSS feed

0.4.0

2 release files

This release

0.3.0 This release

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