From 9f179940f8f1e8072bcda950662cd9d7913f7bee Mon Sep 17 00:00:00 2001 From: pablodanswer Date: Thu, 19 Sep 2024 16:54:18 -0700 Subject: [PATCH] Asana connector (community originated) (#2485) * initial Asana connector * hint on how to get Asana workspace ID * re-format with black * re-order imports * update asana connector for clarity * minor robustification * minor update to naming * update for best practice * update connector --------- Co-authored-by: Daniel Naber --- backend/danswer/configs/constants.py | 1 + backend/danswer/connectors/asana/__init__.py | 0 backend/danswer/connectors/asana/asana_api.py | 233 ++++++++++++++++++ backend/danswer/connectors/asana/connector.py | 120 +++++++++ backend/danswer/connectors/factory.py | 2 + backend/requirements/default.txt | 1 + web/public/Asana.png | Bin 0 -> 6716 bytes web/src/components/icons/icons.tsx | 13 +- web/src/lib/connectors/connectors.ts | 38 +++ web/src/lib/connectors/credentials.ts | 10 + web/src/lib/sources.ts | 7 + web/src/lib/types.ts | 1 + 12 files changed, 425 insertions(+), 1 deletion(-) create mode 100644 backend/danswer/connectors/asana/__init__.py create mode 100755 backend/danswer/connectors/asana/asana_api.py create mode 100755 backend/danswer/connectors/asana/connector.py create mode 100644 web/public/Asana.png diff --git a/backend/danswer/configs/constants.py b/backend/danswer/configs/constants.py index 5ff2ccc7fd..670ad3771f 100644 --- a/backend/danswer/configs/constants.py +++ b/backend/danswer/configs/constants.py @@ -99,6 +99,7 @@ class DocumentSource(str, Enum): CLICKUP = "clickup" MEDIAWIKI = "mediawiki" WIKIPEDIA = "wikipedia" + ASANA = "asana" S3 = "s3" R2 = "r2" GOOGLE_CLOUD_STORAGE = "google_cloud_storage" diff --git a/backend/danswer/connectors/asana/__init__.py b/backend/danswer/connectors/asana/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/danswer/connectors/asana/asana_api.py b/backend/danswer/connectors/asana/asana_api.py new file mode 100755 index 0000000000..57c470c453 --- /dev/null +++ b/backend/danswer/connectors/asana/asana_api.py @@ -0,0 +1,233 @@ +import time +from collections.abc import Iterator +from datetime import datetime +from typing import Dict + +import asana # type: ignore + +from danswer.utils.logger import setup_logger + +logger = setup_logger() + + +# https://github.com/Asana/python-asana/tree/master?tab=readme-ov-file#documentation-for-api-endpoints +class AsanaTask: + def __init__( + self, + id: str, + title: str, + text: str, + link: str, + last_modified: datetime, + project_gid: str, + project_name: str, + ) -> None: + self.id = id + self.title = title + self.text = text + self.link = link + self.last_modified = last_modified + self.project_gid = project_gid + self.project_name = project_name + + def __str__(self) -> str: + return f"ID: {self.id}\nTitle: {self.title}\nLast modified: {self.last_modified}\nText: {self.text}" + + +class AsanaAPI: + def __init__( + self, api_token: str, workspace_gid: str, team_gid: str | None + ) -> None: + self._user = None # type: ignore + self.workspace_gid = workspace_gid + self.team_gid = team_gid + + self.configuration = asana.Configuration() + self.api_client = asana.ApiClient(self.configuration) + self.tasks_api = asana.TasksApi(self.api_client) + self.stories_api = asana.StoriesApi(self.api_client) + self.users_api = asana.UsersApi(self.api_client) + self.project_api = asana.ProjectsApi(self.api_client) + self.workspaces_api = asana.WorkspacesApi(self.api_client) + + self.api_error_count = 0 + self.configuration.access_token = api_token + self.task_count = 0 + + def get_tasks( + self, project_gids: list[str] | None, start_date: str + ) -> Iterator[AsanaTask]: + """Get all tasks from the projects with the given gids that were modified since the given date. + If project_gids is None, get all tasks from all projects in the workspace.""" + logger.info("Starting to fetch Asana projects") + projects = self.project_api.get_projects( + opts={ + "workspace": self.workspace_gid, + "opt_fields": "gid,name,archived,modified_at", + } + ) + start_seconds = int(time.mktime(datetime.now().timetuple())) + projects_list = [] + project_count = 0 + for project_info in projects: + project_gid = project_info["gid"] + if project_gids is None or project_gid in project_gids: + projects_list.append(project_gid) + else: + logger.debug( + f"Skipping project: {project_gid} - not in accepted project_gids" + ) + project_count += 1 + if project_count % 100 == 0: + logger.info(f"Processed {project_count} projects") + + logger.info(f"Found {len(projects_list)} projects to process") + for project_gid in projects_list: + for task in self._get_tasks_for_project( + project_gid, start_date, start_seconds + ): + yield task + logger.info(f"Completed fetching {self.task_count} tasks from Asana") + if self.api_error_count > 0: + logger.warning( + f"Encountered {self.api_error_count} API errors during task fetching" + ) + + def _get_tasks_for_project( + self, project_gid: str, start_date: str, start_seconds: int + ) -> Iterator[AsanaTask]: + project = self.project_api.get_project(project_gid, opts={}) + if project["archived"]: + logger.info(f"Skipping archived project: {project['name']} ({project_gid})") + return [] + if not project["team"] or not project["team"]["gid"]: + logger.info( + f"Skipping project without a team: {project['name']} ({project_gid})" + ) + return [] + if project["privacy_setting"] == "private": + if self.team_gid and project["team"]["gid"] != self.team_gid: + logger.info( + f"Skipping private project not in configured team: {project['name']} ({project_gid})" + ) + return [] + else: + logger.info( + f"Processing private project in configured team: {project['name']} ({project_gid})" + ) + + simple_start_date = start_date.split(".")[0].split("+")[0] + logger.info( + f"Fetching tasks modified since {simple_start_date} for project: {project['name']} ({project_gid})" + ) + + opts = { + "opt_fields": "name,memberships,memberships.project,completed_at,completed_by,created_at," + "created_by,custom_fields,dependencies,due_at,due_on,external,html_notes,liked,likes," + "modified_at,notes,num_hearts,parent,projects,resource_subtype,resource_type,start_on," + "workspace,permalink_url", + "modified_since": start_date, + } + tasks_from_api = self.tasks_api.get_tasks_for_project(project_gid, opts) + for data in tasks_from_api: + self.task_count += 1 + if self.task_count % 10 == 0: + end_seconds = time.mktime(datetime.now().timetuple()) + runtime_seconds = end_seconds - start_seconds + if runtime_seconds > 0: + logger.info( + f"Processed {self.task_count} tasks in {runtime_seconds:.0f} seconds " + f"({self.task_count / runtime_seconds:.2f} tasks/second)" + ) + + logger.debug(f"Processing Asana task: {data['name']}") + + text = self._construct_task_text(data) + + try: + text += self._fetch_and_add_comments(data["gid"]) + + last_modified_date = self.format_date(data["modified_at"]) + text += f"Last modified: {last_modified_date}\n" + + task = AsanaTask( + id=data["gid"], + title=data["name"], + text=text, + link=data["permalink_url"], + last_modified=datetime.fromisoformat(data["modified_at"]), + project_gid=project_gid, + project_name=project["name"], + ) + yield task + except Exception: + logger.error( + f"Error processing task {data['gid']} in project {project_gid}", + exc_info=True, + ) + self.api_error_count += 1 + + def _construct_task_text(self, data: Dict) -> str: + text = f"{data['name']}\n\n" + + if data["notes"]: + text += f"{data['notes']}\n\n" + + if data["created_by"] and data["created_by"]["gid"]: + creator = self.get_user(data["created_by"]["gid"])["name"] + created_date = self.format_date(data["created_at"]) + text += f"Created by: {creator} on {created_date}\n" + + if data["due_on"]: + due_date = self.format_date(data["due_on"]) + text += f"Due date: {due_date}\n" + + if data["completed_at"]: + completed_date = self.format_date(data["completed_at"]) + text += f"Completed on: {completed_date}\n" + + text += "\n" + return text + + def _fetch_and_add_comments(self, task_gid: str) -> str: + text = "" + stories_opts: Dict[str, str] = {} + story_start = time.time() + stories = self.stories_api.get_stories_for_task(task_gid, stories_opts) + + story_count = 0 + comment_count = 0 + + for story in stories: + story_count += 1 + if story["resource_subtype"] == "comment_added": + comment = self.stories_api.get_story( + story["gid"], opts={"opt_fields": "text,created_by,created_at"} + ) + commenter = self.get_user(comment["created_by"]["gid"])["name"] + text += f"Comment by {commenter}: {comment['text']}\n\n" + comment_count += 1 + + story_duration = time.time() - story_start + logger.debug( + f"Processed {story_count} stories (including {comment_count} comments) in {story_duration:.2f} seconds" + ) + + return text + + def get_user(self, user_gid: str) -> Dict: + if self._user is not None: + return self._user + self._user = self.users_api.get_user(user_gid, {"opt_fields": "name,email"}) + + if not self._user: + logger.warning(f"Unable to fetch user information for user_gid: {user_gid}") + return {"name": "Unknown"} + return self._user + + def format_date(self, date_str: str) -> str: + date = datetime.fromisoformat(date_str) + return time.strftime("%Y-%m-%d", date.timetuple()) + + def get_time(self) -> str: + return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) diff --git a/backend/danswer/connectors/asana/connector.py b/backend/danswer/connectors/asana/connector.py new file mode 100755 index 0000000000..3e2c9a8aaf --- /dev/null +++ b/backend/danswer/connectors/asana/connector.py @@ -0,0 +1,120 @@ +import datetime +from typing import Any + +from danswer.configs.app_configs import CONTINUE_ON_CONNECTOR_FAILURE +from danswer.configs.app_configs import INDEX_BATCH_SIZE +from danswer.configs.constants import DocumentSource +from danswer.connectors.asana import asana_api +from danswer.connectors.interfaces import GenerateDocumentsOutput +from danswer.connectors.interfaces import LoadConnector +from danswer.connectors.interfaces import PollConnector +from danswer.connectors.interfaces import SecondsSinceUnixEpoch +from danswer.connectors.models import Document +from danswer.connectors.models import Section +from danswer.utils.logger import setup_logger + +logger = setup_logger() + + +class AsanaConnector(LoadConnector, PollConnector): + def __init__( + self, + asana_workspace_id: str, + asana_project_ids: str | None = None, + asana_team_id: str | None = None, + batch_size: int = INDEX_BATCH_SIZE, + continue_on_failure: bool = CONTINUE_ON_CONNECTOR_FAILURE, + ) -> None: + self.workspace_id = asana_workspace_id + self.project_ids_to_index: list[str] | None = ( + asana_project_ids.split(",") if asana_project_ids is not None else None + ) + self.asana_team_id = asana_team_id + self.batch_size = batch_size + self.continue_on_failure = continue_on_failure + logger.info( + f"AsanaConnector initialized with workspace_id: {asana_workspace_id}" + ) + + def load_credentials(self, credentials: dict[str, Any]) -> dict[str, Any] | None: + self.api_token = credentials["asana_api_token_secret"] + self.asana_client = asana_api.AsanaAPI( + api_token=self.api_token, + workspace_gid=self.workspace_id, + team_gid=self.asana_team_id, + ) + logger.info("Asana credentials loaded and API client initialized") + return None + + def poll_source( + self, start: SecondsSinceUnixEpoch, end: SecondsSinceUnixEpoch | None + ) -> GenerateDocumentsOutput: + start_time = datetime.datetime.fromtimestamp(start).isoformat() + logger.info(f"Starting Asana poll from {start_time}") + asana = asana_api.AsanaAPI( + api_token=self.api_token, + workspace_gid=self.workspace_id, + team_gid=self.asana_team_id, + ) + docs_batch: list[Document] = [] + tasks = asana.get_tasks(self.project_ids_to_index, start_time) + + for task in tasks: + doc = self._message_to_doc(task) + docs_batch.append(doc) + + if len(docs_batch) >= self.batch_size: + logger.info(f"Yielding batch of {len(docs_batch)} documents") + yield docs_batch + docs_batch = [] + + if docs_batch: + logger.info(f"Yielding final batch of {len(docs_batch)} documents") + yield docs_batch + + logger.info("Asana poll completed") + + def load_from_state(self) -> GenerateDocumentsOutput: + logger.notice("Starting full index of all Asana tasks") + return self.poll_source(start=0, end=None) + + def _message_to_doc(self, task: asana_api.AsanaTask) -> Document: + logger.debug(f"Converting Asana task {task.id} to Document") + return Document( + id=task.id, + sections=[Section(link=task.link, text=task.text)], + doc_updated_at=task.last_modified, + source=DocumentSource.ASANA, + semantic_identifier=task.title, + metadata={ + "group": task.project_gid, + "project": task.project_name, + }, + ) + + +if __name__ == "__main__": + import time + import os + + logger.notice("Starting Asana connector test") + connector = AsanaConnector( + os.environ["WORKSPACE_ID"], + os.environ["PROJECT_IDS"], + os.environ["TEAM_ID"], + ) + connector.load_credentials( + { + "asana_api_token_secret": os.environ["API_TOKEN"], + } + ) + logger.info("Loading all documents from Asana") + all_docs = connector.load_from_state() + current = time.time() + one_day_ago = current - 24 * 60 * 60 # 1 day + logger.info("Polling for documents updated in the last 24 hours") + latest_docs = connector.poll_source(one_day_ago, current) + for docs in latest_docs: + for doc in docs: + print(doc.id) + logger.notice("Asana connector test completed") diff --git a/backend/danswer/connectors/factory.py b/backend/danswer/connectors/factory.py index 42d3b0bd93..6df16bea64 100644 --- a/backend/danswer/connectors/factory.py +++ b/backend/danswer/connectors/factory.py @@ -4,6 +4,7 @@ from typing import Type from sqlalchemy.orm import Session from danswer.configs.constants import DocumentSource +from danswer.connectors.asana.connector import AsanaConnector from danswer.connectors.axero.connector import AxeroConnector from danswer.connectors.blob.connector import BlobStorageConnector from danswer.connectors.bookstack.connector import BookstackConnector @@ -91,6 +92,7 @@ def identify_connector_class( DocumentSource.CLICKUP: ClickupConnector, DocumentSource.MEDIAWIKI: MediaWikiConnector, DocumentSource.WIKIPEDIA: WikipediaConnector, + DocumentSource.ASANA: AsanaConnector, DocumentSource.S3: BlobStorageConnector, DocumentSource.R2: BlobStorageConnector, DocumentSource.GOOGLE_CLOUD_STORAGE: BlobStorageConnector, diff --git a/backend/requirements/default.txt b/backend/requirements/default.txt index 5b9d57b9d3..1558ce8764 100644 --- a/backend/requirements/default.txt +++ b/backend/requirements/default.txt @@ -70,6 +70,7 @@ transformers==4.39.2 uvicorn==0.21.1 zulip==0.8.2 hubspot-api-client==8.1.0 +asana==5.0.8 zenpy==2.0.41 dropbox==11.36.2 boto3-stubs[s3]==1.34.133 diff --git a/web/public/Asana.png b/web/public/Asana.png new file mode 100644 index 0000000000000000000000000000000000000000..3d4c0c81d49cbb7785ff6aac299c8f9e94022c59 GIT binary patch literal 6716 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 KzO;JVp8o)59k`tU literal 0 HcmV?d00001 diff --git a/web/src/components/icons/icons.tsx b/web/src/components/icons/icons.tsx index b5e735b0e6..b14e532f41 100644 --- a/web/src/components/icons/icons.tsx +++ b/web/src/components/icons/icons.tsx @@ -52,7 +52,7 @@ import litellmIcon from "../../../public/LiteLLM.jpg"; import awsWEBP from "../../../public/Amazon.webp"; import azureIcon from "../../../public/Azure.png"; - +import asanaIcon from "../../../public/Asana.png"; import anthropicSVG from "../../../public/Anthropic.svg"; import nomicSVG from "../../../public/nomic.svg"; import microsoftIcon from "../../../public/microsoft.png"; @@ -2811,3 +2811,14 @@ export const WindowsIcon = ({ ); }; +export const AsanaIcon = ({ + size = 16, + className = defaultTailwindCSS, +}: IconProps) => ( +
+ Logo +
+); diff --git a/web/src/lib/connectors/connectors.ts b/web/src/lib/connectors/connectors.ts index b526818b08..2a85990a5a 100644 --- a/web/src/lib/connectors/connectors.ts +++ b/web/src/lib/connectors/connectors.ts @@ -763,6 +763,38 @@ For example, specifying .*-support.* as a "channel" will cause the connector to }, ], }, + asana: { + description: "Configure Asana connector", + values: [ + { + type: "text", + query: "Enter your Asana workspace ID:", + label: "Workspace ID", + name: "asana_workspace_id", + optional: false, + description: + "The ID of the Asana workspace to index. You can find this at https://app.asana.com/api/1.0/workspaces. It's a number that looks like 1234567890123456.", + }, + { + type: "text", + query: "Enter project IDs to index (optional):", + label: "Project IDs", + name: "asana_project_ids", + description: + "IDs of specific Asana projects to index, separated by commas. Leave empty to index all projects in the workspace. Example: 1234567890123456,2345678901234567", + optional: true, + }, + { + type: "text", + query: "Enter the Team ID (optional):", + label: "Team ID", + name: "asana_team_id", + optional: true, + description: + "ID of a team to use for accessing team-visible tasks. This allows indexing of team-visible tasks in addition to public tasks. Leave empty if you don't want to use this feature.", + }, + ], + }, mediawiki: { description: "Configure MediaWiki connector", values: [ @@ -1056,6 +1088,12 @@ export interface MediaWikiBaseConfig { recurse_depth?: number; } +export interface AsanaConfig { + asana_workspace_id: string; + asana_project_ids?: string; + asana_team_id?: string; +} + export interface MediaWikiConfig extends MediaWikiBaseConfig { hostname: string; } diff --git a/web/src/lib/connectors/credentials.ts b/web/src/lib/connectors/credentials.ts index 424a07c82f..481a2b3380 100644 --- a/web/src/lib/connectors/credentials.ts +++ b/web/src/lib/connectors/credentials.ts @@ -166,6 +166,10 @@ export interface SharepointCredentialJson { sp_directory_id: string; } +export interface AsanaCredentialJson { + asana_api_token_secret: string; +} + export interface TeamsCredentialJson { teams_client_id: string; teams_client_secret: string; @@ -241,6 +245,9 @@ export const credentialTemplates: Record = { sp_client_secret: "", sp_directory_id: "", } as SharepointCredentialJson, + asana: { + asana_api_token_secret: "", + } as AsanaCredentialJson, teams: { teams_client_id: "", teams_client_secret: "", @@ -412,6 +419,9 @@ export const credentialDisplayNames: Record = { sp_client_secret: "SharePoint Client Secret", sp_directory_id: "SharePoint Directory ID", + // Asana + asana_api_token_secret: "Asana API Token", + // Teams teams_client_id: "Microsoft Teams Client ID", teams_client_secret: "Microsoft Teams Client Secret", diff --git a/web/src/lib/sources.ts b/web/src/lib/sources.ts index bbc63847ad..6ee40e3c5f 100644 --- a/web/src/lib/sources.ts +++ b/web/src/lib/sources.ts @@ -32,6 +32,7 @@ import { ZulipIcon, MediaWikiIcon, WikipediaIcon, + AsanaIcon, S3Icon, OCIStorageIcon, GoogleStorageIcon, @@ -230,6 +231,12 @@ const SOURCE_METADATA_MAP: SourceMap = { category: SourceCategory.Wiki, docs: "https://docs.danswer.dev/connectors/wikipedia", }, + asana: { + icon: AsanaIcon, + displayName: "Asana", + category: SourceCategory.ProjectManagement, + docs: "https://docs.danswer.dev/connectors/asana", + }, mediawiki: { icon: MediaWikiIcon, displayName: "MediaWiki", diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 1ea67d1406..9d38507364 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -247,6 +247,7 @@ const validSources = [ "clickup", "wikipedia", "mediawiki", + "asana", "s3", "r2", "google_cloud_storage",