Repository: wagtail-nest/wagtail-airtable (language: python)

Issue:
New importer
Add a new importer, developed internally at Torchbox for a client project.

The benefits here are:

- Less code, which is easier to follow
- Performance improvements
- Expose possible errors, so they can be fed back to the end user if needed

The bulk of the work is in `wagtail_airtable/importer.py`

I also took the opportunity to replace the explicit usage of `MockAirtable` with `unittest.mock.patch`.
Handle creation of m2m field in model creation
# Changes
- Extract m2m fields before creating model object to avoid "direct assignment to the forward side of a many-to-many set is prohibited" 
- After model object is saved, create the m2m objects
Instance parameter to PARENT_PAGE_ID function is always None
#32 changed the behaviour of the `'PARENT_PAGE_ID'` config option on page model imports so that when it's set to a callable (or a path to one), the function receives an `instance` argument. As far as I can see, this has never worked - the parameter is always `None`.

`import_airtable.py` [passes the variable `obj` to the function](https://github.com/wagtail-nest/wagtail-airtable/blob/bc1604c2aa0e8fc68d6488581b090606e6166dd5/wagtail_airtable/management/commands/import_airtable.py#L499), but reading back through the code [`obj` is only ever set when an existing object has been retrieved by Airtable ID](https://github.com/wagtail-nest/wagtail-airtable/blob/bc1604c2aa0e8fc68d6488581b090606e6166dd5/wagtail_airtable/management/commands/import_airtable.py#L421-L435). The only way you'd reach the parent page logic in this case would be if `update_object` fails with an unspecified exception and `update_object_by_uniq_col_name` falls through as well, and I'm pretty sure this fall-through to the creation code is not an intended feature. For the one case where you'd actually _want_ a creation (i.e. the Airtable ID is not found), `obj` is `None`.

Logically, the call to the `PARENT_PAGE_ID` function should happen [after we've built the unsaved `new_model`, and directly before the call to `add_child`](https://github.com/wagtail-nest/wagtail-airtable/blob/bc1604c2aa0e8fc68d6488581b090606e6166dd5/wagtail_airtable/management/commands/import_airtable.py#L547-L551). However, even with this fixed the example code in the readme makes no sense:

        # Callables and path-to-functions (option #1 and option #2 in the above docs)
        # Take an `instance` kwarg as of v0.2.1. Example below:
        #   def custom_parent_page_id_function(instance=None):
        #       if instance and isinstance(instance, Page):
        #           return Page.objects.get(pk=instance.id).get_parent()

If `instance` has only just been built and not yet saved, then clearly it doesn't have an ID yet, and if it did, why would we be creating it as a child of the parent page it already has? 🙃 And this function is supposed to return an ID, but will return a page object instead (or as things stand now, will return nothing at all because the `if` condition doesn't pass).


Discussion / hints:

Fix patch:
diff --git a/README.md b/README.md
index 939021f..3ef13ae 100644
--- a/README.md
+++ b/README.md
@@ -87,14 +87,11 @@ AIRTABLE_IMPORT_SETTINGS = {
         # Example:
         # 'PARENT_PAGE_ID': 3,
 
-        # If you choose option #1 (callable) or option #2 (path to a function)
-        # Your function needs to return an integer which will represent the Parent
-        # Page ID where all imported pages will be created as child pages.
-        # Callables and path-to-functions (option #1 and option #2 in the above docs)
-        # Take an `instance` kwarg as of v0.2.1. Example below:
+        # If you choose option #1 (callable) or option #2 (path to a function),
+        # the supplied function takes no arguments and returns the ID of the parent
+        # page where all imported pages will be created as child pages. For example:
         #   def custom_parent_page_id_function(instance=None):
-        #       if instance and isinstance(instance, Page):
-        #           return Page.objects.get(pk=instance.id).get_parent()
+        #       Page.objects.get(slug="imported-pages").pk
         'PARENT_PAGE_ID': 'path.to.function',
         # The `AUTO_PUBLISH_NEW_PAGES` setting will tell this package to either
         # Automatically publish a newly created page, or set to draft.
diff --git a/wagtail_airtable/importer.py b/wagtail_airtable/importer.py
new file mode 100644
index 0000000..31b6022
--- /dev/null
+++ b/wagtail_airtable/importer.py
@@ -0,0 +1,282 @@
+import logging
+from airtable import Airtable
+from django.conf import settings
+from django.db.models.fields.related import ManyToManyField
+from modelcluster.contrib.taggit import ClusterTaggableManager
+from taggit.managers import TaggableManager
+from wagtail import hooks
+from wagtail.models import Page
+from .utils import import_string
+from typing import NamedTuple, Optional
+from django.db import transaction
+
+logger = logging.getLogger(__name__)
+
+class AirtableImportResult(NamedTuple):
+    record_id: str
+    fields: dict
+    new: bool
+    errors: Optional[dict] = None
+
+    def error_display(self):
+        """
+        Show the errors in a human-readable way
+        """
+        if "exception" in self.errors:
+            return repr(self.errors['exception'])
+        # It's probably a DRF validation error
+        output = ""
+        for field_name, exceptions in sorted(self.errors.items()):
+            output += f"{field_name}: "
+            output += ", ".join([str(e).rstrip(".") for e in exceptions]) + ". "
+        return output.strip()
+
+
+def get_column_to_field_names(airtable_unique_identifier) -> tuple:
+    uniq_id_type = type(airtable_unique_identifier)
+    airtable_unique_identifier_column_name = None
+    airtable_unique_identifier_field_name = None
+    if uniq_id_type == str:
+        # The unique identifier is a string.
+        # Use it as the Airtable Column name and the Django field name
+        airtable_unique_identifier_column_name = airtable_unique_identifier
+        airtable_unique_identifier_field_name = airtable_unique_identifier
+    elif uniq_id_type == dict:
+        # Unique identifier is a dictionary.
+        # Use the key as the Airtable Column name and the value as the Django Field name.
+        (
+            airtable_unique_identifier_column_name,
+            airtable_unique_identifier_field_name,
+        ) = list(airtable_unique_identifier.items())[0]
+
+    return (
+        airtable_unique_identifier_column_name,
+        airtable_unique_identifier_field_name,
+    )
+
+
+def convert_mapped_fields(record_fields_dict, mapped_fields_dict) -> dict:
+    # Create a dictionary of newly mapped key:value pairs based on the `mappings` dict above.
+    # This wil convert "airtable column name" to "django_field_name"
+    mapped_fields_dict = {
+        mapped_fields_dict[key]: value
+        for (key, value) in record_fields_dict.items()
+        if key in mapped_fields_dict
+    }
+    return mapped_fields_dict
+
+
+def get_data_for_new_model(data, record_id):
+    data_for_new_model = data.copy()
+    data_for_new_model["airtable_record_id"] = record_id
+
+    # First things first, remove any "pk" or "id" items from the mapped_import_fields
+    # This will let Django and Wagtail handle the PK on its own, as it should.
+    # When the model is saved it'll trigger a push to Airtable and automatically update
+    # the necessary column with the new PK so it's always accurate.
+    for key in {
+        "pk",
+        "id",
+    }:
+        try:
+            del data_for_new_model[key]
+        except KeyError:
+            pass
+
+    return data_for_new_model
+
+
+
+class AirtableModelImporter:
+    def __init__(self, model, verbosity=1):
+        self.model = model
+        self.model_settings = settings.AIRTABLE_IMPORT_SETTINGS[model._meta.label]
+        self.model_is_page = issubclass(model, Page)
+        self.model_serializer = import_string(self.model_settings["AIRTABLE_SERIALIZER"])
+
+        if verbosity >= 2:
+            logger.setLevel(logging.DEBUG)
+
+        self.airtable_client = Airtable(
+            self.model_settings.get("AIRTABLE_BASE_KEY"),
+            self.model_settings.get("AIRTABLE_TABLE_NAME"),
+            api_key=settings.AIRTABLE_API_KEY,
+        )
+
+        (
+            self.airtable_unique_identifier_column_name,
+            self.airtable_unique_identifier_field_name,
+        ) = get_column_to_field_names(
+            self.model_settings.get("AIRTABLE_UNIQUE_IDENTIFIER")
+        )
+
+        if not (
+            self.airtable_unique_identifier_field_name and self.airtable_unique_identifier_column_name
+        ):
+            raise ValueError("No unique columns are set in your Airtable configuration")
+
+        parent_page_id_setting = self.model_settings.get("PARENT_PAGE_ID", None)
+        if parent_page_id_setting:
+            if callable(parent_page_id_setting):
+                # A function was passed into the settings. Execute it.
+                parent_page_id = parent_page_id_setting()
+            elif isinstance(parent_page_id_setting, str):
+                parent_page_callable = import_string(parent_page_id_setting)
+                parent_page_id = parent_page_callable()
+            else:
+                parent_page_id = parent_page_id_setting
+
+            self.parent_page = Page.objects.get(pk=parent_page_id)
+        else:
+            self.parent_page = None
+
+    def field_is_m2m(self, field_name):
+        field_type = type(self.model._meta.get_field(field_name))
+
+        return issubclass(
+            field_type,
+            (
+                TaggableManager,
+                ClusterTaggableManager,
+                ManyToManyField,
+            )
+        )
+
+    def update_object(self, instance, record_id, data):
+        if self.model_is_page and instance.locked:
+            logger.debug("Instance for %s is locked. Not updating.", record_id)
+            return False
+
+        if self.model_is_page:
+            before = instance.to_json()
+
+        for field_name, value in data.items():
+            if self.field_is_m2m(field_name):
+                # override existing values
+                getattr(instance, field_name).set(value)
+            else:
+                setattr(instance, field_name, value)
+
+        if self.model_is_page and before == instance.to_json():
+            logger.debug("Instance %s didn't change, skipping save.", record_id)
+            return False
+
+        # When an object is saved it should NOT push its newly saved data back to Airtable.
+        # This could theoretically cause a loop. By default this setting is False. But the
+        # below line confirms it's false, just to be safe.
+        instance.push_to_airtable = False
+
+        instance.airtable_record_id = record_id
+        instance._skip_signals = True
+        instance.save()
+        if self.model_is_page:
+            # When saving a page, create it as a new revision
+            instance.save_revision()
+
+        for fn in hooks.get_hooks("airtable_import_record_updated"):
+            fn(instance=instance, is_wagtail_page=self.model_is_page, record_id=record_id)
+
+        return True
+
+    def create_object(self, data, record_id):
+        data_for_new_model = get_data_for_new_model(data, record_id)
+
+        # extract m2m fields to avoid getting the error
+        # "direct assignment to the forward side of a many-to-many set is prohibited"
+        m2m_data = {}
+        non_m2m_data = {}
+        for field_name, value in data_for_new_model.items():
+            if self.field_is_m2m(field_name):
+                m2m_data[field_name] = value
+            else:
+                non_m2m_data[field_name] = value
+
+        new_model = self.model(**non_m2m_data)
+        new_model._skip_signals = True
+        new_model.push_to_airtable = False
+
+        if self.parent_page:
+            new_model.live = False
+            new_model.has_unpublished_changes = True
+
+            self.parent_page.add_child(instance=new_model)
+
+            # If the settings are set to auto publish a page when it's imported,
+            # save the page revision and publish it. Otherwise just save the revision.
+            if self.model_settings.get("AUTO_PUBLISH_NEW_PAGES", False):
+                new_model.save_revision().publish()
+            else:
+                new_model.save_revision()
+        else:
+            new_model.save()
+            for field_name, value in m2m_data.items():
+                getattr(new_model, field_name).set(value)
+
+        for fn in hooks.get_hooks("airtable_import_record_updated"):
+            fn(instance=new_model, is_wagtail_page=self.model_is_page, record_id=record_id)
+
+    def get_existing_instance(self, record_id, unique_identifier):
+        existing_by_record_id = self.model.objects.filter(airtable_record_id=record_id).first()
+        if existing_by_record_id is not None:
+            logger.debug("Found existing instance by id: %s", existing_by_record_id.id)
+            return existing_by_record_id
+
+        existing_by_unique_identifier = self.model.objects.filter(
+            **{self.airtable_unique_identifier_field_name: unique_identifier}
+        ).first()
+        if existing_by_unique_identifier is not None:
+            logger.debug("Found existing instance by unique identifier: %s", existing_by_unique_identifier.id)
+            return existing_by_unique_identifier
+
+        # Couldn't find an instance
+        return None
+
+    @transaction.atomic
+    def process_record(self, record):
+        record_id = record['id']
+        fields = record["fields"]
+
+        mapped_import_fields = convert_mapped_fields(
+            fields, self.model.map_import_fields()
+        )
+
+        unique_identifier = fields.get(
+            self.airtable_unique_identifier_column_name, None
+        )
+        obj = self.get_existing_instance(record_id, unique_identifier)
+
+        logger.debug("Validating data for %s", record_id)
+        serializer = self.model_serializer(data=mapped_import_fields)
+
+        if not serializer.is_valid():
+            return AirtableImportResult(record_id, fields, errors=serializer.errors, new=obj is not None)
+
+        if obj:
+            logger.debug("Attempting update of %s", obj.id)
+            try:
+                was_updated = self.update_object(
+                    instance=obj,
+                    record_id=record_id,
+                    data=serializer.validated_data,
+                )
+            except Exception as e:  # noqa: B902
+                return AirtableImportResult(record_id, fields, new=False, errors={"exception": e})
+            if was_updated:
+                logger.debug("Updated instance for %s", record_id)
+            else:
+                logger.debug("Skipped update for %s", record_id)
+            return AirtableImportResult(record_id, fields, new=False)
+        else:
+            logger.debug("Creating model for %s", record_id)
+            try:
+                self.create_object(serializer.validated_data, record_id)
+            except Exception as e:  # noqa: B902
+                return AirtableImportResult(record_id, fields, new=True, errors={"exception": e})
+            logger.debug("Created instance for %s", record_id)
+            return AirtableImportResult(record_id, fields, new=True)
+
+    def run(self):
+        for page in self.airtable_client.get_iter():
+            for record in page:
+                logger.info("Processing record %s", record["id"])
+                yield self.process_record(record)
diff --git a/wagtail_airtable/management/commands/import_airtable.py b/wagtail_airtable/management/commands/import_airtable.py
index c20eb9f..0e6d3c7 100644
--- a/wagtail_airtable/management/commands/import_airtable.py
+++ b/wagtail_airtable/management/commands/import_airtable.py
@@ -1,620 +1,10 @@
-import sys
-from importlib import import_module
-from logging import getLogger
-
-from airtable import Airtable
-from django.conf import settings
-from django.core.exceptions import ValidationError
 from django.core.management.base import BaseCommand
-from django.db import IntegrityError, models
-from modelcluster.contrib.taggit import ClusterTaggableManager
-from taggit.managers import TaggableManager
-from wagtail import hooks
-from wagtail.models import Page
-
-from wagtail_airtable.tests import MockAirtable
+from django.conf import settings
+from wagtail_airtable.importer import AirtableModelImporter
 from wagtail_airtable.utils import get_validated_models
+import logging
 
-logger = getLogger(__name__)
-
-DEFAULT_OPTIONS = {
-    "verbosity": 1,
-}
-
-TESTING = getattr(settings, "WAGTAIL_AIRTABLE_TESTING", False)
-
-
-class Importer:
-    def __init__(self, models=[], options=DEFAULT_OPTIONS):
-        self.models = models
-        self.options = options
-        self.records_used = []
-        self.cached_records = {}
-        self.created = 0
-        self.updated = 0
-        self.skipped = 0
-
-    def debug_message(self, message):
-        """
-        Local function. Print debug messages if `verbosity` is 2 or higher.
-        """
-        if self.options["verbosity"] >= 2:
-            if not TESTING:
-                print(message)
-            return message
-
-    def get_model_serializer(self, serializer_string):
-        location, serializer_name = serializer_string.rsplit(".", 1)
-        module = import_module(location)
-        serializer_class = getattr(module, serializer_name)
-        return serializer_class
-
-    def get_model_settings(self, model) -> dict:
-        return settings.AIRTABLE_IMPORT_SETTINGS.get(model._meta.label, {})
-
-    def get_column_to_field_names(self, airtable_unique_identifier) -> tuple:
-        uniq_id_type = type(airtable_unique_identifier)
-        airtable_unique_identifier_column_name = None
-        airtable_unique_identifier_field_name = None
-        if uniq_id_type == str:
-            # The unique identifier is a string.
-            # Use it as the Airtable Column name and the Django field name
-            airtable_unique_identifier_column_name = airtable_unique_identifier
-            airtable_unique_identifier_field_name = airtable_unique_identifier
-        elif uniq_id_type == dict:
-            # Unique identifier is a dictionary.
-            # Use the key as the Airtable Column name and the value as the Django Field name.
-            (
-                airtable_unique_identifier_column_name,
-                airtable_unique_identifier_field_name,
-            ) = list(airtable_unique_identifier.items())[0]
-
-        return (
-            airtable_unique_identifier_column_name,
-            airtable_unique_identifier_field_name,
-        )
-
-    def get_or_set_cached_records(self, airtable_client):
-        # Memoize results from Airtable so we don't hit the same API multiple times
-        # This is largely used to support additional Wagtail/Airatble settings
-        # that are identical to each other, as in they would use the
-        # same Airtable Base as the key in the dictionary.
-        #   ie.
-        #   'yourapp.YourPage': {
-        #       ...
-        #       'AIRTABLE_TABLE_NAME': 'Your Table',
-        #   },
-        #   'different_app.DifferentPage': {
-        #       ...
-        #       'AIRTABLE_TABLE_NAME': 'Your Table',  # Same Airtable Table name
-        #   }
-        #   All of the above settings will use the 'Your Table' results
-        #   instead of hitting the Airtable API for each model and getting the same
-        #   results every time. This is designed to help with API efficiency, reduce
-        #   load/import times, and to reduce how much memory is required to save all
-        #   the records from Airtable.
-        if self.cached_records.get(airtable_client.table_name):
-            all_records = self.cached_records.get(airtable_client.table_name)
-        else:
-            # Get all the airtable records for the specified table.
-            all_records = airtable_client.get_all()
-            self.cached_records[airtable_client.table_name] = all_records
-        return all_records
-
-    def convert_mapped_fields(self, record_fields_dict, mapped_fields_dict) -> dict:
-        # Create a dictionary of newly mapped key:value pairs based on the `mappings` dict above.
-        # This wil convert "airtable column name" to "django_field_name"
-        mapped_fields_dict = {
-            mapped_fields_dict[key]: value
-            for (key, value) in record_fields_dict.items()
-            if key in mapped_fields_dict
-        }
-        return mapped_fields_dict
-
-    def update_model_m2m_fields(self, instance, field_name, value) -> None:
-        m2m_field = getattr(instance, field_name)
-        for m2m_value in value:
-            m2m_field.add(m2m_value)
-
-    def get_fields_and_m2m_status(self, model, data: dict):
-        for field_name, value in data.items():
-            field_type = type(
-                model._meta.get_field(field_name)
-            )  # ie. django.db.models.fields.CharField
-            is_m2m = issubclass(
-                field_type,
-                (
-                    TaggableManager,
-                    ClusterTaggableManager,
-                    models.ManyToManyField,
-                ),
-            )
-
-            yield field_name, value, is_m2m
-
-    def update_object(
-        self, instance, record_id, serialized_data, is_wagtail_model=False
-    ) -> bool:
-        """
-        Attempts to update an object.
-
-        Returns a bool that determines if the object was updated or not.
-        """
-
-        self.debug_message(
-            "\t\t Serializer data was valid. Setting attrs on model..."
-        )
-        model = type(instance)
-        for field_name, value, is_m2m in self.get_fields_and_m2m_status(model, serialized_data.validated_data):
-            if is_m2m:
-                self.update_model_m2m_fields(instance, field_name, value)
-            else:
-                setattr(instance, field_name, value)
-        try:
-            if instance.revisions.count():
-                before = instance.revisions.last().content_json
-            else:
-                before = instance.to_json()
-        except AttributeError:
-            before = {}
-
-        # When an object is saved it should NOT push its newly saved data back to Airtable.
-        # This could theoretically cause a loop. By default this setting is True. But the
-        # below line confirms it's false, just to be safe.
-        instance.airtable_record_id = record_id
-        instance.push_to_airtable = False
-        try:
-            if is_wagtail_model:
-                self.debug_message("\t\t This is a Wagtail Page model")
-                # Wagtail page. Requires a .save_revision()
-                if instance.locked:
-                    self.debug_message("\t\t\t Page IS locked. Skipping Page save.")
-                    self.skipped = self.skipped + 1
-                elif before == instance.to_json():
-                    self.debug_message("\t\t\t Page didn't change. Skipping Page save.")
-                    self.skipped = self.skipped + 1
-                else:
-                    self.debug_message(
-                        "\t\t\t Page is not locked. Saving page and creating a new revision."
-                    )
-                    # Only save the page if the page is not locked
-                    instance._skip_signals = True
-                    instance.save()
-                    instance.save_revision()
-                    self.updated = self.updated + 1
-            else:
-                # Django model. Save normally.
-                self.debug_message("\t\t Saving Django model")
-                instance._skip_signals = True
-                instance.save()
-                self.updated = self.updated + 1
-
-            # New record being processed. Save it to the list of records.
-            # Object updated (and record was used)
-            self.records_used.append(record_id)
-        except ValidationError as error:
-            self.skipped = self.skipped + 1
-            error_message = "; ".join(error.messages)
-            logger.error(
-                f"Unable to save {instance._meta.label} -> '{instance}'. Error(s): {error_message}"
-            )
-            self.debug_message(
-                f"\t\t Could not save Wagtail/Django model. Error: {error_message}"
-            )
-            return False
-
-        return True
-
-    def update_object_by_uniq_col_name(
-        self,
-        field_mapping=None,
-        model=None,
-        serialized_data=None,
-        record_id=None,
-        is_wagtail_model=False,
-    ):
-        k, v = zip(*field_mapping.items())
-        airtable_unique_identifier_field_name = k[0]
-        unique_identifier = v[0]
-
-        if unique_identifier:
-            self.debug_message(
-                f"\t\t An Airtable record based on the unique identifier was found: {airtable_unique_identifier_field_name}"
-            )
-            try:
-                instance = model.objects.get(
-                    **{airtable_unique_identifier_field_name: unique_identifier}
-                )
-                self.debug_message(
-                    f"\t\t Local object found by Airtable unique column name: {airtable_unique_identifier_field_name}"
-                )
-            except model.DoesNotExist:
-                instance = None
-                self.debug_message(
-                    "\t\t No object was found based on the Airtable column name"
-                )
-
-            if instance:
-                # A local model object was found by a unique identifier.
-                for field_name, value, is_m2m in self.get_fields_and_m2m_status(model, serialized_data.validated_data):
-                    if is_m2m:
-                        self.update_model_m2m_fields(instance, field_name, value)
-                    else:
-                        setattr(instance, field_name, value)
-                # When an object is saved it should NOT push its newly saved data back to Airtable.
-                # This could theoretically cause a loop. By default this setting is False. But the
-                # below line confirms it's false, just to be safe.
-                instance.airtable_record_id = record_id
-                instance.push_to_airtable = False
-                try:
-                    if is_wagtail_model:
-                        # Wagtail page. Requires a .save_revision()
-                        if not instance.locked:
-                            # Only save the page if the page is not locked
-                            instance._skip_signals = True
-                            instance.save()
-                            instance.save_revision()
-                            self.updated = self.updated + 1
-                            for fn in hooks.get_hooks('airtable_import_record_updated'):
-                                fn(instance=instance, is_wagtail_page=is_wagtail_model, record_id=record_id)
-                        else:
-                            self.debug_message(
-                                "\t\t\t Page IS locked. Skipping Page save."
-                            )
-                            self.skipped = self.skipped + 1
-                    else:
-                        # Django model. Save normally.
-                        instance._skip_signals = True
-                        instance.save()
-                        self.debug_message("\t\t\t Saved!")
-                        self.updated = self.updated + 1
-
-                    # Record this record as "used"
-                    self.records_used.append(record_id)
-                    for fn in hooks.get_hooks('airtable_import_record_updated'):
-                        fn(instance=instance, is_wagtail_page=is_wagtail_model, record_id=record_id)
-                    # New record being processed. Save it to the list of records.
-                    return True
-
-                except ValidationError as error:
-                    error_message = "; ".join(error.messages)
-                    logger.error(
-                        f"Unable to save {instance}. Error(s): {error_message}"
-                    )
-                    self.debug_message(
-                        f"\t\t Unable to save {instance} (ID: {instance.pk}; Airtable Record ID: {record_id}). Reason: {error_message}"
-                    )
-                    self.skipped = self.skipped + 1
-            else:
-                # No object was found by this unique ID.
-                # Do nothing. The next step will be to create this object in Django
-                logger.info(
-                    f"{model._meta.verbose_name} with field {airtable_unique_identifier_field_name}={unique_identifier} was not found"
-                )
-                self.debug_message(
-                    f"\t\t {model._meta.verbose_name} with field {airtable_unique_identifier_field_name}={unique_identifier} was not found"
-                )
-        else:
-            # There was no unique identifier set for this model.
-            # Nothing can be done about that right now.
-            logger.info(f"{model._meta.verbose_name} does not have a unique identifier")
-            self.debug_message(
-                f"\t\t {model._meta.verbose_name} does not have a unique identifier"
-            )
-            self.skipped = self.skipped + 1
-        return False
-
-    def is_wagtail_page(self, model):
-        if issubclass(model, Page):
-            return True
-        return False
-
-    def get_data_for_new_model(self, serialized_data, mapped_import_fields, record_id):
-        data_for_new_model = serialized_data.validated_data.copy()
-        data_for_new_model["airtable_record_id"] = record_id
-
-        # First things first, remove any "pk" or "id" items from the mapped_import_fields
-        # This will let Django and Wagtail handle the PK on its own, as it should.
-        # When the model is saved it'll trigger a push to Airtable and automatically update
-        # the necessary column with the new PK so it's always accurate.
-        for key in ("pk", "id",):
-            try:
-                del data_for_new_model[key]
-            except KeyError:
-                pass
-
-        return data_for_new_model
-
-    def run(self):
-        models = get_validated_models(models=self.models)
-        self.debug_message(f"Validated models: {models}")
-
-        # Used for re-using API data instead of making several of API request and waiting/spamming the Airtable API
-        # Maintain a list of record Ids that were used already. Every record is a unique ID so processing the
-        # Same record more than once will just be hitting the DB over and over and over again. No bueno.
-
-        for model in models:
-            self.debug_message(f"IMPORTING MODEL: {model}")
-            # Wagtail models require a .save_revision() call when being saved.
-            is_wagtail_model = issubclass(model, Page)
-            # Airtable global settings.
-            airtable_settings = self.get_model_settings(model)
-
-            # Set the unique identifier and serializer.
-            model_serializer = self.get_model_serializer(
-                airtable_settings.get("AIRTABLE_SERIALIZER")
-            )
-            # Get the unique column name and field name.
-            # The CAN be the same value if a string is provided in the settings.
-            (
-                airtable_unique_identifier_column_name,
-                airtable_unique_identifier_field_name,
-            ) = self.get_column_to_field_names(
-                airtable_settings.get("AIRTABLE_UNIQUE_IDENTIFIER")
-            )
-
-            if not airtable_unique_identifier_field_name and not airtable_unique_identifier_column_name:
-                logger.error("No unique columns are set in your Airtable configuration")
-                continue
-
-            # Set the Airtable API client on a per-model basis
-            if not TESTING:
-                airtable = Airtable(
-                    airtable_settings.get("AIRTABLE_BASE_KEY"),
-                    airtable_settings.get("AIRTABLE_TABLE_NAME"),
-                    api_key=settings.AIRTABLE_API_KEY,
-                )
-            else:
-                airtable = MockAirtable(
-                    airtable_settings.get("AIRTABLE_BASE_KEY"),
-                    airtable_settings.get("AIRTABLE_TABLE_NAME"),
-                    api_key=settings.AIRTABLE_API_KEY,
-                )
-
-            all_records = self.get_or_set_cached_records(airtable)
-
-            self.debug_message(
-                f"\t Airtable base key: {airtable_settings.get('AIRTABLE_BASE_KEY')}"
-            )
-            self.debug_message(
-                f"\t Airtable table name: {airtable_settings.get('AIRTABLE_TABLE_NAME')}"
-            )
-            self.debug_message("\t Airtable unique identifier settings:")
-            self.debug_message(f"\t Airtable records: {len(all_records)}")
-            self.debug_message(
-                f"\t\t Airtable column: {airtable_unique_identifier_column_name}"
-            )
-            self.debug_message(
-                f"\t\t Django field name: {airtable_unique_identifier_field_name}"
-            )
-
-            # Loop through every record in the Airtable.
-            for record in all_records:
-                # If a record was used already, skip this iteration.
-                if record["id"] in self.records_used:
-                    continue
-
-                record_id = record["id"]
-                record_fields = record["fields"]
-                mapped_import_fields = self.convert_mapped_fields(
-                    record_fields, model.map_import_fields()
-                )
-                serialized_data = model_serializer(data=mapped_import_fields)
-
-                try:
-                    serialized_data.is_valid(raise_exception=True)
-                except Exception as e:
-                    error_type = type(e)
-                    logger.warn("Failed to import %s", record_id)
-                    self.debug_message(f"{error_type}: {e}")
-                    self.debug_message(
-                        f"\n\t Failed to import {record_id}: {serialized_data.errors}"
-                    )
-                    self.skipped += 1
-                    continue
-
-                # Look for a record by its airtable_record_id.
-                # If it exists, update the data.
-                self.debug_message(
-                    f"\n\t Looking for existing object with record: {record_id}"
-                )
-                try:
-                    obj = model.objects.get(airtable_record_id=record_id)
-                    self.debug_message(f"\t\t Local object found {obj}")
-                except model.DoesNotExist:
-                    obj = None
-                    self.debug_message("\t\t Local object was NOT found")
-                except model.MultipleObjectsReturned:
-                    # In the case that multiple models have gotten the same record_id
-                    objs = model.objects.filter(airtable_record_id=record_id)
-                    # Set the first one to be the "right" one
-                    obj = objs[0]
-                    for ob in objs[1:]:
-                        # set airtable_record_id on the "impostors" to ""
-                        ob.airtable_record_id = ""
-                        ob.save()
-
-                if obj:
-                    # Model object was found by it's airtable_record_id
-                    was_updated = self.update_object(
-                        instance=obj,
-                        record_id=record_id,
-                        serialized_data=serialized_data,
-                        is_wagtail_model=is_wagtail_model,
-                    )
-                    # Object was updated. No need to continue through the rest of this function
-                    if was_updated:
-                        # Instance was updated, trigger an update hook.
-                        for fn in hooks.get_hooks('airtable_import_record_updated'):
-                            fn(instance=obj, is_wagtail_page=is_wagtail_model, record_id=record_id)
-                        continue
-
-                # This `unique_identifier` is the value of an Airtable record.
-                # ie.
-                #   fields = {
-                #         'Slug': 'your-model'
-                #   }
-                # This will return 'your-model' and can now be searched for as model.objects.get(slug='your-model')
-                unique_identifier = record_fields.get(airtable_unique_identifier_column_name, None)
-                was_updated = self.update_object_by_uniq_col_name(
-                    field_mapping={
-                        airtable_unique_identifier_field_name: unique_identifier
-                    },
-                    model=model,
-                    serialized_data=serialized_data,
-                    record_id=record_id,
-                    is_wagtail_model=is_wagtail_model,
-                )
-                if was_updated:
-                    # Instance was updated, trigger an update hook.
-                    for fn in hooks.get_hooks('airtable_import_record_updated'):
-                        fn(instance=obj, is_wagtail_page=is_wagtail_model, record_id=record_id)
-                    continue
-
-                # Look for the a PARENT_PAGE_ID setting and hopefully find a corresponding
-                # Wagtail Page to set as the Parent Page. If the `parent_page` is set in
-                # this conditional, it will be used later to create a new page.
-                parent_page = None
-                if self.is_wagtail_page(model):
-                    # Page record was used. This is for caching duplicate entries to avoid too many API calls.
-                    self.records_used.append(record_id)
-                    self.debug_message("\t\t Checking PARENT_PAGE_ID settings...")
-
-                    # Check if the PARENT_PAGE_ID setting is set. If it's not set in the Django settings,
-                    # Don't try to create a new Wagtail Page.
-                    parent_page_id_setting = airtable_settings.get("PARENT_PAGE_ID", None)
-                    if parent_page_id_setting:
-                        self.debug_message("\t\t Setting found...")
-
-                        # Check if the PARENT_PAGE_ID is a callable, string location to a function, or an int ID.
-                        if callable(parent_page_id_setting):
-                            # A function was passed into the settings. Execute it.
-                            parent_page_id = parent_page_id_setting(instance=obj)
-                        elif isinstance(parent_page_id_setting, str):
-                            # A string location was passed into the settings
-                            # Parse the string function location and execute it
-                            location, function_name = parent_page_id_setting.rsplit(".", 1)
-                            module = import_module(location)
-                            parent_page_callable = getattr(module, function_name)
-                            parent_page_id = parent_page_callable(instance=obj)
-                        else:
-                            # This should be an integer representing the Parent Page to nest pages under
-                            parent_page_id = parent_page_id_setting
-
-                        try:
-                            # Look for the parent page ID. If it doesn't exist, a page cannot be created.
-                            # This needs to be set to a value for later use.
-                            parent_page = Page.objects.get(pk=parent_page_id)
-                        except Page.DoesNotExist:
-                            logger.info(
-                                f"Could not create new Page object. Parent page with ID {parent_page_id} doesn't exist"
-                            )
-                            self.debug_message(
-                                f"\t\t Could not create new Page object. Parent page with ID {parent_page_id} doesn't exist"
-                            )
-                            continue
-
-                # Attempt to format valid data to create a new model form either the
-                # validated data in the serializer, or the mapped_field data.
-                data_for_new_model = self.get_data_for_new_model(
-                    serialized_data, mapped_import_fields, record_id
-                )
-                # extract m2m fields to avoid getting the error
-                # direct assignment to the forward side of a many-to-many set is prohibited
-                m2m_fields = {}
-                temp_data = data_for_new_model.copy()
-                for field_name, value, is_m2m in self.get_fields_and_m2m_status(model, data_for_new_model):
-                    if is_m2m:
-                        m2m_fields[field_name] = value
-                        temp_data.pop(field_name)
-                data_for_new_model = temp_data
-                # If there is no match whatsoever, try to create a new `model` instance.
-                # Note: this may fail if there isn't enough data in the Airtable record.
-                try:
-                    self.debug_message("\t\t Attempting to create a new Page...")
-                    new_model = model(**data_for_new_model)
-                    new_model._skip_signals = True
-                except Exception:
-                    self.debug_message("\tCannot load serialized data into model")
-                    self.skipped = self.skipped + 1
-                    continue
-
-                import_successful = False
-                try:
-                    # `parent_page` is either None or a Page instance.
-                    if parent_page:
-                        # Pages are not live by default, and always have unpublished changes.
-                        new_model.live = False
-                        new_model.has_unpublished_changes = True
-
-                        # Add the new page to the parent page.
-                        parent_page.add_child(instance=new_model)
-
-                        # If the settings are set to auto publish a page when it's imported,
-                        # save the page revision and publish it. Otherwise just save the revision.
-                        if airtable_settings.get("AUTO_PUBLISH_NEW_PAGES", False):
-                            self.debug_message("\t\t Publishing new Page...")
-                            new_model.save_revision().publish()
-                        else:
-                            new_model.save_revision()
-                        self.debug_message("\t\t Page created")
-                    else:
-                        new_model.save()
-                        # create m2m relationship objects
-                        if m2m_fields:
-                            try:
-                                for field_name, value in m2m_fields.items():
-                                    self.update_model_m2m_fields(new_model, field_name, value)
-                            except Exception as e:
-                                logger.info(
-                                    f"Could not create new model m2m relationship. Error: {e}"
-                                )
-                                self.debug_message(f"\tCannot create m2m relationship with the model. Error: {e}")
-                                continue
-                        self.debug_message("\t\t Object created")
-                    import_successful = True
-                    self.created = self.created + 1
-                except ValidationError as e:
-                    logger.info(
-                        f"Could not create new model object. Validation Error: {e}"
-                    )
-                    self.debug_message(
-                        f"\t\t Could not create new model object. Validation Error: {e}"
-                    )
-                except ValueError as value_error:
-                    logger.info(
-                        f"Could not create new model object. Value Error: {value_error}"
-                    )
-                    self.debug_message(
-                        f"\t\t Could not create new model object. Value Error: {value_error}"
-                    )
-                except IntegrityError as e:
-                    logger.info(
-                        f"Could not create new model object. Integrity Error: {e}"
-                    )
-                    self.debug_message(
-                        f"\t\t Could not create new model object. Integrity Error: {e}"
-                    )
-                except AttributeError as e:
-                    logger.info(
-                        f"Could not create new model object. AttributeError. Error: {e}"
-                    )
-                    self.debug_message(
-                        f"\t\t Could not create new model object. AttributeError. Error: {e}"
-                    )
-                except Exception as e:
-                    logger.error(
-                        f"Unhandled error. Could not create a new object for {model._meta.verbose_name}. Error: {e}"
-                    )
-                    self.debug_message(
-                        f"\t\t Unhandled error. Could not create a new object for {model._meta.verbose_name}. Error: {e}. Type: {type(e)}"
-                    )
-                if import_successful:
-                    # Instance was updated, trigger an update hook.
-                    for fn in hooks.get_hooks('airtable_import_record_updated'):
-                        fn(instance=new_model, is_wagtail_page=is_wagtail_model, record_id=record_id)
-
-        return self.created, self.skipped, self.updated
+logger = logging.getLogger(__name__)
 
 
 class Command(BaseCommand):
@@ -622,45 +12,13 @@ class Command(BaseCommand):
 
     def add_arguments(self, parser):
         parser.add_argument(
-            "labels",
+            "model_names",
             metavar="model_name",
             nargs="+",
             help="Model (as app_label.model_name) or app name to populate table entries for, e.g. creditcards.CreditCard",
         )
 
     def handle(self, *args, **options):
-        """
-        Runs the management command with the app_name.ModelName as parameters.
-        ie. python manage.py import_airtable pages.HomePage creditcards.CreditCard
-
-        This will handle Wagtail Pages (with page revisions) along with standard
-        Django models.
-
-        Models passed in to the command go through a quick validation to ensure they
-        exist.
-
-        Every model is then looped through, and a `settings.AIRTABLE_IMPORT_SETTINGS`
-        is searched for based on the model's label (ie. pages.HomePage). These
-        settings are used to connect to a certain Airtable Base (a set of spreadsheets),
-        the name of the table to use, a unique identifier (used for connecting previously
-        unrelated Airtable records to Django objects), and a serializer for validating
-        incoming data from Airtable to make it work with the Django field types.
-
-        Each model loop contains an `airtable.get_all()` command which
-        will get all the data from the Airtable Table and load it into a
-        list of dictionaries. This uses memoization to reduce memory hogging and
-        wasted API calls to Airtable.
-
-        Then every record is iterated over and 3 actions are taken:
-            1. Look for an existing model object by its airtable_record_id.
-               Update if found. Skip to step 2 if not found.
-            2. Search for a model object by its unique identifier.
-               If found, update the object. Skip to step 3 if not found.
-            3. Create a new object. This step is very susceptible to fail based on the
-               model type and fields in the serializer. Wagtail pages cannot be created
-               from Airtable records as there's missing data such as depth and parent
-               pages. And so Wagtail pages are skipped entirely in step 3.
-        """
         # Overwrite verbosity if WAGTAIL_AIRTABLE_DEBUG is enabled.
         if settings.DEBUG:
             # AIRTABLE_DEBUG can only be enabled if standard Django DEBUG is enabled.
@@ -669,13 +27,20 @@ def handle(self, *args, **options):
             if AIRTABLE_DEBUG:
                 options["verbosity"] = 2
 
-        importer = Importer(models=options["labels"], options=options)
-        created, skipped, updated = importer.run()
+        error_results = 0
+        new_results = 0
+        updated_results = 0
 
-        if options["verbosity"] >= 1:
-            self.stdout.write(
-                f"{created} object created. {updated} object updated. {skipped} object skipped."
-            )
+        for model in get_validated_models(options["model_names"]):
+            importer = AirtableModelImporter(model=model, verbosity=options["verbosity"])
+
+            for result in importer.run():
+                if result.errors:
+                    logger.error("Failed to import %s %s", result.record_id, result.errors)
+                    error_results += 1
+                elif result.new:
+                    new_results += 1
+                else:
+                    updated_results += 1
 
-        # Use friendlier message. Slightly differs from the stdout.write() string.
-        return f"{created} items created. {updated} items updated. {skipped} items skipped."
+        return f"{new_results} objects created. {updated_results} objects updated. {error_results} objects skipped due to errors."
diff --git a/wagtail_airtable/mixins.py b/wagtail_airtable/mixins.py
index 847348b..82a603b 100644
--- a/wagtail_airtable/mixins.py
+++ b/wagtail_airtable/mixins.py
@@ -1,4 +1,3 @@
-import sys
 from ast import literal_eval
 from logging import getLogger
 
@@ -12,14 +11,9 @@
 
 from wagtail.admin.widgets.button import Button
 
-from .tests import MockAirtable
-
 logger = getLogger(__name__)
 
 
-TESTING = getattr(settings, "WAGTAIL_AIRTABLE_TESTING", False)
-
-
 class AirtableMixin(models.Model):
     """A mixin to update an Airtable when a model object is saved or deleted."""
 
@@ -83,18 +77,11 @@ def setup_airtable(self) -> None:
                 and self.AIRTABLE_TABLE_NAME
                 and self.AIRTABLE_UNIQUE_IDENTIFIER
             ):
-                if not TESTING:
-                    self.airtable_client = Airtable(
-                        self.AIRTABLE_BASE_KEY,
-                        self.AIRTABLE_TABLE_NAME,
-                        api_key=settings.AIRTABLE_API_KEY,
-                    )
-                else:
-                    self.airtable_client = MockAirtable(
-                        self.AIRTABLE_BASE_KEY,
-                        self.AIRTABLE_TABLE_NAME,
-                        api_key=settings.AIRTABLE_API_KEY,
-                    )
+                self.airtable_client = Airtable(
+                    self.AIRTABLE_BASE_KEY,
+                    self.AIRTABLE_TABLE_NAME,
+                    api_key=settings.AIRTABLE_API_KEY,
+                )
 
                 self._push_to_airtable = True
                 self._is_enabled = True
diff --git a/wagtail_airtable/templatetags/wagtail_airtable_tags.py b/wagtail_airtable/templatetags/wagtail_airtable_tags.py
index 15557ce..532b61c 100644
--- a/wagtail_airtable/templatetags/wagtail_airtable_tags.py
+++ b/wagtail_airtable/templatetags/wagtail_airtable_tags.py
@@ -1,6 +1,5 @@
 from django import template
 from django.conf import settings
-from django.urls import reverse
 
 from wagtail import VERSION as WAGTAIL_VERSION
 
@@ -24,28 +23,6 @@ def can_import_model(model_label) -> bool:
     return bool(has_settings)
 
 
-@register.simple_tag
-def wagtail_route(name, label, model, *args) -> str:
-    """
-    Handles generation of URL depending on the Wagtail version used
-    Addresses the changes in route names within the wagtailsnippets namespace in Wagtail v4.
-    https://docs.wagtail.org/en/stable/releases/4.0.html#url-route-names-for-image-document-and-snippet-apps-have-changed
-
-    Takes the label and the model as arguments.
-
-    Use:
-    {% load wagtail_airtable_tags %}
-    {% wagtail_route 'wagtailsnippets:list' model_opts.app_label model_opts.model_name %}
-
-    Returns a URL.
-    """
-    tokens = name.split(":")
-    name = "_".join([tokens[0], label, model]) + f":{tokens[1]}"
-    url = reverse(name, args=args)
-
-    return url
-
-
 @register.simple_tag
 def wagtail_major_version() -> int:
     """
diff --git a/wagtail_airtable/utils.py b/wagtail_airtable/utils.py
index 3548f74..d5c4111 100644
--- a/wagtail_airtable/utils.py
+++ b/wagtail_airtable/utils.py
@@ -1,6 +1,7 @@
 """
 Utility functions for wagtail-airtable.
 """
+from importlib import import_module
 from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ImproperlyConfigured, ObjectDoesNotExist
@@ -117,22 +118,6 @@ def can_send_airtable_messages(instance) -> bool:
     return False
 
 
-def import_models(models=None, verbosity=1):
-    """
-    Import models set in Wagtail Airtable settings
-
-    Supports a list of models if only a limited set of models need to be imported.
-    """
-
-    # Avoid circular import error as get_validated_models is used in import_airtable
-    # management command.
-    from wagtail_airtable.management.commands.import_airtable import Importer
-
-    models = get_validated_models(models=get_models_as_paths(models), as_path=True) if models else get_all_models(as_path=True)
-    importer = Importer(models=models, options={"verbosity": verbosity})
-    return importer.run()
-
-
 def airtable_message(request, instance, message="Airtable record updated", button_text="View record in Airtable", buttons_enabled=True) -> None:
     """
     Common message handler for Wagtail hooks.
@@ -153,3 +138,9 @@ def airtable_message(request, instance, message="Airtable record updated", butto
                 messages.button(instance.get_record_usage_url(), button_text, True)
             ]
         messages.success(request, message=message, buttons=buttons)
+
+
+def import_string(module_name):
+    location, attribute = module_name.rsplit(".", 1)
+    module = import_module(location)
+    return getattr(module, attribute)
diff --git a/wagtail_airtable/views.py b/wagtail_airtable/views.py
index 9ebba01..35ec264 100644
--- a/wagtail_airtable/views.py
+++ b/wagtail_airtable/views.py
@@ -9,8 +9,8 @@
 from django.views.generic import TemplateView
 
 from wagtail_airtable.forms import AirtableImportModelForm
-from wagtail_airtable.management.commands.import_airtable import Importer
-from wagtail_airtable.utils import get_model_for_path
+from wagtail_airtable.importer import AirtableModelImporter
+from wagtail_airtable.utils import get_model_for_path, get_validated_models
 
 logger = getLogger(__name__)
 
@@ -27,9 +27,22 @@ def post(self, request, *args, **kwargs):
         form = AirtableImportModelForm(request.POST)
         if form.is_valid():
             model_label = form.cleaned_data["model"]
-            importer = Importer(models=[model_label], options={"verbosity": 1})
-            importer.run()
-            message = f"{importer.created} items created. {importer.updated} items updated. {importer.skipped} items skipped."
+            model = get_validated_models([model_label])[0]
+            importer = AirtableModelImporter(model=model, verbosity=1)
+
+            error_results = 0
+            new_results = 0
+            updated_results = 0
+
+            for result in importer.run():
+                if result.errors:
+                    error_results += 1
+                elif result.new:
+                    new_results += 1
+                else:
+                    updated_results += 1
+
+            message = f"{new_results} items created. {updated_results} items updated. {error_results} items skipped."
             messages.add_message(
                 request, messages.SUCCESS, f"Import succeeded with {message}"
             )


Test patch:
diff --git a/tests/fixtures/test.json b/tests/fixtures/test.json
index 96386b9..66c937f 100644
--- a/tests/fixtures/test.json
+++ b/tests/fixtures/test.json
@@ -51,7 +51,7 @@
     "model": "wagtailcore.page",
     "fields": {
         "title": "Home",
-        "numchild": 3,
+        "numchild": 0,
         "show_in_menus": false,
         "live": true,
         "depth": 2,
diff --git a/tests/migrations/0004_auto_20220526_2254.py b/tests/migrations/0004_advert_publications.py
similarity index 87%
rename from tests/migrations/0004_auto_20220526_2254.py
rename to tests/migrations/0004_advert_publications.py
index f390b34..8ef374d 100644
--- a/tests/migrations/0004_auto_20220526_2254.py
+++ b/tests/migrations/0004_advert_publications.py
@@ -20,6 +20,6 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name='advert',
             name='publications',
-            field=models.ManyToManyField(blank=True, null=True, to='tests.Publication'),
+            field=models.ManyToManyField(blank=True, to='tests.Publication'),
         ),
     ]
