From 1adb520499a91c444634e8582ee1dd626d79865b Mon Sep 17 00:00:00 2001 From: Bram Wiepjes <bramw@protonmail.com> Date: Thu, 13 Feb 2025 19:32:24 +0000 Subject: [PATCH] Airtable import report --- .../airtable/airtable_column_types.py | 189 ++++++++-- .../contrib/database/airtable/handler.py | 80 +++- .../database/airtable/import_report.py | 145 ++++++++ .../contrib/database/airtable/registry.py | 16 +- .../contrib/database/airtable/utils.py | 19 + backend/src/baserow/core/constants.py | 32 ++ .../airtable/test_airtable_column_types.py | 351 ++++++++++++++++-- .../airtable/test_airtable_handler.py | 107 +++++- .../database/airtable/test_airtable_utils.py | 35 +- .../feature/3263_missing_airtable_import.json | 7 + .../modules/core/assets/scss/colors.scss | 1 + 11 files changed, 913 insertions(+), 69 deletions(-) create mode 100644 backend/src/baserow/contrib/database/airtable/import_report.py create mode 100644 changelog/entries/unreleased/feature/3263_missing_airtable_import.json diff --git a/backend/src/baserow/contrib/database/airtable/airtable_column_types.py b/backend/src/baserow/contrib/database/airtable/airtable_column_types.py index c2312a644..08eedd06b 100644 --- a/backend/src/baserow/contrib/database/airtable/airtable_column_types.py +++ b/backend/src/baserow/contrib/database/airtable/airtable_column_types.py @@ -1,12 +1,9 @@ -import traceback from datetime import datetime, timezone from decimal import Decimal from typing import Any, Dict, Optional from django.core.exceptions import ValidationError -from loguru import logger - from baserow.contrib.database.export_serialized import DatabaseExportSerializedStructure from baserow.contrib.database.fields.models import ( NUMBER_MAX_DECIMAL_PLACES, @@ -32,13 +29,23 @@ from baserow.contrib.database.fields.registries import field_type_registry from .config import AirtableImportConfig from .helpers import import_airtable_date_type_options, set_select_options_on_field +from .import_report import ( + ERROR_TYPE_DATA_TYPE_MISMATCH, + ERROR_TYPE_UNSUPPORTED_FEATURE, + SCOPE_CELL, + SCOPE_FIELD, + AirtableImportReport, +) from .registry import AirtableColumnType +from .utils import get_airtable_row_primary_value class TextAirtableColumnType(AirtableColumnType): type = "text" - def to_baserow_field(self, raw_airtable_table, raw_airtable_column, config): + def to_baserow_field( + self, raw_airtable_table, raw_airtable_column, config, import_report + ): validator_name = raw_airtable_column.get("typeOptions", {}).get("validatorName") if validator_name == "url": return URLField() @@ -50,17 +57,30 @@ class TextAirtableColumnType(AirtableColumnType): def to_baserow_export_serialized_value( self, row_id_mapping, + raw_airtable_table, + raw_airtable_row, raw_airtable_column, baserow_field, value, files_to_download, config, + import_report, ): if isinstance(baserow_field, (EmailField, URLField)): try: field_type = field_type_registry.get_by_model(baserow_field) field_type.validator(value) except ValidationError: + 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 "{value}" was left empty because it didn\'t pass the email or URL validation.', + ) return "" return value @@ -69,24 +89,31 @@ class TextAirtableColumnType(AirtableColumnType): class MultilineTextAirtableColumnType(AirtableColumnType): type = "multilineText" - def to_baserow_field(self, raw_airtable_table, raw_airtable_column, config): + def to_baserow_field( + self, raw_airtable_table, raw_airtable_column, config, import_report + ): return LongTextField() class RichTextTextAirtableColumnType(AirtableColumnType): type = "richText" - def to_baserow_field(self, raw_airtable_table, raw_airtable_column, config): + def to_baserow_field( + self, raw_airtable_table, raw_airtable_column, config, import_report + ): return LongTextField() def to_baserow_export_serialized_value( self, row_id_mapping, + raw_airtable_table, + raw_airtable_row, raw_airtable_column, baserow_field, value, files_to_download, config, + import_report, ): # We don't support rich text formatting yet, so this converts the value to # plain text. @@ -124,7 +151,9 @@ class RichTextTextAirtableColumnType(AirtableColumnType): class NumberAirtableColumnType(AirtableColumnType): type = "number" - def to_baserow_field(self, raw_airtable_table, raw_airtable_column, config): + def to_baserow_field( + self, raw_airtable_table, raw_airtable_column, config, import_report + ): type_options = raw_airtable_column.get("typeOptions", {}) decimal_places = 0 @@ -142,11 +171,14 @@ class NumberAirtableColumnType(AirtableColumnType): def to_baserow_export_serialized_value( self, row_id_mapping, + raw_airtable_table, + raw_airtable_row, raw_airtable_column, baserow_field, value, files_to_download, config, + import_report, ): if value is not None: value = Decimal(value) @@ -160,7 +192,9 @@ class NumberAirtableColumnType(AirtableColumnType): class RatingAirtableColumnType(AirtableColumnType): type = "rating" - def to_baserow_field(self, raw_airtable_table, raw_airtable_column, config): + def to_baserow_field( + self, raw_airtable_table, raw_airtable_column, config, import_report + ): return RatingField( max_value=raw_airtable_column.get("typeOptions", {}).get("max", 5) ) @@ -169,17 +203,22 @@ class RatingAirtableColumnType(AirtableColumnType): class CheckboxAirtableColumnType(AirtableColumnType): type = "checkbox" - def to_baserow_field(self, raw_airtable_table, raw_airtable_column, config): + def to_baserow_field( + self, raw_airtable_table, raw_airtable_column, config, import_report + ): return BooleanField() def to_baserow_export_serialized_value( self, row_id_mapping, + raw_airtable_table, + raw_airtable_row, raw_airtable_column, baserow_field, value, files_to_download, config, + import_report, ): return "true" if value else "false" @@ -187,7 +226,9 @@ class CheckboxAirtableColumnType(AirtableColumnType): class DateAirtableColumnType(AirtableColumnType): type = "date" - def to_baserow_field(self, raw_airtable_table, raw_airtable_column, config): + def to_baserow_field( + self, raw_airtable_table, raw_airtable_column, config, import_report + ): type_options = raw_airtable_column.get("typeOptions", {}) # Check if a timezone is provided in the type options, if so, we might want # to use that timezone for the conversion later on. @@ -196,6 +237,13 @@ class DateAirtableColumnType(AirtableColumnType): # date_force_timezone=None it the equivalent of 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 return DateField( @@ -207,11 +255,14 @@ class DateAirtableColumnType(AirtableColumnType): def to_baserow_export_serialized_value( self, row_id_mapping, + raw_airtable_table, + raw_airtable_row, raw_airtable_column, baserow_field, value, files_to_download, config, + import_report, ): if value is None: return value @@ -220,10 +271,17 @@ class DateAirtableColumnType(AirtableColumnType): value = datetime.strptime(value, "%Y-%m-%dT%H:%M:%S.%fZ").replace( tzinfo=timezone.utc ) - except ValueError: - tb = traceback.format_exc() - print(f"Importing Airtable datetime cell failed because of: \n{tb}") - logger.error(f"Importing Airtable datetime cell failed because of: \n{tb}") + except ValueError as e: + 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 it didn\'t pass the datetime validation with error: "{str(e)}"', + ) return None if baserow_field.date_include_time: @@ -243,25 +301,39 @@ class DateAirtableColumnType(AirtableColumnType): class FormulaAirtableColumnType(AirtableColumnType): type = "formula" - def to_baserow_field(self, raw_airtable_table, raw_airtable_column, config): + def to_baserow_field( + self, raw_airtable_table, raw_airtable_column, config, import_report + ): type_options = raw_airtable_column.get("typeOptions", {}) display_type = type_options.get("displayType", "") airtable_timezone = type_options.get("timeZone", None) date_show_tzinfo = type_options.get("shouldDisplayTimeZone", False) + is_last_modified = display_type == "lastModifiedTime" + 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". if airtable_timezone == "client": airtable_timezone = None # The formula conversion isn't support yet, but because the Created on and # Last modified fields work as a formula, we can convert those. - if display_type == "lastModifiedTime": + if is_last_modified: return LastModifiedField( date_show_tzinfo=date_show_tzinfo, date_force_timezone=airtable_timezone, **import_airtable_date_type_options(type_options), ) - elif display_type == "createdTime": + elif is_created: return CreatedOnField( date_show_tzinfo=date_show_tzinfo, date_force_timezone=airtable_timezone, @@ -271,11 +343,14 @@ class FormulaAirtableColumnType(AirtableColumnType): def to_baserow_export_serialized_value( self, row_id_mapping, + raw_airtable_table, + raw_airtable_row, raw_airtable_column, baserow_field, value, files_to_download, config, + import_report, ): if isinstance(baserow_field, CreatedOnField): # If `None`, the value will automatically be populated from the @@ -295,7 +370,9 @@ class FormulaAirtableColumnType(AirtableColumnType): class ForeignKeyAirtableColumnType(AirtableColumnType): type = "foreignKey" - def to_baserow_field(self, raw_airtable_table, raw_airtable_column, config): + def to_baserow_field( + self, raw_airtable_table, raw_airtable_column, config, import_report + ): type_options = raw_airtable_column.get("typeOptions", {}) foreign_table_id = type_options.get("foreignTableId") @@ -307,38 +384,64 @@ class ForeignKeyAirtableColumnType(AirtableColumnType): def to_baserow_export_serialized_value( self, row_id_mapping, + raw_airtable_table, + raw_airtable_row, raw_airtable_column, baserow_field, value, files_to_download, config, + import_report, ): foreign_table_id = raw_airtable_column["typeOptions"]["foreignTableId"] # Airtable doesn't always provide an object with a `foreignRowId`. This can # happen with a synced table for example. Because we don't have access to the # source in that case, we need to skip them. - return [ - row_id_mapping[foreign_table_id][v["foreignRowId"]] - for v in value - if "foreignRowId" in v - ] + foreign_row_ids = [v["foreignRowId"] for v in value if "foreignRowId" in v] + + value = [] + for foreign_row_id in foreign_row_ids: + try: + value.append(row_id_mapping[foreign_table_id][foreign_row_id]) + except KeyError: + # If a key error is raised, then we don't have the foreign row id in + # the mapping. This can happen if the data integrity is compromised in + # the Airtable base. We don't want to fail the import, so we're + # reporting instead. + 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'Foreign row id "{foreign_row_id}" was not added as relationship in the cell value was because it was not found in the mapping.', + ) + + return value class MultipleAttachmentAirtableColumnType(AirtableColumnType): type = "multipleAttachment" - def to_baserow_field(self, raw_airtable_table, raw_airtable_column, config): + def to_baserow_field( + self, raw_airtable_table, raw_airtable_column, config, import_report + ): return FileField() def to_baserow_export_serialized_value( self, row_id_mapping, + raw_airtable_table, + raw_airtable_row, raw_airtable_column, baserow_field, value, files_to_download, config, + import_report, ): new_value = [] @@ -367,16 +470,21 @@ class SelectAirtableColumnType(AirtableColumnType): def to_baserow_export_serialized_value( self, row_id_mapping: Dict[str, Dict[str, int]], + table: dict, + raw_airtable_row: dict, raw_airtable_column: dict, baserow_field: Field, value: Any, files_to_download: Dict[str, str], config: AirtableImportConfig, + import_report: AirtableImportReport, ): # use field id and option id for uniqueness return f"{raw_airtable_column.get('id')}_{value}" - def to_baserow_field(self, raw_airtable_table, raw_airtable_column, config): + def to_baserow_field( + self, raw_airtable_table, raw_airtable_column, config, import_report + ): field = SingleSelectField() field = set_select_options_on_field( field, @@ -392,17 +500,22 @@ class MultiSelectAirtableColumnType(AirtableColumnType): def to_baserow_export_serialized_value( self, row_id_mapping: Dict[str, Dict[str, int]], + table: dict, + raw_airtable_row: dict, raw_airtable_column: dict, baserow_field: Field, value: Any, files_to_download: Dict[str, str], config: AirtableImportConfig, + import_report: AirtableImportReport, ): # use field id and option id for uniqueness column_id = raw_airtable_column.get("id") return [f"{column_id}_{val}" for val in value] - def to_baserow_field(self, raw_airtable_table, raw_airtable_column, config): + def to_baserow_field( + self, raw_airtable_table, raw_airtable_column, config, import_report + ): field = MultipleSelectField() field = set_select_options_on_field( field, @@ -415,40 +528,60 @@ class MultiSelectAirtableColumnType(AirtableColumnType): class PhoneAirtableColumnType(AirtableColumnType): type = "phone" - def to_baserow_field(self, raw_airtable_table, raw_airtable_column, config): + def to_baserow_field( + self, raw_airtable_table, raw_airtable_column, config, import_report + ): return PhoneNumberField() def to_baserow_export_serialized_value( self, row_id_mapping, + raw_airtable_table, + raw_airtable_row, raw_airtable_column, baserow_field, value, files_to_download, config, + import_report, ): try: field_type = field_type_registry.get_by_model(baserow_field) field_type.validator(value) return value except ValidationError: + 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 "{value}" was left empty because it didn\'t pass the phone number validation.', + ) return "" class CountAirtableColumnType(AirtableColumnType): type = "count" - def to_baserow_field(self, raw_airtable_table, raw_airtable_column, config): + def to_baserow_field( + self, raw_airtable_table, raw_airtable_column, config, import_report + ): type_options = raw_airtable_column.get("typeOptions", {}) return CountField(through_field_id=type_options.get("relationColumnId")) def to_baserow_export_serialized_value( self, row_id_mapping, + raw_airtable_table, + raw_airtable_row, raw_airtable_column, baserow_field, value, files_to_download, config, + import_report, ): return None diff --git a/backend/src/baserow/contrib/database/airtable/handler.py b/backend/src/baserow/contrib/database/airtable/handler.py index 8fbe53b26..1db69ce6d 100644 --- a/backend/src/baserow/contrib/database/airtable/handler.py +++ b/backend/src/baserow/contrib/database/airtable/handler.py @@ -40,6 +40,14 @@ from .exceptions import ( AirtableImportNotRespectingConfig, AirtableShareIsNotABase, ) +from .import_report import ( + ERROR_TYPE_UNSUPPORTED_FEATURE, + SCOPE_AUTOMATIONS, + SCOPE_FIELD, + SCOPE_INTERFACES, + SCOPE_VIEW, + AirtableImportReport, +) User = get_user_model() @@ -199,6 +207,7 @@ class AirtableHandler: table: dict, column: dict, config: AirtableImportConfig, + import_report: AirtableImportReport, ) -> Union[Tuple[None, None, None], Tuple[Field, FieldType, AirtableColumnType]]: """ Converts the provided Airtable column dict to the right Baserow field object. @@ -208,6 +217,8 @@ class AirtableHandler: :param column: The Airtable column dict. These values will be converted to Baserow format. :param config: Additional configuration related to the import. + :param import_report: Used to collect what wasn't imported to report to the + user. :return: The converted Baserow field, field type and the Airtable column type. """ @@ -215,9 +226,7 @@ class AirtableHandler: baserow_field, airtable_column_type, ) = airtable_column_type_registry.from_airtable_column_to_serialized( - table, - column, - config, + table, column, config, import_report ) if baserow_field is None: @@ -247,17 +256,20 @@ class AirtableHandler: @staticmethod def to_baserow_row_export( + table: dict, row_id_mapping: Dict[str, Dict[str, int]], column_mapping: Dict[str, dict], row: dict, index: int, files_to_download: Dict[str, str], config: AirtableImportConfig, + import_report: AirtableImportReport, ) -> dict: """ Converts the provided Airtable record to a Baserow row by looping over the field types and executing the `from_airtable_column_value_to_serialized` method. + :param table: The Airtable table dict. :param row_id_mapping: A mapping containing the table as key as the value is another mapping where the Airtable row id maps the Baserow row id. :param column_mapping: A mapping where the Airtable column id is the value and @@ -269,6 +281,8 @@ class AirtableHandler: be downloaded. The key is the file name and the value the URL. Additional files can be added to this dict. :param config: Additional configuration related to the import. + :param import_report: Used to collect what wasn't imported to report to the + user. :return: The converted row in Baserow export format. """ @@ -300,11 +314,14 @@ class AirtableHandler: "airtable_column_type" ].to_baserow_export_serialized_value( row_id_mapping, + table, + row, mapping_values["raw_airtable_column"], mapping_values["baserow_field"], column_value, files_to_download, config, + import_report, ) exported_row[f"field_{column_id}"] = baserow_serialized_value @@ -380,6 +397,8 @@ class AirtableHandler: :param schema: An object containing the schema of the Airtable base. :param tables: a list containing the table data. :param config: Additional configuration related to the import. + :param import_report: Used to collect what wasn't imported to report to the + user. :param progress_builder: If provided will be used to build a child progress bar and report on this methods progress to the parent of the progress_builder. :param download_files_buffer: Optionally a file buffer can be provided to store @@ -388,6 +407,11 @@ class AirtableHandler: containing the user files. """ + # This instance allows collecting what we weren't able to import, like + # incompatible fields, filters, etc. This will later be used to create a table + # with an overview of what wasn't imported. + import_report = AirtableImportReport() + progress = ChildProgressBuilder.build(progress_builder, child_total=1000) converting_progress = progress.create_child( represents_progress=500, @@ -440,12 +464,19 @@ class AirtableHandler: baserow_field, baserow_field_type, airtable_column_type, - ) = cls.to_baserow_field(table, column, config) + ) = cls.to_baserow_field(table, column, config, import_report) converting_progress.increment(state=AIRTABLE_EXPORT_JOB_CONVERTING) # None means that none of the field types know how to parse this field, # so we must ignore it. if baserow_field is None: + import_report.add_failed( + column["name"], + SCOPE_FIELD, + table["name"], + ERROR_TYPE_UNSUPPORTED_FEATURE, + f"""Field "{column['name']}" with field type {column["type"]} was not imported because it is not supported.""", + ) continue # Construct a mapping where the Airtable column id is the key and the @@ -483,7 +514,9 @@ class AirtableHandler: baserow_field, baserow_field_type, airtable_column_type, - ) = cls.to_baserow_field(table, airtable_column, config) + ) = cls.to_baserow_field( + table, airtable_column, config, import_report + ) baserow_field.primary = True field_mapping["primary_id"] = { "baserow_field": baserow_field, @@ -507,12 +540,14 @@ class AirtableHandler: for row_index, row in enumerate(tables[table["id"]]["rows"]): exported_rows.append( cls.to_baserow_row_export( + table, row_id_mapping, field_mapping, row, row_index, files_to_download_for_table, config, + import_report, ) ) converting_progress.increment(state=AIRTABLE_EXPORT_JOB_CONVERTING) @@ -529,6 +564,18 @@ class AirtableHandler: empty_serialized_grid_view["id"] = view_id exported_views = [empty_serialized_grid_view] + # Loop over all views to add them to them as failed to the import report + # because the views are not yet supported. + for view in table["views"]: + import_report.add_failed( + view["name"], + SCOPE_VIEW, + table["name"], + ERROR_TYPE_UNSUPPORTED_FEATURE, + f"View \"{view['name']}\" was not imported because views are not " + f"yet supported during import.", + ) + exported_table = DatabaseExportSerializedStructure.table( id=table["id"], name=table["name"], @@ -550,6 +597,29 @@ class AirtableHandler: url = signed_user_content_urls[url] files_to_download[file_name] = url + # Just to be really clear that the automations and interfaces are not included. + import_report.add_failed( + "All automations", + SCOPE_AUTOMATIONS, + "", + ERROR_TYPE_UNSUPPORTED_FEATURE, + "Baserow doesn't support automations.", + ) + import_report.add_failed( + "All interfaces", + SCOPE_INTERFACES, + "", + ERROR_TYPE_UNSUPPORTED_FEATURE, + "Baserow doesn't support interfaces.", + ) + + # Convert the import report to the serialized export format of a Baserow table, + # so that a new table is created with the import report result for the user to + # see. + exported_tables.append( + import_report.get_baserow_export_table(len(schema["tableSchemas"]) + 1) + ) + exported_database = CoreExportSerializedStructure.application( id=1, name=init_data["rawApplications"][init_data["sharedApplicationId"]]["name"], diff --git a/backend/src/baserow/contrib/database/airtable/import_report.py b/backend/src/baserow/contrib/database/airtable/import_report.py new file mode 100644 index 000000000..fb601e097 --- /dev/null +++ b/backend/src/baserow/contrib/database/airtable/import_report.py @@ -0,0 +1,145 @@ +import dataclasses +import random + +from baserow.contrib.database.export_serialized import DatabaseExportSerializedStructure +from baserow.contrib.database.fields.models import ( + LongTextField, + SelectOption, + SingleSelectField, + TextField, +) +from baserow.contrib.database.fields.registries import field_type_registry +from baserow.contrib.database.views.models import GridView +from baserow.contrib.database.views.registries import view_type_registry +from baserow.core.constants import BASEROW_COLORS + +SCOPE_FIELD = SelectOption(id="scope_field", value="Field", color="light-blue", order=1) +SCOPE_CELL = SelectOption(id="scope_cell", value="Cell", color="light-green", order=2) +SCOPE_VIEW = SelectOption(id="scope_view", value="View", color="light-cyan", order=3) +SCOPE_AUTOMATIONS = SelectOption( + id="scope_automations", value="Automations", color="light-orange", order=4 +) +SCOPE_INTERFACES = SelectOption( + id="scope_interfaces", value="Interfaces", color="light-yellow", order=5 +) +ALL_SCOPES = [SCOPE_FIELD, SCOPE_CELL, SCOPE_VIEW, SCOPE_AUTOMATIONS, SCOPE_INTERFACES] + +ERROR_TYPE_UNSUPPORTED_FEATURE = SelectOption( + id="error_type_unsupported_feature", + value="Unsupported feature", + color="yellow", + order=1, +) +ERROR_TYPE_DATA_TYPE_MISMATCH = SelectOption( + id="error_type_data_type_mismatch", value="Data type mismatch", color="red", order=2 +) +ERROR_TYPE_OTHER = SelectOption( + id="error_type_other", value="Other", color="brown", order=3 +) +ALL_ERROR_TYPES = [ + ERROR_TYPE_UNSUPPORTED_FEATURE, + ERROR_TYPE_DATA_TYPE_MISMATCH, + ERROR_TYPE_OTHER, +] + + +@dataclasses.dataclass +class ImportReportFailedItem: + object_name: str + scope: str + table: str + error_type: str + message: str + + +class AirtableImportReport: + def __init__(self): + self.items = [] + + def add_failed(self, object_name, scope, table, error_type, message): + self.items.append( + ImportReportFailedItem(object_name, scope, table, error_type, message) + ) + + def get_baserow_export_table(self, order: int) -> dict: + # Create an empty grid view because the importing of views doesn't work + # yet. It's a bit quick and dirty, but it will be replaced soon. + grid_view = GridView(pk=0, id=None, name="Grid", order=1) + grid_view.get_field_options = lambda *args, **kwargs: [] + grid_view_type = view_type_registry.get_by_model(grid_view) + empty_serialized_grid_view = grid_view_type.export_serialized( + grid_view, None, None, None + ) + empty_serialized_grid_view["id"] = 0 + exported_views = [empty_serialized_grid_view] + + unique_table_names = {item.table for item in self.items if item.table} + unique_table_select_options = { + name: SelectOption( + id=f"table_{name}", + value=name, + color=random.choice(BASEROW_COLORS), # nosec + order=index + 1, + ) + for index, name in enumerate(unique_table_names) + } + + object_name_field = TextField( + id="object_name", + name="Object name", + order=0, + primary=True, + ) + scope_field = SingleSelectField(id="scope", pk="scope", name="Scope", order=1) + scope_field._prefetched_objects_cache = {"select_options": ALL_SCOPES} + table_field = SingleSelectField( + id="table", pk="error_type", name="Table", order=2 + ) + table_field._prefetched_objects_cache = { + "select_options": unique_table_select_options.values() + } + error_field_type = SingleSelectField( + id="error_type", pk="error_type", name="Error type", order=3 + ) + error_field_type._prefetched_objects_cache = {"select_options": ALL_ERROR_TYPES} + message_field = LongTextField(id="message", name="Message", order=4) + + fields = [ + object_name_field, + scope_field, + table_field, + error_field_type, + message_field, + ] + exported_fields = [ + field_type_registry.get_by_model(field).export_serialized(field) + for field in fields + ] + + exported_rows = [] + for index, item in enumerate(self.items): + table_select_option = unique_table_select_options.get(item.table, None) + row = DatabaseExportSerializedStructure.row( + id=index + 1, + order=f"{index + 1}.00000000000000000000", + created_on=None, + updated_on=None, + ) + row["field_object_name"] = item.object_name + row["field_scope"] = item.scope.id + row["field_table"] = table_select_option.id if table_select_option else None + row["field_error_type"] = item.error_type.id + row["field_message"] = item.message + exported_rows.append(row) + + exported_table = DatabaseExportSerializedStructure.table( + id="report", + name="Airtable import report", + order=order, + fields=exported_fields, + views=exported_views, + rows=exported_rows, + data_sync=None, + ) + + return exported_table diff --git a/backend/src/baserow/contrib/database/airtable/registry.py b/backend/src/baserow/contrib/database/airtable/registry.py index 1d70b9b73..cea8fce7f 100644 --- a/backend/src/baserow/contrib/database/airtable/registry.py +++ b/backend/src/baserow/contrib/database/airtable/registry.py @@ -2,6 +2,7 @@ from datetime import tzinfo from typing import Any, Dict, Tuple, Union from baserow.contrib.database.airtable.config import AirtableImportConfig +from baserow.contrib.database.airtable.import_report import AirtableImportReport from baserow.contrib.database.fields.models import Field from baserow.core.registry import Instance, Registry @@ -13,6 +14,7 @@ class AirtableColumnType(Instance): raw_airtable_column: dict, timezone: tzinfo, config: AirtableImportConfig, + import_report: AirtableImportReport, ) -> Union[Field, None]: """ Converts the raw Airtable column to a Baserow field object. It should be @@ -24,6 +26,8 @@ class AirtableColumnType(Instance): converted. :param timezone: The main timezone used for date conversions if needed. :param config: Additional configuration related to the import. + :param import_report: Used to collect what wasn't imported to report to the + user. :return: The Baserow field type related to the Airtable column. If None is provided, then the column is ignored in the conversion. """ @@ -33,11 +37,14 @@ class AirtableColumnType(Instance): def to_baserow_export_serialized_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, value: Any, files_to_download: Dict[str, str], config: AirtableImportConfig, + import_report: AirtableImportReport, ): """ This method should convert a raw Airtable row value to a Baserow export row @@ -47,6 +54,8 @@ class AirtableColumnType(Instance): :param row_id_mapping: A mapping containing the table as key as the value is another mapping where the Airtable row id maps the Baserow row id. + :param raw_airtable_table: The original Airtable table object. + :param raw_airtable_row: The original row object. :param raw_airtable_column: A dict containing the raw Airtable column values. :param baserow_field: The Baserow field that the column has been converted to. :param value: The raw Airtable value that must be converted. @@ -54,6 +63,8 @@ class AirtableColumnType(Instance): be downloaded. The key is the file name and the value the URL. Additional files can be added to this dict. :param config: Additional configuration related to the import. + :param import_report: Used to collect what wasn't imported to report to the + user. :return: The converted value is Baserow export format. """ @@ -68,6 +79,7 @@ class AirtableColumnTypeRegistry(Registry): raw_airtable_table: dict, raw_airtable_column: dict, config: AirtableImportConfig, + import_report: AirtableImportReport, ) -> Union[Tuple[Field, AirtableColumnType], Tuple[None, None]]: """ Tries to find a Baserow field that matches that raw Airtable column data. If @@ -76,6 +88,8 @@ class AirtableColumnTypeRegistry(Registry): :param raw_airtable_table: The raw Airtable table data related to the column. :param raw_airtable_column: The raw Airtable column data that must be imported. :param config: Additional configuration related to the import. + :param import_report: Used to collect what wasn't imported to report to the + user. :return: The related Baserow field and AirtableColumnType that should be used for the conversion. """ @@ -84,7 +98,7 @@ class AirtableColumnTypeRegistry(Registry): type_name = raw_airtable_column.get("type", "") airtable_column_type = self.get(type_name) baserow_field = airtable_column_type.to_baserow_field( - raw_airtable_table, raw_airtable_column, config + raw_airtable_table, raw_airtable_column, config, import_report ) if baserow_field is None: diff --git a/backend/src/baserow/contrib/database/airtable/utils.py b/backend/src/baserow/contrib/database/airtable/utils.py index 72dc056fc..5ff0acb37 100644 --- a/backend/src/baserow/contrib/database/airtable/utils.py +++ b/backend/src/baserow/contrib/database/airtable/utils.py @@ -20,3 +20,22 @@ def extract_share_id_from_url(public_base_url: str) -> str: ) return f"{result.group(1)}{result.group(2)}" + + +def get_airtable_row_primary_value(table, row): + """ + Tries to extract the name of a row using the primary value. If empty or not + available, then it falls back on the row ID> + + :param table: The table where to extract primary column ID from. + :param row: The row to get the value name for. + :return: The primary value or ID of the row. + """ + + primary_column_id = table.get("primaryColumnId", "") + primary_value = row.get("cellValuesByColumnId", {}).get(primary_column_id, None) + + if not primary_value or not isinstance(primary_value, str): + primary_value = row["id"] + + return primary_value diff --git a/backend/src/baserow/core/constants.py b/backend/src/baserow/core/constants.py index f5a8839a4..74cc58a3b 100644 --- a/backend/src/baserow/core/constants.py +++ b/backend/src/baserow/core/constants.py @@ -18,3 +18,35 @@ DATE_TIME_FORMAT = { # Django's choices to use with models.TextField DATE_TIME_FORMAT_CHOICES = [(k, v["name"]) for k, v in DATE_TIME_FORMAT.items()] + +# Should stay in sync with `light-`, (non-prefixed), and 'dark-' in +# `modules/core/assets/scss/colors.scss::$colors`. +BASEROW_COLORS = [ + "light-blue", + "light-cyan", + "light-orange", + "light-yellow", + "light-red", + "light-brown", + "light-purple", + "light-pink", + "light-gray", + "blue", + "cyan", + "orange", + "yellow", + "red", + "brown", + "purple", + "pink", + "gray", + "dark-blue", + "dark-cyan", + "dark-orange", + "dark-yellow", + "dark-red", + "dark-brown", + "dark-purple", + "dark-pink", + "dark-gray", +] diff --git a/backend/tests/baserow/contrib/database/airtable/test_airtable_column_types.py b/backend/tests/baserow/contrib/database/airtable/test_airtable_column_types.py index 0f4ea6a42..41eba6ce2 100644 --- a/backend/tests/baserow/contrib/database/airtable/test_airtable_column_types.py +++ b/backend/tests/baserow/contrib/database/airtable/test_airtable_column_types.py @@ -18,6 +18,11 @@ from baserow.contrib.database.airtable.airtable_column_types import ( TextAirtableColumnType, ) from baserow.contrib.database.airtable.config import AirtableImportConfig +from baserow.contrib.database.airtable.import_report import ( + SCOPE_CELL, + SCOPE_FIELD, + AirtableImportReport, +) from baserow.contrib.database.airtable.registry import airtable_column_type_registry from baserow.contrib.database.fields.models import ( BooleanField, @@ -50,6 +55,7 @@ def test_unknown_column_type(): {}, airtable_field, AirtableImportConfig(), + AirtableImportReport(), ) assert baserow_field is None assert baserow_field is None @@ -64,7 +70,10 @@ def test_unknown_column_type(): baserow_field, airtable_column_type, ) = airtable_column_type_registry.from_airtable_column_to_serialized( - {}, airtable_field, AirtableImportConfig() + {}, + airtable_field, + AirtableImportConfig(), + AirtableImportReport(), ) assert baserow_field is None assert baserow_field is None @@ -82,7 +91,10 @@ def test_airtable_import_text_column(data_fixture, api_client): baserow_field, airtable_column_type, ) = airtable_column_type_registry.from_airtable_column_to_serialized( - {}, airtable_field, AirtableImportConfig() + {}, + airtable_field, + AirtableImportConfig(), + AirtableImportReport(), ) assert isinstance(baserow_field, TextField) assert isinstance(airtable_column_type, TextAirtableColumnType) @@ -101,7 +113,10 @@ def test_airtable_import_checkbox_column(data_fixture, api_client): baserow_field, airtable_column_type, ) = airtable_column_type_registry.from_airtable_column_to_serialized( - {}, airtable_field, AirtableImportConfig() + {}, + airtable_field, + AirtableImportConfig(), + AirtableImportReport(), ) assert isinstance(baserow_field, BooleanField) assert isinstance(airtable_column_type, CheckboxAirtableColumnType) @@ -125,11 +140,15 @@ def test_airtable_import_created_on_column(data_fixture, api_client): "resultIsArray": False, }, } + import_report = AirtableImportReport() ( baserow_field, airtable_column_type, ) = airtable_column_type_registry.from_airtable_column_to_serialized( - {}, airtable_field, AirtableImportConfig() + {}, + airtable_field, + AirtableImportConfig(), + import_report, ) assert isinstance(baserow_field, CreatedOnField) assert isinstance(airtable_column_type, FormulaAirtableColumnType) @@ -137,6 +156,9 @@ def test_airtable_import_created_on_column(data_fixture, api_client): 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 == "Created" + assert import_report.items[0].scope == SCOPE_FIELD airtable_field = { "id": "fldcTpJuoUVpsDNoszO", @@ -158,7 +180,10 @@ def test_airtable_import_created_on_column(data_fixture, api_client): baserow_field, airtable_column_type, ) = airtable_column_type_registry.from_airtable_column_to_serialized( - {}, airtable_field, AirtableImportConfig() + {}, + airtable_field, + AirtableImportConfig(), + AirtableImportReport(), ) assert isinstance(baserow_field, CreatedOnField) assert isinstance(airtable_column_type, FormulaAirtableColumnType) @@ -170,11 +195,14 @@ def test_airtable_import_created_on_column(data_fixture, api_client): assert ( airtable_column_type.to_baserow_export_serialized_value( {}, + {"name": "Test"}, + {"id": "row1"}, airtable_field, baserow_field, "2022-01-03T14:51:00.000Z", {}, AirtableImportConfig(), + AirtableImportReport(), ) is None ) @@ -187,54 +215,90 @@ def test_airtable_import_date_column(data_fixture, api_client): "id": "fldyAXIzheHfugGhuFD", "name": "ISO DATE", "type": "date", - "typeOptions": {"isDateTime": False, "dateFormat": "US"}, + "typeOptions": {"isDateTime": False, "dateFormat": "US", "timeZone": "client"}, } + import_report = AirtableImportReport() ( baserow_field, airtable_column_type, ) = airtable_column_type_registry.from_airtable_column_to_serialized( - {}, airtable_field, AirtableImportConfig() + {}, + airtable_field, + AirtableImportConfig(), + import_report, ) assert isinstance(baserow_field, DateField) assert isinstance(airtable_column_type, DateAirtableColumnType) assert baserow_field.date_format == "US" assert baserow_field.date_include_time is False assert baserow_field.date_time_format == "24" + assert len(import_report.items) == 1 + assert import_report.items[0].object_name == "ISO DATE" + assert import_report.items[0].scope == SCOPE_FIELD assert ( airtable_column_type.to_baserow_export_serialized_value( {}, + {"name": "Test"}, + {"id": "row1"}, airtable_field, baserow_field, "2022-01-03T14:51:00.000Z", {}, AirtableImportConfig(), + AirtableImportReport(), ) == "2022-01-03" ) assert ( airtable_column_type.to_baserow_export_serialized_value( {}, + {"name": "Test"}, + {"id": "row1"}, airtable_field, baserow_field, "0999-02-04T14:51:00.000Z", {}, AirtableImportConfig(), + AirtableImportReport(), ) == "0999-02-04" ) assert ( airtable_column_type.to_baserow_export_serialized_value( {}, + {"name": "Test"}, + {"id": "row1"}, airtable_field, baserow_field, None, {}, AirtableImportConfig(), + AirtableImportReport(), ) is None ) + import_report = AirtableImportReport() + assert ( + airtable_column_type.to_baserow_export_serialized_value( + {}, + {"name": "Test"}, + {"id": "row1"}, + airtable_field, + baserow_field, + "+000000-06-19T21:09:30.000Z", + {}, + AirtableImportConfig(), + import_report, + ) + is None + ) + assert len(import_report.items) == 1 + assert import_report.items[0].object_name == 'Row: "row1", field: "ISO DATE"' + assert import_report.items[0].scope == SCOPE_CELL + assert import_report.items[0].table == "Test" + @pytest.mark.django_db def test_airtable_import_european_date_column(data_fixture, api_client): @@ -252,7 +316,10 @@ def test_airtable_import_european_date_column(data_fixture, api_client): baserow_field, airtable_column_type, ) = airtable_column_type_registry.from_airtable_column_to_serialized( - {}, airtable_field, AirtableImportConfig() + {}, + airtable_field, + AirtableImportConfig(), + AirtableImportReport(), ) assert isinstance(baserow_field, DateField) assert isinstance(airtable_column_type, DateAirtableColumnType) @@ -263,33 +330,42 @@ def test_airtable_import_european_date_column(data_fixture, api_client): assert ( airtable_column_type.to_baserow_export_serialized_value( {}, + {"name": "Test"}, + {"id": "row1"}, airtable_field, baserow_field, "2022-01-03T14:51:00.000Z", {}, AirtableImportConfig(), + AirtableImportReport(), ) == "2022-01-03" ) assert ( airtable_column_type.to_baserow_export_serialized_value( {}, + {"name": "Test"}, + {"id": "row1"}, airtable_field, baserow_field, "2020-08-27T21:10:24.828Z", {}, AirtableImportConfig(), + AirtableImportReport(), ) == "2020-08-27" ) assert ( airtable_column_type.to_baserow_export_serialized_value( {}, + {"name": "Test"}, + {"id": "row1"}, airtable_field, baserow_field, None, {}, AirtableImportConfig(), + AirtableImportReport(), ) is None ) @@ -313,7 +389,10 @@ def test_airtable_import_datetime_column(data_fixture, api_client): baserow_field, airtable_column_type, ) = airtable_column_type_registry.from_airtable_column_to_serialized( - {}, airtable_field, AirtableImportConfig() + {}, + airtable_field, + AirtableImportConfig(), + AirtableImportReport(), ) assert isinstance(baserow_field, DateField) assert isinstance(airtable_column_type, DateAirtableColumnType) @@ -324,33 +403,42 @@ def test_airtable_import_datetime_column(data_fixture, api_client): assert ( airtable_column_type.to_baserow_export_serialized_value( {}, + {"name": "Test"}, + {"id": "row1"}, airtable_field, baserow_field, "2022-01-03T14:51:00.000Z", {}, AirtableImportConfig(), + AirtableImportReport(), ) == "2022-01-03T14:51:00+00:00" ) assert ( airtable_column_type.to_baserow_export_serialized_value( {}, + {"name": "Test"}, + {"id": "row1"}, airtable_field, baserow_field, "2020-08-27T21:10:24.828Z", {}, AirtableImportConfig(), + AirtableImportReport(), ) == "2020-08-27T21:10:24.828000+00:00" ) assert ( airtable_column_type.to_baserow_export_serialized_value( {}, + {"name": "Test"}, + {"id": "row1"}, airtable_field, baserow_field, None, {}, AirtableImportConfig(), + AirtableImportReport(), ) is None ) @@ -376,7 +464,10 @@ def test_airtable_import_datetime_with_default_timezone_column( baserow_field, airtable_column_type, ) = airtable_column_type_registry.from_airtable_column_to_serialized( - {}, airtable_field, AirtableImportConfig() + {}, + airtable_field, + AirtableImportConfig(), + AirtableImportReport(), ) assert isinstance(baserow_field, DateField) assert isinstance(airtable_column_type, DateAirtableColumnType) @@ -388,11 +479,14 @@ def test_airtable_import_datetime_with_default_timezone_column( assert ( airtable_column_type.to_baserow_export_serialized_value( {}, + {"name": "Test"}, + {"id": "row1"}, airtable_field, baserow_field, "2022-01-03T23:51:00.000Z", {}, AirtableImportConfig(), + AirtableImportReport(), ) == "2022-01-03T23:51:00+00:00" ) @@ -418,7 +512,10 @@ def test_airtable_import_datetime_with_different_default_timezone_column( baserow_field, airtable_column_type, ) = airtable_column_type_registry.from_airtable_column_to_serialized( - {}, airtable_field, AirtableImportConfig() + {}, + airtable_field, + AirtableImportConfig(), + AirtableImportReport(), ) assert isinstance(baserow_field, DateField) assert isinstance(airtable_column_type, DateAirtableColumnType) @@ -430,11 +527,14 @@ def test_airtable_import_datetime_with_different_default_timezone_column( assert ( airtable_column_type.to_baserow_export_serialized_value( {}, + {"name": "Test"}, + {"id": "row1"}, airtable_field, baserow_field, "2022-01-03T23:51:00.000Z", {}, AirtableImportConfig(), + AirtableImportReport(), ) == "2022-01-03T23:51:00+00:00" ) @@ -458,7 +558,10 @@ def test_airtable_import_datetime_edge_case_1(data_fixture, api_client): baserow_field, airtable_column_type, ) = airtable_column_type_registry.from_airtable_column_to_serialized( - {}, airtable_field, AirtableImportConfig() + {}, + airtable_field, + AirtableImportConfig(), + AirtableImportReport(), ) assert isinstance(baserow_field, DateField) assert isinstance(airtable_column_type, DateAirtableColumnType) @@ -469,11 +572,14 @@ def test_airtable_import_datetime_edge_case_1(data_fixture, api_client): assert ( airtable_column_type.to_baserow_export_serialized_value( {}, + {"name": "Test"}, + {"id": "row1"}, airtable_field, baserow_field, "+020222-03-28T00:00:00.000Z", {}, AirtableImportConfig(), + AirtableImportReport(), ) is None ) @@ -492,7 +598,10 @@ def test_airtable_import_email_column(data_fixture, api_client): baserow_field, airtable_column_type, ) = airtable_column_type_registry.from_airtable_column_to_serialized( - {}, airtable_field, AirtableImportConfig() + {}, + airtable_field, + AirtableImportConfig(), + AirtableImportReport(), ) assert isinstance(baserow_field, EmailField) assert isinstance(airtable_column_type, TextAirtableColumnType) @@ -500,22 +609,28 @@ def test_airtable_import_email_column(data_fixture, api_client): assert ( airtable_column_type.to_baserow_export_serialized_value( {}, + {"name": "Test"}, + {"id": "row1"}, airtable_field, baserow_field, "NOT_EMAIL", {}, AirtableImportConfig(), + AirtableImportReport(), ) == "" ) assert ( airtable_column_type.to_baserow_export_serialized_value( {}, + {"name": "Test"}, + {"id": "row1"}, airtable_field, baserow_field, "test@test.nl", {}, AirtableImportConfig(), + AirtableImportReport(), ) == "test@test.nl" ) @@ -534,7 +649,10 @@ def test_airtable_import_multiple_attachment_column(data_fixture, api_client): baserow_field, airtable_column_type, ) = airtable_column_type_registry.from_airtable_column_to_serialized( - {}, airtable_field, AirtableImportConfig() + {}, + airtable_field, + AirtableImportConfig(), + AirtableImportReport(), ) assert isinstance(baserow_field, FileField) assert isinstance(airtable_column_type, MultipleAttachmentAirtableColumnType) @@ -542,6 +660,8 @@ def test_airtable_import_multiple_attachment_column(data_fixture, api_client): files_to_download = {} assert airtable_column_type.to_baserow_export_serialized_value( {}, + {"name": "Test"}, + {"id": "row1"}, airtable_field, baserow_field, [ @@ -570,6 +690,7 @@ def test_airtable_import_multiple_attachment_column(data_fixture, api_client): ], files_to_download, AirtableImportConfig(), + AirtableImportReport(), ) == [ { "name": "70e50b90fb83997d25e64937979b6b5b_f3f62d23_file-sample.txt", @@ -603,7 +724,10 @@ def test_airtable_import_multiple_attachment_column_skip_files( baserow_field, airtable_column_type, ) = airtable_column_type_registry.from_airtable_column_to_serialized( - {}, airtable_field, AirtableImportConfig(skip_files=True) + {}, + airtable_field, + AirtableImportConfig(skip_files=True), + AirtableImportReport(), ) assert isinstance(baserow_field, FileField) assert isinstance(airtable_column_type, MultipleAttachmentAirtableColumnType) @@ -612,6 +736,8 @@ def test_airtable_import_multiple_attachment_column_skip_files( assert ( airtable_column_type.to_baserow_export_serialized_value( {}, + {"name": "Test"}, + {"id": "row1"}, airtable_field, baserow_field, [ @@ -640,6 +766,7 @@ def test_airtable_import_multiple_attachment_column_skip_files( ], files_to_download, AirtableImportConfig(skip_files=True), + AirtableImportReport(), ) == [] ) @@ -667,11 +794,15 @@ def test_airtable_import_last_modified_column(data_fixture, api_client): "resultIsArray": False, }, } + import_report = AirtableImportReport() ( baserow_field, airtable_column_type, ) = airtable_column_type_registry.from_airtable_column_to_serialized( - {}, airtable_field, AirtableImportConfig() + {}, + airtable_field, + AirtableImportConfig(), + import_report, ) assert isinstance(baserow_field, LastModifiedField) assert isinstance(airtable_column_type, FormulaAirtableColumnType) @@ -679,6 +810,9 @@ def test_airtable_import_last_modified_column(data_fixture, api_client): 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 airtable_field = { "id": "fldws6n8xdrEJrMxJFJ", @@ -703,7 +837,10 @@ def test_airtable_import_last_modified_column(data_fixture, api_client): baserow_field, airtable_column_type, ) = airtable_column_type_registry.from_airtable_column_to_serialized( - {}, airtable_field, AirtableImportConfig() + {}, + airtable_field, + AirtableImportConfig(), + AirtableImportReport(), ) assert isinstance(baserow_field, LastModifiedField) assert isinstance(airtable_column_type, FormulaAirtableColumnType) @@ -715,11 +852,14 @@ def test_airtable_import_last_modified_column(data_fixture, api_client): assert ( airtable_column_type.to_baserow_export_serialized_value( {}, + {"name": "Test"}, + {"id": "row1"}, airtable_field, baserow_field, "2022-01-03T14:51:00.000Z", {}, AirtableImportConfig(), + AirtableImportReport(), ) == "2022-01-03T14:51:00+00:00" ) @@ -743,7 +883,10 @@ def test_airtable_import_foreign_key_column(data_fixture, api_client): baserow_field, airtable_column_type, ) = airtable_column_type_registry.from_airtable_column_to_serialized( - {"id": "tblxxx"}, airtable_field, AirtableImportConfig() + {"id": "tblxxx"}, + airtable_field, + AirtableImportConfig(), + AirtableImportReport(), ) assert isinstance(baserow_field, LinkRowField) assert isinstance(airtable_column_type, ForeignKeyAirtableColumnType) @@ -757,6 +900,8 @@ def test_airtable_import_foreign_key_column(data_fixture, api_client): "rec5pdtuKyE71lfK1Ah": 2, } }, + {"name": "Test"}, + {"id": "row1"}, airtable_field, baserow_field, [ @@ -771,8 +916,40 @@ def test_airtable_import_foreign_key_column(data_fixture, api_client): ], {}, AirtableImportConfig(), + AirtableImportReport(), ) == [1, 2] + # missing value + import_report = AirtableImportReport() + assert airtable_column_type.to_baserow_export_serialized_value( + { + "tblRpq315qnnIcg5IjI": { + "recWkle1IOXcLmhILmO": 1, + } + }, + {"name": "Test"}, + {"id": "row1"}, + airtable_field, + baserow_field, + [ + { + "foreignRowId": "recWkle1IOXcLmhILmO", + "foreignRowDisplayName": "Bram 1", + }, + { + "foreignRowId": "rec5pdtuKyE71lfK1Ah", + "foreignRowDisplayName": "Bram 2", + }, + ], + {}, + AirtableImportConfig(), + import_report, + ) == [1] + assert len(import_report.items) == 1 + assert import_report.items[0].object_name == 'Row: "row1", field: "Link to Users"' + assert import_report.items[0].scope == SCOPE_CELL + assert import_report.items[0].table == "Test" + # link to same table row airtable_field = { "id": "fldQcEaGEe7xuhUEuPL", @@ -789,7 +966,10 @@ def test_airtable_import_foreign_key_column(data_fixture, api_client): baserow_field, airtable_column_type, ) = airtable_column_type_registry.from_airtable_column_to_serialized( - {"id": "tblRpq315qnnIcg5IjI"}, airtable_field, AirtableImportConfig() + {"id": "tblRpq315qnnIcg5IjI"}, + airtable_field, + AirtableImportConfig(), + AirtableImportReport(), ) assert isinstance(baserow_field, LinkRowField) assert isinstance(airtable_column_type, ForeignKeyAirtableColumnType) @@ -809,7 +989,10 @@ def test_airtable_import_multiline_text_column(data_fixture, api_client): baserow_field, airtable_column_type, ) = airtable_column_type_registry.from_airtable_column_to_serialized( - {}, airtable_field, AirtableImportConfig() + {}, + airtable_field, + AirtableImportConfig(), + AirtableImportReport(), ) assert isinstance(baserow_field, LongTextField) assert isinstance(airtable_column_type, MultilineTextAirtableColumnType) @@ -817,11 +1000,14 @@ def test_airtable_import_multiline_text_column(data_fixture, api_client): assert ( airtable_column_type.to_baserow_export_serialized_value( {}, + {"name": "Test"}, + {"id": "row1"}, airtable_field, baserow_field, "test", {}, AirtableImportConfig(), + AirtableImportReport(), ) == "test" ) @@ -839,7 +1025,10 @@ def test_airtable_import_rich_text_column(data_fixture, api_client): baserow_field, airtable_column_type, ) = airtable_column_type_registry.from_airtable_column_to_serialized( - {}, airtable_field, AirtableImportConfig() + {}, + airtable_field, + AirtableImportConfig(), + AirtableImportReport(), ) assert isinstance(baserow_field, LongTextField) assert isinstance(airtable_column_type, RichTextTextAirtableColumnType) @@ -857,11 +1046,14 @@ def test_airtable_import_rich_text_column(data_fixture, api_client): assert ( airtable_column_type.to_baserow_export_serialized_value( {}, + {"name": "Test"}, + {"id": "row1"}, airtable_field, baserow_field, content, {}, AirtableImportConfig(), + AirtableImportReport(), ) == "Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere " "cubilia curae; Class aptent taciti sociosqu ad litora." @@ -880,7 +1072,10 @@ def test_airtable_import_rich_text_column_with_mention(data_fixture, api_client) baserow_field, airtable_column_type, ) = airtable_column_type_registry.from_airtable_column_to_serialized( - {}, airtable_field, AirtableImportConfig() + {}, + airtable_field, + AirtableImportConfig(), + AirtableImportReport(), ) assert isinstance(baserow_field, LongTextField) assert isinstance(airtable_column_type, RichTextTextAirtableColumnType) @@ -904,11 +1099,14 @@ def test_airtable_import_rich_text_column_with_mention(data_fixture, api_client) } assert airtable_column_type.to_baserow_export_serialized_value( {}, + {"name": "Test"}, + {"id": "row1"}, airtable_field, baserow_field, content, {}, AirtableImportConfig(), + AirtableImportReport(), ) == ( "Vestibulum ante ipsum primis in faucibus orci luctus et ultrices " "@usrr5CVJ5Lz8ErVZS cubilia curae; Class aptent taciti sociosqu ad litora." @@ -946,7 +1144,10 @@ def test_airtable_import_multi_select_column( baserow_field, airtable_column_type, ) = airtable_column_type_registry.from_airtable_column_to_serialized( - {}, airtable_field, AirtableImportConfig() + {}, + airtable_field, + AirtableImportConfig(), + AirtableImportReport(), ) assert isinstance(baserow_field, MultipleSelectField) assert isinstance(airtable_column_type, MultiSelectAirtableColumnType) @@ -986,7 +1187,10 @@ def test_airtable_import_number_integer_column(data_fixture, api_client): baserow_field, airtable_column_type, ) = airtable_column_type_registry.from_airtable_column_to_serialized( - {}, airtable_field, AirtableImportConfig() + {}, + airtable_field, + AirtableImportConfig(), + AirtableImportReport(), ) assert isinstance(baserow_field, NumberField) assert isinstance(airtable_column_type, NumberAirtableColumnType) @@ -996,55 +1200,70 @@ def test_airtable_import_number_integer_column(data_fixture, api_client): assert ( airtable_column_type.to_baserow_export_serialized_value( {}, + {"name": "Test"}, + {"id": "row1"}, airtable_field, baserow_field, "10", {}, AirtableImportConfig(), + AirtableImportReport(), ) == "10" ) assert ( airtable_column_type.to_baserow_export_serialized_value( {}, + {"name": "Test"}, + {"id": "row1"}, airtable_field, baserow_field, 10, {}, AirtableImportConfig(), + AirtableImportReport(), ) == "10" ) assert ( airtable_column_type.to_baserow_export_serialized_value( {}, + {"name": "Test"}, + {"id": "row1"}, airtable_field, baserow_field, "-10", {}, AirtableImportConfig(), + AirtableImportReport(), ) is None ) assert ( airtable_column_type.to_baserow_export_serialized_value( {}, + {"name": "Test"}, + {"id": "row1"}, airtable_field, baserow_field, -10, {}, 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 ) @@ -1067,7 +1286,10 @@ def test_airtable_import_number_decimal_column(data_fixture, api_client): baserow_field, airtable_column_type, ) = airtable_column_type_registry.from_airtable_column_to_serialized( - {}, airtable_field, AirtableImportConfig() + {}, + airtable_field, + AirtableImportConfig(), + AirtableImportReport(), ) assert isinstance(baserow_field, NumberField) assert isinstance(airtable_column_type, NumberAirtableColumnType) @@ -1088,7 +1310,10 @@ def test_airtable_import_number_decimal_column(data_fixture, api_client): baserow_field, airtable_column_type, ) = airtable_column_type_registry.from_airtable_column_to_serialized( - {}, airtable_field, AirtableImportConfig() + {}, + airtable_field, + AirtableImportConfig(), + AirtableImportReport(), ) assert isinstance(baserow_field, NumberField) assert isinstance(airtable_column_type, NumberAirtableColumnType) @@ -1098,55 +1323,70 @@ def test_airtable_import_number_decimal_column(data_fixture, api_client): assert ( airtable_column_type.to_baserow_export_serialized_value( {}, + {"name": "Test"}, + {"id": "row1"}, airtable_field, baserow_field, "10.22", {}, AirtableImportConfig(), + AirtableImportReport(), ) == "10.22" ) assert ( airtable_column_type.to_baserow_export_serialized_value( {}, + {"name": "Test"}, + {"id": "row1"}, airtable_field, baserow_field, 10, {}, AirtableImportConfig(), + AirtableImportReport(), ) == "10" ) assert ( airtable_column_type.to_baserow_export_serialized_value( {}, + {"name": "Test"}, + {"id": "row1"}, airtable_field, baserow_field, "-10.555", {}, AirtableImportConfig(), + AirtableImportReport(), ) == "-10.555" ) assert ( airtable_column_type.to_baserow_export_serialized_value( {}, + {"name": "Test"}, + {"id": "row1"}, airtable_field, baserow_field, -10, {}, AirtableImportConfig(), + AirtableImportReport(), ) == "-10" ) assert ( airtable_column_type.to_baserow_export_serialized_value( {}, + {"name": "Test"}, + {"id": "row1"}, airtable_field, baserow_field, None, {}, AirtableImportConfig(), + AirtableImportReport(), ) is None ) @@ -1165,7 +1405,10 @@ def test_airtable_import_number_decimal_column(data_fixture, api_client): baserow_field, airtable_column_type, ) = airtable_column_type_registry.from_airtable_column_to_serialized( - {}, airtable_field, AirtableImportConfig() + {}, + airtable_field, + AirtableImportConfig(), + AirtableImportReport(), ) assert isinstance(baserow_field, NumberField) assert isinstance(airtable_column_type, NumberAirtableColumnType) @@ -1181,30 +1424,44 @@ def test_airtable_import_phone_column(data_fixture, api_client): baserow_field, airtable_column_type, ) = airtable_column_type_registry.from_airtable_column_to_serialized( - {}, airtable_field, AirtableImportConfig() + {}, + airtable_field, + AirtableImportConfig(), + AirtableImportReport(), ) assert isinstance(baserow_field, PhoneNumberField) assert isinstance(airtable_column_type, PhoneAirtableColumnType) + import_report = AirtableImportReport() assert ( airtable_column_type.to_baserow_export_serialized_value( {}, + {"name": "Test"}, + {"id": "row1"}, airtable_field, baserow_field, "NOT_PHONE", {}, AirtableImportConfig(), + import_report, ) == "" ) + assert len(import_report.items) == 1 + assert import_report.items[0].object_name == 'Row: "row1", field: "Phone"' + assert import_report.items[0].scope == SCOPE_CELL + assert import_report.items[0].table == "Test" assert ( airtable_column_type.to_baserow_export_serialized_value( {}, + {"name": "Test"}, + {"id": "row1"}, airtable_field, baserow_field, "1234", {}, AirtableImportConfig(), + AirtableImportReport(), ) == "1234" ) @@ -1223,7 +1480,10 @@ def test_airtable_import_rating_column(data_fixture, api_client): baserow_field, airtable_column_type, ) = airtable_column_type_registry.from_airtable_column_to_serialized( - {}, airtable_field, AirtableImportConfig() + {}, + airtable_field, + AirtableImportConfig(), + AirtableImportReport(), ) assert isinstance(baserow_field, RatingField) assert isinstance(airtable_column_type, RatingAirtableColumnType) @@ -1231,11 +1491,14 @@ def test_airtable_import_rating_column(data_fixture, api_client): assert ( airtable_column_type.to_baserow_export_serialized_value( {}, + {"name": "Test"}, + {"id": "row1"}, airtable_field, baserow_field, 5, {}, AirtableImportConfig(), + AirtableImportReport(), ) == 5 ) @@ -1272,7 +1535,10 @@ def test_airtable_import_select_column( baserow_field, airtable_column_type, ) = airtable_column_type_registry.from_airtable_column_to_serialized( - {}, airtable_field, AirtableImportConfig() + {}, + airtable_field, + AirtableImportConfig(), + AirtableImportReport(), ) assert isinstance(baserow_field, SingleSelectField) assert isinstance(airtable_column_type, SelectAirtableColumnType) @@ -1308,30 +1574,45 @@ def test_airtable_import_url_column(data_fixture, api_client): baserow_field, airtable_column_type, ) = airtable_column_type_registry.from_airtable_column_to_serialized( - {}, airtable_field, AirtableImportConfig() + {}, + airtable_field, + AirtableImportConfig(), + AirtableImportReport(), ) assert isinstance(baserow_field, URLField) assert isinstance(airtable_column_type, TextAirtableColumnType) + import_report = AirtableImportReport() assert ( airtable_column_type.to_baserow_export_serialized_value( {}, + {"name": "Test"}, + {"id": "row1"}, airtable_field, baserow_field, "NOT_URL", {}, AirtableImportConfig(), + import_report, ) == "" ) + assert len(import_report.items) == 1 + assert import_report.items[0].object_name == 'Row: "row1", field: "Name"' + assert import_report.items[0].scope == SCOPE_CELL + assert import_report.items[0].table == "Test" + assert ( airtable_column_type.to_baserow_export_serialized_value( {}, + {"name": "Test"}, + {"id": "row1"}, airtable_field, baserow_field, "https://test.nl", {}, AirtableImportConfig(), + AirtableImportReport(), ) == "https://test.nl" ) @@ -1355,7 +1636,10 @@ def test_airtable_import_count_column(data_fixture, api_client): baserow_field, airtable_column_type, ) = airtable_column_type_registry.from_airtable_column_to_serialized( - {}, airtable_field, AirtableImportConfig() + {}, + airtable_field, + AirtableImportConfig(), + AirtableImportReport(), ) assert isinstance(baserow_field, CountField) assert isinstance(airtable_column_type, CountAirtableColumnType) @@ -1364,11 +1648,14 @@ def test_airtable_import_count_column(data_fixture, api_client): assert ( airtable_column_type.to_baserow_export_serialized_value( {}, + {"name": "Test"}, + {"id": "row1"}, airtable_field, baserow_field, "1", {}, AirtableImportConfig(), + AirtableImportReport(), ) is None ) diff --git a/backend/tests/baserow/contrib/database/airtable/test_airtable_handler.py b/backend/tests/baserow/contrib/database/airtable/test_airtable_handler.py index 0d35b7f2d..1514cd9d6 100644 --- a/backend/tests/baserow/contrib/database/airtable/test_airtable_handler.py +++ b/backend/tests/baserow/contrib/database/airtable/test_airtable_handler.py @@ -222,7 +222,7 @@ def test_to_baserow_database_export(): assert baserow_database_export["name"] == "Test" assert baserow_database_export["order"] == 1 assert baserow_database_export["type"] == "database" - assert len(baserow_database_export["tables"]) == 2 + assert len(baserow_database_export["tables"]) == 3 # 2 + import report table assert baserow_database_export["tables"][0]["id"] == "tblRpq315qnnIcg5IjI" assert baserow_database_export["tables"][0]["name"] == "Users" @@ -315,6 +315,29 @@ def test_to_baserow_database_export(): } ] + assert baserow_database_export["tables"][2]["rows"][0] == { + "id": 1, + "order": "1.00000000000000000000", + "created_on": None, + "updated_on": None, + "field_object_name": "All", + "field_scope": "scope_view", + "field_table": "table_Users", + "field_error_type": "error_type_unsupported_feature", + "field_message": 'View "All" was not imported because views are not yet supported during import.', + } + assert baserow_database_export["tables"][2]["rows"][1] == { + "id": 2, + "order": "2.00000000000000000000", + "created_on": None, + "updated_on": None, + "field_object_name": "Name lookup (from Users)", + "field_scope": "scope_field", + "field_table": "table_Data", + "field_error_type": "error_type_unsupported_feature", + "field_message": 'Field "Name lookup (from Users)" with field type lookup was not imported because it is not supported.', + } + @pytest.mark.django_db @responses.activate @@ -511,10 +534,11 @@ def test_import_from_airtable_to_workspace( assert database.name == "Test" all_tables = database.table_set.all() - assert len(all_tables) == 2 + assert len(all_tables) == 3 # 2 + import report assert all_tables[0].name == "Users" assert all_tables[1].name == "Data" + assert all_tables[2].name == "Airtable import report" user_fields = all_tables[0].field_set.all() assert len(user_fields) == 4 @@ -537,6 +561,85 @@ def test_import_from_airtable_to_workspace( assert row_1.checkbox is False +@pytest.mark.django_db +@responses.activate +def test_import_from_airtable_to_workspace_with_report_table(data_fixture, tmpdir): + workspace = data_fixture.create_workspace() + base_path = os.path.join( + settings.BASE_DIR, "../../../tests/airtable_responses/basic" + ) + storage = FileSystemStorage(location=(str(tmpdir)), base_url="http://localhost") + + with open(os.path.join(base_path, "file-sample.txt"), "rb") as file: + responses.add( + responses.GET, + "https://dl.airtable.com/.signed/file-sample.txt", + status=200, + body=file.read(), + ) + + with open(os.path.join(base_path, "file-sample_500kB.doc"), "rb") as file: + responses.add( + responses.GET, + "https://dl.airtable.com/.attachments/e93dc201ce27080d9ad9df5775527d09/93e85b28/file-sample_500kB.doc", + status=200, + body=file.read(), + ) + + with open(os.path.join(base_path, "file_example_JPG_100kB.jpg"), "rb") as file: + responses.add( + responses.GET, + "https://dl.airtable.com/.attachments/025730a04991a764bb3ace6d524b45e5/bd61798a/file_example_JPG_100kB.jpg", + status=200, + body=file.read(), + ) + + with open(os.path.join(base_path, "airtable_base.html"), "rb") as file: + responses.add( + responses.GET, + "https://airtable.com/appZkaH3aWX3ZjT3b", + status=200, + body=file.read(), + headers={"Set-Cookie": "brw=test;"}, + ) + + with open(os.path.join(base_path, "airtable_application.json"), "rb") as file: + responses.add( + responses.GET, + "https://airtable.com/v0.3/application/appZkaH3aWX3ZjT3b/read", + status=200, + body=file.read(), + ) + + with open(os.path.join(base_path, "airtable_table.json"), "rb") as file: + responses.add( + responses.GET, + "https://airtable.com/v0.3/table/tbl7glLIGtH8C8zGCzb/readData", + status=200, + body=file.read(), + ) + + progress = Progress(1000) + + database = AirtableHandler.import_from_airtable_to_workspace( + workspace, + "appZkaH3aWX3ZjT3b", + storage=storage, + progress_builder=progress.create_child_builder(represents_progress=1000), + ) + + report_table = database.table_set.last() + assert report_table.name == "Airtable import report" + + model = report_table.get_model(attribute_names=True) + row = model.objects.last() + assert row.object_name == "All interfaces" + assert row.scope.value == "Interfaces" + assert row.table is None + assert row.error_type.value == "Unsupported feature" + assert row.message == "Baserow doesn't support interfaces." + + @pytest.mark.django_db @responses.activate def test_import_from_airtable_to_workspace_duplicated_single_select( diff --git a/backend/tests/baserow/contrib/database/airtable/test_airtable_utils.py b/backend/tests/baserow/contrib/database/airtable/test_airtable_utils.py index cde32ddff..0a2c4f3d0 100644 --- a/backend/tests/baserow/contrib/database/airtable/test_airtable_utils.py +++ b/backend/tests/baserow/contrib/database/airtable/test_airtable_utils.py @@ -1,6 +1,9 @@ import pytest -from baserow.contrib.database.airtable.utils import extract_share_id_from_url +from baserow.contrib.database.airtable.utils import ( + extract_share_id_from_url, + get_airtable_row_primary_value, +) def test_extract_share_id_from_url(): @@ -28,3 +31,33 @@ def test_extract_share_id_from_url(): extract_share_id_from_url(f"https://airtable.com/{long_share_id}") == long_share_id ) + + +def test_get_airtable_row_primary_value_with_primary_field(): + airtable_table = { + "name": "Test", + "primaryColumnId": "fldG9y88Zw7q7u4Z7i4", + } + airtable_row = { + "id": "id1", + "cellValuesByColumnId": {"fldG9y88Zw7q7u4Z7i4": "name1"}, + } + assert get_airtable_row_primary_value(airtable_table, airtable_row) == "name1" + + +def test_get_airtable_row_primary_value_without_primary_field(): + airtable_table = { + "name": "Test", + "primaryColumnId": "fldG9y88Zw7q7u4Z7i4", + } + airtable_row = {"id": "id1"} + assert get_airtable_row_primary_value(airtable_table, airtable_row) == "id1" + + +def test_get_airtable_row_primary_value_without_primary_column_id_in_table(): + airtable_table = { + "name": "Test", + "primaryColumnId": "test", + } + airtable_row = {"id": "id1"} + assert get_airtable_row_primary_value(airtable_table, airtable_row) == "id1" diff --git a/changelog/entries/unreleased/feature/3263_missing_airtable_import.json b/changelog/entries/unreleased/feature/3263_missing_airtable_import.json new file mode 100644 index 000000000..19de0c326 --- /dev/null +++ b/changelog/entries/unreleased/feature/3263_missing_airtable_import.json @@ -0,0 +1,7 @@ +{ + "type": "feature", + "message": "Airtable import report.", + "issue_number": 3263, + "bullet_points": [], + "created_at": "2025-02-09" +} diff --git a/web-frontend/modules/core/assets/scss/colors.scss b/web-frontend/modules/core/assets/scss/colors.scss index 491810e5c..7ab75de28 100644 --- a/web-frontend/modules/core/assets/scss/colors.scss +++ b/web-frontend/modules/core/assets/scss/colors.scss @@ -191,6 +191,7 @@ $color-cyan-200: $palette-cyan-200 !default; $color-cyan-300: $palette-cyan-300 !default; $color-cyan-400: $palette-cyan-400 !default; +// Should stay in sync with 'backend/src/baserow/core/constants.py::BASEROW_COLORS'. $colors: ( 'light-blue': $color-primary-100, 'light-green': $color-success-100,