Skip to main content

Python package & cli for syncing between ActionNetwork & AirTable

Project description

an-at-sync

Python package & cli for syncing between ActionNetwork & AirTable.

How to Use

To set up a new project with an-at-sync, create a new folder for your project:

mkdir project-name && cd project-name

In that folder, create a requirements.txt and add an-at-sync as a dependency:

an-at-sync

Install it with pip:

pip install -r requirements.txt

Create a folder for your project namespace:

mkdir project_name

In that folder, create a models.py with this default content:

from datetime import datetime
from typing import Any, Dict, Optional

from an_at_sync.format import convert_adr, standardize_phone
from an_at_sync.model import BaseActivist, BaseEvent, BaseRSVP
from dateutil import tz
from pyairtable.utils import datetime_to_iso_str
from pydantic import HttpUrl, validator
from pydantic.networks import EmailStr

eastern = tz.gettz("America/New_York")
utc = tz.gettz("UTC")


class Activist(BaseActivist):
    first_name: Optional[str]
    last_name: Optional[str]
    email: EmailStr
    zip_code: Optional[str]
    phone_number: Optional[str]
    address: Optional[str]
    city: Optional[str]
    state: Optional[str]

    @classmethod
    def from_actionnetwork(cls, source: dict, **kwargs: Any):
        address, city, state, zip_code = convert_adr(source["postal_addresses"][0])
        return cls(
            first_name=source.get("given_name"),
            last_name=source.get("family_name"),
            email=source["email_addresses"][0]["address"].strip(),
            address=address,
            city=city,
            state=state,
            zip_code=zip_code,
            phone_number=standardize_phone(source["phone_numbers"][0].get("number")),
        )

    def display_name(self) -> str:
        return f"{self.first_name} {self.last_name}"

    def pk(self) -> Dict:
        return {"Email": self.email}

    def to_airtable(self):
        return {
            "Email": self.email,
            "First Name": self.first_name,
            "Last Name": self.last_name,
            "Phone": self.phone_number,
            "Address": self.address,
            "City": self.city,
            "State": self.state,
            "Zip": self.zip_code,
        }


class RSVP(BaseRSVP):
    id: str
    rsvpd_at: datetime

    @classmethod
    def from_actionnetwork(cls, source, **kwargs: Any):
        return cls(
            id=f"{kwargs['activist_record']['id']}-{kwargs['event_record']['id']}",
            activist=kwargs["activist"],
            event=kwargs["event"],
            rsvpd_at=source["created_date"],
        )

    def display_name(self) -> str:
        return f"{self.activist.display_name()} to {self.event.display_name()}"

    def pk(self):
        return {"Id": self.id}

    def to_airtable(self) -> dict:
        return {
            "Id": self.id,
            "RSVP'd At": datetime_to_iso_str(self.rsvpd_at.replace(tzinfo=None)),
        }

    def activist_column(self) -> str:
        return "Volunteer"

    def event_column(self) -> str:
        return "Event"


class Event(BaseEvent):
    url: HttpUrl
    name: str
    location: str
    start_date: datetime
    end_date: Optional[datetime]
    status: str

    @validator("start_date", "end_date")
    def dates_must_be_eastern(cls, v: datetime):
        return v.replace(tzinfo=eastern) if v is not None else v

    @classmethod
    def from_actionnetwork(cls, source: dict, **kwargs):
        address, city, state, *_ = convert_adr(source["location"])
        event_location = "Zoom" if not address else f"{address}, {city}, {state}"
        return cls(
            url=source["browser_url"],
            name=source["title"],
            start_date=source["start_date"],
            end_date=source.get("end_date"),
            status=source["status"],
            location=event_location,
        )

    def display_name(self) -> str:
        return self.name

    def pk(self) -> Dict:
        return {"Url": str(self.url)}

    def to_airtable(self) -> dict:
        return {
            "Url": str(self.url),
            "Name": self.name,
            "Start At": self.start_date.astimezone(tz=utc)
            .replace(tzinfo=None)
            .isoformat(timespec="milliseconds")
            + "Z",
            "End At": self.end_date.astimezone(tz=utc)
            .replace(tzinfo=None)
            .isoformat(timespec="milliseconds")
            + "Z"
            if self.end_date
            else None,
            "Status": self.status.capitalize(),
            "Location": self.location,
        }

