From 6aae92fc58591cb50eeb0b98d57c0b46242decbd Mon Sep 17 00:00:00 2001 From: Deeman Date: Sat, 28 Feb 2026 22:17:44 +0100 Subject: [PATCH 1/7] fix(admin): strip YAML frontmatter before mistune in _rebuild_article() Fixes a bug where manual article previews rendered raw frontmatter (title:, slug:, etc.) as visible text. Now strips the --- block using the existing _FRONTMATTER_RE before passing the body to mistune.html(). Co-Authored-By: Claude Sonnet 4.6 --- web/src/padelnomics/admin/routes.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/web/src/padelnomics/admin/routes.py b/web/src/padelnomics/admin/routes.py index 84ef4d4..6c02b3d 100644 --- a/web/src/padelnomics/admin/routes.py +++ b/web/src/padelnomics/admin/routes.py @@ -2769,7 +2769,10 @@ async def _rebuild_article(article_id: int): md_path = Path("data/content/articles") / f"{article['slug']}.md" if not md_path.exists(): return - body_html = mistune.html(md_path.read_text()) + raw = md_path.read_text() + m = _FRONTMATTER_RE.match(raw) + body = raw[m.end():] if m else raw + body_html = mistune.html(body) lang = article.get("language", "en") if hasattr(article, "get") else "en" body_html = await bake_scenario_cards(body_html, lang=lang) body_html = await bake_product_cards(body_html, lang=lang) From b1eeb0a0acb94431fb47b315e72ed1731b05db93 Mon Sep 17 00:00:00 2001 From: Deeman Date: Sat, 28 Feb 2026 22:23:00 +0100 Subject: [PATCH 2/7] feat(affiliate): add affiliate_programs table + migration 0027 Creates affiliate_programs for centralised retailer config (URL template, tracking tag, commission %). Adds nullable program_id + product_identifier to affiliate_products for backward compat. Seeds "Amazon" program with oneLink template. Backfills existing products by extracting ASINs from baked affiliate_url values. Co-Authored-By: Claude Sonnet 4.6 --- .../versions/0027_affiliate_programs.py | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 web/src/padelnomics/migrations/versions/0027_affiliate_programs.py diff --git a/web/src/padelnomics/migrations/versions/0027_affiliate_programs.py b/web/src/padelnomics/migrations/versions/0027_affiliate_programs.py new file mode 100644 index 0000000..698c3d6 --- /dev/null +++ b/web/src/padelnomics/migrations/versions/0027_affiliate_programs.py @@ -0,0 +1,79 @@ +"""Migration 0027: Affiliate programs table + program FK on products. + +affiliate_programs: centralises retailer configs (URL template + tag + commission). + - url_template uses {product_id} and {tag} placeholders, assembled at redirect time. + - tracking_tag: e.g. "padelnomics-21" — changing it propagates to all products instantly. + - commission_pct: stored as a decimal (0.03 = 3%) for revenue estimates. + - status: active/inactive — only active programs appear in the product form dropdown. + - notes: internal field for login URLs, account IDs, etc. + +affiliate_products changes: + - program_id (nullable FK): new products use a program; existing products keep their + baked affiliate_url (backward compat via build_affiliate_url() fallback). + - product_identifier: ASIN, product path, or other program-specific ID (e.g. B0XXXXX). + +Amazon OneLink note: we use a single "Amazon" program pointing to amazon.de. +Amazon OneLink (configured in the Associates dashboard, no code changes needed) +auto-redirects visitors to their local marketplace (UK→amazon.co.uk, ES→amazon.es) +with the correct regional tag. One program covers all Amazon marketplaces. +""" +import re + + +def up(conn) -> None: + conn.execute(""" + CREATE TABLE affiliate_programs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + url_template TEXT NOT NULL, + tracking_tag TEXT NOT NULL DEFAULT '', + commission_pct REAL NOT NULL DEFAULT 0, + homepage_url TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'active', + notes TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT + ) + """) + + # Seed the default Amazon program. + # OneLink handles geo-redirect to local marketplaces — no per-country programs needed. + conn.execute(""" + INSERT INTO affiliate_programs (name, slug, url_template, tracking_tag, commission_pct, homepage_url) + VALUES ('Amazon', 'amazon', 'https://www.amazon.de/dp/{product_id}?tag={tag}', 'padelnomics-21', 3.0, 'https://www.amazon.de') + """) + + # Add program FK + product identifier to products table. + # program_id is nullable — existing rows keep their baked affiliate_url. + conn.execute(""" + ALTER TABLE affiliate_products + ADD COLUMN program_id INTEGER REFERENCES affiliate_programs(id) + """) + conn.execute(""" + ALTER TABLE affiliate_products + ADD COLUMN product_identifier TEXT NOT NULL DEFAULT '' + """) + + # Backfill: extract ASIN from existing Amazon affiliate URLs. + # Pattern: /dp/ where ASIN is 10 uppercase alphanumeric chars. + amazon_program = conn.execute( + "SELECT id FROM affiliate_programs WHERE slug = 'amazon'" + ).fetchone() + assert amazon_program is not None, "Amazon program must exist after seed" + amazon_id = amazon_program[0] + + rows = conn.execute( + "SELECT id, affiliate_url FROM affiliate_products" + ).fetchall() + asin_re = re.compile(r"/dp/([A-Z0-9]{10})") + for product_id, url in rows: + if not url: + continue + m = asin_re.search(url) + if m: + asin = m.group(1) + conn.execute( + "UPDATE affiliate_products SET program_id=?, product_identifier=? WHERE id=?", + (amazon_id, asin, product_id), + ) From 8dbbd0df05e05727a9c0855659f8b58f315e76a8 Mon Sep 17 00:00:00 2001 From: Deeman Date: Sat, 28 Feb 2026 22:23:53 +0100 Subject: [PATCH 3/7] feat(affiliate): add program CRUD functions + build_affiliate_url() Adds get_all_programs(), get_program(), get_program_by_slug() for admin CRUD. Adds build_affiliate_url() that assembles URLs from program template + product identifier, with fallback to baked affiliate_url for legacy products. Updates get_product() to JOIN affiliate_programs so _program dict is available at redirect time. _parse_product() extracts program fields into nested _program key. Co-Authored-By: Claude Sonnet 4.6 --- web/src/padelnomics/affiliate.py | 97 +++++++++++++++++++++++++++++--- 1 file changed, 90 insertions(+), 7 deletions(-) diff --git a/web/src/padelnomics/affiliate.py b/web/src/padelnomics/affiliate.py index 24e8cde..f957bbf 100644 --- a/web/src/padelnomics/affiliate.py +++ b/web/src/padelnomics/affiliate.py @@ -21,6 +21,7 @@ logger = logging.getLogger(__name__) VALID_CATEGORIES = ("racket", "ball", "shoe", "bag", "grip", "eyewear", "accessory") VALID_STATUSES = ("draft", "active", "archived") +VALID_PROGRAM_STATUSES = ("active", "inactive") def hash_ip(ip_address: str) -> str: @@ -32,20 +33,86 @@ def hash_ip(ip_address: str) -> str: return hashlib.sha256(raw.encode()).hexdigest() -async def get_product(slug: str, language: str = "de") -> dict | None: - """Return active product by slug+language, falling back to any language.""" +async def get_all_programs(status: str | None = None) -> list[dict]: + """Return all affiliate programs, optionally filtered by status.""" + if status: + assert status in VALID_PROGRAM_STATUSES, f"unknown program status: {status}" + rows = await fetch_all( + "SELECT ap.*, (" + " SELECT COUNT(*) FROM affiliate_products WHERE program_id = ap.id" + ") AS product_count" + " FROM affiliate_programs ap WHERE ap.status = ?" + " ORDER BY ap.name ASC", + (status,), + ) + else: + rows = await fetch_all( + "SELECT ap.*, (" + " SELECT COUNT(*) FROM affiliate_products WHERE program_id = ap.id" + ") AS product_count" + " FROM affiliate_programs ap ORDER BY ap.name ASC" + ) + return [dict(r) for r in rows] + + +async def get_program(program_id: int) -> dict | None: + """Return a single affiliate program by id.""" + assert program_id > 0, "program_id must be positive" + row = await fetch_one( + "SELECT * FROM affiliate_programs WHERE id = ?", (program_id,) + ) + return dict(row) if row else None + + +async def get_program_by_slug(slug: str) -> dict | None: + """Return a single affiliate program by slug.""" assert slug, "slug must not be empty" row = await fetch_one( - "SELECT * FROM affiliate_products" - " WHERE slug = ? AND language = ? AND status = 'active'", + "SELECT * FROM affiliate_programs WHERE slug = ?", (slug,) + ) + return dict(row) if row else None + + +def build_affiliate_url(product: dict, program: dict | None = None) -> str: + """Assemble the final affiliate URL from program template + product identifier. + + Falls back to the baked product["affiliate_url"] when no program is set, + preserving backward compatibility with products created before programs existed. + """ + if not product.get("program_id") or not program: + return product["affiliate_url"] + return program["url_template"].format( + product_id=product["product_identifier"], + tag=program["tracking_tag"], + ) + + +async def get_product(slug: str, language: str = "de") -> dict | None: + """Return active product by slug+language, falling back to any language. + + JOINs affiliate_programs so the returned dict includes program fields + (prefixed with _program_*) for use in build_affiliate_url(). + """ + assert slug, "slug must not be empty" + row = await fetch_one( + "SELECT p.*, pg.url_template AS _program_url_template," + " pg.tracking_tag AS _program_tracking_tag," + " pg.name AS _program_name" + " FROM affiliate_products p" + " LEFT JOIN affiliate_programs pg ON pg.id = p.program_id" + " WHERE p.slug = ? AND p.language = ? AND p.status = 'active'", (slug, language), ) if row: return _parse_product(row) # Graceful fallback: show any language rather than nothing row = await fetch_one( - "SELECT * FROM affiliate_products" - " WHERE slug = ? AND status = 'active' LIMIT 1", + "SELECT p.*, pg.url_template AS _program_url_template," + " pg.tracking_tag AS _program_tracking_tag," + " pg.name AS _program_name" + " FROM affiliate_products p" + " LEFT JOIN affiliate_programs pg ON pg.id = p.program_id" + " WHERE p.slug = ? AND p.status = 'active' LIMIT 1", (slug,), ) return _parse_product(row) if row else None @@ -217,8 +284,24 @@ async def get_distinct_retailers() -> list[str]: def _parse_product(row) -> dict: - """Convert aiosqlite Row to plain dict, parsing JSON pros/cons arrays.""" + """Convert aiosqlite Row to plain dict, parsing JSON pros/cons arrays. + + If the row includes _program_* columns (from a JOIN), extracts them into + a nested "_program" dict so build_affiliate_url() can use them directly. + """ d = dict(row) d["pros"] = json.loads(d.get("pros") or "[]") d["cons"] = json.loads(d.get("cons") or "[]") + # Extract program fields added by get_product()'s JOIN + if "_program_url_template" in d: + if d.get("program_id") and d["_program_url_template"]: + d["_program"] = { + "url_template": d.pop("_program_url_template"), + "tracking_tag": d.pop("_program_tracking_tag", ""), + "name": d.pop("_program_name", ""), + } + else: + d.pop("_program_url_template", None) + d.pop("_program_tracking_tag", None) + d.pop("_program_name", None) return d From 6076a0b30fafcb69a332776ef9f3bd982ba30910 Mon Sep 17 00:00:00 2001 From: Deeman Date: Sat, 28 Feb 2026 22:27:57 +0100 Subject: [PATCH 4/7] feat(affiliate): use build_affiliate_url() in /go/ redirect Program-based products now get URLs assembled from the template at redirect time. Changing a program's tracking_tag propagates instantly to all its products without rebuilding. Legacy products (no program_id) still use their baked affiliate_url via fallback. Co-Authored-By: Claude Sonnet 4.6 --- web/src/padelnomics/app.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/web/src/padelnomics/app.py b/web/src/padelnomics/app.py index 82ba62a..94aaec8 100644 --- a/web/src/padelnomics/app.py +++ b/web/src/padelnomics/app.py @@ -291,7 +291,7 @@ def create_app() -> Quart: Uses 302 (not 301) so every hit is tracked — browsers don't cache 302s. Extracts article_slug and lang from Referer header best-effort. """ - from .affiliate import get_product, log_click + from .affiliate import build_affiliate_url, get_product, log_click from .core import check_rate_limit # Extract lang from Referer path (e.g. /de/blog/... → "de"), default de @@ -314,14 +314,17 @@ def create_app() -> Quart: if not product: abort(404) + # Assemble URL from program template; falls back to baked affiliate_url + url = build_affiliate_url(product, product.get("_program")) + ip = request.remote_addr or "unknown" allowed, _info = await check_rate_limit(f"aff:{ip}", limit=60, window=60) if not allowed: # Still redirect even if rate-limited; just don't log the click - return redirect(product["affiliate_url"], 302) + return redirect(url, 302) await log_click(product["id"], ip, article_slug, referer or None) - return redirect(product["affiliate_url"], 302) + return redirect(url, 302) # Legacy 301 redirects — bookmarked/cached URLs before lang prefixes existed @app.route("/terms") From 53117094eec44e9398608a165b55901887ca39a7 Mon Sep 17 00:00:00 2001 From: Deeman Date: Sat, 28 Feb 2026 22:32:45 +0100 Subject: [PATCH 5/7] feat(affiliate): admin CRUD for affiliate programs Adds program list, create, edit, delete routes with appropriate guards (delete blocked if products reference the program). Adds "Programs" tab to the affiliate subnav. New templates: affiliate_programs.html, affiliate_program_form.html, partials/affiliate_program_results.html. Co-Authored-By: Claude Sonnet 4.6 --- web/src/padelnomics/admin/routes.py | 204 ++++++++++++++++++ .../admin/affiliate_program_form.html | 134 ++++++++++++ .../templates/admin/affiliate_programs.html | 30 +++ .../admin/templates/admin/base_admin.html | 3 +- .../partials/affiliate_program_results.html | 36 ++++ 5 files changed, 406 insertions(+), 1 deletion(-) create mode 100644 web/src/padelnomics/admin/templates/admin/affiliate_program_form.html create mode 100644 web/src/padelnomics/admin/templates/admin/affiliate_programs.html create mode 100644 web/src/padelnomics/admin/templates/admin/partials/affiliate_program_results.html diff --git a/web/src/padelnomics/admin/routes.py b/web/src/padelnomics/admin/routes.py index 6c02b3d..5a90230 100644 --- a/web/src/padelnomics/admin/routes.py +++ b/web/src/padelnomics/admin/routes.py @@ -3257,6 +3257,210 @@ async def outreach_import(): AFFILIATE_CATEGORIES = ("racket", "ball", "shoe", "bag", "grip", "eyewear", "accessory") AFFILIATE_STATUSES = ("draft", "active", "archived") +AFFILIATE_PROGRAM_STATUSES = ("active", "inactive") + + +# ── Affiliate Programs ──────────────────────────────────────────────────────── + +def _form_to_program(form) -> dict: + """Parse affiliate program form values into a data dict.""" + commission_str = form.get("commission_pct", "").strip() + commission_pct = 0.0 + if commission_str: + try: + commission_pct = float(commission_str.replace(",", ".")) + except ValueError: + commission_pct = 0.0 + + return { + "name": form.get("name", "").strip(), + "slug": form.get("slug", "").strip(), + "url_template": form.get("url_template", "").strip(), + "tracking_tag": form.get("tracking_tag", "").strip(), + "commission_pct": commission_pct, + "homepage_url": form.get("homepage_url", "").strip(), + "status": form.get("status", "active").strip(), + "notes": form.get("notes", "").strip(), + } + + +@bp.route("/affiliate/programs") +@role_required("admin") +async def affiliate_programs(): + """Affiliate programs list — full page.""" + from ..affiliate import get_all_programs + + programs = await get_all_programs() + return await render_template( + "admin/affiliate_programs.html", + admin_page="affiliate_programs", + programs=programs, + ) + + +@bp.route("/affiliate/programs/results") +@role_required("admin") +async def affiliate_program_results(): + """HTMX partial: program rows.""" + from ..affiliate import get_all_programs + + programs = await get_all_programs() + return await render_template( + "admin/partials/affiliate_program_results.html", + programs=programs, + ) + + +@bp.route("/affiliate/programs/new", methods=["GET", "POST"]) +@role_required("admin") +@csrf_protect +async def affiliate_program_new(): + """Create an affiliate program.""" + if request.method == "POST": + form = await request.form + data = _form_to_program(form) + + if not data["name"] or not data["slug"] or not data["url_template"]: + await flash("Name, slug, and URL template are required.", "error") + return await render_template( + "admin/affiliate_program_form.html", + admin_page="affiliate_programs", + data=data, + editing=False, + program_statuses=AFFILIATE_PROGRAM_STATUSES, + ) + + existing = await fetch_one( + "SELECT id FROM affiliate_programs WHERE slug = ?", (data["slug"],) + ) + if existing: + await flash(f"Slug '{data['slug']}' already exists.", "error") + return await render_template( + "admin/affiliate_program_form.html", + admin_page="affiliate_programs", + data=data, + editing=False, + program_statuses=AFFILIATE_PROGRAM_STATUSES, + ) + + await execute( + """INSERT INTO affiliate_programs + (name, slug, url_template, tracking_tag, commission_pct, + homepage_url, status, notes) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + ( + data["name"], data["slug"], data["url_template"], + data["tracking_tag"], data["commission_pct"], + data["homepage_url"], data["status"], data["notes"], + ), + ) + await flash(f"Program '{data['name']}' created.", "success") + return redirect(url_for("admin.affiliate_programs")) + + return await render_template( + "admin/affiliate_program_form.html", + admin_page="affiliate_programs", + data={}, + editing=False, + program_statuses=AFFILIATE_PROGRAM_STATUSES, + ) + + +@bp.route("/affiliate/programs//edit", methods=["GET", "POST"]) +@role_required("admin") +@csrf_protect +async def affiliate_program_edit(program_id: int): + """Edit an affiliate program.""" + program = await fetch_one( + "SELECT * FROM affiliate_programs WHERE id = ?", (program_id,) + ) + if not program: + await flash("Program not found.", "error") + return redirect(url_for("admin.affiliate_programs")) + + if request.method == "POST": + form = await request.form + data = _form_to_program(form) + + if not data["name"] or not data["slug"] or not data["url_template"]: + await flash("Name, slug, and URL template are required.", "error") + return await render_template( + "admin/affiliate_program_form.html", + admin_page="affiliate_programs", + data={**dict(program), **data}, + editing=True, + program_id=program_id, + program_statuses=AFFILIATE_PROGRAM_STATUSES, + ) + + if data["slug"] != program["slug"]: + collision = await fetch_one( + "SELECT id FROM affiliate_programs WHERE slug = ? AND id != ?", + (data["slug"], program_id), + ) + if collision: + await flash(f"Slug '{data['slug']}' already exists.", "error") + return await render_template( + "admin/affiliate_program_form.html", + admin_page="affiliate_programs", + data={**dict(program), **data}, + editing=True, + program_id=program_id, + program_statuses=AFFILIATE_PROGRAM_STATUSES, + ) + + await execute( + """UPDATE affiliate_programs + SET name=?, slug=?, url_template=?, tracking_tag=?, commission_pct=?, + homepage_url=?, status=?, notes=?, updated_at=datetime('now') + WHERE id=?""", + ( + data["name"], data["slug"], data["url_template"], + data["tracking_tag"], data["commission_pct"], + data["homepage_url"], data["status"], data["notes"], + program_id, + ), + ) + await flash(f"Program '{data['name']}' updated.", "success") + return redirect(url_for("admin.affiliate_programs")) + + return await render_template( + "admin/affiliate_program_form.html", + admin_page="affiliate_programs", + data=dict(program), + editing=True, + program_id=program_id, + program_statuses=AFFILIATE_PROGRAM_STATUSES, + ) + + +@bp.route("/affiliate/programs//delete", methods=["POST"]) +@role_required("admin") +@csrf_protect +async def affiliate_program_delete(program_id: int): + """Delete an affiliate program — blocked if products reference it.""" + program = await fetch_one( + "SELECT name FROM affiliate_programs WHERE id = ?", (program_id,) + ) + if not program: + return redirect(url_for("admin.affiliate_programs")) + + product_count = await fetch_one( + "SELECT COUNT(*) AS cnt FROM affiliate_products WHERE program_id = ?", + (program_id,), + ) + count = product_count["cnt"] if product_count else 0 + if count > 0: + await flash( + f"Cannot delete '{program['name']}' — {count} product(s) reference it. " + "Reassign or remove those products first.", + "error", + ) + return redirect(url_for("admin.affiliate_programs")) + + await execute("DELETE FROM affiliate_programs WHERE id = ?", (program_id,)) + await flash(f"Program '{program['name']}' deleted.", "success") + return redirect(url_for("admin.affiliate_programs")) def _form_to_product(form) -> dict: diff --git a/web/src/padelnomics/admin/templates/admin/affiliate_program_form.html b/web/src/padelnomics/admin/templates/admin/affiliate_program_form.html new file mode 100644 index 0000000..9c2949d --- /dev/null +++ b/web/src/padelnomics/admin/templates/admin/affiliate_program_form.html @@ -0,0 +1,134 @@ +{% extends "admin/base_admin.html" %} +{% set admin_page = "affiliate_programs" %} + +{% block title %}{% if editing %}Edit Program{% else %}New Program{% endif %} - Admin - {{ config.APP_NAME }}{% endblock %} + +{% block admin_head %} + +{% endblock %} + +{% block admin_content %} +
+
+ ← Programs +

