Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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]
):
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
18 changes: 18 additions & 0 deletions backend/src/baserow/core/formula/argument_types.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -10,6 +14,9 @@


class BaserowRuntimeFormulaArgumentType:
def __init__(self, optional: Optional[bool] = False):
self.optional = optional

def test(self, value):
return True

Expand Down Expand Up @@ -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)
8 changes: 7 additions & 1 deletion backend/src/baserow/core/formula/registries.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
"""
Expand Down
11 changes: 11 additions & 0 deletions backend/src/baserow/core/formula/runtime_formula_context.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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()
33 changes: 22 additions & 11 deletions backend/src/baserow/core/formula/runtime_formula_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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):
Expand Down
Empty file.
49 changes: 49 additions & 0 deletions backend/src/baserow/core/formula/utils/date.py
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Empty file.
51 changes: 51 additions & 0 deletions backend/tests/baserow/core/utils/test_date.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion web-frontend/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
12 changes: 10 additions & 2 deletions web-frontend/modules/builder/dataProviderTypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
}

Expand Down
5 changes: 5 additions & 0 deletions web-frontend/modules/core/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ import {
RuntimeRound,
RuntimeIsEven,
RuntimeIsOdd,
RuntimeDateTimeFormat,
RuntimeDay,
RuntimeMonth,
RuntimeYear,
Expand Down Expand Up @@ -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))
Expand Down
Loading
Loading