diff --git a/backend/src/baserow/contrib/automation/apps.py b/backend/src/baserow/contrib/automation/apps.py index 590e980f7a..58e56543ff 100644 --- a/backend/src/baserow/contrib/automation/apps.py +++ b/backend/src/baserow/contrib/automation/apps.py @@ -36,6 +36,7 @@ def ready(self): LocalBaserowRowsDeletedNodeTriggerType, LocalBaserowRowsUpdatedNodeTriggerType, LocalBaserowUpdateRowNodeType, + SlackWriteMessageActionNodeType, ) from baserow.contrib.automation.nodes.object_scopes import ( AutomationNodeObjectScopeType, @@ -167,6 +168,7 @@ def ready(self): LocalBaserowRowsDeletedNodeTriggerType() ) automation_node_type_registry.register(CorePeriodicTriggerNodeType()) + automation_node_type_registry.register(SlackWriteMessageActionNodeType()) automation_node_type_registry.register(CoreHTTPTriggerNodeType()) automation_node_type_registry.register(AIAgentActionNodeType()) diff --git a/backend/src/baserow/contrib/automation/migrations/0023_slack_write_message_node.py b/backend/src/baserow/contrib/automation/migrations/0023_slack_write_message_node.py new file mode 100644 index 0000000000..5cc283ecac --- /dev/null +++ b/backend/src/baserow/contrib/automation/migrations/0023_slack_write_message_node.py @@ -0,0 +1,36 @@ +# Generated by Django 5.0.14 on 2025-11-04 11:15 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ( + "automation", + "0022_aiagentactionnode", + ), + ] + + operations = [ + migrations.CreateModel( + name="SlackWriteMessageActionNode", + fields=[ + ( + "automationnode_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="automation.automationnode", + ), + ), + ], + options={ + "abstract": False, + }, + bases=("automation.automationnode",), + ), + ] diff --git a/backend/src/baserow/contrib/automation/nodes/handler.py b/backend/src/baserow/contrib/automation/nodes/handler.py index d5914059dc..ffceb92342 100644 --- a/backend/src/baserow/contrib/automation/nodes/handler.py +++ b/backend/src/baserow/contrib/automation/nodes/handler.py @@ -410,5 +410,5 @@ def dispatch_node( ) except ServiceImproperlyConfiguredDispatchException as e: raise AutomationNodeMisconfiguredService( - f"The node {node.id} has a misconfigured service." + f"The node {node.id} is misconfigured and cannot be dispatched. {str(e)}" ) from e diff --git a/backend/src/baserow/contrib/automation/nodes/models.py b/backend/src/baserow/contrib/automation/nodes/models.py index 70c48df569..1e80bc5d10 100644 --- a/backend/src/baserow/contrib/automation/nodes/models.py +++ b/backend/src/baserow/contrib/automation/nodes/models.py @@ -228,3 +228,7 @@ class CoreIteratorActionNode(AutomationActionNode): class AIAgentActionNode(AutomationActionNode): ... + + +class SlackWriteMessageActionNode(AutomationActionNode): + ... diff --git a/backend/src/baserow/contrib/automation/nodes/node_types.py b/backend/src/baserow/contrib/automation/nodes/node_types.py index fb9ea88f3e..9dc510a522 100644 --- a/backend/src/baserow/contrib/automation/nodes/node_types.py +++ b/backend/src/baserow/contrib/automation/nodes/node_types.py @@ -34,6 +34,7 @@ LocalBaserowRowsDeletedTriggerNode, LocalBaserowRowsUpdatedTriggerNode, LocalBaserowUpdateRowActionNode, + SlackWriteMessageActionNode, ) from baserow.contrib.automation.nodes.registries import AutomationNodeType from baserow.contrib.automation.nodes.types import NodePositionType @@ -58,6 +59,9 @@ LocalBaserowRowsUpdatedServiceType, LocalBaserowUpsertRowServiceType, ) +from baserow.contrib.integrations.slack.service_types import ( + SlackWriteMessageServiceType, +) from baserow.core.registry import Instance from baserow.core.services.models import Service from baserow.core.services.registries import service_type_registry @@ -404,3 +408,9 @@ class CoreHTTPTriggerNodeType(AutomationNodeTriggerType): type = "http_trigger" model_class = CoreHTTPTriggerNode service_type = CoreHTTPTriggerServiceType.type + + +class SlackWriteMessageActionNodeType(AutomationNodeActionNodeType): + type = "slack_write_message" + model_class = SlackWriteMessageActionNode + service_type = SlackWriteMessageServiceType.type diff --git a/backend/src/baserow/contrib/integrations/apps.py b/backend/src/baserow/contrib/integrations/apps.py index 0cfec6cc01..9d07b16d77 100644 --- a/backend/src/baserow/contrib/integrations/apps.py +++ b/backend/src/baserow/contrib/integrations/apps.py @@ -12,12 +12,16 @@ def ready(self): from baserow.contrib.integrations.local_baserow.integration_types import ( LocalBaserowIntegrationType, ) + from baserow.contrib.integrations.slack.integration_types import ( + SlackBotIntegrationType, + ) from baserow.core.integrations.registries import integration_type_registry from baserow.core.services.registries import service_type_registry integration_type_registry.register(LocalBaserowIntegrationType()) integration_type_registry.register(SMTPIntegrationType()) integration_type_registry.register(AIIntegrationType()) + integration_type_registry.register(SlackBotIntegrationType()) from baserow.contrib.integrations.local_baserow.service_types import ( LocalBaserowAggregateRowsUserServiceType, @@ -39,6 +43,12 @@ def ready(self): service_type_registry.register(LocalBaserowRowsUpdatedServiceType()) service_type_registry.register(LocalBaserowRowsDeletedServiceType()) + from baserow.contrib.integrations.slack.service_types import ( + SlackWriteMessageServiceType, + ) + + service_type_registry.register(SlackWriteMessageServiceType()) + from baserow.contrib.integrations.core.service_types import ( CoreHTTPRequestServiceType, CoreHTTPTriggerServiceType, diff --git a/backend/src/baserow/contrib/integrations/core/integration_types.py b/backend/src/baserow/contrib/integrations/core/integration_types.py index e59ae78b3c..a3c468dd70 100644 --- a/backend/src/baserow/contrib/integrations/core/integration_types.py +++ b/backend/src/baserow/contrib/integrations/core/integration_types.py @@ -1,8 +1,7 @@ +from baserow.contrib.integrations.core.models import SMTPIntegration from baserow.core.integrations.registries import IntegrationType from baserow.core.integrations.types import IntegrationDict -from .models import SMTPIntegration - class SMTPIntegrationType(IntegrationType): type = "smtp" diff --git a/backend/src/baserow/contrib/integrations/core/service_types.py b/backend/src/baserow/contrib/integrations/core/service_types.py index 656601d10d..c70af2bd99 100644 --- a/backend/src/baserow/contrib/integrations/core/service_types.py +++ b/backend/src/baserow/contrib/integrations/core/service_types.py @@ -54,6 +54,7 @@ HTTPHeader, HTTPQueryParam, ) +from baserow.contrib.integrations.utils import get_http_request_function from baserow.core.formula.types import BaserowFormulaObject from baserow.core.formula.validator import ( ensure_array, @@ -535,24 +536,6 @@ def formulas_to_resolve( return formulas - def _get_request_function(self) -> callable: - """ - Return the appropriate request function based on production environment - or settings. - In production mode, the advocate library is used so that the internal - network can't be reached. This can be disabled by changing the Django - setting INTEGRATIONS_ALLOW_PRIVATE_ADDRESS. - """ - - if settings.INTEGRATIONS_ALLOW_PRIVATE_ADDRESS is True: - from requests import request - - return request - else: - from advocate import request - - return request - def dispatch_data( self, service: CoreHTTPRequestService, @@ -589,7 +572,7 @@ def dispatch_data( } try: - response = self._get_request_function()( + response = get_http_request_function()( method=service.http_method, url=resolved_values["url"], headers=headers, diff --git a/backend/src/baserow/contrib/integrations/migrations/0024_slack_integration_write_message_service.py b/backend/src/baserow/contrib/integrations/migrations/0024_slack_integration_write_message_service.py new file mode 100644 index 0000000000..c0933dfd66 --- /dev/null +++ b/backend/src/baserow/contrib/integrations/migrations/0024_slack_integration_write_message_service.py @@ -0,0 +1,79 @@ +# Generated by Django 5.0.14 on 2025-11-04 11:20 + +import django.db.models.deletion +from django.db import migrations, models + +import baserow.core.formula.field + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0106_schemaoperation"), + ("integrations", "0023_aiagentservice_aiintegration"), + ] + + operations = [ + migrations.CreateModel( + name="SlackBotIntegration", + fields=[ + ( + "integration_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="core.integration", + ), + ), + ( + "token", + models.CharField( + help_text="The Bot User OAuth Token listed in your Slack bot's OAuth & Permissions page.", + max_length=255, + ), + ), + ], + options={ + "abstract": False, + }, + bases=("core.integration",), + ), + migrations.CreateModel( + name="SlackWriteMessageService", + fields=[ + ( + "service_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="core.service", + ), + ), + ( + "channel", + models.CharField( + help_text="The Slack channel ID where the message will be sent.", + max_length=80, + ), + ), + ( + "text", + baserow.core.formula.field.FormulaField( + blank=True, + default="", + help_text="The text content of the Slack message.", + null=True, + ), + ), + ], + options={ + "abstract": False, + }, + bases=("core.service",), + ), + ] diff --git a/backend/src/baserow/contrib/integrations/slack/__init__.py b/backend/src/baserow/contrib/integrations/slack/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/src/baserow/contrib/integrations/slack/integration_types.py b/backend/src/baserow/contrib/integrations/slack/integration_types.py new file mode 100644 index 0000000000..672b843d51 --- /dev/null +++ b/backend/src/baserow/contrib/integrations/slack/integration_types.py @@ -0,0 +1,47 @@ +from typing import Any, Dict + +from baserow.contrib.integrations.slack.models import SlackBotIntegration +from baserow.core.integrations.registries import IntegrationType +from baserow.core.integrations.types import IntegrationDict +from baserow.core.models import Application + + +class SlackBotIntegrationType(IntegrationType): + type = "slack_bot" + model_class = SlackBotIntegration + + class SerializedDict(IntegrationDict): + token: str + + serializer_field_names = ["token"] + allowed_fields = ["token"] + sensitive_fields = ["token"] + + request_serializer_field_names = ["token"] + request_serializer_field_overrides = {} + + def import_serialized( + self, + application: Application, + serialized_values: Dict[str, Any], + id_mapping: Dict, + files_zip=None, + storage=None, + cache=None, + ) -> SlackBotIntegration: + """ + Imports a serialized integration. Ensures that if we're importing an exported + integration (where `token` will be `None`), we set it to an empty string. + """ + + if serialized_values["token"] is None: + serialized_values["token"] = "" # nosec B105 + + return super().import_serialized( + application, + serialized_values, + id_mapping, + files_zip=files_zip, + storage=storage, + cache=cache, + ) diff --git a/backend/src/baserow/contrib/integrations/slack/models.py b/backend/src/baserow/contrib/integrations/slack/models.py new file mode 100644 index 0000000000..6875b1773d --- /dev/null +++ b/backend/src/baserow/contrib/integrations/slack/models.py @@ -0,0 +1,23 @@ +from django.db import models + +from baserow.core.formula.field import FormulaField +from baserow.core.integrations.models import Integration +from baserow.core.services.models import Service + + +class SlackBotIntegration(Integration): + token = models.CharField( + max_length=255, + help_text="The Bot User OAuth Token listed in " + "your Slack bot's OAuth & Permissions page.", + ) + + +class SlackWriteMessageService(Service): + channel = models.CharField( + max_length=80, + help_text="The Slack channel ID where the message will be sent.", + ) + text = FormulaField( + help_text="The text content of the Slack message.", + ) diff --git a/backend/src/baserow/contrib/integrations/slack/service_types.py b/backend/src/baserow/contrib/integrations/slack/service_types.py new file mode 100644 index 0000000000..a2a363661a --- /dev/null +++ b/backend/src/baserow/contrib/integrations/slack/service_types.py @@ -0,0 +1,167 @@ +from typing import Any, Dict, List, Optional + +from django.utils.translation import gettext as _ + +from loguru import logger +from requests import exceptions as request_exceptions +from rest_framework import serializers + +from baserow.contrib.integrations.slack.integration_types import SlackBotIntegrationType +from baserow.contrib.integrations.slack.models import SlackWriteMessageService +from baserow.contrib.integrations.utils import get_http_request_function +from baserow.core.formula import BaserowFormulaObject +from baserow.core.formula.validator import ensure_string +from baserow.core.services.dispatch_context import DispatchContext +from baserow.core.services.exceptions import ( + ServiceImproperlyConfiguredDispatchException, + UnexpectedDispatchException, +) +from baserow.core.services.registries import DispatchTypes, ServiceType +from baserow.core.services.types import DispatchResult, FormulaToResolve, ServiceDict + + +class SlackWriteMessageServiceType(ServiceType): + type = "slack_write_message" + model_class = SlackWriteMessageService + dispatch_types = [DispatchTypes.ACTION] + integration_type = SlackBotIntegrationType.type + + allowed_fields = ["integration_id", "channel", "text"] + serializer_field_names = ["integration_id", "channel", "text"] + simple_formula_fields = ["text"] + + class SerializedDict(ServiceDict): + channel: str + text: BaserowFormulaObject + + @property + def serializer_field_overrides(self): + from baserow.core.formula.serializers import FormulaSerializerField + + return { + "integration_id": serializers.IntegerField( + required=False, + allow_null=True, + help_text="The id of the Slack bot integration.", + ), + "channel": serializers.CharField( + help_text=SlackWriteMessageService._meta.get_field("channel").help_text, + allow_blank=True, + required=False, + default="", + ), + "text": FormulaSerializerField( + help_text=SlackWriteMessageService._meta.get_field("text").help_text + ), + } + + def formulas_to_resolve( + self, service: SlackWriteMessageService + ) -> list[FormulaToResolve]: + return [ + FormulaToResolve( + "text", + service.text, + ensure_string, + 'property "text"', + ), + ] + + def dispatch_data( + self, + service: SlackWriteMessageService, + resolved_values: Dict[str, Any], + dispatch_context: DispatchContext, + ) -> Dict[str, Dict[str, Any]]: + """ + Dispatches the Slack write message service by sending a message to the + specified Slack channel using the Slack API. + + :param service: The SlackWriteMessageService instance to be dispatched. + :param resolved_values: A dictionary containing the resolved values for the + service's fields, including the message text. + :param dispatch_context: The context in which the dispatch is occurring. + :return: A dictionary containing the response data from the Slack API. + :raises UnexpectedDispatchException: If there's an error after the HTTP request. + :raises ServiceImproperlyConfiguredDispatchException: If the Slack service is + improperly configured, indicated by specific error codes from the Slack API. + """ + + try: + token = service.integration.specific.token + response = get_http_request_function()( + method="POST", + url="https://slack.com/api/chat.postMessage", + headers={"Authorization": f"Bearer {token}"}, + params={ + "channel": f"#{service.channel}", + "text": resolved_values["text"], + }, + timeout=10, + ) + response_data = response.json() + except request_exceptions.RequestException as e: + raise UnexpectedDispatchException(str(e)) from e + except Exception as e: + logger.exception("Error while dispatching HTTP request") + raise UnexpectedDispatchException(f"Unknown error: {str(e)}") from e + + # If we've found that the response indicates an error, we raise a + # ServiceImproperlyConfiguredDispatchException with a relevant message. + if not response_data.get("ok", False): + # Some frequently occurring error codes from Slack API. Full list: + # https://docs.slack.dev/reference/methods/chat.postMessage/ + misconfigured_service_error_codes = { + "no_text": "The message text is missing.", + "invalid_auth": "Invalid bot user token.", + "channel_not_found": "The channel #{channel} was not found.", + "not_in_channel": "Your app has not been invited to channel #{channel}.", + "rate_limited": "Your app has sent too many requests in a " + "short period of time.", + "default": "An unknown error occurred while sending the message, " + "the error code was: {error_code}", + } + error_code = response_data["error"] + misconfigured_service_message = misconfigured_service_error_codes.get( + error_code, misconfigured_service_error_codes["default"] + ).format(channel=service.channel, error_code=error_code) + raise ServiceImproperlyConfiguredDispatchException( + misconfigured_service_message + ) + return {"data": response_data} + + def dispatch_transform(self, data): + return DispatchResult(data=data) + + def get_schema_name(self, service: SlackWriteMessageService) -> str: + return f"SlackWriteMessage{service.id}Schema" + + def generate_schema( + self, + service: SlackWriteMessageService, + allowed_fields: Optional[List[str]] = None, + ) -> Optional[Dict[str, Any]]: + """ + Generates a JSON schema for the Slack write message service. + + :param service: The SlackWriteMessageService instance for which to generate the + schema. + :param allowed_fields: An optional list of fields to include in the schema. + :return: A dictionary representing the JSON schema of the service. + """ + + properties = {} + if allowed_fields is None or "ok" in allowed_fields: + properties.update( + **{ + "ok": { + "type": "boolean", + "title": _("OK"), + }, + } + ) + return { + "title": self.get_schema_name(service), + "type": "object", + "properties": properties, + } diff --git a/backend/src/baserow/contrib/integrations/utils.py b/backend/src/baserow/contrib/integrations/utils.py new file mode 100644 index 0000000000..6a05f74a64 --- /dev/null +++ b/backend/src/baserow/contrib/integrations/utils.py @@ -0,0 +1,21 @@ +from typing import Callable + +from django.conf import settings + +import advocate +import requests + + +def get_http_request_function() -> Callable: + """ + Return the appropriate request function based on production environment + or settings. + In production mode, the advocate library is used so that the internal + network can't be reached. This can be disabled by changing the Django + setting INTEGRATIONS_ALLOW_PRIVATE_ADDRESS. + """ + + if settings.INTEGRATIONS_ALLOW_PRIVATE_ADDRESS is True: + return requests.request + else: + return advocate.request diff --git a/backend/src/baserow/core/formula/argument_types.py b/backend/src/baserow/core/formula/argument_types.py index 49624da264..f1efd6fb72 100644 --- a/backend/src/baserow/core/formula/argument_types.py +++ b/backend/src/baserow/core/formula/argument_types.py @@ -27,6 +27,7 @@ def parse(self, value): class NumberBaserowRuntimeFormulaArgumentType(BaserowRuntimeFormulaArgumentType): def __init__(self, *args, **kwargs): self.cast_to_int = kwargs.pop("cast_to_int", False) + self.cast_to_float = kwargs.pop("cast_to_float", False) super().__init__(*args, **kwargs) def test(self, value): @@ -38,7 +39,12 @@ def test(self, value): def parse(self, value): value = ensure_numeric(value) - return int(value) if self.cast_to_int else value + if self.cast_to_int: + return int(value) + elif self.cast_to_float: + return float(value) + else: + return value class TextBaserowRuntimeFormulaArgumentType(BaserowRuntimeFormulaArgumentType): @@ -79,7 +85,11 @@ def parse(self, value): class BooleanBaserowRuntimeFormulaArgumentType(BaserowRuntimeFormulaArgumentType): def test(self, value): - return isinstance(value, bool) + try: + ensure_boolean(value) + return True + except ValidationError: + return False def parse(self, value): return ensure_boolean(value) diff --git a/backend/src/baserow/core/formula/runtime_formula_types.py b/backend/src/baserow/core/formula/runtime_formula_types.py index c80a08e30d..033d7a44c4 100644 --- a/backend/src/baserow/core/formula/runtime_formula_types.py +++ b/backend/src/baserow/core/formula/runtime_formula_types.py @@ -345,24 +345,24 @@ class RuntimeRandomInt(RuntimeFormulaFunction): type = "random_int" args = [ - NumberBaserowRuntimeFormulaArgumentType(), - NumberBaserowRuntimeFormulaArgumentType(), + NumberBaserowRuntimeFormulaArgumentType(cast_to_int=True), + NumberBaserowRuntimeFormulaArgumentType(cast_to_int=True), ] def execute(self, context: FormulaContext, args: FormulaArgs): - return random.randint(int(args[0]), int(args[1])) # nosec: B311 + return random.randint(args[0], args[1]) # nosec: B311 class RuntimeRandomFloat(RuntimeFormulaFunction): type = "random_float" args = [ - NumberBaserowRuntimeFormulaArgumentType(), - NumberBaserowRuntimeFormulaArgumentType(), + NumberBaserowRuntimeFormulaArgumentType(cast_to_float=True), + NumberBaserowRuntimeFormulaArgumentType(cast_to_float=True), ] def execute(self, context: FormulaContext, args: FormulaArgs): - return random.uniform(float(args[0]), float(args[1])) # nosec: B311 + return random.uniform(args[0], args[1]) # nosec: B311 class RuntimeRandomBool(RuntimeFormulaFunction): diff --git a/backend/src/baserow/test_utils/helpers.py b/backend/src/baserow/test_utils/helpers.py index 19ff361ee7..e80ad1d112 100644 --- a/backend/src/baserow/test_utils/helpers.py +++ b/backend/src/baserow/test_utils/helpers.py @@ -624,6 +624,20 @@ def __eq__(self, other): return isinstance(other, int) +class AnyFloat(float): + """A class that can be used to check if a value is a float.""" + + def __eq__(self, other): + return isinstance(other, float) + + +class AnyBool: + """A class that can be used to check if a value is a boolean.""" + + def __eq__(self, other): + return isinstance(other, bool) + + class AnyStr(str): """ A class that can be used to check if a value is an str. Useful in tests when diff --git a/backend/tests/baserow/contrib/builder/test_runtime_formula_results.py b/backend/tests/baserow/contrib/builder/test_runtime_formula_results.py new file mode 100644 index 0000000000..81784aa016 --- /dev/null +++ b/backend/tests/baserow/contrib/builder/test_runtime_formula_results.py @@ -0,0 +1,580 @@ +from django.http import HttpRequest + +import pytest + +from baserow.contrib.builder.data_sources.builder_dispatch_context import ( + BuilderDispatchContext, +) +from baserow.contrib.database.fields.handler import FieldHandler +from baserow.contrib.database.rows.handler import RowHandler +from baserow.core.formula import BaserowFormulaObject, resolve_formula +from baserow.core.formula.registries import formula_runtime_function_registry + +ROWS = [ + [ + "Cherry", # fruit + "Cherry", # fruit duplicate + "Strawberry", # fruit alternate + 9.5, # rating + 3.5, # weight + 3.5, # weight duplicate + 4.98, # weight alternate + "2025-11-12T21:22:23Z", # date + "2025-11-12T21:22:23Z", # date duplicate + "2025-11-13T19:15:56Z", # date alternate + True, # tasty + True, # tasty duplicate + False, # tasty alternate + 4, # even number + 3, # odd number + "DD-MMM-YYYY HH:mm:ss", # Date format + ], + [ + "Durian", + "Durian", + "Banana", + 2, + 8, + 8, + 84.597, + "2025-11-13T06:11:59Z", + "2025-11-13T06:11:59Z", + "2025-11-14T14:09:42Z", + False, + False, + True, + 6, + 7, + "HH:mm:ss", # Time format + ], +] + +TEST_CASES_STRINGS = [ + # formula type, data source type, list rows ID, expected + ("upper", "list_rows", 0, "CHERRY,DURIAN"), + ("upper", "list_rows_item", 0, "CHERRY"), + ("upper", "get_row", None, "CHERRY"), + ("lower", "list_rows", 0, "cherry,durian"), + ("lower", "list_rows_item", 0, "cherry"), + ("lower", "get_row", None, "cherry"), + ("capitalize", "list_rows", 0, "Cherry,durian"), + ("capitalize", "list_rows_item", 0, "Cherry"), + ("capitalize", "get_row", None, "Cherry"), +] + +TEST_CASES_ARITHMETIC = [ + # operator, expected + ("+", 13), + ("-", 6.0), + ("*", 33.25), + ("/", 2.7142857142857144), +] + +# date formulas operate on "2025-11-12T21:22:23Z" +TEST_CASES_DATE = [ + ("day", 12), + ("month", 11), + ("year", 2025), + ("hour", 21), + ("minute", 22), + ("second", 23), +] + +TEST_CASES_COMAPRISON = [ + # Text + ("equal", 0, 1, True), + ("equal", 0, 2, False), + ("not_equal", 0, 1, False), + ("not_equal", 0, 2, True), + # Number + ("equal", 4, 5, True), + ("equal", 4, 6, False), + ("not_equal", 4, 5, False), + ("not_equal", 4, 6, True), + # Date + ("equal", 7, 8, True), + ("equal", 7, 9, False), + ("not_equal", 7, 8, False), + ("not_equal", 7, 9, True), + # Boolean + ("equal", 10, 11, True), + ("equal", 10, 12, False), + ("not_equal", 10, 11, False), + ("not_equal", 10, 12, True), +] + +TEST_CASES_BOOLEAN = [ + # formula_name, column, expected + ("is_even", 13, True), + ("is_even", 14, False), + ("is_odd", 13, False), + ("is_odd", 14, True), +] + +TEST_CASES_COMAPRISON_OPERATOR = [ + # Columns: 10 = True, 11 = True, 12 = False + ("&&", 10, 11, True), + ("&&", 10, 12, False), + ("||", 10, 11, True), + ("||", 10, 12, True), + ("||", 12, 12, False), + ("=", 10, 11, True), + ("=", 10, 12, False), + # Number columns: 3 = 9.5, 4 = 3.5 + (">", 3, 4, True), + (">", 4, 3, False), + (">=", 3, 4, True), + (">=", 4, 3, False), + ("<", 3, 4, False), + ("<", 4, 3, True), + ("<=", 3, 4, False), + ("<=", 4, 3, True), + # Text columns: 1 = Cherry, 2 = Strawberry + (">", 1, 2, False), + (">", 2, 1, True), + (">=", 1, 2, False), + (">=", 2, 1, True), + ("<", 1, 2, True), + ("<", 2, 1, False), + ("<=", 1, 2, True), + ("<=", 2, 1, False), +] + + +def create_test_context(data_fixture): + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + database = data_fixture.create_database_application(user=user, workspace=workspace) + table = data_fixture.create_database_table(database=database) + + field_handler = FieldHandler() + field_fruit = field_handler.create_field( + user=user, table=table, type_name="text", name="Fruit" + ) + field_fruit_duplicate = field_handler.create_field( + user=user, table=table, type_name="text", name="Fruit (duplicate)" + ) + field_fruit_alternate = field_handler.create_field( + user=user, table=table, type_name="text", name="Fruit (alternate)" + ) + field_rating = field_handler.create_field( + user=user, + table=table, + type_name="number", + name="Rating", + number_decimal_places=2, + ) + field_weight = field_handler.create_field( + user=user, + table=table, + type_name="number", + name="Weight KGs", + number_decimal_places=2, + ) + field_weight_duplicate = field_handler.create_field( + user=user, + table=table, + type_name="number", + name="Weight KGs (duplicate)", + number_decimal_places=2, + ) + field_weight_alternate = field_handler.create_field( + user=user, + table=table, + type_name="number", + name="Weight KGs (alternate)", + number_decimal_places=2, + ) + field_harvested = field_handler.create_field( + user=user, + table=table, + type_name="date", + name="Harvested", + date_include_time=True, + ) + field_harvested_duplicate = field_handler.create_field( + user=user, + table=table, + type_name="date", + name="Harvested (duplicate)", + date_include_time=True, + ) + field_harvested_alternate = field_handler.create_field( + user=user, + table=table, + type_name="date", + name="Harvested (alternate)", + date_include_time=True, + ) + field_tasty = field_handler.create_field( + user=user, table=table, type_name="boolean", name="Is Tasty" + ) + field_tasty_duplicate = field_handler.create_field( + user=user, table=table, type_name="boolean", name="Is Tasty (duplicate)" + ) + field_tasty_alternate = field_handler.create_field( + user=user, table=table, type_name="boolean", name="Is Tasty (alternate)" + ) + field_even = field_handler.create_field( + user=user, table=table, type_name="number", name="Even number" + ) + field_odd = field_handler.create_field( + user=user, table=table, type_name="number", name="Odd number" + ) + field_datetime_format = field_handler.create_field( + user=user, table=table, type_name="text", name="Datetime Format" + ) + + fields = [ + field_fruit, + field_fruit_duplicate, + field_fruit_alternate, + field_rating, + field_weight, + field_weight_duplicate, + field_weight_alternate, + field_harvested, + field_harvested_duplicate, + field_harvested_alternate, + field_tasty, + field_tasty_duplicate, + field_tasty_alternate, + field_even, + field_odd, + field_datetime_format, + ] + + row_handler = RowHandler() + rows = [ + row_handler.create_row( + user=user, + table=table, + values={ + fields[0].db_column: row[0], + fields[1].db_column: row[1], + fields[2].db_column: row[2], + fields[3].db_column: row[3], + fields[4].db_column: row[4], + fields[5].db_column: row[5], + fields[6].db_column: row[6], + fields[7].db_column: row[7], + fields[8].db_column: row[8], + fields[9].db_column: row[9], + fields[10].db_column: row[10], + fields[11].db_column: row[11], + fields[12].db_column: row[12], + fields[13].db_column: row[13], + fields[14].db_column: row[14], + fields[15].db_column: row[15], + }, + ) + for row in ROWS + ] + + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + integration = data_fixture.create_local_baserow_integration( + user=user, application=builder + ) + page = data_fixture.create_builder_page(builder=builder) + data_source_list_rows = ( + data_fixture.create_builder_local_baserow_list_rows_data_source( + page=page, integration=integration, table=table + ) + ) + data_source_get_row = data_fixture.create_builder_local_baserow_get_row_data_source( + page=page, integration=integration, table=table, row_id=rows[0].id + ) + + return { + "data_source_list_rows": data_source_list_rows, + "data_source_get_row": data_source_get_row, + "page": page, + "fields": fields, + } + + +@pytest.mark.django_db +def test_runtime_formula_if(data_fixture): + data = create_test_context(data_fixture) + data_source_get_row = data["data_source_get_row"] + page = data["page"] + fields = data["fields"] + + # Cherry + value_cherry = f"get('data_source.{data_source_get_row.id}.{fields[1].db_column}')" + # Strawberry + value_strawberry = ( + f"get('data_source.{data_source_get_row.id}.{fields[2].db_column}')" + ) + # True + value_true = f"get('data_source.{data_source_get_row.id}.{fields[11].db_column}')" + # False + value_false = f"get('data_source.{data_source_get_row.id}.{fields[12].db_column}')" + + fake_request = HttpRequest() + dispatch_context = BuilderDispatchContext( + fake_request, page, only_expose_public_allowed_properties=False + ) + + test_cases = [ + ( + f"if({value_true}, {value_cherry}, {value_strawberry})", + "Cherry", + ), + ( + f"if({value_false}, {value_cherry}, {value_strawberry})", + "Strawberry", + ), + ] + + for item in test_cases: + value, expected = item + formula = BaserowFormulaObject.create(value) + + result = resolve_formula( + formula, formula_runtime_function_registry, dispatch_context + ) + assert result == expected, ( + f"{value} expected to resolve to {expected} " f"but got {result}" + ) + + +@pytest.mark.django_db +def test_runtime_formula_get_property(data_fixture): + data = create_test_context(data_fixture) + data_source_get_row = data["data_source_get_row"] + page = data["page"] + fields = data["fields"] + + # Cherry + key = f"get('data_source.{data_source_get_row.id}.{fields[0].db_column}')" + object_str = '\'{"Cherry": "Dark Red"}\'' + value = f"get_property({object_str}, {key})" + formula = BaserowFormulaObject.create(value) + + fake_request = HttpRequest() + dispatch_context = BuilderDispatchContext( + fake_request, page, only_expose_public_allowed_properties=False + ) + + result = resolve_formula( + formula, formula_runtime_function_registry, dispatch_context + ) + expected = "Dark Red" + assert result == expected, ( + f"{value} expected to resolve to {expected} " f"but got {result}" + ) + + +@pytest.mark.django_db +def test_runtime_formula_datetime_format(data_fixture): + data = create_test_context(data_fixture) + data_source_get_row = data["data_source_get_row"] + page = data["page"] + fields = data["fields"] + + date_str = f"get('data_source.{data_source_get_row.id}.{fields[7].db_column}')" + date_format = f"get('data_source.{data_source_get_row.id}.{fields[15].db_column}')" + value = f"datetime_format({date_str}, {date_format})" + formula = BaserowFormulaObject.create(value) + + fake_request = HttpRequest() + dispatch_context = BuilderDispatchContext( + fake_request, page, only_expose_public_allowed_properties=False + ) + + result = resolve_formula( + formula, formula_runtime_function_registry, dispatch_context + ) + expected = "12-Nov-2025 21:22:23" + assert result == expected, ( + f"{value} expected to resolve to {expected} " f"but got {result}" + ) + + +@pytest.mark.django_db +def test_runtime_formula_comparison_operator(data_fixture): + data = create_test_context(data_fixture) + data_source_get_row = data["data_source_get_row"] + page = data["page"] + fields = data["fields"] + + for test_case in TEST_CASES_COMAPRISON_OPERATOR: + operator, field_a, field_b, expected = test_case + + value_a = ( + f"get('data_source.{data_source_get_row.id}.{fields[field_a].db_column}')" + ) + value_b = ( + f"get('data_source.{data_source_get_row.id}.{fields[field_b].db_column}')" + ) + + value = f"{value_a} {operator} {value_b}" + formula = BaserowFormulaObject.create(value) + + fake_request = HttpRequest() + dispatch_context = BuilderDispatchContext( + fake_request, page, only_expose_public_allowed_properties=False + ) + + result = resolve_formula( + formula, formula_runtime_function_registry, dispatch_context + ) + assert result == expected, ( + f"{value} expected to resolve to {expected} " f"but got {result}" + ) + + +@pytest.mark.django_db +def test_runtime_formula_comparison(data_fixture): + data = create_test_context(data_fixture) + data_source_get_row = data["data_source_get_row"] + page = data["page"] + fields = data["fields"] + + for test_case in TEST_CASES_COMAPRISON: + formula_name, field_a, field_b, expected = test_case + + value_a = ( + f"get('data_source.{data_source_get_row.id}.{fields[field_a].db_column}')" + ) + value_b = ( + f"get('data_source.{data_source_get_row.id}.{fields[field_b].db_column}')" + ) + + value = f"{formula_name}({value_a}, {value_b})" + formula = BaserowFormulaObject.create(value) + + fake_request = HttpRequest() + dispatch_context = BuilderDispatchContext( + fake_request, page, only_expose_public_allowed_properties=False + ) + + result = resolve_formula( + formula, formula_runtime_function_registry, dispatch_context + ) + assert result == expected, ( + f"{value} expected to resolve to {expected} " f"but got {result}" + ) + + +@pytest.mark.django_db +def test_runtime_formula_boolean(data_fixture): + data = create_test_context(data_fixture) + data_source_get_row = data["data_source_get_row"] + page = data["page"] + fields = data["fields"] + + for test_case in TEST_CASES_BOOLEAN: + formula_name, field_id, expected = test_case + + value = ( + f"get('data_source.{data_source_get_row.id}.{fields[field_id].db_column}')" + ) + + value = f"{formula_name}({value})" + formula = BaserowFormulaObject.create(value) + + fake_request = HttpRequest() + dispatch_context = BuilderDispatchContext( + fake_request, page, only_expose_public_allowed_properties=False + ) + + result = resolve_formula( + formula, formula_runtime_function_registry, dispatch_context + ) + assert result == expected, ( + f"{value} expected to resolve to {expected} " f"but got {result}" + ) + + +@pytest.mark.django_db +def test_runtime_formula_date(data_fixture): + data = create_test_context(data_fixture) + data_source_get_row = data["data_source_get_row"] + page = data["page"] + fields = data["fields"] + + for test_case in TEST_CASES_DATE: + formula_name, expected = test_case + + value = f"get('data_source.{data_source_get_row.id}.{fields[7].db_column}')" + + value = f"{formula_name}({value})" + formula = BaserowFormulaObject.create(value) + + fake_request = HttpRequest() + dispatch_context = BuilderDispatchContext( + fake_request, page, only_expose_public_allowed_properties=False + ) + + result = resolve_formula( + formula, formula_runtime_function_registry, dispatch_context + ) + assert result == expected, ( + f"{value} expected to resolve to {expected} " f"but got {result}" + ) + + +@pytest.mark.django_db +def test_runtime_formula_arithmetic(data_fixture): + data = create_test_context(data_fixture) + data_source_get_row = data["data_source_get_row"] + page = data["page"] + fields = data["fields"] + + for test_case in TEST_CASES_ARITHMETIC: + operator, expected = test_case + + value_1 = f"get('data_source.{data_source_get_row.id}.{fields[3].db_column}')" + value_2 = f"get('data_source.{data_source_get_row.id}.{fields[4].db_column}')" + + value = f"{value_1} {operator} {value_2}" + formula = BaserowFormulaObject.create(value) + + fake_request = HttpRequest() + dispatch_context = BuilderDispatchContext( + fake_request, page, only_expose_public_allowed_properties=False + ) + + result = resolve_formula( + formula, formula_runtime_function_registry, dispatch_context + ) + assert result == expected, ( + f"{value} expected to resolve to {expected} " f"but got {result}" + ) + + +@pytest.mark.django_db +def test_runtime_formula_strings(data_fixture): + data = create_test_context(data_fixture) + data_source_list_rows = data["data_source_list_rows"] + data_source_get_row = data["data_source_get_row"] + page = data["page"] + fields = data["fields"] + + for test_case in TEST_CASES_STRINGS: + formula_name, data_source_type, field_id, expected = test_case + + if data_source_type == "list_rows": + value = f"get('data_source.{data_source_list_rows.id}.*.{fields[field_id].db_column}')" + elif data_source_type == "list_rows_item": + value = f"get('data_source.{data_source_list_rows.id}.0.{fields[field_id].db_column}')" + elif data_source_type == "get_row": + value = f"get('data_source.{data_source_get_row.id}.{fields[0].db_column}')" + + value = f"{formula_name}({value})" + formula = BaserowFormulaObject.create(value) + + fake_request = HttpRequest() + dispatch_context = BuilderDispatchContext( + fake_request, page, only_expose_public_allowed_properties=False + ) + + result = resolve_formula( + formula, formula_runtime_function_registry, dispatch_context + ) + assert result == expected, ( + f"{formula_name}() with {data_source_type} expected {expected} " + f"but got {result}" + ) diff --git a/backend/tests/baserow/contrib/database/formula/test_baserow_formula_results.py b/backend/tests/baserow/contrib/database/formula/test_baserow_formula_results.py index 0aca20238d..fcce381bfb 100644 --- a/backend/tests/baserow/contrib/database/formula/test_baserow_formula_results.py +++ b/backend/tests/baserow/contrib/database/formula/test_baserow_formula_results.py @@ -752,6 +752,7 @@ def test_can_use_has_option_on_multiple_select_fields(data_fixture): ), ], ) +@pytest.mark.skip # See: https://github.com/baserow/baserow/issues/4217 def test_can_use_formula_on_lookup_of_multiple_select_fields( formula, expected_value, data_fixture ): diff --git a/backend/tests/baserow/contrib/integrations/slack/test_slack_bot_integration_type.py b/backend/tests/baserow/contrib/integrations/slack/test_slack_bot_integration_type.py new file mode 100644 index 0000000000..a8988d713b --- /dev/null +++ b/backend/tests/baserow/contrib/integrations/slack/test_slack_bot_integration_type.py @@ -0,0 +1,23 @@ +import pytest + +from baserow.contrib.integrations.slack.models import SlackBotIntegration +from baserow.core.integrations.registries import integration_type_registry +from baserow.core.integrations.service import IntegrationService + + +@pytest.mark.django_db +def test_slack_bot_integration_creation(data_fixture): + user = data_fixture.create_user() + application = data_fixture.create_automation_application(user=user) + + integration_type = integration_type_registry.get("slack_bot") + + integration = IntegrationService().create_integration( + user, + integration_type, + application=application, + ) + + assert integration.token == "" + assert integration.application_id == application.id + assert isinstance(integration, SlackBotIntegration) diff --git a/backend/tests/baserow/contrib/integrations/slack/test_slack_write_message_service_type.py b/backend/tests/baserow/contrib/integrations/slack/test_slack_write_message_service_type.py new file mode 100644 index 0000000000..8d2e4fc5ec --- /dev/null +++ b/backend/tests/baserow/contrib/integrations/slack/test_slack_write_message_service_type.py @@ -0,0 +1,351 @@ +import json +from unittest.mock import Mock, patch + +import pytest + +from baserow.contrib.automation.automation_dispatch_context import ( + AutomationDispatchContext, +) +from baserow.contrib.automation.formula_importer import import_formula +from baserow.contrib.integrations.slack.service_types import ( + SlackWriteMessageServiceType, +) +from baserow.core.integrations.registries import integration_type_registry +from baserow.core.integrations.service import IntegrationService +from baserow.core.services.exceptions import ( + ServiceImproperlyConfiguredDispatchException, +) +from baserow.core.services.handler import ServiceHandler +from baserow.core.services.types import DispatchResult +from baserow.test_utils.helpers import AnyInt +from baserow.test_utils.pytest_conftest import FakeDispatchContext + + +@pytest.mark.django_db +def test_dispatch_slack_write_message_basic(data_fixture): + user = data_fixture.create_user() + application = data_fixture.create_automation_application(user=user) + + integration = IntegrationService().create_integration( + user, + integration_type_registry.get("slack_bot"), + application=application, + token="xoxb-test-token-12345", + ) + + service = ServiceHandler().create_service( + SlackWriteMessageServiceType(), + integration=integration, + channel="general", + text="'Hello from Baserow!'", + ) + + service_type = service.get_type() + dispatch_context = FakeDispatchContext() + + # Mock the HTTP request + mock_response = Mock() + mock_response.json.return_value = { + "ok": True, + "channel": "C123456", + "ts": "1503435956.000247", + "message": {"text": "Hello from Baserow!", "username": "baserow_bot"}, + } + + mock_request = Mock(return_value=mock_response) + + with patch( + "baserow.contrib.integrations.slack.service_types.get_http_request_function", + return_value=mock_request, + ): + dispatch_data = service_type.dispatch(service, dispatch_context) + + mock_request.assert_called_once_with( + method="POST", + url="https://slack.com/api/chat.postMessage", + headers={"Authorization": "Bearer xoxb-test-token-12345"}, + params={ + "channel": "#general", + "text": "Hello from Baserow!", + }, + timeout=10, + ) + + assert dispatch_data.data["data"]["ok"] is True + assert "channel" in dispatch_data.data["data"] + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "error_code,expected_message", + [ + ("no_text", "The message text is missing."), + ("invalid_auth", "Invalid bot user token."), + ("channel_not_found", "The channel #general was not found."), + ("not_in_channel", "Your app has not been invited to channel #general."), + ( + "rate_limited", + "Your app has sent too many requests in a short period of time.", + ), + ( + "some_unknown_error", + "An unknown error occurred while sending the message, the error code was: some_unknown_error", + ), + ], +) +def test_dispatch_slack_write_message_api_errors( + data_fixture, error_code, expected_message +): + user = data_fixture.create_user() + application = data_fixture.create_automation_application(user=user) + + integration = IntegrationService().create_integration( + user, + integration_type_registry.get("slack_bot"), + application=application, + token="xoxb-test-token-12345", + ) + + service = ServiceHandler().create_service( + SlackWriteMessageServiceType(), + integration=integration, + channel="general", + text="'Hello from Baserow!'", + ) + + service_type = service.get_type() + dispatch_context = FakeDispatchContext() + + mock_response = Mock() + mock_response.json.return_value = { + "ok": False, + "error": error_code, + } + + mock_request = Mock(return_value=mock_response) + + with pytest.raises(ServiceImproperlyConfiguredDispatchException) as exc_info: + with patch( + "baserow.contrib.integrations.slack.service_types.get_http_request_function", + return_value=mock_request, + ): + service_type.dispatch(service, dispatch_context) + + assert str(exc_info.value) == expected_message + + +@pytest.mark.django_db +def test_dispatch_slack_write_message_with_formulas(data_fixture): + user = data_fixture.create_user() + application = data_fixture.create_automation_application(user=user) + workflow = data_fixture.create_automation_workflow(automation=application) + trigger = workflow.get_trigger() + + integration = IntegrationService().create_integration( + user, + integration_type_registry.get("slack_bot"), + application=application, + token="xoxb-test-token-12345", + ) + + service = ServiceHandler().create_service( + SlackWriteMessageServiceType(), + integration=integration, + channel="general", + text=f"concat('User ', get('previous_node.{trigger.id}.0.name'), ' has joined!')", + ) + + service_type = service.get_type() + dispatch_context = AutomationDispatchContext(workflow) + dispatch_context.after_dispatch( + trigger, DispatchResult(data={"results": [{"name": "John"}]}) + ) + + mock_response = Mock() + mock_response.json.return_value = { + "ok": True, + "channel": "C123456", + "ts": "1503435956.000247", + } + mock_request = Mock(return_value=mock_response) + + with patch( + "baserow.contrib.integrations.slack.service_types.get_http_request_function", + return_value=mock_request, + ): + service_type.dispatch(service, dispatch_context) + + mock_request.assert_called_once_with( + method="POST", + url="https://slack.com/api/chat.postMessage", + headers={"Authorization": "Bearer xoxb-test-token-12345"}, + params={ + "channel": "#general", + "text": "User John has joined!", + }, + timeout=10, + ) + + +@pytest.mark.django_db +def test_slack_write_message_create(data_fixture): + user = data_fixture.create_user() + application = data_fixture.create_automation_application(user=user) + + integration = IntegrationService().create_integration( + user, + integration_type_registry.get("slack_bot"), + application=application, + token="xoxb-test-token-12345", + ) + + service = ServiceHandler().create_service( + SlackWriteMessageServiceType(), + integration=integration, + channel="general", + text="'Hello Slack!'", + ) + + assert service.channel == "general" + assert service.text["formula"] == "'Hello Slack!'" + assert service.integration.specific.token == "xoxb-test-token-12345" + + +@pytest.mark.django_db +def test_slack_write_message_update(data_fixture): + user = data_fixture.create_user() + application = data_fixture.create_automation_application(user=user) + + integration = IntegrationService().create_integration( + user, + integration_type_registry.get("slack_bot"), + application=application, + token="xoxb-test-token-12345", + ) + + service = ServiceHandler().create_service( + SlackWriteMessageServiceType(), + integration=integration, + channel="general", + text="'Hello Slack!'", + ) + + service_type = service.get_type() + + ServiceHandler().update_service( + service_type, + service, + channel="announcements", + text="'Updated message!'", + ) + + service.refresh_from_db() + + assert service.channel == "announcements" + assert service.text["formula"] == "'Updated message!'" + + +@pytest.mark.django_db +def test_slack_write_message_formula_generator(data_fixture): + user = data_fixture.create_user() + application = data_fixture.create_automation_application(user=user) + + integration = IntegrationService().create_integration( + user, + integration_type_registry.get("slack_bot"), + application=application, + token="xoxb-test-token-12345", + ) + + service = ServiceHandler().create_service( + SlackWriteMessageServiceType(), + integration=integration, + channel="general", + text="'Hello Slack!'", + ) + + service_type = service.get_type() + + formulas = list(service_type.formula_generator(service)) + assert formulas == [ + {"mode": "simple", "version": "0.1", "formula": "'Hello Slack!'"}, + ] + + +@pytest.mark.django_db +def test_slack_write_message_export_import(data_fixture): + user = data_fixture.create_user() + application = data_fixture.create_automation_application(user=user) + workflow = data_fixture.create_automation_workflow(automation=application) + old_trigger = workflow.get_trigger() + + integration = IntegrationService().create_integration( + user, + integration_type_registry.get("slack_bot"), + application=application, + token="xoxb-test-token-12345", + ) + + service = ServiceHandler().create_service( + SlackWriteMessageServiceType(), + integration=integration, + channel="general", + text=f"get('previous_node.{old_trigger.id}.0.field_123')", + ) + + service_type = service.get_type() + + serialized = json.loads(json.dumps(service_type.export_serialized(service))) + assert serialized == { + "id": AnyInt(), + "integration_id": integration.id, + "sample_data": None, + "type": "slack_write_message", + "channel": "general", + "text": { + "formula": f"get('previous_node.{old_trigger.id}.0.field_123')", + "version": "0.1", + "mode": "simple", + }, + } + + new_workflow = data_fixture.create_automation_workflow(automation=application) + new_trigger = new_workflow.get_trigger() + id_mapping = {"automation_workflow_nodes": {old_trigger.id: new_trigger.id}} + new_service = service_type.import_serialized( + None, serialized, id_mapping, import_formula + ) + assert new_service.channel == "general" + assert ( + new_service.text["formula"] + == f"get('previous_node.{new_trigger.id}.0.field_123')" + ) + + +@pytest.mark.django_db +def test_slack_write_message_generate_schema(data_fixture): + user = data_fixture.create_user() + application = data_fixture.create_automation_application(user=user) + integration = IntegrationService().create_integration( + user, + integration_type_registry.get("slack_bot"), + application=application, + token="xoxb-test-token-12345", + ) + service = ServiceHandler().create_service( + SlackWriteMessageServiceType(), + integration=integration, + channel="general", + text="'Hello Slack!'", + ) + schema = service.get_type().generate_schema(service) + assert schema == { + "title": f"SlackWriteMessage{service.id}Schema", + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "title": "OK", + }, + }, + } diff --git a/backend/tests/baserow/core/formula/test_runtime_formula_types.py b/backend/tests/baserow/core/formula/test_runtime_formula_types.py index ce19c64783..020bcd760d 100644 --- a/backend/tests/baserow/core/formula/test_runtime_formula_types.py +++ b/backend/tests/baserow/core/formula/test_runtime_formula_types.py @@ -1,12 +1,52 @@ +import uuid +from datetime import datetime from unittest.mock import MagicMock import pytest +from freezegun import freeze_time -from baserow.core.formula.runtime_formula_types import RuntimeConcat +from baserow.core.formula.runtime_formula_types import ( + RuntimeAdd, + RuntimeAnd, + RuntimeCapitalize, + RuntimeConcat, + RuntimeDateTimeFormat, + RuntimeDay, + RuntimeDivide, + RuntimeEqual, + RuntimeGenerateUUID, + RuntimeGet, + RuntimeGetProperty, + RuntimeGreaterThan, + RuntimeGreaterThanOrEqual, + RuntimeHour, + RuntimeIf, + RuntimeIsEven, + RuntimeIsOdd, + RuntimeLessThan, + RuntimeLessThanOrEqual, + RuntimeLower, + RuntimeMinus, + RuntimeMinute, + RuntimeMonth, + RuntimeMultiply, + RuntimeNotEqual, + RuntimeNow, + RuntimeOr, + RuntimeRandomBool, + RuntimeRandomFloat, + RuntimeRandomInt, + RuntimeRound, + RuntimeSecond, + RuntimeToday, + RuntimeUpper, + RuntimeYear, +) +from baserow.test_utils.helpers import AnyBool, AnyFloat, AnyInt @pytest.mark.parametrize( - "formula_args,expected", + "args,expected", [ ( [[["Apple", "Banana"]], "Cherry"], @@ -17,16 +57,1612 @@ "Apple,Banana,Cherry", ), ( - [[["Apple", "Banana"]], ", Cherry"], - "Apple,Banana, Cherry", + [[["a", "b", "c", "d"]], "x"], + "a,b,c,dx", ), ], ) -def test_returns_concatenated_value(formula_args, expected): - """ - Ensure that formula_args and non-formula strings are concatenated correctly. - """ +def test_runtime_concat_execute(args, expected): + parsed_args = RuntimeConcat().parse_args(args) + result = RuntimeConcat().execute({}, parsed_args) + assert result == expected - context = MagicMock() - result = RuntimeConcat().execute(context, formula_args) + +@pytest.mark.parametrize( + "arg,expected", + [ + ("foo", None), + (101, None), + (3.14, None), + (True, None), + (False, None), + ({}, None), + (None, None), + (datetime.now(), None), + ], +) +def test_runtime_concat_validate_type_of_args(arg, expected): + result = RuntimeConcat().validate_type_of_args([arg]) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([], False), + (["foo"], False), + (["foo", "bar"], True), + (["foo", "bar", "baz"], True), + ], +) +def test_runtime_concat_validate_number_of_args(args, expected): + result = RuntimeConcat().validate_number_of_args(args) + assert result is expected + + +def test_runtime_get_execute(): + context = { + "id": 101, + "fruit": "Apple", + "color": "Red", + } + + assert RuntimeGet().execute(context, ["id"]) == 101 + assert RuntimeGet().execute(context, ["fruit"]) == "Apple" + assert RuntimeGet().execute(context, ["color"]) == "Red" + + +@pytest.mark.parametrize( + "args,expected", + [ + ([1, 2], 3), + ([2, 3], 5), + ([2, 3.14], 5.140000000000001), + ([2.43, 3.14], 5.57), + ([-4, 23], 19), + ], +) +def test_runtime_add_execute(args, expected): + parsed_args = RuntimeAdd().parse_args(args) + result = RuntimeAdd().execute({}, parsed_args) + assert result == expected + + +@pytest.mark.parametrize( + "arg,expected", + [ + # These are invalid + ("foo", "foo"), + (True, True), + (None, None), + ({}, {}), + ([], []), + ( + datetime(year=2025, month=11, day=6, hour=12, minute=30), + datetime(year=2025, month=11, day=6, hour=12, minute=30), + ), + # These are valid + (1, None), + (3.14, None), + ("23", None), + ("23.33", None), + ], +) +def test_runtime_add_validate_type_of_args(arg, expected): + result = RuntimeAdd().validate_type_of_args([arg]) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([], False), + (["foo"], False), + (["foo", "bar"], True), + (["foo", "bar", "baz"], False), + ], +) +def test_runtime_add_validate_number_of_args(args, expected): + result = RuntimeAdd().validate_number_of_args(args) + assert result is expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([3, 1], 2), + ([3.14, 4.56], -1.4199999999999995), + ([45.25, -2], 47.25), + ], +) +def test_runtime_minus_execute(args, expected): + parsed_args = RuntimeMinus().parse_args(args) + result = RuntimeMinus().execute({}, parsed_args) + assert result == expected + + +@pytest.mark.parametrize( + "arg,expected", + [ + # These are invalid + ("foo", "foo"), + (True, True), + (None, None), + ({}, {}), + ([], []), + ( + datetime(year=2025, month=11, day=6, hour=12, minute=30), + datetime(year=2025, month=11, day=6, hour=12, minute=30), + ), + # These are valid + (1, None), + (3.14, None), + ("23", None), + ("23.33", None), + ], +) +def test_runtime_minus_validate_type_of_args(arg, expected): + result = RuntimeMinus().validate_type_of_args([arg]) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([], False), + (["foo"], False), + (["foo", "bar"], True), + (["foo", "bar", "baz"], False), + ], +) +def test_runtime_minus_validate_number_of_args(args, expected): + result = RuntimeMinus().validate_number_of_args(args) + assert result is expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([3, 1], 3), + ([3.14, 4.56], 14.318399999999999), + ([52.14, -2], -104.28), + ], +) +def test_runtime_multiply_execute(args, expected): + parsed_args = RuntimeMultiply().parse_args(args) + result = RuntimeMultiply().execute({}, parsed_args) + assert result == expected + + +@pytest.mark.parametrize( + "arg,expected", + [ + # These are invalid + ("foo", "foo"), + (True, True), + (None, None), + ({}, {}), + ([], []), + ( + datetime(year=2025, month=11, day=6, hour=12, minute=30), + datetime(year=2025, month=11, day=6, hour=12, minute=30), + ), + # These are valid + (1, None), + (3.14, None), + ("23", None), + ("23.33", None), + ], +) +def test_runtime_multiply_validate_type_of_args(arg, expected): + result = RuntimeMultiply().validate_type_of_args([arg]) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([], False), + (["foo"], False), + (["foo", "bar"], True), + (["foo", "bar", "baz"], False), + ], +) +def test_runtime_multiply_validate_number_of_args(args, expected): + result = RuntimeMultiply().validate_number_of_args(args) + assert result is expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([4, 2], 2), + ([3.14, 1.56], 2.0128205128205128), + ([23.24, -2], -11.62), + ], +) +def test_runtime_divide_execute(args, expected): + parsed_args = RuntimeDivide().parse_args(args) + result = RuntimeDivide().execute({}, parsed_args) + assert result == expected + + +@pytest.mark.parametrize( + "arg,expected", + [ + # These are invalid + ("foo", "foo"), + (True, True), + (None, None), + ({}, {}), + ([], []), + ( + datetime(year=2025, month=11, day=6, hour=12, minute=30), + datetime(year=2025, month=11, day=6, hour=12, minute=30), + ), + # These are valid + (1, None), + (3.14, None), + ("23", None), + ("23.33", None), + ], +) +def test_runtime_divide_validate_type_of_args(arg, expected): + result = RuntimeDivide().validate_type_of_args([arg]) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([], False), + (["foo"], False), + (["foo", "bar"], True), + (["foo", "bar", "baz"], False), + ], +) +def test_runtime_divide_validate_number_of_args(args, expected): + result = RuntimeDivide().validate_number_of_args(args) + assert result is expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([2, 2], True), + ([2, 3], False), + (["foo", "foo"], True), + (["foo", "bar"], False), + ], +) +def test_runtime_equal_execute(args, expected): + parsed_args = RuntimeEqual().parse_args(args) + result = RuntimeEqual().execute({}, parsed_args) + assert result == expected + + +@pytest.mark.parametrize( + "arg,expected", + [ + # All types are allowed + ("foo", None), + (True, None), + (None, None), + ({}, None), + ([], None), + (datetime(year=2025, month=11, day=6, hour=12, minute=30), None), + (1, None), + (3.14, None), + ("23", None), + ("23.33", None), + ], +) +def test_runtime_equal_validate_type_of_args(arg, expected): + result = RuntimeEqual().validate_type_of_args([arg]) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([], False), + (["foo"], False), + (["foo", "bar"], True), + (["foo", "bar", "baz"], False), + ], +) +def test_runtime_equal_validate_number_of_args(args, expected): + result = RuntimeEqual().validate_number_of_args(args) + assert result is expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([2, 2], False), + ([2, 3], True), + (["foo", "foo"], False), + (["foo", "bar"], True), + ], +) +def test_runtime_not_equal_execute(args, expected): + parsed_args = RuntimeNotEqual().parse_args(args) + result = RuntimeNotEqual().execute({}, parsed_args) + assert result == expected + + +@pytest.mark.parametrize( + "arg,expected", + [ + # All types are allowed + ("foo", None), + (True, None), + (None, None), + ({}, None), + ([], None), + (datetime(year=2025, month=11, day=6, hour=12, minute=30), None), + (1, None), + (3.14, None), + ("23", None), + ("23.33", None), + ], +) +def test_runtime_not_equal_validate_type_of_args(arg, expected): + result = RuntimeNotEqual().validate_type_of_args([arg]) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([], False), + (["foo"], False), + (["foo", "bar"], True), + (["foo", "bar", "baz"], False), + ], +) +def test_runtime_not_equal_validate_number_of_args(args, expected): + result = RuntimeNotEqual().validate_number_of_args(args) + assert result is expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([2, 2], False), + ([2, 3], False), + ([3, 2], True), + (["apple", "ball"], False), + (["ball", "apple"], True), + ], +) +def test_runtime_greater_than_execute(args, expected): + parsed_args = RuntimeGreaterThan().parse_args(args) + result = RuntimeGreaterThan().execute({}, parsed_args) + assert result == expected + + +@pytest.mark.parametrize( + "arg,expected", + [ + # All types are allowed + ("foo", None), + (True, None), + (None, None), + ({}, None), + ([], None), + (datetime(year=2025, month=11, day=6, hour=12, minute=30), None), + (1, None), + (3.14, None), + ("23", None), + ("23.33", None), + ], +) +def test_runtime_greater_than_validate_type_of_args(arg, expected): + result = RuntimeGreaterThan().validate_type_of_args([arg]) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([], False), + (["foo"], False), + (["foo", "bar"], True), + (["foo", "bar", "baz"], False), + ], +) +def test_runtime_greater_than_validate_number_of_args(args, expected): + result = RuntimeGreaterThan().validate_number_of_args(args) + assert result is expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([2, 2], False), + ([2, 3], True), + ([3, 2], False), + (["apple", "ball"], True), + (["ball", "apple"], False), + ], +) +def test_runtime_less_than_execute(args, expected): + parsed_args = RuntimeLessThan().parse_args(args) + result = RuntimeLessThan().execute({}, parsed_args) + assert result == expected + + +@pytest.mark.parametrize( + "arg,expected", + [ + # All types are allowed + ("foo", None), + (True, None), + (None, None), + ({}, None), + ([], None), + (datetime(year=2025, month=11, day=6, hour=12, minute=30), None), + (1, None), + (3.14, None), + ("23", None), + ("23.33", None), + ], +) +def test_runtime_less_than_validate_type_of_args(arg, expected): + result = RuntimeLessThan().validate_type_of_args([arg]) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([], False), + (["foo"], False), + (["foo", "bar"], True), + (["foo", "bar", "baz"], False), + ], +) +def test_runtime_less_than_validate_number_of_args(args, expected): + result = RuntimeLessThan().validate_number_of_args(args) + assert result is expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([2, 2], True), + ([2, 3], False), + ([3, 2], True), + (["apple", "ball"], False), + (["ball", "apple"], True), + ], +) +def test_runtime_greater_than_or_equal_execute(args, expected): + parsed_args = RuntimeGreaterThanOrEqual().parse_args(args) + result = RuntimeGreaterThanOrEqual().execute({}, parsed_args) assert result == expected + + +@pytest.mark.parametrize( + "arg,expected", + [ + # All types are allowed + ("foo", None), + (True, None), + (None, None), + ({}, None), + ([], None), + (datetime(year=2025, month=11, day=6, hour=12, minute=30), None), + (1, None), + (3.14, None), + ("23", None), + ("23.33", None), + ], +) +def test_runtime_greater_than_or_equal_validate_type_of_args(arg, expected): + result = RuntimeGreaterThanOrEqual().validate_type_of_args([arg]) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([], False), + (["foo"], False), + (["foo", "bar"], True), + (["foo", "bar", "baz"], False), + ], +) +def test_runtime_greater_than_or_equal_validate_number_of_args(args, expected): + result = RuntimeGreaterThanOrEqual().validate_number_of_args(args) + assert result is expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([2, 2], True), + ([2, 3], True), + ([3, 2], False), + (["apple", "ball"], True), + (["ball", "apple"], False), + ], +) +def test_runtime_less_than_or_equal_execute(args, expected): + parsed_args = RuntimeLessThanOrEqual().parse_args(args) + result = RuntimeLessThanOrEqual().execute({}, parsed_args) + assert result == expected + + +@pytest.mark.parametrize( + "arg,expected", + [ + # All types are allowed + ("foo", None), + (True, None), + (None, None), + ({}, None), + ([], None), + (datetime(year=2025, month=11, day=6, hour=12, minute=30), None), + (1, None), + (3.14, None), + ("23", None), + ("23.33", None), + ], +) +def test_runtime_less_than_or_equal_validate_type_of_args(arg, expected): + result = RuntimeLessThanOrEqual().validate_type_of_args([arg]) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([], False), + (["foo"], False), + (["foo", "bar"], True), + (["foo", "bar", "baz"], False), + ], +) +def test_runtime_less_than_or_equal_validate_number_of_args(args, expected): + result = RuntimeLessThanOrEqual().validate_number_of_args(args) + assert result is expected + + +@pytest.mark.parametrize( + "args,expected", + [ + (["apple"], "APPLE"), + (["bAll"], "BALL"), + (["Foo Bar"], "FOO BAR"), + ], +) +def test_runtime_upper_execute(args, expected): + parsed_args = RuntimeUpper().parse_args(args) + result = RuntimeUpper().execute({}, parsed_args) + assert result == expected + + +@pytest.mark.parametrize( + "arg,expected", + [ + # Text (or convertable to text) types are allowed + ("foo", None), + (123, None), + (123.45, None), + (None, None), + ({}, None), + ([], None), + (datetime(year=2025, month=11, day=6, hour=12, minute=30), None), + ], +) +def test_runtime_upper_validate_type_of_args(arg, expected): + result = RuntimeUpper().validate_type_of_args([arg]) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([], False), + (["foo"], True), + (["foo", "bar"], False), + ], +) +def test_runtime_upper_validate_number_of_args(args, expected): + result = RuntimeUpper().validate_number_of_args(args) + assert result is expected + + +@pytest.mark.parametrize( + "args,expected", + [ + (["ApPle"], "apple"), + (["BALL"], "ball"), + (["Foo BAR"], "foo bar"), + ], +) +def test_runtime_lower_execute(args, expected): + parsed_args = RuntimeLower().parse_args(args) + result = RuntimeLower().execute({}, parsed_args) + assert result == expected + + +@pytest.mark.parametrize( + "arg,expected", + [ + # Text (or convertable to text) types are allowed + ("foo", None), + (123, None), + (123.45, None), + (None, None), + ({}, None), + ([], None), + (datetime(year=2025, month=11, day=6, hour=12, minute=30), None), + ], +) +def test_runtime_lower_validate_type_of_args(arg, expected): + result = RuntimeLower().validate_type_of_args([arg]) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([], False), + (["foo"], True), + (["foo", "bar"], False), + ], +) +def test_runtime_lower_validate_number_of_args(args, expected): + result = RuntimeLower().validate_number_of_args(args) + assert result is expected + + +@pytest.mark.parametrize( + "args,expected", + [ + (["ApPle"], "Apple"), + (["BALL"], "Ball"), + (["Foo BAR"], "Foo bar"), + ], +) +def test_runtime_capitalize_execute(args, expected): + parsed_args = RuntimeCapitalize().parse_args(args) + result = RuntimeCapitalize().execute({}, parsed_args) + assert result == expected + + +@pytest.mark.parametrize( + "arg,expected", + [ + # Text (or convertable to text) types are allowed + ("foo", None), + (123, None), + (123.45, None), + (None, None), + ({}, None), + ([], None), + (datetime(year=2025, month=11, day=6, hour=12, minute=30), None), + ], +) +def test_runtime_capitalize_validate_type_of_args(arg, expected): + result = RuntimeCapitalize().validate_type_of_args([arg]) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([], False), + (["foo"], True), + (["foo", "bar"], False), + ], +) +def test_runtime_capitalize_validate_number_of_args(args, expected): + result = RuntimeCapitalize().validate_number_of_args(args) + assert result is expected + + +@pytest.mark.parametrize( + "args,expected", + [ + (["23.45", 2], 23.45), + # Defaults to 2 decimal places + ([33.4567], 33.46), + ([33, 0], 33), + ([49.4587, 3], 49.459), + ], +) +def test_runtime_round_execute(args, expected): + parsed_args = RuntimeRound().parse_args(args) + result = RuntimeRound().execute({}, parsed_args) + assert result == expected + + +@pytest.mark.parametrize( + "arg,expected", + [ + # Number (or convertable to number) types are allowed + ("23.34", None), + (123, None), + (123.45, None), + (None, None), + ("foo", "foo"), + ({}, {}), + ([], []), + ( + datetime(year=2025, month=11, day=6, hour=12, minute=30), + datetime(year=2025, month=11, day=6, hour=12, minute=30), + ), + ], +) +def test_runtime_round_validate_type_of_args(arg, expected): + result = RuntimeRound().validate_type_of_args([arg]) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([], False), + (["foo"], True), + (["foo", "bar"], True), + (["foo", "bar", "baz"], False), + ], +) +def test_runtime_round_validate_number_of_args(args, expected): + result = RuntimeRound().validate_number_of_args(args) + assert result is expected + + +@pytest.mark.parametrize( + "args,expected", + [ + (["23.45"], False), + (["24"], True), + ([33.4567], False), + ([33], False), + ([50], True), + ], +) +def test_runtime_is_even_execute(args, expected): + parsed_args = RuntimeIsEven().parse_args(args) + result = RuntimeIsEven().execute({}, parsed_args) + assert result == expected + + +@pytest.mark.parametrize( + "arg,expected", + [ + # Number (or convertable to number) types are allowed + ("23.34", None), + (123, None), + (123.45, None), + (None, None), + ("foo", "foo"), + ({}, {}), + ([], []), + ( + datetime(year=2025, month=11, day=6, hour=12, minute=30), + datetime(year=2025, month=11, day=6, hour=12, minute=30), + ), + ], +) +def test_runtime_is_even_validate_type_of_args(arg, expected): + result = RuntimeIsEven().validate_type_of_args([arg]) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([], False), + (["foo"], True), + (["foo", "bar"], False), + ], +) +def test_runtime_is_even_validate_number_of_args(args, expected): + result = RuntimeIsEven().validate_number_of_args(args) + assert result is expected + + +@pytest.mark.parametrize( + "args,expected", + [ + (["23.45"], True), + (["24"], False), + ([33.4567], True), + ([33], True), + ([50], False), + ], +) +def test_runtime_is_odd_execute(args, expected): + parsed_args = RuntimeIsOdd().parse_args(args) + result = RuntimeIsOdd().execute({}, parsed_args) + assert result == expected + + +@pytest.mark.parametrize( + "arg,expected", + [ + # Number (or convertable to number) types are allowed + ("23.34", None), + (123, None), + (123.45, None), + (None, None), + ("foo", "foo"), + ({}, {}), + ([], []), + ( + datetime(year=2025, month=11, day=6, hour=12, minute=30), + datetime(year=2025, month=11, day=6, hour=12, minute=30), + ), + ], +) +def test_runtime_is_odd_validate_type_of_args(arg, expected): + result = RuntimeIsOdd().validate_type_of_args([arg]) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([], False), + (["foo"], True), + (["foo", "bar"], False), + ], +) +def test_runtime_is_odd_validate_number_of_args(args, expected): + result = RuntimeIsOdd().validate_number_of_args(args) + assert result is expected + + +@pytest.mark.parametrize( + "args,expected", + [ + (["2025-11-03", "YY/MM/DD"], "25/11/03"), + (["2025-11-03", "DD/MM/YYYY HH:mm:ss"], "03/11/2025 00:00:00"), + ( + ["2025-11-06 11:30:30.861096+00:00", "DD/MM/YYYY HH:mm:ss"], + "06/11/2025 11:30:30", + ), + (["2025-11-06 11:30:30.861096+00:00", "%f"], "861096"), + ], +) +def test_runtime_datetime_format_execute(args, expected): + parsed_args = RuntimeDateTimeFormat().parse_args(args) + context = MagicMock() + context.get_timezone_name.return_value = "UTC" + + result = RuntimeDateTimeFormat().execute(context, parsed_args) + assert result == expected + + +@pytest.mark.parametrize( + "arg,expected", + [ + # Date like values are valid + (datetime(year=2025, month=11, day=6, hour=12, minute=30), None), + ("2025-11-06", None), + ("2025-11-06 11:30:30.861096+00:00", None), + # Otherwise the type is invalid + ("23.34", "23.34"), + (123, 123), + (123.45, 123.45), + (None, None), + ("foo", "foo"), + ({}, {}), + ([], []), + ], +) +def test_runtime_datetime_format_validate_type_of_args(arg, expected): + result = RuntimeDateTimeFormat().validate_type_of_args([arg]) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([], False), + (["foo"], False), + (["foo", "bar"], True), + (["foo", "bar", "baz"], True), + (["foo", "bar", "baz", "x"], False), + ], +) +def test_runtime_datetime_format_validate_number_of_args(args, expected): + result = RuntimeDateTimeFormat().validate_number_of_args(args) + assert result is expected + + +@pytest.mark.parametrize( + "args,expected", + [ + (["2025-11-03"], 3), + (["2025-11-04 11:30:30.861096+00:00"], 4), + (["2025-11-05 11:30:30.861096+00:00"], 5), + ], +) +def test_runtime_day_execute(args, expected): + parsed_args = RuntimeDay().parse_args(args) + result = RuntimeDay().execute({}, parsed_args) + assert result == expected + + +@pytest.mark.parametrize( + "arg,expected", + [ + # Date like values are valid + (datetime(year=2025, month=11, day=6, hour=12, minute=30), None), + ("2025-11-06", None), + ("2025-11-06 11:30:30.861096+00:00", None), + # Otherwise the type is invalid + ("23.34", "23.34"), + (123, 123), + (123.45, 123.45), + (None, None), + ("foo", "foo"), + ({}, {}), + ([], []), + ], +) +def test_runtime_day_validate_type_of_args(arg, expected): + result = RuntimeDay().validate_type_of_args([arg]) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([], False), + (["foo"], True), + (["foo", "bar"], False), + ], +) +def test_runtime_day_validate_number_of_args(args, expected): + result = RuntimeDay().validate_number_of_args(args) + assert result is expected + + +@pytest.mark.parametrize( + "args,expected", + [ + (["2025-09-03"], 9), + (["2025-10-04 11:30:30.861096+00:00"], 10), + (["2025-11-05 11:30:30.861096+00:00"], 11), + ], +) +def test_runtime_month_execute(args, expected): + parsed_args = RuntimeMonth().parse_args(args) + result = RuntimeMonth().execute({}, parsed_args) + assert result == expected + + +@pytest.mark.parametrize( + "arg,expected", + [ + # Date like values are valid + (datetime(year=2025, month=11, day=6, hour=12, minute=30), None), + ("2025-11-06", None), + ("2025-11-06 11:30:30.861096+00:00", None), + # Otherwise the type is invalid + ("23.34", "23.34"), + (123, 123), + (123.45, 123.45), + (None, None), + ("foo", "foo"), + ({}, {}), + ([], []), + ], +) +def test_runtime_month_validate_type_of_args(arg, expected): + result = RuntimeMonth().validate_type_of_args([arg]) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([], False), + (["foo"], True), + (["foo", "bar"], False), + ], +) +def test_runtime_month_validate_number_of_args(args, expected): + result = RuntimeMonth().validate_number_of_args(args) + assert result is expected + + +@pytest.mark.parametrize( + "args,expected", + [ + (["2023-09-03"], 2023), + (["2024-10-04 11:30:30.861096+00:00"], 2024), + (["2025-11-05 11:30:30.861096+00:00"], 2025), + ], +) +def test_runtime_year_execute(args, expected): + parsed_args = RuntimeYear().parse_args(args) + result = RuntimeYear().execute({}, parsed_args) + assert result == expected + + +@pytest.mark.parametrize( + "arg,expected", + [ + # Date like values are valid + (datetime(year=2025, month=11, day=6, hour=12, minute=30), None), + ("2025-11-06", None), + ("2025-11-06 11:30:30.861096+00:00", None), + # Otherwise the type is invalid + ("23.34", "23.34"), + (123, 123), + (123.45, 123.45), + (None, None), + ("foo", "foo"), + ({}, {}), + ([], []), + ], +) +def test_runtime_year_validate_type_of_args(arg, expected): + result = RuntimeYear().validate_type_of_args([arg]) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([], False), + (["foo"], True), + (["foo", "bar"], False), + ], +) +def test_runtime_year_validate_number_of_args(args, expected): + result = RuntimeYear().validate_number_of_args(args) + assert result is expected + + +@pytest.mark.parametrize( + "args,expected", + [ + (["2023-09-03"], 0), + (["2024-10-04 11:30:30.861096+00:00"], 11), + (["2025-11-05 12:30:30.861096+00:00"], 12), + (["2025-11-05 16:30:30.861096+00:00"], 16), + ], +) +def test_runtime_hour_execute(args, expected): + parsed_args = RuntimeHour().parse_args(args) + result = RuntimeHour().execute({}, parsed_args) + assert result == expected + + +@pytest.mark.parametrize( + "arg,expected", + [ + # Date like values are valid + (datetime(year=2025, month=11, day=6, hour=12, minute=30), None), + ("2025-11-06", None), + ("2025-11-06 11:30:30.861096+00:00", None), + # Otherwise the type is invalid + ("23.34", "23.34"), + (123, 123), + (123.45, 123.45), + (None, None), + ("foo", "foo"), + ({}, {}), + ([], []), + ], +) +def test_runtime_hour_validate_type_of_args(arg, expected): + result = RuntimeHour().validate_type_of_args([arg]) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([], False), + (["foo"], True), + (["foo", "bar"], False), + ], +) +def test_runtime_hour_validate_number_of_args(args, expected): + result = RuntimeHour().validate_number_of_args(args) + assert result is expected + + +@pytest.mark.parametrize( + "args,expected", + [ + (["2023-09-03"], 0), + (["2024-10-04 11:05:30.861096+00:00"], 5), + (["2025-11-05 12:32:30.861096+00:00"], 32), + (["2025-11-05 16:33:30.861096+00:00"], 33), + ], +) +def test_runtime_minute_execute(args, expected): + parsed_args = RuntimeMinute().parse_args(args) + result = RuntimeMinute().execute({}, parsed_args) + assert result == expected + + +@pytest.mark.parametrize( + "arg,expected", + [ + # Date like values are valid + (datetime(year=2025, month=11, day=6, hour=12, minute=30), None), + ("2025-11-06", None), + ("2025-11-06 11:30:30.861096+00:00", None), + # Otherwise the type is invalid + ("23.34", "23.34"), + (123, 123), + (123.45, 123.45), + (None, None), + ("foo", "foo"), + ({}, {}), + ([], []), + ], +) +def test_runtime_minute_validate_type_of_args(arg, expected): + result = RuntimeMinute().validate_type_of_args([arg]) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([], False), + (["foo"], True), + (["foo", "bar"], False), + ], +) +def test_runtime_minute_validate_number_of_args(args, expected): + result = RuntimeMinute().validate_number_of_args(args) + assert result is expected + + +@pytest.mark.parametrize( + "args,expected", + [ + (["2023-09-03"], 0), + (["2024-10-04 11:05:05.861096+00:00"], 5), + (["2025-11-05 12:32:30.861096+00:00"], 30), + (["2025-11-05 16:33:49.861096+00:00"], 49), + ], +) +def test_runtime_second_execute(args, expected): + parsed_args = RuntimeSecond().parse_args(args) + result = RuntimeSecond().execute({}, parsed_args) + assert result == expected + + +@pytest.mark.parametrize( + "arg,expected", + [ + # Date like values are valid + (datetime(year=2025, month=11, day=6, hour=12, minute=30), None), + ("2025-11-06", None), + ("2025-11-06 11:30:30.861096+00:00", None), + # Otherwise the type is invalid + ("23.34", "23.34"), + (123, 123), + (123.45, 123.45), + (None, None), + ("foo", "foo"), + ({}, {}), + ([], []), + ], +) +def test_runtime_second_validate_type_of_args(arg, expected): + result = RuntimeSecond().validate_type_of_args([arg]) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([], False), + (["foo"], True), + (["foo", "bar"], False), + ], +) +def test_runtime_second_validate_number_of_args(args, expected): + result = RuntimeSecond().validate_number_of_args(args) + assert result is expected + + +def test_runtime_now_execute(): + with freeze_time("2025-11-06 15:48:51"): + parsed_args = RuntimeNow().parse_args([]) + result = RuntimeNow().execute({}, parsed_args) + assert str(result) == "2025-11-06 15:48:51+00:00" + + +@pytest.mark.parametrize( + "args,expected", + [ + ([], True), + (["foo"], False), + ], +) +def test_runtime_now_validate_number_of_args(args, expected): + result = RuntimeNow().validate_number_of_args(args) + assert result is expected + + +def test_runtime_today_execute(): + with freeze_time("2025-11-06 15:48:51"): + parsed_args = RuntimeToday().parse_args([]) + result = RuntimeToday().execute({}, parsed_args) + assert str(result) == "2025-11-06" + + +@pytest.mark.parametrize( + "args,expected", + [ + ([], True), + (["foo"], False), + ], +) +def test_runtime_today_validate_number_of_args(args, expected): + result = RuntimeToday().validate_number_of_args(args) + assert result is expected + + +@pytest.mark.parametrize( + "args,expected", + [ + (['{"foo": "bar"}', "foo"], "bar"), + (['{"foo": "bar"}', "baz"], None), + ], +) +def test_runtime_get_property_execute(args, expected): + parsed_args = RuntimeGetProperty().parse_args(args) + result = RuntimeGetProperty().execute({}, parsed_args) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + # Dict (or convertable to dict) types are allowed + (['{"foo": "bar"}', "foo"], None), + ([{"foo": "bar"}, "foo"], None), + # Invalid types for 1st arg (2nd arg is cast to string) + (["foo", "foo"], "foo"), + ([100, "foo"], 100), + ([12.34, "foo"], 12.34), + ( + [datetime(year=2025, month=11, day=6, hour=12, minute=30), "foo"], + datetime(year=2025, month=11, day=6, hour=12, minute=30), + ), + ([None, "foo"], None), + ([[], "foo"], []), + ], +) +def test_runtime_get_property_validate_type_of_args(args, expected): + result = RuntimeGetProperty().validate_type_of_args(args) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([], False), + (["foo"], False), + (["foo", "bar"], True), + (["foo", "bar", "baz"], False), + ], +) +def test_runtime_get_property_validate_number_of_args(args, expected): + result = RuntimeGetProperty().validate_number_of_args(args) + assert result is expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([1, 100], AnyInt()), + ([10.24, 100.54], AnyInt()), + ], +) +def test_runtime_random_int_execute(args, expected): + parsed_args = RuntimeRandomInt().parse_args(args) + result = RuntimeRandomInt().execute({}, parsed_args) + assert result == expected + assert result >= args[0] and result <= args[1] + + +@pytest.mark.parametrize( + "args,expected", + [ + # numeric types are allowed + ([1, 100], None), + ([2.5, 56.64], None), + (["3", "4.5"], None), + # Invalid types for 1st arg + ([{}, 5], {}), + (["foo", 5], "foo"), + # Invalid types for 2nd arg + ([5, {}], {}), + ([5, "foo"], "foo"), + ], +) +def test_runtime_random_int_validate_type_of_args(args, expected): + result = RuntimeRandomInt().validate_type_of_args(args) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([], False), + (["foo"], False), + (["foo", "bar"], True), + (["foo", "bar", "baz"], False), + ], +) +def test_runtime_random_int_validate_number_of_args(args, expected): + result = RuntimeRandomInt().validate_number_of_args(args) + assert result is expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([1, 100], AnyFloat()), + ([10.24, 100.54], AnyFloat()), + ], +) +def test_runtime_random_float_execute(args, expected): + parsed_args = RuntimeRandomFloat().parse_args(args) + result = RuntimeRandomFloat().execute({}, parsed_args) + assert result == expected + assert result >= args[0] and result <= args[1] + + +@pytest.mark.parametrize( + "args,expected", + [ + # numeric types are allowed + ([1, 100], None), + ([2.5, 56.64], None), + (["3", "4.5"], None), + # Invalid types for 1st arg + ([{}, 5], {}), + (["foo", 5], "foo"), + # Invalid types for 2nd arg + ([5, {}], {}), + ([5, "foo"], "foo"), + ], +) +def test_runtime_random_float_validate_type_of_args(args, expected): + result = RuntimeRandomFloat().validate_type_of_args(args) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([], False), + (["foo"], False), + (["foo", "bar"], True), + (["foo", "bar", "baz"], False), + ], +) +def test_runtime_random_float_validate_number_of_args(args, expected): + result = RuntimeRandomFloat().validate_number_of_args(args) + assert result is expected + + +def test_runtime_random_bool_execute(): + parsed_args = RuntimeRandomBool().parse_args([]) + result = RuntimeRandomBool().execute({}, parsed_args) + assert result == AnyBool() + + +@pytest.mark.parametrize( + "args,expected", + [ + ([], True), + (["foo"], False), + (["foo", "bar"], False), + ], +) +def test_runtime_random_bool_validate_number_of_args(args, expected): + result = RuntimeRandomBool().validate_number_of_args(args) + assert result is expected + + +def test_runtime_generate_uuid_execute(): + parsed_args = RuntimeGenerateUUID().parse_args([]) + result = RuntimeGenerateUUID().execute({}, parsed_args) + assert isinstance(result, str) + + parsed = uuid.UUID(result) + assert str(parsed) == result + + +@pytest.mark.parametrize( + "args,expected", + [ + ([], True), + (["foo"], False), + (["foo", "bar"], False), + ], +) +def test_runtime_generate_uuid_validate_number_of_args(args, expected): + result = RuntimeGenerateUUID().validate_number_of_args(args) + assert result is expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([True, "foo", "bar"], "foo"), + ([False, "foo", "bar"], "bar"), + ], +) +def test_runtime_if_execute(args, expected): + parsed_args = RuntimeIf().parse_args(args) + result = RuntimeIf().execute({}, parsed_args) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + # Valid types for 1st arg (2nd and 3rd args can be Any) + ([True, "foo", "bar"], None), + ([False, "foo", "bar"], None), + (["true", "foo", "bar"], None), + (["false", "foo", "bar"], None), + (["True", "foo", "bar"], None), + (["False", "foo", "bar"], None), + ], +) +def test_runtime_if_validate_type_of_args(args, expected): + result = RuntimeIf().validate_type_of_args(args) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([], False), + (["foo"], False), + (["foo", "bar"], False), + (["foo", "bar", "baz"], True), + (["foo", "bar", "baz", "bat"], False), + ], +) +def test_runtime_if_validate_number_of_args(args, expected): + result = RuntimeIf().validate_number_of_args(args) + assert result is expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([True, True], True), + ([True, False], False), + ([False, False], False), + ], +) +def test_runtime_and_execute(args, expected): + parsed_args = RuntimeAnd().parse_args(args) + result = RuntimeAnd().execute({}, parsed_args) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + # Valid types for 1st arg + ([True, True], None), + (["true", True], None), + (["True", True], None), + ([False, True], None), + (["false", True], None), + (["False", True], None), + # Valid types for 2nd arg + ([True, True], None), + ([True, "true"], None), + ([True, "True"], None), + ([True, False], None), + ([True, "false"], None), + ([True, "False"], None), + # Invalid types for 1st arg + (["foo", True], "foo"), + ([{}, True], {}), + (["", True], ""), + ([100, True], 100), + # Invalid types for 2nd arg + ([True, "foo"], "foo"), + ([True, {}], {}), + ([True, ""], ""), + ([True, 100], 100), + ], +) +def test_runtime_and_validate_type_of_args(args, expected): + result = RuntimeAnd().validate_type_of_args(args) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([], False), + (["foo"], False), + (["foo", "bar"], True), + (["foo", "bar", "baz"], False), + ], +) +def test_runtime_and_validate_number_of_args(args, expected): + result = RuntimeAnd().validate_number_of_args(args) + assert result is expected + + +## +@pytest.mark.parametrize( + "args,expected", + [ + ([True, True], True), + ([True, False], True), + ([False, False], False), + ], +) +def test_runtime_or_execute(args, expected): + parsed_args = RuntimeOr().parse_args(args) + result = RuntimeOr().execute({}, parsed_args) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + # Valid types for 1st arg + ([True, True], None), + (["true", True], None), + (["True", True], None), + ([False, True], None), + (["false", True], None), + (["False", True], None), + # Valid types for 2nd arg + ([True, True], None), + ([True, "true"], None), + ([True, "True"], None), + ([True, False], None), + ([True, "false"], None), + ([True, "False"], None), + # Invalid types for 1st or 2nd arg + (["foo", True], "foo"), + ([{}, True], {}), + (["", True], ""), + ([100, True], 100), + ([True, "foo"], "foo"), + ([True, {}], {}), + ([True, ""], ""), + ([True, 100], 100), + ], +) +def test_runtime_or_validate_type_of_args(args, expected): + result = RuntimeOr().validate_type_of_args(args) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([], False), + (["foo"], False), + (["foo", "bar"], True), + (["foo", "bar", "baz"], False), + ], +) +def test_runtime_or_validate_number_of_args(args, expected): + result = RuntimeOr().validate_number_of_args(args) + assert result is expected diff --git a/enterprise/backend/src/baserow_enterprise/assistant/handler.py b/enterprise/backend/src/baserow_enterprise/assistant/handler.py index fa7033fffc..bfe8b2bf72 100644 --- a/enterprise/backend/src/baserow_enterprise/assistant/handler.py +++ b/enterprise/backend/src/baserow_enterprise/assistant/handler.py @@ -3,6 +3,7 @@ from uuid import UUID from django.contrib.auth.models import AbstractUser +from django.db.models import Count from baserow.core.models import Workspace @@ -63,9 +64,16 @@ def list_chats(self, user: AbstractUser, workspace_id: int) -> list[AssistantCha List all AI assistant chats for the user in the specified workspace. """ - return AssistantChat.objects.filter( - workspace_id=workspace_id, user=user - ).order_by("-updated_on", "id") + return ( + AssistantChat.objects.filter( + workspace_id=workspace_id, + user=user, + ) + .exclude(title="") + .annotate(message_count=Count("messages")) + .filter(message_count__gt=0) + .order_by("-updated_on", "id") + ) def get_chat_message_by_id(self, user: AbstractUser, message_id: int) -> AiMessage: """ diff --git a/enterprise/backend/tests/baserow_enterprise_tests/api/assistant/test_assistant_views.py b/enterprise/backend/tests/baserow_enterprise_tests/api/assistant/test_assistant_views.py index f5705824c6..4d41473e0f 100644 --- a/enterprise/backend/tests/baserow_enterprise_tests/api/assistant/test_assistant_views.py +++ b/enterprise/backend/tests/baserow_enterprise_tests/api/assistant/test_assistant_views.py @@ -75,7 +75,16 @@ def test_list_assistant_chats(api_client, enterprise_data_fixture): for i in range(chats_count) ] with freeze_time("2024-01-14 12:00:00"): - AssistantChat.objects.bulk_create(chats) + created_chats = AssistantChat.objects.bulk_create(chats) + messages = [ + AssistantChatMessage( + role=AssistantChatMessage.Role.HUMAN, + content="What's the weather like?", + chat=chat, + ) + for chat in created_chats + ] + AssistantChatMessage.objects.bulk_create(messages) rsp = api_client.get( reverse("assistant:list") + f"?workspace_id={workspace.id}", @@ -123,6 +132,50 @@ def test_list_assistant_chats(api_client, enterprise_data_fixture): assert data["next"] is not None +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_not_list_empty_assistant_chats(api_client, enterprise_data_fixture): + user, token = enterprise_data_fixture.create_user_and_token() + workspace = enterprise_data_fixture.create_workspace(user=user) + enterprise_data_fixture.enable_enterprise() + + chats = [ + AssistantChat(workspace=workspace, user=user, title=""), + AssistantChat(workspace=workspace, user=user, title="test"), + AssistantChat(workspace=workspace, user=user, title="test2"), + ] + chats = AssistantChat.objects.bulk_create(chats) + + messages = [ + AssistantChatMessage( + id=1, + role=AssistantChatMessage.Role.HUMAN, + content="What's the weather like?", + chat=chats[0], + ), + AssistantChatMessage( + id=2, + role=AssistantChatMessage.Role.HUMAN, + content="What's the weather like?", + chat=chats[2], + ), + ] + AssistantChatMessage.objects.bulk_create(messages) + + rsp = api_client.get( + reverse("assistant:list") + f"?workspace_id={workspace.id}", + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert rsp.status_code == 200 + data = rsp.json() + assert data["count"] == 1 + assert len(data["results"]) == 1 + # The first chat does not have a title, and the second one does not have any + # messages. They should therefore both be excluded. + assert data["results"][0]["uuid"] == str(chats[2].uuid) + + @pytest.mark.django_db @override_settings(DEBUG=True) def test_cannot_send_message_without_valid_workspace( diff --git a/premium/backend/tests/baserow_premium_tests/fields/test_ai_field_type.py b/premium/backend/tests/baserow_premium_tests/fields/test_ai_field_type.py index 108b95e7dd..dd508d8128 100644 --- a/premium/backend/tests/baserow_premium_tests/fields/test_ai_field_type.py +++ b/premium/backend/tests/baserow_premium_tests/fields/test_ai_field_type.py @@ -3,6 +3,7 @@ import pytest from baserow_premium.fields.field_types import AIFieldType from baserow_premium.fields.models import AIField +from pytest_unordered import unordered from rest_framework.status import HTTP_200_OK, HTTP_400_BAD_REQUEST, HTTP_404_NOT_FOUND from baserow.contrib.database.fields.dependencies.models import FieldDependency @@ -1258,15 +1259,16 @@ def test_create_ai_field_with_references(premium_data_fixture): ai_prompt=f"concat('test:',get('fields.field_{ai_field.id}'))", ) - deps = list(FieldDependency.objects.all().order_by("dependant", "dependency")) + deps = list(FieldDependency.objects.values("dependant_id", "dependency_id")) assert len(deps) == 3 - assert deps[0].dependant_id == ai_field.id - assert deps[0].dependency_id == text_field.id - assert deps[1].dependant_id == ai_field.id - assert deps[1].dependency_id == other_text_field.id - assert deps[2].dependency_id == ai_field.id - assert deps[2].dependant_id == other_ai_field.id + assert deps == unordered( + [ + {"dependant_id": ai_field.id, "dependency_id": text_field.id}, + {"dependant_id": ai_field.id, "dependency_id": other_text_field.id}, + {"dependant_id": other_ai_field.id, "dependency_id": ai_field.id}, + ] + ) @pytest.mark.django_db diff --git a/web-frontend/locales/en.json b/web-frontend/locales/en.json index 6c17e02c1f..7ae53a8948 100644 --- a/web-frontend/locales/en.json +++ b/web-frontend/locales/en.json @@ -678,7 +678,7 @@ "formulaTypeFormula": "Function | Functions", "formulaTypeOperator": "Operator | Operators", "formulaTypeData": "Data", - "formulaTypeDataEmpty": "No data sources available", + "formulaTypeDataEmpty": "No data available", "categoryText": "Text", "categoryNumber": "Number", "categoryBoolean": "Boolean", diff --git a/web-frontend/modules/automation/components/workflow/WorkflowNodeContent.vue b/web-frontend/modules/automation/components/workflow/WorkflowNodeContent.vue index dbd9863462..79e1e19be6 100644 --- a/web-frontend/modules/automation/components/workflow/WorkflowNodeContent.vue +++ b/web-frontend/modules/automation/components/workflow/WorkflowNodeContent.vue @@ -17,12 +17,14 @@
chat:writescope.", + "supportPairingHeading": "2. Pairing with your Slack app", + "supportPairingStep1": "If your app is new: navigate to the 'Settings' > 'Install App'. Click the green button to install the app to your workspace.", + "supportPairingStep2": "Copy your 'Bot User OAuth Token' and store it in the 'Bot User Token' field in this form.", + "supportPairingStep3": "Finally, if your app is new: in Slack, invite your app to your chosen channel with
/invite @yourAppName yourChannel" + }, + "slackWriteMessageServiceForm": { + "alertMessage": "This action must be paired with a Slack app. Please follow the guide in the integrations popup to get started.", + "integrationLabel": "Integration", + "channelLabel": "Channel", + "channelPlaceholder": "Enter a channel name", + "messageLabel": "Message", + "messagePlaceholder": "Enter a message...", + "channelNoPrefix": "Remove the '#' before the channel name." + }, "localBaserowTableSelector": { "viewFieldLabel": "View", "tableFieldLabel": "Table", diff --git a/web-frontend/modules/integrations/plugin.js b/web-frontend/modules/integrations/plugin.js index 038385a641..a03fc58be5 100644 --- a/web-frontend/modules/integrations/plugin.js +++ b/web-frontend/modules/integrations/plugin.js @@ -9,8 +9,8 @@ import ko from '@baserow/modules/integrations/locales/ko.json' import { FF_AUTOMATION } from '@baserow/modules/core/plugins/featureFlags' import { LocalBaserowIntegrationType } from '@baserow/modules/integrations/localBaserow/integrationTypes' -import { SMTPIntegrationType } from '@baserow/modules/integrations/core/integrationTypes' import { AIIntegrationType } from '@baserow/modules/integrations/ai/integrationTypes' +import { SMTPIntegrationType } from '@baserow/modules/integrations/core/integrationTypes' import { LocalBaserowGetRowServiceType, LocalBaserowListRowsServiceType, @@ -31,6 +31,8 @@ import { CoreIteratorServiceType, } from '@baserow/modules/integrations/core/serviceTypes' import { AIAgentServiceType } from '@baserow/modules/integrations/ai/serviceTypes' +import { SlackWriteMessageServiceType } from '@baserow/modules/integrations/slack/serviceTypes' +import { SlackBotIntegrationType } from '@baserow/modules/integrations/slack/integrationTypes' export default (context) => { const { app, isDev } = context @@ -54,6 +56,7 @@ export default (context) => { ) app.$registry.register('integration', new SMTPIntegrationType(context)) app.$registry.register('integration', new AIIntegrationType(context)) + app.$registry.register('integration', new SlackBotIntegrationType(context)) app.$registry.register('service', new LocalBaserowGetRowServiceType(context)) app.$registry.register( @@ -84,6 +87,7 @@ export default (context) => { app.$registry.register('service', new AIAgentServiceType(context)) app.$registry.register('service', new PeriodicTriggerServiceType(context)) + app.$registry.register('service', new SlackWriteMessageServiceType(context)) if (app.$featureFlagIsEnabled(FF_AUTOMATION)) { app.$registry.register( diff --git a/web-frontend/modules/integrations/slack/assets/images/slack.svg b/web-frontend/modules/integrations/slack/assets/images/slack.svg new file mode 100644 index 0000000000..69a4eb6a21 --- /dev/null +++ b/web-frontend/modules/integrations/slack/assets/images/slack.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web-frontend/modules/integrations/slack/components/integrations/SlackBotForm.vue b/web-frontend/modules/integrations/slack/components/integrations/SlackBotForm.vue new file mode 100644 index 0000000000..9ed2ad1fea --- /dev/null +++ b/web-frontend/modules/integrations/slack/components/integrations/SlackBotForm.vue @@ -0,0 +1,117 @@ + +
{{ $t('slackBotForm.supportDescription') }}
++ {{ $t('slackBotForm.supportSetupDescription') }} +
+