45c39b1695
- blueprints: register trust_bar (icon/title/text items) and applications_band (2x2 dark-band tiles) block types; render with escaped items, ordinal icon fallback, 8-item cap; auto-exposed in editor via /api/editor/blocks and grouped under business in the studio palette - blocks.css: platform base layer for both blocks (app/static + trade-pro Layer A kept verbatim in sync) + trade-pro bridge vocabulary (icon-circle, hairline dividers, navy->brand gradient band, glass tiles, 900/620 breakpoints, reduced-motion) without touching canvas hooks - tests: test_trade_pro_home_block_driven covers the full studio-draft PUT -> publish -> build chain; 8 block types on the homepage, template markers absent under layout_mode=replace, clean build report - scripts/setup_home_blocks.py: production write path for site 483295c29ea0 (login -> draft -> publish -> build -> verify); real catalogue counts feed the stats block; run after the Docker image picks up the new block types Co-Authored-By: Claude Code <noreply@anthropic.com>
214 lines
10 KiB
Python
214 lines
10 KiB
Python
"""Task 13b: switch the HESU Pipe homepage from template-driven to block-driven.
|
|
|
|
Writes the 8-type block sequence into the site home entry through the real
|
|
studio-draft API (login -> PUT draft -> publish -> build -> verify), so the
|
|
page renders from blocks (layout_mode=replace) with the trade-pro block
|
|
bridge instead of pages/home.html. The template file itself stays untouched.
|
|
|
|
Run AFTER the Docker image has been rebuilt with the trust_bar /
|
|
applications_band block types, then:
|
|
|
|
PYTHONUTF8=1 .venv/Scripts/python scripts/setup_home_blocks.py
|
|
|
|
Reads SITEFORGE_ADMIN_PASSWORD / SITEFORGE_PORT from the repo .env.
|
|
"""
|
|
from __future__ import annotations
|
|
import json
|
|
import sys
|
|
import time
|
|
import uuid
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
sys.path.insert(0, str(ROOT))
|
|
from app import blueprints # noqa: E402 -- local package for block defaults
|
|
|
|
SITE_ID = '483295c29ea0' # HESU Pipe (real import site, theme trade-pro)
|
|
HOME_ENTRY_ID = 'c8ff1d1a45f3' # route '/' home entry
|
|
|
|
|
|
def load_env() -> dict:
|
|
values: dict[str, str] = {}
|
|
env_path = ROOT / '.env'
|
|
if env_path.is_file():
|
|
for line in env_path.read_text(encoding='utf-8').splitlines():
|
|
line = line.strip()
|
|
if line and not line.startswith('#') and '=' in line:
|
|
key, val = line.split('=', 1)
|
|
values[key.strip()] = val.strip()
|
|
return values
|
|
|
|
|
|
def real_stats(entries: list[dict]) -> list[dict]:
|
|
"""Derive stat items from actual catalogue counts - no invented numbers."""
|
|
products = [e for e in entries if e.get('type') in ('product', 'vehicle')]
|
|
posts = [e for e in entries if e.get('type') == 'post']
|
|
|
|
def field(entry, key):
|
|
fields = (entry.get('meta') or {}).get('fields') or {}
|
|
return str(fields.get(key) or '').strip()
|
|
|
|
categories = {field(p, 'category') for p in products if field(p, 'category')}
|
|
brands = {field(p, 'brand') for p in products if field(p, 'brand')}
|
|
items = [('Products', len(products)), ('Categories', len(categories)),
|
|
('Brands', len(brands)), ('News updates', len(posts))]
|
|
return [{'value': str(count), 'label': label} for label, count in items if count > 0]
|
|
|
|
|
|
def build_blocks(entries: list[dict]) -> list[dict]:
|
|
stats = real_stats(entries)
|
|
sequence = [
|
|
blueprints.new_block('hero', {
|
|
'title': '{{site.name}}',
|
|
'subtitle': 'GLOBAL SOURCING · MADE RELIABLE',
|
|
'description': 'Reliable products, transparent specifications and responsive export service.',
|
|
'image': '{{page.cover}}',
|
|
'button': 'Browse products', 'href': '/products/'}),
|
|
blueprints.new_block('trust_bar', {}),
|
|
blueprints.new_block('image_text', {
|
|
'title': 'One partner from inquiry to delivery.',
|
|
'subtitle': 'ABOUT US',
|
|
'description': 'Product specifications, quality control and export documentation in one place.',
|
|
'image': '{{page.cover}}',
|
|
'button': 'About our company', 'href': '/about/'}),
|
|
blueprints.new_block('stats', {'items': stats}) if stats else None,
|
|
blueprints.new_block('collection', {
|
|
'title': 'Featured products', 'subtitle': 'TRADE CATALOGUE',
|
|
'source': 'product', 'limit': 4, 'columns': 4, 'image_fit': 'cover',
|
|
'button': 'Browse all products', 'href': '/products/'}),
|
|
blueprints.new_block('applications_band', {}),
|
|
blueprints.new_block('features', {'title': 'Why buyers stay with us', 'items': [
|
|
{'title': 'Verified sourcing', 'text': 'Suppliers audited against your specifications before the first sample.'},
|
|
{'title': 'Strict quality control', 'text': 'Pre-shipment inspection with photographic evidence for every batch.'},
|
|
{'title': 'Export documentation', 'text': 'Certification, packing lists and customs papers prepared in-house.'},
|
|
{'title': 'Dedicated support', 'text': 'One trade desk contact from inquiry to container loading.'}]}),
|
|
blueprints.new_block('collection', {
|
|
'title': 'Latest updates', 'subtitle': 'COMPANY NEWS',
|
|
'source': 'post', 'limit': 3, 'columns': 3, 'image_fit': 'cover',
|
|
'button': 'View all news', 'href': '/news/'}),
|
|
blueprints.new_block('cta', {
|
|
'title': 'Send one inquiry, get a matched quote.',
|
|
'subtitle': 'Share your specifications and target market; our trade desk replies within one business day.',
|
|
'button': 'Request a quote', 'href': '/contact/'}),
|
|
]
|
|
return [block for block in sequence if block is not None]
|
|
|
|
|
|
def main() -> int:
|
|
env = load_env()
|
|
password = env.get('SITEFORGE_ADMIN_PASSWORD') or ''
|
|
if not password:
|
|
print('ERROR: SITEFORGE_ADMIN_PASSWORD missing from .env')
|
|
return 1
|
|
port = (env.get('SITEFORGE_PORT') or '8787').strip() or '8787'
|
|
base = f'http://127.0.0.1:{port}'
|
|
|
|
with httpx.Client(base_url=base, timeout=30) as client:
|
|
health = client.get('/api/health')
|
|
health.raise_for_status()
|
|
print('server:', health.json())
|
|
|
|
login = client.post('/api/auth/login', json={'username': 'admin', 'password': password})
|
|
if login.status_code != 200:
|
|
print('ERROR: login failed:', login.text[:200])
|
|
return 1
|
|
client.headers['X-SiteForge-CSRF'] = client.get('/api/auth/me').json()['csrf']
|
|
|
|
blocks_catalog = client.get('/api/editor/blocks').json()['blocks']
|
|
missing = [t for t in ('trust_bar', 'applications_band') if t not in blocks_catalog]
|
|
if missing:
|
|
print(f'ERROR: server does not know block types {missing} - rebuild the '
|
|
'Docker image with the current source before running this script.')
|
|
return 1
|
|
|
|
entries = client.get(f'/api/sites/{SITE_ID}/entries').json()
|
|
entry = next((e for e in entries if e.get('route') == '/'), None)
|
|
if not entry or entry['id'] != HOME_ENTRY_ID:
|
|
print(f'ERROR: home entry {HOME_ENTRY_ID} (route /) not found on site {SITE_ID}')
|
|
return 1
|
|
|
|
blocks = build_blocks(entries)
|
|
blueprints.validate_blocks(blocks) # fail fast locally before touching the draft
|
|
|
|
state = client.get(f'/api/sites/{SITE_ID}/pages/{HOME_ENTRY_ID}/studio-draft').json()
|
|
revision = int(state.get('revision') or 0)
|
|
print(f"draft state: {state.get('state')} revision={revision}")
|
|
|
|
put = client.put(f'/api/sites/{SITE_ID}/pages/{HOME_ENTRY_ID}/studio-draft',
|
|
json={'blocks': blocks, 'page_template': 'home',
|
|
'layout_mode': 'replace', 'expected_revision': revision})
|
|
if put.status_code != 200:
|
|
print('ERROR: draft write failed:', put.status_code, put.text[:300])
|
|
return 1
|
|
next_revision = put.json()['revision']
|
|
print(f'draft saved: revision={next_revision} blocks={len(blocks)} '
|
|
f'types={[b["type"] for b in blocks]}')
|
|
|
|
publish = client.post(f'/api/sites/{SITE_ID}/pages/{HOME_ENTRY_ID}/studio-draft/publish',
|
|
json={'expected_revision': next_revision})
|
|
if publish.status_code != 200:
|
|
print('ERROR: publish failed:', publish.status_code, publish.text[:300])
|
|
return 1
|
|
print('published to content library:', publish.json().get('message'))
|
|
|
|
before = {b['id'] for b in client.get(f'/api/sites/{SITE_ID}/builds').json()}
|
|
key = 'task13b-home-' + uuid.uuid4().hex[:12]
|
|
job = client.post(f'/api/sites/{SITE_ID}/builds', json={'mirror_images': False},
|
|
headers={'Idempotency-Key': key})
|
|
if job.status_code != 202:
|
|
print('ERROR: build submit failed:', job.status_code, job.text[:300])
|
|
return 1
|
|
print('build queued:', job.json())
|
|
|
|
build_id = None
|
|
for _ in range(120):
|
|
time.sleep(2)
|
|
rows = client.get(f'/api/sites/{SITE_ID}/builds').json()
|
|
fresh = [b for b in rows if b['id'] not in before]
|
|
done = [b for b in fresh if b['status'] == 'complete']
|
|
if done:
|
|
build_id = done[0]['id']
|
|
break
|
|
failed = [b for b in fresh if b['status'] in ('failed', 'error')]
|
|
if failed:
|
|
print('ERROR: build failed:', json.dumps(failed[0], ensure_ascii=False)[:400])
|
|
return 1
|
|
if not build_id:
|
|
print('ERROR: build did not complete in time')
|
|
return 1
|
|
print('build complete:', build_id)
|
|
|
|
report = client.get(f'/api/sites/{SITE_ID}/builds/{build_id}/report').json()
|
|
print('report: pages=%s errors=%s broken_links=%s missing_local_images=%s'
|
|
% (report.get('pages'), len(report.get('errors') or []),
|
|
len(report.get('broken_links') or []), len(report.get('missing_local_images') or [])))
|
|
if report.get('errors') or report.get('broken_links'):
|
|
print('ERROR: build report is not clean')
|
|
return 1
|
|
|
|
page = client.get(f'/preview/{SITE_ID}/{build_id}/')
|
|
page.raise_for_status()
|
|
html = page.text
|
|
# render_blocks always emits class="sf-section sf-{type}" as the opening
|
|
# attribute, so this is an exact section-level check.
|
|
absent = [t for t in ('hero', 'trust_bar', 'image_text', 'stats',
|
|
'applications_band', 'features', 'cta')
|
|
if f'class="sf-section sf-{t}"' not in html]
|
|
if html.count('class="sf-section sf-collection"') < 2:
|
|
absent.append('collection x2')
|
|
gone = [m for m in ('sf-tp-hero', 'sf-tp-trustbar', 'sf-tp-two-col',
|
|
'sf-tp-applications', 'sf-tp-feature-grid', 'sf-tp-cta') if m in html]
|
|
print('block sections missing:', absent or 'none')
|
|
print('template markers still present:', gone or 'none')
|
|
if absent or gone:
|
|
print('ERROR: homepage is not block-driven as expected')
|
|
return 1
|
|
print('PASS: homepage is now block-driven (layout_mode=replace), build clean.')
|
|
return 0
|
|
|
|
|
|
if __name__ == '__main__':
|
|
raise SystemExit(main())
|