├── .github └── workflows │ ├── ci.yml │ └── publish.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── examples ├── README.md ├── model_example.py ├── multi_page_example.py ├── page_example.py ├── preview.gif ├── serializers.py └── settings.py ├── known-issues.md ├── pyproject.toml ├── runtests.py ├── tests ├── __init__.py ├── fixtures │ └── test.json ├── migrations │ ├── 0001_initial.py │ ├── 0002_modelnotused.py │ ├── 0003_similartoadvert.py │ ├── 0004_advert_publications.py │ ├── 0005_simplepage_airtable_record_id.py │ └── __init__.py ├── mock_airtable.py ├── models.py ├── serializers.py ├── settings.py ├── test_import.py ├── test_models.py ├── test_templatetags.py ├── test_utils.py ├── test_views.py └── urls.py ├── tox.ini └── wagtail_airtable ├── __init__.py ├── apps.py ├── forms.py ├── importer.py ├── management └── commands │ ├── import_airtable.py │ └── reset_local_airtable_records.py ├── migrations └── __init__.py ├── mixins.py ├── serializers.py ├── templates ├── wagtail_airtable │ ├── _import_button.html │ └── airtable_import_listing.html └── wagtailsnippets │ └── snippets │ └── index.html ├── templatetags └── wagtail_airtable_tags.py ├── utils.py ├── views.py └── wagtail_hooks.py /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | max-parallel: 4 10 | matrix: 11 | python: ["3.9", "3.10", "3.11", "3.12", "3.13"] 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | - name: Set up Python ${{ matrix.python }} 16 | uses: actions/setup-python@v4 17 | with: 18 | python-version: ${{ matrix.python }} 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | python -m pip install tox tox-gh-actions 23 | - name: Test with tox 24 | run: tox 25 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # See https://packaging.python.org/en/latest/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/ 2 | # for a detailed guide 3 | name: Publish to PyPI 4 | 5 | on: 6 | release: 7 | types: [published] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | permissions: 13 | contents: read # to fetch code (actions/checkout) 14 | steps: 15 | - uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 0 18 | 19 | - name: Set up Python 3.11 20 | uses: actions/setup-python@v4 21 | with: 22 | python-version: '3.11' 23 | cache: "pip" 24 | cache-dependency-path: "**/pyproject.toml" 25 | 26 | - name: ⬇️ Install build dependencies 27 | run: | 28 | python -m pip install -U flit 29 | 30 | - name: 🏗️ Build 31 | run: python -m flit build 32 | 33 | - uses: actions/upload-artifact@v3 34 | with: 35 | path: ./dist 36 | 37 | publish: 38 | needs: build 39 | runs-on: ubuntu-latest 40 | permissions: 41 | contents: none 42 | id-token: write # required for trusted publishing 43 | environment: 'publish' 44 | steps: 45 | - uses: actions/download-artifact@v3 46 | 47 | - name: 🚀 Publish package distributions to PyPI 48 | uses: pypa/gh-action-pypi-publish@release/v1 49 | with: 50 | packages-dir: artifact/ 51 | print-hash: true 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | *.pyc 3 | .DS_Store 4 | /.coverage 5 | /dist/ 6 | /build/ 7 | /MANIFEST 8 | /wagtail_airtable.egg-info/ 9 | /docs/_build/ 10 | /.tox/ 11 | /.venv 12 | /venv 13 | /node_modules/ 14 | npm-debug.log* 15 | *.idea/ 16 | /*.egg/ 17 | /.cache/ 18 | /.pytest_cache/ 19 | *.sqlite3 20 | __pycache__ 21 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.0.0 (16.12.2024) 4 | 5 | * **Breaking**: Callables passed as `PARENT_PAGE_ID` no longer accept an `instance` argument (Matt Westcott) 6 | * Upgrade to pyairtable 2.x (Matt Westcott) 7 | * Add support for Wagtail 6.1 - 6.3 (Matt Westcott) 8 | * Update testing to include Python 3.13, Django 5.1 (Matt Westcott) 9 | * Rewrite importer module for robustness and better performance (Jake Howard, Matt Westcott) 10 | * Drop support for Python 3.8 (Matt Westcott) 11 | 12 | ## 0.7.0 (06.02.2024) 13 | 14 | * Add support for Wagtail 5.2 (Josh Munn) 15 | * Add support for Wagtail 6.0 (Matt Westcott) 16 | * Update testing to include Python 3.12 (Josh Munn) 17 | * Improve logic for activating mock API when testing (Matt Westcott) 18 | * Use flit for packaging (Dan Braghis) 19 | * Drop support for Wagtail 5.1 and lower (Josh Munn) 20 | * Drop support for Python 3.7 (Josh Munn) 21 | 22 | ## 0.6.0 (13.04.2023) 23 | 24 | * Add ability to disable airtable sync on save (Brady Moe) 25 | 26 | ## 0.5.1 (21.02.2023) 27 | 28 | * Fix issue from excluding tests in the built package (Jacob Topp-Mugglestone) 29 | 30 | ## 0.5.0 (17.02.2023) 31 | 32 | * Update testing to include Python version 3.11 (Katherine Domingo) 33 | * Update testing to include Wagtail 4.2 (Katherine Domingo) 34 | * Update testing to use tox-gh-actions (Katherine Domingo) 35 | * Remove Wagtail < 4.1 support (Katherine Domingo) 36 | * Fix templates to work on Wagtail 4.2 (Josh Munn) 37 | 38 | ## 0.4.0 (01.02.2023) 39 | 40 | * Add support for Wagtail 4.0 (Katherine Domingo) 41 | * Fix to allow local development without Poetry (Brady Moe) 42 | * Remove Python 3.6 support (Brady Moe) 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2019, Torchbox 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Wagtail/Airtable 2 | 3 | An extension for Wagtail allowing content to be transferred between Airtable sheets and your Wagtail/Django models. 4 | 5 | Developed by [Torchbox](https://torchbox.com/) and sponsored by [The Motley Fool](https://www.fool.com/). 6 | 7 | ![Wagtail Airtable demo](examples/preview.gif) 8 | 9 | ### How it works 10 | 11 | When you setup a model to "map" to an Airtable sheet, every time you save the model it will attempt to update the row in Airtable. If a row is not found, it will create a new row in your Airtable. 12 | 13 | When you want to sync your Airtable data to your Wagtail website, you can go to `Settings -> Airtable Import`. You can then import entire tables into your Wagtail instance with the click of a button. If you see "_{Your Model}_ is not setup with the correct Airtable settings" you will need to double check your settings. By default the import page can be found at http://yourwebsite.com/admin/airtable-import/, or if you use a custom /admin/ url it'll be http://yourwebsite.com/{custom_admin_url}/airtable-import/. 14 | 15 | ##### Behind the scenes... 16 | This package will attempt to match a model object against row in Airtable using a `record_id`. If a model does not have a record_id value, it will look for a match using the `AIRTABLE_UNIQUE_IDENTIFIER` to try and match a unique value in the Airtable to the unique value in your model. Should that succeed your model object will be "paired" with the row in Airtable. But should the record-search fail, a new row in Airtable will be created when you save your model, or a new model object will attempt to be created when you import a model from Airtable. 17 | 18 | > **Note**: Object creation _can_ fail when importing from Airtable. This is expected behaviour as an Airtable might not have all the data a model requires. For instance, a Wagtail Page uses django-treebeard, with path as a required field. If the page model import settings do not include the path field, or a path column isn't present in Airtable, the page cannot be created. This same rule applies to other required fields on any Django model including other required fields on a Wagtail Page. 19 | 20 | ### Installation & Configuration 21 | 22 | * Install the package with `pip install wagtail-airtable` 23 | * Add `'wagtail_airtable'` to your project's `INSTALLED_APPS`. 24 | * On Wagtail 5.x, to enable the snippet-specific import button on the Snippet list view make sure `wagtail_airtable` is above `wagtail.snippets` in your `INSTALLED_APPS` 25 | * In your settings you will need to map Django models to Airtable settings. Every model you want to map to an Airtable sheet will need: 26 | * An `AIRTABLE_BASE_KEY`. You can obtain a personal access token at https://airtable.com/create/tokens - from "Create new token", select the scopes `data.records:read` and `data.records:write`, and under Access, select the base you want to work with. Copy the resulting token and paste it as the value of `AIRTABLE_BASE_KEY`. 27 | * An `AIRTABLE_TABLE_NAME` to determine which table to connect to. 28 | * An `AIRTABLE_UNIQUE_IDENTIFIER`. This can either be a string or a dictionary mapping the Airtable column name to your unique field in your model. 29 | * ie. `AIRTABLE_UNIQUE_IDENTIFIER: 'slug',` this will match the `slug` field on your model with the `slug` column name in Airtable. Use this option if your model field and your Airtable column name are identical. 30 | * ie. `AIRTABLE_UNIQUE_IDENTIFIER: {'Airtable Column Name': 'model_field_name'},` this will map the `Airtable Column Name` to a model field called `model_field_name`. Use this option if your Airtable column name and your model field name are different. 31 | * An `AIRTABLE_SERIALIZER` that takes a string path to your serializer. This helps map incoming data from Airtable to your model fields. Django Rest Framework is required for this. See the [examples/](examples/) directory for serializer examples. 32 | 33 | * Lastly make sure you enable wagtail-airtable with `WAGTAIL_AIRTABLE_ENABLED = True`. By default this is disabled so data in your Wagtail site and your Airtable sheets aren't accidentally overwritten. Data is hard to recover, this option helps prevent accidental data loss. 34 | 35 | ### Example Base Configuration 36 | 37 | Below is a base configuration or `ModelName` and `OtherModelName` (both are registered Wagtail snippets), along with `HomePage`. 38 | 39 | ```python 40 | # your settings.py 41 | AIRTABLE_API_KEY = 'yourSuperSecretKey' 42 | WAGTAIL_AIRTABLE_ENABLED = True 43 | AIRTABLE_IMPORT_SETTINGS = { 44 | 'appname.ModelName': { 45 | 'AIRTABLE_BASE_KEY': 'app3ds912jFam032S', 46 | 'AIRTABLE_TABLE_NAME': 'Your Airtable Table Name', 47 | 'AIRTABLE_UNIQUE_IDENTIFIER': 'slug', # Must match the Airtable Column name 48 | 'AIRTABLE_SERIALIZER': 'path.to.your.model.serializer.CustomModelSerializer' 49 | }, 50 | 'appname.OtherModelName': { 51 | 'AIRTABLE_BASE_KEY': 'app4ds902jFam035S', 52 | 'AIRTABLE_TABLE_NAME': 'Your Airtable Table Name', 53 | 'AIRTABLE_UNIQUE_IDENTIFIER': { 54 | 'Page Slug': 'slug', # 'Page Slug' column name in Airtable, 'slug' field name in Wagtail. 55 | }, 56 | 'AIRTABLE_SERIALIZER': 'path.to.your.model.serializer.OtherCustomModelSerializer' 57 | }, 58 | 'pages.HomePage': { 59 | 'AIRTABLE_BASE_KEY': 'app2ds123jP23035Z', 60 | 'AIRTABLE_TABLE_NAME': 'Wagtail Page Tracking Table', 61 | 'AIRTABLE_UNIQUE_IDENTIFIER': { 62 | 'Wagtail Page ID': 'pk', 63 | }, 64 | 'AIRTABLE_SERIALIZER': 'path.to.your.pages.serializer.PageSerializer', 65 | # Below are OPTIONAL settings. 66 | # By disabling `AIRTABLE_IMPORT_ALLOWED` you can prevent Airtable imports 67 | # Use cases may be: 68 | # - disabling page imports since they are difficult to setup and maintain, 69 | # - one-way sync to Airtable only (ie. when a model/Page is saved) 70 | # Default is True 71 | 'AIRTABLE_IMPORT_ALLOWED': True, 72 | # Add the AIRTABLE_BASE_URL setting if you would like to provide a nice link 73 | # to the Airtable Record after a snippet or Page has been saved. 74 | # To get this URL open your Airtable base on Airtable.com and paste the link. 75 | # The recordId will be automatically added so please don't add that 76 | # You can add the below setting. This is optional and disabled by default. 77 | 'AIRTABLE_BASE_URL': 'https://airtable.com/tblxXxXxXxXxXxXx/viwxXxXxXxXxXxXx', 78 | # The PARENT_PAGE_ID setting is used for creating new Airtable Pages. Every 79 | # Wagtail Page requires a "parent" page. This setting can either be: 80 | # 1. A callable (ie `my_function` without the parentheses)' 81 | # Example: 82 | # 'PARENT_PAGE_ID': custom_function, 83 | # 2. A path to a function. (ie. 'appname.directory.filename.my_function') 84 | # Example: 85 | # 'PARENT_PAGE_ID': 'path.to.function', 86 | # 3. A raw integer. 87 | # Example: 88 | # 'PARENT_PAGE_ID': 3, 89 | 90 | # If you choose option #1 (callable) or option #2 (path to a function), 91 | # the supplied function takes no arguments and returns the ID of the parent 92 | # page where all imported pages will be created as child pages. For example: 93 | # def custom_parent_page_id_function(instance=None): 94 | # Page.objects.get(slug="imported-pages").pk 95 | 'PARENT_PAGE_ID': 'path.to.function', 96 | # The `AUTO_PUBLISH_NEW_PAGES` setting will tell this package to either 97 | # Automatically publish a newly created page, or set to draft. 98 | # True = auto publishing is on. False = auto publish is off (pages will be drafts) 99 | 'AUTO_PUBLISH_NEW_PAGES': False, 100 | }, 101 | # ... 102 | } 103 | ``` 104 | 105 | ##### Have multiple models with the same Airtable settings? 106 | The most common approach will likely be to support a handful of models, in which case using the below example would be faster and cleaner. Write a config dictionary once to prevent config bloat. 107 | 108 | ```python 109 | AIRTABLE_API_KEY = 'yourSuperSecretKey' 110 | WAGTAIL_AIRTABLE_ENABLED = True 111 | CUSTOM_PAGE_SETTINGS = { 112 | 'AIRTABLE_BASE_KEY': 'app3ds912jFam032S', 113 | 'AIRTABLE_TABLE_NAME': 'Your Airtable Table Name', 114 | 'AIRTABLE_UNIQUE_IDENTIFIER': 'slug', # Must match the Airtable Column name 115 | 'AIRTABLE_SERIALIZER': 'path.to.your.model.serializer.CustomModelSerializer' 116 | }, 117 | AIRTABLE_IMPORT_SETTINGS = { 118 | 'home.HomePage': CUSTOM_PAGE_SETTINGS, 119 | 'blog.BlogPage': CUSTOM_PAGE_SETTINGS, 120 | 'appname.YourModel': CUSTOM_PAGE_SETTINGS, 121 | } 122 | ``` 123 | 124 | ### Wagtail Page creation on Airtable Imports 125 | 126 | This feature was sponsored by [The Mozilla Foundation](https://foundation.mozilla.org/). 127 | 128 | In `wagtail-airtable` v0.1.6 and up you can create Wagtail Pages from Airtable imports. 129 | 130 | A few settings need to be set for this to work as you would expect. Read through the following code to see which settings are needed: 131 | 132 | ```python 133 | AIRTABLE_IMPORT_SETTINGS = { 134 | 'pages.HomePage': { 135 | 'AIRTABLE_BASE_KEY': 'app2ds123jP23035Z', 136 | 'AIRTABLE_TABLE_NAME': 'Wagtail Page Tracking Table', 137 | 'AIRTABLE_UNIQUE_IDENTIFIER': { 138 | 'Wagtail Page ID': 'pk', 139 | }, 140 | 'AIRTABLE_SERIALIZER': 'path.to.your.pages.serializer.PageSerializer', 141 | 'AIRTABLE_IMPORT_ALLOWED': True, # This must be set 142 | 'PARENT_PAGE_ID': 'path.to.function.that.returns.an.integer', # This must be set 143 | }, 144 | } 145 | ``` 146 | 147 | Once your settings are ready, you can start creating new Pages in Airtable and import those pages via the Wagtail Admin (found in the setting menu). 148 | 149 | **Caveats**: In the above code we see `{'Wagtail Page ID': 'pk',}`, this means there's a column in Airtable named "Wagtail Page ID" and it mapped to a Page pk. When you create a new Wagtail Page inside of an Airtable sheet, _keep this cell blank in your new row_. It will auto-update when it gets imported. This happens because Airtable (and the editors) likely don't know what the new Page ID is going to be, so we let Wagtail set it, and then update the Airtable again. 150 | 151 | ### Hooks 152 | Hooks are a way to execute code once an action has happened. This mimics (and internally uses) Wagtail's hook feature. 153 | 154 | > **Note**: When using hooks it will add processing time to your requests. If you're using Heroku with a 30s timeout you may want to use a management command to avoid hitting a server timeout. 155 | 156 | ##### Updated record 157 | To take an action when a record is updated, you can write a hook like this in your wagtail_hooks.py file: 158 | 159 | ```python 160 | @hooks.register('airtable_import_record_updated') 161 | def airtable_record_updated(instance, is_wagtail_page, record_id): 162 | # Instance is the page or model instance 163 | # is_wagtail_page is a boolean to determine if the object is a wagtail page. This is a shortcut for `isinstance(instance, wagtail.models.Page)` 164 | # record_id is the wagtail record ID. You can use this to perform additional actions against Airtable using the airtable-python-wrapper package. 165 | pass 166 | ``` 167 | 168 | ### Management Commands 169 | 170 | ```bash 171 | python manage.py import_airtable appname.ModelName secondapp.SecondModel 172 | ``` 173 | 174 | Optionally you can turn up the verbosity for better debugging with the `--verbosity=2` flag. 175 | 176 | ##### import_airtable command 177 | This command will look for any `appname.ModelName`s you provide it and use the mapping settings to find data in the Airtable. See the "Behind the scenes" section for more details on how importing works. 178 | 179 | ##### skipping django signals 180 | By default the `import_airtable` command adds an additional attribute to the models being saved called `_skip_signals` - which is set to `True` you can use this to bypass any `post_save` or `pre_save` signals you might have on the models being imported so those don't run. e.g. 181 | 182 | ``` 183 | @receiver(post_save, sender=MyModel) 184 | def post_save_function(sender, **kwargs): 185 | if sender._skip_signals: 186 | # rest of logic 187 | ``` 188 | 189 | if you don't do these checks on your signal, the save will run normally. 190 | 191 | ### Local Testing Advice 192 | 193 | > **Note:** Be careful not to use the production settings as you could overwrite Wagtail or Airtable data. 194 | 195 | Because Airtable doesn't provide a testing environment, you'll need to test against a live table. The best way to do this is to copy your live table to a new table (renaming it will help avoid naming confusion), and update your local settings. With this method, you can test to everything safely against a throw-away Airtable. Should something become broken beyond repair, delete the testing table and re-copy the original one. 196 | 197 | ### Local debugging 198 | Due to the complexity and fragility of connecting Wagtail and Airtable (because an Airtable column can be almost any value) you may need some help debugging your setup. To turn on higher verbosity output, you can enable the Airtable debug setting `WAGTAIL_AIRTABLE_DEBUG = True`. All this does is increase the default verbosity when running the management command. In a standard Django management command you could run `python manage.py import_airtable appname.ModelName --verbosity=2` however when you import from Airtable using the Wagtail admin import page you won't have access to this verbosity argument. But enabling `WAGTAIL_AIRTABLE_DEBUG` you can manually increase the verbosity. 199 | 200 | > **Note**: This only only work while `DEBUG = True` in your settings as to not accidentally flood your production logs. 201 | 202 | ### Airtable Best Practice 203 | Airtable columns can be one of numerous "types", very much like a Python data type or Django field. You can have email columns, url columns, single line of text, checkbox, etc. 204 | 205 | To help maintain proper data synchronisation between your Django/Wagtail instance and your Airtable Base's, you _should_ set the column types to be as similar to your Django fields as possible. 206 | 207 | For example, if you have a BooleanField in a Django model (or Wagtail Page) and you want to support pushing that data to Airtable amd support importing that same data from Airtable, you should set the column type in Airtable to be a Checkbox (because it can only be on/off, much like how a BooleanField can only be True/False). 208 | 209 | In other cases such as Airtables Phone Number column type: if you are using a 3rd party package to handle phone numbers and phone number validation, you'll want to write a custom serializer to handle the incoming value from Airtable (when you import from Airtable). The data will likely come through to Wagtail as a string and you'll want to adjust the string value to be a proper phone number format for internal Wagtail/Django storage. (You may also need to convert the phone number to a standard string when exporting to Airtable as well) 210 | 211 | ### Running Tests 212 | Clone the project and cd into the `wagtail-airtable/` directory. Then run `python runtests.py tests`. This project is using standard Django unit tests. 213 | 214 | To target a specific test you can run `python runtests.py tests.test_file.TheTestClass.test_specific_model` 215 | 216 | Tests are written against Wagtail 2.10 and later. 217 | 218 | ### Customizing the save method 219 | In some cases you may want to customize how saving works, like making the save to airtable asynchronous for example. 220 | 221 | To do so, set: WAGTAIL_AIRTABLE_SAVE_SYNC=False in your settings.py file. 222 | 223 | This _escapes_ out of the original save method and requires you enable the asynchronous part of this on your own. 224 | 225 | An example of how you might set this up using the signal `after_page_publish` with [django_rq](https://github.com/rq/django-rq) 226 | ``` 227 | #settings.py 228 | WAGTAIL_AIRTABLE_SAVE_SYNC=False 229 | WAGTAIL_AIRTABLE_PUSH_MESSAGE="Airtable save happening in background" 230 | 231 | #wagtail_hooks.py 232 | from django.dispatch import receiver 233 | from wagtail.models import Page 234 | 235 | @job('airtable') 236 | def async_airtable_save(page_id): 237 | my_page = Page.objects.get(page_id).specific 238 | my_page.save_to_airtable() 239 | 240 | 241 | @receiver('page_published') 242 | def upload_page_to_airtable(request, page): 243 | async_airtable_save.delay(page.pk) 244 | 245 | ``` 246 | 247 | 248 | The messaging will be off if you do this, so another setting has been made available so you may change the messaging to anything you'd like: 249 | `WAGTAIL_AIRTABLE_PUSH_MESSAGE` - set this to whatever you'd like the messaging to be e.g. `WAGTAIL_AIRTABLE_PUSH_MESSAGE='Airtable save is happening in the background'` 250 | 251 | 252 | ### Adding an Import action to the snippet list view (Wagtail 6.x) 253 | 254 | As of Wagtail 6.0, the Import action is no longer automatically shown on the snippet listing view (although it is still available through Settings -> Airtable import). To add it back, first ensure that your snippet model is [registered with an explicit viewset](https://docs.wagtail.org/en/stable/topics/snippets/registering.html#using-register-snippet-as-a-function). Then, ensure that the index view for that viewset inherits from `SnippetImportActionMixin`: 255 | 256 | ```python 257 | from wagtail.snippets.models import register_snippet 258 | from wagtail.snippets.views.snippets import IndexView, SnippetViewSet 259 | from wagtail_airtable.mixins import SnippetImportActionMixin 260 | from .models import Advert 261 | 262 | 263 | class AdvertIndexView(SnippetImportActionMixin, IndexView): 264 | pass 265 | 266 | 267 | class AdvertViewSet(SnippetViewSet): 268 | model = Advert 269 | index_view_class = AdvertIndexView 270 | 271 | register_snippet(Advert, viewset=AdvertViewSet) 272 | ``` 273 | 274 | 275 | ### Trouble Shooting Tips 276 | #### Duplicates happening on import 277 | Ensure that your serializer matches your field definition *exactly*, and in cases of `CharField`'s that have `blank=True` or `null=True` setting `required=False` on the serializer is also important. 278 | 279 | In some cases 2 Models may get the same Airtable ID. To circumvent this error on imports the first one found will be set as the "real" one and the "impostors" will be set to `""` - this may create duplicate models in your system, if this is happening a lot. Make sure your export method and serializer import are set correctly. 280 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Wagtail Airtable Examples 2 | 3 | > The purpose of these examples are to help clarify how to use this package. Most Python packages are fairly self-contained however wagtail-airtable requires more involvement that may or may not be completely intuitive based on your skill level. 4 | 5 | See the following code examples: 6 | - [x] [Django Model Support](model_example.py) 7 | - [x] [Wagtail Page Support](page_example.py) 8 | - [x] [Wagtail Multi-Page Support](multi_page_example.py) 9 | -------------------------------------------------------------------------------- /examples/model_example.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from wagtail_airtable.mixins import AirtableMixin 4 | 5 | 6 | class YourModel(AirtableMixin, models.Model): 7 | 8 | name = models.CharField(max_length=200, blank=False) 9 | slug = models.SlugField(max_length=200, unique=True, editable=True) 10 | 11 | @classmethod 12 | def map_import_fields(cls): 13 | """ 14 | Maps your Airtable columns to your Django Model Fields. 15 | 16 | Always provide explicit field names as to not accidentally overwrite sensitive information 17 | such as model pk's. 18 | 19 | Return a dictionary such as: {'Airtable column name': 'model_field_name', ...} 20 | """ 21 | mappings = { 22 | # "Name" is the column name in Airtable. "name" (lowercase) is the field name on line 8. 23 | "Name": "name", 24 | # "Slug" is the column name in Airtable. "slug" (lowercase) is the field name on line 8. 25 | "Slug": "slug", 26 | } 27 | return mappings 28 | 29 | def get_export_fields(self): 30 | """ 31 | Export fields are the fields you want to map when saving a model object. 32 | 33 | Everytime a model is saved, it will take the Airtable Column Name and fill the appropriate cell 34 | with the data you tell it. Most often this will be from self.your_field_name. 35 | 36 | Always provide explicit field names as to not accidentally share sensitive information such as 37 | hashed passwords. 38 | 39 | Return a dictionary such as: {"Airtable Column Name": "update_value", ...} 40 | """ 41 | return { 42 | "Name": self.name, 43 | "Slug": self.slug, 44 | } 45 | -------------------------------------------------------------------------------- /examples/multi_page_example.py: -------------------------------------------------------------------------------- 1 | from wagtail.models import Page 2 | 3 | from wagtail_airtable.mixins import AirtableMixin 4 | 5 | 6 | class BasePage(AirtableMixin, Page): 7 | """ 8 | This is using the AirtableMixin and the Wagtail Page model to create a new 9 | "BasePage" model. Then new Pages are created using the "BasePage model 10 | they will automatically inherit the import/export field mapping you see 11 | below. 12 | 13 | Note: You'll most likely want BasePage to be an Abstract Model. 14 | """ 15 | 16 | @classmethod 17 | def map_import_fields(cls): 18 | """ 19 | Fields to update when importing a specific page. 20 | These are just updating the seo_title, title, and search_description 21 | fields that come with wagtailcore.Page. 22 | 23 | NOTE: Unless you store required data like the page depth or tree value 24 | in Airtable, when you import a new page it won't be automatically created. 25 | Wagtail doesn't know where you'd like new pages to be created but requires 26 | tree-structure data. 27 | 28 | Example: 29 | {'Airtable column name': 'model_field_name', ...} 30 | """ 31 | 32 | return { 33 | "SEO Title": "seo_title", 34 | "Title": "title", 35 | "Meta Description": "search_description", 36 | } 37 | 38 | def get_export_fields(self): 39 | """ 40 | Map Airtable columns to values from Wagtail or Django. 41 | 42 | Example: 43 | {'Airtable Column Name': updated_value, ...} 44 | """ 45 | return { 46 | "SEO Title": self.seo_title, 47 | "Title": self.title, 48 | "URL": self.full_url, 49 | "Last Published": self.last_published_at.isoformat() 50 | if self.last_published_at 51 | else "", 52 | "Meta Description": self.search_description, 53 | "Type": self.__class__.__name__, 54 | "Live": self.live, 55 | "Unpublished Changes": self.has_unpublished_changes, 56 | "Wagtail Page ID": self.id, 57 | "Slug": self.slug, 58 | } 59 | 60 | class Meta: 61 | abstract = True 62 | 63 | 64 | class HomePage2(BasePage): 65 | pass 66 | 67 | 68 | class ContactPage(BasePage): 69 | pass 70 | 71 | 72 | class BlogPage(BasePage): 73 | pass 74 | 75 | 76 | class MiscPage(BasePage): 77 | pass 78 | -------------------------------------------------------------------------------- /examples/page_example.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils import timezone 3 | from wagtail.models import Page 4 | 5 | from wagtail_airtable.mixins import AirtableMixin 6 | 7 | 8 | class HomePage(AirtableMixin, Page): 9 | 10 | # Wagtail stuff.. 11 | parent_page_types = ["wagtailcore.page"] 12 | template = ["templates/home_page.html"] 13 | 14 | # Custom fields 15 | name = models.CharField(max_length=200, blank=False) 16 | total_awesomeness = models.DecimalField( 17 | blank=True, null=True, decimal_places=1, max_digits=2 18 | ) 19 | 20 | # Custom property or methods allowed when exporting 21 | # There is no custom property/method support when importing from Airtable because it won't know 22 | # where to map the data to since custom properties and methods are not stored fields. 23 | @property 24 | def top_rated_page(self): 25 | if self.total_awesomeness >= 80: 26 | return True 27 | return False 28 | 29 | @classmethod 30 | def map_import_fields(cls): 31 | """ 32 | Maps your Airtable columns to your Django Model Fields. 33 | 34 | Always provide explicit field names as to not accidentally overwrite sensitive information 35 | such as model pk's. 36 | 37 | Return a dictionary such as: 38 | { 39 | 'Name': 'title', 40 | 'Awesomeness Rating': 'total_awesomeness', 41 | 'Other Airtable Column Name': 'your_django_or_wagtail_field_name', 42 | } 43 | """ 44 | mappings = { 45 | # "Name" is the column name in Airtable. "title" (lowercase) is the field name on line 26. 46 | "Name": "title", 47 | # "Slug" is the column name in Airtable. "slug" (lowercase) comes from Page.slug. 48 | # I've kept "slug" commented out so Airtable cannot overwrite the Page slug as that could cause a lot of trouble with URLs and SEO. But it's possible to do this assuming there aren't two pages with the same slug. 49 | # "Slug": "slug", 50 | "Awesomeness Rating": "total_awesomeness", 51 | "Last Updated": "last_published_at", 52 | } 53 | return mappings 54 | 55 | def get_export_fields(self): 56 | """ 57 | Export fields are the fields you want to map when saving a model object. 58 | 59 | Everytime a model is saved, it will take the Airtable Column Name and fill the appropriate cell 60 | with the data you tell it. Most often this will be from self.your_field_name. 61 | 62 | Always provide explicit field names as to not accidentally share sensitive information such as 63 | hashed passwords. 64 | 65 | Return a dictionary such as: {"Airtable Column Name": "update_value", ...} 66 | """ 67 | return { 68 | "Name": self.name, 69 | "Slug": self.slug, # `slug` is a field found on Page that comes with Wagtail 70 | "Awesomeness Rating": str(self.total_awesomeness) 71 | if self.total_awesomeness 72 | else None, # Send the Decimal as a string. 73 | "Top Rated Awesomeness": self.top_rated_page, # Must be a checkbox column in Airtable. 74 | # If a cell in Airtable should always be filled, but the data might be optional at some point 75 | # You can use a function, method, custom property or ternary operator to set the defaults. 76 | "Last Updated": self.last_published_at 77 | if self.last_published_at 78 | else timezone.now().isoformat(), 79 | } 80 | 81 | class Meta: 82 | verbose_name = "The Best HomePage Ever" 83 | -------------------------------------------------------------------------------- /examples/preview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wagtail-nest/wagtail-airtable/0ef4627cbbd641b2a1287bd6498d008ff6b0c101/examples/preview.gif -------------------------------------------------------------------------------- /examples/serializers.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from django.utils.dateparse import parse_datetime 4 | from rest_framework import serializers 5 | from taggit.models import Tag 6 | 7 | from wagtail_airtable.serializers import AirtableSerializer 8 | 9 | 10 | class TagSerializer(serializers.RelatedField): 11 | """ 12 | A tag serializer to convert a string of tags (ie. `Tag1, Tag2`) into a list of Tag objects (ie. `[Tag], [Tag]`). 13 | 14 | If a tag in Airtable doesn't exist in Wagtail, this snippet will create a new Tag. 15 | 16 | Usage: 17 | class YourModelSerializer(AirtableSerializer): 18 | ... 19 | tags = TagSerializer(required=False) 20 | ... 21 | """ 22 | 23 | def to_internal_value(self, data): 24 | if type(data) == str: 25 | tags = [] 26 | for tag in data.split(","): 27 | tag, _ = Tag.objects.get_or_create(name=tag.strip()) 28 | tags.append(tag) 29 | return tags 30 | elif type(data) == list: 31 | for tag in data: 32 | tag, _ = Tag.objects.get_or_create(name=tag.strip()) 33 | tags.append(tag) 34 | return tags 35 | return data 36 | 37 | def get_queryset(self): 38 | pass 39 | 40 | 41 | class BankNameSerializer(serializers.RelatedField): 42 | """ 43 | Let's assume there's a "bank_name" column in Airtable but it stores a string. 44 | 45 | When importing from Airtable you'll need to find a model object based on that name. 46 | That's what this serializer is doing. 47 | 48 | Usage: 49 | class YourModelSerializer(AirtableSerializer): 50 | ... 51 | bank_name = BankNameSerializer(required=False) 52 | ... 53 | """ 54 | 55 | def to_internal_value(self, data): 56 | from .models import BankOrganisation 57 | 58 | if data: 59 | try: 60 | bank = BankOrganisation.objects.get(name=data) 61 | except BankOrganisation.DoesNotExist: 62 | return None 63 | else: 64 | return bank 65 | return data 66 | 67 | def get_queryset(self): 68 | pass 69 | 70 | 71 | class DateTimeSerializer(serializers.DateTimeField): 72 | # Useful for parsing an Airtable Date field into a Django DateTimeField 73 | def to_internal_value(self, date): 74 | if type(date) == str and len(date): 75 | date = parse_datetime(date).isoformat() 76 | return date 77 | 78 | 79 | class DateSerializer(serializers.DateTimeField): 80 | # Useful for parsing an Airtable Date field into a Django DateField 81 | def to_internal_value(self, date): 82 | if type(date) == str and len(date): 83 | date = datetime.datetime.strptime(date, "%Y-%m-%d").date() 84 | return date 85 | 86 | 87 | class YourModelSerializer(AirtableSerializer): 88 | """ 89 | YourModel serializer used when importing Airtable records. 90 | 91 | This serializer will help validate data coming in from Airtable and help prevent 92 | malicious intentions. 93 | 94 | This model assumes there is a "name" mapping in YourModel.map_import_fields() 95 | """ 96 | 97 | name = serializers.CharField(max_length=200, required=True) 98 | slug = serializers.CharField(max_length=200, required=True) 99 | 100 | 101 | class YourPageSerializer(AirtableSerializer): 102 | """ 103 | YourModel serializer used when importing Airtable records. 104 | 105 | This serializer will help validate data coming in from Airtable and help prevent 106 | malicious intentions. 107 | 108 | This model assumes there is a "name" mapping in YourModel.map_import_fields() 109 | """ 110 | 111 | # Page.title from wagtailcore.page. Airtable can update this value. 112 | title = serializers.CharField(max_length=200, required=True) 113 | # Allow Airtable to overwrite the last_published_at date using a custom serializer. 114 | # This is probably a bad idea to allow this field to be imported, but it's a good code example. 115 | last_published_at = DateSerializer(required=False) 116 | # Custom field we created on `class YourPage`. 117 | # We want Airtable to import and validate this data before updating the value. 118 | name = serializers.CharField(max_length=200, required=True) 119 | # Not supported because we don't want a slug to be overwritten. 120 | # slug = serializers.CharField(max_length=200, required=True) 121 | -------------------------------------------------------------------------------- /examples/settings.py: -------------------------------------------------------------------------------- 1 | # AIRTABLE SETTINGS 2 | COMMON_AIRTABLE_SETTINGS = { 3 | "AIRTABLE_BASE_KEY": "", 4 | "AIRTABLE_TABLE_NAME": "Your Table Name", 5 | "AIRTABLE_UNIQUE_IDENTIFIER": {"Wagtail Page ID": "pk", }, 6 | "AIRTABLE_SERIALIZER": "yourapp.serializers.YourPageSerializer", 7 | }, 8 | WAGTAIL_AIRTABLE_ENABLED = True 9 | WAGTAIL_AIRTABLE_DEBUG = True 10 | AIRTABLE_IMPORT_SETTINGS = { 11 | # Applies to model_example.py 12 | "yourapp.YourModel": { 13 | "AIRTABLE_BASE_KEY": "", # The Airtable Base Code 14 | "AIRTABLE_TABLE_NAME": "Your Table Name", # Airtable Bases can have multiple tables. Tell it which one to use. 15 | "AIRTABLE_UNIQUE_IDENTIFIER": "slug", # Must match the Airtable Column name 16 | "AIRTABLE_SERIALIZER": "yourapp.serializers.YourModelSerializer", # A custom serializer for validating imported data. 17 | "AIRTABLE_BASE_URL": "https://airtable.com/tblxXxXxXxXxXxXx/viwxXxXxXxXxXxXx", # Creates a "View record in Airtable" button in the success notification. 18 | }, 19 | # Applies to page_example.py 20 | "yourapp.HomePage": { 21 | "AIRTABLE_BASE_KEY": "", # The Airtable Base Code 22 | "AIRTABLE_TABLE_NAME": "Your Table Name", # Airtable Bases can have multiple tables. Tell it which one to use. 23 | "AIRTABLE_UNIQUE_IDENTIFIER": { # Takes an {'Airtable Column Name': 'django_field_name'} mapping 24 | "Wagtail Page ID": "pk", 25 | }, 26 | "AIRTABLE_SERIALIZER": "yourapp.serializers.YourPageSerializer", # A custom serializer for validating imported data. 27 | }, 28 | # Applies to multi_page_example.py 29 | "yourapp.HomePage2": COMMON_AIRTABLE_SETTINGS, 30 | "yourapp.ContactPage": COMMON_AIRTABLE_SETTINGS, 31 | "yourapp.BlogPage": COMMON_AIRTABLE_SETTINGS, 32 | "yourapp.MiscPage": COMMON_AIRTABLE_SETTINGS, 33 | # { 34 | # ... More settings 35 | # } 36 | } 37 | -------------------------------------------------------------------------------- /known-issues.md: -------------------------------------------------------------------------------- 1 | ## Known Issues and workarounds 2 | > All of these would make amazing PR's ;) 3 | 4 | #### Decimal Fields 5 | In `get_export_fields()` if you map a decimal field you'll need to convert it to a string for it to be JSONified and for Airtable to accept it. ie: 6 | ```python 7 | rating = models.DecimalField(...) 8 | 9 | def get_export_fields(..): 10 | return { 11 | ... 12 | "rating": str(self.rating) if self.rating else None, 13 | } 14 | ``` 15 | 16 | #### Duplicate records by unique column value 17 | If any operation needs to find a new Airtable record by its unique column name (and value) such as a `slug` or `id`, and multiple records are returned at the same time, wagtail-airtable will use the first available option that Airtable returns. 18 | 19 | The problem with this lies in editing Airtable records. Because of this someone may edit the wrong record and the `import` function may not import the correct data. 20 | 21 | Also Airtable does not need to return the records in order from first to last. For example, if you saved a model and it had 4 matched records in your Airtable because there were 4 cells with the slug of "testing-record-slug", you may not get the first record in the list of returned records. In several test cases there were random cases of the first, middle and last records being selected. This is more of an issue with Airtable not giving us all the records in proper order. Whichever record is found first is the record your Django object will be tied to moving forward. 22 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "wagtail-airtable" 3 | version = "1.0.0" 4 | description = "Sync data between Wagtail and Airtable" 5 | authors = [{name = "Kalob Taulien", email = "kalob.taulien@torchbox.com"}] 6 | maintainers = [ 7 | {name = "Matthew Westcott", email = "matthew.westcott@torchbox.com"}, 8 | ] 9 | readme = "README.md" 10 | license = {file = "LICENSE"} 11 | keywords = ["wagtail", "airtable"] 12 | classifiers=[ 13 | "Environment :: Web Environment", 14 | "Intended Audience :: Developers", 15 | "License :: OSI Approved :: BSD License", 16 | "Operating System :: OS Independent", 17 | "Programming Language :: Python", 18 | "Programming Language :: Python :: 3", 19 | "Programming Language :: Python :: 3.9", 20 | "Programming Language :: Python :: 3.10", 21 | "Programming Language :: Python :: 3.11", 22 | "Programming Language :: Python :: 3.12", 23 | "Programming Language :: Python :: 3.13", 24 | "Framework :: Django", 25 | "Framework :: Django :: 4", 26 | "Framework :: Django :: 5.0", 27 | "Framework :: Django :: 5.1", 28 | "Framework :: Wagtail", 29 | "Framework :: Wagtail :: 5", 30 | "Framework :: Wagtail :: 6", 31 | ] 32 | 33 | requires-python = ">=3.8" 34 | dependencies = [ 35 | "Wagtail>=5.2", 36 | "Django>=4.2", 37 | "pyairtable>=2.3,<3", 38 | "djangorestframework>=3.11.0" 39 | ] 40 | 41 | [project.optional-dependencies] 42 | testing = [ 43 | "tox>=3.27" 44 | ] 45 | 46 | [project.urls] 47 | Source = "https://github.com/wagtail-nest/wagtail-airtable" 48 | Changelog = "https://github.com/wagtail-nest/wagtail-airtable/blob/main/CHANGELOG.md" 49 | Documentation = "https://github.com/wagtail-nest/wagtail-airtable/blob/main/README.md" 50 | 51 | [build-system] 52 | requires = ["flit_core >=3.2,<4"] 53 | build-backend = "flit_core.buildapi" 54 | 55 | [tool.flit.module] 56 | name = "wagtail_airtable" 57 | 58 | [tool.flit.sdist] 59 | exclude = [ 60 | ".*", 61 | "*.db", 62 | "*.json", 63 | "*.ini", 64 | "*.yaml", 65 | "examples", 66 | "tests", 67 | "CHANGELOG.md", 68 | "known-issues.md", 69 | "runtests.py", 70 | ] 71 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import sys 5 | 6 | from django.core.management import execute_from_command_line 7 | 8 | os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.settings' 9 | execute_from_command_line([sys.argv[0], 'test'] + sys.argv[1:]) 10 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wagtail-nest/wagtail-airtable/0ef4627cbbd641b2a1287bd6498d008ff6b0c101/tests/__init__.py -------------------------------------------------------------------------------- /tests/fixtures/test.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "pk": 1, 4 | "model": "wagtailcore.page", 5 | "fields": { 6 | "title": "Root", 7 | "numchild": 1, 8 | "show_in_menus": false, 9 | "live": true, 10 | "depth": 1, 11 | "content_type": [ 12 | "wagtailcore", 13 | "page" 14 | ], 15 | "path": "0001", 16 | "url_path": "/", 17 | "slug": "root" 18 | } 19 | }, 20 | { 21 | "model": "tests.advert", 22 | "pk": 1, 23 | "fields": { 24 | "title": "Red! It's the new blue!", 25 | "description": "Red is a scientifically proven color that moves faster than all other colors.", 26 | "external_link": "https://example.com/", 27 | "is_active": true, 28 | "rating": "1.5", 29 | "long_description": "

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.

