Skip to content
Merged
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ Do not commit secrets or local overrides. Use `.env.local` for development, keep

- On a specific branch, always merge backend migrations file instead of creating new ones only if it was created on the very same branch.
- Django migrations must be executed with zero downtime. This means the new database schema must remain compatible with the previous application version during the deployment.
- Every new field must define a `db_default`.
- Every new field must define a `db_default` or accept `null` values if they are created on a previously existing model.
- Do not remove fields unless you are certain they are no longer used by the previous application version. Instead, keep the field and add a `# TODO ZDM: remove this field in the next version` comment so it can be safely removed in a subsequent release.
- CSS classes respect BEM methodology.
- When working on translations, only update english unless told otherwise. Other languages are handled with Weblate. Don't nest keys too much, just keep one level of nesting.
Expand Down
62 changes: 58 additions & 4 deletions backend/src/baserow/api/polymorphic.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,17 +80,71 @@ class MyPolymorphicSerializer(PolymorphicSerializer):
# Used for instance for creating public serializers
extra_params: Dict[str, Any] = None

default_error_messages = {
"missing_type": "Unable to determine the `type` of the polymorphic data.",
"invalid_type": '"{type_name}" is not a valid type.',
"type_mismatch": (
'The type is fixed to "{default_type_name}" here, so "{type_name}" '
"cannot be used."
),
}

def __init__(self, *args, default_type_name: str | None = None, **kwargs):
# The type to fall back to when the data doesn't name one, and the only
# type accepted when it does. For use where the caller can't change the
# type anyway, like the service of a workflow action whose action type
# pins it.
self.default_type_name = default_type_name
super().__init__(*args, **kwargs)

def fail_on_type_field(self, key, **kwargs):
"""
Like `fail`, but keys the error under the type field, so the error
detail stays a dict whether this serializer is nested or standalone.
"""

try:
self.fail(key, **kwargs)
except serializers.ValidationError as e:
raise serializers.ValidationError({self.type_field_name: e.detail})

def get_type_from_type_name(self, name):
return self.registry.get(name)

def get_type_from_instance(self, instance):
return self.registry.get_by_model(instance.specific)

def get_type_from_mapping(self, mapping):
if self.type_field_name in mapping:
return self.registry.get(mapping[self.type_field_name])
else:
self.fail("Unable to determine the `type` of the polymorphic data.")
type_name = (
mapping.get(self.type_field_name) if isinstance(mapping, dict) else None
)

# An absent, `null` or empty `type` all mean the caller didn't name one.
if type_name is None or type_name == "":
type_name = self.default_type_name

if type_name is None:
self.fail_on_type_field("missing_type")

# A list or a dict would crash the registry lookup, and other
# non-string values could still match an instance's `compat_type`, so
# only a real name is looked up.
if not isinstance(type_name, str):
self.fail_on_type_field("invalid_type", type_name=type_name)

# When the type is pinned, a different one would only pick which
# serializer the values are checked against, and whatever it accepted
# would then be dropped without a word. Refused so the caller hears it.
if self.default_type_name is not None and type_name != self.default_type_name:
self.fail_on_type_field(
"type_mismatch",
type_name=type_name,
default_type_name=self.default_type_name,
)

# An unknown type propagates the registry's own does-not-exist
# exception, which views map to their specific API errors.
return self.registry.get(type_name)

