82 Commits

Author SHA1 Message Date
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
e4c6e3dcef Add launch pipeline and idea marketplace seed commands 2026-04-09 00:29:23 +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
095248277e Route engine header partial to Carbasa header 2026-04-03 22:13:12 +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
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
215297ef41 Style shared richtext blocks via project template override 2026-04-03 01:27:36 +02:00
4b6581c7fe Fix NL go-live legal, SEO, and footer foundations 2026-04-02 17:55:39 +02:00
b0d8a96b76 Replace demo copy and imagery in agency content 2026-04-01 01:54:28 +02:00
02f3007e9e Open services dropdown on hover 2026-04-01 01:29:20 +02:00
d75db13a5a Restore services dropdown in agency header 2026-03-31 04:47:44 +02:00
820096647b Format CTA density cleanup 2026-03-31 04:06:52 +02:00
a9ab4a9518 Reduce CTA density across agency pages 2026-03-31 04:03:23 +02:00
4ffe6adf0a Document devpi release flow and stable fallback 2026-03-31 03:44:44 +02:00
80d8477ba8 Use published template engine package release 2026-03-31 01:47:16 +02:00
138a9644be Use git credential for pinned template engine install 2026-03-31 01:13:58 +02:00
d581b1a348 Pin template engine plugin to internal link fix 2026-03-31 01:11:02 +02:00
eef11801a6 Roll out agency content parity across locales 2026-03-31 00:29:01 +02:00
582efd017d Fix agency site import ordering for CI 2026-03-30 18:35:01 +02:00
9059cd28ae Format agency site refresh command and nav tags 2026-03-30 18:32:15 +02:00
0baae1dbe6 Clean agency navigation and refresh core site content 2026-03-30 18:27:51 +02:00
ebde2806c1 Run nightly checkout on built-in node 2026-03-30 00:11:45 +02:00
3f5d5b637b Use deploy entrypoint for multilingual audit 2026-03-30 00:03:52 +02:00
b9d9a7e88e Run salt audit through dashboard sudo entrypoint 2026-03-29 23:19:04 +02:00
9da7b5cc7d Always archive multilingual audit failure output 2026-03-29 23:16:15 +02:00
dd01f7dd9a Run multilingual audit via serverpillar salt 2026-03-29 23:12:58 +02:00
2931eedf22 Use staging hostname for multilingual audit 2026-03-29 21:47:45 +02:00
e77479f87a Fix Jenkins multilingual audit stage checkout 2026-03-29 21:41:15 +02:00
ebd57a4376 Run multilingual audit stages on built-in Jenkins node 2026-03-29 21:34:31 +02:00
fb6f2e861d Fix import ordering for multilingual CI lint 2026-03-29 21:28:31 +02:00
51b2fd574c Format multilingual audit extraction for CI lint 2026-03-29 21:25:37 +02:00
c516d72c8a Document multilingual audit CI operations 2026-03-29 20:58:34 +02:00
e3bafd3a73 Add multilingual audit CI pipeline + extract mandelblog_content_guard 2026-03-29 20:50:21 +02:00
MandelBot
643aca26d0 Localize shared marketing templates by locale 2026-03-24 21:48:51 +00:00
ca06ab88ba Polish footer UI and localize demo-request form endpoints 2026-03-23 00:29:12 +01:00
Mandel Dashboard
d2adda383e Enable ocyan.plugin.wordspinner 2026-03-19 22:46:16 +00:00
46 changed files with 1464 additions and 212 deletions

30
Jenkinsfile vendored
View File

