Add launch pipeline and idea marketplace seed commands
This commit is contained in:
4989
mandelstudio/management/commands/_agency_content.py
Normal file
4989
mandelstudio/management/commands/_agency_content.py
Normal file
File diff suppressed because it is too large
Load Diff
1385
mandelstudio/management/commands/apply_agency_website_refresh.py
Normal file
1385
mandelstudio/management/commands/apply_agency_website_refresh.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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}"
|
||||
)
|
||||
)
|
||||
120
mandelstudio/management/commands/purge_demo_data.py
Normal file
120
mandelstudio/management/commands/purge_demo_data.py
Normal 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}."
|
||||
)
|
||||
)
|
||||
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}."
|
||||
)
|
||||
)
|
||||
Reference in New Issue
Block a user