Skip to content
Open
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
8 changes: 4 additions & 4 deletions integreat_cms/api/v3/pdf_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ def pdf_export(
:return: The redirect to the generated PDF document
"""
region = request.region
# Request unrestricted queryset because pdf generator performs further operations (e.g. aggregation) on the queryset
# Request unrestricted queryset because pdf generator performs further operations (e.g. aggregation) on the queryset.
# The translations are prefetched inside generate_pdf, so that the (large) page contents are only
# loaded on a cache miss (see :func:`~integreat_cms.cms.utils.pdf_utils.generate_pdf`).
pages = region.get_pages()
if request.GET.get("url"):
# remove leading and trailing slashed to avoid ambiguous urls
Expand All @@ -55,7 +57,5 @@ def pdf_export(
).distinct()
if len(page) != 1:
raise Http404("No matching page translation found for url.")
pages = Page.get_tree(page[0]).prefetch_public_translations()
else:
pages = pages.prefetch_public_translations()
pages = Page.get_tree(page[0])
return generate_pdf(region, language_slug, pages)
32 changes: 28 additions & 4 deletions integreat_cms/cms/models/abstract_content_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,25 +40,33 @@ class ContentQuerySet(models.QuerySet):
def prefetch_translations(
self,
to_attr: str = "prefetched_translations",
defer: list[str] | None = None,
**filters: Any,
) -> ContentQuerySet:
r"""
Get the queryset including the custom attribute ``to_attr`` which contains the latest
translations of each content object in each language, optionally filtered by the given ``status``

:param to_attr: To which attribute the prefetched translations should be added [optional, defaults to ``prefetched_translations``]
:param defer: Names of translation fields to defer, e.g. ``["content"]`` to avoid loading
the (potentially large) page contents when they are not needed [optional]
:param \**filters: Additional filters to be applied on the translations (e.g. by status)
:return: The queryset of content objects
"""
TranslationModel = self.model.get_translation_model()
foreign_field = TranslationModel.foreign_field() + "_id"
translations = (
TranslationModel.objects.filter(**filters)
.order_by(foreign_field, "language_id", "-version")
.distinct(foreign_field, "language_id")
.select_related("language")
)
if defer:
translations = translations.defer(*defer)
return self.prefetch_related(
models.Prefetch(
"translations",
queryset=TranslationModel.objects.filter(**filters)
.order_by(foreign_field, "language_id", "-version")
.distinct(foreign_field, "language_id")
.select_related("language"),
queryset=translations,
to_attr=to_attr,
),
)
Expand All @@ -77,6 +85,22 @@ def prefetch_public_translations(
status=status.PUBLIC,
)

def prefetch_public_translations_without_content(
self,
) -> ContentQuerySet:
"""
Like :meth:`prefetch_public_translations`, but defers the (potentially large)
translation ``content`` field. Useful when only translation metadata (e.g. ``id`` and
``last_updated`` for a cache key) is required and the content is not accessed.

:return: The queryset of content objects
"""
return self.prefetch_translations(
to_attr="prefetched_public_translations",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer to use a different attribute name to avoid confusion. Something like prefetched_public_translations_without_content maybe? I won't die on that hill, though.

defer=["content"],
status=status.PUBLIC,
)

def prefetch_public_or_draft_translations(
self,
) -> ContentQuerySet:
Expand Down
20 changes: 16 additions & 4 deletions integreat_cms/cms/utils/pdf_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,17 @@ def generate_pdf(
:param pages: at least on page to render as PDF document
:return: Redirection to PDF document
"""
# Build a lightweight queryset with the (large) translation content deferred, so that the
# cache key computation and the existence check below do not load the page contents. The
# full content is only fetched further down if the PDF actually has to be rendered.
pages = pages.prefetch_related(None)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes GeneratePdfView.prefetch_public_translations = True in cms/views/pages/page_bulk_actions.py:50 dead config — the prefetch lookup is stripped here before the queryset is ever evaluated, so it never executes. If you want to, you could remove that flag in this PR so it doesn't mislead future readers into thinking the bulk view still prefetches translations.

hash_pages = pages.prefetch_public_translations_without_content()

# first all necessary data for hashing are collected, starting at region slug
# region last_updated field taking into account, to keep track of maybe edited region icons
pdf_key_list = [region.slug, region.last_updated]
for page in pages:
excluded_page_ids = []
for page in hash_pages:
# add translation id and last_updated to hash key list if they exist
page_translation = page.get_public_translation(language_slug)
if page_translation and not page.archived:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking, pre-existing: page.archived resolves implicitly_archivedget_cached_ancestors(), which runs one get_ancestors() query per page since nothing warms _cached_ancestors here. So the cache-hit path is still O(N) queries even with the content deferred. Also, for the whole-region branch the check is redundant — region.get_pages() already returns non_archived_pages. Might be worth a follow-up issue rather than this PR.

Expand All @@ -60,7 +67,9 @@ def generate_pdf(
pdf_key_list.append(page_translation.last_updated)
else:
# if the page has no translation for this language
pages = pages.exclude(id=page.id)
excluded_page_ids.append(page.id)
if excluded_page_ids:
pages = pages.exclude(id__in=excluded_page_ids)
# finally combine all list entries to a single hash key
pdf_key_string = "_".join(map(str, pdf_key_list))
# compute the hash value based on the hash key
Expand Down Expand Up @@ -94,9 +103,12 @@ def generate_pdf(
max_len = 192 - len(ext)
name = f"{settings.BRANDING_TITLE} - {language.translated_name} - {title}"
filename = f"{pdf_hash}/{truncate_bytewise(name, max_len)}{ext}"
# Only generate new pdf if not already exists
# Only generate new pdf if not already exists. The existence check is performed before the
# (expensive) page content is loaded and rendered, so that repeated requests for an already
# generated PDF stay cheap.
if not pdf_storage.exists(filename):
# Convert queryset to annotated list which can be rendered better
# Cache miss: load the full page content and render the PDF document
pages = pages.prefetch_public_translations()
annotated_pages = Page.get_annotated_list_qs(pages)
context = {
"right_to_left": language.text_direction == text_directions.RIGHT_TO_LEFT,
Expand Down
Loading