Skip to content

Commit b0bbdbc

Browse files
authored
Implement datetime_format() formula function (baserow#4148)
* Implement datetime_format and update examples. * Implement the moment to python datetime format converter * Add tests for moment.js to Python datetime formatter. * Refactor to support timezone in the user data provider. * Use the new Timezone argument type and validate the timezone name
1 parent 1435d48 commit b0bbdbc

File tree

18 files changed

+290
-25
lines changed

18 files changed

+290
-25
lines changed

backend/src/baserow/contrib/automation/automation_dispatch_context.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,14 @@ def clone(self, **kwargs):
6969
def data_provider_registry(self):
7070
return automation_data_provider_type_registry
7171

72+
def get_timezone_name(self) -> str:
73+
"""
74+
TODO: Get the timezone from the application settings. For now, returns
75+
the default of "UTC". See: https://github.com/baserow/baserow/issues/4157
76+
"""
77+
78+
return super().get_timezone_name()
79+
7280
def _register_node_result(
7381
self, node: AutomationNode, dispatch_data: Dict[str, Any]
7482
):

backend/src/baserow/contrib/builder/api/data_providers/serializers.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
import pytz
12
from rest_framework import serializers
23
from rest_framework.exceptions import ValidationError
34

45
from baserow.contrib.builder.elements.models import Element
56

7+
IANA_TIMEZONES = [(tz, tz) for tz in pytz.all_timezones]
8+
69

710
class DispatchDataSourceDataSourceContextSerializer(serializers.Serializer):
811
element = serializers.PrimaryKeyRelatedField(
@@ -34,3 +37,15 @@ def validate(self, data):
3437
)
3538

3639
return data
40+
41+
42+
class DispatchDataSourceUserContextSerializer(serializers.Serializer):
43+
id = serializers.IntegerField(
44+
help_text="Current user id.", required=False, allow_null=True
45+
)
46+
timezone = serializers.ChoiceField(
47+
help_text="An IANA timezone name.",
48+
required=False,
49+
allow_null=True,
50+
choices=IANA_TIMEZONES,
51+
)

backend/src/baserow/contrib/builder/data_providers/data_provider_types.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -593,10 +593,12 @@ def get_request_serializer(self):
593593
Returns the serializer used to parse data for this data provider.
594594
"""
595595

596-
return serializers.IntegerField(
597-
help_text="Current user id.", required=False, allow_null=True
596+
from baserow.contrib.builder.api.data_providers.serializers import (
597+
DispatchDataSourceUserContextSerializer,
598598
)
599599

600+
return DispatchDataSourceUserContextSerializer(required=False, allow_null=True)
601+
600602
def translate_default_user_role(self, user: UserSourceUser) -> str:
601603
"""
602604
Returns the translated version of the user role if it is a default role,

backend/src/baserow/contrib/builder/data_sources/builder_dispatch_context.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,15 @@ def element_type(self) -> Optional[Type["ElementType"]]:
127127

128128
return self.element.get_type() # type: ignore
129129

130+
def get_timezone_name(self) -> str:
131+
"""
132+
Returns the timezone from the user data provider.
133+
"""
134+
135+
return self.request_data.get("user", {}).get(
136+
"timezone", super().get_timezone_name()
137+
)
138+
130139
def range(self, service):
131140
"""
132141
Return page range from the `offset`, `count` kwargs,

backend/src/baserow/core/formula/argument_types.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1+
from typing import Optional
2+
13
from django.core.exceptions import ValidationError
24

5+
import pytz
6+
37
from baserow.core.formula.validator import (
48
ensure_boolean,
59
ensure_datetime,
@@ -10,6 +14,9 @@
1014

1115

1216
class BaserowRuntimeFormulaArgumentType:
17+
def __init__(self, optional: Optional[bool] = False):
18+
self.optional = optional
19+
1320
def test(self, value):
1421
return True
1522

@@ -87,3 +94,14 @@ def test(self, value):
8794

8895
def parse(self, value):
8996
return ensure_boolean(value)
97+
98+
99+
class TimezoneBaserowRuntimeFormulaArgumentType(BaserowRuntimeFormulaArgumentType):
100+
def test(self, value):
101+
if not isinstance(value, str):
102+
return False
103+
104+
return value in pytz.all_timezones
105+
106+
def parse(self, value):
107+
return ensure_string(value)

backend/src/baserow/core/formula/registries.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,13 @@ def validate_number_of_args(self, args: FormulaArgs) -> bool:
8484
:return: If the number of arguments is correct
8585
"""
8686

87-
return self.num_args is None or len(args) <= self.num_args
87+
if self.num_args is None:
88+
return True
89+
90+
required_args = len([arg for arg in self.args if not arg.optional])
91+
total_args = len(self.args)
92+
93+
return len(args) >= required_args and len(args) <= total_args
8894

8995
def validate_type_of_args(self, args: FormulaArgs) -> Optional[FormulaArg]:
9096
"""

backend/src/baserow/core/formula/runtime_formula_context.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from typing import TYPE_CHECKING, Any
22

3+
from django.utils import timezone
4+
35
from baserow.core.formula.types import FormulaContext
46
from baserow.core.utils import to_path
57

@@ -38,3 +40,12 @@ def __getitem__(self, key: str) -> Any:
3840
self,
3941
rest,
4042
)
43+
44+
def get_timezone_name(self) -> str:
45+
"""
46+
Returns the current IANA timezone name, e.g. "Europe/Amsterdam".
47+
48+
Defaults to "UTC".
49+
"""
50+
51+
return timezone.get_current_timezone_name()

backend/src/baserow/core/formula/runtime_formula_types.py

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,11 @@
1515
NumberBaserowRuntimeFormulaArgumentType,
1616
SubtractableBaserowRuntimeFormulaArgumentType,
1717
TextBaserowRuntimeFormulaArgumentType,
18+
TimezoneBaserowRuntimeFormulaArgumentType,
1819
)
1920
from baserow.core.formula.registries import RuntimeFormulaFunction
2021
from baserow.core.formula.types import FormulaArg, FormulaArgs, FormulaContext
22+
from baserow.core.formula.utils.date import convert_date_format_moment_to_python
2123
from baserow.core.formula.validator import ensure_string
2224

2325

@@ -262,23 +264,32 @@ class RuntimeDateTimeFormat(RuntimeFormulaFunction):
262264
args = [
263265
DateTimeBaserowRuntimeFormulaArgumentType(),
264266
TextBaserowRuntimeFormulaArgumentType(),
265-
TextBaserowRuntimeFormulaArgumentType(),
267+
TimezoneBaserowRuntimeFormulaArgumentType(optional=True),
266268
]
267269

268270
def execute(self, context: FormulaContext, args: FormulaArgs):
269-
tz_name = None
270-
tz_format = "%Y-%m-%d %H:%M:%S"
271+
datetime_obj = args[0]
272+
moment_format = args[1]
273+
274+
if (len(args)) == 2:
275+
timezone_name = context.get_timezone_name()
276+
else:
277+
timezone_name = args[2]
271278

272-
if len(args) == 3:
273-
tz_name = args[2]
274-
tz_format = args[1]
275-
elif len(args) == 2:
276-
tz_format = args[1]
279+
python_format = convert_date_format_moment_to_python(moment_format)
280+
result = datetime_obj.astimezone(ZoneInfo(timezone_name)).strftime(
281+
python_format
282+
)
277283

278-
if tz_name:
279-
return args[0].replace(tzinfo=ZoneInfo(tz_name)).strftime(tz_format)
284+
if "SSS" in moment_format:
285+
# When Moment's SSS is milliseconds (3 digits), but Python's %f
286+
# is microseconds (6 digits). We need to replace the microseconds
287+
# with milliseconds.
288+
microseconds_str = f"{datetime_obj.microsecond:06d}"
289+
milliseconds_str = f"{datetime_obj.microsecond // 1000:03d}"
290+
result = result.replace(microseconds_str, milliseconds_str)
280291

281-
return args[0].strftime(tz_format)
292+
return result
282293

283294

284295
class RuntimeDay(RuntimeFormulaFunction):

backend/src/baserow/core/formula/utils/__init__.py

Whitespace-only changes.
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import re
2+
3+
4+
def convert_date_format_moment_to_python(moment_format: str) -> str:
5+
"""
6+
Convert a Moment.js datetime string to the Python strftime equivalent.
7+
8+
:param moment_format: The Moment.js format, e.g. 'YYYY-MM-DD'
9+
:return: The Python datetime equivalent, e.g. '%Y-%m-%d'
10+
"""
11+
12+
format_map = {
13+
"YYYY": "%Y",
14+
"YY": "%y",
15+
"MMMM": "%B",
16+
"MMM": "%b",
17+
"MM": "%m",
18+
"M": "%-m",
19+
"DD": "%d",
20+
"D": "%-d",
21+
"dddd": "%A",
22+
"ddd": "%a",
23+
"HH": "%H",
24+
"H": "%-H",
25+
"hh": "%I",
26+
"h": "%-I",
27+
"mm": "%M",
28+
"m": "%-M",
29+
"ss": "%S",
30+
"s": "%-S",
31+
"A": "%p",
32+
"a": "%p",
33+
# Moment's SSS is milliseconds, whereas Python's %f is microseconds
34+
"SSS": "%f",
35+
}
36+
37+
# Sort longest tokens first so 'MMMM' matches before 'MM', etc.
38+
tokens = sorted(format_map.keys(), key=len, reverse=True)
39+
40+
# Build a regex, e.g. re.compile("YYYY|YY")
41+
pattern = re.compile("|".join(token for token in tokens))
42+
43+
def replace_token(match: re.Match) -> str:
44+
"""Replace a matched Moment token with its Python equivalent."""
45+
46+
token = match.group(0)
47+
return format_map[token]
48+
49+
return pattern.sub(replace_token, moment_format)

0 commit comments

Comments
 (0)