DjangoのマイグレーションでPermissionにアクセス

Djangoマイグレーション時にPermissionにアクセスする方法をまとめます。

実行環境

マイグレーションファイルの作成

空のマイグレーションファイルを作成します。

python manage.py makemigrations --empty yourappname
from django.db import migrations


class Migration(migrations.Migration):

    dependencies = [
        ('yourappname', '0001_initial'),
    ]

    operations = [
    ]

RunPythonでPermissionにアクセス

RunPythonの第1引数に与えた access_perm でPermissionにアクセスする状況を想定します。
ここではデフォルトで作成される auth.view_user の取得を試みます。

from django.db import migrations


def access_perm(apps, schema_editor):
    Permission = apps.get_model('auth', 'Permission')
    Permission.objects.get(
        codename='view_user',
        content_type__app_label='auth'
    )


class Migration(migrations.Migration):

    dependencies = [
        ...
    ]

    operations = [
        migrations.RunPython(access_perm),
    ]

しかし、このコードは以下のエラーを出力します。

Permission.DoesNotExist: Permission matching query does not exist.

これはmigrateの最後に送信されるpost_migrateシグナルがDefault permissionsを作成するためです。

...it will create default permissions for new models each time you run manage.py migrate (the function that creates permissions is connected to the post_migrate signal).

post_migrateシグナルの明示的な送信

ということで、django.core.management.sql.emit_post_migrate_signal(verbosity, interactive, db)post_migrate シグナルを送信することでPermissionを作成します。
第3引数のデータベース名はSchemaEditor.connection.aliasから取得してますが、1つしかデータベースがないなら 'default' 固定でも良いです。

from django.core.management.sql import emit_post_migrate_signal

def access_perm(apps, schema_editor):
    db_alias = schema_editor.connection.alias
    emit_post_migrate_signal(2, False, db_alias)

    Permission = apps.get_model('auth', 'Permission')
    Permission.objects.get(
        codename='view_user',
        content_type__app_label='auth'
    )

これでマイグレーションの途中でPermissionが作成され、参照できるようになりました。

$ ./manage.py migrate
...
Running migrations:
...
  Applying yourappname.yourmigrationfile...Running post-migrate handlers for application admin
Adding content type 'admin | logentry'
Adding permission 'admin | log entry | Can add log entry'
Adding permission 'admin | log entry | Can change log entry'
Adding permission 'admin | log entry | Can delete log entry'
Adding permission 'admin | log entry | Can view log entry'
Running post-migrate handlers for application auth
...
Adding permission 'auth | user | Can view user'
...

ただし、他のアプリの post_migrate を実行してしまうため、 emit_post_migrate_signal を実行した段階で存在しないテーブルを参照しようとしてしまう場合があり、完全に安全な方法ではなさそうです。このあたりのチケットでもう少しシンプルに扱えるようになると嬉しいものです。
(そもそもMigration Operationで頑張らずにpost_migrateシグナルで実装すれば良い気がする。)

Wagtail CMSのElasticsearchバックエンドで日本語の全文検索

Wagtail CMSで日本語の全文検索に対応する方法をまとめます。

実行環境

Elasticsearchのインストール

www.digitalocean.com

$ curl -fsSL https://artifacts.elastic.co/GPG-KEY-elasticsearch | sudo apt-key add -
$ echo "deb https://artifacts.elastic.co/packages/7.x/apt stable main" | sudo tee -a /etc/apt/sources.list.d/elastic-7.x.list
$ sudo apt update
$ sudo apt install -y elasticsearch
$ echo "network.host: localhost" | sudo tee -a /etc/elasticsearch/elasticsearch.yml
$ sudo systemctl start elasticsearch
$ sudo systemctl enable elasticsearch

Elasticsearchがポート9200で動作していることを確認します。
バージョンは7.17.1でした。

$ curl -X GET 'http://localhost:9200'
{
  "name" : "...",
  "cluster_name" : "elasticsearch",
  "cluster_uuid" : "...",
  "version" : {
    "number" : "7.17.1",
    "build_flavor" : "default",
    "build_type" : "deb",
    "build_hash" : "...",
    "build_date" : "...",
    "build_snapshot" : false,
    "lucene_version" : "8.11.1",
    "minimum_wire_compatibility_version" : "6.8.0",
    "minimum_index_compatibility_version" : "6.0.0-beta1"
  },
  "tagline" : "You Know, for Search"
}