def to_representation(self, instance):
if not self.required and not instance:
Expand Down
4 changes: 4 additions & 0 deletions backend/src/baserow/contrib/automation/api/nodes/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
validate_body,
)
from baserow.api.schemas import CLIENT_SESSION_ID_SCHEMA_PARAMETER, get_error_schema
from baserow.api.services.errors import ERROR_SERVICE_INVALID_TYPE
from baserow.api.utils import (
DiscriminatorCustomFieldsMappingSerializer,
type_from_data_or_registry,
Expand Down Expand Up @@ -75,6 +76,7 @@
from baserow.contrib.automation.workflows.handler import AutomationWorkflowHandler
from baserow.contrib.automation.workflows.service import AutomationWorkflowService
from baserow.core.graph.exceptions import GraphPointReferencePointInvalid
from baserow.core.services.exceptions import ServiceTypeDoesNotExist

AUTOMATION_NODES_TAG = "Automation nodes"

Expand Down Expand Up @@ -217,6 +219,7 @@ class AutomationNodeView(APIView):
400: get_error_schema(
[
"ERROR_REQUEST_BODY_VALIDATION",
"ERROR_SERVICE_INVALID_TYPE",
]
),
404: get_error_schema(
Expand All @@ -231,6 +234,7 @@ class AutomationNodeView(APIView):
{
AutomationNodeDoesNotExist: ERROR_AUTOMATION_NODE_DOES_NOT_EXIST,
AutomationNodeMisconfiguredService: ERROR_AUTOMATION_NODE_MISCONFIGURED_SERVICE,
ServiceTypeDoesNotExist: ERROR_SERVICE_INVALID_TYPE,
}
)
@require_request_data_type(dict)
Expand Down
3 changes: 3 additions & 0 deletions backend/src/baserow/contrib/automation/nodes/registries.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,14 @@
ServiceImproperlyConfiguredDispatchException,
)
from baserow.core.services.handler import ServiceHandler
from baserow.core.services.mixins import ServiceBackedTypeMixin
from baserow.core.services.registries import ServiceTypeSubClass, service_type_registry
from baserow.core.services.types import DispatchResult
from baserow.core.trash.registries import TrashOperationType


class AutomationNodeType(
ServiceBackedTypeMixin,
PublicCustomFieldsInstanceMixin,
InstanceWithFormulaMixin,
EasyImportExportMixin,
Expand All @@ -48,6 +50,7 @@ class AutomationNodeType(
display_name = _("Unnamed node")

service_type = None
service_field_help_text = "The service associated with this automation node."
parent_property_name = "workflow"
id_mapping_name = "automation_workflow_nodes"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
ERROR_SERVICE_IMPROPERLY_CONFIGURED,
ERROR_SERVICE_INVALID_DISPATCH_CONTEXT,
ERROR_SERVICE_INVALID_DISPATCH_CONTEXT_CONTENT,
ERROR_SERVICE_INVALID_TYPE,
ERROR_SERVICE_UNEXPECTED_DISPATCH_ERROR,
)
from baserow.api.user_sources.authentication import (
Expand Down Expand Up @@ -82,6 +83,7 @@
InvalidContextContentDispatchException,
InvalidContextDispatchException,
ServiceImproperlyConfiguredDispatchException,
ServiceTypeDoesNotExist,
UnexpectedDispatchException,
)
from baserow.core.workflow_actions.exceptions import WorkflowActionDoesNotExist
Expand Down Expand Up @@ -123,6 +125,7 @@ def get_permissions(self):
[
"ERROR_REQUEST_BODY_VALIDATION",
"ERROR_INVALID_WORKFLOW_ACTION_EVENT",
"ERROR_SERVICE_INVALID_TYPE",
]
),
404: get_error_schema(["ERROR_PAGE_DOES_NOT_EXIST"]),
Expand All @@ -134,6 +137,7 @@ def get_permissions(self):
PageDoesNotExist: ERROR_PAGE_DOES_NOT_EXIST,
ElementDoesNotExist: ERROR_ELEMENT_DOES_NOT_EXIST,
InvalidWorkflowActionEvent: ERROR_INVALID_WORKFLOW_ACTION_EVENT,
ServiceTypeDoesNotExist: ERROR_SERVICE_INVALID_TYPE,
}
)
@validate_body_custom_fields(
Expand Down Expand Up @@ -271,6 +275,7 @@ def delete(self, request, workflow_action_id: int):
[
"ERROR_REQUEST_BODY_VALIDATION",
"ERROR_INVALID_WORKFLOW_ACTION_EVENT",
"ERROR_SERVICE_INVALID_TYPE",
]
),
404: get_error_schema(
Expand All @@ -285,6 +290,7 @@ def delete(self, request, workflow_action_id: int):
{
WorkflowActionDoesNotExist: ERROR_WORKFLOW_ACTION_DOES_NOT_EXIST,
InvalidWorkflowActionEvent: ERROR_INVALID_WORKFLOW_ACTION_EVENT,
ServiceTypeDoesNotExist: ERROR_SERVICE_INVALID_TYPE,
}
)
@require_request_data_type(dict)
Expand Down
17 changes: 15 additions & 2 deletions backend/src/baserow/contrib/builder/elements/element_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -315,9 +315,22 @@ def validate_position_as_child(
self, place_in_container: str, instance: ColumnElement
):
max_place_in_container = instance.column_amount - 1
if int(place_in_container) > max_place_in_container:
try:
place_in_container_casted = int(place_in_container)
except (TypeError, ValueError) as exc:
raise DRFValidationError(
f"place_in_container must be an integer between 0 and "
f"{max_place_in_container}, ({place_in_container!r} was given)"
) from exc
if place_in_container_casted < 0:
raise DRFValidationError(
f"place_in_container must be at least 0, "
f"({place_in_container} was given)"
)
if place_in_container_casted > max_place_in_container:
raise DRFValidationError(
f"place_in_container can at most be {max_place_in_container}, ({place_in_container}, was given)"
f"place_in_container can at most be {max_place_in_container}, "
f"({place_in_container}, was given)"
)

