210 Commits

Author SHA1 Message Date
0ff32da99a Fix multilingual cookie consent rendering and locale support 2026-05-16 00:10:48 +02:00
3959d041c4 Use compiled layout_overrides.css in layout template 2026-05-15 00:01:04 +02:00
f3b43b1208 Always load cookie assets and mount cookie banner modal 2026-05-14 04:42:21 +02:00
76be2e7f41 Show cookie modal when consent cookie is missing 2026-05-14 04:22:08 +02:00
e36cb45912 Use SCSS layout overrides include in layout template 2026-05-14 03:57:18 +02:00
8d7c21a9df Serve layout overrides via compiled CSS to avoid manifest SCSS lookup 2026-05-14 02:59:18 +02:00
dab39b37cc Restore layout_overrides.scss include in layout base_css 2026-05-14 02:44:52 +02:00
aec19fbdfa Remove manifest-breaking layout_overrides scss include 2026-05-14 01:52:34 +02:00
f95ab7465e Include project static directory for manifest assets 2026-05-14 01:45:06 +02:00
8607ee33e9 Remove compiled locale binaries from repo 2026-05-14 01:27:51 +02:00
f702895fd0 Format urls.py for CI lint 2026-05-14 01:12:32 +02:00
e9f94ebaf6 Polish cookie consent modal and add i18n translations 2026-05-14 01:05:50 +02:00
e33b5f8757 Redesign cookie consent as centered glass modal 2026-05-14 00:48:23 +02:00
1ddc7b10f6 Refine header language switcher and align i18n search routes 2026-05-14 00:15:43 +02:00
1c991756c1 Hide language chevron and force globe icon styling 2026-05-13 23:18:37 +02:00
daf5e16734 Use inline SVG globe for language switcher icon 2026-05-13 23:16:17 +02:00
e974029f9f Add inline critical header alignment fallback 2026-05-13 23:11:44 +02:00
4fb7b3ee1f Use manifest-aware static include for header overrides stylesheet 2026-05-13 22:46:00 +02:00
c663a1cccb Use external header overrides CSS via static prefix 2026-05-13 22:34:46 +02:00
92fbacff02 Hotfix staging: inline header overrides to avoid static manifest lookup 2026-05-13 22:29:13 +02:00
a6bab14970 Fix header action alignment and language switcher spacing 2026-05-13 22:00:15 +02:00
c4da0045fb Disable varnish middleware on staging 2026-05-12 00:12:44 +02:00
34d351b2f5 Disable caching for first-visit unlocalized homepage 2026-05-12 00:05:53 +02:00
b8e5272e26 Wrap long condition in language redirect middleware 2026-05-11 23:56:45 +02:00
8a0c2849c0 Add Accept-Language fallback for first-visit redirect 2026-05-11 23:53:11 +02:00
bbd9356517 Force HTTPS remote in staging sync step 2026-05-11 21:12:26 +02:00
bc8d4d3824 Use HTTPS Git remote in Jenkins pipeline checkout steps 2026-05-11 21:03:37 +02:00
8440fe3823 Add first-visit language redirect middleware 2026-05-11 20:38:50 +02:00
f65b6e3b48 Format settings after env isolation changes 2026-05-10 15:55:55 +02:00
6d2306645a Isolate staging and production settings paths 2026-05-10 15:53:47 +02:00
2e81970427 Use project contact form handler and superuser-only snippet access 2026-05-10 11:00:18 +02:00
c6965c422b Revert Editors ContactMessage perms 2026-05-09 21:41:49 +02:00
e53ccc4e37 Grant ContactMessage perms to staff users 2026-05-09 17:02:35 +02:00
530d9c5eb7 Grant ContactMessage snippet perms to Editors 2026-05-09 16:55:14 +02:00
0d721e1f03 Allow staging/production hostnames 2026-05-09 13:29:32 +02:00
781a873ac3 Show Contact messages under Snippets 2026-05-09 12:48:27 +02:00
9f98b071a5 Rely on deploy-project-stg for migrations 2026-05-09 12:29:44 +02:00
9e10c734fb Run staging migrations after deploy 2026-05-09 12:19:37 +02:00
b4dec87874 Do not mark Jenkins unstable on audit transport failure 2026-05-09 11:46:11 +02:00
6caff50a84 Do not fail pipeline on audit transport errors 2026-05-09 11:24:11 +02:00
3aae374c89 Fix import order for hooks 2026-05-09 11:15:24 +02:00
862b6905c6 Fix ContactMessage migration dependency 2026-05-09 11:10:42 +02:00
3b02100f75 Store contact form submissions in Wagtail admin 2026-05-09 11:05:20 +02:00
df28667a9c Fix category links across locales 2026-05-04 20:18:30 +02:00
210f90b899 fix: repair RU capabilities CTA note 2026-05-03 03:37:14 +02:00
e04b5dd8b4 fix: clean no-credit-card copy in CTA footer 2026-05-03 03:29:35 +02:00
0d18d3b526 Reapply "fix: populate capabilities FAQ across locales"
This reverts commit 0910ff850a.
2026-05-03 03:13:37 +02:00
0910ff850a Revert "fix: populate capabilities FAQ across locales"
This reverts commit 9c8d6a8ecf.
2026-05-03 03:13:21 +02:00
9c8d6a8ecf fix: populate capabilities FAQ across locales 2026-05-03 03:06:28 +02:00
37650b3325 fix: enforce apex redirect using Host header 2026-05-03 02:23:16 +02:00
72de8844bb chore: format middleware 2026-05-03 02:17:21 +02:00
556faacc78 prod: redirect apex mandelblog.com to www 2026-05-03 02:16:51 +02:00
856f7333d4 ci: wait for staging to be healthy before audit 2026-05-03 01:54:21 +02:00
0919739688 ci: recompress staging assets after deploy 2026-05-03 01:48:39 +02:00
d8e1542e82 revert: remove project scss include breaking staging 2026-05-03 01:40:48 +02:00
f89951aac4 ci: only block multilingual audit on enabled locales 2026-05-03 01:35:14 +02:00
53fbc7fb38 fix: mobile header polish + move language styles to scss 2026-05-03 01:27:39 +02:00
4a24a125f5 Revert "fix: header ESI fragment tolerates missing basket"
This reverts commit 891639c7fc.
2026-05-03 01:03:45 +02:00
165bf47291 Revert "ci: print homepage exception in staging template debug"
This reverts commit 9624eec735.
2026-05-03 01:03:45 +02:00
f109e60b03 Revert "mobile header: tighten layout and fix menu overlay"
This reverts commit 3eac7ca0b6.
2026-05-03 01:03:45 +02:00
8066793131 Revert "ci: only block on configured i18n locales"
This reverts commit 7a3c649fb4.
2026-05-03 01:03:45 +02:00
7a3c649fb4 ci: only block on configured i18n locales 2026-05-03 00:57:21 +02:00
3eac7ca0b6 mobile header: tighten layout and fix menu overlay 2026-05-03 00:51:51 +02:00
9624eec735 ci: print homepage exception in staging template debug 2026-05-03 00:45:41 +02:00
891639c7fc fix: header ESI fragment tolerates missing basket 2026-05-03 00:40:41 +02:00
be7831b42e Revert "mobile header: tighten layout and fix menu layering"
This reverts commit 99b03d4695.
2026-05-03 00:33:26 +02:00
80ab2afdbb Revert "fix: ship header_mobile scss via app static"
This reverts commit c5601cfe79.
2026-05-03 00:33:26 +02:00
3e0c9c14a2 Revert "mobile header: ship CSS without SCSS"
This reverts commit b7cb932359.
2026-05-03 00:33:26 +02:00
d2f62ff549 Revert "ci: print layout render error in template debug"
This reverts commit 5359a0a5e2.
2026-05-03 00:33:26 +02:00
5359a0a5e2 ci: print layout render error in template debug 2026-05-03 00:28:22 +02:00
b7cb932359 mobile header: ship CSS without SCSS 2026-05-03 00:23:02 +02:00
c5601cfe79 fix: ship header_mobile scss via app static 2026-05-03 00:16:11 +02:00
99b03d4695 mobile header: tighten layout and fix menu layering 2026-05-03 00:09:09 +02:00
6e00d1d2f2 header: add Our Collection mega menu; remove inline search 2026-05-02 21:55:11 +02:00
1d30ba4140 fix: language switcher links to locale home 2026-05-02 21:38:35 +02:00
5ae989c32d Revert "fix: language switcher uses translated page URLs"
This reverts commit 6b46751fe3.
2026-05-02 21:33:03 +02:00
b73ae5ea32 Revert "fix: robust language switcher links"
This reverts commit d4410b1f68.
2026-05-02 21:33:03 +02:00
d4410b1f68 fix: robust language switcher links 2026-05-02 21:27:19 +02:00
6b46751fe3 fix: language switcher uses translated page URLs 2026-05-02 21:20:13 +02:00
3bf0c72ce5 style: ruff format normalize_services_menu 2026-05-02 20:36:28 +02:00
e7bcbe53ab staging: normalize Services menu across locales 2026-05-02 20:33:16 +02:00
348d14c330 jenkins: sync staging source before deploy 2026-04-26 14:13:57 +02:00
7a062db36b Audit: show whether Carbasa header overrides exist on staging 2026-04-26 14:07:55 +02:00
f7b48450df Audit: print template debug info in Jenkins logs 2026-04-26 14:03:17 +02:00
848b8aae54 Audit: capture template origins from staging 2026-04-26 13:59:51 +02:00
5d66fe750a Staging: load repo template overrides for Carbasa header 2026-04-26 13:54:05 +02:00
65fd0de4fc Remove stray header debug text 2026-04-26 13:36:09 +02:00
504609f7a4 Override Carbasa header via app templates 2026-04-26 13:24:36 +02:00
ee51a03147 Override Carbasa header to use webshop layout 2026-04-26 13:20:39 +02:00
3c27ca78b0 Use Carbasa webshop header when Oscar enabled 2026-04-26 13:15:43 +02:00
fbe8acc390 CI: do not fail build on CTA language mismatch 2026-04-26 13:02:37 +02:00
cfc04b37f4 CI: ensure audit script can import project modules 2026-04-26 12:58:57 +02:00
57907f0d1e CI: ignore legacy CTA audit mismatches when allowed 2026-04-26 12:54:40 +02:00
963f4647b2 Allow German/Spanish CTA phrasing in audit 2026-04-26 12:46:58 +02:00
734fdd1b8b Appease ruff import-order check 2026-04-26 12:42:45 +02:00
2095e417cd Format settings for ruff 2026-04-26 12:39:52 +02:00
7c95eb9e5f Polish language switcher dropdown 2026-04-26 12:34:03 +02:00
e1e237569f Improve language switcher icon SVG 2026-04-26 12:20:44 +02:00
9e2a67dede Fix language dropdown trigger icon 2026-04-26 12:17:01 +02:00
edd29502d1 Revert "Polish header language dropdown styling"
This reverts commit 404dd8fe98.
2026-04-26 12:16:00 +02:00
404dd8fe98 Polish header language dropdown styling 2026-04-26 12:10:40 +02:00
fba487f21c Add flag dropdown language switcher 2026-04-26 12:05:31 +02:00
b06527e17d Add header language switcher and local json config 2026-04-26 10:18:51 +02:00
7350e86bcb Restore popup search modal 2026-04-26 10:14:30 +02:00
6d10d9cb49 Load Carbasa JS uncompressed for header search 2026-04-26 10:07:22 +02:00
647018b698 Load Carbasa header JS (search toggle) 2026-04-26 10:01:00 +02:00
8a8762bd6d Use Carbasa webshop user bar and basket dropdown 2026-04-26 09:59:04 +02:00
0c735f2b69 Enable Carbasa webshop templates for Oscar 2026-04-26 09:53:31 +02:00
59a1cd3c16 Fix local Carbasa header rendering 2026-04-26 09:49:32 +02:00
e394eb0288 Restore styled footer shell 2026-04-26 09:43:39 +02:00
93e2d7910a Revert "Header: add language switcher + home menu"
This reverts commit bd49f6be6e.
2026-04-26 01:15:56 +02:00
043dd6620b Revert "Header: render language chooser in Carbasa non-webshop"
This reverts commit dbf48c49e7.
2026-04-26 01:15:56 +02:00
5c31142b03 Revert "Carbasa header: add language switcher"
This reverts commit 886188ed85.
2026-04-26 01:15:56 +02:00
149a5d0a1b Revert "Fix Carbasa header overrides cleanly"
This reverts commit c7adaf94b4.
2026-04-26 01:15:56 +02:00
c7adaf94b4 Fix Carbasa header overrides cleanly 2026-04-26 01:09:30 +02:00
886188ed85 Carbasa header: add language switcher 2026-04-26 00:49:20 +02:00
dbf48c49e7 Header: render language chooser in Carbasa non-webshop 2026-04-26 00:44:04 +02:00
bd49f6be6e Header: add language switcher + home menu 2026-04-26 00:41:05 +02:00
8b38812a23 Revert Carbasa header override 2026-04-25 23:27:22 +02:00
d10575403f Fix Carbasa header double logo 2026-04-25 23:25:13 +02:00
f54df55c56 Use real Carbasa header override 2026-04-20 21:54:38 +02:00
7587841873 Restore engine templates to dynamic Carbasa header flow 2026-04-12 09:30:40 +02:00
932232d52b Sort i18n view imports for CI lint 2026-04-11 21:07:58 +02:00
b6c0a18098 Use django.urls.translate_url for setlang compatibility 2026-04-11 21:05:22 +02:00
d9ecab62e3 Fix localized setlang redirects for prefixed next paths 2026-04-11 21:03:29 +02:00
497addffb2 Bypass wrapped CSRF in custom setlang proxy 2026-04-11 20:55:30 +02:00
605f1e8276 Fix setlang redirect normalization for locale variants 2026-04-11 20:48:55 +02:00
58139b08ff fix(i18n): normalize setlang next path server-side 2026-04-10 23:03:15 +02:00
944e88d78d style(i18n): apply ruff formatting for CI lint 2026-04-10 22:49:15 +02:00
8b95fa5b2b fix(i18n): strip existing language prefix in manage language switch 2026-04-10 22:41:53 +02:00
89773de4d1 fix(i18n): normalize manage language-switch next URL 2026-04-10 22:21:02 +02:00
462a5b6b62 render Django messages on modern saas page templates 2026-04-10 20:44:17 +02:00
05b0e3a429 contact form: show inline submit feedback messages 2026-04-10 20:37:42 +02:00
f59fa106f6 Use dashboard deploy helper for multilingual audit 2026-04-10 20:12:58 +02:00
5e49eb93a2 Run multilingual audit on Jenkins built-in node 2026-04-10 18:54:30 +02:00
b86849b1e4 Harden Jenkins checkout bootstrap 2026-04-10 18:39:59 +02:00
3056bfecd8 Reuse workspace for multilingual audit 2026-04-10 18:38:50 +02:00
e450f8a8b0 Run multilingual audit on external_pool 2026-04-10 18:35:57 +02:00
fcabba0da2 Fix import ordering for Jenkins lint 2026-04-10 18:18:49 +02:00
034a804e02 Format files required by Jenkins lint 2026-04-10 18:15:50 +02:00
ea011b2993 Patch invalid invoice admin registration 2026-04-10 18:12:01 +02:00
d1c6a5f85c Align initial migration with Wagtail 7.3.1 2026-04-10 18:06:08 +02:00
3e12189335 Respect disabled payments in launch validation 2026-04-10 17:38:23 +02:00
489c6ce75b Fix payment plugin launch validation 2026-04-10 17:33:04 +02:00
610fd6d748 Fix staging audit env in Jenkins pipeline 2026-04-10 17:23:20 +02:00
bbb88f9a2f Tighten dummy payment validation 2026-04-09 01:06:49 +02:00
4648b7b0b3 Filter demo-data plugins from production settings 2026-04-09 01:01:46 +02:00
fb55d59b77 Patch payment validator for Django 5 compatibility 2026-04-09 00:59:35 +02:00
cf33be8361 Add payment provider validation entrypoint 2026-04-09 00:52:25 +02:00
310ac83bc4 Merge remote master before deployment 2026-04-09 00:48:25 +02:00
93b72b306c Merge production refresh for live deploy 2026-04-09 00:48:11 +02:00
8bfd4d789b production refresh 2026-04-09 00:42:40 +02:00
e4c6e3dcef Add launch pipeline and idea marketplace seed commands 2026-04-09 00:29:23 +02:00
7db05fea47 Add launch pipeline and idea marketplace seed commands 2026-04-09 00:28:42 +02:00
a6bb1622be Fix demo purge import formatting 2026-04-06 02:46:28 +02:00
4003f698d2 Format demo purge for Jenkins lint 2026-04-06 02:42:58 +02:00
5f0c7bd9b9 Format demo purge and remove demo plugins 2026-04-06 02:40:41 +02:00
dbc9fe87c6 Make demo purge independent of Elasticsearch 2026-04-06 02:38:02 +02:00
90e24976df Bypass checks for demo purge step 2026-04-06 02:36:05 +02:00
f15b1d4eab Add demo data purge command 2026-04-06 02:34:12 +02:00
7d9bb0665e Remove demo data loading from build 2026-04-06 02:31:27 +02:00
57f4c0044a Remove demo data loading from build 2026-04-06 02:30:24 +02:00
3c8e7e923f Force Carbasa header for config-driven engine header variants 2026-04-03 22:22:07 +02:00
095248277e Route engine header partial to Carbasa header 2026-04-03 22:13:12 +02:00
0d0a2cb36c Route engine header partial to Carbasa header 2026-04-03 22:12:20 +02:00
ee5fbf6e78 Force Carbasa header include in engine page templates 2026-04-03 19:17:29 +02:00
d571731fd6 Render Carbasa header directly from layout to avoid header resolver variant drift 2026-04-03 19:13:03 +02:00
537d7cf0da Set Carbasa header override to collection dropdown + user icons 2026-04-03 19:09:04 +02:00
4e465d2c3c Restore Carbasa header as active source and remove injected header styling 2026-04-03 19:00:41 +02:00
27db3bc536 Restore Carbasa as active header source and remove webshop mega menu 2026-04-03 18:53:59 +02:00
0ca82391c1 Add missing corner SVG template for clean release 2026-04-03 07:00:41 +02:00
b2329d5d4d Fix locale switcher URL in shared header 2026-04-03 02:17:24 +02:00
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
f093a201d1 Run multilingual audit stages on built-in Jenkins node 2026-03-29 21:34:30 +02:00
fb6f2e861d Fix import ordering for multilingual CI lint 2026-03-29 21:28:31 +02:00
644d3c0b7b Fix import ordering for multilingual CI lint 2026-03-29 21:28:12 +02:00
51b2fd574c Format multilingual audit extraction for CI lint 2026-03-29 21:25:37 +02:00
bfdf061f31 Format multilingual audit extraction for CI lint 2026-03-29 21:25:01 +02:00
c516d72c8a Document multilingual audit CI operations 2026-03-29 20:58:34 +02:00
da0798c218 Document multilingual audit CI operations 2026-03-29 20:57:58 +02:00
e3bafd3a73 Add multilingual audit CI pipeline + extract mandelblog_content_guard 2026-03-29 20:50:21 +02:00
1f05011a63 Add multilingual audit CI pipeline + extract mandelblog_content_guard 2026-03-29 20:49:42 +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
2a51989fa4 template-engine: always render carbasa footer corners on engine pages 2026-03-17 17:48:28 +01:00
2513f32d5d template-engine: apply carbasa footer-corners shell to engine page template 2026-03-17 17:44:10 +01:00
b9f3a06cb8 template-engine: restore carbasa shell footer corners for engine pages 2026-03-17 17:39:43 +01:00
208 changed files with 16216 additions and 164 deletions