", 30 | "points": 95, 31 | "slug": "red-its-new-blue" 32 | } 33 | }, 34 | { 35 | "model": "tests.advert", 36 | "pk": 2, 37 | "fields": { 38 | "title": "Wow brand new?!", 39 | "description": "Lorem says what?", 40 | "external_link": "https://example.com/", 41 | "is_active": true, 42 | "rating": "2.5", 43 | "long_description": "

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.

", 44 | "points": 20, 45 | "slug": "delete-me", 46 | "airtable_record_id": "recNewRecordId" 47 | } 48 | }, 49 | { 50 | "pk": 2, 51 | "model": "wagtailcore.page", 52 | "fields": { 53 | "title": "Home", 54 | "numchild": 0, 55 | "show_in_menus": false, 56 | "live": true, 57 | "depth": 2, 58 | "content_type": ["tests", "simplepage"], 59 | "path": "00010001", 60 | "url_path": "/home/", 61 | "slug": "home" 62 | } 63 | }, 64 | { 65 | "pk": 2, 66 | "model": "tests.simplepage", 67 | "fields": { 68 | "intro": "This is the homepage" 69 | } 70 | }, 71 | { 72 | "pk": 1, 73 | "model": "wagtailcore.site", 74 | "fields": { 75 | "root_page": 2, 76 | "hostname": "localhost", 77 | "port": 80, 78 | "is_default_site": true 79 | } 80 | }, 81 | 82 | { 83 | "pk": 1, 84 | "model": "auth.user", 85 | "fields": { 86 | "username": "admin", 87 | "first_name": "admin", 88 | "last_name": "admin", 89 | "is_active": true, 90 | "is_superuser": true, 91 | "is_staff": true, 92 | "groups": [ 93 | ], 94 | "user_permissions": [], 95 | "password": "md5$seasalt$1e9bf2bf5606aa5c39852cc30f0f6f22", 96 | "email": "admin@example.com" 97 | } 98 | } 99 | ] 100 | -------------------------------------------------------------------------------- /tests/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.6 on 2020-05-14 19:00 2 | 3 | import django.db.models.deletion 4 | import wagtail.fields as wagtail_fields 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | ('wagtailcore', '0045_assign_unlock_grouppagepermission'), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='Advert', 19 | fields=[ 20 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('airtable_record_id', models.CharField(blank=True, db_index=True, max_length=35)), 22 | ('title', models.CharField(max_length=255)), 23 | ('description', models.TextField(blank=True)), 24 | ('external_link', models.URLField(blank=True, max_length=500)), 25 | ('is_active', models.BooleanField(default=False)), 26 | ('rating', models.DecimalField(choices=[(1.0, '1'), (1.5, '1.5'), (2.0, '2'), (2.5, '2.5'), (3.0, '3'), (3.5, '3.5'), (4.0, '4'), (4.5, '4.5'), (5.0, '5')], decimal_places=1, max_digits=2, null=True)), 27 | ('long_description', wagtail_fields.RichTextField(blank=True, null=True)), 28 | ('points', models.IntegerField(blank=True, null=True)), 29 | ('slug', models.SlugField(max_length=100, unique=True)), 30 | ], 31 | options={ 32 | 'verbose_name': 'Advertisement', 33 | 'verbose_name_plural': 'Advertisements', 34 | }, 35 | ), 36 | migrations.CreateModel( 37 | name='SimplePage', 38 | fields=[ 39 | ('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.Page')), 40 | ('intro', models.TextField()), 41 | ], 42 | options={ 43 | 'abstract': False, 44 | }, 45 | bases=('wagtailcore.page',), 46 | ), 47 | ] 48 | -------------------------------------------------------------------------------- /tests/migrations/0002_modelnotused.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.6 on 2020-05-15 18:13 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('tests', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name='ModelNotUsed', 15 | fields=[ 16 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 17 | ('airtable_record_id', models.CharField(blank=True, db_index=True, max_length=35)), 18 | ], 19 | options={ 20 | 'abstract': False, 21 | }, 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /tests/migrations/0003_similartoadvert.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.6 on 2020-05-15 18:16 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('tests', '0002_modelnotused'), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='SimilarToAdvert', 16 | fields=[ 17 | ('advert_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tests.Advert')), 18 | ], 19 | options={ 20 | 'abstract': False, 21 | }, 22 | bases=('tests.advert',), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /tests/migrations/0004_advert_publications.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2022-05-26 20:54 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('tests', '0003_similartoadvert'), 10 | ] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name='Publication', 15 | fields=[ 16 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 17 | ('title', models.CharField(max_length=30)), 18 | ], 19 | ), 20 | migrations.AddField( 21 | model_name='advert', 22 | name='publications', 23 | field=models.ManyToManyField(blank=True, to='tests.Publication'), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /tests/migrations/0005_simplepage_airtable_record_id.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.9 on 2024-12-12 23:29 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("tests", "0004_advert_publications"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="simplepage", 15 | name="airtable_record_id", 16 | field=models.CharField(blank=True, db_index=True, max_length=35), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /tests/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wagtail-nest/wagtail-airtable/0ef4627cbbd641b2a1287bd6498d008ff6b0c101/tests/migrations/__init__.py -------------------------------------------------------------------------------- /tests/mock_airtable.py: -------------------------------------------------------------------------------- 1 | """A mocked Airtable API wrapper.""" 2 | from unittest import mock 3 | from pyairtable.formulas import match 4 | from requests.exceptions import HTTPError 5 | 6 | def get_mock_airtable(): 7 | """ 8 | Wrap it in a function, so it's pure 9 | """ 10 | 11 | class MockTable(mock.Mock): 12 | def iterate(self): 13 | return [self.all()] 14 | 15 | 16 | MockTable.table_name = "app_airtable_advert_base_key" 17 | 18 | MockTable.get = mock.MagicMock("get") 19 | 20 | def get_fn(record_id): 21 | if record_id == "recNewRecordId": 22 | return { 23 | "id": "recNewRecordId", 24 | "fields": { 25 | "title": "Red! It's the new blue!", 26 | "description": "Red is a scientifically proven color that moves faster than all other colors.", 27 | "external_link": "https://example.com/", 28 | "is_active": True, 29 | "rating": "1.5", 30 | "long_description": "

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.

