Files
TimeTracker/app/integrations/trello.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

567 lines
24 KiB
Python

"""
Trello integration connector.
Sync boards, lists, and cards with Trello.
"""
import base64
import hashlib
import hmac
import os
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
import requests
from app.integrations.base import BaseConnector
class TrelloConnector(BaseConnector):
"""Trello integration connector."""
display_name = "Trello"
description = "Sync boards and cards with Trello"
icon = "trello"
BASE_URL = "https://api.trello.com/1"
@property
def provider_name(self) -> str:
return "trello"
def get_authorization_url(self, redirect_uri: str, state: str = None) -> str:
"""Get Trello OAuth authorization URL."""
from app.models import Settings
settings = Settings.get_settings()
creds = settings.get_integration_credentials("trello")
api_key = creds.get("api_key") or os.getenv("TRELLO_API_KEY")
if not api_key:
raise ValueError("TRELLO_API_KEY not configured")
auth_url = "https://trello.com/1/OAuthAuthorizeToken"
params = {
"key": api_key,
"name": "TimeTracker Integration",
"response_type": "token",
"scope": "read,write",
"expiration": "never",
"redirect_uri": redirect_uri,
}
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 (Trello uses token directly)."""
# Trello uses token-based auth, not OAuth flow
# The token is returned directly from the authorization URL
from app.models import Settings
settings = Settings.get_settings()
creds = settings.get_integration_credentials("trello")
api_key = creds.get("api_key") or os.getenv("TRELLO_API_KEY")
if not api_key:
raise ValueError("Trello API key not configured")
# For Trello, the 'code' parameter is actually the token
token = code
# Verify token by getting user info
user_info = {}
try:
response = requests.get(f"{self.BASE_URL}/members/me", params={"key": api_key, "token": token})
if response.status_code == 200:
user_data = response.json()
user_info = {
"id": user_data.get("id"),
"username": user_data.get("username"),
"fullName": user_data.get("fullName"),
"email": user_data.get("email"),
}
except Exception:
pass
return {
"access_token": token,
"refresh_token": None, # Trello tokens don't expire
"expires_at": None,
"token_type": "Bearer",
"extra_data": user_info,
}
def refresh_access_token(self) -> Dict[str, Any]:
"""Refresh access token (Trello tokens don't expire)."""
# Trello tokens don't expire, so just return current token
return {"access_token": self.credentials.access_token if self.credentials else None, "expires_at": None}
def test_connection(self) -> Dict[str, Any]:
"""Test connection to Trello."""
try:
from app.models import Settings
settings = Settings.get_settings()
creds = settings.get_integration_credentials("trello")
api_key = creds.get("api_key") or os.getenv("TRELLO_API_KEY")
headers = {"Authorization": f"Bearer {self.get_access_token()}"}
response = requests.get(
f"{self.BASE_URL}/members/me", params={"key": api_key, "token": self.get_access_token()}
)
if response.status_code == 200:
user_data = response.json()
return {"success": True, "message": f"Connected to Trello as {user_data.get('fullName', '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 boards and cards with Trello."""
from app.utils.integration_sync_context import require_sync_context
try:
from app.models import Settings
settings = Settings.get_settings()
creds = settings.get_integration_credentials("trello")
api_key = creds.get("api_key") or os.getenv("TRELLO_API_KEY")
token = self.get_access_token()
if not token or not api_key:
return {"success": False, "message": "Trello credentials not configured"}
try:
actor_id, client_id = require_sync_context(self.integration)
except ValueError as e:
return {"success": False, "message": str(e)}
# Get sync direction from config
sync_direction = (
self.integration.config.get("sync_direction", "trello_to_timetracker")
if self.integration
else "trello_to_timetracker"
)
if sync_direction in ("trello_to_timetracker", "bidirectional"):
trello_result = self._sync_trello_to_timetracker(api_key, token, actor_id, client_id)
# If bidirectional, also sync TimeTracker to Trello
if sync_direction == "bidirectional":
tracker_result = self._sync_timetracker_to_trello(api_key, token, actor_id, client_id)
# Merge results
if trello_result.get("success") and tracker_result.get("success"):
return {
"success": True,
"synced_count": trello_result.get("synced_count", 0)
+ tracker_result.get("synced_count", 0),
"errors": trello_result.get("errors", []) + tracker_result.get("errors", []),
"message": f"Bidirectional sync: Trello→TimeTracker: {trello_result.get('synced_count', 0)} items | TimeTracker→Trello: {tracker_result.get('synced_count', 0)} items",
}
elif trello_result.get("success"):
return trello_result
elif tracker_result.get("success"):
return tracker_result
else:
return {
"success": False,
"message": f"Both sync directions failed. Trello→TimeTracker: {trello_result.get('message')}, TimeTracker→Trello: {tracker_result.get('message')}",
}
return trello_result
# Handle TimeTracker to Trello sync
if sync_direction == "timetracker_to_trello":
return self._sync_timetracker_to_trello(api_key, token, actor_id, client_id)
return {"success": False, "message": f"Unknown sync direction: {sync_direction}"}
except Exception as e:
return {"success": False, "message": f"Sync failed: {str(e)}"}
def _sync_trello_to_timetracker(
self, api_key: str, token: str, actor_id: int, client_id: int
) -> Dict[str, Any]:
"""Sync Trello boards and cards to TimeTracker projects and tasks."""
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,
set_task_integration_ref,
)
synced_count = 0
errors = []
# Get boards
boards_response = requests.get(
f"{self.BASE_URL}/members/me/boards", params={"key": api_key, "token": token, "filter": "open"}
)
if boards_response.status_code == 200:
boards = boards_response.json()
# Filter by board_ids if configured
board_ids = self.integration.config.get("board_ids", []) if self.integration else []
if board_ids:
boards = [b for b in boards if b.get("id") in board_ids]
for board in boards:
try:
board_id = str(board.get("id") or "")
board_name = (board.get("name") or "Trello board").strip()[:200]
if not board_id:
continue
project = find_project_by_integration_ref(client_id, "trello", board_id)
if not project:
project = Project.query.filter_by(client_id=client_id, name=board_name).first()
if not project:
project = Project(
name=board_name,
client_id=client_id,
description=(board.get("desc") or "") or None,
status="active",
)
db.session.add(project)
db.session.flush()
ensure_project_integration_fields(
project,
source="trello",
ref=board_id,
display_name=board_name,
description=(board.get("desc") or "") or None,
)
cards_response = requests.get(
f"{self.BASE_URL}/boards/{board.get('id')}/cards",
params={"key": api_key, "token": token, "filter": "open"},
)
if cards_response.status_code == 200:
cards = cards_response.json()
for card in cards:
card_id = str(card.get("id") or "")
if not card_id:
continue
cname = (card.get("name") or "Card").strip()[:200]
new_status = self._map_trello_list_to_status(card.get("idList"))
task = find_task_by_integration_ref(project.id, card_id, source="trello")
if not task:
task = Task(
project_id=project.id,
name=cname,
description=(card.get("desc") or "") or None,
status=new_status,
created_by=actor_id,
)
db.session.add(task)
db.session.flush()
else:
if card.get("desc") is not None:
task.description = (card.get("desc") or "") or None
task.name = cname
task.status = new_status
set_task_integration_ref(
task,
source="trello",
ref=card_id,
extra={"trello_list_id": card.get("idList")},
)
synced_count += 1
except Exception as e:
errors.append(f"Error syncing board {board.get('name')}: {str(e)}")
db.session.commit()
return {"success": True, "synced_count": synced_count, "errors": errors}
def _sync_timetracker_to_trello(
self, api_key: str, token: str, actor_id: int, client_id: int
) -> Dict[str, Any]:
"""Sync TimeTracker tasks to Trello cards."""
from app import db
from app.models import Project, Task
from app.utils.integration_sync_context import ensure_project_integration_fields, set_task_integration_ref
synced_count = 0
errors = []
projects = Project.query.filter_by(client_id=client_id, status="active").all()
for project in projects:
cf = project.custom_fields if isinstance(project.custom_fields, dict) else {}
block = cf.get("integration") if isinstance(cf, dict) else {}
trello_board_id = None
if isinstance(block, dict) and block.get("source") == "trello":
trello_board_id = block.get("ref")
if not trello_board_id:
# Try to find or create board
board_name = project.name
boards_response = requests.get(
f"{self.BASE_URL}/members/me/boards", params={"key": api_key, "token": token, "filter": "open"}
)
if boards_response.status_code == 200:
boards = boards_response.json()
matching_board = next((b for b in boards if b.get("name") == board_name), None)
if matching_board:
trello_board_id = matching_board.get("id")
else:
# Create new board (optional - might require additional permissions)
try:
create_response = requests.post(
f"{self.BASE_URL}/boards", params={"key": api_key, "token": token, "name": board_name}
)
if create_response.status_code == 200:
trello_board_id = create_response.json().get("id")
except Exception as e:
errors.append(f"Could not create Trello board for project {project.name}: {str(e)}")
continue
if trello_board_id:
ensure_project_integration_fields(
project,
source="trello",
ref=str(trello_board_id),
display_name=project.name,
description=project.description,
)
if not trello_board_id:
continue
# Get lists for this board
lists_response = requests.get(
f"{self.BASE_URL}/boards/{trello_board_id}/lists",
params={"key": api_key, "token": token, "filter": "open"},
)
if lists_response.status_code != 200:
errors.append(f"Could not get lists for board {project.name}")
continue
lists = lists_response.json()
# Create a mapping of status to list ID
status_to_list = {}
for lst in lists:
list_name = lst.get("name", "").lower()
if "todo" in list_name or "to do" in list_name or "backlog" in list_name:
status_to_list["todo"] = lst.get("id")
elif "in progress" in list_name or "doing" in list_name or "active" in list_name:
status_to_list["in_progress"] = lst.get("id")
elif "done" in list_name or "completed" in list_name:
status_to_list["done"] = lst.get("id")
elif "review" in list_name:
status_to_list["review"] = lst.get("id")
# Default to first list if no mapping found
default_list_id = lists[0].get("id") if lists else None
# Get tasks for this project
tasks = Task.query.filter_by(project_id=project.id).all()
for task in tasks:
try:
tcf = task.custom_fields if isinstance(task.custom_fields, dict) else {}
tblock = tcf.get("integration") if isinstance(tcf, dict) else {}
trello_card_id = None
if isinstance(tblock, dict) and tblock.get("source") == "trello":
trello_card_id = tblock.get("ref")
# Determine target list
target_list_id = status_to_list.get(task.status, default_list_id)
if not target_list_id:
continue
if trello_card_id:
# Update existing card
update_data = {
"name": task.name,
"desc": task.description or "",
"idList": target_list_id,
}
update_response = requests.put(
f"{self.BASE_URL}/cards/{trello_card_id}",
params={"key": api_key, "token": token},
json=update_data,
)
if update_response.status_code == 200:
synced_count += 1
else:
errors.append(
f"Failed to update Trello card for task {task.id}: {update_response.status_code}"
)
else:
# Create new card
create_data = {
"name": task.name,
"desc": task.description or "",
"idList": target_list_id,
}
create_response = requests.post(
f"{self.BASE_URL}/cards", params={"key": api_key, "token": token}, json=create_data
)
if create_response.status_code == 200:
card_data = create_response.json()
trello_card_id = card_data.get("id")
set_task_integration_ref(
task,
source="trello",
ref=str(trello_card_id),
extra={"trello_list_id": target_list_id},
)
synced_count += 1
else:
errors.append(
f"Failed to create Trello card for task {task.id}: {create_response.status_code}"
)
except Exception as e:
errors.append(f"Error syncing task {task.id} to Trello: {str(e)}")
db.session.commit()
return {"success": True, "synced_count": synced_count, "errors": errors}
def _map_trello_list_to_status(self, list_id: str) -> str:
"""Map Trello list to task status."""
from app.models import Settings
settings = Settings.get_settings()
creds = settings.get_integration_credentials("trello")
api_key = creds.get("api_key") or os.getenv("TRELLO_API_KEY")
token = self.get_access_token()
if not token or not api_key:
return "todo"
try:
# Fetch list name
list_response = requests.get(f"{self.BASE_URL}/lists/{list_id}", params={"key": api_key, "token": token})
if list_response.status_code == 200:
list_data = list_response.json()
list_name = list_data.get("name", "").lower()
# Map common list names to statuses
if "done" in list_name or "completed" in list_name or "closed" in list_name:
return "done"
elif "in progress" in list_name or "doing" in list_name or "active" in list_name:
return "in_progress"
elif "todo" in list_name or "to do" in list_name or "backlog" in list_name:
return "todo"
except Exception:
pass
return "todo"
def get_config_schema(self) -> Dict[str, Any]:
"""Get configuration schema."""
return {
"fields": [
{
"name": "board_ids",
"type": "text",
"label": "Board IDs",
"required": False,
"placeholder": "board-id-1, board-id-2",
"description": "Comma-separated list of Trello board IDs to sync (leave empty to sync all)",
"help": "Find board IDs in Trello board URLs or API. Leave empty to sync all accessible boards.",
},
{
"name": "sync_direction",
"type": "select",
"label": "Sync Direction",
"options": [
{"value": "trello_to_timetracker", "label": "Trello → TimeTracker (Import only)"},
{"value": "timetracker_to_trello", "label": "TimeTracker → Trello (Export only)"},
{"value": "bidirectional", "label": "Bidirectional (Two-way sync)"},
],
"default": "trello_to_timetracker",
"description": "Choose how data flows between Trello and TimeTracker",
},
{
"name": "sync_items",
"type": "array",
"label": "Items to Sync",
"options": [
{"value": "boards", "label": "Boards (Projects)"},
{"value": "cards", "label": "Cards (Tasks)"},
{"value": "lists", "label": "Lists"},
],
"default": ["boards", "cards"],
"description": "Select which items to synchronize",
},
{
"name": "auto_sync",
"type": "boolean",
"label": "Auto Sync",
"default": False,
"description": "Automatically sync when webhooks are received from Trello",
},
{
"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": "list_status_mapping",
"type": "json",
"label": "List to Status Mapping",
"placeholder": '{"To Do": "todo", "In Progress": "in_progress", "Done": "completed"}',
"description": "Map Trello list names to TimeTracker task statuses (JSON format)",
"help": "Customize how Trello list names map to TimeTracker task statuses",
},
{
"name": "sync_archived",
"type": "boolean",
"label": "Sync Archived Items",
"default": False,
"description": "Include archived boards and cards in sync",
},
],
"required": [],
"sections": [
{
"title": "Board Settings",
"description": "Configure which Trello boards to sync",
"fields": ["board_ids", "sync_archived"],
},
{
"title": "Sync Settings",
"description": "Configure what and how to sync",
"fields": ["sync_direction", "sync_items", "auto_sync", "sync_interval"],
},
{
"title": "Data Mapping",
"description": "Customize how data translates between Trello and TimeTracker",
"fields": ["list_status_mapping"],
},
],
"sync_settings": {
"enabled": True,
"auto_sync": False,
"sync_interval": "manual",
"sync_direction": "trello_to_timetracker",
"sync_items": ["boards", "cards"],
},
}