diff --git a/tests/migrations/0005_simplepage_airtable_record_id.py b/tests/migrations/0005_simplepage_airtable_record_id.py
new file mode 100644
index 0000000..9406c16
--- /dev/null
+++ b/tests/migrations/0005_simplepage_airtable_record_id.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.0.9 on 2024-12-12 23:29
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("tests", "0004_advert_publications"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="simplepage",
+            name="airtable_record_id",
+            field=models.CharField(blank=True, db_index=True, max_length=35),
+        ),
+    ]
diff --git a/tests/mock_airtable.py b/tests/mock_airtable.py
new file mode 100644
index 0000000..a90f86b
--- /dev/null
+++ b/tests/mock_airtable.py
@@ -0,0 +1,160 @@
+"""A mocked Airtable API wrapper."""
+from unittest import mock
+
+def get_mock_airtable():
+    """
+    Wrap it in a function, so it's pure
+    """
+
+    class MockAirtable(mock.Mock):
+        def get_iter(self):
+            return [self.get_all()]
+
+
+    MockAirtable.table_name = "app_airtable_advert_base_key"
+
+    MockAirtable.get = mock.MagicMock("get")
+    MockAirtable.get.return_value = {
+        "id": "recNewRecordId",
+        "fields": {
+            "title": "Red! It's the new blue!",
+            "description": "Red is a scientifically proven color that moves faster than all other colors.",
+            "external_link": "https://example.com/",
+            "is_active": True,
+            "rating": "1.5",
+            "long_description": "<p>Lorem ipsum dolor sit amet, consectetur adipisicing elit. Veniam laboriosam consequatur saepe. Repellat itaque dolores neque, impedit reprehenderit eum culpa voluptates harum sapiente nesciunt ratione.</p>",
+            "points": 95,
+            "slug": "red-its-new-blue",
+        },
+    }
+
+    MockAirtable.insert = mock.MagicMock("insert")
+    MockAirtable.insert.return_value = {
+        "id": "recNewRecordId",
+        "fields": {
+            "title": "Red! It's the new blue!",
+            "description": "Red is a scientifically proven color that moves faster than all other colors.",
+            "external_link": "https://example.com/",
+            "is_active": True,
+            "rating": "1.5",
+            "long_description": "<p>Lorem ipsum dolor sit amet, consectetur adipisicing elit. Veniam laboriosam consequatur saepe. Repellat itaque dolores neque, impedit reprehenderit eum culpa voluptates harum sapiente nesciunt ratione.</p>",
+            "points": 95,
+            "slug": "red-its-new-blue",
+        },
+    }
+
+    MockAirtable.update = mock.MagicMock("update")
+    MockAirtable.update.return_value = {
+        "id": "recNewRecordId",
+        "fields": {
+            "title": "Red! It's the new blue!",
+            "description": "Red is a scientifically proven color that moves faster than all other colors.",
+            "external_link": "https://example.com/",
+            "is_active": True,
+            "rating": "1.5",
+            "long_description": "<p>Lorem ipsum dolor sit amet, consectetur adipisicing elit. Veniam laboriosam consequatur saepe. Repellat itaque dolores neque, impedit reprehenderit eum culpa voluptates harum sapiente nesciunt ratione.</p>",
+            "points": 95,
+            "slug": "red-its-new-blue",
+        },
+    }
+
+    MockAirtable.delete = mock.MagicMock("delete")
+    MockAirtable.delete.return_value = {"deleted": True, "record": "recNewRecordId"}
+
+    MockAirtable.search = mock.MagicMock("search")
+    MockAirtable.search.return_value = [
+        {
+            "id": "recNewRecordId",
+            "fields": {
+                "title": "Red! It's the new blue!",
+                "description": "Red is a scientifically proven color that moves faster than all other colors.",
+                "external_link": "https://example.com/",
+                "is_active": True,
+                "rating": "1.5",
+                "long_description": "<p>Lorem ipsum dolor sit amet, consectetur adipisicing elit. Veniam laboriosam consequatur saepe. Repellat itaque dolores neque, impedit reprehenderit eum culpa voluptates harum sapiente nesciunt ratione.</p>",
+                "points": 95,
+                "slug": "red-its-new-blue",
+            },
+        },
+        {
+            "id": "Different record",
+            "fields": {
+                "title": "Not the used record.",
+                "description": "This is only used for multiple responses from MockAirtable",
+                "external_link": "https://example.com/",
+                "is_active": False,
+                "rating": "5.5",
+                "long_description": "",
+                "points": 1,
+                "slug": "not-the-used-record",
+            },
+        },
+    ]
+
+    MockAirtable.get_all = mock.MagicMock("get_all")
+    MockAirtable.get_all.return_value = [
+        {
+            "id": "recNewRecordId",
+            "fields": {
+                "title": "Red! It's the new blue!",
+                "description": "Red is a scientifically proven color that moves faster than all other colors.",
+                "external_link": "https://example.com/",
+                "is_active": True,
+                "rating": "1.5",
+                "long_description": "<p>Lorem ipsum dolor sit amet, consectetur adipisicing elit. Veniam laboriosam consequatur saepe. Repellat itaque dolores neque, impedit reprehenderit eum culpa voluptates harum sapiente nesciunt ratione.</p>",
+                "points": 95,
+                "slug": "delete-me",
+                "publications": [
+                    {"title": "Record 1 publication 1"},
+                    {"title": "Record 1 publication 2"},
+                    {"title": "Record 1 publication 3"},
+                ]
+            },
+        },
+        {
+            "id": "Different record",
+            "fields": {
+                "title": "Not the used record.",
+                "description": "This is only used for multiple responses from MockAirtable",
+                "external_link": "https://example.com/",
+                "is_active": False,
+                "rating": "5.5",
+                "long_description": "",
+                "points": 1,
+                "slug": "not-the-used-record",
+            },
+        },
+        {
+            "id": "recRecordThree",
+            "fields": {
+                "title": "A third record.",
+                "description": "This is only used for multiple responses from MockAirtable",
+                "external_link": "https://example.com/",
+                "is_active": False,
+                "rating": "5.5",
+                "long_description": "",
+                "points": 1,
+                "slug": "record-3",
+            },
+        },
+        {
+            "id": "recRecordFour",
+            "fields": {
+                "title": "A fourth record.",
+                "description": "This is only used for multiple responses from MockAirtable",
+                "external_link": "https://example.com/",
+                "is_active": False,
+                "rating": "5.5",
+                "long_description": "",
+                "points": 1,
+                "slug": "record-4",
+                "publications": [
+                    {"title": "Record 4 publication 1"},
+                    {"title": "Record 4 publication 2"},
+                    {"title": "Record 4 publication 3"},
+                ]
+            },
+        },
+    ]
+
+    return MockAirtable
\ No newline at end of file
diff --git a/tests/models.py b/tests/models.py
index 96028e4..b1e4826 100644
--- a/tests/models.py
+++ b/tests/models.py
@@ -7,9 +7,29 @@
 from wagtail_airtable.mixins import AirtableMixin, SnippetImportActionMixin
 
 