@@ -28,7 +28,11 @@ pipeline {
sh ''' sh '''
export GIT_SSH_COMMAND="ssh -i $GIT_KEYFILE -o StrictHostKeyChecking=accept-new" export GIT_SSH_COMMAND="ssh -i $GIT_KEYFILE -o StrictHostKeyChecking=accept-new"
if [ -d .git ]; then if [ -d .git ]; then
git remote set-url origin ssh://git@git.mandelblog.com:2222/salt/mandelstudio.git 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 git fetch --tags --force --progress origin +refs/heads/master:refs/remotes/origin/master
else else
git clone ssh://git@git.mandelblog.com:2222/salt/mandelstudio.git . git clone ssh://git@git.mandelblog.com:2222/salt/mandelstudio.git .
@@ -141,10 +145,26 @@ PY
timeout(time: 10, unit: 'MINUTES') timeout(time: 10, unit: 'MINUTES')
} }
steps { steps {
deleteDir() withCredentials([sshUserPrivateKey(credentialsId: 'gitea-ssh', keyFileVariable: 'GIT_KEYFILE')]) {
checkout scm sh '''
sh 'mkdir -p artifacts && chmod +x scripts/run_remote_multilingual_audit.sh' export GIT_SSH_COMMAND="ssh -i $GIT_KEYFILE -o StrictHostKeyChecking=accept-new"
sh './scripts/run_remote_multilingual_audit.sh' 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 { script {
int status = sh(script: 'python3 scripts/multilingual_audit_ci.py --json artifacts/multilingual-audit.json', returnStatus: true) int status = sh(script: 'python3 scripts/multilingual_audit_ci.py --json artifacts/multilingual-audit.json', returnStatus: true)
if (status == 2) { if (status == 2) {

View File

@@ -10,19 +10,23 @@ pipeline {
skipDefaultCheckout(true) skipDefaultCheckout(true)
} }
environment { environment {
STAGING_AUDIT_HOST = 'root@49.12.204.96' STAGING_AUDIT_PROJECT_NAME = 'mandelstudio'
STAGING_AUDIT_PROJECT_DIR = '/home/www-mandelstudio/mandelstudio' STAGING_AUDIT_PROJECT_DIR = '/home/www-mandelstudio/mandelstudio'
STAGING_AUDIT_MANAGE = '/var/lib/virtualenv/mandelstudio/bin/manage.py' STAGING_AUDIT_MANAGE = '/var/lib/virtualenv/mandelstudio/bin/manage.py'
STAGING_AUDIT_SSH_CREDENTIALS_ID = 'staging-root-ssh'
} }
stages { stages {
stage('Checkout') { stage('Checkout') {
agent { label 'built-in' }
steps { steps {
withCredentials([sshUserPrivateKey(credentialsId: 'gitea-ssh', keyFileVariable: 'GIT_KEYFILE')]) { withCredentials([sshUserPrivateKey(credentialsId: 'gitea-ssh', keyFileVariable: 'GIT_KEYFILE')]) {
sh ''' sh '''
export GIT_SSH_COMMAND="ssh -i $GIT_KEYFILE -o StrictHostKeyChecking=accept-new" export GIT_SSH_COMMAND="ssh -i $GIT_KEYFILE -o StrictHostKeyChecking=accept-new"
if [ -d .git ]; then if [ -d .git ]; then
git remote set-url origin ssh://git@git.mandelblog.com:2222/salt/mandelstudio.git 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 git fetch --tags --force --progress origin +refs/heads/master:refs/remotes/origin/master
else else
git clone ssh://git@git.mandelblog.com:2222/salt/mandelstudio.git . git clone ssh://git@git.mandelblog.com:2222/salt/mandelstudio.git .
@@ -40,9 +44,7 @@ pipeline {
} }
steps { steps {
sh 'mkdir -p artifacts && [ -f artifacts/multilingual-audit.json ] && cp artifacts/multilingual-audit.json artifacts/previous-multilingual-audit.json || true' sh 'mkdir -p artifacts && [ -f artifacts/multilingual-audit.json ] && cp artifacts/multilingual-audit.json artifacts/previous-multilingual-audit.json || true'
withCredentials([sshUserPrivateKey(credentialsId: env.STAGING_AUDIT_SSH_CREDENTIALS_ID, keyFileVariable: 'STAGING_SSH_KEYFILE')]) { sh './scripts/run_remote_multilingual_audit.sh'
sh './scripts/run_remote_multilingual_audit.sh'
}
script { 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) int status = sh(script: 'python3 scripts/multilingual_audit_ci.py --json artifacts/multilingual-audit.json --previous-json artifacts/previous-multilingual-audit.json', returnStatus: true)
if (status == 2) { if (status == 2) {

View File

@@ -0,0 +1,68 @@
## Devpi Release Flow
### Current state
- `mandel/testing` is the active package source for MandelBlog project builds.
- `ocyan.plugin.template_engine==0.2.12` is published there and is the current production-safe version.
- `mandel/stable` is not available yet.
This means production is intentionally running from the testing index for now, to avoid breaking installs while the stable index is not provisioned.
### Index roles
- `mandel/testing`
- pre-production and current fallback source
- currently also the active production source until stable exists
- `mandel/stable`
- intended production index
- not yet provisioned
### Promotion flow
When `mandel/stable` exists, promote existing artifacts without rebuilding:
```bash
devpi use https://pypi.mandelblog.com/mandel/testing
devpi login mandel
devpi push ocyan-plugin-template-engine==0.2.12 mandel/stable
```
### Admin prerequisite
Promotion requires a devpi admin to create the production index and grant upload or push permissions.
Recommended admin setup:
```bash
devpi index -c mandel/stable bases=root/pypi volatile=False acl_upload=mandel,Mandel-publish
```
### Planned stable-first install order
Do not enable this until `mandel/stable` exists:
```bash
PIP_INDEX_URL=https://pypi.mandelblog.com/mandel/stable/+simple/
PIP_EXTRA_INDEX_URL=https://pypi.mandelblog.com/mandel/testing/+simple/
```
### CI behavior
- If the stable index is missing, Jenkins logs:
- `devpi stable index not available, using testing as production source`
- The build does not fail because of the missing stable index.
- Installs continue from `mandel/testing`.
### Validation checklist
After stable becomes available and promotion is done:
1. confirm both wheel and sdist are visible in the stable simple index
2. switch MandelStudio to stable-first
3. run Jenkins build and deploy
4. verify installed version is still `0.2.12`
5. recheck editor validation for:
- `/contact/`
- `/diensten/`
- `#demo`
- absolute URLs

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" default_auto_field = "django.db.models.BigAutoField"
name = "mandelstudio" name = "mandelstudio"
verbose_name = "Mandelstudio" verbose_name = "Mandelstudio"
def ready(self):
from .admin_fixes import patch_invoice_admin
patch_invoice_admin()

View File

@@ -2,7 +2,6 @@ from __future__ import annotations
from typing import Iterable from typing import Iterable
DEMO_MARKERS: tuple[str, ...] = ( DEMO_MARKERS: tuple[str, ...] = (
"demo", "demo",
"dummy", "dummy",

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

@@ -11,7 +11,6 @@ from oscar.core.loading import get_model
from mandelstudio.content_hygiene import DEMO_MARKERS from mandelstudio.content_hygiene import DEMO_MARKERS
IDEA_PRODUCT_CLASS_NAME = "Idea Product" IDEA_PRODUCT_CLASS_NAME = "Idea Product"
DIGITAL_IDEAS_CATEGORY_NAME = "Digital Ideas" DIGITAL_IDEAS_CATEGORY_NAME = "Digital Ideas"
SHORT_DESCRIPTION_ATTRIBUTE_CODE = "short_description" SHORT_DESCRIPTION_ATTRIBUTE_CODE = "short_description"
@@ -211,7 +210,9 @@ def _get_attribute_text(product, code: str) -> str:
def _set_attribute_text(product, attribute, text: str) -> None: def _set_attribute_text(product, attribute, text: str) -> None:
ProductAttributeValue = get_model("catalogue", "ProductAttributeValue") ProductAttributeValue = get_model("catalogue", "ProductAttributeValue")
value_field = ( value_field = (
"value_richtext" if getattr(attribute, "type", "text") == "richtext" else "value_text" "value_richtext"
if getattr(attribute, "type", "text") == "richtext"
else "value_text"
) )
value, _created = ProductAttributeValue.objects.get_or_create( value, _created = ProductAttributeValue.objects.get_or_create(
product=product, product=product,
@@ -240,7 +241,9 @@ def get_idea_full_description(product) -> str:
def get_unlockable_description(product, user) -> tuple[str, bool]: def get_unlockable_description(product, user) -> tuple[str, bool]:
unlocked = user_has_unlocked_idea(user, product) unlocked = user_has_unlocked_idea(user, product)
if unlocked: if unlocked:
return get_idea_full_description(product) or get_idea_short_description(product), True return get_idea_full_description(product) or get_idea_short_description(
product
), True
return get_idea_short_description(product), False return get_idea_short_description(product), False
@@ -261,7 +264,9 @@ def user_has_unlocked_idea(user, product) -> bool:
"delayed-payment", "delayed-payment",
} }
paid_statuses = { paid_statuses = {
status.strip().lower() for status in paid_statuses if isinstance(status, str) and status.strip() status.strip().lower()
for status in paid_statuses
if isinstance(status, str) and status.strip()
} }
status_match = Line.objects.filter( status_match = Line.objects.filter(
@@ -398,7 +403,9 @@ def seed_idea_marketplace_products(
) )
if hasattr(Product, "STANDALONE") and hasattr(product, "structure"): if hasattr(Product, "STANDALONE") and hasattr(product, "structure"):
product.structure = Product.STANDALONE product.structure = Product.STANDALONE
if hasattr(product, "is_public") and not getattr(product, "is_public", False): if hasattr(product, "is_public") and not getattr(
product, "is_public", False
):
product.is_public = True product.is_public = True
product.save() product.save()
created += 1 created += 1
@@ -413,7 +420,9 @@ def seed_idea_marketplace_products(
if hasattr(product, "slug") and product.slug != slugify(item.title): if hasattr(product, "slug") and product.slug != slugify(item.title):
product.slug = slugify(item.title) product.slug = slugify(item.title)
dirty_fields.append("slug") dirty_fields.append("slug")
if hasattr(product, "is_public") and not getattr(product, "is_public", False): if hasattr(product, "is_public") and not getattr(
product, "is_public", False
):
product.is_public = True product.is_public = True
dirty_fields.append("is_public") dirty_fields.append("is_public")
if dirty_fields: if dirty_fields:
@@ -431,7 +440,9 @@ def seed_idea_marketplace_products(
demo_filter = Q() demo_filter = Q()
for marker in DEMO_MARKERS: for marker in DEMO_MARKERS:
demo_filter |= Q(title__icontains=marker) | Q(slug__icontains=marker) demo_filter |= Q(title__icontains=marker) | Q(slug__icontains=marker)
demo_queryset = Product.objects.filter(demo_filter).exclude(title__in=keep_titles) demo_queryset = Product.objects.filter(demo_filter).exclude(
title__in=keep_titles
)
# Also purge any non-canonical products lingering in the Idea Product class # Also purge any non-canonical products lingering in the Idea Product class
# or explicitly grouped under the Digital Ideas category. # or explicitly grouped under the Digital Ideas category.
non_canonical_ideas_queryset = ( non_canonical_ideas_queryset = (

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

@@ -3,13 +3,13 @@ from __future__ import annotations
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.db import transaction from django.db import transaction
from django.db.models import Q from django.db.models import Q
from wagtail.blocks import StreamValue from wagtail.blocks import StreamValue
from wagtail.models import Page from wagtail.models import Page
from mandelstudio.content_hygiene import BLOCKED_DEMO_PAGE_SLUGS, DEMO_MARKERS from mandelstudio.content_hygiene import BLOCKED_DEMO_PAGE_SLUGS, DEMO_MARKERS
from mandelstudio.idea_marketplace import seed_idea_marketplace_products from mandelstudio.idea_marketplace import seed_idea_marketplace_products
HOME_COPY = { HOME_COPY = {
"nl": { "nl": {
"badge": "IDEA MARKETPLACE", "badge": "IDEA MARKETPLACE",
@@ -115,9 +115,7 @@ def _update_homepage_stream(page) -> bool:
return False return False
page.body = StreamValue(page.body.stream_block, stream_data, is_lazy=True) page.body = StreamValue(page.body.stream_block, stream_data, is_lazy=True)
page.search_description = ( page.search_description = "Idea marketplace with premium plans. Preview each strategy and unlock full implementation after purchase."
"Idea marketplace with premium plans. Preview each strategy and unlock full implementation after purchase."
)
page.save() page.save()
return True return True
@@ -131,7 +129,9 @@ def _purge_demo_pages() -> int:
| Q(search_description__icontains=marker) | Q(search_description__icontains=marker)
) )
candidate_ids = set( candidate_ids = set(
Page.objects.exclude(depth__lte=2).filter(marker_filter).values_list("id", flat=True) Page.objects.exclude(depth__lte=2)
.filter(marker_filter)
.values_list("id", flat=True)
) )
candidate_ids.update( candidate_ids.update(
Page.objects.exclude(depth__lte=2) Page.objects.exclude(depth__lte=2)

View File

@@ -4,9 +4,10 @@ from typing import Iterable
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.db.models import Q from django.db.models import Q
from oscar.core.loading import get_model
from wagtail.models import Page from wagtail.models import Page
from oscar.core.loading import get_model
IDEA_PRODUCT_TITLES = { IDEA_PRODUCT_TITLES = {
"B2B Webshop Starter Blueprint", "B2B Webshop Starter Blueprint",
@@ -73,7 +74,9 @@ class Command(BaseCommand):
top_level_products = Product.objects.filter(parent__isnull=True) top_level_products = Product.objects.filter(parent__isnull=True)
if keep_only_ideas: if keep_only_ideas:
products_to_delete = top_level_products.exclude(title__in=IDEA_PRODUCT_TITLES) products_to_delete = top_level_products.exclude(
title__in=IDEA_PRODUCT_TITLES
)
else: else:
products_to_delete = top_level_products.filter(product_filter).exclude( products_to_delete = top_level_products.filter(product_filter).exclude(
title__in=IDEA_PRODUCT_TITLES title__in=IDEA_PRODUCT_TITLES
@@ -83,13 +86,17 @@ class Command(BaseCommand):
Page.objects.live() Page.objects.live()
.public() .public()
.filter(depth__gt=2) .filter(depth__gt=2)
.filter(Q(slug__in=DEMO_PAGE_SLUGS) | _build_demo_text_filter(("title", "slug"))) .filter(
Q(slug__in=DEMO_PAGE_SLUGS) | _build_demo_text_filter(("title", "slug"))
)
) )
product_preview = list(products_to_delete.values_list("id", "title")[:30]) product_preview = list(products_to_delete.values_list("id", "title")[:30])
page_preview = list(pages_to_delete.values_list("id", "slug", "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()}") self.stdout.write(
f"Products matched for deletion: {products_to_delete.count()}"
)
for item in product_preview: for item in product_preview:
self.stdout.write(f" - product#{item[0]}: {item[1]}") self.stdout.write(f" - product#{item[0]}: {item[1]}")
if products_to_delete.count() > len(product_preview): if products_to_delete.count() > len(product_preview):
@@ -102,7 +109,9 @@ class Command(BaseCommand):
self.stdout.write(" - ...") self.stdout.write(" - ...")
if dry_run: if dry_run:
self.stdout.write(self.style.WARNING("Dry run completed. No data was deleted.")) self.stdout.write(
self.style.WARNING("Dry run completed. No data was deleted.")
)
return return
deleted_products = products_to_delete.count() deleted_products = products_to_delete.count()

View File

@@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
import json import json
import os
from pathlib import Path from pathlib import Path
from django.conf import settings from django.conf import settings
@@ -17,6 +16,12 @@ from mandelstudio.idea_marketplace import (
IDEA_PRODUCTS, IDEA_PRODUCTS,
SHORT_DESCRIPTION_ATTRIBUTE_CODE, 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): class Command(BaseCommand):
@@ -32,53 +37,23 @@ class Command(BaseCommand):
StockRecord = get_model("partner", "StockRecord") StockRecord = get_model("partner", "StockRecord")
Page = get_model("wagtailcore", "Page") Page = get_model("wagtailcore", "Page")
validate_payment_provider_config()
installed_apps = list(settings.INSTALLED_APPS) installed_apps = list(settings.INSTALLED_APPS)
payment_apps = [app for app in installed_apps if "payment" in app.lower()] payments_enabled = idea_marketplace_payments_enabled()
checkout_apps = [app for app in installed_apps if "checkout" in app.lower()] payment_apps = get_declared_payment_apps(installed_apps)
if not payment_apps: checkout_apps = get_checkout_apps()
raise CommandError("No payment app found in INSTALLED_APPS.")
if not checkout_apps:
raise CommandError("No checkout app found in INSTALLED_APPS.")
if not any("oscar_checkout" in app.lower() for app in checkout_apps):
raise CommandError("Oscar checkout app is not active.")
def _is_demo_data(value: str) -> bool:
normalized = "".join(ch for ch in str(value).lower() if ch.isalnum())
return "demodata" in normalized
if any(_is_demo_data(app) for app in installed_apps):
raise CommandError(
"Demo data plugin detected in INSTALLED_APPS. Remove all demodata plugins before launch."
)
if any("dummy" in app.lower() for app in payment_apps):
raise CommandError(
"Dummy payment app detected in INSTALLED_APPS. Use a real provider plugin before production launch."
)
if any("mollie" in app.lower() for app in payment_apps):
mollie_settings = (
getattr(settings, "PAYMENT_MOLLIE", None)
or getattr(settings, "payment_mollie", None)
or {}
)
config_key = str(mollie_settings.get("api_key", "")).strip()
env_key = str(os.environ.get("MOLLIE_API_KEY", "")).strip()
effective_key = env_key or config_key
if not effective_key or effective_key.upper() == "CHANGE_ME":
raise CommandError(
"Mollie payment provider is enabled but no valid API key is configured. "
"Set MOLLIE_API_KEY or settings.payment_mollie.api_key to a real key."
)
if not effective_key.startswith("live_"):
raise CommandError(
"Mollie key must be a live key for production launch (expected prefix 'live_')."
)
config_path = Path(__file__).resolve().parents[2] / "ocyan.json" config_path = Path(__file__).resolve().parents[2] / "ocyan.json"
if config_path.exists(): if config_path.exists():
with config_path.open("r", encoding="utf-8") as handle: with config_path.open("r", encoding="utf-8") as handle:
config_payload = json.load(handle) config_payload = json.load(handle)
config_plugins = [str(plugin) for plugin in config_payload.get("ocyan_plugins", [])] config_plugins = [
if any(_is_demo_data(plugin) for plugin in 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( raise CommandError(
"Demo data plugin detected in ocyan.json. Remove it before launch." "Demo data plugin detected in ocyan.json. Remove it before launch."
) )
@@ -100,7 +75,9 @@ class Command(BaseCommand):
if currency != "EUR": if currency != "EUR":
raise CommandError(f"OSCAR_DEFAULT_CURRENCY must be EUR, got '{currency}'.") raise CommandError(f"OSCAR_DEFAULT_CURRENCY must be EUR, got '{currency}'.")
product_class = ProductClass.objects.filter(name=IDEA_PRODUCT_CLASS_NAME).first() product_class = ProductClass.objects.filter(
name=IDEA_PRODUCT_CLASS_NAME
).first()
if product_class is None: if product_class is None:
raise CommandError(f"Missing ProductClass '{IDEA_PRODUCT_CLASS_NAME}'.") raise CommandError(f"Missing ProductClass '{IDEA_PRODUCT_CLASS_NAME}'.")
if product_class.requires_shipping: if product_class.requires_shipping:
@@ -123,12 +100,14 @@ class Command(BaseCommand):
found_titles = set(found_products.values_list("title", flat=True)) found_titles = set(found_products.values_list("title", flat=True))
missing_titles = sorted(expected_titles - found_titles) missing_titles = sorted(expected_titles - found_titles)
if missing_titles: if missing_titles:
raise CommandError(f"Missing seeded idea products: {', '.join(missing_titles)}.") raise CommandError(
f"Missing seeded idea products: {', '.join(missing_titles)}."
)
non_public_idea_titles = list( non_public_idea_titles = list(
found_products.filter(title__in=expected_titles, is_public=False).values_list( found_products.filter(
"title", flat=True title__in=expected_titles, is_public=False
) ).values_list("title", flat=True)
) )
if non_public_idea_titles: if non_public_idea_titles:
raise CommandError( raise CommandError(
@@ -152,9 +131,7 @@ class Command(BaseCommand):
missing_stockrecords: list[str] = [] missing_stockrecords: list[str] = []
for product in found_products.filter(title__in=expected_titles): for product in found_products.filter(title__in=expected_titles):
stockrecord = ( stockrecord = (
StockRecord.objects.filter(product=product) StockRecord.objects.filter(product=product).order_by("id").first()
.order_by("id")
.first()
) )
if stockrecord is None: if stockrecord is None:
missing_stockrecords.append(product.title) missing_stockrecords.append(product.title)
@@ -217,7 +194,9 @@ class Command(BaseCommand):
.values_list("title", "slug")[:10] .values_list("title", "slug")[:10]
) )
if live_demo_pages: if live_demo_pages:
formatted = ", ".join(f"{title} ({slug})" for title, slug in live_demo_pages) formatted = ", ".join(
f"{title} ({slug})" for title, slug in live_demo_pages
)
raise CommandError( raise CommandError(
"Demo-like pages are still live/public. Purge them before launch. " "Demo-like pages are still live/public. Purge them before launch. "
f"Examples: {formatted}" f"Examples: {formatted}"
@@ -227,6 +206,6 @@ class Command(BaseCommand):
self.style.SUCCESS( self.style.SUCCESS(
"Idea marketplace launch validation passed: " "Idea marketplace launch validation passed: "
f"{len(found_titles)} products, EUR currency, checkout apps={checkout_apps}, " f"{len(found_titles)} products, EUR currency, checkout apps={checkout_apps}, "
f"payment apps={payment_apps}." f"payment apps={payment_apps}, payments_enabled={payments_enabled}."
) )
) )

View File

@@ -11,7 +11,7 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
("wagtailcore", "0097_alter_page_locale_alter_page_translation_key"), ("wagtailcore", "0096_referenceindex_referenceindex_source_object_and_more"),
] ]
operations = [ operations = [

View File

@@ -31,7 +31,8 @@
"ocyan.plugin.wagtail_content_page", "ocyan.plugin.wagtail_content_page",
"ocyan.plugin.wagtail_forms", "ocyan.plugin.wagtail_forms",
"ocyan.plugin.wagtail_oscar_integration", "ocyan.plugin.wagtail_oscar_integration",
"ocyan.plugin.roadrunner_highlight_slider" "ocyan.plugin.roadrunner_highlight_slider",
"ocyan.plugin.wordspinner"
], ],
"settings": { "settings": {
"cookie_jar": { "cookie_jar": {

View File

@@ -9,6 +9,7 @@ https://docs.djangoproject.com/en/2.0/ref/settings/
""" """
import importlib.util import importlib.util
import os
import sys import sys
from pathlib import Path from pathlib import Path
@@ -20,13 +21,26 @@ BASE_DIR = str(BASE_PATH)
setup_search_paths("/etc/ocyan/", str(_project_app_path)) setup_search_paths("/etc/ocyan/", str(_project_app_path))
from ocyan.main.settings import * # pylint:disable=W0401,W0614 from ocyan.main.settings import * # noqa: F403,F405 # pylint:disable=W0401,W0614 # pyright: ignore[reportWildcardImportFromLibrary]
from ocyan.core.fender import config as ocyan_config
# Keep existing database schemas stable across Django upgrades.
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
INSTALLED_APPS = [ INSTALLED_APPS = [
"mandelblog_content_guard.apps.MandelblogContentGuardConfig", "mandelblog_content_guard.apps.MandelblogContentGuardConfig",
"mandelstudio", "mandelstudio",
] + INSTALLED_APPS ] + 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 # Route through the project URL layer so MandelStudio can override
# sitemap/robots behavior while still delegating the main Ocyan routes. # sitemap/robots behavior while still delegating the main Ocyan routes.
ROOT_URLCONF = "mandelstudio.urls" ROOT_URLCONF = "mandelstudio.urls"
@@ -51,6 +65,25 @@ _ensure_required_app(
"ocyan.plugin.coyote", "ocyan.plugin.coyote",
) )
def _ensure_installed_app(app_label: str, *, before: str | None = None) -> None:
"""Ensure an app is present in INSTALLED_APPS with optional ordering."""
if app_label in INSTALLED_APPS:
INSTALLED_APPS.remove(app_label)
if before and before in INSTALLED_APPS:
INSTALLED_APPS.insert(INSTALLED_APPS.index(before), app_label)
else:
INSTALLED_APPS.append(app_label)
# Production-clean: prefer the Carbasa webshop template package when Oscar is enabled.
# This guarantees that `carbasa/headers/*` resolves to the webshop templates (search, user bar, cart, megamenu).
_oscar_enabled = ocyan_config.is_webshop or any(
app == "ocyan.plugin.oscar" or app.startswith("ocyan.plugin.oscar_")
for app in INSTALLED_APPS
)
if _oscar_enabled and importlib.util.find_spec("ocyan.plugin.carbasa.webshop"):
_ensure_installed_app("ocyan.plugin.carbasa.webshop", before="ocyan.plugin.carbasa")
# Keep Carbasa/Coyote defaults stable even when plugin settings are not # Keep Carbasa/Coyote defaults stable even when plugin settings are not
# injected early enough during startup on this deployment. # injected early enough during startup on this deployment.
OXYAN_HEADER_OPTIONS = globals().get( OXYAN_HEADER_OPTIONS = globals().get(
@@ -112,6 +145,13 @@ ACTIVE_VERTICAL = "agency"
# Wagtail content internationalization in admin # Wagtail content internationalization in admin
WAGTAIL_I18N_ENABLED = True WAGTAIL_I18N_ENABLED = True
WAGTAIL_CONTENT_LANGUAGES = LANGUAGES WAGTAIL_CONTENT_LANGUAGES = LANGUAGES
WAGTAILADMIN_BASE_URL = os.environ.get("WAGTAILADMIN_BASE_URL", "http://127.0.0.1:8001")
# Local dev convenience: allow Django's test client and common loopback hosts.
ALLOWED_HOSTS = list(globals().get("ALLOWED_HOSTS", []))
for _host in ("localhost", "127.0.0.1", "0.0.0.0", "testserver"):
if _host not in ALLOWED_HOSTS:
ALLOWED_HOSTS.append(_host)
CONTENT_GUARD_STRICT = True CONTENT_GUARD_STRICT = True
CONTENT_GUARD_BLOCK_MEDIUM = False CONTENT_GUARD_BLOCK_MEDIUM = False

View File

@@ -1,13 +1,18 @@
from django.contrib.sitemaps.views import index as sitemap_index_view from django.contrib.sitemaps.views import index as sitemap_index_view
from django.contrib.sitemaps.views import sitemap as sitemap_section_view from django.contrib.sitemaps.views import sitemap as sitemap_section_view
from django.http import HttpResponse from django.http import HttpResponse
from wagtail.models import Locale, Page from wagtail.models import Locale, Page
from ocyan.plugin.wagtail_oscar_integration.constants import CACHE_DURATION from ocyan.plugin.wagtail_oscar_integration.constants import CACHE_DURATION
from ocyan.plugin.wagtail_oscar_integration.sitemap import CategorySitemap from ocyan.plugin.wagtail_oscar_integration.sitemap import (
from ocyan.plugin.wagtail_oscar_integration.sitemap import ProductSitemap CategorySitemap,
from ocyan.plugin.wagtail_oscar_integration.sitemap import ShopSitemap ProductSitemap,
from ocyan.plugin.wagtail_oscar_integration.sitemap import WagtailSitemap as BaseWagtailSitemap ShopSitemap,
)
from ocyan.plugin.wagtail_oscar_integration.sitemap import (
WagtailSitemap as BaseWagtailSitemap,
)
class WagtailSitemap(BaseWagtailSitemap): class WagtailSitemap(BaseWagtailSitemap):
@@ -15,8 +20,8 @@ class WagtailSitemap(BaseWagtailSitemap):
page_ids = [] page_ids = []
for locale in Locale.objects.all(): for locale in Locale.objects.all():
translated_root_page = self.get_wagtail_site().root_page.get_translation_or_none( translated_root_page = (
locale self.get_wagtail_site().root_page.get_translation_or_none(locale)
) )
if translated_root_page is None: if translated_root_page is None:
continue continue

View File

@@ -1,16 +0,0 @@
{% extends "carbasa/headers/header.html" %}
{% load i18n oxyan category_tags ocyan_main ocyanjson wagtailsettings_tags %}
{% block nav %}
{% ocyanjson "theme" "menu_depth" 1 as menu_depth %}
<div class="collapse navbar-collapse menu-bar page-menu-bar" id="navbarSupportedContent">
<div class="brand-wrapper">
{% include 'partials/brand.html' with big=True %}
</div>
<ul class="navbar-nav">
{% rootpage_as_category as page_tree_root %}
{% category_tree 2 page_tree_root as page_tree_items %}
{% include "partials/dropdown.html" with menu_items=page_tree_items limit=2 %}
</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

@@ -0,0 +1,45 @@
<section class="container py-5">
<div class="row justify-content-center">
<div class="col-12 col-lg-10 col-xl-9">
<div class="te-richtext card border-0 shadow-sm rounded-4">
<div class="card-body p-4 p-md-5">
{{ value.content }}
</div>
</div>
</div>
</div>
</section>
<style>
.te-modern-saas .te-richtext {
color: var(--te-color-text-base);
background: color-mix(in srgb, var(--te-color-surface-soft) 18%, white 82%);
}
.te-modern-saas .te-richtext .card-body > * + * {
margin-top: 1rem;
}
.te-modern-saas .te-richtext h2,
.te-modern-saas .te-richtext h3,
.te-modern-saas .te-richtext h4 {
color: var(--te-color-surface-strong);
margin-top: 2rem;
margin-bottom: 0.75rem;
}
.te-modern-saas .te-richtext p,
.te-modern-saas .te-richtext li {
line-height: 1.75;
}
.te-modern-saas .te-richtext ul,
.te-modern-saas .te-richtext ol {
padding-left: 1.25rem;
margin-bottom: 0;
}
.te-modern-saas .te-richtext a {
font-weight: 600;
}
</style>

View File

@@ -1,5 +1,5 @@
{% extends "layout.html" %} {% extends "layout.html" %}
{% load wagtailcore_tags oxyan static string_filters mandelstudio_i18n %} {% load wagtailcore_tags oxyan static string_filters i18n %}
{% block extrahead %} {% block extrahead %}
{{ block.super }} {{ block.super }}
@@ -15,15 +15,39 @@
{% include "engine/partials/tech_theme_overrides.html" %} {% include "engine/partials/tech_theme_overrides.html" %}
{% include "engine/partials/travel_theme_overrides.html" %} {% include "engine/partials/travel_theme_overrides.html" %}
{% include "engine/partials/saas_theme_overrides.html" %} {% include "engine/partials/saas_theme_overrides.html" %}
<style>
:root { --mb-site-header-height: 88px; }
header.mega_header {
z-index: 1200;
}
.te-modern-saas .te-block--saas-testimonials .saas-testimonials__header {
top: calc(var(--mb-site-header-height) + 8px);
z-index: 20;
}
@media (max-width: 991.98px) {
:root { --mb-site-header-height: 72px; }
}
</style>
{% endblock %} {% endblock %}
{% block layout %} {% block layout %}
<a class="btn btn-secondary hidelink" id="main_content_link" href="#skip_header" tabindex="2"> <a class="btn btn-secondary hidelink" id="main_content_link" href="#skip_header" tabindex="2">
{% skip_to_content_text %} {% if request.LANGUAGE_CODE == 'ru' %}Перейти к содержанию{% elif request.LANGUAGE_CODE == 'de' %}Zum Inhalt springen{% elif request.LANGUAGE_CODE == 'fr' %}Aller au contenu{% elif request.LANGUAGE_CODE == 'es' %}Ir al contenido{% elif request.LANGUAGE_CODE == 'it' %}Vai al contenuto{% elif request.LANGUAGE_CODE == 'pt' %}Ir para o conteúdo{% elif request.LANGUAGE_CODE == 'nl' %}Ga naar inhoud{% else %}Skip to content{% endif %}
</a> </a>
{% include_header header_template|default:"engine/partials/header.html" %} {% include_header header_template|default:"engine/partials/header.html" %}
<div id="main_content" tabindex="-1"> <div id="main_content" tabindex="-1">
<div class="te-modern-saas"> <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> <main>
{% for block in self.body %} {% for block in self.body %}
{% with scope_class=block.block_type|split:"_"|join:"-" %} {% with scope_class=block.block_type|split:"_"|join:"-" %}
@@ -35,9 +59,5 @@
</main> </main>
</div> </div>
</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" %} {% include "oxyan/partials/footer.html" %}
{% endblock %} {% endblock %}

View File

@@ -1,5 +1,5 @@
{% extends "layout.html" %} {% extends "layout.html" %}
{% load wagtailcore_tags oxyan static string_filters mandelstudio_i18n %} {% load wagtailcore_tags oxyan static string_filters i18n %}
{% block extrahead %} {% block extrahead %}
{{ block.super }} {{ block.super }}
@@ -15,15 +15,39 @@
{% include "engine/partials/tech_theme_overrides.html" %} {% include "engine/partials/tech_theme_overrides.html" %}
{% include "engine/partials/travel_theme_overrides.html" %} {% include "engine/partials/travel_theme_overrides.html" %}
{% include "engine/partials/saas_theme_overrides.html" %} {% include "engine/partials/saas_theme_overrides.html" %}
<style>
:root { --mb-site-header-height: 88px; }
header.mega_header {
z-index: 1200;
}
.te-modern-saas .te-block--saas-testimonials .saas-testimonials__header {
top: calc(var(--mb-site-header-height) + 8px);
z-index: 20;
}
@media (max-width: 991.98px) {
:root { --mb-site-header-height: 72px; }
}
</style>
{% endblock %} {% endblock %}
{% block layout %} {% block layout %}
<a class="btn btn-secondary hidelink" id="main_content_link" href="#skip_header" tabindex="2"> <a class="btn btn-secondary hidelink" id="main_content_link" href="#skip_header" tabindex="2">
{% skip_to_content_text %} {% if request.LANGUAGE_CODE == 'ru' %}Перейти к содержанию{% elif request.LANGUAGE_CODE == 'de' %}Zum Inhalt springen{% elif request.LANGUAGE_CODE == 'fr' %}Aller au contenu{% elif request.LANGUAGE_CODE == 'es' %}Ir al contenido{% elif request.LANGUAGE_CODE == 'it' %}Vai al contenuto{% elif request.LANGUAGE_CODE == 'pt' %}Ir para o conteúdo{% elif request.LANGUAGE_CODE == 'nl' %}Ga naar inhoud{% else %}Skip to content{% endif %}
</a> </a>
{% include_header header_template|default:"engine/partials/header.html" %} {% include_header header_template|default:"engine/partials/header.html" %}
<div id="main_content" tabindex="-1"> <div id="main_content" tabindex="-1">
<div class="te-modern-saas"> <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"> <main class="te-section">
<div class="container"> <div class="container">
<h1 class="te-section__heading">{{ self.title }}</h1> <h1 class="te-section__heading">{{ self.title }}</h1>
@@ -38,9 +62,5 @@
</main> </main>
</div> </div>
</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" %} {% include "oxyan/partials/footer.html" %}
{% endblock %} {% endblock %}

View File

@@ -1,5 +1,5 @@
{% extends "layout.html" %} {% extends "layout.html" %}
{% load wagtailcore_tags oxyan static string_filters mandelstudio_i18n %} {% load wagtailcore_tags oxyan static string_filters i18n %}
{% block extrahead %} {% block extrahead %}
{{ block.super }} {{ block.super }}
@@ -15,11 +15,24 @@
{% include "engine/partials/tech_theme_overrides.html" %} {% include "engine/partials/tech_theme_overrides.html" %}
{% include "engine/partials/travel_theme_overrides.html" %} {% include "engine/partials/travel_theme_overrides.html" %}
{% include "engine/partials/saas_theme_overrides.html" %} {% include "engine/partials/saas_theme_overrides.html" %}
<style>
:root { --mb-site-header-height: 88px; }
header.mega_header {
z-index: 1200;
}
.te-modern-saas .te-block--saas-testimonials .saas-testimonials__header {
top: calc(var(--mb-site-header-height) + 8px);
z-index: 20;
}
@media (max-width: 991.98px) {
:root { --mb-site-header-height: 72px; }
}
</style>
{% endblock %} {% endblock %}
{% block layout %} {% block layout %}
<a class="btn btn-secondary hidelink" id="main_content_link" href="#skip_header" tabindex="2"> <a class="btn btn-secondary hidelink" id="main_content_link" href="#skip_header" tabindex="2">
{% skip_to_content_text %} {% if request.LANGUAGE_CODE == 'ru' %}Перейти к содержанию{% elif request.LANGUAGE_CODE == 'de' %}Zum Inhalt springen{% elif request.LANGUAGE_CODE == 'fr' %}Aller au contenu{% elif request.LANGUAGE_CODE == 'es' %}Ir al contenido{% elif request.LANGUAGE_CODE == 'it' %}Vai al contenuto{% elif request.LANGUAGE_CODE == 'pt' %}Ir para o conteúdo{% elif request.LANGUAGE_CODE == 'nl' %}Ga naar inhoud{% else %}Skip to content{% endif %}
</a> </a>
{% include_header header_template|default:"engine/partials/header.html" %} {% include_header header_template|default:"engine/partials/header.html" %}
<div id="main_content" tabindex="-1"> <div id="main_content" tabindex="-1">
@@ -35,9 +48,5 @@
</main> </main>
</div> </div>
</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" %} {% include "oxyan/partials/footer.html" %}
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,58 @@
{% load wagtailimages_tags %}
<section class="saas-demo saas-demo--inline saas-demo--{{ self.background_style }}"
data-width="{{ self.layout_width }}">
<div class="saas-demo__container">
<header class="saas-demo__header">
<h2 class="saas-demo__title">{{ self.section_title }}</h2>
{% if self.section_subtitle %}
<div class="saas-demo__subtitle">{{ self.section_subtitle }}</div>
{% endif %}
</header>
<form class="saas-demo__form" action="{% url "contact_form:contact-form-handler" %}" method="post">
{% csrf_token %}
<div class="saas-demo__fields">
{% for field in self.form_fields %}
<div class="saas-demo__field">
<label class="saas-demo__label" for="demo-{{ field.field_type }}">
{{ field.label }}
{% if field.required %}<span class="saas-demo__required">*</span>{% endif %}
</label>
{% if field.field_type == 'message' %}
<textarea class="saas-demo__textarea"
id="demo-{{ field.field_type }}"
name="{% if field.field_type == "email" %}email_from{% elif field.field_type == "phone" %}phonenumber{% elif field.field_type == "text" %}name{% else %}{{ field.field_type }}{% endif %}"
placeholder="{{ field.placeholder }}"
{% if field.required %}required{% endif %}></textarea>
{% elif field.field_type == 'company_size' %}
<select class="saas-demo__select"
id="demo-{{ field.field_type }}"
name="{% if field.field_type == "email" %}email_from{% elif field.field_type == "phone" %}phonenumber{% elif field.field_type == "text" %}name{% else %}{{ field.field_type }}{% endif %}"
{% if field.required %}required{% endif %}>
<option value="">{{ field.placeholder|default:"Select company size" }}</option>
<option value="1-10">1-10 employees</option>
<option value="11-50">11-50 employees</option>
<option value="51-200">51-200 employees</option>
<option value="201-500">201-500 employees</option>
<option value="500+">500+ employees</option>
</select>
{% else %}
<input class="saas-demo__input"
type="{% if field.field_type == 'email' %}email{% elif field.field_type == 'phone' %}tel{% else %}text{% endif %}"
id="demo-{{ field.field_type }}"
name="{% if field.field_type == "email" %}email_from{% elif field.field_type == "phone" %}phonenumber{% elif field.field_type == "text" %}name{% else %}{{ field.field_type }}{% endif %}"
placeholder="{{ field.placeholder }}"
{% if field.required %}required{% endif %}>
{% endif %}
</div>
{% endfor %}
</div>
<button type="submit" class="saas-demo__submit">{{ self.submit_button_text }}</button>
{% if self.privacy_text %}
<div class="saas-demo__privacy">{{ self.privacy_text }}</div>
{% endif %}
</form>
</div>
</section>

View File

@@ -0,0 +1,94 @@
{% load wagtailimages_tags %}
<section class="saas-demo saas-demo--modal-trigger saas-demo--{{ self.background_style }}"
data-width="{{ self.layout_width }}"
data-demo-modal-root>
<div class="saas-demo__container">
<div class="saas-demo__content">
<h2 class="saas-demo__title">{{ self.section_title }}</h2>
{% if self.section_subtitle %}
<div class="saas-demo__subtitle">{{ self.section_subtitle }}</div>
{% endif %}
<button type="button" class="saas-demo__trigger" data-demo-modal-open>
{{ self.submit_button_text }}
</button>
</div>
</div>
<!-- Modal -->
<div class="saas-demo__modal" data-demo-modal hidden>
<div class="saas-demo__modal-backdrop" data-demo-modal-close></div>
<div class="saas-demo__modal-content">
<button type="button" class="saas-demo__modal-close" data-demo-modal-close aria-label="Close">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"><path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
</button>
<h3 class="saas-demo__modal-title">{{ self.section_title }}</h3>
<form class="saas-demo__form" action="{% url "contact_form:contact-form-handler" %}" method="post">
{% csrf_token %}
<div class="saas-demo__fields">
{% for field in self.form_fields %}
<div class="saas-demo__field">
<label class="saas-demo__label" for="modal-{{ field.field_type }}">
{{ field.label }}
{% if field.required %}<span class="saas-demo__required">*</span>{% endif %}
</label>
{% if field.field_type == 'message' %}
<textarea class="saas-demo__textarea"
id="modal-{{ field.field_type }}"
name="{% if field.field_type == "email" %}email_from{% elif field.field_type == "phone" %}phonenumber{% elif field.field_type == "text" %}name{% else %}{{ field.field_type }}{% endif %}"
placeholder="{{ field.placeholder }}"
{% if field.required %}required{% endif %}></textarea>
{% else %}
<input class="saas-demo__input"
type="{% if field.field_type == 'email' %}email{% elif field.field_type == 'phone' %}tel{% else %}text{% endif %}"
id="modal-{{ field.field_type }}"
name="{% if field.field_type == "email" %}email_from{% elif field.field_type == "phone" %}phonenumber{% elif field.field_type == "text" %}name{% else %}{{ field.field_type }}{% endif %}"
placeholder="{{ field.placeholder }}"
{% if field.required %}required{% endif %}>
{% endif %}
</div>
{% endfor %}
</div>
<button type="submit" class="saas-demo__submit">{{ self.submit_button_text }}</button>
{% if self.privacy_text %}
<div class="saas-demo__privacy">{{ self.privacy_text }}</div>
{% endif %}
</form>
</div>
</div>
</section>
<script>
(function () {
const roots = document.querySelectorAll('[data-demo-modal-root]');
roots.forEach((root) => {
if (root.dataset.modalBound === "1") return;
root.dataset.modalBound = "1";
const modal = root.querySelector('[data-demo-modal]');
const openBtn = root.querySelector('[data-demo-modal-open]');
const closeBtns = root.querySelectorAll('[data-demo-modal-close]');
if (!modal || !openBtn) return;
const openModal = () => {
modal.hidden = false;
document.body.style.overflow = 'hidden';
};
const closeModal = () => {
modal.hidden = true;
document.body.style.overflow = '';
};
openBtn.addEventListener('click', openModal);
closeBtns.forEach((btn) => btn.addEventListener('click', closeModal));
root.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && !modal.hidden) closeModal();
});
});
})();
</script>

View File

@@ -0,0 +1,156 @@
{% load wagtailimages_tags %}
<section class="saas-demo saas-demo--split saas-demo--{{ self.background_style }} mandelstudio-demo-split"
data-width="{{ self.layout_width }}">
<style>
.mandelstudio-demo-split {
position: relative;
border-radius: 18px;
overflow: hidden;
background: linear-gradient(180deg, #f8fbff 0%, #f3f7fc 100%);
}
.mandelstudio-demo-split .saas-demo__container {
max-width: 1200px;
margin: 0 auto;
padding: clamp(1.25rem, 2.2vw, 2.25rem);
gap: clamp(1rem, 2vw, 2rem);
}
.mandelstudio-demo-split .saas-demo__title {
font-size: clamp(1.9rem, 3vw, 3rem);
line-height: 1.1;
letter-spacing: -0.03em;
margin-bottom: .8rem;
}
.mandelstudio-demo-split .saas-demo__subtitle {
color: #556070;
max-width: 54ch;
margin-bottom: .9rem;
}
.mandelstudio-demo-split .saas-demo__benefits-list {
margin-bottom: 1.15rem;
}
.mandelstudio-demo-split .saas-demo__benefit {
color: #212b3a;
}
.mandelstudio-demo-split .saas-demo__visual {
margin-top: .5rem;
}
.mandelstudio-demo-split .saas-demo__image {
width: 100%;
border-radius: 16px;
border: 1px solid rgba(39, 66, 107, .14);
box-shadow: 0 12px 28px rgba(20, 35, 68, .12);
}
.mandelstudio-demo-split .saas-demo__form-wrapper {
border: 1px solid rgba(42, 72, 120, .15);
border-radius: 16px;
background: #fff;
box-shadow: 0 14px 30px rgba(18, 38, 76, .09);
}
.mandelstudio-demo-split .saas-demo__form {
padding: clamp(1rem, 2vw, 1.5rem);
}
.mandelstudio-demo-split .saas-demo__input,
.mandelstudio-demo-split .saas-demo__select,
.mandelstudio-demo-split .saas-demo__textarea {
background: #fbfdff;
border-color: #d8e1ec;
transition: border-color .2s ease, box-shadow .2s ease;
}
.mandelstudio-demo-split .saas-demo__input:focus,
.mandelstudio-demo-split .saas-demo__select:focus,
.mandelstudio-demo-split .saas-demo__textarea:focus {
border-color: #377dff;
box-shadow: 0 0 0 .2rem rgba(55, 125, 255, .15);
}
.mandelstudio-demo-split .saas-demo__submit {
box-shadow: 0 8px 18px rgba(40, 95, 214, .3);
}
@media (max-width: 991.98px) {
.mandelstudio-demo-split .saas-demo__visual {
display: none;
}
}
</style>
<div class="saas-demo__container">
<div class="saas-demo__content">
<h2 class="saas-demo__title">{{ self.section_title }}</h2>
{% if self.section_subtitle %}
<div class="saas-demo__subtitle">{{ self.section_subtitle }}</div>
{% endif %}
{% if self.benefits_title or self.benefits %}
<div class="saas-demo__benefits">
{% if self.benefits_title %}
<h3 class="saas-demo__benefits-title">{{ self.benefits_title }}</h3>
{% endif %}
{% if self.benefits %}
<ul class="saas-demo__benefits-list">
{% for benefit in self.benefits %}
<li class="saas-demo__benefit">
<svg class="saas-demo__check" width="20" height="20" viewBox="0 0 20 20" fill="none">
<path d="M4 10L8 14L16 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{{ benefit }}
</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endif %}
{% if self.side_image %}
<div class="saas-demo__visual">
{% image self.side_image width-640 class="saas-demo__image" %}
</div>
{% endif %}
</div>
<div class="saas-demo__form-wrapper">
<form class="saas-demo__form" action="{% url "contact_form:contact-form-handler" %}" method="post">
{% csrf_token %}
<div class="saas-demo__fields">
{% for field in self.form_fields %}
<div class="saas-demo__field">
<label class="saas-demo__label" for="split-{{ field.field_type }}">
{{ field.label }}
{% if field.required %}<span class="saas-demo__required">*</span>{% endif %}
</label>
{% if field.field_type == 'message' %}
<textarea class="saas-demo__textarea"
id="split-{{ field.field_type }}"
name="{% if field.field_type == "email" %}email_from{% elif field.field_type == "phone" %}phonenumber{% elif field.field_type == "text" %}name{% else %}{{ field.field_type }}{% endif %}"
placeholder="{{ field.placeholder }}"
{% if field.required %}required{% endif %}></textarea>
{% elif field.field_type == 'company_size' %}
<select class="saas-demo__select"
id="split-{{ field.field_type }}"
name="{% if field.field_type == "email" %}email_from{% elif field.field_type == "phone" %}phonenumber{% elif field.field_type == "text" %}name{% else %}{{ field.field_type }}{% endif %}"
{% if field.required %}required{% endif %}>
<option value="">{{ field.placeholder|default:"Select company size" }}</option>
<option value="1-10">1-10 employees</option>
<option value="11-50">11-50 employees</option>
<option value="51-200">51-200 employees</option>
<option value="201-500">201-500 employees</option>
<option value="500+">500+ employees</option>
</select>
{% else %}
<input class="saas-demo__input"
type="{% if field.field_type == 'email' %}email{% elif field.field_type == 'phone' %}tel{% else %}text{% endif %}"
id="split-{{ field.field_type }}"
name="{% if field.field_type == "email" %}email_from{% elif field.field_type == "phone" %}phonenumber{% elif field.field_type == "text" %}name{% else %}{{ field.field_type }}{% endif %}"
placeholder="{{ field.placeholder }}"
{% if field.required %}required{% endif %}>
{% endif %}
</div>
{% endfor %}
</div>
<button type="submit" class="saas-demo__submit">{{ self.submit_button_text }}</button>
{% if self.privacy_text %}
<div class="saas-demo__privacy">{{ self.privacy_text }}</div>
{% endif %}
</form>
</div>
</div>
</section>

View File

@@ -0,0 +1,38 @@
{% load wagtailimages_tags %}
<section class="saas-features saas-features--grid saas-features--{{ self.background_style }}"
data-width="{{ self.layout_width }}">
<div class="saas-features__container">
<header class="saas-features__header">
<h2 class="saas-features__title">{{ self.section_title }}</h2>
{% if self.section_subtitle %}
<div class="saas-features__subtitle">{{ self.section_subtitle }}</div>
{% endif %}
</header>
<div class="saas-features__grid saas-features__grid--cols-{{ self.columns }}">
{% for feature in self.features %}
<article class="saas-features__card{% if feature.highlight == 'featured' %} saas-features__card--featured{% endif %}">
{% if feature.highlight == 'new' %}
<span class="saas-features__badge">{% if request.LANGUAGE_CODE == 'ru' %}Ново{% elif request.LANGUAGE_CODE == 'de' %}Neu{% elif request.LANGUAGE_CODE == 'fr' %}Nouveau{% elif request.LANGUAGE_CODE == 'es' %}Nuevo{% elif request.LANGUAGE_CODE == 'it' %}Nuovo{% elif request.LANGUAGE_CODE == 'pt' %}Novo{% else %}New{% endif %}</span>
{% endif %}
<div class="saas-features__icon-wrapper">
{% if feature.icon_image %}
{% image feature.icon_image width-64 class="saas-features__icon-img" %}
{% elif feature.icon %}
<i class="saas-features__icon bi bi-{{ feature.icon }}"></i>
{% else %}
<div class="saas-features__icon-placeholder"></div>
{% endif %}
</div>
<h3 class="saas-features__card-title">{{ feature.title }}</h3>
{% if feature.description %}<div class="saas-features__card-desc">{{ feature.description }}</div>{% endif %}
{% if feature.link_text and feature.link_url %}
<a href="{{ feature.link_url }}" class="saas-features__card-link">
{{ feature.link_text }}
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M3 8H13M13 8L9 4M13 8L9 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</a>
{% endif %}
</article>
{% endfor %}
</div>
</div>
</section>

View File

@@ -0,0 +1,30 @@
{% load wagtailimages_tags %}
<section class="saas-integrations saas-integrations--logo-grid saas-integrations--{{ self.background_style }}" data-width="{{ self.layout_width }}">
<div class="saas-integrations__container">
<header class="saas-integrations__header">
<h2 class="saas-integrations__title">{{ self.section_title }}</h2>
{% if self.section_subtitle %}<div class="saas-integrations__subtitle">{{ self.section_subtitle }}</div>{% endif %}
{% if self.integration_count %}
<span class="saas-integrations__count">{{ self.integration_count }} {% if request.LANGUAGE_CODE == 'ru' %}интеграции{% elif request.LANGUAGE_CODE == 'de' %}Integrationen{% elif request.LANGUAGE_CODE == 'fr' %}intégrations{% elif request.LANGUAGE_CODE == 'es' %}integraciones{% elif request.LANGUAGE_CODE == 'it' %}integrazioni{% elif request.LANGUAGE_CODE == 'pt' %}integrações{% elif request.LANGUAGE_CODE == 'nl' %}integraties{% else %}integrations{% endif %}</span>
{% endif %}
</header>
<div class="saas-integrations__grid">
{% for integration in self.integrations %}
<div class="saas-integrations__item{% if integration.is_featured != 'none' %} saas-integrations__item--{{ integration.is_featured }}{% endif %}">
{% if integration.is_featured == 'new' %}
<span class="saas-integrations__badge">{% if request.LANGUAGE_CODE == 'ru' %}Ново{% elif request.LANGUAGE_CODE == 'de' %}Neu{% elif request.LANGUAGE_CODE == 'fr' %}Nouveau{% elif request.LANGUAGE_CODE == 'es' %}Nuevo{% elif request.LANGUAGE_CODE == 'it' %}Nuovo{% elif request.LANGUAGE_CODE == 'pt' %}Novo{% elif request.LANGUAGE_CODE == 'nl' %}Nieuw{% else %}New{% endif %}</span>
{% elif integration.is_featured == 'popular' %}
<span class="saas-integrations__badge saas-integrations__badge--popular">{% if request.LANGUAGE_CODE == 'ru' %}Populair{% elif request.LANGUAGE_CODE == 'de' %}Beliebt{% elif request.LANGUAGE_CODE == 'fr' %}Populaire{% elif request.LANGUAGE_CODE == 'es' %}Popular{% elif request.LANGUAGE_CODE == 'it' %}Popolare{% elif request.LANGUAGE_CODE == 'pt' %}Popular{% elif request.LANGUAGE_CODE == 'nl' %}Populair{% else %}Popular{% endif %}</span>
{% endif %}
{% if integration.url %}<a href="{{ integration.url }}" class="saas-integrations__link">{% endif %}
<div class="saas-integrations__logo">{% image integration.logo width-48 class="saas-integrations__logo-img" %}</div>
<span class="saas-integrations__name">{{ integration.name }}</span>
{% if integration.url %}</a>{% endif %}
</div>
{% endfor %}
</div>
{% if self.cta_text and self.cta_url %}
<div class="saas-integrations__footer"><a href="{{ self.cta_url }}" class="saas-integrations__cta">{{ self.cta_text }}<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M3 8H13M13 8L9 4M13 8L9 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg></a></div>
{% endif %}
</div>
</section>

View File

@@ -0,0 +1,92 @@
{% extends "base.html" %}
{% load compress %}
{% load i18n %}
{% load oxyan %}
{% load ocyan_main %}
{% load ocyanjson %}
{% load static %}
{% load wagtailcore_tags wagtailimages_tags wagtailuserbar %}
{% block title %}{% firstof page.seo_title self.seo_title page.title self.title shop_name %}{% endblock %}
{% block description %}{% firstof page.search_description self.search_description "" %}{% endblock %}
{% block extrahead %}
{% if cookie_jar.settings.google_tag_manager and cookie_jar.functional.is_allowed %}
<link rel="preconnect" href="https://www.googletagmanager.com"/>
{% endif %}
{% if cookie_jar.settings.google_analytics and cookie_jar.functional.is_allowed %}
<link rel="preconnect" href="https://www.google-analytics.com/">
{% endif %}
{{ block.super }}
{% if cookie_jar.needs_approval %}
<link rel="stylesheet" type="text/css" href="{% static 'cookie_jar/css/cookie_jar.css' %}">
{% endif %}
{% for header_snippet in cookie_jar.activated_snippet_header_templates %}
{% include header_snippet %}
{% endfor %}
{% endblock %}
{% block layout %}
{% if show_basket_popup_setting %}
{% esi_fragment "partials/added_success.html" with sessionid=True oscar_open_basket=True request=request csrf_token=csrf_token only %}
{% endif %}
{% block navbar %}
{% include "carbasa/headers/header.html" %}
{% endblock %}
{% block content_wrapper %}
<div id="main_content" tabindex="-1">
{% block content %}{% endblock %}
</div>
{% endblock %}
{% block footer %}
{% include "oxyan/partials/footer.html" %}
{% endblock %}
{% ocyanjson "themes" "theme-switcher" as theme_switcher %}
{% if theme_switcher %}
{% include "oxyan/partials/theme_switcher.html" %}
{% endif %}
{% endblock %}
{% block extrascripts %}
{% include "oscar/partials/extrascripts.html" %}
{{ block.super }}
{% if cookie_jar.needs_approval %}
<script src="{% static 'cookie_jar/js/cookie_jar.js' %}"></script>
{% endif %}
{% endblock %}
{% block onbodyload %}
{{ block.super }}
oxyan.layout()
oxyan.initModalPopup()
oxyan.initializePriceUpdate()
oxyan.IconHoverFix()
oxyan.lazyIconDropdown()
oxyan.toasts()
oxyan.commerseHeader()
oxyan.initWCAG()
{% ocyanjson "themes" "image_zoom" as image_zoom %}
{% if image_zoom %}
oxyan.initImageZoom()
{% endif %}
{% endblock %}
{% block cdn_scripts %}
{{ block.super }}
{% ocyanjson "wagtail" "wagtailuserbar_position" as position %}
{% if position %}
{% wagtailuserbar position %}
{% endif %}
{% for footer_snippet in cookie_jar.activated_snippet_footer_templates %}
{% include footer_snippet %}
{% endfor %}
{% include "cookie_jar/cookie_banner.html" %}
{% if cookie_jar.needs_approval %}
{% include "cookie_jar/partials/preferences_saved_toast.html" %}
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,11 @@
{% load wagtailcore_tags %}
{% if self.heading %}<p class="footer_header">{{ self.heading }}</p>{% endif %}
{% if children %}
<ul class="mb-footer-links list-unstyled m-0">
{% for page in children %}
<li class="mb-footer-links__item mb-2">
<a href="{% pageurl page %}">{{ page.title }}</a>
</li>
{% endfor %}
</ul>
{% endif %}

View File

@@ -1 +1,9 @@
{% extends "carbasa/headers/header.html" %} {% extends "carbasa/headers/header.html" %}
{% block append_header_block %}
{% include "oxyan/partials/language_chooser.html" %}
{% endblock append_header_block %}
{% block language_chooser %}
{% include "oxyan/partials/language_chooser.html" %}
{% endblock language_chooser %}

View File

@@ -1 +1,9 @@
{% extends "carbasa/headers/mega.html" %} {% extends "carbasa/headers/mega.html" %}
{% block append_header_block %}
{% include "oxyan/partials/language_chooser.html" %}
{% endblock append_header_block %}
{% block language_chooser %}
{% include "oxyan/partials/language_chooser.html" %}
{% endblock language_chooser %}

View File

@@ -1,29 +1,15 @@
{% load i18n i18n_helpers %} {% load 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 }}">
<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()">
<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>
<option value="fr" {% if LANGUAGE_CODE == 'fr' %}selected{% endif %}>FR</option>
<option value="es" {% if LANGUAGE_CODE == 'es' %}selected{% endif %}>ES</option>
<option value="it" {% if LANGUAGE_CODE == 'it' %}selected{% endif %}>IT</option>
<option value="pt" {% if LANGUAGE_CODE == 'pt' %}selected{% endif %}>PT</option>
<option value="ru" {% if LANGUAGE_CODE == 'ru' %}selected{% endif %}>RU</option>
</select>
</form>
<div class="header-right">
<a tabindex="0" aria-label="Open Search" role="search" class="search-toggler user-button menu-circle"> <a tabindex="0" aria-label="Open Search" role="search" class="search-toggler user-button menu-circle">
<i class="fa fa-search"></i> <i class="fa fa-search"></i>
</a> </a>
<a href="{% url 'customer:summary' %}" aria-label="{% trans 'Customer summary' %}" class="user-button menu-circle"><i class="fa fa-user-solid"></i></a> <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' %} {% include 'oxyan/headers/partials/mini_basket.html' %}
{% include "oxyan/partials/language_chooser.html" %}
</div> </div>
<div class="alert-messages-header" aria-live="polite"> <div class="alert-messages-header" aria-live="polite">
{% include "oscar/partials/alert_messages.html" with messages=messages %} {% include "oscar/partials/alert_messages.html" with messages=messages %}
</div> </div>

View File

@@ -1,16 +1,18 @@
<!-- MB OVERRIDE --> {% load i18n static ocyanjson mandelstudio_i18n %}
{% load i18n static ocyanjson i18n_helpers %}
{% get_current_language as current_language %}
{% get_available_languages as available_languages %}
<div class="dropdown language-dropdown"> <div class="dropdown language-dropdown">
<button type="button" id="dropdownMenuButton1" data-bs-toggle="dropdown" aria-expanded="false"> <button type="button" id="dropdownMenuButton1" data-bs-toggle="dropdown" aria-expanded="false">
{% include "oxyan/partials/flags/"|add:LANGUAGE_CODE|add:".svg" %} {% include "oxyan/partials/flags/"|add:current_language|add:".svg" %}
</button> </button>
<ul class="dropdown-menu" aria-labelledby="dropdownMenuButton1"> <ul class="dropdown-menu" aria-labelledby="dropdownMenuButton1">
{% get_language_info_list for languages as languages %} {% get_language_info_list for available_languages as languages %}
{% ocyanjson "i18n" "language_chooser_disabled_options" "" as disabled_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 %} {% 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 %} {% for language in languages %}
{% if language.code not in disabled_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> <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,35 @@
{% load i18n ocyan_thumbnail oxyan %}
{# Carbasa dropdown override: include the root page when it is marked "show in menus". #}
{% rootpage_as_category as menu_root_proxy %}
{% if menu_root_proxy and menu_root_proxy.show_in_menus %}
<li class="nav-item child">
<a class="nav-link" href="{{ menu_root_proxy.get_absolute_url }}">
{{ menu_root_proxy.name }}
</a>
</li>
{% endif %}
{% if menu_items %}
{% for menu_item in menu_items %}
{% with category_icon=menu_item.category.icons.first %}
{% if menu_item.has_children %}
<li class="nav-item {% if menu_item.depth == 1 %}head{% else %}child{% endif %} dropdown pages-dropdown">
<a class="nav-link toggle" role="button" aria-haspopup="true" aria-expanded="false" tabindex="0">
{{ menu_item.name }}<i class="fa fa-chevron-down small ms-2"></i>
</a>
<ul class="{% if mega_menu %}megasubmenu{% else %}dropdown-menu{% endif %}">
{% else %}
<li class="nav-item child">
<a class="nav-link" href="{{ menu_item.get_absolute_url }}">
{{ menu_item.name }}
</a>
</li>
{% endif %}
{% for close in menu_item.num_to_close %}
</ul>
</li>
{% endfor %}
{% endwith %}
{% endfor %}
{% endif %}

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,24 @@
{% load i18n ocyan_thumbnail %}
{% if menu_items %}
{% for menu_item in menu_items %}
{% with category_icon=menu_item.category.icons.first %}
{% if menu_item.has_children %}
<li class="nav-item has_children">
<a class="nav-link category-label" data-name="{{ menu_item.name|safe }}" data-href="{{ menu_item.get_absolute_url }}" tabindex="-1">
<span>{% trans "Show everything in" %}</span>{{ menu_item.name }}
</a>
<ul class="menu-level">
{% else %}
<li class="nav-item child">
<a class="nav-link child-category" href="{{ menu_item.get_absolute_url }}" tabindex="-1">
{{ menu_item.name }}
</a>
</li>
{% endif %}
{% for close in menu_item.num_to_close %}
</ul>
</li>
{% endfor %}
{% endwith %}
{% endfor %}
{% endif %}

View File

@@ -0,0 +1,85 @@
from __future__ import annotations
from django import template
from wagtail.models import Locale, Page
from mandelstudio.management.commands._agency_content import COMMON_CTA
register = template.Library()
SOURCE_PAGE_IDS = {
"about": 128,
"services": 129,
"projects": 130,
"contact": 131,
"process": 192,
}
NAV_CHILDREN = {
"services": [200, 201, 202, 203],
}
NAV_ORDER = ["services", "projects", "process", "about", "contact"]
def _resolve_locale(language_code: str | None) -> Locale | None:
if not language_code:
return None
try:
return Locale.objects.get(language_code=language_code)
except Locale.DoesNotExist:
return None
def _translated_page(source_id: int, language_code: str | None) -> Page | None:
locale = _resolve_locale(language_code)
try:
source = Page.objects.get(id=source_id)
except Page.DoesNotExist:
return None
if locale is None:
return source.specific
translated = (
Page.objects.filter(translation_key=source.translation_key, locale=locale)
.live()
.public()
.specific()
.first()
)
return translated or source.specific
@register.simple_tag(takes_context=True)
def agency_nav_pages(context):
request = context.get("request")
language_code = getattr(request, "LANGUAGE_CODE", None)
pages = []
for key in NAV_ORDER:
page = _translated_page(SOURCE_PAGE_IDS[key], language_code)
if page is not None:
page.nav_children = [
child
for source_id in NAV_CHILDREN.get(key, [])
if (child := _translated_page(source_id, language_code)) is not None
]
page.nav_key = key
pages.append(page)
return pages
@register.simple_tag(takes_context=True)
def agency_page(context, key: str):
request = context.get("request")
language_code = getattr(request, "LANGUAGE_CODE", None)
source_id = SOURCE_PAGE_IDS.get(key)
if source_id is None:
return None
return _translated_page(source_id, language_code)
@register.simple_tag(takes_context=True)
def agency_primary_cta(context):
request = context.get("request")
language_code = getattr(request, "LANGUAGE_CODE", None) or "nl"
return COMMON_CTA.get(language_code, COMMON_CTA["nl"])["primary"]

View File

@@ -19,7 +19,11 @@ def _fallback_locale_url(language_code: str) -> str:
def _is_translatable_page(page) -> bool: def _is_translatable_page(page) -> bool:
return page is not None and hasattr(page, "translation_key") and hasattr(page, "locale") return (
page is not None
and hasattr(page, "translation_key")
and hasattr(page, "locale")
)
def _translated_pages(page): def _translated_pages(page):
@@ -46,15 +50,16 @@ def _build_absolute_url(request, path: str | None, page=None) -> str:
@register.simple_tag @register.simple_tag
def page_language_options(page): def page_language_options(page):
labels = { labels = {
_normalize_language_code(code): label _normalize_language_code(code): label for code, label in settings.LANGUAGES
for code, label in settings.LANGUAGES
} }
if not _is_translatable_page(page): if not _is_translatable_page(page):
return [ return [
{ {
"code": _normalize_language_code(code), "code": _normalize_language_code(code),
"label": labels.get(_normalize_language_code(code), _normalize_language_code(code)), "label": labels.get(
_normalize_language_code(code), _normalize_language_code(code)
),
"url": _fallback_locale_url(code), "url": _fallback_locale_url(code),
} }
for code, _label in settings.LANGUAGES for code, _label in settings.LANGUAGES
@@ -69,7 +74,9 @@ def page_language_options(page):
{ {
"code": language_code, "code": language_code,
"label": labels.get(language_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), "url": translated_page.url
if translated_page is not None
else _fallback_locale_url(language_code),
} }
) )
return options return options
@@ -103,7 +110,9 @@ def page_hreflang_links(context):
links.append( links.append(
{ {
"code": language_code, "code": language_code,
"url": _build_absolute_url(request, translated_page.url, translated_page), "url": _build_absolute_url(
request, translated_page.url, translated_page
),
} }
) )

View File

@@ -1,4 +1,5 @@
from django import template from django import template
from wagtail.models import Site from wagtail.models import Site
from mandelstudio.models import LocalizedFooterContent from mandelstudio.models import LocalizedFooterContent
@@ -17,10 +18,7 @@ def localized_footer_content(context):
language_code = getattr(request, "LANGUAGE_CODE", None) language_code = getattr(request, "LANGUAGE_CODE", None)
if not language_code: if not language_code:
return None return None
return ( return LocalizedFooterContent.objects.filter(
LocalizedFooterContent.objects.filter( site=site,
site=site, locale__language_code=language_code,
locale__language_code=language_code, ).first()
)
.first()
)

View File

@@ -2,6 +2,8 @@ from __future__ import annotations
from django import template from django import template
from mandelstudio.i18n_utils import normalize_set_language_next
register = template.Library() register = template.Library()
SKIP_TO_CONTENT = { SKIP_TO_CONTENT = {
@@ -21,3 +23,9 @@ def skip_to_content_text(context) -> str:
request = context.get("request") request = context.get("request")
language_code = getattr(request, "LANGUAGE_CODE", "nl") language_code = getattr(request, "LANGUAGE_CODE", "nl")
return SKIP_TO_CONTENT.get(language_code, SKIP_TO_CONTENT["en"]) 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,15 +1,14 @@
from django.urls import path
from django.views.decorators.cache import cache_page from django.views.decorators.cache import cache_page
from django.urls import include, path
from ocyan.main.urls import urlpatterns as ocyan_urlpatterns from ocyan.main.urls import urlpatterns as ocyan_urlpatterns
from ocyan.plugin.wagtail_oscar_integration.constants import CACHE_DURATION from ocyan.plugin.wagtail_oscar_integration.constants import CACHE_DURATION
from .sitemaps import robots_txt from .i18n_views import set_language_normalized
from .sitemaps import sitemap_index from .sitemaps import robots_txt, sitemap_index, sitemap_section
from .sitemaps import sitemap_section
urlpatterns = [ 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("robots.txt", robots_txt, name="robots-txt"),
path( path(
"sitemap.xml", "sitemap.xml",

View File

@@ -1,72 +1,67 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
: "${STAGING_AUDIT_HOST:?STAGING_AUDIT_HOST is required}" : "${STAGING_AUDIT_PROJECT_NAME:?STAGING_AUDIT_PROJECT_NAME is required}"
: "${STAGING_AUDIT_PROJECT_DIR:?STAGING_AUDIT_PROJECT_DIR is required}" : "${STAGING_AUDIT_PROJECT_DIR:?STAGING_AUDIT_PROJECT_DIR is required}"
: "${STAGING_AUDIT_MANAGE:?STAGING_AUDIT_MANAGE is required}" : "${STAGING_AUDIT_MANAGE:?STAGING_AUDIT_MANAGE is required}"
mkdir -p artifacts
SSH_OPTS=${SSH_OPTS:-"-o StrictHostKeyChecking=accept-new"}
if [[ -n "${STAGING_SSH_KEYFILE:-}" ]]; then
SSH_OPTS="$SSH_OPTS -i ${STAGING_SSH_KEYFILE}"
fi
AUDIT_TIMEOUT_SECONDS=${AUDIT_TIMEOUT_SECONDS:-300} AUDIT_TIMEOUT_SECONDS=${AUDIT_TIMEOUT_SECONDS:-300}
OUT_FILE="artifacts/multilingual-audit.json" ARTIFACT_DIR=${ARTIFACT_DIR:-artifacts}
TMP_FILE="${OUT_FILE}.tmp" OUT_FILE="${ARTIFACT_DIR}/multilingual-audit.json"
mkdir -p "${ARTIFACT_DIR}"
write_failure_json() { TMP_FILE=$(mktemp)
python3 - <<PY > "$OUT_FILE" trap 'rm -f "$TMP_FILE"' EXIT
import json
print(json.dumps({
"run_id": None,
"total_urls_checked": 0,
"issues_found": 0,
"summary": {},
"issues": {},
"error": ${1@Q}
}, indent=2))
PY
}
REMOTE_CMD="cd '${STAGING_AUDIT_PROJECT_DIR}' && '${STAGING_AUDIT_MANAGE}' audit_locales --format=json" REMOTE_CMD="cd '${STAGING_AUDIT_PROJECT_DIR}' && '${STAGING_AUDIT_MANAGE}' audit_locales --format=json"
set +e set +e
SSH_OPTS="$SSH_OPTS" STAGING_AUDIT_HOST="$STAGING_AUDIT_HOST" REMOTE_CMD="$REMOTE_CMD" AUDIT_TIMEOUT_SECONDS="$AUDIT_TIMEOUT_SECONDS" python3 - <<'PY' > "$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 os
import shlex
import subprocess import subprocess
import sys import sys
ssh_opts = shlex.split(os.environ["SSH_OPTS"]) cmd = [
cmd = ["ssh", *ssh_opts, os.environ["STAGING_AUDIT_HOST"], os.environ["REMOTE_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",
os.environ["STAGING_AUDIT_PROJECT_NAME"],
"--command",
os.environ["REMOTE_CMD"],
]
try: try:
proc = subprocess.run( proc = subprocess.run(
cmd, cmd,
check=True, check=False,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, text=True,
timeout=int(os.environ["AUDIT_TIMEOUT_SECONDS"]), timeout=int(os.environ["AUDIT_TIMEOUT_SECONDS"]),
) )
sys.stdout.write(proc.stdout) except subprocess.TimeoutExpired:
sys.stderr.write(proc.stderr) print(json.dumps({
except subprocess.TimeoutExpired as exc: "error": "audit_failed",
sys.stderr.write(exc.stderr or "") "details": f"Audit command timed out after {os.environ['AUDIT_TIMEOUT_SECONDS']} seconds",
raise SystemExit(124) "exit_code": 124,
except subprocess.CalledProcessError as exc: }, indent=2))
sys.stdout.write(exc.stdout or "") raise SystemExit(2)
sys.stderr.write(exc.stderr or "")
raise SystemExit(exc.returncode) 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 {proc.returncode}",
"exit_code": proc.returncode,
}, indent=2))
raise SystemExit(2)
print(stdout)
PY PY
rc=$? rc=$?
set -e set -e
if [[ $rc -eq 0 ]]; then cp "$TMP_FILE" "$OUT_FILE"
mv "$TMP_FILE" "$OUT_FILE" cat "$OUT_FILE"
exit 0
fi
rm -f "$TMP_FILE"
if [[ $rc -eq 124 ]]; then
write_failure_json "Remote multilingual audit timed out after ${AUDIT_TIMEOUT_SECONDS}s"
else
write_failure_json "Remote multilingual audit failed with exit status ${rc}"
fi
exit $rc 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())