Skip to content
Merged
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 backend/news/441.documentation
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Rewrote the docstrings of the `utils.scripts` site-creation helpers in reStructuredText. @ericof
1 change: 1 addition & 0 deletions backend/news/441.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`create_site` now accepts a list of browser-layer interfaces and can install additional Generic Setup profiles during site creation. @ericof
1 change: 1 addition & 0 deletions backend/news/441.tests
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Expanded test coverage for the site-creation script helpers, including an end-to-end `create_site` test. @ericof
30 changes: 19 additions & 11 deletions backend/scripts/create_site.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,36 @@
from kitconcept.core.interfaces import IBrowserLayer
from kitconcept.core.utils.scripts import create_site
from kitconcept.core.utils import scripts
from pathlib import Path
from typing import Any

import os


SCRIPT_DIR = Path().cwd() / "scripts"

ANSWERS = {
"site_id": os.getenv("SITE_ID"),
"title": os.getenv("SITE_TITLE"),
"description": os.getenv("SITE_DESCRIPTION"),
"distribution": os.getenv("DISTRIBUTION"),
"default_language": os.getenv("SITE_DEFAULT_LANGUAGE"),
"portal_timezone": os.getenv("SITE_PORTAL_TIMEZONE"),
"setup_content": os.getenv("SITE_SETUP_CONTENT", "true"),
}
OPTIONS: tuple[tuple[str, str, Any], ...] = (
("site_id", "SITE_ID", None),
("title", "SITE_TITLE", None),
("description", "SITE_DESCRIPTION", None),
("available_languages", "SITE_AVAILABLE_LANGUAGES", scripts.as_list),
("distribution", "DISTRIBUTION", None),
("default_language", "SITE_DEFAULT_LANGUAGE", None),
("portal_timezone", "SITE_PORTAL_TIMEZONE", None),
("setup_content", "SITE_SETUP_CONTENT", scripts.as_bool),
)


def main():
app = globals()["app"]
filename = os.getenv("ANSWERS", "default.json")
answers_file = SCRIPT_DIR / filename
create_site(app, ANSWERS, answers_file, IBrowserLayer)
scripts.create_site(
app=app,
answers_file=answers_file,
env_answers={},
package_iface=[IBrowserLayer],
env_options=OPTIONS,
)


if __name__ == "__main__":
Expand Down
145 changes: 130 additions & 15 deletions backend/src/kitconcept/core/utils/scripts.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
from AccessControl.SecurityManagement import newSecurityManager
from collections.abc import Sequence
from kitconcept.core import logger
from kitconcept.core.factory import add_site
from kitconcept.core.interfaces import IBrowserLayer
from OFS.Application import Application
from pathlib import Path
from Products.CMFPlone.Portal import PloneSite
from Products.GenericSetup.tool import SetupTool
from Testing.makerequest import makerequest
from typing import Any
from zope.interface import directlyProvidedBy
Expand All @@ -22,10 +24,14 @@
truthy = frozenset(("t", "true", "y", "yes", "on", "1"))


def as_bool(s):
"""Return the boolean value ``True`` if the case-lowered value of string
input ``s`` is a :term:`truthy string`. If ``s`` is already one of the
boolean values ``True`` or ``False``, return it."""
def as_bool(s) -> bool:
"""Coerce a value into a boolean.

:param s: Value to coerce. ``None`` yields ``False``; an existing ``bool``
is returned unchanged; any other value is stringified, stripped, and
compared case-insensitively against :data:`truthy`.
:returns: ``True`` if ``s`` represents a truthy string, ``False`` otherwise.
"""
if s is None:
return False
if isinstance(s, bool):
Expand Down Expand Up @@ -102,6 +108,19 @@ def as_list(value: str) -> list[str]:


def parse_answers(answers_file: Path, answers_env: dict) -> dict:
"""Load site-creation answers from a JSON file and override them from the
environment.

Answers are read from ``answers_file``; for each key present in that file,
a matching value in ``answers_env`` takes precedence. The ``setup_content``
key is coerced with :func:`as_bool` when a value is supplied. Empty or
missing environment values leave the file value untouched.

:param answers_file: Path to a JSON file holding the base answers.
:param answers_env: Mapping of answer keys to environment-provided
overrides, typically produced by :func:`get_environmental_variables`.
:returns: The merged answers mapping.
"""
answers = json.loads(answers_file.read_text())
for key in answers:
env_value = answers_env.get(key, "")
Expand All @@ -115,6 +134,7 @@ def parse_answers(answers_file: Path, answers_env: dict) -> dict:


def _prepare_loggers():
"""Configure logging output and silence noisy third-party loggers."""
logging.basicConfig(format="%(message)s")
logger.setLevel(logging.INFO)
# Silence some loggers
Expand All @@ -125,23 +145,54 @@ def _prepare_loggers():
logging.getLogger(logger_name).setLevel(logging.ERROR)


def _prepare_request(app: Application, package_iface: InterfaceClass | None = None):
def _prepare_request(
app: Application, package_ifaces: tuple[type[InterfaceClass], ...] = ()
) -> None:
"""Mark the application request with the required browser layers.

:param app: The Zope application object whose ``REQUEST`` is decorated.
:param package_ifaces: Additional browser-layer interfaces to provide on
the request, on top of :class:`IBrowserLayer` and any interfaces the
request already provides.
"""
request: HTTPRequest = app.REQUEST
ifaces = [IBrowserLayer]
if package_iface:
ifaces.append(package_iface)
ifaces: list[type[InterfaceClass]] = [IBrowserLayer]
if package_ifaces:
ifaces.extend(list(package_ifaces))
for iface in directlyProvidedBy(request):
ifaces.append(iface)

directlyProvides(request, *ifaces)


def _prepare_user(app: Application):
def _prepare_user(app: Application) -> None:
"""Authenticate the security context as the ``admin`` user.

:param app: The Zope application object providing ``acl_users``.
"""
admin = app.acl_users.getUserById("admin")
admin = admin.__of__(app.acl_users)
newSecurityManager(None, admin)


def _install_additional_profiles(
site: PloneSite, additional_profiles: Sequence[str]
) -> None:
"""Run all import steps for each of the given GenericSetup profiles.

:param site: The Plone site whose ``portal_setup`` tool applies the
profiles.
:param additional_profiles: Profile ids to install. An empty sequence is a
no-op.
"""
if not additional_profiles:
return
setup_tool: SetupTool = site.portal_setup
for profile in additional_profiles:
logger.info(f" - Installing additional profile {profile}")
setup_tool.runAllImportStepsFromProfile(profile)