ICUとkuromojiのインストール

日本語文字列を正規化するICUと日本語の形態素解析器であるkuromojiを追加します。

$ sudo /usr/share/elasticsearch/bin/elasticsearch-plugin install analysis-icu
$ sudo /usr/share/elasticsearch/bin/elasticsearch-plugin install analysis-kuromoji
$ sudo systemctl restart elasticsearch

Wagtailのバックエンド設定

Backends — Wagtail Documentation 2.16.1 documentation

Elasticsearch7用のPythonクライアントをインストールします。

$ pip install "elasticsearch>=7.0.0,<8.0.0" 

settings.WAGTAILSEARCH_BACKENDSwagtail.search.backends.elasticsearch7 を使用し、 INDEX_SETTINGS で日本語を扱うAnalyzerを構成します。
char_filter にはICU Normalization Character Filterを、 filter には必要なkuromojiのToken filterを指定します。

WAGTAILSEARCH_BACKENDS = {
    'default': {
        'BACKEND': 'wagtail.search.backends.elasticsearch7',
        'URLS': ['http://localhost:9200'],
        'INDEX': 'wagtail',
        'TIMEOUT': 5,
        'OPTIONS': {},
        'INDEX_SETTINGS': {
            'settings': {
                'analysis': {
                    'analyzer': {
                        'ja_analyzer': {
                            'type': 'custom',
                            'char_filter': ['icu_normalizer'],
                            'tokenizer': 'kuromoji_tokenizer',
                            'filter': [
                                'kuromoji_baseform',
                                'kuromoji_part_of_speech',
                                'ja_stop',
                                'kuromoji_number',
                                'kuromoji_stemmer'
                            ]
                        }
                    }
                }
            }
        }
    }
}

最後にupdate-indexコマンドでインデクスをリビルドします。

$ ./manage.py update_index

動作確認

部分一致検索が有効なPage.titleで日本語文字列の検索ができることを確認します。

>>> from my_app.models import MyPage
>>> from wagtail.search.backends import get_search_backend
>>> s = get_search_backend()
>>> MyPage.objects.get(title='関西国際空港')
<MyPage: 関西国際空港>

>>> s.search('関西', MyPage)
<SearchResults [<MyPage: 関西国際空港>]>
>>> s.search('国際', MyPage)
<SearchResults [<MyPage: 関西国際空港>]>
>>> s.search('空', MyPage)
<SearchResults [<MyPage: 関西国際空港>]>
>>> s.search('ほげ', MyPage)
<SearchResults []>

参考

Djangoのmodels.Field.validatorsに引数を渡す

django.db.models.Field.validatorsの関数に引数を与えられるようにします。
例えば以下のようなバリデータにおいて、ハードコードされた数値 100 をモデルフィールド単位に指定できるようにします。

def equal_to_100(value):
    if value != 100:
        raise ValidationError(f'{value} is not equal to 100.')
    
class MyModel(models.Model):
    foo = models.IntegerField(validators=[equal_to_100, ])

実行環境

カスタムバリデータクラスの実装

RegexValidatorのようなクラスベースのカスタムバリデータを作成します。
__init__ で任意のバリデータとキーワード引数を受け付け、 __call__ でバリデーションします。
migrationフレームワークシリアライズできるように、 @deconstructible の付与と __eq__ の実装が必要です(詳細はこちら)。

from django.utils.deconstruct import deconstructible

@deconstructible
class BaseValidator:
    def __init__(self, validator, **kwargs):
        self.validator = validator
        self.kwargs = kwargs

    def __call__(self, value):
        self.validator(value, **self.kwargs)

    def __eq__(self, other):
        return (
            isinstance(other, self.__class__) and
            self.validator == other.validator and
            self.kwargs == other.kwargs
        )

バリデータに引数を追加

これでバリデータの指定を validators=[BaseValidator(your_validator, param1=..., param2=...)] といった具合に変更することで、既存の関数に任意のキーワード引数を与えられるようになります。

