68 Commits

Author SHA1 Message Date
e394eb0288 Restore styled footer shell 2026-04-26 09:43:39 +02:00
93e2d7910a Revert "Header: add language switcher + home menu"
This reverts commit bd49f6be6e.
2026-04-26 01:15:56 +02:00
043dd6620b Revert "Header: render language chooser in Carbasa non-webshop"
This reverts commit dbf48c49e7.
2026-04-26 01:15:56 +02:00
5c31142b03 Revert "Carbasa header: add language switcher"
This reverts commit 886188ed85.
2026-04-26 01:15:56 +02:00
149a5d0a1b Revert "Fix Carbasa header overrides cleanly"
This reverts commit c7adaf94b4.
2026-04-26 01:15:56 +02:00
c7adaf94b4 Fix Carbasa header overrides cleanly 2026-04-26 01:09:30 +02:00
886188ed85 Carbasa header: add language switcher 2026-04-26 00:49:20 +02:00
dbf48c49e7 Header: render language chooser in Carbasa non-webshop 2026-04-26 00:44:04 +02:00
bd49f6be6e Header: add language switcher + home menu 2026-04-26 00:41:05 +02:00
8b38812a23 Revert Carbasa header override 2026-04-25 23:27:22 +02:00
d10575403f Fix Carbasa header double logo 2026-04-25 23:25:13 +02:00
f54df55c56 Use real Carbasa header override 2026-04-20 21:54:38 +02:00
7587841873 Restore engine templates to dynamic Carbasa header flow 2026-04-12 09:30:40 +02:00
932232d52b Sort i18n view imports for CI lint 2026-04-11 21:07:58 +02:00
b6c0a18098 Use django.urls.translate_url for setlang compatibility 2026-04-11 21:05:22 +02:00
d9ecab62e3 Fix localized setlang redirects for prefixed next paths 2026-04-11 21:03:29 +02:00
497addffb2 Bypass wrapped CSRF in custom setlang proxy 2026-04-11 20:55:30 +02:00
605f1e8276 Fix setlang redirect normalization for locale variants 2026-04-11 20:48:55 +02:00
58139b08ff fix(i18n): normalize setlang next path server-side 2026-04-10 23:03:15 +02:00
944e88d78d style(i18n): apply ruff formatting for CI lint 2026-04-10 22:49:15 +02:00
8b95fa5b2b fix(i18n): strip existing language prefix in manage language switch 2026-04-10 22:41:53 +02:00
89773de4d1 fix(i18n): normalize manage language-switch next URL 2026-04-10 22:21:02 +02:00
462a5b6b62 render Django messages on modern saas page templates 2026-04-10 20:44:17 +02:00
05b0e3a429 contact form: show inline submit feedback messages 2026-04-10 20:37:42 +02:00
f59fa106f6 Use dashboard deploy helper for multilingual audit 2026-04-10 20:12:58 +02:00
5e49eb93a2 Run multilingual audit on Jenkins built-in node 2026-04-10 18:54:30 +02:00
b86849b1e4 Harden Jenkins checkout bootstrap 2026-04-10 18:39:59 +02:00
3056bfecd8 Reuse workspace for multilingual audit 2026-04-10 18:38:50 +02:00
e450f8a8b0 Run multilingual audit on external_pool 2026-04-10 18:35:57 +02:00
fcabba0da2 Fix import ordering for Jenkins lint 2026-04-10 18:18:49 +02:00
034a804e02 Format files required by Jenkins lint 2026-04-10 18:15:50 +02:00
ea011b2993 Patch invalid invoice admin registration 2026-04-10 18:12:01 +02:00
d1c6a5f85c Align initial migration with Wagtail 7.3.1 2026-04-10 18:06:08 +02:00
3e12189335 Respect disabled payments in launch validation 2026-04-10 17:38:23 +02:00
489c6ce75b Fix payment plugin launch validation 2026-04-10 17:33:04 +02:00
610fd6d748 Fix staging audit env in Jenkins pipeline 2026-04-10 17:23:20 +02:00
bbb88f9a2f Tighten dummy payment validation 2026-04-09 01:06:49 +02:00
4648b7b0b3 Filter demo-data plugins from production settings 2026-04-09 01:01:46 +02:00
fb55d59b77 Patch payment validator for Django 5 compatibility 2026-04-09 00:59:35 +02:00
cf33be8361 Add payment provider validation entrypoint 2026-04-09 00:52:25 +02:00
310ac83bc4 Merge remote master before deployment 2026-04-09 00:48:25 +02:00
93b72b306c Merge production refresh for live deploy 2026-04-09 00:48:11 +02:00
8bfd4d789b production refresh 2026-04-09 00:42:40 +02:00
e4c6e3dcef Add launch pipeline and idea marketplace seed commands 2026-04-09 00:29:23 +02:00
7db05fea47 Add launch pipeline and idea marketplace seed commands 2026-04-09 00:28:42 +02:00
a6bb1622be Fix demo purge import formatting 2026-04-06 02:46:28 +02:00
4003f698d2 Format demo purge for Jenkins lint 2026-04-06 02:42:58 +02:00
5f0c7bd9b9 Format demo purge and remove demo plugins 2026-04-06 02:40:41 +02:00
dbc9fe87c6 Make demo purge independent of Elasticsearch 2026-04-06 02:38:02 +02:00
90e24976df Bypass checks for demo purge step 2026-04-06 02:36:05 +02:00
f15b1d4eab Add demo data purge command 2026-04-06 02:34:12 +02:00
7d9bb0665e Remove demo data loading from build 2026-04-06 02:31:27 +02:00
57f4c0044a Remove demo data loading from build 2026-04-06 02:30:24 +02:00
3c8e7e923f Force Carbasa header for config-driven engine header variants 2026-04-03 22:22:07 +02:00
095248277e Route engine header partial to Carbasa header 2026-04-03 22:13:12 +02:00
0d0a2cb36c Route engine header partial to Carbasa header 2026-04-03 22:12:20 +02:00
ee5fbf6e78 Force Carbasa header include in engine page templates 2026-04-03 19:17:29 +02:00
d571731fd6 Render Carbasa header directly from layout to avoid header resolver variant drift 2026-04-03 19:13:03 +02:00
537d7cf0da Set Carbasa header override to collection dropdown + user icons 2026-04-03 19:09:04 +02:00
4e465d2c3c Restore Carbasa header as active source and remove injected header styling 2026-04-03 19:00:41 +02:00
27db3bc536 Restore Carbasa as active header source and remove webshop mega menu 2026-04-03 18:53:59 +02:00
0ca82391c1 Add missing corner SVG template for clean release 2026-04-03 07:00:41 +02:00
b2329d5d4d Fix locale switcher URL in shared header 2026-04-03 02:17:24 +02:00
f093a201d1 Run multilingual audit stages on built-in Jenkins node 2026-03-29 21:34:30 +02:00
644d3c0b7b Fix import ordering for multilingual CI lint 2026-03-29 21:28:12 +02:00
bfdf061f31 Format multilingual audit extraction for CI lint 2026-03-29 21:25:01 +02:00
da0798c218 Document multilingual audit CI operations 2026-03-29 20:57:58 +02:00
1f05011a63 Add multilingual audit CI pipeline + extract mandelblog_content_guard 2026-03-29 20:49:42 +02:00
51 changed files with 2578 additions and 185 deletions

43
Jenkinsfile vendored
View File

