Add launch pipeline and idea marketplace seed commands
This commit is contained in:
15
Jenkinsfile
vendored
15
Jenkinsfile
vendored
@@ -6,6 +6,13 @@ 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'
|
||||||
@@ -81,8 +88,14 @@ PY
|
|||||||
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 --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
|
manage.py collectstatic --no-input --verbosity=0
|
||||||
pip install "httpx<0.28"
|
pip install "httpx<0.28"
|
||||||
'''
|
'''
|
||||||
|
|||||||
38
mandelstudio/content_hygiene.py
Normal file
38
mandelstudio/content_hygiene.py
Normal 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
|
||||||
465
mandelstudio/idea_marketplace.py
Normal file
465
mandelstudio/idea_marketplace.py
Normal 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,
|
||||||
|
}
|
||||||
@@ -1374,21 +1374,21 @@ STANDARD_COPY = {
|
|||||||
"<p>Voor technische ondersteuning, uitbreidingen of een vervolgfase na livegang.</p>",
|
"<p>Voor technische ondersteuning, uitbreidingen of een vervolgfase na livegang.</p>",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
"form_title": "Vertel kort wat u nodig heeft",
|
"form_title": "Start uw volgende project met helderheid",
|
||||||
"form_sub": "<p>We reageren inhoudelijk en zonder verkooppraat op uw vraag.</p>",
|
"form_sub": "<p>We helpen u ontwerpen, bouwen en opschalen - snel, gestructureerd en zonder giswerk.</p>",
|
||||||
"form_fields": [
|
"form_fields": [
|
||||||
("text", "Naam", "Uw naam"),
|
("text", "Naam", "Uw naam"),
|
||||||
("email", "E-mail", "naam@bedrijf.nl"),
|
("email", "E-mail", "naam@bedrijf.nl"),
|
||||||
("company", "Bedrijf", "Bedrijfsnaam"),
|
("company", "Bedrijf", "Bedrijfsnaam"),
|
||||||
("message", "Vraag of project", "Waar zoekt u hulp bij?"),
|
("message", "Vraag of project", "Waar zoekt u hulp bij?"),
|
||||||
],
|
],
|
||||||
"benefits_title": "Wat u kunt verwachten",
|
"benefits_title": "Waarom dit gesprek werkt",
|
||||||
"benefits": [
|
"benefits": [
|
||||||
"Reactie binnen 24 uur",
|
"Reactie binnen 24 uur",
|
||||||
"Intakegesprek van 15 minuten",
|
"Vrijblijvend strategiegesprek",
|
||||||
"Volledig vrijblijvend",
|
"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?",
|
"cta": "Klaar om een eerste stap te zetten?",
|
||||||
},
|
},
|
||||||
"process": {
|
"process": {
|
||||||
@@ -4028,7 +4028,7 @@ def _standard_body(
|
|||||||
)
|
)
|
||||||
for field_type, label, placeholder in cfg["form_fields"]
|
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"],
|
"form_action_url": urls["contact"],
|
||||||
"benefits_title": cfg["benefits_title"],
|
"benefits_title": cfg["benefits_title"],
|
||||||
"benefits": [item(text) for text in cfg["benefits"]],
|
"benefits": [item(text) for text in cfg["benefits"]],
|
||||||
|
|||||||
@@ -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?",
|
"cta": "Wilt u uw volgende project professioneel neerzetten?",
|
||||||
},
|
},
|
||||||
"contact": {
|
"contact": {
|
||||||
"headline": "Laten we uw vraag concreet maken",
|
"headline": "Laten we uw volgende stap helder maken",
|
||||||
"sub": "<p>Vertel kort wat u nodig heeft. U krijgt een praktische terugkoppeling met haalbare vervolgstappen.</p>",
|
"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_title": "Waarvoor u contact kunt opnemen",
|
||||||
"features_sub": "<p>Kies de route die past bij uw vraag of traject.</p>",
|
"features_sub": "<p>Kies de route die past bij uw vraag of traject.</p>",
|
||||||
"features": [
|
"features": [
|
||||||
@@ -713,8 +713,8 @@ def nl_standard_page(page_key: str, urls: dict[str, str]) -> list[dict[str, Any]
|
|||||||
"layout_width": "container",
|
"layout_width": "container",
|
||||||
"background_style": "light",
|
"background_style": "light",
|
||||||
"layout": "split",
|
"layout": "split",
|
||||||
"section_title": "Vertel kort wat u nodig heeft",
|
"section_title": "Start uw volgende project met helderheid",
|
||||||
"section_subtitle": "<p>We reageren inhoudelijk en zonder verkooppraat op uw vraag.</p>",
|
"section_subtitle": "<p>We helpen u ontwerpen, bouwen en opschalen - snel, gestructureerd en zonder giswerk.</p>",
|
||||||
"form_fields": [
|
"form_fields": [
|
||||||
item(
|
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"],
|
"form_action_url": urls["contact"],
|
||||||
"benefits_title": "Wat u kunt verwachten",
|
"benefits_title": "Waarom dit gesprek werkt",
|
||||||
"benefits": [
|
"benefits": [
|
||||||
item("Reactie binnen 24 uur"),
|
item("Reactie binnen 24 uur"),
|
||||||
item("Intakegesprek van 15 minuten"),
|
item("Vrijblijvend strategiegesprek"),
|
||||||
item("Volledig vrijblijvend"),
|
item("Praktische vervolgstappen"),
|
||||||
],
|
],
|
||||||
"side_image": 1,
|
"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?",
|
"cta": "Klaar om een eerste stap te zetten?",
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from typing import Iterable
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand
|
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 = {
|
IDEA_PRODUCT_TITLES = {
|
||||||
"B2B Webshop Starter Blueprint",
|
"B2B Webshop Starter Blueprint",
|
||||||
@@ -12,136 +16,105 @@ IDEA_PRODUCT_TITLES = {
|
|||||||
"Marketplace Platform Architecture (Django)",
|
"Marketplace Platform Architecture (Django)",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DEMO_PAGE_SLUGS = {
|
||||||
|
"starter-website-2",
|
||||||
|
"business-website-2",
|
||||||
|
"starter-website",
|
||||||
|
"business-website",
|
||||||
|
}
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
DEMO_MARKERS = (
|
||||||
class PurgeStats:
|
"demo",
|
||||||
pages: int = 0
|
"dummy",
|
||||||
products: int = 0
|
"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):
|
class Command(BaseCommand):
|
||||||
help = (
|
help = (
|
||||||
"Remove demo pages/products from Mandel Blog while preserving "
|
"Remove demo content from Wagtail pages and Oscar catalogue. "
|
||||||
"idea marketplace content."
|
"Use --keep-only-idea-products to retain only the five launch idea products."
|
||||||
)
|
)
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--dry-run",
|
"--dry-run",
|
||||||
action="store_true",
|
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(
|
parser.add_argument(
|
||||||
"--keep-only-idea-products",
|
"--keep-only-idea-products",
|
||||||
action="store_true",
|
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):
|
def handle(self, *args, **options):
|
||||||
dry_run = bool(options["dry_run"])
|
dry_run: bool = options["dry_run"]
|
||||||
keep_only_idea_products = bool(options["keep_only_idea_products"])
|
keep_only_ideas: 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,
|
|
||||||
)
|
|
||||||
|
|
||||||
Product = get_model("catalogue", "Product")
|
Product = get_model("catalogue", "Product")
|
||||||
products = Product.objects.all()
|
|
||||||
|
|
||||||
if keep_only_idea_products:
|
product_filter = _build_demo_text_filter(("title",))
|
||||||
candidates = products.exclude(title__in=IDEA_PRODUCT_TITLES)
|
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:
|
else:
|
||||||
title_markers = [
|
products_to_delete = top_level_products.filter(product_filter).exclude(
|
||||||
"demo",
|
title__in=IDEA_PRODUCT_TITLES
|
||||||
"sample",
|
)
|
||||||
"placeholder",
|
|
||||||
"starter website",
|
pages_to_delete = (
|
||||||
"business website",
|
Page.objects.live()
|
||||||
]
|
.public()
|
||||||
candidates = [
|
.filter(depth__gt=2)
|
||||||
p
|
.filter(Q(slug__in=DEMO_PAGE_SLUGS) | _build_demo_text_filter(("title", "slug")))
|
||||||
for p in products
|
)
|
||||||
if any(marker in (p.title or "").lower() for marker in title_markers)
|
|
||||||
]
|
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:
|
if dry_run:
|
||||||
for product in iterable:
|
self.stdout.write(self.style.WARNING("Dry run completed. No data was deleted."))
|
||||||
removed += 1
|
return
|
||||||
self.stdout.write(
|
|
||||||
f"PRODUCT [dry-run] delete id={product.id} title={product.title}"
|
deleted_products = products_to_delete.count()
|
||||||
)
|
deleted_pages = pages_to_delete.count()
|
||||||
return removed
|
|
||||||
|
products_to_delete.delete()
|
||||||
|
for page in pages_to_delete:
|
||||||
|
# Use Wagtail's delete to remove descendants and revisions safely.
|
||||||
|
page.delete()
|
||||||
|
|
||||||
deregister_signal_handlers()
|
|
||||||
try:
|
|
||||||
for product in iterable:
|
|
||||||
removed += 1
|
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
f"PRODUCT delete id={product.id} title={product.title}"
|
self.style.SUCCESS(
|
||||||
|
f"Demo purge complete. Deleted products={deleted_products}, pages={deleted_pages}."
|
||||||
|
)
|
||||||
)
|
)
|
||||||
product.delete()
|
|
||||||
finally:
|
|
||||||
register_signal_handlers()
|
|
||||||
return removed
|
|
||||||
|
|||||||
44
mandelstudio/management/commands/seed_idea_marketplace.py
Normal file
44
mandelstudio/management/commands/seed_idea_marketplace.py
Normal 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']}"
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -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}."
|
||||||
|
)
|
||||||
|
)
|
||||||
45
mandelstudio/templatetags/idea_marketplace.py
Normal file
45
mandelstudio/templatetags/idea_marketplace.py
Normal 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)
|
||||||
Reference in New Issue
Block a user