-class SimplePage(Page):
+def get_import_parent_page():
+    return Page.objects.get(slug="home").pk
+
+
+class SimplePage(AirtableMixin, Page):
     intro = models.TextField()
 
+    @classmethod
+    def map_import_fields(cls):
+        mappings = {
+            "title": "title",
+            "slug": "slug",
+            "intro": "intro",
+        }
+        return mappings
+
+    def get_export_fields(self):
+        return {
+            "title": self.title,
+            "slug": self.slug,
+            "intro": self.intro,
+        }
+
 
 class Publication(models.Model):
     title = models.CharField(max_length=30)
@@ -38,7 +58,7 @@ class Advert(AirtableMixin, models.Model):
     long_description = RichTextField(blank=True, null=True)
     points = models.IntegerField(null=True, blank=True)
     slug = models.SlugField(max_length=100, unique=True, editable=True)
-    publications = models.ManyToManyField(Publication, null=True, blank=True)
+    publications = models.ManyToManyField(Publication, blank=True)
 
     @classmethod
     def map_import_fields(cls):
diff --git a/tests/serializers.py b/tests/serializers.py
index 1e83bed..d5d6532 100644
--- a/tests/serializers.py
+++ b/tests/serializers.py
@@ -37,3 +37,9 @@ class AdvertSerializer(AirtableSerializer):
     title = serializers.CharField(max_length=255)
     external_link = serializers.URLField(required=False)
     publications = PublicationsObjectsSerializer(required=False)
