mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-21 22:00:07 -05:00
9449a46a42
- 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.
398 lines
17 KiB
Python
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"],
|
|
},
|
|
}
|