Compare commits
5 Commits
afd28a9558
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| cb6f5b94a5 | |||
| 6d86c1e689 | |||
| 1848cc8380 | |||
| 0e698ed143 | |||
| 509603a008 |
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.
|
||||
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 '-'}")
|
||||
@@ -67,40 +67,34 @@ class Command(BaseCommand):
|
||||
raise CommandError(f"Page id={page_id} not found")
|
||||
self._fix_page(page, apply_changes=apply_changes)
|
||||
else:
|
||||
source = self._find_contact_source_page()
|
||||
if source is None:
|
||||
raise CommandError("Could not find a source contact page")
|
||||
|
||||
for locale in Locale.objects.all().order_by("language_code"):
|
||||
page = (
|
||||
Page.objects.filter(
|
||||
translation_key=source.translation_key,
|
||||
locale=locale,
|
||||
)
|
||||
.specific()
|
||||
.first()
|
||||
)
|
||||
if page is None:
|
||||
self.stdout.write(
|
||||
f"SKIP {locale.language_code}: no contact translation"
|
||||
)
|
||||
continue
|
||||
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 _find_contact_source_page(self) -> Page | None:
|
||||
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
|
||||
page = Page.objects.filter(locale=locale, slug=slug).specific().first()
|
||||
if page is not None:
|
||||
return page
|
||||
return None
|
||||
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
|
||||
|
||||
@@ -50,7 +50,9 @@
|
||||
{% endif %}
|
||||
<main class="te-section">
|
||||
<div class="container">
|
||||
{% if self.body.0.block_type != "saas_hero_banner" %}
|
||||
<h1 class="te-section__heading">{{ self.title }}</h1>
|
||||
{% endif %}
|
||||
{% for block in self.body %}
|
||||
{% with scope_class=block.block_type|split:"_"|join:"-" %}
|
||||
<section class="te-block te-block--{{ scope_class }}" data-block-type="{{ block.block_type }}">
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
{% load ocyan_main %}
|
||||
{% load ocyanjson %}
|
||||
{% load static %}
|
||||
{% load localized_navigation %}
|
||||
{% load wagtailcore_tags wagtailimages_tags wagtailuserbar %}
|
||||
|
||||
{% 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/">
|
||||
{% endif %}
|
||||
{{ 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' %}">
|
||||
{% for header_snippet in cookie_jar.activated_snippet_header_templates %}
|
||||
{% include header_snippet %}
|
||||
{% endfor %}
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
|
||||
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