Skip to content
Open
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
5 changes: 5 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,8 @@ CANONICAL_CLA_API_URL=https://cla.staging.canonical.com
MARKETO_API_CLIENT=marketo_client
MARKETO_API_SECRET=marketo_secret
MARKETO_API_URL=https://066-EOV-335.mktorest.com

# WordPress hero demo (/wp-hero-demo). URL + page id are defined in code;
# only credentials come from the environment (shared with ubuntu.com).
WORDPRESS_USERNAME=
WORDPRESS_APPLICATION_PASSWORD=
10 changes: 10 additions & 0 deletions konf/site.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -244,3 +244,13 @@ demo:

- name: FLASK_ENV
value: "demo"

- name: WORDPRESS_USERNAME
secretKeyRef:
key: wordpress-username
name: wordpress-api

- name: WORDPRESS_APPLICATION_PASSWORD
secretKeyRef:
key: wordpress-application-password
name: wordpress-api
13 changes: 13 additions & 0 deletions templates/wp-hero-demo.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{% extends 'base_index.html' %}

{% from "_macros/vf_hero.jinja" import vf_hero %}

{% block title %}WordPress hero demo{% endblock %}

{% block meta_description %}
Demo page rendering a hero authored in WordPress via the vf_hero macro.
{% endblock %}

{% block content %}
{% call(slot) vf_hero(**hero) %}{% endcall %}
{% endblock %}
29 changes: 29 additions & 0 deletions tests/fixtures/hero_content_raw_wp.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<!-- wp:group {"layout":{"type":"constrained"},"metadata":{"name":"hero-layout\u002d\u002d50-50"}} -->
<div class="wp-block-group"><!-- wp:group {"className":"hero-layout\u002d\u002d50-50","layout":{"type":"constrained"}} -->
<div class="wp-block-group hero-layout--50-50"><!-- wp:heading {"level":1} -->
<h1 class="wp-block-heading">Take control of your large-scale deployments</h1>
<!-- /wp:heading -->

<!-- wp:heading -->
<h2 class="wp-block-heading">Enterprise-grade tooling, open source at heart</h2>
<!-- /wp:heading -->

<!-- wp:paragraph -->
<p>Deploy, integrate and operate your infrastructure with confidence.</p>
<!-- /wp:paragraph -->

<!-- wp:buttons -->
<div class="wp-block-buttons"><!-- wp:button -->
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="/contact-us">Contact us</a></div>
<!-- /wp:button -->

<!-- wp:button -->
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://github.com/canonical">View on GitHub</a></div>
<!-- /wp:button --></div>
<!-- /wp:buttons -->

<!-- wp:image -->
<figure class="wp-block-image"><img src="https://assets.ubuntu.com/v1/d7c20674-JAAS-arch-diagram.svg" alt="Architecture diagram"/></figure>
<!-- /wp:image --></div>
<!-- /wp:group --></div>
<!-- /wp:group -->
240 changes: 240 additions & 0 deletions tests/test_wordpress.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
import unittest
from pathlib import Path
from unittest.mock import MagicMock

from webapp.wordpress import (
WordPressError,
fetch_page_raw_content,
get_hero_from_page,
map_hero,
parse_blocks,
)

# Real content.raw captured from the WordPress editor for the demo page: it has
# an extra outer wrapper group and "--" encoded as \u002d\u002d inside the
# block-comment JSON. This is a static snapshot so tests run offline (no
# network or credentials); the running app fetches this live over HTTP instead.
WP_FIXTURE = (
Path(__file__).parent / "fixtures" / "hero_content_raw_wp.html"
).read_text()


class TestParseBlocks(unittest.TestCase):
def test_open_close_nesting_and_names(self):
markup = (
'<!-- wp:group {"className":"hero-layout--50-50"} -->'
'<!-- wp:heading {"level":1} --><h1>T</h1><!-- /wp:heading -->'
"<!-- wp:buttons -->"
"<!-- wp:button --><a href=/a>A</a><!-- /wp:button -->"
"<!-- wp:button --><a href=/b>B</a><!-- /wp:button -->"
"<!-- /wp:buttons -->"
"<!-- wp:image --><img src=/i.svg><!-- /wp:image -->"
"<!-- /wp:group -->"
)
group = parse_blocks(markup)[0]
self.assertEqual(group.name, "core/group")
self.assertEqual(
[b.name for b in group.inner_blocks],
["core/heading", "core/buttons", "core/image"],
)
buttons = group.inner_blocks[1]
self.assertEqual(
[b.name for b in buttons.inner_blocks],
["core/button", "core/button"],
)

def test_attrs_json_parsed(self):
blocks = parse_blocks(
'<!-- wp:heading {"level":1} --><h1>T</h1><!-- /wp:heading -->'
)
self.assertEqual(blocks[0].attrs.get("level"), 1)

