Initial commit

This commit is contained in:
2024-08-27 20:33:44 +02:00
commit 1f1832267d
14794 changed files with 1599592 additions and 0 deletions

View File

@@ -0,0 +1,352 @@
import json
import logging
from collections import OrderedDict
from django.db.models import JSONField, F, Q, Subquery, OuterRef
from django.db.models.functions import Cast
from django.db.migrations import RunPython
from django.utils.functional import cached_property
from wagtail.blocks import StreamValue
from wagtail.blocks.migrations import utils
logger = logging.getLogger(__name__)
class MigrateStreamData(RunPython):
"""Subclass of RunPython for streamfield data migration operations"""
def __init__(
self,
app_name,
model_name,
field_name,
operations_and_block_paths,
revisions_from=None,
chunk_size=1024,
**kwargs,
):
"""MigrateStreamData constructor
Args:
app_name (str): Name of the app.
model_name (str): Name of the model.
field_name (str): Name of the streamfield.
operations_and_block_paths (:obj:`list` of :obj:`tuple` of (:obj:`operation`, :obj:`str`)):
List of operations and corresponding block paths to apply.
revisions_from (:obj:`datetime`, optional): Only revisions created from this date
onwards will be updated. Passing `None` updates all revisions. Defaults to `None`.
Note that live and latest revisions will be updated regardless of what value this
takes.
chunk_size (:obj:`int`, optional): chunk size for queryset.iterator and bulk_update.
Defaults to 1024.
**kwargs: atomic, elidable, hints for superclass RunPython can be given
Example:
Renaming a block named `field1` to `block1`::
MigrateStreamData(
app_name="blog",
model_name="BlogPage",
field_name="content",
operations_and_block_paths=[
(RenameStreamChildrenOperation(old_name="field1", new_name="block1"), ""),
],
revisions_from=datetime.date(2022, 7, 25)
),
"""
self.app_name = app_name
self.model_name = model_name
self.field_name = field_name
self.operations_and_block_paths = operations_and_block_paths
self.revisions_from = revisions_from
self.chunk_size = chunk_size
# TODO add reverse code when needed, will probably need another input (reversible?)
# super class kwargs - atomic,elidable,hints
super().__init__(
code=self.migrate_stream_data_forward,
reverse_code=lambda *args: None,
**kwargs,
)
def deconstruct(self):
_, args, kwargs = super().deconstruct()
kwargs["app_name"] = self.app_name
kwargs["model_name"] = self.model_name
kwargs["field_name"] = self.field_name
kwargs["operations_and_block_paths"] = self.operations_and_block_paths
kwargs["revisions_from"] = self.revisions_from
kwargs["chunk_size"] = self.chunk_size
return (self.__class__.__qualname__, args, kwargs)
@property
def migration_name_fragment(self):
# We are using an OrderedDict here to essentially get the functionality of an ordered set
# so that names generated will be consistent.
fragments = OrderedDict(
(op.operation_name_fragment, None)
for op, _ in self.operations_and_block_paths
)
return "_".join(fragments.keys())
def migrate_stream_data_forward(self, apps, schema_editor):
model = apps.get_model(self.app_name, self.model_name)
# Here we can't directly check the wagtail version, rather we need to check the wagtail
# version at the project state when the migration is being applied
try:
apps.get_model("wagtailcore", "Revision")
revision_query_maker = DefaultRevisionQueryMaker(
apps, model, self.revisions_from
)
except LookupError:
revision_query_maker = Wagtail3RevisionQueryMaker(
apps, model, self.revisions_from
)
model_queryset = model.objects.annotate(
raw_content=Cast(F(self.field_name), JSONField())
).all()
updated_model_instances_buffer = []
for instance in model_queryset.iterator(chunk_size=self.chunk_size):
if instance.raw_content is None:
continue
revision_query_maker.append_instance_data_for_revision_query(instance)
raw_data = instance.raw_content
for operation, block_path_str in self.operations_and_block_paths:
try:
raw_data = utils.apply_changes_to_raw_data(
raw_data=raw_data,
block_path_str=block_path_str,
operation=operation,
streamfield=getattr(model, self.field_name),
)
# - TODO add a return value to util to know if changes were made
# - TODO save changed only
except utils.InvalidBlockDefError as e:
raise utils.InvalidBlockDefError(instance=instance) from e
stream_block = getattr(instance, self.field_name).stream_block
setattr(
instance,
self.field_name,
StreamValue(stream_block, raw_data, is_lazy=True),
)
updated_model_instances_buffer.append(instance)
if len(updated_model_instances_buffer) == self.chunk_size:
model.objects.bulk_update(
updated_model_instances_buffer, [self.field_name]
)
updated_model_instances_buffer = []
if len(updated_model_instances_buffer) > 0:
# For any remaining chunks
model.objects.bulk_update(updated_model_instances_buffer, [self.field_name])
# For models without revisions
if not revision_query_maker.has_revisions:
return
revision_queryset = revision_query_maker.get_revision_queryset()
updated_revisions_buffer = []
for revision in revision_queryset.iterator(chunk_size=self.chunk_size):
raw_data = json.loads(revision.content[self.field_name])
for operation, block_path_str in self.operations_and_block_paths:
try:
raw_data = utils.apply_changes_to_raw_data(
raw_data=raw_data,
block_path_str=block_path_str,
operation=operation,
streamfield=getattr(model, self.field_name),
)
except utils.InvalidBlockDefError as e:
if not revision_query_maker.get_is_live_or_latest_revision(
revision
):
logger.exception(
utils.InvalidBlockDefError(
revision=revision, instance=instance
)
)
continue
else:
raise utils.InvalidBlockDefError(
revision=revision, instance=instance
) from e
# - TODO add a return value to util to know if changes were made
# - TODO save changed only
revision.content[self.field_name] = json.dumps(raw_data)
updated_revisions_buffer.append(revision)
if len(updated_revisions_buffer) == self.chunk_size:
revision_query_maker.bulk_update(updated_revisions_buffer)
updated_revisions_buffer = []
if len(updated_revisions_buffer) > 0:
revision_query_maker.bulk_update(updated_revisions_buffer)
class AbstractRevisionQueryMaker:
"""Helper class for making the revision query needed for the data migration"""
def __init__(self, apps, model, revisions_from):
self.apps = apps
self.model = model
self.revisions_from = revisions_from
self.RevisionModel = self.get_revision_model()
self.has_revisions = self.get_has_revisions()
if self.has_revisions:
# latest or live revision ids may be available directly from the instance. In that case
# we can keep track of them here.
self.instance_field_revision_ids = set()
def get_revision_model(self):
raise NotImplementedError
def get_has_revisions(self):
raise NotImplementedError
def append_instance_data_for_revision_query(self, instance):
raise NotImplementedError
def _make_revision_query(self):
raise NotImplementedError
def get_revision_queryset(self):
revision_query = self._make_revision_query()
return self.RevisionModel.objects.filter(revision_query)
def bulk_update(self, data):
self.RevisionModel.objects.bulk_update(data, ["content"])
def get_is_live_or_latest_revision(self, revision):
raise NotImplementedError
class Wagtail3RevisionQueryMaker(AbstractRevisionQueryMaker):
"""Revision Query maker to support Wagtail 3"""
def __init__(self, apps, model, revisions_from):
self.page_ids = []
super().__init__(apps, model, revisions_from)
def get_revision_model(self):
return self.apps.get_model("wagtailcore", "PageRevision")
def get_has_revisions(self):
return issubclass(self.model, self.apps.get_model("wagtailcore", "Page"))
def append_instance_data_for_revision_query(self, instance):
if self.has_revisions:
self.page_ids.append(instance.id)
self.instance_field_revision_ids.add(instance.live_revision_id)
def _make_revision_query(self):
if self.revisions_from is not None:
# All revisions created after the given date.
revision_query = Q(
created_at__gte=self.revisions_from,
page_id__in=self.page_ids,
)
# All live revisions.
revision_query = revision_query | Q(id__in=self.instance_field_revision_ids)
# All latest revisions. For each revision, we check if it is the revision with the
# last `created_at` from all revisions with its `page_id`.
revision_query = revision_query | Q(
id__in=Subquery(
self.RevisionModel.objects.filter(page_id=OuterRef("page_id"))
.order_by("-created_at", "-id")
.values_list("id", flat=True)[:1]
),
page_id__in=self.page_ids,
)
return revision_query
# otherwise query all revisions for the page
else:
return Q(page_id__in=self.page_ids)
def get_is_live_or_latest_revision(self, revision):
if revision.id in self.instance_field_revision_ids:
return True
return revision.id in self._latest_revision_ids
@cached_property
def _latest_revision_ids(self):
return self.RevisionModel.objects.filter(
id__in=Subquery(
self.RevisionModel.objects.filter(page_id=OuterRef("page_id"))
.order_by("-created_at", "-id")
.values_list("id", flat=True)[:1]
),
page_id__in=self.page_ids,
).values_list("id", flat=True)
class DefaultRevisionQueryMaker(AbstractRevisionQueryMaker):
"""Revision Query Maker for Wagtail 4+"""
def __init__(self, apps, model, revisions_from):
self.has_live_revisions = False
self.has_latest_revisions = False
super().__init__(apps, model, revisions_from)
def get_revision_model(self):
return self.apps.get_model("wagtailcore", "Revision")
def get_has_revisions(self):
# We check if the models have a field `latest_revision` and make sure it points to the
# Revision model. This relation is there on models with `RevisionMixin`.
self.has_latest_revisions = (
hasattr(self.model, "latest_revision")
and self.model.latest_revision.field.remote_field.model
is self.RevisionModel
)
# Again, check for `live_revision`. This relation is there on models with `DraftStateMixin`.
self.has_live_revisions = (
hasattr(self.model, "live_revision")
and self.model.live_revision.field.remote_field.model is self.RevisionModel
)
return self.has_latest_revisions or self.has_live_revisions
def append_instance_data_for_revision_query(self, instance):
if self.has_revisions:
# From wagtail 4 onwards, there can be non page models which may have live or latest
# revisions, but not necessarily having both at the same time.
if self.has_latest_revisions:
self.instance_field_revision_ids.add(instance.latest_revision_id)
if self.has_live_revisions:
self.instance_field_revision_ids.add(instance.live_revision_id)
def _make_revision_query(self):
ContentType = self.apps.get_model("contenttypes", "ContentType")
contenttype_id = ContentType.objects.get_for_model(self.model).id
# if revisions_from is given, then query only the revisions created after that
# datetime (and the latest and live revisions if they are not after revisions_from)
if self.revisions_from is not None:
# All revisions created after the given date.
revision_query = Q(
created_at__gte=self.revisions_from,
content_type_id=contenttype_id,
)
# All live and latest revisions
revision_query = revision_query | Q(id__in=self.instance_field_revision_ids)
return revision_query
# otherwise query all revisions for the model
else:
return Q(content_type_id=contenttype_id)
def get_is_live_or_latest_revision(self, revision):
return revision.id in self.instance_field_revision_ids