+
+
+class SimplePageSerializer(AirtableSerializer):
+    title = serializers.CharField(max_length=255, required=True)
+    slug = serializers.CharField(max_length=100, required=True)
+    intro = serializers.CharField()
diff --git a/tests/settings.py b/tests/settings.py
index 4e3e325..72f4e8b 100644
--- a/tests/settings.py
+++ b/tests/settings.py
@@ -132,6 +132,7 @@
         'AIRTABLE_TABLE_NAME': 'xxx',
         'AIRTABLE_UNIQUE_IDENTIFIER': 'slug',
         'AIRTABLE_SERIALIZER': 'tests.serializers.SimplePageSerializer',
+        'PARENT_PAGE_ID': 'tests.models.get_import_parent_page',
     },
     'tests.Advert': {
         'AIRTABLE_BASE_KEY': 'app_airtable_advert_base_key',
diff --git a/tests/test_import.py b/tests/test_import.py
index d2405c6..35d05b2 100644
--- a/tests/test_import.py
+++ b/tests/test_import.py
@@ -1,19 +1,24 @@
+import copy
 from django.conf import settings
-from django.test import TestCase, TransactionTestCase
+from django.test import TestCase, override_settings
+from unittest.mock import MagicMock, patch
+from wagtail import hooks
+from wagtail.models import Page
 
 from tests.models import Advert, ModelNotUsed, SimilarToAdvert, SimplePage
 from tests.serializers import AdvertSerializer
-from wagtail_airtable.management.commands.import_airtable import Importer
-from wagtail_airtable.tests import MockAirtable
+from wagtail_airtable.importer import AirtableModelImporter, get_column_to_field_names, convert_mapped_fields, get_data_for_new_model
+
+from .mock_airtable import get_mock_airtable
 
 
 class TestImportClass(TestCase):
     fixtures = ['test.json']
 
     def setUp(self):
-        self.options = {
-            'verbosity': 2
-        }
+        airtable_patcher = patch("wagtail_airtable.importer.Airtable", new_callable=get_mock_airtable())
+        self.mock_airtable = airtable_patcher.start()
+        self.addCleanup(airtable_patcher.stop)
 
     def get_valid_record_fields(self):
         """Common used method for standard valid airtable records."""
@@ -38,149 +43,41 @@ def get_valid_mapped_fields(self):
             "slug": "slug",
         }
 