def test_void_block(self):
blocks = parse_blocks('<!-- wp:spacer {"height":"20px"} /-->')
self.assertEqual(len(blocks), 1)
self.assertEqual(blocks[0].name, "core/spacer")
self.assertEqual(blocks[0].attrs.get("height"), "20px")
self.assertEqual(blocks[0].inner_blocks, [])

def test_nested_json_attrs(self):
# Real WP group blocks carry a nested "layout" object; the parser
# must not truncate the JSON at the first closing brace.
markup = (
'<!-- wp:group {"className":"hero-layout--50-50",'
'"layout":{"type":"constrained"}} -->\n'
'<div class="wp-block-group hero-layout--50-50"></div>\n'
"<!-- /wp:group -->"
)
blocks = parse_blocks(markup)
self.assertEqual(len(blocks), 1)
self.assertEqual(
blocks[0].attrs.get("className"), "hero-layout--50-50"
)
self.assertEqual(
blocks[0].attrs.get("layout"), {"type": "constrained"}
)

def test_brace_inside_string_not_counted(self):
markup = (
'<!-- wp:paragraph {"content":"a } b"} -->'
"x<!-- /wp:paragraph -->"
)
blocks = parse_blocks(markup)
self.assertEqual(blocks[0].attrs.get("content"), "a } b")

def test_editor_wrapper_group_is_nested(self):
# The editor wraps hero content in an extra outer group.
blocks = parse_blocks(WP_FIXTURE)
self.assertEqual(len(blocks), 1)
self.assertEqual(blocks[0].name, "core/group")
self.assertEqual(
[b.name for b in blocks[0].inner_blocks], ["core/group"]
)


class TestMapHero(unittest.TestCase):
"""Maps the real editor output (outer wrapper + \\u002d\\u002d classes)."""

def setUp(self):
self.hero = map_hero(parse_blocks(WP_FIXTURE))

def test_maps_despite_outer_wrapper_group(self):
self.assertIsNotNone(self.hero)
self.assertEqual(
self.hero["title_text"],
"Take control of your large-scale deployments",
)

def test_subtitle_without_explicit_level(self):
# The H2 has no "level" attribute; it must still be the subtitle.
self.assertEqual(
self.hero["subtitle_text"],
"Enterprise-grade tooling, open source at heart",
)

def test_layout_decoded_from_escaped_class(self):
self.assertEqual(self.hero["layout"], "50-50")

def test_block_order_and_types(self):
types = [b["type"] for b in self.hero["blocks"]]
self.assertEqual(types, ["description", "cta-block", "image"])

def test_description_block(self):
description = next(
b for b in self.hero["blocks"] if b["type"] == "description"
)
self.assertEqual(description["item"]["type"], "html")
self.assertIn("Deploy, integrate", description["item"]["content"])

def test_cta_block(self):
cta = next(b for b in self.hero["blocks"] if b["type"] == "cta-block")[
"item"
]
self.assertEqual(cta["primary"]["content_html"], "Contact us")
self.assertEqual(cta["primary"]["attrs"]["href"], "/contact-us")
self.assertEqual(len(cta["secondaries"]), 1)
self.assertEqual(
cta["secondaries"][0]["attrs"]["href"],
"https://github.com/canonical",
)

def test_image_block(self):
image = next(b for b in self.hero["blocks"] if b["type"] == "image")[
"item"
]
self.assertEqual(
image["attrs"]["src"],
"https://assets.ubuntu.com/v1/d7c20674-JAAS-arch-diagram.svg",
)
self.assertEqual(image["attrs"]["alt"], "Architecture diagram")

def test_image_aspect_ratio_and_dimensions(self):
markup = (
'<!-- wp:group {"className":"hero-layout--50-50"} -->'
'<!-- wp:heading {"level":1} --><h1>T</h1><!-- /wp:heading -->'
'<!-- wp:image {"className":"aspect--16-9"} -->'
'<figure><img src="/a.svg" alt="alt" width="1848" '
'height="933"/></figure><!-- /wp:image -->'
"<!-- /wp:group -->"
)
image = next(
b
for b in map_hero(parse_blocks(markup))["blocks"]
if b["type"] == "image"
)["item"]
self.assertEqual(image["aspect_ratio"], "16-9")
self.assertEqual(image["attrs"]["width"], "1848")
self.assertEqual(image["attrs"]["height"], "933")

def test_no_title_returns_none(self):
blocks = parse_blocks(
"<!-- wp:group -->\n<div></div>\n<!-- /wp:group -->"
)
self.assertIsNone(map_hero(blocks))

