diff --git a/backend/src/baserow/contrib/automation/automation_dispatch_context.py b/backend/src/baserow/contrib/automation/automation_dispatch_context.py index 67bbc7b8f2..a93237060b 100644 --- a/backend/src/baserow/contrib/automation/automation_dispatch_context.py +++ b/backend/src/baserow/contrib/automation/automation_dispatch_context.py @@ -69,6 +69,14 @@ def clone(self, **kwargs): def data_provider_registry(self): return automation_data_provider_type_registry + def get_timezone_name(self) -> str: + """ + TODO: Get the timezone from the application settings. For now, returns + the default of "UTC". See: https://github.com/baserow/baserow/issues/4157 + """ + + return super().get_timezone_name() + def _register_node_result( self, node: AutomationNode, dispatch_data: Dict[str, Any] ): diff --git a/backend/src/baserow/contrib/builder/api/data_providers/serializers.py b/backend/src/baserow/contrib/builder/api/data_providers/serializers.py index 5dfd5e380a..9ae9057392 100644 --- a/backend/src/baserow/contrib/builder/api/data_providers/serializers.py +++ b/backend/src/baserow/contrib/builder/api/data_providers/serializers.py @@ -1,8 +1,11 @@ +import pytz from rest_framework import serializers from rest_framework.exceptions import ValidationError from baserow.contrib.builder.elements.models import Element +IANA_TIMEZONES = [(tz, tz) for tz in pytz.all_timezones] + class DispatchDataSourceDataSourceContextSerializer(serializers.Serializer): element = serializers.PrimaryKeyRelatedField( @@ -34,3 +37,15 @@ def validate(self, data): ) return data + + +class DispatchDataSourceUserContextSerializer(serializers.Serializer): + id = serializers.IntegerField( + help_text="Current user id.", required=False, allow_null=True + ) + timezone = serializers.ChoiceField( + help_text="An IANA timezone name.", + required=False, + allow_null=True, + choices=IANA_TIMEZONES, + ) diff --git a/backend/src/baserow/contrib/builder/data_providers/data_provider_types.py b/backend/src/baserow/contrib/builder/data_providers/data_provider_types.py index 26cf684c8d..25722ac04f 100644 --- a/backend/src/baserow/contrib/builder/data_providers/data_provider_types.py +++ b/backend/src/baserow/contrib/builder/data_providers/data_provider_types.py @@ -593,10 +593,12 @@ def get_request_serializer(self): Returns the serializer used to parse data for this data provider. """ - return serializers.IntegerField( - help_text="Current user id.", required=False, allow_null=True + from baserow.contrib.builder.api.data_providers.serializers import ( + DispatchDataSourceUserContextSerializer, ) + return DispatchDataSourceUserContextSerializer(required=False, allow_null=True) + def translate_default_user_role(self, user: UserSourceUser) -> str: """ Returns the translated version of the user role if it is a default role, diff --git a/backend/src/baserow/contrib/builder/data_sources/builder_dispatch_context.py b/backend/src/baserow/contrib/builder/data_sources/builder_dispatch_context.py index 48b851b422..40f11c789d 100644 --- a/backend/src/baserow/contrib/builder/data_sources/builder_dispatch_context.py +++ b/backend/src/baserow/contrib/builder/data_sources/builder_dispatch_context.py @@ -127,6 +127,15 @@ def element_type(self) -> Optional[Type["ElementType"]]: return self.element.get_type() # type: ignore + def get_timezone_name(self) -> str: + """ + Returns the timezone from the user data provider. + """ + + return self.request_data.get("user", {}).get( + "timezone", super().get_timezone_name() + ) + def range(self, service): """ Return page range from the `offset`, `count` kwargs, diff --git a/backend/src/baserow/core/formula/argument_types.py b/backend/src/baserow/core/formula/argument_types.py index 53e86736e8..2baea8bfc2 100644 --- a/backend/src/baserow/core/formula/argument_types.py +++ b/backend/src/baserow/core/formula/argument_types.py @@ -1,5 +1,9 @@ +from typing import Optional + from django.core.exceptions import ValidationError +import pytz + from baserow.core.formula.validator import ( ensure_boolean, ensure_datetime, @@ -10,6 +14,9 @@ class BaserowRuntimeFormulaArgumentType: + def __init__(self, optional: Optional[bool] = False): + self.optional = optional + def test(self, value): return True @@ -87,3 +94,14 @@ def test(self, value): def parse(self, value): return ensure_boolean(value) + + +class TimezoneBaserowRuntimeFormulaArgumentType(BaserowRuntimeFormulaArgumentType): + def test(self, value): + if not isinstance(value, str): + return False + + return value in pytz.all_timezones + + def parse(self, value): + return ensure_string(value) diff --git a/backend/src/baserow/core/formula/registries.py b/backend/src/baserow/core/formula/registries.py index 40ff72db6a..85a735af78 100644 --- a/backend/src/baserow/core/formula/registries.py +++ b/backend/src/baserow/core/formula/registries.py @@ -84,7 +84,13 @@ def validate_number_of_args(self, args: FormulaArgs) -> bool: :return: If the number of arguments is correct """ - return self.num_args is None or len(args) <= self.num_args + if self.num_args is None: + return True + + required_args = len([arg for arg in self.args if not arg.optional]) + total_args = len(self.args) + + return len(args) >= required_args and len(args) <= total_args def validate_type_of_args(self, args: FormulaArgs) -> Optional[FormulaArg]: """ diff --git a/backend/src/baserow/core/formula/runtime_formula_context.py b/backend/src/baserow/core/formula/runtime_formula_context.py index 07f9e61154..492a0b759a 100644 --- a/backend/src/baserow/core/formula/runtime_formula_context.py +++ b/backend/src/baserow/core/formula/runtime_formula_context.py @@ -1,5 +1,7 @@ from typing import TYPE_CHECKING, Any +from django.utils import timezone + from baserow.core.formula.types import FormulaContext from baserow.core.utils import to_path @@ -38,3 +40,12 @@ def __getitem__(self, key: str) -> Any: self, rest, ) + + def get_timezone_name(self) -> str: + """ + Returns the current IANA timezone name, e.g. "Europe/Amsterdam". + + Defaults to "UTC". + """ + + return timezone.get_current_timezone_name() diff --git a/backend/src/baserow/core/formula/runtime_formula_types.py b/backend/src/baserow/core/formula/runtime_formula_types.py index 71e86e5b11..a5c2ed6f38 100644 --- a/backend/src/baserow/core/formula/runtime_formula_types.py +++ b/backend/src/baserow/core/formula/runtime_formula_types.py @@ -15,9 +15,11 @@ NumberBaserowRuntimeFormulaArgumentType, SubtractableBaserowRuntimeFormulaArgumentType, TextBaserowRuntimeFormulaArgumentType, + TimezoneBaserowRuntimeFormulaArgumentType, ) from baserow.core.formula.registries import RuntimeFormulaFunction from baserow.core.formula.types import FormulaArg, FormulaArgs, FormulaContext +from baserow.core.formula.utils.date import convert_date_format_moment_to_python from baserow.core.formula.validator import ensure_string @@ -262,23 +264,32 @@ class RuntimeDateTimeFormat(RuntimeFormulaFunction): args = [ DateTimeBaserowRuntimeFormulaArgumentType(), TextBaserowRuntimeFormulaArgumentType(), - TextBaserowRuntimeFormulaArgumentType(), + TimezoneBaserowRuntimeFormulaArgumentType(optional=True), ] def execute(self, context: FormulaContext, args: FormulaArgs): - tz_name = None - tz_format = "%Y-%m-%d %H:%M:%S" + datetime_obj = args[0] + moment_format = args[1] + + if (len(args)) == 2: + timezone_name = context.get_timezone_name() + else: + timezone_name = args[2] - if len(args) == 3: - tz_name = args[2] - tz_format = args[1] - elif len(args) == 2: - tz_format = args[1] + python_format = convert_date_format_moment_to_python(moment_format) + result = datetime_obj.astimezone(ZoneInfo(timezone_name)).strftime( + python_format + ) - if tz_name: - return args[0].replace(tzinfo=ZoneInfo(tz_name)).strftime(tz_format) + if "SSS" in moment_format: + # When Moment's SSS is milliseconds (3 digits), but Python's %f + # is microseconds (6 digits). We need to replace the microseconds + # with milliseconds. + microseconds_str = f"{datetime_obj.microsecond:06d}" + milliseconds_str = f"{datetime_obj.microsecond // 1000:03d}" + result = result.replace(microseconds_str, milliseconds_str) - return args[0].strftime(tz_format) + return result class RuntimeDay(RuntimeFormulaFunction): diff --git a/backend/src/baserow/core/formula/utils/__init__.py b/backend/src/baserow/core/formula/utils/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/src/baserow/core/formula/utils/date.py b/backend/src/baserow/core/formula/utils/date.py new file mode 100644 index 0000000000..95a53a9a43 --- /dev/null +++ b/backend/src/baserow/core/formula/utils/date.py @@ -0,0 +1,49 @@ +import re + + +def convert_date_format_moment_to_python(moment_format: str) -> str: + """ + Convert a Moment.js datetime string to the Python strftime equivalent. + + :param moment_format: The Moment.js format, e.g. 'YYYY-MM-DD' + :return: The Python datetime equivalent, e.g. '%Y-%m-%d' + """ + + format_map = { + "YYYY": "%Y", + "YY": "%y", + "MMMM": "%B", + "MMM": "%b", + "MM": "%m", + "M": "%-m", + "DD": "%d", + "D": "%-d", + "dddd": "%A", + "ddd": "%a", + "HH": "%H", + "H": "%-H", + "hh": "%I", + "h": "%-I", + "mm": "%M", + "m": "%-M", + "ss": "%S", + "s": "%-S", + "A": "%p", + "a": "%p", + # Moment's SSS is milliseconds, whereas Python's %f is microseconds + "SSS": "%f", + } + + # Sort longest tokens first so 'MMMM' matches before 'MM', etc. + tokens = sorted(format_map.keys(), key=len, reverse=True) + + # Build a regex, e.g. re.compile("YYYY|YY") + pattern = re.compile("|".join(token for token in tokens)) + + def replace_token(match: re.Match) -> str: + """Replace a matched Moment token with its Python equivalent.""" + + token = match.group(0) + return format_map[token] + + return pattern.sub(replace_token, moment_format) diff --git a/backend/tests/baserow/contrib/builder/data_sources/test_dispatch_context.py b/backend/tests/baserow/contrib/builder/data_sources/test_dispatch_context.py index f07b4f0925..569bf811e6 100644 --- a/backend/tests/baserow/contrib/builder/data_sources/test_dispatch_context.py +++ b/backend/tests/baserow/contrib/builder/data_sources/test_dispatch_context.py @@ -469,3 +469,34 @@ def test_builder_dispatch_context_public_allowed_properties_is_cached( with django_assert_num_queries(0): result = dispatch_context.public_allowed_properties assert result == expected_results + + +@pytest.mark.django_db +def test_get_timezone_name_base(data_fixture): + user = data_fixture.create_user() + page = data_fixture.create_builder_page(user=user) + + fake_request = HttpRequest() + + dispatch_context = BuilderDispatchContext(fake_request, page) + # Should default to UTC + assert dispatch_context.get_timezone_name() == "UTC" + + +@pytest.mark.django_db +def test_get_timezone_name_specific(data_fixture): + user = data_fixture.create_user() + page = data_fixture.create_builder_page(user=user) + + fake_request = HttpRequest() + fake_request.data = { + "metadata": json.dumps( + { + "user": {"timezone": "Europe/Amsterdam"}, + } + ) + } + + dispatch_context = BuilderDispatchContext(fake_request, page) + # When provided by the frontend, should use the specific timezone + assert dispatch_context.get_timezone_name() == "Europe/Amsterdam" diff --git a/backend/tests/baserow/core/utils/__init__.py b/backend/tests/baserow/core/utils/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/tests/baserow/core/utils/test_date.py b/backend/tests/baserow/core/utils/test_date.py new file mode 100644 index 0000000000..b4604140e1 --- /dev/null +++ b/backend/tests/baserow/core/utils/test_date.py @@ -0,0 +1,51 @@ +import pytest + +from baserow.core.formula.utils.date import convert_date_format_moment_to_python + + +@pytest.mark.parametrize( + "moment_format,python_format", + [ + ("YYYY", "%Y"), + ("YY", "%y"), + ("MMMM", "%B"), + ("MMM", "%b"), + ("MM", "%m"), + ("M", "%-m"), + ("DD", "%d"), + ("D", "%-d"), + ("dddd", "%A"), + ("ddd", "%a"), + ("HH", "%H"), + ("H", "%-H"), + ("hh", "%I"), + ("h", "%-I"), + ("mm", "%M"), + ("m", "%-M"), + ("ss", "%S"), + ("s", "%-S"), + ("A", "%p"), + ("a", "%p"), + ("SSS", "%f"), + ("", ""), + ("foo", "foo"), + # Some common formats + ("YYYY-MM-DD", "%Y-%m-%d"), + ("YYYY-MM-DD[T]HH:mm:ss", "%Y-%m-%d[T]%H:%M:%S"), + ("DD/MM/YYYY HH:mm:ss", "%d/%m/%Y %H:%M:%S"), + ("ddd, MMM D, YYYY h:mm A", "%a, %b %-d, %Y %-I:%M %p"), + ("MMMM D, YYYY", "%B %-d, %Y"), + ("MM-DD-YYYY", "%m-%d-%Y"), + ("HH:mm:ss", "%H:%M:%S"), + ("hh:mm A", "%I:%M %p"), + ("H:m:s", "%-H:%-M:%-S"), + ("h:m A", "%-I:%-M %p"), + # Ensure longer tokens are replaced correctly + ("MMMM MMM MM M", "%B %b %m %-m"), + ("YYYY-MM-DD YYYY", "%Y-%m-%d %Y"), + ("HH:mm HH:mm", "%H:%M %H:%M"), + ("DD DD DD", "%d %d %d"), + ], +) +def test_convert_date_format_moment_to_python(moment_format, python_format): + assert convert_date_format_moment_to_python(moment_format) == python_format diff --git a/web-frontend/locales/en.json b/web-frontend/locales/en.json index 9d86eb7f1c..96c266906a 100644 --- a/web-frontend/locales/en.json +++ b/web-frontend/locales/en.json @@ -658,7 +658,7 @@ "roundDescription": "Rounds the first argument to the number of decimal places specified by the second argument.", "evenDescription": "Return true if the argument is even, false otherwise.", "oddDescription": "Return true if the argument is odd, false otherwise.", - "dateTimeDescription": "Formats the date time argument using the format and timezone arguments.", + "dateTimeDescription": "Formats the date time argument using the format and timezone arguments. If the timezone is left blank, it will default to the browser's timezone.", "dayDescription": "Returns the day from the date time argument.", "monthDescription": "Returns the month from the date time argument.", "yearDescription": "Returns the year from the date time argument.", diff --git a/web-frontend/modules/builder/dataProviderTypes.js b/web-frontend/modules/builder/dataProviderTypes.js index 089cc0f038..f4f9b3a927 100644 --- a/web-frontend/modules/builder/dataProviderTypes.js +++ b/web-frontend/modules/builder/dataProviderTypes.js @@ -917,10 +917,18 @@ export class UserDataProviderType extends DataProviderType { const { is_authenticated: isAuthenticated, id } = this.getDataContent(applicationContext) + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone + if (isAuthenticated) { - return id + return { + id, + timezone, + } } else { - return null + return { + id: null, + timezone, + } } } diff --git a/web-frontend/modules/core/plugin.js b/web-frontend/modules/core/plugin.js index 09d5128a65..2dbb0e66e4 100644 --- a/web-frontend/modules/core/plugin.js +++ b/web-frontend/modules/core/plugin.js @@ -108,6 +108,7 @@ import { RuntimeRound, RuntimeIsEven, RuntimeIsOdd, + RuntimeDateTimeFormat, RuntimeDay, RuntimeMonth, RuntimeYear, @@ -282,6 +283,10 @@ export default (context, inject) => { registry.register('runtimeFormulaFunction', new RuntimeRound(context)) registry.register('runtimeFormulaFunction', new RuntimeIsEven(context)) registry.register('runtimeFormulaFunction', new RuntimeIsOdd(context)) + registry.register( + 'runtimeFormulaFunction', + new RuntimeDateTimeFormat(context) + ) registry.register('runtimeFormulaFunction', new RuntimeDay(context)) registry.register('runtimeFormulaFunction', new RuntimeMonth(context)) registry.register('runtimeFormulaFunction', new RuntimeYear(context)) diff --git a/web-frontend/modules/core/runtimeFormulaArgumentTypes.js b/web-frontend/modules/core/runtimeFormulaArgumentTypes.js index 17510d45f2..902f741f72 100644 --- a/web-frontend/modules/core/runtimeFormulaArgumentTypes.js +++ b/web-frontend/modules/core/runtimeFormulaArgumentTypes.js @@ -5,8 +5,13 @@ import { ensureObject, ensureBoolean, } from '@baserow/modules/core/utils/validator' +import moment from '@baserow/modules/core/moment' export class BaserowRuntimeFormulaArgumentType { + constructor({ optional = false } = {}) { + this.optional = optional + } + /** * This function tests if a given value is compatible with its type * @param value - The value being tests @@ -32,11 +37,17 @@ export class BaserowRuntimeFormulaArgumentType { export class NumberBaserowRuntimeFormulaArgumentType extends BaserowRuntimeFormulaArgumentType { test(value) { + // get() formula can't be resolved in the frontend because we don't have + // the data/context. Return true so that the enclosing formula can be resolved. + if (value === undefined) { + return false + } + return !isNaN(value) } parse(value) { - return ensureNumeric(value) + return ensureNumeric(value, { allowNull: true }) } } @@ -96,3 +107,17 @@ export class BooleanBaserowRuntimeFormulaArgumentType extends BaserowRuntimeForm return ensureBoolean(value) } } + +export class TimezoneBaserowRuntimeFormulaArgumentType extends BaserowRuntimeFormulaArgumentType { + test(value) { + if (value == null || typeof value.toString !== 'function') { + return false + } + + return moment.tz.names().includes(value) + } + + parse(value) { + return ensureString(value) + } +} diff --git a/web-frontend/modules/core/runtimeFormulaTypes.js b/web-frontend/modules/core/runtimeFormulaTypes.js index ab0ebefc83..7f52e8a505 100644 --- a/web-frontend/modules/core/runtimeFormulaTypes.js +++ b/web-frontend/modules/core/runtimeFormulaTypes.js @@ -5,6 +5,7 @@ import { DateTimeBaserowRuntimeFormulaArgumentType, ObjectBaserowRuntimeFormulaArgumentType, BooleanBaserowRuntimeFormulaArgumentType, + TimezoneBaserowRuntimeFormulaArgumentType, } from '@baserow/modules/core/runtimeFormulaArgumentTypes' import { InvalidFormulaArgumentType, @@ -16,6 +17,7 @@ import GetFormulaComponent from '@baserow/modules/core/components/formula/GetFor import { mergeAttributes } from '@tiptap/core' import { FORMULA_CATEGORY, FORMULA_TYPE } from '@baserow/modules/core/enums' import _ from 'lodash' +import moment from '@baserow/modules/core/moment' export class RuntimeFormulaFunction extends Registerable { /** @@ -85,7 +87,12 @@ export class RuntimeFormulaFunction extends Registerable { * @returns {boolean} - If the number is correct. */ validateNumberOfArgs(args) { - return this.numArgs === null || args.length === this.numArgs + if (this.numArgs === null) return true + + const requiredArgs = this.args.filter((arg) => !arg.optional).length + const totalArgs = this.args.length + + return args.length >= requiredArgs && args.length <= totalArgs } /** @@ -857,7 +864,7 @@ export class RuntimeIsEven extends RuntimeFormulaFunction { } getExamples() { - return ['even(12) = true'] + return ['is_even(12) = true'] } } @@ -888,7 +895,7 @@ export class RuntimeIsOdd extends RuntimeFormulaFunction { } getExamples() { - return ['odd(11) = true'] + return ['is_odd(11) = true'] } } @@ -909,13 +916,18 @@ export class RuntimeDateTimeFormat extends RuntimeFormulaFunction { return [ new DateTimeBaserowRuntimeFormulaArgumentType(), new TextBaserowRuntimeFormulaArgumentType(), - new TextBaserowRuntimeFormulaArgumentType(), + new TimezoneBaserowRuntimeFormulaArgumentType({ optional: true }), ] } execute(context, args) { - // TODO see: https://github.com/baserow/baserow/issues/4141 - throw new Error("This formula function hasn't been implemented.") + const [ + datetime, + momentFormat, + timezone = Intl.DateTimeFormat().resolvedOptions().timeZone, + ] = args + + return moment(datetime).tz(timezone).format(momentFormat) } getDescription() { @@ -924,7 +936,11 @@ export class RuntimeDateTimeFormat extends RuntimeFormulaFunction { } getExamples() { - return ["datetime_format(now(), '%Y-%m-%d', 'Asia/Dubai') = '2025-10-16'"] + return [ + "datetime_format(now(), 'YYYY-MM-DD') = '2025-11-03'", + "datetime_format(now(), 'YYYY-MM-DD', 'Europe/Amsterdam') = '2025-11-03'", + "datetime_format(now(), 'DD/MM/YYYY HH:mm:ss', 'UTC') = '03/11/2025 12:12:09'", + ] } }