8 Commits

10 changed files with 792 additions and 8 deletions

View File

@@ -0,0 +1,105 @@
# Production Django Command Wrapper
## Purpose
Manual Django management commands on MandelBlog production do not inherit the
`mandelstudio-gunicorn.service` systemd drop-in environment.
That matters for Elasticsearch because production search TLS verification is
enabled only when this environment variable is present:
- `ELASTICSEARCH_CA_CERTS=/etc/ssl/certs/elasticsearch-http-ca.crt`
Without it, `mandelstudio.settings.env.prd` intentionally falls back to:
- `verify_certs=False`
This does not affect the live site, but it does cause noisy CLI warnings during
operator commands such as `django check`, `showmigrations`, and shell sessions.
## Standard production command pattern
Use this pattern for manual Django management commands on production:
```bash
cd /home/www-mandelstudio/mandelstudio && \
ELASTICSEARCH_CA_CERTS=/etc/ssl/certs/elasticsearch-http-ca.crt \
DJANGO_SETTINGS_MODULE=mandelstudio.settings.env.prd \
/var/lib/virtualenv/mandelstudio/bin/python -m django <command>
```
## Examples
Run checks:
```bash
cd /home/www-mandelstudio/mandelstudio && \
ELASTICSEARCH_CA_CERTS=/etc/ssl/certs/elasticsearch-http-ca.crt \
DJANGO_SETTINGS_MODULE=mandelstudio.settings.env.prd \
/var/lib/virtualenv/mandelstudio/bin/python -m django check
```
Inspect migrations:
```bash
cd /home/www-mandelstudio/mandelstudio && \
ELASTICSEARCH_CA_CERTS=/etc/ssl/certs/elasticsearch-http-ca.crt \
DJANGO_SETTINGS_MODULE=mandelstudio.settings.env.prd \
/var/lib/virtualenv/mandelstudio/bin/python -m django showmigrations
```
Open a shell:
```bash
cd /home/www-mandelstudio/mandelstudio && \
ELASTICSEARCH_CA_CERTS=/etc/ssl/certs/elasticsearch-http-ca.crt \
DJANGO_SETTINGS_MODULE=mandelstudio.settings.env.prd \
/var/lib/virtualenv/mandelstudio/bin/python -m django shell
```
Run a project command:
```bash
cd /home/www-mandelstudio/mandelstudio && \
ELASTICSEARCH_CA_CERTS=/etc/ssl/certs/elasticsearch-http-ca.crt \
DJANGO_SETTINGS_MODULE=mandelstudio.settings.env.prd \
/var/lib/virtualenv/mandelstudio/bin/python -m django apply_priority_seo_metadata --dry-run
```
## Optional temporary shell helper
For one operator session, you can define a shell helper instead of repeating
the full command:
```bash
manage_prd () {
cd /home/www-mandelstudio/mandelstudio || return 1
ELASTICSEARCH_CA_CERTS=/etc/ssl/certs/elasticsearch-http-ca.crt \
DJANGO_SETTINGS_MODULE=mandelstudio.settings.env.prd \
/var/lib/virtualenv/mandelstudio/bin/python -m django "$@"
}
```
Example:
```bash
manage_prd check
manage_prd showmigrations oscar_odin_plugin --plan
```
## Scope
This is an operator-only convention.
It does not:
- change Django settings
- change gunicorn runtime behavior
- restart services
- alter production data
- expose secrets or API keys
## Verification
When the command is run with `ELASTICSEARCH_CA_CERTS` set, the Elasticsearch
`verify_certs=False` warning should not appear in command output.

View File

