Metadata-Version: 2.4
Name: django-data-history
Version: 0.3.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"
Dynamic: license-file

# django-data-history

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

- 自动记录模型的 **新增 / 修改 / 删除** 以及 **多对多关系** 的变化；
- 记录每次变更的 **字段级差异**（旧值 → 新值）、版本号、操作人、请求 ID、时间；
- 历史数据以 **加密方式** 存储（基于 `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",
]
```

> 说明：历史记录模型自身（`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` → 记录多对多关系变化；
  - 关联对象（外键 / 反向关系）发生变化时，其历史也会被一并记录与更新。
- **加密存储**：`data`、`userinfo` 字段使用 `SafeTextField` 加密保存。

---

## 四、使用说明

### 如何查看某个对象的历史？

**方式一：通过 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)
```

被忽略的字段不会写入历史；如果一次保存**只有**被忽略的字段发生变化，则不会产生新的历史记录。

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

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

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

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

默认记录的用户信息为：

```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,
    }
```

---

## 五、注意事项与限制

### 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，校验函数由业务提供：

```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` 会一直保留旧密钥的兼容读取能力；
- 默认从未轮换过密钥时无需配置此项。

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

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