(streamfield_migrations)= # StreamField 마이그레이션 (streamfield_migrating_richtext)= ## RichTextField를 StreamField로 마이그레이션하기 기존 `RichTextField` 를 `StreamField` 로 변경하더라도, 두 필드 모두 데이터베이스 내에서 텍스트 컬럼을 사용하므로 데이터베이스 마이그레이션은 오류 없이 완료됩니다. 하지만 `StreamField` 는 데이터에 JSON 표현을 사용하기 때문에, 기존 텍스트를 다시 접근 가능하게 하려면 추가적인 변환 단계가 필요합니다. 이를 위해 `StreamField` 는 사용 가능한 블록 타입 중 하나로 `RichTextBlock` 을 포함해야 합니다. `./manage.py makemigrations` 를 사용하여 평소처럼 마이그레이션을 생성한 다음, 다음과 같이 수정하십시오 (이 예시에서는 `demo.BlogPage` 모델의 'body' 필드가 `rich_text` 라는 이름의 `RichTextBlock` 을 포함하는 `StreamField` 로 변환됩니다): ```python 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` 객체에만 작동합니다. 드래프트 페이지와 페이지 리비전도 마이그레이션해야 하는 경우, 대신 다음 예시처럼 마이그레이션을 수정하십시오: ```python 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_data_migrations)= ## StreamField 데이터 마이그레이션 Wagtail은 `StreamField` 데이터에 대한 데이터 마이그레이션을 생성하기 위한 유틸리티 세트를 제공합니다. 이들은 다음 모듈을 통해 노출됩니다: - `wagtail.blocks.migrations.migrate_operation` - `wagtail.blocks.migrations.operations` - `wagtail.blocks.migrations.utils` ```{note} [wagtail-streamfield-migration-toolkit](https://github.com/wagtail/wagtail-streamfield-migration-toolkit)이라는 추가 기능 패키지를 사용할 수 있으며, 마이그레이션 자동 생성에 대한 제한적인 지원을 추가로 제공합니다. ``` ### 데이터 마이그레이션이 필요한 이유는 무엇입니까? 기존 데이터가 있는 모델에서 `StreamField` 의 블록 정의를 변경하는 경우, 새 형식과 일치하도록 해당 데이터를 수동으로 변경해야 할 수 있습니다. `StreamField` 는 데이터베이스에 단일 JSON 데이터 컬럼으로 저장됩니다. 블록은 JSON 내의 구조로 저장되며, 중첩될 수 있습니다. 그러나 Django가 스키마 마이그레이션을 생성하는 한, 이 컬럼 내부의 모든 것은 JSON 데이터 문자열일 뿐입니다. 데이터베이스 스키마는 `StreamField` 의 내용/구조 변경과 관계없이 변경되지 않습니다. 이는 필드 타입이 변경 전후에 동일하기 때문입니다. 따라서 `StreamField` 에 변경 사항이 있을 때마다 기존 데이터는 필요한 새 구조로 변경되어야 하며, 일반적으로 데이터 마이그레이션을 정의하여 수행됩니다. 데이터가 마이그레이션되지 않으면 블록 이름 변경과 같은 간단한 변경도 이전 데이터가 손실될 수 있습니다. 일반적으로 데이터 마이그레이션은 빈 마이그레이션 파일을 만들고 `RunPython` 명령어의 forward 및 backward 함수를 작성하여 수동으로 수행됩니다. 이 함수들은 이전에 저장된 JSON 표현을 필요한 새 JSON 표현으로 변환하는 로직을 처리합니다. 이는 간단한 변경(예: 블록 이름 변경)의 경우 매우 간단하지만, 중첩된 블록, 여러 필드 및 리비전이 관련된 경우 매우 복잡해질 수 있습니다. 상용구(boilerplate)를 줄이고 오류 가능성을 줄이기 위해 `wagtail.blocks.migrations` 는 다음을 제공합니다: - 스트림 데이터 구조를 재귀적으로 탐색하고 변경 사항을 적용하는 유틸리티 - 블록의 이름 변경, 제거 및 값 변경과 같은 일반적인 사용 사례에 대한 작업 (streamfield_migration_basic_usage)= ### 기본 사용법 `blog` 라는 앱에 다음과 같이 정의된 `BlogPage` 모델이 있다고 가정해 봅시다: ```python class BlogPage(Page): content = StreamField([ ("stream1", blocks.StreamBlock([ ("field1", blocks.CharBlock()) ])), ]) ``` 초기 마이그레이션을 실행하고 데이터베이스에 일부 레코드를 채운 후, `field1` 의 이름을 `block1` 으로 변경하기로 결정했습니다. ```python class BlogPage(Page): content = StreamField([ ("stream1", blocks.StreamBlock([ ("block1", blocks.CharBlock()) ])), ]) ``` `StreamField` 정의에서 이름을 `block1` 으로 변경했더라도 데이터베이스의 실제 데이터는 이를 반영하지 않습니다. 기존 데이터를 업데이트하려면 데이터 마이그레이션을 생성해야 합니다. 먼저 앱 내에 빈 마이그레이션 파일을 생성합니다. Django의 `makemigrations` 명령을 사용하여 이를 수행할 수 있습니다: ```sh python manage.py makemigrations --empty blog ``` 그러면 다음과 같은 빈 마이그레이션 파일이 생성됩니다: ```python # 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` 모델에 대한 마이그레이션이 필요하기 때문입니다. ```python dependencies = [ ('wagtailcore', '0069_log_entry_jsonfield'), ... ] ``` (프로젝트가 Wagtail 4로 시작된 경우, '0076_modellogentry_revision'도 괜찮습니다) 다음으로 Django가 변경 사항을 적용하기 위해 실행할 마이그레이션 작업이 필요합니다. 제공된 유틸리티를 사용하지 않는 경우, `migrations.RunPython` 작업을 사용하고 forward (함수) 인수에 원하는 데이터(모델, 필드 등)와 해당 데이터를 변경하는 방법을 정의합니다. 대신, 관련 데이터에 접근하는 것을 처리할 `migrate_operation.MigrateStreamData` 작업이 있습니다. 아래와 같이 관련 `StreamField` 에 대한 앱 이름, 모델 이름 및 필드 이름을 지정해야 합니다. ```python 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` 이며, 이는 이름이 변경될 블록의 부모입니다 ([](rename_stream_children_operation) 참조 - 이름 변경 및 제거 작업의 경우 항상 부모 블록에 대해 작업합니다). 이 경우 블록 경로는 `stream1` 이 됩니다. 다음으로 데이터를 업데이트할 함수가 필요합니다. 이를 위해 `wagtail.blocks.operations` 모듈에는 일반적으로 사용되는 내부 필드 작업 세트가 있습니다 (또한 [사용자 지정 작업](custom_streamfield_migration_operations)을 작성할 수도 있습니다). 이는 `StreamField` 에서 작동하는 이름 변경 작업이므로, 이전 블록 이름과 새 블록 이름 두 가지 인수를 받는 `wagtail.blocks.operations.RenameStreamChildrenOperation` 을 사용합니다. 따라서 우리의 작업 및 블록 경로 튜플은 다음과 같습니다: ```python (RenameStreamChildrenOperation(old_name="field1", new_name="block1"), "stream1") ``` 그리고 최종 코드는 다음과 같습니다: ```python 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"), ] ), ] ``` (using_streamfield_migration_block_paths)= ### 작업 및 블록 경로를 올바르게 사용하기 `MigrateStreamData` 클래스는 `operations_and_block_paths` 매개변수로 작업 및 해당 블록 경로 목록을 받습니다. 목록의 각 작업은 해당 블록 경로와 일치하는 모든 블록에 적용됩니다. ```python operations_and_block_paths=[ (operation1, block_path1), (operation2, block_path2), ... ] ``` #### 블록 경로 블록 경로는 최상위 `StreamBlock`(StreamField의 모든 블록 컨테이너)에서 일치되어 작업에 전달될 중첩된 블록 타입의 이름 목록을 `.` 으로 구분하여 표시한 것입니다. ```{note} 최상위 `StreamBlock` 에 직접 작업하려는 경우, 블록 경로는 빈 문자열 `""` 여야 합니다. ``` 예를 들어, 스트림 정의가 다음과 같으면: ```python 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"` 이 됩니다: ```python [ { "type": "field1", ... }, # 일치 { "type": "field1", ... }, # 일치 { "type": "nested1", "value": [...] }, { "type": "nested1", "value": [...] }, ... ] ``` `nested1` 의 직접적인 자식인 모든 "deepnested1" 블록과 일치시키려면 블록 경로는 `"nested1.deepnested1"` 이 됩니다: ```python [ { "type": "field1", ... }, { "type": "field1", ... }, { "type": "nested1", "value": [ { "type": "char1", ... }, { "type": "deepnested1", ... }, # 일치 { "type": "deepnested1", ... }, # 일치 ... ] }, { "type": "nested1", "value": [ { "type": "char1", ... }, { "type": "deepnested1", ... }, # 일치 ... ] }, ... ] ``` 경로에 `ListBlock` 자식이 포함된 경우, 해당 자식의 이름으로 'item'이 블록 경로에 추가되어야 합니다. 예를 들어, 다음 스트림 정의를 고려하면: ```python 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"` 으로도 일치시킬 수 있습니다. #### 이름 변경 및 제거 작업 다음 작업은 블록 이름 변경 및 제거에 사용할 수 있습니다. - [RenameStreamChildrenOperation](rename_stream_children_operation) - [RenameStructChildrenOperation](rename_struct_children_operation) - [RemoveStreamChildrenOperation](remove_stream_children_operation) - [RemoveStructChildrenOperation](remove_struct_children_operation) 이러한 모든 작업은 제거 또는 이름이 변경되어야 하는 블록의 부모 블록 값에 대해 작동합니다. 따라서 이러한 작업을 사용할 때는 전달하는 블록 경로가 부모 블록을 가리키는지 확인하십시오 ([기본 사용법](streamfield_migration_basic_usage)의 예시 참조). #### 블록 구조 변경 작업 다음 작업은 특정 방식으로 블록 구조를 변경할 수 있습니다. - [](stream_children_to_list_block_operation): `StreamBlock` 의 값에 대해 작동합니다. `block_name` 타입의 모든 자식 블록을 단일 `ListBlock` 의 자식으로 결합하며, 이는 부모 `StreamBlock` 의 자식입니다. - [](stream_children_to_stream_block_operation): `StreamBlock` 의 값에 대해 작동합니다. 여기서 `block_names` 는 이전 작업의 `block_name` 과 달리 단일 블록 타입이 아니라 블록 타입 목록입니다. `block_names` 에 있는 타입의 각 자식 블록을 단일 `StreamBlock` 의 자식으로 결합하며, 이는 부모 `StreamBlock` 의 자식입니다. - [](stream_children_to_struct_block_operation): 주어진 타입의 각 `StreamBlock` 자식을 새 `StructBlock` 내부로 이동합니다. 새 `StructBlock` 이 주어진 타입의 각 자식 블록에 대해 부모 `StreamBlock` 의 자식으로 생성되고, 해당 자식 블록은 부모 `StreamBlock` 의 자식에서 새 `StructBlock` 내부로 해당 `StructBlock` 의 자식으로 이동됩니다. 예를 들어, 다음 `StreamField` 정의를 고려하면: ```python mystream = StreamField([("char1", CharBlock()) ...], ...) ``` 그러면 스트림 데이터는 다음과 같이 보일 것입니다: ```python [ { "type": "char1", "value": "Value1", ... }, { "type": "char1", "value": "Value2", ... }, ... ] ``` 그리고 작업을 다음과 같이 정의하면: ```python StreamChildrenToStructBlockOperation("char1", "struct1") ``` 변경된 스트림 데이터는 다음과 같이 보일 것입니다: ```python [ ..., { "type": "struct1", "value": { "char1": "Value1" } }, { "type": "struct1", "value": { "char1": "Value2" } }, ... ] ``` ```{note} 새 블록이 이전 블록과 구조적으로 다르므로 블록 ID는 여기에서 보존되지 않습니다. ``` #### 기타 작업 - [](alter_block_value_operation) (custom_streamfield_migration_operations)= ### 사용자 지정 작업 만들기 #### 기본 사용법 이 패키지는 일반적인 사용 사례에 대한 일련의 작업을 제공하지만, 데이터를 매핑하기 위해 자체 작업을 정의해야 하는 경우가 많을 수 있습니다. 사용자 지정 작업을 만드는 것은 매우 간단합니다. `BaseBlockOperation` 클래스를 확장하고 필요한 메서드를 정의하기만 하면 됩니다. - `apply` 기존 블록 값에 실제 변경 사항을 적용하고 새 블록 값을 반환합니다. - `operation_name_fragment` (`@property`) 마이그레이션 이름 생성에 사용될 이름을 반환합니다. (**참고:** `BaseBlockOperation` 은 `abc.ABC` 를 상속하므로 위에 언급된 모든 필수 메서드는 이를 상속하는 모든 클래스에 정의되어야 합니다.) 예를 들어, `CharBlock` 의 문자열을 주어진 길이로 자르려면: ```python 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` 에 전달되는 값은 다음과 같습니다. ```python [ { "type": "...", "value": "...", "id": "..." }, { "type": "...", "value": "...", "id": "..." }, ... ] ``` 일치하는 블록이 `StructBlock` 일 때 `apply` 에 전달되는 값은 다음과 같습니다. ```python { "type1": "...", "type2": "...", ... } ``` 일치하는 블록이 `ListBlock` 일 때 `apply` 에 전달되는 값은 다음과 같습니다. ```python [ { "type": "item", "value": "...", "id": "..." }, { "type": "item", "value": "...", "id": "..." }, ... ] ``` #### 구조적 변경 수행 블록의 구조를 포함하는 변경(예: 블록 유형 변경)을 수행할 때는 변경이 이루어지는 블록이 아닌 부모 블록의 블록 값에 대해 작동해야 할 수 있습니다. `apply` 작업은 블록의 값만 변경하기 때문입니다. 예를 들어 `RenameStreamChildrenOperation` 의 구현을 살펴보십시오. #### 이전 목록 형식 Wagtail 버전 2.16 이전에는 `ListBlock` 자식이 일반 Python 값 목록으로 저장되었습니다. 그러나 최신 Wagtail 버전에서는 목록 블록 자식이 `ListValue` 로 저장됩니다. 원시 데이터를 처리할 때 변경 사항은 다음과 같습니다. 이전 형식 ```python [ value1, value2, ... ] ``` 새 형식 ```python [ { "type": "item", "id": "...", "value": value1 }, { "type": "item", "id": "...", "value": value2 }, ... ] ``` `ListBlock` 값에 대해 작동하는 작업을 정의할 때, 이전 형식의 오래된 데이터가 있는 경우 `wagtail.blocks.migrations.utils.formatted_list_child_generator` 를 사용하여 새 형식의 자식을 다음과 같이 얻을 수 있습니다. ```python def apply(self, block_value): for child_block in formatted_list_child_generator(list_block_value): ... ```