Repository: fullctl/fullctl (language: python)

Issue:
Current request middleware / context
This allows us to access the current request from anywhere in the code without having to pass it as a reference to the function, allowing us to easily retrieve request parameters like path and host easily from anywhere in the code.


Discussion / hints:

Fix patch:
diff --git a/Ctl/config.yaml b/Ctl/config.yaml
index a9491d67..a1f22eac 100644
--- a/Ctl/config.yaml
+++ b/Ctl/config.yaml
@@ -72,6 +72,8 @@ ctl:
 
     - type: changelog
       name: changelog
+    - name: version
+      type: version
     - type: venv
       name: venv
       config:
diff --git a/Ctl/dev/docker-compose.yml b/Ctl/dev/docker-compose.yml
index ef03533f..7470359e 100644
--- a/Ctl/dev/docker-compose.yml
+++ b/Ctl/dev/docker-compose.yml
@@ -16,11 +16,12 @@ services:
       - ./initdb.d:/docker-entrypoint-initdb.d
       - postgres_data:/var/lib/postgresql/data
 
+
   aaactl_web:
     build:
       context: ../../../aaactl
       dockerfile: Dockerfile
-    command: runserver 0.0.0.0:8000
+    command: runserver 0.0.0.0:6000
     env_file:
       - .env
       - ../../../aaactl/Ctl/dev/.env
@@ -37,7 +38,7 @@ services:
         max-file: "3"
     ports:
       # this needs to be set in the shell, compose env vars aren't read yet
-      - "${AAACTL_PORT:-8000}:8000"
+      - "${AAACTL_PORT:-6000}:6000"
     volumes:
       - ../../src/fullctl:/srv/service/main/fullctl
       - ../../../aaactl/src:/srv/service/main
@@ -46,7 +47,7 @@ services:
     build:
       context: ../../../ixctl
       dockerfile: Dockerfile
-    command: runserver 0.0.0.0:8001
+    command: runserver 0.0.0.0:6001
     env_file:
       - .env
       - ../../../ixctl/Ctl/dev/.env
@@ -63,10 +64,56 @@ services:
         max-file: "3"
     ports:
       # this needs to be set in the shell, compose env vars aren't read yet
-      - "${IXCTL_PORT:-8001}:8001"
+      - "${IXCTL_PORT:-6001}:6001"
     volumes:
       - ../../src/fullctl:/srv/service/main/fullctl
       - ../../../ixctl/src:/srv/service/main
 
+  peerctl_web:
+    build:
+      context: ../../../peerctl
+      dockerfile: Dockerfile
+    command: runserver 0.0.0.0:6001
+    env_file:
+      - .env
+      - ../../../peerctl/Ctl/dev/.env
+    environment:
+      DATABASE_PASSWORD: devPASSWORD
+      DATABASE_HOST: postgres
+    logging:
+      options:
+        max-size: 100m
+        max-file: "3"
+    ports:
+      # this needs to be set in the shell, compose env vars aren't read yet
+      - "${PEERCTL_PORT:-6001}:6001"
+    volumes:
+      - ../../src/fullctl:/srv/service/main/fullctl
+      - ../../../peerctl/src:/srv/service/main
+
+
+  devicectl_web:
+    build:
+      context: ../../../devicectl
+      dockerfile: Dockerfile
+    command: runserver 0.0.0.0:6001
+    env_file:
+      - .env
+      - ../../../devicectl/Ctl/dev/.env
+    environment:
+      DATABASE_PASSWORD: devPASSWORD
+      DATABASE_HOST: postgres
+    logging:
+      options:
+        max-size: 100m
+        max-file: "3"
+    ports:
+      # this needs to be set in the shell, compose env vars aren't read yet
+      - "${DEVICECTL_PORT:-6001}:6001"
+    volumes:
+      - ../../src/fullctl:/srv/service/main/fullctl
+      - ../../../devicectl/src:/srv/service/main
+
+
 volumes:
   postgres_data:
diff --git a/Ctl/dev/initdb.d/init-db.sh b/Ctl/dev/initdb.d/init-db.sh
index 5f8bc28a..502a7489 100755
--- a/Ctl/dev/initdb.d/init-db.sh
+++ b/Ctl/dev/initdb.d/init-db.sh
@@ -2,7 +2,7 @@
 
 set -e
 
-CREATE_PG="aaactl ixctl prefixctl"
+CREATE_PG="aaactl ixctl prefixctl devicectl peerctl"
 
 
 for each in $CREATE_PG; do