{% if editing %}Edit Program{% else %}New Program{% endif %}

+
+
+ +
+
+ + +
+ + {# Name #} +
+ + +
+ + {# Slug #} +
+ + +

Lowercase letters, numbers, hyphens only.

+
+ + {# URL Template #} +
+ + +

+ Use {product_id} for the ASIN/product path and {tag} for the tracking tag.
+ Example: https://www.amazon.de/dp/{product_id}?tag={tag} +

+
+ + {# Tracking Tag + Commission row #} +
+
+ + +
+
+ + +

Used for revenue estimates (e.g. 3 = 3%).

+
+
+ + {# Homepage URL #} +
+ + +

Shown as a link in the programs list.

+
+ + {# Status #} +
+ + +

Inactive programs are hidden from the product form dropdown.

+
+ + {# Notes #} +
+ + +
+ + {# Actions #} +
+
+ + Cancel +
+ {% if editing %} + + + + + {% endif %} +
+ +
+ +
+{% endblock %} diff --git a/web/src/padelnomics/admin/templates/admin/affiliate_programs.html b/web/src/padelnomics/admin/templates/admin/affiliate_programs.html new file mode 100644 index 0000000..6b91d7e --- /dev/null +++ b/web/src/padelnomics/admin/templates/admin/affiliate_programs.html @@ -0,0 +1,30 @@ +{% extends "admin/base_admin.html" %} +{% set admin_page = "affiliate_programs" %} + +{% block title %}Affiliate Programs - Admin - {{ config.APP_NAME }}{% endblock %} + +{% block admin_content %} +
+

Affiliate Programs

+ + New Program +
+ +
+ + + + + + + + + + + + + + {% include "admin/partials/affiliate_program_results.html" %} + +
NameSlugTracking TagCommissionProductsStatusActions
+
+{% endblock %} diff --git a/web/src/padelnomics/admin/templates/admin/base_admin.html b/web/src/padelnomics/admin/templates/admin/base_admin.html index 39d3a5f..8f8189c 100644 --- a/web/src/padelnomics/admin/templates/admin/base_admin.html +++ b/web/src/padelnomics/admin/templates/admin/base_admin.html @@ -99,7 +99,7 @@ 'suppliers': 'suppliers', 'articles': 'content', 'scenarios': 'content', 'templates': 'content', 'pseo': 'content', 'emails': 'email', 'inbox': 'email', 'compose': 'email', 'gallery': 'email', 'audiences': 'email', 'outreach': 'email', - 'affiliate': 'affiliate', 'affiliate_dashboard': 'affiliate', + 'affiliate': 'affiliate', 'affiliate_dashboard': 'affiliate', 'affiliate_programs': 'affiliate', 'billing': 'billing', 'seo': 'analytics', 'pipeline': 'pipeline', @@ -206,6 +206,7 @@ {% elif active_section == 'system' %}