Add launch pipeline and idea marketplace seed commands

This commit is contained in:
2026-04-09 00:28:42 +02:00
parent a6bb1622be
commit e4c6e3dcef
10 changed files with 1162 additions and 126 deletions

15
Jenkinsfile vendored
View File

@@ -6,6 +6,13 @@ pipeline {
disableConcurrentBuilds()
skipDefaultCheckout(true)
}
parameters {
booleanParam(
name: 'RUN_DEMO_PURGE',
defaultValue: false,
description: 'Run a one-time demo catalogue purge before the normal idea marketplace seed and launch prep.'
)
}
environment {
PYENVPIPELINE_VIRTUALENV = '1'
GIT_SSH_COMMAND = 'ssh -o StrictHostKeyChecking=accept-new'
@@ -81,8 +88,14 @@ PY
cp "${JOB_BASE_NAME}/ocyan.json" "${JOB_BASE_NAME}/${JOB_BASE_NAME}.json"
pip install ruff vdt.versionplugin.wheel
pip install --upgrade "setuptools==69.5.1" wheel
python3 scripts/validate_payment_provider_config.py
manage.py migrate --no-input --skip-checks
manage.py purge_demo_data --keep-only-idea-products --skip-checks
if [ "${RUN_DEMO_PURGE}" = "true" ]; then
manage.py purge_demo_data
fi
manage.py seed_idea_marketplace
manage.py prepare_idea_marketplace_launch --apply-homepage-copy --purge-demo-pages
manage.py validate_idea_marketplace_launch
manage.py collectstatic --no-input --verbosity=0
pip install "httpx<0.28"
'''

View File

@@ -0,0 +1,38 @@
from __future__ import annotations
from typing import Iterable
DEMO_MARKERS: tuple[str, ...] = (
"demo",
"dummy",
"sample",
"lorem",
"placeholder",
"sandbox",
"staging",
"prototype",
"template-only",
)
# Known legacy/demo pages that should never surface on production.
BLOCKED_DEMO_PAGE_SLUGS: tuple[str, ...] = (
"starter-website-2",
"business-website-2",
)
def contains_demo_marker(values: Iterable[str | None]) -> bool:
for raw_value in values:
if not raw_value:
continue
lowered = raw_value.lower()
if any(marker in lowered for marker in DEMO_MARKERS):
return True
return False
def is_blocked_demo_slug(value: str | None) -> bool:
if not value:
return False
return value.lower() in BLOCKED_DEMO_PAGE_SLUGS

View File

@@ -0,0 +1,465 @@
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
from django.conf import settings
from django.db.models import Q
from django.utils.text import slugify
from oscar.core.loading import get_model
from mandelstudio.content_hygiene import DEMO_MARKERS
IDEA_PRODUCT_CLASS_NAME = "Idea Product"
DIGITAL_IDEAS_CATEGORY_NAME = "Digital Ideas"
SHORT_DESCRIPTION_ATTRIBUTE_CODE = "short_description"
FULL_DESCRIPTION_ATTRIBUTE_CODE = "full_description"
IDEA_PARTNER_NAME = "Mandel Blog Studio"
@dataclass(frozen=True)
class IdeaSeedItem:
title: str
short_description: str
full_description: str
price_eur: Decimal
IDEA_PRODUCTS: tuple[IdeaSeedItem, ...] = (
IdeaSeedItem(
title="B2B Webshop Starter Blueprint",
short_description=(
"Launch a B2B webshop with a quote-first buying flow and enterprise-ready trust structure. "
"Get a clear execution path from positioning to first qualified orders."
),
full_description=(
"Introduction\n"
"A practical B2B ecommerce blueprint for teams that need to sell complex offers with confidence.\n\n"
"Problem it solves\n"
"- Generic webshop setups underperform in B2B because they ignore quote-first journeys and multi-stakeholder buying.\n"
"- Sales and marketing handoff is often unclear, which slows deal velocity.\n\n"
"Step-by-step concept\n"
"1. Define ICP and buying committee signals.\n"
"2. Map quote-first vs direct checkout decision rules.\n"
"3. Build page architecture for trust, proof, and qualification.\n"
"4. Implement lead-to-order routing between website and sales ops.\n"
"5. Run a 90-day optimization loop with conversion checkpoints.\n\n"
"Tech stack\n"
"- Django + Oscar commerce core\n"
"- Wagtail CMS for structured sales content\n"
"- Analytics and event tracking for funnel visibility\n\n"
"Business value\n"
"- Faster sales-qualified lead capture\n"
"- Lower friction for enterprise buyers\n"
"- Higher conversion from product page to qualified pipeline\n\n"
"Who it is for\n"
"Founders, growth teams, and B2B operators launching or rebuilding a serious ecommerce motion."
),
price_eur=Decimal("99.00"),
),
IdeaSeedItem(
title="AI Product Description System",
short_description=(
"Scale product copy with AI while preserving brand tone, SEO intent, and quality control. "
"Turn catalog chaos into a repeatable content engine your team can trust."
),
full_description=(
"Introduction\n"
"A production content system for generating and governing high-quality product descriptions at scale.\n\n"
"Problem it solves\n"
"- Manual copywriting does not scale across growing catalogs.\n"
"- Uncontrolled AI output introduces inconsistency and factual risk.\n\n"
"Step-by-step concept\n"
"1. Define attribute schema and content rules per category.\n"
"2. Build prompt templates linked to taxonomy fields.\n"
"3. Add QA gates for accuracy, tone, and compliance.\n"
"4. Localize with multilingual adaptation rules.\n"
"5. Monitor quality with an editorial review workflow.\n\n"
"Tech stack\n"
"- Django/Wagtail content governance\n"
"- AI model orchestration with prompt templates\n"
"- Validation layer for quality and policy checks\n\n"
"Business value\n"
"- Faster time-to-publish for new products\n"
"- Consistent conversion-focused copy\n"
"- Reduced editorial costs with better control\n\n"
"Who it is for\n"
"Ecommerce teams, marketplaces, and catalog-heavy brands that need reliable AI-assisted copy operations."
),
price_eur=Decimal("49.00"),
),
IdeaSeedItem(
title="High-Converting Landing Page Framework",
short_description=(
"Build landing pages that convert with a proven structure for message clarity, proof, and CTA flow. "
"Stop guessing and launch with a repeatable conversion framework."
),
full_description=(
"Introduction\n"
"A practical landing-page framework focused on conversion, not visual noise.\n\n"
"Problem it solves\n"
"- Teams often launch pages without a clear conversion narrative.\n"
"- Weak proof and CTA sequencing create drop-off before action.\n\n"
"Step-by-step concept\n"
"1. Align offer with one core audience intent.\n"
"2. Build headline and subheadline hierarchy.\n"
"3. Add objection-handling proof blocks and trust signals.\n"
"4. Design CTA progression for low and high intent visitors.\n"
"5. Define test plan for copy, layout, and offer variants.\n\n"
"Tech stack\n"
"- Wagtail page composition\n"
"- Bootstrap 5 component patterns\n"
"- Event tracking for funnel diagnostics\n\n"
"Business value\n"
"- Higher lead quality from the same traffic\n"
"- Faster launch cycles with reusable page logic\n"
"- Better conversion through structured experimentation\n\n"
"Who it is for\n"
"Service businesses, SaaS teams, and agencies that rely on landing pages for growth."
),
price_eur=Decimal("29.00"),
),
IdeaSeedItem(
title="Subscription-Based Service Website Model",
short_description=(
"Design a subscription service website that improves activation, retention, and recurring revenue. "
"Package offers clearly and reduce churn with lifecycle-aware UX."
),
full_description=(
"Introduction\n"
"A complete website model for subscription-first service businesses.\n\n"
"Problem it solves\n"
"- Subscription sites often sell features, not ongoing outcomes.\n"
"- Poor onboarding and renewal communication increases churn risk.\n\n"
"Step-by-step concept\n"
"1. Structure offer tiers by business outcome and support level.\n"
"2. Build onboarding pages for fast activation.\n"
"3. Add lifecycle messaging for renewal and expansion.\n"
"4. Map churn-risk touchpoints and intervention moments.\n"
"5. Track retention metrics and optimize plan positioning.\n\n"
"Tech stack\n"
"- Django + Oscar for billing-ready commerce foundations\n"
"- Wagtail for lifecycle content and onboarding assets\n"
"- Event instrumentation for retention analytics\n\n"
"Business value\n"
"- Improved activation-to-retention conversion\n"
"- More predictable recurring revenue\n"
"- Clearer upgrade path across plan tiers\n\n"
"Who it is for\n"
"Founders and operators running service subscriptions with monthly or annual plans."
),
price_eur=Decimal("69.00"),
),
IdeaSeedItem(
title="Marketplace Platform Architecture (Django)",
short_description=(
"Get a scalable marketplace architecture for Django from MVP to multi-vendor growth. "
"Includes domain boundaries, payments, moderation, and operations blueprint."
),
full_description=(
"Introduction\n"
"A technical blueprint for launching and scaling a marketplace platform on Django.\n\n"
"Problem it solves\n"
"- Marketplace projects fail when core domains and workflows are not separated early.\n"
"- Teams underestimate moderation, payout, and operational complexity.\n\n"
"Step-by-step concept\n"
"1. Define bounded domains for buyers, sellers, listings, and transactions.\n"
"2. Design catalog and search architecture for growth.\n"
"3. Implement payment orchestration and settlement flow.\n"
"4. Add moderation, permissions, and abuse controls.\n"
"5. Plan observability and phased scaling from MVP to expansion.\n\n"
"Tech stack\n"
"- Django service layer and domain modules\n"
"- Oscar commerce primitives where applicable\n"
"- Queue/events for async marketplace operations\n"
"- Monitoring and operational alerting baseline\n\n"
"Business value\n"
"- Lower re-architecture risk at scale\n"
"- Faster delivery of revenue-critical flows\n"
"- Better reliability for multi-sided operations\n\n"
"Who it is for\n"
"Technical founders, CTOs, and product teams building marketplace businesses with Django."
),
price_eur=Decimal("149.00"),
),
)
def _get_attribute_text(product, code: str) -> str:
value = (
product.attribute_values.select_related("attribute")
.filter(attribute__code=code)
.first()
)
if value is None:
return ""
for field_name in (
"value_text",
"value_richtext",
"value_option",
"value_file",
"value_image",
):
field_value = getattr(value, field_name, None)
if field_value:
return str(field_value)
return ""
def _set_attribute_text(product, attribute, text: str) -> None:
ProductAttributeValue = get_model("catalogue", "ProductAttributeValue")
value_field = (
"value_richtext" if getattr(attribute, "type", "text") == "richtext" else "value_text"
)
value, _created = ProductAttributeValue.objects.get_or_create(
product=product,
attribute=attribute,
)
if getattr(value, value_field, "") != text:
setattr(value, value_field, text)
value.save(update_fields=[value_field])
def is_idea_product(product) -> bool:
product_class = getattr(product, "product_class", None)
return bool(product_class and product_class.name == IDEA_PRODUCT_CLASS_NAME)
def get_idea_short_description(product) -> str:
return _get_attribute_text(product, SHORT_DESCRIPTION_ATTRIBUTE_CODE) or (
getattr(product, "description", "") or ""
)
def get_idea_full_description(product) -> str:
return _get_attribute_text(product, FULL_DESCRIPTION_ATTRIBUTE_CODE)
def get_unlockable_description(product, user) -> tuple[str, bool]:
unlocked = user_has_unlocked_idea(user, product)
if unlocked:
return get_idea_full_description(product) or get_idea_short_description(product), True
return get_idea_short_description(product), False
def user_has_unlocked_idea(user, product) -> bool:
if not getattr(user, "is_authenticated", False):
return False
if not is_idea_product(product):
return True
Line = get_model("order", "Line")
PaymentEventQuantity = get_model("order", "PaymentEventQuantity")
paid_statuses = {
getattr(settings, "OSCAR_PAID_ORDER_STATUS", None),
getattr(settings, "OSCAR_COMPLETE_ORDER_STATUS", None),
"paid",
"complete",
"payment-complete",
"delayed-payment",
}
paid_statuses = {
status.strip().lower() for status in paid_statuses if isinstance(status, str) and status.strip()
}
status_match = Line.objects.filter(
order__user=user,
product_id=product.id,
).filter(
Q(order__status__in=paid_statuses)
| Q(order__status__icontains="paid")
| Q(order__status__icontains="complete")
)
if status_match.exists():
return True
# Fallback to payment event evidence so unlocking still works when status names differ per provider.
return PaymentEventQuantity.objects.filter(
line__order__user=user,
line__product_id=product.id,
quantity__gt=0,
).exists()
def _ensure_digital_ideas_category():
Category = get_model("catalogue", "Category")
existing = Category.objects.filter(name=DIGITAL_IDEAS_CATEGORY_NAME).first()
if existing:
return existing
root = (
Category.objects.filter(depth=1).order_by("path").first()
if hasattr(Category, "depth")
else None
)
if root and hasattr(root, "add_child"):
return root.add_child(name=DIGITAL_IDEAS_CATEGORY_NAME)
if hasattr(Category, "add_root"):
return Category.add_root(name=DIGITAL_IDEAS_CATEGORY_NAME)
category = Category(name=DIGITAL_IDEAS_CATEGORY_NAME)
if hasattr(category, "slug"):
category.slug = slugify(DIGITAL_IDEAS_CATEGORY_NAME)
category.save()
return category
def _ensure_product_class():
ProductClass = get_model("catalogue", "ProductClass")
product_class, _created = ProductClass.objects.get_or_create(
name=IDEA_PRODUCT_CLASS_NAME,
defaults={
"requires_shipping": False,
"track_stock": False,
},
)
if product_class.requires_shipping:
product_class.requires_shipping = False
product_class.save(update_fields=["requires_shipping"])
return product_class
def _ensure_product_attributes(product_class):
ProductAttribute = get_model("catalogue", "ProductAttribute")
text_type = getattr(ProductAttribute, "TEXT", "text")
richtext_type = getattr(ProductAttribute, "RICHTEXT", "richtext")
short_attr, _ = ProductAttribute.objects.get_or_create(
product_class=product_class,
code=SHORT_DESCRIPTION_ATTRIBUTE_CODE,
defaults={
"name": "Short description",
"type": text_type,
"required": False,
},
)
full_attr, _ = ProductAttribute.objects.get_or_create(
product_class=product_class,
code=FULL_DESCRIPTION_ATTRIBUTE_CODE,
defaults={
"name": "Full description",
"type": richtext_type,
"required": False,
},
)
return short_attr, full_attr
def _ensure_partner():
Partner = get_model("partner", "Partner")
partner, _ = Partner.objects.get_or_create(name=IDEA_PARTNER_NAME)
return partner
def _upsert_stockrecord(product, partner, price_eur: Decimal):
StockRecord = get_model("partner", "StockRecord")
defaults = {
"partner_sku": f"idea-{product.id}",
"price_currency": "EUR",
"price_excl_tax": price_eur,
"num_in_stock": 99999,
}
stockrecord, _created = StockRecord.objects.get_or_create(
product=product,
partner=partner,
defaults=defaults,
)
dirty_fields: list[str] = []
for field_name, field_value in defaults.items():
if getattr(stockrecord, field_name, None) != field_value:
setattr(stockrecord, field_name, field_value)
dirty_fields.append(field_name)
if dirty_fields:
stockrecord.save(update_fields=dirty_fields)
def seed_idea_marketplace_products(
*, purge_demo_products: bool = True, retire_non_idea_products: bool = True
) -> dict[str, int]:
Product = get_model("catalogue", "Product")
product_class = _ensure_product_class()
category = _ensure_digital_ideas_category()
short_attr, full_attr = _ensure_product_attributes(product_class)
partner = _ensure_partner()
created = 0
updated = 0
for item in IDEA_PRODUCTS:
product = Product.objects.filter(title=item.title).first()
if product is None:
product = Product(
title=item.title,
slug=slugify(item.title),
product_class=product_class,
description=item.short_description,
)
if hasattr(Product, "STANDALONE") and hasattr(product, "structure"):
product.structure = Product.STANDALONE
if hasattr(product, "is_public") and not getattr(product, "is_public", False):
product.is_public = True
product.save()
created += 1
else:
dirty_fields: list[str] = []
if product.product_class_id != product_class.id:
product.product_class = product_class
dirty_fields.append("product_class")
if product.description != item.short_description:
product.description = item.short_description
dirty_fields.append("description")
if hasattr(product, "slug") and product.slug != slugify(item.title):
product.slug = slugify(item.title)
dirty_fields.append("slug")
if hasattr(product, "is_public") and not getattr(product, "is_public", False):
product.is_public = True
dirty_fields.append("is_public")
if dirty_fields:
product.save(update_fields=dirty_fields)
updated += 1
product.categories.add(category)
_set_attribute_text(product, short_attr, item.short_description)
_set_attribute_text(product, full_attr, item.full_description)
_upsert_stockrecord(product, partner, item.price_eur)
deleted_demo = 0
if purge_demo_products:
keep_titles = {item.title for item in IDEA_PRODUCTS}
demo_filter = Q()
for marker in DEMO_MARKERS:
demo_filter |= Q(title__icontains=marker) | Q(slug__icontains=marker)
demo_queryset = Product.objects.filter(demo_filter).exclude(title__in=keep_titles)
# Also purge any non-canonical products lingering in the Idea Product class
# or explicitly grouped under the Digital Ideas category.
non_canonical_ideas_queryset = (
Product.objects.filter(
Q(product_class=product_class)
| Q(categories__name__iexact=DIGITAL_IDEAS_CATEGORY_NAME)
)
.exclude(title__in=keep_titles)
.distinct()
)
delete_ids = set(demo_queryset.values_list("id", flat=True)) | set(
non_canonical_ideas_queryset.values_list("id", flat=True)
)
deleted_demo = len(delete_ids)
if deleted_demo:
Product.objects.filter(id__in=delete_ids).delete()
retired_non_idea = 0
if retire_non_idea_products:
keep_titles = {item.title for item in IDEA_PRODUCTS}
non_idea_public_qs = Product.objects.exclude(title__in=keep_titles).filter(
is_public=True
)
retired_non_idea = non_idea_public_qs.update(is_public=False)
return {
"created": created,
"updated": updated,
"deleted_demo": deleted_demo,
"retired_non_idea": retired_non_idea,
}

View File

@@ -1374,21 +1374,21 @@ STANDARD_COPY = {
"<p>Voor technische ondersteuning, uitbreidingen of een vervolgfase na livegang.</p>",
),
],
"form_title": "Vertel kort wat u nodig heeft",
"form_sub": "<p>We reageren inhoudelijk en zonder verkooppraat op uw vraag.</p>",
"form_title": "Start uw volgende project met helderheid",
"form_sub": "<p>We helpen u ontwerpen, bouwen en opschalen - snel, gestructureerd en zonder giswerk.</p>",
"form_fields": [
("text", "Naam", "Uw naam"),
("email", "E-mail", "naam@bedrijf.nl"),
("company", "Bedrijf", "Bedrijfsnaam"),
("message", "Vraag of project", "Waar zoekt u hulp bij?"),
],
"benefits_title": "Wat u kunt verwachten",
"benefits_title": "Waarom dit gesprek werkt",
"benefits": [
"Reactie binnen 24 uur",
"Intakegesprek van 15 minuten",
"Volledig vrijblijvend",
"Vrijblijvend strategiegesprek",
"Praktische vervolgstappen",
],
"privacy": "<p>We gebruiken uw gegevens alleen voor contact over deze aanvraag.</p>",
"privacy": "<p>We gebruiken uw gegevens alleen voor contact over deze aanvraag. Geen verplichtingen.</p>",
"cta": "Klaar om een eerste stap te zetten?",
},
"process": {
@@ -4028,7 +4028,7 @@ def _standard_body(
)
for field_type, label, placeholder in cfg["form_fields"]
],
"submit_button_text": cta["primary"],
"submit_button_text": "Plan mijn strategiegesprek",
"form_action_url": urls["contact"],
"benefits_title": cfg["benefits_title"],
"benefits": [item(text) for text in cfg["benefits"]],

View File

@@ -686,8 +686,8 @@ def nl_standard_page(page_key: str, urls: dict[str, str]) -> list[dict[str, Any]
"cta": "Wilt u uw volgende project professioneel neerzetten?",
},
"contact": {
"headline": "Laten we uw vraag concreet maken",
"sub": "<p>Vertel kort wat u nodig heeft. U krijgt een praktische terugkoppeling met haalbare vervolgstappen.</p>",
"headline": "Laten we uw volgende stap helder maken",
"sub": "<p>Deel kort waar u hulp bij nodig heeft. U krijgt een helder antwoord en een concreet voorstel voor de volgende stap.</p>",
"features_title": "Waarvoor u contact kunt opnemen",
"features_sub": "<p>Kies de route die past bij uw vraag of traject.</p>",
"features": [
@@ -713,8 +713,8 @@ def nl_standard_page(page_key: str, urls: dict[str, str]) -> list[dict[str, Any]
"layout_width": "container",
"background_style": "light",
"layout": "split",
"section_title": "Vertel kort wat u nodig heeft",
"section_subtitle": "<p>We reageren inhoudelijk en zonder verkooppraat op uw vraag.</p>",
"section_title": "Start uw volgende project met helderheid",
"section_subtitle": "<p>We helpen u ontwerpen, bouwen en opschalen - snel, gestructureerd en zonder giswerk.</p>",
"form_fields": [
item(
{
@@ -749,16 +749,16 @@ def nl_standard_page(page_key: str, urls: dict[str, str]) -> list[dict[str, Any]
}
),
],
"submit_button_text": primary,
"submit_button_text": "Plan mijn strategiegesprek",
"form_action_url": urls["contact"],
"benefits_title": "Wat u kunt verwachten",
"benefits_title": "Waarom dit gesprek werkt",
"benefits": [
item("Reactie binnen 24 uur"),
item("Intakegesprek van 15 minuten"),
item("Volledig vrijblijvend"),
item("Vrijblijvend strategiegesprek"),
item("Praktische vervolgstappen"),
],
"side_image": 1,
"privacy_text": "<p>We gebruiken uw gegevens alleen voor contact over deze aanvraag.</p>",
"privacy_text": "<p>We gebruiken uw gegevens alleen voor contact over deze aanvraag. Geen verplichtingen.</p>",
},
),
"cta": "Klaar om een eerste stap te zetten?",

View File

@@ -0,0 +1,226 @@
from __future__ import annotations
from django.core.management.base import BaseCommand
from django.db import transaction
from django.db.models import Q
from wagtail.blocks import StreamValue
from wagtail.models import Page
from mandelstudio.content_hygiene import BLOCKED_DEMO_PAGE_SLUGS, DEMO_MARKERS
from mandelstudio.idea_marketplace import seed_idea_marketplace_products
HOME_COPY = {
"nl": {
"badge": "IDEA MARKETPLACE",
"headline": "Premium ideeën die je direct kunt uitvoeren",
"sub_headline": "<p>Ontdek bewezen plannen, koop de strategie en ontgrendel het volledige implementatieplan.</p>",
"features_title": "Idea Marketplace",
"features_subtitle": "<p>Preview eerst. Koop alleen wat past. Ontgrendel daarna de complete blueprint.</p>",
"footer_headline": "Klaar om een premium idee te ontgrendelen?",
"footer_subheadline": "<p>Kies een plan, rond checkout af en krijg direct toegang tot de volledige strategie.</p>",
"cta_explore": "Explore Ideas",
"cta_buy": "Buy Strategy",
"cta_unlock": "Unlock Full Plan",
},
"en": {
"badge": "IDEA MARKETPLACE",
"headline": "Premium ideas you can execute immediately",
"sub_headline": "<p>Explore proven plans, buy the strategy, and unlock the full implementation blueprint.</p>",
"features_title": "Idea Marketplace",
"features_subtitle": "<p>Preview first. Buy what fits. Unlock complete execution plans after checkout.</p>",
"footer_headline": "Ready to unlock a premium idea?",
"footer_subheadline": "<p>Select a plan, complete checkout, and get full strategy access instantly.</p>",
"cta_explore": "Explore Ideas",
"cta_buy": "Buy Strategy",
"cta_unlock": "Unlock Full Plan",
},
}
SUPPORTED_LANGUAGES = {"nl", "en", "de", "fr", "es", "it", "pt", "ru"}
def _copy_for(language_code: str) -> dict[str, str]:
normalized = (language_code or "nl").split("-")[0].lower()
if normalized not in SUPPORTED_LANGUAGES:
normalized = "nl"
return HOME_COPY["en"] if normalized != "nl" else HOME_COPY["nl"]
def _shop_url_for(language_code: str) -> str:
normalized = (language_code or "nl").split("-")[0].lower()
if normalized == "nl":
return "/shop/"
return f"/{normalized}/shop/"
def _update_homepage_stream(page) -> bool:
if not hasattr(page, "body"):
return False
body = page.body
if not body:
return False
copy = _copy_for(getattr(page.locale, "language_code", "nl"))
shop_url = _shop_url_for(getattr(page.locale, "language_code", "nl"))
stream_data = list(body.stream_data)
changed = False
for block in stream_data:
block_type = block.get("type")
value = block.get("value", {})
if not isinstance(value, dict):
continue
if block_type == "saas_hero_banner":
updates = {
"badge_text": copy["badge"],
"headline": copy["headline"],
"sub_headline": copy["sub_headline"],
"primary_cta_text": copy["cta_explore"],
"primary_cta_url": shop_url,
"secondary_cta_text": copy["cta_buy"],
"secondary_cta_url": shop_url,
}
for key, new_value in updates.items():
if value.get(key) != new_value:
value[key] = new_value
changed = True
if block_type == "saas_features":
updates = {
"section_title": copy["features_title"],
"section_subtitle": copy["features_subtitle"],
}
for key, new_value in updates.items():
if value.get(key) != new_value:
value[key] = new_value
changed = True
if block_type == "saas_cta_footer":
updates = {
"headline": copy["footer_headline"],
"subheadline": copy["footer_subheadline"],
"primary_cta_text": copy["cta_unlock"],
"primary_cta_url": shop_url,
"secondary_cta_text": copy["cta_explore"],
"secondary_cta_url": shop_url,
}
for key, new_value in updates.items():
if value.get(key) != new_value:
value[key] = new_value
changed = True
if not changed:
return False
page.body = StreamValue(page.body.stream_block, stream_data, is_lazy=True)
page.search_description = (
"Idea marketplace with premium plans. Preview each strategy and unlock full implementation after purchase."
)
page.save()
return True
def _purge_demo_pages() -> int:
marker_filter = Q()
for marker in DEMO_MARKERS:
marker_filter |= (
Q(title__icontains=marker)
| Q(slug__icontains=marker)
| Q(search_description__icontains=marker)
)
candidate_ids = set(
Page.objects.exclude(depth__lte=2).filter(marker_filter).values_list("id", flat=True)
)
candidate_ids.update(
Page.objects.exclude(depth__lte=2)
.filter(slug__in=BLOCKED_DEMO_PAGE_SLUGS)
.values_list("id", flat=True)
)
candidates = Page.objects.filter(id__in=candidate_ids).specific()
deleted = 0
for page in candidates:
page.delete()
deleted += 1
return deleted
def _update_homepages() -> int:
updated = 0
# In this architecture localized homepages are expected at depth=2.
for page in Page.objects.filter(depth=2).specific():
if _update_homepage_stream(page):
updated += 1
return updated
class Command(BaseCommand):
help = (
"Prepare production idea marketplace launch: seed idea products, purge obvious demo pages, "
"and refresh homepage sections/CTAs to marketplace messaging."
)
def add_arguments(self, parser):
parser.add_argument(
"--no-seed",
action="store_true",
help="Skip idea product seeding.",
)
parser.add_argument(
"--purge-demo-pages",
action="store_true",
help="Delete pages with obvious demo/lorem/sample markers.",
)
parser.add_argument(
"--skip-purge-demo-pages",
action="store_true",
help="Skip deleting obvious demo pages (enabled by default).",
)
parser.add_argument(
"--apply-homepage-copy",
action="store_true",
help="Update homepage stream blocks to idea marketplace messaging and CTAs.",
)
parser.add_argument(
"--skip-apply-homepage-copy",
action="store_true",
help="Skip homepage marketplace copy refresh (enabled by default).",
)
@transaction.atomic
def handle(self, *args, **options):
if not options["no_seed"]:
seed_stats = seed_idea_marketplace_products(
purge_demo_products=True,
retire_non_idea_products=True,
)
self.stdout.write(
self.style.SUCCESS(
"Seeded idea products: "
f"created={seed_stats['created']}, "
f"updated={seed_stats['updated']}, "
f"deleted_demo_products={seed_stats['deleted_demo']}, "
f"retired_non_idea_products={seed_stats['retired_non_idea']}"
)
)
should_purge_demo_pages = (
options["purge_demo_pages"] or not options["skip_purge_demo_pages"]
)
if should_purge_demo_pages:
deleted_pages = _purge_demo_pages()
self.stdout.write(
self.style.SUCCESS(f"Deleted demo pages: {deleted_pages}")
)
should_apply_homepage_copy = (
options["apply_homepage_copy"] or not options["skip_apply_homepage_copy"]
)
if should_apply_homepage_copy:
updated_pages = _update_homepages()
self.stdout.write(
self.style.SUCCESS(
f"Updated homepages with marketplace copy: {updated_pages}"
)
)

View File

@@ -1,8 +1,12 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Iterable
from django.core.management.base import BaseCommand
from django.db.models import Q
from oscar.core.loading import get_model
from wagtail.models import Page
IDEA_PRODUCT_TITLES = {
"B2B Webshop Starter Blueprint",
@@ -12,136 +16,105 @@ IDEA_PRODUCT_TITLES = {
"Marketplace Platform Architecture (Django)",
}
DEMO_PAGE_SLUGS = {
"starter-website-2",
"business-website-2",
"starter-website",
"business-website",
}
@dataclass(frozen=True)
class PurgeStats:
pages: int = 0
products: int = 0
DEMO_MARKERS = (
"demo",
"dummy",
"sample",
"placeholder",
"starter website",
"business website",
"lorem ipsum",
)
def _build_demo_text_filter(fields: Iterable[str]) -> Q:
query = Q()
for field in fields:
for marker in DEMO_MARKERS:
query |= Q(**{f"{field}__icontains": marker})
return query
class Command(BaseCommand):
help = (
"Remove demo pages/products from Mandel Blog while preserving "
"idea marketplace content."
"Remove demo content from Wagtail pages and Oscar catalogue. "
"Use --keep-only-idea-products to retain only the five launch idea products."
)
def add_arguments(self, parser):
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would be removed without deleting.",
default=False,
help="Show what would be deleted without applying changes.",
)
parser.add_argument(
"--keep-only-idea-products",
action="store_true",
help="Delete all products except the 5 idea marketplace products.",
default=False,
help="Delete every top-level product except the five launch idea products.",
)
def handle(self, *args, **options):
dry_run = bool(options["dry_run"])
keep_only_idea_products = bool(options["keep_only_idea_products"])
page_count = self._purge_pages(dry_run=dry_run)
product_count = self._purge_products(
dry_run=dry_run,
keep_only_idea_products=keep_only_idea_products,
)
stats = PurgeStats(pages=page_count, products=product_count)
mode = "DRY RUN" if dry_run else "APPLIED"
self.stdout.write(
self.style.SUCCESS(
f"[{mode}] Removed pages={stats.pages}, products={stats.products}"
)
)
def _purge_pages(self, *, dry_run: bool) -> int:
from wagtail.models import Page
slug_markers = [
"starter-website",
"business-website",
"demo",
"voorbeeld",
"sample",
"template",
]
title_markers = [
"starter website",
"business website",
"demo",
"voorbeeld",
"sample",
"template",
]
qs = Page.objects.exclude(depth=1).exclude(slug__in=["home"]).specific()
candidates = []
for page in qs:
slug = (page.slug or "").lower()
title = (page.title or "").lower()
if any(marker in slug for marker in slug_markers) or any(
marker in title for marker in title_markers
):
candidates.append(page)
removed = 0
for page in candidates:
removed += 1
self.stdout.write(
f"PAGE {'[dry-run] ' if dry_run else ''}delete id={page.id} "
f"slug={page.slug} title={page.title}"
)
if not dry_run:
page.delete()
return removed
def _purge_products(self, *, dry_run: bool, keep_only_idea_products: bool) -> int:
from oscar.core.loading import get_model
from oscar_elasticsearch.search.signal_handlers import (
deregister_signal_handlers,
register_signal_handlers,
)
dry_run: bool = options["dry_run"]
keep_only_ideas: bool = options["keep_only_idea_products"]
Product = get_model("catalogue", "Product")
products = Product.objects.all()
if keep_only_idea_products:
candidates = products.exclude(title__in=IDEA_PRODUCT_TITLES)
product_filter = _build_demo_text_filter(("title",))
product_filter |= Q(slug__in=DEMO_PAGE_SLUGS)
top_level_products = Product.objects.filter(parent__isnull=True)
if keep_only_ideas:
products_to_delete = top_level_products.exclude(title__in=IDEA_PRODUCT_TITLES)
else:
title_markers = [
"demo",
"sample",
"placeholder",
"starter website",
"business website",
]
candidates = [
p
for p in products
if any(marker in (p.title or "").lower() for marker in title_markers)
]
products_to_delete = top_level_products.filter(product_filter).exclude(
title__in=IDEA_PRODUCT_TITLES
)
pages_to_delete = (
Page.objects.live()
.public()
.filter(depth__gt=2)
.filter(Q(slug__in=DEMO_PAGE_SLUGS) | _build_demo_text_filter(("title", "slug")))
)
product_preview = list(products_to_delete.values_list("id", "title")[:30])
page_preview = list(pages_to_delete.values_list("id", "slug", "title")[:30])
self.stdout.write(f"Products matched for deletion: {products_to_delete.count()}")
for item in product_preview:
self.stdout.write(f" - product#{item[0]}: {item[1]}")
if products_to_delete.count() > len(product_preview):
self.stdout.write(" - ...")
self.stdout.write(f"Pages matched for deletion: {pages_to_delete.count()}")
for item in page_preview:
self.stdout.write(f" - page#{item[0]}: /{item[1]}/ ({item[2]})")
if pages_to_delete.count() > len(page_preview):
self.stdout.write(" - ...")
removed = 0
iterable = candidates if isinstance(candidates, list) else list(candidates)
if dry_run:
for product in iterable:
removed += 1
self.stdout.write(
f"PRODUCT [dry-run] delete id={product.id} title={product.title}"
)
return removed
self.stdout.write(self.style.WARNING("Dry run completed. No data was deleted."))
return
deregister_signal_handlers()
try:
for product in iterable:
removed += 1
self.stdout.write(
f"PRODUCT delete id={product.id} title={product.title}"
)
product.delete()
finally:
register_signal_handlers()
return removed
deleted_products = products_to_delete.count()
deleted_pages = pages_to_delete.count()
products_to_delete.delete()
for page in pages_to_delete:
# Use Wagtail's delete to remove descendants and revisions safely.
page.delete()
self.stdout.write(
self.style.SUCCESS(
f"Demo purge complete. Deleted products={deleted_products}, pages={deleted_pages}."
)
)

View File

@@ -0,0 +1,44 @@
from __future__ import annotations
from django.core.management.base import BaseCommand
from django.db import transaction
from mandelstudio.idea_marketplace import seed_idea_marketplace_products
class Command(BaseCommand):
help = (
"Seed production-ready Oscar idea products and remove obvious demo products "
"from the catalogue. By default, this also retires non-idea products from public "
"listing."
)
def add_arguments(self, parser):
parser.add_argument(
"--keep-demo-products",
action="store_true",
help="Do not delete demo/sample products.",
)
parser.add_argument(
"--keep-non-idea-products-public",
action="store_true",
help="Do not retire non-idea products from public listing.",
)
@transaction.atomic
def handle(self, *args, **options):
purge_demo = not options["keep_demo_products"]
retire_non_idea = not options["keep_non_idea_products_public"]
stats = seed_idea_marketplace_products(
purge_demo_products=purge_demo,
retire_non_idea_products=retire_non_idea,
)
self.stdout.write(
self.style.SUCCESS(
"Idea marketplace seeded: "
f"created={stats['created']}, "
f"updated={stats['updated']}, "
f"deleted_demo={stats['deleted_demo']}, "
f"retired_non_idea={stats['retired_non_idea']}"
)
)

View File

@@ -0,0 +1,232 @@
from __future__ import annotations
import json
import os
from pathlib import Path
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from django.db.models import Q
from oscar.core.loading import get_model
from mandelstudio.content_hygiene import BLOCKED_DEMO_PAGE_SLUGS, DEMO_MARKERS
from mandelstudio.idea_marketplace import (
FULL_DESCRIPTION_ATTRIBUTE_CODE,
IDEA_PRODUCT_CLASS_NAME,
IDEA_PRODUCTS,
SHORT_DESCRIPTION_ATTRIBUTE_CODE,
)
class Command(BaseCommand):
help = (
"Fail-fast launch validation for idea marketplace: payment provider, "
"catalog integrity, digital/non-shipping behavior, and EUR pricing."
)
def handle(self, *args, **options):
Product = get_model("catalogue", "Product")
ProductClass = get_model("catalogue", "ProductClass")
ProductAttribute = get_model("catalogue", "ProductAttribute")
StockRecord = get_model("partner", "StockRecord")
Page = get_model("wagtailcore", "Page")
installed_apps = list(settings.INSTALLED_APPS)
payment_apps = [app for app in installed_apps if "payment" in app.lower()]
checkout_apps = [app for app in installed_apps if "checkout" in app.lower()]
if not payment_apps:
raise CommandError("No payment app found in INSTALLED_APPS.")
if not checkout_apps:
raise CommandError("No checkout app found in INSTALLED_APPS.")
if not any("oscar_checkout" in app.lower() for app in checkout_apps):
raise CommandError("Oscar checkout app is not active.")
def _is_demo_data(value: str) -> bool:
normalized = "".join(ch for ch in str(value).lower() if ch.isalnum())
return "demodata" in normalized
if any(_is_demo_data(app) for app in installed_apps):
raise CommandError(
"Demo data plugin detected in INSTALLED_APPS. Remove all demodata plugins before launch."
)
if any("dummy" in app.lower() for app in payment_apps):
raise CommandError(
"Dummy payment app detected in INSTALLED_APPS. Use a real provider plugin before production launch."
)
if any("mollie" in app.lower() for app in payment_apps):
mollie_settings = (
getattr(settings, "PAYMENT_MOLLIE", None)
or getattr(settings, "payment_mollie", None)
or {}
)
config_key = str(mollie_settings.get("api_key", "")).strip()
env_key = str(os.environ.get("MOLLIE_API_KEY", "")).strip()
effective_key = env_key or config_key
if not effective_key or effective_key.upper() == "CHANGE_ME":
raise CommandError(
"Mollie payment provider is enabled but no valid API key is configured. "
"Set MOLLIE_API_KEY or settings.payment_mollie.api_key to a real key."
)
if not effective_key.startswith("live_"):
raise CommandError(
"Mollie key must be a live key for production launch (expected prefix 'live_')."
)
config_path = Path(__file__).resolve().parents[2] / "ocyan.json"
if config_path.exists():
with config_path.open("r", encoding="utf-8") as handle:
config_payload = json.load(handle)
config_plugins = [str(plugin) for plugin in config_payload.get("ocyan_plugins", [])]
if any(_is_demo_data(plugin) for plugin in config_plugins):
raise CommandError(
"Demo data plugin detected in ocyan.json. Remove it before launch."
)
settings_payload = config_payload.get("settings", {})
domain = str(settings_payload.get("django", {}).get("domain", "")).strip()
shop_base_url = str(
settings_payload.get("oscar", {}).get("shop_base_url", "")
).strip("/")
if not domain or domain.upper() == "CHANGE_ME":
raise CommandError(
"settings.django.domain is missing/placeholder in ocyan.json."
)
if not shop_base_url:
raise CommandError(
"settings.oscar.shop_base_url is missing in ocyan.json."
)
currency = getattr(settings, "OSCAR_DEFAULT_CURRENCY", "EUR")
if currency != "EUR":
raise CommandError(f"OSCAR_DEFAULT_CURRENCY must be EUR, got '{currency}'.")
product_class = ProductClass.objects.filter(name=IDEA_PRODUCT_CLASS_NAME).first()
if product_class is None:
raise CommandError(f"Missing ProductClass '{IDEA_PRODUCT_CLASS_NAME}'.")
if product_class.requires_shipping:
raise CommandError("Idea Product class requires_shipping must be False.")
short_attr_exists = ProductAttribute.objects.filter(
product_class=product_class, code=SHORT_DESCRIPTION_ATTRIBUTE_CODE
).exists()
full_attr_exists = ProductAttribute.objects.filter(
product_class=product_class, code=FULL_DESCRIPTION_ATTRIBUTE_CODE
).exists()
if not short_attr_exists or not full_attr_exists:
raise CommandError(
"Missing required idea product attributes: short_description and/or full_description."
)
expected_titles = {item.title for item in IDEA_PRODUCTS}
expected_prices = {item.title: item.price_eur for item in IDEA_PRODUCTS}
found_products = Product.objects.filter(product_class=product_class)
found_titles = set(found_products.values_list("title", flat=True))
missing_titles = sorted(expected_titles - found_titles)
if missing_titles:
raise CommandError(f"Missing seeded idea products: {', '.join(missing_titles)}.")
non_public_idea_titles = list(
found_products.filter(title__in=expected_titles, is_public=False).values_list(
"title", flat=True
)
)
if non_public_idea_titles:
raise CommandError(
"Seeded idea products must be public to appear in the storefront. "
f"Examples: {', '.join(sorted(non_public_idea_titles))}"
)
invalid_shipping_products = [
product.title
for product in found_products
if getattr(product, "is_shipping_required", False)
]
if invalid_shipping_products:
raise CommandError(
"Some idea products still require shipping; expected digital-only products: "
+ ", ".join(invalid_shipping_products)
)
# Validate each seeded idea has EUR stockrecord pricing in the expected range.
invalid_stockrecords: list[str] = []
missing_stockrecords: list[str] = []
for product in found_products.filter(title__in=expected_titles):
stockrecord = (
StockRecord.objects.filter(product=product)
.order_by("id")
.first()
)
if stockrecord is None:
missing_stockrecords.append(product.title)
continue
if stockrecord.price_currency != "EUR":
invalid_stockrecords.append(
f"{product.title} (currency={stockrecord.price_currency})"
)
continue
expected = expected_prices[product.title]
actual = stockrecord.price_excl_tax
if actual is None or actual != expected:
invalid_stockrecords.append(
f"{product.title} (price_excl_tax={actual}, expected={expected})"
)
continue
if actual < 29 or actual > 149:
invalid_stockrecords.append(
f"{product.title} (out-of-range price_excl_tax={actual})"
)
if missing_stockrecords:
raise CommandError(
"Missing stockrecords for seeded idea products: "
+ ", ".join(sorted(missing_stockrecords))
)
if invalid_stockrecords:
raise CommandError(
"Invalid stockrecord pricing for seeded idea products: "
+ "; ".join(sorted(invalid_stockrecords))
)
non_idea_public_titles = list(
Product.objects.exclude(title__in=expected_titles)
.filter(is_public=True)
.values_list("title", flat=True)[:10]
)
if non_idea_public_titles:
raise CommandError(
"Non-idea products are still public. Retire them before launch. "
f"Examples: {', '.join(non_idea_public_titles)}"
)
demo_page_filter = Q()
for marker in DEMO_MARKERS:
demo_page_filter |= (
Q(title__icontains=marker)
| Q(slug__icontains=marker)
| Q(search_description__icontains=marker)
)
live_demo_pages = (
Page.objects.live()
.public()
.exclude(depth__lte=2)
.filter(demo_page_filter | Q(slug__in=BLOCKED_DEMO_PAGE_SLUGS))
.values_list("title", "slug")[:10]
)
if live_demo_pages:
formatted = ", ".join(f"{title} ({slug})" for title, slug in live_demo_pages)
raise CommandError(
"Demo-like pages are still live/public. Purge them before launch. "
f"Examples: {formatted}"
)
self.stdout.write(
self.style.SUCCESS(
"Idea marketplace launch validation passed: "
f"{len(found_titles)} products, EUR currency, checkout apps={checkout_apps}, "
f"payment apps={payment_apps}."
)
)

View File

@@ -0,0 +1,45 @@
from __future__ import annotations
from django import template
from mandelstudio.idea_marketplace import (
get_idea_full_description,
get_idea_short_description,
get_unlockable_description,
is_idea_product,
user_has_unlocked_idea,
)
register = template.Library()
@register.simple_tag
def idea_is_product(product):
return is_idea_product(product)
@register.simple_tag
def idea_is_unlocked(product, user):
return user_has_unlocked_idea(user, product)
@register.simple_tag
def idea_payments_enabled():
from django.conf import settings
return getattr(settings, "IDEA_MARKETPLACE_PAYMENTS_ENABLED", False)
@register.simple_tag
def idea_short_description(product):
return get_idea_short_description(product)
@register.simple_tag
def idea_full_description(product):
return get_idea_full_description(product)
@register.simple_tag
def idea_description_for_user(product, user):
return get_unlockable_description(product, user)