Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions bedrock/base/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.

import logging

from django.core.cache.backends.locmem import DEFAULT_TIMEOUT, LocMemCache

from bedrock.base import metrics

logger = logging.getLogger(__name__)


class SimpleDictCache(LocMemCache):
"""A local memory cache that doesn't pickle values.
Expand Down
16 changes: 16 additions & 0 deletions bedrock/cms/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,24 @@
# file, You can obtain one at https://mozilla.org/MPL/2.0/.

from django.apps import AppConfig
from django.db import connection
from django.db.models.signals import post_migrate


class CmsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "bedrock.cms"

def ready(self):
import bedrock.cms.signal_handlers # noqa: F401
from bedrock.cms.utils import warm_page_path_cache

if "wagtailcore_page" in connection.introspection.table_names():
# The route to take if the DB already exists in a viable state
warm_page_path_cache()
else:
# The route to take the DB isn't ready yet (eg tests or an empty DB)
post_migrate.connect(
bedrock.cms.signal_handlers.trigger_cache_warming,
sender=self,
)
34 changes: 34 additions & 0 deletions bedrock/cms/bedrock_wagtail_urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.

# Custom version of wagtail_urls that wraps the wagtail_serve route
# with a decorator that does a lookahead to see if Wagtail will 404 or not
# (based on a precomputed cache of URLs in the CMS)

from django.urls import re_path

from wagtail.urls import urlpatterns as wagtail_urlpatterns
from wagtail.views import serve as wagtail_serve

from bedrock.cms.decorators import pre_check_for_cms_404

# Modify the wagtail_urlpatterns to replace `wagtail_serve` with a decorated
# version of the same view, so we can pre-empt Wagtail looking up a page
# that we know will be a 404
custom_wagtail_urls = []

for pattern in wagtail_urlpatterns:
if hasattr(pattern.callback, "__name__") and pattern.callback.__name__ == "serve":
custom_wagtail_urls.append(
re_path(
# This is a RegexPattern not a RoutePattern, which is why we use re_path not path
pattern.pattern,
pre_check_for_cms_404(wagtail_serve),
name=pattern.name,
)
)
else:
custom_wagtail_urls.append(pattern)

# Note: custom_wagtail_urls is imported into the main project urls.py instead of wagtail_urls
30 changes: 30 additions & 0 deletions bedrock/cms/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,23 @@
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.

import logging
from functools import wraps

from django.conf import settings
from django.http import Http404

from wagtail.views import serve as wagtail_serve

from bedrock.base.i18n import remove_lang_prefix
from bedrock.cms.utils import path_exists_in_cms
from lib.l10n_utils.fluent import get_active_locales

from .utils import get_cms_locales_for_path

logger = logging.getLogger(__name__)


HTTP_200_OK = 200


Expand Down Expand Up @@ -176,3 +182,27 @@ def wrapped_view(request, *args, **kwargs):
else:
# Otherwise, apply the decorator directly to view_func
return decorator(view_func)


def pre_check_for_cms_404(view):
"""
Decorator intended to avoid going through the Wagtail's serve view
for a route that we know will be a 404. How do we know? We have a
pre-warmed cache of all the pages of _live_ pages known to Wagtail
- see bedrock.cms.utils for that.

This decorator can be skipped if settings.CMS_DO_PAGE_PATH_PRECHECK is
set to False via env vars.
"""

def wrapped_view(request, *args, **kwargs):
_path_to_check = request.path
if settings.CMS_DO_PAGE_PATH_PRECHECK:
if not path_exists_in_cms(_path_to_check):
logger.info(f"Raising early 404 for {_path_to_check} because it doesn't exist in the CMS")
raise Http404

# Proceed to the original view
return view(request, *args, **kwargs)

return wrapped_view
39 changes: 39 additions & 0 deletions bedrock/cms/migrations/0003_simplekvstore.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.

# Generated by Django 4.2.18 on 2025-01-27 15:19

from django.db import migrations, models

import bedrock.cms.models.utility


class Migration(migrations.Migration):
dependencies = [
("cms", "0002_bedrockimage_bedrockrendition"),
]

operations = [
migrations.CreateModel(
name="SimpleKVStore",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("key", models.CharField(max_length=64, unique=True)),
(
"value",
models.JSONField(encoder=bedrock.cms.models.utility.SetAwareEncoder),
),
("created", models.DateTimeField(auto_now_add=True)),
("last_updated", models.DateTimeField(auto_now=True)),
],
),
]
1 change: 1 addition & 0 deletions bedrock/cms/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@

from .pages import * # noqa
from .images import * # noqa
from .utility import * # noqa
45 changes: 45 additions & 0 deletions bedrock/cms/models/utility.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.

import json

from django.db import models


class SetAwareEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, set):
return list(obj)
return json.JSONEncoder.default(self, obj)


class SimpleKVStore(models.Model):
"""Allows us to use the DB as a simple key-value store via the ORM"""

key = models.CharField(
blank=False,
max_length=64,
null=False,
unique=True,
)
value = models.JSONField(
blank=False,
null=False,
encoder=SetAwareEncoder,
)
created = models.DateTimeField(auto_now_add=True)
last_updated = models.DateTimeField(auto_now=True)

def __repr__(self):
return f"<SimpleKVStore entry {self.key}: {self._truncated_display_value}"

def __str__(self):
return f"SimpleKVStore entry for {self.key}: {self._truncated_display_value}"

@property
def _truncated_display_value(self):
if len(self.value) > 10:
return f"{self.value}..."
else:
return self.value
99 changes: 99 additions & 0 deletions bedrock/cms/signal_handlers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.

import logging
from typing import TYPE_CHECKING, Type

from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.mail import send_mail
from django.template.loader import render_to_string

from wagtail.signals import page_published, page_unpublished, post_page_move
from wagtail_localize_smartling.signals import individual_translation_imported

from bedrock.cms.utils import warm_page_path_cache

if TYPE_CHECKING:
from wagtail_localize.models import Translation
from wagtail_localize_smartling.models import Job


logger = logging.getLogger(__name__)


def notify_of_imported_translation(
sender: Type["Job"],
instance: "Job",
translation: "Translation",
**kwargs,
):
"""
Signal handler for receiving news that a translation has landed from
Smartling.

