diff --git a/docs/source/endpoints/index.md b/docs/source/endpoints/index.md index 3d33783b4..a582b1cda 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 000000000..e8e7d21e0 --- /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/news/1994.feature b/news/1994.feature new file mode 100644 index 000000000..272478312 --- /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 0f42573cd..98e3b5bcd 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 000000000..e69de29bb diff --git a/src/plone/restapi/services/themes/configure.zcml b/src/plone/restapi/services/themes/configure.zcml new file mode 100644 index 000000000..1b8d0759f --- /dev/null +++ b/src/plone/restapi/services/themes/configure.zcml @@ -0,0 +1,35 @@ + + + + + + + + + + + diff --git a/src/plone/restapi/services/themes/get.py b/src/plone/restapi/services/themes/get.py new file mode 100644 index 000000000..53e8f5b29 --- /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 000000000..30e8817d5 --- /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 000000000..d1be1e88d --- /dev/null +++ b/src/plone/restapi/services/themes/post.py @@ -0,0 +1,68 @@ +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 + +import zipfile + + +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/http-examples/themes_get.req b/src/plone/restapi/tests/http-examples/themes_get.req new file mode 100644 index 000000000..443f76e0a --- /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 000000000..0311f2a19 --- /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 000000000..8579e2844 --- /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 000000000..387dc98cd --- /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 000000000..79952c3d0 --- /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 000000000..58e46abbc --- /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 000000000..e316be1cf --- /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 000000000..58e46abbc --- /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 000000000..63495899e --- /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 000000000..ce1372d24 --- /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" +} 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 000000000..adc0ddee0 --- /dev/null +++ b/src/plone/restapi/tests/test_services_themes.py @@ -0,0 +1,199 @@ +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 + +import io +import os +import unittest + + +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"}) + self.api_session.auth = (SITE_OWNER_NAME, SITE_OWNER_PASSWORD) + + 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)