@property
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
from rest_framework import serializers

from baserow.api.services.serializers import (
PolymorphicServiceRequestSerializer,
PolymorphicServiceSerializer,
PublicPolymorphicServiceSerializer,
)
Expand Down Expand Up @@ -59,6 +58,7 @@
from baserow.core.integrations.models import Integration
from baserow.core.registry import Instance
from baserow.core.services.handler import ServiceHandler
from baserow.core.services.mixins import ServiceBackedTypeMixin
from baserow.core.services.models import Service
from baserow.core.services.registries import service_type_registry
from baserow.core.services.types import DispatchResult
Expand Down Expand Up @@ -244,16 +244,14 @@ def deserialize_property(
)


class BuilderWorkflowServiceActionType(BuilderWorkflowActionType):
class BuilderWorkflowServiceActionType(
ServiceBackedTypeMixin, BuilderWorkflowActionType
):
service_type = None # Must be implemented by subclasses.
service_field_help_text = (
"The service which this workflow action is associated with."
)
serializer_field_names = ["service"]
request_serializer_field_overrides = {
"service": PolymorphicServiceRequestSerializer(
default=None,
required=False,
help_text="The service which this workflow action is associated with.",
)
}
is_server_workflow = True
serializer_field_overrides = {
"service": PolymorphicServiceSerializer(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from baserow.api.errors import ERROR_USER_NOT_IN_GROUP
from baserow.api.exceptions import ThrottledAPIException
from baserow.api.schemas import CLIENT_SESSION_ID_SCHEMA_PARAMETER, get_error_schema
from baserow.api.services.errors import ERROR_SERVICE_INVALID_TYPE
from baserow.api.utils import (
CustomFieldRegistryMappingSerializer,
DiscriminatorCustomFieldsMappingSerializer,
Expand Down Expand Up @@ -70,6 +71,7 @@
)
from baserow.core.exceptions import UserNotInWorkspace
from baserow.core.feature_flags import FF_BUTTON_FIELD, feature_flag_is_enabled
from baserow.core.services.exceptions import ServiceTypeDoesNotExist
from baserow.core.workflow_actions.exceptions import WorkflowActionDoesNotExist


Expand Down Expand Up @@ -105,6 +107,7 @@ class DatabaseWorkflowActionsView(APIView):
"ERROR_REQUEST_BODY_VALIDATION",
"ERROR_USER_NOT_IN_GROUP",
"ERROR_WORKFLOW_ACTION_INVALID_INTEGRATION",
"ERROR_SERVICE_INVALID_TYPE",
]
),
403: get_error_schema(
Expand All @@ -120,6 +123,7 @@ class DatabaseWorkflowActionsView(APIView):
UserNotInWorkspace: ERROR_USER_NOT_IN_GROUP,
WorkflowActionTypeDeactivated: ERROR_WORKFLOW_ACTION_TYPE_DEACTIVATED,
WorkflowActionInvalidIntegration: ERROR_WORKFLOW_ACTION_INVALID_INTEGRATION,
ServiceTypeDoesNotExist: ERROR_SERVICE_INVALID_TYPE,
}
)
@validate_body_custom_fields(
Expand Down Expand Up @@ -277,6 +281,7 @@ def delete(self, request, workflow_action_id: int):
"ERROR_REQUEST_BODY_VALIDATION",
"ERROR_USER_NOT_IN_GROUP",
"ERROR_WORKFLOW_ACTION_INVALID_INTEGRATION",
"ERROR_SERVICE_INVALID_TYPE",
]
),
403: get_error_schema(
Expand All @@ -296,6 +301,7 @@ def delete(self, request, workflow_action_id: int):
UserNotInWorkspace: ERROR_USER_NOT_IN_GROUP,
WorkflowActionTypeDeactivated: ERROR_WORKFLOW_ACTION_TYPE_DEACTIVATED,
WorkflowActionInvalidIntegration: ERROR_WORKFLOW_ACTION_INVALID_INTEGRATION,
ServiceTypeDoesNotExist: ERROR_SERVICE_INVALID_TYPE,
}
)
@require_request_data_type(dict)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,6 @@
from django.core.files.storage import Storage
from django.db.models import Manager, Prefetch, QuerySet

