mirror of
https://gitlab.com/bramw/baserow.git
synced 2025-03-31 11:25:00 +00:00
2️⃣ Support for duration in formula: add backend/frontend support
This commit is contained in:
parent
21b66a7aaa
commit
83cbe6ced2
30 changed files with 509 additions and 51 deletions
backend
src/baserow/contrib/database
api/fields
fields
formula
migrations
views
tests/baserow/contrib/database
changelog/entries/unreleased
breaking_change
feature
premium/backend/tests/baserow_premium_tests/export
web-frontend/modules/database
|
@ -301,4 +301,9 @@ class DurationFieldSerializer(serializers.Field):
|
|||
)
|
||||
|
||||
def to_representation(self, value):
|
||||
return value.total_seconds()
|
||||
if isinstance(value, (int, float)):
|
||||
# Durations are stored as the number of seconds for lookups/arrays in
|
||||
# formula land, so just return the value in that case.
|
||||
return value
|
||||
else:
|
||||
return value.total_seconds()
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
from typing import Any, Dict, List
|
||||
|
||||
from baserow.contrib.database.fields.registries import field_type_registry
|
||||
from baserow.contrib.database.fields.utils.duration import D_H_M
|
||||
|
||||
|
||||
def construct_all_possible_field_kwargs(
|
||||
|
@ -194,7 +195,11 @@ def construct_all_possible_field_kwargs(
|
|||
{"name": "formula_int", "formula": "1"},
|
||||
{"name": "formula_bool", "formula": "true"},
|
||||
{"name": "formula_decimal", "formula": "100/3"},
|
||||
{"name": "formula_dateinterval", "formula": "date_interval('1 day')"},
|
||||
{
|
||||
"name": "formula_dateinterval",
|
||||
"formula": "date_interval('1 day')",
|
||||
"duration_format": D_H_M,
|
||||
},
|
||||
{"name": "formula_date", "formula": "todate('20200101', 'YYYYMMDD')"},
|
||||
{"name": "formula_singleselect", "formula": "field('single_select')"},
|
||||
{"name": "formula_email", "formula": "field('email')"},
|
||||
|
|
|
@ -104,6 +104,7 @@ from baserow.core.utils import list_to_comma_separated_string
|
|||
|
||||
from ..formula.types.formula_types import (
|
||||
BaserowFormulaArrayType,
|
||||
BaserowFormulaDurationType,
|
||||
BaserowFormulaMultipleSelectType,
|
||||
BaserowFormulaSingleFileType,
|
||||
)
|
||||
|
@ -1796,6 +1797,14 @@ class DurationFieldType(FieldType):
|
|||
|
||||
return {**base, "duration_format": field.duration_format}
|
||||
|
||||
def to_baserow_formula_type(self, field) -> BaserowFormulaType:
|
||||
return BaserowFormulaDurationType(
|
||||
duration_format=field.duration_format, nullable=True
|
||||
)
|
||||
|
||||
def from_baserow_formula_type(self, formula_type: BaserowFormulaCharType):
|
||||
return self.model_class(duration_format=formula_type.duration_format)
|
||||
|
||||
|
||||
class LinkRowFieldType(ManyToManyFieldTypeSerializeToInputValueMixin, FieldType):
|
||||
"""
|
||||
|
|
|
@ -489,6 +489,13 @@ class FormulaField(Field):
|
|||
null=True,
|
||||
help_text="Force a timezone for the field overriding user profile settings.",
|
||||
)
|
||||
duration_format = models.CharField(
|
||||
choices=DURATION_FORMAT_CHOICES,
|
||||
default=DURATION_FORMAT_CHOICES[0][0],
|
||||
max_length=32,
|
||||
null=True,
|
||||
help_text=_("The format of the duration."),
|
||||
)
|
||||
needs_periodic_update = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Indicates if the field needs to be periodically updated.",
|
||||
|
|
|
@ -105,8 +105,8 @@ from baserow.contrib.database.formula.types.formula_types import (
|
|||
BaserowFormulaBooleanType,
|
||||
BaserowFormulaButtonType,
|
||||
BaserowFormulaCharType,
|
||||
BaserowFormulaDateIntervalType,
|
||||
BaserowFormulaDateType,
|
||||
BaserowFormulaDurationType,
|
||||
BaserowFormulaLinkType,
|
||||
BaserowFormulaMultipleSelectType,
|
||||
BaserowFormulaNumberType,
|
||||
|
@ -1237,9 +1237,12 @@ class BaserowEqual(TwoArgumentBaserowFunction):
|
|||
return func_call.with_valid_type(BaserowFormulaBooleanType())
|
||||
|
||||
def to_django_expression(self, arg1: Expression, arg2: Expression) -> Expression:
|
||||
return EqualsExpr(
|
||||
arg1,
|
||||
arg2,
|
||||
return Case(
|
||||
When(
|
||||
condition=IsNullExpr(arg1, output_field=fields.BooleanField()),
|
||||
then=IsNullExpr(arg2, output_field=fields.BooleanField()),
|
||||
),
|
||||
default=EqualsExpr(arg1, arg2, output_field=fields.BooleanField()),
|
||||
output_field=fields.BooleanField(),
|
||||
)
|
||||
|
||||
|
@ -1728,7 +1731,7 @@ class BaserowDateInterval(OneArgumentBaserowFunction):
|
|||
func_call: BaserowFunctionCall[UnTyped],
|
||||
arg: BaserowExpression[BaserowFormulaValidType],
|
||||
) -> BaserowExpression[BaserowFormulaType]:
|
||||
return func_call.with_valid_type(BaserowFormulaDateIntervalType(nullable=True))
|
||||
return func_call.with_valid_type(BaserowFormulaDurationType(nullable=True))
|
||||
|
||||
def to_django_expression(self, arg: Expression) -> Expression:
|
||||
return Func(
|
||||
|
|
|
@ -18,7 +18,7 @@ from django.db.models import (
|
|||
When,
|
||||
fields,
|
||||
)
|
||||
from django.db.models.functions import Cast, Coalesce, JSONObject
|
||||
from django.db.models.functions import Cast, Coalesce, Extract, JSONObject
|
||||
|
||||
from baserow.contrib.database.formula.ast.exceptions import UnknownFieldReference
|
||||
from baserow.contrib.database.formula.ast.tree import (
|
||||
|
@ -372,6 +372,7 @@ class BaserowExpressionToDjangoExpressionGenerator(
|
|||
self, db_column: str, model_field: fields.Field, already_in_subquery: bool
|
||||
) -> Expression:
|
||||
from baserow.contrib.database.fields.fields import (
|
||||
DurationField,
|
||||
MultipleSelectManyToManyField,
|
||||
SingleSelectForeignKey,
|
||||
)
|
||||
|
@ -417,6 +418,14 @@ class BaserowExpressionToDjangoExpressionGenerator(
|
|||
),
|
||||
Value([], output_field=JSONField()),
|
||||
)
|
||||
elif isinstance(model_field, DurationField) and already_in_subquery:
|
||||
# already_in_subquery is set to True in a lookup, but the JSON produced by
|
||||
# looking up a duration field cannot contains intervals/timedelta, so we
|
||||
# need to convert the value to a number of seconds instead.
|
||||
return ExpressionWrapper(
|
||||
Extract(db_column, "epoch"),
|
||||
output_field=model_field,
|
||||
)
|
||||
else:
|
||||
return ExpressionWrapper(
|
||||
F(db_column),
|
||||
|
|
|
@ -21,6 +21,7 @@ from baserow.contrib.database.fields.expressions import (
|
|||
)
|
||||
from baserow.contrib.database.fields.field_sortings import OptionallyAnnotatedOrderBy
|
||||
from baserow.contrib.database.fields.mixins import get_date_time_format
|
||||
from baserow.contrib.database.fields.utils.duration import D_H_M_S
|
||||
from baserow.contrib.database.formula.ast.tree import (
|
||||
BaserowBooleanLiteral,
|
||||
BaserowDecimalLiteral,
|
||||
|
@ -414,12 +415,12 @@ def _calculate_addition_interval_type(
|
|||
) -> BaserowFormulaValidType:
|
||||
arg1_type = arg1.expression_type
|
||||
arg2_type = arg2.expression_type
|
||||
if isinstance(arg1_type, BaserowFormulaDateIntervalType) and isinstance(
|
||||
arg2_type, BaserowFormulaDateIntervalType
|
||||
if isinstance(arg1_type, BaserowFormulaDateIntervalTypeMixin) and isinstance(
|
||||
arg2_type, BaserowFormulaDateIntervalTypeMixin
|
||||
):
|
||||
# interval + interval = interval
|
||||
resulting_type = arg1_type
|
||||
elif isinstance(arg1_type, BaserowFormulaDateIntervalType):
|
||||
elif isinstance(arg1_type, BaserowFormulaDateIntervalTypeMixin):
|
||||
# interval + date = date
|
||||
resulting_type = arg2_type
|
||||
else:
|
||||
|
@ -429,9 +430,20 @@ def _calculate_addition_interval_type(
|
|||
return resulting_type
|
||||
|
||||
|
||||
# noinspection PyMethodMayBeStatic
|
||||
class BaserowFormulaDateIntervalTypeMixin:
|
||||
"""
|
||||
Empty mixin to allow us to check if a type is a date interval type or a duration
|
||||
type. NOTE: This can be removed once the BaserowFormulaDateIntervalType is removed.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# Deprecated, use BaserowFormulaDurationType instead
|
||||
class BaserowFormulaDateIntervalType(
|
||||
BaserowFormulaTypeHasEmptyBaserowExpression, BaserowFormulaValidType
|
||||
BaserowFormulaTypeHasEmptyBaserowExpression,
|
||||
BaserowFormulaValidType,
|
||||
BaserowFormulaDateIntervalTypeMixin,
|
||||
):
|
||||
type = "date_interval"
|
||||
baserow_field_type = None
|
||||
|
@ -535,6 +547,74 @@ class BaserowFormulaDateIntervalType(
|
|||
return Cast(field.db_column, output_field=models.TextField())
|
||||
|
||||
|
||||
class BaserowFormulaDurationType(
|
||||
BaserowFormulaTypeHasEmptyBaserowExpression,
|
||||
BaserowFormulaValidType,
|
||||
BaserowFormulaDateIntervalTypeMixin,
|
||||
):
|
||||
type = "duration"
|
||||
baserow_field_type = "duration"
|
||||
user_overridable_formatting_option_fields = ["duration_format"]
|
||||
can_group_by = True
|
||||
can_order_by_in_array = True
|
||||
|
||||
def __init__(self, duration_format: str = D_H_M_S, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.duration_format = duration_format
|
||||
|
||||
@property
|
||||
def comparable_types(self) -> List[Type["BaserowFormulaValidType"]]:
|
||||
return [type(self)]
|
||||
|
||||
@property
|
||||
def limit_comparable_types(self) -> List[Type["BaserowFormulaValidType"]]:
|
||||
return [type(self)]
|
||||
|
||||
@property
|
||||
def addable_types(self) -> List[Type["BaserowFormulaValidType"]]:
|
||||
return [type(self), BaserowFormulaDateType]
|
||||
|
||||
@property
|
||||
def subtractable_types(self) -> List[Type["BaserowFormulaValidType"]]:
|
||||
return [type(self)]
|
||||
|
||||
def add(
|
||||
self,
|
||||
add_func_call: "BaserowFunctionCall[UnTyped]",
|
||||
arg1: "BaserowExpression[BaserowFormulaValidType]",
|
||||
arg2: "BaserowExpression[BaserowFormulaValidType]",
|
||||
):
|
||||
return add_func_call.with_valid_type(
|
||||
_calculate_addition_interval_type(arg1, arg2)
|
||||
)
|
||||
|
||||
def minus(
|
||||
self,
|
||||
minus_func_call: "BaserowFunctionCall[UnTyped]",
|
||||
arg1: "BaserowExpression[BaserowFormulaValidType]",
|
||||
arg2: "BaserowExpression[BaserowFormulaValidType]",
|
||||
):
|
||||
return minus_func_call.with_valid_type(
|
||||
BaserowFormulaDurationType(
|
||||
duration_format=self.duration_format,
|
||||
nullable=arg1.expression_type.nullable or arg2.expression_type.nullable,
|
||||
)
|
||||
)
|
||||
|
||||
def placeholder_empty_value(self):
|
||||
return Value(datetime.timedelta(hours=0), output_field=models.DurationField())
|
||||
|
||||
def placeholder_empty_baserow_expression(
|
||||
self,
|
||||
) -> "BaserowExpression[BaserowFormulaValidType]":
|
||||
return literal(datetime.timedelta(hours=0))
|
||||
|
||||
def get_order_by_in_array_expr(self, field, field_name, order_direction):
|
||||
return JSONBSingleKeyArrayExpression(
|
||||
field_name, "value", "interval", output_field=models.DurationField()
|
||||
)
|
||||
|
||||
|
||||
class BaserowFormulaDateType(BaserowFormulaValidType):
|
||||
type = "date"
|
||||
baserow_field_type = "date"
|
||||
|
@ -579,11 +659,11 @@ class BaserowFormulaDateType(BaserowFormulaValidType):
|
|||
|
||||
@property
|
||||
def addable_types(self) -> List[Type["BaserowFormulaValidType"]]:
|
||||
return [BaserowFormulaDateIntervalType]
|
||||
return [BaserowFormulaDateIntervalType, BaserowFormulaDurationType]
|
||||
|
||||
@property
|
||||
def subtractable_types(self) -> List[Type["BaserowFormulaValidType"]]:
|
||||
return [type(self), BaserowFormulaDateIntervalType]
|
||||
return [type(self), BaserowFormulaDateIntervalType, BaserowFormulaDurationType]
|
||||
|
||||
def add(
|
||||
self,
|
||||
|
@ -604,12 +684,12 @@ class BaserowFormulaDateType(BaserowFormulaValidType):
|
|||
arg1_type = arg1.expression_type
|
||||
arg2_type = arg2.expression_type
|
||||
if isinstance(arg2_type, BaserowFormulaDateType):
|
||||
# date - date = interval
|
||||
resulting_type = BaserowFormulaDateIntervalType(
|
||||
# date - date = duration
|
||||
resulting_type = BaserowFormulaDurationType(
|
||||
nullable=arg1_type.nullable or arg2_type.nullable
|
||||
)
|
||||
else:
|
||||
# date - interval = date
|
||||
# date - duration = date
|
||||
resulting_type = arg1_type
|
||||
return minus_func_call.with_valid_type(resulting_type)
|
||||
|
||||
|
@ -1003,6 +1083,11 @@ class BaserowFormulaArrayType(BaserowFormulaValidType):
|
|||
# strings, we need to reparse them back first before giving it to
|
||||
# the date field type.
|
||||
list_item = parser.isoparse(list_item)
|
||||
elif list_item is not None and self.sub_type.type == "duration":
|
||||
# Arrays are stored as JSON which means the durations are converted to
|
||||
# the number of seconds, we need to reparse them back first before
|
||||
# giving the duration field type.
|
||||
list_item = datetime.timedelta(seconds=list_item)
|
||||
export_value = map_func(list_item)
|
||||
if export_value is None:
|
||||
export_value = ""
|
||||
|
@ -1268,7 +1353,8 @@ BASEROW_FORMULA_TYPES = [
|
|||
BaserowFormulaCharType,
|
||||
BaserowFormulaButtonType,
|
||||
BaserowFormulaLinkType,
|
||||
BaserowFormulaDateIntervalType,
|
||||
BaserowFormulaDateIntervalType, # Deprecated in favor of BaserowFormulaDurationType
|
||||
BaserowFormulaDurationType,
|
||||
BaserowFormulaDateType,
|
||||
BaserowFormulaBooleanType,
|
||||
BaserowFormulaNumberType,
|
||||
|
|
|
@ -0,0 +1,78 @@
|
|||
# Generated by Django 4.0.10 on 2024-01-05 15:22
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("database", "0149_alter_durationfield_duration_format"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="formulafield",
|
||||
name="duration_format",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("h:mm", "hours:minutes"),
|
||||
("h:mm:ss", "hours:minutes:seconds"),
|
||||
("h:mm:ss.s", "hours:minutes:seconds:deciseconds"),
|
||||
("h:mm:ss.ss", "hours:minutes:seconds:centiseconds"),
|
||||
("h:mm:ss.sss", "hours:minutes:seconds:milliseconds"),
|
||||
("d h", "days:hours"),
|
||||
("d h:mm", "days:hours:minutes"),
|
||||
("d h:mm:ss", "days:hours:minutes:seconds"),
|
||||
],
|
||||
default="h:mm",
|
||||
help_text="The format of the duration.",
|
||||
max_length=32,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="formulafield",
|
||||
name="array_formula_type",
|
||||
field=models.TextField(
|
||||
choices=[
|
||||
("invalid", "invalid"),
|
||||
("text", "text"),
|
||||
("char", "char"),
|
||||
("button", "button"),
|
||||
("link", "link"),
|
||||
("date_interval", "date_interval"),
|
||||
("duration", "duration"),
|
||||
("date", "date"),
|
||||
("boolean", "boolean"),
|
||||
("number", "number"),
|
||||
("single_select", "single_select"),
|
||||
("multiple_select", "multiple_select"),
|
||||
("single_file", "single_file"),
|
||||
],
|
||||
default=None,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="formulafield",
|
||||
name="formula_type",
|
||||
field=models.TextField(
|
||||
choices=[
|
||||
("invalid", "invalid"),
|
||||
("text", "text"),
|
||||
("char", "char"),
|
||||
("button", "button"),
|
||||
("link", "link"),
|
||||
("date_interval", "date_interval"),
|
||||
("duration", "duration"),
|
||||
("date", "date"),
|
||||
("boolean", "boolean"),
|
||||
("number", "number"),
|
||||
("array", "array"),
|
||||
("single_select", "single_select"),
|
||||
("multiple_select", "multiple_select"),
|
||||
("single_file", "single_file"),
|
||||
],
|
||||
default="invalid",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -55,6 +55,7 @@ from baserow.contrib.database.formula import (
|
|||
)
|
||||
from baserow.contrib.database.formula.types.formula_types import (
|
||||
BaserowFormulaDateIntervalType,
|
||||
BaserowFormulaDurationType,
|
||||
BaserowFormulaSingleFileType,
|
||||
)
|
||||
from baserow.core.datetime import get_timezones
|
||||
|
@ -1405,6 +1406,7 @@ class EmptyViewFilterType(ViewFilterType):
|
|||
BaserowFormulaDateType.type,
|
||||
BaserowFormulaBooleanType.type,
|
||||
BaserowFormulaDateIntervalType.type,
|
||||
BaserowFormulaDurationType.type,
|
||||
FormulaFieldType.array_of(BaserowFormulaSingleFileType.type),
|
||||
),
|
||||
]
|
||||
|
|
|
@ -948,7 +948,7 @@ def test_can_type_a_valid_formula_field(data_fixture, api_client):
|
|||
"api:database:formula:type_formula",
|
||||
kwargs={"table_id": table.id},
|
||||
),
|
||||
{f"formula": "1+1", "name": formula_field_name},
|
||||
{"formula": "1+1", "name": formula_field_name},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
|
@ -960,6 +960,7 @@ def test_can_type_a_valid_formula_field(data_fixture, api_client):
|
|||
"date_time_format": None,
|
||||
"date_show_tzinfo": None,
|
||||
"date_force_timezone": None,
|
||||
"duration_format": None,
|
||||
"error": None,
|
||||
"formula": "1+1",
|
||||
"formula_type": "number",
|
||||
|
|
|
@ -245,14 +245,14 @@ def test_get_row_serializer_with_user_field_names(data_fixture):
|
|||
{"id": 2, "value": "-123.456"},
|
||||
{"id": 3, "value": ""},
|
||||
],
|
||||
"duration_hm": 3660,
|
||||
"duration_hms": 3666,
|
||||
"duration_hm": 3660.0,
|
||||
"duration_hms": 3666.0,
|
||||
"duration_hms_s": 3666.6,
|
||||
"duration_hms_ss": 3666.66,
|
||||
"duration_hms_sss": 3666.666,
|
||||
"duration_dh": 90000,
|
||||
"duration_dhm": 90060,
|
||||
"duration_dhms": 90066,
|
||||
"duration_dh": 90000.0,
|
||||
"duration_dhm": 90060.0,
|
||||
"duration_dhms": 90066.0,
|
||||
"email": "test@example.com",
|
||||
"file": [
|
||||
{
|
||||
|
@ -333,7 +333,7 @@ def test_get_row_serializer_with_user_field_names(data_fixture):
|
|||
"url": "https://www.google.com",
|
||||
"formula_bool": True,
|
||||
"formula_date": "2020-01-01",
|
||||
"formula_dateinterval": "1 day",
|
||||
"formula_dateinterval": 86400.0,
|
||||
"formula_decimal": "33.3333333333",
|
||||
"formula_email": "test@example.com",
|
||||
"formula_int": "1",
|
||||
|
|
|
@ -207,7 +207,7 @@ def test_serialize_group_by_metadata_on_all_fields_in_interesting_table(data_fix
|
|||
"formula_int": [{"count": 2, "field_formula_int": "1"}],
|
||||
"formula_bool": [{"count": 2, "field_formula_bool": True}],
|
||||
"formula_decimal": [{"count": 2, "field_formula_decimal": "33.3333333333"}],
|
||||
"formula_dateinterval": [{"count": 2, "field_formula_dateinterval": "1 day"}],
|
||||
"formula_dateinterval": [{"count": 2, "field_formula_dateinterval": 24 * 3600}],
|
||||
"formula_date": [{"count": 2, "field_formula_date": "2020-01-01"}],
|
||||
"formula_email": [
|
||||
{"count": 1, "field_formula_email": ""},
|
||||
|
|
|
@ -236,7 +236,7 @@ def test_can_export_every_interesting_different_field_to_csv(
|
|||
"1,,,,,,,,,0,False,,,,,,,01/02/2021 12:00,01/02/2021,02/01/2021 12:00,02/01/2021,"
|
||||
"02/01/2021 13:00,01/02/2021 12:00,01/02/2021,02/01/2021 12:00,02/01/2021,02/01/2021 13:00,"
|
||||
"user@example.com,user@example.com,,,,,,,,,,,,,,,,,,,test FORMULA,1,True,33.3333333333,"
|
||||
"1 day,2020-01-01,,,label (https://google.com),https://google.com,,0,0.000,,"
|
||||
"1d 0:00,2020-01-01,,,label (https://google.com),https://google.com,,0,0.000,,"
|
||||
"00000000-0000-4000-8000-000000000001,1\r\n"
|
||||
"2,text,long_text,https://www.google.com,test@example.com,-1,1,-1.2,1.2,3,True,"
|
||||
"02/01/2020 01:23,02/01/2020,01/02/2020 01:23,01/02/2020,01/02/2020 02:23,"
|
||||
|
@ -250,7 +250,7 @@ def test_can_export_every_interesting_different_field_to_csv(
|
|||
'"a.txt (http://localhost:8000/media/user_files/hashed_name.txt),'
|
||||
'b.txt (http://localhost:8000/media/user_files/other_name.txt)",A,"D,C,E",'
|
||||
'"user2@example.com,user3@example.com",\'+4412345678,test FORMULA,1,True,33.3333333333,'
|
||||
"1 day,2020-01-01,A,test@example.com,label (https://google.com),https://google.com,"
|
||||
"1d 0:00,2020-01-01,A,test@example.com,label (https://google.com),https://google.com,"
|
||||
'"D,C,E",3,-122.222,"linked_row_1,linked_row_2,",00000000-0000-4000-8000-000000000002,2\r\n'
|
||||
)
|
||||
|
||||
|
|
|
@ -662,3 +662,126 @@ def test_get_group_by_metadata_in_rows_with_duration_field(data_fixture):
|
|||
]
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.field_duration
|
||||
@pytest.mark.django_db
|
||||
def test_duration_field_can_be_used_in_formulas(data_fixture):
|
||||
user = data_fixture.create_user()
|
||||
table = data_fixture.create_database_table(user=user)
|
||||
duration_field = data_fixture.create_duration_field(
|
||||
table=table, name="duration", duration_format="h:mm:ss"
|
||||
)
|
||||
ref_field = data_fixture.create_formula_field(
|
||||
table=table, name="ref", formula=f"field('{duration_field.name}')"
|
||||
)
|
||||
add_field = data_fixture.create_formula_field(
|
||||
table=table,
|
||||
formula=f"field('{duration_field.name}') + field('{ref_field.name}')",
|
||||
)
|
||||
sub_field = data_fixture.create_formula_field(
|
||||
table=table,
|
||||
formula=f"field('{duration_field.name}') - field('{ref_field.name}')",
|
||||
)
|
||||
compare_field = data_fixture.create_formula_field(
|
||||
table=table,
|
||||
formula=f"field('{duration_field.name}') = field('{ref_field.name}')",
|
||||
)
|
||||
|
||||
RowHandler().create_rows(
|
||||
user,
|
||||
table,
|
||||
rows_values=[
|
||||
{duration_field.db_column: 3600},
|
||||
{duration_field.db_column: 0},
|
||||
{},
|
||||
],
|
||||
)
|
||||
|
||||
model = table.get_model()
|
||||
|
||||
assert list(
|
||||
model.objects.all().values(
|
||||
ref_field.db_column,
|
||||
add_field.db_column,
|
||||
sub_field.db_column,
|
||||
compare_field.db_column,
|
||||
)
|
||||
) == [
|
||||
{
|
||||
ref_field.db_column: timedelta(seconds=3600),
|
||||
add_field.db_column: timedelta(seconds=7200),
|
||||
sub_field.db_column: timedelta(seconds=0),
|
||||
compare_field.db_column: True,
|
||||
},
|
||||
{
|
||||
ref_field.db_column: timedelta(seconds=0),
|
||||
add_field.db_column: timedelta(seconds=0),
|
||||
sub_field.db_column: timedelta(seconds=0),
|
||||
compare_field.db_column: True,
|
||||
},
|
||||
{
|
||||
ref_field.db_column: None,
|
||||
add_field.db_column: timedelta(seconds=0),
|
||||
sub_field.db_column: timedelta(seconds=0),
|
||||
compare_field.db_column: True,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.field_duration
|
||||
@pytest.mark.django_db
|
||||
def test_duration_field_can_be_looked_up(data_fixture):
|
||||
user = data_fixture.create_user()
|
||||
table_a, table_b, link_field = data_fixture.create_two_linked_tables(user=user)
|
||||
duration_field = data_fixture.create_duration_field(
|
||||
table=table_b, name="duration", duration_format="h:mm:ss"
|
||||
)
|
||||
lookup_field = data_fixture.create_formula_field(
|
||||
table=table_a, formula=f"lookup('{link_field.name}', 'duration')"
|
||||
)
|
||||
|
||||
# Also a formula field referencing a duration can be looked up
|
||||
duration_formula = data_fixture.create_formula_field(
|
||||
table=table_b,
|
||||
name="formula",
|
||||
formula=f"field('{duration_field.name}') + date_interval('60s')",
|
||||
)
|
||||
lookup_formula = data_fixture.create_formula_field(
|
||||
table=table_a, formula=f"lookup('{link_field.name}', 'formula')"
|
||||
)
|
||||
|
||||
model_b = table_b.get_model()
|
||||
row_b_1, row_b_2 = RowHandler().create_rows(
|
||||
user=user,
|
||||
table=table_b,
|
||||
rows_values=[
|
||||
{duration_field.db_column: 3600},
|
||||
{duration_field.db_column: 60},
|
||||
],
|
||||
model=model_b,
|
||||
)
|
||||
|
||||
assert list(model_b.objects.values_list(duration_formula.db_column, flat=True)) == [
|
||||
timedelta(seconds=3600 + 60),
|
||||
timedelta(seconds=60 + 60),
|
||||
]
|
||||
|
||||
model_a = table_a.get_model()
|
||||
(row,) = RowHandler().create_rows(
|
||||
user=user,
|
||||
table=table_a,
|
||||
rows_values=[
|
||||
{f"field_{link_field.id}": [row_b_1.id, row_b_2.id]},
|
||||
],
|
||||
model=model_a,
|
||||
)
|
||||
assert getattr(row, f"field_{lookup_field.id}") == [
|
||||
{"id": row_b_1.id, "value": 3600},
|
||||
{"id": row_b_2.id, "value": 60},
|
||||
]
|
||||
|
||||
assert getattr(row, f"field_{lookup_formula.id}") == [
|
||||
{"id": row_b_1.id, "value": 3600 + 60},
|
||||
{"id": row_b_2.id, "value": 60 + 60},
|
||||
]
|
||||
|
|
|
@ -567,7 +567,7 @@ def test_human_readable_values(data_fixture):
|
|||
"url": "",
|
||||
"formula_bool": "True",
|
||||
"formula_date": "2020-01-01",
|
||||
"formula_dateinterval": "1 day",
|
||||
"formula_dateinterval": "1d 0:00",
|
||||
"formula_decimal": "33.3333333333",
|
||||
"formula_email": "",
|
||||
"formula_int": "1",
|
||||
|
@ -622,7 +622,7 @@ def test_human_readable_values(data_fixture):
|
|||
"url": "https://www.google.com",
|
||||
"formula_bool": "True",
|
||||
"formula_date": "2020-01-01",
|
||||
"formula_dateinterval": "1 day",
|
||||
"formula_dateinterval": "1d 0:00",
|
||||
"formula_decimal": "33.3333333333",
|
||||
"formula_email": "test@example.com",
|
||||
"formula_int": "1",
|
||||
|
|
|
@ -147,7 +147,7 @@ VALID_FORMULA_TESTS = [
|
|||
("or(false, true)", True),
|
||||
("or(true, true)", True),
|
||||
("'a' + 'b'", "ab"),
|
||||
("date_interval('1 year')", "1 year"),
|
||||
("date_interval('1 year')", 365 * 24 * 3600),
|
||||
("date_interval('1 year') > date_interval('1 day')", True),
|
||||
("date_interval('1 invalid')", None),
|
||||
("todate('20200101', 'YYYYMMDD') + date_interval('1 year')", "2021-01-01"),
|
||||
|
@ -163,8 +163,11 @@ VALID_FORMULA_TESTS = [
|
|||
")",
|
||||
"6",
|
||||
),
|
||||
("todate('20200101', 'YYYYMMDD') - todate('20210101', 'YYYYMMDD')", "-366 days"),
|
||||
("date_interval('1 year') - date_interval('1 day')", "1 year -1 days"),
|
||||
(
|
||||
"todate('20200101', 'YYYYMMDD') - todate('20210101', 'YYYYMMDD')",
|
||||
-366 * 24 * 3600,
|
||||
),
|
||||
("date_interval('1 year') - date_interval('1 day')", 364 * 24 * 3600),
|
||||
("now() > todate('20200101', 'YYYYMMDD')", True),
|
||||
("todate('01123456', 'DDMMYYYY') < now()", False),
|
||||
("todate('01123456', 'DDMMYYYY') < today()", False),
|
||||
|
@ -582,7 +585,7 @@ def test_can_lookup_date_intervals(data_fixture, api_client):
|
|||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert [o[lookup_formula.db_column] for o in response.json()["results"]] == [
|
||||
[{"id": row_1.id, "value": "2 days"}]
|
||||
[{"id": row_1.id, "value": 2 * 24 * 3600}]
|
||||
]
|
||||
|
||||
|
||||
|
@ -964,13 +967,13 @@ INVALID_FORMULA_TESTS = [
|
|||
"todate('20200101', 'YYYYMMDD') + todate('20210101', 'YYYYMMDD')",
|
||||
"ERROR_WITH_FORMULA",
|
||||
"Error with formula: argument number 2 given to operator + was of type date "
|
||||
"but the only usable type for this argument is date_interval.",
|
||||
"but the only usable types for this argument are date_interval,duration.",
|
||||
),
|
||||
(
|
||||
"date_interval('1 second') - todate('20210101', 'YYYYMMDD')",
|
||||
"ERROR_WITH_FORMULA",
|
||||
"Error with formula: argument number 2 given to operator - was of type date "
|
||||
"but the only usable type for this argument is date_interval.",
|
||||
"but the only usable type for this argument is duration.",
|
||||
),
|
||||
(
|
||||
"when_empty(1, 'a')",
|
||||
|
|
|
@ -4181,7 +4181,9 @@ def test_get_group_by_on_all_fields_in_interesting_table(data_fixture):
|
|||
"formula_decimal": [
|
||||
{"field_formula_decimal": Decimal("33.3333333333"), "count": 2}
|
||||
],
|
||||
"formula_dateinterval": [{"field_formula_dateinterval": "1 day", "count": 2}],
|
||||
"formula_dateinterval": [
|
||||
{"field_formula_dateinterval": datetime.timedelta(days=1), "count": 2}
|
||||
],
|
||||
"formula_date": [{"field_formula_date": datetime.date(2020, 1, 1), "count": 2}],
|
||||
"formula_email": [
|
||||
{"field_formula_email": "", "count": 1},
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"type": "breaking_change",
|
||||
"message": "New formulas returning a date_interval/duration are sent as number of seconds instead of a formatted string.",
|
||||
"issue_number": 2190,
|
||||
"bullet_points": [],
|
||||
"created_at": "2024-01-12"
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"type": "feature",
|
||||
"message": "Add support to reference duration fields in the formula language.",
|
||||
"issue_number": 2190,
|
||||
"bullet_points": [],
|
||||
"created_at": "2024-01-08"
|
||||
}
|
|
@ -88,7 +88,7 @@ def test_can_export_every_interesting_different_field_to_json(
|
|||
"formula_int": 1,
|
||||
"formula_bool": true,
|
||||
"formula_decimal": "33.3333333333",
|
||||
"formula_dateinterval": "1 day",
|
||||
"formula_dateinterval": "1d 0:00",
|
||||
"formula_date": "2020-01-01",
|
||||
"formula_singleselect": "",
|
||||
"formula_email": "",
|
||||
|
@ -195,7 +195,7 @@ def test_can_export_every_interesting_different_field_to_json(
|
|||
"formula_int": 1,
|
||||
"formula_bool": true,
|
||||
"formula_decimal": "33.3333333333",
|
||||
"formula_dateinterval": "1 day",
|
||||
"formula_dateinterval": "1d 0:00",
|
||||
"formula_date": "2020-01-01",
|
||||
"formula_singleselect": "A",
|
||||
"formula_email": "test@example.com",
|
||||
|
@ -351,7 +351,7 @@ def test_can_export_every_interesting_different_field_to_xml(
|
|||
<formula-int>1</formula-int>
|
||||
<formula-bool>true</formula-bool>
|
||||
<formula-decimal>33.3333333333</formula-decimal>
|
||||
<formula-dateinterval>1 day</formula-dateinterval>
|
||||
<formula-dateinterval>1d 0:00</formula-dateinterval>
|
||||
<formula-date>2020-01-01</formula-date>
|
||||
<formula-singleselect/>
|
||||
<formula-email/>
|
||||
|
@ -458,7 +458,7 @@ def test_can_export_every_interesting_different_field_to_xml(
|
|||
<formula-int>1</formula-int>
|
||||
<formula-bool>true</formula-bool>
|
||||
<formula-decimal>33.3333333333</formula-decimal>
|
||||
<formula-dateinterval>1 day</formula-dateinterval>
|
||||
<formula-dateinterval>1d 0:00</formula-dateinterval>
|
||||
<formula-date>2020-01-01</formula-date>
|
||||
<formula-singleselect>A</formula-singleselect>
|
||||
<formula-email>test@example.com</formula-email>
|
||||
|
|
|
@ -15,17 +15,29 @@
|
|||
:view="view"
|
||||
>
|
||||
</FieldDateSubForm>
|
||||
<FieldDurationSubForm
|
||||
v-else-if="formulaType === 'duration'"
|
||||
:default-values="defaultValues"
|
||||
:table="table"
|
||||
:view="view"
|
||||
>
|
||||
</FieldDurationSubForm>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import FieldNumberSubForm from '@baserow/modules/database/components/field/FieldNumberSubForm'
|
||||
import FieldDateSubForm from '@baserow/modules/database/components/field/FieldDateSubForm'
|
||||
import FieldDurationSubForm from '@baserow/modules/database/components/field/FieldDurationSubForm'
|
||||
import form from '@baserow/modules/core/mixins/form'
|
||||
import fieldSubForm from '@baserow/modules/database/mixins/fieldSubForm'
|
||||
|
||||
export default {
|
||||
name: 'FormulaTypeSubForms',
|
||||
components: { FieldNumberSubForm, FieldDateSubForm },
|
||||
components: {
|
||||
FieldNumberSubForm,
|
||||
FieldDateSubForm,
|
||||
FieldDurationSubForm,
|
||||
},
|
||||
mixins: [form, fieldSubForm],
|
||||
props: {
|
||||
table: {
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
<template functional>
|
||||
<div v-if="props.value" class="array-field__item">
|
||||
<span>
|
||||
{{ $options.methods.formatValue(props.field, props.value) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import durationField from '@baserow/modules/database/mixins/durationField'
|
||||
export default {
|
||||
name: 'FunctionalFormulaArrayDurationItem',
|
||||
mixins: [durationField],
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,28 @@
|
|||
<template>
|
||||
<div class="control__elements">
|
||||
<div>{{ formattedValue }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import rowEditField from '@baserow/modules/database/mixins/rowEditField'
|
||||
import durationField from '@baserow/modules/database/mixins/durationField'
|
||||
|
||||
export default {
|
||||
mixins: [rowEditField, durationField],
|
||||
watch: {
|
||||
'field.duration_format': {
|
||||
handler() {
|
||||
this.updateCopy(this.field, this.value)
|
||||
this.updateFormattedValue(this.field, this.copy)
|
||||
},
|
||||
},
|
||||
value: {
|
||||
handler(newValue) {
|
||||
this.updateCopy(this.field, newValue)
|
||||
this.updateFormattedValue(this.field, this.copy)
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -18,7 +18,7 @@
|
|||
class="grid-field-duration__input"
|
||||
:placeholder="field.duration_format"
|
||||
@keypress="onKeyPress(field, $event)"
|
||||
@keyup="updateCopy(field, $event.target.value)"
|
||||
@input="onInput(field, $event)"
|
||||
/>
|
||||
<div v-show="!isValid()" class="grid-view__cell--error align-right">
|
||||
{{ getError() }}
|
||||
|
@ -43,7 +43,11 @@ export default {
|
|||
this.updateFormattedValue(this.field, value)
|
||||
return this.$super(gridFieldInput).beforeSave(value)
|
||||
},
|
||||
afterEdit() {
|
||||
afterEdit(event, value) {
|
||||
if (value !== null) {
|
||||
this.updateCopy(this.field, value)
|
||||
this.updateFormattedValue(this.field, this.copy)
|
||||
}
|
||||
this.$nextTick(() => {
|
||||
this.$refs.input.focus()
|
||||
this.$refs.input.selectionStart = this.$refs.input.selectionEnd = 100000
|
||||
|
|
|
@ -2261,6 +2261,10 @@ export class DurationFieldType extends FieldType {
|
|||
return DURATION_FORMATS.get(field.duration_format).example
|
||||
}
|
||||
|
||||
canBeReferencedByFormulaField() {
|
||||
return true
|
||||
}
|
||||
|
||||
getDocsDescription(field) {
|
||||
return this.app.i18n.t('fieldDocs.duration', {
|
||||
format: field.duration_format,
|
||||
|
|
|
@ -17,7 +17,9 @@ import RowEditFieldMultipleSelectReadOnly from '@baserow/modules/database/compon
|
|||
import RowEditFieldArray from '@baserow/modules/database/components/row/RowEditFieldArray'
|
||||
import RowEditFieldLinkURL from '@baserow/modules/database/components/row/RowEditFieldLinkURL'
|
||||
import RowEditFieldButton from '@baserow/modules/database/components/row/RowEditFieldButton'
|
||||
import RowEditFieldDurationReadOnly from '@baserow/modules/database/components/row/RowEditFieldDurationReadOnly.vue'
|
||||
import FunctionalFormulaArrayItem from '@baserow/modules/database/components/formula/array/FunctionalFormulaArrayItem'
|
||||
import FunctionalFormulaArrayDurationItem from '@baserow/modules/database/components/formula/array/FunctionalFormulaArrayDurationItem'
|
||||
import FunctionalFormulaBooleanArrayItem from '@baserow/modules/database/components/formula/array/FunctionalFormulaBooleanArrayItem'
|
||||
import FunctionalFormulaDateArrayItem from '@baserow/modules/database/components/formula/array/FunctionalFormulaDateArrayItem'
|
||||
import FunctionalFormulaSingleSelectArrayItem from '@baserow/modules/database/components/formula/array/FunctionalFormulaSingleSelectArrayItem'
|
||||
|
@ -351,6 +353,41 @@ export class BaserowFormulaDateType extends BaserowFormulaTypeDefinition {
|
|||
}
|
||||
}
|
||||
|
||||
export class BaserowFormulaDurationType extends BaserowFormulaTypeDefinition {
|
||||
static getType() {
|
||||
return 'duration'
|
||||
}
|
||||
|
||||
getFieldType() {
|
||||
return 'duration'
|
||||
}
|
||||
|
||||
getIconClass() {
|
||||
return 'iconoir-clock-rotate-right'
|
||||
}
|
||||
|
||||
getRowEditFieldComponent(field) {
|
||||
return RowEditFieldDurationReadOnly
|
||||
}
|
||||
|
||||
getSortOrder() {
|
||||
return 5
|
||||
}
|
||||
|
||||
canGroupByInView() {
|
||||
return true
|
||||
}
|
||||
|
||||
getFunctionalGridViewFieldArrayComponent() {
|
||||
return FunctionalFormulaArrayDurationItem
|
||||
}
|
||||
|
||||
canBeSortedWhenInArray(field) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Deprecated, use BaserowFormulaDurationType instead.
|
||||
export class BaserowFormulaDateIntervalType extends BaserowFormulaTypeDefinition {
|
||||
static getType() {
|
||||
return 'date_interval'
|
||||
|
|
|
@ -47,11 +47,17 @@ export default {
|
|||
return newCopy
|
||||
}
|
||||
},
|
||||
onKeyPress(field, event) {
|
||||
isValidChar(char) {
|
||||
const allowedChars = ['.', ':', ' ', 'd', 'h']
|
||||
if (!/\d/.test(event.key) && !allowedChars.includes(event.key)) {
|
||||
return /\d/.test(char) || allowedChars.includes(char)
|
||||
},
|
||||
onKeyPress(field, event) {
|
||||
if (!this.isValidChar(event.key)) {
|
||||
return event.preventDefault()
|
||||
}
|
||||
},
|
||||
onInput(field, event) {
|
||||
this.updateCopy(field, event.target.value)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -144,7 +144,7 @@ export default {
|
|||
|
||||
this.editing = true
|
||||
this.copy = value === null ? this.value : value
|
||||
this.afterEdit(event)
|
||||
this.afterEdit(event, value)
|
||||
},
|
||||
/**
|
||||
* Method that can be called when in the editing state. It will bring the
|
||||
|
|
|
@ -217,7 +217,8 @@ import {
|
|||
BaserowFormulaButtonType,
|
||||
BaserowFormulaCharType,
|
||||
BaserowFormulaLinkType,
|
||||
BaserowFormulaDateIntervalType,
|
||||
BaserowFormulaDateIntervalType, // Deprecated
|
||||
BaserowFormulaDurationType,
|
||||
BaserowFormulaDateType,
|
||||
BaserowFormulaInvalidType,
|
||||
BaserowFormulaNumberType,
|
||||
|
@ -613,6 +614,10 @@ export default (context) => {
|
|||
'formula_type',
|
||||
new BaserowFormulaDateIntervalType(context)
|
||||
)
|
||||
app.$registry.register(
|
||||
'formula_type',
|
||||
new BaserowFormulaDurationType(context)
|
||||
)
|
||||
app.$registry.register('formula_type', new BaserowFormulaNumberType(context))
|
||||
app.$registry.register('formula_type', new BaserowFormulaArrayType(context))
|
||||
app.$registry.register('formula_type', new BaserowFormulaSpecialType(context))
|
||||
|
|
|
@ -117,7 +117,7 @@ export const DURATION_FORMATS = new Map([
|
|||
example: '1:23:40',
|
||||
toString(d, h, m, s) {
|
||||
return `${d * 24 + h}:${m.toString().padStart(2, '0')}:${s
|
||||
.toString()
|
||||
.toFixed(0)
|
||||
.padStart(2, '0')}`
|
||||
},
|
||||
round: (value) => Math.round(value),
|
||||
|
@ -191,7 +191,7 @@ export const DURATION_FORMATS = new Map([
|
|||
example: '1d 2:34:56',
|
||||
toString(d, h, m, s) {
|
||||
return `${d}d ${h}:${m.toString().padStart(2, '0')}:${s
|
||||
.toString()
|
||||
.toFixed(0)
|
||||
.padStart(2, '0')}`
|
||||
},
|
||||
round: (value) => Math.round(value),
|
||||
|
|
Loading…
Add table
Reference in a new issue