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)