1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-07 22:35:36 +00:00

Merge branch '3472-cache-the-public-builder-data-sources-elements-and-workflow-actions-endpoints' into 'develop'

Resolve "Cache the public builder data sources, elements and workflow actions endpoints."

Closes 

See merge request 
This commit is contained in:
Peter Evans 2025-02-26 09:29:58 +00:00
commit 91b9857fdf
13 changed files with 397 additions and 139 deletions

View file

@ -1,8 +1,11 @@
from typing import Any, Dict, List
from django.db import transaction
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework.permissions import AllowAny
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
@ -27,7 +30,11 @@ from baserow.contrib.builder.api.data_sources.errors import (
from baserow.contrib.builder.api.data_sources.serializers import (
DispatchDataSourceRequestSerializer,
)
from baserow.contrib.builder.api.domains.serializers import PublicBuilderSerializer
from baserow.contrib.builder.api.domains.serializers import (
PublicBuilderSerializer,
PublicDataSourceSerializer,
PublicElementSerializer,
)
from baserow.contrib.builder.api.pages.errors import ERROR_PAGE_DOES_NOT_EXIST
from baserow.contrib.builder.api.workflow_actions.serializers import (
BuilderWorkflowActionSerializer,
@ -42,12 +49,17 @@ from baserow.contrib.builder.data_sources.exceptions import (
)
from baserow.contrib.builder.data_sources.handler import DataSourceHandler
from baserow.contrib.builder.data_sources.service import DataSourceService
from baserow.contrib.builder.domains.handler import DomainHandler
from baserow.contrib.builder.domains.service import DomainService
from baserow.contrib.builder.elements.registries import element_type_registry
from baserow.contrib.builder.elements.service import ElementService
from baserow.contrib.builder.errors import ERROR_BUILDER_DOES_NOT_EXIST
from baserow.contrib.builder.exceptions import BuilderDoesNotExist
from baserow.contrib.builder.handler import BuilderHandler
from baserow.contrib.builder.handler import (
BUILDER_PUBLIC_BUILDER_BY_DOMAIN_TTL_SECONDS,
BUILDER_PUBLIC_RECORDS_CACHE_TTL_SECONDS,
BuilderHandler,
)
from baserow.contrib.builder.pages.exceptions import PageDoesNotExist
from baserow.contrib.builder.pages.handler import PageHandler
from baserow.contrib.builder.service import BuilderService
@ -65,8 +77,7 @@ from baserow.core.services.exceptions import (
ServiceSortPropertyDoesNotExist,
)
from baserow.core.services.registries import service_type_registry
from .serializers import PublicDataSourceSerializer, PublicElementSerializer
from baserow.core.utils import safe_get_or_set_cache
class PublicBuilderByDomainNameView(APIView):
@ -93,18 +104,39 @@ class PublicBuilderByDomainNameView(APIView):
},
)
@map_exceptions({BuilderDoesNotExist: ERROR_BUILDER_DOES_NOT_EXIST})
def get(self, request, domain_name):
def get(self, request: Request, domain_name: str):
"""
Responds with a serialized version of the builder related to the query.
Try to match a published builder for the given domain name. Used to display
the public site.
"""
data = safe_get_or_set_cache(
cache_key=DomainHandler.get_public_builder_by_domain_cache_key(domain_name),
version_cache_key=DomainHandler.get_public_builder_by_domain_version_cache_key(
domain_name
),
default=lambda: self._get_public_builder_by_domain(request, domain_name),
timeout=BUILDER_PUBLIC_BUILDER_BY_DOMAIN_TTL_SECONDS,
)
return Response(data)
def _get_public_builder_by_domain(self, request: Request, domain_name: str):
"""
Returns a serialized builder which has a domain matching `domain_name`.
Only requested if the public get-by-domain cache is stale, or if the
application has been re-published.
:param request: the HTTP request.
:param domain_name: the domain name to match.
:return: a publicly serialized builder.
"""
builder = DomainService().get_public_builder_by_domain_name(
request.user, domain_name
)
return Response(PublicBuilderSerializer(builder).data)
return PublicBuilderSerializer(builder).data
class PublicBuilderByIdView(APIView):
@ -180,20 +212,43 @@ class PublicElementsView(APIView):
PageDoesNotExist: ERROR_PAGE_DOES_NOT_EXIST,
}
)
def get(self, request, page_id):
def get(self, request: Request, page_id: int):
"""
Responds with a list of serialized elements that belongs to the given page id.
"""
if PageHandler().is_published_page(page_id):
data = safe_get_or_set_cache(
cache_key=PageHandler.get_page_public_records_cache_key(
page_id, request.user_source_user, "elements"
),
default=lambda: self._get_public_page_elements(request, page_id),
timeout=BUILDER_PUBLIC_RECORDS_CACHE_TTL_SECONDS,
)
else:
data = self._get_public_page_elements(request, page_id)
return Response(data)
def _get_public_page_elements(
self, request: Request, page_id: int
) -> List[Dict[str, Any]]:
"""
Returns a list of serialized elements that belong to the given page id.
Only requested if the public elements cache is stale.
:param request: the HTTP request.
:param page_id: the page id.
:return: a list of serialized elements.
"""
page = PageHandler().get_page(page_id)
elements = ElementService().get_elements(request.user, page)
data = [
return [
element_type_registry.get_serializer(element, PublicElementSerializer).data
for element in elements
]
return Response(data)
class PublicDataSourcesView(APIView):
@ -227,26 +282,47 @@ class PublicDataSourcesView(APIView):
PageDoesNotExist: ERROR_PAGE_DOES_NOT_EXIST,
}
)
def get(self, request, page_id):
def get(self, request: Request, page_id: int):
"""
Responds with a list of serialized data_sources that belong to the page if the
user has access to it.
"""
page = PageHandler().get_page(page_id)
if PageHandler().is_published_page(page_id):
data = safe_get_or_set_cache(
cache_key=PageHandler.get_page_public_records_cache_key(
page_id, request.user_source_user, "data_sources"
),
default=lambda: self._get_public_page_data_sources(request, page_id),
timeout=BUILDER_PUBLIC_RECORDS_CACHE_TTL_SECONDS,
)
else:
data = self._get_public_page_data_sources(request, page_id)
return Response(data)
def _get_public_page_data_sources(self, request: Request, page_id: int):
"""
Returns a list of serialized data sources that belong to the given page id.
Only requested if the public data sources cache is stale.
:param request: the HTTP request.
:param page_id: the page id.
:return: a list of serialized data sources.
"""
page = PageHandler().get_page(page_id)
data_sources = DataSourceService().get_data_sources(request.user, page)
handler = BuilderHandler()
public_properties = handler.get_builder_public_properties(
public_properties = BuilderHandler().get_builder_public_properties(
request.user_source_user, page.builder
)
allowed_fields = []
for fields in public_properties["external"].values():
allowed_fields.extend(fields)
data = [
return [
service_type_registry.get_serializer(
data_source.service,
PublicDataSourceSerializer,
@ -256,8 +332,6 @@ class PublicDataSourcesView(APIView):
if data_source.service and data_source.service.integration_id
]
return Response(data)
class PublicBuilderWorkflowActionsView(APIView):
permission_classes = (AllowAny,)
@ -295,14 +369,44 @@ class PublicBuilderWorkflowActionsView(APIView):
PageDoesNotExist: ERROR_PAGE_DOES_NOT_EXIST,
}
)
def get(self, request, page_id: int):
page = PageHandler().get_page(page_id)
def get(self, request: Request, page_id: int):
""" "
Responds with a list of serialized workflow actions that belongs to the given
page id.
"""
if PageHandler().is_published_page(page_id):
data = safe_get_or_set_cache(
cache_key=PageHandler.get_page_public_records_cache_key(
page_id, request.user_source_user, "workflow_actions"
),
default=lambda: self._get_public_page_workflow_actions(
request, page_id
),
timeout=BUILDER_PUBLIC_RECORDS_CACHE_TTL_SECONDS,
)
else:
data = self._get_public_page_workflow_actions(request, page_id)
return Response(data)
def _get_public_page_workflow_actions(self, request: Request, page_id: int):
"""
Returns a list of serialized workflow actions that belong to the given page id.
Only requested if the public workflow actions cache is stale.
:param request: the HTTP request.
:param page_id: the page id.
:return: a list of serialized workflow actions.
"""
page = PageHandler().get_page(page_id)
workflow_actions = BuilderWorkflowActionService().get_workflow_actions(
request.user, page
)
data = [
return [
builder_workflow_action_type_registry.get_serializer(
workflow_action,
BuilderWorkflowActionSerializer,
@ -311,8 +415,6 @@ class PublicBuilderWorkflowActionsView(APIView):
for workflow_action in workflow_actions
]
return Response(data)
class PublicDispatchDataSourceView(APIView):
permission_classes = (AllowAny,)

View file

@ -19,7 +19,7 @@ from baserow.core.models import Workspace
from baserow.core.registries import ImportExportConfig, application_type_registry
from baserow.core.storage import get_default_storage
from baserow.core.trash.handler import TrashHandler
from baserow.core.utils import Progress, extract_allowed
from baserow.core.utils import Progress, extract_allowed, invalidate_versioned_cache
class DomainHandler:
@ -275,4 +275,21 @@ class DomainHandler:
domain.last_published = datetime.now(tz=timezone.utc)
domain.save()
# Invalidate the public builder-by-domain cache after a new publication.
DomainHandler.invalidate_public_builder_by_domain_cache(domain.domain_name)
return domain
@classmethod
def get_public_builder_by_domain_cache_key(cls, domain_name: str) -> str:
return f"ab_public_builder_by_domain_{domain_name}"
@classmethod
def get_public_builder_by_domain_version_cache_key(cls, domain_name: str) -> str:
return f"ab_public_builder_by_domain_{domain_name}_version"
@classmethod
def invalidate_public_builder_by_domain_cache(cls, domain_name: str):
invalidate_versioned_cache(
cls.get_public_builder_by_domain_version_cache_key(domain_name)
)

View file

@ -1,7 +1,5 @@
from typing import TYPE_CHECKING, Dict, List, Set
from django.contrib.auth.models import AbstractUser
from antlr4.tree import Tree
from baserow.contrib.builder.data_providers.registries import (
@ -11,6 +9,7 @@ from baserow.contrib.builder.elements.models import Element
from baserow.contrib.builder.formula_importer import BaserowFormulaImporter
from baserow.core.formula import BaserowFormula
from baserow.core.formula.exceptions import InvalidBaserowFormula
from baserow.core.user_sources.user_source_user import UserSourceUser
from baserow.core.utils import merge_dicts_no_duplicates, to_path
if TYPE_CHECKING:
@ -176,10 +175,10 @@ def get_data_source_property_names(
def get_builder_used_property_names(
user: AbstractUser, builder: "Builder"
user: UserSourceUser, builder: "Builder"
) -> Dict[str, Dict[int, List[str]]]:
"""
Given a User and a Builder, return all property names used in the all the
Given a UserSourceUser and a Builder, return all property names used in the all the
pages.
This involves looping over all Elements, Workflow Actions, and Data Sources

View file

@ -1,8 +1,6 @@
from typing import Dict, List, Optional
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AbstractUser
from django.db.models.query import QuerySet
from baserow.contrib.builder.formula_property_extractor import (
@ -14,12 +12,19 @@ from baserow.core.handler import CoreHandler
from baserow.core.models import Workspace
from baserow.core.user_sources.handler import UserSourceHandler
from baserow.core.user_sources.models import UserSource
from baserow.core.user_sources.user_source_user import UserSourceUser
from baserow.core.utils import invalidate_versioned_cache, safe_get_or_set_cache
User = get_user_model()
CACHE_KEY_PREFIX = "used_properties_for_page"
BUILDER_PREVIEW_USED_PROPERTIES_CACHE_TTL_SECONDS = 60
USED_PROPERTIES_CACHE_KEY_PREFIX = "used_properties_for_page"
# The duration of the cached public element, data source and workflow action API views.
BUILDER_PUBLIC_RECORDS_CACHE_TTL_SECONDS = 60 * 60
# The duration of the cached public `get_public_builder_by_domain_name` view.
BUILDER_PUBLIC_BUILDER_BY_DOMAIN_TTL_SECONDS = 60 * 60
# The duration of the cached public properties for the builder API views.
BUILDER_PREVIEW_USED_PROPERTIES_CACHE_TTL_SECONDS = 60
SENTINEL = "__no_results__"
@ -50,20 +55,15 @@ class BuilderHandler:
)
@classmethod
def _get_builder_version_cache(cls, builder: Builder):
return f"{CACHE_KEY_PREFIX}_version_{builder.id}"
def _get_builder_public_properties_version_cache(cls, builder: Builder) -> str:
return f"{USED_PROPERTIES_CACHE_KEY_PREFIX}_version_{builder.id}"
def get_builder_used_properties_cache_key(
self, user: AbstractUser, builder: Builder
) -> Optional[str]:
self, user: UserSourceUser, builder: Builder
) -> str:
"""
Returns a cache key that can be used to key the results of making the
expensive function call to get_builder_used_property_names().
If the user is a Django user, return None. This is because the Page
Designer should always have the latest data in the Preview (e.g. when
they are not authenticated). Also, the Django user doesn't have the role
attribute, unlike the User Source User.
"""
if user.is_anonymous or not user.role:
@ -72,14 +72,16 @@ class BuilderHandler:
else:
role = f"_{user.role}"
return f"{CACHE_KEY_PREFIX}_{builder.id}{role}"
return f"{USED_PROPERTIES_CACHE_KEY_PREFIX}_{builder.id}{role}"
@classmethod
def invalidate_builder_public_properties_cache(cls, builder):
invalidate_versioned_cache(cls._get_builder_version_cache(builder))
def invalidate_builder_public_properties_cache(cls, builder: Builder):
invalidate_versioned_cache(
cls._get_builder_public_properties_version_cache(builder)
)
def get_builder_public_properties(
self, user: AbstractUser, builder: Builder
self, user: UserSourceUser, builder: Builder
) -> Dict[str, Dict[int, List[str]]]:
"""
Return a Dict where keys are ["all", "external", "internal"] and values
@ -100,7 +102,7 @@ class BuilderHandler:
result = safe_get_or_set_cache(
self.get_builder_used_properties_cache_key(user, builder),
self._get_builder_version_cache(builder),
self._get_builder_public_properties_version_cache(builder),
default=compute_properties,
timeout=settings.BUILDER_PUBLICLY_USED_PROPERTIES_CACHE_TTL_SECONDS
if builder.workspace_id
@ -146,3 +148,13 @@ class BuilderHandler:
)
)
return UserSourceHandler().aggregate_user_counts(workspace, queryset)
@classmethod
def get_public_builder_by_domain_version_cache(cls, domain_name: str) -> str:
return f"get_public_builder_by_domain_{domain_name}"
@classmethod
def invalidate_public_builder_by_domain_cache(cls, domain_name: str):
invalidate_versioned_cache(
cls.get_public_builder_by_domain_version_cache(domain_name)
)

View file

@ -42,7 +42,15 @@ from baserow.contrib.builder.workflow_actions.handler import (
)
from baserow.core.exceptions import IdDoesNotExist
from baserow.core.storage import ExportZipFile
from baserow.core.utils import ChildProgressBuilder, MirrorDict, find_unused_name
from baserow.core.user_sources.user_source_user import UserSourceUser
from baserow.core.utils import (
ChildProgressBuilder,
MirrorDict,
find_unused_name,
safe_get_or_set_cache,
)
BUILDER_PAGE_IS_PUBLISHED_CACHE_TTL_SECONDS = 60 * 60
class PageHandler:
@ -229,6 +237,55 @@ class PageHandler:
return full_order
@classmethod
def get_page_public_records_cache_key(
cls, page_id: int, user: UserSourceUser, record_name: str
):
"""
Generates the cache key used by the public elements, data sources and workflow
actions endpoints. If the `user` is authenticated, and they have a role, we will
include the role in the cache key.
:param page_id: the ID of the public page being requested.
:param user: the `UserSourceUser` performing the HTTP request.
:param record_name: one of "elements", "data_sources" or "workflow_actions".
Used to differentiate between public view endpoints.
:return: the cache key.
"""
role = f"_{user.role}" if not user.is_anonymous and user.role else ""
return f"ab_public_page_{page_id}{role}_{record_name}_records"
def is_published_page(self, public_page_id: int) -> bool:
"""
Returns whether this public page ID points to a published domain
application or not.
:param public_page_id: The ID of the public page.
:return: whether this public page ID is published or not.
"""
return safe_get_or_set_cache(
f"ab_public_page_{public_page_id}_published",
default=lambda: self._is_published_application_page(public_page_id),
timeout=BUILDER_PAGE_IS_PUBLISHED_CACHE_TTL_SECONDS,
)
def _is_published_application_page(self, public_page_id: int) -> bool:
"""
Given a *public* page ID, is responsible for returning the published domain
application it's associated with.
:param public_page_id: The ID of the public page.
:return: The published domain application associated with the public page.
"""
return (
Builder.objects.filter(page__id=public_page_id)
.exclude(published_from=None)
.exists()
)
def duplicate_page(
self, page: Page, progress_builder: Optional[ChildProgressBuilder] = None
):

View file

@ -38,6 +38,7 @@ from redis.exceptions import LockNotOwnedError
from requests.utils import guess_json_utf
from baserow.contrib.database.db.schema import optional_atomic
from baserow.version import VERSION as BASEROW_VERSION
from .exceptions import CannotCalculateIntermediateOrder
@ -1208,6 +1209,9 @@ def are_hostnames_same(hostname1: str, hostname2: str) -> bool:
return not ips1.isdisjoint(ips2)
SENTINEL = object()
def safe_get_or_set_cache(
cache_key: str,
version_cache_key: str = None,
@ -1232,23 +1236,23 @@ def safe_get_or_set_cache(
:return: The cached value if it exists; otherwise, the newly set value.
"""
cached = cache.get(cache_key)
cache_key_to_use = cache_key
cache_key_to_use = f"{BASEROW_VERSION}_{cache_key}"
if version_cache_key is not None:
version = cache.get(version_cache_key, 0)
cache_key_to_use = f"{cache_key}__version_{version}"
if cached is None:
cached = cache.get(cache_key_to_use, SENTINEL)
if cached is SENTINEL:
use_lock = hasattr(cache, "lock")
if use_lock:
cache_lock = cache.lock(f"{cache_key_to_use}__lock", timeout=10)
cache_lock.acquire()
try:
cached = cache.get(cache_key_to_use)
cached = cache.get(cache_key_to_use, SENTINEL)
# We check again to make sure it hasn't been populated in the meantime
# while acquiring the lock
if cached is None:
if cached is SENTINEL:
if callable(default):
cached = default()
else:

View file

@ -107,12 +107,12 @@ def test_get_public_builder_by_domain_name(api_client, data_fixture):
page2 = data_fixture.create_builder_page(user=user, builder=builder_to)
domain = data_fixture.create_builder_custom_domain(
domain_name="test.getbaserow.io", published_to=builder_to
domain_name="xyztest.getbaserow.io", published_to=builder_to
)
url = reverse(
"api:builder:domains:get_builder_by_domain_name",
kwargs={"domain_name": "test.getbaserow.io"},
kwargs={"domain_name": "xyztest.getbaserow.io"},
)
# Anonymous request
@ -212,14 +212,14 @@ def test_get_builder_missing_domain_name(api_client, data_fixture):
def test_get_non_public_builder(api_client, data_fixture):
user, token = data_fixture.create_user_and_token()
page = data_fixture.create_builder_page(user=user)
page2 = data_fixture.create_builder_page(builder=page.builder, user=user)
domain = data_fixture.create_builder_custom_domain(
domain_name="test.getbaserow.io", builder=page.builder
data_fixture.create_builder_page(builder=page.builder, user=user)
data_fixture.create_builder_custom_domain(
domain_name="notpublic.getbaserow.io", builder=page.builder
)
url = reverse(
"api:builder:domains:get_builder_by_domain_name",
kwargs={"domain_name": "test.getbaserow.io"},
kwargs={"domain_name": "notpublic.getbaserow.io"},
)
response = api_client.get(
url,

View file

@ -180,32 +180,25 @@ class PublicTestWorkflowActionType(NotificationWorkflowActionType):
@pytest.mark.django_db
def test_public_workflow_actions_view(
api_client, data_fixture, mutable_builder_workflow_action_registry
):
def test_public_workflow_actions_view(api_client, data_fixture):
user, token = data_fixture.create_user_and_token()
page = data_fixture.create_builder_page(user=user)
workspace = data_fixture.create_workspace(user=user)
mutable_builder_workflow_action_registry.unregister(
NotificationWorkflowActionType().type
)
mutable_builder_workflow_action_registry.register(PublicTestWorkflowActionType())
workflow_action = BuilderWorkflowActionHandler().create_workflow_action(
PublicTestWorkflowActionType(), test="hello", page=page
builder = data_fixture.create_builder_application(workspace=workspace)
page = data_fixture.create_builder_page(builder=builder)
BuilderWorkflowActionHandler().create_workflow_action(
NotificationWorkflowActionType(), page=page
)
url = reverse(
"api:builder:workflow_action:item",
kwargs={"workflow_action_id": workflow_action.id},
)
response = api_client.get(
url,
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
published_builder = data_fixture.create_builder_application(workspace=None)
published_page = data_fixture.create_builder_page(builder=published_builder)
BuilderWorkflowActionHandler().create_workflow_action(
NotificationWorkflowActionType(), page=published_page
)
assert "test" not in response.json()
data_fixture.create_builder_custom_domain(
builder=builder, published_to=published_builder
)
url = reverse(
"api:builder:domains:list_workflow_actions",
@ -217,8 +210,23 @@ def test_public_workflow_actions_view(
HTTP_AUTHORIZATION=f"JWT {token}",
)
[workflow_action_in_response] = response.json()
assert "test" in workflow_action_in_response
response_json = response.json()
assert len(response_json) == 1
assert response_json[0]["type"] == NotificationWorkflowActionType.type
url = reverse(
"api:builder:domains:list_workflow_actions",
kwargs={"page_id": published_page.id},
)
response = api_client.get(
url,
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
response_json = response.json()
assert len(response_json) == 1
assert response_json[0]["type"] == NotificationWorkflowActionType.type
@pytest.mark.django_db

View file

@ -9,7 +9,7 @@ from baserow.contrib.builder.domains.handler import DomainHandler
from baserow.contrib.builder.domains.models import Domain
from baserow.contrib.builder.exceptions import BuilderDoesNotExist
from baserow.contrib.builder.models import Builder
from baserow.core.utils import Progress
from baserow.core.utils import Progress, safe_get_or_set_cache
@pytest.mark.django_db
@ -171,15 +171,27 @@ def test_domain_publishing(data_fixture):
page1 = data_fixture.create_builder_page(builder=builder)
page2 = data_fixture.create_builder_page(builder=builder)
element1 = data_fixture.create_builder_heading_element(
page=page1, level=2, value="'foo'"
)
element2 = data_fixture.create_builder_text_element(page=page1)
element3 = data_fixture.create_builder_heading_element(page=page2)
data_fixture.create_builder_heading_element(page=page1, level=2, value="'foo'")
data_fixture.create_builder_text_element(page=page1)
data_fixture.create_builder_heading_element(page=page2)
progress = Progress(100)
DomainHandler().publish(domain1, progress)
domain1 = DomainHandler().publish(domain1, progress)
# Pretend that someone visited the public builder-by-domain endpoint.
builder_by_domain_cache_key = (
DomainHandler.get_public_builder_by_domain_version_cache_key(
domain1.domain_name
)
)
version_key = DomainHandler.get_public_builder_by_domain_version_cache_key(
domain1.domain_name
)
# We populate the builder domain cache
safe_get_or_set_cache(builder_by_domain_cache_key, version_key, default="before")
domain1.refresh_from_db()
@ -192,9 +204,15 @@ def test_domain_publishing(data_fixture):
assert progress.progress == progress.total
# Lets publish it a second time.
# Let's publish it a second time.
DomainHandler().publish(domain1, progress)
# Following a re-publish, the builder-by-domain cache is invalidated
assert (
safe_get_or_set_cache(builder_by_domain_cache_key, version_key, default="after")
== "after"
)
assert Builder.objects.count() == 2

View file

@ -1,5 +1,6 @@
import pytest
from baserow.contrib.builder.domains.handler import DomainHandler
from baserow.contrib.builder.elements.models import ColumnElement, TextElement
from baserow.contrib.builder.elements.registries import element_type_registry
from baserow.contrib.builder.pages.constants import ILLEGAL_PATH_SAMPLE_CHARACTER
@ -17,6 +18,7 @@ from baserow.contrib.builder.pages.exceptions import (
)
from baserow.contrib.builder.pages.handler import PageHandler
from baserow.contrib.builder.pages.models import Page
from baserow.core.user_sources.user_source_user import UserSourceUser
@pytest.mark.django_db
@ -517,3 +519,37 @@ def test_validate_query_params_edge_cases():
invalid_params = [{"name": f"filter{char}", "type": "text"}]
with pytest.raises(InvalidQueryParamName):
handler.validate_query_params(path, path_params, invalid_params)
def test_get_page_public_records_cache_key():
user_with_role = UserSourceUser(
None, None, 1, "username", "foo@bar.com", role="admin"
)
assert (
PageHandler.get_page_public_records_cache_key(123, user_with_role, "elements")
== "ab_public_page_123_admin_elements_records"
)
user_without_role = UserSourceUser(None, None, 1, "username", "foo@bar.com")
assert (
PageHandler.get_page_public_records_cache_key(
123, user_without_role, "elements"
)
== "ab_public_page_123_elements_records"
)
@pytest.mark.django_db
def test_is_published_application_page(data_fixture):
user = data_fixture.create_user()
workspace = data_fixture.create_workspace(user=user)
builder = data_fixture.create_builder_application(workspace=workspace)
page = data_fixture.create_builder_page(builder=builder)
domain = data_fixture.create_builder_custom_domain(builder=builder)
domain = DomainHandler().publish(domain)
published_builder = domain.published_to
published_page = published_builder.page_set.get()
assert not PageHandler()._is_published_application_page(page.id)
assert PageHandler()._is_published_application_page(published_page.id)

View file

@ -4,7 +4,10 @@ from django.contrib.auth import get_user_model
import pytest
from baserow.contrib.builder.handler import CACHE_KEY_PREFIX, BuilderHandler
from baserow.contrib.builder.handler import (
USED_PROPERTIES_CACHE_KEY_PREFIX,
BuilderHandler,
)
from baserow.core.exceptions import ApplicationDoesNotExist
from baserow.core.user_sources.user_source_user import UserSourceUser
@ -46,17 +49,17 @@ def test_get_builder_select_related_theme_config(
(
True,
"",
f"{CACHE_KEY_PREFIX}_100",
f"{USED_PROPERTIES_CACHE_KEY_PREFIX}_100",
),
(
True,
"foo_role",
f"{CACHE_KEY_PREFIX}_100",
f"{USED_PROPERTIES_CACHE_KEY_PREFIX}_100",
),
(
False,
"foo_role",
f"{CACHE_KEY_PREFIX}_100_foo_role",
f"{USED_PROPERTIES_CACHE_KEY_PREFIX}_100_foo_role",
),
],
)

View file

@ -1,5 +1,3 @@
from django.core.cache import cache
from baserow.core.utils import invalidate_versioned_cache, safe_get_or_set_cache
@ -7,15 +5,13 @@ def test_safe_get_or_set_cache_literally_stores_default():
"""If the cache is empty, a literal default value is stored and returned."""
cache_key = "test_literal_default"
cache.delete(cache_key)
result = safe_get_or_set_cache(
cache_key=cache_key,
default="my_default_value",
timeout=60,
timeout=6,
)
assert result == "my_default_value"
assert cache.get(cache_key) == "my_default_value"
def test_safe_get_or_set_cache_callable_stores_return_value():
@ -24,7 +20,6 @@ def test_safe_get_or_set_cache_callable_stores_return_value():
"""
cache_key = "test_callable_default"
cache.delete(cache_key)
def some_callable():
return "callable_value"
@ -32,10 +27,9 @@ def test_safe_get_or_set_cache_callable_stores_return_value():
result = safe_get_or_set_cache(
cache_key=cache_key,
default=some_callable,
timeout=60,
timeout=6,
)
assert result == "callable_value"
assert cache.get(cache_key) == "callable_value"
def test_safe_get_or_set_cache_uses_existing_value():
@ -44,17 +38,20 @@ def test_safe_get_or_set_cache_uses_existing_value():
"""
cache_key = "test_existing"
cache.delete(cache_key)
cache.set(cache_key, "existing_value", 60)
result = safe_get_or_set_cache(
cache_key=cache_key,
default="existing_value",
timeout=60,
)
result = safe_get_or_set_cache(
cache_key=cache_key,
default="unused_default",
timeout=60,
timeout=6,
)
assert result == "existing_value"
# Confirm it didn't overwrite with 'unused_default'
assert cache.get(cache_key) == "existing_value"
def test_versioned_cache_set_and_retrieve():
@ -65,19 +62,15 @@ def test_versioned_cache_set_and_retrieve():
base_key = "test_versioned_base"
version_cache_key = "test_versioned_key"
cache.delete(base_key)
cache.delete(version_cache_key)
# No version exists, so this should initialize version=0
result = safe_get_or_set_cache(
cache_key=base_key,
version_cache_key=version_cache_key,
default="versioned_value",
timeout=60,
timeout=6,
)
assert result == "versioned_value"
# Confirm the value is stored under the versioned key
assert cache.get(f"{base_key}__version_0") == "versioned_value"
def test_versioned_cache_hit():
@ -88,45 +81,47 @@ def test_versioned_cache_hit():
base_key = "test_versioned_base2"
version_cache_key = "test_versioned_key2"
cache.delete(base_key)
cache.delete(version_cache_key)
# Manually set version=5
cache.set(version_cache_key, 5)
full_key = f"{base_key}__version_5"
cache.set(full_key, "already_versioned", 60)
result = safe_get_or_set_cache(
cache_key=base_key,
version_cache_key=version_cache_key,
default="already_versioned",
timeout=6,
)
result = safe_get_or_set_cache(
cache_key=base_key,
version_cache_key=version_cache_key,
default="unused_default",
timeout=60,
timeout=6,
)
assert result == "already_versioned"
assert cache.get(full_key) == "already_versioned"
def test_invalidate_versioned_cache_increments_existing():
def test_versioned_cache_invalidation():
"""
If a version_cache_key already exists, calling invalidate_versioned_cache should
increment the version.
If a versioned key already exists, safe_get_or_set_cache should retrieve
that existing value rather than setting a new one.
"""
version_key = "test_invalidate_existing"
cache.set(version_key, 3)
base_key = "test_versioned_base2"
version_cache_key = "test_versioned_key2"
invalidate_versioned_cache(version_key)
assert cache.get(version_key) == 4
result = safe_get_or_set_cache(
cache_key=base_key,
version_cache_key=version_cache_key,
default="already_versioned",
timeout=6,
)
invalidate_versioned_cache(version_cache_key)
def test_invalidate_versioned_cache_sets_new_if_absent():
"""
If a versioned cache key doesn't exist, calling invalidate_versioned_cache should
create it and set it to 1.
"""
result = safe_get_or_set_cache(
cache_key=base_key,
version_cache_key=version_cache_key,
default="new_value",
timeout=6,
)
version_key = "test_invalidate_absent"
cache.delete(version_key)
invalidate_versioned_cache(version_key)
assert cache.get(version_key) == 1
assert result == "new_value"

View file

@ -0,0 +1,7 @@
{
"type": "feature",
"message": "[Builder] Improved the performance of published applications.",
"issue_number": 3472,
"bullet_points": [],
"created_at": "2025-02-25"
}