Add launch pipeline and idea marketplace seed commands

This commit is contained in:
2026-04-09 00:28:42 +02:00
parent 57f4c0044a
commit 7db05fea47
10 changed files with 7589 additions and 9 deletions

54
Jenkinsfile vendored
View File

@@ -6,13 +6,19 @@ pipeline {
disableConcurrentBuilds() disableConcurrentBuilds()
skipDefaultCheckout(true) 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 { environment {
PYENVPIPELINE_VIRTUALENV = '1' PYENVPIPELINE_VIRTUALENV = '1'
GIT_SSH_COMMAND = 'ssh -o StrictHostKeyChecking=accept-new' GIT_SSH_COMMAND = 'ssh -o StrictHostKeyChecking=accept-new'
STAGING_AUDIT_HOST = 'root@49.12.204.96' STAGING_AUDIT_PROJECT_NAME = 'mandelstudio'
STAGING_AUDIT_PROJECT_DIR = '/home/www-mandelstudio/mandelstudio' STAGING_AUDIT_PROJECT_DIR = '/home/www-mandelstudio/mandelstudio'
STAGING_AUDIT_MANAGE = '/var/lib/virtualenv/mandelstudio/bin/manage.py' STAGING_AUDIT_MANAGE = '/var/lib/virtualenv/mandelstudio/bin/manage.py'
STAGING_AUDIT_SSH_CREDENTIALS_ID = 'staging-root-ssh'
} }
stages { stages {
@@ -36,6 +42,30 @@ pipeline {
stage('Build') { stage('Build') {
steps { steps {
sh ''' sh '''
STABLE_INDEX_URL=${STABLE_INDEX_URL:-https://pypi.mandelblog.com/mandel/stable/+simple/}
TESTING_INDEX_URL=${TESTING_INDEX_URL:-https://pypi.mandelblog.com/mandel/testing/+simple/}
ROOT_INDEX_URL=${PIP_EXTRA_INDEX_URL:-https://pypi.mandelblog.com/root/pypi/+simple/}
export STABLE_INDEX_URL
if python3 - <<'PY'
import os
import sys
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError
url = os.environ["STABLE_INDEX_URL"]
try:
req = Request(url, method='HEAD')
with urlopen(req, timeout=10) as response:
sys.exit(0 if response.status < 400 else 1)
except HTTPError as exc:
sys.exit(0 if exc.code < 400 else 1)
except URLError:
sys.exit(1)
PY
then
echo "devpi stable index available, but stable-first install is not enabled yet"
else
echo "devpi stable index not available, using testing as production source"
fi
if command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then if command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then
sudo apt-get update -y sudo apt-get update -y
sudo apt-get install -y python3-venv python3-pip make build-essential libpq-dev \ sudo apt-get install -y python3-venv python3-pip make build-essential libpq-dev \
@@ -52,14 +82,20 @@ pipeline {
. .venv/bin/activate . .venv/bin/activate
pip install coverage pip install coverage
pip install --upgrade pip "setuptools==69.5.1" wheel pip install --upgrade pip "setuptools==69.5.1" wheel
PIP_INDEX_URL=${PIP_INDEX_URL:-https://pypi.mandelblog.com/mandel/testing/+simple/} \ PIP_INDEX_URL="$TESTING_INDEX_URL" \
PIP_EXTRA_INDEX_URL=${PIP_EXTRA_INDEX_URL:-https://pypi.mandelblog.com/root/pypi/+simple/} \ PIP_EXTRA_INDEX_URL="$ROOT_INDEX_URL" \
pip install --no-build-isolation --pre --editable . setuptools wheel --upgrade --upgrade-strategy=eager --use-deprecated=legacy-resolver pip install --no-build-isolation --pre --editable . setuptools wheel --upgrade --upgrade-strategy=eager --use-deprecated=legacy-resolver
cp "${JOB_BASE_NAME}/ocyan.json" "${JOB_BASE_NAME}/${JOB_BASE_NAME}.json" cp "${JOB_BASE_NAME}/ocyan.json" "${JOB_BASE_NAME}/${JOB_BASE_NAME}.json"
pip install ruff vdt.versionplugin.wheel pip install ruff vdt.versionplugin.wheel
pip install --upgrade "setuptools==69.5.1" 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 migrate --no-input --skip-checks
manage.py purge_demo_data 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 manage.py collectstatic --no-input --verbosity=0
pip install "httpx<0.28" pip install "httpx<0.28"
''' '''
@@ -105,10 +141,10 @@ pipeline {
timeout(time: 10, unit: 'MINUTES') timeout(time: 10, unit: 'MINUTES')
} }
steps { steps {
sh 'mkdir -p artifacts' deleteDir()
withCredentials([sshUserPrivateKey(credentialsId: env.STAGING_AUDIT_SSH_CREDENTIALS_ID, keyFileVariable: 'STAGING_SSH_KEYFILE')]) { checkout scm
sh './scripts/run_remote_multilingual_audit.sh' sh 'mkdir -p artifacts && chmod +x scripts/run_remote_multilingual_audit.sh'
} sh './scripts/run_remote_multilingual_audit.sh'
script { script {
int status = sh(script: 'python3 scripts/multilingual_audit_ci.py --json artifacts/multilingual-audit.json', returnStatus: true) int status = sh(script: 'python3 scripts/multilingual_audit_ci.py --json artifacts/multilingual-audit.json', returnStatus: true)
if (status == 2) { if (status == 2) {

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,
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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

@@ -0,0 +1,120 @@
from __future__ import annotations
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",
"AI Product Description System",
"High-Converting Landing Page Framework",
"Subscription-Based Service Website Model",
"Marketplace Platform Architecture (Django)",
}
DEMO_PAGE_SLUGS = {
"starter-website-2",
"business-website-2",
"starter-website",
"business-website",
}
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 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",
default=False,
help="Show what would be deleted without applying changes.",
)
parser.add_argument(
"--keep-only-idea-products",
action="store_true",
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_ideas: bool = options["keep_only_idea_products"]
Product = get_model("catalogue", "Product")
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:
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(" - ...")
if dry_run:
self.stdout.write(self.style.WARNING("Dry run completed. No data was deleted."))
return
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)