From 5652b5192a381697040c1e86a6b978fb2c28bf05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Suttor?= Date: Wed, 11 Mar 2026 15:09:40 +0100 Subject: [PATCH 1/5] feat(@themes): add REST endpoint for programmatic theme management Add GET, POST and PATCH /@themes services to list available themes, upload a Diazo theme from a zip file, and activate/deactivate themes. Enables theme provisioning from a Kubernetes initContainer without requiring access to the Plone control panel UI. --- news/1994.feature | 1 + src/plone/restapi/services/configure.zcml | 4 + src/plone/restapi/services/themes/__init__.py | 0 .../restapi/services/themes/configure.zcml | 30 +++ src/plone/restapi/services/themes/get.py | 46 ++++ src/plone/restapi/services/themes/patch.py | 52 +++++ src/plone/restapi/services/themes/post.py | 68 ++++++ .../restapi/tests/test_services_themes.py | 218 ++++++++++++++++++ 8 files changed, 419 insertions(+) create mode 100644 news/1994.feature create mode 100644 src/plone/restapi/services/themes/__init__.py create mode 100644 src/plone/restapi/services/themes/configure.zcml create mode 100644 src/plone/restapi/services/themes/get.py create mode 100644 src/plone/restapi/services/themes/patch.py create mode 100644 src/plone/restapi/services/themes/post.py create mode 100644 src/plone/restapi/tests/test_services_themes.py diff --git a/news/1994.feature b/news/1994.feature new file mode 100644 index 0000000000..2724783129 --- /dev/null +++ b/news/1994.feature @@ -0,0 +1 @@ +Added `@themes` endpoint to list, upload, and activate themes via the REST API. Supports zip upload via `POST /@themes` (multipart/form-data), theme activation/deactivation via `PATCH /@themes/{name}`, and listing via `GET /@themes`. @bsuttor diff --git a/src/plone/restapi/services/configure.zcml b/src/plone/restapi/services/configure.zcml index 0f42573cd3..98e3b5bcde 100644 --- a/src/plone/restapi/services/configure.zcml +++ b/src/plone/restapi/services/configure.zcml @@ -41,6 +41,10 @@ + diff --git a/src/plone/restapi/services/themes/__init__.py b/src/plone/restapi/services/themes/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/plone/restapi/services/themes/configure.zcml b/src/plone/restapi/services/themes/configure.zcml new file mode 100644 index 0000000000..dc3ffbffcd --- /dev/null +++ b/src/plone/restapi/services/themes/configure.zcml @@ -0,0 +1,30 @@ + + + + + + + + + + + diff --git a/src/plone/restapi/services/themes/get.py b/src/plone/restapi/services/themes/get.py new file mode 100644 index 0000000000..53e8f5b299 --- /dev/null +++ b/src/plone/restapi/services/themes/get.py @@ -0,0 +1,46 @@ +from plone.app.theming.interfaces import IThemeSettings +from plone.app.theming.utils import getAvailableThemes +from plone.registry.interfaces import IRegistry +from plone.restapi.services import Service +from zope.component import getUtility +from zope.interface import implementer +from zope.publisher.interfaces import IPublishTraverse + + +@implementer(IPublishTraverse) +class ThemesGet(Service): + def __init__(self, context, request): + super().__init__(context, request) + self.params = [] + + def publishTraverse(self, request, name): + self.params.append(name) + return self + + def reply(self): + registry = getUtility(IRegistry) + settings = registry.forInterface(IThemeSettings, False) + current = settings.currentTheme + + themes = getAvailableThemes() + + if self.params: + name = self.params[0] + for theme in themes: + if theme.__name__ == name: + return self._serialize(theme, current) + self.request.response.setStatus(404) + return {"error": "Theme not found"} + + return [self._serialize(t, current) for t in themes] + + def _serialize(self, theme, current_name): + return { + "@id": f"{self.context.absolute_url()}/@themes/{theme.__name__}", + "id": theme.__name__, + "title": theme.title, + "description": theme.description, + "active": theme.__name__ == current_name, + "preview": theme.preview, + "rules": theme.rules, + } diff --git a/src/plone/restapi/services/themes/patch.py b/src/plone/restapi/services/themes/patch.py new file mode 100644 index 0000000000..30e8817d55 --- /dev/null +++ b/src/plone/restapi/services/themes/patch.py @@ -0,0 +1,52 @@ +from plone.app.theming.interfaces import IThemeSettings +from plone.app.theming.utils import applyTheme +from plone.app.theming.utils import getAvailableThemes +from plone.protect.interfaces import IDisableCSRFProtection +from plone.registry.interfaces import IRegistry +from plone.restapi.deserializer import json_body +from plone.restapi.services import Service +from zope.component import getUtility +from zope.interface import alsoProvides +from zope.interface import implementer +from zope.publisher.interfaces import IPublishTraverse + + +@implementer(IPublishTraverse) +class ThemesPatch(Service): + def __init__(self, context, request): + super().__init__(context, request) + self.params = [] + + def publishTraverse(self, request, name): + self.params.append(name) + return self + + def reply(self): + alsoProvides(self.request, IDisableCSRFProtection) + if not self.params: + self.request.response.setStatus(400) + return {"error": "Theme name required in URL"} + + theme_name = self.params[0] + body = json_body(self.request) + active = body.get("active") + + registry = getUtility(IRegistry) + settings = registry.forInterface(IThemeSettings, False) + + if active is True: + themes = getAvailableThemes() + theme = next((t for t in themes if t.__name__ == theme_name), None) + if theme is None: + self.request.response.setStatus(404) + return {"error": "Theme not found"} + applyTheme(theme) + settings.enabled = True + elif active is False: + applyTheme(None) + settings.enabled = False + else: + self.request.response.setStatus(400) + return {"error": "Body must contain 'active': true or false"} + + return self.reply_no_content() diff --git a/src/plone/restapi/services/themes/post.py b/src/plone/restapi/services/themes/post.py new file mode 100644 index 0000000000..1c34035916 --- /dev/null +++ b/src/plone/restapi/services/themes/post.py @@ -0,0 +1,68 @@ +import zipfile + +from plone.app.theming.interfaces import IThemeSettings +from plone.app.theming.plugins.utils import getPlugins +from plone.app.theming.utils import applyTheme +from plone.app.theming.utils import extractThemeInfo +from plone.app.theming.utils import getOrCreatePersistentResourceDirectory +from plone.protect.interfaces import IDisableCSRFProtection +from plone.registry.interfaces import IRegistry +from plone.restapi.services import Service +from zope.component import getUtility +from zope.interface import alsoProvides + + +class ThemesPost(Service): + def reply(self): + alsoProvides(self.request, IDisableCSRFProtection) + + theme_archive = self.request.form.get("themeArchive") + if not theme_archive: + self.request.response.setStatus(400) + return {"error": "Missing 'themeArchive' field"} + + enable = self.request.form.get("enable", "false").lower() == "true" + replace = self.request.form.get("replace", "false").lower() == "true" + + try: + theme_zip = zipfile.ZipFile(theme_archive) + except (zipfile.BadZipFile, zipfile.LargeZipFile) as e: + self.request.response.setStatus(400) + return {"error": f"Invalid zip file: {e}"} + + try: + theme_info = extractThemeInfo(theme_zip, checkRules=False) + except (ValueError, KeyError) as e: + self.request.response.setStatus(400) + return {"error": f"Invalid theme: {e}"} + + theme_container = getOrCreatePersistentResourceDirectory() + theme_name = theme_info.__name__ + + if theme_name in theme_container: + if not replace: + self.request.response.setStatus(409) + return { + "error": f"Theme '{theme_name}' already exists. Use replace=true to overwrite." + } + del theme_container[theme_name] + + theme_container.importZip(theme_zip) + + # Call plugin lifecycle hooks + theme_directory = theme_container[theme_name] + for _name, plugin in getPlugins(): + plugin.onCreated(theme_name, {}, theme_directory) + + if enable: + applyTheme(theme_info) + registry = getUtility(IRegistry) + settings = registry.forInterface(IThemeSettings, False) + settings.enabled = True + + self.request.response.setStatus(201) + return { + "@id": f"{self.context.absolute_url()}/@themes/{theme_name}", + "id": theme_name, + "title": theme_info.title, + } diff --git a/src/plone/restapi/tests/test_services_themes.py b/src/plone/restapi/tests/test_services_themes.py new file mode 100644 index 0000000000..cec8ea1448 --- /dev/null +++ b/src/plone/restapi/tests/test_services_themes.py @@ -0,0 +1,218 @@ +import io +import os +import unittest + +from plone.app.testing import setRoles +from plone.app.testing import TEST_USER_ID +from plone.restapi.testing import PLONE_RESTAPI_DX_FUNCTIONAL_TESTING +from plone.restapi.testing import RelativeSession + + +THEMING_ZIPFILES = os.path.join( + os.path.dirname(__file__), + "..", + "..", + "..", + "..", + "..", + ".venv", + "lib", + "python3.12", + "site-packages", + "plone", + "app", + "theming", + "tests", + "zipfiles", +) + +# Use a zip with a manifest so extractThemeInfo works +MANIFEST_ZIP = os.path.join(THEMING_ZIPFILES, "manifest_rules.zip") + + +def get_theming_zipfile(name): + """Return the path to a plone.app.theming test zip file.""" + import plone.app.theming.tests + + tests_dir = os.path.dirname(plone.app.theming.tests.__file__) + return os.path.join(tests_dir, "zipfiles", name) + + +class TestServicesThemes(unittest.TestCase): + layer = PLONE_RESTAPI_DX_FUNCTIONAL_TESTING + + def setUp(self): + self.app = self.layer["app"] + self.portal = self.layer["portal"] + self.portal_url = self.portal.absolute_url() + setRoles(self.portal, TEST_USER_ID, ["Manager"]) + + self.api_session = RelativeSession(self.portal_url, test=self) + self.api_session.headers.update({"Accept": "application/json"}) + + def tearDown(self): + self.api_session.close() + + def test_get_themes_list(self): + response = self.api_session.get("/@themes") + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertIsInstance(data, list) + self.assertGreater(len(data), 0) + # Each theme should have basic fields + theme = data[0] + self.assertIn("id", theme) + self.assertIn("title", theme) + self.assertIn("active", theme) + self.assertIn("@id", theme) + + def test_get_theme_by_name(self): + # First get list to find a valid theme name + response = self.api_session.get("/@themes") + themes = response.json() + theme_id = themes[0]["id"] + + response = self.api_session.get(f"/@themes/{theme_id}") + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data["id"], theme_id) + + def test_get_theme_not_found(self): + response = self.api_session.get("/@themes/nonexistent-theme-xyz") + self.assertEqual(response.status_code, 404) + + def test_post_theme_upload(self): + zip_path = get_theming_zipfile("manifest_rules.zip") + if not os.path.exists(zip_path): + self.skipTest("plone.app.theming test zips not available") + + with open(zip_path, "rb") as f: + response = self.api_session.post( + "/@themes", + files={"themeArchive": ("manifest_rules.zip", f, "application/zip")}, + ) + + self.assertEqual(response.status_code, 201) + data = response.json() + self.assertIn("id", data) + self.assertIn("title", data) + self.assertIn("@id", data) + + def test_post_theme_missing_archive(self): + response = self.api_session.post( + "/@themes", + data={}, + ) + self.assertEqual(response.status_code, 400) + self.assertIn("error", response.json()) + + def test_post_theme_invalid_zip(self): + fake_zip = io.BytesIO(b"not a zip file") + response = self.api_session.post( + "/@themes", + files={"themeArchive": ("bad.zip", fake_zip, "application/zip")}, + ) + self.assertEqual(response.status_code, 400) + self.assertIn("error", response.json()) + + def test_post_theme_duplicate_without_replace(self): + zip_path = get_theming_zipfile("manifest_rules.zip") + if not os.path.exists(zip_path): + self.skipTest("plone.app.theming test zips not available") + + # Upload once + with open(zip_path, "rb") as f: + self.api_session.post( + "/@themes", + files={"themeArchive": ("manifest_rules.zip", f, "application/zip")}, + ) + + # Upload again without replace + with open(zip_path, "rb") as f: + response = self.api_session.post( + "/@themes", + files={"themeArchive": ("manifest_rules.zip", f, "application/zip")}, + ) + + self.assertEqual(response.status_code, 409) + self.assertIn("error", response.json()) + + def test_post_theme_duplicate_with_replace(self): + zip_path = get_theming_zipfile("manifest_rules.zip") + if not os.path.exists(zip_path): + self.skipTest("plone.app.theming test zips not available") + + # Upload once + with open(zip_path, "rb") as f: + self.api_session.post( + "/@themes", + files={"themeArchive": ("manifest_rules.zip", f, "application/zip")}, + ) + + # Upload again with replace=true + with open(zip_path, "rb") as f: + response = self.api_session.post( + "/@themes", + files={ + "themeArchive": ("manifest_rules.zip", f, "application/zip"), + }, + data={"replace": "true"}, + ) + + self.assertEqual(response.status_code, 201) + + def test_patch_theme_activate(self): + # Get a theme name from the list + response = self.api_session.get("/@themes") + themes = response.json() + theme_id = themes[0]["id"] + + response = self.api_session.patch( + f"/@themes/{theme_id}", + json={"active": True}, + ) + self.assertEqual(response.status_code, 204) + + def test_patch_theme_deactivate(self): + # Get a theme name from the list + response = self.api_session.get("/@themes") + themes = response.json() + theme_id = themes[0]["id"] + + # Activate first + self.api_session.patch( + f"/@themes/{theme_id}", + json={"active": True}, + ) + + # Then deactivate + response = self.api_session.patch( + f"/@themes/{theme_id}", + json={"active": False}, + ) + self.assertEqual(response.status_code, 204) + + def test_patch_theme_not_found(self): + response = self.api_session.patch( + "/@themes/nonexistent-theme-xyz", + json={"active": True}, + ) + self.assertEqual(response.status_code, 404) + + def test_patch_theme_no_name(self): + response = self.api_session.patch( + "/@themes", + json={"active": True}, + ) + self.assertEqual(response.status_code, 400) + + def test_patch_theme_invalid_body(self): + response = self.api_session.get("/@themes") + themes = response.json() + theme_id = themes[0]["id"] + + response = self.api_session.patch( + f"/@themes/{theme_id}", + json={"active": "maybe"}, + ) + self.assertEqual(response.status_code, 400) From e06f8b6ddbdeaae5aadf650f5d701c2a226baa8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Suttor?= Date: Wed, 11 Mar 2026 16:30:48 +0100 Subject: [PATCH 2/5] chore: add documentation of new themes endpoint --- docs/source/endpoints/index.md | 1 + docs/source/endpoints/themes.md | 106 ++++++++++++++++++ .../tests/http-examples/themes_get.req | 3 + .../tests/http-examples/themes_get.resp | 12 ++ .../tests/http-examples/themes_get_list.req | 3 + .../tests/http-examples/themes_get_list.resp | 14 +++ .../http-examples/themes_patch_activate.req | 6 + .../http-examples/themes_patch_activate.resp | 1 + .../http-examples/themes_patch_deactivate.req | 6 + .../themes_patch_deactivate.resp | 1 + .../tests/http-examples/themes_post.req | 11 ++ .../tests/http-examples/themes_post.resp | 12 ++ 12 files changed, 176 insertions(+) create mode 100644 docs/source/endpoints/themes.md create mode 100644 src/plone/restapi/tests/http-examples/themes_get.req create mode 100644 src/plone/restapi/tests/http-examples/themes_get.resp create mode 100644 src/plone/restapi/tests/http-examples/themes_get_list.req create mode 100644 src/plone/restapi/tests/http-examples/themes_get_list.resp create mode 100644 src/plone/restapi/tests/http-examples/themes_patch_activate.req create mode 100644 src/plone/restapi/tests/http-examples/themes_patch_activate.resp create mode 100644 src/plone/restapi/tests/http-examples/themes_patch_deactivate.req create mode 100644 src/plone/restapi/tests/http-examples/themes_patch_deactivate.resp create mode 100644 src/plone/restapi/tests/http-examples/themes_post.req create mode 100644 src/plone/restapi/tests/http-examples/themes_post.resp diff --git a/docs/source/endpoints/index.md b/docs/source/endpoints/index.md index 3d33783b44..a582b1cdae 100644 --- a/docs/source/endpoints/index.md +++ b/docs/source/endpoints/index.md @@ -49,6 +49,7 @@ searching sharing site system +themes transactions translations tusupload diff --git a/docs/source/endpoints/themes.md b/docs/source/endpoints/themes.md new file mode 100644 index 0000000000..e8e7d21e0b --- /dev/null +++ b/docs/source/endpoints/themes.md @@ -0,0 +1,106 @@ +--- +myst: + html_meta: + "description": "Diazo themes can be managed programmatically through the @themes endpoint in a Plone site." + "property=og:description": "Diazo themes can be managed programmatically through the @themes endpoint in a Plone site." + "property=og:title": "Themes" + "keywords": "Plone, plone.restapi, REST, API, Themes, Diazo" +--- + +# Themes + +Diazo themes can be managed programmatically through the `@themes` endpoint in a Plone site. +This endpoint requires `plone.app.theming` to be installed and the `cmf.ManagePortal` permission. + +It is particularly useful in containerized deployments (such as Kubernetes) where access to the Plone UI may not be available. + +## Listing themes + +A list of all available themes can be retrieved by sending a `GET` request to the `@themes` endpoint: + +```{eval-rst} +.. http:example:: curl httpie python-requests + :request: ../../../src/plone/restapi/tests/http-examples/themes_get_list.req +``` + +Response: + +```{literalinclude} ../../../src/plone/restapi/tests/http-examples/themes_get_list.resp +:language: http +``` + +The following fields are returned for each theme: + +- `@id`: hypermedia link to the theme resource +- `id`: the theme identifier +- `title`: the friendly name of the theme +- `description`: description of the theme +- `active`: whether this theme is currently active +- `preview`: path to the theme preview image (or `null`) +- `rules`: path to the theme rules file + +## Reading a theme + +A single theme can be retrieved by sending a `GET` request with the theme ID as a path parameter: + +```{eval-rst} +.. http:example:: curl httpie python-requests + :request: ../../../src/plone/restapi/tests/http-examples/themes_get.req +``` + +Response: + +```{literalinclude} ../../../src/plone/restapi/tests/http-examples/themes_get.resp +:language: http +``` + +## Uploading a theme + +A new theme can be uploaded by sending a `POST` request with a ZIP archive as `multipart/form-data`: + +```{eval-rst} +.. http:example:: curl httpie python-requests + :request: ../../../src/plone/restapi/tests/http-examples/themes_post.req +``` + +Response: + +```{literalinclude} ../../../src/plone/restapi/tests/http-examples/themes_post.resp +:language: http +``` + +The following form fields are accepted: + +- `themeArchive` (required): the ZIP file containing the theme +- `enable` (optional): set to `true` to activate the theme immediately after upload +- `replace` (optional): set to `true` to overwrite an existing theme with the same ID + +## Activating a theme + +A theme can be activated by sending a `PATCH` request with `{"active": true}`: + +```{eval-rst} +.. http:example:: curl httpie python-requests + :request: ../../../src/plone/restapi/tests/http-examples/themes_patch_activate.req +``` + +Response: + +```{literalinclude} ../../../src/plone/restapi/tests/http-examples/themes_patch_activate.resp +:language: http +``` + +## Deactivating a theme + +A theme can be deactivated by sending a `PATCH` request with `{"active": false}`: + +```{eval-rst} +.. http:example:: curl httpie python-requests + :request: ../../../src/plone/restapi/tests/http-examples/themes_patch_deactivate.req +``` + +Response: + +```{literalinclude} ../../../src/plone/restapi/tests/http-examples/themes_patch_deactivate.resp +:language: http +``` diff --git a/src/plone/restapi/tests/http-examples/themes_get.req b/src/plone/restapi/tests/http-examples/themes_get.req new file mode 100644 index 0000000000..443f76e0a5 --- /dev/null +++ b/src/plone/restapi/tests/http-examples/themes_get.req @@ -0,0 +1,3 @@ +GET /plone/@themes/plonetheme.barceloneta HTTP/1.1 +Accept: application/json +Authorization: Basic YWRtaW46c2VjcmV0 diff --git a/src/plone/restapi/tests/http-examples/themes_get.resp b/src/plone/restapi/tests/http-examples/themes_get.resp new file mode 100644 index 0000000000..0311f2a194 --- /dev/null +++ b/src/plone/restapi/tests/http-examples/themes_get.resp @@ -0,0 +1,12 @@ +HTTP/1.1 200 OK +Content-Type: application/json + +{ + "@id": "http://localhost:55001/plone/@themes/plonetheme.barceloneta", + "id": "plonetheme.barceloneta", + "title": "Barceloneta Theme", + "description": "The default Plone 5 theme", + "active": true, + "preview": "++theme++plonetheme.barceloneta/preview.png", + "rules": "++theme++plonetheme.barceloneta/rules.xml" +} diff --git a/src/plone/restapi/tests/http-examples/themes_get_list.req b/src/plone/restapi/tests/http-examples/themes_get_list.req new file mode 100644 index 0000000000..8579e2844d --- /dev/null +++ b/src/plone/restapi/tests/http-examples/themes_get_list.req @@ -0,0 +1,3 @@ +GET /plone/@themes HTTP/1.1 +Accept: application/json +Authorization: Basic YWRtaW46c2VjcmV0 diff --git a/src/plone/restapi/tests/http-examples/themes_get_list.resp b/src/plone/restapi/tests/http-examples/themes_get_list.resp new file mode 100644 index 0000000000..387dc98cd8 --- /dev/null +++ b/src/plone/restapi/tests/http-examples/themes_get_list.resp @@ -0,0 +1,14 @@ +HTTP/1.1 200 OK +Content-Type: application/json + +[ + { + "@id": "http://localhost:55001/plone/@themes/plonetheme.barceloneta", + "id": "plonetheme.barceloneta", + "title": "Barceloneta Theme", + "description": "The default Plone 5 theme", + "active": true, + "preview": "++theme++plonetheme.barceloneta/preview.png", + "rules": "++theme++plonetheme.barceloneta/rules.xml" + } +] diff --git a/src/plone/restapi/tests/http-examples/themes_patch_activate.req b/src/plone/restapi/tests/http-examples/themes_patch_activate.req new file mode 100644 index 0000000000..79952c3d01 --- /dev/null +++ b/src/plone/restapi/tests/http-examples/themes_patch_activate.req @@ -0,0 +1,6 @@ +PATCH /plone/@themes/plonetheme.barceloneta HTTP/1.1 +Accept: application/json +Authorization: Basic YWRtaW46c2VjcmV0 +Content-Type: application/json + +{"active": true} diff --git a/src/plone/restapi/tests/http-examples/themes_patch_activate.resp b/src/plone/restapi/tests/http-examples/themes_patch_activate.resp new file mode 100644 index 0000000000..58e46abbc9 --- /dev/null +++ b/src/plone/restapi/tests/http-examples/themes_patch_activate.resp @@ -0,0 +1 @@ +HTTP/1.1 204 No Content diff --git a/src/plone/restapi/tests/http-examples/themes_patch_deactivate.req b/src/plone/restapi/tests/http-examples/themes_patch_deactivate.req new file mode 100644 index 0000000000..e316be1cf3 --- /dev/null +++ b/src/plone/restapi/tests/http-examples/themes_patch_deactivate.req @@ -0,0 +1,6 @@ +PATCH /plone/@themes/plonetheme.barceloneta HTTP/1.1 +Accept: application/json +Authorization: Basic YWRtaW46c2VjcmV0 +Content-Type: application/json + +{"active": false} diff --git a/src/plone/restapi/tests/http-examples/themes_patch_deactivate.resp b/src/plone/restapi/tests/http-examples/themes_patch_deactivate.resp new file mode 100644 index 0000000000..58e46abbc9 --- /dev/null +++ b/src/plone/restapi/tests/http-examples/themes_patch_deactivate.resp @@ -0,0 +1 @@ +HTTP/1.1 204 No Content diff --git a/src/plone/restapi/tests/http-examples/themes_post.req b/src/plone/restapi/tests/http-examples/themes_post.req new file mode 100644 index 0000000000..63495899e2 --- /dev/null +++ b/src/plone/restapi/tests/http-examples/themes_post.req @@ -0,0 +1,11 @@ +POST /plone/@themes HTTP/1.1 +Accept: application/json +Authorization: Basic YWRtaW46c2VjcmV0 +Content-Type: multipart/form-data; boundary=---------------------------1234567890 + +-----------------------------1234567890 +Content-Disposition: form-data; name="themeArchive"; filename="mytheme.zip" +Content-Type: application/zip + + +-----------------------------1234567890-- diff --git a/src/plone/restapi/tests/http-examples/themes_post.resp b/src/plone/restapi/tests/http-examples/themes_post.resp new file mode 100644 index 0000000000..ce1372d247 --- /dev/null +++ b/src/plone/restapi/tests/http-examples/themes_post.resp @@ -0,0 +1,12 @@ +HTTP/1.1 201 Created +Content-Type: application/json + +{ + "@id": "http://localhost:55001/plone/@themes/mytheme", + "id": "mytheme", + "title": "My Theme", + "description": "", + "active": false, + "preview": null, + "rules": "/++theme++mytheme/rules.xml" +} From f2bb9f8519888f9cc90924b3271ee42f4943773d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Suttor?= Date: Wed, 11 Mar 2026 16:50:50 +0100 Subject: [PATCH 3/5] fix(tests): better way to get zip theme, and use admin user to test themes endpoint --- .../restapi/tests/test_services_themes.py | 31 ++++--------------- 1 file changed, 6 insertions(+), 25 deletions(-) diff --git a/src/plone/restapi/tests/test_services_themes.py b/src/plone/restapi/tests/test_services_themes.py index cec8ea1448..adc0ddee0f 100644 --- a/src/plone/restapi/tests/test_services_themes.py +++ b/src/plone/restapi/tests/test_services_themes.py @@ -1,33 +1,13 @@ -import io -import os -import unittest - from plone.app.testing import setRoles +from plone.app.testing import SITE_OWNER_NAME +from plone.app.testing import SITE_OWNER_PASSWORD from plone.app.testing import TEST_USER_ID from plone.restapi.testing import PLONE_RESTAPI_DX_FUNCTIONAL_TESTING from plone.restapi.testing import RelativeSession - -THEMING_ZIPFILES = os.path.join( - os.path.dirname(__file__), - "..", - "..", - "..", - "..", - "..", - ".venv", - "lib", - "python3.12", - "site-packages", - "plone", - "app", - "theming", - "tests", - "zipfiles", -) - -# Use a zip with a manifest so extractThemeInfo works -MANIFEST_ZIP = os.path.join(THEMING_ZIPFILES, "manifest_rules.zip") +import io +import os +import unittest def get_theming_zipfile(name): @@ -49,6 +29,7 @@ def setUp(self): self.api_session = RelativeSession(self.portal_url, test=self) self.api_session.headers.update({"Accept": "application/json"}) + self.api_session.auth = (SITE_OWNER_NAME, SITE_OWNER_PASSWORD) def tearDown(self): self.api_session.close() From f1ae269ffc3953a5f8b298b82e97fb8a56631a28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Suttor?= Date: Wed, 11 Mar 2026 17:00:11 +0100 Subject: [PATCH 4/5] fix(lint): isort --- src/plone/restapi/services/themes/post.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/plone/restapi/services/themes/post.py b/src/plone/restapi/services/themes/post.py index 1c34035916..d1be1e88d8 100644 --- a/src/plone/restapi/services/themes/post.py +++ b/src/plone/restapi/services/themes/post.py @@ -1,5 +1,3 @@ -import zipfile - from plone.app.theming.interfaces import IThemeSettings from plone.app.theming.plugins.utils import getPlugins from plone.app.theming.utils import applyTheme @@ -11,6 +9,8 @@ from zope.component import getUtility from zope.interface import alsoProvides +import zipfile + class ThemesPost(Service): def reply(self): From dc62daba3e032c44de87c0fa8d38707bbc206ef2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Suttor?= Date: Wed, 11 Mar 2026 17:05:24 +0100 Subject: [PATCH 5/5] fix(lint): zpretty --- .../restapi/services/themes/configure.zcml | 47 ++++++++++--------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/src/plone/restapi/services/themes/configure.zcml b/src/plone/restapi/services/themes/configure.zcml index dc3ffbffcd..1b8d0759f3 100644 --- a/src/plone/restapi/services/themes/configure.zcml +++ b/src/plone/restapi/services/themes/configure.zcml @@ -1,30 +1,35 @@ - + - + + method="GET" + factory=".get.ThemesGet" + for="Products.CMFPlone.interfaces.IPloneSiteRoot" + permission="cmf.ManagePortal" + name="@themes" + /> + method="POST" + factory=".post.ThemesPost" + for="Products.CMFPlone.interfaces.IPloneSiteRoot" + permission="cmf.ManagePortal" + name="@themes" + /> + method="PATCH" + factory=".patch.ThemesPatch" + for="Products.CMFPlone.interfaces.IPloneSiteRoot" + permission="cmf.ManagePortal" + name="@themes" + />