1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-28 06:22:24 +00:00

Merge branch 'field-airtable-import-improvements' into 'develop'

Airtable import field type improvements

Closes 

See merge request 
This commit is contained in:
Bram Wiepjes 2025-03-03 10:50:10 +00:00
commit 5412427ca8
13 changed files with 1444 additions and 119 deletions

View file

@ -1,16 +1,18 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from typing import Any, Dict, Optional from typing import Any, Dict
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from baserow.contrib.database.export_serialized import DatabaseExportSerializedStructure from baserow.contrib.database.export_serialized import DatabaseExportSerializedStructure
from baserow.contrib.database.fields.models import ( from baserow.contrib.database.fields.models import (
NUMBER_MAX_DECIMAL_PLACES, NUMBER_MAX_DECIMAL_PLACES,
AutonumberField,
BooleanField, BooleanField,
CountField, CountField,
CreatedOnField, CreatedOnField,
DateField, DateField,
DurationField,
EmailField, EmailField,
Field, Field,
FileField, FileField,
@ -26,9 +28,18 @@ from baserow.contrib.database.fields.models import (
URLField, URLField,
) )
from baserow.contrib.database.fields.registries import field_type_registry from baserow.contrib.database.fields.registries import field_type_registry
from baserow.contrib.database.fields.utils.duration import D_H, H_M_S_SSS
from baserow.core.utils import get_value_at_path
from .config import AirtableImportConfig from .config import AirtableImportConfig
from .constants import AIRTABLE_NUMBER_FIELD_SEPARATOR_FORMAT_MAPPING from .constants import (
AIRTABLE_DURATION_FIELD_DURATION_FORMAT_MAPPING,
AIRTABLE_MAX_DURATION_VALUE,
AIRTABLE_NUMBER_FIELD_SEPARATOR_FORMAT_MAPPING,
AIRTABLE_RATING_COLOR_MAPPING,
AIRTABLE_RATING_ICON_MAPPING,
)
from .exceptions import AirtableSkipCellValue
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,
@ -38,7 +49,7 @@ from .import_report import (
AirtableImportReport, AirtableImportReport,
) )
from .registry import AirtableColumnType from .registry import AirtableColumnType
from .utils import get_airtable_row_primary_value from .utils import get_airtable_row_primary_value, quill_to_markdown
class TextAirtableColumnType(AirtableColumnType): class TextAirtableColumnType(AirtableColumnType):
@ -53,7 +64,7 @@ class TextAirtableColumnType(AirtableColumnType):
elif validator_name == "email": elif validator_name == "email":
return EmailField() return EmailField()
else: else:
return TextField() return TextField(text_default=raw_airtable_column.get("default", ""))
def to_baserow_export_serialized_value( def to_baserow_export_serialized_value(
self, self,
@ -86,6 +97,25 @@ class TextAirtableColumnType(AirtableColumnType):
return value return value
def to_baserow_export_empty_value(
self,
row_id_mapping,
raw_airtable_table,
raw_airtable_row,
raw_airtable_column,
baserow_field,
files_to_download,
config,
import_report,
):
# If the `text_default` is set, then we must return an empty string. If we
# don't, the value is omitted in the export, resulting in the default value
# automatically being set, while it's actually empty in Airtable.
if isinstance(baserow_field, TextField) and baserow_field.text_default != "":
return ""
else:
raise AirtableSkipCellValue
class MultilineTextAirtableColumnType(AirtableColumnType): class MultilineTextAirtableColumnType(AirtableColumnType):
type = "multilineText" type = "multilineText"
@ -102,7 +132,7 @@ class RichTextTextAirtableColumnType(AirtableColumnType):
def to_baserow_field( def to_baserow_field(
self, raw_airtable_table, raw_airtable_column, config, import_report self, raw_airtable_table, raw_airtable_column, config, import_report
): ):
return LongTextField() return LongTextField(long_text_enable_rich_text=True)
def to_baserow_export_serialized_value( def to_baserow_export_serialized_value(
self, self,
@ -116,37 +146,7 @@ class RichTextTextAirtableColumnType(AirtableColumnType):
config, config,
import_report, import_report,
): ):
# We don't support rich text formatting yet, so this converts the value to return quill_to_markdown(value["documentValue"])
# plain text.
rich_values = []
for v in value["documentValue"]:
insert_value = v["insert"]
if isinstance(insert_value, str):
rich_values.append(insert_value)
elif isinstance(insert_value, dict):
rich_value = self._extract_value_from_airtable_rich_value_dict(
insert_value
)
if rich_value is not None:
rich_values.append(rich_value)
return "".join(rich_values)
def _extract_value_from_airtable_rich_value_dict(
self, insert_value_dict: Dict[Any, Any]
) -> Optional[str]:
"""
Airtable rich text fields can contain references to users. For now this method
attempts to return a @userId reference string. In the future if Baserow has
a rich text field and the ability to reference users in them we should map
this airtable userId to the corresponding Baserow user id.
"""
mention = insert_value_dict.get("mention")
if isinstance(mention, dict):
user_id = mention.get("userId")
if user_id is not None:
return f"@{user_id}"
class NumberAirtableColumnType(AirtableColumnType): class NumberAirtableColumnType(AirtableColumnType):
@ -155,10 +155,51 @@ class NumberAirtableColumnType(AirtableColumnType):
def to_baserow_field( def to_baserow_field(
self, raw_airtable_table, raw_airtable_column, config, import_report self, raw_airtable_table, raw_airtable_column, config, import_report
): ):
self.add_import_report_failed_if_default_is_provided(
raw_airtable_table, raw_airtable_column, import_report
)
type_options = raw_airtable_column.get("typeOptions", {}) type_options = raw_airtable_column.get("typeOptions", {})
options_format = type_options.get("format", "") options_format = type_options.get("format", "")
if options_format in ["duration", "durationInDays"]:
return self.to_duration_field(
raw_airtable_table, raw_airtable_column, config, import_report
)
else:
return self.to_number_field(
raw_airtable_table, raw_airtable_column, config, import_report
)
def to_duration_field(
self, raw_airtable_table, raw_airtable_column, config, import_report
):
type_options = raw_airtable_column.get("typeOptions", {})
options_format = type_options.get("format", "")
duration_format = type_options.get("durationFormat", "")
if options_format == "durationInDays":
# It looks like this option is broken in Airtable. When this is selected,
# the exact value seems to be in seconds, but it should be in days. We
# will therefore convert it to days when calculating the value.
duration_format = D_H
else:
# Fallback to the most specific format because that leaves most of the
# value intact.
duration_format = AIRTABLE_DURATION_FIELD_DURATION_FORMAT_MAPPING.get(
duration_format, H_M_S_SSS
)
return DurationField(duration_format=duration_format)
def to_number_field(
self, raw_airtable_table, raw_airtable_column, config, import_report
):
suffix = "" suffix = ""
type_options = raw_airtable_column.get("typeOptions", {})
options_format = type_options.get("format", "")
if "percent" in options_format: if "percent" in options_format:
suffix = "%" suffix = "%"
@ -173,7 +214,7 @@ class NumberAirtableColumnType(AirtableColumnType):
if separator_format != "" and number_separator == "": if separator_format != "" and number_separator == "":
import_report.add_failed( import_report.add_failed(
f"Number field: \"{raw_airtable_column['name']}\"", raw_airtable_column["name"],
SCOPE_FIELD, SCOPE_FIELD,
raw_airtable_table.get("name", ""), raw_airtable_table.get("name", ""),
ERROR_TYPE_UNSUPPORTED_FEATURE, ERROR_TYPE_UNSUPPORTED_FEATURE,
@ -204,14 +245,40 @@ class NumberAirtableColumnType(AirtableColumnType):
if value is None: if value is None:
return None return None
type_options = raw_airtable_column.get("typeOptions", {})
options_format = type_options.get("format", "")
row_name = get_airtable_row_primary_value(raw_airtable_table, raw_airtable_row)
if options_format == "durationInDays":
# If the formatting is in days, we must multiply the raw value in seconds
# by the number of seconds in a day.
value = value * 60 * 60 * 24
if "duration" in options_format:
# If the value is higher than the maximum that the `timedelta` can handle,
# then we can't use it, so we have to drop it. The maximum number of days
# in `timedelta` is `999999999`, so the max number of seconds are
# 999999999 * 24 * 60 * 60 = 86399999913600.
if abs(value) > AIRTABLE_MAX_DURATION_VALUE:
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 duration seconds {value} "
f'is outside the -86399999913600 and 86399999913600 range."',
)
return None
# If the value is a duration, then we can use the same value because both
# store it as seconds.
return value
try: try:
value = Decimal(value) value = Decimal(value)
except InvalidOperation: except InvalidOperation:
# If the value can't be parsed as decimal, then it might be corrupt, so we # 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. # 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( import_report.add_failed(
f"Row: \"{row_name}\", field: \"{raw_airtable_column['name']}\"", f"Row: \"{row_name}\", field: \"{raw_airtable_column['name']}\"",
SCOPE_CELL, SCOPE_CELL,
@ -224,8 +291,6 @@ class NumberAirtableColumnType(AirtableColumnType):
# Airtable stores 10% as 0.1, so we would need to multiply it by 100 so get the # Airtable stores 10% as 0.1, so we would need to multiply it by 100 so get the
# correct value in Baserow. # correct value in Baserow.
type_options = raw_airtable_column.get("typeOptions", {})
options_format = type_options.get("format", "")
if "percent" in options_format: if "percent" in options_format:
value = value * 100 value = value * 100
@ -241,8 +306,39 @@ class RatingAirtableColumnType(AirtableColumnType):
def to_baserow_field( def to_baserow_field(
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", {})
airtable_icon = type_options.get("icon", "")
airtable_max = type_options.get("max", 5)
airtable_color = type_options.get("color", "")
style = AIRTABLE_RATING_ICON_MAPPING.get(airtable_icon, "")
if style == "":
style = list(AIRTABLE_RATING_ICON_MAPPING.values())[0]
import_report.add_failed(
raw_airtable_column["name"],
SCOPE_FIELD,
raw_airtable_table.get("name", ""),
ERROR_TYPE_UNSUPPORTED_FEATURE,
f"The field was imported, but the icon {airtable_icon} does not "
f"exist, so it defaulted to {style}.",
)
color = AIRTABLE_RATING_COLOR_MAPPING.get(airtable_color, "")
if color == "":
color = list(AIRTABLE_RATING_COLOR_MAPPING.values())[0]
import_report.add_failed(
raw_airtable_column["name"],
SCOPE_FIELD,
raw_airtable_table.get("name", ""),
ERROR_TYPE_UNSUPPORTED_FEATURE,
f"The field was imported, but the color {airtable_color} does not "
f"exist, so it defaulted to {color}.",
)
return RatingField( return RatingField(
max_value=raw_airtable_column.get("typeOptions", {}).get("max", 5) max_value=airtable_max,
style=style,
color=color,
) )
@ -252,6 +348,32 @@ class CheckboxAirtableColumnType(AirtableColumnType):
def to_baserow_field( def to_baserow_field(
self, raw_airtable_table, raw_airtable_column, config, import_report self, raw_airtable_table, raw_airtable_column, config, import_report
): ):
self.add_import_report_failed_if_default_is_provided(
raw_airtable_table, raw_airtable_column, import_report
)
type_options = raw_airtable_column.get("typeOptions", {})
airtable_icon = type_options.get("icon", "check")
airtable_color = type_options.get("color", "green")
if airtable_icon != "check":
import_report.add_failed(
raw_airtable_column["name"],
SCOPE_FIELD,
raw_airtable_table.get("name", ""),
ERROR_TYPE_UNSUPPORTED_FEATURE,
f"The field was imported, but the icon {airtable_icon} is not supported.",
)
if airtable_color != "green":
import_report.add_failed(
raw_airtable_column["name"],
SCOPE_FIELD,
raw_airtable_table.get("name", ""),
ERROR_TYPE_UNSUPPORTED_FEATURE,
f"The field was imported, but the color {airtable_color} is not supported.",
)
return BooleanField() return BooleanField()
def to_baserow_export_serialized_value( def to_baserow_export_serialized_value(
@ -275,6 +397,13 @@ class DateAirtableColumnType(AirtableColumnType):
def to_baserow_field( def to_baserow_field(
self, raw_airtable_table, raw_airtable_column, config, import_report self, raw_airtable_table, raw_airtable_column, config, import_report
): ):
self.add_import_report_failed_if_default_is_provided(
raw_airtable_table,
raw_airtable_column,
import_report,
to_human_readable_default=lambda x: "Current date",
)
type_options = raw_airtable_column.get("typeOptions", {}) type_options = raw_airtable_column.get("typeOptions", {})
# Check if a timezone is provided in the type options, if so, we might want # Check if a timezone is provided in the type options, if so, we might want
# to use that timezone for the conversion later on. # to use that timezone for the conversion later on.
@ -283,13 +412,6 @@ class DateAirtableColumnType(AirtableColumnType):
# date_force_timezone=None it the equivalent of airtable_timezone="client". # date_force_timezone=None it the equivalent of airtable_timezone="client".
if airtable_timezone == "client": if airtable_timezone == "client":
import_report.add_failed(
raw_airtable_column["name"],
SCOPE_FIELD,
raw_airtable_table.get("name", ""),
ERROR_TYPE_UNSUPPORTED_FEATURE,
"The date field was imported, but the client timezone setting was dropped.",
)
airtable_timezone = None airtable_timezone = None
return DateField( return DateField(
@ -358,15 +480,6 @@ class FormulaAirtableColumnType(AirtableColumnType):
is_last_modified = display_type == "lastModifiedTime" is_last_modified = display_type == "lastModifiedTime"
is_created = display_type == "createdTime" is_created = display_type == "createdTime"
if is_last_modified or is_created and airtable_timezone == "client":
import_report.add_failed(
raw_airtable_column["name"],
SCOPE_FIELD,
raw_airtable_table.get("name", ""),
ERROR_TYPE_UNSUPPORTED_FEATURE,
"The field was imported, but the client timezone setting was dropped.",
)
# date_force_timezone=None it the equivalent of airtable_timezone="client". # date_force_timezone=None it the equivalent of airtable_timezone="client".
if airtable_timezone == "client": if airtable_timezone == "client":
airtable_timezone = None airtable_timezone = None
@ -374,6 +487,22 @@ class FormulaAirtableColumnType(AirtableColumnType):
# The formula conversion isn't support yet, but because the Created on and # The formula conversion isn't support yet, but because the Created on and
# Last modified fields work as a formula, we can convert those. # Last modified fields work as a formula, we can convert those.
if is_last_modified: if is_last_modified:
dependencies = type_options.get("dependencies", {})
all_column_modifications = dependencies.get(
"dependsOnAllColumnModifications", False
)
if not all_column_modifications:
import_report.add_failed(
raw_airtable_column["name"],
SCOPE_FIELD,
raw_airtable_table.get("name", ""),
ERROR_TYPE_UNSUPPORTED_FEATURE,
f"The field was imported, but the support to depend on "
f"specific fields was dropped because that's not supported by "
f"Baserow.",
)
return LastModifiedField( return LastModifiedField(
date_show_tzinfo=date_show_tzinfo, date_show_tzinfo=date_show_tzinfo,
date_force_timezone=airtable_timezone, date_force_timezone=airtable_timezone,
@ -421,6 +550,54 @@ class ForeignKeyAirtableColumnType(AirtableColumnType):
): ):
type_options = raw_airtable_column.get("typeOptions", {}) type_options = raw_airtable_column.get("typeOptions", {})
foreign_table_id = type_options.get("foreignTableId") foreign_table_id = type_options.get("foreignTableId")
relationship = type_options.get("relationship", "many") # can be: one
view_id_for_record_selection = type_options.get(
"viewIdForRecordSelection", None
)
filters_for_record_selection = type_options.get(
"filtersForRecordSelection", None
)
ai_matching_options = type_options.get("aiMatchingOptions", None)
if relationship != "many":
import_report.add_failed(
raw_airtable_column["name"],
SCOPE_FIELD,
raw_airtable_table.get("name", ""),
ERROR_TYPE_UNSUPPORTED_FEATURE,
f"The field was imported, but support for a one to many "
f"relationship was dropped because it's not supported by Baserow.",
)
if view_id_for_record_selection is not None:
import_report.add_failed(
raw_airtable_column["name"],
SCOPE_FIELD,
raw_airtable_table.get("name", ""),
ERROR_TYPE_UNSUPPORTED_FEATURE,
f"The field was imported, but limiting record selection to a view "
f"was dropped because the views have not been imported.",
)
if filters_for_record_selection is not None:
import_report.add_failed(
raw_airtable_column["name"],
SCOPE_FIELD,
raw_airtable_table.get("name", ""),
ERROR_TYPE_UNSUPPORTED_FEATURE,
f"The field was imported, but filtering record by a condition "
f"was dropped because it's not supported by Baserow.",
)
if ai_matching_options is not None:
import_report.add_failed(
raw_airtable_column["name"],
SCOPE_FIELD,
raw_airtable_table.get("name", ""),
ERROR_TYPE_UNSUPPORTED_FEATURE,
f"The field was imported, but using AI to show top matches was "
f"dropped because it's not supported by Baserow.",
)
return LinkRowField( return LinkRowField(
link_row_table_id=foreign_table_id, link_row_table_id=foreign_table_id,
@ -531,12 +708,21 @@ class SelectAirtableColumnType(AirtableColumnType):
def to_baserow_field( def to_baserow_field(
self, raw_airtable_table, raw_airtable_column, config, import_report self, raw_airtable_table, raw_airtable_column, config, import_report
): ):
field = SingleSelectField() id_value = raw_airtable_column.get("id", "")
field = set_select_options_on_field( type_options = raw_airtable_column.get("typeOptions", {})
field,
raw_airtable_column.get("id", ""), def get_default(x):
raw_airtable_column.get("typeOptions", {}), return get_value_at_path(type_options, f"choices.{x}.name", "")
self.add_import_report_failed_if_default_is_provided(
raw_airtable_table,
raw_airtable_column,
import_report,
to_human_readable_default=get_default,
) )
field = SingleSelectField()
field = set_select_options_on_field(field, id_value, type_options)
return field return field
@ -562,12 +748,27 @@ class MultiSelectAirtableColumnType(AirtableColumnType):
def to_baserow_field( def to_baserow_field(
self, raw_airtable_table, raw_airtable_column, config, import_report self, raw_airtable_table, raw_airtable_column, config, import_report
): ):
field = MultipleSelectField() id_value = raw_airtable_column.get("id", "")
field = set_select_options_on_field( type_options = raw_airtable_column.get("typeOptions", {})
field,
raw_airtable_column.get("id", ""), def get_default(default):
raw_airtable_column.get("typeOptions", {}), default = default or []
return ", ".join(
[
get_value_at_path(type_options, f"choices.{v}.name", "")
for v in default
]
)
self.add_import_report_failed_if_default_is_provided(
raw_airtable_table,
raw_airtable_column,
import_report,
to_human_readable_default=get_default,
) )
field = MultipleSelectField()
field = set_select_options_on_field(field, id_value, type_options)
return field return field
@ -631,3 +832,12 @@ class CountAirtableColumnType(AirtableColumnType):
import_report, import_report,
): ):
return None return None
class AutoNumberAirtableColumnType(AirtableColumnType):
type = "autoNumber"
def to_baserow_field(
self, raw_airtable_table, raw_airtable_column, config, import_report
):
return AutonumberField()

View file

@ -1,3 +1,12 @@
from baserow.contrib.database.fields.utils.duration import (
H_M,
H_M_S,
H_M_S_S,
H_M_S_SS,
H_M_S_SSS,
)
AIRTABLE_MAX_DURATION_VALUE = 86399999913600
AIRTABLE_EXPORT_JOB_DOWNLOADING_BASE = "downloading-base" AIRTABLE_EXPORT_JOB_DOWNLOADING_BASE = "downloading-base"
AIRTABLE_EXPORT_JOB_CONVERTING = "converting" AIRTABLE_EXPORT_JOB_CONVERTING = "converting"
AIRTABLE_EXPORT_JOB_DOWNLOADING_FILES = "downloading-files" AIRTABLE_EXPORT_JOB_DOWNLOADING_FILES = "downloading-files"
@ -19,3 +28,27 @@ AIRTABLE_NUMBER_FIELD_SEPARATOR_FORMAT_MAPPING = {
"spaceComma": "SPACE_COMMA", "spaceComma": "SPACE_COMMA",
"spacePeriod": "SPACE_PERIOD", "spacePeriod": "SPACE_PERIOD",
} }
AIRTABLE_DURATION_FIELD_DURATION_FORMAT_MAPPING = {
"h:mm": H_M,
"h:mm:ss": H_M_S,
"h:mm:ss.s": H_M_S_S,
"h:mm:ss.ss": H_M_S_SS,
"h:mm:ss.sss": H_M_S_SSS,
}
# All colors from the rating field in Airtable: yellow, orange, red, pink, purple,
# blue, cyan, teal, green, gray. We're only mapping the ones that we have an
# alternative for.
AIRTABLE_RATING_COLOR_MAPPING = {
"blue": "dark-blue",
"green": "dark-green",
"orange": "dark-orange",
"red": "dark-red",
}
# All icons from Airtable: star, heart, thumbsUp, flag, dot. We're only mapping the
# ones that we have an alternative for.
AIRTABLE_RATING_ICON_MAPPING = {
"star": "star",
"heart": "heart",
"thumbsUp": "thumbs-up",
"flag": "flag",
}

View file

@ -8,3 +8,10 @@ class AirtableShareIsNotABase(Exception):
class AirtableImportNotRespectingConfig(Exception): class AirtableImportNotRespectingConfig(Exception):
"""Raised when the Airtable import is not respecting the `AirtableImportConfig`.""" """Raised when the Airtable import is not respecting the `AirtableImportConfig`."""
class AirtableSkipCellValue(Exception):
"""
Raised when an Airtable cell value must be skipped, and be omitted from the
export.
"""

View file

@ -39,6 +39,7 @@ from .exceptions import (
AirtableBaseNotPublic, AirtableBaseNotPublic,
AirtableImportNotRespectingConfig, AirtableImportNotRespectingConfig,
AirtableShareIsNotABase, AirtableShareIsNotABase,
AirtableSkipCellValue,
) )
from .import_report import ( from .import_report import (
ERROR_TYPE_UNSUPPORTED_FEATURE, ERROR_TYPE_UNSUPPORTED_FEATURE,
@ -247,6 +248,7 @@ class AirtableHandler:
baserow_field.pk = 0 baserow_field.pk = 0
baserow_field.name = column["name"] baserow_field.name = column["name"]
baserow_field.order = order baserow_field.order = order
baserow_field.description = column.get("description", None) or None
baserow_field.primary = ( baserow_field.primary = (
baserow_field_type.can_be_primary_field(baserow_field) baserow_field_type.can_be_primary_field(baserow_field)
and table["primaryColumnId"] == column["id"] and table["primaryColumnId"] == column["id"]
@ -305,25 +307,42 @@ class AirtableHandler:
# Some empty rows don't have the `cellValuesByColumnId` property because it # Some empty rows don't have the `cellValuesByColumnId` property because it
# doesn't contain values, hence the fallback to prevent failing hard. # doesn't contain values, hence the fallback to prevent failing hard.
cell_values = row.get("cellValuesByColumnId", {}) cell_values = row.get("cellValuesByColumnId", {})
for column_id, column_value in cell_values.items():
if column_id not in column_mapping:
continue
mapping_values = column_mapping[column_id] for column_id, mapping_values in column_mapping.items():
baserow_serialized_value = mapping_values[ airtable_column_type = mapping_values["airtable_column_type"]
"airtable_column_type" args = [
].to_baserow_export_serialized_value(
row_id_mapping, row_id_mapping,
table, table,
row, row,
mapping_values["raw_airtable_column"], mapping_values["raw_airtable_column"],
mapping_values["baserow_field"], mapping_values["baserow_field"],
column_value, cell_values.get(column_id, None),
files_to_download, files_to_download,
config, config,
import_report, import_report,
) ]
exported_row[f"field_{column_id}"] = baserow_serialized_value
try:
# The column_id typically doesn't exist in the `cell_values` if the
# value is empty in Airtable.
if column_id in cell_values:
baserow_serialized_value = (
airtable_column_type.to_baserow_export_serialized_value(*args)
)
else:
# remove the cell_value because that one is not accepted in the args
# of this method.
args.pop(5)
baserow_serialized_value = (
airtable_column_type.to_baserow_export_empty_value(*args)
)
exported_row[f"field_{column_id}"] = baserow_serialized_value
except AirtableSkipCellValue:
# If the `AirtableSkipCellValue` is raised, then the cell value must
# not be included in the export. This is the default behavior for
# `to_baserow_export_empty_value`, but in some cases, a specific empty
# value must be returned.
pass
return exported_row return exported_row
@ -500,6 +519,13 @@ class AirtableHandler:
).can_be_primary_field(value["baserow_field"]): ).can_be_primary_field(value["baserow_field"]):
value["baserow_field"].primary = True value["baserow_field"].primary = True
found_existing_field = True found_existing_field = True
import_report.add_failed(
value["baserow_field"].name,
SCOPE_FIELD,
table["name"],
ERROR_TYPE_UNSUPPORTED_FEATURE,
f"""Changed primary field to "{value["baserow_field"].name}" because the original primary field is incompatible.""",
)
break break
# If none of the existing fields can be primary, we will add a new # If none of the existing fields can be primary, we will add a new
@ -524,6 +550,13 @@ class AirtableHandler:
"raw_airtable_column": airtable_column, "raw_airtable_column": airtable_column,
"airtable_column_type": airtable_column_type, "airtable_column_type": airtable_column_type,
} }
import_report.add_failed(
baserow_field.name,
SCOPE_FIELD,
table["name"],
ERROR_TYPE_UNSUPPORTED_FEATURE,
f"""Created new primary field "{baserow_field.name}" because none of the provided fields are compatible.""",
)
# Loop over all the fields and convert them to Baserow serialized format. # Loop over all the fields and convert them to Baserow serialized format.
exported_fields = [ exported_fields = [

View file

@ -2,7 +2,12 @@ from datetime import tzinfo
from typing import Any, Dict, Tuple, Union from typing import Any, Dict, Tuple, Union
from baserow.contrib.database.airtable.config import AirtableImportConfig from baserow.contrib.database.airtable.config import AirtableImportConfig
from baserow.contrib.database.airtable.import_report import AirtableImportReport from baserow.contrib.database.airtable.exceptions import AirtableSkipCellValue
from baserow.contrib.database.airtable.import_report import (
ERROR_TYPE_UNSUPPORTED_FEATURE,
SCOPE_FIELD,
AirtableImportReport,
)
from baserow.contrib.database.fields.models import Field from baserow.contrib.database.fields.models import Field
from baserow.core.registry import Instance, Registry from baserow.core.registry import Instance, Registry
@ -70,6 +75,40 @@ class AirtableColumnType(Instance):
return value return value
def to_baserow_export_empty_value(
self,
row_id_mapping: Dict[str, Dict[str, int]],
raw_airtable_table: dict,
raw_airtable_row: dict,
raw_airtable_column: dict,
baserow_field: Field,
files_to_download: Dict[str, str],
config: AirtableImportConfig,
import_report: AirtableImportReport,
):
# By default, raise the `AirtableSkipCellValue` so that the value is not
# included in the export.
raise AirtableSkipCellValue
def add_import_report_failed_if_default_is_provided(
self,
raw_airtable_table: dict,
raw_airtable_column: dict,
import_report: AirtableImportReport,
to_human_readable_default=(lambda x: x),
):
default = raw_airtable_column.get("default", "")
if default:
default = to_human_readable_default(default)
import_report.add_failed(
raw_airtable_column["name"],
SCOPE_FIELD,
raw_airtable_table.get("name", ""),
ERROR_TYPE_UNSUPPORTED_FEATURE,
f"The field was imported, but the default value "
f"{default} was dropped because that's not supported in Baserow.",
)
class AirtableColumnTypeRegistry(Registry): class AirtableColumnTypeRegistry(Registry):
name = "airtable_column" name = "airtable_column"

View file

@ -39,3 +39,137 @@ def get_airtable_row_primary_value(table, row):
primary_value = row["id"] primary_value = row["id"]
return primary_value return primary_value
def quill_parse_inline(insert, attributes):
if "bold" in attributes:
insert = f"**{insert}**"
if "italic" in attributes:
insert = f"_{insert}_"
if "strike" in attributes:
insert = f"~{insert}~"
if "code" in attributes:
insert = f"`{insert}`"
if "link" in attributes:
insert = f"[{insert}]({attributes['link']})"
if isinstance(insert, object) and "mention" in insert:
insert = f"@{insert['mention'].get('userId', '')}"
return insert
def quill_wrap_block(attributes):
prepend = ""
append = ""
multi_line = False
if "header" in attributes:
prepend = "#" * attributes["header"] + " "
if "list" in attributes:
list_type = attributes["list"]
prepend = " " * attributes.get("indent", 0) * 4
if list_type == "ordered":
prepend += f"1. "
elif list_type == "bullet":
prepend += "- "
elif list_type == "unchecked":
prepend += "- [ ] "
elif list_type == "checked":
prepend += "- [x] "
if "blockquote" in attributes:
prepend = "> "
if "≈≈" in attributes:
prepend = "> "
if "code-block" in attributes:
prepend = "```\n"
append = "```\n"
multi_line = True
return prepend, append, multi_line
def quill_split_with_newlines(value):
parts = re.split(r"(\n)", value)
if parts and parts[0] == "":
parts.pop(0)
if parts and parts[-1] == "":
parts.pop()
return parts
def quill_to_markdown(ops: list) -> str:
"""
Airtable uses the QuillJS editor for their rich text field. There is no library
to convert it in Baserow compatible markdown. This is a simple, custom written
function to convert it to Baserow compatible markdown.
The format is a bit odd because a newline entry can define how it should have been
formatted as on block level, making it a bit tricky because it's not sequential.
See the `test_quill_to_markdown_airtable_example` test for an example.
:param ops: The QuillJS delta object that must be converted to markdown.
:return: The converted markdown string.
"""
md_output = []
# Holds everything that must be written as a line. Each entry in the ops can add to
# it until a "\n" character is detected.
current_object = ""
# Temporarily holds markdown code that has start and ending block, like with
# code "```", for example. Need to temporarily store the prepend and append values,
# so that we can add to it if it consists of multiple lines.
current_multi_line = None
def flush_line():
nonlocal md_output
nonlocal current_object
if current_object != "":
md_output.append(current_object)
current_object = ""
def flush_multi_line(current_prepend, current_append):
nonlocal current_object
nonlocal current_multi_line
if current_multi_line is not None and current_multi_line != (
current_prepend,
current_append,
):
current_object = (
current_multi_line[0] + current_object + current_multi_line[1]
)
flush_line()
current_multi_line = None
for index, op in enumerate(ops):
raw_insert = op.get("insert", "")
attributes = op.get("attributes", {})
if isinstance(raw_insert, str):
insert_lines = quill_split_with_newlines(raw_insert)
else:
insert_lines = [raw_insert]
# Break the insert by "\n" because the block formatting options should only
# refer to the previous line.
for insert_line in insert_lines:
is_new_line = insert_line == "\n"
if is_new_line:
prepend, append, multi_line = quill_wrap_block(attributes)
flush_multi_line(prepend, append)
# Starting a new multi-line block. All the following lines will be
# enclosed by the prepend and append.
if multi_line and current_multi_line is None:
current_multi_line = (prepend, append)
parsed_insert = quill_parse_inline(insert_line, attributes)
current_object += parsed_insert
if is_new_line and not multi_line:
current_object = prepend + current_object + append
flush_line()
flush_multi_line(None, None)
flush_line()
return "".join(md_output).strip()

View file

@ -615,6 +615,7 @@ class DatabaseConfig(AppConfig):
webhook_event_type_registry.register(ViewDeletedEventType()) webhook_event_type_registry.register(ViewDeletedEventType())
from .airtable.airtable_column_types import ( from .airtable.airtable_column_types import (
AutoNumberAirtableColumnType,
CheckboxAirtableColumnType, CheckboxAirtableColumnType,
CountAirtableColumnType, CountAirtableColumnType,
DateAirtableColumnType, DateAirtableColumnType,
@ -645,6 +646,7 @@ class DatabaseConfig(AppConfig):
airtable_column_type_registry.register(MultipleAttachmentAirtableColumnType()) airtable_column_type_registry.register(MultipleAttachmentAirtableColumnType())
airtable_column_type_registry.register(RichTextTextAirtableColumnType()) airtable_column_type_registry.register(RichTextTextAirtableColumnType())
airtable_column_type_registry.register(CountAirtableColumnType()) airtable_column_type_registry.register(CountAirtableColumnType())
airtable_column_type_registry.register(AutoNumberAirtableColumnType())
from .data_sync.data_sync_types import ( from .data_sync.data_sync_types import (
ICalCalendarDataSyncType, ICalCalendarDataSyncType,

View file

@ -1923,7 +1923,7 @@ class DurationFieldType(FieldType):
_db_column_fields = [] _db_column_fields = []
def get_model_field(self, instance: DurationField, **kwargs): def get_model_field(self, instance: DurationField, **kwargs):
return DurationModelField(instance.duration_format, null=True) return DurationModelField(instance.duration_format, null=True, **kwargs)
def get_serializer_field(self, instance: DurationField, **kwargs): def get_serializer_field(self, instance: DurationField, **kwargs):
return DurationFieldSerializer( return DurationFieldSerializer(

View file

@ -54,12 +54,14 @@
{ {
"id":"fldG9y88Zw7q7u4Z7i4", "id":"fldG9y88Zw7q7u4Z7i4",
"name":"Name", "name":"Name",
"type":"text" "type":"text",
"description": ""
}, },
{ {
"id":"fldB7wkyR0buF1sRF9O", "id":"fldB7wkyR0buF1sRF9O",
"name":"Email", "name":"Email",
"type":"text", "type":"text",
"description": "This is an email",
"typeOptions":{ "typeOptions":{
"validatorName":"email" "validatorName":"email"
} }

View file

@ -18,6 +18,7 @@ from baserow.contrib.database.airtable.airtable_column_types import (
TextAirtableColumnType, TextAirtableColumnType,
) )
from baserow.contrib.database.airtable.config import AirtableImportConfig from baserow.contrib.database.airtable.config import AirtableImportConfig
from baserow.contrib.database.airtable.exceptions import AirtableSkipCellValue
from baserow.contrib.database.airtable.import_report import ( from baserow.contrib.database.airtable.import_report import (
SCOPE_CELL, SCOPE_CELL,
SCOPE_FIELD, SCOPE_FIELD,
@ -25,10 +26,12 @@ from baserow.contrib.database.airtable.import_report import (
) )
from baserow.contrib.database.airtable.registry import airtable_column_type_registry from baserow.contrib.database.airtable.registry import airtable_column_type_registry
from baserow.contrib.database.fields.models import ( from baserow.contrib.database.fields.models import (
AutonumberField,
BooleanField, BooleanField,
CountField, CountField,
CreatedOnField, CreatedOnField,
DateField, DateField,
DurationField,
EmailField, EmailField,
FileField, FileField,
LastModifiedField, LastModifiedField,
@ -42,6 +45,7 @@ from baserow.contrib.database.fields.models import (
TextField, TextField,
URLField, URLField,
) )
from baserow.contrib.database.fields.utils.duration import D_H, H_M, H_M_S
@pytest.mark.django_db @pytest.mark.django_db
@ -87,6 +91,47 @@ def test_airtable_import_text_column(data_fixture, api_client):
"name": "Single line text", "name": "Single line text",
"type": "text", "type": "text",
} }
import_report = AirtableImportReport()
(
baserow_field,
airtable_column_type,
) = airtable_column_type_registry.from_airtable_column_to_serialized(
{},
airtable_field,
AirtableImportConfig(),
import_report,
)
assert baserow_field.text_default == ""
assert len(import_report.items) == 0
assert isinstance(baserow_field, TextField)
assert isinstance(airtable_column_type, TextAirtableColumnType)
with pytest.raises(AirtableSkipCellValue):
assert (
airtable_column_type.to_baserow_export_empty_value(
{},
{"name": "Test"},
{"id": "row1"},
airtable_field,
baserow_field,
{},
AirtableImportConfig(),
AirtableImportReport(),
)
== ""
)
@pytest.mark.django_db
@responses.activate
def test_airtable_import_text_column_preserve_default(data_fixture, api_client):
airtable_field = {
"id": "fldwSc9PqedIhTSqhi5",
"name": "Single line text",
"type": "text",
"default": "test",
}
import_report = AirtableImportReport()
( (
baserow_field, baserow_field,
airtable_column_type, airtable_column_type,
@ -96,8 +141,22 @@ def test_airtable_import_text_column(data_fixture, api_client):
AirtableImportConfig(), AirtableImportConfig(),
AirtableImportReport(), AirtableImportReport(),
) )
assert isinstance(baserow_field, TextField) assert baserow_field.text_default == "test"
assert isinstance(airtable_column_type, TextAirtableColumnType) assert len(import_report.items) == 0
assert (
airtable_column_type.to_baserow_export_empty_value(
{},
{"name": "Test"},
{"id": "row1"},
airtable_field,
baserow_field,
{},
AirtableImportConfig(),
AirtableImportReport(),
)
== ""
)
@pytest.mark.django_db @pytest.mark.django_db
@ -109,6 +168,7 @@ def test_airtable_import_checkbox_column(data_fixture, api_client):
"type": "checkbox", "type": "checkbox",
"typeOptions": {"color": "green", "icon": "check"}, "typeOptions": {"color": "green", "icon": "check"},
} }
import_report = AirtableImportReport()
( (
baserow_field, baserow_field,
airtable_column_type, airtable_column_type,
@ -116,12 +176,89 @@ def test_airtable_import_checkbox_column(data_fixture, api_client):
{}, {},
airtable_field, airtable_field,
AirtableImportConfig(), AirtableImportConfig(),
AirtableImportReport(), import_report,
) )
assert len(import_report.items) == 0
assert isinstance(baserow_field, BooleanField) assert isinstance(baserow_field, BooleanField)
assert isinstance(airtable_column_type, CheckboxAirtableColumnType) assert isinstance(airtable_column_type, CheckboxAirtableColumnType)
@pytest.mark.django_db
@responses.activate
def test_airtable_import_checkbox_column_with_default_value(data_fixture, api_client):
airtable_field = {
"id": "fldTn59fpliSFcwpFA9",
"name": "Checkbox",
"type": "checkbox",
"typeOptions": {"color": "green", "icon": "check"},
"default": True,
}
import_report = AirtableImportReport()
(
baserow_field,
airtable_column_type,
) = 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 == "Checkbox"
assert import_report.items[0].scope == SCOPE_FIELD
assert import_report.items[0].table == ""
@pytest.mark.django_db
@responses.activate
def test_airtable_import_checkbox_column_invalid_icon(data_fixture, api_client):
airtable_field = {
"id": "fldp1IFu0zdgRy70RoX",
"name": "Checkbox",
"type": "checkbox",
"typeOptions": {"color": "green", "icon": "TEST"},
}
import_report = AirtableImportReport()
(
baserow_field,
airtable_column_type,
) = 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 == "Checkbox"
assert import_report.items[0].scope == SCOPE_FIELD
assert import_report.items[0].table == ""
@pytest.mark.django_db
@responses.activate
def test_airtable_import_checkbox_column_invalid_color(data_fixture, api_client):
airtable_field = {
"id": "fldp1IFu0zdgRy70RoX",
"name": "Checkbox",
"type": "checkbox",
"typeOptions": {"color": "TEST", "icon": "check"},
}
import_report = AirtableImportReport()
(
baserow_field,
airtable_column_type,
) = 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 == "Checkbox"
assert import_report.items[0].scope == SCOPE_FIELD
assert import_report.items[0].table == ""
@pytest.mark.django_db @pytest.mark.django_db
@responses.activate @responses.activate
def test_airtable_import_created_on_column(data_fixture, api_client): def test_airtable_import_created_on_column(data_fixture, api_client):
@ -156,9 +293,7 @@ def test_airtable_import_created_on_column(data_fixture, api_client):
assert baserow_field.date_include_time is False assert baserow_field.date_include_time is False
assert baserow_field.date_time_format == "24" assert baserow_field.date_time_format == "24"
assert baserow_field.date_force_timezone is None assert baserow_field.date_force_timezone is None
assert len(import_report.items) == 1 assert len(import_report.items) == 0
assert import_report.items[0].object_name == "Created"
assert import_report.items[0].scope == SCOPE_FIELD
airtable_field = { airtable_field = {
"id": "fldcTpJuoUVpsDNoszO", "id": "fldcTpJuoUVpsDNoszO",
@ -232,9 +367,7 @@ def test_airtable_import_date_column(data_fixture, api_client):
assert baserow_field.date_format == "US" assert baserow_field.date_format == "US"
assert baserow_field.date_include_time is False assert baserow_field.date_include_time is False
assert baserow_field.date_time_format == "24" assert baserow_field.date_time_format == "24"
assert len(import_report.items) == 1 assert len(import_report.items) == 0
assert import_report.items[0].object_name == "ISO DATE"
assert import_report.items[0].scope == SCOPE_FIELD
assert ( assert (
airtable_column_type.to_baserow_export_serialized_value( airtable_column_type.to_baserow_export_serialized_value(
@ -585,6 +718,37 @@ def test_airtable_import_datetime_edge_case_1(data_fixture, api_client):
) )
@pytest.mark.django_db
@responses.activate
def test_airtable_import_datetime_with_default_value(data_fixture, api_client):
airtable_field = {
"id": "fldEB5dp0mNjVZu0VJI",
"name": "Date",
"type": "date",
"default": "test",
"typeOptions": {
"isDateTime": True,
"dateFormat": "Local",
"timeFormat": "24hour",
"timeZone": "client",
},
}
import_report = AirtableImportReport()
(
baserow_field,
airtable_column_type,
) = 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 == "Date"
assert import_report.items[0].scope == SCOPE_FIELD
assert import_report.items[0].table == ""
@pytest.mark.django_db @pytest.mark.django_db
@responses.activate @responses.activate
def test_airtable_import_email_column(data_fixture, api_client): def test_airtable_import_email_column(data_fixture, api_client):
@ -810,9 +974,7 @@ def test_airtable_import_last_modified_column(data_fixture, api_client):
assert baserow_field.date_include_time is False assert baserow_field.date_include_time is False
assert baserow_field.date_time_format == "24" assert baserow_field.date_time_format == "24"
assert baserow_field.date_force_timezone is None assert baserow_field.date_force_timezone is None
assert len(import_report.items) == 1 assert len(import_report.items) == 0
assert import_report.items[0].object_name == "Last"
assert import_report.items[0].scope == SCOPE_FIELD
airtable_field = { airtable_field = {
"id": "fldws6n8xdrEJrMxJFJ", "id": "fldws6n8xdrEJrMxJFJ",
@ -865,6 +1027,52 @@ def test_airtable_import_last_modified_column(data_fixture, api_client):
) )
@pytest.mark.django_db
@responses.activate
def test_airtable_import_last_modified_column_depending_fields(
data_fixture, api_client
):
airtable_field = {
"id": "fldws6n8xdrEJrMxJFJ",
"name": "Last",
"type": "formula",
"typeOptions": {
"isDateTime": False,
"dateFormat": "Local",
"displayType": "lastModifiedTime",
"timeZone": "client",
"formulaTextParsed": "LAST_MODIFIED_TIME()",
"dependencies": {
"referencedColumnIdsForValue": [],
"referencedColumnIdsForModification": ["fld123445678"],
"dependsOnAllColumnModifications": False,
},
"resultType": "date",
"resultIsArray": False,
},
}
import_report = AirtableImportReport()
(
baserow_field,
airtable_column_type,
) = airtable_column_type_registry.from_airtable_column_to_serialized(
{},
airtable_field,
AirtableImportConfig(),
import_report,
)
assert isinstance(baserow_field, LastModifiedField)
assert isinstance(airtable_column_type, FormulaAirtableColumnType)
assert baserow_field.date_format == "ISO"
assert baserow_field.date_include_time is False
assert baserow_field.date_time_format == "24"
assert baserow_field.date_force_timezone is None
assert len(import_report.items) == 1
assert import_report.items[0].object_name == "Last"
assert import_report.items[0].scope == SCOPE_FIELD
assert import_report.items[0].table == ""
@pytest.mark.django_db @pytest.mark.django_db
@responses.activate @responses.activate
def test_airtable_import_foreign_key_column(data_fixture, api_client): def test_airtable_import_foreign_key_column(data_fixture, api_client):
@ -879,15 +1087,14 @@ def test_airtable_import_foreign_key_column(data_fixture, api_client):
"symmetricColumnId": "fldFh5wIL430N62LN6t", "symmetricColumnId": "fldFh5wIL430N62LN6t",
}, },
} }
import_report = AirtableImportReport()
( (
baserow_field, baserow_field,
airtable_column_type, airtable_column_type,
) = airtable_column_type_registry.from_airtable_column_to_serialized( ) = airtable_column_type_registry.from_airtable_column_to_serialized(
{"id": "tblxxx"}, {"id": "tblxxx"}, airtable_field, AirtableImportConfig(), import_report
airtable_field,
AirtableImportConfig(),
AirtableImportReport(),
) )
assert len(import_report.items) == 0
assert isinstance(baserow_field, LinkRowField) assert isinstance(baserow_field, LinkRowField)
assert isinstance(airtable_column_type, ForeignKeyAirtableColumnType) assert isinstance(airtable_column_type, ForeignKeyAirtableColumnType)
assert baserow_field.link_row_table_id == "tblRpq315qnnIcg5IjI" assert baserow_field.link_row_table_id == "tblRpq315qnnIcg5IjI"
@ -977,6 +1184,49 @@ def test_airtable_import_foreign_key_column(data_fixture, api_client):
assert baserow_field.link_row_related_field_id == "fldQcEaGEe7xuhUEuPL" assert baserow_field.link_row_related_field_id == "fldQcEaGEe7xuhUEuPL"
@pytest.mark.django_db
@responses.activate
def test_airtable_import_foreign_key_column_failed_import(data_fixture, api_client):
airtable_field = {
"id": "fldQcEaGEe7xuhUEuPL",
"name": "Link to Users",
"type": "foreignKey",
"typeOptions": {
"foreignTableId": "tblRpq315qnnIcg5IjI",
"relationship": "one",
"unreversed": True,
"symmetricColumnId": "fldFh5wIL430N62LN6t",
"viewIdForRecordSelection": "vw1234",
"filtersForRecordSelection": [None],
"aiMatchingOptions": {"isAutoFillEnabled": False},
},
}
import_report = AirtableImportReport()
(
baserow_field,
airtable_column_type,
) = airtable_column_type_registry.from_airtable_column_to_serialized(
{"id": "tblxxx"}, airtable_field, AirtableImportConfig(), import_report
)
assert len(import_report.items) == 4
assert import_report.items[0].object_name == "Link to Users"
assert import_report.items[0].scope == SCOPE_FIELD
assert import_report.items[0].table == ""
assert import_report.items[1].object_name == "Link to Users"
assert import_report.items[1].scope == SCOPE_FIELD
assert import_report.items[1].table == ""
assert import_report.items[2].object_name == "Link to Users"
assert import_report.items[2].scope == SCOPE_FIELD
assert import_report.items[2].table == ""
assert import_report.items[3].object_name == "Link to Users"
assert import_report.items[3].scope == SCOPE_FIELD
assert import_report.items[3].table == ""
assert isinstance(baserow_field, LinkRowField)
assert isinstance(airtable_column_type, ForeignKeyAirtableColumnType)
assert baserow_field.link_row_table_id == "tblRpq315qnnIcg5IjI"
assert baserow_field.link_row_related_field_id == "fldFh5wIL430N62LN6t"
@pytest.mark.django_db @pytest.mark.django_db
@responses.activate @responses.activate
def test_airtable_import_multiline_text_column(data_fixture, api_client): def test_airtable_import_multiline_text_column(data_fixture, api_client):
@ -1032,6 +1282,7 @@ def test_airtable_import_rich_text_column(data_fixture, api_client):
) )
assert isinstance(baserow_field, LongTextField) assert isinstance(baserow_field, LongTextField)
assert isinstance(airtable_column_type, RichTextTextAirtableColumnType) assert isinstance(airtable_column_type, RichTextTextAirtableColumnType)
assert baserow_field.long_text_enable_rich_text is True
content = { content = {
"otDocumentId": "otdHtbNg2tJKWj62WMn", "otDocumentId": "otdHtbNg2tJKWj62WMn",
@ -1043,20 +1294,19 @@ def test_airtable_import_rich_text_column(data_fixture, api_client):
{"insert": " cubilia curae; Class aptent taciti sociosqu ad litora."}, {"insert": " cubilia curae; Class aptent taciti sociosqu ad litora."},
], ],
} }
assert ( assert airtable_column_type.to_baserow_export_serialized_value(
airtable_column_type.to_baserow_export_serialized_value( {},
{}, {"name": "Test"},
{"name": "Test"}, {"id": "row1"},
{"id": "row1"}, airtable_field,
airtable_field, baserow_field,
baserow_field, content,
content, {},
{}, AirtableImportConfig(),
AirtableImportConfig(), AirtableImportReport(),
AirtableImportReport(), ) == (
) "**Vestibulum** ante ipsum primis in faucibus orci luctus et ultrices "
== "Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere " "_posuere_ cubilia curae; Class aptent taciti sociosqu ad litora."
"cubilia curae; Class aptent taciti sociosqu ad litora."
) )
@ -1079,6 +1329,7 @@ def test_airtable_import_rich_text_column_with_mention(data_fixture, api_client)
) )
assert isinstance(baserow_field, LongTextField) assert isinstance(baserow_field, LongTextField)
assert isinstance(airtable_column_type, RichTextTextAirtableColumnType) assert isinstance(airtable_column_type, RichTextTextAirtableColumnType)
assert baserow_field.long_text_enable_rich_text is True
content = { content = {
"otDocumentId": "otdHtbNg2tJKWj62WMn", "otDocumentId": "otdHtbNg2tJKWj62WMn",
@ -1108,7 +1359,7 @@ def test_airtable_import_rich_text_column_with_mention(data_fixture, api_client)
AirtableImportConfig(), AirtableImportConfig(),
AirtableImportReport(), AirtableImportReport(),
) == ( ) == (
"Vestibulum ante ipsum primis in faucibus orci luctus et ultrices " "**Vestibulum** ante ipsum primis in faucibus orci luctus et ultrices "
"@usrr5CVJ5Lz8ErVZS cubilia curae; Class aptent taciti sociosqu ad litora." "@usrr5CVJ5Lz8ErVZS cubilia curae; Class aptent taciti sociosqu ad litora."
) )
@ -1170,6 +1421,50 @@ def test_airtable_import_multi_select_column(
assert select_options[1].order == 0 assert select_options[1].order == 0
@pytest.mark.django_db
@responses.activate
def test_airtable_import_multi_select_column_with_default_value(
data_fixture, api_client, django_assert_num_queries
):
table = data_fixture.create_database_table()
airtable_field = {
"id": "fldURNo0cvi6YWYcYj1",
"name": "Multiple select",
"type": "multiSelect",
"default": ["selEOJmenvqEd6pndFQ", "sel5ekvuoNVvl03olMO"],
"typeOptions": {
"choiceOrder": ["sel5ekvuoNVvl03olMO", "selEOJmenvqEd6pndFQ"],
"choices": {
"selEOJmenvqEd6pndFQ": {
"id": "selEOJmenvqEd6pndFQ",
"color": "blue",
"name": "Option 1",
},
"sel5ekvuoNVvl03olMO": {
"id": "sel5ekvuoNVvl03olMO",
"color": "cyan",
"name": "Option 2",
},
},
"disableColors": False,
},
}
import_report = AirtableImportReport()
(
baserow_field,
airtable_column_type,
) = 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 == "Multiple select"
assert import_report.items[0].scope == SCOPE_FIELD
assert import_report.items[0].table == ""
@pytest.mark.django_db @pytest.mark.django_db
@responses.activate @responses.activate
def test_airtable_import_number_integer_column(data_fixture, api_client): def test_airtable_import_number_integer_column(data_fixture, api_client):
@ -1556,7 +1851,7 @@ def test_airtable_import_currency_column_non_existing_separator_format(
import_report, import_report,
) )
assert len(import_report.items) == 1 assert len(import_report.items) == 1
assert import_report.items[0].object_name == 'Number field: "Currency"' assert import_report.items[0].object_name == "Currency"
assert import_report.items[0].scope == SCOPE_FIELD assert import_report.items[0].scope == SCOPE_FIELD
assert import_report.items[0].table == "" assert import_report.items[0].table == ""
@ -1663,6 +1958,219 @@ def test_airtable_import_percentage_column(data_fixture, api_client):
) )
@pytest.mark.django_db
@responses.activate
def test_airtable_import_number_column_default_value(data_fixture, api_client):
airtable_field = {
"id": "fldZBmr4L45mhjILhlA",
"name": "Number",
"type": "number",
"default": 1,
"typeOptions": {},
}
import_report = AirtableImportReport()
(
baserow_field,
airtable_column_type,
) = 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"
assert import_report.items[0].scope == SCOPE_FIELD
assert import_report.items[0].table == ""
@pytest.mark.django_db
@responses.activate
def test_airtable_import_days_duration_column(data_fixture, api_client):
airtable_field = {
"id": "fldZBmr4L45mhjILhlA",
"name": "Duration",
"type": "number",
"typeOptions": {
"format": "durationInDays",
},
}
import_report = AirtableImportReport()
(
baserow_field,
airtable_column_type,
) = airtable_column_type_registry.from_airtable_column_to_serialized(
{}, airtable_field, AirtableImportConfig(), import_report
)
assert len(import_report.items) == 0
assert isinstance(baserow_field, DurationField)
assert isinstance(airtable_column_type, NumberAirtableColumnType)
assert baserow_field.duration_format == D_H
assert (
airtable_column_type.to_baserow_export_serialized_value(
{},
{"name": "Test"},
{"id": "row1"},
airtable_field,
baserow_field,
None,
{},
AirtableImportConfig(),
AirtableImportReport(),
)
is None
)
assert (
airtable_column_type.to_baserow_export_serialized_value(
{},
{"name": "Test"},
{"id": "row1"},
airtable_field,
baserow_field,
1,
{},
AirtableImportConfig(),
AirtableImportReport(),
)
== 86400 # 1 * 60 * 60 * 24
)
@pytest.mark.django_db
@responses.activate
def test_airtable_import_duration_column(data_fixture, api_client):
airtable_field = {
"id": "fldZBmr4L45mhjILhlA",
"name": "Duration",
"type": "number",
"typeOptions": {"format": "duration", "durationFormat": "h:mm"},
}
import_report = AirtableImportReport()
(
baserow_field,
airtable_column_type,
) = airtable_column_type_registry.from_airtable_column_to_serialized(
{}, airtable_field, AirtableImportConfig(), import_report
)
assert len(import_report.items) == 0
assert isinstance(baserow_field, DurationField)
assert isinstance(airtable_column_type, NumberAirtableColumnType)
assert baserow_field.duration_format == H_M
airtable_field["typeOptions"]["durationFormat"] = "h:mm:ss"
(
baserow_field,
airtable_column_type,
) = airtable_column_type_registry.from_airtable_column_to_serialized(
{}, airtable_field, AirtableImportConfig(), import_report
)
assert baserow_field.duration_format == H_M_S
assert (
airtable_column_type.to_baserow_export_serialized_value(
{},
{"name": "Test"},
{"id": "row1"},
airtable_field,
baserow_field,
None,
{},
AirtableImportConfig(),
AirtableImportReport(),
)
is None
)
assert (
airtable_column_type.to_baserow_export_serialized_value(
{},
{"name": "Test"},
{"id": "row1"},
airtable_field,
baserow_field,
1,
{},
AirtableImportConfig(),
AirtableImportReport(),
)
== 1
)
@pytest.mark.django_db
@responses.activate
def test_airtable_import_duration_column_max_value(data_fixture, api_client):
airtable_field = {
"id": "fldZBmr4L45mhjILhlA",
"name": "Duration",
"type": "number",
"typeOptions": {"format": "duration", "durationFormat": "h:mm"},
}
import_report = AirtableImportReport()
(
baserow_field,
airtable_column_type,
) = airtable_column_type_registry.from_airtable_column_to_serialized(
{}, airtable_field, AirtableImportConfig(), import_report
)
assert (
airtable_column_type.to_baserow_export_serialized_value(
{},
{"name": "Test"},
{"id": "row1"},
airtable_field,
baserow_field,
86399999913601,
{},
AirtableImportConfig(),
import_report,
)
is None
)
assert len(import_report.items) == 1
assert import_report.items[0].object_name == 'Row: "row1", field: "Duration"'
assert import_report.items[0].scope == SCOPE_CELL
assert import_report.items[0].table == "Test"
@pytest.mark.django_db
@responses.activate
def test_airtable_import_duration_column_max_negative_value(data_fixture, api_client):
airtable_field = {
"id": "fldZBmr4L45mhjILhlA",
"name": "Duration",
"type": "number",
"typeOptions": {"format": "duration", "durationFormat": "h:mm"},
}
import_report = AirtableImportReport()
(
baserow_field,
airtable_column_type,
) = airtable_column_type_registry.from_airtable_column_to_serialized(
{}, airtable_field, AirtableImportConfig(), import_report
)
assert (
airtable_column_type.to_baserow_export_serialized_value(
{},
{"name": "Test"},
{"id": "row1"},
airtable_field,
baserow_field,
-86399999913601,
{},
AirtableImportConfig(),
import_report,
)
is None
)
assert len(import_report.items) == 1
assert import_report.items[0].object_name == 'Row: "row1", field: "Duration"'
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_phone_column(data_fixture, api_client): def test_airtable_import_phone_column(data_fixture, api_client):
@ -1721,8 +2229,9 @@ def test_airtable_import_rating_column(data_fixture, api_client):
"id": "fldp1IFu0zdgRy70RoX", "id": "fldp1IFu0zdgRy70RoX",
"name": "Rating", "name": "Rating",
"type": "rating", "type": "rating",
"typeOptions": {"color": "yellow", "icon": "star", "max": 5}, "typeOptions": {"color": "blue", "icon": "heart", "max": 5},
} }
import_report = AirtableImportReport()
( (
baserow_field, baserow_field,
airtable_column_type, airtable_column_type,
@ -1730,11 +2239,14 @@ def test_airtable_import_rating_column(data_fixture, api_client):
{}, {},
airtable_field, airtable_field,
AirtableImportConfig(), AirtableImportConfig(),
AirtableImportReport(), import_report,
) )
assert len(import_report.items) == 0
assert isinstance(baserow_field, RatingField) assert isinstance(baserow_field, RatingField)
assert isinstance(airtable_column_type, RatingAirtableColumnType) assert isinstance(airtable_column_type, RatingAirtableColumnType)
assert baserow_field.max_value == 5 assert baserow_field.max_value == 5
assert baserow_field.color == "dark-blue"
assert baserow_field.style == "heart"
assert ( assert (
airtable_column_type.to_baserow_export_serialized_value( airtable_column_type.to_baserow_export_serialized_value(
{}, {},
@ -1745,12 +2257,68 @@ def test_airtable_import_rating_column(data_fixture, api_client):
5, 5,
{}, {},
AirtableImportConfig(), AirtableImportConfig(),
AirtableImportReport(), import_report,
) )
== 5 == 5
) )
@pytest.mark.django_db
@responses.activate
def test_airtable_import_rating_column_invalid_icon(data_fixture, api_client):
airtable_field = {
"id": "fldp1IFu0zdgRy70RoX",
"name": "Rating",
"type": "rating",
"typeOptions": {"color": "blue", "icon": "TEST", "max": 5},
}
import_report = AirtableImportReport()
(
baserow_field,
airtable_column_type,
) = 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 == "Rating"
assert import_report.items[0].scope == SCOPE_FIELD
assert import_report.items[0].table == ""
assert baserow_field.max_value == 5
assert baserow_field.color == "dark-blue"
assert baserow_field.style == "star"
@pytest.mark.django_db
@responses.activate
def test_airtable_import_rating_column_invalid_color(data_fixture, api_client):
airtable_field = {
"id": "fldp1IFu0zdgRy70RoX",
"name": "Rating",
"type": "rating",
"typeOptions": {"color": "TEST", "icon": "heart", "max": 5},
}
import_report = AirtableImportReport()
(
baserow_field,
airtable_column_type,
) = 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 == "Rating"
assert import_report.items[0].scope == SCOPE_FIELD
assert import_report.items[0].table == ""
assert baserow_field.max_value == 5
assert baserow_field.color == "dark-blue"
assert baserow_field.style == "heart"
@pytest.mark.django_db @pytest.mark.django_db
@responses.activate @responses.activate
def test_airtable_import_select_column( def test_airtable_import_select_column(
@ -1808,6 +2376,47 @@ def test_airtable_import_select_column(
assert select_options[1].order == 1 assert select_options[1].order == 1
@pytest.mark.django_db
@responses.activate
def test_airtable_import_select_column_with_default_value(
data_fixture, api_client, django_assert_num_queries
):
table = data_fixture.create_database_table()
airtable_field = {
"id": "fldRd2Vkzgsf6X4z6B4",
"name": "Single select",
"type": "select",
"default": "selbh6rEWaaiyQvWyfg",
"typeOptions": {
"choiceOrder": ["selbh6rEWaaiyQvWyfg", "selvZgpWhbkeRVphROT"],
"choices": {
"selbh6rEWaaiyQvWyfg": {
"id": "selbh6rEWaaiyQvWyfg",
"color": "blue",
"name": "Option A",
},
"selvZgpWhbkeRVphROT": {
"id": "selvZgpWhbkeRVphROT",
"color": "cyan",
"name": "Option B",
},
},
"disableColors": False,
},
}
import_report = AirtableImportReport()
(
baserow_field,
airtable_column_type,
) = 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 == "Single select"
assert import_report.items[0].scope == SCOPE_FIELD
assert import_report.items[0].table == ""
@pytest.mark.django_db @pytest.mark.django_db
@responses.activate @responses.activate
def test_airtable_import_url_column(data_fixture, api_client): def test_airtable_import_url_column(data_fixture, api_client):
@ -1906,3 +2515,44 @@ def test_airtable_import_count_column(data_fixture, api_client):
) )
is None is None
) )
@pytest.mark.django_db
@responses.activate
def test_airtable_import_autonumber_column(data_fixture, api_client):
airtable_field = {
"id": "fldG9y88Zw7q7u4Z7i4",
"name": "ID",
"type": "autoNumber",
"typeOptions": {
"maxUsedAutoNumber": 8,
},
}
import_report = AirtableImportReport()
(
baserow_field,
airtable_column_type,
) = airtable_column_type_registry.from_airtable_column_to_serialized(
{},
airtable_field,
AirtableImportConfig(),
AirtableImportReport(),
)
assert len(import_report.items) == 0
assert isinstance(baserow_field, AutonumberField)
assert (
airtable_column_type.to_baserow_export_serialized_value(
{},
{"name": "Test"},
{"id": "row1"},
airtable_field,
baserow_field,
1,
{},
AirtableImportConfig(),
import_report,
)
== 1
)
assert len(import_report.items) == 0

View file

@ -232,7 +232,7 @@ def test_to_baserow_database_export():
assert baserow_database_export["tables"][1]["id"] == "tbl7glLIGtH8C8zGCzb" assert baserow_database_export["tables"][1]["id"] == "tbl7glLIGtH8C8zGCzb"
assert baserow_database_export["tables"][1]["name"] == "Data" assert baserow_database_export["tables"][1]["name"] == "Data"
assert baserow_database_export["tables"][1]["order"] == 1 assert baserow_database_export["tables"][1]["order"] == 1
assert len(baserow_database_export["tables"][1]["fields"]) == 25 assert len(baserow_database_export["tables"][1]["fields"]) == 26
# We don't have to check all the fields and rows, just a single one, because we have # We don't have to check all the fields and rows, just a single one, because we have
# separate tests for mapping the Airtable fields and values to Baserow. # separate tests for mapping the Airtable fields and values to Baserow.
@ -255,7 +255,7 @@ def test_to_baserow_database_export():
"type": "email", "type": "email",
"id": "fldB7wkyR0buF1sRF9O", "id": "fldB7wkyR0buF1sRF9O",
"name": "Email", "name": "Email",
"description": None, "description": "This is an email",
"order": 1, "order": 1,
"primary": False, "primary": False,
"read_only": False, "read_only": False,
@ -435,6 +435,17 @@ def test_to_baserow_database_export_without_primary_value():
AirtableImportConfig(), AirtableImportConfig(),
) )
assert baserow_database_export["tables"][0]["fields"][0]["primary"] is True assert baserow_database_export["tables"][0]["fields"][0]["primary"] is True
assert baserow_database_export["tables"][1]["rows"][0] == {
"id": 1,
"order": "1.00000000000000000000",
"created_on": None,
"updated_on": None,
"field_object_name": "Name",
"field_scope": "scope_field",
"field_table": "table_Users",
"field_error_type": "error_type_unsupported_feature",
"field_message": 'Changed primary field to "Name" because the original primary field is incompatible.',
}
user_table_json["data"]["tableSchemas"][0]["columns"] = [] user_table_json["data"]["tableSchemas"][0]["columns"] = []
schema, tables = AirtableHandler.extract_schema(deepcopy([user_table_json])) schema, tables = AirtableHandler.extract_schema(deepcopy([user_table_json]))
@ -455,6 +466,17 @@ def test_to_baserow_database_export_without_primary_value():
"immutable_properties": False, "immutable_properties": False,
} }
] ]
assert baserow_database_export["tables"][1]["rows"][0] == {
"id": 1,
"order": "1.00000000000000000000",
"created_on": None,
"updated_on": None,
"field_object_name": "Primary field (auto created)",
"field_scope": "scope_field",
"field_table": "table_Users",
"field_error_type": "error_type_unsupported_feature",
"field_message": 'Created new primary field "Primary field (auto created)" because none of the provided fields are compatible.',
}
@pytest.mark.django_db @pytest.mark.django_db