from rest_framework import serializers
from rest_framework.exceptions import ErrorDetail
from rest_framework.fields import empty

from baserow.api.services.serializers import PolymorphicServiceRequestSerializer
from baserow.contrib.database.api.workflow_actions.serializers import (
DatabasePolymorphicServiceSerializer,
)
Expand Down Expand Up @@ -53,6 +48,7 @@
ServiceImproperlyConfiguredDispatchException,
)
from baserow.core.services.handler import ServiceHandler
from baserow.core.services.mixins import ServiceBackedTypeMixin
from baserow.core.services.models import Service
from baserow.core.services.registries import service_type_registry
from baserow.core.services.types import DispatchResult
Expand All @@ -64,46 +60,13 @@
)


class DefaultTypedServiceRequestSerializer(PolymorphicServiceRequestSerializer):
"""
A service request serializer that names the service type itself when the
caller leaves it out, so an action can be created already configured.

The action type decides which service backs it, and the editor knows that
service under a name of its own, so it has no way to supply this one.
"""

def __init__(self, *args, service_type_name: str = None, **kwargs):
self.service_type_name = service_type_name
super().__init__(*args, **kwargs)

def run_validation(self, data=empty) -> Any:
if isinstance(data, dict):
supplied_type = data.get("type")
if not supplied_type:
data = {**data, "type": self.service_type_name}
elif supplied_type != self.service_type_name:
# The action type already fixes which service backs it, so a
# different type here only picks the serializer, and whatever
# it accepted is then dropped without a word. Refused rather
# than corrected, so the caller hears about it.
raise serializers.ValidationError(
{
"type": [
ErrorDetail(
f"This action is always backed by a "
f"'{self.service_type_name}' service, so "
f"'{supplied_type}' cannot be used here.",
code="invalid",
)
]
}
)
return super().run_validation(data)


class DatabaseWorkflowServiceActionType(DatabaseWorkflowActionType):
class DatabaseWorkflowServiceActionType(
ServiceBackedTypeMixin, DatabaseWorkflowActionType
):
service_type = None # Must be implemented by subclasses.
service_field_help_text = (
"The service which this workflow action is associated with."
)

# Where `import_serialized` leaves the field it is importing into, for
# `deserialize_property`, which the base class hands the cache but not the
Expand All @@ -126,24 +89,6 @@ class DatabaseWorkflowServiceActionType(DatabaseWorkflowActionType):
class SerializedDict(DatabaseWorkflowActionDict):
service: Dict

def get_field_overrides(
self, request_serializer: bool, extra_params: Dict, **kwargs
) -> Dict:
# Built per type rather than declared, so the serializer can fall back
# to the service type this action carries.
if request_serializer:
return {
"service": DefaultTypedServiceRequestSerializer(
service_type_name=self.service_type,
default=None,
required=False,
help_text="The service which this workflow action is "
"associated with.",
)
}

return super().get_field_overrides(request_serializer, extra_params, **kwargs)

@property
def allowed_fields(self) -> List[str]:
return super().allowed_fields + ["service"]
Expand Down
Loading
Loading