", 31 | "points": 95, 32 | "slug": "red-its-new-blue", 33 | }, 34 | } 35 | else: 36 | raise HTTPError("404 Client Error: Not Found") 37 | 38 | MockTable.get.side_effect = get_fn 39 | 40 | MockTable.create = mock.MagicMock("create") 41 | 42 | MockTable.create.return_value = { 43 | "id": "recNewRecordId", 44 | "fields": { 45 | "title": "Red! It's the new blue!", 46 | "description": "Red is a scientifically proven color that moves faster than all other colors.", 47 | "external_link": "https://example.com/", 48 | "is_active": True, 49 | "rating": "1.5", 50 | "long_description": "

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.

", 51 | "points": 95, 52 | "slug": "red-its-new-blue", 53 | }, 54 | } 55 | 56 | MockTable.update = mock.MagicMock("update") 57 | MockTable.update.return_value = { 58 | "id": "recNewRecordId", 59 | "fields": { 60 | "title": "Red! It's the new blue!", 61 | "description": "Red is a scientifically proven color that moves faster than all other colors.", 62 | "external_link": "https://example.com/", 63 | "is_active": True, 64 | "rating": "1.5", 65 | "long_description": "

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.

", 66 | "points": 95, 67 | "slug": "red-its-new-blue", 68 | }, 69 | } 70 | 71 | MockTable.delete = mock.MagicMock("delete") 72 | MockTable.delete.return_value = {"deleted": True, "record": "recNewRecordId"} 73 | 74 | MockTable.all = mock.MagicMock("all") 75 | def all_fn(formula=None): 76 | if formula is None: 77 | return [ 78 | { 79 | "id": "recNewRecordId", 80 | "fields": { 81 | "title": "Red! It's the new blue!", 82 | "description": "Red is a scientifically proven color that moves faster than all other colors.", 83 | "external_link": "https://example.com/", 84 | "is_active": True, 85 | "rating": "1.5", 86 | "long_description": "

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.

", 87 | "points": 95, 88 | "slug": "delete-me", 89 | "publications": [ 90 | {"title": "Record 1 publication 1"}, 91 | {"title": "Record 1 publication 2"}, 92 | {"title": "Record 1 publication 3"}, 93 | ] 94 | }, 95 | }, 96 | { 97 | "id": "Different record", 98 | "fields": { 99 | "title": "Not the used record.", 100 | "description": "This is only used for multiple responses from MockAirtable", 101 | "external_link": "https://example.com/", 102 | "is_active": False, 103 | "rating": "5.5", 104 | "long_description": "", 105 | "points": 1, 106 | "slug": "not-the-used-record", 107 | }, 108 | }, 109 | { 110 | "id": "recRecordThree", 111 | "fields": { 112 | "title": "A third record.", 113 | "description": "This is only used for multiple responses from MockAirtable", 114 | "external_link": "https://example.com/", 115 | "is_active": False, 116 | "rating": "5.5", 117 | "long_description": "", 118 | "points": 1, 119 | "slug": "record-3", 120 | }, 121 | }, 122 | { 123 | "id": "recRecordFour", 124 | "fields": { 125 | "title": "A fourth record.", 126 | "description": "This is only used for multiple responses from MockAirtable", 127 | "external_link": "https://example.com/", 128 | "is_active": False, 129 | "rating": "5.5", 130 | "long_description": "", 131 | "points": 1, 132 | "slug": "record-4", 133 | "publications": [ 134 | {"title": "Record 4 publication 1"}, 135 | {"title": "Record 4 publication 2"}, 136 | {"title": "Record 4 publication 3"}, 137 | ] 138 | }, 139 | }, 140 | ] 141 | elif formula == match({"slug": "red-its-new-blue"}): 142 | return [ 143 | { 144 | "id": "recNewRecordId", 145 | "fields": { 146 | "title": "Red! It's the new blue!", 147 | "description": "Red is a scientifically proven color that moves faster than all other colors.", 148 | "external_link": "https://example.com/", 149 | "is_active": True, 150 | "rating": "1.5", 151 | "long_description": "

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.

", 152 | "points": 95, 153 | "slug": "red-its-new-blue", 154 | }, 155 | }, 156 | { 157 | "id": "Different record", 158 | "fields": { 159 | "title": "Not the used record.", 160 | "description": "This is only used for multiple responses from MockAirtable", 161 | "external_link": "https://example.com/", 162 | "is_active": False, 163 | "rating": "5.5", 164 | "long_description": "", 165 | "points": 1, 166 | "slug": "not-the-used-record", 167 | }, 168 | }, 169 | ] 170 | elif formula == match({"slug": "a-matching-slug"}): 171 | return [ 172 | { 173 | "id": "recMatchedRecordId", 174 | "fields": { 175 | "title": "Red! It's the new blue!", 176 | "description": "Red is a scientifically proven color that moves faster than all other colors.", 177 | "external_link": "https://example.com/", 178 | "is_active": True, 179 | "rating": "1.5", 180 | "long_description": "

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.

