Compare commits
3 Commits
fix/carbas
...
8bfd4d789b
| Author | SHA1 | Date | |
|---|---|---|---|
| 8bfd4d789b | |||
| 7db05fea47 | |||
| 57f4c0044a |
52
Jenkinsfile
vendored
52
Jenkinsfile
vendored
@@ -6,13 +6,19 @@ pipeline {
|
||||
disableConcurrentBuilds()
|
||||
skipDefaultCheckout(true)
|
||||
}
|
||||
parameters {
|
||||
booleanParam(
|
||||
name: 'RUN_DEMO_PURGE',
|
||||
defaultValue: false,
|
||||
description: 'Run a one-time demo catalogue purge before the normal idea marketplace seed and launch prep.'
|
||||
)
|
||||
}
|
||||
environment {
|
||||
PYENVPIPELINE_VIRTUALENV = '1'
|
||||
GIT_SSH_COMMAND = 'ssh -o StrictHostKeyChecking=accept-new'
|
||||
STAGING_AUDIT_HOST = 'root@49.12.204.96'
|
||||
STAGING_AUDIT_PROJECT_NAME = 'mandelstudio'
|
||||
STAGING_AUDIT_PROJECT_DIR = '/home/www-mandelstudio/mandelstudio'
|
||||
STAGING_AUDIT_MANAGE = '/var/lib/virtualenv/mandelstudio/bin/manage.py'
|
||||
STAGING_AUDIT_SSH_CREDENTIALS_ID = 'staging-root-ssh'
|
||||
}
|
||||
|
||||
stages {
|
||||
@@ -36,6 +42,30 @@ pipeline {
|
||||
stage('Build') {
|
||||
steps {
|
||||
sh '''
|
||||
STABLE_INDEX_URL=${STABLE_INDEX_URL:-https://pypi.mandelblog.com/mandel/stable/+simple/}
|
||||
TESTING_INDEX_URL=${TESTING_INDEX_URL:-https://pypi.mandelblog.com/mandel/testing/+simple/}
|
||||
ROOT_INDEX_URL=${PIP_EXTRA_INDEX_URL:-https://pypi.mandelblog.com/root/pypi/+simple/}
|
||||
export STABLE_INDEX_URL
|
||||
if python3 - <<'PY'
|
||||
import os
|
||||
import sys
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import URLError, HTTPError
|
||||
url = os.environ["STABLE_INDEX_URL"]
|
||||
try:
|
||||
req = Request(url, method='HEAD')
|
||||
with urlopen(req, timeout=10) as response:
|
||||
sys.exit(0 if response.status < 400 else 1)
|
||||
except HTTPError as exc:
|
||||
sys.exit(0 if exc.code < 400 else 1)
|
||||
except URLError:
|
||||
sys.exit(1)
|
||||
PY
|
||||
then
|
||||
echo "devpi stable index available, but stable-first install is not enabled yet"
|
||||
else
|
||||
echo "devpi stable index not available, using testing as production source"
|
||||
fi
|
||||
if command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install -y python3-venv python3-pip make build-essential libpq-dev \
|
||||
@@ -52,14 +82,20 @@ pipeline {
|
||||
. .venv/bin/activate
|
||||
pip install coverage
|
||||
pip install --upgrade pip "setuptools==69.5.1" wheel
|
||||
PIP_INDEX_URL=${PIP_INDEX_URL:-https://pypi.mandelblog.com/mandel/testing/+simple/} \
|
||||
PIP_EXTRA_INDEX_URL=${PIP_EXTRA_INDEX_URL:-https://pypi.mandelblog.com/root/pypi/+simple/} \
|
||||
PIP_INDEX_URL="$TESTING_INDEX_URL" \
|
||||
PIP_EXTRA_INDEX_URL="$ROOT_INDEX_URL" \
|
||||
pip install --no-build-isolation --pre --editable . setuptools wheel --upgrade --upgrade-strategy=eager --use-deprecated=legacy-resolver
|
||||
cp "${JOB_BASE_NAME}/ocyan.json" "${JOB_BASE_NAME}/${JOB_BASE_NAME}.json"
|
||||
pip install ruff vdt.versionplugin.wheel
|
||||
pip install --upgrade "setuptools==69.5.1" wheel
|
||||
python3 scripts/validate_payment_provider_config.py
|
||||
manage.py migrate --no-input --skip-checks
|
||||
manage.py loaddemodata || true
|
||||
if [ "${RUN_DEMO_PURGE}" = "true" ]; then
|
||||
manage.py purge_demo_data
|
||||
fi
|
||||
manage.py seed_idea_marketplace
|
||||
manage.py prepare_idea_marketplace_launch --apply-homepage-copy --purge-demo-pages
|
||||
manage.py validate_idea_marketplace_launch
|
||||
manage.py collectstatic --no-input --verbosity=0
|
||||
pip install "httpx<0.28"
|
||||
'''
|
||||
@@ -105,10 +141,10 @@ pipeline {
|
||||
timeout(time: 10, unit: 'MINUTES')
|
||||
}
|
||||
steps {
|
||||
sh 'mkdir -p artifacts'
|
||||
withCredentials([sshUserPrivateKey(credentialsId: env.STAGING_AUDIT_SSH_CREDENTIALS_ID, keyFileVariable: 'STAGING_SSH_KEYFILE')]) {
|
||||
deleteDir()
|
||||
checkout scm
|
||||
sh 'mkdir -p artifacts && chmod +x scripts/run_remote_multilingual_audit.sh'
|
||||
sh './scripts/run_remote_multilingual_audit.sh'
|
||||
}
|
||||
script {
|
||||
int status = sh(script: 'python3 scripts/multilingual_audit_ci.py --json artifacts/multilingual-audit.json', returnStatus: true)
|
||||
if (status == 2) {
|
||||
|
||||
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,
|
||||
}
|
||||
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}."
|
||||
)
|
||||
)
|
||||
60
mandelstudio/migrations/0001_initial.py
Normal file
60
mandelstudio/migrations/0001_initial.py
Normal file
@@ -0,0 +1,60 @@
|
||||
# Generated by Django 5.2.11 on 2026-03-25 16:37
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
import wagtail.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("wagtailcore", "0097_alter_page_locale_alter_page_translation_key"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="LocalizedFooterContent",
|
||||
fields=[
|
||||
("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("title", models.CharField(default="Footer content", max_length=120)),
|
||||
("translation_key", models.UUIDField(default=uuid.uuid4, editable=False)),
|
||||
(
|
||||
"footer",
|
||||
wagtail.fields.StreamField(
|
||||
[("about_us", 2), ("text", 2), ("page_list", 4), ("SubscriptionBlock", 7)],
|
||||
block_lookup={
|
||||
0: ("wagtail.blocks.CharBlock", (), {"help_text": "Heading of the content block.", "label": "Heading", "required": False}),
|
||||
1: ("wagtail.blocks.RichTextBlock", (), {}),
|
||||
2: ("wagtail.blocks.StructBlock", [[("heading", 0), ("content", 1)]], {}),
|
||||
3: ("wagtail.blocks.PageChooserBlock", (), {"help_text": "List pages below this page", "label": "Page"}),
|
||||
4: ("wagtail.blocks.StructBlock", [[("heading", 0), ("page", 3)]], {}),
|
||||
5: ("wagtail.blocks.CharBlock", (), {"label": "Title", "required": False}),
|
||||
6: ("wagtail.blocks.TextBlock", (), {"label": "Description", "required": False}),
|
||||
7: ("wagtail.blocks.StructBlock", [[("title", 5), ("description", 6)]], {}),
|
||||
},
|
||||
default=list,
|
||||
),
|
||||
),
|
||||
(
|
||||
"mini_footer",
|
||||
wagtail.fields.StreamField(
|
||||
[("text", 0)],
|
||||
block_lookup={0: ("wagtail.blocks.RichTextBlock", (), {})},
|
||||
default=list,
|
||||
),
|
||||
),
|
||||
("locale", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name="+", to="wagtailcore.locale")),
|
||||
("site", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="localized_footer_contents", to="wagtailcore.site")),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Localized footer content",
|
||||
"verbose_name_plural": "Localized footer contents",
|
||||
"abstract": False,
|
||||
"constraints": [models.UniqueConstraint(fields=("site", "locale"), name="unique_localized_footer_per_site_locale")],
|
||||
"unique_together": {("translation_key", "locale")},
|
||||
},
|
||||
),
|
||||
]
|
||||
236
mandelstudio/migrations/0002_seed_localized_footer_content.py
Normal file
236
mandelstudio/migrations/0002_seed_localized_footer_content.py
Normal file
@@ -0,0 +1,236 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
CONTENT = {
|
||||
"nl": {
|
||||
"about": "<p>Wij bouwen snelle websites en webshops die je team zelf kan beheren. Van eerste lancering tot doorontwikkeling: helder, schaalbaar en zonder ruis.</p>",
|
||||
"links_heading": "Snelle links",
|
||||
"support_heading": "Help & support",
|
||||
"link_labels": {
|
||||
"about": "Over ons",
|
||||
"services": "Diensten",
|
||||
"projects": "Projecten",
|
||||
"contact": "Contact",
|
||||
"capabilities": "Mogelijkheden",
|
||||
"ai_search": "AI Search",
|
||||
"book_call": "Plan een gesprek",
|
||||
},
|
||||
"mini": "<p><a href=\"{contact}\">Contact</a> - <a href=\"{services}\">Diensten</a> - <a href=\"{projects}\">Projecten</a> - Copyright 2026 - MandelBlog Studio</p>",
|
||||
},
|
||||
"en": {
|
||||
"about": "<p>We build fast websites and webshops your team can manage without friction. From launch to growth, the setup stays clear, scalable, and easy to extend.</p>",
|
||||
"links_heading": "Quick links",
|
||||
"support_heading": "Help & support",
|
||||
"link_labels": {
|
||||
"about": "About us",
|
||||
"services": "Services",
|
||||
"projects": "Projects",
|
||||
"contact": "Contact",
|
||||
"capabilities": "Capabilities",
|
||||
"ai_search": "AI Search",
|
||||
"book_call": "Book a call",
|
||||
},
|
||||
"mini": "<p><a href=\"{contact}\">Contact</a> - <a href=\"{services}\">Services</a> - <a href=\"{projects}\">Projects</a> - Copyright 2026 - MandelBlog Studio</p>",
|
||||
},
|
||||
"de": {
|
||||
"about": "<p>Wir entwickeln schnelle Websites und Webshops, die Ihr Team selbst pflegen kann. Von der ersten Veröffentlichung bis zur Weiterentwicklung bleibt alles klar, skalierbar und wartbar.</p>",
|
||||
"links_heading": "Schnellzugriff",
|
||||
"support_heading": "Hilfe & Support",
|
||||
"link_labels": {
|
||||
"about": "Über uns",
|
||||
"services": "Dienstleistungen",
|
||||
"projects": "Projekte",
|
||||
"contact": "Kontakt",
|
||||
"capabilities": "Möglichkeiten",
|
||||
"ai_search": "KI-Suche",
|
||||
"book_call": "Gespräch planen",
|
||||
},
|
||||
"mini": "<p><a href=\"{contact}\">Kontakt</a> - <a href=\"{services}\">Dienstleistungen</a> - <a href=\"{projects}\">Projekte</a> - Copyright 2026 - MandelBlog Studio</p>",
|
||||
},
|
||||
"fr": {
|
||||
"about": "<p>Nous créons des sites web et des boutiques en ligne rapides que votre équipe peut gérer facilement. Du lancement à la croissance, tout reste clair, évolutif et simple à maintenir.</p>",
|
||||
"links_heading": "Accès rapide",
|
||||
"support_heading": "Aide & support",
|
||||
"link_labels": {
|
||||
"about": "À propos",
|
||||
"services": "Services",
|
||||
"projects": "Projets",
|
||||
"contact": "Contact",
|
||||
"capabilities": "Possibilités",
|
||||
"ai_search": "Recherche IA",
|
||||
"book_call": "Planifier un échange",
|
||||
},
|
||||
"mini": "<p><a href=\"{contact}\">Contact</a> - <a href=\"{services}\">Services</a> - <a href=\"{projects}\">Projets</a> - Copyright 2026 - MandelBlog Studio</p>",
|
||||
},
|
||||
"es": {
|
||||
"about": "<p>Construimos sitios web y tiendas online rápidas que tu equipo puede gestionar sin complicaciones. Desde el lanzamiento hasta el crecimiento, todo se mantiene claro, escalable y fácil de ampliar.</p>",
|
||||
"links_heading": "Accesos rápidos",
|
||||
"support_heading": "Ayuda y soporte",
|
||||
"link_labels": {
|
||||
"about": "Sobre nosotros",
|
||||
"services": "Servicios",
|
||||
"projects": "Proyectos",
|
||||
"contact": "Contacto",
|
||||
"capabilities": "Posibilidades",
|
||||
"ai_search": "Búsqueda con IA",
|
||||
"book_call": "Planificar una llamada",
|
||||
},
|
||||
"mini": "<p><a href=\"{contact}\">Contacto</a> - <a href=\"{services}\">Servicios</a> - <a href=\"{projects}\">Proyectos</a> - Copyright 2026 - MandelBlog Studio</p>",
|
||||
},
|
||||
"it": {
|
||||
"about": "<p>Realizziamo siti web e negozi online veloci che il tuo team può gestire in autonomia. Dal lancio alla crescita, tutto rimane chiaro, scalabile e semplice da estendere.</p>",
|
||||
"links_heading": "Link rapidi",
|
||||
"support_heading": "Aiuto e supporto",
|
||||
"link_labels": {
|
||||
"about": "Chi siamo",
|
||||
"services": "Servizi",
|
||||
"projects": "Progetti",
|
||||
"contact": "Contatto",
|
||||
"capabilities": "Possibilità",
|
||||
"ai_search": "Ricerca AI",
|
||||
"book_call": "Prenota una call",
|
||||
},
|
||||
"mini": "<p><a href=\"{contact}\">Contatto</a> - <a href=\"{services}\">Servizi</a> - <a href=\"{projects}\">Progetti</a> - Copyright 2026 - MandelBlog Studio</p>",
|
||||
},
|
||||
"pt": {
|
||||
"about": "<p>Criamos sites e lojas online rápidos que a sua equipa consegue gerir com autonomia. Do lançamento ao crescimento, tudo permanece claro, escalável e simples de evoluir.</p>",
|
||||
"links_heading": "Acesso rápido",
|
||||
"support_heading": "Ajuda e suporte",
|
||||
"link_labels": {
|
||||
"about": "Sobre nós",
|
||||
"services": "Serviços",
|
||||
"projects": "Projetos",
|
||||
"contact": "Contacto",
|
||||
"capabilities": "Possibilidades",
|
||||
"ai_search": "Pesquisa IA",
|
||||
"book_call": "Marcar conversa",
|
||||
},
|
||||
"mini": "<p><a href=\"{contact}\">Contacto</a> - <a href=\"{services}\">Serviços</a> - <a href=\"{projects}\">Projetos</a> - Copyright 2026 - MandelBlog Studio</p>",
|
||||
},
|
||||
"ru": {
|
||||
"about": "<p>Мы создаём быстрые сайты и интернет-магазины, которыми ваша команда может управлять самостоятельно. От запуска до развития всё остаётся понятным, масштабируемым и удобным для роста.</p>",
|
||||
"links_heading": "Быстрые ссылки",
|
||||
"support_heading": "Помощь и поддержка",
|
||||
"link_labels": {
|
||||
"about": "О нас",
|
||||
"services": "Услуги",
|
||||
"projects": "Проекты",
|
||||
"contact": "Контакт",
|
||||
"capabilities": "Возможности",
|
||||
"ai_search": "AI Search",
|
||||
"book_call": "Запланировать звонок",
|
||||
},
|
||||
"mini": "<p><a href=\"{contact}\">Контакт</a> - <a href=\"{services}\">Услуги</a> - <a href=\"{projects}\">Проекты</a> - Copyright 2026 - MandelBlog Studio</p>",
|
||||
},
|
||||
}
|
||||
|
||||
SOURCE_SLUGS = {
|
||||
"about": "over-ons",
|
||||
"services": "diensten",
|
||||
"projects": "projecten",
|
||||
"contact": "contact",
|
||||
"capabilities": "mogelijkheden",
|
||||
"ai_search": "ai-search",
|
||||
}
|
||||
|
||||
|
||||
def build_urls(Page, code):
|
||||
source_pages = {
|
||||
key: Page.objects.filter(locale__language_code="nl", slug=slug).first()
|
||||
for key, slug in SOURCE_SLUGS.items()
|
||||
}
|
||||
urls = {}
|
||||
for key, page in source_pages.items():
|
||||
if not page:
|
||||
urls[key] = "/"
|
||||
continue
|
||||
translated = Page.objects.filter(
|
||||
translation_key=page.translation_key, locale__language_code=code
|
||||
).first()
|
||||
chosen = translated or page
|
||||
urls[key] = getattr(chosen, "url", None) or "/"
|
||||
return urls
|
||||
|
||||
|
||||
def make_footer_raw(code, urls):
|
||||
content = CONTENT[code]
|
||||
labels = content["link_labels"]
|
||||
links_html = (
|
||||
f'<p><a href="{urls["about"]}">{labels["about"]}</a><br/>'
|
||||
f'<a href="{urls["services"]}">{labels["services"]}</a><br/>'
|
||||
f'<a href="{urls["projects"]}">{labels["projects"]}</a><br/>'
|
||||
f'<a href="{urls["contact"]}">{labels["contact"]}</a></p>'
|
||||
)
|
||||
support_html = (
|
||||
f'<p><a href="{urls["capabilities"]}">{labels["capabilities"]}</a><br/>'
|
||||
f'<a href="{urls["ai_search"]}">{labels["ai_search"]}</a><br/>'
|
||||
f'<a href="{urls["contact"]}">{labels["book_call"]}</a><br/>'
|
||||
f'<a href="mailto:info@mandelblog.com">info@mandelblog.com</a></p>'
|
||||
)
|
||||
return [
|
||||
{
|
||||
"type": "about_us",
|
||||
"id": str(uuid.uuid4()),
|
||||
"value": {"heading": "MandelBlog Studio", "content": content["about"]},
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": str(uuid.uuid4()),
|
||||
"value": {"heading": content["links_heading"], "content": links_html},
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": str(uuid.uuid4()),
|
||||
"value": {"heading": content["support_heading"], "content": support_html},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def make_mini_raw(code, urls):
|
||||
return [
|
||||
{
|
||||
"type": "text",
|
||||
"id": str(uuid.uuid4()),
|
||||
"value": CONTENT[code]["mini"].format(**urls),
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def seed_footer_content(apps, schema_editor):
|
||||
LocalizedFooterContent = apps.get_model("mandelstudio", "LocalizedFooterContent")
|
||||
Site = apps.get_model("wagtailcore", "Site")
|
||||
Locale = apps.get_model("wagtailcore", "Locale")
|
||||
site = Site.objects.order_by("id").first()
|
||||
if site is None:
|
||||
return
|
||||
|
||||
from wagtail.models import Page
|
||||
|
||||
translation_key = uuid.uuid4()
|
||||
for code in CONTENT.keys():
|
||||
locale, _ = Locale.objects.get_or_create(language_code=code)
|
||||
urls = build_urls(Page, code)
|
||||
LocalizedFooterContent.objects.update_or_create(
|
||||
site=site,
|
||||
locale=locale,
|
||||
defaults={
|
||||
"title": f"Footer content ({code})",
|
||||
"translation_key": translation_key,
|
||||
"footer": make_footer_raw(code, urls),
|
||||
"mini_footer": make_mini_raw(code, urls),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def reverse_seed(apps, schema_editor):
|
||||
LocalizedFooterContent = apps.get_model("mandelstudio", "LocalizedFooterContent")
|
||||
LocalizedFooterContent.objects.all().delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("mandelstudio", "0001_initial")]
|
||||
operations = [migrations.RunPython(seed_footer_content, reverse_seed)]
|
||||
51
mandelstudio/migrations/0003_locale_audit_models.py
Normal file
51
mandelstudio/migrations/0003_locale_audit_models.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("mandelstudio", "0002_seed_localized_footer_content")]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="LocaleAuditRun",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("started_at", models.DateTimeField(auto_now_add=True)),
|
||||
("finished_at", models.DateTimeField(blank=True, null=True)),
|
||||
("locale_codes", models.JSONField(blank=True, default=list)),
|
||||
("fix_enabled", models.BooleanField(default=False)),
|
||||
("total_urls_checked", models.PositiveIntegerField(default=0)),
|
||||
("issues_found", models.PositiveIntegerField(default=0)),
|
||||
("pages_with_issues", models.PositiveIntegerField(default=0)),
|
||||
("summary", models.JSONField(blank=True, default=dict)),
|
||||
],
|
||||
options={"ordering": ["-started_at"]},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="LocaleAuditIssue",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("locale_code", models.CharField(max_length=12)),
|
||||
("object_id", models.PositiveIntegerField(blank=True, null=True)),
|
||||
("object_type", models.CharField(blank=True, max_length=128)),
|
||||
("url", models.TextField(blank=True)),
|
||||
("title", models.CharField(blank=True, max_length=255)),
|
||||
("severity", models.CharField(max_length=16)),
|
||||
("issue_type", models.CharField(max_length=64)),
|
||||
("field_path", models.CharField(blank=True, max_length=512)),
|
||||
("bad_value", models.TextField(blank=True)),
|
||||
("replacement", models.TextField(blank=True)),
|
||||
("fixed", models.BooleanField(default=False)),
|
||||
("extra", models.JSONField(blank=True, default=dict)),
|
||||
(
|
||||
"run",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="issues",
|
||||
to="mandelstudio.localeauditrun",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={"ordering": ["locale_code", "url", "field_path"]},
|
||||
),
|
||||
]
|
||||
0
mandelstudio/migrations/__init__.py
Normal file
0
mandelstudio/migrations/__init__.py
Normal file
@@ -2,7 +2,6 @@
|
||||
"ocyan_plugins": [
|
||||
"ocyan.plugin.contact_form",
|
||||
"ocyan.plugin.cookie_jar",
|
||||
"ocyan.plugin.demo_data",
|
||||
"ocyan.plugin.django",
|
||||
"ocyan.plugin.newsletter",
|
||||
"ocyan.plugin.oscar",
|
||||
@@ -15,7 +14,7 @@
|
||||
"ocyan.plugin.oscar_partner",
|
||||
"ocyan.plugin.oscar_shipping",
|
||||
"ocyan.plugin.oscar_sequential_order_numbers",
|
||||
"ocyan.plugin.payment_dummy",
|
||||
"ocyan.plugin.payment_mollie",
|
||||
"ocyan.plugin.roadrunner_bs5",
|
||||
"ocyan.plugin.template_engine",
|
||||
"ocyan.plugin.roadrunner_productchooser",
|
||||
@@ -64,8 +63,23 @@
|
||||
"en"
|
||||
]
|
||||
},
|
||||
"ocyan_dummy_payment_plugin": {
|
||||
"help_text": "Hit pay, to simulate payment."
|
||||
"payment_mollie": {
|
||||
"api_key": "CHANGE_ME",
|
||||
"ideal": true,
|
||||
"creditcard": true,
|
||||
"paypal": true,
|
||||
"bancontact": true,
|
||||
"sofort": true,
|
||||
"banktransfer": false,
|
||||
"belfius": false,
|
||||
"bitcoin": false,
|
||||
"directdebit": false,
|
||||
"eps": false,
|
||||
"giftcard": false,
|
||||
"giropay": false,
|
||||
"inghomepay": false,
|
||||
"kbc": false,
|
||||
"mistercash": false
|
||||
},
|
||||
"oscar": {
|
||||
"allow_anon_checkout": true,
|
||||
|
||||
@@ -8,6 +8,7 @@ For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/2.0/ref/settings/
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -26,6 +27,49 @@ INSTALLED_APPS = [
|
||||
"mandelstudio",
|
||||
] + INSTALLED_APPS
|
||||
|
||||
# Route through the project URL layer so MandelStudio can override
|
||||
# sitemap/robots behavior while still delegating the main Ocyan routes.
|
||||
ROOT_URLCONF = "mandelstudio.urls"
|
||||
|
||||
|
||||
def _ensure_required_app(*candidates):
|
||||
"""Ensure required plugin apps remain enabled when /etc/ocyan config omits them."""
|
||||
if any(app in INSTALLED_APPS for app in candidates):
|
||||
return
|
||||
for app in candidates:
|
||||
if importlib.util.find_spec(app):
|
||||
INSTALLED_APPS.append(app)
|
||||
return
|
||||
|
||||
|
||||
_ensure_required_app(
|
||||
"ocyan.plugin.carbasa.carbasa",
|
||||
"ocyan.plugin.carbasa",
|
||||
)
|
||||
_ensure_required_app(
|
||||
"ocyan.plugin.coyote.coyote",
|
||||
"ocyan.plugin.coyote",
|
||||
)
|
||||
|
||||
# Keep Carbasa/Coyote defaults stable even when plugin settings are not
|
||||
# injected early enough during startup on this deployment.
|
||||
OXYAN_HEADER_OPTIONS = globals().get(
|
||||
"OXYAN_HEADER_OPTIONS",
|
||||
[
|
||||
("basic", "Basic Header"),
|
||||
("big", "Big Header"),
|
||||
("mega", "Mega Header"),
|
||||
],
|
||||
)
|
||||
COMPRESS_CACHE_KEY_FUNCTION = globals().get(
|
||||
"COMPRESS_CACHE_KEY_FUNCTION",
|
||||
"ocyan.plugin.coyote.utils.get_compressor_cache_key",
|
||||
)
|
||||
OXYAN_LAZY_THEME_DEFINITIONS = globals().get(
|
||||
"OXYAN_LAZY_THEME_DEFINITIONS",
|
||||
"ocyan.plugin.coyote.definitions.get_coyote_definitions",
|
||||
)
|
||||
|
||||
# Enable request language negotiation.
|
||||
if "django.middleware.locale.LocaleMiddleware" not in MIDDLEWARE:
|
||||
if "django.contrib.sessions.middleware.SessionMiddleware" in MIDDLEWARE:
|
||||
|
||||
81
mandelstudio/sitemaps.py
Normal file
81
mandelstudio/sitemaps.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from django.contrib.sitemaps.views import index as sitemap_index_view
|
||||
from django.contrib.sitemaps.views import sitemap as sitemap_section_view
|
||||
from django.http import HttpResponse
|
||||
from wagtail.models import Locale, Page
|
||||
|
||||
from ocyan.plugin.wagtail_oscar_integration.constants import CACHE_DURATION
|
||||
from ocyan.plugin.wagtail_oscar_integration.sitemap import CategorySitemap
|
||||
from ocyan.plugin.wagtail_oscar_integration.sitemap import ProductSitemap
|
||||
from ocyan.plugin.wagtail_oscar_integration.sitemap import ShopSitemap
|
||||
from ocyan.plugin.wagtail_oscar_integration.sitemap import WagtailSitemap as BaseWagtailSitemap
|
||||
|
||||
|
||||
class WagtailSitemap(BaseWagtailSitemap):
|
||||
def items(self):
|
||||
page_ids = []
|
||||
|
||||
for locale in Locale.objects.all():
|
||||
translated_root_page = self.get_wagtail_site().root_page.get_translation_or_none(
|
||||
locale
|
||||
)
|
||||
if translated_root_page is None:
|
||||
continue
|
||||
|
||||
locale_page_ids = (
|
||||
translated_root_page.get_descendants(inclusive=True)
|
||||
.live()
|
||||
.public()
|
||||
.order_by()
|
||||
.values_list("pk", flat=True)
|
||||
)
|
||||
page_ids.extend(locale_page_ids)
|
||||
|
||||
if not page_ids:
|
||||
return []
|
||||
|
||||
return (
|
||||
Page.objects.filter(pk__in=page_ids)
|
||||
.live()
|
||||
.public()
|
||||
.defer_streamfields()
|
||||
.order_by("path")
|
||||
.specific()
|
||||
)
|
||||
|
||||
|
||||
def gather_sitemaps():
|
||||
return {
|
||||
"pages": WagtailSitemap,
|
||||
"shop": ShopSitemap,
|
||||
"products": ProductSitemap,
|
||||
"categories": CategorySitemap,
|
||||
}
|
||||
|
||||
|
||||
def sitemap_index(request):
|
||||
return sitemap_index_view(
|
||||
request,
|
||||
sitemaps=gather_sitemaps(),
|
||||
sitemap_url_name="sitemaps",
|
||||
)
|
||||
|
||||
|
||||
def sitemap_section(request, section=None):
|
||||
return sitemap_section_view(
|
||||
request,
|
||||
sitemaps=gather_sitemaps(),
|
||||
section=section,
|
||||
)
|
||||
|
||||
|
||||
def robots_txt(request):
|
||||
sitemap_url = request.build_absolute_uri("/sitemap.xml")
|
||||
content = "\n".join(
|
||||
[
|
||||
"User-agent: *",
|
||||
"Allow: /",
|
||||
f"Sitemap: {sitemap_url}",
|
||||
"",
|
||||
]
|
||||
)
|
||||
return HttpResponse(content, content_type="text/plain; charset=utf-8")
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "layout.html" %}
|
||||
{% load wagtailcore_tags oxyan static string_filters %}
|
||||
{% load wagtailcore_tags oxyan static string_filters mandelstudio_i18n %}
|
||||
|
||||
{% block extrahead %}
|
||||
{{ block.super }}
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
{% block layout %}
|
||||
<a class="btn btn-secondary hidelink" id="main_content_link" href="#skip_header" tabindex="2">
|
||||
Ga naar inhoud
|
||||
{% skip_to_content_text %}
|
||||
</a>
|
||||
{% include_header header_template|default:"engine/partials/header.html" %}
|
||||
<div id="main_content" tabindex="-1">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "layout.html" %}
|
||||
{% load wagtailcore_tags oxyan static string_filters %}
|
||||
{% load wagtailcore_tags oxyan static string_filters mandelstudio_i18n %}
|
||||
|
||||
{% block extrahead %}
|
||||
{{ block.super }}
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
{% block layout %}
|
||||
<a class="btn btn-secondary hidelink" id="main_content_link" href="#skip_header" tabindex="2">
|
||||
Ga naar inhoud
|
||||
{% skip_to_content_text %}
|
||||
</a>
|
||||
{% include_header header_template|default:"engine/partials/header.html" %}
|
||||
<div id="main_content" tabindex="-1">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "layout.html" %}
|
||||
{% load wagtailcore_tags oxyan static string_filters %}
|
||||
{% load wagtailcore_tags oxyan static string_filters mandelstudio_i18n %}
|
||||
|
||||
{% block extrahead %}
|
||||
{{ block.super }}
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
{% block layout %}
|
||||
<a class="btn btn-secondary hidelink" id="main_content_link" href="#skip_header" tabindex="2">
|
||||
Ga naar inhoud
|
||||
{% skip_to_content_text %}
|
||||
</a>
|
||||
{% include_header header_template|default:"engine/partials/header.html" %}
|
||||
<div id="main_content" tabindex="-1">
|
||||
|
||||
1
mandelstudio/templates/engine/partials/header5.html
Normal file
1
mandelstudio/templates/engine/partials/header5.html
Normal file
@@ -0,0 +1 @@
|
||||
{% include "carbasa/headers/header.html" %}
|
||||
36
mandelstudio/templates/oxyan/partials/footer.html
Normal file
36
mandelstudio/templates/oxyan/partials/footer.html
Normal file
@@ -0,0 +1,36 @@
|
||||
{% load staticfiles %}
|
||||
{% load wagtailcore_tags wagtailimages_tags wagtailsettings_tags cache mandelstudio_footer %}
|
||||
{% get_settings %}
|
||||
{% localized_footer_content as localized_footer %}
|
||||
|
||||
{% cache 300 footer_menu LANGUAGE_CODE request.site %}
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
{% with footer=localized_footer.footer|default:settings.ocyan_plugin_wagtail.OcyanSettings.footer %}
|
||||
{% for block in footer %}
|
||||
<div class="col-lg-3 col-md-6 col-sm-12 footer_column {{ block.block_type|slugify }}">
|
||||
{% include_block block %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<section class="copyright_wrapper">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-lg-12 copyright_block">
|
||||
{% if localized_footer and localized_footer.mini_footer %}
|
||||
{% for block in localized_footer.mini_footer %}
|
||||
{% include_block block %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{% include_block settings.ocyan_plugin_wagtail.OcyanSettings.mini_footer %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endcache %}
|
||||
@@ -8,7 +8,7 @@
|
||||
<ul class="dropdown-menu" aria-labelledby="dropdownMenuButton1">
|
||||
{% get_language_info_list for languages as languages %}
|
||||
{% ocyanjson "i18n" "language_chooser_disabled_options" "" as disabled_languages %}
|
||||
<form action="{% url set_language %}" method="post" class="language_form">
|
||||
<form action="{% url 'set_language' %}" method="post" class="language_form">
|
||||
{% csrf_token %}
|
||||
<input name="next" type="hidden" value="{{ language_neutral_url_path|default:request.path|untranslated_url }}"/>
|
||||
{% for language in languages %}
|
||||
|
||||
0
mandelstudio/templatetags/__init__.py
Normal file
0
mandelstudio/templatetags/__init__.py
Normal file
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)
|
||||
120
mandelstudio/templatetags/localized_navigation.py
Normal file
120
mandelstudio/templatetags/localized_navigation.py
Normal file
@@ -0,0 +1,120 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django import template
|
||||
from django.conf import settings
|
||||
|
||||
from wagtail.models import Page
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
def _normalize_language_code(language_code: str | None) -> str:
|
||||
return (language_code or settings.LANGUAGE_CODE).split("-")[0]
|
||||
|
||||
|
||||
def _fallback_locale_url(language_code: str) -> str:
|
||||
default_language = _normalize_language_code(settings.LANGUAGE_CODE)
|
||||
target_language = _normalize_language_code(language_code)
|
||||
return "/" if target_language == default_language else f"/{target_language}/"
|
||||
|
||||
|
||||
def _is_translatable_page(page) -> bool:
|
||||
return page is not None and hasattr(page, "translation_key") and hasattr(page, "locale")
|
||||
|
||||
|
||||
def _translated_pages(page):
|
||||
if not _is_translatable_page(page):
|
||||
return {}
|
||||
|
||||
return {
|
||||
_normalize_language_code(translated.locale.language_code): translated
|
||||
for translated in Page.objects.filter(translation_key=page.translation_key)
|
||||
.live()
|
||||
.public()
|
||||
.specific()
|
||||
}
|
||||
|
||||
|
||||
def _build_absolute_url(request, path: str | None, page=None) -> str:
|
||||
if path and request is not None:
|
||||
return request.build_absolute_uri(path)
|
||||
if page is not None:
|
||||
return getattr(page, "full_url", "") or path or ""
|
||||
return path or ""
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def page_language_options(page):
|
||||
labels = {
|
||||
_normalize_language_code(code): label
|
||||
for code, label in settings.LANGUAGES
|
||||
}
|
||||
|
||||
if not _is_translatable_page(page):
|
||||
return [
|
||||
{
|
||||
"code": _normalize_language_code(code),
|
||||
"label": labels.get(_normalize_language_code(code), _normalize_language_code(code)),
|
||||
"url": _fallback_locale_url(code),
|
||||
}
|
||||
for code, _label in settings.LANGUAGES
|
||||
]
|
||||
|
||||
translations = _translated_pages(page)
|
||||
options = []
|
||||
for code, _label in settings.LANGUAGES:
|
||||
language_code = _normalize_language_code(code)
|
||||
translated_page = translations.get(language_code)
|
||||
options.append(
|
||||
{
|
||||
"code": language_code,
|
||||
"label": labels.get(language_code, language_code),
|
||||
"url": translated_page.url if translated_page is not None else _fallback_locale_url(language_code),
|
||||
}
|
||||
)
|
||||
return options
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def page_canonical_url(context):
|
||||
request = context.get("request")
|
||||
page = context.get("page") or context.get("self")
|
||||
if page is not None and getattr(page, "url", None):
|
||||
return _build_absolute_url(request, page.url, page)
|
||||
if request is not None:
|
||||
return request.build_absolute_uri()
|
||||
return ""
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def page_hreflang_links(context):
|
||||
request = context.get("request")
|
||||
page = context.get("page") or context.get("self")
|
||||
if not _is_translatable_page(page):
|
||||
return []
|
||||
|
||||
translations = _translated_pages(page)
|
||||
links = []
|
||||
for code, _label in settings.LANGUAGES:
|
||||
language_code = _normalize_language_code(code)
|
||||
translated_page = translations.get(language_code)
|
||||
if translated_page is None or not getattr(translated_page, "url", None):
|
||||
continue
|
||||
links.append(
|
||||
{
|
||||
"code": language_code,
|
||||
"url": _build_absolute_url(request, translated_page.url, translated_page),
|
||||
}
|
||||
)
|
||||
|
||||
default_language = _normalize_language_code(settings.LANGUAGE_CODE)
|
||||
default_page = translations.get(default_language)
|
||||
if default_page is not None and getattr(default_page, "url", None):
|
||||
links.append(
|
||||
{
|
||||
"code": "x-default",
|
||||
"url": _build_absolute_url(request, default_page.url, default_page),
|
||||
}
|
||||
)
|
||||
|
||||
return links
|
||||
26
mandelstudio/templatetags/mandelstudio_footer.py
Normal file
26
mandelstudio/templatetags/mandelstudio_footer.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from django import template
|
||||
from wagtail.models import Site
|
||||
|
||||
from mandelstudio.models import LocalizedFooterContent
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def localized_footer_content(context):
|
||||
request = context.get("request")
|
||||
if request is None:
|
||||
return None
|
||||
site = getattr(request, "site", None) or Site.find_for_request(request)
|
||||
if site is None:
|
||||
return None
|
||||
language_code = getattr(request, "LANGUAGE_CODE", None)
|
||||
if not language_code:
|
||||
return None
|
||||
return (
|
||||
LocalizedFooterContent.objects.filter(
|
||||
site=site,
|
||||
locale__language_code=language_code,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
23
mandelstudio/templatetags/mandelstudio_i18n.py
Normal file
23
mandelstudio/templatetags/mandelstudio_i18n.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django import template
|
||||
|
||||
register = template.Library()
|
||||
|
||||
SKIP_TO_CONTENT = {
|
||||
"nl": "Ga naar inhoud",
|
||||
"en": "Skip to content",
|
||||
"de": "Zum Inhalt springen",
|
||||
"fr": "Aller au contenu",
|
||||
"es": "Ir al contenido",
|
||||
"it": "Vai al contenuto",
|
||||
"pt": "Ir para o conteúdo",
|
||||
"ru": "Перейти к содержанию",
|
||||
}
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def skip_to_content_text(context) -> str:
|
||||
request = context.get("request")
|
||||
language_code = getattr(request, "LANGUAGE_CODE", "nl")
|
||||
return SKIP_TO_CONTENT.get(language_code, SKIP_TO_CONTENT["en"])
|
||||
@@ -1,13 +1,26 @@
|
||||
from django.conf.urls.i18n import i18n_patterns
|
||||
from django.views.decorators.cache import cache_page
|
||||
from django.urls import include, path
|
||||
|
||||
from ocyan.main.urls import urlpatterns as ocyan_urlpatterns
|
||||
from ocyan.plugin.wagtail_oscar_integration.constants import CACHE_DURATION
|
||||
|
||||
from .sitemaps import robots_txt
|
||||
from .sitemaps import sitemap_index
|
||||
from .sitemaps import sitemap_section
|
||||
|
||||
urlpatterns = [
|
||||
path("i18n/", include("django.conf.urls.i18n")),
|
||||
path("robots.txt", robots_txt, name="robots-txt"),
|
||||
path(
|
||||
"sitemap.xml",
|
||||
cache_page(CACHE_DURATION)(sitemap_index),
|
||||
name="sitemap-index",
|
||||
),
|
||||
path(
|
||||
"sitemap-<section>.xml",
|
||||
cache_page(CACHE_DURATION)(sitemap_section),
|
||||
name="sitemaps",
|
||||
),
|
||||
]
|
||||
|
||||
urlpatterns += i18n_patterns(
|
||||
*ocyan_urlpatterns,
|
||||
prefix_default_language=False,
|
||||
)
|
||||
urlpatterns += ocyan_urlpatterns
|
||||
|
||||
2
setup.py
2
setup.py
@@ -3,7 +3,7 @@ import json
|
||||
|
||||
from setuptools import find_packages, setup
|
||||
|
||||
install_requires: list = ["setuptools", "ocyan.main"]
|
||||
install_requires: list = ["setuptools", "ocyan.main", "elasticsearch<9"]
|
||||
|
||||
# Add frets dependencies
|
||||
with open("mandelstudio/ocyan.json", encoding="utf-8") as fp:
|
||||
|
||||
36
templates/oxyan/partials/footer.html
Normal file
36
templates/oxyan/partials/footer.html
Normal file
@@ -0,0 +1,36 @@
|
||||
{% load staticfiles %}
|
||||
{% load wagtailcore_tags wagtailimages_tags wagtailsettings_tags cache mandelstudio_footer %}
|
||||
{% get_settings %}
|
||||
{% localized_footer_content as localized_footer %}
|
||||
|
||||
{% cache 300 footer_menu LANGUAGE_CODE request.site %}
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
{% with footer=localized_footer.footer|default:settings.ocyan_plugin_wagtail.OcyanSettings.footer %}
|
||||
{% for block in footer %}
|
||||
<div class="col-lg-3 col-md-6 col-sm-12 footer_column {{ block.block_type|slugify }}">
|
||||
{% include_block block %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<section class="copyright_wrapper">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-lg-12 copyright_block">
|
||||
{% if localized_footer and localized_footer.mini_footer %}
|
||||
{% for block in localized_footer.mini_footer %}
|
||||
{% include_block block %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{% include_block settings.ocyan_plugin_wagtail.OcyanSettings.mini_footer %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endcache %}
|
||||
Reference in New Issue
Block a user