View File

@@ -0,0 +1,332 @@
from abc import ABC, abstractmethod
from wagtail.blocks.migrations.utils import formatted_list_child_generator
from django.utils.deconstruct import deconstructible
class BaseBlockOperation(ABC):
def __init__(self):
pass
@abstractmethod
def apply(self, block_value):
pass
@property
@abstractmethod
def operation_name_fragment(self):
pass
@deconstructible
class RenameStreamChildrenOperation(BaseBlockOperation):
"""Renames all StreamBlock children of the given type
Note:
The `block_path_str` when using this operation should point to the parent StreamBlock
which contains the blocks to be renamed, not the block being renamed.
Attributes:
old_name (str): name of the child block type to be renamed
new_name (str): new name to rename to
"""
def __init__(self, old_name, new_name):
super().__init__()
self.old_name = old_name
self.new_name = new_name
def apply(self, block_value):
mapped_block_value = []
for child_block in block_value:
if child_block["type"] == self.old_name:
mapped_block_value.append({**child_block, "type": self.new_name})
else:
mapped_block_value.append(child_block)
return mapped_block_value
@property
def operation_name_fragment(self):
return f"rename_{self.old_name}_to_{self.new_name}"
@deconstructible
class RenameStructChildrenOperation(BaseBlockOperation):
"""Renames all StructBlock children of the given type
Note:
The `block_path_str` when using this operation should point to the parent StructBlock
which contains the blocks to be renamed, not the block being renamed.
Attributes:
old_name (str): name of the child block type to be renamed
new_name (str): new name to rename to
"""
def __init__(self, old_name, new_name):
super().__init__()
self.old_name = old_name
self.new_name = new_name
def apply(self, block_value):
mapped_block_value = {}
for child_key, child_value in block_value.items():
if child_key == self.old_name:
mapped_block_value[self.new_name] = child_value
else:
mapped_block_value[child_key] = child_value
return mapped_block_value
@property
def operation_name_fragment(self):
return f"rename_{self.old_name}_to_{self.new_name}"
@deconstructible
class RemoveStreamChildrenOperation(BaseBlockOperation):
"""Removes all StreamBlock children of the given type
Note:
The `block_path_str` when using this operation should point to the parent StreamBlock
which contains the blocks to be removed, not the block being removed.
Attributes:
name (str): name of the child block type to be removed
"""
def __init__(self, name):
super().__init__()
self.name = name
def apply(self, block_value):
return [
child_block
for child_block in block_value
if child_block["type"] != self.name
]
@property
def operation_name_fragment(self):
return f"remove_{self.name}"
@deconstructible
class RemoveStructChildrenOperation(BaseBlockOperation):
"""Removes all StructBlock children of the given type
Note:
The `block_path_str` when using this operation should point to the parent StructBlock
which contains the blocks to be removed, not the block being removed.
Attributes:
name (str): name of the child block type to be removed
"""
def __init__(self, name):
super().__init__()
self.name = name
def apply(self, block_value):
return {
child_key: child_value
for child_key, child_value in block_value.items()
if child_key != self.name
}
@property
def operation_name_fragment(self):
return f"remove_{self.name}"
class StreamChildrenToListBlockOperation(BaseBlockOperation):
"""Combines StreamBlock children of the given type into a new ListBlock
Note:
The `block_path_str` when using this operation should point to the parent StreamBlock
which contains the blocks to be combined, not the child block itself.
Attributes:
block_name (str): name of the child block type to be combined
list_block_name (str): name of the new ListBlock type
"""
def __init__(self, block_name, list_block_name):
super().__init__()
self.block_name = block_name
self.list_block_name = list_block_name
self.temp_blocks = []
def apply(self, block_value):
mapped_block_value = []
for child_block in block_value:
if child_block["type"] == self.block_name:
self.temp_blocks.append(child_block)
else:
mapped_block_value.append(child_block)
self.map_temp_blocks_to_list_items()
if self.temp_blocks:
new_list_block = {"type": self.list_block_name, "value": self.temp_blocks}
mapped_block_value.append(new_list_block)
return mapped_block_value
def map_temp_blocks_to_list_items(self):
new_temp_blocks = []
for block in self.temp_blocks:
new_temp_blocks.append({**block, "type": "item"})
self.temp_blocks = new_temp_blocks
@property
def operation_name_fragment(self):
return f"{self.block_name}_to_list_block_{self.list_block_name}"
class StreamChildrenToStreamBlockOperation(BaseBlockOperation):
"""Combines StreamBlock children of the given types into a new StreamBlock
Note:
The `block_path_str` when using this operation should point to the parent StreamBlock
which contains the blocks to be combined, not the child block itself.
Attributes:
block_names (:obj:`list` of :obj:`str`): names of the child block types to be combined
stream_block_name (str): name of the new StreamBlock type
"""
def __init__(self, block_names, stream_block_name):
super().__init__()
self.block_names = block_names
self.stream_block_name = stream_block_name
def apply(self, block_value):
mapped_block_value = []
stream_value = []
for child_block in block_value:
if child_block["type"] in self.block_names:
stream_value.append(child_block)
else:
mapped_block_value.append(child_block)
if stream_value:
new_stream_block = {"type": self.stream_block_name, "value": stream_value}
mapped_block_value.append(new_stream_block)
return mapped_block_value
@property
def operation_name_fragment(self):
return "{}_to_stream_block".format("_".join(self.block_names))
class AlterBlockValueOperation(BaseBlockOperation):
"""Alters the value of each block to the given value
Attributes:
new_value : new value to change to
"""
def __init__(self, new_value):
super().__init__()
self.new_value = new_value
def apply(self, block_value):
return self.new_value
@property
def operation_name_fragment(self):
return "alter_block_value"
class StreamChildrenToStructBlockOperation(BaseBlockOperation):
"""Move each StreamBlock child of the given type inside a new StructBlock
A new StructBlock will be created as a child of the parent StreamBlock for each child block of
the given type, and then that child block will be moved from the parent StreamBlocks children
inside the new StructBlock as a child of that StructBlock.
Example:
Consider the following StreamField definition::
mystream = StreamField([("char1", CharBlock()) ...], ...)
Then the stream data would look like the following::
[
...
{ "type": "char1", "value": "Value1", ... },
{ "type": "char1", "value": "Value2", ... },
...
]
And if we define the operation like this::
StreamChildrenToStructBlockOperation("char1", "struct1")
Our altered stream data would look like this::
[
...
{ "type": "struct1", "value": { "char1": "Value1" } },
{ "type": "struct1", "value": { "char1": "Value2" } },
...
]
Note:
The `block_path_str` when using this operation should point to the parent StreamBlock
which contains the blocks to be combined, not the child block itself.
Note:
Block ids are not preserved here since the new blocks are structurally different than the
previous blocks.
Attributes:
block_names (str): names of the child block types to be combined
struct_block_name (str): name of the new StructBlock type
"""
def __init__(self, block_name, struct_block_name):
super().__init__()
self.block_name = block_name
self.struct_block_name = struct_block_name
def apply(self, block_value):
mapped_block_value = []
for child_block in block_value:
if child_block["type"] == self.block_name:
mapped_block_value.append(
{
**child_block,
"type": self.struct_block_name,
"value": {self.block_name: child_block["value"]},
}
)
else:
mapped_block_value.append(child_block)
return mapped_block_value
@property
def operation_name_fragment(self):
return f"{self.block_name}_to_struct_block_{self.struct_block_name}"
class ListChildrenToStructBlockOperation(BaseBlockOperation):
def __init__(self, block_name):
super().__init__()
self.block_name = block_name
def apply(self, block_value):
mapped_block_value = []
# In case there is data from the old list format (wagtail < 2.16), we use the generator
# to convert them into the new list format
for child_block in formatted_list_child_generator(block_value):
mapped_block_value.append(
{**child_block, "value": {self.block_name: child_block["value"]}}
)
return mapped_block_value
@property
def operation_name_fragment(self):
return f"list_block_items_to_{self.block_name}"

