Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions docs/source/endpoints/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ searching
sharing
site
system
themes
transactions
translations
tusupload
Expand Down
106 changes: 106 additions & 0 deletions docs/source/endpoints/themes.md
Original file line number Diff line number Diff line change
@@ -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
```
1 change: 1 addition & 0 deletions news/1994.feature
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions src/plone/restapi/services/configure.zcml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@
<include package=".site" />
<include package=".system" />
<include package=".sources" />
<include
package=".themes"
zcml:condition="installed plone.app.theming"
/>
<include package=".transactions" />
<include package=".types" />
<include package=".upgrade" />
Expand Down
Empty file.
35 changes: 35 additions & 0 deletions src/plone/restapi/services/themes/configure.zcml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<configure
xmlns="http://namespaces.zope.org/zope"
xmlns:plone="http://namespaces.plone.org/plone"
>

<include
package="plone.restapi"
file="meta.zcml"
/>

<plone:service
method="GET"
factory=".get.ThemesGet"
for="Products.CMFPlone.interfaces.IPloneSiteRoot"
permission="cmf.ManagePortal"
name="@themes"
/>

<plone:service
method="POST"
factory=".post.ThemesPost"
for="Products.CMFPlone.interfaces.IPloneSiteRoot"
permission="cmf.ManagePortal"
name="@themes"
/>

<plone:service
method="PATCH"
factory=".patch.ThemesPatch"
for="Products.CMFPlone.interfaces.IPloneSiteRoot"
permission="cmf.ManagePortal"
name="@themes"
/>

</configure>
46 changes: 46 additions & 0 deletions src/plone/restapi/services/themes/get.py
Original file line number Diff line number Diff line change
@@ -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,
}
52 changes: 52 additions & 0 deletions src/plone/restapi/services/themes/patch.py
Original file line number Diff line number Diff line change
@@ -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()
68 changes: 68 additions & 0 deletions src/plone/restapi/services/themes/post.py
Original file line number Diff line number Diff line change
@@ -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,
}
3 changes: 3 additions & 0 deletions src/plone/restapi/tests/http-examples/themes_get.req
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
GET /plone/@themes/plonetheme.barceloneta HTTP/1.1
Accept: application/json
Authorization: Basic YWRtaW46c2VjcmV0
12 changes: 12 additions & 0 deletions src/plone/restapi/tests/http-examples/themes_get.resp
Original file line number Diff line number Diff line change
@@ -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"
}
3 changes: 3 additions & 0 deletions src/plone/restapi/tests/http-examples/themes_get_list.req
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
GET /plone/@themes HTTP/1.1
Accept: application/json
Authorization: Basic YWRtaW46c2VjcmV0
14 changes: 14 additions & 0 deletions src/plone/restapi/tests/http-examples/themes_get_list.resp
Original file line number Diff line number Diff line change
@@ -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"
}
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
PATCH /plone/@themes/plonetheme.barceloneta HTTP/1.1
Accept: application/json
Authorization: Basic YWRtaW46c2VjcmV0
Content-Type: application/json

{"active": true}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
HTTP/1.1 204 No Content
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
PATCH /plone/@themes/plonetheme.barceloneta HTTP/1.1
Accept: application/json
Authorization: Basic YWRtaW46c2VjcmV0
Content-Type: application/json

{"active": false}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
HTTP/1.1 204 No Content
11 changes: 11 additions & 0 deletions src/plone/restapi/tests/http-examples/themes_post.req
Original file line number Diff line number Diff line change
@@ -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

<binary zip data>
-----------------------------1234567890--
Loading