StreamField 마이그레이션

RichTextField를 StreamField로 마이그레이션하기

기존 RichTextFieldStreamField 로 변경하더라도, 두 필드 모두 데이터베이스 내에서 텍스트 컬럼을 사용하므로 데이터베이스 마이그레이션은 오류 없이 완료됩니다. 하지만 StreamField 는 데이터에 JSON 표현을 사용하기 때문에, 기존 텍스트를 다시 접근 가능하게 하려면 추가적인 변환 단계가 필요합니다. 이를 위해 StreamField 는 사용 가능한 블록 타입 중 하나로 RichTextBlock 을 포함해야 합니다. ./manage.py makemigrations 를 사용하여 평소처럼 마이그레이션을 생성한 다음, 다음과 같이 수정하십시오 (이 예시에서는 demo.BlogPage 모델의 ‘body’ 필드가 rich_text 라는 이름의 RichTextBlock 을 포함하는 StreamField 로 변환됩니다):

import json

from django.core.serializers.json import DjangoJSONEncoder
from django.db import migrations

import wagtail.blocks
import wagtail.fields


def convert_to_streamfield(apps, schema_editor):
    BlogPage = apps.get_model("demo", "BlogPage")
    for page in BlogPage.objects.all():
        page.body = json.dumps(
            [{"type": "rich_text", "value": page.body}],
            cls=DjangoJSONEncoder
        )
        page.save()


def convert_to_richtext(apps, schema_editor):
    BlogPage = apps.get_model("demo", "BlogPage")
    for page in BlogPage.objects.all():
        if page.body:
            stream = json.loads(page.body)
            page.body = "".join([
                child["value"] for child in stream
                if child["type"] == "rich_text"
            ])
            page.save()


class Migration(migrations.Migration):

    dependencies = [
        # 생성된 마이그레이션의 의존성(dependency) 라인을 그대로 두십시오!
        ("demo", "0001_initial"),
    ]

    operations = [
        migrations.RunPython(
            convert_to_streamfield,
            convert_to_richtext,
        ),

        # 생성된 AlterField를 그대로 두십시오!
        migrations.AlterField(
            model_name="BlogPage",
            name="body",
            field=wagtail.fields.StreamField(
                [("rich_text", wagtail.blocks.RichTextBlock())],
            ),
        ),
    ]

위 마이그레이션은 게시된 Page 객체에만 작동합니다. 드래프트 페이지와 페이지 리비전도 마이그레이션해야 하는 경우, 대신 다음 예시처럼 마이그레이션을 수정하십시오:

import json

from django.contrib.contenttypes.models import ContentType
from django.core.serializers.json import DjangoJSONEncoder
from django.db import migrations

import wagtail.blocks
import wagtail.fields


def page_to_streamfield(page):
    changed = False
    try:
        json.loads(page.body)
    except ValueError:
        page.body = json.dumps(
            [{"type": "rich_text", "value": page.body}],
        )
        changed = True
    else:
        # 이미 유효한 JSON입니다. 그대로 두십시오.
        pass

    return page, changed


def pagerevision_to_streamfield(revision_data):
    changed = False
    body = revision_data.get("body")
    if body:
        try:
            json.loads(body)
        except ValueError:
            revision_data["body"] = json.dumps(
                [{
                    "value": body,
                    "type": "rich_text"
                }],
                cls=DjangoJSONEncoder)
            changed = True
        else:
            # 이미 유효한 JSON입니다. 그대로 두십시오.
            pass
    return revision_data, changed


def page_to_richtext(page):
    changed = False
    if page.body:
        try:
            body_data = json.loads(page.body)
        except ValueError:
            # StreamField가 아닌 것 같습니다. 그대로 두십시오.
            pass
        else:
            page.body = "".join([
                child["value"] for child in body_data
                if child["type"] == "rich_text"
            ])
            changed = True

    return page, changed


def pagerevision_to_richtext(revision_data):
    changed = False
    body = revision_data.get("body", "definitely non-JSON string")
    if body:
        try:
            body_data = json.loads(body)
        except ValueError:
            # StreamField가 아닌 것 같습니다. 그대로 두십시오.
            pass
        else:
            raw_text = "".join([
                child["value"] for child in body_data
                if child["type"] == "rich_text"
            ])
            revision_data["body"] = raw_text
            changed = True
    return revision_data, changed