", 181 | "points": 95, 182 | "slug": "a-matching-slug", 183 | }, 184 | }, 185 | ] 186 | elif formula == match({"Page Slug": "home"}): 187 | return [ 188 | { 189 | "id": "recHomePageId", 190 | "fields": { 191 | "title": "Home", 192 | "Page Slug": "home", 193 | "intro": "This is the home page.", 194 | }, 195 | }, 196 | ] 197 | else: 198 | return [] 199 | 200 | MockTable.all.side_effect = all_fn 201 | 202 | class MockApi(mock.Mock): 203 | def __init__(self, *args, **kwargs): 204 | super().__init__(*args, **kwargs) 205 | self._table = MockTable() 206 | 207 | def table(self, base_id, table_name): 208 | return self._table 209 | 210 | return MockApi -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from wagtail.fields import RichTextField 3 | from wagtail.models import Page 4 | from wagtail.snippets.models import register_snippet 5 | from wagtail.snippets.views.snippets import IndexView, SnippetViewSet 6 | 7 | from wagtail_airtable.mixins import AirtableMixin, SnippetImportActionMixin 8 | 9 | 10 | def get_import_parent_page(): 11 | return Page.objects.get(slug="home").pk 12 | 13 | 14 | class SimplePage(AirtableMixin, Page): 15 | intro = models.TextField() 16 | 17 | @classmethod 18 | def map_import_fields(cls): 19 | mappings = { 20 | "title": "title", 21 | "Page Slug": "slug", 22 | "intro": "intro", 23 | } 24 | return mappings 25 | 26 | def get_export_fields(self): 27 | return { 28 | "title": self.title, 29 | "Page Slug": self.slug, 30 | "intro": self.intro, 31 | } 32 | 33 | 34 | class Publication(models.Model): 35 | title = models.CharField(max_length=30) 36 | 37 | 38 | class Advert(AirtableMixin, models.Model): 39 | STAR_RATINGS = ( 40 | (1.0, "1"), 41 | (1.5, "1.5"), 42 | (2.0, "2"), 43 | (2.5, "2.5"), 44 | (3.0, "3"), 45 | (3.5, "3.5"), 46 | (4.0, "4"), 47 | (4.5, "4.5"), 48 | (5.0, "5"), 49 | ) 50 | 51 | title = models.CharField(max_length=255) 52 | description = models.TextField(blank=True) 53 | external_link = models.URLField(blank=True, max_length=500) 54 | is_active = models.BooleanField(default=False) 55 | rating = models.DecimalField( 56 | null=True, choices=STAR_RATINGS, decimal_places=1, max_digits=2 57 | ) 58 | long_description = RichTextField(blank=True, null=True) 59 | points = models.IntegerField(null=True, blank=True) 60 | slug = models.SlugField(max_length=100, unique=True, editable=True) 61 | publications = models.ManyToManyField(Publication, blank=True) 62 | 63 | @classmethod 64 | def map_import_fields(cls): 65 | """{'Airtable column name': 'model_field_name', ...}""" 66 | mappings = { 67 | "title": "title", 68 | "description": "description", 69 | "external_link": "external_link", 70 | "is_active": "is_active", 71 | "rating": "rating", 72 | "long_description": "long_description", 73 | "points": "points", 74 | "slug": "slug", 75 | "publications": "publications", 76 | } 77 | return mappings 78 | 79 | def get_export_fields(self): 80 | return { 81 | "title": self.title, 82 | "description": self.description, 83 | "external_link": self.external_link, 84 | "is_active": self.is_active, 85 | "rating": self.rating, 86 | "long_description": self.long_description, 87 | "points": self.points, 88 | "slug": self.slug, 89 | "publications": self.publications, 90 | } 91 | 92 | class Meta: 93 | verbose_name = "Advertisement" 94 | verbose_name_plural = "Advertisements" 95 | 96 | def __str__(self): 97 | return self.title 98 | 99 | 100 | class AdvertIndexView(SnippetImportActionMixin, IndexView): 101 | pass 102 | 103 | 104 | class AdvertViewSet(SnippetViewSet): 105 | model = Advert 106 | index_view_class = AdvertIndexView 107 | 108 | register_snippet(Advert, viewset=AdvertViewSet) 109 | 110 | 111 | @register_snippet 112 | class SimilarToAdvert(Advert): 113 | pass 114 | 115 | 116 | @register_snippet 117 | class ModelNotUsed(AirtableMixin, models.Model): 118 | pass 119 | -------------------------------------------------------------------------------- /tests/serializers.py: -------------------------------------------------------------------------------- 1 | # from django.utils.dateparse import parse_datetime 2 | from rest_framework import serializers 3 | 4 | from wagtail_airtable.serializers import AirtableSerializer 5 | 6 | 7 | class PublicationsObjectsSerializer(serializers.RelatedField): 8 | """ 9 | Let's assume there's a "bank_name" column in Airtable but it stores a string. 10 | 11 | When importing from Airtable you'll need to find a model object based on that name. 12 | That's what this serializer is doing. 13 | 14 | Usage: 15 | class YourModelSerializer(AirtableSerializer): 16 | ... 17 | bank_name = BankNameSerializer(required=False) 18 | ... 19 | """ 20 | 21 | def to_internal_value(self, data): 22 | from .models import Publication 23 | publications = [] 24 | if data: 25 | for publication in data: 26 | publication_obj, _ = Publication.objects.get_or_create(title=publication["title"]) 27 | publications.append(publication_obj) 28 | return publications 29 | return data 30 | 31 | def get_queryset(self): 32 | pass 33 | 34 | 35 | class AdvertSerializer(AirtableSerializer): 36 | slug = serializers.CharField(max_length=100, required=True) 37 | title = serializers.CharField(max_length=255) 38 | external_link = serializers.URLField(required=False) 39 | publications = PublicationsObjectsSerializer(required=False) 40 | 41 | 42 | class SimplePageSerializer(AirtableSerializer): 43 | title = serializers.CharField(max_length=255, required=True) 44 | slug = serializers.CharField(max_length=100, required=True) 45 | intro = serializers.CharField() 46 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 4 | 5 | 6 | # Quick-start development settings - unsuitable for production 7 | # See https://docs.djangoproject.com/en/{{ docs_version }}/howto/deployment/checklist/ 8 | 9 | 10 | # Application definition 11 | INSTALLED_APPS = [ 12 | 'tests', 13 | 'wagtail_airtable', 14 | 15 | 'wagtail.contrib.forms', 16 | 'wagtail.contrib.redirects', 17 | 'wagtail.embeds', 18 | 'wagtail.sites', 19 | 'wagtail.users', 20 | 'wagtail.snippets', 21 | 'wagtail.documents', 22 | 'wagtail.images', 23 | 'wagtail.search', 24 | 'wagtail.admin', 25 | 'wagtail', 26 | 27 | 'modelcluster', 28 | 'taggit', 29 | 30 | 'django.contrib.admin', 31 | 'django.contrib.auth', 32 | 'django.contrib.contenttypes', 33 | 'django.contrib.sessions', 34 | 'django.contrib.messages', 35 | 'django.contrib.staticfiles', 36 | ] 37 | 38 | MIDDLEWARE = [ 39 | 'django.contrib.sessions.middleware.SessionMiddleware', 40 | 'django.middleware.common.CommonMiddleware', 41 | 'django.middleware.csrf.CsrfViewMiddleware', 42 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 43 | 'django.contrib.messages.middleware.MessageMiddleware', 44 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 45 | 'django.middleware.security.SecurityMiddleware', 46 | 47 | 'wagtail.contrib.redirects.middleware.RedirectMiddleware', 48 | ] 49 | 50 | ROOT_URLCONF = 'tests.urls' 51 | 52 | TEMPLATES = [ 53 | { 54 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 55 | 'APP_DIRS': True, 56 | 'OPTIONS': { 57 | 'context_processors': [ 58 | 'django.template.context_processors.debug', 59 | 'django.template.context_processors.request', 60 | 'django.contrib.auth.context_processors.auth', 61 | 'django.contrib.messages.context_processors.messages', 62 | ], 63 | }, 64 | }, 65 | ] 66 | 67 | WSGI_APPLICATION = 'tests.wsgi.application' 68 | 69 | 70 | # Database 71 | DATABASES = { 72 | 'default': { 73 | 'ENGINE': 'django.db.backends.sqlite3', 74 | 'NAME': os.path.join(BASE_DIR, 'test-db.sqlite3'), 75 | } 76 | } 77 | 78 | AUTH_PASSWORD_VALIDATORS = [ 79 | { 80 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 81 | }, 82 | { 83 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 84 | }, 85 | { 86 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 87 | }, 88 | { 89 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 90 | }, 91 | ] 92 | 93 | PASSWORD_HASHERS = ( 94 | 'django.contrib.auth.hashers.MD5PasswordHasher', # don't use the intentionally slow default password hasher 95 | ) 96 | 97 | LANGUAGE_CODE = 'en-us' 98 | TIME_ZONE = 'UTC' 99 | USE_I18N = True 100 | USE_L10N = True 101 | USE_TZ = True 102 | 103 | 104 | STATICFILES_FINDERS = [ 105 | 'django.contrib.staticfiles.finders.FileSystemFinder', 106 | 'django.contrib.staticfiles.finders.AppDirectoriesFinder', 107 | ] 108 | 109 | STATIC_ROOT = os.path.join(BASE_DIR, 'static') 110 | STATIC_URL = '/static/' 111 | 112 | MEDIA_ROOT = os.path.join(BASE_DIR, 'test-media') 113 | MEDIA_URL = 'http://media.example.com/media/' 114 | 115 | SECRET_KEY = 'not needed' 116 | 117 | # Wagtail settings 118 | 119 | WAGTAIL_SITE_NAME = "wagtail-airtable" 120 | WAGTAILADMIN_BASE_URL = 'http://example.com' 121 | 122 | AIRTABLE_API_KEY = 'keyWoWoWoWoW' 123 | WAGTAIL_AIRTABLE_ENABLED = True 124 | WAGTAIL_AIRTABLE_DEBUG = False 125 | 126 | # Use the mock Airtable API for testing 127 | WAGTAIL_AIRTABLE_TESTING = True 128 | 129 | AIRTABLE_IMPORT_SETTINGS = { 130 | 'tests.SimplePage': { 131 | 'AIRTABLE_BASE_KEY': 'xxx', 132 | 'AIRTABLE_TABLE_NAME': 'xxx', 133 | 'AIRTABLE_UNIQUE_IDENTIFIER': {'Page Slug': 'slug'}, 134 | 'AIRTABLE_SERIALIZER': 'tests.serializers.SimplePageSerializer', 135 | 'PARENT_PAGE_ID': 'tests.models.get_import_parent_page', 136 | }, 137 | 'tests.Advert': { 138 | 'AIRTABLE_BASE_KEY': 'app_airtable_advert_base_key', 139 | 'AIRTABLE_TABLE_NAME': 'Advert Table Name', 140 | 'AIRTABLE_UNIQUE_IDENTIFIER': 'slug', 141 | 'AIRTABLE_SERIALIZER': 'tests.serializers.AdvertSerializer', 142 | 'AIRTABLE_BASE_URL': 'https://airtable.com/tblxXxXxXxXx/viwXxXxXXxXXx' 143 | }, 144 | 'tests.SimilarToAdvert': { # Exact same as 'tests.Advert' 145 | 'AIRTABLE_BASE_KEY': 'app_airtable_advert_base_key', 146 | 'AIRTABLE_TABLE_NAME': 'Advert Table Name', 147 | 'AIRTABLE_UNIQUE_IDENTIFIER': 'slug', 148 | 'AIRTABLE_SERIALIZER': 'tests.serializers.AdvertSerializer', 149 | 'AIRTABLE_BASE_URL': 'https://airtable.com/tblxXxXxXxXx/viwXxXxXXxXXx' 150 | }, 151 | } 152 | 153 | DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' 154 | -------------------------------------------------------------------------------- /tests/test_import.py: -------------------------------------------------------------------------------- 1 | import copy 2 | from django.conf import settings 3 | from django.test import TestCase, override_settings 4 | from unittest.mock import MagicMock, patch 5 | from wagtail import hooks 6 | from wagtail.models import Page 7 | 8 | from tests.models import Advert, ModelNotUsed, SimilarToAdvert, SimplePage 9 | from tests.serializers import AdvertSerializer 10 | from wagtail_airtable.importer import AirtableModelImporter, get_column_to_field_names, convert_mapped_fields, get_data_for_new_model 11 | 12 | from .mock_airtable import get_mock_airtable 13 | 14 | 15 | class TestImportClass(TestCase): 16 | fixtures = ['test.json'] 17 | 18 | def setUp(self): 19 | airtable_patcher = patch("wagtail_airtable.importer.Api", new_callable=get_mock_airtable()) 20 | self.mock_airtable = airtable_patcher.start() 21 | self.addCleanup(airtable_patcher.stop) 22 | 23 | def get_valid_record_fields(self): 24 | """Common used method for standard valid airtable records.""" 25 | return { 26 | "Page Title": "Red! It's the new blue!", 27 | "SEO Description": "Red is a scientifically proven...", 28 | "External Link": "https://example.com/", 29 | "Is Active": True, 30 | "rating": "1.5", 31 | "long_description": "

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.

", 32 | "points": 95, 33 | "slug": "red-its-new-blue", 34 | } 35 | 36 | def get_valid_mapped_fields(self): 37 | """Common used method for standard valid airtable mapped fields.""" 38 | return { 39 | "Page Title": "title", 40 | "SEO Description": "description", 41 | "External Link": "external_link", 42 | "Is Active": "is_active", 43 | "slug": "slug", 44 | } 45 | 46 | def test_get_model_serializer(self): 47 | self.assertEqual(AirtableModelImporter(model=Advert).model_serializer, AdvertSerializer) 48 | 49 | def test_get_model_settings(self): 50 | # Finds config settings 51 | self.assertDictEqual(AirtableModelImporter(model=Advert).model_settings, settings.AIRTABLE_IMPORT_SETTINGS['tests.Advert']) 52 | 53 | # Does not find config settings 54 | with self.assertRaises(KeyError): 55 | AirtableModelImporter(model=ModelNotUsed) 56 | 57 | # Finds adjacent model settings 58 | self.assertDictEqual(AirtableModelImporter(model=SimilarToAdvert).model_settings, settings.AIRTABLE_IMPORT_SETTINGS['tests.Advert']) 59 | 60 | def test_get_column_to_field_names(self): 61 | # The airtable column is the same name as the django field name 62 | # ie: slug and slug 63 | column, field = get_column_to_field_names('slug') 64 | self.assertEqual(column, 'slug') 65 | self.assertEqual(field, 'slug') 66 | # The airtable column is different from the django field name 67 | # ie: "Page Title" and "title" 68 | column, field = get_column_to_field_names({"Page Title": "title"}) 69 | self.assertEqual(column, 'Page Title') 70 | self.assertEqual(field, 'title') 71 | # Different settings were specified and arent currently handled 72 | # Returns empty values 73 | column, field = get_column_to_field_names(None) 74 | self.assertEqual(column, None) 75 | self.assertEqual(field, None) 76 | 77 | def test_convert_mapped_fields(self): 78 | record_fields_dict = self.get_valid_record_fields() 79 | record_fields_dict['extra_field_from_airtable'] = "Not mapped" 80 | mapped_fields = convert_mapped_fields( 81 | record_fields_dict, 82 | self.get_valid_mapped_fields(), 83 | ) 84 | # Ensure the new mapped fields have the proper django field keys 85 | # And that each value is the value from the airtable record. 86 | self.assertEqual( 87 | mapped_fields['title'], 88 | "Red! It's the new blue!", 89 | ) 90 | self.assertEqual( 91 | mapped_fields['description'], 92 | "Red is a scientifically proven...", 93 | ) 94 | # Ensure a field from Airtable that's not mapped to a model does not get 95 | # passed into the newly mapped fields 96 | self.assertFalse(hasattr(mapped_fields, 'extra_field_from_airtable')) 97 | 98 | def test_update_object(self): 99 | importer = AirtableModelImporter(model=Advert) 100 | advert = Advert.objects.get(airtable_record_id="recNewRecordId") 101 | self.assertNotEqual(advert.title, "Red! It's the new blue!") 102 | self.assertEqual(len(advert.publications.all()), 0) 103 | 104 | hook_fn = MagicMock() 105 | with hooks.register_temporarily("airtable_import_record_updated", hook_fn): 106 | updated_result = next(result for result in importer.run() if not result.new) 107 | 108 | self.assertEqual(updated_result.record_id, advert.airtable_record_id) 109 | self.assertIsNone(updated_result.errors) 110 | hook_fn.assert_called_once_with(instance=advert, is_wagtail_page=False, record_id="recNewRecordId") 111 | 112 | advert.refresh_from_db() 113 | self.assertEqual(advert.title, "Red! It's the new blue!") 114 | self.assertEqual(updated_result.record_id, "recNewRecordId") 115 | self.assertEqual(len(advert.publications.all()), 3) 116 | 117 | @patch('wagtail_airtable.mixins.Api') 118 | def test_create_object(self, mixin_airtable): 119 | importer = AirtableModelImporter(model=Advert) 120 | self.assertFalse(Advert.objects.filter(slug="test-created").exists()) 121 | self.assertFalse(Advert.objects.filter(airtable_record_id="test-created-id").exists()) 122 | self.mock_airtable._table.all.side_effect = None 123 | self.mock_airtable._table.all.return_value = [{ 124 | "id": "test-created-id", 125 | "fields": { 126 | "title": "The created one", 127 | "description": "This one, we created.", 128 | "external_link": "https://example.com/", 129 | "is_active": True, 130 | "rating": "1.5", 131 | "long_description": "

Long description is long.

", 132 | "points": 95, 133 | "slug": "test-created", 134 | "publications": [ 135 | {"title": "Created record publication 1"}, 136 | {"title": "Created record publication 2"}, 137 | {"title": "Created record publication 3"}, 138 | ], 139 | }, 140 | }] 141 | 142 | hook_fn = MagicMock() 143 | with hooks.register_temporarily("airtable_import_record_updated", hook_fn): 144 | created_result = next(importer.run()) 145 | 146 | self.assertTrue(created_result.new) 147 | self.assertIsNone(created_result.errors) 148 | 149 | advert = Advert.objects.get(airtable_record_id=created_result.record_id) 150 | hook_fn.assert_called_once_with(instance=advert, is_wagtail_page=False, record_id="test-created-id") 151 | self.assertEqual(advert.title, "The created one") 152 | self.assertEqual(advert.slug, "test-created") 153 | self.assertEqual(len(advert.publications.all()), 3) 154 | 155 | def test_update_object_with_invalid_serialized_data(self): 156 | advert = Advert.objects.get(airtable_record_id="recNewRecordId") 157 | importer = AirtableModelImporter(model=Advert) 158 | self.assertNotEqual(advert.description, "Red is a scientifically proven..") 159 | self.mock_airtable._table.all.side_effect = None 160 | self.mock_airtable._table.all.return_value = [ 161 | { 162 | "id": "recNewRecordId", 163 | "fields": { 164 | "SEO Description": "Red is a scientifically proven...", 165 | "External Link": "https://example.com/", 166 | "slug": "red-its-new-blue", 167 | "Rating": "2.5", 168 | } 169 | }, 170 | ] 171 | 172 | result = next(importer.run()) 173 | self.assertEqual(result.errors, {'title': ['This field is required.']}) 174 | advert.refresh_from_db() 175 | self.assertNotEqual(advert.description, "Red is a scientifically proven..") 176 | 177 | def test_get_existing_instance(self): 178 | importer = AirtableModelImporter(model=Advert) 179 | advert = Advert.objects.get(airtable_record_id="recNewRecordId") 180 | 181 | self.assertEqual(importer.get_existing_instance("recNewRecordId", None), advert) 182 | 183 | self.assertEqual(importer.airtable_unique_identifier_field_name, "slug") 184 | self.assertEqual(importer.get_existing_instance("nothing", advert.slug), advert) 185 | 186 | def test_is_wagtail_page(self): 187 | self.assertTrue(AirtableModelImporter(SimplePage).model_is_page) 188 | self.assertFalse(AirtableModelImporter(Advert).model_is_page) 189 | 190 | def test_get_data_for_new_model(self): 191 | mapped_fields = convert_mapped_fields( 192 | self.get_valid_record_fields(), 193 | self.get_valid_mapped_fields(), 194 | ) 195 | 196 | data_for_new_model = get_data_for_new_model(mapped_fields, 'recSomeRecordId') 197 | self.assertTrue(data_for_new_model.get('airtable_record_id')) 198 | self.assertEqual(data_for_new_model['airtable_record_id'], 'recSomeRecordId') 199 | self.assertIsNone(data_for_new_model.get('id')) 200 | self.assertIsNone(data_for_new_model.get('pk')) 201 | 202 | @patch('wagtail_airtable.mixins.Api') 203 | def test_create_page(self, mixin_airtable): 204 | importer = AirtableModelImporter(model=SimplePage) 205 | self.assertEqual(Page.objects.get(slug="home").get_children().count(), 0) 206 | 207 | self.mock_airtable._table.all.side_effect = None 208 | self.mock_airtable._table.all.return_value = [{ 209 | "id": "test-created-page-id", 210 | "fields": { 211 | "title": "A simple page", 212 | "Page Slug": "a-simple-page", 213 | "intro": "How much more simple can it get? And the answer is none. None more simple.", 214 | }, 215 | }] 216 | hook_fn = MagicMock() 217 | with hooks.register_temporarily("airtable_import_record_updated", hook_fn): 218 | created_result = next(importer.run()) 219 | self.assertTrue(created_result.new) 220 | self.assertIsNone(created_result.errors) 221 | 222 | page = Page.objects.get(slug="home").get_children().first().specific 223 | self.assertIsInstance(page, SimplePage) 224 | hook_fn.assert_called_once_with(instance=page, is_wagtail_page=True, record_id="test-created-page-id") 225 | self.assertEqual(page.title, "A simple page") 226 | self.assertEqual(page.slug, "a-simple-page") 227 | self.assertEqual(page.intro, "How much more simple can it get? And the answer is none. None more simple.") 228 | self.assertFalse(page.live) 229 | 230 | @patch('wagtail_airtable.mixins.Api') 231 | def test_create_and_publish_page(self, mixin_airtable): 232 | new_settings = copy.deepcopy(settings.AIRTABLE_IMPORT_SETTINGS) 233 | new_settings['tests.SimplePage']['AUTO_PUBLISH_NEW_PAGES'] = True 234 | with override_settings(AIRTABLE_IMPORT_SETTINGS=new_settings): 235 | importer = AirtableModelImporter(model=SimplePage) 236 | self.assertEqual(Page.objects.get(slug="home").get_children().count(), 0) 237 | 238 | self.mock_airtable._table.all.side_effect = None 239 | self.mock_airtable._table.all.return_value = [{ 240 | "id": "test-created-page-id", 241 | "fields": { 242 | "title": "A simple page", 243 | "Page Slug": "a-simple-page", 244 | "intro": "How much more simple can it get? And the answer is none. None more simple.", 245 | }, 246 | }] 247 | hook_fn = MagicMock() 248 | with hooks.register_temporarily("airtable_import_record_updated", hook_fn): 249 | created_result = next(importer.run()) 250 | self.assertTrue(created_result.new) 251 | self.assertIsNone(created_result.errors) 252 | 253 | page = Page.objects.get(slug="home").get_children().first().specific 254 | self.assertIsInstance(page, SimplePage) 255 | hook_fn.assert_called_once_with(instance=page, is_wagtail_page=True, record_id="test-created-page-id") 256 | self.assertEqual(page.title, "A simple page") 257 | self.assertEqual(page.slug, "a-simple-page") 258 | self.assertEqual(page.intro, "How much more simple can it get? And the answer is none. None more simple.") 259 | self.assertTrue(page.live) 260 | 261 | @patch('wagtail_airtable.mixins.Api') 262 | def test_update_page(self, mixin_airtable): 263 | importer = AirtableModelImporter(model=SimplePage) 264 | parent_page = Page.objects.get(slug="home") 265 | page = SimplePage( 266 | title="A simple page", 267 | slug="a-simple-page", 268 | intro="How much more simple can it get? And the answer is none. None more simple.", 269 | ) 270 | page.push_to_airtable = False 271 | parent_page.add_child(instance=page) 272 | self.assertEqual(page.revisions.count(), 0) 273 | 274 | self.mock_airtable._table.all.side_effect = None 275 | self.mock_airtable._table.all.return_value = [{ 276 | "id": "test-created-page-id", 277 | "fields": { 278 | "title": "A simple page", 279 | "Page Slug": "a-simple-page", 280 | "intro": "How much more simple can it get? Oh, actually it can get more simple.", 281 | }, 282 | }] 283 | hook_fn = MagicMock() 284 | with hooks.register_temporarily("airtable_import_record_updated", hook_fn): 285 | updated_result = next(importer.run()) 286 | self.assertFalse(updated_result.new) 287 | self.assertIsNone(updated_result.errors) 288 | 289 | page.refresh_from_db() 290 | hook_fn.assert_called_once_with(instance=page, is_wagtail_page=True, record_id="test-created-page-id") 291 | self.assertEqual(page.title, "A simple page") 292 | self.assertEqual(page.slug, "a-simple-page") 293 | self.assertEqual(page.intro, "How much more simple can it get? Oh, actually it can get more simple.") 294 | self.assertEqual(page.revisions.count(), 1) 295 | 296 | @patch('wagtail_airtable.mixins.Api') 297 | def test_skip_update_page_if_unchanged(self, mixin_airtable): 298 | importer = AirtableModelImporter(model=SimplePage) 299 | parent_page = Page.objects.get(slug="home") 300 | page = SimplePage( 301 | title="A simple page", 302 | slug="a-simple-page", 303 | intro="How much more simple can it get? And the answer is none. None more simple.", 304 | ) 305 | page.push_to_airtable = False 306 | parent_page.add_child(instance=page) 307 | self.assertEqual(page.revisions.count(), 0) 308 | 309 | self.mock_airtable._table.all.side_effect = None 310 | self.mock_airtable._table.all.return_value = [{ 311 | "id": "test-created-page-id", 312 | "fields": { 313 | "title": "A simple page", 314 | "Page Slug": "a-simple-page", 315 | "intro": "How much more simple can it get? And the answer is none. None more simple.", 316 | }, 317 | }] 318 | hook_fn = MagicMock() 319 | with hooks.register_temporarily("airtable_import_record_updated", hook_fn): 320 | updated_result = next(importer.run()) 321 | self.assertFalse(updated_result.new) 322 | self.assertIsNone(updated_result.errors) 323 | hook_fn.assert_not_called() 324 | 325 | self.assertEqual(page.revisions.count(), 0) 326 | 327 | @patch('wagtail_airtable.mixins.Api') 328 | def test_skip_update_page_if_locked(self, mixin_airtable): 329 | importer = AirtableModelImporter(model=SimplePage) 330 | parent_page = Page.objects.get(slug="home") 331 | page = SimplePage( 332 | title="A simple page", 333 | slug="a-simple-page", 334 | intro="How much more simple can it get? And the answer is none. None more simple.", 335 | ) 336 | page.push_to_airtable = False 337 | page.locked = True 338 | parent_page.add_child(instance=page) 339 | self.assertEqual(page.revisions.count(), 0) 340 | 341 | self.mock_airtable._table.all.side_effect = None 342 | self.mock_airtable._table.all.return_value = [{ 343 | "id": "test-created-page-id", 344 | "fields": { 345 | "title": "A simple page", 346 | "Page Slug": "a-simple-page", 347 | "intro": "How much more simple can it get? Oh, actually it can get more simple.", 348 | }, 349 | }] 350 | hook_fn = MagicMock() 351 | with hooks.register_temporarily("airtable_import_record_updated", hook_fn): 352 | updated_result = next(importer.run()) 353 | self.assertFalse(updated_result.new) 354 | self.assertIsNone(updated_result.errors) 355 | hook_fn.assert_not_called() 356 | 357 | self.assertEqual(page.revisions.count(), 0) 358 | page.refresh_from_db() 359 | self.assertEqual(page.intro, "How much more simple can it get? And the answer is none. None more simple.") 360 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | from copy import copy 2 | 3 | from django.test import TestCase 4 | from pyairtable.formulas import match 5 | 6 | from tests.models import Advert, SimplePage 7 | from wagtail_airtable.mixins import AirtableMixin 8 | from unittest.mock import ANY, patch 9 | from .mock_airtable import get_mock_airtable 10 | 11 | 12 | class TestAirtableModel(TestCase): 13 | fixtures = ['test.json'] 14 | 15 | def setUp(self): 16 | airtable_patcher = patch("wagtail_airtable.mixins.Api", new_callable=get_mock_airtable()) 17 | airtable_patcher.start() 18 | self.addCleanup(airtable_patcher.stop) 19 | 20 | self.client.login(username='admin', password='password') 21 | self.advert = Advert.objects.first() 22 | 23 | def test_model_connection_settings(self): 24 | # Make sure the settings are being passed into the model after .setup_airtable() is called 25 | advert = copy(self.advert) 26 | advert.setup_airtable() 27 | self.assertEqual(advert.AIRTABLE_BASE_KEY, 'app_airtable_advert_base_key') 28 | self.assertEqual(advert.AIRTABLE_TABLE_NAME, 'Advert Table Name') 29 | self.assertEqual(advert.AIRTABLE_UNIQUE_IDENTIFIER, 'slug') 30 | self.assertEqual(advert.AIRTABLE_SERIALIZER, 'tests.serializers.AdvertSerializer') 31 | 32 | def test_model_connection_settings_before_setup(self): 33 | # Make sure instances are not instantiated with Airtable settings. 34 | # By preventing automatic Airtable API instantiation we can avoid 35 | # holding an Airtable API object on every model instance in Wagtail List views. 36 | advert = copy(self.advert) 37 | self.assertEqual(advert.AIRTABLE_BASE_KEY, None) 38 | self.assertEqual(advert.AIRTABLE_TABLE_NAME, None) 39 | self.assertEqual(advert.AIRTABLE_UNIQUE_IDENTIFIER, None) 40 | # Object don't need to store the AIRTABLE_SERIALIZER property on them. 41 | # Thus they should not have the property at all. 42 | self.assertFalse(hasattr(advert, 'AIRTABLE_SERIALIZER')) 43 | 44 | def test_get_export_fields(self): 45 | self.assertTrue(hasattr(self.advert, 'get_export_fields')) 46 | export_fields = self.advert.get_export_fields() 47 | self.assertEqual(type(export_fields), dict) 48 | 49 | def test_get_import_fields(self): 50 | self.assertTrue(hasattr(self.advert, 'map_import_fields')) 51 | mapped_import_fields = self.advert.map_import_fields() 52 | self.assertEqual(type(mapped_import_fields), dict) 53 | 54 | def test_create_object_from_url(self): 55 | self.client.post('/admin/snippets/tests/advert/add/', { 56 | 'title': 'Second advert', 57 | 'description': 'Lorem ipsum dolor sit amet, consectetur adipisicing elit.', 58 | 'rating': "1.5", 59 | 'slug': 'second-advert', 60 | }) 61 | advert = Advert.objects.last() 62 | self.assertEqual(advert.airtable_record_id, 'recNewRecordId') 63 | self.assertEqual(advert.title, 'Second advert') 64 | self.assertFalse(advert._ran_airtable_setup) 65 | self.assertFalse(advert._is_enabled) 66 | self.assertFalse(advert._push_to_airtable) 67 | self.assertFalse(hasattr(advert, 'airtable_client')) 68 | 69 | advert.setup_airtable() 70 | 71 | self.assertEqual(advert.AIRTABLE_BASE_KEY, 'app_airtable_advert_base_key') 72 | self.assertEqual(advert.AIRTABLE_TABLE_NAME, 'Advert Table Name') 73 | self.assertEqual(advert.AIRTABLE_UNIQUE_IDENTIFIER, 'slug') 74 | self.assertTrue(advert._ran_airtable_setup) 75 | self.assertTrue(advert._is_enabled) 76 | self.assertTrue(advert._push_to_airtable) 77 | self.assertTrue(hasattr(advert, 'airtable_client')) 78 | 79 | def test_create_object_with_existing_airtable_record_id(self): 80 | advert = Advert.objects.create( 81 | title='Testing creation', 82 | description='Lorem ipsum dolor sit amet, consectetur adipisicing elit.', 83 | rating="2.5", 84 | slug='testing-creation', 85 | airtable_record_id='recNewRecordId', 86 | ) 87 | # save_to_airtable will confirm that a record with the given ID exists 88 | # and update that record 89 | advert.airtable_client._table.get.assert_called_once_with('recNewRecordId') 90 | advert.airtable_client._table.update.assert_called_once_with('recNewRecordId', ANY) 91 | call_args = advert.airtable_client._table.update.call_args.args 92 | self.assertEqual(call_args[1]['title'], 'Testing creation') 93 | advert.airtable_client._table.create.assert_not_called() 94 | 95 | def test_create_object_with_missing_id_and_matching_airtable_record(self): 96 | advert = Advert.objects.create( 97 | title='Testing creation', 98 | description='Lorem ipsum dolor sit amet, consectetur adipisicing elit.', 99 | rating="2.5", 100 | slug='a-matching-slug', 101 | airtable_record_id='recMissingRecordId', 102 | ) 103 | # save_to_airtable will find that a record with the given ID does not exist, 104 | # but one matching the slug does, and update that record 105 | advert.airtable_client._table.get.assert_called_once_with('recMissingRecordId') 106 | advert.airtable_client._table.all.assert_called_once_with(formula=match({'slug': 'a-matching-slug'})) 107 | advert.airtable_client._table.update.assert_called_once_with('recMatchedRecordId', ANY) 108 | call_args = advert.airtable_client._table.update.call_args.args 109 | self.assertEqual(call_args[1]['title'], 'Testing creation') 110 | advert.airtable_client._table.create.assert_not_called() 111 | advert.refresh_from_db() 112 | self.assertEqual(advert.airtable_record_id, 'recMatchedRecordId') 113 | 114 | def test_create_object_with_no_id_and_matching_airtable_record(self): 115 | advert = Advert.objects.create( 116 | title='Testing creation', 117 | description='Lorem ipsum dolor sit amet, consectetur adipisicing elit.', 118 | rating="2.5", 119 | slug='a-matching-slug', 120 | ) 121 | # save_to_airtable will skip the lookup by ID, but find a record matching the slug, 122 | # and update that record 123 | advert.airtable_client._table.get.assert_not_called() 124 | advert.airtable_client._table.all.assert_called_once_with(formula=match({'slug': 'a-matching-slug'})) 125 | advert.airtable_client._table.update.assert_called_once_with('recMatchedRecordId', ANY) 126 | call_args = advert.airtable_client._table.update.call_args.args 127 | self.assertEqual(call_args[1]['title'], 'Testing creation') 128 | advert.airtable_client._table.create.assert_not_called() 129 | advert.refresh_from_db() 130 | self.assertEqual(advert.airtable_record_id, 'recMatchedRecordId') 131 | 132 | def test_create_object_with_missing_id_and_non_matching_airtable_record(self): 133 | advert = Advert.objects.create( 134 | title='Testing creation', 135 | description='Lorem ipsum dolor sit amet, consectetur adipisicing elit.', 136 | rating="2.5", 137 | slug='a-non-matching-slug', 138 | airtable_record_id='recMissingRecordId', 139 | ) 140 | # save_to_airtable will find that a record with the given ID does not exist, 141 | # and neither does one matching the slug - so it will create a new one 142 | # and update the model with the new record ID 143 | advert.airtable_client._table.get.assert_called_once_with('recMissingRecordId') 144 | advert.airtable_client._table.all.assert_called_once_with(formula=match({'slug': 'a-non-matching-slug'})) 145 | advert.airtable_client._table.create.assert_called_once() 146 | call_args = advert.airtable_client._table.create.call_args.args 147 | self.assertEqual(call_args[0]['title'], 'Testing creation') 148 | advert.airtable_client._table.update.assert_not_called() 149 | advert.refresh_from_db() 150 | self.assertEqual(advert.airtable_record_id, 'recNewRecordId') 151 | 152 | def test_edit_object(self): 153 | advert = Advert.objects.get(airtable_record_id='recNewRecordId') 154 | advert.title = "Edited title" 155 | advert.description = "Edited description" 156 | advert.save() 157 | # save_to_airtable will confirm that a record with the given ID exists and update it 158 | advert.airtable_client._table.get.assert_called_once_with('recNewRecordId') 159 | advert.airtable_client._table.update.assert_called_once_with('recNewRecordId', ANY) 160 | call_args = advert.airtable_client._table.update.call_args.args 161 | advert.airtable_client._table.create.assert_not_called() 162 | self.assertEqual(call_args[1]['title'], 'Edited title') 163 | self.assertEqual(advert.title, "Edited title") 164 | 165 | def test_delete_object(self): 166 | advert = Advert.objects.get(slug='delete-me') 167 | self.assertEqual(advert.airtable_record_id, 'recNewRecordId') 168 | advert.delete() 169 | advert.airtable_client._table.delete.assert_called_once_with('recNewRecordId') 170 | find_deleted_advert = Advert.objects.filter(slug='delete-me').count() 171 | self.assertEqual(find_deleted_advert, 0) 172 | 173 | 174 | class TestAirtableMixin(TestCase): 175 | fixtures = ['test.json'] 176 | 177 | def setUp(self): 178 | airtable_patcher = patch("wagtail_airtable.mixins.Api", new_callable=get_mock_airtable()) 179 | self.mock_airtable = airtable_patcher.start() 180 | self.addCleanup(airtable_patcher.stop) 181 | 182 | def test_setup_airtable(self): 183 | advert = copy(Advert.objects.first()) 184 | self.assertFalse(advert._ran_airtable_setup) 185 | self.assertFalse(advert._is_enabled) 186 | self.assertFalse(advert._push_to_airtable) 187 | self.assertFalse(hasattr(advert, 'airtable_client')) 188 | 189 | advert.setup_airtable() 190 | 191 | self.assertEqual(advert.AIRTABLE_BASE_KEY, 'app_airtable_advert_base_key') 192 | self.assertEqual(advert.AIRTABLE_TABLE_NAME, 'Advert Table Name') 193 | self.assertEqual(advert.AIRTABLE_UNIQUE_IDENTIFIER, 'slug') 194 | self.assertTrue(advert._ran_airtable_setup) 195 | self.assertTrue(advert._is_enabled) 196 | self.assertTrue(advert._push_to_airtable) 197 | self.assertTrue(hasattr(advert, 'airtable_client')) 198 | 199 | def test_delete_record(self): 200 | advert = Advert.objects.get(airtable_record_id='recNewRecordId') 201 | advert.setup_airtable() 202 | deleted = advert.delete_record() 203 | self.assertTrue(deleted) 204 | advert.airtable_client._table.delete.assert_called_once_with("recNewRecordId") 205 | 206 | def test_parse_request_error(self): 207 | error_401 = "401 Client Error: Unauthorized for url: https://api.airtable.com/v0/appYourAppId/Your%20Table?filterByFormula=.... [Error: {'type': 'AUTHENTICATION_REQUIRED', 'message': 'Authentication required'}]" 208 | parsed_error = AirtableMixin.parse_request_error(error_401) 209 | self.assertEqual(parsed_error['status_code'], 401) 210 | self.assertEqual(parsed_error['type'], 'AUTHENTICATION_REQUIRED') 211 | self.assertEqual(parsed_error['message'], 'Authentication required') 212 | 213 | error_404 = "404 Client Error: Not Found for url: https://api.airtable.com/v0/app3dozZtsCotiIpf/Brokerages/nope [Error: NOT_FOUND]" 214 | parsed_error = AirtableMixin.parse_request_error(error_404) 215 | self.assertEqual(parsed_error['status_code'], 404) 216 | self.assertEqual(parsed_error['type'], 'NOT_FOUND') 217 | self.assertEqual(parsed_error['message'], 'Record not found') 218 | 219 | error_404 = "404 Client Error: Not Found for url: https://api.airtable.com/v0/app3dozZtsCotiIpf/Brokerages%2022 [Error: {'type': 'TABLE_NOT_FOUND', 'message': 'Could not find table table_name in appxxxxx'}]" 220 | parsed_error = AirtableMixin.parse_request_error(error_404) 221 | self.assertEqual(parsed_error['status_code'], 404) 222 | self.assertEqual(parsed_error['type'], 'TABLE_NOT_FOUND') 223 | self.assertEqual(parsed_error['message'], 'Could not find table table_name in appxxxxx') 224 | 225 | def test_match_record(self): 226 | advert = Advert.objects.get(slug='red-its-new-blue') 227 | advert.setup_airtable() 228 | record_id = advert.match_record() 229 | self.assertEqual(record_id, 'recNewRecordId') 230 | advert.airtable_client._table.all.assert_called_once_with(formula=match({'slug': 'red-its-new-blue'})) 231 | 232 | def test_match_record_with_dict_identifier(self): 233 | page = SimplePage.objects.get(slug='home') 234 | page.setup_airtable() 235 | record_id = page.match_record() 236 | self.assertEqual(record_id, 'recHomePageId') 237 | page.airtable_client._table.all.assert_called_once_with(formula=match({'Page Slug': 'home'})) 238 | 239 | def test_check_record_exists(self): 240 | advert = Advert.objects.get(airtable_record_id='recNewRecordId') 241 | advert.setup_airtable() 242 | record_exists = advert.check_record_exists('recNewRecordId') 243 | self.assertTrue(record_exists) 244 | advert.airtable_client._table.get.assert_called_once_with('recNewRecordId') 245 | -------------------------------------------------------------------------------- /tests/test_templatetags.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from wagtail_airtable.templatetags.wagtail_airtable_tags import \ 4 | can_import_model 5 | 6 | 7 | class TestTemplateTags(TestCase): 8 | 9 | def test_can_import_model(self): 10 | allowed_to_import = can_import_model("tests.Advert") 11 | self.assertTrue(allowed_to_import) 12 | 13 | def test_cannot_import_model(self): 14 | allowed_to_import = can_import_model("tests.MissingModel") 15 | self.assertFalse(allowed_to_import) 16 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | from django.contrib.messages import get_messages 4 | from django.core.exceptions import ImproperlyConfigured 5 | from django.test import TestCase, override_settings 6 | 7 | from tests.models import Advert, Publication, SimilarToAdvert, SimplePage 8 | from wagtail_airtable.utils import (airtable_message, 9 | can_send_airtable_messages, get_all_models, 10 | get_model_for_path, get_validated_models) 11 | from wagtail_airtable.mixins import AirtableMixin 12 | 13 | 14 | class TestUtilFunctions(TestCase): 15 | fixtures = ['test.json'] 16 | 17 | def setUp(self): 18 | self.client.login(username='admin', password='password') 19 | 20 | def test_get_model_for_path(self): 21 | advert_model = get_model_for_path("tests.Advert") 22 | self.assertEqual(advert_model, Advert) 23 | simple_page = get_model_for_path("tests.SimplePage") 24 | self.assertEqual(simple_page, SimplePage) 25 | bad_model_path = get_model_for_path("tests.BadModelPathName") 26 | self.assertFalse(bad_model_path) 27 | 28 | def test_get_validated_models_with_single_valid_model(self): 29 | models = ["tests.Advert"] 30 | models = get_validated_models(models=models) 31 | self.assertListEqual(models, [Advert]) 32 | 33 | def test_get_validated_models_with_multiple_valid_models(self): 34 | models = ["tests.Advert", "tests.SimplePage", "tests.SimilarToAdvert"] 35 | models = get_validated_models(models=models) 36 | self.assertListEqual(models, [Advert, SimplePage, SimilarToAdvert]) 37 | 38 | def test_get_validated_models_with_invalid_model(self): 39 | models = ["fake.ModelName"] 40 | with self.assertRaises(ImproperlyConfigured) as context: 41 | get_validated_models(models=models) 42 | self.assertEqual("'fake.ModelName' is not recognised as a model name.", str(context.exception)) 43 | 44 | def test_get_all_models(self): 45 | available_models = get_all_models() 46 | self.assertListEqual(available_models, [SimplePage, Advert, SimilarToAdvert]) 47 | 48 | def test_get_all_models_as_path(self): 49 | available_models = get_all_models(as_path=True) 50 | self.assertListEqual(available_models, ['tests.simplepage', 'tests.advert', 'tests.similartoadvert']) 51 | 52 | def test_can_send_airtable_messages(self): 53 | instance = Advert.objects.first() 54 | enabled = can_send_airtable_messages(instance) 55 | self.assertTrue(enabled) 56 | 57 | def test_cannot_send_airtable_messages(self): 58 | instance = Publication.objects.create(title="Moby Dick") 59 | enabled = can_send_airtable_messages(instance) 60 | self.assertFalse(enabled) 61 | 62 | def test_airtable_messages(self): 63 | instance = Advert.objects.first() 64 | response = self.client.get('/admin/login/') 65 | request = response.wsgi_request 66 | result = airtable_message(request, instance, message="Custom message here", button_text="Custom button text") 67 | self.assertEqual(result, None) 68 | 69 | instance.airtable_record_id = 'recTestingRecordId' # Enables the Airtable button 70 | result = airtable_message(request, instance, message="Second custom message here", button_text="2nd custom button text") 71 | messages = list(get_messages(response.wsgi_request)) 72 | self.assertEqual(len(messages), 2) 73 | 74 | message1 = messages[0].message 75 | self.assertIn('Custom message here', message1) 76 | self.assertNotIn('Custom button text', message1) 77 | 78 | message2 = messages[1].message 79 | self.assertIn('Second custom message here', message2) 80 | self.assertIn('2nd custom button text', message2) 81 | 82 | @patch.object(AirtableMixin, 'save_to_airtable') 83 | def test_save_airtable_call(self, mock): 84 | instance = Advert.objects.last() 85 | 86 | instance.save() 87 | 88 | self.assertTrue(mock.called) 89 | 90 | @patch.object(AirtableMixin, 'save_to_airtable') 91 | @override_settings(WAGTAIL_AIRTABLE_SAVE_SYNC=False) 92 | def test_save_airtable_not_called(self, mock): 93 | instance = Advert.objects.last() 94 | 95 | instance.save() 96 | 97 | self.assertFalse(mock.called) 98 | -------------------------------------------------------------------------------- /tests/test_views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.messages import get_messages 2 | from django.test import TestCase, override_settings 3 | from django.urls import reverse 4 | 5 | from tests.models import Advert 6 | from unittest.mock import patch 7 | from .mock_airtable import get_mock_airtable 8 | 9 | 10 | class TestAdminViews(TestCase): 11 | fixtures = ['test.json'] 12 | 13 | def setUp(self): 14 | airtable_mixins_patcher = patch("wagtail_airtable.mixins.Api", new_callable=get_mock_airtable()) 15 | airtable_mixins_patcher.start() 16 | self.addCleanup(airtable_mixins_patcher.stop) 17 | airtable_importer_patcher = patch("wagtail_airtable.importer.Api", new_callable=get_mock_airtable()) 18 | self.mock_airtable = airtable_importer_patcher.start() 19 | self.addCleanup(airtable_importer_patcher.stop) 20 | 21 | self.client.login(username='admin', password='password') 22 | 23 | def test_get(self): 24 | response = self.client.get(reverse('airtable_import_listing')) 25 | self.assertEqual(response.status_code, 200) 26 | self.assertContains(response, 'Models you can import from Airtable') 27 | self.assertContains(response, 'Advert') 28 | self.assertNotContains(response, 'Simple Page') 29 | 30 | def test_post(self): 31 | advert = Advert.objects.get(airtable_record_id="recNewRecordId") 32 | self.assertNotEqual(advert.title, "Red! It's the new blue!") 33 | 34 | response = self.client.post(reverse('airtable_import_listing'), { 35 | 'model': 'tests.Advert', 36 | }) 37 | self.assertRedirects(response, reverse('airtable_import_listing')) 38 | 39 | advert.refresh_from_db() 40 | self.assertEqual(advert.title, "Red! It's the new blue!") 41 | 42 | def test_list_snippets(self): 43 | url = reverse('wagtailsnippets_tests_advert:list') 44 | 45 | response = self.client.get(url) 46 | self.assertEqual(response.status_code, 200) 47 | 48 | def test_snippet_detail(self): 49 | url = reverse('wagtailsnippets_tests_advert:edit', args=[1]) 50 | 51 | response = self.client.get(url) 52 | self.assertEqual(response.status_code, 200) 53 | # Ensure the default Advert does not have an Airtable Record ID 54 | instance = response.context_data['object'] 55 | 56 | self.assertEqual(instance.airtable_record_id, '') 57 | 58 | def test_import_snippet_button_on_list_view(self): 59 | url = reverse('wagtailsnippets_tests_advert:list') 60 | 61 | response = self.client.get(url) 62 | self.assertContains(response, 'Import Advert') 63 | 64 | def test_no_import_snippet_button_on_list_view(self): 65 | url = reverse('wagtailsnippets_tests_modelnotused:list') 66 | 67 | response = self.client.get(url) 68 | self.assertNotContains(response, 'Import Advert') 69 | 70 | def test_airtable_message_on_instance_create(self): 71 | url = reverse('wagtailsnippets_tests_advert:add') 72 | 73 | response = self.client.post(url, { 74 | 'title': 'New advert', 75 | 'description': 'Lorem ipsum dolor sit amet, consectetur adipisicing elit.', 76 | 'rating': "1.5", 77 | 'slug': 'wow-super-new-advert', 78 | }) 79 | messages = list(get_messages(response.wsgi_request)) 80 | self.assertEqual(len(messages), 2) 81 | self.assertIn('Advertisement 'New advert' created', messages[0].message) 82 | self.assertIn('Airtable record updated', messages[1].message) 83 | 84 | def test_airtable_message_on_instance_edit(self): 85 | advert = Advert.objects.first() 86 | 87 | url = reverse('wagtailsnippets_tests_advert:edit', args=[advert.pk]) 88 | 89 | response = self.client.post(url, { 90 | 'title': 'Edited', 91 | 'description': 'Edited advert', 92 | 'slug': 'crazy-edited-advert-insane-right', 93 | 'rating': "1.5", 94 | 'is_active': True, 95 | }) 96 | messages = list(get_messages(response.wsgi_request)) 97 | self.assertEqual(len(messages), 2) 98 | self.assertIn('Advertisement 'Edited' updated', messages[0].message) 99 | self.assertIn('Airtable record updated', messages[1].message) 100 | 101 | def test_airtable_message_on_instance_delete(self): 102 | advert = Advert.objects.get(slug='delete-me') 103 | 104 | url = reverse('wagtailsnippets_tests_advert:delete', args=[advert.pk]) 105 | 106 | response = self.client.post(url) 107 | messages = list(get_messages(response.wsgi_request)) 108 | self.assertEqual(len(messages), 2) 109 | self.assertIn('Advertisement 'Wow brand new?!' deleted', messages[0].message) 110 | self.assertIn('Airtable record deleted', messages[1].message) 111 | 112 | def test_snippet_list_redirect(self): 113 | airtable_import_url = reverse("airtable_import_listing") 114 | params = [ 115 | # DEBUG, next URL, expected redirect 116 | (True, "http://testserver/redirect/", "http://testserver/redirect/"), 117 | (True, "http://not-allowed-host/redirect/", airtable_import_url), 118 | (False, "https://testserver/redirect/", "https://testserver/redirect/"), 119 | (False, "http://testserver/redirect/", airtable_import_url), 120 | (False, "https://not-allowed-host/redirect/", airtable_import_url), 121 | ] 122 | for debug, next_url, expected_location in params: 123 | with self.subTest( 124 | debug=debug, next_url=next_url, expected_location=expected_location 125 | ): 126 | with override_settings(DEBUG=debug): 127 | response = self.client.post( 128 | airtable_import_url, 129 | {"model": "Advert", "next": next_url}, 130 | secure=next_url.startswith("https"), 131 | ) 132 | self.assertEqual(response.url, expected_location) 133 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import include, path 2 | from wagtail import urls as wagtail_urls 3 | from wagtail.admin import urls as wagtailadmin_urls 4 | 5 | urlpatterns = [ 6 | path("admin/", include(wagtailadmin_urls)), 7 | path("", include(wagtail_urls)), 8 | ] 9 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | python{3.9,3.10,3.11,3.12,3.13}-django{4.2,5.0,5.1}-wagtail{5.2,6.0,6.1,6.2,6.3} 4 | 5 | [testenv] 6 | commands = python runtests.py 7 | 8 | basepython = 9 | python3.9: python3.9 10 | python3.10: python3.10 11 | python3.11: python3.11 12 | python3.12: python3.12 13 | python3.13: python3.13 14 | 15 | deps = 16 | django4.2: Django>=4.2,<5.0 17 | django5.0: Django>=5.0,<5.1 18 | django5.0: Django>=5.1,<5.2 19 | wagtail5.2: wagtail>=5.2,<6.0 20 | wagtail6.0: wagtail>=6.0,<6.1 21 | wagtail6.1: wagtail>=6.1,<6.2 22 | wagtail6.2: wagtail>=6.2,<6.3 23 | wagtail6.3: wagtail>=6.3,<6.4 24 | -------------------------------------------------------------------------------- /wagtail_airtable/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wagtail-nest/wagtail-airtable/0ef4627cbbd641b2a1287bd6498d008ff6b0c101/wagtail_airtable/__init__.py -------------------------------------------------------------------------------- /wagtail_airtable/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class WagtailAirtableConfig(AppConfig): 5 | name = "wagtail_airtable" 6 | -------------------------------------------------------------------------------- /wagtail_airtable/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.conf import settings 3 | 4 | 5 | class AirtableImportModelForm(forms.Form): 6 | 7 | model = forms.CharField() 8 | 9 | def clean_model(self): 10 | """Make sure this model is in the AIRTABLE_IMPORT_SETTINGS config.""" 11 | 12 | model_label = self.cleaned_data["model"].lower() 13 | airtable_settings = getattr(settings, "AIRTABLE_IMPORT_SETTINGS", {}) 14 | is_valid_model = False 15 | 16 | for label, model_settings in airtable_settings.items(): 17 | if model_label == label.lower(): 18 | is_valid_model = True 19 | break 20 | 21 | if not is_valid_model: 22 | raise forms.ValidationError("You are importing an unsupported model") 23 | 24 | return model_label 25 | -------------------------------------------------------------------------------- /wagtail_airtable/importer.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from pyairtable import Api 3 | from django.conf import settings 4 | from django.db.models.fields.related import ManyToManyField 5 | from modelcluster.contrib.taggit import ClusterTaggableManager 6 | from taggit.managers import TaggableManager 7 | from wagtail import hooks 8 | from wagtail.models import Page 9 | from .utils import import_string 10 | from typing import NamedTuple, Optional 11 | from django.db import transaction 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | class AirtableImportResult(NamedTuple): 16 | record_id: str 17 | fields: dict 18 | new: bool 19 | errors: Optional[dict] = None 20 | 21 | def error_display(self): 22 | """ 23 | Show the errors in a human-readable way 24 | """ 25 | if "exception" in self.errors: 26 | return repr(self.errors['exception']) 27 | # It's probably a DRF validation error 28 | output = "" 29 | for field_name, exceptions in sorted(self.errors.items()): 30 | output += f"{field_name}: " 31 | output += ", ".join([str(e).rstrip(".") for e in exceptions]) + ". " 32 | return output.strip() 33 | 34 | 35 | def get_column_to_field_names(airtable_unique_identifier) -> tuple: 36 | uniq_id_type = type(airtable_unique_identifier) 37 | airtable_unique_identifier_column_name = None 38 | airtable_unique_identifier_field_name = None 39 | if uniq_id_type == str: 40 | # The unique identifier is a string. 41 | # Use it as the Airtable Column name and the Django field name 42 | airtable_unique_identifier_column_name = airtable_unique_identifier 43 | airtable_unique_identifier_field_name = airtable_unique_identifier 44 | elif uniq_id_type == dict: 45 | # Unique identifier is a dictionary. 46 | # Use the key as the Airtable Column name and the value as the Django Field name. 47 | ( 48 | airtable_unique_identifier_column_name, 49 | airtable_unique_identifier_field_name, 50 | ) = list(airtable_unique_identifier.items())[0] 51 | 52 | return ( 53 | airtable_unique_identifier_column_name, 54 | airtable_unique_identifier_field_name, 55 | ) 56 | 57 | 58 | def convert_mapped_fields(record_fields_dict, mapped_fields_dict) -> dict: 59 | # Create a dictionary of newly mapped key:value pairs based on the `mappings` dict above. 60 | # This wil convert "airtable column name" to "django_field_name" 61 | mapped_fields_dict = { 62 | mapped_fields_dict[key]: value 63 | for (key, value) in record_fields_dict.items() 64 | if key in mapped_fields_dict 65 | } 66 | return mapped_fields_dict 67 | 68 | 69 | def get_data_for_new_model(data, record_id): 70 | data_for_new_model = data.copy() 71 | data_for_new_model["airtable_record_id"] = record_id 72 | 73 | # First things first, remove any "pk" or "id" items from the mapped_import_fields 74 | # This will let Django and Wagtail handle the PK on its own, as it should. 75 | # When the model is saved it'll trigger a push to Airtable and automatically update 76 | # the necessary column with the new PK so it's always accurate. 77 | for key in { 78 | "pk", 79 | "id", 80 | }: 81 | try: 82 | del data_for_new_model[key] 83 | except KeyError: 84 | pass 85 | 86 | return data_for_new_model 87 | 88 | 89 | 90 | class AirtableModelImporter: 91 | def __init__(self, model, verbosity=1): 92 | self.model = model 93 | self.model_settings = settings.AIRTABLE_IMPORT_SETTINGS[model._meta.label] 94 | self.model_is_page = issubclass(model, Page) 95 | self.model_serializer = import_string(self.model_settings["AIRTABLE_SERIALIZER"]) 96 | 97 | if verbosity >= 2: 98 | logger.setLevel(logging.DEBUG) 99 | 100 | api = Api(api_key=settings.AIRTABLE_API_KEY) 101 | self.airtable_client = api.table( 102 | self.model_settings.get("AIRTABLE_BASE_KEY"), 103 | self.model_settings.get("AIRTABLE_TABLE_NAME"), 104 | ) 105 | 106 | ( 107 | self.airtable_unique_identifier_column_name, 108 | self.airtable_unique_identifier_field_name, 109 | ) = get_column_to_field_names( 110 | self.model_settings.get("AIRTABLE_UNIQUE_IDENTIFIER") 111 | ) 112 | 113 | if not ( 114 | self.airtable_unique_identifier_field_name and self.airtable_unique_identifier_column_name 115 | ): 116 | raise ValueError("No unique columns are set in your Airtable configuration") 117 | 118 | parent_page_id_setting = self.model_settings.get("PARENT_PAGE_ID", None) 119 | if parent_page_id_setting: 120 | if callable(parent_page_id_setting): 121 | # A function was passed into the settings. Execute it. 122 | parent_page_id = parent_page_id_setting() 123 | elif isinstance(parent_page_id_setting, str): 124 | parent_page_callable = import_string(parent_page_id_setting) 125 | parent_page_id = parent_page_callable() 126 | else: 127 | parent_page_id = parent_page_id_setting 128 | 129 | self.parent_page = Page.objects.get(pk=parent_page_id) 130 | else: 131 | self.parent_page = None 132 | 133 | def field_is_m2m(self, field_name): 134 | field_type = type(self.model._meta.get_field(field_name)) 135 | 136 | return issubclass( 137 | field_type, 138 | ( 139 | TaggableManager, 140 | ClusterTaggableManager, 141 | ManyToManyField, 142 | ) 143 | ) 144 | 145 | def update_object(self, instance, record_id, data): 146 | if self.model_is_page and instance.locked: 147 | logger.debug("Instance for %s is locked. Not updating.", record_id) 148 | return False 149 | 150 | if self.model_is_page: 151 | before = instance.to_json() 152 | 153 | for field_name, value in data.items(): 154 | if self.field_is_m2m(field_name): 155 | # override existing values 156 | getattr(instance, field_name).set(value) 157 | else: 158 | setattr(instance, field_name, value) 159 | 160 | if self.model_is_page and before == instance.to_json(): 161 | logger.debug("Instance %s didn't change, skipping save.", record_id) 162 | return False 163 | 164 | # When an object is saved it should NOT push its newly saved data back to Airtable. 165 | # This could theoretically cause a loop. By default this setting is False. But the 166 | # below line confirms it's false, just to be safe. 167 | instance.push_to_airtable = False 168 | 169 | instance.airtable_record_id = record_id 170 | instance._skip_signals = True 171 | instance.save() 172 | if self.model_is_page: 173 | # When saving a page, create it as a new revision 174 | instance.save_revision() 175 | 176 | for fn in hooks.get_hooks("airtable_import_record_updated"): 177 | fn(instance=instance, is_wagtail_page=self.model_is_page, record_id=record_id) 178 | 179 | return True 180 | 181 | def create_object(self, data, record_id): 182 | data_for_new_model = get_data_for_new_model(data, record_id) 183 | 184 | # extract m2m fields to avoid getting the error 185 | # "direct assignment to the forward side of a many-to-many set is prohibited" 186 | m2m_data = {} 187 | non_m2m_data = {} 188 | for field_name, value in data_for_new_model.items(): 189 | if self.field_is_m2m(field_name): 190 | m2m_data[field_name] = value 191 | else: 192 | non_m2m_data[field_name] = value 193 | 194 | new_model = self.model(**non_m2m_data) 195 | new_model._skip_signals = True 196 | new_model.push_to_airtable = False 197 | 198 | if self.parent_page: 199 | new_model.live = False 200 | new_model.has_unpublished_changes = True 201 | 202 | self.parent_page.add_child(instance=new_model) 203 | 204 | # If the settings are set to auto publish a page when it's imported, 205 | # save the page revision and publish it. Otherwise just save the revision. 206 | if self.model_settings.get("AUTO_PUBLISH_NEW_PAGES", False): 207 | new_model.save_revision().publish() 208 | else: 209 | new_model.save_revision() 210 | else: 211 | new_model.save() 212 | for field_name, value in m2m_data.items(): 213 | getattr(new_model, field_name).set(value) 214 | 215 | for fn in hooks.get_hooks("airtable_import_record_updated"): 216 | fn(instance=new_model, is_wagtail_page=self.model_is_page, record_id=record_id) 217 | 218 | def get_existing_instance(self, record_id, unique_identifier): 219 | existing_by_record_id = self.model.objects.filter(airtable_record_id=record_id).first() 220 | if existing_by_record_id is not None: 221 | logger.debug("Found existing instance by id: %s", existing_by_record_id.id) 222 | return existing_by_record_id 223 | 224 | existing_by_unique_identifier = self.model.objects.filter( 225 | **{self.airtable_unique_identifier_field_name: unique_identifier} 226 | ).first() 227 | if existing_by_unique_identifier is not None: 228 | logger.debug("Found existing instance by unique identifier: %s", existing_by_unique_identifier.id) 229 | return existing_by_unique_identifier 230 | 231 | # Couldn't find an instance 232 | return None 233 | 234 | @transaction.atomic 235 | def process_record(self, record): 236 | record_id = record['id'] 237 | fields = record["fields"] 238 | 239 | mapped_import_fields = convert_mapped_fields( 240 | fields, self.model.map_import_fields() 241 | ) 242 | 243 | unique_identifier = fields.get( 244 | self.airtable_unique_identifier_column_name, None 245 | ) 246 | obj = self.get_existing_instance(record_id, unique_identifier) 247 | 248 | logger.debug("Validating data for %s", record_id) 249 | serializer = self.model_serializer(data=mapped_import_fields) 250 | 251 | if not serializer.is_valid(): 252 | return AirtableImportResult(record_id, fields, errors=serializer.errors, new=obj is not None) 253 | 254 | if obj: 255 | logger.debug("Attempting update of %s", obj.id) 256 | try: 257 | was_updated = self.update_object( 258 | instance=obj, 259 | record_id=record_id, 260 | data=serializer.validated_data, 261 | ) 262 | except Exception as e: # noqa: B902 263 | return AirtableImportResult(record_id, fields, new=False, errors={"exception": e}) 264 | if was_updated: 265 | logger.debug("Updated instance for %s", record_id) 266 | else: 267 | logger.debug("Skipped update for %s", record_id) 268 | return AirtableImportResult(record_id, fields, new=False) 269 | else: 270 | logger.debug("Creating model for %s", record_id) 271 | try: 272 | self.create_object(serializer.validated_data, record_id) 273 | except Exception as e: # noqa: B902 274 | return AirtableImportResult(record_id, fields, new=True, errors={"exception": e}) 275 | logger.debug("Created instance for %s", record_id) 276 | return AirtableImportResult(record_id, fields, new=True) 277 | 278 | def run(self): 279 | for page in self.airtable_client.iterate(): 280 | for record in page: 281 | logger.info("Processing record %s", record["id"]) 282 | yield self.process_record(record) 283 | -------------------------------------------------------------------------------- /wagtail_airtable/management/commands/import_airtable.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | from django.conf import settings 3 | from wagtail_airtable.importer import AirtableModelImporter 4 | from wagtail_airtable.utils import get_validated_models 5 | import logging 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | class Command(BaseCommand): 11 | help = "Import data from an Airtable and overwrite model or page information" 12 | 13 | def add_arguments(self, parser): 14 | parser.add_argument( 15 | "model_names", 16 | metavar="model_name", 17 | nargs="+", 18 | help="Model (as app_label.model_name) or app name to populate table entries for, e.g. creditcards.CreditCard", 19 | ) 20 | 21 | def handle(self, *args, **options): 22 | # Overwrite verbosity if WAGTAIL_AIRTABLE_DEBUG is enabled. 23 | if settings.DEBUG: 24 | # AIRTABLE_DEBUG can only be enabled if standard Django DEBUG is enabled. 25 | # The idea is to protect logs and output in production from the noise of these imports. 26 | AIRTABLE_DEBUG = getattr(settings, "WAGTAIL_AIRTABLE_DEBUG", False) 27 | if AIRTABLE_DEBUG: 28 | options["verbosity"] = 2 29 | 30 | error_results = 0 31 | new_results = 0 32 | updated_results = 0 33 | 34 | for model in get_validated_models(options["model_names"]): 35 | importer = AirtableModelImporter(model=model, verbosity=options["verbosity"]) 36 | 37 | for result in importer.run(): 38 | if result.errors: 39 | logger.error("Failed to import %s %s", result.record_id, result.errors) 40 | error_results += 1 41 | elif result.new: 42 | new_results += 1 43 | else: 44 | updated_results += 1 45 | 46 | return f"{new_results} objects created. {updated_results} objects updated. {error_results} objects skipped due to errors." 47 | -------------------------------------------------------------------------------- /wagtail_airtable/management/commands/reset_local_airtable_records.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | 3 | from wagtail_airtable.utils import get_all_models 4 | 5 | 6 | class Command(BaseCommand): 7 | help = "Looks through every available model in the AIRTABLE_IMPORT_SETTINGS and unsets the `airtable_record_id`" 8 | 9 | def handle(self, *args, **options): 10 | """ 11 | Gets all the models set in AIRTABLE_IMPORT_SETTINGS, loops through them, and set `airtable_record_id=''` to every one. 12 | """ 13 | records_updated = 0 14 | models = get_all_models() 15 | for model in models: 16 | if hasattr(model, "airtable_record_id"): 17 | total_updated = model.objects.update(airtable_record_id="") 18 | records_updated = records_updated + total_updated 19 | 20 | if options["verbosity"] >= 1: 21 | self.stdout.write(f"Set {records_updated} objects to airtable_record_id=''") 22 | -------------------------------------------------------------------------------- /wagtail_airtable/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wagtail-nest/wagtail-airtable/0ef4627cbbd641b2a1287bd6498d008ff6b0c101/wagtail_airtable/migrations/__init__.py -------------------------------------------------------------------------------- /wagtail_airtable/mixins.py: -------------------------------------------------------------------------------- 1 | from ast import literal_eval 2 | from logging import getLogger 3 | 4 | from pyairtable import Api 5 | from pyairtable.formulas import match 6 | from django.conf import settings 7 | from django.db import models 8 | from django.middleware.csrf import get_token 9 | from django.urls import reverse 10 | from django.utils.functional import cached_property 11 | from requests import HTTPError 12 | 13 | from wagtail.admin.widgets.button import Button 14 | 15 | logger = getLogger(__name__) 16 | 17 | 18 | class AirtableMixin(models.Model): 19 | """A mixin to update an Airtable when a model object is saved or deleted.""" 20 | 21 | AIRTABLE_BASE_KEY = None 22 | AIRTABLE_TABLE_NAME = None 23 | AIRTABLE_UNIQUE_IDENTIFIER = None 24 | 25 | # On import, a lot of saving happens, so this attribute gets set to True during import and could be 26 | # used as a bit of logic to skip a post_save signal, for example. 27 | _skip_signals = False 28 | # If the Airtable integration for this model is enabled. Used for sending data to Airtable. 29 | _is_enabled = False 30 | # If the Airtable api setup is complete in this model. Used for singleton-like setup_airtable() method. 31 | _ran_airtable_setup = False 32 | # Upon save, should this model's data be sent to Airtable? 33 | # This is an internal variable. Both _push_to_airtable and push_to_airtable needs to be True 34 | # before a push to Airtable will happen. 35 | # _push_to_airtable is for internal use only 36 | _push_to_airtable = False 37 | # Case for disabling this: when importing data from Airtable as to not 38 | # ... import data, save the model, and push the same data back to Airtable. 39 | # push_to_airtable can be set from outside the model 40 | push_to_airtable = True 41 | 42 | airtable_record_id = models.CharField(max_length=35, db_index=True, blank=True) 43 | 44 | def setup_airtable(self) -> None: 45 | """ 46 | This method is used in place of __init__() as to not check global settings and 47 | set the Airtable api client over and over again. 48 | 49 | self._ran_airtable_setup is used to ensure this method is only ever run once. 50 | """ 51 | if not self._ran_airtable_setup: 52 | # Don't run this more than once on a model. 53 | self._ran_airtable_setup = True 54 | 55 | if not hasattr(settings, "AIRTABLE_IMPORT_SETTINGS") or not getattr( 56 | settings, "WAGTAIL_AIRTABLE_ENABLED", False 57 | ): 58 | # No AIRTABLE_IMPORT_SETTINGS were found. Skip checking for settings. 59 | return None 60 | 61 | # Look for airtable settings. Default to an empty dict. 62 | AIRTABLE_SETTINGS = settings.AIRTABLE_IMPORT_SETTINGS.get( 63 | self._meta.label, {} 64 | ) 65 | 66 | self.AIRTABLE_BASE_URL = AIRTABLE_SETTINGS.get("AIRTABLE_BASE_URL", None) 67 | # Set the airtable settings. 68 | self.AIRTABLE_BASE_KEY = AIRTABLE_SETTINGS.get("AIRTABLE_BASE_KEY") 69 | self.AIRTABLE_TABLE_NAME = AIRTABLE_SETTINGS.get("AIRTABLE_TABLE_NAME") 70 | self.AIRTABLE_UNIQUE_IDENTIFIER = AIRTABLE_SETTINGS.get( 71 | "AIRTABLE_UNIQUE_IDENTIFIER" 72 | ) 73 | self.AIRTABLE_SERIALIZER = AIRTABLE_SETTINGS.get("AIRTABLE_SERIALIZER") 74 | if ( 75 | AIRTABLE_SETTINGS 76 | and settings.AIRTABLE_API_KEY 77 | and self.AIRTABLE_BASE_KEY 78 | and self.AIRTABLE_TABLE_NAME 79 | and self.AIRTABLE_UNIQUE_IDENTIFIER 80 | ): 81 | api = Api(api_key=settings.AIRTABLE_API_KEY) 82 | self.airtable_client = api.table( 83 | self.AIRTABLE_BASE_KEY, 84 | self.AIRTABLE_TABLE_NAME, 85 | ) 86 | 87 | self._push_to_airtable = True 88 | self._is_enabled = True 89 | else: 90 | logger.warning( 91 | f"Airtable settings are not enabled for the {self._meta.verbose_name} " 92 | f"({self._meta.model_name}) model" 93 | ) 94 | 95 | def get_record_usage_url(self): 96 | if self.is_airtable_enabled and self.AIRTABLE_BASE_URL and self.airtable_record_id: 97 | url = self.AIRTABLE_BASE_URL.rstrip('/') 98 | return f"{url}/{self.airtable_record_id}" 99 | return None 100 | 101 | @property 102 | def is_airtable_enabled(self): 103 | """ 104 | Used in the template to determine if a model can or cannot be imported from Airtable. 105 | """ 106 | if not self._ran_airtable_setup: 107 | self.setup_airtable() 108 | return self._is_enabled 109 | 110 | def get_import_fields(self): 111 | """ 112 | When implemented, should return a dictionary of the mapped fields from Airtable to the model. 113 | ie. 114 | { 115 | "Airtable Column Name": "model_field_name", 116 | ... 117 | } 118 | """ 119 | raise NotImplementedError 120 | 121 | def get_export_fields(self): 122 | """ 123 | When implemented, should return a dictionary of the mapped fields from Airtable to the model. 124 | ie. 125 | { 126 | "airtable_column": self.airtable_column, 127 | "annual_fee": self.annual_fee, 128 | } 129 | """ 130 | raise NotImplementedError 131 | 132 | @cached_property 133 | def mapped_export_fields(self): 134 | return self.get_export_fields() 135 | 136 | def check_record_exists(self, airtable_record_id) -> bool: 137 | """ 138 | Check if a record exists in an Airtable by its exact Airtable Record ID. 139 | 140 | This will trigger an Airtable API request. 141 | Returns a True/False response. 142 | """ 143 | try: 144 | record = self.airtable_client.get(airtable_record_id) 145 | except HTTPError: 146 | record = {} 147 | return bool(record) 148 | 149 | def delete_record(self) -> bool: 150 | """ 151 | Deletes a record from Airtable, but does not delete the object from Django. 152 | 153 | Returns True if the record is successfully deleted, otherwise False. 154 | """ 155 | try: 156 | response = self.airtable_client.delete(self.airtable_record_id) 157 | deleted = response["deleted"] 158 | except HTTPError: 159 | deleted = False 160 | return deleted 161 | 162 | def match_record(self) -> str: 163 | """ 164 | Look for a record in an Airtable. Search by the AIRTABLE_UNIQUE_IDENTIFIER. 165 | 166 | Instead of looking for an Airtable record by it's exact Record ID, it will 167 | search through the specified Airtable column for a specific value. 168 | 169 | WARNING: If more than one record is found, the first one in the returned 170 | list of records (a list of dicts) will be used. 171 | 172 | This differs from check_record_exists() as this will return the record string 173 | (or an empty string if a record is not found), whereas check_record_exists() 174 | will return a True/False boolean to let you know if a record simply exists, 175 | or doesn't exist. 176 | """ 177 | if type(self.AIRTABLE_UNIQUE_IDENTIFIER) == dict: 178 | keys = list(self.AIRTABLE_UNIQUE_IDENTIFIER.keys()) 179 | values = list(self.AIRTABLE_UNIQUE_IDENTIFIER.values()) 180 | # TODO: Edge case handling: 181 | # - Handle multiple dictionary keys 182 | # - Handle empty dictionary 183 | airtable_column_name = keys[0] 184 | model_field_name = values[0] 185 | value = getattr(self, model_field_name) 186 | else: 187 | _airtable_unique_identifier = self.AIRTABLE_UNIQUE_IDENTIFIER 188 | value = getattr(self, _airtable_unique_identifier) 189 | airtable_column_name = self.AIRTABLE_UNIQUE_IDENTIFIER 190 | records = self.airtable_client.all(formula=match({airtable_column_name: value})) 191 | total_records = len(records) 192 | if total_records: 193 | # If more than 1 record was returned log a warning. 194 | if total_records > 1: 195 | logger.info( 196 | f"Found {total_records} Airtable records for {airtable_column_name}={value}. " 197 | f"Using first available record ({records[0]['id']}) and ignoring the others." 198 | ) 199 | # Always return the first record 200 | return records[0]["id"] 201 | 202 | return "" 203 | 204 | def refresh_mapped_export_fields(self) -> None: 205 | """Delete the @cached_property caching on self.mapped_export_fields.""" 206 | try: 207 | del self.mapped_export_fields 208 | except Exception: 209 | # Doesn't matter what the error is. 210 | pass 211 | 212 | @classmethod 213 | def parse_request_error(cls, error): 214 | """ 215 | Parse an Airtable/requests HTTPError string. 216 | 217 | Example: 401 Client Error: Unauthorized for url: https://api.airtable.com/v0/appYourAppId/Your%20Table?filterByFormula=.... [Error: {'type': 'AUTHENTICATION_REQUIRED', 'message': 'Authentication required'}] 218 | Example: 503 Server Error: Service Unavailable for url: https://api.airtable.com/v0/appXXXXXXXX/BaseName' 219 | """ 220 | if not error or "503 Server Error" in error: 221 | # If there is a 503 error 222 | return { 223 | "status_code": 503, 224 | "type": "SERVICE_UNAVAILABLE", 225 | "message": "Airtable may be down, or is otherwise unreachable" 226 | } 227 | 228 | code = int(error.split(":", 1)[0].split(" ")[0]) 229 | if code == 502: 230 | # If there is a 502 error 231 | return { 232 | "status_code": code, 233 | "type": "SERVER_ERROR", 234 | "message": "Service may be down, or is otherwise unreachable" 235 | } 236 | 237 | error_json = error.split("[Error: ")[1].rstrip("]") 238 | if error_json == "NOT_FOUND": # 404's act different 239 | return { 240 | "status_code": code, 241 | "type": "NOT_FOUND", 242 | "message": "Record not found", 243 | } 244 | else: 245 | error_info = literal_eval(error_json) 246 | return { 247 | "status_code": code, 248 | "type": error_info["type"], 249 | "message": error_info["message"], 250 | } 251 | 252 | def _update_record(self, record_id, fields): 253 | try: 254 | self.airtable_client.update(record_id, fields) 255 | except HTTPError as e: 256 | error = self.parse_request_error(e.args[0]) 257 | message = ( 258 | f"Could not update Airtable record. Reason: {error['message']}" 259 | ) 260 | logger.warning(message) 261 | # Used in the `after_edit_page` hook. If it exists, an error message will be displayed. 262 | self._airtable_update_error = message 263 | return False 264 | return True 265 | 266 | def _create_record(self, fields): 267 | try: 268 | record = self.airtable_client.create(fields) 269 | except HTTPError as e: 270 | error = self.parse_request_error(e.args[0]) 271 | message = ( 272 | f"Could not create Airtable record. Reason: {error['message']}" 273 | ) 274 | logger.warning(message) 275 | # Used in the `after_edit_page` hook. If it exists, an error message will be displayed. 276 | self._airtable_update_error = message 277 | return None 278 | return record["id"] 279 | 280 | def save_to_airtable(self): 281 | """ 282 | If there's an existing airtable record id, update the row. 283 | Otherwise attempt to create a new record. 284 | """ 285 | self.setup_airtable() 286 | if self._push_to_airtable and self.push_to_airtable: 287 | # Every airtable model needs mapped fields. 288 | # mapped_export_fields is a cached property. Delete the cached prop and get new values upon save. 289 | self.refresh_mapped_export_fields() 290 | if self.airtable_record_id and self.check_record_exists(self.airtable_record_id): 291 | # If this model has an airtable_record_id, attempt to update the record. 292 | self._update_record(self.airtable_record_id, self.mapped_export_fields) 293 | else: 294 | record_id = self.match_record() 295 | if record_id: 296 | # A match was found by unique identifier. Update the record. 297 | success = self._update_record(record_id, self.mapped_export_fields) 298 | else: 299 | record_id = self._create_record(self.mapped_export_fields) 300 | success = bool(record_id) 301 | 302 | if success: 303 | self.airtable_record_id = record_id 304 | super().save(update_fields=["airtable_record_id"]) 305 | 306 | def save(self, *args, **kwargs): 307 | # Save to database first so we get pk, in case it's used for uniqueness 308 | super().save(*args, **kwargs) 309 | 310 | if getattr(settings, "WAGTAIL_AIRTABLE_SAVE_SYNC", True): 311 | # If WAGTAIL_AIRTABLE_SAVE_SYNC is set to True we do it the synchronous way 312 | self.save_to_airtable() 313 | 314 | def delete(self, *args, **kwargs): 315 | self.setup_airtable() 316 | if self.push_to_airtable and self._push_to_airtable and self.airtable_record_id: 317 | # Try to delete the record from the Airtable. 318 | self.delete_record() 319 | return super().delete(*args, **kwargs) 320 | 321 | class Meta: 322 | abstract = True 323 | 324 | 325 | class ImportButton(Button): 326 | template_name = "wagtail_airtable/_import_button.html" 327 | 328 | def __init__(self, *args, request=None, model_opts = None, **kwargs): 329 | super().__init__(*args, **kwargs) 330 | self.request = request 331 | self.model_opts = model_opts 332 | 333 | def get_context_data(self, parent_context): 334 | context = super().get_context_data(parent_context) 335 | context["csrf_token"] = get_token(self.request) 336 | context["model_opts"] = self.model_opts 337 | context["next"] = self.request.path 338 | return context 339 | 340 | 341 | class SnippetImportActionMixin: 342 | # Add a new action to the snippet listing page to import from Airtable 343 | @cached_property 344 | def header_buttons(self): 345 | buttons = super().header_buttons 346 | if issubclass(self.model, AirtableMixin) and self.add_url: 347 | buttons.append( 348 | ImportButton( 349 | "Import from Airtable", 350 | url=reverse("airtable_import_listing"), 351 | request=self.request, 352 | model_opts=self.model and self.model._meta, 353 | ) 354 | ) 355 | return buttons 356 | -------------------------------------------------------------------------------- /wagtail_airtable/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | 4 | class AirtableSerializer(serializers.Serializer): 5 | """ 6 | Generic Airtable serializer for parsing Airtable API JSON data into proper model data. 7 | """ 8 | 9 | def validate(self, data): 10 | """ 11 | Loop through all the values, and if anything comes back as 'None' return an empty string. 12 | 13 | Not all fields will have cleaned data. Some fields could be stored as 'None' in Airtable, 14 | so we need to loop through every value and converting 'None' to '' 15 | """ 16 | for key, value in data.items(): 17 | # If any fields pass validation with the string 'None', return a blank string 18 | if value == "None": 19 | data[key] = "" 20 | return data 21 | -------------------------------------------------------------------------------- /wagtail_airtable/templates/wagtail_airtable/_import_button.html: -------------------------------------------------------------------------------- 1 | {% load i18n wagtailadmin_tags %} 2 | 3 | {% blocktranslate asvar action_label with snippet_type_name=model_opts.verbose_name_plural %}Import {{ snippet_type_name }}{% endblocktranslate %} 4 |
5 | {% csrf_token %} 6 | 7 | 8 | 11 |
12 | -------------------------------------------------------------------------------- /wagtail_airtable/templates/wagtail_airtable/airtable_import_listing.html: -------------------------------------------------------------------------------- 1 | {% extends "wagtailadmin/base.html" %} 2 | {% load wagtailadmin_tags %} 3 | 4 | {% block titletag %}Import Airtable Sheets{% endblock %} 5 | 6 | {% block content %} 7 | {% include "wagtailadmin/shared/header.html" with title="Airtable Import" %} 8 | 9 |
10 |