These models represent the various part of the system we're going to interact independently from the two systems that will use them. The Activist represents an individual person in your campaign. The Event represents a single campaign event, and an RSVP represents a response from an Activist to attend an event.

You can use this as a baseline for your own models, allowing you to customize which fields are synced to AirTable & how.

Next, create this .env file:

AN_AT_SYNC_MODELS="project_name.models"
AN_API_KEY="TODO: ActionNetwork API Key"
AT_API_KEY="TODO: Airtable API Key"
AT_BASE="TODO: Base"
AT_ACTIVISTS_TABLE="TODO: Volunteers Table"
AT_EVENTS_TABLE="TODO: Events Table"
AT_RSVP_TABLE="TODO: RSVP Table"

Given the project_name above, the AN_AT_SYNC_MODELS points to the module that an-at-sync can load our custom models from. The rest of the env vars require you to get the proper keys and configure your AirTable account.

Getting Your ActionNetwork API Key

Details -> API & Sync. "Your API Key" -> Generate Key. Copy into .env.

Creating Your AirTable Base

Need to create the base first. Create Volunteers, Events, & RSVPs table. Get base id from first part of the URL (starts with app).

Getting Your AirTable API Key

User stetings -> Developer Hub -> Personal Access token. data.records:read & data.records:write. Access to the created base. Copy the token, add to .env.

Creating Your AirTable Volunteers Table

Create fields returned by to_airtable method. Copy from second part of URL and add to .env.

Creating Your AirTAble Events Table

Create fields returned by to_airtable method. Copy from second part of URL and add to .env.

Creating Your AirTable RSVP Table

Create fields returned by to_airtable method. Copy from second part of URL and add to .env.

Run your first sync!

python -m an_at_sync sync events --rsvps

Configuring the Webhook Handler on Fly.io

Create a Dockerfile in your repo:

FROM maadhattah/an-at-sync:latest

COPY ./project_name /app/project_name

This will copy your configuration onto the Docker image.

Next, deploy it to Fly.io:

fly launch

It will create an app based on the Dockerfile image. Next, import your secrets:

cat .env | fly secrets import

Lastly, create the webhook in ActionNetwork with the url https://<projet-name>.fly.dev/api/webhooks/actionnetwork.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

an_at_sync-0.9.2.tar.gz (14.0 kB view details)

Uploaded Source

Built Distribution

an_at_sync-0.9.2-py3-none-any.whl (14.5 kB view details)

Uploaded Python 3

File details

Details for the file an_at_sync-0.9.2.tar.gz.

File metadata

  • Download URL: an_at_sync-0.9.2.tar.gz
  • Upload date:
  • Size: 14.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/5.0.0 CPython/3.12.2

File hashes

Hashes for an_at_sync-0.9.2.tar.gz
Algorithm Hash digest
SHA256 91d4cbb706cbc866a15b054c943dae7a45892ecdd6fccd48f5adef4f8e874fd7
MD5 e88845990af62d7b1a9cc9663b786e42
BLAKE2b-256 aa3cb5732ec7adc79d7f6f55d29b97e243abcdcd9690811f3e63732c376741ac

See more details on using hashes here.

File details

Details for the file an_at_sync-0.9.2-py3-none-any.whl.

File metadata

  • Download URL: an_at_sync-0.9.2-py3-none-any.whl
  • Upload date:
  • Size: 14.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/5.0.0 CPython/3.12.2

File hashes

Hashes for an_at_sync-0.9.2-py3-none-any.whl
Algorithm Hash digest
SHA256 bb8bd50d3ff6358191d564a0e1afe7ba7a8fd87939c4df5c1c55036cb8732c9a
MD5 834713da0cfc2b548d162322a2446d32
BLAKE2b-256 a18b618f7bd584c08abb5c9fe49b415866746c24d97364a7752a84b99f88c661

See more details on using hashes here.

Supported by

AWS AWS Cloud computing and Security Sponsor Datadog Datadog Monitoring Fastly Fastly CDN Google Google Download Analytics Microsoft Microsoft PSF Sponsor Pingdom Pingdom Monitoring Sentry Sentry Error logging StatusPage StatusPage Status page