@@ -139,6 +139,15 @@ FOOTER_CONTENT = {
} }
def _contact_form_anchor(url: str | None) -> str | None:
if not url:
return url
if url.endswith("#contact-form"):
return url
base = url.split("#", 1)[0]
return f"{base}#contact-form"
def uid() -> str: def uid() -> str:
return str(uuid.uuid4()) return str(uuid.uuid4())
@@ -3904,6 +3913,11 @@ def _standard_body(
cta = COMMON_CTA[locale] cta = COMMON_CTA[locale]
common = STANDARD_COPY[locale] common = STANDARD_COPY[locale]
cfg = common["pages"][page_key] cfg = common["pages"][page_key]
contact_cta_url = (
_contact_form_anchor(urls["contact"])
if page_key == "contact"
else urls["contact"]
)
blocks = [ blocks = [
block( block(
"saas_hero_banner", "saas_hero_banner",
@@ -3934,7 +3948,7 @@ def _standard_body(
"headline": cfg["headline"], "headline": cfg["headline"],
"sub_headline": cfg["sub"], "sub_headline": cfg["sub"],
"primary_cta_text": cta["primary"], "primary_cta_text": cta["primary"],
"primary_cta_url": urls["contact"], "primary_cta_url": contact_cta_url,
"secondary_cta_text": "", "secondary_cta_text": "",
"secondary_cta_url": "", "secondary_cta_url": "",
"hero_image": ( "hero_image": (
@@ -4066,7 +4080,7 @@ def _standard_body(
"headline": cfg["cta"], "headline": cfg["cta"],
"subheadline": common["cta_sub"], "subheadline": common["cta_sub"],
"primary_cta_text": cta["primary"], "primary_cta_text": cta["primary"],
"primary_cta_url": urls["contact"], "primary_cta_url": contact_cta_url,
"secondary_cta_text": "", "secondary_cta_text": "",
"secondary_cta_url": "", "secondary_cta_url": "",
"background_image": AGENCY_SUPPORT_IMAGE_ID, "background_image": AGENCY_SUPPORT_IMAGE_ID,

View File

@@ -15,6 +15,7 @@ from mandelstudio.management.commands._agency_content import (
CTA_VARIANTS, CTA_VARIANTS,
FOOTER_CONTENT, FOOTER_CONTENT,
NL_REPLACEMENTS, NL_REPLACEMENTS,
_contact_form_anchor,
) )
from mandelstudio.management.commands._agency_content import ( from mandelstudio.management.commands._agency_content import (
body_for as localized_body_for, body_for as localized_body_for,
@@ -548,6 +549,11 @@ def nl_home(urls: dict[str, str]) -> list[dict[str, Any]]:
def nl_standard_page(page_key: str, urls: dict[str, str]) -> list[dict[str, Any]]: def nl_standard_page(page_key: str, urls: dict[str, str]) -> list[dict[str, Any]]:
primary = COMMON_CTA["nl"]["primary"] primary = COMMON_CTA["nl"]["primary"]
secondary = COMMON_CTA["nl"]["secondary"] secondary = COMMON_CTA["nl"]["secondary"]
contact_cta_url = (
_contact_form_anchor(urls["contact"])
if page_key == "contact"
else urls["contact"]
)
page_data = { page_data = {
"about": { "about": {
"headline": "Wie MandelBlog is en hoe we werken", "headline": "Wie MandelBlog is en hoe we werken",
@@ -809,7 +815,7 @@ def nl_standard_page(page_key: str, urls: dict[str, str]) -> list[dict[str, Any]
"headline": cfg["headline"], "headline": cfg["headline"],
"sub_headline": cfg["sub"], "sub_headline": cfg["sub"],
"primary_cta_text": primary, "primary_cta_text": primary,
"primary_cta_url": urls["contact"], "primary_cta_url": contact_cta_url,
"secondary_cta_text": secondary, "secondary_cta_text": secondary,
"secondary_cta_url": urls["services"], "secondary_cta_url": urls["services"],
"hero_image": 1 if page_key != "process" else 24, "hero_image": 1 if page_key != "process" else 24,
@@ -836,8 +842,10 @@ def nl_standard_page(page_key: str, urls: dict[str, str]) -> list[dict[str, Any]
"link_text": primary "link_text": primary
if page_key in {"contact", "about"} if page_key in {"contact", "about"}
else secondary, else secondary,
"link_url": urls["contact"] "link_url": contact_cta_url
if page_key in {"contact", "about"} if page_key == "contact"
else urls["contact"]
if page_key == "about"
else urls["services"], else urls["services"],
"highlight": "none", "highlight": "none",
} }
@@ -884,7 +892,7 @@ def nl_standard_page(page_key: str, urls: dict[str, str]) -> list[dict[str, Any]
], ],
"show_contact_cta": "card", "show_contact_cta": "card",
"contact_cta_text": primary, "contact_cta_text": primary,
"contact_cta_url": urls["contact"], "contact_cta_url": contact_cta_url,
}, },
) )
) )
@@ -898,7 +906,7 @@ def nl_standard_page(page_key: str, urls: dict[str, str]) -> list[dict[str, Any]
"headline": cfg["cta"], "headline": cfg["cta"],
"subheadline": "<p>Plan een kennismakingsgesprek en we laten zien welke route logisch is voor uw bedrijf.</p>", "subheadline": "<p>Plan een kennismakingsgesprek en we laten zien welke route logisch is voor uw bedrijf.</p>",
"primary_cta_text": primary, "primary_cta_text": primary,
"primary_cta_url": urls["contact"], "primary_cta_url": contact_cta_url,
"secondary_cta_text": secondary, "secondary_cta_text": secondary,
"secondary_cta_url": urls["services"], "secondary_cta_url": urls["services"],
"background_image": 1, "background_image": 1,

View File

@@ -0,0 +1,306 @@
from __future__ import annotations
import json
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from wagtail.models import Page
def _load_json_rows(path: Path) -> list[dict[str, Any]]:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError as exc:
raise CommandError(f"Input file not found: {path}") from exc
except json.JSONDecodeError as exc:
raise CommandError(f"Invalid JSON in {path}: {exc}") from exc
if not isinstance(payload, list):
raise CommandError(f"Expected a JSON list in {path}")
rows: list[dict[str, Any]] = []
for idx, row in enumerate(payload, start=1):
if not isinstance(row, dict):
raise CommandError(f"Row {idx} in {path} is not a JSON object")
rows.append(row)
return rows
def _is_safe_apply_row(row: dict[str, Any]) -> bool:
return bool(row.get("apply_now")) and row.get("recommended_action") == "apply_and_publish"
def _timestamp() -> str:
return datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
def _snapshot_path_for(input_path: Path) -> Path:
return input_path.with_name(
f"{input_path.stem}_preapply_snapshot_{_timestamp()}.json"
)
def _snapshot_entry(page: Page) -> dict[str, Any]:
specific = page.specific
return {
"page_id": specific.id,
"title": specific.title,
"slug": specific.slug,
"live": bool(specific.live),
"seo_title": getattr(specific, "seo_title", "") or "",
"search_description": getattr(specific, "search_description", "") or "",
"latest_revision_id": getattr(specific, "latest_revision_id", None),
"live_revision_id": getattr(specific, "live_revision_id", None),
}
class Command(BaseCommand):
help = "Apply or roll back priority SEO metadata updates from a JSON matrix"
def add_arguments(self, parser):
parser.add_argument(
"--input",
help="Path to the JSON apply matrix.",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Preview changes only (default when --apply is not set).",
)
parser.add_argument(
"--apply",
action="store_true",
help="Persist approved SEO metadata changes.",
)
parser.add_argument(
"--rollback-from",
help="Path to a previously exported snapshot JSON file.",
)
def handle(self, *args, **options):
input_value = options.get("input")
rollback_value = options.get("rollback_from")
dry_run = bool(options.get("dry_run"))
apply_changes = bool(options.get("apply"))
if apply_changes and dry_run:
raise CommandError("Use either --dry-run or --apply, not both.")
if not apply_changes:
dry_run = True
if rollback_value and input_value:
raise CommandError("Use either --input or --rollback-from, not both.")
if rollback_value:
snapshot_rows = _load_json_rows(Path(rollback_value))
self._run_rollback(snapshot_rows, dry_run=dry_run)
return
if not input_value:
raise CommandError("The --input option is required unless --rollback-from is used.")
matrix_path = Path(input_value)
rows = _load_json_rows(matrix_path)
self._run_apply(rows, matrix_path=matrix_path, dry_run=dry_run)
def _load_page(self, page_id: int) -> Page:
page = Page.objects.filter(id=page_id).specific().first()
if page is None:
raise CommandError(f"Page id={page_id} not found")
return page
def _export_snapshot(
self, rows: list[dict[str, Any]], *, matrix_path: Path
) -> tuple[Path, list[dict[str, Any]]]:
snapshot_rows: list[dict[str, Any]] = []
seen: set[int] = set()
for row in rows:
if not _is_safe_apply_row(row):
continue
page_id = int(row["page_id"])
if page_id in seen:
continue
seen.add(page_id)
page = self._load_page(page_id)
snapshot_row = _snapshot_entry(page)
snapshot_row["url"] = row.get("url", "")
snapshot_rows.append(snapshot_row)
snapshot_path = _snapshot_path_for(matrix_path)
snapshot_path.write_text(
json.dumps(snapshot_rows, ensure_ascii=False, indent=2),
encoding="utf-8",
)
return snapshot_path, snapshot_rows
def _run_apply(
self, rows: list[dict[str, Any]], *, matrix_path: Path, dry_run: bool
) -> None:
applied = 0
skipped = 0
errors = 0
changed_ids: list[int] = []
snapshot_path: Path | None = None
snapshot_rows: list[dict[str, Any]] = []
if not dry_run:
snapshot_path, snapshot_rows = self._export_snapshot(rows, matrix_path=matrix_path)
def process() -> None:
nonlocal applied, skipped, errors
for row in rows:
page_id = int(row["page_id"])
if not _is_safe_apply_row(row):
skipped += 1
self.stdout.write(
f"SKIP page={page_id}: matrix action={row.get('recommended_action')}"
)
continue
try:
page = self._load_page(page_id)
except CommandError as exc:
errors += 1
self.stdout.write(f"ERR page={page_id}: {exc}")
continue
specific = page.specific
current_seo = getattr(specific, "seo_title", "") or ""
current_desc = getattr(specific, "search_description", "") or ""
target_seo = row.get("proposed_seo_title", "") or ""
target_desc = row.get("proposed_search_description", "") or ""
if current_seo == target_seo and current_desc == target_desc:
skipped += 1
self.stdout.write(
f"SKIP page={page_id}: metadata already matches target"
)
continue
changed_ids.append(page_id)
if dry_run:
applied += 1
self.stdout.write(
f"DRY page={page_id}: would update seo_title/search_description"
)
continue
specific.seo_title = target_seo
specific.search_description = target_desc
revision = specific.save_revision()
if row.get("should_be_published_immediately"):
revision.publish()
applied += 1
self.stdout.write(
f"APPLY page={page_id}: updated seo_title/search_description"
)
if dry_run:
process()
else:
with transaction.atomic():
process()
self._print_summary(
total_rows=len(rows),
applied=applied,
skipped=skipped,
errors=errors,
changed_ids=changed_ids,
snapshot_path=snapshot_path,
mode="dry-run" if dry_run else "apply",
)
def _run_rollback(self, snapshot_rows: list[dict[str, Any]], *, dry_run: bool) -> None:
applied = 0
skipped = 0
errors = 0
changed_ids: list[int] = []
def process() -> None:
nonlocal applied, skipped, errors
for row in snapshot_rows:
page_id = int(row["page_id"])
try:
page = self._load_page(page_id)
except CommandError as exc:
errors += 1
self.stdout.write(f"ERR page={page_id}: {exc}")
continue
specific = page.specific
target_seo = row.get("seo_title", "") or ""
target_desc = row.get("search_description", "") or ""
current_seo = getattr(specific, "seo_title", "") or ""
current_desc = getattr(specific, "search_description", "") or ""
if current_seo == target_seo and current_desc == target_desc:
skipped += 1
self.stdout.write(
f"SKIP page={page_id}: current metadata already matches snapshot"
)
continue
changed_ids.append(page_id)
if dry_run:
applied += 1
self.stdout.write(
f"DRY page={page_id}: would restore seo_title/search_description"
)
continue
specific.seo_title = target_seo
specific.search_description = target_desc
revision = specific.save_revision()
if row.get("live"):
revision.publish()
applied += 1
self.stdout.write(
f"ROLL page={page_id}: restored seo_title/search_description"
)
if dry_run:
process()
else:
with transaction.atomic():
process()
self._print_summary(
total_rows=len(snapshot_rows),
applied=applied,
skipped=skipped,
errors=errors,
changed_ids=changed_ids,
snapshot_path=None,
mode="rollback-dry-run" if dry_run else "rollback-apply",
)
def _print_summary(
self,
*,
total_rows: int,
applied: int,
skipped: int,
errors: int,
changed_ids: list[int],
snapshot_path: Path | None,
mode: str,
) -> None:
self.stdout.write("")
self.stdout.write("Summary")
self.stdout.write(f"mode: {mode}")
self.stdout.write(f"total rows: {total_rows}")
self.stdout.write(f"applied: {applied}")
self.stdout.write(f"skipped: {skipped}")
self.stdout.write(f"errors: {errors}")
self.stdout.write(
"page IDs changed: "
+ (", ".join(str(page_id) for page_id in changed_ids) if changed_ids else "-")
)
self.stdout.write(f"snapshot path: {snapshot_path if snapshot_path else '-'}")

View File

@@ -0,0 +1,133 @@
from __future__ import annotations
from typing import Any
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from wagtail.blocks import StreamValue
from wagtail.models import Locale, Page
CONTACT_SLUGS = {
"nl": "contact",
"en": "contact",
"de": "kontakt",
"fr": "contact",
"es": "contacto",
"it": "contatto",
"pt": "contato",
"ru": "контакт",
}
CONTACT_ANCHOR = "#contact-form"
CTA_BLOCK_TYPES = {"saas_hero_banner", "saas_cta_footer"}
def _with_contact_anchor(url: str | None) -> str | None:
if not url:
return url
if url.endswith(CONTACT_ANCHOR):
return url
return f"{url.split('#', 1)[0]}{CONTACT_ANCHOR}"
def _localized_page_url(source: Page, locale: Locale) -> str | None:
translated = (
Page.objects.filter(translation_key=source.translation_key, locale=locale)
.specific()
.first()
)
chosen = translated or source
return getattr(chosen, "url", None)
class Command(BaseCommand):
help = "Anchor contact page CTA buttons to the contact form across locales"
def add_arguments(self, parser):
parser.add_argument(
"--page-id",
type=int,
help="Optional Wagtail page id to fix.",
)
parser.add_argument(
"--apply",
action="store_true",
help="Persist and publish changes (default is dry-run).",
)
def handle(self, *args, **options):
apply_changes = options["apply"]
page_id = options.get("page_id")
with transaction.atomic():
if page_id:
page = Page.objects.filter(id=page_id).specific().first()
if page is None:
raise CommandError(f"Page id={page_id} not found")
self._fix_page(page, apply_changes=apply_changes)
else:
any_found = False
for page in self._iter_contact_pages():
any_found = True
self._fix_page(page, apply_changes=apply_changes)
if not any_found:
raise CommandError("Could not find any localized contact pages")
if not apply_changes:
raise CommandError(
"Dry-run complete. Re-run with --apply to persist changes."
)
def _iter_contact_pages(self):
yielded = False
for code, slug in CONTACT_SLUGS.items():
locale = Locale.objects.filter(language_code=code).first()
if locale is None:
self.stdout.write(f"SKIP {code}: locale not found")
continue
pages = list(Page.objects.filter(locale=locale, slug=slug).specific())
if not pages:
self.stdout.write(f"SKIP {code}: no contact page for slug={slug}")
continue
for page in pages:
yielded = True
yield page
if not yielded:
return
def _fix_page(self, page: Page, *, apply_changes: bool) -> None:
specific = page.specific
code = page.locale.language_code
if not hasattr(specific, "body"):
self.stdout.write(f"SKIP {code}: no body streamfield")
return
localized_contact_url = _with_contact_anchor(
_localized_page_url(page, page.locale)
)
body = specific.body
raw_data = list(body.raw_data)
changed = False
for block in raw_data:
if block.get("type") not in CTA_BLOCK_TYPES:
continue
value = block.get("value")
if not isinstance(value, dict):
continue
if value.get("primary_cta_url") != localized_contact_url:
value["primary_cta_url"] = localized_contact_url
block["value"] = value
changed = True
if not changed:
self.stdout.write(f"OK {code}: contact CTA already anchored")
return
self.stdout.write(f"CHG {code}: anchored contact CTA buttons")
specific.body = StreamValue(body.stream_block, raw_data, is_lazy=True)
if apply_changes:
rev = specific.save_revision()
rev.publish()

View File

@@ -50,7 +50,9 @@
{% endif %} {% endif %}
<main class="te-section"> <main class="te-section">
<div class="container"> <div class="container">
{% if self.body.0.block_type != "saas_hero_banner" %}
<h1 class="te-section__heading">{{ self.title }}</h1> <h1 class="te-section__heading">{{ self.title }}</h1>
{% endif %}
{% for block in self.body %} {% for block in self.body %}
{% with scope_class=block.block_type|split:"_"|join:"-" %} {% with scope_class=block.block_type|split:"_"|join:"-" %}
<section class="te-block te-block--{{ scope_class }}" data-block-type="{{ block.block_type }}"> <section class="te-block te-block--{{ scope_class }}" data-block-type="{{ block.block_type }}">

View File

@@ -106,7 +106,7 @@
</div> </div>
<div class="saas-demo__form-wrapper"> <div class="saas-demo__form-wrapper">
<form class="saas-demo__form" action="{% url "contact_form:contact-form-handler" %}" method="post"> <form id="contact-form" class="saas-demo__form" action="{% url "contact_form:contact-form-handler" %}" method="post">
{% csrf_token %} {% csrf_token %}
<div class="saas-demo__fields"> <div class="saas-demo__fields">
{% for field in self.form_fields %} {% for field in self.form_fields %}

View File

@@ -6,6 +6,7 @@
{% load ocyan_main %} {% load ocyan_main %}
{% load ocyanjson %} {% load ocyanjson %}
{% load static %} {% load static %}
{% load localized_navigation %}
{% load wagtailcore_tags wagtailimages_tags wagtailuserbar %} {% load wagtailcore_tags wagtailimages_tags wagtailuserbar %}
{% block title %}{% firstof page.seo_title self.seo_title page.title self.title shop_name %}{% endblock %} {% block title %}{% firstof page.seo_title self.seo_title page.title self.title shop_name %}{% endblock %}
@@ -26,12 +27,37 @@
<link rel="preconnect" href="https://www.google-analytics.com/"> <link rel="preconnect" href="https://www.google-analytics.com/">
{% endif %} {% endif %}
{{ block.super }} {{ block.super }}
{% page_canonical_url as canonical_url %}
{% if canonical_url %}
<link rel="canonical" href="{{ canonical_url }}">
{% endif %}
{% firstof page.seo_title self.seo_title page.title self.title shop_name as og_title %}
{% firstof page.search_description self.search_description "" as og_description %}
{% block open_graph %}
{% if og_title %}
<meta property="og:title" content="{{ og_title }}">
{% endif %}
{% if og_description %}
<meta property="og:description" content="{{ og_description }}">
{% endif %}
{% if canonical_url %}
<meta property="og:url" content="{{ canonical_url }}">
{% endif %}
<meta property="og:type" content="website">
{% endblock %}
<link rel="stylesheet" type="text/css" href="{% static 'cookie_jar/css/cookie_jar.css' %}"> <link rel="stylesheet" type="text/css" href="{% static 'cookie_jar/css/cookie_jar.css' %}">
{% for header_snippet in cookie_jar.activated_snippet_header_templates %} {% for header_snippet in cookie_jar.activated_snippet_header_templates %}
{% include header_snippet %} {% include header_snippet %}
{% endfor %} {% endfor %}
{% endblock %} {% endblock %}
{% block hreflang %}
{% page_hreflang_links as hreflang_links %}
{% for link in hreflang_links %}
<link rel="alternate" hreflang="{{ link.code }}" href="{{ link.url }}">
{% endfor %}
{% endblock %}
{% block layout %} {% block layout %}
{% if show_basket_popup_setting %} {% if show_basket_popup_setting %}
{% esi_fragment "partials/added_success.html" with sessionid=True oscar_open_basket=True request=request csrf_token=csrf_token only %} {% esi_fragment "partials/added_success.html" with sessionid=True oscar_open_basket=True request=request csrf_token=csrf_token only %}

View File

@@ -0,0 +1,130 @@
from __future__ import annotations
import json
from contextlib import nullcontext
from io import StringIO
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest import mock
from django.core.management import call_command
from django.test import SimpleTestCase
class _FakeRevision:
def __init__(self):
self.publish = mock.Mock()
class _FakePage:
def __init__(
self,
*,
page_id: int,
seo_title: str = "",
search_description: str = "",
live: bool = True,
):
self.id = page_id
self.title = f"Page {page_id}"
self.slug = f"page-{page_id}"
self.live = live
self.specific = self
self.seo_title = seo_title
self.search_description = search_description
self.saved_revision = _FakeRevision()
def save_revision(self):
return self.saved_revision
class ApplyPrioritySeoMetadataCommandTests(SimpleTestCase):
def test_dry_run_only_targets_safe_rows(self):
matrix = [
{
"page_id": 100,
"url": "https://example.com/en/",
"proposed_seo_title": "SEO title",
"proposed_search_description": "Description text",
"apply_now": True,
"recommended_action": "apply_and_publish",
"should_be_published_immediately": True,
},
{
"page_id": 101,
"url": "https://example.com/",
"proposed_seo_title": "Keep current",
"proposed_search_description": "Keep current description",
"apply_now": False,
"recommended_action": "preserve_current_manual_review",
"should_be_published_immediately": False,
},
]
page = _FakePage(page_id=100)
with TemporaryDirectory() as tmp:
matrix_path = Path(tmp) / "matrix.json"
matrix_path.write_text(json.dumps(matrix), encoding="utf-8")
out = StringIO()
with mock.patch(
"mandelstudio.management.commands.apply_priority_seo_metadata.Command._load_page",
side_effect=lambda page_id: page if page_id == 100 else _FakePage(page_id=101),
):
call_command(
"apply_priority_seo_metadata",
"--input",
str(matrix_path),
"--dry-run",
stdout=out,
)
rendered = out.getvalue()
self.assertIn("DRY page=100", rendered)
self.assertIn("SKIP page=101", rendered)
self.assertIn("applied: 1", rendered)
self.assertIn("snapshot path: -", rendered)
page.saved_revision.publish.assert_not_called()
def test_apply_creates_snapshot_and_publishes(self):
matrix = [
{
"page_id": 200,
"url": "https://example.com/en/contact/",
"proposed_seo_title": "Contact MandelBlog | Schedule a consultation",
"proposed_search_description": "Contact MandelBlog for a practical consultation about your website or webshop project today.",
"apply_now": True,
"recommended_action": "apply_and_publish",
"should_be_published_immediately": True,
}
]
page = _FakePage(page_id=200)
with TemporaryDirectory() as tmp:
matrix_path = Path(tmp) / "matrix.json"
matrix_path.write_text(json.dumps(matrix), encoding="utf-8")
out = StringIO()
with mock.patch(
"mandelstudio.management.commands.apply_priority_seo_metadata.Command._load_page",
return_value=page,
), mock.patch(
"mandelstudio.management.commands.apply_priority_seo_metadata.transaction.atomic",
return_value=nullcontext(),
):
call_command(
"apply_priority_seo_metadata",
"--input",
str(matrix_path),
"--apply",
stdout=out,
)
snapshots = list(Path(tmp).glob("matrix_preapply_snapshot_*.json"))
self.assertEqual(len(snapshots), 1)
snapshot_rows = json.loads(snapshots[0].read_text(encoding="utf-8"))
self.assertEqual(snapshot_rows[0]["page_id"], 200)
self.assertEqual(page.seo_title, matrix[0]["proposed_seo_title"])
self.assertEqual(
page.search_description, matrix[0]["proposed_search_description"]
)
page.saved_revision.publish.assert_called_once()

View File

@@ -0,0 +1,60 @@
from types import SimpleNamespace
from unittest import mock
from django.test import RequestFactory, SimpleTestCase, override_settings
from mandelstudio.templatetags import localized_navigation
@override_settings(
CANONICAL_BASE_URL="https://www.mandelblog.com",
LANGUAGE_CODE="nl",
LANGUAGES=(
("nl", "Dutch"),
("en", "English"),
("de", "German"),
),
)
class LocalizedNavigationTagTests(SimpleTestCase):
def setUp(self):
self.factory = RequestFactory()
def test_page_canonical_url_uses_canonical_host_and_page_url(self):
request = self.factory.get("/en/contact/")
page = SimpleNamespace(url="/en/contact/")
canonical = localized_navigation.page_canonical_url(
{"request": request, "page": page}
)
self.assertEqual(canonical, "https://www.mandelblog.com/en/contact/")
@mock.patch(
"mandelstudio.templatetags.localized_navigation._translated_pages"
)
def test_page_hreflang_links_only_include_live_public_translations(
self, translated_pages_mock
):
request = self.factory.get("/en/contact/")
current_page = SimpleNamespace(
translation_key="key",
locale=SimpleNamespace(language_code="en"),
url="/en/contact/",
)
translated_pages_mock.return_value = {
"nl": SimpleNamespace(url="/contact/", locale=SimpleNamespace(language_code="nl")),
"en": SimpleNamespace(url="/en/contact/", locale=SimpleNamespace(language_code="en")),
}
links = localized_navigation.page_hreflang_links(
{"request": request, "page": current_page}
)
self.assertEqual(
links,
[
{"code": "nl", "url": "https://www.mandelblog.com/contact/"},
{"code": "en", "url": "https://www.mandelblog.com/en/contact/"},
{"code": "x-default", "url": "https://www.mandelblog.com/contact/"},
],
)