def convert(apps, schema_editor, page_converter, pagerevision_converter):
    BlogPage = apps.get_model("demo", "BlogPage")
    content_type = ContentType.objects.get_for_model(BlogPage)
    Revision = apps.get_model("wagtailcore", "Revision")

    for page in BlogPage.objects.all():

        page, changed = page_converter(page)
        if changed:
            page.save()

        for revision in Revision.objects.filter(
            content_type_id=content_type.pk, object_id=page.pk
        ):
            revision_data = revision.content
            revision_data, changed = pagerevision_converter(revision_data)
            if changed:
                revision.content = revision_data
                revision.save()


def convert_to_streamfield(apps, schema_editor):
    return convert(apps, schema_editor, page_to_streamfield, pagerevision_to_streamfield)


def convert_to_richtext(apps, schema_editor):
    return convert(apps, schema_editor, page_to_richtext, pagerevision_to_richtext)


class Migration(migrations.Migration):

    dependencies = [
        # 생성된 마이그레이션의 의존성 라인을 그대로 두십시오!
        ("demo", "0001_initial"),
        ("wagtailcore", "0076_modellogentry_revision"),
    ]

    operations = [
        migrations.RunPython(
            convert_to_streamfield,
            convert_to_richtext,
        ),

        # 생성된 AlterField를 그대로 두십시오!
        migrations.AlterField(
            model_name="BlogPage",
            name="body",
            field=wagtail.fields.StreamField(
                [("rich_text", wagtail.blocks.RichTextBlock())],
            ),
        ),
    ]

StreamField 데이터 마이그레이션

Wagtail은 StreamField 데이터에 대한 데이터 마이그레이션을 생성하기 위한 유틸리티 세트를 제공합니다. 이들은 다음 모듈을 통해 노출됩니다:

  • wagtail.blocks.migrations.migrate_operation

  • wagtail.blocks.migrations.operations

  • wagtail.blocks.migrations.utils

참고

wagtail-streamfield-migration-toolkit이라는 추가 기능 패키지를 사용할 수 있으며, 마이그레이션 자동 생성에 대한 제한적인 지원을 추가로 제공합니다.

데이터 마이그레이션이 필요한 이유는 무엇입니까?

기존 데이터가 있는 모델에서 StreamField 의 블록 정의를 변경하는 경우, 새 형식과 일치하도록 해당 데이터를 수동으로 변경해야 할 수 있습니다.

StreamField 는 데이터베이스에 단일 JSON 데이터 컬럼으로 저장됩니다. 블록은 JSON 내의 구조로 저장되며, 중첩될 수 있습니다. 그러나 Django가 스키마 마이그레이션을 생성하는 한, 이 컬럼 내부의 모든 것은 JSON 데이터 문자열일 뿐입니다. 데이터베이스 스키마는 StreamField 의 내용/구조 변경과 관계없이 변경되지 않습니다. 이는 필드 타입이 변경 전후에 동일하기 때문입니다. 따라서 StreamField 에 변경 사항이 있을 때마다 기존 데이터는 필요한 새 구조로 변경되어야 하며, 일반적으로 데이터 마이그레이션을 정의하여 수행됩니다. 데이터가 마이그레이션되지 않으면 블록 이름 변경과 같은 간단한 변경도 이전 데이터가 손실될 수 있습니다.

일반적으로 데이터 마이그레이션은 빈 마이그레이션 파일을 만들고 RunPython 명령어의 forward 및 backward 함수를 작성하여 수동으로 수행됩니다. 이 함수들은 이전에 저장된 JSON 표현을 필요한 새 JSON 표현으로 변환하는 로직을 처리합니다. 이는 간단한 변경(예: 블록 이름 변경)의 경우 매우 간단하지만, 중첩된 블록, 여러 필드 및 리비전이 관련된 경우 매우 복잡해질 수 있습니다.

상용구(boilerplate)를 줄이고 오류 가능성을 줄이기 위해 wagtail.blocks.migrations 는 다음을 제공합니다:

  • 스트림 데이터 구조를 재귀적으로 탐색하고 변경 사항을 적용하는 유틸리티

  • 블록의 이름 변경, 제거 및 값 변경과 같은 일반적인 사용 사례에 대한 작업

기본 사용법

blog 라는 앱에 다음과 같이 정의된 BlogPage 모델이 있다고 가정해 봅시다:

class BlogPage(Page):
    content = StreamField([
        ("stream1", blocks.StreamBlock([
            ("field1", blocks.CharBlock())
        ])),
    ])

초기 마이그레이션을 실행하고 데이터베이스에 일부 레코드를 채운 후, field1 의 이름을 block1 으로 변경하기로 결정했습니다.

