django-asana¶
Overview¶
django-asana leverages python-asana, the official python client library for Asana. To this, django-asana adds django models and commands for importing data from Asana into these models, and for keeping a django project in sync with related Asana data.
- Documentation: https://django-asana.readthedocs.io/en/latest/
About¶
django-asana
aims to allow for rich interaction between Django projects and Asana projects.
The vision is to allow automated processes done in Django to interact with human Asana users toward project completion.
For example, an Asana project might include a workflow of ten tasks, several of which are automated, that must all be completed in order.
This tool will monitor the Asana project status, complete the automated steps when they are ready to be done, and report completion back to Asana so the workflow may continue.
This tool can do a one time sync from Asana, storing the current status of workspaces, projects, tasks, users, teams and tags in Django models. Depending on the size of the Asana workspaces, this initial sync may take some time. Successive syncs are faster if they are performed within the hour, which is the timeout for Asana’s sync token. You may specify to sync only specific workspaces, projects or models.
Optionally, Webhook receivers are registered so the Django project will remain synced to Asana in real-time. The Asana API only supports webhooks for projects and tasks, so even if you use django-asana webhooks to keep your projects in sync in real-time you will still periodically want to run the sync-from-asana task to get new projects and reflect additions and changes to workspaces, users, etc.
Task.sync_to_asana() can be used to update Asana to reflect local changes, like task completion. Task.add_comment() can be used to add a comment to a task in Asana.
Requirements¶
- Python 3.7+
- Django 3.2.14 - 4.0+
- python-asana 1.0.0+
- django-braces 1.13+ for JsonRequestResponseMixin
Quick start¶
1. Configure your django settings file. Asana allows two different authentication methods. For Oauth2, provide values for the following settings: ASANA_CLIENT_ID, ASANA_CLIENT_SECRET, and ASANA_OAUTH_REDIRECT_URI. To use an access token, provide a value for ASANA_ACCESS_TOKEN. Then add “django-asana” to your INSTALLED_APPS setting.
INSTALLED_APPS = [
...
'djasana',
]
If you have multiple Asana workspaces but only ever need to sync one with Django, specify it.
ASANA_WORKSPACE = 'This Workspace'
In the production version of your settings, set a base url and pattern for the webhooks. It must be reachable by Asana and secured by SSL. In your dev environment it is fine to leave this setting out; your project will be synced whenever you run the management command.
DJASANA_WEBHOOK_URL = 'https://mysite.com'
DJASANA_WEBHOOK_PATTERN = r'^djasana/webhooks/'
With that value, your webhook urls will be something like this: https://mysite.com/djasana/webhooks/project/1337/
2. If your project is “live” and has a webserver to which Asana can send requests, you can enable webhooks. To enable webhooks so Asana can keep your data in sync, add the following to your base urls.py
urlpatterns += [
url(settings.DJASANA_WEBHOOK_PATTERN, include('djasana.urls')),
]
- Run python manage.py migrate to create the Asana models.
- Run the command to synchronize data from Asana to Django:
python manage.py sync_from_asasa
Command line options¶
--workspace, -w |
Restrict work to the specified Asana workspace, by id or name. Can be used multiple times. By default, all workspaces will used. Ex: python manage.py sync_from_asana -w 1234567890 |
--project, -p |
Restrict work to the specified Asana project, by id or name. Can be used multiple times. By default, all projects will used. If you specify a project and have multiple workspaces and have not set ASANA_WORKSPACE, also specify the workspace. Ex: python manage.py sync_from_asana -p MyProject.com python manage.py sync_from_asana -w 1234567890 -p MyProject.com |
--model, -m |
Restrict work to the named model. Can be used multiple times. By default, all models will used. Capitalization is ignored. Ex: python manage.py sync_from_asana -m Workspace -m Project -m Task |
--model-exclude, -mx |
Exclude the named model. Can be used multiple times. Capitalization is ignored. Ex: python manage.py sync_from_asana -mx Story -mx Attachment -mx Tag |
--archive, -a |
Sync task, attachments, etc. of projects even if those projects are archived. The default behavior is to skip archived projects, saving a lot of processing for larger data sets. |
--nocommit |
Connects to Asana and outputs work in debug log but does not commit any database changes. |
--noinput |
Skip the warning that running this process will make data changes. |
Note that due to option parsing limitations, it is less error prone to pass in the id of the object rather than the name. The easiest way to find the id of a project or task in Asana is to examine the url. The list view in Asana is like https://app.asana.com/0/{project_id}/list and for a specific task https://app.asana.com/0/{project_id}/{task_id}.
Good example:
python manage.py sync_from_asana -w 123456
Bad example:
Warning
python manage.py sync_from_asana -w=”Personal Projects”
python manage.py sync_from_asana: error: unrecognized arguments: Projects
Further note that when including a model, the models it depends on will also be included. You cannot sync tasks without syncing the projects those tasks belong to.
The dependency chain for models it this, from the bottom up:
Story –> Task –> Project –> WorkspaceTags –> TaskAttachment –> TaskProject –> TeamTask –> User –> Workspace
Effectively, this means you can explicitly include models from the top down or exclude models from the bottom up:
python manage.py sync_from_asana -mx=Story -mx=Attachment -mx=Tag --noinput
See also python manage.py sync_from_asana –help
Other Settings¶
To restrict your project to a single workspace, add the setting ASANA_WORKSPACE.
ASANA_WORKSPACE = ‘Personal Projects’
Asana id versus gid¶
Asana has begun migrating from numeric ids to string gids. django-asana populates both of these fields, and will follow the migration path Asana has established.
Limitations¶
django-asana is designed for copying data from Asana to Django. Although it contains a useful client for connecting the two, for creating data in Asana (as in, wholesale syncing to Asana from Django) the developer is mostly left to use python-asana directly. The Task methods sync_to_asana and add_comment cover two typical use cases and can be used as examples on writing to Asana. For more info see Create Data.
django-asana support for custom fields is not well tested. If you use custom fields with django-asana, please report any bugs you find.
django-asana does not support updating user photo data. It will read user photo data from Asana, if available, but only the path to the 128x128 version of the photo.
If a project or task that has been synced to Django is deleted in Asana, and webhooks are not used, it is not deleted in Django with the sync_from_asana command. This is forthcoming functionality.
Asana has not documented the possible choices for Story.resource_subtype
and will likely add more without notice.
If you find a resource_subtype in the wild that is not supported yet, feel free to add a patch for it.
You can check what values you have by running code like this:
[s for s in Story.objects.distinct().values_list(
'resource_subtype', flat=True).order_by('resource_subtype')]
Running tests¶
After installing django-asana and adding it to your project, run tests against it as you would any other app:
python manage.py test djasana
For more info see Testing Your Code.
Creating Data in Asana¶
There are two methods on Task for writing to Asana: sync_to_asana() and add_comment().
Task.sync_to_asana(fields=None)¶
With no arguments, can be used to update the ‘completed’ status of a Task:
task.completed = True
task.save()
task.sync_to_asana()
Optionally, a sequence of fields can be passed:
task.notes = 'Get it done!'
task.due_on = today
task.save()
task.sync_to_asana(fields=('notes', 'due_on'))
Task.add_comment(text)¶
task.add_comment('Email sent to 123 users.')
Everything else¶
Other than those use cases, to create data in Asana, use the client provided by django-asana but beyond that use python-asana directly.
from dateutil.parser import parse
from django.db import IntegrityError
from djasana.connect import client_connect
from djasana.models import Task
def create_task():
client = client_connect()
workspace_id = 123456 # A djasana.models.Workspace.remote_id,
owner_id = 567890, # Maybe your own id at Asana
project = { # A dict of values you want to create
'name': 'A test project',
'workspace': workspace_id,
'owner': owner_id,
}
# projects.create is a method provided by python-asana:
project_response = client.projects.create(data)
project_remote_id = project_response['gid']
for key in (
'followers', 'members', 'owner', 'team', 'workspace'):
project_response.pop(key, None)
# Convert string to boolean:
response['archived'] = response['archived'] == 'true'
Project.objects.create(
remote_id=project_remote_id,
owner_id=owner_id,
workspace_id=workspace_id,
**project_response
)
task = {'name': 'A test task', projects: [project_remote_id]}
# tasks.create is a method provided by python-asana:
task_response = client.tasks.create(workspace=workspace_id, **task)
task_response['remote_id'] = task_response['gid']
task_response['assignee_id'] = task_response.pop('assignee')['gid']
if 'due_on' in task_response and isinstance(task_response['due_on'], str):
task_response['due_on'] = parse(task_response['due_on'])
if 'parent' in task_response and task_response['parent']:
task_response['parent_id'] = task_response.pop('parent')['gid']
for key in (
'followers', 'hearts', 'liked', 'likes', 'num_likes', 'num_hearts',
'memberships', 'projects', 'tags', 'workspace'):
task_response.pop(key, None)
Task.objects.create(**task_response)
Testing Your Code¶
Asana does not provide a testing sandbox API (although you can request a “Non-Productive Use” Order Form from their API support email address to get an account for testing purposes). When testing the integration of your application with Asana, you will likely want to mock any connections to Asana that would write data to avoid creating a lot of junk data in Asana.
Some strategies for testing with django-asana are as follows:
- Override the ASANA_ACCESS_TOKEN setting so that if your tests do reach out to Asana they will get an authentication error instead of writing bad data. This would be unnecessary if you follow the other steps, but by adding it to your TestCase, any new tests that do not mock your connections to Asana will fail loudly. If you want to test a read from Asana, you would not want to override your credentials.
- Mock the connection itself. django-asana provides some mock responses you can test with.
- When you need multiple mocks returned for database insertion, you will want them all to have unique ids to avoid integrity errors.
from unittest.mock import MagicMock, patch
from django.test import TestCase, override_settings
from unittest.mock import patch
def counter():
count = 1
while True:
yield count
count += 1
COUNTER = counter()
def get_task(**kwargs):
if 'assignee' in kwargs:
kwargs['assignee'] = {'gid': kwargs['assignee']}
if 'parent' in kwargs:
kwargs['parent'] = {'gid': kwargs['parent']}
return task(**kwargs)
def new_task(**kwargs):
"""Returns a mock Asana response for a task create."""
task_ = get_task(**kwargs)
task_['gid'] = next(COUNTER)
return task_
def update_task(*args):
"""Returns a mock Asana response for an update.
update gets passed a tuple: (gid, dict)
"""
kwargs = {key: value for key, value in args[1].items()}
task_ = get_task(**kwargs)
task_['gid'] = args[0]
return task_
@override_settings(ASANA_ACCESS_TOKEN='foo') # Assures your credentials are not real
class TestTask(TestCase):
@patch('djasana.models.client_connect')
def test_update_date_tasks(self, mock_connect):
"""Demonstrates how to mock for testing purposes."""
mock_client = mock_connect.return_value
mock_client.tasks.find_all.return_value = [task()]
mock_client.tasks.create.side_effect = new_task
mock_client.tasks.update.side_effect = update_task
mock_client.tasks.set_parent.side_effect = new_task
# Do something with those mocks
Sync from Asana¶
Connect¶
Models¶
Views¶
Changelog¶
All notable changes to this project will be documented in this file.
Unreleased¶
Added¶
- Updates urls.py for newer Django versions
- Updates supported Django and Python versions
1.4.7 (2021-11-29)¶
Added¶
- Updates supported Django and Python versions
- Increased support for custom fields
- Bug fixes
1.4.0 (2019-03-05)¶
Added¶
- Improves forward compatibility with Asana API changes by popping unexpected fields before update_or_create. In the past, undocumented and unannounced changes to the Asana API would often break django-asana.
1.3.3 (2018-10-23)¶
Added¶
- Added support for tags
- Improved support for resource_type, resource_subtype
1.3.1 (2018-09-27)¶
Added¶
- Pop unused field “resource_type”
Changed¶
- Fixed syncing of child task of task without a project. Pull request 3.
1.1.2 (2018-09-15)¶
Added¶
- Adds model fields for updated Asana API.
Changed¶
- Required updated python-asana.