from django.core.exceptions import ValidationError

def equal_to(value, expected=None):
    if value != expected:
        raise ValidationError(f'{value} is not equal to {expected}.')

class MyModel(models.Model):
    foo = models.IntegerField(validators=[BaseValidator(equal_to, expected=100)])

WagtailをTransifexで翻訳してみた

DjangoベースのCMSであるWagtailを使い始めましたが、日本語対応がまだまだな印象なので翻訳していきます。

f:id:hiroki-sawano:20211105012953p:plain

翻訳はWebベースのTransifexが使われているので簡単です。Djangoと同じですね。

docs.wagtail.io

You can find a list of currently available translations on Wagtail’s Transifex page....
...you can easily contribute new languages or correct mistakes. Sign up and submit changes to Transifex. Translation updates are typically merged into an official release within one month of being submitted.

日本語への対応状況は6割程度でした。

www.transifex.com

f:id:hiroki-sawano:20211104221908p:plain

Transifexアカウントの作成

まずはこちらからサインアップします。

f:id:hiroki-sawano:20211105010537p:plain

f:id:hiroki-sawano:20211105010714p:plain

言語とリソースの選択

[Languages] > [Japanese]から翻訳対象を選択します。
今回はAdminサイトの wagtailadmin を翻訳していきます。

f:id:hiroki-sawano:20211105005121p:plain

日本語に翻訳

目当ての文字列を検索して、中央のテキストフォームに日本語を入力し、「Save Changes」するだけです。
More InfoOccurrences からソースを確認した上で、文脈に適した訳文を書きます。
例えば以下だと templates/wagtailadmin/pages/workflow_history/detail.html の14行目なので、ここですね。
同様のキーワードに対する訳を参照して全体的な統一感を持たせることも意識していきます。

f:id:hiroki-sawano:20211105011612p:plain

さいごに

結果は後で追記します。

Synology DiskStation DS215jでドライブの読み込み異常(UNCエラー)を検出

先日、何気なく自宅のNASにアクセスすると、あるドライブで読み込み異常が検出されていました。
6年間使い続けているので遂にディスクの寿命がきたのでしょうか...。

f:id:hiroki-sawano:20211031021916p:plain

イベントの詳細

「S.M.A.R.T」の拡張テストを実行し、指示に従うように、とのことです。

f:id:hiroki-sawano:20211031021901p:plain

S.M.A.R.T. Attributeの確認

kb.synology.com

Your drives may be damaged if the raw data value is not 0 for ID 1 (drives of WD and Samsung) or ID 5, 197, and 198 (drives of all other manufacturers).

Raw DataはID 1のRaw_Read_Error_Rateが38でID 197のCurrent_Pending_Sectorが1なので確かに異常が確認できます。

f:id:hiroki-sawano:20211101003404p:plain f:id:hiroki-sawano:20211101003417p:plain

拡張テストの実行

[Storage Manager] -> [HDD/SSD] -> [Health Info] -> [S.M.A.R.T]から「Extended Test」を実行します。

f:id:hiroki-sawano:20211031022502p:plain

402分ほどかかるようです。完了まで気長に待ちます。

f:id:hiroki-sawano:20211031022718p:plain

テスト結果

翌日結果を確認すると、予想に反して「Healthy」でした。

f:id:hiroki-sawano:20211101001214p:plain

どうやら以下の2つ目のケースに該当し、 テストではそこまで深刻ではないと診断されたようです。

In the following situations, you might receive warnings regarding your drives' bad sectors, but find no abnormality from the S.M.A.R.T. test.

・The value of raw data is 0 for ID 5, 197, 198, and 199.

・The value of raw data is not 0 for ID 5, 197, 198, and 199, but the drive status is not serious enough to be diagnosed as drive failure by the S.M.A.R.T. test.

今の段階でディスク交換に踏み切るのは早計な気がするので、もう少しペンディングセクタの増加を監視してから判断することにします。

異常検出時の通知について

本来であればメール通知で早期に気付けた問題ですが、いつの間にか401 Unauthorizedエラーでメール送信が失敗するようになっていました。

f:id:hiroki-sawano:20211103155838p:plain

