Metadata-Version: 2.4
Name: django-data-history
Version: 0.4.0
Summary: A Django application that allows you to store detailed data in the change log and display the detailed information in object's history view.
Author: rRR0VrFP
Maintainer: rRR0VrFP
License-Expression: Apache-2.0
Project-URL: Homepage, https://gitee.com/rRR0VrFP/django-data-history
Project-URL: Repository, https://gitee.com/rRR0VrFP/django-data-history
Project-URL: Bug Tracker, https://gitee.com/rRR0VrFP/django-data-history/issues
Keywords: django,django data history
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.7
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Requires-Python: >=3.7
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: django>=3.2
Requires-Dist: django-safe-fields>=0.2.3
Requires-Dist: django-middleware-global-request>=0.3.1
Requires-Dist: django-middleware-request-id>=0.1.2
Requires-Dist: django-static-jquery-ui
Requires-Dist: zenutils
Provides-Extra: test
Requires-Dist: django-app-requires; extra == "test"
Requires-Dist: pytest; extra == "test"
Requires-Dist: pytest-cov; extra == "test"
Requires-Dist: pytest-django; extra == "test"
Provides-Extra: dev
Requires-Dist: ruff; extra == "dev"
Dynamic: license-file

# django-data-history

一个 Django 应用：自动把模型数据的变更明细记录下来，并在后台（admin）的对象历史页面中展示完整的变更信息。

- 自动记录模型的 **新增 / 修改 / 删除** 以及 **多对多关系** 的变化；
- 记录每次变更的 **字段级差异**（旧值 → 新值）、版本号、操作人、请求 ID、时间；
- 统一规范化 **日期/时间/JSON/浮点** 等字段的序列化，杜绝「无实际变化却记版本」的假记录；
- 历史数据以 **加密方式** 存储（基于 `django-safe-fields`）；
- 可在 admin 的对象历史页面中直观查看；
- 支持把不同模型的历史记录写到 **独立的历史表** 中；
- 提供 **加密遗留明文** 与 **简化遗留历史** 的管理命令，并可选配 **文件字段比较/记录钩子**。

---

## 一、安装

```shell
pip install django-data-history
```

本应用依赖以下包（安装本应用时会自动安装）：

```text
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` **之前**：

```python
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`

```python
MIDDLEWARE = [
    ...
    "django_middleware_global_request.middleware.GlobalRequestMiddleware",
    "django_middleware_request_id.middlewares.DjangoMiddlewareRequestId",
    ...
]
```

- `GlobalRequestMiddleware`：让应用能在信号中拿到当前请求，从而记录操作用户；
- `DjangoMiddlewareRequestId`：为每个请求生成 `request_id`，用于串联同一次请求产生的多条记录。

### 3. 配置记录范围

```python
# 默认 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
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`**

```python
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`**

```python
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`** —— 无需额外配置

```python
from django.contrib import admin
from .models import Article, Tag

admin.site.register(Article)
admin.site.register(Tag)
# 开启记录后，本应用会自动把历史查看能力注入这些 ModelAdmin；
# 「历史」页面会显示每个版本的字段级差异、操作人、时间与复制按钮。
```

**4）执行迁移并使用**

```shell
python manage.py makemigrations && python manage.py migrate
```

```python
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` 字段（加密存储）解析后的字典：

```python
{
    "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 会自动注入历史查看能力。进入对象的「历史」页面即可看到所有版本（含字段级差异、操作人、时间等）。

**方式二：通过代码**

```python
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`：

```python
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），使路径随内容变化：

```python
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，同路径内容变化即可被审计）：

```python
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` 用不同钩子；未命中的字段回退到 `"*"`，都没有则视为未配置）：

```python
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)
```

全局兜底示例（对所有未单独配置的模型生效）：

```python
# 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` 白名单：

```python
class Category(models.Model):

    django_data_history_related_fields = ["books"]
```

注意：

- 只有白名单里的反向关联会以「完整对象」记录；未列入的仍是主键列表；
- 以完整对象记录时，**子对象自身字段**的变化也会让父对象产生新版本；而主键列表模式下，子对象字段变化不会影响父对象（只记录在子对象自己的历史里），仅新增/删除子对象会改变父对象的主键列表；
- 若想全局恢复「所有反向关联都记录完整对象」的旧行为：

```python
# 默认 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` 把该反向关联记录为**完整对象**，则子对象任意字段变化仍会级联父对象（因为父对象快照包含子对象字段）。

若需要恢复「对所有外键目标都级联」的旧行为：

```python
# 默认 False：只对已开启数据历史的模型级联
# True：对所有外键目标都级联（旧行为）
DATA_HISTORY_CASCADE_FK_FOR_UNTRACKED_MODELS = True
```

### 如何把历史保存到独立的表？

给模型类添加 `DATA_HISTORY_STORAGE_CLASS` 属性即可。该值必须是 `django_data_history.models.DataHistoryBase` 的子类，可以是**类对象**，也可以是**类的绝对点分路径字符串**。

```python
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`。

### 如何自定义「操作用户信息」？

默认记录的用户信息为：

```python
{
    "username": user.username,
    "name": user.last_name + user.first_name,
    "email": user.email,
}
```

可通过 `settings.GET_USER_INFORMATION` 指定一个函数的绝对点分路径来覆盖：

```python
GET_USER_INFORMATION = "your_project.utils.get_user_information"
```

```python
def get_user_information(user):
    return {
        "username": user.username,
        "name": user.get_full_name(),
        "email": user.email,
    }
```

### 如何手动记录一条历史？

多数场景由信号自动记录；特殊流程（绕过 `save()`、离线修复、外部系统同步）可手动补记：

```python
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`）在**没有任何字段变化**时不产生新记录。

也可在运行时为某个模型**开启记录**（等价于把它加入记录范围）：

```python
from django_data_history.models import save_data_histories_for

save_data_histories_for(MyModel)
```

### 如何读取最新 / 指定版本的历史？

```python
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`），可自行注册查看，例如：

```python
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 字符串）。读取时虽然能自动回退到明文，但明文落库仍有泄露风险，建议用内置命令把它们**原地加密**：

```bash
# 处理所有历史表
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` 版本。用内置命令可把已有历史**原地简化**为当前形态，消除这次假变更：

```bash
# 处理所有历史表
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，校验函数由业务提供：

```python
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」作为业务校验：

```python
import json


def is_history_json(value):
    try:
        json.loads(value)
        return True
    except Exception:
        return False
```

再把它**同时作为当前 cipher 与旧 cipher** 使用（旧密钥放在环境变量 `OLD_SECRET_KEY` 中）：

```python
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 = "数据变更记录"
```

然后让业务模型使用这张历史表：

```python
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` 强制记录：

```python
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](LICENSE) 开源。