diff --git a/README.md b/README.md
index fcc618af..9c3949fa 100644
--- a/README.md
+++ b/README.md
@@ -1,9 +1,10 @@
-# FULLCTL 
+# fullctl
 
 [![PyPI](https://img.shields.io/pypi/v/fullctl.svg?maxAge=60)](https://pypi.python.org/pypi/fullctl)
 [![PyPI](https://img.shields.io/pypi/pyversions/fullctl.svg?maxAge=600)](https://pypi.python.org/pypi/fullctl)
 [![Tests](https://github.com/fullctl/fullctl/workflows/tests/badge.svg)](https://github.com/fullctl/fullctl)
 [![Codecov](https://img.shields.io/codecov/c/github/fullctl/fullctl/master.svg?maxAge=60)](https://codecov.io/github/fullctl/fullctl)
+[![Total alerts](https://img.shields.io/lgtm/alerts/g/fullctl/fullctl.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/fullctl/fullctl/alerts/)
 
 
 Core classes and functions for service applications
diff --git a/docs/authentication.md b/docs/authentication.md
new file mode 100644
index 00000000..3f82b35a
--- /dev/null
+++ b/docs/authentication.md
@@ -0,0 +1,7 @@
+# aaactl
+
+Authentication for fullctl based services and applications should be facilitated through [aaactl](https://github.com/fullctl/aaactl).
+
+aaactl is an authentication and account management hub that can also handle service billing to the users. 
+
+![authentication flowchart](img/auth-flow.png)
diff --git a/docs/develop.md b/docs/develop.md
index 89ed263d..ebb57ec4 100644
--- a/docs/develop.md
+++ b/docs/develop.md
@@ -23,21 +23,15 @@ poetry install
 poetry run pre-commit clean
 ```
 
-### Services
+## Database
 
-fullctl expects all used services to be cloned in it's parent dir. For example:
-
-```
-github.com/fullctl/
-  fullctl/
-  aaactl/
-  ixctl/
-  prefixctl/
-```
+This uses a common database server between fullctl services, each service still has it's own database.
 
-### Database
+To start it separately:
 
-This uses a common database server between fullctl services, each service still has it's own database.
+```sh
+poetry run Ctl/dev/compose.sh up postgres
+```
 
 #### Backups
 
@@ -69,6 +63,36 @@ Check perms on table
 SELECT grantee, privilege_type FROM information_schema.role_table_grants WHERE table_name='django_migrations';
 ```
 
-### TODO
+## Services
+
+fullctl expects all used services to be cloned in it's parent dir. For example:
+
+```
+github.com/fullctl/
+  fullctl/
+  aaactl/
+  ixctl/
+  prefixctl/
+```
+
+To start individually, for example `aaactl`
+semanage port -m -t http_port_t -p tcp 8001
+
+
+```sh
+# change the port it listens on if needed
+# export AAACTL_PORT=7002
+poetry run Ctl/dev/compose.sh up aaactl_web
+```
+
 
-Decide whether to use a common `.env` file (like aaactl_web) or use one from the service directory (like ixctl_web).
+### Bumping release
+
+```sh
+poetry run ctl version bump .
+poetry lock
+git commit -am "lock, bump version"
+poetry run ctl deploy_dev
+```
+
+### TODO
diff --git a/docs/img/auth-flow.png b/docs/img/auth-flow.png
new file mode 100755
index 00000000..f525f17d
Binary files /dev/null and b/docs/img/auth-flow.png differ
diff --git a/docs/tasks.md b/docs/tasks.md
new file mode 100644
index 00000000..f00e8f41
--- /dev/null
+++ b/docs/tasks.md
@@ -0,0 +1,249 @@
+# Task classes
+
+## Definition
+
+### Needs to be registered
+
+Task models need to be decorated with `fullctl.django.tasks.register`
+
+### Need to be proxy
+
+All task models should be proxy models of `fullctl.django.models.Task`
+
+### Need to set HandleRef tag
+
+Task models use the handle ref tag to unqiuely identify their type / model
+when it comes to converting from Task to the proxy model.
+
+### Config through TaskMeta
+
+The TaskMeta meta class can be used to specified task limits and task qualifiers
+for each task model individually.
+
+```py
+from fullctl.django.models import Task
+from fullctl.django.tasks import register
+
+@register
+class SumTask(Task):
+
+    # required 
+    class Meta:
+        proxy = True
+    
+    # required
+    class HandleRef:
+        tag = "task_sum"
+
+    # optional
+    class TaskMeta:
+        limit = 1
+        result_type = str
+        qualifiers = [qualifiers.Setting('IMPORTANT_SETTING', True),]
+
+    # required
+    def run(self, a, b, *args, **kwargs):
+        return (a,b)
+```
+
+### Needs to be imported
+
+Don't forget to make sure your task models are imported through your django app's `models.py`
+
+### Task Qualifiers
+
+The fullctl task queue allows you to limit which pollers are allowed to work on which tasks through the `qualifiers` system.
+
+If qualifiers are set on your task, only the pollers that meet the requirements will be able to poll and claim those tasks.
+
+#### Settings Qualifier
+
+This qualifier allows you to require certain conditions to exist within the django project settings.
+
+##### Example
+
+```py
+from fullctl.django.tasks import qualifiers
+class MyTask(Task):
+
+    class Meta:
+        proxy = True
+    
+    class HandleRef:
+        tag = "task_my"
+
+    class TaskMeta:
+        qualifiers = [
+            # require settings.IMPORTANT_SETTING to be True
+            qualifiers.Settings('IMPORTANT_SETTING', True),
+
+            # require value 'xyz' to exist in settings.IMPORTANT_LIST
+            qualifiers.Settings(
+                'IMPORTANT_LIST',
+                lambda v: 'xyz' in v
+            )
+        ]
+```
+
+### Task Limits
+
+You may want to limit how many pending instances can exist of a task model.
+
+This is done through the `TaskMeta` limit property and the task model's `limit_id` property.
+
+#### Examples
+
+Allow a maximum of 1 pending instances to be up for the task.
+
+```py
+class MyTask(Task):
+    class Meta:
+        proxy = True
+    
+    class HandleRef:
+        tag = "task_my"
+
+    class TaskMeta:
+        limit = 1
+
+
+MyTask.create_task() # ok
+MyTask.create_task() # raises TaskLimitError
+```
+
+Allow 1 pending instances to be up for each variation of task as specified
+by the first task argument
+
+```py
+class MyTask(Task):
+    class Meta:
+        proxy = True
+    
+    class HandleRef:
+        tag = "task_my"
+
+    class TaskMeta:
+        limit = 1 
+
+    @property
+    def generate_limit_id(self):
+        return self.param["args"][0]
+
+MyTask.create_task("first") # ok
+MyTask.create_task("second") # ok
+MyTask.create_task("first") # raises TaskLimitError
+```
+
+### Migrations
+
+Django still wants to make migrations for proxy models and will do so at the next
+`makemigrations` call. However it does not seem to be mandatory for normal operation. Just be aware that such migrations will be made.
+
+
+# Creating task instances
+
+Tasks are spawned by creating object instances of the task.
+
+This should be done through the models `create_task` class method.
+
+```py
+MyTask.create_task()
+```
+
+## Execution
+
+Execution logic should be overridden in the model's `run` method.
+
+### Task arguments
+
+Any arguments and keyword arguments passed to create task (excl `parent` and `timeout`) will be also passed to the tasks `run` method.
+
+### Task results
+
+A task wont have any result value until its processed. You can wait for the results by calling the `wait` method on the task. If `async` context you can also use `async_wait` instead.
+
+#### Result type
+
+You can specify a result type for the task in `TaskMeta` with the `result_type` property
+
+### Example
+
+```py
+class SumTask(Task):
+    class Meta:
+        proxy = True
+
+    class HandleRef:
+        tag = "task_sum"
+
+    class TaskMeta:
+        result_type = int
+
+    def run(self, a, b, *args, **kwargs):
+        return (a+b)
+
+task = SumTask.create_task(2,3)
+
+# wait for task to be processed
+task.wait(timeout=10)
+
+assert task.result == 5
+```
+
+## Chaining tasks
+
+You can chain tasks, requiring the parent task to be processed succesfully before processing the child task
+
+This is done by specifying the `parent` parameter in `create_task`
+
+```py
+first_task = MyTask.create_task()
+next_task = MyTask.create_task(parent=first_task)
+```
+
+The `child` task will not be processed until the parent task is processed.
+
+## Timeouts
+
+You can give a task a maximum execution time via the timeout parameter in `create_task`
+
+```py
+task = MyTask.create_task(timeout=600) # 10 minutes max. execution time
+
+task.wait(timeout=10) # separate timeout for waiting for a result
+```
+
+# Django settings
+
+- `TASK_ORM_WORKER_ID`: specifies how the environment is identified when spawning or processing a task. if not specified this will default to "{hostname}:{pid}"
+
+# Django admin
+
+Task state and listing can be accessed from `/admin/django_fullctl/task`
+
+- source: hostname and process id that spawned the task
+- queue_id: hostname and process id that claimed the task for processing
+- parent: parent task
+- status: task status (pending, running, completed, failed, cancelled)
+- param: task parameters
+- time: task completion time
+
+# Queue a fullctl django command
+
+All fullctl django commands have a `-Q` paramenter that allows you to send the command execution to the queue.
+
+## Example
+
+```sh
+manage.py fullctl_promote_user vegu -Q
+```
+
+# Process tasks
+
+Fetch and process tasks in the task queue using the `fullctl_poll_tasks` command
+
+```sh
+manage.py fullctl_poll_tasks --workers 4
+```
+
+This will run forever and automatically fetch and process tasks that the environment is qualified to handle.
diff --git a/poetry.lock b/poetry.lock
index 920e5013..3c26f541 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -19,7 +19,7 @@ python-versions = "*"
 
 [[package]]
 name = "asgiref"
-version = "3.3.4"
+version = "3.4.1"
 description = "ASGI specs, helper code, and adapters"
 category = "main"
 optional = false
@@ -53,6 +53,21 @@ docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"]
 tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface"]
 tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins"]
 
+[[package]]
+name = "backports.entry-points-selectable"
+version = "1.1.0"
+description = "Compatibility shim providing selectable entry points for older implementations"
+category = "dev"
+optional = false
+python-versions = ">=2.7"
+
+[package.dependencies]
+importlib-metadata = {version = "*", markers = "python_version < \"3.8\""}
+
+[package.extras]
+docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"]
+testing = ["pytest (>=4.6)", "pytest-flake8", "pytest-cov", "pytest-black (>=0.3.7)", "pytest-mypy", "pytest-checkdocs (>=2.4)", "pytest-enabler (>=1.0.1)"]
+
 [[package]]
 name = "bandit"
 version = "1.7.0"
@@ -94,7 +109,7 @@ python-versions = "*"
 
 [[package]]
 name = "black"
-version = "21.6b0"
+version = "21.7b0"
 description = "The uncompromising code formatter."
 category = "dev"
 optional = false
@@ -106,7 +121,7 @@ click = ">=7.1.2"
 mypy-extensions = ">=0.4.3"
 pathspec = ">=0.8.1,<1"
 regex = ">=2020.1.8"
-toml = ">=0.10.1"
+tomli = ">=0.2.6,<2.0.0"
 typed-ast = {version = ">=1.4.2", markers = "python_version < \"3.8\""}
 typing-extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""}
 
@@ -126,29 +141,28 @@ python-versions = "*"
 
 [[package]]
 name = "celery"
-version = "5.1.0"
+version = "5.0.2"
 description = "Distributed Task Queue."
 category = "main"
 optional = false
 python-versions = ">=3.6,"
 
 [package.dependencies]
-billiard = ">=3.6.4.0,<4.0"
-click = ">=7.0,<8.0"
+billiard = ">=3.6.3.0,<4.0"
+click = ">=7.0"
 click-didyoumean = ">=0.0.3"
-click-plugins = ">=1.1.1"
 click-repl = ">=0.1.6"
-kombu = ">=5.1.0,<6.0"
+kombu = ">=5.0.0,<6.0"
 pytz = ">0.0-dev"
 vine = ">=5.0.0,<6.0"
 
 [package.extras]
 arangodb = ["pyArango (>=1.3.2)"]
 auth = ["cryptography"]
-azureblockblob = ["azure-storage-blob (==12.6.0)"]
+azureblockblob = ["azure-storage (==0.36.0)", "azure-common (==1.1.5)", "azure-storage-common (==1.1.0)"]
 brotli = ["brotli (>=1.0.0)", "brotlipy (>=0.7.0)"]
 cassandra = ["cassandra-driver (<3.21.0)"]
-consul = ["python-consul2"]
+consul = ["python-consul"]
 cosmosdbsql = ["pydocumentdb (==2.3.2)"]
 couchbase = ["couchbase (>=3.0.0)"]
 couchdb = ["pycouchdb"]
@@ -158,12 +172,12 @@ elasticsearch = ["elasticsearch"]
 eventlet = ["eventlet (>=0.26.1)"]
 gevent = ["gevent (>=1.0.0)"]
 librabbitmq = ["librabbitmq (>=1.5.0)"]
+lzma = ["backports.lzma"]
 memcache = ["pylibmc"]
 mongodb = ["pymongo[srv] (>=3.3.0)"]
 msgpack = ["msgpack"]
 pymemcache = ["python-memcached"]
 pyro = ["pyro4"]
-pytest = ["pytest-celery"]
 redis = ["redis (>=3.2.0)"]
 s3 = ["boto3 (>=1.9.125)"]
 slmq = ["softlayer-messaging (>=1.0.3)"]
@@ -185,7 +199,7 @@ python-versions = "*"
 
 [[package]]
 name = "cffi"
-version = "1.14.5"
+version = "1.14.6"
 description = "Foreign Function Interface for Python calling C code."
 category = "main"
 optional = false
@@ -214,20 +228,27 @@ python-versions = "*"
 six = "<=2.0.0"
 
 [[package]]
-name = "chardet"
-version = "4.0.0"
-description = "Universal encoding detector for Python 2 and 3"
+name = "charset-normalizer"
+version = "2.0.4"
+description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
 category = "main"
 optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+python-versions = ">=3.5.0"
+
+[package.extras]
+unicode_backport = ["unicodedata2"]
 
 [[package]]
 name = "click"
-version = "7.1.2"
+version = "8.0.1"
 description = "Composable command line interface toolkit"
 category = "main"
 optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+python-versions = ">=3.6"
+
+[package.dependencies]
+colorama = {version = "*", markers = "platform_system == \"Windows\""}
+importlib-metadata = {version = "*", markers = "python_version < \"3.8\""}
 
 [[package]]
 name = "click-didyoumean"
@@ -240,20 +261,6 @@ python-versions = "*"
 [package.dependencies]
 click = "*"
 
-[[package]]
-name = "click-plugins"
-version = "1.1.1"
-description = "An extension module for click to enable registering CLI commands via setuptools entry-points."
-category = "main"
-optional = false
-python-versions = "*"
-
-[package.dependencies]
-click = ">=4.0"
-
-[package.extras]
-dev = ["pytest (>=3.6)", "pytest-cov", "wheel", "coveralls"]
-
 [[package]]
 name = "click-repl"
 version = "0.2.0"
@@ -267,21 +274,33 @@ click = "*"
 prompt-toolkit = "*"
 six = "*"
 
+[[package]]
+name = "codecov"
+version = "2.1.12"
+description = "Hosted coverage reports for GitHub, Bitbucket and Gitlab"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
+[package.dependencies]
+coverage = "*"
+requests = ">=2.7.9"
+
 [[package]]
 name = "colorama"
 version = "0.4.4"
 description = "Cross-platform colored terminal text."
-category = "dev"
+category = "main"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
 
 [[package]]
 name = "confu"
-version = "1.7.1"
+version = "1.8.0"
 description = "Configuration file validation and generation"
-category = "dev"
+category = "main"
 optional = false
-python-versions = ">=3.6,<4.0"
+python-versions = ">=3.6.2,<4.0.0"
 
 [[package]]
 name = "coverage"
@@ -346,7 +365,7 @@ python-versions = "*"
 
 [[package]]
 name = "distro"
-version = "1.5.0"
+version = "1.6.0"
 description = "Distro - an OS platform information API"
 category = "dev"
 optional = false
@@ -354,7 +373,7 @@ python-versions = "*"
 
 [[package]]
 name = "django"
-version = "3.2.4"
+version = "3.2.6"
 description = "A high-level Python Web framework that encourages rapid development and clean, pragmatic design."
 category = "main"
 optional = false
@@ -400,6 +419,14 @@ maintainer = ["transifex-client", "zest.releaser", "django"]
 pyuca = ["pyuca"]
 test = ["pytest", "pytest-django", "pytest-cov", "graphene-django"]
 
+[[package]]
+name = "django-crispy-forms"
+version = "1.12.0"
+description = "Best way to have Django DRY forms"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
 [[package]]
 name = "django-grainy"
 version = "1.9.1"
@@ -414,36 +441,57 @@ six = ">=1.11.0,<=2.0.0"
 
 [[package]]
 name = "django-handleref"
-version = "0.6.0"
-description = "track when an object was created or changed and allowe querying based on time and versioning"
+version = "1.0.0"
+description = "django object tracking"
 category = "main"
 optional = false
-python-versions = "*"
-
-[package.dependencies]
-six = ">=1.10,<2.0"
+python-versions = ">=3.6.2,<4.0.0"
 
 [[package]]
 name = "django-inet"
-version = "0.5.0"
+version = "1.0.0"
 description = "django internet utilities"
 category = "main"
 optional = false
+python-versions = ">=3.6.2,<4.0.0"
+
+[[package]]
+name = "django-netfields"
+version = "1.2.2"
+description = "Django PostgreSQL netfields implementation"
+category = "main"
+optional = false
 python-versions = "*"
 
+[package.dependencies]
+django = ">=1.8"
+ipaddress = "*"
+netaddr = "*"
+six = "*"
+
 [[package]]
 name = "django-peeringdb"
-version = "2.7.0"
-description = "PeeringDB models and local synchronization for Django"
+version = "2.8.0"
+description = "PeeringDB Django models"
+category = "main"
+optional = false
+python-versions = ">=3.6.2,<4.0.0"
+
+[package.dependencies]
+django_countries = ">1"
+django_handleref = ">=1,<2"
+django_inet = ">=1,<2"
+
+[[package]]
+name = "django-recaptcha"
+version = "2.0.6"
+description = "Django recaptcha form field/widget app."
 category = "main"
 optional = false
 python-versions = "*"
 
 [package.dependencies]
-django_countries = ">=0.1.0"
-django_handleref = ">=0.6,<0.7"
-django_inet = ">=0.5,<0.6"
-"twentyc.rpc" = ">=0.4.0,<0.5"
+django = ">1.11,<4.0"
 
 [[package]]
 name = "django-reversion"
@@ -598,15 +646,15 @@ smmap = ">=3.0.1,<5"
 
 [[package]]
 name = "gitpython"
-version = "3.1.17"
+version = "3.1.20"
 description = "Python Git Library"
 category = "dev"
 optional = false
-python-versions = ">=3.5"
+python-versions = ">=3.6"
 
 [package.dependencies]
 gitdb = ">=4.0.1,<5"
-typing-extensions = {version = ">=3.7.4.0", markers = "python_version < \"3.8\""}
+typing-extensions = {version = ">=3.7.4.3", markers = "python_version < \"3.10\""}
 
 [[package]]
 name = "grainy"
@@ -618,7 +666,7 @@ python-versions = ">=3.6,<4.0"
 
 [[package]]
 name = "identify"
-version = "2.2.10"
+version = "2.2.13"
 description = "File identification library for Python"
 category = "dev"
 optional = false
@@ -629,15 +677,15 @@ license = ["editdistance-s"]
 
 [[package]]
 name = "idna"
-version = "2.10"
+version = "3.2"
 description = "Internationalized Domain Names in Applications (IDNA)"
 category = "main"
 optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+python-versions = ">=3.5"
 
 [[package]]
 name = "importlib-metadata"
-version = "4.5.0"
+version = "4.6.3"
 description = "Read metadata from Python packages"
 category = "main"
 optional = false
@@ -649,7 +697,8 @@ zipp = ">=0.5"
 
 [package.extras]
 docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"]
-testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"]
+perf = ["ipython"]
+testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"]
 
 [[package]]
 name = "iniconfig"
@@ -659,18 +708,27 @@ category = "dev"
 optional = false
 python-versions = "*"
 
+[[package]]
+name = "ipaddress"
+version = "1.0.23"
+description = "IPv4/IPv6 manipulation library"
+category = "main"
+optional = false
+python-versions = "*"
+
 [[package]]
 name = "isort"
-version = "5.8.0"
+version = "5.9.3"
 description = "A Python utility / library to sort Python imports."
 category = "dev"
 optional = false
-python-versions = ">=3.6,<4.0"
+python-versions = ">=3.6.1,<4.0"
 
 [package.extras]
 pipfile_deprecated_finder = ["pipreqs", "requirementslib"]
 requirements_deprecated_finder = ["pipreqs", "pip-api"]
 colors = ["colorama (>=0.4.3,<0.5.0)"]
+plugins = ["setuptools"]
 
 [[package]]
 name = "jinja2"
@@ -785,7 +843,7 @@ python-versions = ">=3.6"
 
 [[package]]
 name = "mkdocs"
-version = "1.2.1"
+version = "1.2.2"
 description = "Project documentation with Markdown."
 category = "dev"
 optional = false
@@ -808,16 +866,20 @@ i18n = ["babel (>=2.9.0)"]
 
 [[package]]
 name = "munge"
-version = "1.1.0"
-description = "data manipulation client / library"
+version = "1.2.0"
+description = "data manipulation library and client"
 category = "main"
 optional = false
-python-versions = "*"
+python-versions = ">=3.6,<4.0"
 
 [package.dependencies]
-click = ">=5.1"
-PyYAML = ">=3.10"
-requests = ">=2.6.0,<3"
+click = ">=8.0.1,<9.0.0"
+requests = ">=2.6,<3.0"
+
+[package.extras]
+yaml = ["PyYAML (>=5.1,<6.0)"]
+toml = ["toml (>=0.10.2,<0.11.0)"]
+tomlkit = ["tomlkit (>=0.7.2,<0.8.0)"]
 
 [[package]]
 name = "mypy-extensions"
@@ -827,6 +889,14 @@ category = "dev"
 optional = false
 python-versions = "*"
 
+[[package]]
+name = "netaddr"
+version = "0.8.0"
+description = "A network address manipulation library for Python"
+category = "main"
+optional = false
+python-versions = "*"
+
 [[package]]
 name = "nodeenv"
 version = "1.6.0"
@@ -850,11 +920,11 @@ signedtoken = ["cryptography (>=3.0.0,<4)", "pyjwt (>=2.0.0,<3)"]
 
 [[package]]
 name = "packaging"
-version = "20.9"
+version = "21.0"
 description = "Core utilities for Python packages"
 category = "dev"
 optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+python-versions = ">=3.6"
 
 [package.dependencies]
 pyparsing = ">=2.0.2"
@@ -880,11 +950,11 @@ invoke = ["invoke (>=1.3)"]
 
 [[package]]
 name = "pathspec"
-version = "0.8.1"
+version = "0.9.0"
 description = "Utility library for gitignore style pattern matching of file paths."
 category = "dev"
 optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
 
 [[package]]
 name = "pbr"
@@ -908,6 +978,18 @@ munge = ">=0.1.5"
 PyYAML = ">=3.11"
 "twentyc.rpc" = ">=0.3.4"
 
+[[package]]
+name = "platformdirs"
+version = "2.2.0"
+description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.extras]
+docs = ["Sphinx (>=4)", "furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)"]
+test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)"]
+
 [[package]]
 name = "pluggy"
 version = "0.13.1"
@@ -935,7 +1017,7 @@ munge = ">=1.0.0,<2.0.0"
 
 [[package]]
 name = "pre-commit"
-version = "2.13.0"
+version = "2.14.0"
 description = "A framework for managing and maintaining multi-language pre-commit hooks."
 category = "dev"
 optional = false
@@ -952,7 +1034,7 @@ virtualenv = ">=20.0.8"
 
 [[package]]
 name = "prompt-toolkit"
-version = "3.0.18"
+version = "3.0.19"
 description = "Library for building powerful interactive command lines in Python"
 category = "main"
 optional = false
@@ -961,6 +1043,14 @@ python-versions = ">=3.6.1"
 [package.dependencies]
 wcwidth = "*"
 
+[[package]]
+name = "psycopg2-binary"
+version = "2.9.1"
+description = "psycopg2 - Python-PostgreSQL Database Adapter"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
 [[package]]
 name = "py"
 version = "1.10.0"
@@ -1033,11 +1123,11 @@ python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
 
 [[package]]
 name = "pyrsistent"
-version = "0.17.3"
+version = "0.18.0"
 description = "Persistent/Functional/Immutable data structures"
 category = "dev"
 optional = false
-python-versions = ">=3.5"
+python-versions = ">=3.6"
 
 [[package]]
 name = "pytest"
@@ -1105,7 +1195,7 @@ pytest = ">=2.5.2"
 
 [[package]]
 name = "python-dateutil"
-version = "2.8.1"
+version = "2.8.2"
 description = "Extensions to the standard Python datetime module"
 category = "dev"
 optional = false
@@ -1116,11 +1206,11 @@ six = ">=1.5"
 
 [[package]]
 name = "python-dotenv"
-version = "0.17.1"
+version = "0.19.0"
 description = "Read key-value pairs from a .env file and set them as environment variables"
 category = "dev"
 optional = false
-python-versions = "*"
+python-versions = ">=3.5"
 
 [package.extras]
 cli = ["click (>=5.0)"]
@@ -1150,7 +1240,7 @@ python-versions = "*"
 
 [[package]]
 name = "pyupgrade"
-version = "2.19.4"
+version = "2.23.3"
 description = "A tool to automatically upgrade syntax for newer versions."
 category = "dev"
 optional = false
@@ -1188,7 +1278,7 @@ pyyaml = "*"
 
 [[package]]
 name = "regex"
-version = "2021.4.4"
+version = "2021.8.3"
 description = "Alternative regular expression module, to replace re."
 category = "dev"
 optional = false
@@ -1196,21 +1286,21 @@ python-versions = "*"
 
 [[package]]
 name = "requests"
-version = "2.25.1"
+version = "2.26.0"
 description = "Python HTTP for Humans."
 category = "main"
 optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
 
 [package.dependencies]
 certifi = ">=2017.4.17"
-chardet = ">=3.0.2,<5"
-idna = ">=2.5,<3"
+charset-normalizer = {version = ">=2.0.0,<2.1.0", markers = "python_version >= \"3\""}
+idna = {version = ">=2.5,<4", markers = "python_version >= \"3\""}
 urllib3 = ">=1.21.1,<1.27"
 
 [package.extras]
-security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"]
 socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"]
+use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"]
 
 [[package]]
 name = "requests-oauthlib"
@@ -1299,9 +1389,20 @@ python-versions = ">=3.6"
 importlib-metadata = {version = ">=1.7.0", markers = "python_version < \"3.8\""}
 pbr = ">=2.0.0,<2.1.0 || >2.1.0"
 
+[[package]]
+name = "stripe"
+version = "2.60.0"
+description = "Python bindings for the Stripe API"
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
+[package.dependencies]
+requests = {version = ">=2.20", markers = "python_version >= \"3.0\""}
+
 [[package]]
 name = "texttable"
-version = "1.6.3"
+version = "1.6.4"
 description = "module for creating simple ASCII tables"
 category = "dev"
 optional = false
@@ -1334,6 +1435,14 @@ category = "dev"
 optional = false
 python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
 
+[[package]]
+name = "tomli"
+version = "1.2.1"
+description = "A lil' TOML parser"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
 [[package]]
 name = "twentyc.rpc"
 version = "0.4.0"
@@ -1364,7 +1473,7 @@ python-versions = "*"
 
 [[package]]
 name = "urllib3"
-version = "1.26.5"
+version = "1.26.6"
 description = "HTTP library with thread-safe connection pooling, file post, and more."
 category = "main"
 optional = false
@@ -1375,6 +1484,14 @@ brotli = ["brotlipy (>=0.6.0)"]
 secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"]
 socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
 
+[[package]]
+name = "uwsgi"
+version = "2.0.19.1"
+description = "The uWSGI server"
+category = "main"
+optional = false
+python-versions = "*"
+
 [[package]]
 name = "vine"
 version = "5.0.0"
@@ -1385,26 +1502,27 @@ python-versions = ">=3.6"
 
 [[package]]
 name = "virtualenv"
-version = "20.4.7"
+version = "20.7.0"
 description = "Virtual Python Environment builder"
 category = "dev"
 optional = false
-python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7"
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
 
 [package.dependencies]
-appdirs = ">=1.4.3,<2"
+"backports.entry-points-selectable" = ">=1.0.4"
 distlib = ">=0.3.1,<1"
 filelock = ">=3.0.0,<4"
 importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""}
+platformdirs = ">=2,<3"
 six = ">=1.9.0,<2"
 
 [package.extras]
 docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=19.9.0rc1)"]
-testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)", "xonsh (>=0.9.16)"]
+testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)"]
 
 [[package]]
 name = "watchdog"
-version = "2.1.2"
+version = "2.1.3"
 description = "Filesystem events monitoring"
 category = "dev"
 optional = false
@@ -1434,7 +1552,7 @@ six = "*"
 
 [[package]]
 name = "whitenoise"
-version = "5.2.0"
+version = "5.3.0"
 description = "Radically simplified static file serving for WSGI applications"
 category = "main"
 optional = false
@@ -1445,7 +1563,7 @@ brotli = ["brotli"]
 
 [[package]]
 name = "zipp"
-version = "3.4.1"
+version = "3.5.0"
 description = "Backport of pathlib-compatible object wrapper for zip files"
 category = "main"
 optional = false
@@ -1453,12 +1571,12 @@ python-versions = ">=3.6"
 
 [package.extras]
 docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"]
-testing = ["pytest (>=4.6)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "pytest-enabler", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"]
+testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"]
 
 [metadata]
 lock-version = "1.1"
 python-versions = "^3.7"
-content-hash = "be15e64d09fb9a8f526a72903e5305a6cb200308860075d11e16421dbd3904d3"
+content-hash = "c3448a95453f635c54a195da020565b9d8c6559bd07784b7018188f2adc4b52d"
 
 [metadata.files]
 amqp = [
@@ -1470,8 +1588,8 @@ appdirs = [
     {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"},
 ]
 asgiref = [
-    {file = "asgiref-3.3.4-py3-none-any.whl", hash = "sha256:92906c611ce6c967347bbfea733f13d6313901d54dcca88195eaeb52b2a8e8ee"},
-    {file = "asgiref-3.3.4.tar.gz", hash = "sha256:d1216dfbdfb63826470995d31caed36225dcaf34f182e0fa257a4dd9e86f1b78"},
+    {file = "asgiref-3.4.1-py3-none-any.whl", hash = "sha256:ffc141aa908e6f175673e7b1b3b7af4fdb0ecb738fc5c8b88f69f055c2415214"},
+    {file = "asgiref-3.4.1.tar.gz", hash = "sha256:4ef1ab46b484e3c706329cedeff284a5d40824200638503f5768edb6de7d58e9"},
 ]
 atomicwrites = [
     {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"},
@@ -1481,6 +1599,10 @@ attrs = [
     {file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"},
     {file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"},
 ]
+"backports.entry-points-selectable" = [
+    {file = "backports.entry_points_selectable-1.1.0-py2.py3-none-any.whl", hash = "sha256:a6d9a871cde5e15b4c4a53e3d43ba890cc6861ec1332c9c2428c92f977192acc"},
+    {file = "backports.entry_points_selectable-1.1.0.tar.gz", hash = "sha256:988468260ec1c196dab6ae1149260e2f5472c9110334e5d51adcb77867361f6a"},
+]
 bandit = [
     {file = "bandit-1.7.0-py3-none-any.whl", hash = "sha256:216be4d044209fa06cf2a3e51b319769a51be8318140659719aa7a115c35ed07"},
     {file = "bandit-1.7.0.tar.gz", hash = "sha256:8a4c7415254d75df8ff3c3b15cfe9042ecee628a1e40b44c15a98890fbfc2608"},
@@ -1499,59 +1621,62 @@ billiard = [
     {file = "billiard-3.6.4.0.tar.gz", hash = "sha256:299de5a8da28a783d51b197d496bef4f1595dd023a93a4f59dde1886ae905547"},
 ]
 black = [
-    {file = "black-21.6b0-py3-none-any.whl", hash = "sha256:dfb8c5a069012b2ab1e972e7b908f5fb42b6bbabcba0a788b86dc05067c7d9c7"},
-    {file = "black-21.6b0.tar.gz", hash = "sha256:dc132348a88d103016726fe360cb9ede02cecf99b76e3660ce6c596be132ce04"},
+    {file = "black-21.7b0-py3-none-any.whl", hash = "sha256:1c7aa6ada8ee864db745b22790a32f94b2795c253a75d6d9b5e439ff10d23116"},
+    {file = "black-21.7b0.tar.gz", hash = "sha256:c8373c6491de9362e39271630b65b964607bc5c79c83783547d76c839b3aa219"},
 ]
 cached-property = [
     {file = "cached-property-1.5.2.tar.gz", hash = "sha256:9fa5755838eecbb2d234c3aa390bd80fbd3ac6b6869109bfc1b499f7bd89a130"},
     {file = "cached_property-1.5.2-py2.py3-none-any.whl", hash = "sha256:df4f613cf7ad9a588cc381aaf4a512d26265ecebd5eb9e1ba12f1319eb85a6a0"},
 ]
 celery = [
-    {file = "celery-5.1.0-py3-none-any.whl", hash = "sha256:1329de1edeaf734ef859e630cb42df2c116d53e59d2f46433b13aed196e85620"},
-    {file = "celery-5.1.0.tar.gz", hash = "sha256:65f061c04578cf189cd7352c192e1a79fdeb370b916bff792bcc769560e81184"},
+    {file = "celery-5.0.2-py3-none-any.whl", hash = "sha256:930c3acd55349d028c4e7104a7d377729cbcca19d9fce470c17172d9e7f9a8b6"},
+    {file = "celery-5.0.2.tar.gz", hash = "sha256:012c814967fe89e3f5d2cf49df2dba3de5f29253a7f4f2270e8fce6b901b4ebf"},
 ]
 certifi = [
     {file = "certifi-2021.5.30-py2.py3-none-any.whl", hash = "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8"},
     {file = "certifi-2021.5.30.tar.gz", hash = "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee"},
 ]
 cffi = [
-    {file = "cffi-1.14.5-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:bb89f306e5da99f4d922728ddcd6f7fcebb3241fc40edebcb7284d7514741991"},
-    {file = "cffi-1.14.5-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:34eff4b97f3d982fb93e2831e6750127d1355a923ebaeeb565407b3d2f8d41a1"},
-    {file = "cffi-1.14.5-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:99cd03ae7988a93dd00bcd9d0b75e1f6c426063d6f03d2f90b89e29b25b82dfa"},
-    {file = "cffi-1.14.5-cp27-cp27m-win32.whl", hash = "sha256:65fa59693c62cf06e45ddbb822165394a288edce9e276647f0046e1ec26920f3"},
-    {file = "cffi-1.14.5-cp27-cp27m-win_amd64.whl", hash = "sha256:51182f8927c5af975fece87b1b369f722c570fe169f9880764b1ee3bca8347b5"},
-    {file = "cffi-1.14.5-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:43e0b9d9e2c9e5d152946b9c5fe062c151614b262fda2e7b201204de0b99e482"},
-    {file = "cffi-1.14.5-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:cbde590d4faaa07c72bf979734738f328d239913ba3e043b1e98fe9a39f8b2b6"},
-    {file = "cffi-1.14.5-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:5de7970188bb46b7bf9858eb6890aad302577a5f6f75091fd7cdd3ef13ef3045"},
-    {file = "cffi-1.14.5-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:a465da611f6fa124963b91bf432d960a555563efe4ed1cc403ba5077b15370aa"},
-    {file = "cffi-1.14.5-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:d42b11d692e11b6634f7613ad8df5d6d5f8875f5d48939520d351007b3c13406"},
-    {file = "cffi-1.14.5-cp35-cp35m-win32.whl", hash = "sha256:72d8d3ef52c208ee1c7b2e341f7d71c6fd3157138abf1a95166e6165dd5d4369"},
-    {file = "cffi-1.14.5-cp35-cp35m-win_amd64.whl", hash = "sha256:29314480e958fd8aab22e4a58b355b629c59bf5f2ac2492b61e3dc06d8c7a315"},
-    {file = "cffi-1.14.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:3d3dd4c9e559eb172ecf00a2a7517e97d1e96de2a5e610bd9b68cea3925b4892"},
-    {file = "cffi-1.14.5-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:48e1c69bbacfc3d932221851b39d49e81567a4d4aac3b21258d9c24578280058"},
-    {file = "cffi-1.14.5-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:69e395c24fc60aad6bb4fa7e583698ea6cc684648e1ffb7fe85e3c1ca131a7d5"},
-    {file = "cffi-1.14.5-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:9e93e79c2551ff263400e1e4be085a1210e12073a31c2011dbbda14bda0c6132"},
-    {file = "cffi-1.14.5-cp36-cp36m-win32.whl", hash = "sha256:58e3f59d583d413809d60779492342801d6e82fefb89c86a38e040c16883be53"},
-    {file = "cffi-1.14.5-cp36-cp36m-win_amd64.whl", hash = "sha256:005a36f41773e148deac64b08f233873a4d0c18b053d37da83f6af4d9087b813"},
-    {file = "cffi-1.14.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:2894f2df484ff56d717bead0a5c2abb6b9d2bf26d6960c4604d5c48bbc30ee73"},
-    {file = "cffi-1.14.5-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:0857f0ae312d855239a55c81ef453ee8fd24136eaba8e87a2eceba644c0d4c06"},
-    {file = "cffi-1.14.5-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:cd2868886d547469123fadc46eac7ea5253ea7fcb139f12e1dfc2bbd406427d1"},
-    {file = "cffi-1.14.5-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:35f27e6eb43380fa080dccf676dece30bef72e4a67617ffda586641cd4508d49"},
-    {file = "cffi-1.14.5-cp37-cp37m-win32.whl", hash = "sha256:9ff227395193126d82e60319a673a037d5de84633f11279e336f9c0f189ecc62"},
-    {file = "cffi-1.14.5-cp37-cp37m-win_amd64.whl", hash = "sha256:9cf8022fb8d07a97c178b02327b284521c7708d7c71a9c9c355c178ac4bbd3d4"},
-    {file = "cffi-1.14.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8b198cec6c72df5289c05b05b8b0969819783f9418e0409865dac47288d2a053"},
-    {file = "cffi-1.14.5-cp38-cp38-manylinux1_i686.whl", hash = "sha256:ad17025d226ee5beec591b52800c11680fca3df50b8b29fe51d882576e039ee0"},
-    {file = "cffi-1.14.5-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:6c97d7350133666fbb5cf4abdc1178c812cb205dc6f41d174a7b0f18fb93337e"},
-    {file = "cffi-1.14.5-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:8ae6299f6c68de06f136f1f9e69458eae58f1dacf10af5c17353eae03aa0d827"},
-    {file = "cffi-1.14.5-cp38-cp38-win32.whl", hash = "sha256:b85eb46a81787c50650f2392b9b4ef23e1f126313b9e0e9013b35c15e4288e2e"},
-    {file = "cffi-1.14.5-cp38-cp38-win_amd64.whl", hash = "sha256:1f436816fc868b098b0d63b8920de7d208c90a67212546d02f84fe78a9c26396"},
-    {file = "cffi-1.14.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1071534bbbf8cbb31b498d5d9db0f274f2f7a865adca4ae429e147ba40f73dea"},
-    {file = "cffi-1.14.5-cp39-cp39-manylinux1_i686.whl", hash = "sha256:9de2e279153a443c656f2defd67769e6d1e4163952b3c622dcea5b08a6405322"},
-    {file = "cffi-1.14.5-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:6e4714cc64f474e4d6e37cfff31a814b509a35cb17de4fb1999907575684479c"},
-    {file = "cffi-1.14.5-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:158d0d15119b4b7ff6b926536763dc0714313aa59e320ddf787502c70c4d4bee"},
-    {file = "cffi-1.14.5-cp39-cp39-win32.whl", hash = "sha256:afb29c1ba2e5a3736f1c301d9d0abe3ec8b86957d04ddfa9d7a6a42b9367e396"},
-    {file = "cffi-1.14.5-cp39-cp39-win_amd64.whl", hash = "sha256:f2d45f97ab6bb54753eab54fffe75aaf3de4ff2341c9daee1987ee1837636f1d"},
-    {file = "cffi-1.14.5.tar.gz", hash = "sha256:fd78e5fee591709f32ef6edb9a015b4aa1a5022598e36227500c8f4e02328d9c"},
+    {file = "cffi-1.14.6-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:22b9c3c320171c108e903d61a3723b51e37aaa8c81255b5e7ce102775bd01e2c"},
+    {file = "cffi-1.14.6-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:f0c5d1acbfca6ebdd6b1e3eded8d261affb6ddcf2186205518f1428b8569bb99"},
+    {file = "cffi-1.14.6-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:99f27fefe34c37ba9875f224a8f36e31d744d8083e00f520f133cab79ad5e819"},
+    {file = "cffi-1.14.6-cp27-cp27m-win32.whl", hash = "sha256:55af55e32ae468e9946f741a5d51f9896da6b9bf0bbdd326843fec05c730eb20"},
+    {file = "cffi-1.14.6-cp27-cp27m-win_amd64.whl", hash = "sha256:7bcac9a2b4fdbed2c16fa5681356d7121ecabf041f18d97ed5b8e0dd38a80224"},
+    {file = "cffi-1.14.6-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:ed38b924ce794e505647f7c331b22a693bee1538fdf46b0222c4717b42f744e7"},
+    {file = "cffi-1.14.6-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:e22dcb48709fc51a7b58a927391b23ab37eb3737a98ac4338e2448bef8559b33"},
+    {file = "cffi-1.14.6-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:e8c6a99be100371dbb046880e7a282152aa5d6127ae01783e37662ef73850d8f"},
+    {file = "cffi-1.14.6-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:19ca0dbdeda3b2615421d54bef8985f72af6e0c47082a8d26122adac81a95872"},
+    {file = "cffi-1.14.6-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:d950695ae4381ecd856bcaf2b1e866720e4ab9a1498cba61c602e56630ca7195"},
+    {file = "cffi-1.14.6-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e9dc245e3ac69c92ee4c167fbdd7428ec1956d4e754223124991ef29eb57a09d"},
+    {file = "cffi-1.14.6-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a8661b2ce9694ca01c529bfa204dbb144b275a31685a075ce123f12331be790b"},
+    {file = "cffi-1.14.6-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b315d709717a99f4b27b59b021e6207c64620790ca3e0bde636a6c7f14618abb"},
+    {file = "cffi-1.14.6-cp36-cp36m-win32.whl", hash = "sha256:80b06212075346b5546b0417b9f2bf467fea3bfe7352f781ffc05a8ab24ba14a"},
+    {file = "cffi-1.14.6-cp36-cp36m-win_amd64.whl", hash = "sha256:a9da7010cec5a12193d1af9872a00888f396aba3dc79186604a09ea3ee7c029e"},
+    {file = "cffi-1.14.6-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4373612d59c404baeb7cbd788a18b2b2a8331abcc84c3ba40051fcd18b17a4d5"},
+    {file = "cffi-1.14.6-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:f10afb1004f102c7868ebfe91c28f4a712227fe4cb24974350ace1f90e1febbf"},
+    {file = "cffi-1.14.6-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:fd4305f86f53dfd8cd3522269ed7fc34856a8ee3709a5e28b2836b2db9d4cd69"},
+    {file = "cffi-1.14.6-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6d6169cb3c6c2ad50db5b868db6491a790300ade1ed5d1da29289d73bbe40b56"},
+    {file = "cffi-1.14.6-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5d4b68e216fc65e9fe4f524c177b54964af043dde734807586cf5435af84045c"},
+    {file = "cffi-1.14.6-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33791e8a2dc2953f28b8d8d300dde42dd929ac28f974c4b4c6272cb2955cb762"},
+    {file = "cffi-1.14.6-cp37-cp37m-win32.whl", hash = "sha256:0c0591bee64e438883b0c92a7bed78f6290d40bf02e54c5bf0978eaf36061771"},
+    {file = "cffi-1.14.6-cp37-cp37m-win_amd64.whl", hash = "sha256:8eb687582ed7cd8c4bdbff3df6c0da443eb89c3c72e6e5dcdd9c81729712791a"},
+    {file = "cffi-1.14.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ba6f2b3f452e150945d58f4badd92310449876c4c954836cfb1803bdd7b422f0"},
+    {file = "cffi-1.14.6-cp38-cp38-manylinux1_i686.whl", hash = "sha256:64fda793737bc4037521d4899be780534b9aea552eb673b9833b01f945904c2e"},
+    {file = "cffi-1.14.6-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:9f3e33c28cd39d1b655ed1ba7247133b6f7fc16fa16887b120c0c670e35ce346"},
+    {file = "cffi-1.14.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26bb2549b72708c833f5abe62b756176022a7b9a7f689b571e74c8478ead51dc"},
+    {file = "cffi-1.14.6-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eb687a11f0a7a1839719edd80f41e459cc5366857ecbed383ff376c4e3cc6afd"},
+    {file = "cffi-1.14.6-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d2ad4d668a5c0645d281dcd17aff2be3212bc109b33814bbb15c4939f44181cc"},
+    {file = "cffi-1.14.6-cp38-cp38-win32.whl", hash = "sha256:487d63e1454627c8e47dd230025780e91869cfba4c753a74fda196a1f6ad6548"},
+    {file = "cffi-1.14.6-cp38-cp38-win_amd64.whl", hash = "sha256:c33d18eb6e6bc36f09d793c0dc58b0211fccc6ae5149b808da4a62660678b156"},
+    {file = "cffi-1.14.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:06c54a68935738d206570b20da5ef2b6b6d92b38ef3ec45c5422c0ebaf338d4d"},
+    {file = "cffi-1.14.6-cp39-cp39-manylinux1_i686.whl", hash = "sha256:f174135f5609428cc6e1b9090f9268f5c8935fddb1b25ccb8255a2d50de6789e"},
+    {file = "cffi-1.14.6-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:f3ebe6e73c319340830a9b2825d32eb6d8475c1dac020b4f0aa774ee3b898d1c"},
+    {file = "cffi-1.14.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c8d896becff2fa653dc4438b54a5a25a971d1f4110b32bd3068db3722c80202"},
+    {file = "cffi-1.14.6-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4922cd707b25e623b902c86188aca466d3620892db76c0bdd7b99a3d5e61d35f"},
+    {file = "cffi-1.14.6-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c9e005e9bd57bc987764c32a1bee4364c44fdc11a3cc20a40b93b444984f2b87"},
+    {file = "cffi-1.14.6-cp39-cp39-win32.whl", hash = "sha256:eb9e2a346c5238a30a746893f23a9535e700f8192a68c07c0258e7ece6ff3728"},
+    {file = "cffi-1.14.6-cp39-cp39-win_amd64.whl", hash = "sha256:818014c754cd3dba7229c0f5884396264d51ffb87ec86e927ef0be140bfdb0d2"},
+    {file = "cffi-1.14.6.tar.gz", hash = "sha256:c9a875ce9d7fe32887784274dd533c57909b7b1dcadcc128a2ac21331a9765dd"},
 ]
 cfgv = [
     {file = "cfgv-3.3.0-py2.py3-none-any.whl", hash = "sha256:b449c9c6118fe8cca7fa5e00b9ec60ba08145d281d52164230a69211c5d597a1"},
@@ -1560,31 +1685,33 @@ cfgv = [
 cfu = [
     {file = "cfu-1.5.0.tar.gz", hash = "sha256:5052fdec7a808823893b73cb438c39a4f780d2c0bba8af06e02192af99424f60"},
 ]
-chardet = [
-    {file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"},
-    {file = "chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"},
+charset-normalizer = [
+    {file = "charset-normalizer-2.0.4.tar.gz", hash = "sha256:f23667ebe1084be45f6ae0538e4a5a865206544097e4e8bbcacf42cd02a348f3"},
+    {file = "charset_normalizer-2.0.4-py3-none-any.whl", hash = "sha256:0c8911edd15d19223366a194a513099a302055a962bca2cec0f54b8b63175d8b"},
 ]
 click = [
-    {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"},
-    {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"},
+    {file = "click-8.0.1-py3-none-any.whl", hash = "sha256:fba402a4a47334742d782209a7c79bc448911afe1149d07bdabdf480b3e2f4b6"},
+    {file = "click-8.0.1.tar.gz", hash = "sha256:8c04c11192119b1ef78ea049e0a6f0463e4c48ef00a30160c704337586f3ad7a"},
 ]
 click-didyoumean = [
     {file = "click-didyoumean-0.0.3.tar.gz", hash = "sha256:112229485c9704ff51362fe34b2d4f0b12fc71cc20f6d2b3afabed4b8bfa6aeb"},
 ]
-click-plugins = [
-    {file = "click-plugins-1.1.1.tar.gz", hash = "sha256:46ab999744a9d831159c3411bb0c79346d94a444df9a3a3742e9ed63645f264b"},
-    {file = "click_plugins-1.1.1-py2.py3-none-any.whl", hash = "sha256:5d262006d3222f5057fd81e1623d4443e41dcda5dc815c06b442aa3c02889fc8"},
-]
 click-repl = [
     {file = "click-repl-0.2.0.tar.gz", hash = "sha256:cd12f68d745bf6151210790540b4cb064c7b13e571bc64b6957d98d120dacfd8"},
     {file = "click_repl-0.2.0-py3-none-any.whl", hash = "sha256:94b3fbbc9406a236f176e0506524b2937e4b23b6f4c0c0b2a0a83f8a64e9194b"},
 ]
+codecov = [
+    {file = "codecov-2.1.12-py2.py3-none-any.whl", hash = "sha256:585dc217dc3d8185198ceb402f85d5cb5dbfa0c5f350a5abcdf9e347776a5b47"},
+    {file = "codecov-2.1.12-py3.8.egg", hash = "sha256:782a8e5352f22593cbc5427a35320b99490eb24d9dcfa2155fd99d2b75cfb635"},
+    {file = "codecov-2.1.12.tar.gz", hash = "sha256:a0da46bb5025426da895af90938def8ee12d37fcbcbbbc15b6dc64cf7ebc51c1"},
+]
 colorama = [
     {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"},
     {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"},
 ]
 confu = [
-    {file = "confu-1.7.1.tar.gz", hash = "sha256:b2302fac738ee23143fe1768053a1c63216928fa54d1bba2326282d1c0fe6de9"},
+    {file = "confu-1.8.0-py3-none-any.whl", hash = "sha256:109cbf7342e90abe84d6f15b3152a1433a953fb34d9242df6de1b205cd60685e"},
+    {file = "confu-1.8.0.tar.gz", hash = "sha256:fc5c99c14135dd8d396c9ff89e40ddac63261aea4bc84784d7b54b9c3b45ba68"},
 ]
 coverage = [
     {file = "coverage-5.5-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:b6d534e4b2ab35c9f93f46229363e17f63c53ad01330df9f2d6bd1187e5eaacf"},
@@ -1664,12 +1791,12 @@ distlib = [
     {file = "distlib-0.3.2.zip", hash = "sha256:106fef6dc37dd8c0e2c0a60d3fca3e77460a48907f335fa28420463a6f799736"},
 ]
 distro = [
-    {file = "distro-1.5.0-py2.py3-none-any.whl", hash = "sha256:df74eed763e18d10d0da624258524ae80486432cd17392d9c3d96f5e83cd2799"},
-    {file = "distro-1.5.0.tar.gz", hash = "sha256:0e58756ae38fbd8fc3020d54badb8eae17c5b9dcbed388b17bb55b8a5928df92"},
+    {file = "distro-1.6.0-py2.py3-none-any.whl", hash = "sha256:c8713330ab31a034623a9515663ed87696700b55f04556b97c39cd261aa70dc7"},
+    {file = "distro-1.6.0.tar.gz", hash = "sha256:83f5e5a09f9c5f68f60173de572930effbcc0287bb84fdc4426cb4168c088424"},
 ]
 django = [
-    {file = "Django-3.2.4-py3-none-any.whl", hash = "sha256:ea735cbbbb3b2fba6d4da4784a0043d84c67c92f1fdf15ad6db69900e792c10f"},
-    {file = "Django-3.2.4.tar.gz", hash = "sha256:66c9d8db8cc6fe938a28b7887c1596e42d522e27618562517cc8929eb7e7f296"},
+    {file = "Django-3.2.6-py3-none-any.whl", hash = "sha256:7f92413529aa0e291f3be78ab19be31aefb1e1c9a52cd59e130f505f27a51f13"},
+    {file = "Django-3.2.6.tar.gz", hash = "sha256:f27f8544c9d4c383bbe007c57e3235918e258364577373d4920e9162837be022"},
 ]
 django-autocomplete-light = [
     {file = "django-autocomplete-light-3.8.2.tar.gz", hash = "sha256:25f0ea71b59a8f1f97a8a564e33e429570b0ea77c5eac81f7beb283073b4ba90"},
@@ -1678,17 +1805,30 @@ django-countries = [
     {file = "django-countries-7.2.1.tar.gz", hash = "sha256:26878b54d36bedff30b4535ceefcb8af6784741a8b30b1b8a662fb14a936a4ab"},
     {file = "django_countries-7.2.1-py3-none-any.whl", hash = "sha256:adc965f1d348124274b7d918fc1aad5e29609758af999e1822baa9f2cc06d1b8"},
 ]
+django-crispy-forms = [
+    {file = "django-crispy-forms-1.12.0.tar.gz", hash = "sha256:d196db62ee8b4fc32d1f9583d0e4be1bb17328b662682c1ecb9fb77bbc0fcf77"},
+    {file = "django_crispy_forms-1.12.0-py3-none-any.whl", hash = "sha256:a3320356c84d0cdc631e1ec7b8908aa0117bc2a5f0ab1d053d33eba08f584808"},
+]
 django-grainy = [
     {file = "django-grainy-1.9.1.tar.gz", hash = "sha256:a2ed568a185fdce27bc4765c732fa67a10627d0e392f7a41d645ddfcd74763f0"},
 ]
 django-handleref = [
-    {file = "django-handleref-0.6.0.tar.gz", hash = "sha256:93b001aed1e0133abf8fc1b63b4b4bd60b2ba1375fe5df82a335651f54240a07"},
+    {file = "django-handleref-1.0.0.tar.gz", hash = "sha256:3256a0d06d1324e40e3556fc16ab20bd1993db9effef118cf53f9eb4b7e285de"},
+    {file = "django_handleref-1.0.0-py3-none-any.whl", hash = "sha256:2614cb7c2e1d9997d7c5ac9c2ff5991f4af7d6ac695ebc7bdf600031561e1701"},
 ]
 django-inet = [
-    {file = "django-inet-0.5.0.tar.gz", hash = "sha256:842f0aed36f05168599d1b596cabd971a501e202483bc3bc8cb00e53533cf974"},
+    {file = "django-inet-1.0.0.tar.gz", hash = "sha256:0dcc046c716245c901fad0f04f3491849fde36abf0a59c53fd16f21623aef409"},
+    {file = "django_inet-1.0.0-py3-none-any.whl", hash = "sha256:2da5d4c4b5be55ea6df0f24e253ddc4ba65285c7837acfa4e6811d6a446e6925"},
+]
+django-netfields = [
+    {file = "django-netfields-1.2.2.tar.gz", hash = "sha256:fac4ffc01532f048a336f3583399ca7e1faf18b0cb815ec843185455f25787b0"},
 ]
 django-peeringdb = [
-    {file = "django-peeringdb-2.7.0.tar.gz", hash = "sha256:0d9ecbd974126f62a676860df8dc5221c17621c57c843c73589e4c556f6904a2"},
+    {file = "django-peeringdb-2.8.0.tar.gz", hash = "sha256:b8f19d23a486dc82ec72d57063887a3fd4b1b5325ab9064916005e25a7765fa5"},
+    {file = "django_peeringdb-2.8.0-py3-none-any.whl", hash = "sha256:ffde1032480728a2943d1cd89a1d6d7af99d2c982009ff2a1204907d17b49748"},
+]
+django-recaptcha = [
+    {file = "django_recaptcha-2.0.6-py2.py3-none-any.whl", hash = "sha256:567784963fd5400feaf92e8951d8dbbbdb4b4c48a76e225d4baa63a2c9d2cd8c"},
 ]
 django-reversion = [
     {file = "django-reversion-3.0.9.tar.gz", hash = "sha256:a5af55f086a3f9c38be2f049c251e06005b9ed48ba7a109473736b1fc95a066f"},
@@ -1736,31 +1876,35 @@ gitdb = [
     {file = "gitdb-4.0.7.tar.gz", hash = "sha256:96bf5c08b157a666fec41129e6d327235284cca4c81e92109260f353ba138005"},
 ]
 gitpython = [
-    {file = "GitPython-3.1.17-py3-none-any.whl", hash = "sha256:29fe82050709760081f588dd50ce83504feddbebdc4da6956d02351552b1c135"},
-    {file = "GitPython-3.1.17.tar.gz", hash = "sha256:ee24bdc93dce357630764db659edaf6b8d664d4ff5447ccfeedd2dc5c253f41e"},
+    {file = "GitPython-3.1.20-py3-none-any.whl", hash = "sha256:b1e1c269deab1b08ce65403cf14e10d2ef1f6c89e33ea7c5e5bb0222ea593b8a"},
+    {file = "GitPython-3.1.20.tar.gz", hash = "sha256:df0e072a200703a65387b0cfdf0466e3bab729c0458cf6b7349d0e9877636519"},
 ]
 grainy = [
     {file = "grainy-1.8.1.tar.gz", hash = "sha256:2cfd8d50b3f5cce3c463f3c5e86324442f61a7cd46dfe7b134ee926559e56556"},
 ]
 identify = [
-    {file = "identify-2.2.10-py2.py3-none-any.whl", hash = "sha256:18d0c531ee3dbc112fa6181f34faa179de3f57ea57ae2899754f16a7e0ff6421"},
-    {file = "identify-2.2.10.tar.gz", hash = "sha256:5b41f71471bc738e7b586308c3fca172f78940195cb3bf6734c1e66fdac49306"},
+    {file = "identify-2.2.13-py2.py3-none-any.whl", hash = "sha256:7199679b5be13a6b40e6e19ea473e789b11b4e3b60986499b1f589ffb03c217c"},
+    {file = "identify-2.2.13.tar.gz", hash = "sha256:7bc6e829392bd017236531963d2d937d66fc27cadc643ac0aba2ce9f26157c79"},
 ]
 idna = [
-    {file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"},
-    {file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"},
+    {file = "idna-3.2-py3-none-any.whl", hash = "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a"},
+    {file = "idna-3.2.tar.gz", hash = "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3"},
 ]
 importlib-metadata = [
-    {file = "importlib_metadata-4.5.0-py3-none-any.whl", hash = "sha256:833b26fb89d5de469b24a390e9df088d4e52e4ba33b01dc5e0e4f41b81a16c00"},
-    {file = "importlib_metadata-4.5.0.tar.gz", hash = "sha256:b142cc1dd1342f31ff04bb7d022492b09920cb64fed867cd3ea6f80fe3ebd139"},
+    {file = "importlib_metadata-4.6.3-py3-none-any.whl", hash = "sha256:51c6635429c77cf1ae634c997ff9e53ca3438b495f10a55ba28594dd69764a8b"},
+    {file = "importlib_metadata-4.6.3.tar.gz", hash = "sha256:0645585859e9a6689c523927a5032f2ba5919f1f7d0e84bd4533312320de1ff9"},
 ]
 iniconfig = [
     {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"},
     {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"},
 ]
+ipaddress = [
+    {file = "ipaddress-1.0.23-py2.py3-none-any.whl", hash = "sha256:6e0f4a39e66cb5bb9a137b00276a2eff74f93b71dcbdad6f10ff7df9d3557fcc"},
+    {file = "ipaddress-1.0.23.tar.gz", hash = "sha256:b7f8e0369580bb4a24d5ba1d7cc29660a4a6987763faf1d8a8046830e020e7e2"},
+]
 isort = [
-    {file = "isort-5.8.0-py3-none-any.whl", hash = "sha256:2bb1680aad211e3c9944dbce1d4ba09a989f04e238296c87fe2139faa26d655d"},
-    {file = "isort-5.8.0.tar.gz", hash = "sha256:0a943902919f65c5684ac4e0154b1ad4fac6dcaa5d9f3426b732f1c8b5419be6"},
+    {file = "isort-5.9.3-py3-none-any.whl", hash = "sha256:e17d6e2b81095c9db0a03a8025a957f334d6ea30b26f9ec70805411e5c7c81f2"},
+    {file = "isort-5.9.3.tar.gz", hash = "sha256:9c2ea1e62d871267b78307fe511c0838ba0da28698c5732d54e2790bf3ba9899"},
 ]
 jinja2 = [
     {file = "Jinja2-2.11.3-py2.py3-none-any.whl", hash = "sha256:03e47ad063331dd6a3f04a43eddca8a966a26ba0c5b7207a9a9e4e08f1b29419"},
@@ -1826,16 +1970,21 @@ mergedeep = [
     {file = "mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8"},
 ]
 mkdocs = [
-    {file = "mkdocs-1.2.1-py3-none-any.whl", hash = "sha256:11141126e5896dd9d279b3e4814eb488e409a0990fb638856255020406a8e2e7"},
-    {file = "mkdocs-1.2.1.tar.gz", hash = "sha256:6e0ea175366e3a50d334597b0bc042b8cebd512398cdd3f6f34842d0ef524905"},
+    {file = "mkdocs-1.2.2-py3-none-any.whl", hash = "sha256:d019ff8e17ec746afeb54eb9eb4112b5e959597aebc971da46a5c9486137f0ff"},
+    {file = "mkdocs-1.2.2.tar.gz", hash = "sha256:a334f5bd98ec960638511366eb8c5abc9c99b9083a0ed2401d8791b112d6b078"},
 ]
 munge = [
-    {file = "munge-1.1.0.tar.gz", hash = "sha256:a5ec65d880a7e7fdfb3c8a4ff05e0826250f94d866c88be6191a80da6344020d"},
+    {file = "munge-1.2.0-py3-none-any.whl", hash = "sha256:5190e5778ca909b9aaf716b923ad35cbe4cc917916016217f24c86188282fe4b"},
+    {file = "munge-1.2.0.tar.gz", hash = "sha256:591da81f8c3a4e72f67c41b85327504b9b9bc3c2cfc716f22c7111a5f07a2de9"},
 ]
 mypy-extensions = [
     {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"},
     {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"},
 ]
+netaddr = [
+    {file = "netaddr-0.8.0-py2.py3-none-any.whl", hash = "sha256:9666d0232c32d2656e5e5f8d735f58fd6c7457ce52fc21c98d45f2af78f990ac"},
+    {file = "netaddr-0.8.0.tar.gz", hash = "sha256:d6cc57c7a07b1d9d2e917aa8b36ae8ce61c35ba3fcd1b83ca31c5a0ee2b5a243"},
+]
 nodeenv = [
     {file = "nodeenv-1.6.0-py2.py3-none-any.whl", hash = "sha256:621e6b7076565ddcacd2db0294c0381e01fd28945ab36bcf00f41c5daf63bef7"},
     {file = "nodeenv-1.6.0.tar.gz", hash = "sha256:3ef13ff90291ba2a4a7a4ff9a979b63ffdd00a464dbe04acf0ea6471517a4c2b"},
@@ -1845,16 +1994,16 @@ oauthlib = [
     {file = "oauthlib-3.1.1.tar.gz", hash = "sha256:8f0215fcc533dd8dd1bee6f4c412d4f0cd7297307d43ac61666389e3bc3198a3"},
 ]
 packaging = [
-    {file = "packaging-20.9-py2.py3-none-any.whl", hash = "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"},
-    {file = "packaging-20.9.tar.gz", hash = "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5"},
+    {file = "packaging-21.0-py3-none-any.whl", hash = "sha256:c86254f9220d55e31cc94d69bade760f0847da8000def4dfe1c6b872fd14ff14"},
+    {file = "packaging-21.0.tar.gz", hash = "sha256:7dc96269f53a4ccec5c0670940a4281106dd0bb343f47b7471f779df49c2fbe7"},
 ]
 paramiko = [
     {file = "paramiko-2.7.2-py2.py3-none-any.whl", hash = "sha256:4f3e316fef2ac628b05097a637af35685183111d4bc1b5979bd397c2ab7b5898"},
     {file = "paramiko-2.7.2.tar.gz", hash = "sha256:7f36f4ba2c0d81d219f4595e35f70d56cc94f9ac40a6acdf51d6ca210ce65035"},
 ]
 pathspec = [
-    {file = "pathspec-0.8.1-py2.py3-none-any.whl", hash = "sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d"},
-    {file = "pathspec-0.8.1.tar.gz", hash = "sha256:86379d6b86d75816baba717e64b1a3a3469deb93bb76d613c9ce79edc5cb68fd"},
+    {file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"},
+    {file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"},
 ]
 pbr = [
     {file = "pbr-5.6.0-py2.py3-none-any.whl", hash = "sha256:c68c661ac5cc81058ac94247278eeda6d2e6aecb3e227b0387c30d277e7ef8d4"},
@@ -1863,6 +2012,10 @@ pbr = [
 peeringdb = [
     {file = "peeringdb-1.1.0.tar.gz", hash = "sha256:927a34c31e5b93130a855bb4c8fd84dcf604b9939678f75918f7c1bd8a501471"},
 ]
+platformdirs = [
+    {file = "platformdirs-2.2.0-py3-none-any.whl", hash = "sha256:4666d822218db6a262bdfdc9c39d21f23b4cfdb08af331a81e92751daf6c866c"},
+    {file = "platformdirs-2.2.0.tar.gz", hash = "sha256:632daad3ab546bd8e6af0537d09805cec458dce201bccfe23012df73332e181e"},
+]
 pluggy = [
     {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"},
     {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"},
@@ -1872,12 +2025,43 @@ pluginmgr = [
     {file = "pluginmgr-1.0.1.tar.gz", hash = "sha256:151c8afe8df535f2e02255433cd3340000b3428018692f0581ff53d6f74c3630"},
 ]
 pre-commit = [
-    {file = "pre_commit-2.13.0-py2.py3-none-any.whl", hash = "sha256:b679d0fddd5b9d6d98783ae5f10fd0c4c59954f375b70a58cbe1ce9bcf9809a4"},
-    {file = "pre_commit-2.13.0.tar.gz", hash = "sha256:764972c60693dc668ba8e86eb29654ec3144501310f7198742a767bec385a378"},
+    {file = "pre_commit-2.14.0-py2.py3-none-any.whl", hash = "sha256:ec3045ae62e1aa2eecfb8e86fa3025c2e3698f77394ef8d2011ce0aedd85b2d4"},
+    {file = "pre_commit-2.14.0.tar.gz", hash = "sha256:2386eeb4cf6633712c7cc9ede83684d53c8cafca6b59f79c738098b51c6d206c"},
 ]
 prompt-toolkit = [
-    {file = "prompt_toolkit-3.0.18-py3-none-any.whl", hash = "sha256:bf00f22079f5fadc949f42ae8ff7f05702826a97059ffcc6281036ad40ac6f04"},
-    {file = "prompt_toolkit-3.0.18.tar.gz", hash = "sha256:e1b4f11b9336a28fa11810bc623c357420f69dfdb6d2dac41ca2c21a55c033bc"},
+    {file = "prompt_toolkit-3.0.19-py3-none-any.whl", hash = "sha256:7089d8d2938043508aa9420ec18ce0922885304cddae87fb96eebca942299f88"},
+    {file = "prompt_toolkit-3.0.19.tar.gz", hash = "sha256:08360ee3a3148bdb5163621709ee322ec34fc4375099afa4bbf751e9b7b7fa4f"},
+]
+psycopg2-binary = [
+    {file = "psycopg2-binary-2.9.1.tar.gz", hash = "sha256:b0221ca5a9837e040ebf61f48899926b5783668b7807419e4adae8175a31f773"},
+    {file = "psycopg2_binary-2.9.1-cp36-cp36m-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:c250a7ec489b652c892e4f0a5d122cc14c3780f9f643e1a326754aedf82d9a76"},
+    {file = "psycopg2_binary-2.9.1-cp36-cp36m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aef9aee84ec78af51107181d02fe8773b100b01c5dfde351184ad9223eab3698"},
+    {file = "psycopg2_binary-2.9.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:123c3fb684e9abfc47218d3784c7b4c47c8587951ea4dd5bc38b6636ac57f616"},
+    {file = "psycopg2_binary-2.9.1-cp36-cp36m-manylinux_2_24_aarch64.whl", hash = "sha256:995fc41ebda5a7a663a254a1dcac52638c3e847f48307b5416ee373da15075d7"},
+    {file = "psycopg2_binary-2.9.1-cp36-cp36m-manylinux_2_24_ppc64le.whl", hash = "sha256:fbb42a541b1093385a2d8c7eec94d26d30437d0e77c1d25dae1dcc46741a385e"},
+    {file = "psycopg2_binary-2.9.1-cp36-cp36m-win32.whl", hash = "sha256:20f1ab44d8c352074e2d7ca67dc00843067788791be373e67a0911998787ce7d"},
+    {file = "psycopg2_binary-2.9.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f6fac64a38f6768e7bc7b035b9e10d8a538a9fadce06b983fb3e6fa55ac5f5ce"},
+    {file = "psycopg2_binary-2.9.1-cp37-cp37m-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:1e3a362790edc0a365385b1ac4cc0acc429a0c0d662d829a50b6ce743ae61b5a"},
+    {file = "psycopg2_binary-2.9.1-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f8559617b1fcf59a9aedba2c9838b5b6aa211ffedecabca412b92a1ff75aac1a"},
+    {file = "psycopg2_binary-2.9.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a36c7eb6152ba5467fb264d73844877be8b0847874d4822b7cf2d3c0cb8cdcb0"},
+    {file = "psycopg2_binary-2.9.1-cp37-cp37m-manylinux_2_24_aarch64.whl", hash = "sha256:2f62c207d1740b0bde5c4e949f857b044818f734a3d57f1d0d0edc65050532ed"},
+    {file = "psycopg2_binary-2.9.1-cp37-cp37m-manylinux_2_24_ppc64le.whl", hash = "sha256:cfc523edecddaef56f6740d7de1ce24a2fdf94fd5e704091856a201872e37f9f"},
+    {file = "psycopg2_binary-2.9.1-cp37-cp37m-win32.whl", hash = "sha256:1e85b74cbbb3056e3656f1cc4781294df03383127a8114cbc6531e8b8367bf1e"},
+    {file = "psycopg2_binary-2.9.1-cp37-cp37m-win_amd64.whl", hash = "sha256:1473c0215b0613dd938db54a653f68251a45a78b05f6fc21af4326f40e8360a2"},
+    {file = "psycopg2_binary-2.9.1-cp38-cp38-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:35c4310f8febe41f442d3c65066ca93cccefd75013df3d8c736c5b93ec288140"},
+    {file = "psycopg2_binary-2.9.1-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8c13d72ed6af7fd2c8acbd95661cf9477f94e381fce0792c04981a8283b52917"},
+    {file = "psycopg2_binary-2.9.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14db1752acdd2187d99cb2ca0a1a6dfe57fc65c3281e0f20e597aac8d2a5bd90"},
+    {file = "psycopg2_binary-2.9.1-cp38-cp38-manylinux_2_24_aarch64.whl", hash = "sha256:aed4a9a7e3221b3e252c39d0bf794c438dc5453bc2963e8befe9d4cd324dff72"},
+    {file = "psycopg2_binary-2.9.1-cp38-cp38-manylinux_2_24_ppc64le.whl", hash = "sha256:da113b70f6ec40e7d81b43d1b139b9db6a05727ab8be1ee559f3a69854a69d34"},
+    {file = "psycopg2_binary-2.9.1-cp38-cp38-win32.whl", hash = "sha256:4235f9d5ddcab0b8dbd723dca56ea2922b485ea00e1dafacf33b0c7e840b3d32"},
+    {file = "psycopg2_binary-2.9.1-cp38-cp38-win_amd64.whl", hash = "sha256:988b47ac70d204aed01589ed342303da7c4d84b56c2f4c4b8b00deda123372bf"},
+    {file = "psycopg2_binary-2.9.1-cp39-cp39-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:7360647ea04db2e7dff1648d1da825c8cf68dc5fbd80b8fb5b3ee9f068dcd21a"},
+    {file = "psycopg2_binary-2.9.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca86db5b561b894f9e5f115d6a159fff2a2570a652e07889d8a383b5fae66eb4"},
+    {file = "psycopg2_binary-2.9.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ced67f1e34e1a450cdb48eb53ca73b60aa0af21c46b9b35ac3e581cf9f00e31"},
+    {file = "psycopg2_binary-2.9.1-cp39-cp39-manylinux_2_24_aarch64.whl", hash = "sha256:0f2e04bd2a2ab54fa44ee67fe2d002bb90cee1c0f1cc0ebc3148af7b02034cbd"},
+    {file = "psycopg2_binary-2.9.1-cp39-cp39-manylinux_2_24_ppc64le.whl", hash = "sha256:3242b9619de955ab44581a03a64bdd7d5e470cc4183e8fcadd85ab9d3756ce7a"},
+    {file = "psycopg2_binary-2.9.1-cp39-cp39-win32.whl", hash = "sha256:0b7dae87f0b729922e06f85f667de7bf16455d411971b2043bbd9577af9d1975"},
+    {file = "psycopg2_binary-2.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:b4d7679a08fea64573c969f6994a2631908bb2c0e69a7235648642f3d2e39a68"},
 ]
 py = [
     {file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"},
@@ -1924,7 +2108,27 @@ pyparsing = [
     {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"},
 ]
 pyrsistent = [
-    {file = "pyrsistent-0.17.3.tar.gz", hash = "sha256:2e636185d9eb976a18a8a8e96efce62f2905fea90041958d8cc2a189756ebf3e"},
+    {file = "pyrsistent-0.18.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f4c8cabb46ff8e5d61f56a037974228e978f26bfefce4f61a4b1ac0ba7a2ab72"},
+    {file = "pyrsistent-0.18.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:da6e5e818d18459fa46fac0a4a4e543507fe1110e808101277c5a2b5bab0cd2d"},
+    {file = "pyrsistent-0.18.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:5e4395bbf841693eaebaa5bb5c8f5cdbb1d139e07c975c682ec4e4f8126e03d2"},
+    {file = "pyrsistent-0.18.0-cp36-cp36m-win32.whl", hash = "sha256:527be2bfa8dc80f6f8ddd65242ba476a6c4fb4e3aedbf281dfbac1b1ed4165b1"},
+    {file = "pyrsistent-0.18.0-cp36-cp36m-win_amd64.whl", hash = "sha256:2aaf19dc8ce517a8653746d98e962ef480ff34b6bc563fc067be6401ffb457c7"},
+    {file = "pyrsistent-0.18.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:58a70d93fb79dc585b21f9d72487b929a6fe58da0754fa4cb9f279bb92369396"},
+    {file = "pyrsistent-0.18.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:4916c10896721e472ee12c95cdc2891ce5890898d2f9907b1b4ae0f53588b710"},
+    {file = "pyrsistent-0.18.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:73ff61b1411e3fb0ba144b8f08d6749749775fe89688093e1efef9839d2dcc35"},
+    {file = "pyrsistent-0.18.0-cp37-cp37m-win32.whl", hash = "sha256:b29b869cf58412ca5738d23691e96d8aff535e17390128a1a52717c9a109da4f"},
+    {file = "pyrsistent-0.18.0-cp37-cp37m-win_amd64.whl", hash = "sha256:097b96f129dd36a8c9e33594e7ebb151b1515eb52cceb08474c10a5479e799f2"},
+    {file = "pyrsistent-0.18.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:772e94c2c6864f2cd2ffbe58bb3bdefbe2a32afa0acb1a77e472aac831f83427"},
+    {file = "pyrsistent-0.18.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:c1a9ff320fa699337e05edcaae79ef8c2880b52720bc031b219e5b5008ebbdef"},
+    {file = "pyrsistent-0.18.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:cd3caef37a415fd0dae6148a1b6957a8c5f275a62cca02e18474608cb263640c"},
+    {file = "pyrsistent-0.18.0-cp38-cp38-win32.whl", hash = "sha256:e79d94ca58fcafef6395f6352383fa1a76922268fa02caa2272fff501c2fdc78"},
+    {file = "pyrsistent-0.18.0-cp38-cp38-win_amd64.whl", hash = "sha256:a0c772d791c38bbc77be659af29bb14c38ced151433592e326361610250c605b"},
+    {file = "pyrsistent-0.18.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d5ec194c9c573aafaceebf05fc400656722793dac57f254cd4741f3c27ae57b4"},
+    {file = "pyrsistent-0.18.0-cp39-cp39-manylinux1_i686.whl", hash = "sha256:6b5eed00e597b5b5773b4ca30bd48a5774ef1e96f2a45d105db5b4ebb4bca680"},
+    {file = "pyrsistent-0.18.0-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:48578680353f41dca1ca3dc48629fb77dfc745128b56fc01096b2530c13fd426"},
+    {file = "pyrsistent-0.18.0-cp39-cp39-win32.whl", hash = "sha256:f3ef98d7b76da5eb19c37fda834d50262ff9167c65658d1d8f974d2e4d90676b"},
+    {file = "pyrsistent-0.18.0-cp39-cp39-win_amd64.whl", hash = "sha256:404e1f1d254d314d55adb8d87f4f465c8693d6f902f67eb6ef5b4526dc58e6ea"},
+    {file = "pyrsistent-0.18.0.tar.gz", hash = "sha256:773c781216f8c2900b42a7b638d5b517bb134ae1acbebe4d1e8f1f41ea60eb4b"},
 ]
 pytest = [
     {file = "pytest-6.2.4-py3-none-any.whl", hash = "sha256:91ef2131a9bd6be8f76f1f08eac5c5317221d6ad1e143ae03894b862e8976890"},
@@ -1942,12 +2146,12 @@ pytest-pythonpath = [
     {file = "pytest-pythonpath-0.7.3.tar.gz", hash = "sha256:63fc546ace7d2c845c1ee289e8f7a6362c2b6bae497d10c716e58e253e801d62"},
 ]
 python-dateutil = [
-    {file = "python-dateutil-2.8.1.tar.gz", hash = "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c"},
-    {file = "python_dateutil-2.8.1-py2.py3-none-any.whl", hash = "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"},
+    {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"},
+    {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"},
 ]
 python-dotenv = [
-    {file = "python-dotenv-0.17.1.tar.gz", hash = "sha256:b1ae5e9643d5ed987fc57cc2583021e38db531946518130777734f9589b3141f"},
-    {file = "python_dotenv-0.17.1-py2.py3-none-any.whl", hash = "sha256:00aa34e92d992e9f8383730816359647f358f4a3be1ba45e5a5cefd27ee91544"},
+    {file = "python-dotenv-0.19.0.tar.gz", hash = "sha256:f521bc2ac9a8e03c736f62911605c5d83970021e3fa95b37d769e2bbbe9b6172"},
+    {file = "python_dotenv-0.19.0-py2.py3-none-any.whl", hash = "sha256:aae25dc1ebe97c420f50b81fb0e5c949659af713f31fdb63c749ca68748f34b1"},
 ]
 python3-openid = [
     {file = "python3-openid-3.2.0.tar.gz", hash = "sha256:33fbf6928f401e0b790151ed2b5290b02545e8775f982485205a066f874aaeaf"},
@@ -1958,8 +2162,8 @@ pytz = [
     {file = "pytz-2021.1.tar.gz", hash = "sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da"},
 ]
 pyupgrade = [
-    {file = "pyupgrade-2.19.4-py2.py3-none-any.whl", hash = "sha256:7bfb634a804d71a642dfb6e85283295953c0f990b8be55c0909c6155d88c5af6"},
-    {file = "pyupgrade-2.19.4.tar.gz", hash = "sha256:28525a24fa3f4f68389b157d3fa063c9b96d8e30f034614c17a4d95a0475d1d9"},
+    {file = "pyupgrade-2.23.3-py2.py3-none-any.whl", hash = "sha256:ee2355a5f4bf8541eed3687545c59640e50789268cf1802cf214540e5bcc860a"},
+    {file = "pyupgrade-2.23.3.tar.gz", hash = "sha256:c5262dcfea5b464bd0e4eb9c3b402bee604b9414885df11cf44ed1eca3037dc4"},
 ]
 pywin32 = [
     {file = "pywin32-227-cp27-cp27m-win32.whl", hash = "sha256:371fcc39416d736401f0274dd64c2302728c9e034808e37381b5e1b22be4a6b0"},
@@ -2003,51 +2207,43 @@ pyyaml-env-tag = [
     {file = "pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb"},
 ]
 regex = [
-    {file = "regex-2021.4.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:619d71c59a78b84d7f18891fe914446d07edd48dc8328c8e149cbe0929b4e000"},
-    {file = "regex-2021.4.4-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:47bf5bf60cf04d72bf6055ae5927a0bd9016096bf3d742fa50d9bf9f45aa0711"},
-    {file = "regex-2021.4.4-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:281d2fd05555079448537fe108d79eb031b403dac622621c78944c235f3fcf11"},
-    {file = "regex-2021.4.4-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:bd28bc2e3a772acbb07787c6308e00d9626ff89e3bfcdebe87fa5afbfdedf968"},
-    {file = "regex-2021.4.4-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:7c2a1af393fcc09e898beba5dd59196edaa3116191cc7257f9224beaed3e1aa0"},
-    {file = "regex-2021.4.4-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:c38c71df845e2aabb7fb0b920d11a1b5ac8526005e533a8920aea97efb8ec6a4"},
-    {file = "regex-2021.4.4-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:96fcd1888ab4d03adfc9303a7b3c0bd78c5412b2bfbe76db5b56d9eae004907a"},
-    {file = "regex-2021.4.4-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:ade17eb5d643b7fead300a1641e9f45401c98eee23763e9ed66a43f92f20b4a7"},
-    {file = "regex-2021.4.4-cp36-cp36m-win32.whl", hash = "sha256:e8e5b509d5c2ff12f8418006d5a90e9436766133b564db0abaec92fd27fcee29"},
-    {file = "regex-2021.4.4-cp36-cp36m-win_amd64.whl", hash = "sha256:11d773d75fa650cd36f68d7ca936e3c7afaae41b863b8c387a22aaa78d3c5c79"},
-    {file = "regex-2021.4.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:d3029c340cfbb3ac0a71798100ccc13b97dddf373a4ae56b6a72cf70dfd53bc8"},
-    {file = "regex-2021.4.4-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:18c071c3eb09c30a264879f0d310d37fe5d3a3111662438889ae2eb6fc570c31"},
-    {file = "regex-2021.4.4-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:4c557a7b470908b1712fe27fb1ef20772b78079808c87d20a90d051660b1d69a"},
-    {file = "regex-2021.4.4-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:01afaf2ec48e196ba91b37451aa353cb7eda77efe518e481707e0515025f0cd5"},
-    {file = "regex-2021.4.4-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:3a9cd17e6e5c7eb328517969e0cb0c3d31fd329298dd0c04af99ebf42e904f82"},
-    {file = "regex-2021.4.4-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:90f11ff637fe8798933fb29f5ae1148c978cccb0452005bf4c69e13db951e765"},
-    {file = "regex-2021.4.4-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:919859aa909429fb5aa9cf8807f6045592c85ef56fdd30a9a3747e513db2536e"},
-    {file = "regex-2021.4.4-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:339456e7d8c06dd36a22e451d58ef72cef293112b559010db3d054d5560ef439"},
-    {file = "regex-2021.4.4-cp37-cp37m-win32.whl", hash = "sha256:67bdb9702427ceddc6ef3dc382455e90f785af4c13d495f9626861763ee13f9d"},
-    {file = "regex-2021.4.4-cp37-cp37m-win_amd64.whl", hash = "sha256:32e65442138b7b76dd8173ffa2cf67356b7bc1768851dded39a7a13bf9223da3"},
-    {file = "regex-2021.4.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1e1c20e29358165242928c2de1482fb2cf4ea54a6a6dea2bd7a0e0d8ee321500"},
-    {file = "regex-2021.4.4-cp38-cp38-manylinux1_i686.whl", hash = "sha256:314d66636c494ed9c148a42731b3834496cc9a2c4251b1661e40936814542b14"},
-    {file = "regex-2021.4.4-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:6d1b01031dedf2503631d0903cb563743f397ccaf6607a5e3b19a3d76fc10480"},
-    {file = "regex-2021.4.4-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:741a9647fcf2e45f3a1cf0e24f5e17febf3efe8d4ba1281dcc3aa0459ef424dc"},
-    {file = "regex-2021.4.4-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:4c46e22a0933dd783467cf32b3516299fb98cfebd895817d685130cc50cd1093"},
-    {file = "regex-2021.4.4-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:e512d8ef5ad7b898cdb2d8ee1cb09a8339e4f8be706d27eaa180c2f177248a10"},
-    {file = "regex-2021.4.4-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:980d7be47c84979d9136328d882f67ec5e50008681d94ecc8afa8a65ed1f4a6f"},
-    {file = "regex-2021.4.4-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:ce15b6d103daff8e9fee13cf7f0add05245a05d866e73926c358e871221eae87"},
-    {file = "regex-2021.4.4-cp38-cp38-win32.whl", hash = "sha256:a91aa8619b23b79bcbeb37abe286f2f408d2f2d6f29a17237afda55bb54e7aac"},
-    {file = "regex-2021.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:c0502c0fadef0d23b128605d69b58edb2c681c25d44574fc673b0e52dce71ee2"},
-    {file = "regex-2021.4.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:598585c9f0af8374c28edd609eb291b5726d7cbce16be6a8b95aa074d252ee17"},
-    {file = "regex-2021.4.4-cp39-cp39-manylinux1_i686.whl", hash = "sha256:ee54ff27bf0afaf4c3b3a62bcd016c12c3fdb4ec4f413391a90bd38bc3624605"},
-    {file = "regex-2021.4.4-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:7d9884d86dd4dd489e981d94a65cd30d6f07203d90e98f6f657f05170f6324c9"},
-    {file = "regex-2021.4.4-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:bf5824bfac591ddb2c1f0a5f4ab72da28994548c708d2191e3b87dd207eb3ad7"},
-    {file = "regex-2021.4.4-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:563085e55b0d4fb8f746f6a335893bda5c2cef43b2f0258fe1020ab1dd874df8"},
-    {file = "regex-2021.4.4-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:b9c3db21af35e3b3c05764461b262d6f05bbca08a71a7849fd79d47ba7bc33ed"},
-    {file = "regex-2021.4.4-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:3916d08be28a1149fb97f7728fca1f7c15d309a9f9682d89d79db75d5e52091c"},
-    {file = "regex-2021.4.4-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:fd45ff9293d9274c5008a2054ecef86a9bfe819a67c7be1afb65e69b405b3042"},
-    {file = "regex-2021.4.4-cp39-cp39-win32.whl", hash = "sha256:fa4537fb4a98fe8fde99626e4681cc644bdcf2a795038533f9f711513a862ae6"},
-    {file = "regex-2021.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:97f29f57d5b84e73fbaf99ab3e26134e6687348e95ef6b48cfd2c06807005a07"},
-    {file = "regex-2021.4.4.tar.gz", hash = "sha256:52ba3d3f9b942c49d7e4bc105bb28551c44065f139a65062ab7912bef10c9afb"},
+    {file = "regex-2021.8.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:8764a78c5464ac6bde91a8c87dd718c27c1cabb7ed2b4beaf36d3e8e390567f9"},
+    {file = "regex-2021.8.3-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4551728b767f35f86b8e5ec19a363df87450c7376d7419c3cac5b9ceb4bce576"},
+    {file = "regex-2021.8.3-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:577737ec3d4c195c4aef01b757905779a9e9aee608fa1cf0aec16b5576c893d3"},
+    {file = "regex-2021.8.3-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c856ec9b42e5af4fe2d8e75970fcc3a2c15925cbcc6e7a9bcb44583b10b95e80"},
+    {file = "regex-2021.8.3-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3835de96524a7b6869a6c710b26c90e94558c31006e96ca3cf6af6751b27dca1"},
+    {file = "regex-2021.8.3-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:cea56288eeda8b7511d507bbe7790d89ae7049daa5f51ae31a35ae3c05408531"},
+    {file = "regex-2021.8.3-cp36-cp36m-win32.whl", hash = "sha256:a4eddbe2a715b2dd3849afbdeacf1cc283160b24e09baf64fa5675f51940419d"},
+    {file = "regex-2021.8.3-cp36-cp36m-win_amd64.whl", hash = "sha256:57fece29f7cc55d882fe282d9de52f2f522bb85290555b49394102f3621751ee"},
+    {file = "regex-2021.8.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a5c6dbe09aff091adfa8c7cfc1a0e83fdb8021ddb2c183512775a14f1435fe16"},
+    {file = "regex-2021.8.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ff4a8ad9638b7ca52313d8732f37ecd5fd3c8e3aff10a8ccb93176fd5b3812f6"},
+    {file = "regex-2021.8.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b63e3571b24a7959017573b6455e05b675050bbbea69408f35f3cb984ec54363"},
+    {file = "regex-2021.8.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:fbc20975eee093efa2071de80df7f972b7b35e560b213aafabcec7c0bd00bd8c"},
+    {file = "regex-2021.8.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:14caacd1853e40103f59571f169704367e79fb78fac3d6d09ac84d9197cadd16"},
+    {file = "regex-2021.8.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:bb350eb1060591d8e89d6bac4713d41006cd4d479f5e11db334a48ff8999512f"},
+    {file = "regex-2021.8.3-cp37-cp37m-win32.whl", hash = "sha256:18fdc51458abc0a974822333bd3a932d4e06ba2a3243e9a1da305668bd62ec6d"},
+    {file = "regex-2021.8.3-cp37-cp37m-win_amd64.whl", hash = "sha256:026beb631097a4a3def7299aa5825e05e057de3c6d72b139c37813bfa351274b"},
+    {file = "regex-2021.8.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:16d9eaa8c7e91537516c20da37db975f09ac2e7772a0694b245076c6d68f85da"},
+    {file = "regex-2021.8.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3905c86cc4ab6d71635d6419a6f8d972cab7c634539bba6053c47354fd04452c"},
+    {file = "regex-2021.8.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:937b20955806381e08e54bd9d71f83276d1f883264808521b70b33d98e4dec5d"},
+    {file = "regex-2021.8.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:28e8af338240b6f39713a34e337c3813047896ace09d51593d6907c66c0708ba"},
+    {file = "regex-2021.8.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c09d88a07483231119f5017904db8f60ad67906efac3f1baa31b9b7f7cca281"},
+    {file = "regex-2021.8.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:85f568892422a0e96235eb8ea6c5a41c8ccbf55576a2260c0160800dbd7c4f20"},
+    {file = "regex-2021.8.3-cp38-cp38-win32.whl", hash = "sha256:bf6d987edd4a44dd2fa2723fca2790f9442ae4de2c8438e53fcb1befdf5d823a"},
+    {file = "regex-2021.8.3-cp38-cp38-win_amd64.whl", hash = "sha256:8fe58d9f6e3d1abf690174fd75800fda9bdc23d2a287e77758dc0e8567e38ce6"},
+    {file = "regex-2021.8.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7976d410e42be9ae7458c1816a416218364e06e162b82e42f7060737e711d9ce"},
+    {file = "regex-2021.8.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9569da9e78f0947b249370cb8fadf1015a193c359e7e442ac9ecc585d937f08d"},
+    {file = "regex-2021.8.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:459bbe342c5b2dec5c5223e7c363f291558bc27982ef39ffd6569e8c082bdc83"},
+    {file = "regex-2021.8.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:4f421e3cdd3a273bace013751c345f4ebeef08f05e8c10757533ada360b51a39"},
+    {file = "regex-2021.8.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea212df6e5d3f60341aef46401d32fcfded85593af1d82b8b4a7a68cd67fdd6b"},
+    {file = "regex-2021.8.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a3b73390511edd2db2d34ff09aa0b2c08be974c71b4c0505b4a048d5dc128c2b"},
+    {file = "regex-2021.8.3-cp39-cp39-win32.whl", hash = "sha256:f35567470ee6dbfb946f069ed5f5615b40edcbb5f1e6e1d3d2b114468d505fc6"},
+    {file = "regex-2021.8.3-cp39-cp39-win_amd64.whl", hash = "sha256:bfa6a679410b394600eafd16336b2ce8de43e9b13f7fb9247d84ef5ad2b45e91"},
+    {file = "regex-2021.8.3.tar.gz", hash = "sha256:8935937dad2c9b369c3d932b0edbc52a62647c2afb2fafc0c280f14a8bf56a6a"},
 ]
 requests = [
-    {file = "requests-2.25.1-py2.py3-none-any.whl", hash = "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"},
-    {file = "requests-2.25.1.tar.gz", hash = "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804"},
+    {file = "requests-2.26.0-py2.py3-none-any.whl", hash = "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24"},
+    {file = "requests-2.26.0.tar.gz", hash = "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7"},
 ]
 requests-oauthlib = [
     {file = "requests-oauthlib-1.3.0.tar.gz", hash = "sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a"},
@@ -2079,9 +2275,13 @@ stevedore = [
     {file = "stevedore-3.3.0-py3-none-any.whl", hash = "sha256:50d7b78fbaf0d04cd62411188fa7eedcb03eb7f4c4b37005615ceebe582aa82a"},
     {file = "stevedore-3.3.0.tar.gz", hash = "sha256:3a5bbd0652bf552748871eaa73a4a8dc2899786bc497a2aa1fcb4dcdb0debeee"},
 ]
+stripe = [
+    {file = "stripe-2.60.0-py2.py3-none-any.whl", hash = "sha256:0050763cb67df6745973bd9757f7a765bed1b82b5d5261fb8908cfc6ec9e5200"},
+    {file = "stripe-2.60.0.tar.gz", hash = "sha256:8966b7793014380f60c6f121ba333d6f333a55818edaf79c8d70464ce0a7a808"},
+]
 texttable = [
-    {file = "texttable-1.6.3-py2.py3-none-any.whl", hash = "sha256:f802f2ef8459058736264210f716c757cbf85007a30886d8541aa8c3404f1dda"},
-    {file = "texttable-1.6.3.tar.gz", hash = "sha256:ce0faf21aa77d806bbff22b107cc22cce68dc9438f97a2df32c93e9afa4ce436"},
+    {file = "texttable-1.6.4-py2.py3-none-any.whl", hash = "sha256:dd2b0eaebb2a9e167d1cefedab4700e5dcbdb076114eed30b58b97ed6b37d6f2"},
+    {file = "texttable-1.6.4.tar.gz", hash = "sha256:42ee7b9e15f7b225747c3fa08f43c5d6c83bc899f80ff9bae9319334824076e9"},
 ]
 tmpl = [
     {file = "tmpl-0.3.0.tar.gz", hash = "sha256:b334f7f1ead0212ea3a55d6548adcb189a236d68eba5d45f199a73dfdf6b075e"},
@@ -2094,6 +2294,10 @@ toml = [
     {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"},
     {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
 ]
+tomli = [
+    {file = "tomli-1.2.1-py3-none-any.whl", hash = "sha256:8dd0e9524d6f386271a36b41dbf6c57d8e32fd96fd22b6584679dc569d20899f"},
+    {file = "tomli-1.2.1.tar.gz", hash = "sha256:a5b75cb6f3968abb47af1b40c1819dc519ea82bcc065776a866e8d74c5ca9442"},
+]
 "twentyc.rpc" = [
     {file = "twentyc.rpc-0.4.0.tar.gz", hash = "sha256:c6a08a0fa8610332f430911061a662efee8c251a5568c1ffd592c566d9da0768"},
 ]
@@ -2135,35 +2339,42 @@ typing-extensions = [
     {file = "typing_extensions-3.10.0.0.tar.gz", hash = "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342"},
 ]
 urllib3 = [
-    {file = "urllib3-1.26.5-py2.py3-none-any.whl", hash = "sha256:753a0374df26658f99d826cfe40394a686d05985786d946fbe4165b5148f5a7c"},
-    {file = "urllib3-1.26.5.tar.gz", hash = "sha256:a7acd0977125325f516bda9735fa7142b909a8d01e8b2e4c8108d0984e6e0098"},
+    {file = "urllib3-1.26.6-py2.py3-none-any.whl", hash = "sha256:39fb8672126159acb139a7718dd10806104dec1e2f0f6c88aab05d17df10c8d4"},
+    {file = "urllib3-1.26.6.tar.gz", hash = "sha256:f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f"},
+]
+uwsgi = [
+    {file = "uWSGI-2.0.19.1.tar.gz", hash = "sha256:faa85e053c0b1be4d5585b0858d3a511d2cd10201802e8676060fd0a109e5869"},
 ]
 vine = [
     {file = "vine-5.0.0-py2.py3-none-any.whl", hash = "sha256:4c9dceab6f76ed92105027c49c823800dd33cacce13bdedc5b914e3514b7fb30"},
     {file = "vine-5.0.0.tar.gz", hash = "sha256:7d3b1624a953da82ef63462013bbd271d3eb75751489f9807598e8f340bd637e"},
 ]
 virtualenv = [
-    {file = "virtualenv-20.4.7-py2.py3-none-any.whl", hash = "sha256:2b0126166ea7c9c3661f5b8e06773d28f83322de7a3ff7d06f0aed18c9de6a76"},
-    {file = "virtualenv-20.4.7.tar.gz", hash = "sha256:14fdf849f80dbb29a4eb6caa9875d476ee2a5cf76a5f5415fa2f1606010ab467"},
+    {file = "virtualenv-20.7.0-py2.py3-none-any.whl", hash = "sha256:fdfdaaf0979ac03ae7f76d5224a05b58165f3c804f8aa633f3dd6f22fbd435d5"},
+    {file = "virtualenv-20.7.0.tar.gz", hash = "sha256:97066a978431ec096d163e72771df5357c5c898ffdd587048f45e0aecc228094"},
 ]
 watchdog = [
-    {file = "watchdog-2.1.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:581e3548159fe7d2a9f377a1fbcb41bdcee46849cca8ab803c7ac2e5e04ec77c"},
-    {file = "watchdog-2.1.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:edcd9ef3fd460bb8a98eb1fcf99941e9fd9f275f45f1a82cb1359ec92975d647"},
-    {file = "watchdog-2.1.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d34ce2261f118ecd57eedeef95fc2a495fc4a40b3ed7b3bf0bd7a8ccc1ab4f8f"},
-    {file = "watchdog-2.1.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:668391e6c32742d76e5be5db6bf95c455fa4b3d11e76a77c13b39bccb3a47a72"},
-    {file = "watchdog-2.1.2-pp36-pypy36_pp73-macosx_10_9_x86_64.whl", hash = "sha256:6ef9fe57162c4c361692620e1d9167574ba1975ee468b24051ca11c9bba6438e"},
-    {file = "watchdog-2.1.2-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:58ebb1095ee493008a7789d47dd62e4999505d82be89fc884d473086fccc6ebd"},
-    {file = "watchdog-2.1.2-py3-none-manylinux2014_aarch64.whl", hash = "sha256:91387ee2421f30b75f7ff632c9d48f76648e56bf346a7c805c0a34187a93aab4"},
-    {file = "watchdog-2.1.2-py3-none-manylinux2014_armv7l.whl", hash = "sha256:a6471517315a8541a943c00b45f1d252e36898a3ae963d2d52509b89a50cb2b9"},
-    {file = "watchdog-2.1.2-py3-none-manylinux2014_i686.whl", hash = "sha256:a42e6d652f820b2b94cd03156c62559a2ea68d476476dfcd77d931e7f1012d4a"},
-    {file = "watchdog-2.1.2-py3-none-manylinux2014_ppc64.whl", hash = "sha256:3d6405681471ebe0beb3aa083998c4870e48b57f8afdb45ea1b5957cc5cf1014"},
-    {file = "watchdog-2.1.2-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:598d772beeaf9c98d0df946fbabf0c8365dd95ea46a250c224c725fe0c4730bc"},
-    {file = "watchdog-2.1.2-py3-none-manylinux2014_s390x.whl", hash = "sha256:4b219d46d89cfa49af1d73175487c14a318a74cb8c5442603fd13c6a5b418c86"},
-    {file = "watchdog-2.1.2-py3-none-manylinux2014_x86_64.whl", hash = "sha256:188145185c08c73c56f1478ccf1f0f0f85101191439679b35b6b100886ce0b39"},
-    {file = "watchdog-2.1.2-py3-none-win32.whl", hash = "sha256:255a32d44bbbe62e52874ff755e2eefe271b150e0ec240ad7718a62a7a7a73c4"},
-    {file = "watchdog-2.1.2-py3-none-win_amd64.whl", hash = "sha256:1a62a4671796dc93d1a7262286217d9e75823c63d4c42782912d39a506d30046"},
-    {file = "watchdog-2.1.2-py3-none-win_ia64.whl", hash = "sha256:104266a778906ae0e971368d368a65c4cd032a490a9fca5ba0b78c6c7ae11720"},
-    {file = "watchdog-2.1.2.tar.gz", hash = "sha256:0237db4d9024859bea27d0efb59fe75eef290833fd988b8ead7a879b0308c2db"},
+    {file = "watchdog-2.1.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:9628f3f85375a17614a2ab5eac7665f7f7be8b6b0a2a228e6f6a2e91dd4bfe26"},
+    {file = "watchdog-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:acc4e2d5be6f140f02ee8590e51c002829e2c33ee199036fcd61311d558d89f4"},
+    {file = "watchdog-2.1.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:85b851237cf3533fabbc034ffcd84d0fa52014b3121454e5f8b86974b531560c"},
+    {file = "watchdog-2.1.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a12539ecf2478a94e4ba4d13476bb2c7a2e0a2080af2bb37df84d88b1b01358a"},
+    {file = "watchdog-2.1.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6fe9c8533e955c6589cfea6f3f0a1a95fb16867a211125236c82e1815932b5d7"},
+    {file = "watchdog-2.1.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d9456f0433845e7153b102fffeb767bde2406b76042f2216838af3b21707894e"},
+    {file = "watchdog-2.1.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fd8c595d5a93abd441ee7c5bb3ff0d7170e79031520d113d6f401d0cf49d7c8f"},
+    {file = "watchdog-2.1.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0bcfe904c7d404eb6905f7106c54873503b442e8e918cc226e1828f498bdc0ca"},
+    {file = "watchdog-2.1.3-pp36-pypy36_pp73-macosx_10_9_x86_64.whl", hash = "sha256:bf84bd94cbaad8f6b9cbaeef43080920f4cb0e61ad90af7106b3de402f5fe127"},
+    {file = "watchdog-2.1.3-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:b8ddb2c9f92e0c686ea77341dcb58216fa5ff7d5f992c7278ee8a392a06e86bb"},
+    {file = "watchdog-2.1.3-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8805a5f468862daf1e4f4447b0ccf3acaff626eaa57fbb46d7960d1cf09f2e6d"},
+    {file = "watchdog-2.1.3-py3-none-manylinux2014_armv7l.whl", hash = "sha256:3e305ea2757f81d8ebd8559d1a944ed83e3ab1bdf68bcf16ec851b97c08dc035"},
+    {file = "watchdog-2.1.3-py3-none-manylinux2014_i686.whl", hash = "sha256:431a3ea70b20962e6dee65f0eeecd768cd3085ea613ccb9b53c8969de9f6ebd2"},
+    {file = "watchdog-2.1.3-py3-none-manylinux2014_ppc64.whl", hash = "sha256:e4929ac2aaa2e4f1a30a36751160be391911da463a8799460340901517298b13"},
+    {file = "watchdog-2.1.3-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:201cadf0b8c11922f54ec97482f95b2aafca429c4c3a4bb869a14f3c20c32686"},
+    {file = "watchdog-2.1.3-py3-none-manylinux2014_s390x.whl", hash = "sha256:3a7d242a7963174684206093846537220ee37ba9986b824a326a8bb4ef329a33"},
+    {file = "watchdog-2.1.3-py3-none-manylinux2014_x86_64.whl", hash = "sha256:54e057727dd18bd01a3060dbf5104eb5a495ca26316487e0f32a394fd5fe725a"},
+    {file = "watchdog-2.1.3-py3-none-win32.whl", hash = "sha256:b5fc5c127bad6983eecf1ad117ab3418949f18af9c8758bd10158be3647298a9"},
+    {file = "watchdog-2.1.3-py3-none-win_amd64.whl", hash = "sha256:44acad6f642996a2b50bb9ce4fb3730dde08f23e79e20cd3d8e2a2076b730381"},
+    {file = "watchdog-2.1.3-py3-none-win_ia64.whl", hash = "sha256:0bcdf7b99b56a3ae069866c33d247c9994ffde91b620eaf0306b27e099bd1ae0"},
+    {file = "watchdog-2.1.3.tar.gz", hash = "sha256:e5236a8e8602ab6db4b873664c2d356c365ab3cac96fbdec4970ad616415dd45"},
 ]
 wcwidth = [
     {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"},
@@ -2174,10 +2385,10 @@ websocket-client = [
     {file = "websocket_client-0.59.0-py2.py3-none-any.whl", hash = "sha256:2e50d26ca593f70aba7b13a489435ef88b8fc3b5c5643c1ce8808ff9b40f0b32"},
 ]
 whitenoise = [
-    {file = "whitenoise-5.2.0-py2.py3-none-any.whl", hash = "sha256:05d00198c777028d72d8b0bbd234db605ef6d60e9410125124002518a48e515d"},
-    {file = "whitenoise-5.2.0.tar.gz", hash = "sha256:05ce0be39ad85740a78750c86a93485c40f08ad8c62a6006de0233765996e5c7"},
+    {file = "whitenoise-5.3.0-py2.py3-none-any.whl", hash = "sha256:d963ef25639d1417e8a247be36e6aedd8c7c6f0a08adcb5a89146980a96b577c"},
+    {file = "whitenoise-5.3.0.tar.gz", hash = "sha256:d234b871b52271ae7ed6d9da47ffe857c76568f11dd30e28e18c5869dbd11e12"},
 ]
 zipp = [
-    {file = "zipp-3.4.1-py3-none-any.whl", hash = "sha256:51cb66cc54621609dd593d1787f286ee42a5c0adbb4b29abea5a63edc3e03098"},
-    {file = "zipp-3.4.1.tar.gz", hash = "sha256:3607921face881ba3e026887d8150cca609d517579abe052ac81fc5aeffdbd76"},
+    {file = "zipp-3.5.0-py3-none-any.whl", hash = "sha256:957cfda87797e389580cb8b9e3870841ca991e2125350677b2ca83a0e99390a3"},
+    {file = "zipp-3.5.0.tar.gz", hash = "sha256:f5812b1e007e48cff63449a5e9f4e7ebea716b4111f9c4f9a645f91d579bf0c4"},
 ]
diff --git a/pyproject.toml b/pyproject.toml
index f3c022ed..309ee066 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -30,25 +30,46 @@ multi_line_output = 3
 
 [tool.poetry.dependencies]
 python = "^3.7"
-django-handleref = ">=0.5"
-djangorestframework = ">=3.11,<4"
-Django = ">=2.2,<4"
-django-peeringdb = "*"
-django-reversion = "<4"
-django-inet = "*"
-django-autocomplete-light = ">=3,<=4"
-social-auth-app-django = "<4"
-grainy = ">=1.8.1,<2"
-django-grainy = ">=1.9.0,<2"
-pyyaml = "*"
+
+# django deps
+Django = "~3.2"
+django-autocomplete-light = "^3"
+django-crispy-forms = "^1.8"
+django-grainy = "^1.9"
+django-handleref = "^1"
+django-inet = "^1"
+django-recaptcha = "^2"
+djangorestframework = "^3"
+django-reversion = "^3"
+social-auth-app-django = "^3"
+
+# only needed for ixctl now, compare vs django-inet?
+django-netfields = "<2"
+
+confu = "^1.5"
+grainy = "^1.8.1"
+psycopg2-binary = ">2.8,<3"
+pyyaml = "^5.4"
+
+# move to aaactl?
+stripe = "^2.55.2"
+
+# move to pdbctl
+django-peeringdb = "^2.8"
+peeringdb = "^1"
+
+# XXX still used?
+whitenoise = "^5.2.0"
 pip = "*"
 celery = "<6,>=5"
-peeringdb = "<2"
-whitenoise = "^5.2.0"
+uWSGI = "^2.0.19"
+
 
 [tool.poetry.dev-dependencies]
 docker-compose = "^1.29.1"
 # tests
+codecov = ">=2"
+coverage = ">=5"
 pytest = ">=6.0.1"
 pytest-django = ">=3.8.0"
 pytest-cov = "*"
diff --git a/src/fullctl/django/admin.py b/src/fullctl/django/admin.py
index d1351a98..6d7e9b8b 100644
--- a/src/fullctl/django/admin.py
+++ b/src/fullctl/django/admin.py
@@ -3,7 +3,7 @@
 from django_handleref.admin import VersionAdmin
 
 import fullctl.django.auditlog as auditlog
-from fullctl.django.models.concrete import AuditLog, OrganizationUser
+from fullctl.django.models.concrete import AuditLog, OrganizationUser, Task
 
 
 class BaseAdmin(VersionAdmin):
@@ -39,6 +39,28 @@ class OrganizationAdmin(BaseAdmin):
     inlines = (OrganizationUserInline,)
 
 
+class TaskAdmin(BaseAdmin):
+    list_display = (
+        "id",
+        "source",
+        "queue_id",
+        "parent",
+        "status",
+        "op",
+        "limit_id",
+        "param",
+        "time",
+        "created",
+        "updated",
+    )
+    list_filter = ("status", "op")
+
+
+@admin.register(Task)
+class TaskAdmin(TaskAdmin):
+    pass
+
+
 @admin.register(AuditLog)
 class AuditLogAdmin(admin.ModelAdmin):
     list_display = (
diff --git a/src/fullctl/django/apps.py b/src/fullctl/django/apps.py
index 9271fd7b..fbbf8bbf 100644
--- a/src/fullctl/django/apps.py
+++ b/src/fullctl/django/apps.py
@@ -4,6 +4,7 @@
 class DjangoFullctlConfig(AppConfig):
     name = "fullctl.django"
     label = "django_fullctl"
+    default_auto_field = "django.db.models.AutoField"
 
     def ready(self):
         import fullctl.django.signals  # noqa: F401
diff --git a/src/fullctl/django/context.py b/src/fullctl/django/context.py
new file mode 100644
index 00000000..329e35b9
--- /dev/null
+++ b/src/fullctl/django/context.py
@@ -0,0 +1,29 @@
+import contextvars
+from contextlib import contextmanager
+
+# stores current request in a thread safe context aware
+# manner.
+_current_request = contextvars.ContextVar("current_request")
+
+
+@contextmanager
+def current_request(request=None):
+
+    """
+    Will yield the current request, if there is one.
+
+    To se the current request for the context pass it to
+    the request parameter.
+    """
+
+    if request:
+        token = _current_request.set(request)
+    else:
+        token = None
+    try:
+        yield _current_request.get()
+    except LookupError:
+        yield None
+    finally:
+        if token:
+            _current_request.reset(token)
diff --git a/src/fullctl/django/decorators.py b/src/fullctl/django/decorators.py
index 2a9d32b8..327bb4c8 100644
--- a/src/fullctl/django/decorators.py
+++ b/src/fullctl/django/decorators.py
@@ -56,7 +56,12 @@ def wrapped(request, *args, **kwargs):
 
             org = request.org
             if not public and org not in Organization.accessible(request.user):
-                raise django.http.Http404()
+                if request.user.is_authenticated:
+                    raise django.http.Http404()
+                else:
+                    return redirect(
+                        reverse("login") + f"?next={request.get_full_path()}"
+                    )
 
             try:
                 instance, _ = model.objects.get_or_create(org=org)
diff --git a/src/fullctl/django/management/commands/base.py b/src/fullctl/django/management/commands/base.py
new file mode 100644
index 00000000..5793cba2
--- /dev/null
+++ b/src/fullctl/django/management/commands/base.py
@@ -0,0 +1,140 @@
+import logging
+import traceback
+
+import reversion
+from django.core.management.base import BaseCommand
+from django.db import transaction
+
+from fullctl.django.auditlog import auditlog
+from fullctl.django.models.concrete.tasks import CallCommand
+from fullctl.django.tasks.orm import worker_id
+
+
+class PretendMode(IOError):
+    pass
+
+
+class CommandInterface(BaseCommand):
+
+    """
+    Interface for fullctl commands
+
+    - database transactions
+    - auditlog enabled
+    - reversion
+    - task queueable
+    """
+
+    # if set to `False` command will by default run in
+    # pretend mode and provide a `--commit` flag to
+    # allow it to step into committal mode
+    always_commit = False
+
+    # enable auditlogging for command
+    auditlog_enabled = True
+
+    # can command be queued?
+    queue_allowed = True
+
+    @property
+    def log(self):
+        if not hasattr(self, "_log"):
+            self._log = logging.getLogger(__name__)
+        return self._log
+
+    def add_arguments(self, parser):
+        if not self.always_commit:
+            parser.add_argument("--commit", action="store_true", help="Commit changes")
+        if self.queue_allowed:
+            parser.add_argument(
+                "--queue",
+                "-Q",
+                action="store_true",
+                help="Queue the execution of this command as a task",
+            )
+            parser.add_argument(
+                "--timeout", type=int, help="Max execution time in queue (seconds)"
+            )
+
+    def _run(self, *args, **kwargs):
+        with reversion.create_revision():
+            self.run(*args, **kwargs)
+            if not self.commit:
+                raise PretendMode()
+
+    @auditlog()
+    def handle(self, auditlog=None, *args, **kwargs):
+        self.auditlog_context = auditlog
+        self.output = []
+        self.queue = kwargs.get("queue")
+
+        if not self.always_commit:
+            self.commit = kwargs.get("commit")
+        else:
+            self.commit = True
+
+        command_kwargs = kwargs.copy()
+        command_kwargs.pop("stdout", None)
+        command_kwargs.pop("stderr", None)
+        command_kwargs.pop("timeout", None)
+        command_name = self.__class__.__module__.split(".")[-1]
+
+        if self.queue:
+            command_kwargs.pop("commit", None)
+            command_kwargs.pop("queue", None)
+            CallCommand.create_task(
+                timeout=kwargs.get("timeout", None),
+                *([command_name] + list(args)),
+                **command_kwargs,
+            )
+            auditlog.log("command:queued")
+            self.log_info(f"Command sent to queue: {command_name}")
+            return
+
+        auditlog.append_data(
+            command_args=args,
+            command_kwargs=command_kwargs,
+            command_name=command_name,
+            clean=True,
+        )
+        auditlog.set("info", f"{worker_id()}:{command_name}")
+
+        try:
+            sid = transaction.savepoint()
+            self._run(*args, **kwargs)
+        except PretendMode:
+            if sid:
+                transaction.savepoint_rollback(sid)
+            else:
+                transaction.rollback()
+
+            self.log_info(
+                "Command was executed in pretend mode, all changes have been rolled back. To run command in committal mode set the --commit flag"
+            )
+        except Exception:
+            if sid:
+                transaction.savepoint_rollback(sid)
+            else:
+                transaction.rollback()
+            err_txt = traceback.format_exc()
+            self.log_error(err_txt)
+
+        if self.auditlog_enabled and self.commit:
+            auditlog.append_data(output="\n".join(self.output))
+            auditlog.log("command")
+
+    def log_debug(self, msg):
+        self.log.debug(msg)
+        self.stdout.write(msg)
+
+    def log_info(self, msg):
+        self.log.info(msg)
+        self.output.append(msg)
+        self.stdout.write(msg)
+
+    def log_error(self, msg):
+        self.log.error(msg)
+        self.auditlog_context.append_data(error=f"{msg}")
+
+    def run(self, *args, **kwargs):
+        raise NotImplementedError()
diff --git a/src/fullctl/django/management/commands/fullctl_peeringdb_sync.py b/src/fullctl/django/management/commands/fullctl_peeringdb_sync.py
index 1ebc1088..58ba4a87 100644
--- a/src/fullctl/django/management/commands/fullctl_peeringdb_sync.py
+++ b/src/fullctl/django/management/commands/fullctl_peeringdb_sync.py
@@ -1,20 +1,25 @@
 from django.conf import settings
-from django.core.management.base import BaseCommand
-from peeringdb import get_backend, initialize_backend, resource
+from peeringdb import initialize_backend, resource
 from peeringdb.client import Client
 
+from fullctl.django.management.commands.base import CommandInterface
 
-class Command(BaseCommand):
+
+class Command(CommandInterface):
 
     """
     Pull peeringdb updates
     """
 
+    always_commit = True
+
     def add_arguments(self, parser):
         parser.add_argument("--pdburl", default="https://www.peeringdb.com/api")
 
-    def handle(self, *args, **kwargs):
+    def run(self, *args, **kwargs):
         self.pdburl = kwargs.get("pdburl")
+        self.username = getattr(settings, "PDB_API_USERNAME", "")
+        self.password = getattr(settings, "PDB_API_PASSWORD", "")
         self.sync()
 
     def sync(self):
@@ -22,8 +27,8 @@ def sync(self):
         config = {
             "sync": {
                 "url": self.pdburl,
-                "user": "",
-                "password": "",
+                "user": self.username,
+                "password": self.password,
                 "strip_tz": 1,
                 "timeout": 0,
                 "only": [],
@@ -36,8 +41,6 @@ def sync(self):
         }
 
         initialize_backend("django_peeringdb", **config["orm"])
-        get_backend()
 
         client = Client(config, **config)
-        print("Syncing from", config["sync"]["url"])
         client.update_all(resource.all_resources())
diff --git a/src/fullctl/django/management/commands/fullctl_poll_tasks.py b/src/fullctl/django/management/commands/fullctl_poll_tasks.py
new file mode 100644
index 00000000..ce9b600a
--- /dev/null
+++ b/src/fullctl/django/management/commands/fullctl_poll_tasks.py
@@ -0,0 +1,153 @@
+import asyncio
+import uuid
+
+import reversion
+from asgiref.sync import sync_to_async
+
+from fullctl.django.management.commands.base import CommandInterface
+from fullctl.django.tasks.orm import TaskClaimed, claim_task, fetch_task
+
+
+class Worker:
+
+    """
+    Async task processor.
+
+    Will run fullctl_work_task command async through asyncio.subprocess
+    """
+
+    def __init__(self):
+        self.id = f"{uuid.uuid4()}"[:8]
+        self.task = None
+        self.process = None
+
+    def set_task(self, task):
+        if task and self.task:
+            raise OSError("Worker has already been assiged a task")
+        self.task = task
+        self.process = None
+
+    async def work(self):
+
+        """
+        Starts the worker on the tasks or waits for the currently
+        assigned task to complete.
+
+        Task is assigned through set_task
+        """
+
+        if not self.task:
+            return
+        self.task
+        if not self.process:
+            # no process has been spawned yet, run the command
+            await self._run_command()
+        else:
+            # process has been spawned, check if it is done
+            if self.process.returncode is not None:
+                # its done, set task to None, indicating
+                # that the worker is ready for more work
+                self.set_task(None)
+
+    async def _run_command(self):
+        task = self.task
+        cmd = [
+            "python",
+            "manage.py",
+            "fullctl_work_task",
+            f"{task.id}",
+        ]
+        p = await asyncio.create_subprocess_shell(
+            " ".join(cmd),
+        )
+        self.process = p
+        # print("Worker", self.id, p.pid, "subprocess spawned")
+
+
+class Command(CommandInterface):
+
+    help = "Process task queue"
+
+    always_commit = True
+
+    @property
+    def worker_available(self):
+        for worker in self.workers:
+            if not worker.task:
+                return True
+        return False
+
+    def add_arguments(self, parser):
+        super().add_arguments(parser)
+        parser.add_argument("--workers", help="number of concurrent  workers", type=int)
+
+    def _run(self, *args, **kwargs):
+        self.sleep_interval = 0.5
+        self.workers_num = int(kwargs.get("workers") or 1)
+
+        self.workers = [Worker() for i in range(0, self.workers_num)]
+
+        async def _main():
+            await asyncio.gather(
+                asyncio.create_task(self._poll_tasks()),
+                asyncio.create_task(self._process_workers()),
+            )
+
+        asyncio.run(_main())
+
+    async def _process_workers(self):
+
+        while True:
+
+            await asyncio.sleep(self.sleep_interval)
+
+            for worker in self.workers:
+                await worker.work()
+
+    async def _poll_tasks(self):
+
+        while True:
+
+            await asyncio.sleep(self.sleep_interval)
+
+            if not self.worker_available:
+                self.log_info("All workers busy")
+                continue
+
+            # django call needs to be wrapped in sync_to_async
+
+            task = await sync_to_async(fetch_task)()
+
+            if not task or task.queue_id:
+                continue
+
+            self.log_info(f"New task {task}")
+
+            # django call needs to be wrapped in sync_to_async
+
+            await sync_to_async(self.claim_task)(task)
+
+            await self.delegate_task(task)
+
+    def claim_task(self, task):
+        try:
+            with reversion.create_revision():
+                self.log_info(f"Claiming task {task}")
+                return claim_task(task)
+        except TaskClaimed:
+            if not task.queue_id:
+                err = "Task has a claim, but no queue id - this should never happen"
+                task.status = "failed"
+                task.error = err
+                task.save()
+                self.log_error(err)
+            self.log_debug("Task already claimed, skipping")
+
+    async def delegate_task(self, task):
+        self.log_info(f"Delegating task {task}")
+        while True:
+            await asyncio.sleep(0.5)
+            for worker in self.workers:
+                if not worker.task:
+                    worker.set_task(task)
+                    return
diff --git a/src/fullctl/django/management/commands/fullctl_promote_user.py b/src/fullctl/django/management/commands/fullctl_promote_user.py
new file mode 100644
index 00000000..9d37566a
--- /dev/null
+++ b/src/fullctl/django/management/commands/fullctl_promote_user.py
@@ -0,0 +1,25 @@
+from django.contrib.auth import get_user_model
+
+from fullctl.django.management.commands.base import CommandInterface
+
+
+class Command(CommandInterface):
+
+    help = "Promote a user to superuser"
+
+    def add_arguments(self, parser):
+        super().add_arguments(parser)
+
+        parser.add_argument("username", nargs="?")
+
+    def run(self, *args, **kwargs):
+        username = kwargs.get("username")
+
+        User = get_user_model()
+        user = User.objects.get(username=username)
+
+        user.is_superuser = True
+        user.is_staff = True
+        user.save()
+
+        self.log_info(f"Promoted user: {username}")
diff --git a/src/fullctl/django/management/commands/fullctl_work_task.py b/src/fullctl/django/management/commands/fullctl_work_task.py
new file mode 100644
index 00000000..22468f54
--- /dev/null
+++ b/src/fullctl/django/management/commands/fullctl_work_task.py
@@ -0,0 +1,29 @@
+from fullctl.django.management.commands.base import CommandInterface
+from fullctl.django.models import Task
+from fullctl.django.tasks.orm import specify_task, work_task
+
+
+class Command(CommandInterface):
+
+    help = "Process the specified task"
+
+    always_commit = True
+
+    def add_arguments(self, parser):
+        super().add_arguments(parser)
+        parser.add_argument("task_id", nargs="?")
+
+    def run(self, *args, **kwargs):
+        task_id = kwargs.get("task_id")
+
+        task = specify_task(Task.objects.get(id=task_id))
+
+        self.log_info(f"Processing {task}")
+
+        work_task(task)
+
+        if task.error:
+            self.log_info("Task failed")
+            self.log_error(task.error)
+        else:
+            self.log_info("Task finished")
diff --git a/src/fullctl/django/middleware.py b/src/fullctl/django/middleware.py
index 83f0c2e6..ff73fd9f 100644
--- a/src/fullctl/django/middleware.py
+++ b/src/fullctl/django/middleware.py
@@ -1,9 +1,26 @@
 from django.http import Http404
 
 from fullctl.django.auth import permissions
+from fullctl.django.context import current_request
 from fullctl.django.models import Organization
 
 
+class CurrentRequestContext:
+
+    """
+    Middleware that sets the current request context
+
+    This allows us to access the current request from anywhere we need to
+    """
+
+    def __init__(self, get_response):
+        self.get_response = get_response
+
+    def __call__(self, request):
+        with current_request(request):
+            return self.get_response(request)
+
+
 class RequestAugmentation:
 
     """
diff --git a/src/fullctl/django/migrations/0008_managementtask.py b/src/fullctl/django/migrations/0008_managementtask.py
new file mode 100644
index 00000000..ad37e1a8
--- /dev/null
+++ b/src/fullctl/django/migrations/0008_managementtask.py
@@ -0,0 +1,127 @@
+# Generated by Django 3.2.4 on 2021-07-07 13:36
+
+import django.db.models.deletion
+import django.db.models.manager
+import django_handleref.models
+from django.db import migrations, models
+
+import fullctl.django.tasks.util
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("contenttypes", "0002_remove_content_type_name"),
+        ("django_fullctl", "0007_auditlog_ip_address"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="ManagementTask",
+            fields=[
+                ("id", models.AutoField(primary_key=True, serialize=False)),
+                (
+                    "created",
+                    django_handleref.models.CreatedDateTimeField(
+                        auto_now_add=True, verbose_name="Created"
+                    ),
+                ),
+                (
+                    "updated",
+                    django_handleref.models.UpdatedDateTimeField(
+                        auto_now=True, verbose_name="Updated"
+                    ),
+                ),
+                ("version", models.IntegerField(default=0)),
+                ("op", models.CharField(max_length=255)),
+                (
+                    "status",
+                    models.CharField(
+                        choices=[
+                            ("pending", "Pending"),
+                            ("running", "Running"),
+                            ("completed", "Completed"),
+                            ("failed", "Failed"),
+                        ],
+                        default="pending",
+                        max_length=255,
+                    ),
+                ),
+                (
+                    "param_json",
+                    models.TextField(
+                        blank=True,
+                        help_text="json containing args and kwargs",
+                        null=True,
+                    ),
+                ),
+                (
+                    "error",
+                    models.TextField(
+                        blank=True,
+                        help_text="if the task failed will contain traceback or error info",
+                        null=True,
+                    ),
+                ),
+                (
+                    "output",
+                    models.TextField(
+                        blank=True,
+                        help_text="task output - can also be used to store results",
+                        null=True,
+                    ),
+                ),
+                (
+                    "timeout",
+                    models.IntegerField(
+                        blank=True,
+                        default=None,
+                        help_text="task timeout in seconds",
+                        null=True,
+                    ),
+                ),
+                (
+                    "time",
+                    models.FloatField(default=0.0, help_text="time sepnt in seconds"),
+                ),
+                (
+                    "source",
+                    models.CharField(
+                        blank=True,
+                        default=fullctl.django.tasks.util.worker_id,
+                        help_text="host id where task was triggered",
+                        max_length=255,
+                        null=True,
+                    ),
+                ),
+                (
+                    "queue_id",
+                    models.CharField(
+                        blank=True,
+                        help_text="task queue id (celery task id or orm worker id)",
+                        max_length=255,
+                        null=True,
+                        unique=True,
+                    ),
+                ),
+                ("parent_id", models.PositiveIntegerField(blank=True, null=True)),
+                (
+                    "parent_type",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to="contenttypes.contenttype",
+                    ),
+                ),
+            ],
+            options={
+                "verbose_name": "Django Management Task",
+                "verbose_name_plural": "Django Management Tasks",
+                "db_table": "fullctl_task_command",
+            },
+            managers=[
+                ("handleref", django.db.models.manager.Manager()),
+            ],
+        ),
+    ]
diff --git a/src/fullctl/django/migrations/0009_taskclaim.py b/src/fullctl/django/migrations/0009_taskclaim.py
new file mode 100644
index 00000000..c1b83c56
--- /dev/null
+++ b/src/fullctl/django/migrations/0009_taskclaim.py
@@ -0,0 +1,68 @@
+# Generated by Django 3.2.4 on 2021-07-08 07:52
+
+import django.db.models.deletion
+import django.db.models.manager
+import django_handleref.models
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("contenttypes", "0002_remove_content_type_name"),
+        ("django_fullctl", "0008_managementtask"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="TaskClaim",
+            fields=[
+                ("id", models.AutoField(primary_key=True, serialize=False)),
+                (
+                    "created",
+                    django_handleref.models.CreatedDateTimeField(
+                        auto_now_add=True, verbose_name="Created"
+                    ),
+                ),
+                (
+                    "updated",
+                    django_handleref.models.UpdatedDateTimeField(
+                        auto_now=True, verbose_name="Updated"
+                    ),
+                ),
+                ("version", models.IntegerField(default=0)),
+                (
+                    "status",
+                    models.CharField(
+                        choices=[
+                            ("ok", "Ok"),
+                            ("pending", "Pending"),
+                            ("deactivated", "Deactivated"),
+                            ("failed", "Failed"),
+                            ("expired", "Expired"),
+                        ],
+                        default="ok",
+                        max_length=12,
+                    ),
+                ),
+                ("task_id", models.PositiveIntegerField()),
+                ("worker_id", models.CharField(max_length=255)),
+                (
+                    "task_type",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to="contenttypes.contenttype",
+                    ),
+                ),
+            ],
+            options={
+                "verbose_name": "Claimed Task",
+                "verbose_name_plural": "Claimed Tasks",
+                "db_table": "fullctl_task_claim",
+                "unique_together": {("task_type", "task_id")},
+            },
+            managers=[
+                ("handleref", django.db.models.manager.Manager()),
+            ],
+        ),
+    ]
diff --git a/src/fullctl/django/migrations/0010_alter_managementtask_queue_id.py b/src/fullctl/django/migrations/0010_alter_managementtask_queue_id.py
new file mode 100644
index 00000000..3881c7b9
--- /dev/null
+++ b/src/fullctl/django/migrations/0010_alter_managementtask_queue_id.py
@@ -0,0 +1,23 @@
+# Generated by Django 3.2.4 on 2021-07-08 10:46
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("django_fullctl", "0009_taskclaim"),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name="managementtask",
+            name="queue_id",
+            field=models.CharField(
+                blank=True,
+                help_text="task queue id (celery task id or orm worker id)",
+                max_length=255,
+                null=True,
+            ),
+        ),
+    ]
diff --git a/src/fullctl/django/migrations/0011_alter_managementtask_status.py b/src/fullctl/django/migrations/0011_alter_managementtask_status.py
new file mode 100644
index 00000000..a985ea7c
--- /dev/null
+++ b/src/fullctl/django/migrations/0011_alter_managementtask_status.py
@@ -0,0 +1,28 @@
+# Generated by Django 3.2.4 on 2021-08-05 10:17
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("django_fullctl", "0010_alter_managementtask_queue_id"),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name="managementtask",
+            name="status",
+            field=models.CharField(
+                choices=[
+                    ("pending", "Pending"),
+                    ("running", "Running"),
+                    ("completed", "Completed"),
+                    ("failed", "Failed"),
+                    ("cancelled", "Cancelled"),
+                ],
+                default="pending",
+                max_length=255,
+            ),
+        ),
+    ]
diff --git a/src/fullctl/django/migrations/0012_auto_20210805_1153.py b/src/fullctl/django/migrations/0012_auto_20210805_1153.py
new file mode 100644
index 00000000..aba8f7cc
--- /dev/null
+++ b/src/fullctl/django/migrations/0012_auto_20210805_1153.py
@@ -0,0 +1,164 @@
+# Generated by Django 3.2.4 on 2021-08-05 11:53
+
+import django.db.models.deletion
+import django.db.models.manager
+import django_handleref.models
+from django.db import migrations, models
+
+import fullctl.django.tasks.util
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("django_fullctl", "0011_alter_managementtask_status"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="Task",
+            fields=[
+                ("id", models.AutoField(primary_key=True, serialize=False)),
+                (
+                    "created",
+                    django_handleref.models.CreatedDateTimeField(
+                        auto_now_add=True, verbose_name="Created"
+                    ),
+                ),
+                (
+                    "updated",
+                    django_handleref.models.UpdatedDateTimeField(
+                        auto_now=True, verbose_name="Updated"
+                    ),
+                ),
+                ("version", models.IntegerField(default=0)),
+                ("op", models.CharField(max_length=255)),
+                (
+                    "status",
+                    models.CharField(
+                        choices=[
+                            ("pending", "Pending"),
+                            ("running", "Running"),
+                            ("completed", "Completed"),
+                            ("failed", "Failed"),
+                            ("cancelled", "Cancelled"),
+                        ],
+                        default="pending",
+                        max_length=255,
+                    ),
+                ),
+                (
+                    "param_json",
+                    models.TextField(
+                        blank=True,
+                        help_text="json containing args and kwargs",
+                        null=True,
+                    ),
+                ),
+                (
+                    "error",
+                    models.TextField(
+                        blank=True,
+                        help_text="if the task failed will contain traceback or error info",
+                        null=True,
+                    ),
+                ),
+                (
+                    "output",
+                    models.TextField(
+                        blank=True,
+                        help_text="task output - can also be used to store results",
+                        null=True,
+                    ),
+                ),
+                (
+                    "timeout",
+                    models.IntegerField(
+                        blank=True,
+                        default=None,
+                        help_text="task timeout in seconds",
+                        null=True,
+                    ),
+                ),
+                (
+                    "time",
+                    models.FloatField(default=0.0, help_text="time sepnt in seconds"),
+                ),
+                (
+                    "source",
+                    models.CharField(
+                        blank=True,
+                        default=fullctl.django.tasks.util.worker_id,
+                        help_text="host id where task was triggered",
+                        max_length=255,
+                        null=True,
+                    ),
+                ),
+                (
+                    "queue_id",
+                    models.CharField(
+                        blank=True,
+                        help_text="task queue id (celery task id or orm worker id)",
+                        max_length=255,
+                        null=True,
+                    ),
+                ),
+                (
+                    "parent",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to="django_fullctl.task",
+                    ),
+                ),
+            ],
+            options={
+                "verbose_name": "Task",
+                "verbose_name_plural": "Tasks",
+                "db_table": "fullctl_task",
+            },
+            managers=[
+                ("handleref", django.db.models.manager.Manager()),
+            ],
+        ),
+        migrations.AlterUniqueTogether(
+            name="taskclaim",
+            unique_together=set(),
+        ),
+        migrations.DeleteModel(
+            name="ManagementTask",
+        ),
+        migrations.CreateModel(
+            name="CallCommandTask",
+            fields=[],
+            options={
+                "proxy": True,
+                "indexes": [],
+                "constraints": [],
+            },
+            bases=("django_fullctl.task",),
+            managers=[
+                ("handleref", django.db.models.manager.Manager()),
+            ],
+        ),
+        migrations.RemoveField(
+            model_name="taskclaim",
+            name="task_id",
+        ),
+        migrations.RemoveField(
+            model_name="taskclaim",
+            name="task_type",
+        ),
+        migrations.AddField(
+            model_name="taskclaim",
+            name="task",
+            field=models.ForeignKey(
+                default=1,
+                on_delete=django.db.models.deletion.CASCADE,
+                to="django_fullctl.task",
+                unique=True,
+            ),
+            preserve_default=False,
+        ),
+    ]
diff --git a/src/fullctl/django/migrations/0013_auto_20210805_1200.py b/src/fullctl/django/migrations/0013_auto_20210805_1200.py
new file mode 100644
index 00000000..4b3535aa
--- /dev/null
+++ b/src/fullctl/django/migrations/0013_auto_20210805_1200.py
@@ -0,0 +1,30 @@
+# Generated by Django 3.2.4 on 2021-08-05 12:00
+
+import django.db.models.manager
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("django_fullctl", "0012_auto_20210805_1153"),
+    ]
+
+    operations = [
+        migrations.DeleteModel(
+            name="CallCommandTask",
+        ),
+        migrations.CreateModel(
+            name="CallCommand",
+            fields=[],
+            options={
+                "proxy": True,
+                "indexes": [],
+                "constraints": [],
+            },
+            bases=("django_fullctl.task",),
+            managers=[
+                ("handleref", django.db.models.manager.Manager()),
+            ],
+        ),
+    ]
diff --git a/src/fullctl/django/migrations/0014_alter_taskclaim_task.py b/src/fullctl/django/migrations/0014_alter_taskclaim_task.py
new file mode 100644
index 00000000..3cff4f38
--- /dev/null
+++ b/src/fullctl/django/migrations/0014_alter_taskclaim_task.py
@@ -0,0 +1,21 @@
+# Generated by Django 3.2.4 on 2021-08-05 12:01
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("django_fullctl", "0013_auto_20210805_1200"),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name="taskclaim",
+            name="task",
+            field=models.OneToOneField(
+                on_delete=django.db.models.deletion.CASCADE, to="django_fullctl.task"
+            ),
+        ),
+    ]
diff --git a/src/fullctl/django/migrations/0015_alter_task_parent.py b/src/fullctl/django/migrations/0015_alter_task_parent.py
new file mode 100644
index 00000000..94708d9a
--- /dev/null
+++ b/src/fullctl/django/migrations/0015_alter_task_parent.py
@@ -0,0 +1,25 @@
+# Generated by Django 3.2.4 on 2021-08-05 12:11
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("django_fullctl", "0014_alter_taskclaim_task"),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name="task",
+            name="parent",
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name="children",
+                to="django_fullctl.task",
+            ),
+        ),
+    ]
diff --git a/src/fullctl/django/migrations/0016_task_limit_id.py b/src/fullctl/django/migrations/0016_task_limit_id.py
new file mode 100644
index 00000000..0a8dd7f6
--- /dev/null
+++ b/src/fullctl/django/migrations/0016_task_limit_id.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.4 on 2021-08-06 10:37
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("django_fullctl", "0015_alter_task_parent"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="task",
+            name="limit_id",
+            field=models.CharField(blank=True, default="", max_length=255),
+        ),
+    ]
diff --git a/src/fullctl/django/models/__init__.py b/src/fullctl/django/models/__init__.py
index f18b27fd..f6e573f2 100644
--- a/src/fullctl/django/models/__init__.py
+++ b/src/fullctl/django/models/__init__.py
@@ -1,2 +1,3 @@
 from fullctl.django.models.concrete.account import *  # noqa: F401, F403
 from fullctl.django.models.concrete.auditlog import *  # noqa: F401, F403
+from fullctl.django.models.concrete.tasks import *  # noqa: F401, F403
diff --git a/src/fullctl/django/models/abstract/alert.py b/src/fullctl/django/models/abstract/alert.py
index 4a624cb2..ca252a37 100644
--- a/src/fullctl/django/models/abstract/alert.py
+++ b/src/fullctl/django/models/abstract/alert.py
@@ -4,12 +4,13 @@
 
 from fullctl.django.mail import send_plain
 from fullctl.django.models.abstract.base import HandleRefModel
-from fullctl.django.models.abstract.task_interface import TaskContainer
 
 ALERT_RECIPIENT_TYPE = (("email", _("Email")),)
 
+# XXX this needs to be adapted to the new task system
 
-class AlertGroup(TaskContainer):
+
+class AlertGroup:
 
     """
     Describes a group of recipients for alert notifications
diff --git a/src/fullctl/django/models/abstract/base.py b/src/fullctl/django/models/abstract/base.py
index b59782ac..2e573301 100644
--- a/src/fullctl/django/models/abstract/base.py
+++ b/src/fullctl/django/models/abstract/base.py
@@ -4,6 +4,11 @@
 
 from fullctl.django.inet.util import pdb_lookup
 
+__all__ = [
+    "HandleRefModel",
+    "PdbRefModel",
+]
+
 
 class HandleRefModel(SoftDeleteHandleRefModel):
     """
diff --git a/src/fullctl/django/models/abstract/task_interface.py b/src/fullctl/django/models/abstract/task_interface.py
deleted file mode 100644
index f941ee03..00000000
--- a/src/fullctl/django/models/abstract/task_interface.py
+++ /dev/null
@@ -1,184 +0,0 @@
-import datetime
-import json
-import subprocess
-import traceback
-
-import pytz
-from django.core.exceptions import ValidationError
-from django.db import models
-
-from fullctl.django.models.abstract.base import HandleRefModel
-from fullctl.django.tasks import launch_task
-
-
-class TaskLimitError(IOError):
-    pass
-
-
-class Task(HandleRefModel):
-
-    """
-    Describes an asynchronous task
-
-    Task status values:
-        - pending: task waiting for execution
-        - running: task is currently executing
-        - completed: task completed succesfully
-        - failed: task ended due to error
-    """
-
-    op = models.CharField(max_length=255)
-
-    param_json = models.TextField(
-        blank=True, null=True, help_text="json containing args and kwargs"
-    )
-    error = models.TextField(
-        blank=True,
-        null=True,
-        help_text="if the task failed will contain " "traceback or error info",
-    )
-    output = models.TextField(
-        blank=True,
-        null=True,
-        help_text="task output - can also be used to " "store results",
-    )
-
-    queue_id = models.CharField(
-        max_length=255,
-        unique=True,
-        null=True,
-        blank=True,
-        help_text="task queue id (celery task id)",
-    )
-
-    limit = -1
-
-    class Meta:
-        abstract = True
-
-    class HandleRef:
-        tag = "task"
-
-    @property
-    def param(self):
-        """
-        Will return a dict from the param_json value
-        """
-        if self.param_json:
-            return json.loads(self.param_json)
-        return {"args": [], "kwargs": {}}
-
-    @param.setter
-    def param(self, param):
-        """
-        Will take a dict and convert to json and
-        store in the param_json field
-        """
-        self.param_json = json.dumps(param)
-
-    def clean(self):
-        super().clean()
-        op_name = f"op_{self.op}"
-        if not hasattr(self, op_name):
-            raise ValidationError(f"The operation `{op_name}` does not exist")
-
-        if not callable(getattr(self, op_name)):
-            raise ValidationError(f"The operation `{op_name}` is not callable")
-
-        try:
-            if self.param_json:
-                json.loads(self.param_json)
-        except Exception as exc:
-            raise ValidationError(f"Parameters could not be JSON encoded: {exc}")
-
-    def _complete(self, output):
-        self.output = output
-        self.status = "completed"
-        self.save()
-
-    def _fail(self, error):
-        self.error = error
-        self.status = "failed"
-        self.save()
-
-    def _run(self):
-        self.status = "running"
-        self.save()
-        try:
-            op = getattr(self, f"op_{self.op}")
-            param = self.param
-            output = op(*param["args"], **param["kwargs"])
-            self._complete(output)
-        except Exception:
-            self._fail(traceback.format_exc())
-
-    def run_command(self, command):
-        p = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
-
-        output, error = p.communic
-
-
-class TaskContainer(HandleRefModel):
-
-    """
-    Decribes a model that is able to spawn tasks
-
-    Needs to implement a `task_class` property
-    that returns the model to use for task storage
-    """
-
-    class Meta:
-        abstract = True
-
-    @property
-    def task_class(self):
-        """override this"""
-        return Task
-
-    @property
-    def tasks_active(self):
-        return self.task_set.exclude(status__in=["completed", "failed"])
-
-    def task_validate(self, task):
-        if task.limit > -1:
-            if self.tasks_active.filter(op=task.op) > task.limit:
-                raise TaskLimitError(task.op)
-        task.full_clean()
-        return task
-
-    def start_task(self, op, *args, **kwargs):
-        task = self.task_class(
-            status="pending",
-            op=op,
-            owner=self,
-        )
-        task.param = {"args": args, "kwargs": kwargs}
-        task = self.task_validate(task)
-        task.save()
-        launch_task(task)
-        return task
-
-    def task_time(self, op=None):
-        """
-        Returns the amount of time (in seconds) that has passed
-        since a task has been run.
-
-        Keyword Argument(s):
-            - op(str): if specified only consider tasks with that op
-
-        Returns:
-            - None: if no task has been run
-            - int: seconds since task has been run
-        """
-        qs = self.task_set.all().order_by("-created")
-        if op:
-            qs.filter(op=op)
-
-        task = qs.first()
-
-        if not task:
-            return None
-
-        return (
-            datetime.datetime.now().replace(tzinfo=pytz.UTC) - task.updated
-        ).total_seconds()
diff --git a/src/fullctl/django/models/concrete/__init__.py b/src/fullctl/django/models/concrete/__init__.py
index f18b27fd..f6e573f2 100644
--- a/src/fullctl/django/models/concrete/__init__.py
+++ b/src/fullctl/django/models/concrete/__init__.py
@@ -1,2 +1,3 @@
 from fullctl.django.models.concrete.account import *  # noqa: F401, F403
 from fullctl.django.models.concrete.auditlog import *  # noqa: F401, F403
+from fullctl.django.models.concrete.tasks import *  # noqa: F401, F403
diff --git a/src/fullctl/django/models/concrete/account.py b/src/fullctl/django/models/concrete/account.py
index cb833733..41dbbe8b 100644
--- a/src/fullctl/django/models/concrete/account.py
+++ b/src/fullctl/django/models/concrete/account.py
@@ -9,6 +9,13 @@
 from fullctl.django.auth import permissions
 from fullctl.django.models.abstract import HandleRefModel
 
+__all__ = [
+    "generate_secret",
+    "Organization",
+    "Instance",
+    "OrganizationUser",
+]
+
 
 def generate_secret():
     return token_urlsafe()
@@ -231,7 +238,7 @@ class HandleRef:
     class Meta:
         db_table = "fullctl_org_user"
         verbose_name = _("Organization User")
-        verbose_name = _("Organization Users")
+        verbose_name_plural = _("Organization Users")
 
     def __str__(self):
         return f"{self.user.username} <{self.user.email}>"
diff --git a/src/fullctl/django/models/concrete/tasks.py b/src/fullctl/django/models/concrete/tasks.py
new file mode 100644
index 00000000..dc76fb74
--- /dev/null
+++ b/src/fullctl/django/models/concrete/tasks.py
@@ -0,0 +1,440 @@
+import asyncio
+import json
+import subprocess
+import time
+import traceback
+from io import StringIO
+
+from asgiref.sync import sync_to_async
+from django.core.exceptions import ValidationError
+from django.core.management import call_command
+from django.db import models
+from django.utils.translation import gettext_lazy as _
+
+import fullctl.django.tasks
+from fullctl.django.models.abstract.base import HandleRefModel
+from fullctl.django.tasks.util import worker_id
+
+__all__ = [
+    "LimitAction",
+    "TaskClaimed",
+    "WorkerUnqualified",
+    "TaskLimitError",
+    "TaskAlreadyStarted",
+    "ParentTaskNotFinished",
+    "Task",
+    "TaskClaim",
+    "CallCommand",
+]
+
+
+class LimitAction:
+    error = 0
+    silent = 1
+
+
+class TaskClaimed(IOError):
+    def __init__(self, task):
+        super().__init__(f"Task already claimed by another worker: {task}")
+
+
+class WorkerUnqualified(IOError):
+    def __init__(self, task, qualifier):
+        super().__init__(
+            f"Worker does not qualify to process this task: {task}, {qualifier}"
+        )
+
+
+class TaskLimitError(IOError):
+    """
+    Raised when there are currently too many pending
+    instances of a task
+    """
+
+    pass
+
+
+class TaskAlreadyStarted(IOError):
+    """
+    Raised when trying to work a task that is already
+    started
+    """
+
+    pass
+
+
+class ParentTaskNotFinished(IOError):
+    """
+    Raised when trying to work a child task
+    before the parent task has finished
+    """
+
+    pass
+
+
+class Task(HandleRefModel):
+
+    """
+    Describes an asynchronous task
+
+    Task status values:
+        - pending: task waiting for execution
+        - running: task is currently executing
+        - completed: task completed succesfully
+        - failed: task ended due to error
+        - cancelled: task canceled
+    """
+
+    op = models.CharField(max_length=255)
+
+    limit_id = models.CharField(max_length=255, blank=True, default="")
+
+    status = models.CharField(
+        max_length=255,
+        choices=(
+            ("pending", _("Pending")),
+            ("running", _("Running")),
+            ("completed", _("Completed")),
+            ("failed", _("Failed")),
+            ("cancelled", _("Cancelled")),
+        ),
+        default="pending",
+    )
+
+    param_json = models.TextField(
+        blank=True, null=True, help_text="json containing args and kwargs"
+    )
+    error = models.TextField(
+        blank=True,
+        null=True,
+        help_text="if the task failed will contain traceback or error info",
+    )
+    output = models.TextField(
+        blank=True,
+        null=True,
+        help_text="task output - can also be used to store results",
+    )
+
+    timeout = models.IntegerField(
+        null=True, blank=True, default=None, help_text="task timeout in seconds"
+    )
+
+    time = models.FloatField(default=0.0, help_text="time sepnt in seconds")
+
+    source = models.CharField(
+        max_length=255,
+        null=True,
+        blank=True,
+        default=worker_id,
+        help_text="host id where task was triggered",
+    )
+
+    queue_id = models.CharField(
+        max_length=255,
+        null=True,
+        blank=True,
+        help_text="task queue id (celery task id or orm worker id)",
+    )
+
+    parent = models.ForeignKey(
+        "django_fullctl.Task",
+        on_delete=models.CASCADE,
+        null=True,
+        blank=True,
+        related_name="children",
+    )
+
+    class Meta:
+        db_table = "fullctl_task"
+        verbose_name = _("Task")
+        verbose_name_plural = _("Tasks")
+
+    class HandleRef:
+        tag = "task"
+
+    @classmethod
+    def create_task(cls, *args, **kwargs):
+
+        parent = kwargs.pop("parent", None)
+        timeout = kwargs.pop("timeout", None)
+        op = cls.HandleRef.tag
+
+        if parent:
+            parent = Task(id=parent.id)
+
+        param = {"args": args or [], "kwargs": kwargs or {}}
+
+        task = cls(op=op, param=param, status="pending", parent=parent, timeout=timeout)
+        task.limit_id = task.generate_limit_id
+
+        try:
+            task.clean()
+        except TaskLimitError:
+            if task.limit_action == LimitAction.silent:
+                pass
+            raise
+
+        task.save()
+        return task
+
+    @property
+    def generate_limit_id(self):
+        return ""
+
+    @property
+    def limit(self):
+        return self.task_meta_property("limit")
+
+    @property
+    def limit_action(self):
+        return self.task_meta_property("limit_action", LimitAction.error)
+
+    @property
+    def param(self):
+        """
+        Will return a dict from the param_json value
+        """
+        if self.param_json:
+            return json.loads(self.param_json)
+        return {"args": [], "kwargs": {}}
+
+    @param.setter
+    def param(self, param):
+        """
+        Will take a dict and convert to json and
+        store in the param_json field
+        """
+        self.param_json = json.dumps(param)
+
+    @property
+    def result(self):
+        """
+        Returns the task result if task completed
+        otherwise returns None
+
+        You can type cast a result type by specifying
+        the `result_type` property in `TaskMeta`
+        """
+        if self.status == "completed":
+            typ = self.task_meta_property("result_type", str)
+            return typ(self.output)
+        return None
+
+    @property
+    def task_meta(self):
+        """
+        Returns TaskMeta if it exists, None otherwise
+        """
+        return getattr(self, "TaskMeta", None)
+
+    @property
+    def qualifies(self):
+
+        """
+        Checks if the environment qualifies to process
+        this task.
+
+        Will raise a WorkerUnqualified exception if not
+        """
+
+        task_meta = self.task_meta
+
+        if not task_meta:
+            return True
+
+        qualifiers = getattr(task_meta, "qualifiers", [])
+
+        for qualifier in qualifiers:
+            if not qualifier.check(self):
+                raise WorkerUnqualified(self, qualifier)
+
+        return True
+
+    def __str__(self):
+        return f"{self.__class__.__name__}({self.id}): {self.param['args']}"
+
+    def wait(self, timeout=None):
+        """
+        Waits for the task to be completed. This is a blocking action
+
+        Keyword Arguments:
+
+        - timeout(`int`): if specified timeout after n seconds
+        """
+        t = time.time()
+
+        while True:
+            if self.status == "completed":
+                return
+            time.sleep(0.1)
+            self.refresh_from_db()
+
+            if timeout and (time.time() - t).total_seconds > timeout:
+                raise OSError("Task wait() timeout")
+
+    async def async_wait(self, timeout=None):
+        """
+        Waits for a task to be completed with asyncio.
+        This is a blocking action
+
+        Keyword Arguments:
+
+        - timeout(`int`): if specified timeout after n seconds
+        """
+
+        t = time.time()
+
+        while True:
+            if self.status == "completed":
+                return
+            await asyncio.sleep(0.1)
+            sync_to_async(self.refresh_from_db)()
+            if timeout and (time.time() - t).total_seconds > timeout:
+                raise OSError("Task wait() timeout")
+
+    def task_meta_property(self, name, default=None):
+        """
+        Returns a TaskMeta property value
+
+        Arguments:
+
+        - name(`str`) property name
+
+        Keyword Arguments:
+
+        - default(`mixed`): default value to return if property
+        is not specified
+        """
+        task_meta = self.task_meta
+        if not task_meta:
+            return default
+
+        return getattr(task_meta, name, default)
+
+    def validate_limits(self):
+        """
+        Checks that creating a new instance of this task
+        doesnt violate any limit we have specified
+        """
+
+        # no limit specified
+        if self.limit is None:
+            return
+
+        op = self.HandleRef.tag
+        limit_id = self.generate_limit_id
+
+        count = self._meta.model.objects.filter(
+            op=op, limit_id=limit_id, status__in=["pending", "running"]
+        ).count()
+
+        # if the count of currently pending / running instances of this
+        # task is higher than the limit we specified we raise a
+        # TaskLimitError
+        if self.limit <= count:
+            raise TaskLimitError()
+
+    def clean(self):
+        super().clean()
+        try:
+            if self.param_json:
+                json.loads(self.param_json)
+        except Exception as exc:
+            raise ValidationError(f"Parameters could not be JSON encoded: {exc}")
+
+        # this needs to be the last validation
+        self.validate_limits()
+
+    def cancel(self, reason):
+        self._cancel(reason)
+
+    def _cancel(self, reason):
+        self.output = reason
+        self.status = "cancelled"
+        self.save()
+
+    def _complete(self, output):
+        self.output = output
+        self.status = "completed"
+        self.save()
+
+    def _fail(self, error):
+        self.error = error
+        self.status = "failed"
+        self.save()
+
+    def _run(self):
+
+        if self.status != "pending":
+            raise TaskAlreadyStarted()
+
+        if self.parent and self.parent.status != "completed":
+            raise ParentTaskNotFinished()
+
+        self.status = "running"
+        self.save()
+        t_start = time.time()
+        try:
+            param = self.param
+            output = self.run(*param["args"], **param["kwargs"])
+            t_end = time.time()
+            self.time = t_end - t_start
+            self._complete(output)
+        except Exception:
+            self._fail(traceback.format_exc())
+
+    def run(self, *args, **kwargs):
+        """extend in proxy model"""
+        raise NotImplementedError()
+
+    def run_command(self, command):
+        # TODO this needs to capture output
+        subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+
+
+class TaskClaim(HandleRefModel):
+
+    """
+    Used by a worker to claim a task
+
+    Whenever a worker claims a task the worker first
+    needs to create a taskclaim object for it.
+
+    This object has a unique constraint on the task,
+    preventing race conditions when several workers
+    are polling and claiming tasks asynchronously.
+    """
+
+    task = models.OneToOneField(Task, on_delete=models.CASCADE)
+    worker_id = models.CharField(max_length=255)
+
+    class Meta:
+        db_table = "fullctl_task_claim"
+        verbose_name = _("Claimed Task")
+        verbose_name_plural = _("Claimed Tasks")
+
+    class HandleRef:
+        tag = "task_claim"
+
+
+@fullctl.django.tasks.register
+class CallCommand(Task):
+
+    """
+    Django management tasks
+    """
+
+    class Meta:
+        proxy = True
+
+    class HandleRef:
+        tag = "task_callcommand"
+
+    def run(self, *args, **kwargs):
+        """
+        Executes a django management command
+        """
+
+        out = StringIO()
+        call_command(commit=True, *args, **kwargs, stdout=out)
+        return f"{out.getvalue()}"
diff --git a/src/fullctl/django/rest/api_schema.py b/src/fullctl/django/rest/api_schema.py
index 36906647..de7737a3 100644
--- a/src/fullctl/django/rest/api_schema.py
+++ b/src/fullctl/django/rest/api_schema.py
@@ -115,7 +115,7 @@ def request_body_schema(self, op_dict, content="application/json"):
 
 class PeeringDBImportSchema(AutoSchema):
     def __init__(self, *args, **kwargs):
-        super(AutoSchema, self).__init__(*args, **kwargs)
+        super().__init__(*args, **kwargs)
 
     def get_operation(self, path, method):
         operation = super().get_operation(path, method)
diff --git a/src/fullctl/django/rest/decorators.py b/src/fullctl/django/rest/decorators.py
index 13365380..7a947299 100644
--- a/src/fullctl/django/rest/decorators.py
+++ b/src/fullctl/django/rest/decorators.py
@@ -104,7 +104,8 @@ def wrapped(self, request, *args, **kwargs):
             if decorator.require_auth and not request.user.is_authenticated:
                 return Response(status=401)
 
-            decorator.load_org_instance(request, kwargs)
+            if decorator.instance_class:
+                decorator.load_org_instance(request, kwargs)
 
             with reversion.create_revision():
                 if isinstance(request.user, get_user_model()):
diff --git a/src/fullctl/django/rest/filters.py b/src/fullctl/django/rest/filters.py
index 7b61edf8..6481a63c 100644
--- a/src/fullctl/django/rest/filters.py
+++ b/src/fullctl/django/rest/filters.py
@@ -7,7 +7,6 @@ class CaseInsensitiveOrderingFilter(OrderingFilter):
     def __init__(self, ordering_fields):
         self.ordering_fields = ordering_fields
 
-
     def _handle_field(self, name, queryset):
         # Since we got name from self.get_ordering() we are
         # assuming the field name is valid
@@ -22,9 +21,6 @@ def _handle_field(self, name, queryset):
                 return Lower(field.name)
         return name
 
-
-
-
     def filter_queryset(self, request, queryset, view):
         _ordering = self.get_ordering(request, queryset, view)
 
diff --git a/src/fullctl/django/rest/renderers.py b/src/fullctl/django/rest/renderers.py
index 87f82394..5a60d849 100644
--- a/src/fullctl/django/rest/renderers.py
+++ b/src/fullctl/django/rest/renderers.py
@@ -43,8 +43,7 @@ def render(self, data, media_type=None, renderer_context=None):
 
         # FIXME: should be a config value to disable/enable profile
         # info in the response data
-        if True:
-            container["profiling"] = {"queries": len(connection.queries)}
+        container["profiling"] = {"queries": len(connection.queries)}
 
         if status >= 400:
             container["errors"] = data
diff --git a/src/fullctl/django/rest/route/aaactl_sync.py b/src/fullctl/django/rest/route/aaactl_sync.py
new file mode 100644
index 00000000..89f9f752
--- /dev/null
+++ b/src/fullctl/django/rest/route/aaactl_sync.py
@@ -0,0 +1,15 @@
+from rest_framework import routers
+
+router = routers.DefaultRouter()
+
+
+def route(viewset):
+
+    if hasattr(viewset, "ref_tag"):
+        ref_tag = viewset.ref_tag
+    else:
+        ref_tag = viewset.serializer_class.ref_tag
+
+    prefix = f"aaactl-sync/{ref_tag}"
+    router.register(prefix, viewset, basename=f"aaactl-sync-{ref_tag}")
+    return viewset
diff --git a/src/fullctl/django/rest/route/account.py b/src/fullctl/django/rest/route/account.py
index 669a42fd..7143b449 100644
--- a/src/fullctl/django/rest/route/account.py
+++ b/src/fullctl/django/rest/route/account.py
@@ -10,3 +10,4 @@ def route(viewset):
         ref_tag = viewset.serializer_class.ref_tag
 
     router.register(ref_tag, viewset, basename=ref_tag)
+    return viewset
diff --git a/src/fullctl/django/rest/serializers/aaactl_sync.py b/src/fullctl/django/rest/serializers/aaactl_sync.py
new file mode 100644
index 00000000..1d23725f
--- /dev/null
+++ b/src/fullctl/django/rest/serializers/aaactl_sync.py
@@ -0,0 +1,73 @@
+from django.contrib.auth import get_user_model
+from rest_framework import serializers
+
+import fullctl.django.models as models
+from fullctl.django.rest.decorators import serializer_registry
+from fullctl.django.rest.serializers import ModelSerializer
+
+Serializers, register = serializer_registry()
+
+
+@register
+class User(ModelSerializer):
+
+    ref_tag = "user"
+
+    class Meta:
+        model = get_user_model()
+        fields = [
+            "username",
+            "first_name",
+            "last_name",
+            "email",
+            "is_superuser",
+            "is_staff",
+        ]
+
+
+@register
+class Organization(ModelSerializer):
+    class Meta:
+        model = models.Organization
+        fields = ["name", "slug", "personal", "backend", "remote_id"]
+
+
+@register
+class OrganizationUser(serializers.Serializer):
+
+    ref_tag = "orguser"
+    user = serializers.IntegerField()
+    orgs = serializers.ListField(child=serializers.IntegerField(), allow_empty=True)
+
+    class Meta:
+        fields = ["user", "orgs"]
+
+    def save(self):
+        user = get_user_model().objects.get(id=self.validated_data["user"])
+        for remote_org_id in self.validated_data["orgs"]:
+            try:
+                org = models.Organization.objects.get(remote_id=remote_org_id)
+            except models.Organization.DoesNotExist:
+                continue
+
+            if not user.org_set.filter(org=org).exists():
+                models.OrganizationUser.objects.create(org=org, user=user)
+
+        for orguser in user.org_set.all():
+            if orguser.org.remote_id not in self.validated_data["orgs"]:
+                orguser.delete()
+
+
+@register
+class ExpireSession(serializers.Serializer):
+
+    """
+    not implemented yet
+    """
+
+    username = serializers.CharField()
+
+    ref_tag = "expire_session"
+
+    class Meta:
+        fields = ["username"]
diff --git a/src/fullctl/django/rest/urls/aaactl_sync.py b/src/fullctl/django/rest/urls/aaactl_sync.py
new file mode 100644
index 00000000..8c91ab09
--- /dev/null
+++ b/src/fullctl/django/rest/urls/aaactl_sync.py
@@ -0,0 +1,8 @@
+from django.urls import include, path
+
+import fullctl.django.rest.route.aaactl_sync
+import fullctl.django.rest.views.aaactl_sync
+
+urlpatterns = [
+    path("", include(fullctl.django.rest.route.aaactl_sync.router.urls)),
+]
diff --git a/src/fullctl/django/rest/views/aaactl_sync.py b/src/fullctl/django/rest/views/aaactl_sync.py
new file mode 100644
index 00000000..4e45d456
--- /dev/null
+++ b/src/fullctl/django/rest/views/aaactl_sync.py
@@ -0,0 +1,106 @@
+"""
+API views to facilitate data synchronization from aaactl to the fullctl
+service.
+
+These views require permissions to the `aaactl_sync` namespace which usually
+only exists on the internal api key used for service bridging
+"""
+
+from django.contrib.auth import get_user_model
+from rest_framework import viewsets
+from rest_framework.response import Response
+
+import fullctl.django.models as models
+from fullctl.django.rest.core import BadRequest
+from fullctl.django.rest.decorators import grainy_endpoint
+from fullctl.django.rest.mixins import CachedObjectMixin
+from fullctl.django.rest.route.aaactl_sync import route
+from fullctl.django.rest.serializers.aaactl_sync import Serializers
+
+
+@route
+class User(CachedObjectMixin, viewsets.GenericViewSet):
+
+    """
+    Sync user information from aaactl using the service bridge
+
+    - username
+    - email
+    - first name
+    - last name
+    """
+
+    serializer_class = Serializers.user
+    queryset = get_user_model().objects.all()
+
+    # this approach assumes that aaactl is the only authentication
+    # provider
+    lookup_field = "social_auth__uid"
+    lookup_url_kwarg = "remote_id"
+
+    @grainy_endpoint("aaactl_sync.user")
+    def update(self, request, remote_id, *args, **kwargs):
+        user = self.get_object()
+        serializer = self.serializer_class(instance=user, data=request.data)
+        if not serializer.is_valid():
+            return BadRequest(serializer.errors)
+        serializer.save()
+        return Response(serializer.data)
+
+
+@route
+class Organization(CachedObjectMixin, viewsets.GenericViewSet):
+
+    """
+    Sync organization information from aaactl using the service
+    brdige
+
+    - name
+    - url slug
+    """
+
+    serializer_class = Serializers.org
+    queryset = models.Organization.objects.all()
+    lookup_field = "remote_id"
+    lookup_url_kwarg = "remote_id"
+
+    @grainy_endpoint("aaactl_sync.org")
+    def create(self, request, *args, **kwargs):
+        try:
+            instance = models.Organization.objects.get(
+                remote_id=request.data["remote_id"]
+            )
+        except models.Organization.DoesNotExist:
+            instance = None
+        serializer = self.serializer_class(instance=instance, data=request.data)
+
+        if not serializer.is_valid():
+            return BadRequest(serializer.errors)
+        serializer.save()
+        return Response(serializer.data)
+
+
+@route
+class OrganizationUser(CachedObjectMixin, viewsets.GenericViewSet):
+
+    """
+    Sync organization<->user relationships from aaactl using the service
+    bridge
+    """
+
+    serializer_class = Serializers.orguser
+    queryset = get_user_model().objects.all()
+
+    # this approach assumes that aaactl is the only authentication
+    # provider
+    lookup_field = "social_auth__uid"
+    lookup_url_kwarg = "remote_id"
+
+    @grainy_endpoint("aaactl_sync.orguser")
+    def update(self, request, remote_id, *args, **kwargs):
+        self.get_object()
+        serializer = self.serializer_class(data=request.data)
+        if not serializer.is_valid():
+            return BadRequest(serializer.errors)
+        serializer.save()
+        return Response(serializer.data)
diff --git a/src/fullctl/django/social/backends/twentyc.py b/src/fullctl/django/social/backends/twentyc.py
index 262d7931..04a2ce99 100644
--- a/src/fullctl/django/social/backends/twentyc.py
+++ b/src/fullctl/django/social/backends/twentyc.py
@@ -26,6 +26,8 @@ def get_user_details(self, response):
             "email": response.get("email") or "",
             "first_name": response.get("given_name"),
             "last_name": response.get("family_name"),
+            "is_superuser": response.get("is_superuser"),
+            "is_staff": response.get("is_staff"),
         }
 
     def user_data(self, access_token, *args, **kwargs):
diff --git a/src/fullctl/django/static/common/20c/twentyc.rest.js b/src/fullctl/django/static/common/20c/twentyc.rest.js
index 9cd0d085..c4a8984c 100644
--- a/src/fullctl/django/static/common/20c/twentyc.rest.js
+++ b/src/fullctl/django/static/common/20c/twentyc.rest.js
@@ -62,13 +62,26 @@ twentyc.rest.Response = twentyc.cls.define(
   {
 
     Response : function(response, status) {
+
       if(!response || (!response.data && !response.errors) ) {
         this.status = status;
         return;
       }
+      this.status = status;
       this.content = response;
     },
 
+    /**
+     * invokes a callback on each row in the api response
+     * data resultset
+     *
+     * callback will be passed the data row as well as the index
+     * of the row entry
+     *
+     * @method rows
+     * @param {Function} callback
+     */
+
     rows : function(callback) {
       var i;
       for(i = 0; i < this.content.data.length; i++) {
@@ -76,6 +89,14 @@ twentyc.rest.Response = twentyc.cls.define(
       }
     },
 
+    /**
+     * returns the first row in the api response
+     * data resultset
+     *
+     * @method first
+     * @returns {Object}
+     */
+
     first : function() {
       if(this.content && this.content.data) {
         return this.content.data[0];
@@ -83,6 +104,17 @@ twentyc.rest.Response = twentyc.cls.define(
       return null;
     },
 
+    /**
+     * process field errors returned by the api
+     * in a callback
+     *
+     * callback wil be invoked once for each field and
+     * be passed an array of strings
+     *
+     * @method field_errors
+     * @param {Function} callback
+     */
+
     field_errors : function(callback) {
       if(!this.has_errors())
         return
@@ -96,6 +128,14 @@ twentyc.rest.Response = twentyc.cls.define(
       }
     },
 
+    /**
+     * process non-field errors returned by the api
+     * in a callback
+     *
+     * @method non_field_errors
+     * @param {Function} callback
+     */
+
     non_field_errors : function(callback) {
       if(!this.has_errors())
         return
@@ -106,21 +146,40 @@ twentyc.rest.Response = twentyc.cls.define(
         nfe = [nfe];
 
       if(nfe) {
-        var i;
         callback(nfe)
       }
 
     },
 
+    /**
+     * returns a user friendly error message for the
+     * http status of the response
+     *
+     * @method http_status_text
+     * @returns {String}
+     */
+
     http_status_text : function() {
       if(this.status == 401) return "Unauthorized Access";
       if(this.status == 403) return "Permission Denied";
       if(this.status == 404) return "Resource not found";
       if(this.status == 405) return "Method not allowed";
+      if(this.status == 429){
+        if (this.content.errors.detail) return this.content.errors.detail;
+        return "Request is rate limited";
+      }
       if(this.status == 500) return "Internal Error";
       return "Http Error "+this.status;
     },
 
+    /**
+     * returns whether the response has error information
+     * or not
+     *
+     * @method has_errors
+     * @returns {Boolean}
+     */
+
     has_errors : function() {
       if(this.status > 400) {
         if(!this.content)
@@ -135,6 +194,15 @@ twentyc.rest.Response = twentyc.cls.define(
   }
 );
 
+/**
+ * Base API functionality / connection handler
+ *
+ * @class Client
+ * @constructor
+ * @namespace twentyc.rest
+ * @param {String} base_url path to the api root
+ */
+
 twentyc.rest.Client = twentyc.cls.define(
   "Client",
   {
@@ -142,16 +210,40 @@ twentyc.rest.Client = twentyc.cls.define(
       this.base_url = base_url.replace(/\/$/g,'');
     },
 
+    /**
+     * returns path to endpoint by appending
+     * provided value to the api root base url
+     * @method endpoint_url
+     * @param {String} endpoint
+     */
+
     endpoint_url : function(endpoint) {
       if(!endpoint)
         return this.base_url+"/";
       return this.base_url+"/"+endpoint + "/";
     },
 
+    /**
+     * Encodes and object literal to json
+     * @method encode
+     * @param {Object} data
+     * @returns {String}
+     */
+
     encode : function(data) {
       return JSON.stringify(data);
     },
 
+    /**
+     * Perform a read request (GET, HEAD, OPTIONS) on the api
+     *
+     * @method read
+     * @param {String} endpoint api endpoint
+     * @param {Object} data url parameters to pass
+     * @param {String} method http request method (GET, HEAD or OPTIONS)
+     * @returns {Promise}
+     */
+
     read : function(endpoint, data, method) {
       method = method.toLowerCase()
       $(this).trigger("api-request:before", [endpoint,data,method])
@@ -184,6 +276,16 @@ twentyc.rest.Client = twentyc.cls.define(
       return request;
     },
 
+    /**
+     * Perform a write request (POST, PUT, PATCH, DELETE) on the api
+     *
+     * @method write
+     * @param {String} endpoint api endpoint
+     * @param {Object} data payload
+     * @param {String} method http request method
+     * @returns {Promise}
+     */
+
     write : function(endpoint, data, method) {
       method = method.toLowerCase();
       $(this).trigger("api-request:before", [endpoint,data,method])
@@ -221,34 +323,108 @@ twentyc.rest.Client = twentyc.cls.define(
       return request;
     },
 
+    /**
+     * Wrapper to perform a `GET` request on the api
+     *
+     * @method get
+     * @param {String} endpoint
+     * @param {Object} params url parameters
+     * @returns {Promise}
+     */
+
     get : function(endpoint, params) {
       return this.read(endpoint, params, "get");
     },
 
+    /**
+     * Wrapper to perform a `OPTIONS` request on the api
+     *
+     * @method options
+     * @param {String} endpoint
+     * @param {Object} params url parameters
+     * @returns {Promise}
+     */
+
+    options : function(endpoint, params) {
+      return this.read(endpoint, params, "options");
+    },
+
+    /**
+     * Wrapper to perform a `POST` request on the api
+     *
+     * @method post
+     * @param {String} endpoint
+     * @param {object} data payload
+     * @returns {Promise}
+     */
+
     post : function(endpoint, data) {
       return this.write(endpoint, data, "post");
     },
 
+    /**
+     * Wrapper to perform a `PUT` request on the api
+     *
+     * @method post
+     * @param {String} endpoint
+     * @param {object} data payload
+     * @returns {Promise}
+     */
+
     put : function(endpoint, data) {
       return this.write(endpoint, data, "put");
     },
 
+    /**
+     * Wrapper to perform a `DELETE` request on the api
+     *
+     * @method post
+     * @param {String} endpoint
+     * @param {object} data payload
+     * @returns {Promise}
+     */
+
+
     delete : function(endpoint, data) {
       return this.write(endpoint, data, "delete");
     }
   }
 );
 
+/**
+ * Base class for API widgets
+ *
+ * @class Widget
+ * @extends twentyc.rest.Client
+ * @namespace twentyc.rest
+ * @constructor
+ * @param {String} base_url api root
+ * @param {jQuery result} jq jquery result holding the main element of the widget
+ */
+
 twentyc.rest.Widget = twentyc.cls.extend(
   "Widget",
   {
     Widget : function(base_url, jq) {
      this.action = jq.data("api-action")
+
+     /**
+      * allows you to define local actions (experimental)
+      * @property {Object} local_actions
+      */
      this.local_actions = {}
      this.Client(base_url);
      this.bind(jq);
     },
 
+    /**
+     * Sets the widget state to processing
+     *
+     * This will trigger the `processing` event
+     *
+     * @method start_processing
+     */
+
     start_processing : function() {
       this.busy = true
       if(!this.loading_shim) {
@@ -259,6 +435,15 @@ twentyc.rest.Widget = twentyc.cls.extend(
       $(this).trigger("processing");
     },
 
+
+    /**
+     * Sets the widget state to ready or done with processing
+     *
+     * This will trigger the `ready` event
+     *
+     * @method done_processing
+     */
+
     done_processing : function() {
       this.busy = false
       if(this.loading_shim && !window.debug_loading_shim)
@@ -266,12 +451,45 @@ twentyc.rest.Widget = twentyc.cls.extend(
       $(this).trigger("ready");
     },
 
+    /**
+     * Clones a template element by name and returns it
+     *
+     * Templates for a widget should be stored within an element inside
+     * the widget that has it's `.templates` css class set
+     *
+     * A template is designated a template if it has the
+     * `data-template` attribute set to hold the template name
+     *
+     * ```html
+     * <div class="widget">
+     *  <div class="templates">
+     *    <div data-template="my_template"></div>
+     *  </div>
+     * </div>
+     * ```
+     *
+     * ```javascript
+     * var tmpl = widget.template('my_template')
+     * ```
+     *
+     * @method template
+     * @param {String} name template name (as specified in data-template)
+     * @returns {jQuery result} jquery result holding the cloned template node
+     */
+
     template : function(name) {
       var tmpl = this.element.closest_descendant('.templates').find('[data-template="'+name+'"]')
       var clone = tmpl.clone().attr('data-template', null);
       return clone;
     },
 
+    /**
+     * Binds the widget to a html element
+     *
+     * @method bind
+     * @param {jQuery result} jq jquery result holding the html element
+     */
+
     bind : function(jq) {
       jq.data("rest_widget", this);
       this.element = jq;
@@ -294,21 +512,45 @@ twentyc.rest.Widget = twentyc.cls.extend(
       $(this).on("api-read:after", function() {
         this.done_processing();
       }.bind(this));
-
-
     },
 
+    /**
+     * Defines a local action that some widget's
+     * may invoke
+     *
+     * Local actions can be set on the widget element or sub elements
+     * via the `data-action` attribute
+     *
+     * @method local_action
+     * @param {String} name action name
+     * @param {Function} fn
+     */
+
     local_action : function(name, fn) {
       this.local_actions[name] = fn;
     },
 
+    /**
+     * Clears all validation / input errors for the widget
+     *
+     * @method clear_errors
+     */
+
     clear_errors : function() {
       this.element.find('.validation-error').detach();
       this.element.find('.validation-error-indicator').removeClass("validation-error-indicator");
     },
 
+    /**
+     * Renders field errors (think input vaidation errors)
+     *
+     * @method render_error
+     * @param {String} key field name
+     * @param {Array} errors list of error strings
+     */
+
     render_error : function(key, errors) {
-      var i, e;
+      var i;
       var error_node = $('<div>').addClass("validation-error");
       var input = this.element.find('[name="'+key+'"]');
       input.addClass("validation-error-indicator")
@@ -319,8 +561,16 @@ twentyc.rest.Widget = twentyc.cls.extend(
         error_node.insertAfter(input);
     },
 
+    /**
+     * Renders non field errors (think server errors, generic errors)
+     *
+     * @method render_non_field_errors
+     * @param {Array} errors list of error strings
+     */
+
     render_non_field_errors : function(errors) {
       var error_node = $('<div>').addClass("alert alert-danger validation-error non-field-errors");
+      let i;
       for(i = 0; i < errors.length; i++) {
         $(twentyc.rest).trigger("non-field-error", [errors[i], errors, i, error_node, this]);
         if(errors[i])
@@ -329,6 +579,15 @@ twentyc.rest.Widget = twentyc.cls.extend(
       this.element.prepend(error_node)
     },
 
+    /**
+     * Prepares the payload for the widget's write request
+     * by checking for form elements and converting their values
+     * to an object literal compatible to be sent as a payload
+     *
+     * @param payload
+     * @returns {Object}
+     */
+
     payload : function() {
       var data = {};
       this.element.find('[data-api-submit="yes"]').each(function() {
@@ -353,6 +612,29 @@ twentyc.rest.Widget = twentyc.cls.extend(
   twentyc.rest.Client
 );
 
+/**
+ * Form widget that binds a HTML form to the REST api
+ *
+ * The form element should have the following attributes set
+ *
+ * # required
+ *
+ * - data-api-base: api root or full path to endpoint
+ *
+ * # optional
+ *
+ * - data-api-action: if specified will be appended as endpoint to data-api-base
+ * - data-api-method: request method for writes, defaults to POST
+ *
+ * If the form element contains a button element with the
+ * `submit` css class set it will be wired to submit the form
+ * on click
+ *
+ * @class Form
+ * @extends twentyc.rest.Widget
+ * @namespace twentyc.rest
+ * @param {jQuery result} jq form element
+ */
 
 twentyc.rest.Form = twentyc.cls.extend(
   "Form",
@@ -363,6 +645,17 @@ twentyc.rest.Form = twentyc.cls.extend(
       this.Widget(base_url, jq);
     },
 
+    /**
+     * Fill the form fields from values provided in
+     * an object literal thats keyed by field name
+     *
+     * Names in `data` will be matched against the `name` attribute
+     * of the form inputs
+     *
+     * @method fill
+     * @param {Object} data
+     */
+
     fill : function(data) {
       var key, value;
       for(key in data) {
@@ -386,6 +679,13 @@ twentyc.rest.Form = twentyc.cls.extend(
       response.non_field_errors(this.render_non_field_errors.bind(this))
     },
 
+    /**
+     * Submit the form using the specified method
+     *
+     * @method submit
+     * @param {String} method http request method
+     */
+
     submit : function(method) {
       this.clear_errors();
 
@@ -399,6 +699,15 @@ twentyc.rest.Form = twentyc.cls.extend(
       );
     },
 
+    /**
+     * Binds the form widget to a html element
+     *
+     * This is called automatically in the constructor
+     *
+     * @method bind
+     * @param {jQuery result} jq
+     */
+
     bind : function(jq) {
       this.Widget_bind(jq);
       this.method = jq.data("api-method") || "POST";
@@ -420,6 +729,13 @@ twentyc.rest.Form = twentyc.cls.extend(
       });
     },
 
+    /**
+     * Wires a button to submit the form
+     *
+     * @method wire_submit
+     * @param {jQuery result} jq_button jquery result holding button element
+     */
+
     wire_submit : function(jq_button) {
       var widget = this;
       jq_button.off("click").click(function() {
@@ -430,6 +746,36 @@ twentyc.rest.Form = twentyc.cls.extend(
   twentyc.rest.Widget
 );
 
+/**
+ * Wires a `select` element to the API
+ *
+ * The select element should have the following attributes set
+ *
+ * # required
+ *
+ * - data-api-base: api root or full path to endpoint
+ *
+ * # optional
+ *
+ * - data-api-load: endpoint that should be requested to load options
+ * - data-name-field: which data resultset field to use for option text,
+ *   defaults to "name"
+ * - data-id-field: which data resultset field to use for option value,
+ *   defaultd to "id"
+ * - data-selected-field: which data resultset field to check whether and option
+ *   should be auto-selected, defaults to "selected"
+ * - data-load-type: what load method to use, can be "get" or "drf_choices",
+ *   with the latter being a way to load in django-rest-framework field values
+ *   choices. Defaults to "get"
+ * - data-drf-name: relevant if load type is "drf-choices". Specifies the
+ *   serializer field name, will default to "name" attribute if not specified.
+ *
+ * @class Select
+ * @extends twentyc.rest.widget
+ * @namespace twentyc.rest
+ * @param {jQuery result} jq jquery result holding the select element
+ */
+
 twentyc.rest.Select = twentyc.cls.extend(
   "Select",
   {
@@ -439,6 +785,8 @@ twentyc.rest.Select = twentyc.cls.extend(
       this.name_field = jq.data("name-field") || "name"
       this.id_field = jq.data("id-field") || "id"
       this.selected_field = jq.data("selected-field") || "selected"
+      this.load_type = jq.data("load-type") || "get"
+      this.drf_name = jq.data("drf-name") || jq.attr("name");
       this.Widget(base_url, jq);
     },
 
@@ -456,8 +804,54 @@ twentyc.rest.Select = twentyc.cls.extend(
     },
 
 
+    /**
+     * loads options from the api
+     *
+     * this will call _load_get or _load_drf_choices depending
+     * on which load-type is specified (see data-load-type attribute)
+     *
+     * triggers load:after event
+     *
+     * @method load
+     * @param {Mixed} select_this if specified will select this value after
+     *   load
+     */
+
     load : function(select_this) {
 
+      if(this.load_type == "drf-choices")
+        return this._load_drf_choices(select_this);
+
+      return this._load_get(select_this);
+
+    },
+
+    /**
+     * loads data in via a GET request
+     *
+     * expects data to come back as a list of object literals
+     * containing keys for `this.name_field` and `this.id_field`
+     *
+     * this method is called automatically by `this.load` if the
+     * the load-type of the select widget is set to "get"
+     *
+     * Example assuming this.name_field == 'name' and this.id_field = 'id'
+     *
+     * {
+     *   "data": [
+     *     { "name": "Choice 1", "id": 1 },
+     *     { "name": "Choice 2", "id": 2 }
+     *   ]
+     * }
+     *
+     * @method _load_get
+     * @private
+     * @param {Mixed} select_this if specified will select this value after
+     *   load
+     */
+
+    _load_get : function(select_this) {
+
       return this.get().then(function(response) {
         var select = this.element;
         var name_field = this.name_field
@@ -472,10 +866,52 @@ twentyc.rest.Select = twentyc.cls.extend(
           select.append(opt);
         });
 
+        if(select_this)
+          select.val(select_this);
+
         $(this).trigger("load:after", [select, response.content.data]);
       }.bind(this));
     },
 
+    /**
+     * loads data in via a OPTIONS request to a django-rest-framework
+     * api endpoint
+     *
+     * this method is called automatically by `this.load` if the
+     * the load-type of the select widget is set to "drf_choices"
+     *
+     * @method _load_drf_choices
+     * @private
+     * @param {Mixed} select_this if specified will select this value after
+     *   load
+     */
+
+    _load_drf_choices : function(select_this) {
+      return this.options().then(function(response) {
+        var select = this.element.empty();
+        var options = response.content.data[0].actions.POST[this.drf_name].choices;
+        $(options).each(function() {
+          select.append(
+            $('<option>').val(this.value).text(this.display_name)
+          )
+        });
+
+        if(select_this)
+          select.val(select_this);
+
+      }.bind(this));
+    },
+
+    /**
+     * refreshes options from the api
+     *
+     * this is the same as load, but will maintain the current
+     * selection choice as long as it still exists in the the
+     * fresh dataset
+     *
+     * @method refresh
+     */
+
     refresh : function() {
       var val = this.element.val()
       return this.load().then(
@@ -508,6 +944,73 @@ twentyc.rest.Select = twentyc.cls.extend(
   twentyc.rest.Widget
 );
 
+/**
+ * Data listing widget
+ *
+ * A way to visualize an api data response in a table
+ *
+ * # element attributes
+ *
+ * This widget element should have these html attributes set
+ *
+ * ## required
+ *
+ * - data-api-base: api root or full path to endpoint
+ *
+ * ## optional
+ *
+ * - data-api-action: if specified will be appended as endpoint to data-api-base
+ *
+ * # DOM structure
+ *
+ * The widget element should contain the following child elements
+ *
+ * - element with `list-body` css class, which will serve as a
+ *   container for rows, if your widget element is a table this
+ *   would be a tbody element.
+ * - element with data-template="row" attr set, which will be used
+ *   to clone for individual rows
+ *
+ *   within the row element, child elements will be scanned for
+ *   data-field attributes to match against the field names in the api
+ *   result set.
+ *
+ * # Example
+ *
+ * ```json
+ * {
+ *   "data": [
+ *      { "id": 1, "name": "first row" },
+ *      {" id": 2, "name": "second row" }
+ *   ]
+ * }
+ * ```
+ *
+ * ```html
+ * <table data-api-base="/api/my_list" id="my_list">
+ *  <tbody class="list-body"></tbody>
+ *  <tbody class="templates">
+ *    <tr data-template="row">
+ *      <td data-field="id"></td>
+ *      <td data-field="name"></td>
+ *    </tr>
+ *  </tbody>
+ * </table>
+ * ```
+ *
+ * ```javascript
+ * var list = new twentyc.rest.List($('#my_list'))
+ * list.load();
+ * ```
+ *
+ *
+ * @class List
+ * @extends twentyc.rest.Widget
+ * @namespace twentyc.rest
+ * @constructor
+ * @param {jQuery result} jq jquery result holding widget element (table)
+ */
+
 twentyc.rest.List = twentyc.cls.extend(
   "List",
   {
@@ -520,6 +1023,16 @@ twentyc.rest.List = twentyc.cls.extend(
       this.Widget(base_url, jq);
     },
 
+    /**
+     * loads rows into the list
+     *
+     * this empties all current rows before it does so
+     *
+     * triggers load:after event
+     *
+     * @method load
+     */
+
     load : function() {
       return this.get(this.action, this.payload()).then(function(response) {
         this.list_body.empty()
@@ -531,6 +1044,15 @@ twentyc.rest.List = twentyc.cls.extend(
       }.bind(this));
     },
 
+    /**
+     * insert a new row from object
+     *
+     * triggers insert:after event
+     *
+     * @method insert
+     * @param {Object} data
+     */
+
     insert : function(data) {
       var toggle, k, row_element, col_element;
 
@@ -572,11 +1094,26 @@ twentyc.rest.List = twentyc.cls.extend(
       }.bind(this));
     },
 
+    /**
+     * Removes the row for the supplied api response row object
+     *
+     * @method remove
+     * @param {Object} data
+     */
+
     remove : function(data) {
       $(this).trigger("remove:before", [data]);
       this.list_body.find('.row-'+data[this.id_field]).detach();
     },
 
+    /**
+     * return row element for object id
+     *
+     * @method find_row
+     * @param {String} id
+     * @returns {jQuery result}
+     */
+
     find_row : function(id) {
       return this.list_body.find('.row-'+id);
     },
@@ -615,7 +1152,7 @@ twentyc.rest.List = twentyc.cls.extend(
               return apiobj[p1];
             }
           )
-          var request = widget[method](_action, row.data("apiobject")).then(
+          widget[method](_action, row.data("apiobject")).then(
             callback, widget.action_failure.bind(widget)
           );
         });
@@ -631,15 +1168,57 @@ twentyc.rest.List = twentyc.cls.extend(
   twentyc.rest.Widget
 );
 
+/**
+ * Special form widget for handling user permissions
+ *
+ * This expect a form element that contains checkboxes for
+ * permission flags
+ *
+ * Each checkbox element should have these attributes set
+ *
+ * - data-permission-flag unique flag name to describe the permssion level
+ *
+ * # Example
+ *
+ * ```html
+ * <span><input data-permission-flag="c" type="checkbox"> create</span>
+ * <span><input data-permission-flag="r" type="checkbox"> read</span>
+ * <span><input data-permission-flag="u" type="checkbox"> update</span>
+ * <span><input data-permission-flag="d" type="checkbox"> delete</span>
+ * ```
+ * @class PermissionForm
+ * @extends twentyc.rest.Form
+ * @namespace twentyc.rest
+ * @constructor
+ */
 
 twentyc.rest.PermissionsForm = twentyc.cls.extend(
   "PermissionsForm",
   {
+
+    /**
+     * Updates the checkboxes states according to the flags
+     * provided
+     *
+     * For example passing `crud` would check all checkboxes
+     * while passing `r` would only check the checkbox for read access
+     *
+     * @method set_flag_values
+     * @param {String} flags
+     */
+
     set_flag_values : function(flags) {
-      var form = this;
       this.element.find('input[data-permission-flag]').each(function() {
         var flag_name = $(this).data("permission-flag")
-        var value = (flags.indexOf(flag_name) > -1)
+        if(flag_name.length == 1) {
+          var value = (flags.perms.indexOf(flag_name) > -1)
+        } else {
+          var i, value = true;
+          for(i = 0; i < flag_name.length; i++) {
+            if(flags.perms.indexOf(flag_name.charAt(i)) == -1)
+              value = false;
+          }
+        }
         $(this).prop("checked", value)
       });
     },
diff --git a/src/fullctl/django/static/common/app.js b/src/fullctl/django/static/common/app.js
index dfb5d776..49afd75c 100644
--- a/src/fullctl/django/static/common/app.js
+++ b/src/fullctl/django/static/common/app.js
@@ -1,9 +1,10 @@
 (function($, $tc) {
 
 
-fullctl = {
+window.fullctl = {
   urlparam : new URLSearchParams(window.location.search)
 }
+var fullctl = window.fullctl;
 
 fullctl.template = function(name) {
   return $('[data-template="'+name+'"]').clone().attr("data-template",null);
@@ -307,8 +308,6 @@ fullctl.application.Header = $tc.extend(
 
     wire_elements : function() {
 
-      var header = this;
-
       this.widget('select_org', ($e) => {
         var w = new twentyc.rest.List($e.select_org);
         var org = $e.select_org.data('org')
@@ -377,6 +376,7 @@ fullctl.application.Application = $tc.define(
         main : new fullctl.application.Component("main")
       }
       this.tools = this.$t = {}
+      $(fullctl.application).trigger("initialized", [this, id]);
     },
 
     tool : function(name, fn) {
diff --git a/src/fullctl/django/static/common/service_bridge.js b/src/fullctl/django/static/common/service_bridge.js
index 7cf6abbd..e00f614f 100644
--- a/src/fullctl/django/static/common/service_bridge.js
+++ b/src/fullctl/django/static/common/service_bridge.js
@@ -140,7 +140,7 @@ $ctl.service_bridge.aaactl = $tc.extend(
      */
 
     has_service_costs : function(product, callback) {
-      var j, item, org = this.org(), status = {};
+      var j, item, org = this.org();
 
       return this.get(`billing/${org}/services/`).then((response) => {
         response.rows((row, i) => {
diff --git a/src/fullctl/django/static/common/themes/dark.css b/src/fullctl/django/static/common/themes/dark.css
index 73e9f4ff..6662b939 100644
--- a/src/fullctl/django/static/common/themes/dark.css
+++ b/src/fullctl/django/static/common/themes/dark.css
@@ -338,9 +338,15 @@ div.menu div.menu-deco-border {
 }
 
 
+div.dropdown-meny.show,
+span.header-control.show,
+span.dropdown.show {
+  display: inline-block !important;
+}
+
 .dropdown-item,
 .dropdown-menu {
-  background-color: #0F1317;
+  background-color: #0F1317 !important;
 }
 
 .dropdown-item.selected:hover,
diff --git a/src/fullctl/django/tasks/__init__.py b/src/fullctl/django/tasks/__init__.py
new file mode 100644
index 00000000..af7f6f0f
--- /dev/null
+++ b/src/fullctl/django/tasks/__init__.py
@@ -0,0 +1,10 @@
+TASK_MODELS = {}
+
+
+def register(cls):
+    TASK_MODELS[cls.HandleRef.tag] = cls
+    return cls
+
+
+def specify_task(task):
+    return TASK_MODELS[task.op].objects.get(id=task.id)
diff --git a/src/fullctl/django/tasks.py b/src/fullctl/django/tasks/celery.py
similarity index 100%
rename from src/fullctl/django/tasks.py
rename to src/fullctl/django/tasks/celery.py
diff --git a/src/fullctl/django/tasks/orm.py b/src/fullctl/django/tasks/orm.py
new file mode 100644
index 00000000..4d389893
--- /dev/null
+++ b/src/fullctl/django/tasks/orm.py
@@ -0,0 +1,81 @@
+"""
+ORM based task delegation
+"""
+from django.db import IntegrityError
+
+from fullctl.django.models import Task, TaskClaimed, WorkerUnqualified
+from fullctl.django.models.concrete.tasks import TaskClaim
+from fullctl.django.tasks import specify_task
+from fullctl.django.tasks.util import worker_id
+
+
+def fetch_task(**filters):
+    """
+    Checks for the next available and qualifying
+    tasks and returns it
+    """
+
+    tasks = fetch_tasks(limit=1, **filters)
+    if tasks:
+        return tasks[0]
+    return None
+
+
+def fetch_tasks(limit=1, **filters):
+
+    tasks = []
+
+    # filter tasks waiting for execution
+    qset = Task.objects.filter(status="pending", queue_id__isnull=True)
+
+    if filters:
+        qset = qset.filter(**filters)
+
+    # order by date of creation
+    qset = qset.order_by("created")
+
+    # no pending tasks for this task model
+    if not qset.exists():
+        return tasks
+
+    for task in qset:
+        if task.parent_id and task.parent.status != "completed":
+            if task.parent.status in ["failed", "cancelled"]:
+                task.cancel(f"parent status: {task.parent.status}")
+            continue
+        try:
+            task = specify_task(task)
+            task.qualifies
+            tasks.append(task)
+        except WorkerUnqualified:
+            continue
+
+        if len(tasks) == limit:
+            return tasks
+
+    return tasks
+
+
+def claim_task(task):
+
+    """
+    Claims the specified task
+    """
+
+    try:
+        claim = TaskClaim.objects.create(task=task, worker_id=worker_id())
+        task.queue_id = claim.worker_id
+        task.save()
+        return claim
+    except IntegrityError as exc:
+        print(exc)
+        raise TaskClaimed(task)
+
+
+def work_task(task):
+
+    """
+    processes the specified task
+    """
+
+    task._run()
diff --git a/src/fullctl/django/tasks/qualifiers.py b/src/fullctl/django/tasks/qualifiers.py
new file mode 100644
index 00000000..7b7c04c7
--- /dev/null
+++ b/src/fullctl/django/tasks/qualifiers.py
@@ -0,0 +1,72 @@
+"""
+Task qualifiers to facilitate selective task processing
+"""
+
+from django.conf import settings
+
+
+class Base:
+
+    """
+    Base task qualifier interface
+
+    Extend this
+    """
+
+    def check(self, task):
+        raise NotImplementedError()
+
+
+class Setting(Base):
+    """
+    Requires there to be specific settings value set
+    in order for the task to qualify for processing
+    on this instance
+    """
+
+    def __init__(self, setting, value):
+
+        self.setting = setting
+        self.value = value
+
+    def __str__(self):
+        return f"{self.__class__.__name__} {self.setting}"
+
+    def check(self, task):
+
+        try:
+            _value = getattr(settings, self.setting)
+        except AttributeError:
+            return False
+
+        # if a callback was passed to self.value
+        # run the settings value through it and return
+        # the result
+        if callable(self.value):
+            b = self.value(_value)
+            if not isinstance(b, bool):
+                raise TypeError("Base value callbacks should return a boolean")
+            return b
+
+        # otherwise compare values
+        return self.value == _value
+
+
+class SettingUnset(Base):
+
+    """
+    Requires a specific setting to be UNSET
+    """
+
+    def __init__(self, setting):
+        self.setting = setting
+
+    def __str__(self):
+        return f"{self.__class__.__name__} {self.setting}"
+
+    def check(self, task):
+        try:
+            getattr(settings, self.setting)
+        except AttributeError:
+            return True
+        return False
diff --git a/src/fullctl/django/tasks/util.py b/src/fullctl/django/tasks/util.py
new file mode 100644
index 00000000..bccdb9ef
--- /dev/null
+++ b/src/fullctl/django/tasks/util.py
@@ -0,0 +1,20 @@
+import os
+import socket
+
+from django.conf import settings
+
+
+def worker_id():
+
+    """
+    Returns the worker id for this instance
+
+    This can be specified manually through the
+    `TASK_ORM_WORKER_ID` setting
+
+    If unspecified will default to hostname:pid
+    """
+
+    return getattr(
+        settings, "TASK_ORM_WORKER_ID", f"{socket.gethostname()}:{os.getpid()}"
+    )
diff --git a/src/fullctl/django/templates/common/base.html b/src/fullctl/django/templates/common/base.html
index 22af835f..d9370f38 100644
--- a/src/fullctl/django/templates/common/base.html
+++ b/src/fullctl/django/templates/common/base.html
@@ -8,10 +8,7 @@
     <link rel="shortcut icon" type="image/ico" href="{% static "website/favicon.ico" %}">
 
     {% block "js" %}
-    <script
-      src="https://code.jquery.com/jquery-3.3.1.min.js"
-      integrity="sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8="
-      crossorigin="anonymous"></script>
+    <script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
     <script src="https://cdnjs.cloudflare.com/ajax/libs/tether/1.4.0/js/tether.min.js" integrity="sha384-DztdAPBWPRXSA/3eYEEUWrWCy7G5KFbe8fFjk5JAIxUYHKkDx6Qin1DkWx51bBrb" crossorigin="anonymous"></script>
     <script src="https://unpkg.com/popper.js/dist/umd/popper.min.js" crossorigin="anonymous"></script>
     <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.min.js" integrity="sha384-ChfqqxuZUCnJSK3+MXmPNIyE6ZbWh2IMqE241rYiqJxyMiZ6OW/JmZQ5stwEULTy" crossorigin="anonymous"></script>
diff --git a/src/fullctl/django/urls.py b/src/fullctl/django/urls.py
index a3fd32b4..7fdfd767 100644
--- a/src/fullctl/django/urls.py
+++ b/src/fullctl/django/urls.py
@@ -46,6 +46,13 @@
             namespace="fullctl_usage_api",
         ),
     ),
+    path(
+        "api/",
+        include(
+            ("fullctl.django.rest.urls.aaactl_sync", "fullctl_aaactl_sync_api"),
+            namespace="fullctl_aaactl_sync_api",
+        ),
+    ),
     path(
         "login/",
         auth_views.LoginView.as_view(template_name="common/auth/login.html"),
diff --git a/src/fullctl/django/util.py b/src/fullctl/django/util.py
index 0e11d46e..4cd128f5 100644
--- a/src/fullctl/django/util.py
+++ b/src/fullctl/django/util.py
@@ -1,11 +1,28 @@
 from django.conf import settings
 
+from fullctl.django.context import current_request
+
 if "django_peeringdb" in settings.INSTALLED_APPS:
     import django_peeringdb.models.concrete as pdb_models
 else:
     pdb_models = None
 
 
+def host_url():
+
+    """
+    Will see if a current request context exist and if it
+    does return host url as it exists on the requests HTTP_HOST attribute.
+
+    Otherwise settings.HOST_URL is returned
+    """
+
+    with current_request() as request:
+        if request:
+            return request.META["HTTP_HOST"]
+        return settings.HOST_URL
+
+
 def verified_asns(perms):
 
     if not pdb_models:
diff --git a/src/fullctl/service_bridge/client.py b/src/fullctl/service_bridge/client.py
index 27f2153d..24c93a01 100644
--- a/src/fullctl/service_bridge/client.py
+++ b/src/fullctl/service_bridge/client.py
@@ -5,6 +5,7 @@ class ServiceBridgeError(IOError):
     def __init__(self, bridge, status, data=None):
         super().__init__(f"Service bridge error: {bridge} [{status}]")
         self.data = data
+        self.status = status
 
     @property
     def errors(self):
@@ -26,6 +27,10 @@ class Bridge:
     class Meta:
         service = "base"
 
+    @property
+    def auth_headers(self):
+        return {"Authorization": f"token {self.key}"}
+
     def __init__(self, host, key, org_slug):
         self.url = f"{host}/api"
         self.org = org_slug
@@ -42,13 +47,28 @@ def _data(self, response):
         else:
             raise ServiceBridgeError(self, status)
 
+    def _requests_kwargs(self, **kwargs):
+        if kwargs.get("headers"):
+            kwargs["headers"].update(self.auth_headers)
+        else:
+            kwargs["headers"] = self.auth_headers
+        return kwargs
+
     def get(self, endpoint, **kwargs):
-        url = f"{self.url}/{endpoint}?key={self.key}"
-        return self._data(requests.get(url, **kwargs))
+        url = f"{self.url}/{endpoint}"
+        return self._data(requests.get(url, **self._requests_kwargs(**kwargs)))
 
     def post(self, endpoint, **kwargs):
-        url = f"{self.url}/{endpoint}?key={self.key}"
-        return self._data(requests.post(url, **kwargs))
+        url = f"{self.url}/{endpoint}"
+        return self._data(requests.post(url, **self._requests_kwargs(**kwargs)))
+
+    def put(self, endpoint, **kwargs):
+        url = f"{self.url}/{endpoint}"
+        return self._data(requests.put(url, **self._requests_kwargs(**kwargs)))
+
+    def delete(self, endpoint, **kwargs):
+        url = f"{self.url}/{endpoint}"
+        return self._data(requests.delete(url, **self._requests_kwargs(**kwargs)))
 
 
 class AaaCtl(Bridge):


Test patch:
diff --git a/tests/django_tests/project/settings.py b/tests/django_tests/project/settings.py
index 1d9660e9..5ae882f1 100644
--- a/tests/django_tests/project/settings.py
+++ b/tests/django_tests/project/settings.py
@@ -46,6 +46,7 @@
     "rest_framework",
     "social_django",
     "fullctl.django.apps.DjangoFullctlConfig",
+    "tests.django_tests.testapp.apps.TestAppConfig",
 ]
 
 MIDDLEWARE = [
diff --git a/tests/django_tests/test_tasks.py b/tests/django_tests/test_tasks.py
new file mode 100644
index 00000000..db59fe27
--- /dev/null
+++ b/tests/django_tests/test_tasks.py
@@ -0,0 +1,105 @@
+import pytest
+
+import fullctl.django.tasks.orm as orm
+import tests.django_tests.testapp.models as models
+from fullctl.django.models.concrete.tasks import TaskLimitError
+
+
+@pytest.mark.django_db
+def test_fetch_tasks():
+
+    task = models.TestTask.create_task(1, 2)
+    assert task == orm.fetch_task()
+
+
+@pytest.mark.django_db
+def test_work_tasks():
+
+    task = models.TestTask.create_task(1, 2)
+
+    orm.claim_task(task)
+
+    assert task.queue_id
+    assert task.taskclaim
+
+    orm.work_task(task)
+
+    assert task.status == "completed"
+    assert int(task.output) == 3
+
+
+@pytest.mark.django_db
+def test_parent_task():
+
+    task_par = models.TestTask.create_task(1, 2)
+    task_chl = models.TestTask.create_task(7, 3, parent=task_par)
+
+    # cant run child task before parent is finished
+
+    with pytest.raises(IOError):
+        orm.work_task(task_chl)
+
+    tasks = orm.fetch_tasks()
+
+    # child task should not be fetched while its waiting
+    # for parent
+    assert task_chl not in tasks
+    assert task_par in tasks
+
+    orm.work_task(task_par)
+
+    tasks = orm.fetch_tasks()
+
+    assert task_chl in tasks
+
+    # child task can be worked now
+    task_chl.refresh_from_db()
+    orm.work_task(task_chl)
+
+
+@pytest.mark.django_db
+def test_task_qualifiers(settings):
+
+    task = models.QualifierTestTask.create_task(1, 2)
+
+    # worker qualifies, task should be in tasks
+
+    settings.TEST_QUALIFIER = True
+    assert task in orm.fetch_tasks()
+
+    # worker no longer qualifiers, task should not be in tasks
+
+    settings.TEST_QUALIFIER = False
+    assert task not in orm.fetch_tasks()
+
+
+@pytest.mark.django_db
+def test_task_result():
+    task = models.TestTask.create_task(1, 2)
+    orm.work_task(task)
+    assert task.result == 3
+
+
+@pytest.mark.django_db
+def test_task_limits():
+    task = models.LimitedTask.create_task("test")
+    with pytest.raises(TaskLimitError):
+        task = models.LimitedTask.create_task("test")
+
+    orm.work_task(task)
+    task = models.LimitedTask.create_task("test")
+
+
+@pytest.mark.django_db
+def test_task_limits_with_id():
+    task_a = models.LimitedTaskWithLimitId.create_task("test")
+    task_b = models.LimitedTaskWithLimitId.create_task("other")
+    with pytest.raises(TaskLimitError):
+        task_a = models.LimitedTaskWithLimitId.create_task("test")
+    with pytest.raises(TaskLimitError):
+        task_b = models.LimitedTaskWithLimitId.create_task("other")
+
+    orm.work_task(task_a)
+    orm.work_task(task_b)
+    task_a = models.LimitedTaskWithLimitId.create_task("test")
+    task_b = models.LimitedTaskWithLimitId.create_task("other")
diff --git a/tests/django_tests/testapp/__init__.py b/tests/django_tests/testapp/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/django_tests/testapp/admin.py b/tests/django_tests/testapp/admin.py
new file mode 100644
index 00000000..4185d360
--- /dev/null
+++ b/tests/django_tests/testapp/admin.py
@@ -0,0 +1,3 @@
+# from django.contrib import admin
+
+# Register your models here.
diff --git a/tests/django_tests/testapp/apps.py b/tests/django_tests/testapp/apps.py
new file mode 100644
index 00000000..ed7fe44b
--- /dev/null
+++ b/tests/django_tests/testapp/apps.py
@@ -0,0 +1,7 @@
+from django.apps import AppConfig
+
+
+class TestAppConfig(AppConfig):
+    name = "tests.django_tests.testapp"
+    label = "fullctl_testapp"
+    default_auto_field = "django.db.models.AutoField"
diff --git a/tests/django_tests/testapp/migrations/__init__.py b/tests/django_tests/testapp/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/django_tests/testapp/models.py b/tests/django_tests/testapp/models.py
new file mode 100644
index 00000000..1c9b8fb3
--- /dev/null
+++ b/tests/django_tests/testapp/models.py
@@ -0,0 +1,94 @@
+"""
+Contains a bunch of concrete models to help testing
+"""
+
+from fullctl.django.models import Task
+from fullctl.django.tasks import qualifiers, register
+
+
+@register
+class TestTask(Task):
+    class Meta:
+        proxy = True
+
+    class HandleRef:
+        tag = "task_test"
+
+    class TaskMeta:
+        result_type = int
+
+    def run(self, a, b, *args, **kwargs):
+        return a + b
+
+
+@register
+class ParentTestTask:
+    class Meta:
+        proxy = True
+
+    class HandleRef:
+        tag = "task_parent_test"
+
+    def run(self, a, *args, **kwargs):
+        b = int(self.parent.output)
+        return a + b
+
+
+@register
+class QualifierTestTask(TestTask):
+    class Meta:
+        proxy = True
+
+    class HandleRef:
+        tag = "task_qualifier_test"
+
+    class TaskMeta:
+        qualifiers = [
+            qualifiers.Setting("TEST_QUALIFIER", True),
+        ]
+
+
+@register
+class LongTask(TestTask):
+    class Meta:
+        proxy = True
+
+    class HandleRef:
+        tag = "task_long"
+
+    def run(self, a, b, *args, **kwargs):
+        import time
+
+        time.sleep(1)
+        return a + b
+
+
+@register
+class LimitedTask(Task):
+    class Meta:
+        proxy = True
+
+    class HandleRef:
+        tag = "task_limited"
+
+    class TaskMeta:
+        limit = 1
+
+    def run(self, *args, **kwargs):
+        return True
+
+
+@register
+class LimitedTaskWithLimitId(LimitedTask):
+    class Meta:
+        proxy = True
+
+    class HandleRef:
+        tag = "task_limited_2"
+
+    class TaskMeta:
+        limit = 1
+
+    @property
+    def generate_limit_id(self):
+        return self.param["args"][0]
diff --git a/tests/django_tests/testapp/tests.py b/tests/django_tests/testapp/tests.py
new file mode 100644
index 00000000..a79ca8be
--- /dev/null
+++ b/tests/django_tests/testapp/tests.py
@@ -0,0 +1,3 @@
+# from django.test import TestCase
+
+# Create your tests here.
diff --git a/tests/django_tests/testapp/views.py b/tests/django_tests/testapp/views.py
new file mode 100644
index 00000000..fd0e0449
--- /dev/null
+++ b/tests/django_tests/testapp/views.py
@@ -0,0 +1,3 @@
+# from django.shortcuts import render
+
+# Create your views here.