def get_environmental_variables(
options: tuple[tuple[str, str, Any], ...] = OPTIONS,
) -> dict[str, Any]:
Expand Down Expand Up @@ -180,11 +231,33 @@ def _create_site(
distribution: str,
delete_existing: bool,
answers: dict[str, Any],
package_iface: InterfaceClass | None = None,
package_ifaces: tuple[type[InterfaceClass], ...] = (),
additional_profiles: Sequence[str] = (),
) -> PloneSite:
"""Create (or reuse) a Plone site inside the Zope application.

Prepares the request, security context and loggers, then adds a site with
id ``answers["site_id"]``. If a site with that id already exists it is
reused, unless ``delete_existing`` is set, in which case it is removed and
recreated. Additional GenericSetup profiles are installed on newly created
sites.

:param app: The Zope application object.
:param distribution: Distribution name used when ``answers`` does not carry
its own ``distribution`` key.
:param delete_existing: When ``True``, delete a pre-existing site with the
same id before creating a new one.
:param answers: Site-creation parameters passed through to
:func:`~kitconcept.core.factory.add_site`; must contain ``site_id``.
:param package_ifaces: Additional browser-layer interfaces to provide on
the request.
:param additional_profiles: GenericSetup profile ids to install on a newly
created site.
:returns: The created or reused Plone site.
"""
_prepare_loggers()
app = makerequest(app)
_prepare_request(app, package_iface)
_prepare_request(app, package_ifaces)
_prepare_user(app)
if "distribution" not in answers:
answers["distribution"] = distribution
Expand All @@ -211,6 +284,7 @@ def _create_site(
if site_id not in app.objectIds():
with transaction.manager:
site = add_site(app, **answers)
_install_additional_profiles(site, additional_profiles)
logger.info(f" - Site {site.id} created!")
else:
site = app[site_id]
Expand All @@ -221,14 +295,55 @@ def create_site(
app: Application,
answers_file: Path,
env_answers: dict[str, Any],
package_iface: InterfaceClass | None = None,
env_options: tuple[tuple[str, str, Any], ...] = (),
package_iface: type[InterfaceClass] | Sequence[type[InterfaceClass]] | None = None,
env_options: tuple[tuple[str, str, Any], ...] = OPTIONS,
additional_profiles: Sequence[str] = (),
distribution: str = "",
) -> PloneSite:
distribution = str(os.getenv("DISTRIBUTION", ""))
"""Create a new Plone site from a JSON answers file and the environment.

High-level entry point that resolves configuration from arguments,
environment variables and ``answers_file``, then delegates the actual
creation to :func:`_create_site`. The ``DELETE_EXISTING`` and
``DISTRIBUTION`` environment variables provide defaults for their
respective options. When ``env_answers`` is empty, overrides are collected
via :func:`get_environmental_variables`.

:param app: The Zope application object.
:param answers_file: Path to the JSON file holding the base answers.
:param env_answers: Pre-computed environment overrides; when falsy they are
gathered from the environment using ``env_options``.
:param package_iface: A single browser-layer interface or a sequence of
them to provide on the request, or ``None``.
:param env_options: Option definitions used to read overrides from the
environment via :func:`get_environmental_variables`; defaults to
:data:`OPTIONS`.
:param additional_profiles: GenericSetup profile ids to install on a newly
created site.
:param distribution: Distribution name; falls back to the ``DISTRIBUTION``
environment variable when empty.
:returns: The created or reused Plone site.
:raises FileNotFoundError: If ``answers_file`` does not exist.
:raises json.JSONDecodeError: If ``answers_file`` is not valid JSON.
:raises KeyError: If the resolved answers do not contain ``site_id``.
"""
package_ifaces: tuple[type[InterfaceClass], ...] = ()
delete_existing = as_bool(os.getenv("DELETE_EXISTING"))
distribution = distribution if distribution else str(os.getenv("DISTRIBUTION", ""))
if not env_answers:
# Extract answers from environment variables
env_answers = get_environmental_variables(env_options)
if package_iface and not isinstance(package_iface, Sequence):
package_ifaces = (package_iface,)
elif package_iface:
package_ifaces = tuple(package_iface)
# Load site creation parameters
answers = parse_answers(answers_file, env_answers)
return _create_site(app, distribution, delete_existing, answers, package_iface)
return _create_site(
app=app,
distribution=distribution,
delete_existing=delete_existing,
answers=answers,
package_ifaces=package_ifaces,
additional_profiles=additional_profiles,
)
12 changes: 7 additions & 5 deletions backend/tests/utils/default.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
{
"site_id": "Plone",
"title": "Câmara Modelo 2",
"description": "Site da câmara modelo",
"default_language": "pt-br",
"portal_timezone": "America/Sao_Paulo",
"setup_content": true
"title": "Core!",
"description": "kitconcept core distribution",
"available_languages": ["en"],
"default_language": "en",
"portal_timezone": "UTC",
"setup_content": false,
"authentication": {"provider": "internal"}
}
35 changes: 30 additions & 5 deletions backend/tests/utils/test_utils_scripts.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,23 @@ def answers_file():
(
("site_id", "", "Plone"),
("site_id", "Site", "Site"),
("title", "", "Core!"),
("title", "Foo Bar", "Foo Bar"),
("description", "A new site", "A new site"),
(
"description",
"",
"Site da câmara modelo",
"kitconcept core distribution",
),
("default_language", "", "pt-br"),
("available_languages", "", ["en"]),
("available_languages", ["de", "en"], ["de", "en"]),
("default_language", "", "en"),
("default_language", "de", "de"),
("portal_timezone", "", "America/Sao_Paulo"),
("portal_timezone", "UTC", "UTC"),
("setup_content", "", True),
("portal_timezone", "", "UTC"),
("portal_timezone", "America/Sao_Paulo", "America/Sao_Paulo"),
("setup_content", "", False),
("setup_content", "f", False),
("setup_content", "t", True),
),
)
def test_parse_answers(answers_file, key: str, value: str, expected: str | bool):
Expand Down Expand Up @@ -146,3 +150,24 @@ def test_mixed_plain_and_nested(self, monkeypatch):
result = scripts.get_environmental_variables(self.options)
assert result["site_id"] == "Plone"
assert result["authentication"] == {"provider": "oidc"}


class TestCreateSite:
distribution = "testing"

@pytest.fixture(autouse=True)
def _setup(self, app, answers_file):
self.app = app
self.answers_file = answers_file

def test_create_site(self):
site = scripts.create_site(
app=self.app,
answers_file=self.answers_file,
env_answers={},
package_iface=None,
env_options=scripts.OPTIONS,
distribution=self.distribution,
)
assert site is not None
assert site.id == "Plone"
Loading