こんにちは。
これはHameeの2021年のアドベントカレンダー、6日目の記事です。
同じくアドベントカレンダーの @yamamoto_hiroya さんの4日目の記事にもあるのですが、この11月からHameeの開発部署のプロジェクト管理をRedmineからAsanaに変更しました。その関係で、旧来できていた運用の再現や、手間をはぶく新しい運用の適用やツールの作成が急ピッチで進められていました。
そしてタスクのステータスをAsana上のセクションで管理するようになったのですが、あるとき、そのタスクの差し戻し回数や、保留されている時間を知り、プロジェクト管理の見える化をはかろうとしました。ここではそのためにおこなったAsanaのAPIの疎通方法を記載します。
Asanaとは
いま一つだけプロジェクトを管理ツールを選べと言われたらAsanaを選ぶ、僕にとってAsanaはいまやそういう存在のプロジェクト管理ツールになりました。ですが本当は「プロジェクト管理ツール」ではないらしいです。「ワークマネジメントツール」らしいです。なので、プロジェクトを管理する以上の機能がAsanaには詰め込まれています。
例えばポートフォリオやレポートといった機能でしょうか。メンバーがどのような稼働をしているのか、プロジェクトを横断的に確認することができたり、プロジェクトのステータスを収集したりしてくれます。
AsanaはもともとはFacebookのエンジニアが自分たちのプロジェクトを管理するためのツールとして生まれたそうです。多種多様で柔軟な機能群はそう言った実地で生み出されているのかもしれません。「それが欲しかったわー!」という、ワクワクするような新機能が実装され続けています。凄いね!
とはいえAsanaのワクワク機能実装をもってしても、すべての自社のわがままに付き合ってくれるわけではありません。しかしAPIを使えばある程度の情報をAsanaから引き出せたり、情報更新ができたりします。
ここでは試しに、Asana APIを使用し、あるプロジェクトに紐づくタスクのストーリー(アクティビティ)を持ってきます。
Asana APIを利用するための準備
ひとまずAsanaのAPIトークンを払い出します。Developer consoleで新しいアプリを作り、APIトークンを払い出すのが個人で使用するなら早いでしょう。
下記以降、ここで取得したトークンは ASANA_TOKEN
という環境変数に入っているものとします。内容理解のために自分でPython書いてますが、最初にお伝えすると、すでにオフィシャルでライブラリがあるので、実務へはそちらを使うのがよろしいでしょう。
Projectを取得する
#!/usr/bin/env python
import os
import json
import urllib.request
from datetime import datetime, timezone
import dateutil.parser
class AsanaResource:
end_point = 'https://app.asana.com/api/1.0'
headers = {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + os.environ.get('ASANA_TOKEN')
}
@classmethod
def get_response(cls, path):
response = ''
url = cls.end_point + path
req = urllib.request.Request(url, method='GET', headers=AsanaResource.headers)
with urllib.request.urlopen(req) as response:
response = response.read().decode("utf-8")
return response
class Project(AsanaResource):
def __init__(self, project_gid):
self.project_gid = project_gid
def load(self):
path = '/projects/{project_gid}?opt_fields=gid,name'.replace('{project_gid}', self.project_gid)
response = AsanaResource.get_response(path)
project_data = json.loads(response)['data']
self.load_from_response(project_data)
def load_from_response(self, project_data):
self.project_gid = project_data['gid']
self.name = project_data['name']
def main():
project = Project('12345678')
project.load()
print(project.name)
if __name__ == '__main__':
main()
project = Project('12345678')
この部分はプロジェクトのGIDとなります。(これは適当な値を入れています)基本、下記のようにURLに記載があるのでコピペで取得してください。あるいは、この値もAPIで取得できます。
このスクリプトを実行すると、下記のようにプロジェクト名が取得できているのが分かります。
ito.masakuni % python ./asana_report
TEMP/MASAKUNI_NO_PJ
Project〜Task〜Storyを取得する
プロジェクトのデータ取得が確認したところで、ずあーっとタスクとそのアクティビティであるストーリーを取得します。全体的に下記のように変更します。
#!/usr/bin/env python
import os
import json
import urllib.request
from datetime import datetime, timezone
import dateutil.parser
class AsanaResource:
end_point = 'https://app.asana.com/api/1.0'
headers = {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + os.environ.get('ASANA_TOKEN')
}
@classmethod
def get_response(cls, path):
response = ''
url = cls.end_point + path
req = urllib.request.Request(url, method='GET', headers=AsanaResource.headers)
with urllib.request.urlopen(req) as response:
response = response.read().decode("utf-8")
return response
class Project(AsanaResource):
def __init__(self, project_gid):
self.project_gid = project_gid
def load(self):
path = '/projects/{project_gid}?opt_fields=gid,name'.replace('{project_gid}', self.project_gid)
response = AsanaResource.get_response(path)
project_data = json.loads(response)['data']
self.load_from_response(project_data)
def load_from_response(self, project_data):
self.project_gid = project_data['gid']
self.name = project_data['name']
def get_tasks(self):
tasks = []
path = '/projects/{project_gid}/tasks?opt_fields=gid,name,assignee.name,created_at,memberships.section.name'.replace('{project_gid}', self.project_gid)
response = AsanaResource.get_response(path)
tasks_data = json.loads(response)['data']
tasks = []
for task_data in tasks_data:
task = Task(task_data['gid'])
task.load_from_response(task_data)
tasks.append(task)
return tasks
class Task(AsanaResource):
def __init__(self, task_gid):
self.task_gid = task_gid
def load(self):
path = '/tasks/{task_gid}?opt_fields=gid,name,assignee.name,created_at,memberships.section.name'.replace('{task_gid}', self.task_gid)
response = AsanaResource.get_response(path)
self.load_from_response(json.loads(response)['data'])
def load_from_response(self, task_data):
self.task_gid = task_data['gid']
self.name = task_data['name']
self.created_at = task_data['created_at']
self.assignee = None
if not task_data['assignee'] is None:
self.assignee = task_data['assignee']['name']
self.section_name = None
for membership in task_data['memberships']:
if 'section' in membership:
self.section_name = membership['section']['name']
def get_stories(self):
stories = []
path = '/tasks/{task_gid}/stories?opt_fields=text,created_at'.replace('{task_gid}', self.task_gid)
response = AsanaResource.get_response(path)
stories_data = json.loads(response)['data']
for story_data in stories_data:
story = Story(story_data['gid'])
story.load_from_response(story_data)
stories.append(story)
return stories
class Story(AsanaResource):
text = None
created_at = None
def __init__(self, story_gid):
self.story_gid = story_gid
def load(self):
path = '/stories/{story_id}?opt_fields=gid,text,created_at'.replace('{story_id}', self.story_gid)
response = AsanaResource.get_response(path)
self.load_from_response(json.loads(response)['data'])
def load_from_response(self, story_data):
self.story_gid = story_data['gid']
self.text = story_data['text']
self.created_at = story_data['created_at']
def main():
project = Project('12345678')
for task in project.get_tasks():
for story in task.get_stories():
print(story.text)
if __name__ == '__main__':
main()
この実行結果は下記となり、各タスクのストーリーが取得できているのが分かります。
ito.masakuni % python ./asana_report
Masakuni Ito added to https://app.asana.com/0/12345678/987654321
コメントを追加
Masakuni Ito changed the due date to December 7
Masakuni Ito assigned to you
画面上のストーリーとも一致することが分かります。
ちょっとだけ解説
path = '/projects/{project_gid}/tasks?opt_fields=gid,name,assignee.name,created_at,memberships.section.name'.replace('{project_gid}', self.project_gid)
AsanaのAPIは各リソースに複数のアクセス方法があるようです。上記はプロジェクトに紐付いているタスクを取得します。
path = '/tasks/{task_gid}?opt_fields=gid,name,assignee.name,created_at,memberships.section.name'.replace('{task_gid}', self.task_gid)
こちらはタスクのIDから取得します。
opt_fields
opt_fields
をつけないとAsanaで定められたデフォルトのデータしか取得できないのですが、 opt_fields
でパラメータを指定すると、そのデータが取得できるようになっています。ドキュメントの Input/Output Options をご覧ください。てゆーか、基本ここで取得できるものはCreate時のレスポンスと同じようなものだろうなと思っている(例えばTaskのCreate時のデータ)のですが、誰かこの一覧をご存じの方いらっしゃいませんか。
まとめ
APIを使用せずとも、さらに柔軟なレポートがAsana上で作れるようになると僕は信じています。