Models you can import from Airtable

11 | {% for model_name, model_path, is_airtable_enabled, grouped_models in models %} 12 |

{{ model_name }}

13 | {% if grouped_models %} 14 |
15 |

When you import {{ model_name }} you'll also be importing these ({{ grouped_models|length }}) as well: {% for model_name in grouped_models %}{{ model_name }}{% if not forloop.last %}, {% endif %} {% endfor %}

16 |
17 | {% endif %} 18 | {% if is_airtable_enabled %} 19 |
20 | {% csrf_token %} 21 | 22 | 23 | 27 |
28 | {% else %} 29 | {{ model_name }} is not setup with the correct Airtable settings 30 | {% endif %} 31 | {% empty %} 32 | There are no models configured yet 33 | {% endfor %} 34 |
35 | {% endblock %} 36 | -------------------------------------------------------------------------------- /wagtail_airtable/templates/wagtailsnippets/snippets/index.html: -------------------------------------------------------------------------------- 1 | {% extends "wagtailsnippets/snippets/index.html" %} 2 | {% load i18n wagtailadmin_tags wagtail_airtable_tags %} 3 | 4 | {% block content %} 5 | {% wagtail_major_version as wagtail_major_version %} 6 | {% if wagtail_major_version < 6 %} 7 | {% include 'wagtailadmin/shared/headers/slim_header.html' %} 8 | {% fragment as base_action_locale %}{% if locale %}{% include 'wagtailadmin/shared/locale_selector.html' with theme="large" %}{% endif %}{% endfragment %} 9 | {% fragment as action_url_add_snippet %}{% if can_add_snippet %}{% url view.add_url_name %}{% if locale %}?locale={{ locale.language_code }}{% endif %}{% endif %}{% endfragment %} 10 | {% fragment as action_text_snippet %}{% blocktrans trimmed with snippet_type_name=model_opts.verbose_name %}Add {{ snippet_type_name }}{% endblocktrans %}{% endfragment %} 11 | 12 | {% fragment as extra_actions %} 13 | {% if view.list_export %} 14 | {% include view.export_buttons_template_name %} 15 | {% endif %} 16 | {# begin wagtail-airtable customisation for Wagtail 5.2 #} 17 | {% can_import_model model_opts.label as can_import_model %} 18 | {% if can_add_snippet and can_import_model %} 19 |
20 | {% csrf_token %} 21 | 22 | 23 | 27 |
28 | {% endif %} 29 | {# end wagtail-airtable customisation #} 30 | {% endfragment %} 31 | 32 | {% include 'wagtailadmin/shared/header.html' with title=model_opts.verbose_name_plural|capfirst icon=header_icon search_url=search_url base_actions=base_action_locale action_url=action_url_add_snippet action_icon="plus" action_text=action_text_snippet extra_actions=extra_actions search_results_url=index_results_url %} 33 |
34 |
35 | {% include "wagtailsnippets/snippets/index_results.html" %} 36 |
37 | {% if filters %} 38 | {% include "wagtailadmin/shared/filters.html" %} 39 | {% endif %} 40 | {% trans "Select all snippets in listing" as select_all_text %} 41 | {% include 'wagtailadmin/bulk_actions/footer.html' with select_all_obj_text=select_all_text app_label=model_opts.app_label model_name=model_opts.model_name objects=page_obj %} 42 |
43 | {% else %} 44 | {{ block.super }} 45 | {% endif %} 46 | {% endblock %} 47 | -------------------------------------------------------------------------------- /wagtail_airtable/templatetags/wagtail_airtable_tags.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from django.conf import settings 3 | 4 | from wagtail import VERSION as WAGTAIL_VERSION 5 | 6 | 7 | register = template.Library() 8 | 9 | 10 | @register.simple_tag 11 | def can_import_model(model_label) -> bool: 12 | """ 13 | Check if a model can be imported based on its model label. 14 | 15 | Use: 16 | {% load wagtail_airtable_tags %} 17 | {% can_import_model "yourapp.ModelName" as template_var %} 18 | 19 | Returns True or False. 20 | """ 21 | airtable_settings = getattr(settings, "AIRTABLE_IMPORT_SETTINGS", {}) 22 | has_settings = airtable_settings.get(model_label, False) 23 | return bool(has_settings) 24 | 25 | 26 | @register.simple_tag 27 | def wagtail_major_version() -> int: 28 | """ 29 | Returns the major version of Wagtail as an integer. 30 | """ 31 | return WAGTAIL_VERSION[0] 32 | -------------------------------------------------------------------------------- /wagtail_airtable/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Utility functions for wagtail-airtable. 3 | """ 4 | from importlib import import_module 5 | from django.conf import settings 6 | from django.contrib.contenttypes.models import ContentType 7 | from django.core.exceptions import ImproperlyConfigured, ObjectDoesNotExist 8 | from wagtail.admin import messages 9 | 10 | from wagtail_airtable.mixins import AirtableMixin 11 | 12 | WAGTAIL_AIRTABLE_ENABLED = getattr(settings, "WAGTAIL_AIRTABLE_ENABLED", False) 13 | 14 | 15 | def get_model_for_path(model_path): 16 | """ 17 | Given an 'app_name.model_name' string, return the model class or False 18 | """ 19 | app_label, model_name = model_path.lower().split(".") 20 | try: 21 | return ContentType.objects.get_by_natural_key( 22 | app_label, model_name 23 | ).model_class() 24 | except ObjectDoesNotExist: 25 | return False 26 | 27 | 28 | def get_models_as_paths(models) -> list: 29 | """ 30 | Given a model list, return a list of model as string 31 | """ 32 | models_paths = [] 33 | 34 | for model in models: 35 | content_type = ContentType.objects.get_for_model(model) 36 | model_path = "{}.{}".format(content_type.app_label, content_type.model).lower() 37 | models_paths.append(model_path) 38 | 39 | return models_paths 40 | 41 | 42 | def get_all_models(as_path=False) -> list: 43 | """ 44 | Gets all models from settings.AIRTABLE_IMPORT_SETTINGS. 45 | 46 | Returns a list of models. 47 | Accepts an optionnal argument to return a list of models paths instead of a list of models. 48 | """ 49 | airtable_settings = getattr(settings, "AIRTABLE_IMPORT_SETTINGS", {}) 50 | validated_models = [] 51 | for label, model_settings in airtable_settings.items(): 52 | if model_settings.get("AIRTABLE_IMPORT_ALLOWED", True): 53 | label = label.lower() 54 | if "." in label: 55 | try: 56 | model = get_model_for_path(label) 57 | validated_models.append(model) 58 | except ObjectDoesNotExist: 59 | raise ImproperlyConfigured( 60 | "%r is not recognised as a model name." % label 61 | ) 62 | 63 | if as_path: 64 | return get_models_as_paths(validated_models) 65 | return validated_models 66 | 67 | 68 | def get_validated_models(models=[], as_path=False) -> list: 69 | """ 70 | Accept a list of model paths (ie. ['appname.Model1', 'appname.Model2']). 71 | 72 | Looks for models from a string and checks if the mode actually exists. 73 | Then it'll loop through each model and check if it's allowed to be imported. 74 | 75 | Returns a list of validated models. 76 | """ 77 | validated_models = [] 78 | for label in models: 79 | if "." in label: 80 | # interpret as a model 81 | model = get_model_for_path(label) 82 | if not model: 83 | raise ImproperlyConfigured( 84 | "%r is not recognised as a model name." % label 85 | ) 86 | 87 | validated_models.append(model) 88 | 89 | models = validated_models[:] 90 | for model in validated_models: 91 | airtable_settings = settings.AIRTABLE_IMPORT_SETTINGS.get(model._meta.label, ) 92 | # Remove this model from the `models` list so it doesn't hit the Airtable API. 93 | if not airtable_settings.get("AIRTABLE_IMPORT_ALLOWED", True): 94 | models.remove(model) 95 | 96 | if as_path: 97 | return get_models_as_paths(models) 98 | return models 99 | 100 | 101 | def can_send_airtable_messages(instance) -> bool: 102 | """ 103 | Check if a model instance is a subclass of AirtableMixin and if it's enabled. 104 | """ 105 | 106 | # Check if the page is an AirtableMixin Subclass 107 | # When AirtableMixin.save() is called.. 108 | # Either it'll connect with Airtable and update the row as expected, or 109 | # it will have some type of error. 110 | # If _airtable_update_error exists on the page, use that string as the 111 | # message error. 112 | # Otherwise assume a successful update happened on the Airtable row 113 | if ( 114 | WAGTAIL_AIRTABLE_ENABLED and issubclass(instance.__class__, AirtableMixin) 115 | and hasattr(instance, "is_airtable_enabled") and instance.is_airtable_enabled 116 | ): 117 | return True 118 | return False 119 | 120 | 121 | def airtable_message(request, instance, message="Airtable record updated", button_text="View record in Airtable", buttons_enabled=True) -> None: 122 | """ 123 | Common message handler for Wagtail hooks. 124 | 125 | Supports a custom message, custom button text, and the ability to disable buttons entirely (use case: deleting a record) 126 | """ 127 | custom_message = getattr(settings, "WAGTAIL_AIRTABLE_PUSH_MESSAGE", '') 128 | 129 | if custom_message: 130 | message = custom_message 131 | 132 | if hasattr(instance, "_airtable_update_error"): 133 | messages.error(request, message=instance._airtable_update_error) 134 | else: 135 | buttons = None 136 | if buttons_enabled and instance.get_record_usage_url(): 137 | buttons = [ 138 | messages.button(instance.get_record_usage_url(), button_text, True) 139 | ] 140 | messages.success(request, message=message, buttons=buttons) 141 | 142 | 143 | def import_string(module_name): 144 | location, attribute = module_name.rsplit(".", 1) 145 | module = import_module(location) 146 | return getattr(module, attribute) 147 | -------------------------------------------------------------------------------- /wagtail_airtable/views.py: -------------------------------------------------------------------------------- 1 | from logging import getLogger 2 | 3 | from django.conf import settings 4 | from django.contrib import messages 5 | from django.core.exceptions import ImproperlyConfigured 6 | from django.shortcuts import redirect 7 | from django.urls import reverse 8 | from django.utils.http import url_has_allowed_host_and_scheme 9 | from django.views.generic import TemplateView 10 | 11 | from wagtail_airtable.forms import AirtableImportModelForm 12 | from wagtail_airtable.importer import AirtableModelImporter 13 | from wagtail_airtable.utils import get_model_for_path, get_validated_models 14 | 15 | logger = getLogger(__name__) 16 | 17 | 18 | class AirtableImportListing(TemplateView): 19 | """ 20 | Loads options for importing Airtable data 21 | """ 22 | 23 | template_name = "wagtail_airtable/airtable_import_listing.html" 24 | http_method_names = ["get", "post"] 25 | 26 | def post(self, request, *args, **kwargs): 27 | form = AirtableImportModelForm(request.POST) 28 | if form.is_valid(): 29 | model_label = form.cleaned_data["model"] 30 | model = get_validated_models([model_label])[0] 31 | importer = AirtableModelImporter(model=model, verbosity=1) 32 | 33 | error_results = 0 34 | new_results = 0 35 | updated_results = 0 36 | 37 | for result in importer.run(): 38 | if result.errors: 39 | error_results += 1 40 | elif result.new: 41 | new_results += 1 42 | else: 43 | updated_results += 1 44 | 45 | message = f"{new_results} items created. {updated_results} items updated. {error_results} items skipped." 46 | messages.add_message( 47 | request, messages.SUCCESS, f"Import succeeded with {message}" 48 | ) 49 | else: 50 | messages.add_message(request, messages.ERROR, "Could not import") 51 | 52 | if (next_url := request.POST.get("next")) and url_has_allowed_host_and_scheme( 53 | next_url, allowed_hosts=settings.ALLOWED_HOSTS, require_https=not settings.DEBUG 54 | ): 55 | return redirect(next_url) 56 | return redirect(reverse("airtable_import_listing")) 57 | 58 | def _get_base_model(self, model): 59 | """ 60 | For the given model, return the highest concrete model in the inheritance tree - 61 | e.g. for BlogPage, return Page 62 | """ 63 | if model._meta.parents: 64 | model = model._meta.get_parent_list()[0] 65 | return model 66 | 67 | def get_validated_models(self): 68 | """Get models from AIRTABLE_IMPORT_SETTINGS, validate they exist, and return a list of tuples. 69 | 70 | returns: 71 | [ 72 | ('Credit Card', 'creditcards.CreditCard', ), 73 | ('..', '..'), 74 | ] 75 | """ 76 | airtable_settings = getattr(settings, "AIRTABLE_IMPORT_SETTINGS", {}) 77 | 78 | # Loop through all the models in the settings and create a new dict 79 | # of the unique settings for each model label. 80 | # If settings were used more than once the second (3rd, 4th, etc) common settings 81 | # will be bulked into a "grouped_models" list. 82 | tracked_settings = [] 83 | models = {} 84 | for label, model_settings in airtable_settings.items(): 85 | if model_settings not in tracked_settings: 86 | tracked_settings.append(model_settings) 87 | models[label] = model_settings 88 | models[label]["grouped_models"] = [] 89 | else: 90 | for label2, model_settings2 in models.items(): 91 | if model_settings is model_settings2: 92 | models[label2]["grouped_models"].append(label) 93 | 94 | # Validated models are models that actually exist. 95 | # This way fake models can't be added. 96 | validated_models = [] 97 | for label, model_settings in models.items(): 98 | # If this model is allowed to be imported. Default is True. 99 | if model_settings.get("AIRTABLE_IMPORT_ALLOWED", True): 100 | # A temporary variable for holding grouped model names. 101 | # This is added to the validated_models item later. 102 | # This is only used for displaying model names in the import template 103 | _grouped_models = [] 104 | # Loop through the grouped_models list in each setting, validate each model, 105 | # then add it to the larger grouped_models 106 | if model_settings.get("grouped_models"): 107 | for grouped_model_label in model_settings.get("grouped_models"): 108 | if "." in grouped_model_label: 109 | model = get_model_for_path(grouped_model_label) 110 | if model: 111 | _grouped_models.append(model._meta.verbose_name_plural) 112 | 113 | if "." in label: 114 | model = get_model_for_path(label) 115 | if model: 116 | # Append a triple-tuple to the validated_models with the: 117 | # (1. Models verbose name, 2. Model label, 3. is_airtable_enabled from the model, and 4. List of grouped models) 118 | airtable_enabled_for_model = getattr( 119 | model, "is_airtable_enabled", False 120 | ) 121 | validated_models.append( 122 | ( 123 | model._meta.verbose_name_plural, 124 | label, 125 | airtable_enabled_for_model, 126 | _grouped_models, 127 | ) 128 | ) 129 | else: 130 | raise ImproperlyConfigured( 131 | "%r is not recognised as a model name." % label 132 | ) 133 | 134 | return validated_models 135 | 136 | def get_context_data(self, **kwargs): 137 | """Add validated models from the AIRTABLE_IMPORT_SETTINGS to the context.""" 138 | return {"models": self.get_validated_models()} 139 | -------------------------------------------------------------------------------- /wagtail_airtable/wagtail_hooks.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.urls import path, reverse 3 | from wagtail import hooks 4 | from wagtail.admin.menu import MenuItem 5 | 6 | from wagtail_airtable.utils import airtable_message, can_send_airtable_messages 7 | from wagtail_airtable.views import AirtableImportListing 8 | 9 | 10 | @hooks.register("register_admin_urls") 11 | def register_airtable_url(): 12 | return [ 13 | path( 14 | "airtable-import/", 15 | AirtableImportListing.as_view(), 16 | name="airtable_import_listing", 17 | ), 18 | ] 19 | 20 | 21 | @hooks.register("register_settings_menu_item") 22 | def register_airtable_setting(): 23 | def is_shown(request): 24 | return getattr(settings, "WAGTAIL_AIRTABLE_ENABLED", False) 25 | 26 | menu_item = MenuItem( 27 | "Airtable Import", 28 | reverse("airtable_import_listing"), 29 | icon_name="cog", 30 | order=1000, 31 | ) 32 | menu_item.is_shown = is_shown 33 | return menu_item 34 | 35 | 36 | @hooks.register("after_edit_page") 37 | def after_page_update(request, page): 38 | if can_send_airtable_messages(page): 39 | airtable_message(request, page) 40 | 41 | 42 | @hooks.register("after_create_snippet") 43 | @hooks.register("after_edit_snippet") 44 | def after_snippet_update(request, instance): 45 | if can_send_airtable_messages(instance): 46 | airtable_message(request, instance) 47 | 48 | 49 | @hooks.register("after_delete_snippet") 50 | def after_snippet_delete(request, instances): 51 | total_deleted = len(instances) 52 | instance = instances[0] 53 | if can_send_airtable_messages(instance): 54 | message = "Airtable record deleted" 55 | if total_deleted > 1: 56 | message = f"{total_deleted} Airtable records deleted" 57 | airtable_message(request, instance, message=message, buttons_enabled=False) 58 | --------------------------------------------------------------------------------