再ログインして問題解決です。以下のようなメールが受け取れるようになりました。
通知失敗の通知が受け取れずにエラーを見落とすのは恐ろしいですが、レアケースなのでまぁいいでしょう。気が向いたらSMS通知も設定します。

f:id:hiroki-sawano:20211101001157p:plain

Kubernetes(EKS)でApacheの統計情報を使用した水平オートスケール(Prometheus Adaptor/Apache Exporter)

本エントリではApache(prefork)のプロセス数でPodを水平オートスケールする手順をまとめます。
大まかな流れとしては、Apache ExporterPrometheusにメトリクスを収集し、Prometheus AdaptorHPAにカスタムメトリクスを連携していきます。

実行環境

  • Kubernetes(Amazon EKS): 1.18
  • helm: 3.4.1
  • prometheus-community/prometheus: 14.3.0
  • prometheus-community/prometheus-adapter: 2.14.2
  • Apache: 2.4
  • apache_exporter: 0.9.0

Prometheusの名前空間を作成

$ kubectl create namespace prometheus

Helmチャートリポジトリの追加

# See https://github.com/prometheus-community/helm-charts
$ helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
$ helm repo update

Prometheusのインストール

$ helm upgrade -i prometheus prometheus-community/prometheus \
    --namespace prometheus \
    --set alertmanager.persistentVolume.storageClass="gp2",server.persistentVolume.storageClass="gp2"

$ helm ls -n prometheus
NAME            NAMESPACE       REVISION        UPDATED                                 STATUS          CHART                   APP VERSION
prometheus      prometheus      1               2021-06-29 17:08:33.697191 +0900 JST    deployed        prometheus-14.3.0       2.26.0

Apache統計情報の収集

以下のDeployment apache を例に、 apache コンテナの統計情報をPrometheusに収集するための設定を行なっていきます。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: apache
  labels:
    app: apache
spec:
  selector:
    matchLabels:
      app: apache
  template:
    metadata:
      labels:
        app: apache
    spec:
      containers:
      - name: apache
        ...

Apacheの設定

apache コンテナで次の設定を行います。

mod_statusの有効化

サーバの統計情報を取得するため、 status_module が有効であることを確認します。

# apachectl -M | grep status_module
status_module (shared)

ステータスの公開

localhostからのアクセスのみを対象に、 /server-status でステータスを取得できるように以下の設定を追加します。

<Location "/server-status">
    SetHandler server-status
    Require host localhost
</Location>

メトリクスの収集

Deployment apacheapache-exporterコンテナを追加します。
また、 spec.template.metadata.annotationsapache_exporterがメトリクスを公開するエンドポイントに関する情報を指定します。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: apache
  labels:
    app: apache
spec:
  selector:
    matchLabels:
      app: apache
  template:
    metadata:
      labels:
        app: apache
      annotations:
        prometheus.io/scrape: 'true'
        prometheus.io/port: '9117'
        prometheus.io/path: /metrics
    spec:
      containers:
      - name: apache-exporter
        image: bitnami/apache-exporter:0.9.0
      - name: apache
        ...

Prometheus ServerのWebUIからApache統計情報が取得できることを確認します。

$ kubectl --namespace=prometheus port-forward deploy/prometheus-server 9090

f:id:hiroki-sawano:20210707160136p:plain

Prometheus Adapterのインストール

ここまででPrometheusに収集したメトリクスをKubernetesに連携するため、prometheus-adapterをインストールします。
チャートに渡す設定ファイルでは、こちらを参考に、 state="busy"apache_workersapache_busy_workers (プロセス数)として公開します。

rules:
  custom:
  - seriesQuery: 'apache_workers{kubernetes_namespace!="",kubernetes_pod_name!="",state="busy"}'
    resources:
      overrides:
        kubernetes_namespace: {resource: "namespace"}
        kubernetes_pod_name: {resource: "pod"}
    name:
      as: "apache_busy_workers"
    metricsQuery: 'sum(<<.Series>>{<<.LabelMatchers>>,state="busy"}) by (<<.GroupBy>>)'

prometheus:
  url: http://prometheus-server
  port: 80
  path: "/"