View File

@@ -0,0 +1,291 @@
from wagtail.blocks import ListBlock, StreamBlock, StructBlock
class InvalidBlockDefError(Exception):
"""Exception for invalid block definitions"""
def __init__(self, *args, instance=None, revision=None, **kwargs):
# in the case of a revision pass both instance and revision
self.instance = instance
self.revision = revision
super().__init__(*args, **kwargs)
def __str__(self):
message = ""
if self.instance is not None:
message += "Invalid block def in {} object ({})".format(
self.instance.__class__.__name__, self.instance.id
)
if self.revision is not None:
message += " for revision id ({}) created at {}".format(
self.revision.id,
self.revision.created_at,
)
if self.args:
message += "\n"
message += super().__str__()
return message
def should_alter_block(block_name, block_path):
# If the block is not at the start of `block_path`, then neither it nor its children are
# blocks that we need to alter.
return block_name == block_path[0]
def map_block_value(block_value, block_def, block_path, operation, **kwargs):
"""
Maps the value of a block.
Args:
block_value:
The value of the block. This would be a list or dict of children for structural blocks.
block_def:
The definition of the block.
block_path:
A '.' separated list of names of the blocks from the current block (not included) to
the nested block of which the value will be passed to the operation.
operation:
An Operation class instance (extends `BaseBlockOperation`), which has an `apply` method
for mapping values.
Returns:
mapped_value:
"""
# If the `block_path` length is 0, that means we've reached the end of the block path, that
# is, the block where we need to apply the operation. Note that we are asking the user to
# pass "item" as part of the block path for list children, so it won't give rise to any
# problems here.
if len(block_path) == 0:
return operation.apply(block_value)
# Depending on whether the block is a ListBlock, StructBlock or StreamBlock we call a
# different function to alter its children.
if isinstance(block_def, StreamBlock):
return map_stream_block_value(
block_value,
operation=operation,
block_def=block_def,
block_path=block_path,
**kwargs,
)
elif isinstance(block_def, ListBlock):
return map_list_block_value(
block_value,
operation=operation,
block_def=block_def,
block_path=block_path,
**kwargs,
)
elif isinstance(block_def, StructBlock):
return map_struct_block_value(
block_value,
operation=operation,
block_def=block_def,
block_path=block_path,
**kwargs,
)
else:
raise ValueError(f"Unexpected Structural Block: {block_value}")
def map_stream_block_value(stream_block_value, block_def, block_path, **kwargs):
"""
Maps each child block in a StreamBlock value.
Args:
stream_block_value:
The value of the StreamBlock, a list of child blocks
block_def:
The definition of the StreamBlock
block_path:
A '.' separated list of names of the blocks from the current block (not included) to
the nested block of which the value will be passed to the operation.
Returns
mapped_value:
The value of the StreamBlock after mapping all the children.
"""
mapped_value = []
for child_block in stream_block_value:
if not should_alter_block(child_block["type"], block_path):
mapped_value.append(child_block)
else:
try:
child_block_def = block_def.child_blocks[child_block["type"]]
except KeyError:
raise InvalidBlockDefError(
"No current block def named {}".format(child_block["type"])
)
mapped_child_value = map_block_value(
child_block["value"],
block_def=child_block_def,
block_path=block_path[1:],
**kwargs,
)
mapped_value.append({**child_block, "value": mapped_child_value})
return mapped_value
def map_struct_block_value(struct_block_value, block_def, block_path, **kwargs):
"""
Maps each child block in a StructBlock value.
Args:
stream_block_value:
The value of the StructBlock, a dict of child blocks
block_def:
The definition of the StructBlock
block_path:
A '.' separated list of names of the blocks from the current block (not included) to
the nested block of which the value will be passed to the operation.
Returns
mapped_value:
The value of the StructBlock after mapping all the children.
"""
mapped_value = {}
for key, child_value in struct_block_value.items():
if not should_alter_block(key, block_path):
mapped_value[key] = child_value
else:
try:
child_block_def = block_def.child_blocks[key]
except KeyError:
raise InvalidBlockDefError(f"No current block def named {key}")
altered_child_value = map_block_value(
child_value,
block_def=child_block_def,
block_path=block_path[1:],
**kwargs,
)
mapped_value[key] = altered_child_value
return mapped_value
def map_list_block_value(list_block_value, block_def, block_path, **kwargs):
"""
Maps each child block in a ListBlock value.
Args:
stream_block_value:
The value of the ListBlock, a list of child blocks
block_def:
The definition of the ListBlock
block_path:
A '.' separated list of names of the blocks from the current block (not included) to
the nested block of which the value will be passed to the operation.
Returns
mapped_value:
The value of the ListBlock after mapping all the children.
"""
mapped_value = []
# In case data is in old list format
for child_block in formatted_list_child_generator(list_block_value):
mapped_child_value = map_block_value(
child_block["value"],
block_def=block_def.child_block,
block_path=block_path[1:],
**kwargs,
)
mapped_value.append({**child_block, "value": mapped_child_value})
return mapped_value
def formatted_list_child_generator(list_block_value):
is_old_format = False
if not isinstance(list_block_value[0], dict):
is_old_format = True
elif "type" not in list_block_value[0] or list_block_value[0]["type"] != "item":
is_old_format = True
for child in list_block_value:
if not is_old_format:
yield child
else:
yield {"type": "item", "value": child}
def apply_changes_to_raw_data(
raw_data, block_path_str, operation, streamfield, **kwargs
):
"""
Applies changes to raw stream data
Args:
raw_data:
The current stream data (a list of top level blocks)
block_path_str:
A '.' separated list of names of the blocks from the top level block to the nested
block of which the value will be passed to the operation.
eg:- 'simplestream.struct1' would point to,
[..., { type: simplestream, value: [..., { type: struct1, value: {...} }] }]
NOTE: If we're directly applying changes on the top level stream block, then this will
be "".
NOTE: When the path contains a ListBlock child, 'item' must be added to the block as
the name of said child.
eg:- 'list1.item.stream1' where the list child is a StructBlock would point to,
[
...,
{
type: list1,
value: [
{
type: item,
value: { ..., stream1: [...] }
},
...
]
}
]
operation:
A subclass of `operations.BaseBlockOperation`. It will have the `apply` method
for applying changes to the matching block values.
streamfield:
The streamfield for which data is being migrated. This is used to get the definitions
of the blocks.
Returns:
altered_raw_data:
"""
if block_path_str == "":
# If block_path_str is "", we're directly applying the operation on the top level
# streamblock.
block_path = []
else:
block_path = block_path_str.split(".")
block_def = streamfield.field.stream_block
altered_raw_data = map_block_value(
raw_data,
block_def=block_def,
block_path=block_path,
operation=operation,
**kwargs,
)
return altered_raw_data