Files
TimeTracker/app/integrations/asana.py
T
Dries Peeters 9449a46a42 feat(integrations): Linear connector and shared HTTP/sync helpers
- Add Linear import (GraphQL, personal API key, optional team key filter).
- Centralize integration HTTP via integration_session and session_request.
- Add integration_sync_context for project/task refs and custom_fields metadata.
- Refactor Asana, GitHub, GitLab, Jira, Trello, ActivityWatch, and QuickBooks to use helpers.
- Extend integration UI, settings, and scheduled sync behavior as needed.
2026-04-05 08:39:18 +02:00

398 lines
17 KiB
Python

"""
Asana integration connector.
Sync tasks and projects with Asana.
"""
import os
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
import requests
from app.integrations.base import BaseConnector
class AsanaConnector(BaseConnector):
"""Asana integration connector."""
display_name = "Asana"
description = "Sync tasks and projects with Asana"
icon = "asana"
BASE_URL = "https://app.asana.com/api/1.0"
@property
def provider_name(self) -> str:
return "asana"
def get_authorization_url(self, redirect_uri: str, state: str = None) -> str:
"""Get Asana OAuth authorization URL."""
from app.models import Settings
settings = Settings.get_settings()
creds = settings.get_integration_credentials("asana")
client_id = creds.get("client_id") or os.getenv("ASANA_CLIENT_ID")
if not client_id:
raise ValueError("ASANA_CLIENT_ID not configured")
auth_url = "https://app.asana.com/-/oauth_authorize"
params = {"client_id": client_id, "redirect_uri": redirect_uri, "response_type": "code", "state": state or ""}
query_string = "&".join([f"{k}={v}" for k, v in params.items()])
return f"{auth_url}?{query_string}"
def exchange_code_for_tokens(self, code: str, redirect_uri: str) -> Dict[str, Any]:
"""Exchange authorization code for tokens."""
from app.models import Settings
settings = Settings.get_settings()
creds = settings.get_integration_credentials("asana")
client_id = creds.get("client_id") or os.getenv("ASANA_CLIENT_ID")
client_secret = creds.get("client_secret") or os.getenv("ASANA_CLIENT_SECRET")
if not client_id or not client_secret:
raise ValueError("Asana OAuth credentials not configured")
token_url = f"{self.BASE_URL}/oauth_token"
response = requests.post(
token_url,
data={
"grant_type": "authorization_code",
"client_id": client_id,
"client_secret": client_secret,
"code": code,
"redirect_uri": redirect_uri,
},
)
response.raise_for_status()
data = response.json()
expires_at = None
if "expires_in" in data:
expires_at = datetime.utcnow() + timedelta(seconds=data["expires_in"])
# Get user info
user_info = {}
if "access_token" in data:
try:
user_response = requests.get(
f"{self.BASE_URL}/users/me", headers={"Authorization": f"Bearer {data['access_token']}"}
)
if user_response.status_code == 200:
user_data = user_response.json().get("data", {})
user_info = {
"gid": user_data.get("gid"),
"name": user_data.get("name"),
"email": user_data.get("email"),
}
except Exception as e:
# Log error but don't fail - user info is optional
import logging
logger = logging.getLogger(__name__)
logger.debug(f"Could not fetch Asana user info: {e}")
return {
"access_token": data.get("access_token"),
"refresh_token": data.get("refresh_token"),
"expires_at": expires_at.isoformat() if expires_at else None,
"token_type": "Bearer",
"extra_data": user_info,
}
def refresh_access_token(self) -> Dict[str, Any]:
"""Refresh access token."""
if not self.credentials or not self.credentials.refresh_token:
raise ValueError("No refresh token available")
from app.models import Settings
settings = Settings.get_settings()
creds = settings.get_integration_credentials("asana")
client_id = creds.get("client_id") or os.getenv("ASANA_CLIENT_ID")
client_secret = creds.get("client_secret") or os.getenv("ASANA_CLIENT_SECRET")
token_url = f"{self.BASE_URL}/oauth_token"
response = requests.post(
token_url,
data={
"grant_type": "refresh_token",
"client_id": client_id,
"client_secret": client_secret,
"refresh_token": self.credentials.refresh_token,
},
)
response.raise_for_status()
data = response.json()
expires_at = None
if "expires_in" in data:
expires_at = datetime.utcnow() + timedelta(seconds=data["expires_in"])
# Update credentials
self.credentials.access_token = data.get("access_token")
if "refresh_token" in data:
self.credentials.refresh_token = data.get("refresh_token")
if expires_at:
self.credentials.expires_at = expires_at
self.credentials.save()
return {"access_token": data.get("access_token"), "expires_at": expires_at.isoformat() if expires_at else None}
def test_connection(self) -> Dict[str, Any]:
"""Test connection to Asana."""
try:
headers = {"Authorization": f"Bearer {self.get_access_token()}"}
response = requests.get(f"{self.BASE_URL}/users/me", headers=headers)
if response.status_code == 200:
user_data = response.json().get("data", {})
return {"success": True, "message": f"Connected to Asana as {user_data.get('name', 'Unknown')}"}
else:
return {"success": False, "message": f"Connection test failed: {response.status_code}"}
except Exception as e:
return {"success": False, "message": f"Connection test failed: {str(e)}"}
def sync_data(self, sync_type: str = "full") -> Dict[str, Any]:
"""Sync tasks and projects with Asana."""
from app import db
from app.models import Project, Task
from app.utils.integration_sync_context import (
ensure_project_integration_fields,
find_project_by_integration_ref,
find_task_by_integration_ref,
require_sync_context,
set_task_integration_ref,
)
try:
actor_id, client_id = require_sync_context(self.integration)
except ValueError as e:
return {"success": False, "message": str(e)}
try:
headers = {"Authorization": f"Bearer {self.get_access_token()}"}
# Get workspace from config
workspace_gid = self.integration.config.get("workspace_gid")
if not workspace_gid:
return {"success": False, "message": "Workspace GID not configured"}
synced_count = 0
errors = []
# Sync projects from Asana
projects_response = requests.get(
f"{self.BASE_URL}/projects",
headers=headers,
params={"workspace": workspace_gid, "opt_fields": "name,notes,archived"},
)
if projects_response.status_code == 200:
asana_projects = projects_response.json().get("data", [])
for asana_project in asana_projects:
try:
ap_gid = str(asana_project.get("gid") or "")
ap_name = (asana_project.get("name") or "Asana project").strip()[:200]
if not ap_gid:
continue
project = find_project_by_integration_ref(client_id, "asana", ap_gid)
if not project:
project = Project.query.filter_by(client_id=client_id, name=ap_name).first()
if not project:
project = Project(
name=ap_name,
client_id=client_id,
description=(asana_project.get("notes") or "") or None,
status="active" if not asana_project.get("archived") else "archived",
)
db.session.add(project)
db.session.flush()
ensure_project_integration_fields(
project,
source="asana",
ref=ap_gid,
display_name=ap_name,
description=(asana_project.get("notes") or "") or None,
)
# Sync tasks from Asana project
tasks_response = requests.get(
f"{self.BASE_URL}/projects/{asana_project.get('gid')}/tasks",
headers=headers,
params={"opt_fields": "name,notes,completed,due_on"},
)
if tasks_response.status_code == 200:
asana_tasks = tasks_response.json().get("data", [])
for asana_task in asana_tasks:
try:
at_gid = str(asana_task.get("gid") or "")
if not at_gid:
continue
task_response = requests.get(
f"{self.BASE_URL}/tasks/{at_gid}",
headers=headers,
params={"opt_fields": "name,notes,completed,due_on,assignee"},
)
if task_response.status_code == 200:
task_data = task_response.json().get("data", {})
tname = (task_data.get("name") or "Task").strip()[:200]
tstatus = "done" if task_data.get("completed") else "todo"
task = find_task_by_integration_ref(project.id, at_gid, source="asana")
if not task:
task = Task(
project_id=project.id,
name=tname,
description=(task_data.get("notes") or "") or None,
status=tstatus,
created_by=actor_id,
)
db.session.add(task)
db.session.flush()
else:
task.name = tname
task.description = (task_data.get("notes") or "") or None
task.status = tstatus
set_task_integration_ref(
task,
source="asana",
ref=at_gid,
extra={"asana_task_gid": at_gid},
)
synced_count += 1
except Exception as e:
errors.append(
f"Error syncing task in project {asana_project.get('name')}: {str(e)}"
)
except Exception as e:
errors.append(f"Error syncing project {asana_project.get('name')}: {str(e)}")
db.session.commit()
return {"success": True, "synced_count": synced_count, "errors": errors}
except Exception as e:
return {"success": False, "message": f"Sync failed: {str(e)}"}
def get_config_schema(self) -> Dict[str, Any]:
"""Get configuration schema."""
return {
"fields": [
{
"name": "workspace_gid",
"type": "string",
"label": "Workspace GID",
"required": True,
"placeholder": "1234567890",
"description": "Asana workspace GID to sync with",
"help": "Find your workspace GID in Asana workspace settings or API",
},
{
"name": "project_gids",
"type": "text",
"label": "Project GIDs",
"required": False,
"placeholder": "1234567890, 9876543210",
"description": "Comma-separated list of specific project GIDs to sync (leave empty to sync all)",
"help": "Optional: Limit sync to specific projects",
},
{
"name": "sync_direction",
"type": "select",
"label": "Sync Direction",
"options": [
{"value": "asana_to_timetracker", "label": "Asana → TimeTracker (Import only)"},
{"value": "timetracker_to_asana", "label": "TimeTracker → Asana (Export only)"},
{"value": "bidirectional", "label": "Bidirectional (Two-way sync)"},
],
"default": "asana_to_timetracker",
"description": "Choose how data flows between Asana and TimeTracker",
},
{
"name": "sync_items",
"type": "array",
"label": "Items to Sync",
"options": [
{"value": "projects", "label": "Projects"},
{"value": "tasks", "label": "Tasks"},
{"value": "subtasks", "label": "Subtasks"},
],
"default": ["projects", "tasks"],
"description": "Select which items to synchronize",
},
{
"name": "auto_sync",
"type": "boolean",
"label": "Auto Sync",
"default": False,
"description": "Automatically sync when changes are detected",
},
{
"name": "sync_interval",
"type": "select",
"label": "Sync Schedule",
"options": [
{"value": "manual", "label": "Manual only"},
{"value": "hourly", "label": "Every hour"},
{"value": "daily", "label": "Daily"},
],
"default": "manual",
"description": "How often to automatically sync data",
},
{
"name": "sync_completed",
"type": "boolean",
"label": "Sync Completed Tasks",
"default": False,
"description": "Include completed tasks in sync",
},
{
"name": "status_mapping",
"type": "json",
"label": "Status Mapping",
"placeholder": '{"completed": "completed", "incomplete": "todo"}',
"description": "Map Asana task completion status to TimeTracker statuses (JSON format)",
"help": "Customize how Asana task statuses map to TimeTracker task statuses",
},
],
"required": ["workspace_gid"],
"sections": [
{
"title": "Workspace Settings",
"description": "Configure your Asana workspace",
"fields": ["workspace_gid", "project_gids"],
},
{
"title": "Sync Settings",
"description": "Configure what and how to sync",
"fields": ["sync_direction", "sync_items", "sync_completed", "auto_sync", "sync_interval"],
},
{
"title": "Data Mapping",
"description": "Customize how data translates between Asana and TimeTracker",
"fields": ["status_mapping"],
},
],
"sync_settings": {
"enabled": True,
"auto_sync": False,
"sync_interval": "manual",
"sync_direction": "asana_to_timetracker",
"sync_items": ["projects", "tasks"],
},
}