@@ -6,6 +6,13 @@ 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'
@@ -21,7 +28,11 @@ pipeline {
sh '''
export GIT_SSH_COMMAND="ssh -i $GIT_KEYFILE -o StrictHostKeyChecking=accept-new"
if [ -d .git ]; then
if git remote get-url origin >/dev/null 2>&1; then
git remote set-url origin ssh://git@git.mandelblog.com:2222/salt/mandelstudio.git
else
git remote add origin ssh://git@git.mandelblog.com:2222/salt/mandelstudio.git
fi
git fetch --tags --force --progress origin +refs/heads/master:refs/remotes/origin/master
else
git clone ssh://git@git.mandelblog.com:2222/salt/mandelstudio.git .
@@ -81,8 +92,14 @@ PY
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"
'''
@@ -128,10 +145,26 @@ PY
timeout(time: 10, unit: 'MINUTES')
}
steps {
deleteDir()
checkout scm
sh 'mkdir -p artifacts && chmod +x scripts/run_remote_multilingual_audit.sh'
sh './scripts/run_remote_multilingual_audit.sh'
withCredentials([sshUserPrivateKey(credentialsId: 'gitea-ssh', keyFileVariable: 'GIT_KEYFILE')]) {
sh '''
export GIT_SSH_COMMAND="ssh -i $GIT_KEYFILE -o StrictHostKeyChecking=accept-new"
if [ -d .git ]; then
if git remote get-url origin >/dev/null 2>&1; then
git remote set-url origin ssh://git@git.mandelblog.com:2222/salt/mandelstudio.git
else
git remote add origin ssh://git@git.mandelblog.com:2222/salt/mandelstudio.git
fi
git fetch --tags --force --progress origin +refs/heads/master:refs/remotes/origin/master
else
git clone ssh://git@git.mandelblog.com:2222/salt/mandelstudio.git .
git fetch --tags --force --progress origin +refs/heads/master:refs/remotes/origin/master
fi
git checkout -f refs/remotes/origin/master
mkdir -p artifacts
chmod +x scripts/run_remote_multilingual_audit.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) {

View File

@@ -22,7 +22,11 @@ pipeline {
sh '''
export GIT_SSH_COMMAND="ssh -i $GIT_KEYFILE -o StrictHostKeyChecking=accept-new"
if [ -d .git ]; then
if git remote get-url origin >/dev/null 2>&1; then
git remote set-url origin ssh://git@git.mandelblog.com:2222/salt/mandelstudio.git
else
git remote add origin ssh://git@git.mandelblog.com:2222/salt/mandelstudio.git
fi
git fetch --tags --force --progress origin +refs/heads/master:refs/remotes/origin/master
else
git clone ssh://git@git.mandelblog.com:2222/salt/mandelstudio.git .
@@ -39,9 +43,7 @@ pipeline {
timeout(time: 10, unit: 'MINUTES')
}
steps {
checkout scm
sh 'mkdir -p artifacts && [ -f artifacts/multilingual-audit.json ] && cp artifacts/multilingual-audit.json artifacts/previous-multilingual-audit.json || true'
sh '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 --previous-json artifacts/previous-multilingual-audit.json', returnStatus: true)

View File

@@ -55,13 +55,18 @@ The audit summary is interpreted as follows:
This keeps deploys safe without making warning-level cleanup a hard blocker.
## Jenkins requirements
No dedicated staging SSH credential is required for the multilingual audit stage.
## Required Jenkins credential
Credential location:
- `Manage Jenkins -> Credentials -> System -> Global credentials`
The audit runs through `/srv/apps/mandel-dashboard/bin/deploy_stg_from_jenkins.py --command`, using the same sudo-whitelisted deployment entrypoint as staging deployment.
Credential to add:
- `Kind`: `SSH Username with private key`
- `ID`: `staging-root-ssh`
- `Username`: `root`
- `Private key`: staging SSH key
Current implementation uses the following environment defaults:
- `STAGING_AUDIT_PROJECT_NAME=mandelstudio`
- `STAGING_AUDIT_HOST=root@49.12.204.96`
- `STAGING_AUDIT_PROJECT_DIR=/home/www-mandelstudio/mandelstudio`
- `STAGING_AUDIT_MANAGE=/var/lib/virtualenv/mandelstudio/bin/manage.py`
@@ -101,7 +106,7 @@ This happens when the remote audit times out or fails, and is intentional so Jen
## Local rerun
To rerun the same remote audit flow locally:
```bash
export STAGING_AUDIT_PROJECT_NAME='mandelstudio'
export STAGING_AUDIT_HOST='root@49.12.204.96'
export STAGING_AUDIT_PROJECT_DIR='/home/www-mandelstudio/mandelstudio'
export STAGING_AUDIT_MANAGE='/var/lib/virtualenv/mandelstudio/bin/manage.py'
./scripts/run_remote_multilingual_audit.sh

View File

@@ -49,7 +49,6 @@ CTA_RULES = {
r"^Service",
r"^Dienstleistungen",
r"^Erstgespräch",
r"^Beratung",
r"^Einführ",
r"^Anpassung",
r"^Ansichts",
@@ -84,7 +83,6 @@ CTA_RULES = {
r"^Descubrir",
r"^Contactar",
r"^Planificar",
r"^Program",
r"^Programe",
r"^Concertar",
r"^Enviar",
@@ -143,8 +141,6 @@ def validate_cta(locale_code: str, field_path: str, normalized: str):
last_segment = field_path.split(".")[-1]
if last_segment not in CTA_FIELDS:
return []
if any(
re.search(pattern, normalized) for pattern in CTA_RULES.get(locale_code, ())
):
if any(re.search(pattern, normalized) for pattern in CTA_RULES.get(locale_code, ())):
return []
return [make_issue("cta_language_mismatch", field_path, normalized)]

View File

@@ -0,0 +1,28 @@
from django.contrib import admin
from django.contrib.admin.sites import NotRegistered
def patch_invoice_admin():
"""
Load the invoice admin stack in a safe order and remove the invalid
date_hierarchy setting injected by the communications plugin.
"""
try:
from oscar.core.loading import get_model
import oscar_invoices.admin # noqa: F401
from ocyan.plugin.oscar_communications.oscar_invoices_extension.admin import (
InvoiceAdmin,
)
except ImportError:
return
Invoice = get_model("oscar_invoices", "Invoice")
InvoiceAdmin.date_hierarchy = None
try:
admin.site.unregister(Invoice)
except NotRegistered:
pass
admin.site.register(Invoice, InvoiceAdmin)

View File

@@ -5,3 +5,8 @@ class MandelstudioConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "mandelstudio"
verbose_name = "Mandelstudio"
def ready(self):
from .admin_fixes import patch_invoice_admin
patch_invoice_admin()

View File

@@ -0,0 +1,37 @@
from __future__ import annotations
from typing import Iterable
DEMO_MARKERS: tuple[str, ...] = (
"demo",
"dummy",
"sample",
"lorem",
"placeholder",
"sandbox",
"staging",
"prototype",
"template-only",
)
# Known legacy/demo pages that should never surface on production.
BLOCKED_DEMO_PAGE_SLUGS: tuple[str, ...] = (
"starter-website-2",
"business-website-2",
)
def contains_demo_marker(values: Iterable[str | None]) -> bool:
for raw_value in values:
if not raw_value:
continue
lowered = raw_value.lower()
if any(marker in lowered for marker in DEMO_MARKERS):
return True
return False
def is_blocked_demo_slug(value: str | None) -> bool:
if not value:
return False
return value.lower() in BLOCKED_DEMO_PAGE_SLUGS

View File

@@ -0,0 +1,37 @@
from __future__ import annotations
import re
from urllib.parse import urlsplit, urlunsplit
from django.conf import settings
def normalize_set_language_next(value: str | None) -> str:
"""
Normalize the `next` path used by Django's set_language view.
Removes any leading language prefix from the path so switching from one
locale to another cannot produce duplicated prefixes like `/de/en/...`.
"""
if not value:
return "/"
parsed = urlsplit(str(value))
path = parsed.path or "/"
if not path.startswith("/"):
path = f"/{path}"
configured_codes = {
str(code).lower().replace("_", "-") for code, _ in settings.LANGUAGES
}
first_segment, _, remainder = path.lstrip("/").partition("/")
normalized_segment = first_segment.lower().replace("_", "-")
looks_like_language_code = bool(
re.fullmatch(r"[a-z]{2}(?:-[a-z]{2})?", normalized_segment)
)
should_strip = normalized_segment in configured_codes or looks_like_language_code
if should_strip:
path = f"/{remainder}" if remainder else "/"
return urlunsplit(("", "", path, parsed.query, ""))

View File

@@ -0,0 +1,55 @@
from __future__ import annotations
from django.conf import settings
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.urls import translate_url
from django.utils.http import url_has_allowed_host_and_scheme
from django.utils.translation import check_for_language
from django.utils.translation import get_language as _get_language
from django.views.decorators.csrf import csrf_exempt
from django.views.i18n import LANGUAGE_QUERY_PARAMETER
from .i18n_utils import normalize_set_language_next
@csrf_exempt
def set_language_normalized(request: HttpRequest) -> HttpResponse:
"""
Set language while normalizing `next` to avoid duplicated locale prefixes.
Mirrors Django's set_language behavior closely, but enforces `next`
normalization before translating redirects.
"""
next_url = request.POST.get("next", request.GET.get("next"))
if next_url:
next_url = normalize_set_language_next(next_url)
if next_url and not url_has_allowed_host_and_scheme(
url=next_url,
allowed_hosts={request.get_host()},
require_https=request.is_secure(),
):
next_url = request.META.get("HTTP_REFERER")
if not next_url:
next_url = "/"
response: HttpResponse = HttpResponseRedirect(next_url)
if request.method == "POST":
lang_code = request.POST.get(LANGUAGE_QUERY_PARAMETER)
if lang_code and check_for_language(lang_code):
translated = translate_url(next_url, lang_code)
if translated != next_url:
response = HttpResponseRedirect(translated)
response.set_cookie(
settings.LANGUAGE_COOKIE_NAME,
lang_code,
max_age=settings.LANGUAGE_COOKIE_AGE,
path=settings.LANGUAGE_COOKIE_PATH,
domain=settings.LANGUAGE_COOKIE_DOMAIN,
secure=settings.LANGUAGE_COOKIE_SECURE,
httponly=settings.LANGUAGE_COOKIE_HTTPONLY,
samesite=settings.LANGUAGE_COOKIE_SAMESITE,
)
response.headers.setdefault("Content-Language", _get_language())
return response

View File

@@ -0,0 +1,476 @@
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,
}

View File

@@ -0,0 +1,104 @@
from __future__ import annotations
import json
import os
from pathlib import Path
from django.conf import settings
from django.core.management.base import CommandError
def _is_demo_data(value: str) -> bool:
normalized = "".join(ch for ch in str(value).lower() if ch.isalnum())
return "demodata" in normalized
def _is_dummy_payment_app(app_label: str) -> bool:
normalized = str(app_label).lower().replace(":", ".")
parts = [part for part in normalized.split(".") if part]
return "payment_dummy" in parts or normalized == "payment_dummy"
def _load_config_payload() -> dict:
config_path = Path(__file__).resolve().parent / "ocyan.json"
if not config_path.exists():
return {}
with config_path.open("r", encoding="utf-8") as handle:
return json.load(handle)
def get_declared_plugins() -> list[str]:
payload = _load_config_payload()
return [str(plugin) for plugin in payload.get("ocyan_plugins", [])]
def get_declared_payment_apps(installed_apps: list[str] | None = None) -> list[str]:
declared_plugins = [
plugin for plugin in get_declared_plugins() if "payment" in plugin.lower()
]
if declared_plugins:
return declared_plugins
installed_apps = installed_apps or list(settings.INSTALLED_APPS)
return [app for app in installed_apps if "payment" in app.lower()]
def get_checkout_apps() -> list[str]:
return [app for app in settings.INSTALLED_APPS if "checkout" in app.lower()]
def idea_marketplace_payments_enabled() -> bool:
return bool(getattr(settings, "IDEA_MARKETPLACE_PAYMENTS_ENABLED", False))
def validate_payment_provider_config() -> None:
installed_apps = list(settings.INSTALLED_APPS)
payment_apps = get_declared_payment_apps(installed_apps)
checkout_apps = get_checkout_apps()
config_plugins = get_declared_plugins()
if not idea_marketplace_payments_enabled():
if any(_is_dummy_payment_app(app) for app in config_plugins):
raise CommandError(
"Dummy payment app is declared in ocyan.json. Remove it even when payments are disabled."
)
return
if not payment_apps:
raise CommandError("No payment app declared for this project.")
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.")
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(_is_dummy_payment_app(app) for app in config_plugins):
raise CommandError(
"Dummy payment app is declared in ocyan.json. 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_')."
)
if config_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."
)

View File

@@ -1374,21 +1374,21 @@ STANDARD_COPY = {
"<p>Voor technische ondersteuning, uitbreidingen of een vervolgfase na livegang.</p>",
),
],
"form_title": "Vertel kort wat u nodig heeft",
"form_sub": "<p>We reageren inhoudelijk en zonder verkooppraat op uw vraag.</p>",
"form_title": "Start uw volgende project met helderheid",
"form_sub": "<p>We helpen u ontwerpen, bouwen en opschalen - snel, gestructureerd en zonder giswerk.</p>",
"form_fields": [
("text", "Naam", "Uw naam"),
("email", "E-mail", "naam@bedrijf.nl"),
("company", "Bedrijf", "Bedrijfsnaam"),
("message", "Vraag of project", "Waar zoekt u hulp bij?"),
],
"benefits_title": "Wat u kunt verwachten",
"benefits_title": "Waarom dit gesprek werkt",
"benefits": [
"Reactie binnen 24 uur",
"Intakegesprek van 15 minuten",
"Volledig vrijblijvend",
"Vrijblijvend strategiegesprek",
"Praktische vervolgstappen",
],
"privacy": "<p>We gebruiken uw gegevens alleen voor contact over deze aanvraag.</p>",
"privacy": "<p>We gebruiken uw gegevens alleen voor contact over deze aanvraag. Geen verplichtingen.</p>",
"cta": "Klaar om een eerste stap te zetten?",
},
"process": {
@@ -4028,7 +4028,7 @@ def _standard_body(
)
for field_type, label, placeholder in cfg["form_fields"]
],
"submit_button_text": cta["primary"],
"submit_button_text": "Plan mijn strategiegesprek",
"form_action_url": urls["contact"],
"benefits_title": cfg["benefits_title"],
"benefits": [item(text) for text in cfg["benefits"]],

View File

@@ -686,8 +686,8 @@ def nl_standard_page(page_key: str, urls: dict[str, str]) -> list[dict[str, Any]
"cta": "Wilt u uw volgende project professioneel neerzetten?",
},
"contact": {
"headline": "Laten we uw vraag concreet maken",
"sub": "<p>Vertel kort wat u nodig heeft. U krijgt een praktische terugkoppeling met haalbare vervolgstappen.</p>",
"headline": "Laten we uw volgende stap helder maken",
"sub": "<p>Deel kort waar u hulp bij nodig heeft. U krijgt een helder antwoord en een concreet voorstel voor de volgende stap.</p>",
"features_title": "Waarvoor u contact kunt opnemen",
"features_sub": "<p>Kies de route die past bij uw vraag of traject.</p>",
"features": [
@@ -713,8 +713,8 @@ def nl_standard_page(page_key: str, urls: dict[str, str]) -> list[dict[str, Any]
"layout_width": "container",
"background_style": "light",
"layout": "split",
"section_title": "Vertel kort wat u nodig heeft",
"section_subtitle": "<p>We reageren inhoudelijk en zonder verkooppraat op uw vraag.</p>",
"section_title": "Start uw volgende project met helderheid",
"section_subtitle": "<p>We helpen u ontwerpen, bouwen en opschalen - snel, gestructureerd en zonder giswerk.</p>",
"form_fields": [
item(
{
@@ -749,16 +749,16 @@ def nl_standard_page(page_key: str, urls: dict[str, str]) -> list[dict[str, Any]
}
),
],
"submit_button_text": primary,
"submit_button_text": "Plan mijn strategiegesprek",
"form_action_url": urls["contact"],
"benefits_title": "Wat u kunt verwachten",
"benefits_title": "Waarom dit gesprek werkt",
"benefits": [
item("Reactie binnen 24 uur"),
item("Intakegesprek van 15 minuten"),
item("Volledig vrijblijvend"),
item("Vrijblijvend strategiegesprek"),
item("Praktische vervolgstappen"),
],
"side_image": 1,
"privacy_text": "<p>We gebruiken uw gegevens alleen voor contact over deze aanvraag.</p>",
"privacy_text": "<p>We gebruiken uw gegevens alleen voor contact over deze aanvraag. Geen verplichtingen.</p>",
},
),
"cta": "Klaar om een eerste stap te zetten?",

View File

@@ -0,0 +1,226 @@
from __future__ import annotations
from django.core.management.base import BaseCommand
from django.db import transaction
from django.db.models import Q
from wagtail.blocks import StreamValue
from wagtail.models import Page
from mandelstudio.content_hygiene import BLOCKED_DEMO_PAGE_SLUGS, DEMO_MARKERS
from mandelstudio.idea_marketplace import seed_idea_marketplace_products
HOME_COPY = {
"nl": {
"badge": "IDEA MARKETPLACE",
"headline": "Premium ideeën die je direct kunt uitvoeren",
"sub_headline": "<p>Ontdek bewezen plannen, koop de strategie en ontgrendel het volledige implementatieplan.</p>",
"features_title": "Idea Marketplace",
"features_subtitle": "<p>Preview eerst. Koop alleen wat past. Ontgrendel daarna de complete blueprint.</p>",
"footer_headline": "Klaar om een premium idee te ontgrendelen?",
"footer_subheadline": "<p>Kies een plan, rond checkout af en krijg direct toegang tot de volledige strategie.</p>",
"cta_explore": "Explore Ideas",
"cta_buy": "Buy Strategy",
"cta_unlock": "Unlock Full Plan",
},
"en": {
"badge": "IDEA MARKETPLACE",
"headline": "Premium ideas you can execute immediately",
"sub_headline": "<p>Explore proven plans, buy the strategy, and unlock the full implementation blueprint.</p>",
"features_title": "Idea Marketplace",
"features_subtitle": "<p>Preview first. Buy what fits. Unlock complete execution plans after checkout.</p>",
"footer_headline": "Ready to unlock a premium idea?",
"footer_subheadline": "<p>Select a plan, complete checkout, and get full strategy access instantly.</p>",
"cta_explore": "Explore Ideas",
"cta_buy": "Buy Strategy",
"cta_unlock": "Unlock Full Plan",
},
}
SUPPORTED_LANGUAGES = {"nl", "en", "de", "fr", "es", "it", "pt", "ru"}
def _copy_for(language_code: str) -> dict[str, str]:
normalized = (language_code or "nl").split("-")[0].lower()
if normalized not in SUPPORTED_LANGUAGES:
normalized = "nl"
return HOME_COPY["en"] if normalized != "nl" else HOME_COPY["nl"]
def _shop_url_for(language_code: str) -> str:
normalized = (language_code or "nl").split("-")[0].lower()
if normalized == "nl":
return "/shop/"
return f"/{normalized}/shop/"
def _update_homepage_stream(page) -> bool:
if not hasattr(page, "body"):
return False
body = page.body
if not body:
return False
copy = _copy_for(getattr(page.locale, "language_code", "nl"))
shop_url = _shop_url_for(getattr(page.locale, "language_code", "nl"))
stream_data = list(body.stream_data)
changed = False
for block in stream_data:
block_type = block.get("type")
value = block.get("value", {})
if not isinstance(value, dict):
continue
if block_type == "saas_hero_banner":
updates = {
"badge_text": copy["badge"],
"headline": copy["headline"],
"sub_headline": copy["sub_headline"],
"primary_cta_text": copy["cta_explore"],
"primary_cta_url": shop_url,
"secondary_cta_text": copy["cta_buy"],
"secondary_cta_url": shop_url,
}
for key, new_value in updates.items():
if value.get(key) != new_value:
value[key] = new_value
changed = True
if block_type == "saas_features":
updates = {
"section_title": copy["features_title"],
"section_subtitle": copy["features_subtitle"],
}
for key, new_value in updates.items():
if value.get(key) != new_value:
value[key] = new_value
changed = True
if block_type == "saas_cta_footer":
updates = {
"headline": copy["footer_headline"],
"subheadline": copy["footer_subheadline"],
"primary_cta_text": copy["cta_unlock"],
"primary_cta_url": shop_url,
"secondary_cta_text": copy["cta_explore"],
"secondary_cta_url": shop_url,
}
for key, new_value in updates.items():
if value.get(key) != new_value:
value[key] = new_value
changed = True
if not changed:
return False
page.body = StreamValue(page.body.stream_block, stream_data, is_lazy=True)
page.search_description = "Idea marketplace with premium plans. Preview each strategy and unlock full implementation after purchase."
page.save()
return True
def _purge_demo_pages() -> int:
marker_filter = Q()
for marker in DEMO_MARKERS:
marker_filter |= (
Q(title__icontains=marker)
| Q(slug__icontains=marker)
| Q(search_description__icontains=marker)
)
candidate_ids = set(
Page.objects.exclude(depth__lte=2)
.filter(marker_filter)
.values_list("id", flat=True)
)
candidate_ids.update(
Page.objects.exclude(depth__lte=2)
.filter(slug__in=BLOCKED_DEMO_PAGE_SLUGS)
.values_list("id", flat=True)
)
candidates = Page.objects.filter(id__in=candidate_ids).specific()
deleted = 0
for page in candidates:
page.delete()
deleted += 1
return deleted
def _update_homepages() -> int:
updated = 0
# In this architecture localized homepages are expected at depth=2.
for page in Page.objects.filter(depth=2).specific():
if _update_homepage_stream(page):
updated += 1
return updated
class Command(BaseCommand):
help = (
"Prepare production idea marketplace launch: seed idea products, purge obvious demo pages, "
"and refresh homepage sections/CTAs to marketplace messaging."
)
def add_arguments(self, parser):
parser.add_argument(
"--no-seed",
action="store_true",
help="Skip idea product seeding.",
)
parser.add_argument(
"--purge-demo-pages",
action="store_true",
help="Delete pages with obvious demo/lorem/sample markers.",
)
parser.add_argument(
"--skip-purge-demo-pages",
action="store_true",
help="Skip deleting obvious demo pages (enabled by default).",
)
parser.add_argument(
"--apply-homepage-copy",
action="store_true",
help="Update homepage stream blocks to idea marketplace messaging and CTAs.",
)
parser.add_argument(
"--skip-apply-homepage-copy",
action="store_true",
help="Skip homepage marketplace copy refresh (enabled by default).",
)
@transaction.atomic
def handle(self, *args, **options):
if not options["no_seed"]:
seed_stats = seed_idea_marketplace_products(
purge_demo_products=True,
retire_non_idea_products=True,
)
self.stdout.write(
self.style.SUCCESS(
"Seeded idea products: "
f"created={seed_stats['created']}, "
f"updated={seed_stats['updated']}, "
f"deleted_demo_products={seed_stats['deleted_demo']}, "
f"retired_non_idea_products={seed_stats['retired_non_idea']}"
)
)
should_purge_demo_pages = (
options["purge_demo_pages"] or not options["skip_purge_demo_pages"]
)
if should_purge_demo_pages:
deleted_pages = _purge_demo_pages()
self.stdout.write(
self.style.SUCCESS(f"Deleted demo pages: {deleted_pages}")
)
should_apply_homepage_copy = (
options["apply_homepage_copy"] or not options["skip_apply_homepage_copy"]
)
if should_apply_homepage_copy:
updated_pages = _update_homepages()
self.stdout.write(
self.style.SUCCESS(
f"Updated homepages with marketplace copy: {updated_pages}"
)
)

View File

@@ -0,0 +1,129 @@
from __future__ import annotations
from typing import Iterable
from django.core.management.base import BaseCommand
from django.db.models import Q
from wagtail.models import Page
from oscar.core.loading import get_model
IDEA_PRODUCT_TITLES = {
"B2B Webshop Starter Blueprint",
"AI Product Description System",
"High-Converting Landing Page Framework",
"Subscription-Based Service Website Model",
"Marketplace Platform Architecture (Django)",
}
DEMO_PAGE_SLUGS = {
"starter-website-2",
"business-website-2",
"starter-website",
"business-website",
}
DEMO_MARKERS = (
"demo",
"dummy",
"sample",
"placeholder",
"starter website",
"business website",
"lorem ipsum",
)
def _build_demo_text_filter(fields: Iterable[str]) -> Q:
query = Q()
for field in fields:
for marker in DEMO_MARKERS:
query |= Q(**{f"{field}__icontains": marker})
return query
class Command(BaseCommand):
help = (
"Remove demo content from Wagtail pages and Oscar catalogue. "
"Use --keep-only-idea-products to retain only the five launch idea products."
)
def add_arguments(self, parser):
parser.add_argument(
"--dry-run",
action="store_true",
default=False,
help="Show what would be deleted without applying changes.",
)
parser.add_argument(
"--keep-only-idea-products",
action="store_true",
default=False,
help="Delete every top-level product except the five launch idea products.",
)
def handle(self, *args, **options):
dry_run: bool = options["dry_run"]
keep_only_ideas: bool = options["keep_only_idea_products"]
Product = get_model("catalogue", "Product")
product_filter = _build_demo_text_filter(("title",))
product_filter |= Q(slug__in=DEMO_PAGE_SLUGS)
top_level_products = Product.objects.filter(parent__isnull=True)
if keep_only_ideas:
products_to_delete = top_level_products.exclude(
title__in=IDEA_PRODUCT_TITLES
)
else:
products_to_delete = top_level_products.filter(product_filter).exclude(
title__in=IDEA_PRODUCT_TITLES
)
pages_to_delete = (
Page.objects.live()
.public()
.filter(depth__gt=2)
.filter(
Q(slug__in=DEMO_PAGE_SLUGS) | _build_demo_text_filter(("title", "slug"))
)
)
product_preview = list(products_to_delete.values_list("id", "title")[:30])
page_preview = list(pages_to_delete.values_list("id", "slug", "title")[:30])
self.stdout.write(
f"Products matched for deletion: {products_to_delete.count()}"
)
for item in product_preview:
self.stdout.write(f" - product#{item[0]}: {item[1]}")
if products_to_delete.count() > len(product_preview):
self.stdout.write(" - ...")
self.stdout.write(f"Pages matched for deletion: {pages_to_delete.count()}")
for item in page_preview:
self.stdout.write(f" - page#{item[0]}: /{item[1]}/ ({item[2]})")
if pages_to_delete.count() > len(page_preview):
self.stdout.write(" - ...")
if dry_run:
self.stdout.write(
self.style.WARNING("Dry run completed. No data was deleted.")
)
return
deleted_products = products_to_delete.count()
deleted_pages = pages_to_delete.count()
products_to_delete.delete()
for page in pages_to_delete:
# Use Wagtail's delete to remove descendants and revisions safely.
page.delete()
self.stdout.write(
self.style.SUCCESS(
f"Demo purge complete. Deleted products={deleted_products}, pages={deleted_pages}."
)
)

View File

@@ -0,0 +1,44 @@
from __future__ import annotations
from django.core.management.base import BaseCommand
from django.db import transaction
from mandelstudio.idea_marketplace import seed_idea_marketplace_products
class Command(BaseCommand):
help = (
"Seed production-ready Oscar idea products and remove obvious demo products "
"from the catalogue. By default, this also retires non-idea products from public "
"listing."
)
def add_arguments(self, parser):
parser.add_argument(
"--keep-demo-products",
action="store_true",
help="Do not delete demo/sample products.",
)
parser.add_argument(
"--keep-non-idea-products-public",
action="store_true",
help="Do not retire non-idea products from public listing.",
)
@transaction.atomic
def handle(self, *args, **options):
purge_demo = not options["keep_demo_products"]
retire_non_idea = not options["keep_non_idea_products_public"]
stats = seed_idea_marketplace_products(
purge_demo_products=purge_demo,
retire_non_idea_products=retire_non_idea,
)
self.stdout.write(
self.style.SUCCESS(
"Idea marketplace seeded: "
f"created={stats['created']}, "
f"updated={stats['updated']}, "
f"deleted_demo={stats['deleted_demo']}, "
f"retired_non_idea={stats['retired_non_idea']}"
)
)

View File

@@ -0,0 +1,211 @@
from __future__ import annotations
import json
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,
)
from mandelstudio.launch_validation import (
get_checkout_apps,
get_declared_payment_apps,
idea_marketplace_payments_enabled,
validate_payment_provider_config,
)
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")
validate_payment_provider_config()
installed_apps = list(settings.INSTALLED_APPS)
payments_enabled = idea_marketplace_payments_enabled()
payment_apps = get_declared_payment_apps(installed_apps)
checkout_apps = get_checkout_apps()
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(
"demodata" in "".join(ch for ch in str(plugin).lower() if ch.isalnum())
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}, payments_enabled={payments_enabled}."
)
)

View 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", "0096_referenceindex_referenceindex_source_object_and_more"),
]
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")},
},
),
]

View 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)]

View 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"]},
),
]

View File

View 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",
@@ -65,8 +64,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,

View File

@@ -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,58 @@ INSTALLED_APPS = [
"mandelstudio",
] + INSTALLED_APPS
# Never allow demo-data plugins in this production project context.
def _is_demo_data_app(app_label: str) -> bool:
normalized = "".join(ch for ch in app_label.lower() if ch.isalnum())
return "demodata" in normalized
INSTALLED_APPS = [app for app in INSTALLED_APPS if not _is_demo_data_app(app)]
# 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:

86
mandelstudio/sitemaps.py Normal file
View File

@@ -0,0 +1,86 @@
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,
ProductSitemap,
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")

View File

@@ -1,61 +0,0 @@
{% extends "carbasa/headers/header.html" %}
{% load agency_navigation %}
{% block nav %}
<style>
.agency-nav-dropdown .dropdown-menu {
min-width: 16rem;
border-radius: 1rem;
border: 1px solid rgba(15, 23, 42, 0.08);
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.12);
padding: 0.55rem;
}
.agency-nav-dropdown .dropdown-item {
border-radius: 0.75rem;
font-weight: 600;
padding: 0.65rem 0.8rem;
}
.agency-nav-dropdown .dropdown-toggle::after {
margin-left: 0.45rem;
vertical-align: 0.15em;
}
@media (min-width: 992px) {
.agency-nav-dropdown:hover > .dropdown-menu,
.agency-nav-dropdown:focus-within > .dropdown-menu {
display: block;
margin-top: 0;
}
}
</style>
<div class="collapse navbar-collapse menu-bar page-menu-bar" id="navbarSupportedContent">
<div class="brand-wrapper">
{% include 'partials/brand.html' with big=True %}
</div>
{% agency_nav_pages as nav_pages %}
<ul class="navbar-nav">
{% for nav_page in nav_pages %}
{% if nav_page.nav_children %}
<li class="nav-item dropdown agency-nav-dropdown">
<a class="nav-link dropdown-toggle" href="{{ nav_page.url }}" id="agency-nav-{{ nav_page.nav_key }}" role="button" data-bs-toggle="dropdown" aria-expanded="false">
{{ nav_page.title }}
</a>
<ul class="dropdown-menu" aria-labelledby="agency-nav-{{ nav_page.nav_key }}">
{% for child_page in nav_page.nav_children %}
<li>
<a class="dropdown-item" href="{{ child_page.url }}">{{ child_page.title }}</a>
</li>
{% endfor %}
</ul>
</li>
{% else %}
<li class="nav-item child">
<a class="nav-link" href="{{ nav_page.url }}">{{ nav_page.title }}</a>
</li>
{% endif %}
{% endfor %}
</ul>
</div>
{% endblock %}

View File

@@ -0,0 +1,29 @@
{% load i18n %}
<h3 class="text-center">{{ self.title }}</h3>
<hr>
{% if messages %}
<div class="contact-form-messages mb-4" aria-live="polite" aria-atomic="true">
{% for message in messages %}
<div class="alert {% if 'error' in message.tags %}alert-danger{% elif 'success' in message.tags %}alert-success{% elif 'warning' in message.tags %}alert-warning{% else %}alert-info{% endif %}" role="alert">
{{ message }}
</div>
{% endfor %}
</div>
{% endif %}
<form id="contact-form" method="post" action="{% url 'contact_form:contact-form-handler' %}">
{% csrf_token %}
<div class="controls">
<div class="row">
{% for field in form %}
<div class="col-lg-12">
{% include 'oscar/partials/form_field.html' with field=field placeholder=field.field.widget.attrs.placeholder %}
</div>
{% endfor %}
<div class="col-lg-12">
<input type="submit" class="btn btn-primary btn-send" value="{% trans 'Submit' %}">
</div>
</div>
</div>
</form>

View File

@@ -37,6 +37,17 @@
{% include_header header_template|default:"engine/partials/header.html" %}
<div id="main_content" tabindex="-1">
<div class="te-modern-saas">
{% if messages %}
<div class="container mt-4">
<div class="contact-form-messages" aria-live="polite" aria-atomic="true">
{% for message in messages %}
<div class="alert {% if 'error' in message.tags %}alert-danger{% elif 'success' in message.tags %}alert-success{% elif 'warning' in message.tags %}alert-warning{% else %}alert-info{% endif %}" role="alert">
{{ message }}
</div>
{% endfor %}
</div>
</div>
{% endif %}
<main>
{% for block in self.body %}
{% with scope_class=block.block_type|split:"_"|join:"-" %}
@@ -48,9 +59,5 @@
</main>
</div>
</div>
<div class="footer-corners">
{% include "svg/corner.svg" with class="left" %}
{% include "svg/corner.svg" with class="right" %}
</div>
{% include "oxyan/partials/footer.html" %}
{% endblock %}

View File

@@ -37,6 +37,17 @@
{% include_header header_template|default:"engine/partials/header.html" %}
<div id="main_content" tabindex="-1">
<div class="te-modern-saas">
{% if messages %}
<div class="container mt-4">
<div class="contact-form-messages" aria-live="polite" aria-atomic="true">
{% for message in messages %}
<div class="alert {% if 'error' in message.tags %}alert-danger{% elif 'success' in message.tags %}alert-success{% elif 'warning' in message.tags %}alert-warning{% else %}alert-info{% endif %}" role="alert">
{{ message }}
</div>
{% endfor %}
</div>
</div>
{% endif %}
<main class="te-section">
<div class="container">
<h1 class="te-section__heading">{{ self.title }}</h1>
@@ -51,9 +62,5 @@
</main>
</div>
</div>
<div class="footer-corners">
{% include "svg/corner.svg" with class="left" %}
{% include "svg/corner.svg" with class="right" %}
</div>
{% include "oxyan/partials/footer.html" %}
{% endblock %}

View File

@@ -48,9 +48,5 @@
</main>
</div>
</div>
<div class="footer-corners">
{% include "svg/corner.svg" with class="left" %}
{% include "svg/corner.svg" with class="right" %}
</div>
{% include "oxyan/partials/footer.html" %}
{% endblock %}

View File

@@ -0,0 +1 @@
{% include "carbasa/headers/header.html" %}

View File

@@ -0,0 +1,6 @@
{% comment %}
Project-level header override:
force engine pages to render the Carbasa header instead of
the template_engine fallback header.
{% endcomment %}
{% include "carbasa/headers/header.html" %}

View File

@@ -0,0 +1 @@
{% include "carbasa/headers/header.html" %}

View File

@@ -0,0 +1 @@
{% include "carbasa/headers/header.html" %}

View File

@@ -33,7 +33,7 @@
{% endif %}
{% block navbar %}
{% include_header 'oxyan/headers/header.html' %}
{% include "carbasa/headers/header.html" %}
{% endblock %}
{% block content_wrapper %}

View File

@@ -0,0 +1 @@
{% extends "carbasa/headers/header.html" %}

View File

@@ -0,0 +1 @@
{% extends "carbasa/headers/mega.html" %}

View File

@@ -1,39 +1,11 @@
{% load i18n i18n_helpers agency_navigation %}
<style>
.ms-lang-switcher { display: inline-flex; align-items: center; }
.ms-lang-switcher .form-select {
border-radius: 999px;
border: 1px solid #c7d4e9;
background: #ffffff;
color: #0f172a;
font-size: 0.82rem;
font-weight: 600;
line-height: 1.1;
padding: 0.36rem 1.85rem 0.36rem 0.8rem;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
}
.ms-lang-switcher .form-select:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 0.18rem rgba(59, 130, 246, 0.18);
}
.ms-header-cta {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 0.55rem 1rem;
font-size: 0.84rem;
font-weight: 700;
text-decoration: none;
margin-left: 0.5rem;
}
</style>
{% load i18n mandelstudio_i18n %}
{% get_current_language as LANGUAGE_CODE %}
<div class="header-right">
<form action="{% url 'set_language' %}" method="post" class="ms-lang-switcher me-2" aria-label="Language switcher">
{% csrf_token %}
<input name="next" type="hidden" value="{{ request.path|untranslated_url }}">
<input name="next" type="hidden" value="{{ language_neutral_url_path|default:request.path|language_neutral_path }}">
<label for="header-language-switcher" class="visually-hidden">{% trans "Language" %}</label>
<select id="header-language-switcher" name="language" class="form-select form-select-sm" onchange="this.form.submit()">
{% get_current_language as LANGUAGE_CODE %}
<option value="nl" {% if LANGUAGE_CODE == 'nl' %}selected{% endif %}>NL</option>
<option value="en" {% if LANGUAGE_CODE == 'en' %}selected{% endif %}>EN</option>
<option value="de" {% if LANGUAGE_CODE == 'de' %}selected{% endif %}>DE</option>
@@ -48,11 +20,8 @@
<a tabindex="0" aria-label="Open Search" role="search" class="search-toggler user-button menu-circle">
<i class="fa fa-search"></i>
</a>
{% agency_page 'contact' as contact_page %}
{% if contact_page %}
<a href="{{ contact_page.url }}" class="btn btn-primary ms-header-cta">{% agency_primary_cta %}</a>
{% endif %}
<a href="{% url 'customer:summary' %}" aria-label="{% trans 'Customer summary' %}" class="user-button menu-circle"><i class="fa fa-user-solid"></i></a>
{% include "oxyan/headers/partials/mini_basket.html" %}
</div>
<div class="alert-messages-header" aria-live="polite">

View File

@@ -1,6 +1,7 @@
{% load staticfiles %}
{% load wagtailcore_tags wagtailimages_tags wagtailsettings_tags cache %}
{% 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 %}
<style>
@@ -112,7 +113,7 @@
<footer class="footer mb-footer">
<div class="container">
<div class="row g-4">
{% with footer=settings.ocyan_plugin_wagtail.OcyanSettings.footer %}
{% with footer=localized_footer.footer|default:settings.ocyan_plugin_wagtail.OcyanSettings.footer %}
{% for block in footer %}
{% if block.block_type == 'page_list' and block.value.page and not block.value.page.get_children.live.public %}
{% else %}
@@ -132,7 +133,13 @@
<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>

View File

@@ -1,5 +1,5 @@
<!-- MB OVERRIDE -->
{% load i18n static ocyanjson i18n_helpers %}
{% load i18n static ocyanjson mandelstudio_i18n %}
<div class="dropdown language-dropdown">
<button type="button" id="dropdownMenuButton1" data-bs-toggle="dropdown" aria-expanded="false">
@@ -8,9 +8,9 @@
<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 }}"/>
<input name="next" type="hidden" value="{{ language_neutral_url_path|default:request.path|language_neutral_path }}"/>
{% for language in languages %}
{% if language.code not in disabled_languages %}
<li><button class="dropdown-item" type="submit" value="{{language.code}}" name="language">{% include "oxyan/partials/flags/"|add:language.code|add:".svg" %} {{ language.name_local|title }}</button></li>

View File

@@ -0,0 +1,3 @@
<svg viewBox="0 0 500 500" preserveAspectRatio="none" class="corner {{ class }}">
<path class="st0" d="M0,0c166.7,0,333.3,0,500,0c-31.4-0.5-212-0.1-356.5,145C0,289.1-0.5,468.2,0,500C0,333.3,0,166.7,0,0z"/>
</svg>

After

Width:  |  Height:  |  Size: 214 B

View File

@@ -0,0 +1,45 @@
from __future__ import annotations
from django import template
from mandelstudio.idea_marketplace import (
get_idea_full_description,
get_idea_short_description,
get_unlockable_description,
is_idea_product,
user_has_unlocked_idea,
)
register = template.Library()
@register.simple_tag
def idea_is_product(product):
return is_idea_product(product)
@register.simple_tag
def idea_is_unlocked(product, user):
return user_has_unlocked_idea(user, product)
@register.simple_tag
def idea_payments_enabled():
from django.conf import settings
return getattr(settings, "IDEA_MARKETPLACE_PAYMENTS_ENABLED", False)
@register.simple_tag
def idea_short_description(product):
return get_idea_short_description(product)
@register.simple_tag
def idea_full_description(product):
return get_idea_full_description(product)
@register.simple_tag
def idea_description_for_user(product, user):
return get_unlockable_description(product, user)

View File

@@ -0,0 +1,129 @@
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

View File

@@ -0,0 +1,24 @@
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()

View File

@@ -0,0 +1,31 @@
from __future__ import annotations
from django import template
from mandelstudio.i18n_utils import normalize_set_language_next
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"])
@register.filter(name="language_neutral_path")
def language_neutral_path(value: str | None) -> str:
"""Normalize a path for set_language by removing any leading language prefix."""
return normalize_set_language_next(value)

View File

@@ -0,0 +1,72 @@
from django.test import RequestFactory, SimpleTestCase, override_settings
from mandelstudio.i18n_utils import normalize_set_language_next
from mandelstudio.i18n_views import set_language_normalized
@override_settings(
LANGUAGES=(
("nl", "Dutch"),
("en", "English"),
("de", "German"),
("fr", "French"),
("es", "Spanish"),
("it", "Italian"),
("pt", "Portuguese"),
("ru", "Russian"),
)
)
class SetLanguageNormalizationTests(SimpleTestCase):
def setUp(self):
self.factory = RequestFactory()
def test_normalize_set_language_next_strips_single_prefix(self):
self.assertEqual(
normalize_set_language_next("/en/manage/checkout/paymentmethod/"),
"/manage/checkout/paymentmethod/",
)
def test_normalize_set_language_next_strips_locale_variant_prefix(self):
self.assertEqual(
normalize_set_language_next("/en-us/manage/checkout/paymentmethod/"),
"/manage/checkout/paymentmethod/",
)
def test_post_next_is_normalized_before_delegate(self):
request = self.factory.post(
"/i18n/setlang/",
data={"language": "de", "next": "/en/manage/checkout/paymentmethod/"},
)
request.META["HTTP_HOST"] = "testserver"
response = set_language_normalized(request)
self.assertEqual(response.status_code, 302)
self.assertEqual(
response["Location"],
"/de/manage/checkout/paymentmethod/",
)
def test_get_next_is_normalized_before_delegate(self):
request = self.factory.get(
"/i18n/setlang/",
data={"language": "fr", "next": "/de/manage/"},
)
request.META["HTTP_HOST"] = "testserver"
response = set_language_normalized(request)
self.assertEqual(response.status_code, 302)
self.assertEqual(response["Location"], "/manage/")
def test_set_language_view_is_csrf_exempt(self):
request = self.factory.post(
"/i18n/setlang/",
data={"language": "nl", "next": "/en/manage/"},
)
request.csrf_processing_done = False
request.META["HTTP_HOST"] = "testserver"
response = set_language_normalized(request)
self.assertEqual(response.status_code, 302)

View File

@@ -0,0 +1,39 @@
from django.test import SimpleTestCase, override_settings
from mandelstudio.templatetags.mandelstudio_i18n import language_neutral_path
@override_settings(
LANGUAGES=(
("nl", "Dutch"),
("en", "English"),
("de", "German"),
("fr", "French"),
("es", "Spanish"),
("it", "Italian"),
("pt", "Portuguese"),
("ru", "Russian"),
)
)
class LanguageNeutralPathFilterTests(SimpleTestCase):
def test_strips_language_prefix(self):
self.assertEqual(language_neutral_path("/en/manage/"), "/manage/")
self.assertEqual(
language_neutral_path("/fr/manage/checkout/paymentmethod/"),
"/manage/checkout/paymentmethod/",
)
def test_keeps_unprefixed_path(self):
self.assertEqual(language_neutral_path("/manage/"), "/manage/")
self.assertEqual(
language_neutral_path("/manage/checkout/paymentmethod/"),
"/manage/checkout/paymentmethod/",
)
def test_preserves_query_string(self):
self.assertEqual(
language_neutral_path(
"/de/manage/?next=/de/manage/checkout/paymentmethod/"
),
"/manage/?next=/de/manage/checkout/paymentmethod/",
)

View File

@@ -1,13 +1,25 @@
from django.conf.urls.i18n import i18n_patterns
from django.urls import include, path
from django.urls import path
from django.views.decorators.cache import cache_page
from ocyan.main.urls import urlpatterns as ocyan_urlpatterns
from ocyan.plugin.wagtail_oscar_integration.constants import CACHE_DURATION
from .i18n_views import set_language_normalized
from .sitemaps import robots_txt, sitemap_index, sitemap_section
urlpatterns = [
path("i18n/", include("django.conf.urls.i18n")),
path("i18n/setlang/", set_language_normalized, name="set_language"),
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

View File

@@ -7,56 +7,61 @@ set -euo pipefail
AUDIT_TIMEOUT_SECONDS=${AUDIT_TIMEOUT_SECONDS:-300}
ARTIFACT_DIR=${ARTIFACT_DIR:-artifacts}
OUTPUT_JSON=${OUTPUT_JSON:-${ARTIFACT_DIR}/multilingual-audit.json}
OUT_FILE="${ARTIFACT_DIR}/multilingual-audit.json"
mkdir -p "${ARTIFACT_DIR}"
TMP_FILE=$(mktemp)
trap 'rm -f "$TMP_FILE"' EXIT
REMOTE_CMD="cd '${STAGING_AUDIT_PROJECT_DIR}' && '${STAGING_AUDIT_MANAGE}' audit_locales --format=json"
set +e
STAGING_AUDIT_PROJECT_NAME="$STAGING_AUDIT_PROJECT_NAME" REMOTE_CMD="$REMOTE_CMD" AUDIT_TIMEOUT_SECONDS="$AUDIT_TIMEOUT_SECONDS" python3 - <<'PY2' > "$TMP_FILE"
STAGING_AUDIT_PROJECT_NAME="$STAGING_AUDIT_PROJECT_NAME" REMOTE_CMD="$REMOTE_CMD" AUDIT_TIMEOUT_SECONDS="$AUDIT_TIMEOUT_SECONDS" python3 - <<'PY' > "$TMP_FILE"
import json
import os
import subprocess
import sys
project = os.environ["STAGING_AUDIT_PROJECT_NAME"]
remote_cmd = os.environ["REMOTE_CMD"]
timeout_seconds = int(os.environ["AUDIT_TIMEOUT_SECONDS"])
cmd = [
"sudo", "-n", "-u", "mandel", "-g", "www-data",
"/srv/apps/mandel-dashboard/.venv/bin/python",
"/srv/apps/mandel-dashboard/bin/deploy_stg_from_jenkins.py",
project,
os.environ["STAGING_AUDIT_PROJECT_NAME"],
"--command",
remote_cmd,
os.environ["REMOTE_CMD"],
]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout_seconds, check=False)
proc = subprocess.run(
cmd,
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=int(os.environ["AUDIT_TIMEOUT_SECONDS"]),
)
except subprocess.TimeoutExpired:
print(json.dumps({
"error": "audit_failed",
"details": f"Audit command timed out after {timeout_seconds} seconds",
"details": f"Audit command timed out after {os.environ['AUDIT_TIMEOUT_SECONDS']} seconds",
"exit_code": 124,
}, indent=2))
sys.exit(2)
stdout = result.stdout.strip()
stderr = result.stderr.strip()
if result.returncode != 0:
raise SystemExit(2)
stdout = proc.stdout.strip()
stderr = proc.stderr.strip()
if proc.returncode != 0:
if stdout:
print(stdout)
else:
print(json.dumps({
"error": "audit_failed",
"details": stderr or f"Audit command failed with exit status {result.returncode}",
"exit_code": result.returncode,
"details": stderr or f"Audit command failed with exit status {proc.returncode}",
"exit_code": proc.returncode,
}, indent=2))
sys.exit(2)
raise SystemExit(2)
print(stdout)
PY2
status=$?
PY
rc=$?
set -e
cp "$TMP_FILE" "$OUTPUT_JSON"
cat "$OUTPUT_JSON"
exit $status
cp "$TMP_FILE" "$OUT_FILE"
cat "$OUT_FILE"
exit $rc

View File

@@ -0,0 +1,32 @@
#!/usr/bin/env python3
from __future__ import annotations
import os
import sys
from pathlib import Path
def main() -> int:
project_root = Path(__file__).resolve().parents[1]
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mandelstudio.settings.base")
os.chdir(project_root)
if str(project_root) not in sys.path:
sys.path.insert(0, str(project_root))
from mandelstudio.main import _patch_legacy_django_translation_aliases
_patch_legacy_django_translation_aliases()
import django
django.setup()
from mandelstudio.launch_validation import validate_payment_provider_config
validate_payment_provider_config()
print("Payment provider configuration validation passed.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -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:

View File

@@ -0,0 +1,148 @@
{% 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 %}
<style>
.mb-footer-wrap {
margin-top: clamp(2rem, 4vw, 3.5rem);
position: relative;
}
.mb-footer {
position: relative;
background:
radial-gradient(120% 120% at 0% 0%, rgba(84, 149, 230, .22) 0%, rgba(84, 149, 230, 0) 45%),
radial-gradient(90% 120% at 100% 0%, rgba(65, 206, 186, .16) 0%, rgba(65, 206, 186, 0) 45%),
linear-gradient(180deg, #264f72 0%, #203f5c 100%);
border-radius: 28px 28px 0 0;
padding: clamp(2rem, 4vw, 3rem) 0;
box-shadow:
inset 0 1px 0 rgba(255,255,255,.12),
0 -10px 24px rgba(20, 43, 72, .20);
overflow: hidden;
}
.mb-footer:before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(90deg, rgba(255,255,255,.07) 0%, rgba(255,255,255,0) 35%, rgba(255,255,255,.06) 100%);
pointer-events: none;
}
.mb-footer .footer_column {
padding-top: .75rem;
padding-bottom: .75rem;
}
.mb-footer .mb-footer__card {
height: 100%;
background: rgba(255, 255, 255, .055);
border: 1px solid rgba(255, 255, 255, .14);
border-radius: 16px;
padding: 1.1rem 1.15rem;
backdrop-filter: blur(1.2px);
}
.mb-footer .footer_header {
font-size: 1.05rem;
font-weight: 700;
letter-spacing: .01em;
margin-bottom: .9rem;
color: #f4f8ff;
}
.mb-footer .footer_column,
.mb-footer .footer_column * {
color: rgba(237, 244, 255, .93);
}
.mb-footer .footer_column a {
color: #eef4ff;
text-decoration: none;
transition: color .2s ease, transform .2s ease;
}
.mb-footer .footer_column a:hover {
color: #ffffff;
transform: translateX(2px);
}
.mb-footer .footer_column .rich-text p {
margin-bottom: .65rem;
line-height: 1.65;
max-width: 34ch;
}
.mb-footer .mb-footer__card .aboutus-logo {
max-height: 52px;
width: auto;
}
.mb-footer .mb-footer__card .social {
margin-top: 1rem;
}
.mb-footer .mb-footer__card .social a {
border-color: rgba(255, 255, 255, .42);
color: #ffffff;
background: rgba(255,255,255,.08);
}
.mb-footer .mb-footer__card .social a:hover {
background: rgba(255,255,255,.18);
}
.mb-copyright {
background: #1b3650;
padding: 1rem 0;
border-top: 1px solid rgba(255,255,255,.16);
}
.mb-copyright .copyright_block,
.mb-copyright .copyright_block * {
color: rgba(234, 241, 255, .92);
margin: 0;
font-size: .95rem;
}
.mb-copyright .copyright_block a {
color: #ffffff;
text-decoration: none;
}
.mb-copyright .copyright_block a:hover {
text-decoration: underline;
}
@media (max-width: 991.98px) {
.mb-footer {
border-radius: 20px 20px 0 0;
}
.mb-footer .mb-footer__card {
padding: 1rem;
}
}
</style>
<div class="mb-footer-wrap">
<footer class="footer mb-footer">
<div class="container">
<div class="row g-4">
{% with footer=localized_footer.footer|default:settings.ocyan_plugin_wagtail.OcyanSettings.footer %}
{% for block in footer %}
{% if block.block_type == 'page_list' and block.value.page and not block.value.page.get_children.live.public %}
{% else %}
<div class="{% if footer|length == 3 %}col-lg-4{% elif footer|length == 2 %}col-lg-6{% else %}col-lg-3{% endif %} col-md-6 col-sm-12 footer_column {{ block.block_type|slugify }}">
<div class="mb-footer__card">
{% include_block block %}
</div>
</div>
{% endif %}
{% endfor %}
{% endwith %}
</div>
</div>
</footer>
<section class="copyright_wrapper mb-copyright">
<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>
</div>
{% endcache %}