-    def get_invalid_record_fields(self):
-        """Common used method for standard invalid airtable records."""
-        return {
-            "SEO Description": "Red is a scientifically proven...",
-            "External Link": "https://example.com/",
-            "slug": "red-its-new-blue",
-        }
-
-    def get_invalid_mapped_fields(self):
-        """Common used method for standard invalid airtable mapped fields."""
-        return {
-            "SEO Description": "description",
-            "External Link": "external_link",
-            "slug": "slug",
-        }
-
-    def test_debug_message(self):
-        models = ["fake.ModelName"]
-        text = "Testing debug message with high verbosity"
-
-        importer = Importer(models=models, options=self.options)
-        debug_message = importer.debug_message(text)
-        self.assertEqual(debug_message, text)
-
-        # Lower verbosity
-        importer = Importer(models=models, options={'verbosity': 1})
-        debug_message = importer.debug_message(text)
-        self.assertEqual(debug_message, None)
-
     def test_get_model_serializer(self):
-        importer = Importer(models=["tests.Advert"])
-        advert_serializer = importer.get_model_serializer("tests.serializers.AdvertSerializer")
-        self.assertEqual(advert_serializer, AdvertSerializer)
-
-    def test_get_incorrect_model_serializer(self):
-        importer = Importer(models=["tests.Advert"])
-        with self.assertRaises(AttributeError) as context:
-            importer.get_model_serializer("tests.serializers.MissingSerializer")
-        self.assertEqual("module 'tests.serializers' has no attribute 'MissingSerializer'", str(context.exception))
+        self.assertEqual(AirtableModelImporter(model=Advert).model_serializer, AdvertSerializer)
 
     def test_get_model_settings(self):