class BlogPage(Page):
    content = StreamField([
        ("stream1", blocks.StreamBlock([
            ("block1", blocks.CharBlock())
        ])),
    ])

StreamField 정의에서 이름을 block1 으로 변경했더라도 데이터베이스의 실제 데이터는 이를 반영하지 않습니다. 기존 데이터를 업데이트하려면 데이터 마이그레이션을 생성해야 합니다.

먼저 앱 내에 빈 마이그레이션 파일을 생성합니다. Django의 makemigrations 명령을 사용하여 이를 수행할 수 있습니다:

python manage.py makemigrations --empty blog

그러면 다음과 같은 빈 마이그레이션 파일이 생성됩니다:

# Generated by Django 4.0.3 on 2022-09-09 21:33

from django.db import migrations


class Migration(migrations.Migration):

    dependencies = [...]

    operations = [
    ]

이 마이그레이션 또는 이 마이그레이션이 의존하는 마이그레이션 중 하나가 Wagtail 코어 마이그레이션을 의존성으로 가지고 있는지 확인해야 합니다. 유틸리티가 실행될 수 있도록 Revision 모델에 대한 마이그레이션이 필요하기 때문입니다.

    dependencies = [
        ('wagtailcore', '0069_log_entry_jsonfield'),
        ...
    ]

(프로젝트가 Wagtail 4로 시작된 경우, ‘0076_modellogentry_revision’도 괜찮습니다)

다음으로 Django가 변경 사항을 적용하기 위해 실행할 마이그레이션 작업이 필요합니다. 제공된 유틸리티를 사용하지 않는 경우, migrations.RunPython 작업을 사용하고 forward (함수) 인수에 원하는 데이터(모델, 필드 등)와 해당 데이터를 변경하는 방법을 정의합니다.

대신, 관련 데이터에 접근하는 것을 처리할 migrate_operation.MigrateStreamData 작업이 있습니다. 아래와 같이 관련 StreamField 에 대한 앱 이름, 모델 이름 및 필드 이름을 지정해야 합니다.

from django.db import migrations

from wagtail.blocks.migrations.migrate_operation import MigrateStreamData

class Migration(migrations.Migration):

    dependencies = [...]

    operations = [
        MigrateStreamData(
            app_name="blog",
            model_name="BlogPage",
            field_name="content",
            operations_and_block_paths=[...]
        ),
    ]

StreamField 에서는 필드에만 접근하는 것으로는 충분하지 않습니다. 일반적으로 특정 블록 타입에 대해 작업을 수행해야 하기 때문입니다. 이를 위해 StreamField 정의 내에서 특정 블록 경로를 가리키는 블록 경로를 정의하여 필요한 특정 데이터를 얻습니다. 마지막으로 해당 데이터를 업데이트하는 작업을 정의합니다. 따라서 (IntraFieldOperation(), 'block_path') 튜플을 가집니다. operations_and_block_paths 에는 원하는 만큼 이러한 튜플을 가질 수 있지만, 지금은 이름 변경 작업에 대한 단일 튜플을 살펴보겠습니다.

이 경우 우리가 작업하는 블록은 stream1 이며, 이는 이름이 변경될 블록의 부모입니다 (RenameStreamChildrenOperation 참조 - 이름 변경 및 제거 작업의 경우 항상 부모 블록에 대해 작업합니다). 이 경우 블록 경로는 stream1 이 됩니다. 다음으로 데이터를 업데이트할 함수가 필요합니다. 이를 위해 wagtail.blocks.operations 모듈에는 일반적으로 사용되는 내부 필드 작업 세트가 있습니다 (또한 사용자 지정 작업을 작성할 수도 있습니다). 이는 StreamField 에서 작동하는 이름 변경 작업이므로, 이전 블록 이름과 새 블록 이름 두 가지 인수를 받는 wagtail.blocks.operations.RenameStreamChildrenOperation 을 사용합니다. 따라서 우리의 작업 및 블록 경로 튜플은 다음과 같습니다:

(RenameStreamChildrenOperation(old_name="field1", new_name="block1"), "stream1")

그리고 최종 코드는 다음과 같습니다:

from django.db import migrations

from wagtail.blocks.migrations.migrate_operation import MigrateStreamData
from wagtail.blocks.migrations.operations import RenameStreamChildrenOperation

class Migration(migrations.Migration):

    dependencies = [
        ...
    ]

    operations = [
        MigrateStreamData(
            app_name="blog",
            model_name="BlogPage",
            field_name="content",
            operations_and_block_paths=[
                (RenameStreamChildrenOperation(old_name="field1", new_name="block1"), "stream1"),
            ]
        ),
    ]