def test_signpost_image(self):
markup = (
'<!-- wp:group {"className":"hero-layout--25-75"} -->\n'
'<!-- wp:heading {"level":1} -->\n<h1>Title</h1>\n'
"<!-- /wp:heading -->\n"
'<!-- wp:image {"className":"signpost"} -->\n'
'<figure><img src="/sign.svg" alt="sign"/></figure>\n'
"<!-- /wp:image -->\n"
"<!-- /wp:group -->"
)
hero = map_hero(parse_blocks(markup))
signposts = [
b for b in hero["blocks"] if b["type"] == "signpost_image"
]
self.assertEqual(len(signposts), 1)
self.assertEqual(signposts[0]["item"]["attrs"]["src"], "/sign.svg")


class TestFetch(unittest.TestCase):
def _session(self):
session = MagicMock()
response = MagicMock()
response.json.return_value = {"content": {"raw": WP_FIXTURE}}
response.raise_for_status.return_value = None
session.get.return_value = response
return session

def test_fetch_calls_edit_context_with_auth(self):
session = self._session()
raw = fetch_page_raw_content(
session, "https://wp.example.com/", "user", "pass", "42"
)
self.assertEqual(raw, WP_FIXTURE)
session.get.assert_called_once()
args, kwargs = session.get.call_args
self.assertEqual(
args[0], "https://wp.example.com/wp-json/wp/v2/pages/42"
)
self.assertEqual(kwargs["params"], {"context": "edit"})
self.assertEqual(kwargs["auth"], ("user", "pass"))

def test_get_hero_from_page(self):
session = self._session()
hero = get_hero_from_page(
session, "https://wp.example.com", "user", "pass", "42"
)
self.assertEqual(
hero["title_text"],
"Take control of your large-scale deployments",
)

def test_missing_raw_raises_wordpress_error(self):
session = MagicMock()
response = MagicMock()
# Unauthenticated ?context=edit returns rendered but no raw.
response.json.return_value = {"content": {"rendered": "<p>x</p>"}}
response.raise_for_status.return_value = None
session.get.return_value = response
with self.assertRaises(WordPressError):
fetch_page_raw_content(
session, "https://wp.example.com", "user", "pass", "42"
)


if __name__ == "__main__":
unittest.main()
59 changes: 59 additions & 0 deletions webapp/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
from webapp.greenhouse import Greenhouse, HarvestV3
from webapp.handlers import init_handlers
from webapp import llms
from webapp import wordpress
from webapp.navigation import (
build_navigation,
get_current_page_bubble,
Expand Down Expand Up @@ -217,6 +218,64 @@ def index():
return flask.render_template("index.html")


# WordPress-driven hero demo: proves the "edit in WordPress -> REST API ->
# vf_hero render" pipeline. A hero is authored in wp-admin as core Gutenberg
# blocks on a *draft* page; Flask fetches the raw block markup with
# ?context=edit (which returns unpublished drafts to an authenticated request),
# maps it to the vf_hero schema and renders it. Access to this preview route is
# intended to be restricted at the ingress (VPN IP allowlist), not in the app -
# see the commented nginx location block in konf/site.yaml.
Comment on lines +225 to +227
#
# The WordPress instance URL and demo page id are not secrets, so they are
# defined in code. Only the credentials come from the environment; the env var
# names and the wordpress-api secret are shared with ubuntu.com.
WP_API_URL = "https://admin.insights.ubuntu.com"
WP_HERO_DEMO_PAGE_ID = "131995"
WORDPRESS_USERNAME = os.getenv("WORDPRESS_USERNAME")
WORDPRESS_APPLICATION_PASSWORD = os.getenv("WORDPRESS_APPLICATION_PASSWORD")


@app.route("/wp-hero-demo")
def wp_hero_demo():
required = {
"WORDPRESS_USERNAME": WORDPRESS_USERNAME,
"WORDPRESS_APPLICATION_PASSWORD": WORDPRESS_APPLICATION_PASSWORD,
}
missing = [name for name, value in required.items() if not value]
if missing:
logger.warning(
"wp-hero-demo not configured; missing env vars: %s",
", ".join(missing),
)
flask.abort(
503,
"WordPress hero demo is not configured. Missing/empty env "
"vars: " + ", ".join(missing) + ". Set them in .env.local and "
"restart dotrun (env is read at startup).",
)

try:
with get_requests_session() as session:
hero = wordpress.get_hero_from_page(
session,
WP_API_URL,
WORDPRESS_USERNAME,
WORDPRESS_APPLICATION_PASSWORD,
WP_HERO_DEMO_PAGE_ID,
)
except (
wordpress.WordPressError,
requests.exceptions.RequestException,
) as error:
logger.exception("WordPress hero demo fetch failed")
flask.abort(502, f"Could not fetch the hero from WordPress: {error}")

if not hero:
flask.abort(404, "No hero block found on the configured page.")

return flask.render_template("wp-hero-demo.html", hero=hero)


app.add_url_rule("/sitemap.xml", view_func=index_sitemap)
app.add_url_rule("/sitemap-links.xml", view_func=home_sitemap)
app.add_url_rule("/asset/<file_name>", view_func=json_asset_query)
Expand Down
Loading
Loading