-        importer = Importer()
         # Finds config settings
-        advert_settings = importer.get_model_settings(Advert)
-        self.assertEqual(type(advert_settings), dict)
-        self.assertDictEqual(settings.AIRTABLE_IMPORT_SETTINGS['tests.Advert'], advert_settings)
+        self.assertDictEqual(AirtableModelImporter(model=Advert).model_settings, settings.AIRTABLE_IMPORT_SETTINGS['tests.Advert'])
 
         # Does not find config settings
-        unused_model_settings = importer.get_model_settings(ModelNotUsed)
-        self.assertEqual(type(unused_model_settings), dict)
-        self.assertDictEqual(unused_model_settings, {})
+        with self.assertRaises(KeyError):
+            AirtableModelImporter(model=ModelNotUsed)
 
         # Finds adjacent model settings
-        advert_settings = importer.get_model_settings(SimilarToAdvert)
-        self.assertEqual(type(advert_settings), dict)
-        self.assertDictEqual(settings.AIRTABLE_IMPORT_SETTINGS['tests.Advert'], advert_settings)
+        self.assertDictEqual(AirtableModelImporter(model=SimilarToAdvert).model_settings, settings.AIRTABLE_IMPORT_SETTINGS['tests.Advert'])
 
     def test_get_column_to_field_names(self):
-        importer = Importer()
         # The airtable column is the same name as the django field name
         # ie: slug and slug
-        column, field = importer.get_column_to_field_names('slug')
+        column, field = get_column_to_field_names('slug')
         self.assertEqual(column, 'slug')
         self.assertEqual(field, 'slug')
         # The airtable column is different from the django field name
         # ie: "Page Title" and "title"
-        column, field = importer.get_column_to_field_names({"Page Title": "title"})
+        column, field = get_column_to_field_names({"Page Title": "title"})
         self.assertEqual(column, 'Page Title')
         self.assertEqual(field, 'title')
         # Different settings were specified and arent currently handled
         # Returns empty values
-        column, field = importer.get_column_to_field_names(None)
+        column, field = get_column_to_field_names(None)
         self.assertEqual(column, None)
         self.assertEqual(field, None)
 
-    def test_get_or_set_cached_records(self):
-        importer = Importer()
-        self.assertEqual(importer.cached_records, {})
-
-        # Make one API call. Update the cached_records.
-        client1 = MockAirtable()
-        all_records = importer.get_or_set_cached_records(client1)
-        client1.get_all.assert_called()
-        self.assertEqual(len(all_records), 4)
-
-        cached_records = {
-            client1.table_name: all_records
-        }
-        self.assertEqual(importer.cached_records, cached_records)
-        self.assertEqual(len(importer.cached_records), 1)
-
-        # Second API call is the same. Use the pre-existing cached records.
-        client2 = MockAirtable()
-        all_records = importer.get_or_set_cached_records(client2)
-        self.assertEqual(importer.cached_records, cached_records)
-        self.assertEqual(len(importer.cached_records), 1)
-
-        # Third API call will create a new cached record in the cached_records dict
-        client3 = MockAirtable()
-        client3.table_name = "second_cached_entry"
-        all_records = importer.get_or_set_cached_records(client3)
-        self.assertNotEqual(importer.cached_records, cached_records)
-        self.assertEqual(len(importer.cached_records), 2)
-        # Ensure the internal dictionary "cache" has been updated
-        cached_records["second_cached_entry"] = all_records
-        self.assertEqual(importer.cached_records, cached_records)
-
-    def test_check_field_is_m2m(self):
-        importer = Importer()
-
-        client = MockAirtable()
-        records = client.get_all()
-        client.get_all.assert_called()
-        for i, record in enumerate(records):
-            for field_name, value, m2m in importer.get_fields_and_m2m_status(Advert, record["fields"]):
-                if field_name == "publications":
-                    self.assertEqual(m2m, True)
-                else:
-                    self.assertEqual(m2m, False)
-
-    def test_update_m2m_fields(self):
-        importer = Importer()
-
-        client = MockAirtable()
-        records = client.get_all()
-        client.get_all.assert_called()
-        advert = Advert.objects.first()
-        self.assertEqual(len(advert.publications.all()), 0)
-
-        advert_serializer = AdvertSerializer(data=records[0]["fields"])
-        self.assertEqual(advert_serializer.is_valid(), True)
-
-        publications_dict = advert_serializer.validated_data["publications"]
-
-        importer.update_model_m2m_fields(advert, "publications", publications_dict)
-
-        self.assertEqual(len(advert.publications.all()), 3)
-
     def test_convert_mapped_fields(self):
-        importer = Importer()
         record_fields_dict = self.get_valid_record_fields()
         record_fields_dict['extra_field_from_airtable'] = "Not mapped"