For now, sends a notification email to all Admins
"""
UserModel = get_user_model()

admin_emails = UserModel.objects.filter(
is_superuser=True,
is_active=True,
).values_list("email", flat=True)
admin_emails = [email for email in admin_emails if email] # Safety check to ensure no empty email addresses are included

if not admin_emails:
logger.warning("Unable to send translation-imported email alerts: no admins in system")
return

email_subject = "New translations imported into Bedrock CMS"

job_name = instance.name
translation_source_name = str(instance.translation_source.get_source_instance())

smartling_cms_dashboard_url = f"{settings.WAGTAILADMIN_BASE_URL}/cms-admin/smartling-jobs/inspect/{instance.pk}/"

email_body = render_to_string(
template_name="cms/email/notifications/individual_translation_imported__body.txt",
context={
"job_name": job_name,
"translation_source_name": translation_source_name,
"translation_target_language_code": translation.target_locale.language_code,
"smartling_cms_dashboard_url": smartling_cms_dashboard_url,
},
)

send_mail(
subject=email_subject,
message=email_body,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=admin_emails,
)
logger.info(f"Translation-imported notification sent to {len(admin_emails)} admins")


individual_translation_imported.connect(notify_of_imported_translation, weak=False)


def trigger_cache_warming(sender, **kwargs):
# Run after the post-migrate signal is sent for the `cms` app
warm_page_path_cache()


def rebuild_path_cache_after_page_move(sender, **kwargs):
# Check if a page has moved up or down within the tree
# (rather than just being reordered). If it has really moved
# then we should update the cache
if kwargs["url_path_before"] == kwargs["url_path_after"]:
# No URLs are changing - nothing to do here!
return

# The page is moving, so we need to rebuild the entire pre-empting-lookup cache
warm_page_path_cache()


post_page_move.connect(rebuild_path_cache_after_page_move)

page_published.connect(trigger_cache_warming)
page_unpublished.connect(trigger_cache_warming)
25 changes: 25 additions & 0 deletions bedrock/cms/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import pytest
import wagtail_factories
from wagtail.contrib.redirects.models import Redirect
from wagtail.models import Locale, Site

from bedrock.cms.tests.factories import LocaleFactory, SimpleRichTextPageFactory
Expand Down Expand Up @@ -74,8 +75,14 @@ def tiny_localized_site():
site = Site.objects.get(is_default_site=True)

en_us_root_page = site.root_page

fr_root_page = en_us_root_page.copy_for_translation(fr_locale)
rev = fr_root_page.save_revision()
fr_root_page.publish(rev)

pt_br_root_page = en_us_root_page.copy_for_translation(pt_br_locale)
rev = pt_br_root_page.save_revision()
pt_br_root_page.publish(rev)

en_us_homepage = SimpleRichTextPageFactory(
title="Test Page",
Expand Down Expand Up @@ -148,3 +155,21 @@ def tiny_localized_site():
assert fr_homepage.live is True
assert fr_child.live is True
assert fr_grandchild.live is True


@pytest.fixture
def tiny_localized_site_redirects():
"""Some test redirects that complement the tiny_localized_site fixture.

Useful for things like the tests for the cache-based lookup
in bedrock.cms.tests.test_utils.test_path_exists_in_cms
"""

Redirect.add_redirect(
old_path="/fr/moved-page/",
redirect_to="/fr/test-page/child-page/",
)
Redirect.add_redirect(
old_path="/en-US/deeper/nested/moved-page/",
redirect_to="/fr/test-page/",
)
Loading
Loading