작업 및 블록 경로를 올바르게 사용하기

MigrateStreamData 클래스는 operations_and_block_paths 매개변수로 작업 및 해당 블록 경로 목록을 받습니다. 목록의 각 작업은 해당 블록 경로와 일치하는 모든 블록에 적용됩니다.

operations_and_block_paths=[
    (operation1, block_path1),
    (operation2, block_path2),
    ...
]

블록 경로

블록 경로는 최상위 StreamBlock(StreamField의 모든 블록 컨테이너)에서 일치되어 작업에 전달될 중첩된 블록 타입의 이름 목록을 . 으로 구분하여 표시한 것입니다.

참고

최상위 StreamBlock 에 직접 작업하려는 경우, 블록 경로는 빈 문자열 "" 여야 합니다.

예를 들어, 스트림 정의가 다음과 같으면:

class MyDeepNestedBlock(StreamBlock):
    foo = CharBlock()
    date = DateBlock()

class MyNestedBlock(StreamBlock):
    char1 = CharBlock()
    deepnested1 = MyDeepNestedBlock()

class MyStreamBlock(StreamBlock):
    field1 = CharBlock()
    nested1 = MyNestedBlock()

class MyPage(Page):
    content = StreamField(MyStreamBlock)

모든 “field1” 블록과 일치시키려면 블록 경로는 "field1" 이 됩니다:

[
    { "type": "field1", ... }, # 일치
    { "type": "field1", ... }, # 일치
    { "type": "nested1", "value": [...] },
    { "type": "nested1", "value": [...] },
    ...
]

nested1 의 직접적인 자식인 모든 “deepnested1” 블록과 일치시키려면 블록 경로는 "nested1.deepnested1" 이 됩니다:

[
    { "type": "field1", ... },
    { "type": "field1", ... },
    { "type": "nested1", "value": [
        { "type": "char1", ... },
        { "type": "deepnested1", ... }, # 일치
        { "type": "deepnested1", ... }, # 일치
        ...
    ] },
    { "type": "nested1", "value": [
        { "type": "char1", ... },
        { "type": "deepnested1", ... }, # 일치
        ...
    ] },
    ...
]

경로에 ListBlock 자식이 포함된 경우, 해당 자식의 이름으로 ‘item’이 블록 경로에 추가되어야 합니다. 예를 들어, 다음 스트림 정의를 고려하면:

class MyStructBlock(StructBlock):
    char1 = CharBlock()
    char2 = CharBlock()

class MyStreamBlock(StreamBlock):
    list1 = ListBlock(MyStructBlock())

직접적인 목록 자식인 StructBlock 의 자식인 “char1”과 일치시키려면 block_path_str="list1.char1" 대신 block_path_str="list1.item.char1" 을 사용해야 합니다. ListBlock 자식을 block_path_str="list1.item" 으로도 일치시킬 수 있습니다.

이름 변경 및 제거 작업

다음 작업은 블록 이름 변경 및 제거에 사용할 수 있습니다.

이러한 모든 작업은 제거 또는 이름이 변경되어야 하는 블록의 부모 블록 값에 대해 작동합니다. 따라서 이러한 작업을 사용할 때는 전달하는 블록 경로가 부모 블록을 가리키는지 확인하십시오 (기본 사용법의 예시 참조).

블록 구조 변경 작업

다음 작업은 특정 방식으로 블록 구조를 변경할 수 있습니다.

  • StreamChildrenToListBlockOperation: StreamBlock 의 값에 대해 작동합니다. block_name 타입의 모든 자식 블록을 단일 ListBlock 의 자식으로 결합하며, 이는 부모 StreamBlock 의 자식입니다.

  • StreamChildrenToStreamBlockOperation: StreamBlock 의 값에 대해 작동합니다. 여기서 block_names 는 이전 작업의 block_name 과 달리 단일 블록 타입이 아니라 블록 타입 목록입니다. block_names 에 있는 타입의 각 자식 블록을 단일 StreamBlock 의 자식으로 결합하며, 이는 부모 StreamBlock 의 자식입니다.

  • StreamChildrenToStructBlockOperation: 주어진 타입의 각 StreamBlock 자식을 새 StructBlock 내부로 이동합니다.

StructBlock 이 주어진 타입의 각 자식 블록에 대해 부모 StreamBlock 의 자식으로 생성되고, 해당 자식 블록은 부모 StreamBlock 의 자식에서 새 StructBlock 내부로 해당 StructBlock 의 자식으로 이동됩니다.

