mirror of
https://gitlab.com/bramw/baserow.git
synced 2025-04-14 17:18:33 +00:00
Airtable import currency field improvements
This commit is contained in:
parent
8b71d7c709
commit
c88c316d63
5 changed files with 324 additions and 11 deletions
backend
src/baserow/contrib/database/airtable
tests/baserow/contrib/database/airtable
changelog/entries/unreleased/bug
|
@ -1,5 +1,5 @@
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from decimal import Decimal
|
from decimal import Decimal, InvalidOperation
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
@ -28,6 +28,7 @@ from baserow.contrib.database.fields.models import (
|
||||||
from baserow.contrib.database.fields.registries import field_type_registry
|
from baserow.contrib.database.fields.registries import field_type_registry
|
||||||
|
|
||||||
from .config import AirtableImportConfig
|
from .config import AirtableImportConfig
|
||||||
|
from .constants import AIRTABLE_NUMBER_FIELD_SEPARATOR_FORMAT_MAPPING
|
||||||
from .helpers import import_airtable_date_type_options, set_select_options_on_field
|
from .helpers import import_airtable_date_type_options, set_select_options_on_field
|
||||||
from .import_report import (
|
from .import_report import (
|
||||||
ERROR_TYPE_DATA_TYPE_MISMATCH,
|
ERROR_TYPE_DATA_TYPE_MISMATCH,
|
||||||
|
@ -155,17 +156,37 @@ class NumberAirtableColumnType(AirtableColumnType):
|
||||||
self, raw_airtable_table, raw_airtable_column, config, import_report
|
self, raw_airtable_table, raw_airtable_column, config, import_report
|
||||||
):
|
):
|
||||||
type_options = raw_airtable_column.get("typeOptions", {})
|
type_options = raw_airtable_column.get("typeOptions", {})
|
||||||
decimal_places = 0
|
options_format = type_options.get("format", "")
|
||||||
|
suffix = ""
|
||||||
|
|
||||||
if type_options.get("format", "integer") == "decimal":
|
if "percent" in options_format:
|
||||||
# Minimum of 1 and maximum of 5 decimal places.
|
suffix = "%"
|
||||||
decimal_places = min(
|
|
||||||
max(1, type_options.get("precision", 1)), NUMBER_MAX_DECIMAL_PLACES
|
decimal_places = min(
|
||||||
|
max(0, type_options.get("precision", 0)), NUMBER_MAX_DECIMAL_PLACES
|
||||||
|
)
|
||||||
|
prefix = type_options.get("symbol", "")
|
||||||
|
separator_format = type_options.get("separatorFormat", "")
|
||||||
|
number_separator = AIRTABLE_NUMBER_FIELD_SEPARATOR_FORMAT_MAPPING.get(
|
||||||
|
separator_format, ""
|
||||||
|
)
|
||||||
|
|
||||||
|
if separator_format != "" and number_separator == "":
|
||||||
|
import_report.add_failed(
|
||||||
|
f"Number field: \"{raw_airtable_column['name']}\"",
|
||||||
|
SCOPE_FIELD,
|
||||||
|
raw_airtable_table.get("name", ""),
|
||||||
|
ERROR_TYPE_UNSUPPORTED_FEATURE,
|
||||||
|
f"The field was imported, but the separator format "
|
||||||
|
f"{separator_format} was dropped because it doesn't exist in Baserow.",
|
||||||
)
|
)
|
||||||
|
|
||||||
return NumberField(
|
return NumberField(
|
||||||
number_decimal_places=decimal_places,
|
number_decimal_places=decimal_places,
|
||||||
number_negative=type_options.get("negative", True),
|
number_negative=type_options.get("negative", True),
|
||||||
|
number_prefix=prefix,
|
||||||
|
number_suffix=suffix,
|
||||||
|
number_separator=number_separator,
|
||||||
)
|
)
|
||||||
|
|
||||||
def to_baserow_export_serialized_value(
|
def to_baserow_export_serialized_value(
|
||||||
|
@ -180,13 +201,38 @@ class NumberAirtableColumnType(AirtableColumnType):
|
||||||
config,
|
config,
|
||||||
import_report,
|
import_report,
|
||||||
):
|
):
|
||||||
if value is not None:
|
if value is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
value = Decimal(value)
|
value = Decimal(value)
|
||||||
|
except InvalidOperation:
|
||||||
|
# If the value can't be parsed as decimal, then it might be corrupt, so we
|
||||||
|
# need to inform the user and skip the import.
|
||||||
|
row_name = get_airtable_row_primary_value(
|
||||||
|
raw_airtable_table, raw_airtable_row
|
||||||
|
)
|
||||||
|
import_report.add_failed(
|
||||||
|
f"Row: \"{row_name}\", field: \"{raw_airtable_column['name']}\"",
|
||||||
|
SCOPE_CELL,
|
||||||
|
raw_airtable_table["name"],
|
||||||
|
ERROR_TYPE_DATA_TYPE_MISMATCH,
|
||||||
|
f"Cell value was left empty because the numeric value {value} "
|
||||||
|
f'could not be parsed"',
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
if value is not None and not baserow_field.number_negative and value < 0:
|
# Airtable stores 10% as 0.1, so we would need to multiply it by 100 so get the
|
||||||
value = None
|
# correct value in Baserow.
|
||||||
|
type_options = raw_airtable_column.get("typeOptions", {})
|
||||||
|
options_format = type_options.get("format", "")
|
||||||
|
if "percent" in options_format:
|
||||||
|
value = value * 100
|
||||||
|
|
||||||
return None if value is None else str(value)
|
if not baserow_field.number_negative and value < 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
class RatingAirtableColumnType(AirtableColumnType):
|
class RatingAirtableColumnType(AirtableColumnType):
|
||||||
|
|
|
@ -13,3 +13,9 @@ AIRTABLE_BASEROW_COLOR_MAPPING = {
|
||||||
"purple": "dark-blue",
|
"purple": "dark-blue",
|
||||||
"gray": "light-gray",
|
"gray": "light-gray",
|
||||||
}
|
}
|
||||||
|
AIRTABLE_NUMBER_FIELD_SEPARATOR_FORMAT_MAPPING = {
|
||||||
|
"commaPeriod": "COMMA_PERIOD",
|
||||||
|
"periodComma": "PERIOD_COMMA",
|
||||||
|
"spaceComma": "SPACE_COMMA",
|
||||||
|
"spacePeriod": "SPACE_PERIOD",
|
||||||
|
}
|
||||||
|
|
|
@ -1196,6 +1196,9 @@ def test_airtable_import_number_integer_column(data_fixture, api_client):
|
||||||
assert isinstance(airtable_column_type, NumberAirtableColumnType)
|
assert isinstance(airtable_column_type, NumberAirtableColumnType)
|
||||||
assert baserow_field.number_decimal_places == 0
|
assert baserow_field.number_decimal_places == 0
|
||||||
assert baserow_field.number_negative is False
|
assert baserow_field.number_negative is False
|
||||||
|
assert baserow_field.number_separator == ""
|
||||||
|
assert baserow_field.number_prefix == ""
|
||||||
|
assert baserow_field.number_suffix == ""
|
||||||
|
|
||||||
assert (
|
assert (
|
||||||
airtable_column_type.to_baserow_export_serialized_value(
|
airtable_column_type.to_baserow_export_serialized_value(
|
||||||
|
@ -1269,6 +1272,50 @@ def test_airtable_import_number_integer_column(data_fixture, api_client):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@responses.activate
|
||||||
|
def test_airtable_import_number_invalid_number(data_fixture, api_client):
|
||||||
|
airtable_field = {
|
||||||
|
"id": "fldZBmr4L45mhjILhlA",
|
||||||
|
"name": "Number",
|
||||||
|
"type": "number",
|
||||||
|
"typeOptions": {
|
||||||
|
"format": "integer",
|
||||||
|
"negative": False,
|
||||||
|
"validatorName": "positive",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
(
|
||||||
|
baserow_field,
|
||||||
|
airtable_column_type,
|
||||||
|
) = airtable_column_type_registry.from_airtable_column_to_serialized(
|
||||||
|
{},
|
||||||
|
airtable_field,
|
||||||
|
AirtableImportConfig(),
|
||||||
|
AirtableImportReport(),
|
||||||
|
)
|
||||||
|
|
||||||
|
import_report = AirtableImportReport()
|
||||||
|
assert (
|
||||||
|
airtable_column_type.to_baserow_export_serialized_value(
|
||||||
|
{},
|
||||||
|
{"name": "Test"},
|
||||||
|
{"id": "row1"},
|
||||||
|
airtable_field,
|
||||||
|
baserow_field,
|
||||||
|
"INVALID_NUMBER",
|
||||||
|
{},
|
||||||
|
AirtableImportConfig(),
|
||||||
|
import_report,
|
||||||
|
)
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
assert len(import_report.items) == 1
|
||||||
|
assert import_report.items[0].object_name == 'Row: "row1", field: "Number"'
|
||||||
|
assert import_report.items[0].scope == SCOPE_CELL
|
||||||
|
assert import_report.items[0].table == "Test"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@responses.activate
|
@responses.activate
|
||||||
def test_airtable_import_number_decimal_column(data_fixture, api_client):
|
def test_airtable_import_number_decimal_column(data_fixture, api_client):
|
||||||
|
@ -1293,7 +1340,7 @@ def test_airtable_import_number_decimal_column(data_fixture, api_client):
|
||||||
)
|
)
|
||||||
assert isinstance(baserow_field, NumberField)
|
assert isinstance(baserow_field, NumberField)
|
||||||
assert isinstance(airtable_column_type, NumberAirtableColumnType)
|
assert isinstance(airtable_column_type, NumberAirtableColumnType)
|
||||||
assert baserow_field.number_decimal_places == 1
|
assert baserow_field.number_decimal_places == 0
|
||||||
assert baserow_field.number_negative is False
|
assert baserow_field.number_negative is False
|
||||||
|
|
||||||
airtable_field = {
|
airtable_field = {
|
||||||
|
@ -1319,6 +1366,9 @@ def test_airtable_import_number_decimal_column(data_fixture, api_client):
|
||||||
assert isinstance(airtable_column_type, NumberAirtableColumnType)
|
assert isinstance(airtable_column_type, NumberAirtableColumnType)
|
||||||
assert baserow_field.number_decimal_places == 2
|
assert baserow_field.number_decimal_places == 2
|
||||||
assert baserow_field.number_negative is True
|
assert baserow_field.number_negative is True
|
||||||
|
assert baserow_field.number_separator == ""
|
||||||
|
assert baserow_field.number_prefix == ""
|
||||||
|
assert baserow_field.number_suffix == ""
|
||||||
|
|
||||||
assert (
|
assert (
|
||||||
airtable_column_type.to_baserow_export_serialized_value(
|
airtable_column_type.to_baserow_export_serialized_value(
|
||||||
|
@ -1414,6 +1464,203 @@ def test_airtable_import_number_decimal_column(data_fixture, api_client):
|
||||||
assert isinstance(airtable_column_type, NumberAirtableColumnType)
|
assert isinstance(airtable_column_type, NumberAirtableColumnType)
|
||||||
assert baserow_field.number_decimal_places == 10
|
assert baserow_field.number_decimal_places == 10
|
||||||
assert baserow_field.number_negative is True
|
assert baserow_field.number_negative is True
|
||||||
|
assert baserow_field.number_separator == ""
|
||||||
|
assert baserow_field.number_prefix == ""
|
||||||
|
assert baserow_field.number_suffix == ""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@responses.activate
|
||||||
|
def test_airtable_import_currency_column(data_fixture, api_client):
|
||||||
|
airtable_field = {
|
||||||
|
"id": "fldZBmr4L45mhjILhlA",
|
||||||
|
"name": "Currency",
|
||||||
|
"type": "number",
|
||||||
|
"typeOptions": {
|
||||||
|
"format": "currency",
|
||||||
|
"precision": 3,
|
||||||
|
"symbol": "$",
|
||||||
|
"separatorFormat": "commaPeriod",
|
||||||
|
"negative": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
(
|
||||||
|
baserow_field,
|
||||||
|
airtable_column_type,
|
||||||
|
) = airtable_column_type_registry.from_airtable_column_to_serialized(
|
||||||
|
{},
|
||||||
|
airtable_field,
|
||||||
|
AirtableImportConfig(),
|
||||||
|
AirtableImportReport(),
|
||||||
|
)
|
||||||
|
assert isinstance(baserow_field, NumberField)
|
||||||
|
assert isinstance(airtable_column_type, NumberAirtableColumnType)
|
||||||
|
assert baserow_field.number_decimal_places == 3
|
||||||
|
assert baserow_field.number_negative is False
|
||||||
|
assert baserow_field.number_separator == "COMMA_PERIOD"
|
||||||
|
assert baserow_field.number_prefix == "$"
|
||||||
|
assert baserow_field.number_suffix == ""
|
||||||
|
|
||||||
|
airtable_field = {
|
||||||
|
"id": "fldZBmr4L45mhjILhlA",
|
||||||
|
"name": "Currency",
|
||||||
|
"type": "number",
|
||||||
|
"typeOptions": {
|
||||||
|
"format": "currency",
|
||||||
|
"precision": 2,
|
||||||
|
"symbol": "€",
|
||||||
|
"separatorFormat": "spacePeriod",
|
||||||
|
"negative": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
(
|
||||||
|
baserow_field,
|
||||||
|
airtable_column_type,
|
||||||
|
) = airtable_column_type_registry.from_airtable_column_to_serialized(
|
||||||
|
{},
|
||||||
|
airtable_field,
|
||||||
|
AirtableImportConfig(),
|
||||||
|
AirtableImportReport(),
|
||||||
|
)
|
||||||
|
assert isinstance(baserow_field, NumberField)
|
||||||
|
assert isinstance(airtable_column_type, NumberAirtableColumnType)
|
||||||
|
assert baserow_field.number_decimal_places == 2
|
||||||
|
assert baserow_field.number_negative is True
|
||||||
|
assert baserow_field.number_separator == "SPACE_PERIOD"
|
||||||
|
assert baserow_field.number_prefix == "€"
|
||||||
|
assert baserow_field.number_suffix == ""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@responses.activate
|
||||||
|
def test_airtable_import_currency_column_non_existing_separator_format(
|
||||||
|
data_fixture, api_client
|
||||||
|
):
|
||||||
|
airtable_field = {
|
||||||
|
"id": "fldZBmr4L45mhjILhlA",
|
||||||
|
"name": "Currency",
|
||||||
|
"type": "number",
|
||||||
|
"typeOptions": {
|
||||||
|
"format": "currency",
|
||||||
|
"precision": 3,
|
||||||
|
"symbol": "$",
|
||||||
|
"separatorFormat": "TEST",
|
||||||
|
"negative": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
import_report = AirtableImportReport()
|
||||||
|
airtable_column_type_registry.from_airtable_column_to_serialized(
|
||||||
|
{},
|
||||||
|
airtable_field,
|
||||||
|
AirtableImportConfig(),
|
||||||
|
import_report,
|
||||||
|
)
|
||||||
|
assert len(import_report.items) == 1
|
||||||
|
assert import_report.items[0].object_name == 'Number field: "Currency"'
|
||||||
|
assert import_report.items[0].scope == SCOPE_FIELD
|
||||||
|
assert import_report.items[0].table == ""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@responses.activate
|
||||||
|
def test_airtable_import_percentage_column(data_fixture, api_client):
|
||||||
|
airtable_field = {
|
||||||
|
"id": "fldZBmr4L45mhjILhlA",
|
||||||
|
"name": "Currency",
|
||||||
|
"type": "number",
|
||||||
|
"typeOptions": {
|
||||||
|
"format": "percentage",
|
||||||
|
"precision": 1,
|
||||||
|
"negative": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
(
|
||||||
|
baserow_field,
|
||||||
|
airtable_column_type,
|
||||||
|
) = airtable_column_type_registry.from_airtable_column_to_serialized(
|
||||||
|
{},
|
||||||
|
airtable_field,
|
||||||
|
AirtableImportConfig(),
|
||||||
|
AirtableImportReport(),
|
||||||
|
)
|
||||||
|
assert isinstance(baserow_field, NumberField)
|
||||||
|
assert isinstance(airtable_column_type, NumberAirtableColumnType)
|
||||||
|
assert baserow_field.number_decimal_places == 1
|
||||||
|
assert baserow_field.number_negative is False
|
||||||
|
assert baserow_field.number_separator == ""
|
||||||
|
assert baserow_field.number_prefix == ""
|
||||||
|
assert baserow_field.number_suffix == "%"
|
||||||
|
|
||||||
|
assert (
|
||||||
|
airtable_column_type.to_baserow_export_serialized_value(
|
||||||
|
{},
|
||||||
|
{"name": "Test"},
|
||||||
|
{"id": "row1"},
|
||||||
|
airtable_field,
|
||||||
|
baserow_field,
|
||||||
|
0.5,
|
||||||
|
{},
|
||||||
|
AirtableImportConfig(),
|
||||||
|
AirtableImportReport(),
|
||||||
|
)
|
||||||
|
== "50.0"
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
airtable_column_type.to_baserow_export_serialized_value(
|
||||||
|
{},
|
||||||
|
{"name": "Test"},
|
||||||
|
{"id": "row1"},
|
||||||
|
airtable_field,
|
||||||
|
baserow_field,
|
||||||
|
0.5,
|
||||||
|
{},
|
||||||
|
AirtableImportConfig(),
|
||||||
|
AirtableImportReport(),
|
||||||
|
)
|
||||||
|
== "50.0"
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
airtable_column_type.to_baserow_export_serialized_value(
|
||||||
|
{},
|
||||||
|
{"name": "Test"},
|
||||||
|
{"id": "row1"},
|
||||||
|
airtable_field,
|
||||||
|
baserow_field,
|
||||||
|
"0.05",
|
||||||
|
{},
|
||||||
|
AirtableImportConfig(),
|
||||||
|
AirtableImportReport(),
|
||||||
|
)
|
||||||
|
== "5.00"
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
airtable_column_type.to_baserow_export_serialized_value(
|
||||||
|
{},
|
||||||
|
{"name": "Test"},
|
||||||
|
{"id": "row1"},
|
||||||
|
airtable_field,
|
||||||
|
baserow_field,
|
||||||
|
"",
|
||||||
|
{},
|
||||||
|
AirtableImportConfig(),
|
||||||
|
AirtableImportReport(),
|
||||||
|
)
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
airtable_column_type.to_baserow_export_serialized_value(
|
||||||
|
{},
|
||||||
|
{"name": "Test"},
|
||||||
|
{"id": "row1"},
|
||||||
|
airtable_field,
|
||||||
|
baserow_field,
|
||||||
|
None,
|
||||||
|
{},
|
||||||
|
AirtableImportConfig(),
|
||||||
|
AirtableImportReport(),
|
||||||
|
)
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"type": "bug",
|
||||||
|
"message": "Preserve the precision of the currency field in the Airtable import.",
|
||||||
|
"issue_number": 1058,
|
||||||
|
"bullet_points": [],
|
||||||
|
"created_at": "2025-02-09"
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"type": "bug",
|
||||||
|
"message": "Preserve the currency symbol and formatting of the currency field in Airtable import.",
|
||||||
|
"issue_number": 3395,
|
||||||
|
"bullet_points": [],
|
||||||
|
"created_at": "2025-02-09"
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue