Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add get_parents and get_closed_parent functions #560

Merged
18 commits merged into from
Mar 5, 2025
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions docs/content.md
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,94 @@ view = api.content.get_view(
%
% self.assertEqual(view.__name__, u'plone')

(content-get-parents-example)=

## Get parent objects

You can get all parents of a content object (from immediate parent to the portal root) using the {meth}`api.content.get_parents` method.

```python
from plone import api
portal = api.portal.get()
team = portal['about']['team']

# Get all parents
parents = api.content.get_parents(obj=team)
```

% invisible-code-block: python
%
% self.assertListEqual(parents, ['about', 'plone'])

You can filter parent objects by interface:

```python
from plone import api
from Products.CMFCore.interfaces import IFolderish
portal = api.portal.get()
team = portal['about']['team']

# Get folder parents only
folder_parents = api.content.get_parents(obj=team, interface=IFolderish)
```

% invisible-code-block: python
%
% self.assertListEqual(folder_parents, ['about', 'plone'])

You can also filter parents using a custom predicate function:

```python
from plone import api
portal = api.portal.get()
team = portal['about']['team']

# Get only published parents
def is_published(obj):
return api.content.get_state(obj=obj) == 'published'

published_parents = api.content.get_parents(obj=team, predicate=is_published)
```

(content-get-closed-parent-example)=

## Get closest parent

To get the closest parent object that matches certain criteria, use the {meth}`api.content.get_closed_parent` method.

```python
from plone import api
from Products.CMFCore.interfaces import IFolderish
portal = api.portal.get()
team = portal['about']['team']

# Get immediate parent folder
parent_folder = api.content.get_closed_parent(obj=team, interface=IFolderish)
```

% invisible-code-block: python
%
% self.assertEqual(parent_folder.id, 'about')

You can also use a predicate function to find the closest parent matching custom criteria:

```python
from plone import api
portal = api.portal.get()
team = portal['about']['team']

# Get closest published parent
def is_published(obj):
return api.content.get_state(obj=obj) == 'published'

closest_published = api.content.get_closed_parent(obj=team, predicate=is_published)
```

% invisible-code-block: python
%
% # Assuming 'about' folder is published
% self.assertEqual(closest_published.id, 'about')

## Further reading

For more information on possible flags and usage options please see the full {ref}`plone-api-content` specification.
1 change: 1 addition & 0 deletions news/531.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add get_parents and get_closed_parent functions for content hierarchy retrieval @rohnsha0
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
keywords="plone api",
python_requires=">=3.8",
install_requires=[
"Acquisition",
"Products.statusmessages",
"Products.PlonePAS",
"Products.CMFPlone",
Expand Down
51 changes: 51 additions & 0 deletions src/plone/api/content.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Module that provides functionality for content manipulation."""

from Acquisition import aq_chain
from Acquisition import aq_inner
from copy import copy as _copy
from plone.api import portal
from plone.api.exc import InvalidParameterError
Expand Down Expand Up @@ -666,3 +668,52 @@ def find(context=None, depth=None, unrestricted=False, **kwargs):
return catalog.unrestrictedSearchResults(**query)
else:
return catalog(**query)


@required_parameters("obj")
def get_parents(obj: None, predicate: None, interface: None):
"""Get all parents of an object, with optional filtering.

:param obj: [required] Object for which we want to get the parents.
:type obj: Content object
:param predicate: Optional callable that takes an object and returns a boolean.
Used to filter the parents.
:type predicate: callable
:param interface: Optional interface to filter the parents.
:type interface: zope.interface.Interface
:returns: List of parent objects, from immediate to site root.
:rtype: list
:Example: :ref:`content-get-parents-example`

"""
chain = aq_chain(aq_inner(obj))[1:]

if interface is not None:
chain = (obj for obj in chain if interface.providedBy(obj))

if predicate is not None:
chain = map(predicate, chain)

return list(chain)


@required_parameters("obj")
def get_closest_parent(obj=None, predicate=None, interface=None):
"""Get the closest parent of an object that satisfies the given criteria.

:param obj: [required] Object for which we want to get the parent.
:type obj: Content object
:param predicate: Optional callable that takes an object and returns a boolean.
Used to filter the parents.
:type predicate: callable
:param interface: Optional interface to filter the parents.
:type interface: zope.interface.Interface
:returns: Closest matching parents object or None if no match found.
:rtype: Content object or None
:Example: :ref:`content-get-closed-parent-example`

"""
parents = get_parents(obj=obj, predicate=predicate, interface=interface)
if parents:
return parents[0]
return None
124 changes: 124 additions & 0 deletions src/plone/api/tests/test_content.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from OFS.interfaces import IObjectWillBeMovedEvent
from plone import api
from plone.api.content import _parse_object_provides_query
from plone.api.exc import MissingParameterError
from plone.api.tests.base import INTEGRATION_TESTING
from plone.app.contenttypes.interfaces import IFolder
from plone.app.linkintegrity.exceptions import LinkIntegrityNotificationException
Expand All @@ -14,6 +15,7 @@
from plone.indexer import indexer
from plone.uuid.interfaces import IMutableUUID
from plone.uuid.interfaces import IUUIDGenerator
from Products.CMFCore.interfaces import IFolderish
from Products.CMFCore.WorkflowCore import WorkflowException
from Products.ZCatalog.interfaces import IZCatalog
from unittest import mock
Expand Down Expand Up @@ -1447,3 +1449,125 @@ def test_get_view_view_not_found(self):

for should_be_there in should_be_theres:
self.assertIn((should_be_there + "\n"), str(cm.exception))

def test_get_parents_required_parameter(self):
"""Test that get_parents requires an obj parameter"""

with self.assertRaises(MissingParameterError):
api.content.get_parents()

def test_get_parents_basic(self):
"""Test getting all parents without filter"""

# Test nested content (in sprint folder)
parents = api.content.get_parents(self.sprint)
self.assertListEqual(parents, [self.events, self.portal])

# Test nested content (in team folder)
parents = api.content.get_parents(self.team)
self.assertEqual(len(parents), 2)
self.assertListEqual(parents, [self.about, self.portal])

def test_get_parents_with_interface_filter(self):
"""Test getting all parents with an interface filter"""

# Test content in nested folder
parents = api.content.get_parents(self.sprint, IFolderish)

# Should return [events, portal] as both are folders
self.assertEqual(len(parents), 2)
self.assertListEqual(parents, [self.events, self.portal])

# Test content with a mixed parent type
parents = api.content.get_parents(self.image, IFolderish)

# Should return [portal] as only parent folder
self.assertListEqual(parents, [self.portal])

def test_get_parents_with_predicate_filter(self):
"""Test getting all parents with a predicate filter"""

# Set event to published state
api.content.transition(self.events, to_state="published")

# Test get only the published parents
parents = api.content.get_parents(
self.sprint, predicate=lambda x: api.content.get_state(x) == "published"
)

# Should return [events]
self.assertListEqual(parents, [self.events])

def test_get_parent_with_both_filters(self):
"""Test getting all parents with both filters"""

# Set event to published state
api.content.transition(self.events, to_state="published")

# Get only published folder parents
parents = api.content.get_parents(
self.sprint,
interface=IFolderish,
predicate=lambda x: api.content.get_state(x) == "published",
)

# Should return events
self.assertEqual(len(parents), 1)
self.assertListEqual(parents, [self.events])
self.assertTrue(IFolderish.providedBy(parents[0]))

def test_get_parents_root_level(self):
"""Test getting all parents at the root level"""

# Test root level content
parents = api.content.get_parents(self.blog)
self.assertListEqual(parents, [self.portal])

def test_closest_parent_requires_parameter(self):
"""Test that closest_parent requires an obj parameter"""

with self.assertRaises(MissingParameterError):
api.content.get_closest_parent()

def test_closest_parent_basic(self):
"""Test getting closest parent without filters"""

# Test nested content (in event folder)
parent = api.content.get_closest_parent(self.sprint)
self.assertEqual(parent.getId(), "events")

# Test nested content (in team folder)
parent = api.content.get_closest_parent(self.team)
self.assertEqual(parent.getId(), "about")

def test_closest_parent_interface_folder(self):
"""Test getting closest parent with an interface filter"""

# Test nested content (in event folder)
parent = api.content.get_closest_parent(self.sprint, interface=IFolderish)
self.assertTrue(IFolderish.providedBy(parent))
self.assertEqual(parent.getId(), "events")

def test_closest_parent_predicate_filter(self):
"""Test getting closest parent with a predicate filter"""

api.content.transition(self.portal, to_state="published")

# Test nested content (in event folder)
parent = api.content.get_closest_parent(
self.sprint, predicate=lambda x: api.content.get_state(x) == "published"
)
self.assertEqual(parent, self.portal)

def test_closes_parent_no_match(self):
"""Test getting closest parents when no parents is found"""

parents = api.content.get_closest_parent(self.sprint, predicate=lambda x: False)
self.assertIsNone(parents)

def test_closest_parent_root_level(self):
"""Test getting closest parent at the root level"""

# Test root level content
parent = api.content.get_closest_parent(self.blog)
self.assertEqual(parent, self.portal)
Loading