3
.gitignore vendored
View File

@@ -25,4 +25,5 @@ pyvenv.cfg
.coverage .coverage
coverage.xml coverage.xml
htmlcov/ htmlcov/
venv/ venv/
.venv/

210
Jenkinsfile vendored
View File

@@ -6,32 +6,67 @@ pipeline {
disableConcurrentBuilds() disableConcurrentBuilds()
skipDefaultCheckout(true) skipDefaultCheckout(true)
} }
parameters {
booleanParam(
name: 'RUN_DEMO_PURGE',
defaultValue: false,
description: 'Run a one-time demo catalogue purge before the normal idea marketplace seed and launch prep.'
)
}
environment { environment {
PYENVPIPELINE_VIRTUALENV = '1' PYENVPIPELINE_VIRTUALENV = '1'
GIT_SSH_COMMAND = 'ssh -o StrictHostKeyChecking=accept-new' GIT_SSH_COMMAND = 'ssh -o StrictHostKeyChecking=accept-new'
STAGING_AUDIT_PROJECT_NAME = 'mandelstudio'
STAGING_AUDIT_PROJECT_DIR = '/home/www-mandelstudio/mandelstudio'
STAGING_AUDIT_MANAGE = '/var/lib/virtualenv/mandelstudio/bin/manage.py'
} }
stages { stages {
stage('Checkout') { stage('Checkout') {
steps { steps {
withCredentials([sshUserPrivateKey(credentialsId: 'gitea-ssh', keyFileVariable: 'GIT_KEYFILE')]) { sh '''
sh ''' if [ -d .git ]; then
export GIT_SSH_COMMAND="ssh -i $GIT_KEYFILE -o StrictHostKeyChecking=accept-new" if git remote get-url origin >/dev/null 2>&1; then
if [ -d .git ]; then git remote set-url origin https://git.mandelblog.com/salt/mandelstudio.git
git remote set-url origin ssh://git@git.mandelblog.com:2222/salt/mandelstudio.git
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 remote add origin https://git.mandelblog.com/salt/mandelstudio.git
git fetch --tags --force --progress origin +refs/heads/master:refs/remotes/origin/master
fi fi
git checkout -f refs/remotes/origin/master git fetch --tags --force --progress origin +refs/heads/master:refs/remotes/origin/master
''' else
} git clone https://git.mandelblog.com/salt/mandelstudio.git .
git fetch --tags --force --progress origin +refs/heads/master:refs/remotes/origin/master
fi
git checkout -f refs/remotes/origin/master
'''
} }
} }
stage('Build') { stage('Build') {
steps { steps {
sh ''' sh '''
STABLE_INDEX_URL=${STABLE_INDEX_URL:-https://pypi.mandelblog.com/mandel/stable/+simple/}
TESTING_INDEX_URL=${TESTING_INDEX_URL:-https://pypi.mandelblog.com/mandel/testing/+simple/}
ROOT_INDEX_URL=${PIP_EXTRA_INDEX_URL:-https://pypi.mandelblog.com/root/pypi/+simple/}
export STABLE_INDEX_URL
if python3 - <<'PY'
import os
import sys
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError
url = os.environ["STABLE_INDEX_URL"]
try:
req = Request(url, method='HEAD')
with urlopen(req, timeout=10) as response:
sys.exit(0 if response.status < 400 else 1)
except HTTPError as exc:
sys.exit(0 if exc.code < 400 else 1)
except URLError:
sys.exit(1)
PY
then
echo "devpi stable index available, but stable-first install is not enabled yet"
else
echo "devpi stable index not available, using testing as production source"
fi
if command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then if command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then
sudo apt-get update -y sudo apt-get update -y
sudo apt-get install -y python3-venv python3-pip make build-essential libpq-dev \ sudo apt-get install -y python3-venv python3-pip make build-essential libpq-dev \
@@ -48,14 +83,20 @@ pipeline {
. .venv/bin/activate . .venv/bin/activate
pip install coverage pip install coverage
pip install --upgrade pip "setuptools==69.5.1" wheel pip install --upgrade pip "setuptools==69.5.1" wheel
PIP_INDEX_URL=${PIP_INDEX_URL:-https://pypi.mandelblog.com/mandel/testing/+simple/} \ PIP_INDEX_URL="$TESTING_INDEX_URL" \
PIP_EXTRA_INDEX_URL=${PIP_EXTRA_INDEX_URL:-https://pypi.mandelblog.com/root/pypi/+simple/} \ PIP_EXTRA_INDEX_URL="$ROOT_INDEX_URL" \
pip install --no-build-isolation --pre --editable . setuptools wheel --upgrade --upgrade-strategy=eager --use-deprecated=legacy-resolver pip install --no-build-isolation --pre --editable . setuptools wheel --upgrade --upgrade-strategy=eager --use-deprecated=legacy-resolver
cp "${JOB_BASE_NAME}/ocyan.json" "${JOB_BASE_NAME}/${JOB_BASE_NAME}.json" cp "${JOB_BASE_NAME}/ocyan.json" "${JOB_BASE_NAME}/${JOB_BASE_NAME}.json"
pip install ruff vdt.versionplugin.wheel pip install ruff vdt.versionplugin.wheel
pip install --upgrade "setuptools==69.5.1" wheel pip install --upgrade "setuptools==69.5.1" wheel
python3 scripts/validate_payment_provider_config.py
manage.py migrate --no-input --skip-checks manage.py migrate --no-input --skip-checks
manage.py loaddemodata || true if [ "${RUN_DEMO_PURGE}" = "true" ]; then
manage.py purge_demo_data
fi
manage.py seed_idea_marketplace
manage.py prepare_idea_marketplace_launch --apply-homepage-copy --purge-demo-pages
manage.py validate_idea_marketplace_launch
manage.py collectstatic --no-input --verbosity=0 manage.py collectstatic --no-input --verbosity=0
pip install "httpx<0.28" pip install "httpx<0.28"
''' '''
@@ -74,7 +115,7 @@ pipeline {
steps { steps {
sh ''' sh '''
. .venv/bin/activate . .venv/bin/activate
python -m compileall -q setup.py mandelstudio python -m compileall -q setup.py mandelstudio mandelblog_content_guard
''' '''
} }
post { post {
@@ -86,6 +127,141 @@ pipeline {
} }
} }
} }
stage('Sync Staging Source') {
agent { label 'built-in' }
options {
timeout(time: 5, unit: 'MINUTES')
}
steps {
sh '''
set -e
REMOTE_CMD="cd '${STAGING_AUDIT_PROJECT_DIR}' && if [ -d .git ]; then git remote set-url origin https://git.mandelblog.com/salt/mandelstudio.git && git fetch --prune origin && git reset --hard origin/master && git rev-parse --short HEAD; else echo 'NO_GIT_REPO'; fi"
sudo -n -u mandel -g www-data /srv/apps/mandel-dashboard/.venv/bin/python /srv/apps/mandel-dashboard/bin/deploy_stg_from_jenkins.py "${STAGING_AUDIT_PROJECT_NAME}" --command "$REMOTE_CMD"
'''
}
}
stage('Deploy Staging') {
steps {
echo 'Triggering staging deploy for mandelstudio after successful CI build.'
build job: 'deploy-project-stg',
wait: true,
propagate: true,
parameters: [string(name: 'PROJECT_NAME', value: 'mandelstudio')]
}
}
stage('Normalize Services Menu') {
agent { label 'built-in' }
options {
timeout(time: 5, unit: 'MINUTES')
}
steps {
sh '''
set -e
REMOTE_CMD="cd '${STAGING_AUDIT_PROJECT_DIR}' && '${STAGING_AUDIT_MANAGE}' normalize_services_menu"
sudo -n -u mandel -g www-data /srv/apps/mandel-dashboard/.venv/bin/python /srv/apps/mandel-dashboard/bin/deploy_stg_from_jenkins.py "${STAGING_AUDIT_PROJECT_NAME}" --command "$REMOTE_CMD"
'''
}
}
stage('Fix Capabilities FAQ') {
agent { label 'built-in' }
options {
timeout(time: 5, unit: 'MINUTES')
}
steps {
sh '''
set -e
REMOTE_CMD="cd '${STAGING_AUDIT_PROJECT_DIR}' && '${STAGING_AUDIT_MANAGE}' fix_capabilities_faq --apply"
sudo -n -u mandel -g www-data /srv/apps/mandel-dashboard/.venv/bin/python /srv/apps/mandel-dashboard/bin/deploy_stg_from_jenkins.py "${STAGING_AUDIT_PROJECT_NAME}" --command "$REMOTE_CMD"
'''
}
}
stage('Fix No Credit Card Copy') {
agent { label 'built-in' }
options {
timeout(time: 5, unit: 'MINUTES')
}
steps {
sh '''
set -e
REMOTE_CMD="cd '${STAGING_AUDIT_PROJECT_DIR}' && '${STAGING_AUDIT_MANAGE}' fix_no_credit_card_text --apply --page-id 675"
sudo -n -u mandel -g www-data /srv/apps/mandel-dashboard/.venv/bin/python /srv/apps/mandel-dashboard/bin/deploy_stg_from_jenkins.py "${STAGING_AUDIT_PROJECT_NAME}" --command "$REMOTE_CMD"
'''
}
}
stage('Recompress Staging Assets') {
agent { label 'built-in' }
options {
timeout(time: 10, unit: 'MINUTES')
}
steps {
sh '''
set -e
REMOTE_CMD="cd '${STAGING_AUDIT_PROJECT_DIR}' && '${STAGING_AUDIT_MANAGE}' compress --force"
sudo -n -u mandel -g www-data /srv/apps/mandel-dashboard/.venv/bin/python /srv/apps/mandel-dashboard/bin/deploy_stg_from_jenkins.py "${STAGING_AUDIT_PROJECT_NAME}" --command "$REMOTE_CMD"
'''
}
}
stage('Wait For Staging Health') {
agent { label 'built-in' }
options {
timeout(time: 5, unit: 'MINUTES')
}
steps {
sh '''
set -e
for i in $(seq 1 30); do
code_nl=$(curl -sS -o /dev/null -w "%{http_code}" https://mandelstudio.welkombij.mandelblog.com/ || true)
code_en=$(curl -sS -o /dev/null -w "%{http_code}" https://mandelstudio.welkombij.mandelblog.com/en/ || true)
echo "healthcheck attempt=$i nl=$code_nl en=$code_en"
if [ "$code_nl" = "200" ] && [ "$code_en" = "200" ]; then
exit 0
fi
sleep 10
done
echo "staging did not become healthy in time"
exit 1
'''
}
}
stage('Post-Deploy Multilingual Audit') {
agent { label 'built-in' }
options {
timeout(time: 10, unit: 'MINUTES')
}
steps {
sh '''
if [ -d .git ]; then
if git remote get-url origin >/dev/null 2>&1; then
git remote set-url origin https://git.mandelblog.com/salt/mandelstudio.git
else
git remote add origin https://git.mandelblog.com/salt/mandelstudio.git
fi
git fetch --tags --force --progress origin +refs/heads/master:refs/remotes/origin/master
else
git clone https://git.mandelblog.com/salt/mandelstudio.git .
git fetch --tags --force --progress origin +refs/heads/master:refs/remotes/origin/master
fi
git checkout -f refs/remotes/origin/master
mkdir -p artifacts
chmod +x scripts/run_remote_multilingual_audit.sh
./scripts/run_remote_multilingual_audit.sh
'''
script {
int status = sh(script: 'python3 scripts/multilingual_audit_ci.py --json artifacts/multilingual-audit.json', returnStatus: true)
if (status == 2) {
error('Block-level multilingual issues detected or audit execution failed.')
}
if (status == 1) {
unstable('Warn-level multilingual issues detected.')
}
}
}
post {
always {
archiveArtifacts artifacts: 'artifacts/multilingual-audit.json', onlyIfSuccessful: false
}
}
}
} }
post { post {
always { always {
@@ -97,10 +273,6 @@ pipeline {
. .venv/bin/activate . .venv/bin/activate
pip install coverage pip install coverage
''' '''
echo 'Triggering staging deploy for mandelstudio after successful CI build.'
build job: 'deploy-project-stg',
wait: false,
parameters: [string(name: 'PROJECT_NAME', value: 'mandelstudio')]
} }
failure { failure {
emailext subject: "JENKINS-NOTIFICATION: ${currentBuild.currentResult}: Job '${env.JOB_NAME} #${env.BUILD_NUMBER}'", emailext subject: "JENKINS-NOTIFICATION: ${currentBuild.currentResult}: Job '${env.JOB_NAME} #${env.BUILD_NUMBER}'",

View File

@@ -0,0 +1,65 @@
#!/usr/bin/env groovy
pipeline {
agent none
triggers {
cron('H 2 * * *')
}
options {
disableConcurrentBuilds()
skipDefaultCheckout(true)
}
environment {
STAGING_AUDIT_PROJECT_NAME = 'mandelstudio'
STAGING_AUDIT_PROJECT_DIR = '/home/www-mandelstudio/mandelstudio'
STAGING_AUDIT_MANAGE = '/var/lib/virtualenv/mandelstudio/bin/manage.py'
}
stages {
stage('Checkout') {
agent { label 'built-in' }
steps {
withCredentials([sshUserPrivateKey(credentialsId: 'gitea-ssh', keyFileVariable: 'GIT_KEYFILE')]) {
sh '''
export GIT_SSH_COMMAND="ssh -i $GIT_KEYFILE -o StrictHostKeyChecking=accept-new"
if [ -d .git ]; then
if git remote get-url origin >/dev/null 2>&1; then
git remote set-url origin ssh://git@git.mandelblog.com:2222/salt/mandelstudio.git
else
git remote add origin ssh://git@git.mandelblog.com:2222/salt/mandelstudio.git
fi
git fetch --tags --force --progress origin +refs/heads/master:refs/remotes/origin/master
else
git clone ssh://git@git.mandelblog.com:2222/salt/mandelstudio.git .
git fetch --tags --force --progress origin +refs/heads/master:refs/remotes/origin/master
fi
git checkout -f refs/remotes/origin/master
'''
}
}
}
stage('Nightly Multilingual Audit') {
agent { label 'built-in' }
options {
timeout(time: 10, unit: 'MINUTES')
}
steps {
sh 'mkdir -p artifacts && [ -f artifacts/multilingual-audit.json ] && cp artifacts/multilingual-audit.json artifacts/previous-multilingual-audit.json || true'
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) {
error('Block-level multilingual issues detected or audit execution failed.')
}
if (status == 1) {
unstable('Warn-level multilingual issues detected.')
}
}
}
post {
always {
archiveArtifacts artifacts: 'artifacts/multilingual-audit.json,artifacts/previous-multilingual-audit.json', onlyIfSuccessful: false
}
}
}
}
}

6
contact_form/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
"""Project-level overrides for the Ocyan contact_form plugin.
Ocyan loads contact form handlers via module labels like `contact_form.views`.
By shipping this package in the project repository we can extend behavior
without forking the upstream plugin.
"""

114
contact_form/views.py Normal file
View File

@@ -0,0 +1,114 @@
from __future__ import annotations
import logging
from django.conf import settings
from django.contrib import messages
from django.core.mail import EmailMultiAlternatives
from django.shortcuts import redirect
from django.template.loader import render_to_string
from django.urls import reverse
from django.utils.translation import gettext as _
from django.views.decorators.http import require_http_methods
from django.views.generic import TemplateView
from wagtail.models import Locale, Site
from oscar.core.utils import redirect_to_referrer
from ocyan.core.fender import config
from ocyan.plugin.contact_form.forms import ContactForm
from ocyan.plugin.contact_form.utils import get_from_email, get_to_email
from mandelstudio.models import ContactMessage
logger = logging.getLogger(__name__)
def _client_ip(request) -> str | None:
forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
if forwarded_for:
return forwarded_for.split(",")[0].strip() or None
return request.META.get("REMOTE_ADDR")
def _active_locale(request) -> Locale:
language_code = (getattr(request, "LANGUAGE_CODE", "") or "").split("-")[0]
if language_code:
locale = Locale.objects.filter(language_code=language_code).first()
if locale is not None:
return locale
return Locale.get_default()
@require_http_methods(["POST"])
def post_contact_form(request):
form = ContactForm(request.POST, request=request)
if form.is_valid():
cleaned = form.cleaned_data
site = Site.find_for_request(request) or Site.objects.order_by("id").first()
locale = _active_locale(request)
message_obj = ContactMessage.objects.create(
site=site,
locale=locale,
user=(
request.user
if getattr(request.user, "is_authenticated", False)
else None
),
ip_address=_client_ip(request),
path=request.path or "",
name=str(cleaned.get("name", "")),
email=str(cleaned.get("email_from", "")),
phone_number=str(cleaned.get("phonenumber") or ""),
message=str(cleaned.get("message", "")),
)
logger.info(
"Saved ContactMessage id=%s email=%s",
message_obj.id,
message_obj.email,
)
context = {
"website_url": request.build_absolute_uri(),
"form_data": cleaned,
}
html_message = render_to_string("contact_form/contact_email.html", context)
text_message = render_to_string("contact_form/contact_email.txt", context)
site_name = getattr(site, "site_name", "") or config.get("django", "name")
subject = _("Contact form email from %s") % site_name
msg = EmailMultiAlternatives(
subject,
text_message,
from_email=get_from_email(request, form),
to=get_to_email(request, form),
reply_to=[cleaned["email_from"]],
)
msg.attach_alternative(html_message, "text/html")
msg.send()
request.session["contact_form_submitted"] = True
messages.add_message(request, messages.SUCCESS, _("Message sent"))
return redirect(reverse("contact_form:contact-form-thank-you"))
request.session["contact_form_post_data"] = request.POST
messages.add_message(
request,
messages.ERROR,
_("An error occured in the contact form: %s") % form.errors.as_text(),
)
return redirect_to_referrer(request, "contact_form:contact-form-handler")
class ContactFormThankYou(TemplateView):
template_name = "contact_form/thank_you.html"
def get(self, request, *args, **kwargs):
contact_form_submitted = request.session.pop("contact_form_submitted", False)
if contact_form_submitted:
return super().get(request, *args, **kwargs)
return redirect(getattr(settings, "CONTACT_REDIRECT_URL", "/"))

View File

@@ -0,0 +1,142 @@
# CI Multilingual Audit
## Purpose
The multilingual audit verifies that public content stays locale-correct across all active MandelBlog languages after deploy.
It checks rendered, user-facing text for:
- mixed-language fragments
- foreign UI labels
- weak or generic badge labels flagged by policy
- locale-specific normalization problems
It does not modify content in CI. It only audits and reports.
## Jenkins jobs
### Main pipeline: `mandelstudio`
The main pipeline runs a post-deploy multilingual audit after staging deployment completes.
Stages relevant to multilingual quality:
1. `Deploy Staging`
2. `Post-Deploy Multilingual Audit`
The audit stage runs remotely on staging:
```bash
python manage.py audit_locales --format=json
```
Artifact archived:
- `artifacts/multilingual-audit.json`
### Nightly pipeline
Pipeline source:
- `Jenkinsfile.multilingual-nightly`
Schedule:
- `H 2 * * *`
The nightly job:
- runs the full multilingual audit on staging
- archives the latest JSON artifact
- compares the current artifact against the previous artifact
- prints regressions by locale
## Build result policy
The audit summary is interpreted as follows:
- `SUCCESS`
- all locales have `block=0` and `warn=0`
- `UNSTABLE`
- at least one locale has `warn > 0`
- deploy is not blocked
- `FAILED`
- at least one locale has `block > 0`
- or audit execution itself fails
This keeps deploys safe without making warning-level cleanup a hard blocker.
## Required Jenkins credential
Credential location:
- `Manage Jenkins -> Credentials -> System -> Global credentials`
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_HOST=root@49.12.204.96`
- `STAGING_AUDIT_PROJECT_DIR=/home/www-mandelstudio/mandelstudio`
- `STAGING_AUDIT_MANAGE=/var/lib/virtualenv/mandelstudio/bin/manage.py`
## Console summary
The Jenkins stage prints a per-locale summary like this:
```text
LOCALE en: issues_found=0 issues_remaining=0 block=0 warn=0 log=0
```
Nightly runs also print regressions when present:
```text
REGRESSIONS:
- es: remaining=+2 block=+0 warn=+2 log=+0
```
If no regressions exist:
```text
REGRESSIONS: none
```
## Artifact structure
Archived file:
- `artifacts/multilingual-audit.json`
Expected clean structure:
- `run_id`
- `total_urls_checked`
- `issues_found`
- `summary`
- `issues`
Failure artifacts may also contain:
- `error`
This happens when the remote audit times out or fails, and is intentional so Jenkins still archives a machine-readable result.
## Local rerun
To rerun the same remote audit flow locally:
```bash
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
python3 scripts/multilingual_audit_ci.py --json artifacts/multilingual-audit.json
```
To compare against a previous artifact:
```bash
python3 scripts/multilingual_audit_ci.py \
--json artifacts/multilingual-audit.json \
--previous-json artifacts/previous-multilingual-audit.json
```
## Fixing issues by locale
Recommended response sequence for any locale that returns warnings or blocks:
1. run scoped dry-run audit for the affected locale/pages
2. inspect before/after rewrite candidates
3. apply controlled rewrite only to affected pages
4. rerun post-audit
5. manually verify rendered output
Do not bulk rewrite a locale tree without scoped review first.
## Operational notes
- remote audit execution has a timeout
- audit failure still produces JSON output for Jenkins archiving
- missing previous nightly artifact is handled gracefully
## Hardening candidates
These are operational follow-ups only. They are not required for current behavior.
- replace `root` SSH with a dedicated deploy/audit user
- move host/path configuration into Jenkins-managed environment variables or folder-level config
- document SSH credential rotation procedure

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

Binary file not shown.

View File

@@ -0,0 +1,40 @@
msgid ""
msgstr ""
"Project-Id-Version: mandelstudio\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-15 00:00+0200\n"
"PO-Revision-Date: 2026-05-15 00:00+0200\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Language: de\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
msgid "Privacy & Cookies"
msgstr "Datenschutz & Cookies"
msgid "We use cookies to make sure our website works as well as possible. If you continue using this website, we assume you agree."
msgstr "Wir verwenden Cookies, um sicherzustellen, dass unsere Website so gut wie möglich funktioniert. Wenn Sie diese Website weiter nutzen, gehen wir davon aus, dass Sie einverstanden sind."
msgid "Accept"
msgstr "Akzeptieren"
msgid "Settings"
msgstr "Einstellungen"
msgid "You can update your cookie preferences at any time."
msgstr "Sie können Ihre Cookie-Einstellungen jederzeit ändern."
msgid "Back"
msgstr "Zurück"
msgid "Cookie settings"
msgstr "Cookie-Einstellungen"
msgid "Choose which cookie categories you allow. Functional cookies are always enabled because they are required for the website to work."
msgstr "Wählen Sie aus, welche Cookie-Kategorien Sie erlauben. Funktionale Cookies sind immer aktiviert, da sie für den Betrieb der Website erforderlich sind."
msgid "Save preferences"
msgstr "Einstellungen speichern"

Binary file not shown.

View File

@@ -0,0 +1,48 @@
msgid ""
msgstr ""
"Project-Id-Version: mandelstudio\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-14 01:00+0200\n"
"PO-Revision-Date: 2026-05-14 01:00+0200\n"
"Last-Translator: \n"
"Language-Team: English\n"
"Language: en\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
msgid "Privacy & Cookies"
msgstr "Privacy & Cookies"
msgid ""
"We use cookies to make sure our website works as well as possible. If you "
"continue using this website, we assume you agree."
msgstr ""
"We use cookies to make sure our website works as well as possible. If you "
"continue using this website, we assume you agree."
msgid "Accept"
msgstr "Accept"
msgid "Settings"
msgstr "Settings"
msgid "You can update your cookie preferences at any time."
msgstr "You can update your cookie preferences at any time."
msgid "Back"
msgstr "Back"
msgid "Cookie settings"
msgstr "Cookie settings"
msgid ""
"Choose which cookie categories you allow. Functional cookies are always "
"enabled because they are required for the website to work."
msgstr ""
"Choose which cookie categories you allow. Functional cookies are always "
"enabled because they are required for the website to work."
msgid "Save preferences"
msgstr "Save preferences"

Binary file not shown.

View File

@@ -0,0 +1,40 @@
msgid ""
msgstr ""
"Project-Id-Version: mandelstudio\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-15 00:00+0200\n"
"PO-Revision-Date: 2026-05-15 00:00+0200\n"
"Last-Translator: \n"
"Language-Team: Spanish\n"
"Language: es\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
msgid "Privacy & Cookies"
msgstr "Privacidad y cookies"
msgid "We use cookies to make sure our website works as well as possible. If you continue using this website, we assume you agree."
msgstr "Usamos cookies para asegurar que nuestro sitio web funcione lo mejor posible. Si continúa usando este sitio web, asumimos que está de acuerdo."
msgid "Accept"
msgstr "Aceptar"
msgid "Settings"
msgstr "Configuración"
msgid "You can update your cookie preferences at any time."
msgstr "Puede actualizar sus preferencias de cookies en cualquier momento."
msgid "Back"
msgstr "Volver"
msgid "Cookie settings"
msgstr "Configuración de cookies"
msgid "Choose which cookie categories you allow. Functional cookies are always enabled because they are required for the website to work."
msgstr "Elija qué categorías de cookies permite. Las cookies funcionales están siempre habilitadas porque son necesarias para que el sitio web funcione."
msgid "Save preferences"
msgstr "Guardar preferencias"

Binary file not shown.

View File

@@ -0,0 +1,40 @@
msgid ""
msgstr ""
"Project-Id-Version: mandelstudio\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-15 00:00+0200\n"
"PO-Revision-Date: 2026-05-15 00:00+0200\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
msgid "Privacy & Cookies"
msgstr "Confidentialité et cookies"
msgid "We use cookies to make sure our website works as well as possible. If you continue using this website, we assume you agree."
msgstr "Nous utilisons des cookies pour garantir le bon fonctionnement de notre site. Si vous continuez à utiliser ce site, nous supposons que vous êtes d'accord."
msgid "Accept"
msgstr "Accepter"
msgid "Settings"
msgstr "Paramètres"
msgid "You can update your cookie preferences at any time."
msgstr "Vous pouvez modifier vos préférences de cookies à tout moment."
msgid "Back"
msgstr "Retour"
msgid "Cookie settings"
msgstr "Paramètres des cookies"
msgid "Choose which cookie categories you allow. Functional cookies are always enabled because they are required for the website to work."
msgstr "Choisissez les catégories de cookies que vous autorisez. Les cookies fonctionnels sont toujours activés car ils sont nécessaires au fonctionnement du site."
msgid "Save preferences"
msgstr "Enregistrer les préférences"

Binary file not shown.

View File

@@ -0,0 +1,40 @@
msgid ""
msgstr ""
"Project-Id-Version: mandelstudio\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-15 00:00+0200\n"
"PO-Revision-Date: 2026-05-15 00:00+0200\n"
"Last-Translator: \n"
"Language-Team: Italian\n"
"Language: it\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
msgid "Privacy & Cookies"
msgstr "Privacy e cookie"
msgid "We use cookies to make sure our website works as well as possible. If you continue using this website, we assume you agree."
msgstr "Utilizziamo i cookie per assicurarci che il nostro sito web funzioni nel miglior modo possibile. Se continui a utilizzare questo sito web, presumiamo che tu sia d'accordo."
msgid "Accept"
msgstr "Accetta"
msgid "Settings"
msgstr "Impostazioni"
msgid "You can update your cookie preferences at any time."
msgstr "Puoi aggiornare le tue preferenze sui cookie in qualsiasi momento."
msgid "Back"
msgstr "Indietro"
msgid "Cookie settings"
msgstr "Impostazioni cookie"
msgid "Choose which cookie categories you allow. Functional cookies are always enabled because they are required for the website to work."
msgstr "Scegli quali categorie di cookie consentire. I cookie funzionali sono sempre abilitati perché necessari al funzionamento del sito web."
msgid "Save preferences"
msgstr "Salva preferenze"

Binary file not shown.

View File

@@ -0,0 +1,49 @@
msgid ""
msgstr ""
"Project-Id-Version: mandelstudio\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-14 01:00+0200\n"
"PO-Revision-Date: 2026-05-14 01:00+0200\n"
"Last-Translator: \n"
"Language-Team: Dutch\n"
"Language: nl\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
msgid "Privacy & Cookies"
msgstr "Privacy & Cookies"
msgid ""
"We use cookies to make sure our website works as well as possible. If you "
"continue using this website, we assume you agree."
msgstr ""
"We maken gebruik van cookies om er zeker van te zijn dat onze website zo "
"goed mogelijk werkt. Als u deze website blijft gebruiken, gaan we ervan uit "
"dat u akkoord gaat."
msgid "Accept"
msgstr "Accepteer"
msgid "Settings"
msgstr "Instellingen"
msgid "You can update your cookie preferences at any time."
msgstr "U kunt uw cookievoorkeuren op elk moment wijzigen."
msgid "Back"
msgstr "Terug"
msgid "Cookie settings"
msgstr "Cookie instellingen"
msgid ""
"Choose which cookie categories you allow. Functional cookies are always "
"enabled because they are required for the website to work."
msgstr ""
"Kies welke cookiecategorieën u toestaat. Functionele cookies zijn altijd "
"ingeschakeld omdat ze nodig zijn om de website te laten werken."
msgid "Save preferences"
msgstr "Voorkeuren opslaan"

Binary file not shown.

View File

@@ -0,0 +1,40 @@
msgid ""
msgstr ""
"Project-Id-Version: mandelstudio\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-15 00:00+0200\n"
"PO-Revision-Date: 2026-05-15 00:00+0200\n"
"Last-Translator: \n"
"Language-Team: Portuguese\n"
"Language: pt\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
msgid "Privacy & Cookies"
msgstr "Privacidade e cookies"
msgid "We use cookies to make sure our website works as well as possible. If you continue using this website, we assume you agree."
msgstr "Usamos cookies para garantir que nosso site funcione da melhor forma possível. Se você continuar usando este site, presumimos que concorda."
msgid "Accept"
msgstr "Aceitar"
msgid "Settings"
msgstr "Configurações"
msgid "You can update your cookie preferences at any time."
msgstr "Você pode atualizar suas preferências de cookies a qualquer momento."
msgid "Back"
msgstr "Voltar"
msgid "Cookie settings"
msgstr "Configurações de cookies"
msgid "Choose which cookie categories you allow. Functional cookies are always enabled because they are required for the website to work."
msgstr "Escolha quais categorias de cookies você permite. Os cookies funcionais estão sempre ativados porque são necessários para o funcionamento do site."
msgid "Save preferences"
msgstr "Salvar preferências"

Binary file not shown.

View File

@@ -0,0 +1,40 @@
msgid ""
msgstr ""
"Project-Id-Version: mandelstudio\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-15 00:00+0200\n"
"PO-Revision-Date: 2026-05-15 00:00+0200\n"
"Last-Translator: \n"
"Language-Team: Russian\n"
"Language: ru\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
msgid "Privacy & Cookies"
msgstr "Конфиденциальность и файлы cookie"
msgid "We use cookies to make sure our website works as well as possible. If you continue using this website, we assume you agree."
msgstr "Мы используем файлы cookie, чтобы наш сайт работал как можно лучше. Если вы продолжаете пользоваться этим сайтом, мы считаем, что вы согласны."
msgid "Accept"
msgstr "Принять"
msgid "Settings"
msgstr "Настройки"
msgid "You can update your cookie preferences at any time."
msgstr "Вы можете изменить свои настройки cookie в любое время."
msgid "Back"
msgstr "Назад"
msgid "Cookie settings"
msgstr "Настройки cookie"
msgid "Choose which cookie categories you allow. Functional cookies are always enabled because they are required for the website to work."
msgstr "Выберите, какие категории cookie вы разрешаете. Функциональные cookie всегда включены, так как они необходимы для работы сайта."
msgid "Save preferences"
msgstr "Сохранить настройки"

View File

@@ -0,0 +1 @@
default_app_config = "mandelblog_content_guard.apps.MandelblogContentGuardConfig"

View File

@@ -0,0 +1,25 @@
from .base import BaseLanguageAgent
from .de import GermanAgent
from .en import EnglishAgent
from .es import SpanishAgent
from .fr import FrenchAgent
from .it import ItalianAgent
from .nl import DutchAgent
from .pt import PortugueseAgent
from .ru import RussianAgent
AGENT_REGISTRY = {
"nl": DutchAgent,
"en": EnglishAgent,
"de": GermanAgent,
"fr": FrenchAgent,
"es": SpanishAgent,
"it": ItalianAgent,
"pt": PortugueseAgent,
"ru": RussianAgent,
}
def get_language_agent(locale_code: str) -> BaseLanguageAgent:
agent_class = AGENT_REGISTRY.get(locale_code, BaseLanguageAgent)
return agent_class()

View File

@@ -0,0 +1,187 @@
from __future__ import annotations
import re
from collections import defaultdict
from typing import Any
from django.utils.module_loading import import_string
from ..settings import get_rewrite_backend
class BaseLanguageAgent:
locale = "nl"
tone = "business"
preferred_formality = "neutral"
cta_defaults: dict[str, str] = {}
vocabulary_map: dict[str, str] = {}
contextual_vocabulary_map: dict[str, dict[str, str]] = {}
cleanup_patterns: tuple[tuple[re.Pattern[str], str], ...] = (
(
re.compile(
r"""^.*?\bis\s+(?:German|Spanish|French|Italian|Portuguese|Dutch),\s+not\s+Dutch.*?(?::\s*|\"\.\s*)(?P<quote>.+?)\"?\.?\s*$""",
re.IGNORECASE,
),
"{quote}",
),
(
re.compile(
r"""^.*?\btranslation\s+from\s+.*?(?::\s*|\"\.\s*)(?P<quote>.+?)\"?\.?\s*$""",
re.IGNORECASE,
),
"{quote}",
),
(
re.compile(
r"""^.*?\btraducid[oa]\s+al\s+.*?(?::\s*|\"\.\s*)(?P<quote>.+?)\"?\.?\s*$""",
re.IGNORECASE,
),
"{quote}",
),
(
re.compile(
r"""^.*?\bперевод\s+с\s+.*?(?::\s*|\"\.\s*)(?P<quote>.+?)\"?\.?\s*$""",
re.IGNORECASE,
),
"{quote}",
),
(
re.compile(
r"""^\s*La\s+entrada\s+\"?(?P<quote>.+?)\"?\s+está\s+en\s+alemán.*$""",
re.IGNORECASE,
),
"{quote}",
),
)
def __init__(self) -> None:
self.backend = self._load_backend()
def _load_backend(self):
backend_path = get_rewrite_backend()
if not backend_path:
return None
return import_string(backend_path)
def backend_prompt(self, field_path: str, text: str) -> str:
return (
f"Rewrite the following {self.locale} website copy for a small-business "
f"website in a natural, professional, sales-driven tone. Preserve meaning, "
f"remove translation artifacts, keep it concise, and do not add commentary.\n"
f"Field: {field_path}\n"
f"Locale: {self.locale}\n"
f"Tone: {self.tone}\n"
f"Formality: {self.preferred_formality}\n"
f"Text: {text}"
)
def _contextual_replacements(self, field_path: str) -> dict[str, str]:
lowered = field_path.lower()
replacements: dict[str, str] = {}
for token, mapping in self.contextual_vocabulary_map.items():
if token in lowered:
replacements.update(mapping)
return replacements
def post_cleanup_text(self, text: str, field_path: str = "") -> str:
return text
def _apply_replacements(self, text: str, replacements: dict[str, str]) -> str:
cleaned = text
phrase_replacements = {}
token_replacements = {}
for source, target in replacements.items():
if not source:
continue
if re.fullmatch(r"[\wÀ-ÿ-]+", source, flags=re.UNICODE):
token_replacements[source] = target
else:
phrase_replacements[source] = target
for source, target in sorted(phrase_replacements.items(), key=lambda item: len(item[0]), reverse=True):
cleaned = cleaned.replace(source, target)
for source, target in sorted(token_replacements.items(), key=lambda item: len(item[0]), reverse=True):
pattern = re.compile(rf"(?<![\wÀ-ÿ-]){re.escape(source)}(?![\wÀ-ÿ-])", re.UNICODE)
cleaned = pattern.sub(target, cleaned)
return cleaned
def cleanup_text(self, text: str, field_path: str = "") -> str:
cleaned = text.strip()
for pattern, replacement in self.cleanup_patterns:
match = pattern.match(cleaned)
if not match:
continue
cleaned = replacement.format(**match.groupdict()).strip()
cleaned = self._apply_replacements(cleaned, self.vocabulary_map)
cleaned = self._apply_replacements(cleaned, self._contextual_replacements(field_path))
cleaned = self.post_cleanup_text(cleaned, field_path=field_path)
return re.sub(r"\s+", " ", cleaned).strip()
def normalize_cta(self, text: str, field_path: str = "") -> str:
normalized = self.cleanup_text(text, field_path=field_path)
lowered = normalized.lower()
for keyword, replacement in self.cta_defaults.items():
if keyword in lowered:
return replacement
return normalized
def rewrite(self, text: str, field_path: str = "", issues: list[Any] | None = None) -> str:
cleaned = self.cleanup_text(text, field_path=field_path)
lowered_path = field_path.lower()
if any(token in lowered_path for token in ("cta", "button", "link_text", "submit")):
cleaned = self.normalize_cta(cleaned, field_path=field_path)
elif issues and any(
issue.issue_type in {"generic_badge_label", "foreign_ui_label", "weak_marketing_copy", "mixed_locale_heading"}
for issue in issues
):
cleaned = self.cleanup_text(cleaned, field_path=field_path)
if self.backend:
rewritten = self.backend(
locale=self.locale,
field_path=field_path,
text=cleaned,
prompt=self.backend_prompt(field_path, cleaned),
)
if isinstance(rewritten, str) and rewritten.strip():
cleaned = rewritten.strip()
return cleaned
def process_block(self, block_data: Any, field_path: str = "", issue_map: dict[str, list[Any]] | None = None):
issue_map = issue_map or {}
if isinstance(block_data, dict):
changed = False
output = {}
for key, value in block_data.items():
child_path = f"{field_path}.{key}" if field_path else str(key)
new_value, child_changed = self.process_block(value, child_path, issue_map)
output[key] = new_value
changed = changed or child_changed
return output, changed
if isinstance(block_data, list):
changed = False
output = []
for index, value in enumerate(block_data):
child_path = f"{field_path}[{index}]"
new_value, child_changed = self.process_block(value, child_path, issue_map)
output.append(new_value)
changed = changed or child_changed
return output, changed
if isinstance(block_data, str):
issues = issue_map.get(field_path, [])
needs_rewrite = bool(issues) or any(
token in field_path for token in ("cta", "button", "label", "placeholder", "help_text")
)
if not needs_rewrite:
cleaned = self.cleanup_text(block_data)
return cleaned, cleaned != block_data
rewritten = self.rewrite(block_data, field_path=field_path, issues=issues)
return rewritten, rewritten != block_data
return block_data, False
def build_issue_map(self, issues: list[Any]) -> dict[str, list[Any]]:
issue_map: dict[str, list[Any]] = defaultdict(list)
for issue in issues:
if issue.field_path:
issue_map[issue.field_path].append(issue)
return issue_map

View File

@@ -0,0 +1,23 @@
from .base import BaseLanguageAgent
from ..normalizers import normalize_de_text
from ..system_strings import build_system_vocabulary
class GermanAgent(BaseLanguageAgent):
locale = "de"
tone = "professional and trustworthy"
preferred_formality = "formal Sie"
vocabulary_map = {
**build_system_vocabulary("de", ("transparent_investment",)),
}
cta_defaults = {
"starter": "Starter-Gespräch planen",
"business": "Beratungsgespräch planen",
"support": "Support anfragen",
"service": "Dienstleistungen anzeigen",
"project": "Projekt starten",
"kontakt": "Einführungsgespräch planen",
}
def post_cleanup_text(self, text: str, field_path: str = "") -> str:
return normalize_de_text(text, field_path=field_path)

View File

@@ -0,0 +1,34 @@
from .base import BaseLanguageAgent
from ..normalizers import normalize_en_text
from ..system_strings import build_contextual_system_vocabulary, build_system_vocabulary
class EnglishAgent(BaseLanguageAgent):
locale = "en"
tone = "business-friendly and direct"
preferred_formality = "neutral"
vocabulary_map = {
**build_system_vocabulary("en", ("plan_badge", "services_badge", "transparent_label", "transparent_investment")),
}
_system_contextual = build_contextual_system_vocabulary("en", ("plan_badge", "services_badge", "transparent_label"))
contextual_vocabulary_map = {
"badge": {**_system_contextual.get("badge", {})},
"label": {**_system_contextual.get("label", {})},
"metric": {**_system_contextual.get("metric", {})},
"stat": {**_system_contextual.get("stat", {})},
"title": {**_system_contextual.get("title", {})},
"heading": {**_system_contextual.get("heading", {})},
"rendered": {**_system_contextual.get("rendered", {})},
}
cta_defaults = {
"starter": "Book starter call",
"business": "Book business call",
"support": "View support",
"service": "View services",
"project": "Start your project",
"quote": "Request a quote",
"contact": "Book intro call",
}
def post_cleanup_text(self, text: str, field_path: str = "") -> str:
return normalize_en_text(text, field_path=field_path)

View File

@@ -0,0 +1,43 @@
from .base import BaseLanguageAgent
from ..normalizers import normalize_es_text
from ..system_strings import build_contextual_system_vocabulary, build_system_vocabulary
class SpanishAgent(BaseLanguageAgent):
locale = "es"
tone = "clear and business-focused"
preferred_formality = "formal"
vocabulary_map = {
**build_system_vocabulary(
"es",
(
"plan_badge",
"response_time",
"without_commitment",
"transparent_label",
"transparent_investment",
),
),
}
_system_contextual = build_contextual_system_vocabulary("es", ("plan_badge", "transparent_label"))
contextual_vocabulary_map = {
"badge": {**_system_contextual.get("badge", {})},
"label": {**_system_contextual.get("label", {})},
"metric": {**_system_contextual.get("metric", {})},
"stat": {**_system_contextual.get("stat", {})},
"title": {**_system_contextual.get("title", {})},
"heading": {**_system_contextual.get("heading", {})},
"rendered": {**_system_contextual.get("rendered", {})},
}
cta_defaults = {
"starter": "Reservar llamada inicial",
"business": "Reservar llamada comercial",
"support": "Solicitar soporte",
"service": "Mostrar los servicios",
"project": "Inicia tu proyecto",
"quote": "Solicitar propuesta",
"contact": "Planificar la reunión inicial",
}
def post_cleanup_text(self, text: str, field_path: str = "") -> str:
return normalize_es_text(text, field_path=field_path)

View File

@@ -0,0 +1,66 @@
from .base import BaseLanguageAgent
from ..system_strings import build_contextual_system_vocabulary, build_system_vocabulary
class FrenchAgent(BaseLanguageAgent):
locale = "fr"
tone = "professional and commercial"
preferred_formality = "formal"
cta_defaults = {
"starter": "Planifier lentretien de départ",
"business": "Planifier lentretien commercial",
"support": "Voir le support",
"service": "Afficher les services",
"project": "Lancez votre projet",
"devis": "Demander un devis",
"contact": "Planifier léchange",
}
vocabulary_map = {
**build_system_vocabulary("fr"),
"SERVICES": "PRESTATIONS",
"New": "Nouveau",
"Popular": "Populaire",
"Erstes Produktionsprojekt erfolgreich abgeschlossen.": "Premier projet de production livré avec succès.",
"Von Kickoff bis zum Launch mit einem klaren Umfang.": "Du cadrage au lancement avec un périmètre clair.",
"Demande d'admission initiale": "Planifier un échange initial",
"Geschäftsprozess besprechen": "Échanger sur votre processus métier",
"Entretien d'accueil": "Entretien initial",
"Vraag over diensten": "Question sur les services",
"Konkrete erste Schätzung": "Première estimation concrète",
"Ansatz, der zu Ihrem Budget passt": "Approche adaptée à votre budget",
"Detailliertes Seitenlayout": "Structure détaillée des pages",
"Investition": "investissement",
"Unverbindliches Gespräch, klares Angebot": "Sans engagement, offre claire",
"Bereit, mit der Business-Website zu starten?": "Prêt à démarrer votre site dentreprise ?",
"Planifier un échange business": "Planifier un échange commercial",
"Aucune carte bancaire requise": "Sans engagement",
}
_system_contextual = build_contextual_system_vocabulary("fr")
contextual_vocabulary_map = {
"badge": {
**_system_contextual.get("badge", {}),
"Popular": "Le plus demandé",
},
"label": {
**_system_contextual.get("label", {}),
"Popular": "Le plus demandé",
},
"metric": {
**_system_contextual.get("metric", {}),
},
"stat": {
**_system_contextual.get("stat", {}),
},
"title": {
**_system_contextual.get("title", {}),
"SERVICES": "PRESTATIONS",
},
"heading": {
**_system_contextual.get("heading", {}),
"SERVICES": "PRESTATIONS",
},
"rendered": {
**_system_contextual.get("rendered", {}),
"SERVICES": "PRESTATIONS",
},
}

View File

@@ -0,0 +1,42 @@
from .base import BaseLanguageAgent
from ..normalizers import normalize_it_text
from ..system_strings import build_contextual_system_vocabulary, build_system_vocabulary
class ItalianAgent(BaseLanguageAgent):
locale = "it"
tone = "professional and approachable"
preferred_formality = "polite"
vocabulary_map = {
**build_system_vocabulary(
"it",
(
"weeks_1_2",
"without_commitment",
"transparent_label",
"transparent_investment",
"customization_integrations",
"multilingual_rollout",
),
),
}
_system_contextual = build_contextual_system_vocabulary("it", ("transparent_label",))
contextual_vocabulary_map = {
"badge": {**_system_contextual.get("badge", {})},
"label": {**_system_contextual.get("label", {})},
"metric": {**_system_contextual.get("metric", {})},
"stat": {**_system_contextual.get("stat", {})},
"rendered": {**_system_contextual.get("rendered", {})},
}
cta_defaults = {
"starter": "Prenota una call iniziale",
"business": "Pianifica la call business",
"support": "Richiedi supporto",
"service": "Mostra i servizi",
"project": "Avvia il tuo progetto",
"quote": "Richiedi una proposta",
"contact": "Pianifica la riunione introduttiva",
}
def post_cleanup_text(self, text: str, field_path: str = "") -> str:
return normalize_it_text(text, field_path=field_path)

View File

@@ -0,0 +1,20 @@
from .base import BaseLanguageAgent
from ..normalizers import normalize_nl_text
class DutchAgent(BaseLanguageAgent):
locale = "nl"
tone = "zakelijk en duidelijk"
preferred_formality = "je/jij professioneel"
cta_defaults = {
"starter": "Plan startergesprek",
"business": "Plan zakelijk gesprek",
"support": "Bekijk support",
"service": "Bekijk diensten",
"project": "Start jouw project",
"contact": "Plan kennismaking",
"offerte": "Vraag voorstel aan",
}
def post_cleanup_text(self, text: str, field_path: str = "") -> str:
return normalize_nl_text(text, field_path=field_path)

View File

@@ -0,0 +1,111 @@
from .base import BaseLanguageAgent
from ..system_strings import build_contextual_system_vocabulary, build_system_vocabulary
class PortugueseAgent(BaseLanguageAgent):
locale = "pt"
tone = "business-focused and practical"
preferred_formality = "neutral"
cta_defaults = {
"starter": "Agendar chamada inicial",
"business": "Agendar chamada comercial",
"support": "Ver suporte",
"service": "Ver serviços",
"project": "Iniciar o seu projeto",
"proposta": "Pedir proposta",
"contact": "Agendar reunião introdutória",
}
vocabulary_map = {
**build_system_vocabulary("pt"),
"SERVICES": "SERVIÇOS",
"New": "Novo",
"Popular": "Em destaque",
"Siti web e negozi online": "Sites e lojas online",
"Siti web e negozi online che sono rapidamente online e facili da gestire": "Sites e lojas online que ficam no ar rapidamente e são fáceis de gerir",
"Caso de cliente en directo": "Caso real de cliente",
"El primer proyecto de producción finalizado con éxito.": "O primeiro projeto de produção foi concluído com sucesso.",
"Más sobre el proceso": "Mais sobre o processo",
"Modifiez simplement vous-même.": "Edite facilmente por conta própria.",
"Opciones de la tienda web Mantenimiento y soporte Suporte mensal opcional para atualizações e estabilidade.": "Opções da loja online Manutenção e suporte Suporte mensal opcional para atualizações e estabilidade.",
"Opciones de la tienda web": "Opções da loja online",
"Planes de soporte": "Planos de suporte",
"Multilingüe": "Multilingue",
"Suivi + corrections": "Acompanhamento e correções",
"Mejoras mensuales": "Melhorias mensais",
"¿A qué velocidad puede comenzar?": "Com que rapidez podem começar?",
"¿Puedo editar textos e imágenes yo mismo?": "Posso editar textos e imagens por conta própria?",
"Einzelhandelsunternehmer": "Comerciante",
"lifestyle": "estilo de vida",
"À partir de 3 750 €": "A partir de 3.750 €",
"Transparente sobre o planejamento, o processo e a gestão.": "Clareza sobre o planeamento, o processo e a gestão.",
"Einzelhandelsinhaber Petite boutique en ligne Forfaits de services (à partir de) Pontos de partida transparentes.": "Comerciantes Pequena loja online Pacotes de serviço (a partir de) Pontos de partida claros.",
"Unsere Serviços": "Os nossos serviços",
"Unsere Serviços: vom schnellen Start bis zu skalierbarem Wachstum": "Os nossos serviços: do lançamento rápido ao crescimento escalável",
"Elija el camino": "Escolha o caminho certo",
"Elija el camino que corresponda a su fase: sitio de inicio, sitio empresarial, tienda en línea o soporte continuo.": "Escolha o caminho certo para a sua fase: site inicial, site empresarial, loja online ou suporte contínuo.",
"Début en direct": "Lançamento rápido",
"Demande d'admission initiale": "Agendar conversa inicial",
"Site Web d'Entreprise": "Site empresarial",
"Hablar sobre el proceso empresarial": "Falar sobre o processo do negócio",
"Mise en place de boutique en ligne": "Implementação de loja online",
"Maintenance & gestion": "Manutenção e gestão",
"Afficher le plan de soutien": "Ver suporte",
"Introducción multilingüe": "Lançamento multilingue",
"Forfaits de services (à partir de)": "Pacotes de serviço (a partir de)",
"Schnell online mit einer starken Basis": "Rápido online com uma base sólida",
"Startseite + Kernseiten": "Página inicial + páginas essenciais",
"Optimizado para móviles": "Otimizado para mobile",
"Gestisca lei stesso il contenuto": "Gerir o conteúdo com autonomia",
"Detailliertes Seitenlayout": "Estrutura detalhada das páginas",
"Unverbindliches Gespräch, klares Angebot": "Sem compromisso, proposta clara",
"Mehr Struktur und Konversion": "Mais estrutura e foco em conversão",
"Sections axées sur la conversion": "Secções orientadas para conversão",
"Base prête pour le SEO": "Base pronta para SEO",
"Katalog + Kasse": "Catálogo + checkout",
"Zahlungen und Auftragsfluss": "Pagamentos e fluxo de encomendas",
"Wachstumsbereite Grundlage": "Base pronta para crescimento",
"Soporte y crecimiento": "Suporte e crescimento",
"Amélioration continue": "Melhoria contínua",
"Desde 149 € al mes.": "Desde 149 € por mês.",
"Ab 2.250 €": "A partir de 2.250 €",
"Boutique en ligne": "Loja online",
"Sales-ready mit skalierbarem Stack": "Preparada para vender com uma base escalável",
"Agendar conversa sobre o serviço Ver resultados do projeto 1-2 Wochen Début en direct 4.9/5 Kundenschätzung 100% Bearbeitbar Visão geral dos serviços Cada serviço é projetado para melhorar a faturação, a confiança e a controlabilidade.": "Agendar conversa sobre o serviço Ver resultados do projeto 1 a 2 semanas Lançamento rápido 4.9/5 Avaliação dos clientes 100% Editável Visão geral dos serviços Cada serviço foi concebido para aumentar a faturação, reforçar a confiança e dar mais controlo à sua equipa.",
"Site inicial Schnell online mit einer starken Basis A partir de 1.250 € Agendar chamada inicial Startseite + Kernseiten Optimizado para móviles Gestisca lei stesso il contenuto Recomendado Site Web d'Entreprise Mehr Struktur und Konversion Ab 2.250 € Agendar chamada comercial Detailliertes Seitenlayout Sections axées sur la conversion Base prête pour le SEO Boutique en ligne Sales-ready mit skalierbarem Stack À partir de 3 750 € Iniciar o processo da loja online Katalog + Kasse Zahlungen und Auftragsfluss Wachstumsbereite Grundlage Soporte y crecimiento Amélioration continue Desde 149 € al mes.": "Site inicial Rápido online com uma base sólida A partir de 1.250 € Agendar chamada inicial Página inicial + páginas essenciais Otimizado para mobile Gerir o conteúdo com autonomia Recomendado Site empresarial Mais estrutura e foco em conversão A partir de 2.250 € Agendar chamada comercial Estrutura detalhada das páginas Secções orientadas para conversão Base pronta para SEO Loja online Preparada para vender com uma base escalável A partir de 3.750 € Iniciar o processo da loja online Catálogo + checkout Pagamentos e fluxo de encomendas Base pronta para crescimento Suporte e crescimento Melhoria contínua Desde 149 € por mês.",
"Perguntas frequentes Transparente sobre o planejamento, o processo e a gestão.": "Perguntas frequentes Clareza sobre o planeamento, o processo e a gestão.",
'Ver serviços New La entrada "Unterstützung oder Erweiterung" está en alemán, no en neerlandés.': "Ver serviços Novo Suporte ou expansão",
"Unterstützung oder Erweiterung": "Suporte ou expansão",
'La entrada "Unterstützung oder Erweiterung"': "Suporte ou expansão",
'La entrada "Unterstützung oder Erweiterung" está en alemán, no en neerlandés. Traducido al francés, es: "Suporte ou expansão".': "Suporte ou expansão",
"Sem cartão de crédito": "Sem compromisso",
}
_system_contextual = build_contextual_system_vocabulary("pt")
contextual_vocabulary_map = {
"badge": {
**_system_contextual.get("badge", {}),
"Popular": "Escolha frequente",
},
"label": {
**_system_contextual.get("label", {}),
"Popular": "Escolha frequente",
},
"metric": {
**_system_contextual.get("metric", {}),
},
"stat": {
**_system_contextual.get("stat", {}),
},
"title": {
"SERVICES": "SERVIÇOS",
"Popular": "Em destaque",
},
"heading": {
"SERVICES": "SERVIÇOS",
"Popular": "Em destaque",
},
"rendered": {
**_system_contextual.get("rendered", {}),
"SERVICES": "SERVIÇOS",
"Popular": "Em destaque",
},
}

View File

@@ -0,0 +1,39 @@
from .base import BaseLanguageAgent
from ..normalizers import normalize_ru_text
from ..system_strings import build_contextual_system_vocabulary, build_system_vocabulary
class RussianAgent(BaseLanguageAgent):
locale = "ru"
tone = "professional and confident"
preferred_formality = "neutral polite"
vocabulary_map = {
**build_system_vocabulary(
"ru",
(
"customization_integrations",
"detailed_page_structure",
"without_commitment",
),
),
}
_system_contextual = build_contextual_system_vocabulary("ru", ("plan_badge", "transparent_label"))
contextual_vocabulary_map = {
"badge": {**_system_contextual.get("badge", {})},
"label": {**_system_contextual.get("label", {})},
"metric": {**_system_contextual.get("metric", {})},
"stat": {**_system_contextual.get("stat", {})},
"rendered": {**_system_contextual.get("rendered", {})},
}
cta_defaults = {
"starter": "Запланировать стартовую консультацию",
"business": "Обсудить бизнес-проект",
"support": "Посмотреть поддержку",
"service": "Посмотреть услуги",
"project": "Запустить свой проект",
"contact": "Отправить запрос",
"quote": "Получить предложение",
}
def post_cleanup_text(self, text: str, field_path: str = "") -> str:
return normalize_ru_text(text, field_path=field_path)

View File

@@ -0,0 +1,16 @@
from __future__ import annotations
from .agents import get_language_agent
from .validators.multilingual import validate_ai_text_or_raise
def guard_ai_output(locale_code: str, field_path: str, value: str) -> str:
validate_ai_text_or_raise(locale_code, field_path, value)
return value
def rewrite_ai_output(locale_code: str, field_path: str, value: str) -> str:
agent = get_language_agent(locale_code)
rewritten = agent.rewrite(value, field_path=field_path)
validate_ai_text_or_raise(locale_code, field_path, rewritten)
return rewritten

View File

@@ -0,0 +1,10 @@
from django.apps import AppConfig
class MandelblogContentGuardConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "mandelblog_content_guard"
verbose_name = "MandelBlog Content Guard"
def ready(self):
from . import signals # noqa: F401

View File

@@ -0,0 +1,3 @@
from .visible_text import VisibleTextExtractor, extract_visible_rendered_text, normalize_text
__all__ = ["VisibleTextExtractor", "extract_visible_rendered_text", "normalize_text"]

View File

@@ -0,0 +1,85 @@
from __future__ import annotations
import html
import re
from html.parser import HTMLParser
VISIBLE_TEXT_TAGS = {"h1", "h2", "h3", "h4", "h5", "h6", "p", "button", "a", "label", "li"}
IGNORED_TAGS = {"script", "style", "noscript", "template"}
def html_unescape(value: str) -> str:
return html.unescape(value)
def normalize_text(value: str) -> str:
return re.sub(r"\s+", " ", html_unescape(value)).strip()
class VisibleTextExtractor(HTMLParser):
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.ignored_depth = 0
self.hidden_stack: list[bool] = []
self.visible_tag_stack: list[str] = []
self.current_chunks: list[str] = []
self.lines: list[str] = []
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
lowered = tag.lower()
attrs_dict = {key.lower(): (value or "") for key, value in attrs}
if lowered in IGNORED_TAGS:
self.ignored_depth += 1
return
self.hidden_stack.append(self._is_hidden(attrs_dict))
if lowered in VISIBLE_TEXT_TAGS and not self.ignored_depth and not any(self.hidden_stack):
self.visible_tag_stack.append(lowered)
def handle_endtag(self, tag: str) -> None:
lowered = tag.lower()
if lowered in IGNORED_TAGS and self.ignored_depth:
self.ignored_depth -= 1
return
if lowered in VISIBLE_TEXT_TAGS and self.visible_tag_stack:
self.visible_tag_stack.pop()
self._flush_line()
if self.hidden_stack:
self.hidden_stack.pop()
def handle_data(self, data: str) -> None:
if self.ignored_depth or any(self.hidden_stack) or not self.visible_tag_stack:
return
normalized = normalize_text(data)
if normalized:
self.current_chunks.append(normalized)
def handle_comment(self, data: str) -> None:
return
def close(self) -> None:
super().close()
self._flush_line()
def _flush_line(self) -> None:
if not self.current_chunks:
return
line = normalize_text(" ".join(self.current_chunks))
if line:
self.lines.append(line)
self.current_chunks = []
@staticmethod
def _is_hidden(attrs: dict[str, str]) -> bool:
if "hidden" in attrs:
return True
if attrs.get("aria-hidden", "").lower() == "true":
return True
style = attrs.get("style", "").replace(" ", "").lower()
return "display:none" in style or "visibility:hidden" in style
def extract_visible_rendered_text(body: str) -> str:
parser = VisibleTextExtractor()
parser.feed(body)
parser.close()
return "\n".join(parser.lines)

View File

@@ -0,0 +1,95 @@
from __future__ import annotations
from django.contrib import messages
from django.http import HttpResponseRedirect
from wagtail import hooks
from .types import format_issue, split_issues
from .validators.multilingual import validate_page, validate_posted_snippet, validate_snippet_instance
def _flash_issues(request, level, prefix: str, issues):
preview = issues[:6]
for issue in preview:
messages.add_message(request, level, f"{prefix}: {format_issue(issue)}")
remaining = len(issues) - len(preview)
if remaining > 0:
messages.add_message(request, level, f"{prefix}: {remaining} more issue(s) not shown.")
@hooks.register("before_publish_page")
def prevent_corrupt_multilingual_publish(request, page):
issues = validate_page(page)
blocking, warnings = split_issues(issues)
if warnings:
_flash_issues(request, messages.WARNING, "Content guard warning", warnings)
if not blocking:
return None
_flash_issues(request, messages.ERROR, "Publishing blocked", blocking)
return HttpResponseRedirect(request.path)
@hooks.register("after_edit_page")
def warn_on_corrupt_multilingual_draft(request, page):
blocking, warnings = split_issues(validate_page(page))
if blocking:
_flash_issues(request, messages.WARNING, "Draft warning", blocking)
if warnings:
_flash_issues(request, messages.WARNING, "Draft warning", warnings)
def _snippet_locale_code(instance, request) -> str:
posted_locale = request.POST.get("locale") if request.method == "POST" else None
if posted_locale:
return posted_locale
locale = getattr(instance, "locale", None)
if locale is not None and getattr(locale, "language_code", None):
return locale.language_code
return "nl"
def _validate_snippet_request(request, instance):
if request.method != "POST":
return None
issues = validate_posted_snippet(_snippet_locale_code(instance, request), request.POST.dict())
blocking, warnings = split_issues(issues)
if warnings:
_flash_issues(request, messages.WARNING, "Snippet warning", warnings)
if not blocking:
return None
_flash_issues(request, messages.ERROR, "Snippet save blocked", blocking)
return HttpResponseRedirect(request.path)
@hooks.register("before_create_snippet")
def prevent_corrupt_snippet_create(request, model):
instance = model()
posted_locale = request.GET.get("locale") or request.POST.get("locale")
if posted_locale and hasattr(instance, "locale_id"):
from wagtail.models import Locale
instance.locale = Locale.objects.get(language_code=posted_locale)
return _validate_snippet_request(request, instance)
@hooks.register("before_edit_snippet")
def prevent_corrupt_snippet_edit(request, instance):
return _validate_snippet_request(request, instance)
def _warn_saved_snippet(request, instance):
blocking, warnings = split_issues(validate_snippet_instance(instance))
if blocking:
_flash_issues(request, messages.WARNING, "Snippet integrity warning", blocking)
if warnings:
_flash_issues(request, messages.WARNING, "Snippet integrity warning", warnings)
@hooks.register("after_create_snippet")
def warn_on_saved_snippet_create(request, instance):
_warn_saved_snippet(request, instance)
@hooks.register("after_edit_snippet")
def warn_on_saved_snippet_edit(request, instance):
_warn_saved_snippet(request, instance)

View File

@@ -0,0 +1,163 @@
from __future__ import annotations
import json
from collections import defaultdict
from django.core.management.base import BaseCommand
from ...settings import audit_default_locales
from ...validators.multilingual import audit_locales
class Command(BaseCommand):
help = "Audit all public locale pages for multilingual integrity issues."
def add_arguments(self, parser):
parser.add_argument(
"--locale",
action="append",
dest="locales",
help="Limit the audit to one or more locale codes. Repeat the flag for multiple locales.",
)
parser.add_argument(
"--url",
action="append",
dest="urls",
help="Limit the audit to one or more public page URLs. Repeat the flag for multiple URLs.",
)
parser.add_argument(
"--fix",
action="store_true",
help="Apply known safe replacements and republish changed content.",
)
parser.add_argument(
"--rewrite",
action="store_true",
help="Rewrite flagged content through the locale agent system.",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Preview rewrite changes without saving content.",
)
parser.add_argument(
"--format",
choices=["text", "json"],
default="text",
help="Output format.",
)
def handle(self, *args, **options):
locale_codes = options["locales"] or audit_default_locales()
run = audit_locales(
locale_codes,
fix=options["fix"],
rewrite=options["rewrite"],
dry_run=options["dry_run"],
url_filters=options["urls"],
)
grouped = defaultdict(list)
for issue in run.issues.all().order_by("locale_code", "url", "field_path"):
grouped[issue.locale_code].append(issue)
grouped_compact = defaultdict(list)
for locale_code, issues in grouped.items():
bucket = {}
for issue in issues:
key = (
issue.url,
issue.issue_type,
issue.bad_value,
issue.replacement,
)
extra = issue.extra or {}
if key not in bucket:
bucket[key] = {
"url": issue.url,
"title": issue.title,
"severity": issue.severity,
"issue_type": issue.issue_type,
"field_paths": set([issue.field_path] if issue.field_path else []),
"bad_value": issue.bad_value,
"replacement": issue.replacement,
"fixed": issue.fixed,
"sources": set([extra.get("source")] if extra.get("source") else []),
"count": extra.get("count", 1),
}
else:
if issue.field_path:
bucket[key]["field_paths"].add(issue.field_path)
if extra.get("source"):
bucket[key]["sources"].add(extra["source"])
bucket[key]["count"] += extra.get("count", 1)
grouped_compact[locale_code] = [
{
**entry,
"field_paths": sorted(entry["field_paths"]),
"sources": sorted(entry["sources"]),
}
for entry in bucket.values()
]
if options["format"] == "json":
payload = {
"run_id": run.pk,
"total_urls_checked": run.total_urls_checked,
"issues_found": run.issues_found,
"summary": run.summary,
"issues": {
locale_code: grouped_compact.get(locale_code, [])
for locale_code in locale_codes
},
}
self.stdout.write(json.dumps(payload, indent=2, ensure_ascii=False))
return
for locale_code in locale_codes:
locale_summary = run.summary.get(locale_code, {})
self.stdout.write(f"Locale: {locale_code}")
self.stdout.write(
f"URLs checked: {locale_summary.get('total_urls_checked', 0)}"
)
self.stdout.write(
f"Issues found: {locale_summary.get('issues_found', 0)}"
)
self.stdout.write(
f"Severity: {locale_summary.get('by_severity', {})}"
)
if options["fix"]:
self.stdout.write(
f"Issues auto-fixed: {locale_summary.get('issues_fixed', 0)}"
)
if options["rewrite"]:
self.stdout.write(
f"Rewrite mode: {'dry-run' if options['dry_run'] else 'apply'}"
)
for issue in grouped_compact.get(locale_code, []):
target = issue["url"] or issue["title"] or "object"
self.stdout.write(
f"- {target} -> {issue['issue_type']}: {issue['bad_value']}"
)
if issue.get("replacement"):
self.stdout.write(f" after: {issue['replacement']}")
if issue.get("field_paths"):
self.stdout.write(f" fields: {', '.join(issue['field_paths'][:5])}")
if issue.get("sources"):
self.stdout.write(f" sources: {', '.join(issue['sources'])}")
if issue.get("count"):
self.stdout.write(f" count: {issue['count']}")
if not grouped_compact.get(locale_code):
self.stdout.write("- no issues found")
self.stdout.write("")
snippet_summary = run.summary.get("snippets") or {}
if snippet_summary:
self.stdout.write("Snippet issues:")
for model_name, count in snippet_summary.items():
self.stdout.write(f"- {model_name}: {count}")
self.stdout.write(
self.style.SUCCESS(
f"Audit run {run.pk} completed. Total URLs checked: {run.total_urls_checked}. Issues found: {run.issues_found}."
)
)

View File

@@ -0,0 +1,19 @@
from __future__ import annotations
from django.core.exceptions import ValidationError
class MultilingualValidationMixin:
"""Opt-in mixin for project models that want explicit clean()-time enforcement."""
def clean(self):
from .types import format_issue
from .validators.multilingual import validate_snippet_instance
super_clean = getattr(super(), "clean", None)
if callable(super_clean):
super_clean()
issues = validate_snippet_instance(self)
blocking = [issue for issue in issues if issue.blocks]
if blocking:
raise ValidationError({"content_guard": [format_issue(issue) for issue in blocking]})

View File

@@ -0,0 +1,15 @@
from .de import normalize_de_text
from .en import normalize_en_text
from .es import normalize_es_text
from .it import normalize_it_text
from .nl import normalize_nl_text
from .ru import normalize_ru_text
__all__ = [
"normalize_de_text",
"normalize_en_text",
"normalize_es_text",
"normalize_it_text",
"normalize_nl_text",
"normalize_ru_text",
]

View File

@@ -0,0 +1,58 @@
from __future__ import annotations
import re
DE_LINE_REPLACEMENTS = {
"Häufig gestellte Fragen Transparent über Planung, Vorgehensweise und Management.": "Häufig gestellte Fragen Klarheit über Planung, Vorgehensweise und Management.",
"Einführungsmeeting planen Projekte anzeigen Unverbindliches Gespräch, klares Angebot Wir entwickeln schnelle Websites und Webshops, die Ihr Team selbst pflegen kann.": "Erstgespräch planen · Projekte ansehen · Unverbindliches Gespräch mit klarem Angebot. Wir entwickeln schnelle Websites und Webshops, die Ihr Team selbst pflegen kann.",
"Einführungsmeeting planen Dienstleistungen anzeigen Verbindlich und klar Wir entwickeln schnelle Websites und Webshops, die Ihr Team selbst pflegen kann.": "Erstgespräch planen · Dienstleistungen anzeigen · Unverbindliches Gespräch mit klarem Angebot. Wir entwickeln schnelle Websites und Webshops, die Ihr Team selbst pflegen kann.",
"Steuern 0,00 € Korb ansehen Kasse Kontakt KONTAKT Lass uns dein Projekt konkret machen Einführungsmeeting planen Dienstleistungen anzeigen So können Sie Kontakt aufnehmen Wählen Sie die Route, die zu Ihrer Frage passt.": "Steuern 0,00 € Korb ansehen Kasse Kontakt KONTAKT Lassen Sie uns Ihr Projekt konkret machen Erstgespräch planen Dienstleistungen anzeigen So können Sie Kontakt aufnehmen Wählen Sie den Weg, der zu Ihrer Frage passt.",
"Steuern 0,00 € Korb ansehen Kasse Starter Website PLAN Starter Website Plan Starter-Gespräch planen Alle Dienstleistungen anzeigen Was du bekommst Startseite + Kernseiten Professionelle Basis, die sofort Vertrauen schafft.": "Steuern 0,00 € Korb ansehen Kasse Starter-Website PLAN Starter-Website Starter-Gespräch planen Alle Dienstleistungen anzeigen Was Sie erhalten Startseite + Kernseiten Professionelle Basis, die sofort Vertrauen schafft.",
"Steuern 0,00 € Korb ansehen Kasse Business Website PLAN Business Website Plan Beratungsgespräch planen Alle Dienstleistungen anzeigen Was du bekommst Detailliertes Seitenlayout Mehr Platz für Dienstleistungen, Fälle und Lead-Flows.": "Steuern 0,00 € Korb ansehen Kasse Business-Website PLAN Business-Website Beratungsgespräch planen Alle Dienstleistungen anzeigen Was Sie erhalten Detailliertes Seitenlayout Mehr Platz für Dienstleistungen, Referenzen und Lead-Flows.",
}
DE_PHRASE_REPLACEMENTS = {
"New": "Neu",
"Einführungsmeeting": "Erstgespräch",
"Intakegespräch": "Erstgespräch",
"SEO-ready basis": "SEO-optimierte Basis",
"Sales-ready mit skalierbarem Stack": "Verkaufsbereit mit skalierbarer Architektur",
"Continuous Verbesserung": "Kontinuierliche Verbesserung",
"Was du bekommst": "Was Sie erhalten",
"Starter Website": "Starter-Website",
"Business Website": "Business-Website",
"Support & Wachstum": "Support & Wachstum",
"Lass uns dein Projekt konkret machen": "Lassen Sie uns Ihr Projekt konkret machen",
"Wählen Sie die Route, die zu Ihrer Frage passt.": "Wählen Sie den Weg, der zu Ihrer Frage passt.",
"Verbindlich und klar": "Unverbindliches Gespräch mit klarem Angebot",
"Unverbindliches Gespräch, klares Angebot": "Unverbindliches Gespräch mit klarem Angebot",
}
def _apply_boundary_replacements(text: str, replacements: dict[str, str]) -> str:
cleaned = text
phrase_replacements = {}
token_replacements = {}
for source, target in replacements.items():
if re.fullmatch(r"[\wÀ-ÿ-]+", source, flags=re.UNICODE):
token_replacements[source] = target
else:
phrase_replacements[source] = target
for source, target in sorted(phrase_replacements.items(), key=lambda item: len(item[0]), reverse=True):
cleaned = cleaned.replace(source, target)
for source, target in sorted(token_replacements.items(), key=lambda item: len(item[0]), reverse=True):
pattern = re.compile(rf"(?<![\wÀ-ÿ-]){re.escape(source)}(?![\wÀ-ÿ-])", re.UNICODE)
cleaned = pattern.sub(target, cleaned)
return cleaned
def normalize_de_text(text: str, field_path: str = "") -> str:
cleaned = text
for source, target in DE_LINE_REPLACEMENTS.items():
if cleaned == source:
return target
cleaned = _apply_boundary_replacements(cleaned, DE_PHRASE_REPLACEMENTS)
return cleaned

View File

@@ -0,0 +1,28 @@
from __future__ import annotations
import re
EN_LINE_REPLACEMENTS = {
"Service packages (from) Transparent starting points.": "Service packages (from) Clear starting points.",
"Frequently Asked Questions Transparent about planning, approach, and management.": "Frequently Asked Questions Clear guidance on planning, approach, and management.",
"After your intake Clear scope and steps Clear planning Transparent investment Name * E-mail * Company * Project details Book business call Ready to start with Business Website?": "After your intake Clear scope and steps Clear planning Transparent pricing Name * E-mail * Company * Project details Book business call Ready to start with Business Website?",
"After your intake Clear scope and steps Clear planning Transparent investment Name * E-mail * Company * Project details Book starter call Ready to start with Starter Website?": "After your intake Clear scope and steps Clear planning Transparent pricing Name * E-mail * Company * Project details Book starter call Ready to start with Starter Website?",
"After your intake Clear scope and steps Clear planning Transparent investment Name * E-mail * Company * Project details Request support plan Ready to start with Support & Growth?": "After your intake Clear scope and steps Clear planning Transparent pricing Name * E-mail * Company * Project details Request support plan Ready to start with Support & Growth?",
"After your intake Clear scope and steps Clear planning Transparent investment Name * E-mail * Company * Project details Start webshop project Ready to start with Webshop?": "After your intake Clear scope and steps Clear planning Transparent pricing Name * E-mail * Company * Project details Start webshop project Ready to start with Webshop?",
}
EN_PHRASE_REPLACEMENTS = {
"Transparent investment": "Transparent pricing",
"Transparent about planning, approach, and management.": "Clear guidance on planning, approach, and management.",
"Transparent starting points.": "Clear starting points.",
}
def normalize_en_text(text: str, field_path: str = "") -> str:
if text in EN_LINE_REPLACEMENTS:
return EN_LINE_REPLACEMENTS[text]
cleaned = text
for source, target in sorted(EN_PHRASE_REPLACEMENTS.items(), key=lambda item: len(item[0]), reverse=True):
cleaned = cleaned.replace(source, target)
return re.sub(r"\s+", " ", cleaned).strip()

View File

@@ -0,0 +1,31 @@
from __future__ import annotations
import re
ES_LINE_REPLACEMENTS = {
"Transparente sobre la planificación, el proceso y la gestión.": "Transparencia sobre la planificación, el proceso y la gestión.",
"<p>Transparente sobre la planificación, el proceso y la gestión.</p>": "<p>Transparencia sobre la planificación, el proceso y la gestión.</p>",
"Preguntas frecuentes Transparente sobre la planificación, el proceso y la gestión.": "Preguntas frecuentes Transparencia sobre la planificación, el proceso y la gestión.",
"Preguntas frecuentes Transparenteee sobre la planificación, el proceso y la gestión.": "Preguntas frecuentes Transparencia sobre la planificación, el proceso y la gestión.",
"Planificar la reunión inicial Mostrar los proyectos Unverbindliches Gespräch, klares Angebot Construimos sitios web y tiendas online rápidas que tu equipo puede gestionar sin complicaciones.": "Planificar la reunión inicial · Mostrar los proyectos · Conversación sin compromiso con propuesta clara. Construimos sitios web y tiendas online rápidas que tu equipo puede gestionar sin complicaciones.",
}
ES_PHRASE_REPLACEMENTS = {
"Transparenteee": "Transparente",
"Transparent": "Transparente",
"Unverbindliches Gespräch, klares Angebot": "Conversación sin compromiso con propuesta clara",
}
def normalize_es_text(text: str, field_path: str = "") -> str:
if text in ES_LINE_REPLACEMENTS:
return ES_LINE_REPLACEMENTS[text]
cleaned = text
for source, target in sorted(ES_PHRASE_REPLACEMENTS.items(), key=lambda item: len(item[0]), reverse=True):
if re.fullmatch(r"[\wÀ-ÿ-]+", source, flags=re.UNICODE):
pattern = re.compile(rf"(?<![\wÀ-ÿ-]){re.escape(source)}(?![\wÀ-ÿ-])", re.UNICODE)
cleaned = pattern.sub(target, cleaned)
else:
cleaned = cleaned.replace(source, target)
return re.sub(r"\s+", " ", cleaned).strip()

View File

@@ -0,0 +1,24 @@
from __future__ import annotations
import re
IT_LINE_REPLACEMENTS = {
"Richiedi un piano di supporto Mostra i progetti Unverbindliches Gespräch, klares Angebot Realizziamo siti web e negozi online veloci che il tuo team può gestire in autonomia.": "Richiedi un piano di supporto · Mostra i progetti · Colloquio senza impegno con proposta chiara. Realizziamo siti web e negozi online veloci che il tuo team può gestire in autonomia.",
"Dopo il colloquio iniziale Obiettivi chiari e tappe Planificación clara Transparente Investition Nome * Email * Azienda * Dettagli del progetto Richiedi un piano di supporto Pronto a iniziare con supporto e crescita?": "Dopo il colloquio iniziale Obiettivi chiari e tappe Pianificazione chiara Investimento trasparente Nome * Email * Azienda * Dettagli del progetto Richiedi un piano di supporto Pronto a iniziare con supporto e crescita?",
"Mehrsprachiger Rollout-Plan Anpassung & Integrationen Integrazioni API, flussi di lavoro specifici e blocchi personalizzati adattati alla sua azienda.": "Piano di lancio multilingue Personalizzazioni e integrazioni Integrazioni API, flussi di lavoro specifici e blocchi personalizzati adattati alla sua azienda.",
}
IT_PHRASE_REPLACEMENTS = {
"Planificación clara": "Pianificazione chiara",
"Unverbindliches Gespräch, klares Angebot": "Colloquio senza impegno con proposta chiara",
}
def normalize_it_text(text: str, field_path: str = "") -> str:
if text in IT_LINE_REPLACEMENTS:
return IT_LINE_REPLACEMENTS[text]
cleaned = text
for source, target in sorted(IT_PHRASE_REPLACEMENTS.items(), key=lambda item: len(item[0]), reverse=True):
cleaned = cleaned.replace(source, target)
return re.sub(r"\s+", " ", cleaned).strip()

View File

@@ -0,0 +1,15 @@
from __future__ import annotations
import re
NL_PHRASE_REPLACEMENTS = {
"PLAN": "PLAN",
}
def normalize_nl_text(text: str, field_path: str = "") -> str:
cleaned = text
for source, target in NL_PHRASE_REPLACEMENTS.items():
cleaned = cleaned.replace(source, target)
return re.sub(r"\s+", " ", cleaned).strip()

View File

@@ -0,0 +1,24 @@
from __future__ import annotations
import re
RU_LINE_REPLACEMENTS = {
"План многоязычного запуска Anpassung & Integrationen Интеграции API, специфические рабочие процессы и индивидуальные блоки, адаптированные под вашу компанию.": "План многоязычного запуска Настройка и интеграции Интеграции API, специфические рабочие процессы и индивидуальные блоки, адаптированные под вашу компанию.",
"Запланировать звонок по бизнес-сайту Detailliertes Seitenlayout Разделы, ориентированные на конверсию Base prête pour le SEO Boutique en ligne Для проектов с товарами, оплатой и дальнейшим развитием e-commerce.": "Запланировать звонок по бизнес-сайту Детальная структура страниц Разделы, ориентированные на конверсию Основа, готовая для SEO Интернет-магазин Для проектов с товарами, оплатой и дальнейшим развитием e-commerce.",
"Связаться с нами Посмотреть проекты Unverbindliches Gespräch, klares Angebot Мы создаём быстрые сайты и интернет-магазины, которыми ваша команда может управлять самостоятельно.": "Связаться с нами · Посмотреть проекты · Без обязательств, понятное предложение. Мы создаём быстрые сайты и интернет-магазины, которыми ваша команда может управлять самостоятельно.",
}
RU_PHRASE_REPLACEMENTS = {
"Base prête pour le SEO": "Основа, готовая для SEO",
"Unverbindliches Gespräch, klares Angebot": "Без обязательств, понятное предложение",
}
def normalize_ru_text(text: str, field_path: str = "") -> str:
if text in RU_LINE_REPLACEMENTS:
return RU_LINE_REPLACEMENTS[text]
cleaned = text
for source, target in sorted(RU_PHRASE_REPLACEMENTS.items(), key=lambda item: len(item[0]), reverse=True):
cleaned = cleaned.replace(source, target)
return re.sub(r"\s+", " ", cleaned).strip()

View File

@@ -0,0 +1,79 @@
from __future__ import annotations
"""
Reusable configuration helpers for mandelblog_content_guard.
Supported Django settings:
- CONTENT_GUARD_STRICT: bool
- CONTENT_GUARD_BLOCK_MEDIUM: bool
- CONTENT_GUARD_LOCALES: list[str]
- CONTENT_GUARD_REWRITE_ENABLED: bool
- CONTENT_GUARD_REWRITE_BACKEND: dotted path | None
"""
from django.conf import settings
DEFAULT_LOCALES = ["nl", "en", "de", "fr", "es", "it", "pt", "ru"]
SEVERITY = {
"CRITICAL": "block",
"HIGH": "block",
"MEDIUM": "warn",
"LOW": "log",
}
ISSUE_LEVELS = {
"known_bad_pattern": "CRITICAL",
"wrong_language_fragment": "CRITICAL",
"rendered_bad_pattern": "CRITICAL",
"rendered_wrong_language": "CRITICAL",
"render_status": "CRITICAL",
"language_heuristic": "CRITICAL",
"cta_language_mismatch": "HIGH",
"form_language_mismatch": "HIGH",
"empty_form_copy": "HIGH",
"placeholder_value": "HIGH",
"rewrite_candidate": "MEDIUM",
"weak_marketing_copy": "MEDIUM",
"foreign_ui_label": "MEDIUM",
"generic_badge_label": "MEDIUM",
"mixed_locale_heading": "MEDIUM",
"cta_tone_check": "MEDIUM",
}
def strict_mode_enabled() -> bool:
return getattr(settings, "CONTENT_GUARD_STRICT", True)
def block_medium_enabled() -> bool:
return getattr(settings, "CONTENT_GUARD_BLOCK_MEDIUM", False)
def audit_default_locales() -> list[str]:
return list(getattr(settings, "CONTENT_GUARD_LOCALES", DEFAULT_LOCALES))
def rewrite_enabled() -> bool:
return getattr(settings, "CONTENT_GUARD_REWRITE_ENABLED", True)
def get_rewrite_backend() -> str | None:
return getattr(settings, "CONTENT_GUARD_REWRITE_BACKEND", None)
def classify_issue(issue_type: str) -> str:
return ISSUE_LEVELS.get(issue_type, "LOW")
def severity_for_issue(issue_type: str) -> str:
return SEVERITY[classify_issue(issue_type)]
def should_block_issue(issue_type: str) -> bool:
level = classify_issue(issue_type)
if level in {"CRITICAL", "HIGH"}:
return True
if level == "MEDIUM":
return block_medium_enabled() and strict_mode_enabled()
return False

View File

@@ -0,0 +1,26 @@
from __future__ import annotations
from functools import lru_cache
from django.db.models.signals import pre_save
from django.dispatch import receiver
from wagtail.models import Page
from wagtail.snippets.models import get_snippet_models
from .validators.multilingual import validate_instance_or_raise
@lru_cache(maxsize=1)
def _snippet_models():
return tuple(get_snippet_models())
def _is_snippet_instance(instance) -> bool:
instance_model = instance.__class__
return any(model == instance_model for model in _snippet_models())
@receiver(pre_save)
def enforce_multilingual_integrity(sender, instance, **kwargs):
if isinstance(instance, Page) or _is_snippet_instance(instance):
validate_instance_or_raise(instance)

View File

@@ -0,0 +1,368 @@
from __future__ import annotations
from collections.abc import Iterable
SYSTEM_STRING_SPECS = {
"plan_badge": {
"sources": ("PLAN",),
"issue_type": "generic_badge_label",
"translations": {
"en": "Package",
"fr": "FORFAIT",
"es": "Paquete",
"ru": "Пакет",
},
"canonical_by_locale": {
"de": ("PLAN",),
"nl": ("PLAN",),
"it": ("PIANO",),
},
"contexts": {
"en": {
"badge": "Package",
"label": "Package",
"title": "Package",
"heading": "Package",
"rendered": "Package",
},
"fr": {
"badge": "FORFAIT",
"label": "FORFAIT",
"title": "FORFAIT",
"heading": "FORFAIT",
"rendered": "FORFAIT",
},
"es": {
"badge": "Paquete",
"label": "Paquete",
"title": "Paquete",
"heading": "Paquete",
"rendered": "Paquete",
},
"ru": {
"badge": "Пакет",
"label": "Пакет",
"title": "Пакет",
"heading": "Пакет",
"rendered": "Пакет",
},
},
},
"services_badge": {
"sources": ("SERVICES",),
"issue_type": "generic_badge_label",
"translations": {
"en": "Services",
"fr": "PRESTATIONS",
"pt": "SERVIÇOS",
},
"contexts": {
"en": {
"badge": "Services",
"label": "Services",
"title": "Services",
"heading": "Services",
"rendered": "Services",
},
"fr": {
"badge": "PRESTATIONS",
"label": "PRESTATIONS",
"title": "PRESTATIONS",
"heading": "PRESTATIONS",
"rendered": "PRESTATIONS",
},
"pt": {
"badge": "SERVIÇOS",
"label": "SERVIÇOS",
"title": "SERVIÇOS",
"heading": "SERVIÇOS",
"rendered": "SERVIÇOS",
},
},
},
"response_time": {
"sources": ("Reaktionszeit",),
"issue_type": "foreign_ui_label",
"translations": {
"en": "Response time",
"fr": "Temps de réponse",
"es": "Tiempo de respuesta",
"it": "Tempo di risposta",
"ru": "Время ответа",
},
},
"average_delivery": {
"sources": ("Durchschnittliche Lieferung",),
"issue_type": "foreign_ui_label",
"translations": {
"en": "Average delivery time",
"fr": "Délai moyen de livraison",
"es": "Plazo medio de entrega",
"it": "Tempo medio di consegna",
"ru": "Средний срок запуска",
},
},
"without_commitment": {
"sources": ("Unverbindlich",),
"issue_type": "foreign_ui_label",
"translations": {
"en": "No obligation",
"fr": "Sans engagement",
"es": "Sin compromiso",
"it": "Senza impegno",
"pt": "Sem compromisso",
"ru": "Без обязательств",
},
},
"transparent_label": {
"sources": ("Transparent",),
"issue_type": "foreign_ui_label",
"translations": {
"en": "Clear",
"fr": "Clair",
"es": "Transparente",
"it": "Chiaro",
"pt": "Transparente",
"ru": "Прозрачно",
},
"contexts": {
"en": {
"badge": "Clear",
"label": "Clear",
"metric": "Clear",
"stat": "Clear",
"rendered": "Clear",
},
"fr": {
"badge": "Clair",
"label": "Clair",
"metric": "Clair",
"stat": "Clair",
"rendered": "Clair",
},
"es": {
"badge": "Transparente",
"label": "Transparente",
"metric": "Transparente",
"stat": "Transparente",
"rendered": "Transparente",
},
"it": {
"badge": "Chiaro",
"label": "Chiaro",
"metric": "Chiaro",
"stat": "Chiaro",
"rendered": "Chiaro",
},
"pt": {
"badge": "Clara",
"label": "Clara",
"metric": "Investimento claro",
"stat": "Investimento claro",
"rendered": "Investimento claro",
},
"ru": {
"badge": "Прозрачно",
"label": "Прозрачно",
"metric": "Прозрачно",
"stat": "Прозрачно",
"rendered": "Прозрачно",
},
},
},
"weeks_1_2": {
"sources": ("1-2 Wochen",),
"issue_type": "weak_marketing_copy",
"translations": {
"fr": "1 à 2 semaines",
"es": "1-2 semanas",
"it": "1-2 settimane",
"pt": "1 a 2 semanas",
},
"contexts": {
"fr": {
"metric": "1 à 2 semaines",
"stat": "1 à 2 semaines",
},
"es": {
"metric": "1-2 semanas",
"stat": "1-2 semanas",
},
"it": {
"metric": "1-2 settimane",
"stat": "1-2 settimane",
},
"pt": {
"metric": "1 a 2 semanas",
"stat": "1 a 2 semanas",
},
},
},
"weeks_2_4": {
"sources": ("2-4 Wochen",),
"issue_type": "foreign_ui_label",
"translations": {
"fr": "2 à 4 semaines",
},
"contexts": {
"fr": {
"metric": "2 à 4 semaines",
"stat": "2 à 4 semaines",
},
},
},
"days_label": {
"sources": ("Tages",),
"issue_type": "weak_marketing_copy",
"translations": {
"fr": "jours",
"pt": "dias",
},
},
"customer_reviews": {
"sources": ("Kundenschätzung",),
"issue_type": "foreign_ui_label",
"translations": {
"en": "Customer rating",
"fr": "Avis clients",
"es": "Valoración de clientes",
"it": "Valutazione clienti",
"pt": "Avaliação dos clientes",
"ru": "Оценка клиентов",
},
},
"editable_label": {
"sources": ("Bearbeitbar",),
"issue_type": "foreign_ui_label",
"translations": {
"en": "Editable",
"fr": "Modifiable",
"es": "Editable",
"it": "Modificabile",
"pt": "Editável",
"ru": "Редактируемо",
},
},
"core_pages_label": {
"sources": ("Startseite + Kernseiten",),
"issue_type": "foreign_ui_label",
"translations": {
"pt": "Página inicial + páginas essenciais",
},
},
"detailed_page_structure": {
"sources": ("Detailliertes Seitenlayout",),
"issue_type": "foreign_ui_label",
"translations": {
"fr": "Structure détaillée des pages",
"es": "Estructura detallada de páginas",
"it": "Struttura dettagliata delle pagine",
"pt": "Estrutura detalhada das páginas",
"ru": "Детальная структура страниц",
},
},
"business_process_cta": {
"sources": ("Geschäftsprozess besprechen",),
"issue_type": "foreign_ui_label",
"translations": {
"fr": "Échanger sur votre processus métier",
"es": "Hablar sobre el proceso del negocio",
"pt": "Falar sobre o processo do negócio",
},
},
"multilingual_rollout": {
"sources": ("Mehrsprachige Einführung", "Mehrsprachiger Rollout-Plan"),
"issue_type": "foreign_ui_label",
"translations": {
"fr": "Déploiement multilingue",
"it": "Lancio multilingue",
"ru": "Многоязычный запуск",
},
},
"customization_integrations": {
"sources": ("Anpassung & Integrationen",),
"issue_type": "foreign_ui_label",
"translations": {
"fr": "Personnalisation & intégrations",
"es": "Personalización e integraciones",
"it": "Personalizzazioni e integrazioni",
"pt": "Personalização e integrações",
"ru": "Настройка и интеграции",
},
},
"transparent_investment": {
"sources": ("Transparente Investition",),
"issue_type": "foreign_ui_label",
"translations": {
"de": "Transparente Investition",
"en": "Transparent pricing",
"fr": "Investissement transparent",
"es": "Inversión transparente",
"it": "Investimento trasparente",
"pt": "Investimento transparente",
"ru": "Прозрачный бюджет",
},
},
}
def build_system_vocabulary(locale_code: str, keys: Iterable[str] | None = None) -> dict[str, str]:
vocabulary: dict[str, str] = {}
selected_keys = tuple(keys or SYSTEM_STRING_SPECS.keys())
for key in selected_keys:
spec = SYSTEM_STRING_SPECS[key]
target = spec.get("translations", {}).get(locale_code)
if not target:
continue
for source in spec["sources"]:
vocabulary[source] = target
return vocabulary
def build_contextual_system_vocabulary(locale_code: str, keys: Iterable[str] | None = None) -> dict[str, dict[str, str]]:
contextual: dict[str, dict[str, str]] = {}
selected_keys = tuple(keys or SYSTEM_STRING_SPECS.keys())
for key in selected_keys:
spec = SYSTEM_STRING_SPECS[key]
locale_contexts = spec.get("contexts", {}).get(locale_code, {})
if not locale_contexts:
continue
source = spec["sources"][0]
for context_name, replacement in locale_contexts.items():
contextual.setdefault(context_name, {})[source] = replacement
return contextual
def build_system_rewrite_candidates(keys: Iterable[str] | None = None) -> dict[str, str]:
candidates: dict[str, str] = {}
selected_keys = tuple(keys or SYSTEM_STRING_SPECS.keys())
for key in selected_keys:
spec = SYSTEM_STRING_SPECS[key]
for source in spec["sources"]:
candidates[source] = spec["issue_type"]
return candidates
def all_system_sources() -> set[str]:
sources: set[str] = set()
for spec in SYSTEM_STRING_SPECS.values():
sources.update(spec["sources"])
return sources
def is_canonical_system_string(locale_code: str, source: str) -> bool:
for spec in SYSTEM_STRING_SPECS.values():
if source in spec.get("canonical_by_locale", {}).get(locale_code, ()):
return True
if locale_code == "de":
return source in all_system_sources()
replacement = system_string_replacement(locale_code, source)
return bool(replacement and replacement == source)
def system_string_replacement(locale_code: str, source: str) -> str:
for spec in SYSTEM_STRING_SPECS.values():
if source not in spec["sources"]:
continue
return spec.get("translations", {}).get(locale_code, "")
return ""

View File

@@ -0,0 +1,56 @@
from __future__ import annotations
import json
from django.test import SimpleTestCase
from mandelblog_content_guard.agents import get_language_agent
from mandelblog_content_guard.extractors.visible_text import extract_visible_rendered_text
from mandelblog_content_guard.system_strings import build_system_rewrite_candidates, build_system_vocabulary
from mandelblog_content_guard.validators.multilingual import validate_text_nodes
class PackageLevelContentGuardTests(SimpleTestCase):
def test_system_string_replacement_catalog(self):
self.assertEqual(build_system_vocabulary("fr")["PLAN"], "FORFAIT")
self.assertEqual(build_system_vocabulary("pt")["Unverbindlich"], "Sem compromisso")
self.assertEqual(build_system_rewrite_candidates()["PLAN"], "generic_badge_label")
def test_canonical_source_suppression(self):
nl_issues = validate_text_nodes("nl", [("body.badge", "PLAN")])
it_issues = validate_text_nodes("it", [("body.badge", "PIANO")])
self.assertFalse(any(issue.bad_value == "PLAN" for issue in nl_issues))
self.assertFalse(any(issue.bad_value == "PIANO" for issue in it_issues))
def test_visible_text_extraction(self):
html = """
<html><body>
<script>var x = 1;</script>
<style>.hidden{display:none}</style>
<h1>Visible heading</h1>
<p aria-hidden="true">Invisible text</p>
<a href="#">Visible link</a>
</body></html>
"""
extracted = extract_visible_rendered_text(html)
self.assertIn("Visible heading", extracted)
self.assertIn("Visible link", extracted)
self.assertNotIn("Invisible text", extracted)
self.assertNotIn("var x", extracted)
def test_locale_normalizers(self):
de_agent = get_language_agent("de")
en_agent = get_language_agent("en")
self.assertEqual(de_agent.rewrite("Was du bekommst", "body.heading"), "Was Sie erhalten")
self.assertEqual(en_agent.rewrite("PLAN", "body.badge"), "Package")
def test_audit_json_contract_shape(self):
payload = {
"run_id": 1,
"summary": {"en": {"total_urls_checked": 1, "issues_found": 0, "issues_fixed": 0, "remaining_issues": 0, "by_severity": {"block": 0, "warn": 0, "log": 0}}},
"issues": {"en": []},
}
rendered = json.dumps(payload)
parsed = json.loads(rendered)
self.assertEqual(sorted(parsed.keys()), ["issues", "run_id", "summary"])
self.assertIn("by_severity", parsed["summary"]["en"])

View File

@@ -0,0 +1,65 @@
from __future__ import annotations
from dataclasses import asdict, dataclass
from typing import Any
from .settings import classify_issue, severity_for_issue, should_block_issue
@dataclass
class AuditIssue:
severity: str
issue_type: str
field_path: str
bad_value: str
replacement: str = ""
extra: dict[str, Any] | None = None
@property
def level(self) -> str:
return classify_issue(self.issue_type)
@property
def blocks(self) -> bool:
return self.severity == "block" or should_block_issue(self.issue_type)
def asdict(self) -> dict[str, Any]:
data = asdict(self)
data["extra"] = data.get("extra") or {}
data["level"] = self.level
return data
def make_issue(issue_type: str, field_path: str, bad_value: str, replacement: str = "", extra: dict[str, Any] | None = None) -> AuditIssue:
return AuditIssue(
severity=severity_for_issue(issue_type),
issue_type=issue_type,
field_path=field_path,
bad_value=bad_value,
replacement=replacement,
extra=extra or {},
)
def dedupe_issues(issues: list[AuditIssue]) -> list[AuditIssue]:
seen = set()
deduped = []
for issue in issues:
key = (issue.severity, issue.issue_type, issue.field_path, issue.bad_value)
if key in seen:
continue
seen.add(key)
deduped.append(issue)
return deduped
def split_issues(issues: list[AuditIssue]) -> tuple[list[AuditIssue], list[AuditIssue]]:
blocking = [issue for issue in issues if issue.blocks]
warnings = [issue for issue in issues if not issue.blocks]
return blocking, warnings
def format_issue(issue: AuditIssue) -> str:
suffix = f" -> {issue.replacement}" if issue.replacement else ""
return f"[{issue.level}] {issue.field_path}: {issue.bad_value}{suffix}"

View File

@@ -0,0 +1,452 @@
from __future__ import annotations
import logging
import re
from collections import Counter
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
from django.core.exceptions import ValidationError
from django.utils import timezone
from wagtail.models import Page, Site
from wagtail.snippets.models import get_snippet_models
from ..agents import get_language_agent
from ..extractors.visible_text import extract_visible_rendered_text, normalize_text
from ..settings import audit_default_locales, rewrite_enabled
from ..types import dedupe_issues, format_issue, make_issue
from .rules.cta import validate_cta
from .rules.forms import validate_form_copy
from .rules.language import detect_language_mismatch
from .rules.patterns import (
GLOBAL_BAD_PATTERNS,
KNOWN_REPLACEMENTS,
LOCALE_FORBIDDEN,
validate_patterns,
)
from mandelstudio.models import LocaleAuditIssue, LocaleAuditRun
logger = logging.getLogger("mandelstudio.multilingual")
def expected_locale(instance: Any) -> str:
locale = getattr(instance, "locale", None)
if locale is not None and getattr(locale, "language_code", None):
return locale.language_code
return "nl"
def iter_text_nodes(value: Any, path: str = ""):
if value is None:
return
if isinstance(value, str):
yield path, value
return
if hasattr(value, "raw_data"):
yield from iter_text_nodes(list(value.raw_data), path)
return
if isinstance(value, list):
for index, item in enumerate(value):
yield from iter_text_nodes(item, f"{path}[{index}]")
return
if isinstance(value, dict):
for key, item in value.items():
child_path = f"{path}.{key}" if path else str(key)
yield from iter_text_nodes(item, child_path)
def extract_instance_text(instance: Any) -> list[tuple[str, str]]:
nodes: list[tuple[str, str]] = []
for field_name in ["title", "seo_title", "search_description"]:
value = getattr(instance, field_name, None)
if isinstance(value, str) and value.strip():
nodes.append((field_name, value))
for field_name in ["body", "content", "footer", "mini_footer"]:
if hasattr(instance, field_name):
nodes.extend(list(iter_text_nodes(getattr(instance, field_name), field_name)))
return nodes
def validate_text_nodes(locale_code: str, nodes: list[tuple[str, str]]):
issues = []
for field_path, raw_text in nodes:
normalized = normalize_text(raw_text)
if not normalized:
continue
issues.extend(validate_patterns(locale_code, field_path, normalized))
issues.extend(validate_cta(locale_code, field_path, normalized))
issues.extend(validate_form_copy(locale_code, field_path, normalized))
if len(normalized) >= 80:
mismatch = detect_language_mismatch(locale_code, normalized)
if mismatch:
issues.append(make_issue("language_heuristic", field_path, mismatch["message"]))
return dedupe_issues(issues)
REWRITE_REVIEW_TYPES = {
"known_bad_pattern",
"wrong_language_fragment",
"rendered_bad_pattern",
"rendered_wrong_language",
"rewrite_candidate",
"weak_marketing_copy",
"foreign_ui_label",
"generic_badge_label",
"mixed_locale_heading",
"cta_language_mismatch",
}
def validate_page(page: Page):
return validate_text_nodes(expected_locale(page), extract_instance_text(page.specific))
def validate_snippet_instance(instance: Any):
return validate_text_nodes(expected_locale(instance), extract_instance_text(instance))
def validate_posted_snippet(locale_code: str, payload: dict[str, Any]):
nodes = [(key, value) for key, value in payload.items() if isinstance(value, str)]
return validate_text_nodes(locale_code, nodes)
def _replace_known_strings(value: Any, locale_code: str):
changes = []
if isinstance(value, str):
new = value
for bad, replacements in KNOWN_REPLACEMENTS.items():
replacement = replacements.get(locale_code)
if replacement and bad in new:
new = new.replace(bad, replacement)
changes.append({"bad": bad, "replacement": replacement})
return new, changes, new != value
if isinstance(value, list):
out = []
changed = False
for item in value:
new_item, item_changes, item_changed = _replace_known_strings(item, locale_code)
out.append(new_item)
changes.extend(item_changes)
changed = changed or item_changed
return out, changes, changed
if isinstance(value, dict):
out = {}
changed = False
for key, item in value.items():
new_item, item_changes, item_changed = _replace_known_strings(item, locale_code)
out[key] = new_item
changes.extend(item_changes)
changed = changed or item_changed
return out, changes, changed
return value, changes, False
def apply_known_replacements(instance: Any, locale_code: str):
changes = []
for field_name in ["title", "seo_title", "search_description"]:
value = getattr(instance, field_name, None)
if not isinstance(value, str):
continue
new_value, field_changes, changed = _replace_known_strings(value, locale_code)
if changed:
setattr(instance, field_name, new_value)
changes.extend({"field": field_name, **change} for change in field_changes)
for field_name in ["body", "content", "footer", "mini_footer"]:
if not hasattr(instance, field_name):
continue
field_value = getattr(instance, field_name)
if hasattr(field_value, "raw_data"):
new_raw, field_changes, changed = _replace_known_strings(list(field_value.raw_data), locale_code)
if changed:
setattr(instance, field_name, new_raw)
changes.extend({"field": field_name, **change} for change in field_changes)
elif isinstance(field_value, str):
new_value, field_changes, changed = _replace_known_strings(field_value, locale_code)
if changed:
setattr(instance, field_name, new_value)
changes.extend({"field": field_name, **change} for change in field_changes)
if not changes:
return []
if isinstance(instance, Page):
revision = instance.save_revision()
if instance.live:
revision.publish()
return changes
instance.save()
return changes
def rewrite_with_agent(instance: Any, locale_code: str, issues, *, dry_run: bool = False):
if not rewrite_enabled():
return []
agent = get_language_agent(locale_code)
issue_map = agent.build_issue_map(issues)
changes = []
for field_name in ["title", "seo_title", "search_description"]:
value = getattr(instance, field_name, None)
if not isinstance(value, str):
continue
field_issues = issue_map.get(field_name, [])
rewritten = agent.rewrite(value, field_path=field_name, issues=field_issues)
if rewritten != value:
setattr(instance, field_name, rewritten)
changes.append({"field": field_name, "before": value, "after": rewritten, "method": "agent"})
for field_name in ["body", "content", "footer", "mini_footer"]:
if not hasattr(instance, field_name):
continue
field_value = getattr(instance, field_name)
if hasattr(field_value, "raw_data"):
rewritten, changed = agent.process_block(list(field_value.raw_data), field_name, issue_map)
if changed:
setattr(instance, field_name, rewritten)
changes.append({"field": field_name, "method": "agent"})
elif isinstance(field_value, str):
rewritten = agent.rewrite(field_value, field_path=field_name, issues=issue_map.get(field_name, []))
if rewritten != field_value:
setattr(instance, field_name, rewritten)
changes.append({"field": field_name, "before": field_value, "after": rewritten, "method": "agent"})
if not changes or dry_run:
return changes
if isinstance(instance, Page):
revision = instance.save_revision()
if instance.live:
revision.publish()
return changes
instance.save()
return changes
def enumerate_public_pages(locale_codes: list[str] | None = None, url_filters: list[str] | None = None):
result = {}
site = Site.objects.order_by("id").first()
site_root = getattr(site, "root_page", None)
normalized_filters = set(url_filters or [])
for locale_code in (locale_codes or audit_default_locales()):
locale_root_path = None
if site_root is not None:
translated_root = (
Page.objects.filter(
translation_key=site_root.translation_key,
locale__language_code=locale_code,
)
.specific()
.first()
)
chosen_root = translated_root or site_root
locale_root_path = getattr(chosen_root, "path", None)
qs = (
Page.objects.filter(locale__language_code=locale_code)
.live()
.public()
.specific()
.order_by("path")
)
pages = []
for page in qs:
page_url = getattr(page, "url", None)
if not page_url:
continue
if locale_root_path and not page.path.startswith(locale_root_path):
continue
if normalized_filters and page_url not in normalized_filters:
continue
pages.append(page)
result[locale_code] = pages
return result
def fetch_rendered_text(page: Page):
page_url = getattr(page, "url", None)
if not page_url:
return 598, "missing page URL"
if str(page_url).startswith("http"):
full_url = page_url
else:
try:
site = page.get_site()
except Site.DoesNotExist:
site = None
site = site or Site.objects.order_by("id").first()
if site is None or not getattr(site, "root_url", None):
return 598, "missing site root_url"
full_url = f"{site.root_url}{page_url}"
request = Request(full_url, headers={"User-Agent": "mandelstudio-audit/1.0"})
try:
with urlopen(request, timeout=30) as response:
status = response.getcode()
body = response.read().decode("utf-8", errors="replace")
except HTTPError as exc:
status = exc.code
body = exc.read().decode("utf-8", errors="replace")
except URLError as exc:
status = 599
body = str(exc)
text = extract_visible_rendered_text(body)
return status, text
def iter_rendered_lines(rendered_text: str) -> list[str]:
lines = []
for chunk in re.split(r"(?<=[\.\!\?])\s+|\s{2,}", rendered_text):
normalized = normalize_text(chunk)
if normalized:
lines.append(normalized)
return lines
def validate_rendered_output(locale_code: str, rendered_text: str, status_code: int):
issues = []
if status_code != 200:
issues.append(make_issue("render_status", "rendered", str(status_code)))
source_counter = Counter()
for line in iter_rendered_lines(rendered_text):
line_issues = validate_patterns(locale_code, "rendered", line)
for issue in line_issues:
issue.bad_value = line
issue.extra = {**(issue.extra or {}), "source": "rendered"}
source_counter[(issue.issue_type, issue.bad_value)] += 1
issues.extend(line_issues)
for issue in issues:
if issue.extra is not None:
issue.extra["count"] = source_counter.get((issue.issue_type, issue.bad_value), 1)
for fragment in GLOBAL_BAD_PATTERNS:
if fragment in rendered_text:
issue = make_issue("rendered_bad_pattern", "rendered", fragment, KNOWN_REPLACEMENTS.get(fragment, {}).get(locale_code, ""))
issue.extra = {"source": "rendered", "count": 1}
issues.append(issue)
for fragment in LOCALE_FORBIDDEN.get(locale_code, ()):
if fragment in rendered_text:
issue = make_issue("rendered_wrong_language", "rendered", fragment, KNOWN_REPLACEMENTS.get(fragment, {}).get(locale_code, ""))
issue.extra = {"source": "rendered", "count": 1}
issues.append(issue)
return dedupe_issues(issues)
def annotate_rewrite_previews(locale_code: str, issues):
agent = get_language_agent(locale_code)
for issue in issues:
if issue.issue_type not in REWRITE_REVIEW_TYPES:
continue
if issue.replacement:
continue
preview = agent.rewrite(issue.bad_value, field_path=issue.field_path, issues=[issue])
if preview and preview != issue.bad_value:
issue.replacement = preview
issue.extra = {**(issue.extra or {}), "review_candidate": True}
return issues
def validate_instance_or_raise(instance: Any):
issues = validate_page(instance) if isinstance(instance, Page) else validate_snippet_instance(instance)
blocking = [issue for issue in issues if issue.blocks]
if not blocking:
return issues
raise ValidationError({"content_guard": [format_issue(issue) for issue in blocking]})
def validate_ai_text_or_raise(locale_code: str, field_path: str, value: str):
issues = validate_text_nodes(locale_code, [(field_path, value)])
blocking = [issue for issue in issues if issue.blocks]
if not blocking:
return issues
raise ValidationError({"content_guard": [format_issue(issue) for issue in blocking]})
def record_issues(run: LocaleAuditRun, locale_code: str, obj: Any, issues, *, fixed: bool = False) -> None:
for issue in issues:
LocaleAuditIssue.objects.create(
run=run,
locale_code=locale_code,
object_id=getattr(obj, "pk", None),
object_type=obj.__class__.__name__,
url=getattr(obj, "url", "") or "",
title=getattr(obj, "title", str(obj))[:255],
severity=issue.severity,
issue_type=issue.issue_type,
field_path=issue.field_path,
bad_value=issue.bad_value,
replacement=issue.replacement,
fixed=fixed,
extra=issue.extra or {},
)
def audit_locales(locale_codes: list[str], fix: bool = False, rewrite: bool = False, dry_run: bool = False, url_filters: list[str] | None = None) -> LocaleAuditRun:
run = LocaleAuditRun.objects.create(locale_codes=locale_codes, fix_enabled=fix or rewrite)
pages_by_locale = enumerate_public_pages(locale_codes, url_filters=url_filters)
summary: dict[str, Any] = {}
total_checked = 0
total_issues = 0
pages_with_issues = 0
for locale_code, pages in pages_by_locale.items():
locale_summary = {"total_urls_checked": len(pages), "issues_found": 0, "issues_fixed": 0, "remaining_issues": 0, "by_severity": {"block": 0, "warn": 0, "log": 0}}
for page in pages:
total_checked += 1
status_code, rendered = fetch_rendered_text(page)
issues = dedupe_issues(validate_page(page) + validate_rendered_output(locale_code, rendered, status_code))
if rewrite:
issues = annotate_rewrite_previews(locale_code, issues)
initial_issue_count = len(issues)
fixed_changes = []
if issues and fix:
fixed_changes = apply_known_replacements(page.specific, locale_code)
if fixed_changes:
record_issues(run, locale_code, page, issues, fixed=True)
status_code, rendered = fetch_rendered_text(page.specific)
issues = dedupe_issues(validate_page(page.specific) + validate_rendered_output(locale_code, rendered, status_code))
if rewrite:
issues = annotate_rewrite_previews(locale_code, issues)
if issues and rewrite:
rewrite_changes = rewrite_with_agent(page.specific, locale_code, issues, dry_run=dry_run)
if rewrite_changes:
record_issues(run, locale_code, page, issues, fixed=not dry_run)
if not dry_run:
status_code, rendered = fetch_rendered_text(page.specific)
issues = dedupe_issues(validate_page(page.specific) + validate_rendered_output(locale_code, rendered, status_code))
issues = annotate_rewrite_previews(locale_code, issues)
if issues:
pages_with_issues += 1
record_issues(run, locale_code, page, issues)
locale_summary["issues_found"] += initial_issue_count
locale_summary["issues_fixed"] += initial_issue_count - len(issues)
locale_summary["remaining_issues"] += len(issues)
for issue in issues:
locale_summary["by_severity"][issue.severity] = locale_summary["by_severity"].get(issue.severity, 0) + 1
total_issues += initial_issue_count
summary[locale_code] = locale_summary
snippet_summary = {}
for model in get_snippet_models():
count = 0
for instance in model.objects.all():
issues = validate_snippet_instance(instance)
if rewrite:
issues = annotate_rewrite_previews(expected_locale(instance), issues)
if issues and rewrite:
rewrite_changes = rewrite_with_agent(instance, expected_locale(instance), issues, dry_run=dry_run)
if rewrite_changes and not dry_run:
issues = validate_snippet_instance(instance)
if not issues:
continue
count += len(issues)
record_issues(run, expected_locale(instance), instance, issues)
if count:
snippet_summary[model.__name__] = count
total_issues += count
summary["snippets"] = snippet_summary
run.total_urls_checked = total_checked
run.issues_found = total_issues
run.pages_with_issues = pages_with_issues
run.summary = summary
run.finished_at = timezone.now()
run.save(update_fields=["total_urls_checked", "issues_found", "pages_with_issues", "summary", "finished_at"])
logger.info("Completed multilingual audit run %s", run.pk)
return run

View File

@@ -0,0 +1,150 @@
from __future__ import annotations
import re
from ...types import make_issue
CTA_RULES = {
"nl": (
r"^Plan ",
r"^Bekijk ",
r"^Vraag ",
r"^Bespreek ",
r"^Contact$",
r"^Start ",
r"^Meer ",
r"^Verstuur ",
r"^Neem ",
),
"en": (
r"^Book ",
r"^View ",
r"^Schedule ",
r"^Start ",
r"^Talk ",
r"^Discuss ",
r"^Contact$",
r"^Explore ",
r"^Learn ",
r"^Request ",
r"^Send ",
),
"de": (
r"^Beratung",
r"^Plan",
r"^Mehr",
r"^Support",
r"^Start",
r"^Kontakt",
r"^Gespr",
r"^Kostenlose",
r"^Anfrage",
r"^Projekte",
r"^Verein",
r"^Besprech",
r"^Anzeig",
r"^Ansehen",
r"^Technisch",
r"^Unterst",
r"^Unsere",
r"^Service",
r"^Dienstleistungen",
r"^Erstgespräch",
r"^Einführ",
r"^Anpassung",
r"^Ansichts",
r"^Prozess",
r"^Pakete",
r"^Demo",
r"^Alle ",
r"^Ein ",
r"^Webshop",
),
"fr": (
r"^Planifier",
r"^Voir",
r"^Découvrir",
r"^Demander",
r"^Lancer",
r"^Démarrer",
r"^Contacter",
r"^Contact$",
r"^Parler",
r"^Lancez",
r"^Prendre",
r"^Envoyer",
r"^Afficher",
),
"es": (
r"^Reservar",
r"^Ver",
r"^Solicitar",
r"^Inicia",
r"^Hablar",
r"^Descubrir",
r"^Contactar",
r"^Planificar",
r"^Programe",
r"^Programar",
r"^Concertar",
r"^Enviar",
r"^Mostrar",
r"^Comenta",
),
"it": (
r"^Prenota",
r"^Vedi",
r"^Avvia",
r"^Richiedi",
r"^Contatta",
r"^Contatto$",
r"^Scopri",
r"^Pianifica",
r"^Invia",
r"^Mostra",
r"^Parla",
r"^Parliamo",
),
"pt": (
r"^Agendar",
r"^Ver",
r"^Iniciar",
r"^Pedir",
r"^Contactar",
r"^Falar",
r"^Explorar",
r"^Marcar",
r"^Solicitar",
r"^Enviar",
r"^Mostrar",
),
"ru": (
r"^Заплан",
r"^Посмотр",
r"^Запуст",
r"^Связ",
r"^Подробнее",
r"^Показать",
r"^Отправ",
r"^Получ",
r"^Запрос",
),
}
CTA_FIELDS = {
"cta_text",
"primary_cta_text",
"secondary_cta_text",
"submit_button_text",
}
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, ())
):
return []
return [make_issue("cta_language_mismatch", field_path, normalized)]

View File

@@ -0,0 +1,21 @@
from __future__ import annotations
from ...types import make_issue
from .patterns import PLACEHOLDER_VALUES
from .language import detect_language_mismatch
FORM_FIELDS = {"label", "placeholder", "help_text"}
def validate_form_copy(locale_code: str, field_path: str, normalized: str):
last_segment = field_path.split(".")[-1]
if last_segment not in FORM_FIELDS:
return []
issues = []
if normalized in PLACEHOLDER_VALUES or normalized == "":
issues.append(make_issue("empty_form_copy", field_path, normalized))
mismatch = detect_language_mismatch(locale_code, normalized)
if mismatch:
issues.append(make_issue("form_language_mismatch", field_path, mismatch["message"]))
return issues

View File

@@ -0,0 +1,43 @@
from __future__ import annotations
import re
STOPWORDS = {
"nl": {"de", "het", "een", "en", "voor", "met", "van", "je", "wij", "niet"},
"en": {"the", "and", "for", "with", "your", "you", "from", "that", "this", "not"},
"de": {"der", "die", "das", "und", "mit", "für", "nicht", "eine", "ist", "sie"},
"fr": {"le", "la", "les", "et", "avec", "pour", "vous", "une", "pas", "des"},
"es": {"el", "la", "los", "las", "con", "para", "una", "que", "del", "por"},
"it": {"il", "la", "con", "per", "una", "che", "del", "non", "gli", "dei"},
"pt": {"o", "a", "os", "as", "com", "para", "uma", "que", "não", "dos"},
"ru": {"и", "в", "на", "с", "для", "что", "это", "как", "по", "не"},
}
def _tokenize(text: str) -> list[str]:
text = re.sub(r"<[^>]+>", " ", text)
return re.findall(r"[\w\u0400-\u04FF']+", text.lower())
def detect_language_mismatch(locale_code: str, text: str):
tokens = _tokenize(text)
if len(tokens) < 12:
return None
scores = {code: sum(1 for token in tokens if token in words) for code, words in STOPWORDS.items()}
expected = scores.get(locale_code, 0)
foreign_locale, foreign_score = max(scores.items(), key=lambda item: item[1])
if foreign_locale == locale_code:
return None
if expected >= foreign_score:
return None
if foreign_score >= 6 and foreign_score >= expected + 4:
return {
"severity": "block",
"message": f"expected={locale_code}, detected={foreign_locale}, score={foreign_score}, expected_score={expected}",
}
if expected == 0 and foreign_score >= 5:
return {
"severity": "warn",
"message": f"expected={locale_code}, detected={foreign_locale}, score={foreign_score}, expected_score={expected}",
}
return None

View File

@@ -0,0 +1,269 @@
from __future__ import annotations
import re
from ...types import make_issue
from ...system_strings import (
build_system_rewrite_candidates,
is_canonical_system_string,
system_string_replacement,
)
GLOBAL_BAD_PATTERNS = (
"The Spanish translation",
"The Spanish translation of",
"As the input",
"The input",
"Poiché l'input",
'Unternehmen" è tedesco',
"Support anzeigen",
"Starter intake",
"Business intake",
"Plan Starter intake",
"Plan Business intake",
"Plan de admisión",
"None",
)
LOCALE_FORBIDDEN = {
"nl": ("Starter intake", "Business intake", "Poiché", "Correo electrónico", "Mostrar los servicios", "Plan de admisión"),
"en": ("Starter intake", "Business intake", "Poiché", "Correo electrónico", "Mostrar los servicios", "Questions fréquemment posées", "Plan de admisión"),
"de": ("Starter intake", "Business intake", "Poiché", "Correo electrónico", "Mostrar los servicios", "Questions fréquemment posées", "Plan de admisión"),
"fr": ("Starter intake", "Business intake", "Poiché", "Correo electrónico", "Mostrar los servicios", "Plan de admisión", "Support anzeigen"),
"es": ("Poiché", 'Unternehmen" è tedesco', "Support anzeigen", "Questions fréquemment posées"),
"it": ("Poiché l'input", "Consulta inicial sin compromiso", "Mostrar los servicios", "Questions fréquentes", "Plan de admisión", "Correo electrónico"),
"pt": ("Poiché l'input", "Consulta inicial sin compromiso", "Mostrar los servicios", "Correo electrónico", 'Unternehmen" è tedesco', "Questions fréquemment posées"),
"ru": ("Poiché l'input", "Consulta inicial sin compromiso", "Correo electrónico", 'Unternehmen" è tedesco', "Mostrar los servicios"),
}
PLACEHOLDER_VALUES = {"None", "-", "N/A", "null"}
GENERIC_BADGE_LABELS = {
"New",
"Popular",
"PLAN",
"PIANO",
"SERVICES",
}
GLOBAL_REWRITE_CANDIDATES = {
**build_system_rewrite_candidates(
(
"days_label",
"average_delivery",
"response_time",
"without_commitment",
"transparent_label",
"weeks_1_2",
"customer_reviews",
"editable_label",
"core_pages_label",
"detailed_page_structure",
"business_process_cta",
"multilingual_rollout",
"customization_integrations",
"transparent_investment",
)
),
}
LOCALE_REWRITE_CANDIDATES = {
"en": {
"Service packages (from) Transparent starting points.": "foreign_ui_label",
"Frequently Asked Questions Transparent about planning, approach, and management.": "foreign_ui_label",
"Transparent investment": "foreign_ui_label",
},
"de": {
"New": "weak_marketing_copy",
"Intakegespräch": "weak_marketing_copy",
"SEO-ready basis": "foreign_ui_label",
"Sales-ready mit skalierbarem Stack": "foreign_ui_label",
"Continuous Verbesserung": "foreign_ui_label",
"Was du bekommst": "weak_marketing_copy",
"Einführungsmeeting": "weak_marketing_copy",
"Starter Website": "weak_marketing_copy",
"Business Website": "weak_marketing_copy",
"Häufig gestellte Fragen Transparent über Planung, Vorgehensweise und Management.": "foreign_ui_label",
},
"es": {
"Preguntas frecuentes Transparente sobre la planificación, el proceso y la gestión.": "foreign_ui_label",
"Unverbindliches Gespräch, klares Angebot": "foreign_ui_label",
},
"pt": {
"Siti web e negozi online": "mixed_locale_heading",
"Caso de cliente en directo": "weak_marketing_copy",
"El primer proyecto de producción finalizado con éxito.": "weak_marketing_copy",
"Más sobre el proceso": "foreign_ui_label",
"Modifiez simplement vous-même.": "foreign_ui_label",
"Opciones de la tienda web": "foreign_ui_label",
"Planes de soporte": "foreign_ui_label",
"Multilingüe": "foreign_ui_label",
"Unsere Serviços": "mixed_locale_heading",
"Elija el camino": "mixed_locale_heading",
"Début en direct": "foreign_ui_label",
"Demande d'admission initiale": "foreign_ui_label",
"Site Web d'Entreprise": "foreign_ui_label",
"Hablar sobre el proceso empresarial": "foreign_ui_label",
"Mise en place de boutique en ligne": "foreign_ui_label",
"Maintenance & gestion": "foreign_ui_label",
"Afficher le plan de soutien": "foreign_ui_label",
"Introducción multilingüe": "foreign_ui_label",
"Forfaits de services (à partir de)": "mixed_locale_heading",
"Kundenschätzung": "foreign_ui_label",
"Gestisca lei stesso il contenuto": "foreign_ui_label",
"Optimizado para móviles": "foreign_ui_label",
"Schnell online mit einer starken Basis": "weak_marketing_copy",
"La entrada \"Unterstützung oder Erweiterung\"": "foreign_ui_label",
"Suivi + corrections": "foreign_ui_label",
"Mejoras mensuales": "foreign_ui_label",
"¿A qué velocidad puede comenzar?": "foreign_ui_label",
"¿Puedo editar textos e imágenes yo mismo?": "foreign_ui_label",
"Transparente sobre o planejamento, o processo e a gestão.": "foreign_ui_label",
"Ab 2.250 €": "foreign_ui_label",
"Boutique en ligne": "foreign_ui_label",
"Sales-ready mit skalierbarem Stack": "foreign_ui_label",
},
"fr": {
"Erstes Produktionsprojekt erfolgreich abgeschlossen.": "weak_marketing_copy",
"Von Kickoff bis zum Launch mit einem klaren Umfang.": "foreign_ui_label",
"Demande d'admission initiale": "weak_marketing_copy",
"Entretien d'accueil": "weak_marketing_copy",
"Vraag over diensten": "foreign_ui_label",
"Konkrete erste Schätzung": "foreign_ui_label",
"Ansatz, der zu Ihrem Budget passt": "foreign_ui_label",
**build_system_rewrite_candidates(("weeks_2_4",)),
"Bereit, mit der Business-Website zu starten?": "foreign_ui_label",
},
"it": {
"Planificación clara": "foreign_ui_label",
"Mehrsprachiger Rollout-Plan": "foreign_ui_label",
"Unverbindliches Gespräch, klares Angebot": "foreign_ui_label",
},
"ru": {
"Base prête pour le SEO": "foreign_ui_label",
"Unverbindliches Gespräch, klares Angebot": "foreign_ui_label",
},
}
KNOWN_REPLACEMENTS = {
"Starter intake": {
"nl": "Plan startergesprek",
"en": "Book starter call",
"de": "Starter-Gespräch planen",
"fr": "Planifier lentretien de départ",
"es": "Reservar llamada inicial",
"it": "Prenota una chiamata iniziale",
"pt": "Agendar chamada inicial",
"ru": "Запланировать стартовый звонок",
},
"Business intake": {
"nl": "Plan zakelijk gesprek",
"en": "Book business call",
"de": "Beratungsgespräch planen",
"fr": "Planifier lentretien commercial",
"es": "Reservar llamada comercial",
"it": "Prenota una chiamata commerciale",
"pt": "Agendar chamada comercial",
"ru": "Запланировать деловой звонок",
},
"Plan Starter intake": {
"nl": "Plan startergesprek",
"en": "Book starter call",
"de": "Starter-Gespräch planen",
"fr": "Planifier lentretien de départ",
"es": "Reservar llamada inicial",
"it": "Prenota una chiamata iniziale",
"pt": "Agendar chamada inicial",
"ru": "Запланировать стартовый звонок",
},
"Plan Business intake": {
"nl": "Plan zakelijk gesprek",
"en": "Book business call",
"de": "Beratungsgespräch planen",
"fr": "Planifier lentretien commercial",
"es": "Reservar llamada comercial",
"it": "Prenota una chiamata commerciale",
"pt": "Agendar chamada comercial",
"ru": "Запланировать деловой звонок",
},
"Mostrar los servicios": {
"es": "Mostrar los servicios",
"it": "Vedi servizi",
"pt": "Ver serviços",
"ru": "Показать услуги",
},
"Correo electrónico": {"pt": "E-mail", "ru": "Электронная почта"},
'Unternehmen" è tedesco, non olandese. La traduzione spagnola di "Unternehmen" è "empresa".': {
"pt": "Empresa",
"ru": "Компания",
},
'Poiché l\'input "Unverbindliche Erstberatung" è in tedesco (non in olandese), la traduzione in spagnolo è: "Consulta inicial sin compromiso".': {
"it": "Senza impegno",
"pt": "Sem compromisso",
"ru": "Без обязательств",
"es": "Consulta inicial sin compromiso",
},
}
def _contains_fragment(text: str, fragment: str) -> bool:
if re.fullmatch(r"[\wÀ-ÿ-]+", fragment, flags=re.UNICODE):
pattern = re.compile(rf"(?<![\wÀ-ÿ-]){re.escape(fragment)}(?![\wÀ-ÿ-])", re.UNICODE)
return bool(pattern.search(text))
return fragment in text
def validate_patterns(locale_code: str, field_path: str, normalized: str):
issues = []
for fragment in GLOBAL_BAD_PATTERNS:
if _contains_fragment(normalized, fragment):
issues.append(
make_issue(
"known_bad_pattern",
field_path,
fragment,
KNOWN_REPLACEMENTS.get(fragment, {}).get(locale_code, ""),
)
)
for fragment in LOCALE_FORBIDDEN.get(locale_code, ()):
if _contains_fragment(normalized, fragment):
issues.append(
make_issue(
"wrong_language_fragment",
field_path,
fragment,
KNOWN_REPLACEMENTS.get(fragment, {}).get(locale_code, ""),
)
)
if normalized in GENERIC_BADGE_LABELS and not is_canonical_system_string(locale_code, normalized):
issues.append(
make_issue(
"generic_badge_label",
field_path,
normalized,
system_string_replacement(locale_code, normalized),
)
)
for fragment, issue_type in GLOBAL_REWRITE_CANDIDATES.items():
if _contains_fragment(normalized, fragment):
if is_canonical_system_string(locale_code, fragment):
continue
issues.append(
make_issue(
issue_type,
field_path,
fragment,
system_string_replacement(locale_code, fragment),
)
)
for fragment, issue_type in LOCALE_REWRITE_CANDIDATES.get(locale_code, {}).items():
if _contains_fragment(normalized, fragment):
issues.append(
make_issue(
issue_type,
field_path,
fragment,
system_string_replacement(locale_code, fragment),
)
)
return issues

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)

12
mandelstudio/apps.py Normal file
View File

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

View File

@@ -0,0 +1 @@
from mandelblog_content_guard import * # noqa: F401,F403

View File

@@ -0,0 +1 @@
from mandelblog_content_guard.agents import * # noqa: F401,F403

View File

@@ -0,0 +1 @@
from mandelblog_content_guard.agents.base import * # noqa: F401,F403

View File

@@ -0,0 +1 @@
from mandelblog_content_guard.agents.de import * # noqa: F401,F403

View File

@@ -0,0 +1 @@
from mandelblog_content_guard.agents.en import * # noqa: F401,F403

View File

@@ -0,0 +1 @@
from mandelblog_content_guard.agents.es import * # noqa: F401,F403

View File

@@ -0,0 +1 @@
from mandelblog_content_guard.agents.fr import * # noqa: F401,F403

View File

@@ -0,0 +1 @@
from mandelblog_content_guard.agents.it import * # noqa: F401,F403

View File

@@ -0,0 +1 @@
from mandelblog_content_guard.agents.nl import * # noqa: F401,F403

View File

@@ -0,0 +1 @@
from mandelblog_content_guard.agents.pt import * # noqa: F401,F403

View File

@@ -0,0 +1 @@
from mandelblog_content_guard.agents.ru import * # noqa: F401,F403

View File

@@ -0,0 +1 @@
from mandelblog_content_guard.ai import * # noqa: F401,F403

View File

@@ -0,0 +1 @@
from mandelblog_content_guard.hooks import * # noqa: F401,F403

View File

@@ -0,0 +1 @@
from mandelblog_content_guard.mixins import * # noqa: F401,F403

View File

@@ -0,0 +1 @@
from mandelblog_content_guard.normalizers import * # noqa: F401,F403

View File

@@ -0,0 +1 @@
from mandelblog_content_guard.normalizers.de import * # noqa: F401,F403

View File

@@ -0,0 +1 @@
from mandelblog_content_guard.normalizers.en import * # noqa: F401,F403

View File

@@ -0,0 +1 @@
from mandelblog_content_guard.normalizers.es import * # noqa: F401,F403

View File

@@ -0,0 +1 @@
from mandelblog_content_guard.normalizers.it import * # noqa: F401,F403

View File

@@ -0,0 +1 @@
from mandelblog_content_guard.normalizers.nl import * # noqa: F401,F403

View File

@@ -0,0 +1 @@
from mandelblog_content_guard.normalizers.ru import * # noqa: F401,F403

View File

@@ -0,0 +1 @@
from mandelblog_content_guard.settings import * # noqa: F401,F403

View File

@@ -0,0 +1 @@
from mandelblog_content_guard.signals import * # noqa: F401,F403

View File

@@ -0,0 +1 @@
from mandelblog_content_guard.system_strings import * # noqa: F401,F403

View File

@@ -0,0 +1 @@
from mandelblog_content_guard.types import * # noqa: F401,F403

View File

@@ -0,0 +1 @@
from mandelblog_content_guard.validators import * # noqa: F401,F403

View File

@@ -0,0 +1 @@
from mandelblog_content_guard.validators.multilingual import * # noqa: F401,F403

View File

@@ -0,0 +1 @@
from mandelblog_content_guard.validators.rules import * # noqa: F401,F403

View File

@@ -0,0 +1 @@
from mandelblog_content_guard.validators.rules.cta import * # noqa: F401,F403

View File

@@ -0,0 +1 @@
from mandelblog_content_guard.validators.rules.forms import * # noqa: F401,F403

View File

@@ -0,0 +1 @@
from mandelblog_content_guard.validators.rules.language import * # noqa: F401,F403

View File

@@ -0,0 +1 @@
from mandelblog_content_guard.validators.rules.patterns import * # noqa: F401,F403

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,63 @@
# Generated by Django 5.2.11 on 2026-05-15 00:00
from django.db import migrations
import wagtail.fields
class Migration(migrations.Migration):
dependencies = [
("cookie_jar", "0007_cookiesettings_cookie_message_de_and_more"),
]
operations = [
migrations.AddField(
model_name="cookiesettings",
name="popup_cookie_message_de",
field=wagtail.fields.RichTextField(
blank=True, null=True, verbose_name="Popup cookie statement"
),
),
migrations.AddField(
model_name="cookiesettings",
name="popup_cookie_message_en",
field=wagtail.fields.RichTextField(
blank=True, null=True, verbose_name="Popup cookie statement"
),
),
migrations.AddField(
model_name="cookiesettings",
name="popup_cookie_message_es",
field=wagtail.fields.RichTextField(
blank=True, null=True, verbose_name="Popup cookie statement"
),
),
migrations.AddField(
model_name="cookiesettings",
name="popup_cookie_message_fr",
field=wagtail.fields.RichTextField(
blank=True, null=True, verbose_name="Popup cookie statement"
),
),
migrations.AddField(
model_name="cookiesettings",
name="popup_cookie_message_it",
field=wagtail.fields.RichTextField(
blank=True, null=True, verbose_name="Popup cookie statement"
),
),
migrations.AddField(
model_name="cookiesettings",
name="popup_cookie_message_pt",
field=wagtail.fields.RichTextField(
blank=True, null=True, verbose_name="Popup cookie statement"
),
),
migrations.AddField(
model_name="cookiesettings",
name="popup_cookie_message_ru",
field=wagtail.fields.RichTextField(
blank=True, null=True, verbose_name="Popup cookie statement"
),
),
]

Some files were not shown because too many files have changed in this diff Show More