-        mapped_fields = importer.convert_mapped_fields(
+        mapped_fields = convert_mapped_fields(
             record_fields_dict,
             self.get_valid_mapped_fields(),
         )
@@ -199,211 +96,251 @@ def test_convert_mapped_fields(self):
         self.assertFalse(hasattr(mapped_fields, 'extra_field_from_airtable'))
 
     def test_update_object(self):
-        importer = Importer(models=["tests.Advert"])
-        advert_serializer = importer.get_model_serializer("tests.serializers.AdvertSerializer")
-        record_fields_dict = self.get_valid_record_fields()
-        record_fields_dict["SEO Description"] = "Red is a scientifically proven..."
-        mapped_fields = importer.convert_mapped_fields(
-            record_fields_dict,
-            self.get_valid_mapped_fields(),
-        )
-        # Ensure mapped_fields are mapped properly
-        self.assertEqual(
-            mapped_fields['description'],
-            "Red is a scientifically proven...",
-        )
-        # Check serialized data is valid
-        serialized_data = advert_serializer(data=mapped_fields)
-        is_valid = serialized_data.is_valid()
-        self.assertTrue(is_valid)
-        # Get the advert object.
-        instance = Advert.objects.first()
-        self.assertEqual(instance.airtable_record_id, '')
-        # Importer should have zero updates objects.
-        self.assertEqual(importer.updated, 0)
-
-        saved = importer.update_object(instance, 'recNewRecordId', serialized_data)
-
-        self.assertTrue(saved)
-        # Check to make sure _skip_signals is set.
-        self.assertTrue(instance._skip_signals)
-        self.assertEqual(importer.updated, 1)
-        self.assertEqual(importer.records_used, ['recNewRecordId'])
-        # Re-fetch the Advert instance and check its airtable_record_id
-        instance = Advert.objects.first()
-        self.assertEqual(instance.airtable_record_id, 'recNewRecordId')
+        importer = AirtableModelImporter(model=Advert)
+        advert = Advert.objects.get(airtable_record_id="recNewRecordId")
+        self.assertNotEqual(advert.title, "Red! It's the new blue!")
+        self.assertEqual(len(advert.publications.all()), 0)
+
+        hook_fn = MagicMock()
+        with hooks.register_temporarily("airtable_import_record_updated", hook_fn):
+            updated_result = next(result for result in importer.run() if not result.new)
+
+        self.assertEqual(updated_result.record_id, advert.airtable_record_id)
+        self.assertIsNone(updated_result.errors)
+        hook_fn.assert_called_once_with(instance=advert, is_wagtail_page=False, record_id="recNewRecordId")
+
+        advert.refresh_from_db()
+        self.assertEqual(advert.title, "Red! It's the new blue!")
+        self.assertEqual(updated_result.record_id, "recNewRecordId")
+        self.assertEqual(len(advert.publications.all()), 3)
+
+    @patch('wagtail_airtable.mixins.Airtable')
+    def test_create_object(self, mixin_airtable):
+        importer = AirtableModelImporter(model=Advert)
+        self.assertFalse(Advert.objects.filter(slug="test-created").exists())
+        self.assertFalse(Advert.objects.filter(airtable_record_id="test-created-id").exists())
+        self.mock_airtable.get_all.return_value = [{
+            "id": "test-created-id",
+            "fields": {
+                "title": "The created one",
+                "description": "This one, we created.",
+                "external_link": "https://example.com/",
+                "is_active": True,
+                "rating": "1.5",
+                "long_description": "<p>Long description is long.</p>",
+                "points": 95,
+                "slug": "test-created",
+                "publications": [
+                    {"title": "Created record publication 1"},
+                    {"title": "Created record publication 2"},
+                    {"title": "Created record publication 3"},
+                ],
+            },
+        }]
+
+        hook_fn = MagicMock()
+        with hooks.register_temporarily("airtable_import_record_updated", hook_fn):
+            created_result = next(importer.run())
+
+        self.assertTrue(created_result.new)
+        self.assertIsNone(created_result.errors)
+
+        advert = Advert.objects.get(airtable_record_id=created_result.record_id)
+        hook_fn.assert_called_once_with(instance=advert, is_wagtail_page=False, record_id="test-created-id")
+        self.assertEqual(advert.title, "The created one")
+        self.assertEqual(advert.slug, "test-created")
+        self.assertEqual(len(advert.publications.all()), 3)
 
     def test_update_object_with_invalid_serialized_data(self):
-        instance = Advert.objects.first()
-        importer = Importer(models=["tests.Advert"])
-        advert_serializer = importer.get_model_serializer("tests.serializers.AdvertSerializer")
-        record_fields_dict = {
+        advert = Advert.objects.get(airtable_record_id="recNewRecordId")
+        importer = AirtableModelImporter(model=Advert)
+        self.assertNotEqual(advert.description, "Red is a scientifically proven..")
+        self.mock_airtable.get_all()[0]['fields'] = {
             "SEO Description": "Red is a scientifically proven...",
             "External Link": "https://example.com/",
             "slug": "red-its-new-blue",
             "Rating": "2.5",
         }
-        mapped_fields_dict = {
-            "SEO Description": "description",
-            "External Link": "external_link",
-            "slug": "slug",
-            "Rating": "rating",
-        }
-        mapped_fields = importer.convert_mapped_fields(
-            record_fields_dict,
-            mapped_fields_dict,
-        )
-        serialized_data = advert_serializer(data=mapped_fields)
-        is_valid = serialized_data.is_valid()
-        self.assertFalse(is_valid)
-        saved = importer.update_object(instance, 'recNewRecordId', serialized_data)
-        self.assertTrue(saved)
-
-    def test_update_object_by_uniq_col_name_missing_uniq_id(self):
-        importer = Importer()
-        updated = importer.update_object_by_uniq_col_name(
-            field_mapping={'slug': ''},
-            model=Advert,
-            serialized_data=object,
-            record_id='',
-        )
+        result = next(importer.run())
+        self.assertEqual(result.errors, {'title': ['This field is required.']})
+        advert.refresh_from_db()
+        self.assertNotEqual(advert.description, "Red is a scientifically proven..")
 
-        self.assertFalse(updated)
-        self.assertEqual(importer.skipped, 1)
+    def test_get_existing_instance(self):
+        importer = AirtableModelImporter(model=Advert)
+        advert = Advert.objects.get(airtable_record_id="recNewRecordId")
 
-    def test_update_object_by_uniq_col_name_object_found(self):
-        importer = Importer(models=["tests.Advert"])
-        advert_serializer = importer.get_model_serializer("tests.serializers.AdvertSerializer")
-        record_fields_dict = {
-            "Page Title": "Red! It's the new blue!",
-            "SEO Description": "Red is a scientifically proven...",
-            "External Link": "https://example.com/UPDATED",
-            "Is Active": True,
-            "rating": "1.5",
-            "long_description": "<p>Lorem ipsum dolor sit amet, consectetur adipisicing elit. Veniam laboriosam consequatur saepe. Repellat itaque dolores neque, impedit reprehenderit eum culpa voluptates harum sapiente nesciunt ratione.</p>",
-            "points": 95,
-            "slug": "red-its-new-blue",
-        }
-        mapped_fields_dict = {
-            "Page Title": "title",
-            "SEO Description": "description",
-            "External Link": "external_link",
-            "Is Active": "is_active",
-            "slug": "slug",
-        }
-        mapped_fields = importer.convert_mapped_fields(
-            record_fields_dict,
-            mapped_fields_dict,
-        )
-        # Check serialized data is valid
-        serialized_data = advert_serializer(data=mapped_fields)
-        self.assertTrue(serialized_data.is_valid())
-
-        # with self.assertRaises(AttributeError)
-        updated = importer.update_object_by_uniq_col_name(
-            field_mapping={'slug': 'red-its-new-blue'},
-            model=Advert,
-            serialized_data=serialized_data,
-            record_id='recNewRecordId',
-        )
-        self.assertTrue(updated)
-        self.assertEqual(importer.skipped, 0)
-        self.assertEqual(importer.updated, 1)
-
-        advert = Advert.objects.get(slug="red-its-new-blue")
-        self.assertEqual(advert.external_link, "https://example.com/UPDATED")
+        self.assertEqual(importer.get_existing_instance("recNewRecordId", None), advert)
 
-    def test_update_object_by_uniq_col_name_object_not_found(self):
-        importer = Importer(models=["tests.Advert"])
-        advert_serializer = importer.get_model_serializer("tests.serializers.AdvertSerializer")
-        record_fields_dict = {
-            "Page Title": "Red! It's the new blue!",
-            "SEO Description": "Red is a scientifically proven...",
-            "External Link": "https://example.com/",
-            "Is Active": True,
-            "rating": "1.5",
-            "long_description": "<p>Lorem ipsum dolor sit amet, consectetur adipisicing elit. Veniam laboriosam consequatur saepe. Repellat itaque dolores neque, impedit reprehenderit eum culpa voluptates harum sapiente nesciunt ratione.</p>",
-            "points": 95,
-            "slug": "red-its-new-blue",
-        }
-        mapped_fields_dict = {
-            "Page Title": "title",
-            "SEO Description": "description",
-            "External Link": "external_link",
-            "Is Active": "is_active",
-            "slug": "slug",
-        }
-        mapped_fields = importer.convert_mapped_fields(
-            record_fields_dict,
-            mapped_fields_dict,
-        )
-        # Check serialized data is valid
-        serialized_data = advert_serializer(data=mapped_fields)
-        self.assertTrue(serialized_data.is_valid())
-
-        # with self.assertRaises(AttributeError)
-        updated = importer.update_object_by_uniq_col_name(
-            field_mapping={'slug': 'MISSING-OBJECT'},
-            model=Advert,
-            serialized_data=serialized_data,
-            record_id='recNewRecordId',
-        )
-        self.assertFalse(updated)
-        self.assertEqual(importer.updated, 0)
+        self.assertEqual(importer.airtable_unique_identifier_field_name, "slug")
+        self.assertEqual(importer.get_existing_instance("nothing", advert.slug), advert)
 
     def test_is_wagtail_page(self):
-        importer = Importer()
-        self.assertTrue(importer.is_wagtail_page(SimplePage))
-        self.assertFalse(importer.is_wagtail_page(Advert))
-
-    def test_get_data_for_new_model_with_valid_serialized_data(self):
-        importer = Importer(models=["tests.Advert"])
-        advert_serializer = importer.get_model_serializer("tests.serializers.AdvertSerializer")
-        mapped_fields = importer.convert_mapped_fields(
+        self.assertTrue(AirtableModelImporter(SimplePage).model_is_page)
+        self.assertFalse(AirtableModelImporter(Advert).model_is_page)
+
+    def test_get_data_for_new_model(self):
+        mapped_fields = convert_mapped_fields(
             self.get_valid_record_fields(),
             self.get_valid_mapped_fields(),
         )
-        # Check serialized data is valid
-        serialized_data = advert_serializer(data=mapped_fields)
-        is_valid = serialized_data.is_valid()
-        self.assertTrue(is_valid)
 
-        data_for_new_model = importer.get_data_for_new_model(serialized_data, mapped_fields, 'recSomeRecordId')
+        data_for_new_model = get_data_for_new_model(mapped_fields, 'recSomeRecordId')
         self.assertTrue(data_for_new_model.get('airtable_record_id'))
         self.assertEqual(data_for_new_model['airtable_record_id'], 'recSomeRecordId')
         self.assertIsNone(data_for_new_model.get('id'))
         self.assertIsNone(data_for_new_model.get('pk'))
 
-        new_dict = dict(serialized_data.validated_data)
-        new_dict['airtable_record_id'] = 'recSomeRecordId'
-        self.assertDictEqual(new_dict, data_for_new_model)
-
-    def test_get_data_for_new_model_with_invalid_serialized_data(self):
-        importer = Importer(models=["tests.Advert"])
-        advert_serializer = importer.get_model_serializer("tests.serializers.AdvertSerializer")
-
-        mapped_fields = importer.convert_mapped_fields(
-            self.get_invalid_record_fields(),
-            self.get_invalid_mapped_fields(),
+    @patch('wagtail_airtable.mixins.Airtable')
+    def test_create_page(self, mixin_airtable):
+        importer = AirtableModelImporter(model=SimplePage)
+        self.assertEqual(Page.objects.get(slug="home").get_children().count(), 0)
+
+        self.mock_airtable.get_all.return_value = [{
+            "id": "test-created-page-id",
+            "fields": {
+                "title": "A simple page",
+                "slug": "a-simple-page",
+                "intro": "How much more simple can it get? And the answer is none. None more simple.",
+            },
+        }]
+        hook_fn = MagicMock()
+        with hooks.register_temporarily("airtable_import_record_updated", hook_fn):
+            created_result = next(importer.run())
+        self.assertTrue(created_result.new)
+        self.assertIsNone(created_result.errors)
+
+        page = Page.objects.get(slug="home").get_children().first().specific
+        self.assertIsInstance(page, SimplePage)
+        hook_fn.assert_called_once_with(instance=page, is_wagtail_page=True, record_id="test-created-page-id")
+        self.assertEqual(page.title, "A simple page")
+        self.assertEqual(page.slug, "a-simple-page")
+        self.assertEqual(page.intro, "How much more simple can it get? And the answer is none. None more simple.")
+        self.assertFalse(page.live)
+
+    @patch('wagtail_airtable.mixins.Airtable')
+    def test_create_and_publish_page(self, mixin_airtable):
+        new_settings = copy.deepcopy(settings.AIRTABLE_IMPORT_SETTINGS)
+        new_settings['tests.SimplePage']['AUTO_PUBLISH_NEW_PAGES'] = True
+        with override_settings(AIRTABLE_IMPORT_SETTINGS=new_settings):
+            importer = AirtableModelImporter(model=SimplePage)
+            self.assertEqual(Page.objects.get(slug="home").get_children().count(), 0)
+
+            self.mock_airtable.get_all.return_value = [{
+                "id": "test-created-page-id",
+                "fields": {
+                    "title": "A simple page",
+                    "slug": "a-simple-page",
+                    "intro": "How much more simple can it get? And the answer is none. None more simple.",
+                },
+            }]
+            hook_fn = MagicMock()
+            with hooks.register_temporarily("airtable_import_record_updated", hook_fn):
+                created_result = next(importer.run())
+            self.assertTrue(created_result.new)
+            self.assertIsNone(created_result.errors)
+
+            page = Page.objects.get(slug="home").get_children().first().specific
+            self.assertIsInstance(page, SimplePage)
+            hook_fn.assert_called_once_with(instance=page, is_wagtail_page=True, record_id="test-created-page-id")
+            self.assertEqual(page.title, "A simple page")
+            self.assertEqual(page.slug, "a-simple-page")
+            self.assertEqual(page.intro, "How much more simple can it get? And the answer is none. None more simple.")
+            self.assertTrue(page.live)
+
+    @patch('wagtail_airtable.mixins.Airtable')
+    def test_update_page(self, mixin_airtable):
+        importer = AirtableModelImporter(model=SimplePage)
+        parent_page = Page.objects.get(slug="home")
+        page = SimplePage(
+            title="A simple page",
+            slug="a-simple-page",
+            intro="How much more simple can it get? And the answer is none. None more simple.",
         )
-        # Check serialized data is valid
-        serialized_data = advert_serializer(data=mapped_fields)
-        is_valid = serialized_data.is_valid()
-        self.assertFalse(is_valid)
-
-        data_for_new_model = importer.get_data_for_new_model(serialized_data, mapped_fields, 'recSomeRecordId')
-        self.assertTrue(data_for_new_model.get('airtable_record_id'))
-        self.assertEqual(data_for_new_model['airtable_record_id'], 'recSomeRecordId')
-        self.assertIsNone(data_for_new_model.get('id'))
-        self.assertIsNone(data_for_new_model.get('pk'))
-
-        new_dict = mapped_fields.copy()
-        new_dict['airtable_record_id'] = 'recSomeRecordId'
-        self.assertDictEqual(data_for_new_model, {'airtable_record_id': 'recSomeRecordId'})
-
-
-class TestImportCommand(TransactionTestCase):
-
-    def test_import_command(self):
-        from django.core.management import call_command
-        call_command("import_airtable", "tests.Advert", verbosity=1)
+        page.push_to_airtable = False
+        parent_page.add_child(instance=page)
+        self.assertEqual(page.revisions.count(), 0)
+
+        self.mock_airtable.get_all.return_value = [{
+            "id": "test-created-page-id",
+            "fields": {
+                "title": "A simple page",
+                "slug": "a-simple-page",
+                "intro": "How much more simple can it get? Oh, actually it can get more simple.",
+            },
+        }]
+        hook_fn = MagicMock()
+        with hooks.register_temporarily("airtable_import_record_updated", hook_fn):
+            updated_result = next(importer.run())
+        self.assertFalse(updated_result.new)
+        self.assertIsNone(updated_result.errors)
+
+        page.refresh_from_db()
+        hook_fn.assert_called_once_with(instance=page, is_wagtail_page=True, record_id="test-created-page-id")
+        self.assertEqual(page.title, "A simple page")
+        self.assertEqual(page.slug, "a-simple-page")
+        self.assertEqual(page.intro, "How much more simple can it get? Oh, actually it can get more simple.")
+        self.assertEqual(page.revisions.count(), 1)
+
+    @patch('wagtail_airtable.mixins.Airtable')
+    def test_skip_update_page_if_unchanged(self, mixin_airtable):
+        importer = AirtableModelImporter(model=SimplePage)
+        parent_page = Page.objects.get(slug="home")
+        page = SimplePage(
+            title="A simple page",
+            slug="a-simple-page",
+            intro="How much more simple can it get? And the answer is none. None more simple.",
+        )
+        page.push_to_airtable = False
+        parent_page.add_child(instance=page)
+        self.assertEqual(page.revisions.count(), 0)
+
+        self.mock_airtable.get_all.return_value = [{
+            "id": "test-created-page-id",
+            "fields": {
+                "title": "A simple page",
+                "slug": "a-simple-page",
+                "intro": "How much more simple can it get? And the answer is none. None more simple.",
+            },
+        }]
+        hook_fn = MagicMock()
+        with hooks.register_temporarily("airtable_import_record_updated", hook_fn):
+            updated_result = next(importer.run())
+        self.assertFalse(updated_result.new)
+        self.assertIsNone(updated_result.errors)
+        hook_fn.assert_not_called()
+
+        self.assertEqual(page.revisions.count(), 0)
+
+    @patch('wagtail_airtable.mixins.Airtable')
+    def test_skip_update_page_if_locked(self, mixin_airtable):
+        importer = AirtableModelImporter(model=SimplePage)
+        parent_page = Page.objects.get(slug="home")
+        page = SimplePage(
+            title="A simple page",
+            slug="a-simple-page",
+            intro="How much more simple can it get? And the answer is none. None more simple.",
+        )
+        page.push_to_airtable = False
+        page.locked = True
+        parent_page.add_child(instance=page)
+        self.assertEqual(page.revisions.count(), 0)
+
+        self.mock_airtable.get_all.return_value = [{
+            "id": "test-created-page-id",
+            "fields": {
+                "title": "A simple page",
+                "slug": "a-simple-page",
+                "intro": "How much more simple can it get? Oh, actually it can get more simple.",
+            },
+        }]
+        hook_fn = MagicMock()
+        with hooks.register_temporarily("airtable_import_record_updated", hook_fn):
+            updated_result = next(importer.run())
+        self.assertFalse(updated_result.new)
+        self.assertIsNone(updated_result.errors)
+        hook_fn.assert_not_called()
+
+        self.assertEqual(page.revisions.count(), 0)
+        page.refresh_from_db()
+        self.assertEqual(page.intro, "How much more simple can it get? And the answer is none. None more simple.")
diff --git a/tests/test_models.py b/tests/test_models.py
index 4d38792..654e72c 100644
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -4,12 +4,18 @@
 
 from tests.models import Advert
 from wagtail_airtable.mixins import AirtableMixin
+from unittest.mock import patch
+from .mock_airtable import get_mock_airtable
 
 
 class TestAirtableModel(TestCase):
     fixtures = ['test.json']
 
     def setUp(self):
+        airtable_patcher = patch("wagtail_airtable.mixins.Airtable", new_callable=get_mock_airtable())
+        airtable_patcher.start()
+        self.addCleanup(airtable_patcher.stop)
+
         self.client.login(username='admin', password='password')
         self.advert = Advert.objects.first()
 
@@ -104,6 +110,10 @@ class TestAirtableMixin(TestCase):
     fixtures = ['test.json']
 
     def setUp(self):
+        airtable_patcher = patch("wagtail_airtable.mixins.Airtable", new_callable=get_mock_airtable())
+        self.mock_airtable = airtable_patcher.start()
+        self.addCleanup(airtable_patcher.stop)
+
         self.advert = Advert.objects.first()
 
     def test_setup_airtable(self):
diff --git a/tests/test_utils.py b/tests/test_utils.py
index d1c8c1d..236d05e 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -4,12 +4,10 @@
 from django.core.exceptions import ImproperlyConfigured
 from django.test import TestCase, override_settings
 
-from tests.models import Advert, SimilarToAdvert, SimplePage
-from wagtail_airtable.management.commands.import_airtable import Importer
+from tests.models import Advert, Publication, SimilarToAdvert, SimplePage
 from wagtail_airtable.utils import (airtable_message,
                                     can_send_airtable_messages, get_all_models,
-                                    get_model_for_path, get_validated_models,
-                                    import_models)
+                                    get_model_for_path, get_validated_models)
 from wagtail_airtable.mixins import AirtableMixin
 
 
@@ -57,7 +55,7 @@ def test_can_send_airtable_messages(self):
         self.assertTrue(enabled)
 
     def test_cannot_send_airtable_messages(self):
-        instance = SimplePage.objects.first()
+        instance = Publication.objects.create(title="Moby Dick")
         enabled = can_send_airtable_messages(instance)
         self.assertFalse(enabled)
 
@@ -81,31 +79,6 @@ def test_airtable_messages(self):
         self.assertIn('Second custom message here', message2)
         self.assertIn('2nd custom button text', message2)
 
-    @patch.object(Importer, '__init__')
-    def test_import_models_without_arguments(self, mock):
-        try:
-            import_models()
-        except:
-            # Do nothing here for now, just fail silently
-            pass
-
-        self.assertTrue(mock.called)
-        # Ensure that the constructor with all models.
-        mock.assert_called_with(models=get_all_models(as_path=True), options={"verbosity": 1})
-
-    @patch.object(Importer, '__init__')
-    def test_import_models_with_arguments(self, mock):
-        mock.return_value = None
-        try:
-            import_models(models=[Advert], verbosity=2)
-        except:
-            # Do nothing here for now, just fail silently
-            pass
-
-        self.assertTrue(mock.called)
-        # Ensure that the constructor with specified arguments
-        mock.assert_called_with(models=["tests.advert"], options={"verbosity": 2})
-
     @patch.object(AirtableMixin, 'save_to_airtable')
     def test_save_airtable_call(self, mock):
         instance = Advert.objects.last()
@@ -122,4 +95,3 @@ def test_save_airtable_not_called(self, mock):
         instance.save()
 
         self.assertFalse(mock.called)
-
diff --git a/tests/test_views.py b/tests/test_views.py
index 5253894..b394e83 100644
--- a/tests/test_views.py
+++ b/tests/test_views.py
@@ -3,12 +3,21 @@
 from django.urls import reverse
 
 from tests.models import Advert
+from unittest.mock import patch
+from .mock_airtable import get_mock_airtable
 
 
 class TestAdminViews(TestCase):
     fixtures = ['test.json']
 
     def setUp(self):
+        airtable_mixins_patcher = patch("wagtail_airtable.mixins.Airtable", new_callable=get_mock_airtable())
+        airtable_mixins_patcher.start()
+        self.addCleanup(airtable_mixins_patcher.stop)
+        airtable_importer_patcher = patch("wagtail_airtable.importer.Airtable", new_callable=get_mock_airtable())
+        self.mock_airtable = airtable_importer_patcher.start()
+        self.addCleanup(airtable_importer_patcher.stop)
+
         self.client.login(username='admin', password='password')
 
     def test_get(self):
@@ -18,6 +27,18 @@ def test_get(self):
         self.assertContains(response, 'Advert')
         self.assertNotContains(response, 'Simple Page')
 
+    def test_post(self):
+        advert = Advert.objects.get(airtable_record_id="recNewRecordId")
+        self.assertNotEqual(advert.title, "Red! It's the new blue!")
+
+        response = self.client.post(reverse('airtable_import_listing'), {
+            'model': 'tests.Advert',
+        })
+        self.assertRedirects(response, reverse('airtable_import_listing'))
+
+        advert.refresh_from_db()
+        self.assertEqual(advert.title, "Red! It's the new blue!")
+
     def test_list_snippets(self):
         url = reverse('wagtailsnippets_tests_advert:list')
 
diff --git a/wagtail_airtable/tests.py b/wagtail_airtable/tests.py
deleted file mode 100644
index ce7e5f7..0000000
--- a/wagtail_airtable/tests.py
+++ /dev/null
@@ -1,154 +0,0 @@
-"""A mocked Airtable API wrapper."""
-from unittest import mock
-
-
-class MockAirtable(mock.Mock):
-    pass
-
-
-MockAirtable.table_name = "app_airtable_advert_base_key"
-
-MockAirtable.get = mock.MagicMock("get")
-MockAirtable.get.return_value = {
-    "id": "recNewRecordId",
-    "fields": {
-        "title": "Red! It's the new blue!",
-        "description": "Red is a scientifically proven color that moves faster than all other colors.",
-        "external_link": "https://example.com/",
-        "is_active": True,
-        "rating": "1.5",
-        "long_description": "<p>Lorem ipsum dolor sit amet, consectetur adipisicing elit. Veniam laboriosam consequatur saepe. Repellat itaque dolores neque, impedit reprehenderit eum culpa voluptates harum sapiente nesciunt ratione.</p>",
-        "points": 95,
-        "slug": "red-its-new-blue",
-    },
-}
-
-MockAirtable.insert = mock.MagicMock("insert")
-MockAirtable.insert.return_value = {
-    "id": "recNewRecordId",
-    "fields": {
-        "title": "Red! It's the new blue!",
-        "description": "Red is a scientifically proven color that moves faster than all other colors.",
-        "external_link": "https://example.com/",
-        "is_active": True,
-        "rating": "1.5",
-        "long_description": "<p>Lorem ipsum dolor sit amet, consectetur adipisicing elit. Veniam laboriosam consequatur saepe. Repellat itaque dolores neque, impedit reprehenderit eum culpa voluptates harum sapiente nesciunt ratione.</p>",
-        "points": 95,
-        "slug": "red-its-new-blue",
-    },
-}
-
-MockAirtable.update = mock.MagicMock("update")
-MockAirtable.update.return_value = {
-    "id": "recNewRecordId",
-    "fields": {
-        "title": "Red! It's the new blue!",
-        "description": "Red is a scientifically proven color that moves faster than all other colors.",
-        "external_link": "https://example.com/",
-        "is_active": True,
-        "rating": "1.5",
-        "long_description": "<p>Lorem ipsum dolor sit amet, consectetur adipisicing elit. Veniam laboriosam consequatur saepe. Repellat itaque dolores neque, impedit reprehenderit eum culpa voluptates harum sapiente nesciunt ratione.</p>",
-        "points": 95,
-        "slug": "red-its-new-blue",
-    },
-}
-
-MockAirtable.delete = mock.MagicMock("delete")
-MockAirtable.delete.return_value = {"deleted": True, "record": "recNewRecordId"}
-
-MockAirtable.search = mock.MagicMock("search")
-MockAirtable.search.return_value = [
-    {
-        "id": "recNewRecordId",
-        "fields": {
-            "title": "Red! It's the new blue!",
-            "description": "Red is a scientifically proven color that moves faster than all other colors.",
-            "external_link": "https://example.com/",
-            "is_active": True,
-            "rating": "1.5",
-            "long_description": "<p>Lorem ipsum dolor sit amet, consectetur adipisicing elit. Veniam laboriosam consequatur saepe. Repellat itaque dolores neque, impedit reprehenderit eum culpa voluptates harum sapiente nesciunt ratione.</p>",
-            "points": 95,
-            "slug": "red-its-new-blue",
-        },
-    },
-    {
-        "id": "Different record",
-        "fields": {
-            "title": "Not the used record.",
-            "description": "This is only used for multiple responses from MockAirtable",
-            "external_link": "https://example.com/",
-            "is_active": False,
-            "rating": "5.5",
-            "long_description": "",
-            "points": 1,
-            "slug": "not-the-used-record",
-        },
-    },
-]
-
-MockAirtable.get_all = mock.MagicMock("get_all")
-MockAirtable.get_all.return_value = [
-    {
-        "id": "recNewRecordId",
-        "fields": {
-            "title": "Red! It's the new blue!",
-            "description": "Red is a scientifically proven color that moves faster than all other colors.",
-            "external_link": "https://example.com/",
-            "is_active": True,
-            "rating": "1.5",
-            "long_description": "<p>Lorem ipsum dolor sit amet, consectetur adipisicing elit. Veniam laboriosam consequatur saepe. Repellat itaque dolores neque, impedit reprehenderit eum culpa voluptates harum sapiente nesciunt ratione.</p>",
-            "points": 95,
-            "slug": "red-its-new-blue",
-            "publications": [
-                {"title": "Record 1 publication 1"},
-                {"title": "Record 1 publication 2"},
-                {"title": "Record 1 publication 3"},
-            ]
-        },
-    },
-    {
-        "id": "Different record",
-        "fields": {
-            "title": "Not the used record.",
-            "description": "This is only used for multiple responses from MockAirtable",
-            "external_link": "https://example.com/",
-            "is_active": False,
-            "rating": "5.5",
-            "long_description": "",
-            "points": 1,
-            "slug": "not-the-used-record",
-        },
-    },
-    {
-        "id": "recRecordThree",
-        "fields": {
-            "title": "A third record.",
-            "description": "This is only used for multiple responses from MockAirtable",
-            "external_link": "https://example.com/",
-            "is_active": False,
-            "rating": "5.5",
-            "long_description": "",
-            "points": 1,
-            "slug": "record-3",
-        },
-    },
-    {
-        "id": "recRecordFour",
-        "fields": {
-            "title": "A fourth record.",
-            "description": "This is only used for multiple responses from MockAirtable",
-            "external_link": "https://example.com/",
-            "is_active": False,
-            "rating": "5.5",
-            "long_description": "",
-            "points": 1,
-            "slug": "record-4",
-            "publications": [
-                {"title": "Record 4 publication 1"},
-                {"title": "Record 4 publication 2"},
-                {"title": "Record 4 publication 3"},
-            ]
-        },
-    },
-]
-