예를 들어, 다음 StreamField 정의를 고려하면:

mystream = StreamField([("char1", CharBlock()) ...], ...)

그러면 스트림 데이터는 다음과 같이 보일 것입니다:

[
    { "type": "char1", "value": "Value1", ... },
    { "type": "char1", "value": "Value2", ... },
    ...
]

그리고 작업을 다음과 같이 정의하면:

StreamChildrenToStructBlockOperation("char1", "struct1")

변경된 스트림 데이터는 다음과 같이 보일 것입니다:

[
    ...,
    { "type": "struct1", "value": { "char1": "Value1" } },
    { "type": "struct1", "value": { "char1": "Value2" } },
    ...
]

참고

새 블록이 이전 블록과 구조적으로 다르므로 블록 ID는 여기에서 보존되지 않습니다.

기타 작업

사용자 지정 작업 만들기

기본 사용법

이 패키지는 일반적인 사용 사례에 대한 일련의 작업을 제공하지만, 데이터를 매핑하기 위해 자체 작업을 정의해야 하는 경우가 많을 수 있습니다. 사용자 지정 작업을 만드는 것은 매우 간단합니다. BaseBlockOperation 클래스를 확장하고 필요한 메서드를 정의하기만 하면 됩니다.

  • apply 기존 블록 값에 실제 변경 사항을 적용하고 새 블록 값을 반환합니다.

  • operation_name_fragment (@property) 마이그레이션 이름 생성에 사용될 이름을 반환합니다.

(참고: BaseBlockOperationabc.ABC 를 상속하므로 위에 언급된 모든 필수 메서드는 이를 상속하는 모든 클래스에 정의되어야 합니다.)

예를 들어, CharBlock 의 문자열을 주어진 길이로 자르려면:

from wagtail.blocks.migrations.operations import BaseBlockOperation

class MyBlockOperation(BaseBlockOperation):
    def __init__(self, length):
        super().__init__()
        # 길이를 작업의 속성으로 유지해야 합니다.
        self.length = length

    def apply(self, block_value):
        # block_value는 CharBlock의 문자열 값입니다.
        new_block_value = block_value[:self.length]
        return new_block_value


    @property
    def operation_name_fragment(self):
        return "truncate_{}".format(self.length)

block_value

처리하는 블록 유형에 따라 apply 에 전달되는 block_value 는 다른 구조를 가질 수 있습니다.

구조적이지 않은 블록의 경우 블록의 값이 직접 전달됩니다. 예를 들어, CharBlock 을 다루는 경우 문자열 값이 됩니다.

일치하는 블록이 StreamBlock 일 때 apply 에 전달되는 값은 다음과 같습니다.

[
    { "type": "...", "value": "...", "id": "..." },
    { "type": "...", "value": "...", "id": "..." },
    ...
]

일치하는 블록이 StructBlock 일 때 apply 에 전달되는 값은 다음과 같습니다.

{
    "type1": "...",
    "type2": "...",
    ...
}

일치하는 블록이 ListBlock 일 때 apply 에 전달되는 값은 다음과 같습니다.

[
    { "type": "item", "value": "...", "id": "..." },
    { "type": "item", "value": "...", "id": "..." },
    ...
]

구조적 변경 수행

블록의 구조를 포함하는 변경(예: 블록 유형 변경)을 수행할 때는 변경이 이루어지는 블록이 아닌 부모 블록의 블록 값에 대해 작동해야 할 수 있습니다. apply 작업은 블록의 값만 변경하기 때문입니다.

예를 들어 RenameStreamChildrenOperation 의 구현을 살펴보십시오.

이전 목록 형식

Wagtail 버전 2.16 이전에는 ListBlock 자식이 일반 Python 값 목록으로 저장되었습니다. 그러나 최신 Wagtail 버전에서는 목록 블록 자식이 ListValue 로 저장됩니다. 원시 데이터를 처리할 때 변경 사항은 다음과 같습니다.

이전 형식

[
    value1,
    value2,
    ...
]

새 형식

[
    { "type": "item", "id": "...", "value": value1 },
    { "type": "item", "id": "...", "value": value2 },
    ...
]

ListBlock 값에 대해 작동하는 작업을 정의할 때, 이전 형식의 오래된 데이터가 있는 경우 wagtail.blocks.migrations.utils.formatted_list_child_generator 를 사용하여 새 형식의 자식을 다음과 같이 얻을 수 있습니다.

    def apply(self, block_value):
        for child_block in formatted_list_child_generator(list_block_value):
            ...