View file

@ -3,6 +3,7 @@ import pytest
from baserow.contrib.database.airtable.utils import ( from baserow.contrib.database.airtable.utils import (
extract_share_id_from_url, extract_share_id_from_url,
get_airtable_row_primary_value, get_airtable_row_primary_value,
quill_to_markdown,
) )
@ -61,3 +62,188 @@ def test_get_airtable_row_primary_value_without_primary_column_id_in_table():
} }
airtable_row = {"id": "id1"} airtable_row = {"id": "id1"}
assert get_airtable_row_primary_value(airtable_table, airtable_row) == "id1" assert get_airtable_row_primary_value(airtable_table, airtable_row) == "id1"
def test_quill_to_markdown_airtable_example():
markdown_value = quill_to_markdown(
[
{"insert": "He"},
{"attributes": {"bold": True}, "insert": "adi"},
{"insert": "ng 1"},
{"attributes": {"header": 1}, "insert": "\n"},
{"insert": "He"},
{"attributes": {"link": "https://airtable.com"}, "insert": "a"},
{
"attributes": {"link": "https://airtable.com", "bold": True},
"insert": "di",
},
{"attributes": {"bold": True}, "insert": "n"},
{"insert": "g 2"},
{"attributes": {"header": 2}, "insert": "\n"},
{"insert": "Heading 3"},
{"attributes": {"header": 3}, "insert": "\n"},
{"insert": "\none"},
{"attributes": {"list": "ordered"}, "insert": "\n"},
{"insert": "two"},
{"attributes": {"list": "ordered"}, "insert": "\n"},
{"insert": "three"},
{"attributes": {"list": "ordered"}, "insert": "\n"},
{"insert": "Sub 1"},
{"attributes": {"indent": 1, "list": "ordered"}, "insert": "\n"},
{"insert": "Sub 2"},
{"attributes": {"indent": 1, "list": "ordered"}, "insert": "\n"},
{"insert": "\none"},
{"attributes": {"list": "bullet"}, "insert": "\n"},
{"insert": "two"},
{"attributes": {"list": "bullet"}, "insert": "\n"},
{"insert": "three"},
{"attributes": {"list": "bullet"}, "insert": "\n"},
{"insert": "Sub 1"},
{"attributes": {"indent": 1, "list": "bullet"}, "insert": "\n"},
{"insert": "Sub 2"},
{"attributes": {"indent": 1, "list": "bullet"}, "insert": "\n"},
{"insert": "\nCheck 1"},
{"attributes": {"list": "unchecked"}, "insert": "\n"},
{"insert": "Check 2"},
{"attributes": {"list": "unchecked"}, "insert": "\n"},
{"insert": "Check 3"},
{"insert": "\n", "attributes": {"list": "unchecked"}},
{"insert": "\nLorem "},
{"insert": "ipsum", "attributes": {"bold": True}},
{"insert": " dolor "},
{"attributes": {"italic": True}, "insert": "sit"},
{"insert": " amet, "},
{"attributes": {"strike": True}, "insert": "consectetur"},
{"insert": " adipiscing "},
{"attributes": {"code": True}, "insert": "elit"},
{"insert": ". "},
{"attributes": {"link": "https://airtable.com"}, "insert": "Proin"},
{"insert": " ut metus quam. Ut tempus at "},
{
"attributes": {"link": "https://airtable.com"},
"insert": "https://airtable.com",
},
{
"insert": " vel varius. Phasellus nec diam vitae urna mollis cursus. Donec mattis pellentesque nunc id dictum. Maecenas vel tortor quam. Vestibulum et enim ut mauris lacinia malesuada. Pellentesque euismod\niaculis felis, at posuere velit ullamcorper a. Aliquam eu ultricies neque, cursus accumsan metus. Etiam consectetur eu nisi id aliquet. "
},
{
"insert": {
"mention": {
"userId": "usrGIN77VWdhm7LKk",
"mentionId": "menuy6d9AwkWbnNXx",
}
}
},
{
"insert": " gravida vestibulum egestas. Praesent pretium velit eu pretium ultrices. Nullam ut est non quam vulputate "
},
{"attributes": {"code": True}, "insert": "tempus nec vel augue"},
{
"insert": ". Aenean dui velit, ornare nec tincidunt eget, fermentum sed arcu. Suspendisse consequat bibendum molestie. Fusce at pulvinar enim.\n"
},
{
"insert": {
"mention": {
"userId": "usrGIN77VWdhm7LKk",
"mentionId": "menflYNpEgIJljLuR",
}
}
},
{"insert": "\nQuote, but not actually"},
{"attributes": {"blockquote": True}, "insert": "\n"},
{"insert": "on multiple lines"},
{"attributes": {"blockquote": True}, "insert": "\n"},
{"insert": "\ncode"},
{"attributes": {"code-block": True}, "insert": "\n"},
{"insert": "line"},
{"attributes": {"code-block": True}, "insert": "\n"},
{"insert": "2"},
{"insert": "\n", "attributes": {"code-block": True}},
{"insert": "\n{"},
{"insert": "\n", "attributes": {"code-block": True}},
{"insert": ' "test": "test",'},
{"attributes": {"code-block": True}, "insert": "\n"},
{"insert": ' "yeah": "test"'},
{"attributes": {"code-block": True}, "insert": "\n"},
{"insert": "}"},
{"insert": "\n", "attributes": {"code-block": True}},
]
)
assert (
markdown_value
== """# He**adi**ng 1
## He[a](https://airtable.com)[**di**](https://airtable.com)**n**g 2
### Heading 3
1. one
1. two
1. three
1. Sub 1
1. Sub 2
- one
- two
- three
- Sub 1
- Sub 2
- [ ] Check 1
- [ ] Check 2
- [ ] Check 3
Lorem **ipsum** dolor _sit_ amet, ~consectetur~ adipiscing `elit`. [Proin](https://airtable.com) ut metus quam. Ut tempus at [https://airtable.com](https://airtable.com) vel varius. Phasellus nec diam vitae urna mollis cursus. Donec mattis pellentesque nunc id dictum. Maecenas vel tortor quam. Vestibulum et enim ut mauris lacinia malesuada. Pellentesque euismod
iaculis felis, at posuere velit ullamcorper a. Aliquam eu ultricies neque, cursus accumsan metus. Etiam consectetur eu nisi id aliquet. @usrGIN77VWdhm7LKk gravida vestibulum egestas. Praesent pretium velit eu pretium ultrices. Nullam ut est non quam vulputate `tempus nec vel augue`. Aenean dui velit, ornare nec tincidunt eget, fermentum sed arcu. Suspendisse consequat bibendum molestie. Fusce at pulvinar enim.
@usrGIN77VWdhm7LKk
> Quote, but not actually
> on multiple lines
```
code
line
2
```
```
{
"test": "test",
"yeah": "test"
}
```"""
)
def test_quill_to_markdown_airtable_example_two_lists():
markdown_value = quill_to_markdown(
[
{"insert": "This is great"},
{"insert": "\n", "attributes": {"header": 2}},
{"insert": "option 1"},
{"attributes": {"list": "unchecked"}, "insert": "\n"},
{"insert": "option 2"},
{"attributes": {"list": "unchecked"}, "insert": "\n"},
{"insert": "option that is "},
{"attributes": {"bold": True}, "insert": "bold"},
{"attributes": {"list": "unchecked"}, "insert": "\n"},
{"insert": "\n"},
{"attributes": {"bold": True}, "insert": "item"},
{"attributes": {"list": "bullet"}, "insert": "\n"},
{"attributes": {"italic": True}, "insert": "item"},
{"attributes": {"list": "bullet"}, "insert": "\n"},
{"attributes": {"strike": True}, "insert": "Item"},
{"attributes": {"list": "bullet"}, "insert": "\n"},
{"attributes": {"link": "https://airtable.com"}, "insert": "link"},
{"insert": "\n", "attributes": {"list": "bullet"}},
]
)
assert (
markdown_value
== """## This is great
- [ ] option 1
- [ ] option 2
- [ ] option that is **bold**
- **item**
- _item_
- ~Item~
- [link](https://airtable.com)"""
)

View file

@ -0,0 +1,7 @@
{
"type": "feature",
"message": "Airtable import field type improvements.",
"issue_number": 3455,
"bullet_points": [],
"created_at": "2025-02-17"
}