Compare commits
8 Commits
16244eed5c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| cb6f5b94a5 | |||
| 6d86c1e689 | |||
| 1848cc8380 | |||
| 0e698ed143 | |||
| 509603a008 | |||
| afd28a9558 | |||
| c339487497 | |||
| 38eb5693cd |
105
docs/PRODUCTION_DJANGO_COMMANDS.md
Normal file
105
docs/PRODUCTION_DJANGO_COMMANDS.md
Normal 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.
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
306
mandelstudio/management/commands/apply_priority_seo_metadata.py
Normal file
306
mandelstudio/management/commands/apply_priority_seo_metadata.py
Normal 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 '-'}")
|
||||||
133
mandelstudio/management/commands/fix_contact_cta_anchor.py
Normal file
133
mandelstudio/management/commands/fix_contact_cta_anchor.py
Normal 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()
|
||||||
@@ -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 }}">
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
130
mandelstudio/tests/test_apply_priority_seo_metadata_command.py
Normal file
130
mandelstudio/tests/test_apply_priority_seo_metadata_command.py
Normal 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()
|
||||||
60
mandelstudio/tests/test_localized_navigation_tags.py
Normal file
60
mandelstudio/tests/test_localized_navigation_tags.py
Normal 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/"},
|
||||||
|
],
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user