$ helm upgrade -i prometheus-adapter prometheus-community/prometheus-adapter \
    --namespace prometheus \
    --values config.yaml
$ helm ls -n prometheus
NAME                    NAMESPACE       REVISION        UPDATED                                 STATUS          CHART                           APP VERSION
prometheus              prometheus      1               2021-06-29 17:08:33.697191 +0900 JST    deployed        prometheus-14.3.0               2.26.0
prometheus-adapter      prometheus      1               2021-06-30 15:38:14.160989 +0900 JST    deployed        prometheus-adapter-2.14.2       v0.8.4

カスタムメトリクスAPIから apache_busy_workers が取得できることを確認します。
この例では、2つのApacheサーバでそれぞれ9つ、6つのプロセスが起動していることがわかります。

$ kubectl get --raw /apis/custom.metrics.k8s.io/v1beta1/namespaces/<namespace>/pods/*/apache_busy_workers | jq
{
  "kind": "MetricValueList",
  "apiVersion": "custom.metrics.k8s.io/v1beta1",
  "metadata": {
    "selfLink": "/apis/custom.metrics.k8s.io/v1beta1/namespaces/<namespace>/pods/%2A/apache_busy_workers"
  },
  "items": [
    {
      "describedObject": {
        "kind": "Pod",
        "namespace": "<namespace>",
        "name": "<pod-name>",
        "apiVersion": "/v1"
      },
      "metricName": "apache_busy_workers",
      "timestamp": "2021-07-05T11:15:11Z",
      "value": "9",
      "selector": null
    },
    {
      "describedObject": {
        "kind": "Pod",
        "namespace": "<namespace>",
        "name": "<pod-name>",
        "apiVersion": "/v1"
      },
      "metricName": "apache_busy_workers",
      "timestamp": "2021-07-05T11:15:11Z",
      "value": "6",
      "selector": null
    }
  ]
}

プロセス数で水平オートスケール

最後にHPAで全Apacheサーバのプロセス数平均を基準としたオートスケールの設定を行います。
ここでは平均プロセス数 75閾値に指定します。

apiVersion: autoscaling/v2beta2
kind: HorizontalPodAutoscaler
metadata:
  name: apache
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: apache
  minReplicas: 2
  maxReplicas: 5
  metrics:
  - type: Pods
    pods:
      metric:
        name: apache_busy_workers
      target:
        type: AverageValue
        averageValue: 75

これでプロセス数の増減に応じてDeployment apache がオートスケールするようになりました。

$ kubectl get hpa -n lms  # 2つのApacheでプロセス数がそれぞれ9つ、6つの場合
NAME     REFERENCE           TARGETS    MINPODS   MAXPODS   REPLICAS   AGE
apache   Deployment/apache   7500m/75   2         5         2          173m

参考

Djangoのフォームウィザードで単一モデルオブジェクトを登録・更新(django-formtools 2.2)

Djangoでモデルオブジェクトの登録・更新機能は、汎用ビューであるCreateViewUpdateViewで簡単に実装することができます。
しかし扱うモデルフィールドが多い場合、これらのビューは1つの画面に多数のフォームを描画するために使用性が低くなりがちです。

そこで本エントリでは、このような課題を django-formtools のフォームウィザードで解決していきます。

github.com

フォームウィザードの完成イメージがこちらです。
ページ切り替えの都度、フォームバリデーションを実行し、中間データはセッションに保存されます。

f:id:hiroki-sawano:20210127173048g:plain

django-formtoolsのインストール

pipで django-formtools をインストールします。

pip install django-formtools

執筆時点でバージョンは2.2です。

❯ pip show django-formtools
Name: django-formtools
Version: 2.2
...

モデル

更新対象となるモデルを用意します。
フォームバリデーションおよびファイルアップロードの動きを確認するため、 char1 のみ入力必須、 file のみFileFieldとしています。

from django.db import models


class MyModel(models.Model):
    char1 = models.CharField('項目1', max_length=255)
    char2 = models.CharField('項目2', max_length=255, blank=True, null=True)
    char3 = models.CharField('項目3', max_length=255, blank=True, null=True)
    char4 = models.CharField('項目4', max_length=255, blank=True, null=True)
    file = models.FileField('ファイル', blank=True, null=True)

フォーム

フォームウィザードの各ページで表示するフォームをそれぞれModelFormとして作成します。
いずれもモデル MyModel を扱うため、 Meta.model = MyModel とし、 Meta.fields に各ページで表示するフォームフィールドを指定します。

from django import forms

from .models import MyModel


class Form0(forms.ModelForm):
    class Meta:
        model = MyModel
        fields = ['char1', 'char2', 'file']


class Form1(forms.ModelForm):
    class Meta:
        model = MyModel
        fields = ['char3', 'char4']

テンプレート

新規登録・更新ウィザードの両方で利用するテンプレート wizard_form.html を作成します。
Bootstrapとdjango-crispy-formsを使用した例です。
利用可能なコンテキストの詳細についてはこちらを参照してください。

{% load crispy_forms_tags %}
...
{{ wizard.form.media }}
...
<form method="post" enctype="multipart/form-data" novalidate>
  {% csrf_token %}
  {{ wizard.management_form }}
  {% if wizard.form.forms %}
    {{ wizard.form.management_form }}
    {% for form in wizard.form.forms %}
      {{ form|crispy }}
    {% endfor %}
  {% else %}
    {{ wizard.form|crispy }}
  {% endif %}
  <div class="row">
    <div class="col-lg-12">
      {% if wizard.steps.prev %}
        <button type="submit" class="btn btn-success pull-left" name="wizard_goto_step" value="{{ wizard.steps.prev }}">
          戻る
        </button>
      {% endif %}
      {% if wizard.steps.step1 < wizard.steps.count %}
        <button type="submit" class="btn btn-success pull-right">
          次へ
        </button>
      {% else %}
        <button type="submit" class="btn btn-success pull-right">
          登録
        </button>
      {% endif %}
    </div>
  </div>
</form>

ビュー

ビューはWizardViewのサブクラスとして実装します。
中間データの保存方法( WizardView.storage_name )に応じて、次のクラスが用意されていますので、実際にはこちらを使うと良いです。

以降の説明ではSessionWizardViewを使用していきます。

from formtools.wizard.views import SessionWizardView

class MyWizardView(SessionWizardView):
    ...

新規作成フォームウィザード

SessionWizardViewを継承し、次の実装を持つ汎用クラスビュー WizardCreateView を実装します。

  • file_storageFileFieldを扱う場合に、中間ファイルの保存先を指定する設定です。例ではMEDIA_ROOT以下の temp ディレクトリを指定しています。
  • model:後述するURLのas_view()で更新対象となるモデルクラスを指定するフィールドです。
  • done():最終ページのフォーム送信で、実際にモデルを更新する実装を持ちます。
import os

from django.conf import settings
from django.core.files.storage import FileSystemStorage
from django.http import HttpResponseRedirect
from formtools.wizard.views import SessionWizardView


class WizardCreateView(SessionWizardView):
    file_storage = FileSystemStorage(
        location=os.path.join(settings.MEDIA_ROOT, 'temp')
    )
    model = None

    def done(self, form_list, **kwargs):
        # 各フォームデータをマージしてモデルオブジェクトを作成
        data = {}
        for form in form_list:
            data.update(form.cleaned_data)
        self.model.objects.create(**data)
        # 遷移先の指定
        return HttpResponseRedirect(...)

続いて、urlpatternspath()を追加します。
as_view()には描画するフォームのクラスをリストで渡します。
使用するテンプレートはtemplate_nameに、 更新対象のモデルは model に指定します。

from .forms import Form0, Form1
from .models import MyModel
from .views import WizardCreateView

urlpatterns = [
    path(
        'my-model/new',
        WizardCreateView.as_view(
            [Form0, Form1],
            template_name='wizard_form.html',
            model=MyModel,
        ),
        name='create'
    ),
]

my-model/new にアクセスすると、 Form0 で定義した3つのフォームが表示されます。

f:id:hiroki-sawano:20210126232251p:plain

値を入力せずに「次へ」でフォームを送信すると、フォームバリデーションが実行され、必須項目 「項目1」がエラーとなります。

f:id:hiroki-sawano:20210126232320p:plain

各フォームに適当な値を入力し、再度フォームを送信します。

f:id:hiroki-sawano:20210126232355p:plain

正常なフォームを送信したため、次のページに遷移し、 Form1 が表示されました。

f:id:hiroki-sawano:20210126232439p:plain

この時、送信した Form0 の値はセッションに保存されています。
アップロードしたファイルは一時的に WizardCreateView.file_storageで指定したパスに保存されます。

$ ls media/temp/
test.txt

「戻る」から前のページに戻るとセッションに保存された情報が読み込まれます。
ファイルはフォーム上表示されていませんが、実際には中間ファイルとして保存されているので、そのまま進んでもちゃんと登録されます(関連Issue)。

f:id:hiroki-sawano:20210126232613p:plain

次のページに再度進み、「項目3」のみ値を入力して「登録」します。

f:id:hiroki-sawano:20210126232728p:plain

MyModel から登録したデータが取得できることを確認します。

>>> instance = MyModel.objects.first()
>>> instance.char1
'aaa'
>>> instance.char2
'bbb'
>>> instance.char3
'ccc'
>>> instance.char4
>>> instance.file
<FieldFile: test.txt>

登録が完了すると media/temp に保存されていた中間ファイルはMEDIA_ROOTに移動します。
ファイルアップロード後に登録を中断した場合には media/temp にファイルが残ってしまうため、定期的なメンテナンスが必要となる点に注意してください。

$ ls media/
test.txt

$ ls media/temp/

更新ウィザード

新規登録ウィザードと同様に、SessionWizardViewを継承し、次の実装を持つ汎用クラスビュー WizardUpdateView を実装します。

  • get_form_instance():URLパラメタ pk をキーに取得した MyModel の更新対象オブジェクトを返却します。
  • done():最終ページのフォーム送信で、実際にモデルを更新する実装を持ちます。
from django.db.models.fields.files import FieldFile


class WizardUpdateView(SessionWizardView):
    file_storage = FileSystemStorage(
        location=os.path.join(settings.MEDIA_ROOT, 'temp')
    )
    model = None

    def get_form_instance(self, step):
        pk = self.kwargs['pk']
        return self.model.objects.get(pk=pk)

    def done(self, form_list, **kwargs):
        # 各フォームデータをマージ
        data = {}
        for form in form_list:
            data.update(form.cleaned_data)
        # 更新対象のオブジェクトを取得
        instance = self.model.objects.get(pk=kwargs['pk'])
        # instance.update(**data)だとFileFieldのファイル保存がうまく機能しなかったため、save()を使用
        for attr, value in data.items():
            # FileFieldの場合はClearableFileInputでクリアするときにFalseがくるのでNoneを設定
            if isinstance(getattr(instance, attr), FieldFile) and not value:
                setattr(instance, attr, None)
            else:
                setattr(instance, attr, value)
        instance.save()
        # 遷移先の指定
        return HttpResponseRedirect(...)

URLを追加します。更新対象オブジェクトのPK値をパラメタとして渡すようにします。

urlpatterns = [
    ...
    path(
        'my-model/<int:pk>',
        WizardUpdateView.as_view(
            [Form0, Form1],
            template_name='wizard_form.html',
            model=MyModel,
        ),
        name='update'
    ),
]

my-model/<int:pk> にアクセスして先ほど登録したデータを更新します。

f:id:hiroki-sawano:20210126233908p:plain

試しに「項目1」を空にして、フォームバリデーションが新規作成時と同様に機能することを確認します。

f:id:hiroki-sawano:20210126233952p:plain

「項目1」を aaa から zzz に変更し、「ファイル」のクリアにチェックを入れて(削除指示)、「次へ」を押下します。

次のページでは、「項目3」に ccc が登録されています。

「項目3」を ddd に変更し、「項目4」に eee を入力して「登録」します。

これで MyModel の既存データを更新することができました。

>>> instance = MyModel.objects.first()
>>> instance.char1
'zzz'
>>> instance.char2
'bbb'
>>> instance.char3
'ddd'
>>> instance.char4
'eee'
>>> instance.file
<FieldFile: None>

参考