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