Djangoのモデル操作を記録できるdjango-auditlogの紹介

企業向けのシステム開発をしていると、コンプライアンス上の理由からシステムの操作記録やデータの変更内容を記録したいという要件はよくあります。そういったときに役立つDjango拡張[django-auditlog](https://django-auditlog.readthedocs.io/en/latest/)を紹介します。

django-auditlogは、名前の通りaudit log（監査ログ）を記録するためのDjango拡張です。django-auditlogを導入すると、モデル操作の履歴を記録できます。標準にも似たような機能がありますが、主に以下の違いがあります。

|  | 標準機能 | django-auditlog |
|:-:|:-:|:-:|
| いつ記録するか | adminからの操作があった場合 | Djangoのモデルを使った機能すべて |
| 記録対象の操作 | 登録・更新・削除 | 登録・更新・削除・閲覧 |
| 変更の差分を記録するか | 記録しない | 記録する |

django-auditlogのメンテナは、Djangoユーザーにはお馴染みの[Django Debug Toolbar](https://django-debug-toolbar.readthedocs.io/en/latest/)を開発しているコミュニティ「[Jazzband](https://jazzband.co/)」です。

以下でdjango-auditlogの導入方法と使い方について説明します。
なお、本記事のサンプルコードはdjango-auditlog 2.3.0で動作確認しています。

## 導入方法
まず、以下コマンドを実行してdjango-auditlogをインストールします。

```shell
$ pip install django-auditlog
```

次に、Djangoの`settings.py`をエディタで開き、`INSTALLED_APPS`に`"auditlog"`を加えます。

```python
INSTALLED_APPS = [
    # 省略
    "auditlog",
    # 省略
]
```

次に、`settings.py`の`MIDDLEWARE`に`"auditlog.middleware.AuditlogMiddleware"`を加えます。リクエストを変更するミドルウェアは`"auditlog.middleware.AuditlogMiddleware"`より上に書いてください。

```python
MIDDLEWARE = [
    # 省略（リクエストを変更するミドルウェアはここに書く）
    "auditlog.middleware.AuditlogMiddleware",
    # 省略
]
```

## 使い方
ここでは、`books`アプリケーションに以下のモデルを定義している前提でdjango-auditlogの使い方について説明します。

```python:books/models.py
from django.db import models


class Book(models.Model):
    title = models.CharField(max_length=50)
    price = models.IntegerField()

    class Meta:
        verbose_name = "book"
        verbose_name_plural = "books"

    def __str__(self):
        return self.title
```

本記事で紹介するサンプルコードを含むDjangoプロジェクトは以下GitHubリポジトリに置いているので、実際に動かしてみたい場合は参照してください。
https://github.com/ryu22e/django_auditlog_example

### モデルをdjango-auditlogに登録する
django-auditlogはデフォルトではモデルの操作履歴を記録しません。
記録できるようにする方法はいくつかありますが、今回は一番簡単な方法を紹介します。`settings.py`に以下の設定を加えるだけです。

```python
AUDITLOG_INCLUDE_ALL_MODELS = True
```

上記の設定で、すべてのモデルがdjango-auditlogに登録され、登録・更新・削除の操作が記録されるようになります。なお、閲覧についてはビューのコードを変更する必要があります。具体的な方法については後述します。

### モデルの登録・更新・削除の記録を確認する
adminにログインすると、画面上部に「AUDIT LOG」という項目が表示されています。これがdjango-auditlogの監査ログです。

![Site_administration___Django_site_admin.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7809/6f3f7038-41bc-6740-b734-71709452081c.jpeg)

admin上で`Book`モデルの登録・更新・削除を行ってから、`http://127.0.0.1:8000/admin/auditlog/logentry/`を開いてください。モデルの操作履歴が表示されているはずです。

![Select_log_entry_to_change___Django_site_admin_と_ryu22e_macbook-pro-4__Users_ryu22e_development_django_auditlog_example.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7809/5d226788-b800-eca4-5f47-a92671831634.jpeg)

一部のモデルやフィールドの操作を記録したくない場合は、`AUDITLOG_EXCLUDE_TRACKING_FIELDS`と`AUDITLOG_EXCLUDE_TRACKING_FIELDS`を追記します。

```python
# 記録しないモデル
AUDITLOG_EXCLUDE_TRACKING_MODELS = (
    "accounts.User",
)
# 記録しないフィールド
AUDITLOG_EXCLUDE_TRACKING_FIELDS = (
    "created",
    "modified"
)
```

### モデルの閲覧記録を確認する
閲覧記録の場合は、ビューのコードに手を加える必要があります。
以下のようにクラスビューに`auditlog.mixins.LogAccessMixin`クラスを継承させます。

```python:books/views.py
from auditlog.mixins import LogAccessMixin
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic.detail import DetailView

from .models import Book


class DetailView(LogAccessMixin, LoginRequiredMixin, DetailView):
    model = Book
```

`LogAccessMixin`クラスは`render_to_response()`メソッドをオーバーライドするので、`render_to_response()`メソッドを定義していないクラスビューには使えません。つまり、[Django REST framework](https://www.django-rest-framework.org/)のビューの場合は、`LogAccessMixin`クラスは使えません。

 Django REST frameworkの場合は、以下のように`get_object()`メソッドをオーバーライドして、取得したモデルのインスタンスを`auditlog.signals.accessed.send()`メソッドに渡します。

```python:books/views.py
from rest_framework import viewsets
from rest_framework.permissions import IsAuthenticated
from auditlog.signals import accessed

from .models import Book
from .serializers import BookSerializer

class BookRetrieveViewSet(viewsets.ReadOnlyModelViewSet):
    queryset = Book.objects.all()
    serializer_class = BookSerializer
    permission_classes = (IsAuthenticated,)

    # ↓これを追加
    def get_object(self):
        obj = super().get_object()
        # ↓ここで閲覧の記録を取れる
        accessed.send(sender=obj.__class__, instance=obj)
        return obj
```

## django-auditlogで提供していない機能
以下の機能はdjango-auditlogでは提供していないので、必要なら自分で仕組みを実装してください。

### 監査ログのレポート出力
django-auditlogではadmin上でログを確認する手段しか提供していません。別の形式でレポートを出力したい場合は、以下のように[auditlog.models.LogEntry](https://django-auditlog.readthedocs.io/en/latest/internals.html#auditlog.models.LogEntry)クラスを使って監査ログを取得してからレポートを出力するコードを書くといいでしょう。

```python
from pathlib import Path
from datetime import timezone

from django.core.management.base import BaseCommand
from dateutil.parser import parse
from auditlog.models import LogEntry


class Command(BaseCommand):
    help = "Output audit log report"

    def _generate_row(self, log_entry):
        return f"{log_entry.timestamp} {log_entry.get_action_display()} {log_entry.content_type} {log_entry.action} {log_entry.changes}"

    def add_arguments(self, parser):
        parser.add_argument("--start_datetime", "-s", type=str, help="Start datetime")
        parser.add_argument("--end_datetime", "-e", type=str, help="End datetime")
        parser.add_argument("--output_file", "-o", type=str, help="Output file")

    def handle(self, *args, **options):
        start_datetime = parse(options["start_datetime"]).astimezone(timezone.utc)
        end_datetime = parse(options["end_datetime"]).astimezone(timezone.utc)
        output_file = options["output_file"]

        p = Path(output_file)
        for log_entry in LogEntry.objects.filter(
            timestamp__gte=start_datetime, timestamp__lte=end_datetime
        ):
            row = self._generate_row(log_entry)
            p.write_text(f"{row}\n")

        self.stdout.write(self.style.SUCCESS(f"Report written to {output_file}"))
```

### 古い監査ログの削除
保存された監査ログは有効期限がないので溜まっていく一方です。以下のようなコマンドを実装し、定期実行して古い監査ログを削除するといいでしょう。

```python
from django.core.management.base import BaseCommand
from django.utils import timezone
from dateutil.relativedelta import relativedelta
from auditlog.models import LogEntry


class Command(BaseCommand):
    help = "Cleanup auditlog entries"

    def _generate_row(self, log_entry):
        return f"{log_entry.timestamp} {log_entry.get_action_display()} {log_entry.content_type} {log_entry.action} {log_entry.changes}"

    def add_arguments(self, parser):
        parser.add_argument(
            "--retention_days", "-r", type=int, default=1825, help="Retention days"
        )

    def handle(self, *args, **options):
        retention_days = options["retention_days"]

        result = LogEntry.objects.filter(
            timestamp__date__lt=(
                timezone.now() - relativedelta(days=retention_days)
            ).date()
        ).delete()
        c = result[0]

        self.stdout.write(self.style.SUCCESS(f"Deleted {c} rows"))
```

## バージョン3系にアップデートするにあたっての注意点
2024/03/17現在、django-auditlogの最新バージョンは2.3.0です。バージョン3からはデータの扱いが変わるため、以下の手順でデータを変換する必要があります。

1. `settings.py`に以下の設定を追加
    1. `AUDITLOG_TWO_STEP_MIGRATION = True`
    1. `AUDITLOG_USE_TEXT_CHANGES_IF_JSON_IS_NOT_PRESENT = True`
1. django-auditlogをバージョン3系にアップデートしてデプロイ
1. `django-admin auditlogmigratejson`コマンドで2系のデータを3系のデータに変換
1. `1.`で加えた設定をすべて削除するか、値を`False`に変更してデプロイ

メンテナンス時間を置かずに上記作業を実施する場合は、古いデータは `LogEntry.changes_text` でアクセスするコードに変更してください。

バージョン3系への移行についての詳細は、以下公式ドキュメントも参照してください。
[Upgrading to version 3 — django-auditlog 3.0b4.post6+gac720cd documentation](https://django-auditlog.readthedocs.io/en/latest/upgrade.html)