Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial development #1

Merged
merged 65 commits into from
May 22, 2020
Merged
Show file tree
Hide file tree
Changes from 52 commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
584727d
WIP: Initial commit
KalobTaulien Apr 22, 2020
a795153
Package support
KalobTaulien Apr 22, 2020
aaeee4a
Minor adjustments
KalobTaulien Apr 30, 2020
389fdeb
Adding debug statements for easier local development
KalobTaulien Apr 30, 2020
04d368e
Documentation updates
KalobTaulien Apr 30, 2020
b39d5e7
Local debugging support
KalobTaulien Apr 30, 2020
04c70f1
Example integrations
KalobTaulien Apr 30, 2020
0fd2315
Better mapping support
KalobTaulien Apr 30, 2020
780b199
Dev notes
KalobTaulien Apr 30, 2020
3685865
Page dictionary support for unique column_name to field_name mapping
KalobTaulien May 1, 2020
d4ee236
Moved logic to outside method
KalobTaulien May 1, 2020
9091280
Moved private method down as setup method is more important
KalobTaulien May 1, 2020
5fe5370
Added docs around the EXTRA_SUPPORTED_MODELS setting
KalobTaulien May 1, 2020
6352d19
Cleanup & Airtable best practices doc
KalobTaulien May 4, 2020
7ca0098
Added a known issue
KalobTaulien May 4, 2020
8c918f4
Somewhat handle multiple returned records
KalobTaulien May 4, 2020
69c4ebf
Auto-register airtable import admin url
KalobTaulien May 4, 2020
0fc0ccb
Support additional model import in the template
KalobTaulien May 4, 2020
b045538
ctrl+z; undoing last commit as it was a bad idea
KalobTaulien May 4, 2020
3303508
Optional setting: disable airtable importing on pecific models
KalobTaulien May 4, 2020
22b04ed
Adding api response memoization
KalobTaulien May 4, 2020
e88aa38
Docs and extra model support
KalobTaulien May 4, 2020
a415f22
Resets all airtable record ids from management command
KalobTaulien May 5, 2020
6e5f043
Small fixes
KalobTaulien May 5, 2020
b3c7e57
Moved testing const to mixin file
KalobTaulien May 5, 2020
36416e3
Try/catch a failed Airtable request
KalobTaulien May 6, 2020
f726dbb
Remove cache purge line
KalobTaulien May 6, 2020
4e3ab70
Add Airtable record to object when creating a new record
KalobTaulien May 6, 2020
d89ec15
Auto increment model pks when creating new model from an import
KalobTaulien May 6, 2020
33ffc17
Better json to dict evaluation
KalobTaulien May 7, 2020
57973dd
Properly create new models from Airtable withour throwing an Integrit…
KalobTaulien May 7, 2020
a641d80
Use POST instead of GET requests
KalobTaulien May 7, 2020
93e996f
Default message when no models are configured
KalobTaulien May 12, 2020
ce28580
Hide Import menu option when disabled
KalobTaulien May 12, 2020
d11bf42
Django messages when page is saved w/ or w/o airtable record update
KalobTaulien May 12, 2020
4a6e32a
Future Snippet hook support
KalobTaulien May 12, 2020
34be856
Mock Airtable Client
KalobTaulien May 14, 2020
57ab574
Ignore common files and dirs
KalobTaulien May 14, 2020
b1b118b
View, Model and Mixin tests
KalobTaulien May 14, 2020
c34b157
Refactoring import command as separate class; writing tests for the n…
KalobTaulien May 15, 2020
5bf118c
Import management command tests
KalobTaulien May 18, 2020
bbc16a4
Cleanup + Black formatting
KalobTaulien May 18, 2020
30cbb4e
Flake8 formatting (sans E501's from Black formatting)
KalobTaulien May 18, 2020
73f9362
Check is Mixin is enabled before creating ERROR/SUCCESS messages
KalobTaulien May 18, 2020
5332460
Animated preview and test docs
KalobTaulien May 18, 2020
a6fac29
API key documentation line
KalobTaulien May 19, 2020
d1f6f67
Reusable test dictionaries; fixed a test
KalobTaulien May 19, 2020
ed94594
Misc. updates from code review
KalobTaulien May 20, 2020
b434f79
Removed EXTRA_SUPPORTED_MODELS in favour of re-using dictionaries
KalobTaulien May 20, 2020
5a1d340
Moving common logic into util functions
KalobTaulien May 20, 2020
26286ce
Better way to save models
KalobTaulien May 20, 2020
7d710ef
Use Importer and util functions instead of the call call_command
KalobTaulien May 20, 2020
8223804
Util tests
KalobTaulien May 20, 2020
b1aacb8
ImproperlyConfigured exception
KalobTaulien May 20, 2020
0787f8a
'None' => None
KalobTaulien May 20, 2020
6b6f647
m2m support; renamed obj to instance
KalobTaulien May 20, 2020
6bde27d
Wording update
KalobTaulien May 20, 2020
57d52b4
Removed single record testing condition
KalobTaulien May 20, 2020
46b2dc6
Using empty str helps prevent default IntegrityErrors on non null tex…
KalobTaulien May 21, 2020
e8253d4
Default to False
KalobTaulien May 21, 2020
0d30697
Default AIRTABLE to False in hooks
KalobTaulien May 21, 2020
6b1ca24
fixup! Default AIRTABLE to False in hooks
KalobTaulien May 21, 2020
149859d
Group models with the same settings together
KalobTaulien May 21, 2020
ca98b9b
Review touchups and flake8
KalobTaulien May 22, 2020
9a795c4
Black formatting
KalobTaulien May 22, 2020
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
*.swp
*.pyc
.DS_Store
/.coverage
/dist/
/build/
/MANIFEST
/wagtail_airtable.egg-info/
/docs/_build/
/.tox/
/venv
/node_modules/
npm-debug.log*
*.idea/
/*.egg/
/.cache/
/.pytest_cache/
*.sqlite3
__pycache__
29 changes: 29 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
BSD 3-Clause License

Copyright (c) 2019, Torchbox
All rights reserved.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:

* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.

* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.

* Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
6 changes: 6 additions & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
include LICENSE *.rst *.txt *.md
graft wagtail_airtable
global-exclude __pycache__
global-exclude *.py[co]
global-exclude .DS_Store
global-exclude examples/
130 changes: 129 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,129 @@
# Future home of wagtail-airtable
# Wagtail/Airtable

An extension for Wagtail allowing content to be transferred between Airtable sheets and your Wagtail/Django models.

Developed by [Torchbox](https://torchbox.com/) and sponsored by [The Motley Fool](https://www.fool.com/).

![Wagtail Airtable demo](examples/preview.gif)

### How it works

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.

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/.

##### Behind the scenes...
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.

> **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.

### Installation & Configuration

* Install the package with `pip install wagtail-airtable`
* Add `'wagtail_airtable'` to your project's `INSTALLED_APPS`
* In your settings you will need to map models to Airtable settings. Every model you want to map to an Airtable sheet will need:
* An `AIRTABLE_BASE_KEY`. You can find the base key in your Airtable docs when you're signed in to Airtable.com
* An `AIRTABLE_TABLE_NAME` to determine which table to connect to.
* 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.
* 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.
* 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.
* 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.

* 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.

### Example Base Configuration

Below is a base configuration or `ModelName` and `OtherModelName` (both are registered Wagtail snippets), along with `HomePage`.

```python
# your settings.py
AIRTABLE_API_KEY = 'yourSuperSecretKey'
WAGTAIL_AIRTABLE_ENABLED = True
AIRTABLE_IMPORT_SETTINGS = {
'appname.ModelName': {
'AIRTABLE_BASE_KEY': 'app3ds912jFam032S',
'AIRTABLE_TABLE_NAME': 'Your Airtable Table Name',
'AIRTABLE_UNIQUE_IDENTIFIER': 'slug', # Must match the Airtable Column name
'AIRTABLE_SERIALIZER': 'path.to.your.model.serializer.CustomModelSerializer'
},
'appname.OtherModelName': {
'AIRTABLE_BASE_KEY': 'app4ds902jFam035S',
'AIRTABLE_TABLE_NAME': 'Your Airtable Table Name',
'AIRTABLE_UNIQUE_IDENTIFIER':
'Page Slug': 'slug', # 'Page Slug' column name in Airtable, 'slug' field name in Wagtail.
},
'AIRTABLE_SERIALIZER': 'path.to.your.model.serializer.OtherCustomModelSerializer'
},
'pages.HomePage': {
'AIRTABLE_BASE_KEY': 'app2ds123jP23035Z',
'AIRTABLE_TABLE_NAME': 'Wagtail Page Tracking Table',
'AIRTABLE_UNIQUE_IDENTIFIER':
'Wagtail Page ID': 'pk',
},
'AIRTABLE_SERIALIZER': 'path.to.your.pages.serializer.PageSerializer',
# Below is an OPTIONAL setting.
# By disabling `AIRTABLE_IMPORT_ALLOWED` you can prevent Airtable imports
# Use cases may be:
# - disabling page imports since they are difficult to setup and maintain,
# - one-way sync to Airtable only (ie. when a model/Page is saved)
# Default is True
'AIRTABLE_IMPORT_ALLOWED': False,
},
# ...
}
```

##### Have multiple models with the same Airtable settings?
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.

```python
AIRTABLE_API_KEY = 'yourSuperSecretKey'
WAGTAIL_AIRTABLE_ENABLED = True
CUSTOM_PAGE_SETTINGS = {
'AIRTABLE_BASE_KEY': 'app3ds912jFam032S',
'AIRTABLE_TABLE_NAME': 'Your Airtable Table Name',
'AIRTABLE_UNIQUE_IDENTIFIER': 'slug', # Must match the Airtable Column name
'AIRTABLE_SERIALIZER': 'path.to.your.model.serializer.CustomModelSerializer'
},
AIRTABLE_IMPORT_SETTINGS = {
'home.HomePage': CUSTOM_PAGE_SETTINGS,
'blog.BlogPage': CUSTOM_PAGE_SETTINGS,
'appname.YourModel': CUSTOM_PAGE_SETTINGS,
}
```

### Management Commands

```bash
python manage.py import_airtable appname.ModelName secondapp.SecondModel
```

Optionally you can turn up the verbosity for better debugging with the `--verbosity=2` flag.

##### import_airtable command
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.

### Local Testing Advice

> **Note:** Be careful not to use the production settings as you could overwrite Wagtail or Airtable data.

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.

### Local debugging
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 Aritable 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 --verbosty=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.

> **Note**: This only only work while `DEBUG = True` in your settings as to not accidentally flood your production logs.

### Airtable Best Practice
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.

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.

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).

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)

### Running Tests
Clone the project and cd into the `wagtail-airtable/tests/` directory. Then run `python runtests.py tests`. This project is using standard Django unit tests.

To target a specific test you can run `python runtests.py tests.test_file.TheTestClass.test_specific_model`
8 changes: 8 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Wagtail Airtable Examples

> 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.

See the following code examples:
- [x] [Django Model Support](model_example.py)
- [x] [Wagtail Page Support](page_example.py)
- [x] [Wagtail Multi-Page Support](multi_page_example.py)
44 changes: 44 additions & 0 deletions examples/model_example.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from django.db import models

from wagtail_airtable.mixins import AirtableMixin


class YourModel(AirtableMixin, models.Model):

name = models.CharField(max_length=200, blank=False)
slug = models.SlugField(max_length=200, unique=True, editable=True)

@classmethod
def map_import_fields(cls):
"""
Maps your Airtable columns to your Django Model Fields.

Always provide explicit field names as to not accidentally overwrite sensitive information
such as model pk's.

Return a dictionary such as: {'Airtable column name': 'model_field_name', ...}
"""
mappings = {
# "Name" is the column name in Airtable. "name" (lowercase) is the field name on line 8.
"Name": "name",
# "Slug" is the column name in Airtable. "slug" (lowercase) is the field name on line 8.
"Slug": "slug",
}
return mappings

def get_export_fields(self):
"""
Export fields are the fields you want to map when saving a model object.

Everytime a model is saved, it will take the Airtable Column Name and fill the appropriate cell
with the data you tell it. Most often this will be from self.your_field_name.

Always provide explicit field names as to not accidentally share sensitive information such as
hashed passwords.

Return a dictionary such as: {"Airtable Column Name": "update_value", ...}
"""
return {
"Name": self.name,
"Slug": self.slug,
}
79 changes: 79 additions & 0 deletions examples/multi_page_example.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
from django.db import models
from django.utils import timezone

from wagtail.core.models import Page
from wagtail_airtable.mixins import AirtableMixin


class BasePage(AirtableMixin, Page):
"""
This is using the AirtableMixin and the Wagtail Page model to create a new
"BasePage" model. Then new Pages are created using the "BasePage model
they will automatically inherit the import/export field mapping you see
below.

Note: You'll most likely want BasePage to be an Abstract Model.
"""

@classmethod
def map_import_fields(cls):
"""
Fields to update when importing a specific page.
These are just updating the seo_title, title, and search_description
fields that come with wagtailcore.Page.

NOTE: Unless you store required data like the page depth or tree value
in Airtable, when you import a new page it won't be automatically created.
Wagtail doesn't know where you'd like new pages to be created but requires
tree-structure data.

Example:
{'Airtable column name': 'model_field_name', ...}
"""

return {
"SEO Title": "seo_title",
"Title": "title",
"Meta Description": "search_description",
}

def get_export_fields(self):
"""
Map Airtable columns to values from Wagtail or Django.

Example:
{'Airtable Column Name': updated_value, ...}
"""
return {
"SEO Title": self.seo_title,
"Title": self.title,
"URL": self.full_url,
"Last Published": self.last_published_at.isoformat()
if self.last_published_at
else "",
"Meta Description": self.search_description,
"Type": self.__class__.__name__,
"Live": self.live,
"Unpublished Changes": self.has_unpublished_changes,
"Wagtail Page ID": self.id,
"Slug": self.slug,
}

class Meta:
abstract = True


class HomePage2(BasePage):
pass


class ContactPage(BasePage):
pass


class BlogPage(BasePage):
pass


class MiscPage(BasePage):
pass
Loading