13 Commits

26 changed files with 841 additions and 236 deletions

View File

@@ -10,13 +10,13 @@ pipeline {
skipDefaultCheckout(true)
}
environment {
STAGING_AUDIT_PROJECT_NAME = 'mandelstudio'
STAGING_AUDIT_HOST = 'root@49.12.204.96'
STAGING_AUDIT_PROJECT_DIR = '/home/www-mandelstudio/mandelstudio'
STAGING_AUDIT_MANAGE = '/var/lib/virtualenv/mandelstudio/bin/manage.py'
STAGING_AUDIT_SSH_CREDENTIALS_ID = 'staging-root-ssh'
}
stages {
stage('Checkout') {
agent { label 'built-in' }
steps {
withCredentials([sshUserPrivateKey(credentialsId: 'gitea-ssh', keyFileVariable: 'GIT_KEYFILE')]) {
sh '''
@@ -39,10 +39,10 @@ pipeline {
timeout(time: 10, unit: 'MINUTES')
}
steps {
checkout scm
sh 'mkdir -p artifacts && [ -f artifacts/multilingual-audit.json ] && cp artifacts/multilingual-audit.json artifacts/previous-multilingual-audit.json || true'
sh 'chmod +x scripts/run_remote_multilingual_audit.sh'
withCredentials([sshUserPrivateKey(credentialsId: env.STAGING_AUDIT_SSH_CREDENTIALS_ID, keyFileVariable: 'STAGING_SSH_KEYFILE')]) {
sh './scripts/run_remote_multilingual_audit.sh'
}
script {
int status = sh(script: 'python3 scripts/multilingual_audit_ci.py --json artifacts/multilingual-audit.json --previous-json artifacts/previous-multilingual-audit.json', returnStatus: true)
if (status == 2) {

View File

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

View File

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

View File

@@ -0,0 +1,60 @@
# Generated by Django 5.2.11 on 2026-03-25 16:37
import django.db.models.deletion
import uuid
import wagtail.fields
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("wagtailcore", "0097_alter_page_locale_alter_page_translation_key"),
]
operations = [
migrations.CreateModel(
name="LocalizedFooterContent",
fields=[
("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("title", models.CharField(default="Footer content", max_length=120)),
("translation_key", models.UUIDField(default=uuid.uuid4, editable=False)),
(
"footer",
wagtail.fields.StreamField(
[("about_us", 2), ("text", 2), ("page_list", 4), ("SubscriptionBlock", 7)],
block_lookup={
0: ("wagtail.blocks.CharBlock", (), {"help_text": "Heading of the content block.", "label": "Heading", "required": False}),
1: ("wagtail.blocks.RichTextBlock", (), {}),
2: ("wagtail.blocks.StructBlock", [[("heading", 0), ("content", 1)]], {}),
3: ("wagtail.blocks.PageChooserBlock", (), {"help_text": "List pages below this page", "label": "Page"}),
4: ("wagtail.blocks.StructBlock", [[("heading", 0), ("page", 3)]], {}),
5: ("wagtail.blocks.CharBlock", (), {"label": "Title", "required": False}),
6: ("wagtail.blocks.TextBlock", (), {"label": "Description", "required": False}),
7: ("wagtail.blocks.StructBlock", [[("title", 5), ("description", 6)]], {}),
},
default=list,
),
),
(
"mini_footer",
wagtail.fields.StreamField(
[("text", 0)],
block_lookup={0: ("wagtail.blocks.RichTextBlock", (), {})},
default=list,
),
),
("locale", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name="+", to="wagtailcore.locale")),
("site", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="localized_footer_contents", to="wagtailcore.site")),
],
options={
"verbose_name": "Localized footer content",
"verbose_name_plural": "Localized footer contents",
"abstract": False,
"constraints": [models.UniqueConstraint(fields=("site", "locale"), name="unique_localized_footer_per_site_locale")],
"unique_together": {("translation_key", "locale")},
},
),
]

View File

@@ -0,0 +1,236 @@
from __future__ import annotations
import uuid
from django.db import migrations
CONTENT = {
"nl": {
"about": "<p>Wij bouwen snelle websites en webshops die je team zelf kan beheren. Van eerste lancering tot doorontwikkeling: helder, schaalbaar en zonder ruis.</p>",
"links_heading": "Snelle links",
"support_heading": "Help & support",
"link_labels": {
"about": "Over ons",
"services": "Diensten",
"projects": "Projecten",
"contact": "Contact",
"capabilities": "Mogelijkheden",
"ai_search": "AI Search",
"book_call": "Plan een gesprek",
},
"mini": "<p><a href=\"{contact}\">Contact</a> - <a href=\"{services}\">Diensten</a> - <a href=\"{projects}\">Projecten</a> - Copyright 2026 - MandelBlog Studio</p>",
},
"en": {
"about": "<p>We build fast websites and webshops your team can manage without friction. From launch to growth, the setup stays clear, scalable, and easy to extend.</p>",
"links_heading": "Quick links",
"support_heading": "Help & support",
"link_labels": {
"about": "About us",
"services": "Services",
"projects": "Projects",
"contact": "Contact",
"capabilities": "Capabilities",
"ai_search": "AI Search",
"book_call": "Book a call",
},
"mini": "<p><a href=\"{contact}\">Contact</a> - <a href=\"{services}\">Services</a> - <a href=\"{projects}\">Projects</a> - Copyright 2026 - MandelBlog Studio</p>",
},
"de": {
"about": "<p>Wir entwickeln schnelle Websites und Webshops, die Ihr Team selbst pflegen kann. Von der ersten Veröffentlichung bis zur Weiterentwicklung bleibt alles klar, skalierbar und wartbar.</p>",
"links_heading": "Schnellzugriff",
"support_heading": "Hilfe & Support",
"link_labels": {
"about": "Über uns",
"services": "Dienstleistungen",
"projects": "Projekte",
"contact": "Kontakt",
"capabilities": "Möglichkeiten",
"ai_search": "KI-Suche",
"book_call": "Gespräch planen",
},
"mini": "<p><a href=\"{contact}\">Kontakt</a> - <a href=\"{services}\">Dienstleistungen</a> - <a href=\"{projects}\">Projekte</a> - Copyright 2026 - MandelBlog Studio</p>",
},
"fr": {
"about": "<p>Nous créons des sites web et des boutiques en ligne rapides que votre équipe peut gérer facilement. Du lancement à la croissance, tout reste clair, évolutif et simple à maintenir.</p>",
"links_heading": "Accès rapide",
"support_heading": "Aide & support",
"link_labels": {
"about": "À propos",
"services": "Services",
"projects": "Projets",
"contact": "Contact",
"capabilities": "Possibilités",
"ai_search": "Recherche IA",
"book_call": "Planifier un échange",
},
"mini": "<p><a href=\"{contact}\">Contact</a> - <a href=\"{services}\">Services</a> - <a href=\"{projects}\">Projets</a> - Copyright 2026 - MandelBlog Studio</p>",
},
"es": {
"about": "<p>Construimos sitios web y tiendas online rápidas que tu equipo puede gestionar sin complicaciones. Desde el lanzamiento hasta el crecimiento, todo se mantiene claro, escalable y fácil de ampliar.</p>",
"links_heading": "Accesos rápidos",
"support_heading": "Ayuda y soporte",
"link_labels": {
"about": "Sobre nosotros",
"services": "Servicios",
"projects": "Proyectos",
"contact": "Contacto",
"capabilities": "Posibilidades",
"ai_search": "Búsqueda con IA",
"book_call": "Planificar una llamada",
},
"mini": "<p><a href=\"{contact}\">Contacto</a> - <a href=\"{services}\">Servicios</a> - <a href=\"{projects}\">Proyectos</a> - Copyright 2026 - MandelBlog Studio</p>",
},
"it": {
"about": "<p>Realizziamo siti web e negozi online veloci che il tuo team può gestire in autonomia. Dal lancio alla crescita, tutto rimane chiaro, scalabile e semplice da estendere.</p>",
"links_heading": "Link rapidi",
"support_heading": "Aiuto e supporto",
"link_labels": {
"about": "Chi siamo",
"services": "Servizi",
"projects": "Progetti",
"contact": "Contatto",
"capabilities": "Possibilità",
"ai_search": "Ricerca AI",
"book_call": "Prenota una call",
},
"mini": "<p><a href=\"{contact}\">Contatto</a> - <a href=\"{services}\">Servizi</a> - <a href=\"{projects}\">Progetti</a> - Copyright 2026 - MandelBlog Studio</p>",
},
"pt": {
"about": "<p>Criamos sites e lojas online rápidos que a sua equipa consegue gerir com autonomia. Do lançamento ao crescimento, tudo permanece claro, escalável e simples de evoluir.</p>",
"links_heading": "Acesso rápido",
"support_heading": "Ajuda e suporte",
"link_labels": {
"about": "Sobre nós",
"services": "Serviços",
"projects": "Projetos",
"contact": "Contacto",
"capabilities": "Possibilidades",
"ai_search": "Pesquisa IA",
"book_call": "Marcar conversa",
},
"mini": "<p><a href=\"{contact}\">Contacto</a> - <a href=\"{services}\">Serviços</a> - <a href=\"{projects}\">Projetos</a> - Copyright 2026 - MandelBlog Studio</p>",
},
"ru": {
"about": "<p>Мы создаём быстрые сайты и интернет-магазины, которыми ваша команда может управлять самостоятельно. От запуска до развития всё остаётся понятным, масштабируемым и удобным для роста.</p>",
"links_heading": "Быстрые ссылки",
"support_heading": "Помощь и поддержка",
"link_labels": {
"about": "О нас",
"services": "Услуги",
"projects": "Проекты",
"contact": "Контакт",
"capabilities": "Возможности",
"ai_search": "AI Search",
"book_call": "Запланировать звонок",
},
"mini": "<p><a href=\"{contact}\">Контакт</a> - <a href=\"{services}\">Услуги</a> - <a href=\"{projects}\">Проекты</a> - Copyright 2026 - MandelBlog Studio</p>",
},
}
SOURCE_SLUGS = {
"about": "over-ons",
"services": "diensten",
"projects": "projecten",
"contact": "contact",
"capabilities": "mogelijkheden",
"ai_search": "ai-search",
}
def build_urls(Page, code):
source_pages = {
key: Page.objects.filter(locale__language_code="nl", slug=slug).first()
for key, slug in SOURCE_SLUGS.items()
}
urls = {}
for key, page in source_pages.items():
if not page:
urls[key] = "/"
continue
translated = Page.objects.filter(
translation_key=page.translation_key, locale__language_code=code
).first()
chosen = translated or page
urls[key] = getattr(chosen, "url", None) or "/"
return urls
def make_footer_raw(code, urls):
content = CONTENT[code]
labels = content["link_labels"]
links_html = (
f'<p><a href="{urls["about"]}">{labels["about"]}</a><br/>'
f'<a href="{urls["services"]}">{labels["services"]}</a><br/>'
f'<a href="{urls["projects"]}">{labels["projects"]}</a><br/>'
f'<a href="{urls["contact"]}">{labels["contact"]}</a></p>'
)
support_html = (
f'<p><a href="{urls["capabilities"]}">{labels["capabilities"]}</a><br/>'
f'<a href="{urls["ai_search"]}">{labels["ai_search"]}</a><br/>'
f'<a href="{urls["contact"]}">{labels["book_call"]}</a><br/>'
f'<a href="mailto:info@mandelblog.com">info@mandelblog.com</a></p>'
)
return [
{
"type": "about_us",
"id": str(uuid.uuid4()),
"value": {"heading": "MandelBlog Studio", "content": content["about"]},
},
{
"type": "text",
"id": str(uuid.uuid4()),
"value": {"heading": content["links_heading"], "content": links_html},
},
{
"type": "text",
"id": str(uuid.uuid4()),
"value": {"heading": content["support_heading"], "content": support_html},
},
]
def make_mini_raw(code, urls):
return [
{
"type": "text",
"id": str(uuid.uuid4()),
"value": CONTENT[code]["mini"].format(**urls),
}
]
def seed_footer_content(apps, schema_editor):
LocalizedFooterContent = apps.get_model("mandelstudio", "LocalizedFooterContent")
Site = apps.get_model("wagtailcore", "Site")
Locale = apps.get_model("wagtailcore", "Locale")
site = Site.objects.order_by("id").first()
if site is None:
return
from wagtail.models import Page
translation_key = uuid.uuid4()
for code in CONTENT.keys():
locale, _ = Locale.objects.get_or_create(language_code=code)
urls = build_urls(Page, code)
LocalizedFooterContent.objects.update_or_create(
site=site,
locale=locale,
defaults={
"title": f"Footer content ({code})",
"translation_key": translation_key,
"footer": make_footer_raw(code, urls),
"mini_footer": make_mini_raw(code, urls),
},
)
def reverse_seed(apps, schema_editor):
LocalizedFooterContent = apps.get_model("mandelstudio", "LocalizedFooterContent")
LocalizedFooterContent.objects.all().delete()
class Migration(migrations.Migration):
dependencies = [("mandelstudio", "0001_initial")]
operations = [migrations.RunPython(seed_footer_content, reverse_seed)]

View File

@@ -0,0 +1,51 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [("mandelstudio", "0002_seed_localized_footer_content")]
operations = [
migrations.CreateModel(
name="LocaleAuditRun",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("started_at", models.DateTimeField(auto_now_add=True)),
("finished_at", models.DateTimeField(blank=True, null=True)),
("locale_codes", models.JSONField(blank=True, default=list)),
("fix_enabled", models.BooleanField(default=False)),
("total_urls_checked", models.PositiveIntegerField(default=0)),
("issues_found", models.PositiveIntegerField(default=0)),
("pages_with_issues", models.PositiveIntegerField(default=0)),
("summary", models.JSONField(blank=True, default=dict)),
],
options={"ordering": ["-started_at"]},
),
migrations.CreateModel(
name="LocaleAuditIssue",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("locale_code", models.CharField(max_length=12)),
("object_id", models.PositiveIntegerField(blank=True, null=True)),
("object_type", models.CharField(blank=True, max_length=128)),
("url", models.TextField(blank=True)),
("title", models.CharField(blank=True, max_length=255)),
("severity", models.CharField(max_length=16)),
("issue_type", models.CharField(max_length=64)),
("field_path", models.CharField(blank=True, max_length=512)),
("bad_value", models.TextField(blank=True)),
("replacement", models.TextField(blank=True)),
("fixed", models.BooleanField(default=False)),
("extra", models.JSONField(blank=True, default=dict)),
(
"run",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="issues",
to="mandelstudio.localeauditrun",
),
),
],
options={"ordering": ["locale_code", "url", "field_path"]},
),
]

View File

View File

@@ -14,6 +14,7 @@
"ocyan.plugin.oscar_partner",
"ocyan.plugin.oscar_shipping",
"ocyan.plugin.oscar_sequential_order_numbers",
"ocyan.plugin.payment_mollie",
"ocyan.plugin.roadrunner_bs5",
"ocyan.plugin.template_engine",
"ocyan.plugin.roadrunner_productchooser",
@@ -63,6 +64,24 @@
"en"
]
},
"payment_mollie": {
"api_key": "CHANGE_ME",
"ideal": true,
"creditcard": true,
"paypal": true,
"bancontact": true,
"sofort": true,
"banktransfer": false,
"belfius": false,
"bitcoin": false,
"directdebit": false,
"eps": false,
"giftcard": false,
"giropay": false,
"inghomepay": false,
"kbc": false,
"mistercash": false
},
"oscar": {
"allow_anon_checkout": true,
"cancelled_order_status": "cancelled",

View File

@@ -8,6 +8,7 @@ For the full list of settings and their values, see
https://docs.djangoproject.com/en/2.0/ref/settings/
"""
import importlib.util
import sys
from pathlib import Path
@@ -26,6 +27,49 @@ INSTALLED_APPS = [
"mandelstudio",
] + INSTALLED_APPS
# Route through the project URL layer so MandelStudio can override
# sitemap/robots behavior while still delegating the main Ocyan routes.
ROOT_URLCONF = "mandelstudio.urls"
def _ensure_required_app(*candidates):
"""Ensure required plugin apps remain enabled when /etc/ocyan config omits them."""
if any(app in INSTALLED_APPS for app in candidates):
return
for app in candidates:
if importlib.util.find_spec(app):
INSTALLED_APPS.append(app)
return
_ensure_required_app(
"ocyan.plugin.carbasa.carbasa",
"ocyan.plugin.carbasa",
)
_ensure_required_app(
"ocyan.plugin.coyote.coyote",
"ocyan.plugin.coyote",
)
# Keep Carbasa/Coyote defaults stable even when plugin settings are not
# injected early enough during startup on this deployment.
OXYAN_HEADER_OPTIONS = globals().get(
"OXYAN_HEADER_OPTIONS",
[
("basic", "Basic Header"),
("big", "Big Header"),
("mega", "Mega Header"),
],
)
COMPRESS_CACHE_KEY_FUNCTION = globals().get(
"COMPRESS_CACHE_KEY_FUNCTION",
"ocyan.plugin.coyote.utils.get_compressor_cache_key",
)
OXYAN_LAZY_THEME_DEFINITIONS = globals().get(
"OXYAN_LAZY_THEME_DEFINITIONS",
"ocyan.plugin.coyote.definitions.get_coyote_definitions",
)
# Enable request language negotiation.
if "django.middleware.locale.LocaleMiddleware" not in MIDDLEWARE:
if "django.contrib.sessions.middleware.SessionMiddleware" in MIDDLEWARE:

81
mandelstudio/sitemaps.py Normal file
View File

@@ -0,0 +1,81 @@
from django.contrib.sitemaps.views import index as sitemap_index_view
from django.contrib.sitemaps.views import sitemap as sitemap_section_view
from django.http import HttpResponse
from wagtail.models import Locale, Page
from ocyan.plugin.wagtail_oscar_integration.constants import CACHE_DURATION
from ocyan.plugin.wagtail_oscar_integration.sitemap import CategorySitemap
from ocyan.plugin.wagtail_oscar_integration.sitemap import ProductSitemap
from ocyan.plugin.wagtail_oscar_integration.sitemap import ShopSitemap
from ocyan.plugin.wagtail_oscar_integration.sitemap import WagtailSitemap as BaseWagtailSitemap
class WagtailSitemap(BaseWagtailSitemap):
def items(self):
page_ids = []
for locale in Locale.objects.all():
translated_root_page = self.get_wagtail_site().root_page.get_translation_or_none(
locale
)
if translated_root_page is None:
continue
locale_page_ids = (
translated_root_page.get_descendants(inclusive=True)
.live()
.public()
.order_by()
.values_list("pk", flat=True)
)
page_ids.extend(locale_page_ids)
if not page_ids:
return []
return (
Page.objects.filter(pk__in=page_ids)
.live()
.public()
.defer_streamfields()
.order_by("path")
.specific()
)
def gather_sitemaps():
return {
"pages": WagtailSitemap,
"shop": ShopSitemap,
"products": ProductSitemap,
"categories": CategorySitemap,
}
def sitemap_index(request):
return sitemap_index_view(
request,
sitemaps=gather_sitemaps(),
sitemap_url_name="sitemaps",
)
def sitemap_section(request, section=None):
return sitemap_section_view(
request,
sitemaps=gather_sitemaps(),
section=section,
)
def robots_txt(request):
sitemap_url = request.build_absolute_uri("/sitemap.xml")
content = "\n".join(
[
"User-agent: *",
"Allow: /",
f"Sitemap: {sitemap_url}",
"",
]
)
return HttpResponse(content, content_type="text/plain; charset=utf-8")

View File

@@ -1,33 +1,16 @@
{% extends "carbasa/headers/header.html" %}
{% load agency_navigation %}
{% 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>
{% agency_nav_pages as nav_pages %}
<ul class="navbar-nav">
{% for nav_page in nav_pages %}
{% if nav_page.nav_children %}
<li class="nav-item dropdown agency-nav-dropdown">
<a class="nav-link dropdown-toggle" href="{{ nav_page.url }}" id="agency-nav-{{ nav_page.nav_key }}" role="button" data-bs-toggle="dropdown" aria-expanded="false">
{{ nav_page.title }}
</a>
<ul class="dropdown-menu" aria-labelledby="agency-nav-{{ nav_page.nav_key }}">
{% for child_page in nav_page.nav_children %}
<li>
<a class="dropdown-item" href="{{ child_page.url }}">{{ child_page.title }}</a>
</li>
{% endfor %}
</ul>
</li>
{% else %}
<li class="nav-item child">
<a class="nav-link" href="{{ nav_page.url }}">{{ nav_page.title }}</a>
</li>
{% endif %}
{% endfor %}
{% 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

@@ -1,5 +1,5 @@
{% extends "layout.html" %}
{% load wagtailcore_tags oxyan static string_filters i18n %}
{% load wagtailcore_tags oxyan static string_filters mandelstudio_i18n %}
{% block extrahead %}
{{ block.super }}
@@ -32,7 +32,7 @@
{% block layout %}
<a class="btn btn-secondary hidelink" id="main_content_link" href="#skip_header" tabindex="2">
{% 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 %}
{% skip_to_content_text %}
</a>
{% include "carbasa/headers/header.html" %}
<div id="main_content" tabindex="-1">

View File

@@ -1,5 +1,5 @@
{% extends "layout.html" %}
{% load wagtailcore_tags oxyan static string_filters i18n %}
{% load wagtailcore_tags oxyan static string_filters mandelstudio_i18n %}
{% block extrahead %}
{{ block.super }}
@@ -32,7 +32,7 @@
{% block layout %}
<a class="btn btn-secondary hidelink" id="main_content_link" href="#skip_header" tabindex="2">
{% 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 %}
{% skip_to_content_text %}
</a>
{% include "carbasa/headers/header.html" %}
<div id="main_content" tabindex="-1">

View File

@@ -1,5 +1,5 @@
{% extends "layout.html" %}
{% load wagtailcore_tags oxyan static string_filters i18n %}
{% load wagtailcore_tags oxyan static string_filters mandelstudio_i18n %}
{% block extrahead %}
{{ block.super }}
@@ -32,7 +32,7 @@
{% block layout %}
<a class="btn btn-secondary hidelink" id="main_content_link" href="#skip_header" tabindex="2">
{% 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 %}
{% skip_to_content_text %}
</a>
{% include "carbasa/headers/header.html" %}
<div id="main_content" tabindex="-1">

View File

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

View File

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

View File

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

View File

@@ -1,15 +1,19 @@
{% load i18n %}
{% load i18n i18n_helpers %}
{% get_current_language as LANGUAGE_CODE %}
<div class="header-right">
<form action="{% url 'set_language' %}" method="post" class="language-switcher-form d-inline-flex align-items-center me-2" aria-label="Language switcher">
<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.get_full_path }}">
<label for="header-language-switcher" class="visually-hidden">{% trans "Language" %}</label>
<select id="header-language-switcher" name="language" class="form-select form-select-sm" style="width:auto; min-width:84px;" onchange="this.form.submit()">
{% get_current_language as LANGUAGE_CODE %}
{% get_available_languages as LANGUAGES %}
{% for code, name in LANGUAGES %}
<option value="{{ code }}" {% if code == LANGUAGE_CODE %}selected{% endif %}>{{ code|upper }}</option>
{% endfor %}
<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>

View File

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

View File

@@ -0,0 +1,120 @@
from __future__ import annotations
from django import template
from django.conf import settings
from wagtail.models import Page
register = template.Library()
def _normalize_language_code(language_code: str | None) -> str:
return (language_code or settings.LANGUAGE_CODE).split("-")[0]
def _fallback_locale_url(language_code: str) -> str:
default_language = _normalize_language_code(settings.LANGUAGE_CODE)
target_language = _normalize_language_code(language_code)
return "/" if target_language == default_language else f"/{target_language}/"
def _is_translatable_page(page) -> bool:
return page is not None and hasattr(page, "translation_key") and hasattr(page, "locale")
def _translated_pages(page):
if not _is_translatable_page(page):
return {}
return {
_normalize_language_code(translated.locale.language_code): translated
for translated in Page.objects.filter(translation_key=page.translation_key)
.live()
.public()
.specific()
}
def _build_absolute_url(request, path: str | None, page=None) -> str:
if path and request is not None:
return request.build_absolute_uri(path)
if page is not None:
return getattr(page, "full_url", "") or path or ""
return path or ""
@register.simple_tag
def page_language_options(page):
labels = {
_normalize_language_code(code): label
for code, label in settings.LANGUAGES
}
if not _is_translatable_page(page):
return [
{
"code": _normalize_language_code(code),
"label": labels.get(_normalize_language_code(code), _normalize_language_code(code)),
"url": _fallback_locale_url(code),
}
for code, _label in settings.LANGUAGES
]
translations = _translated_pages(page)
options = []
for code, _label in settings.LANGUAGES:
language_code = _normalize_language_code(code)
translated_page = translations.get(language_code)
options.append(
{
"code": language_code,
"label": labels.get(language_code, language_code),
"url": translated_page.url if translated_page is not None else _fallback_locale_url(language_code),
}
)
return options
@register.simple_tag(takes_context=True)
def page_canonical_url(context):
request = context.get("request")
page = context.get("page") or context.get("self")
if page is not None and getattr(page, "url", None):
return _build_absolute_url(request, page.url, page)
if request is not None:
return request.build_absolute_uri()
return ""
@register.simple_tag(takes_context=True)
def page_hreflang_links(context):
request = context.get("request")
page = context.get("page") or context.get("self")
if not _is_translatable_page(page):
return []
translations = _translated_pages(page)
links = []
for code, _label in settings.LANGUAGES:
language_code = _normalize_language_code(code)
translated_page = translations.get(language_code)
if translated_page is None or not getattr(translated_page, "url", None):
continue
links.append(
{
"code": language_code,
"url": _build_absolute_url(request, translated_page.url, translated_page),
}
)
default_language = _normalize_language_code(settings.LANGUAGE_CODE)
default_page = translations.get(default_language)
if default_page is not None and getattr(default_page, "url", None):
links.append(
{
"code": "x-default",
"url": _build_absolute_url(request, default_page.url, default_page),
}
)
return links

View File

@@ -0,0 +1,26 @@
from django import template
from wagtail.models import Site
from mandelstudio.models import LocalizedFooterContent
register = template.Library()
@register.simple_tag(takes_context=True)
def localized_footer_content(context):
request = context.get("request")
if request is None:
return None
site = getattr(request, "site", None) or Site.find_for_request(request)
if site is None:
return None
language_code = getattr(request, "LANGUAGE_CODE", None)
if not language_code:
return None
return (
LocalizedFooterContent.objects.filter(
site=site,
locale__language_code=language_code,
)
.first()
)

View File

@@ -0,0 +1,23 @@
from __future__ import annotations
from django import template
register = template.Library()
SKIP_TO_CONTENT = {
"nl": "Ga naar inhoud",
"en": "Skip to content",
"de": "Zum Inhalt springen",
"fr": "Aller au contenu",
"es": "Ir al contenido",
"it": "Vai al contenuto",
"pt": "Ir para o conteúdo",
"ru": "Перейти к содержанию",
}
@register.simple_tag(takes_context=True)
def skip_to_content_text(context) -> str:
request = context.get("request")
language_code = getattr(request, "LANGUAGE_CODE", "nl")
return SKIP_TO_CONTENT.get(language_code, SKIP_TO_CONTENT["en"])

View File

@@ -1,13 +1,26 @@
from django.conf.urls.i18n import i18n_patterns
from django.views.decorators.cache import cache_page
from django.urls import include, path
from ocyan.main.urls import urlpatterns as ocyan_urlpatterns
from ocyan.plugin.wagtail_oscar_integration.constants import CACHE_DURATION
from .sitemaps import robots_txt
from .sitemaps import sitemap_index
from .sitemaps import sitemap_section
urlpatterns = [
path("i18n/", include("django.conf.urls.i18n")),
path("robots.txt", robots_txt, name="robots-txt"),
path(
"sitemap.xml",
cache_page(CACHE_DURATION)(sitemap_index),
name="sitemap-index",
),
path(
"sitemap-<section>.xml",
cache_page(CACHE_DURATION)(sitemap_section),
name="sitemaps",
),
]
urlpatterns += i18n_patterns(
*ocyan_urlpatterns,
prefix_default_language=False,
)
urlpatterns += ocyan_urlpatterns

View File

@@ -1,62 +1,72 @@
#!/usr/bin/env bash
set -euo pipefail
: "${STAGING_AUDIT_PROJECT_NAME:?STAGING_AUDIT_PROJECT_NAME is required}"
: "${STAGING_AUDIT_HOST:?STAGING_AUDIT_HOST is required}"
: "${STAGING_AUDIT_PROJECT_DIR:?STAGING_AUDIT_PROJECT_DIR 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}
ARTIFACT_DIR=${ARTIFACT_DIR:-artifacts}
OUTPUT_JSON=${OUTPUT_JSON:-${ARTIFACT_DIR}/multilingual-audit.json}
mkdir -p "${ARTIFACT_DIR}"
TMP_FILE=$(mktemp)
trap 'rm -f "$TMP_FILE"' EXIT
OUT_FILE="artifacts/multilingual-audit.json"
TMP_FILE="${OUT_FILE}.tmp"
write_failure_json() {
python3 - <<PY > "$OUT_FILE"
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"
set +e
STAGING_AUDIT_PROJECT_NAME="$STAGING_AUDIT_PROJECT_NAME" REMOTE_CMD="$REMOTE_CMD" AUDIT_TIMEOUT_SECONDS="$AUDIT_TIMEOUT_SECONDS" python3 - <<'PY2' > "$TMP_FILE"
import json
SSH_OPTS="$SSH_OPTS" STAGING_AUDIT_HOST="$STAGING_AUDIT_HOST" REMOTE_CMD="$REMOTE_CMD" AUDIT_TIMEOUT_SECONDS="$AUDIT_TIMEOUT_SECONDS" python3 - <<'PY' > "$TMP_FILE"
import os
import shlex
import subprocess
import sys
project = os.environ["STAGING_AUDIT_PROJECT_NAME"]
remote_cmd = os.environ["REMOTE_CMD"]
timeout_seconds = int(os.environ["AUDIT_TIMEOUT_SECONDS"])
cmd = [
"sudo", "-n", "-u", "mandel", "-g", "www-data",
"/srv/apps/mandel-dashboard/.venv/bin/python",
"/srv/apps/mandel-dashboard/bin/deploy_stg_from_jenkins.py",
project,
"--command",
remote_cmd,
]
ssh_opts = shlex.split(os.environ["SSH_OPTS"])
cmd = ["ssh", *ssh_opts, os.environ["STAGING_AUDIT_HOST"], os.environ["REMOTE_CMD"]]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout_seconds, check=False)
except subprocess.TimeoutExpired:
print(json.dumps({
"error": "audit_failed",
"details": f"Audit command timed out after {timeout_seconds} seconds",
"exit_code": 124,
}, indent=2))
sys.exit(2)
stdout = result.stdout.strip()
stderr = result.stderr.strip()
if result.returncode != 0:
if stdout:
print(stdout)
else:
print(json.dumps({
"error": "audit_failed",
"details": stderr or f"Audit command failed with exit status {result.returncode}",
"exit_code": result.returncode,
}, indent=2))
sys.exit(2)
print(stdout)
PY2
status=$?
proc = subprocess.run(
cmd,
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=int(os.environ["AUDIT_TIMEOUT_SECONDS"]),
)
sys.stdout.write(proc.stdout)
sys.stderr.write(proc.stderr)
except subprocess.TimeoutExpired as exc:
sys.stderr.write(exc.stderr or "")
raise SystemExit(124)
except subprocess.CalledProcessError as exc:
sys.stdout.write(exc.stdout or "")
sys.stderr.write(exc.stderr or "")
raise SystemExit(exc.returncode)
PY
rc=$?
set -e
cp "$TMP_FILE" "$OUTPUT_JSON"
cat "$OUTPUT_JSON"
exit $status
if [[ $rc -eq 0 ]]; then
mv "$TMP_FILE" "$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

View File

@@ -3,7 +3,7 @@ import json
from setuptools import find_packages, setup
install_requires: list = ["setuptools", "ocyan.main"]
install_requires: list = ["setuptools", "ocyan.main", "elasticsearch<9"]
# Add frets dependencies
with open("mandelstudio/ocyan.json", encoding="utf-8") as fp:

View File

@@ -0,0 +1,36 @@
{% load staticfiles %}
{% load wagtailcore_tags wagtailimages_tags wagtailsettings_tags cache mandelstudio_footer %}
{% get_settings %}
{% localized_footer_content as localized_footer %}
{% cache 300 footer_menu LANGUAGE_CODE request.site %}
<footer class="footer">
<div class="container">
<div class="row">
{% with footer=localized_footer.footer|default:settings.ocyan_plugin_wagtail.OcyanSettings.footer %}
{% for block in footer %}
<div class="col-lg-3 col-md-6 col-sm-12 footer_column {{ block.block_type|slugify }}">
{% include_block block %}
</div>
{% endfor %}
{% endwith %}
</div>
</div>
</footer>
<section class="copyright_wrapper">
<div class="container">
<div class="row">
<div class="col-lg-12 copyright_block">
{% if localized_footer and localized_footer.mini_footer %}
{% for block in localized_footer.mini_footer %}
{% include_block block %}
{% endfor %}
{% else %}
{% include_block settings.ocyan_plugin_wagtail.OcyanSettings.mini_footer %}
{% endif %}
</div>
</div>
</div>
</section>
{% endcache %}