Skip to content

Commit

Permalink
Centralize logic for finding all blocks (#1648)
Browse files Browse the repository at this point in the history
* Centralize block visiting logic

* use generator instead of callback

* lint

* fix zcml for Plone 5

* fix types

* remove unused imports

* Use block visitors for SearchableText indexing

* Depth-first traversal, to match existing behavior

* changelog

* test that visit_blocks returns blocks in the expected order

* docs
  • Loading branch information
davisagli committed Jun 26, 2023
1 parent eb102cc commit a29583a
Show file tree
Hide file tree
Showing 11 changed files with 200 additions and 179 deletions.
13 changes: 13 additions & 0 deletions docs/source/usage/blocks.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,3 +231,16 @@ This adapter needs to be registered as a named adapter, where the name is the sa
```xml
<adapter name="image" factory=".indexers.ImageBlockSearchableText" />
```

## Visit all blocks

Since blocks can be contained inside other blocks,
it is not always obvious how to find all of the blocks stored on a content item.
The `visit_blocks` utility function will iterate over all blocks:

```python
from plone.restapi.blocks import visit_blocks

for block in visit_blocks(context, context.blocks):
print(block)
```
1 change: 1 addition & 0 deletions news/1648.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add `visit_blocks` util for finding all nested blocks. @davisagli
75 changes: 75 additions & 0 deletions src/plone/restapi/blocks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
from zope.component import adapter
from zope.component import subscribers
from zope.interface import implementer
from zope.interface import Interface
from zope.globalrequest import getRequest
from zope.publisher.interfaces.browser import IBrowserRequest
from plone.restapi.interfaces import IBlockVisitor


def visit_blocks(context, blocks):
"""Generator yielding all blocks, including nested blocks.
context: Content item where these blocks are stored.
blocks: A dict mapping block ids to a dict of block data.
"""
request = getRequest()
visitors = subscribers((context, request), IBlockVisitor)

def _visit_subblocks(block):
for visitor in visitors:
for subblock in visitor(block):
yield from _visit_subblocks(subblock)
yield block

for block in blocks.values():
yield from _visit_subblocks(block)


def visit_subblocks(context, block):
"""Generator yielding the immediate subblocks of a block.
context: Context item where this block is stored
block: A dict of block data
"""
request = getRequest()
visitors = subscribers((context, request), IBlockVisitor)
for visitor in visitors:
for subblock in visitor(block):
yield subblock


def iter_block_transform_handlers(context, block_value, interface):
"""Find valid handlers for a particular block transformation.
Looks for adapters of the context and request to this interface.
Then skips any that are disabled or don't match the block type,
and returns the remaining handlers sorted by `order`.
"""
block_type = block_value.get("@type", "")
handlers = []
for handler in subscribers((context, getRequest()), interface):
if handler.block_type == block_type or handler.block_type is None:
handler.blockid = id
handlers.append(handler)
for handler in sorted(handlers, key=lambda h: h.order):
if not getattr(handler, "disabled", False):
yield handler


@implementer(IBlockVisitor)
@adapter(Interface, IBrowserRequest)
class NestedBlocksVisitor:
"""Visit nested blocks."""

def __init__(self, context, request):
pass

def __call__(self, block_value):
"""Visit nested blocks in ["data"]["blocks"] or ["blocks"]"""
if "data" in block_value:
if isinstance(block_value["data"], dict):
if "blocks" in block_value["data"]:
yield from block_value["data"]["blocks"].values()
if "blocks" in block_value:
yield from block_value["blocks"].values()
20 changes: 4 additions & 16 deletions src/plone/restapi/blocks_linkintegrity.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,10 @@
from plone.app.linkintegrity.interfaces import IRetriever
from plone.app.linkintegrity.retriever import DXGeneral
from plone.restapi.behaviors import IBlocks
from plone.restapi.blocks import iter_block_transform_handlers, visit_blocks
from plone.restapi.deserializer.blocks import iterate_children
from plone.restapi.interfaces import IBlockFieldLinkIntegrityRetriever
from zope.component import adapter
from zope.component import subscribers
from zope.globalrequest import getRequest
from zope.interface import implementer
from zope.publisher.interfaces.browser import IBrowserRequest

Expand All @@ -22,22 +21,11 @@ def retrieveLinks(self):
blocks = getattr(self.context, "blocks", {})
if not blocks:
return links
request = getattr(self.context, "REQUEST", None)
if request is None:
# context does not have full acquisition chain
request = getRequest()
for block in blocks.values():
block_type = block.get("@type", None)
handlers = []
for h in subscribers(
(self.context, request),
IBlockFieldLinkIntegrityRetriever,
for block in visit_blocks(self.context, blocks):
for handler in iter_block_transform_handlers(
self.context, block, IBlockFieldLinkIntegrityRetriever
):
if h.block_type == block_type or h.block_type is None:
handlers.append(h)
for handler in sorted(handlers, key=lambda h: h.order):
links |= set(handler(block))

return links


Expand Down
5 changes: 5 additions & 0 deletions src/plone/restapi/configure.zcml
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,11 @@
name="plone.restapi"
/>

<subscriber
factory=".blocks.NestedBlocksVisitor"
provides="plone.restapi.interfaces.IBlockVisitor"
/>

<!-- blocks link integrity -->
<adapter factory=".blocks_linkintegrity.BlocksRetriever" />

Expand Down
59 changes: 8 additions & 51 deletions src/plone/restapi/deserializer/blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@
from plone import api
from plone.restapi.bbb import IPloneSiteRoot
from plone.restapi.behaviors import IBlocks
from plone.restapi.blocks import iter_block_transform_handlers, visit_blocks
from plone.restapi.deserializer.dxfields import DefaultFieldDeserializer
from plone.restapi.deserializer.utils import path2uid
from plone.restapi.interfaces import IBlockFieldDeserializationTransformer
from plone.restapi.interfaces import IFieldDeserializer
from plone.schema import IJSONField
from zope.component import adapter
from zope.component import subscribers
from zope.interface import implementer
from zope.publisher.interfaces.browser import IBrowserRequest

Expand All @@ -32,60 +32,17 @@ def iterate_children(value):
@implementer(IFieldDeserializer)
@adapter(IJSONField, IBlocks, IBrowserRequest)
class BlocksJSONFieldDeserializer(DefaultFieldDeserializer):
def _transform(self, blocks):
for id, block_value in blocks.items():
self.handle_subblocks(block_value)
block_type = block_value.get("@type", "")
handlers = []
for h in subscribers(
(self.context, self.request),
IBlockFieldDeserializationTransformer,
):
if h.block_type == block_type or h.block_type is None:
h.blockid = id
handlers.append(h)

for handler in sorted(handlers, key=lambda h: h.order):
block_value = handler(block_value)

blocks[id] = block_value

return blocks

def handle_subblocks(self, block_value):
if "data" in block_value:
if isinstance(block_value["data"], dict):
if "blocks" in block_value["data"]:
block_value["data"]["blocks"] = self._transform(
block_value["data"]["blocks"]
)

if "blocks" in block_value:
block_value["blocks"] = self._transform(block_value["blocks"])

def __call__(self, value):
value = super().__call__(value)

if self.field.getName() == "blocks":
for id, block_value in value.items():
self.handle_subblocks(block_value)
block_type = block_value.get("@type", "")

handlers = []
for h in subscribers(
(self.context, self.request),
IBlockFieldDeserializationTransformer,
for block in visit_blocks(self.context, value):
new_block = block.copy()
for handler in iter_block_transform_handlers(
self.context, block, IBlockFieldDeserializationTransformer
):
if h.block_type == block_type or h.block_type is None:
h.blockid = id
handlers.append(h)

for handler in sorted(handlers, key=lambda h: h.order):
if not getattr(handler, "disabled", False):
block_value = handler(block_value)

value[id] = block_value

new_block = handler(new_block)
block.clear()
block.update(new_block)
return value


Expand Down
19 changes: 2 additions & 17 deletions src/plone/restapi/indexers.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from plone.app.contenttypes.indexers import SearchableText
from plone.indexer.decorator import indexer
from plone.restapi.behaviors import IBlocks
from plone.restapi.blocks import visit_subblocks
from plone.restapi.interfaces import IBlockSearchableText
from zope.component import adapter
from zope.component import queryMultiAdapter
Expand Down Expand Up @@ -70,21 +71,6 @@ def __call__(self, block):
return block.get("plaintext", "")


def extract_subblocks(block):
"""Extract subblocks from a block.
:param block: Dictionary with block information.
:returns: A list with subblocks, if present, or an empty list.
"""
if "data" in block and "blocks" in block["data"]:
raw_blocks = block["data"]["blocks"]
elif "blocks" in block:
raw_blocks = block["blocks"]
else:
raw_blocks = None
return list(raw_blocks.values()) if isinstance(raw_blocks, dict) else []


def extract_text(block, obj, request):
"""Extract text information from a block.
Expand Down Expand Up @@ -115,8 +101,7 @@ def extract_text(block, obj, request):
adapter = queryMultiAdapter((obj, request), IBlockSearchableText, name=block_type)
result = adapter(block) if adapter is not None else ""
if not result:
subblocks = extract_subblocks(block)
for subblock in subblocks:
for subblock in visit_subblocks(obj, block):
tmp_result = extract_text(subblock, obj, request)
result = f"{result}\n{tmp_result}"
return result
Expand Down
66 changes: 34 additions & 32 deletions src/plone/restapi/interfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,41 +83,47 @@ def __call__(value):
"""Convert the provided JSON value to a field value."""


class IBlockFieldDeserializationTransformer(Interface):
"""Convert/adjust raw block deserialized value into block value."""
class IBlockTransformer(Interface):
"""Transform a block value.
Meant to be looked up as an adapter of context and request.
The list of transformers is filtered by block_type and sorted by order.
Disabled transformers are ignored.
Block transformers for specific use cases extend this interface.
"""

block_type = Attribute(
"A string with the type of block, the @type from " "the block value"
"A string with the type of block, the @type from the block value"
)
order = Attribute(
"A number used in sorting value transformers. " "Smaller is executed first"
"A number used in sorting value transformers. Smaller is executed first"
)
disabled = Attribute("Boolean that disables the transformer if required")

def __init__(field, context, request):
"""Adapts context and the request."""
def __call__(value):
"""Do the transform."""


class IBlockFieldDeserializationTransformer(IBlockTransformer):
"""Convert/adjust raw block deserialized value into block value."""

def __call__(value):
"""Convert the provided raw Python value to a block value."""


class IBlockFieldSerializationTransformer(Interface):
class IBlockFieldSerializationTransformer(IBlockTransformer):
"""Transform block value before final JSON serialization"""

block_type = Attribute(
"A string with the type of block, the @type from " "the block value"
)
order = Attribute(
"A number used in sorting value transformers for the "
"same block. Smaller is executed first"
)
disabled = Attribute("Boolean that disables the transformer if required")
def __call__(value):
"""Convert the provided raw Python value to a block value."""

def __init__(field, context, request):
"""Adapts context and the request."""

class IBlockFieldLinkIntegrityRetriever(Interface):
"""Retrieve internal links set in current block."""

def __call__(value):
"""Convert the provided raw Python value to a block value."""
"""Return a list of internal links set in this block."""


class IExpandableElement(Interface):
Expand Down Expand Up @@ -210,20 +216,6 @@ def __call__(value):
"""Extract text from the block value. Returns text"""


class IBlockFieldLinkIntegrityRetriever(Interface):
"""Retrieve internal links set in current block."""

block_type = Attribute(
"A string with the type of block, the @type from " "the block value"
)

def __init__(field, context, request):
"""Adapts context and the request."""

def __call__(value):
"""Return a list of internal links set in this block."""


class IJSONSummarySerializerMetadata(Interface):
"""Configure JSONSummary serializer."""

Expand All @@ -238,3 +230,13 @@ def non_metadata_attributes():

def blocklisted_attributes():
"""Returns a set with attributes blocked during serialization."""


class IBlockVisitor(Interface):
"""Find sub-blocks
Used by the visit_blocks utility.
"""

def __call__(self, block):
"""Return an iterable of sub-blocks found inside `block`."""

0 comments on commit a29583a

Please sign in to comment.