Spaces:
Sleeping
Sleeping
| # ============================================================================ | |
| # PM TOOL CLIENT | |
| # ============================================================================ | |
| import asyncio | |
| import os | |
| import base64 | |
| from typing import Dict, Any, List, Optional, Tuple | |
| import aiohttp | |
| from fastapi import HTTPException | |
| from app.config import Config | |
| from app.models.enums import TaskType | |
| from app.models.schemas import Credentials | |
| from app.utils.logger import logger | |
| from app.utils.cache import token_cache | |
| from app.utils.http_client import http_client | |
| class PMToolClient: | |
| """Async client for PM Tool API""" | |
| # Additional endpoints | |
| PROJECTS_ENDPOINT = f"{Config.PM_TOOL_BASE_URL}/Projects" | |
| PROJECT_BY_NAME_ENDPOINT = f"{Config.PM_TOOL_BASE_URL}/Projects/ByName" | |
| ATTACHMENT_DOWNLOAD_ENDPOINT = f"{Config.PM_TOOL_ATTACHMENTS_ENDPOINT}/Download" | |
| async def login(credentials: Credentials) -> Dict[str, Any]: | |
| """Authenticate and get token""" | |
| # Check cache first | |
| cached_token = await token_cache.get(credentials.email) | |
| if cached_token: | |
| return {"token": cached_token, "from_cache": True} | |
| session = http_client.get_session() | |
| for attempt in range(Config.MAX_RETRY_ATTEMPTS): | |
| try: | |
| async with session.post( | |
| Config.PM_TOOL_LOGIN_ENDPOINT, | |
| json={"email": credentials.email, "password": credentials.password}, | |
| headers={"Content-Type": "application/json"} | |
| ) as response: | |
| if response.status == 401: | |
| raise HTTPException( | |
| status_code=401, | |
| detail="Invalid credentials for PM Tool" | |
| ) | |
| response.raise_for_status() | |
| data = await response.json() | |
| # Cache token | |
| await token_cache.set(credentials.email, data['token']) | |
| logger.info(f"β Logged in as {data.get('firstName')} {data.get('lastName')}") | |
| return data | |
| except aiohttp.ClientError as e: | |
| if attempt == Config.MAX_RETRY_ATTEMPTS - 1: | |
| logger.error(f"Login failed after {Config.MAX_RETRY_ATTEMPTS} attempts: {e}") | |
| raise HTTPException( | |
| status_code=503, | |
| detail=f"PM Tool API unreachable: {str(e)}" | |
| ) | |
| logger.warning(f"Login attempt {attempt + 1} failed, retrying...") | |
| await asyncio.sleep(Config.RETRY_DELAY) | |
| async def fetch_tasks( | |
| token: str, | |
| task_type: TaskType = TaskType.TASK, | |
| task_ids: Optional[List[int]] = None | |
| ) -> List[Dict[str, Any]]: | |
| """Fetch tasks from PM Tool""" | |
| session = http_client.get_session() | |
| headers = { | |
| "Accept": "application/json", | |
| "Authorization": f"Bearer {token}" | |
| } | |
| params = {"type": task_type.value} | |
| try: | |
| async with session.get( | |
| Config.PM_TOOL_TASKS_ENDPOINT, | |
| headers=headers, | |
| params=params | |
| ) as response: | |
| if response.status == 401: | |
| raise HTTPException(status_code=401, detail="Token expired") | |
| response.raise_for_status() | |
| tasks = await response.json() | |
| # Filter by task IDs if provided | |
| if task_ids: | |
| tasks = [t for t in tasks if t.get('id') in task_ids] | |
| logger.info(f"π₯ Fetched {len(tasks)} tasks") | |
| return tasks | |
| except aiohttp.ClientError as e: | |
| logger.error(f"Failed to fetch tasks: {e}") | |
| raise HTTPException( | |
| status_code=503, | |
| detail=f"Failed to fetch tasks: {str(e)}" | |
| ) | |
| async def fetch_attachments( | |
| token: str, | |
| project_id: Optional[int] = None, | |
| task_id: Optional[int] = None | |
| ) -> List[Dict[str, Any]]: | |
| """Fetch attachments for project or task""" | |
| session = http_client.get_session() | |
| headers = { | |
| "Accept": "application/json", | |
| "Authorization": f"Bearer {token}" | |
| } | |
| # Determine endpoint | |
| if project_id: | |
| url = f"{Config.PM_TOOL_ATTACHMENTS_ENDPOINT}/Project/{project_id}" | |
| elif task_id: | |
| url = f"{Config.PM_TOOL_ATTACHMENTS_ENDPOINT}/Task/{task_id}" | |
| else: | |
| return [] | |
| try: | |
| async with session.get(url, headers=headers) as response: | |
| if response.status == 404: | |
| logger.debug(f"No attachments found for project/task") | |
| return [] | |
| response.raise_for_status() | |
| attachments = await response.json() | |
| logger.debug(f"π Fetched {len(attachments)} attachments") | |
| return attachments | |
| except aiohttp.ClientError as e: | |
| logger.warning(f"Failed to fetch attachments: {e}") | |
| return [] # Don't fail the whole request | |
| async def fetch_projects(token: str) -> List[Dict[str, Any]]: | |
| """Fetch all projects from PM Tool""" | |
| session = http_client.get_session() | |
| headers = { | |
| "Accept": "application/json", | |
| "Authorization": f"Bearer {token}" | |
| } | |
| try: | |
| async with session.get( | |
| PMToolClient.PROJECTS_ENDPOINT, | |
| headers=headers | |
| ) as response: | |
| if response.status == 401: | |
| raise HTTPException(status_code=401, detail="Token expired") | |
| response.raise_for_status() | |
| projects = await response.json() | |
| logger.info(f"π Fetched {len(projects)} projects") | |
| return projects | |
| except aiohttp.ClientError as e: | |
| logger.error(f"Failed to fetch projects: {e}") | |
| raise HTTPException( | |
| status_code=503, | |
| detail=f"Failed to fetch projects: {str(e)}" | |
| ) | |
| async def fetch_issues(token: str) -> List[Dict[str, Any]]: | |
| """Fetch all issues from PM Tool | |
| Issues contain projectId which links tasks to projects. | |
| """ | |
| session = http_client.get_session() | |
| headers = { | |
| "Accept": "application/json", | |
| "Authorization": f"Bearer {token}" | |
| } | |
| url = f"{Config.PM_TOOL_BASE_URL}/Issues" | |
| try: | |
| async with session.get(url, headers=headers) as response: | |
| if response.status == 401: | |
| raise HTTPException(status_code=401, detail="Token expired") | |
| response.raise_for_status() | |
| issues = await response.json() | |
| logger.info(f"π Fetched {len(issues)} issues") | |
| return issues | |
| except aiohttp.ClientError as e: | |
| logger.error(f"Failed to fetch issues: {e}") | |
| raise HTTPException( | |
| status_code=503, | |
| detail=f"Failed to fetch issues: {str(e)}" | |
| ) | |
| def build_project_name_id_mapping( | |
| tasks: List[Dict[str, Any]], | |
| issues: List[Dict[str, Any]] | |
| ) -> Dict[str, int]: | |
| """Build projectName -> projectId mapping from tasks and issues | |
| Task has 'issuesId' -> Issue has 'projectId' | |
| This bridges the gap between task projectName and attachment entityId. | |
| """ | |
| issues_lookup = {i['id']: i for i in issues} | |
| mapping = {} | |
| for task in tasks: | |
| issue_id = task.get('issuesId') | |
| if issue_id and issue_id in issues_lookup: | |
| project_id = issues_lookup[issue_id].get('projectId') | |
| project_name = task.get('projectName') | |
| if project_name and project_id: | |
| mapping[project_name] = project_id | |
| logger.info(f"π Built projectName->projectId mapping for {len(mapping)} projects") | |
| return mapping | |
| async def fetch_project_by_name(token: str, project_name: str) -> Optional[Dict[str, Any]]: | |
| """Fetch project by name (case-insensitive partial match)""" | |
| projects = await PMToolClient.fetch_projects(token) | |
| # Try exact match first | |
| for project in projects: | |
| if project.get('name', '').lower() == project_name.lower(): | |
| logger.info(f"π― Found project by exact name: {project.get('name')}") | |
| return project | |
| # Try partial match | |
| for project in projects: | |
| if project_name.lower() in project.get('name', '').lower(): | |
| logger.info(f"π― Found project by partial name: {project.get('name')}") | |
| return project | |
| logger.warning(f"β οΈ Project not found: {project_name}") | |
| return None | |
| async def fetch_project_by_id(token: str, project_id: int) -> Optional[Dict[str, Any]]: | |
| """Fetch project by ID""" | |
| session = http_client.get_session() | |
| headers = { | |
| "Accept": "application/json", | |
| "Authorization": f"Bearer {token}" | |
| } | |
| url = f"{PMToolClient.PROJECTS_ENDPOINT}/{project_id}" | |
| try: | |
| async with session.get(url, headers=headers) as response: | |
| if response.status == 404: | |
| logger.warning(f"Project not found: {project_id}") | |
| return None | |
| response.raise_for_status() | |
| project = await response.json() | |
| logger.info(f"π Found project: {project.get('name')}") | |
| return project | |
| except aiohttp.ClientError as e: | |
| logger.error(f"Failed to fetch project {project_id}: {e}") | |
| return None | |
| async def resolve_project( | |
| token: str, | |
| project_id: Optional[int] = None, | |
| project_name: Optional[str] = None | |
| ) -> Optional[Dict[str, Any]]: | |
| """Resolve project by ID or name (auto-select)""" | |
| if project_id: | |
| return await PMToolClient.fetch_project_by_id(token, project_id) | |
| elif project_name: | |
| return await PMToolClient.fetch_project_by_name(token, project_name) | |
| return None | |
| async def fetch_tasks_by_project( | |
| token: str, | |
| project_id: int, | |
| task_type: TaskType = TaskType.TASK | |
| ) -> List[Dict[str, Any]]: | |
| """Fetch all tasks for a specific project""" | |
| session = http_client.get_session() | |
| headers = { | |
| "Accept": "application/json", | |
| "Authorization": f"Bearer {token}" | |
| } | |
| # Some APIs support project filter directly | |
| url = f"{Config.PM_TOOL_TASKS_ENDPOINT}/Project/{project_id}" | |
| try: | |
| async with session.get(url, headers=headers) as response: | |
| if response.status == 404: | |
| # Fallback: fetch all and filter | |
| all_tasks = await PMToolClient.fetch_tasks(token, task_type) | |
| return [t for t in all_tasks if t.get('projectId') == project_id] | |
| response.raise_for_status() | |
| tasks = await response.json() | |
| logger.info(f"π₯ Fetched {len(tasks)} tasks for project {project_id}") | |
| return tasks | |
| except aiohttp.ClientError as e: | |
| # Fallback: fetch all and filter | |
| logger.warning(f"Direct project tasks fetch failed, using filter: {e}") | |
| all_tasks = await PMToolClient.fetch_tasks(token, task_type) | |
| return [t for t in all_tasks if t.get('projectId') == project_id] | |
| async def download_attachment( | |
| token: str, | |
| attachment_id: str, | |
| download_dir: str = None | |
| ) -> Tuple[Optional[str], Optional[bytes]]: | |
| """Download attachment content from PM Tool | |
| Returns: (file_path, file_bytes) - file_path if saved, file_bytes if in-memory | |
| """ | |
| session = http_client.get_session() | |
| headers = { | |
| "Accept": "application/octet-stream", | |
| "Authorization": f"Bearer {token}" | |
| } | |
| url = f"{PMToolClient.ATTACHMENT_DOWNLOAD_ENDPOINT}/{attachment_id}" | |
| try: | |
| async with session.get(url, headers=headers) as response: | |
| if response.status == 404: | |
| logger.warning(f"Attachment not found: {attachment_id}") | |
| return None, None | |
| response.raise_for_status() | |
| content = await response.read() | |
| # Get filename from header if available | |
| content_disp = response.headers.get('Content-Disposition', '') | |
| filename = None | |
| if 'filename=' in content_disp: | |
| filename = content_disp.split('filename=')[1].strip('"') | |
| # Save to disk if download_dir provided | |
| if download_dir: | |
| os.makedirs(download_dir, exist_ok=True) | |
| if not filename: | |
| filename = f"attachment_{attachment_id}" | |
| # Sanitize filename for Windows (remove invalid chars) | |
| import re | |
| filename = re.sub(r'[<>:"/\\|?*()]', '_', filename) | |
| filename = filename[:200] # Limit length | |
| filepath = os.path.join(download_dir, filename) | |
| with open(filepath, 'wb') as f: | |
| f.write(content) | |
| logger.info(f"π₯ Downloaded attachment: {filename} ({len(content)} bytes)") | |
| return filepath, None | |
| logger.info(f"π₯ Fetched attachment: {attachment_id} ({len(content)} bytes in-memory)") | |
| return None, content | |
| except aiohttp.ClientError as e: | |
| logger.error(f"Failed to download attachment {attachment_id}: {e}") | |
| return None, None | |
| async def fetch_attachments_by_project(token: str, project_name: str) -> List[Dict[str, Any]]: | |
| """Fetch all attachments for a project by name | |
| Uses dynamic mapping: Task -> issuesId -> Issue -> projectId | |
| Uses cache to avoid repeated API calls. | |
| """ | |
| from app.utils.cache import attachment_cache | |
| # Check cache first | |
| cached = await attachment_cache.get_attachments(project_name) | |
| if cached is not None: | |
| logger.info(f"π Using cached attachments for: {project_name}") | |
| return cached | |
| # Build dynamic mapping from tasks and issues (with cache) | |
| mapping = await attachment_cache.get_mapping() | |
| if mapping is None: | |
| tasks = await PMToolClient.fetch_tasks(token) | |
| issues = await PMToolClient.fetch_issues(token) | |
| mapping = PMToolClient.build_project_name_id_mapping(tasks, issues) | |
| await attachment_cache.set_mapping(mapping) | |
| # Get project ID from dynamic mapping | |
| project_id = mapping.get(project_name) | |
| if not project_id: | |
| logger.info(f"No project ID mapping found for: {project_name}") | |
| return [] | |
| session = http_client.get_session() | |
| headers = { | |
| "Accept": "application/json", | |
| "Authorization": f"Bearer {token}" | |
| } | |
| url = f"{Config.PM_TOOL_ATTACHMENTS_ENDPOINT}/Project/{project_id}" | |
| try: | |
| async with session.get(url, headers=headers) as response: | |
| if response.status == 404: | |
| logger.info(f"No attachments found for project: {project_name} (ID: {project_id})") | |
| return [] | |
| if response.status == 401: | |
| raise HTTPException(status_code=401, detail="Token expired") | |
| response.raise_for_status() | |
| attachments = await response.json() | |
| logger.info(f"π Fetched {len(attachments)} attachments for project: {project_name} (ID: {project_id})") | |
| # Cache the result | |
| await attachment_cache.set_attachments(project_name, attachments) | |
| return attachments | |
| except aiohttp.ClientError as e: | |
| logger.error(f"Failed to fetch attachments for project {project_name}: {e}") | |
| return [] | |
| async def fetch_all_attachments(token: str) -> List[Dict[str, Any]]: | |
| """Fetch ALL attachments from PM Tool | |
| Returns all 315+ attachments with their entityId (project ID). | |
| Used to build project attachment cache. | |
| """ | |
| session = http_client.get_session() | |
| headers = { | |
| "Accept": "application/json", | |
| "Authorization": f"Bearer {token}" | |
| } | |
| url = f"{Config.PM_TOOL_ATTACHMENTS_ENDPOINT}" | |
| try: | |
| async with session.get(url, headers=headers) as response: | |
| if response.status == 401: | |
| raise HTTPException(status_code=401, detail="Token expired") | |
| response.raise_for_status() | |
| attachments = await response.json() | |
| logger.info(f"π Fetched {len(attachments)} total attachments") | |
| return attachments | |
| except aiohttp.ClientError as e: | |
| logger.error(f"Failed to fetch all attachments: {e}") | |
| return [] | |
| def build_project_attachment_cache(attachments: List[Dict[str, Any]]) -> Dict[int, List[Dict[str, Any]]]: | |
| """Build cache of attachments grouped by project ID | |
| Returns: {project_id: [attachment1, attachment2, ...]} | |
| """ | |
| cache = {} | |
| for att in attachments: | |
| entity_type = att.get('entityType', '') | |
| if entity_type == 'Project': | |
| project_id = att.get('entityId') | |
| if project_id: | |
| if project_id not in cache: | |
| cache[project_id] = [] | |
| cache[project_id].append(att) | |
| logger.info(f"π Built attachment cache for {len(cache)} projects") | |
| return cache | |
| async def fetch_and_download_attachments( | |
| token: str, | |
| project_id: Optional[int] = None, | |
| task_id: Optional[int] = None, | |
| download_dir: str = None, | |
| attachment_configs: List[Dict[str, Any]] = None | |
| ) -> List[Dict[str, Any]]: | |
| """Fetch attachments list and optionally download them | |
| Returns list with attachment info + downloaded file path (if applicable) | |
| """ | |
| # Fetch attachment list | |
| attachments = await PMToolClient.fetch_attachments( | |
| token=token, | |
| project_id=project_id, | |
| task_id=task_id | |
| ) | |
| if not attachments: | |
| return [] | |
| # Build config lookup | |
| config_lookup = {} | |
| if attachment_configs: | |
| for cfg in attachment_configs: | |
| # Match by ID or filename | |
| key = cfg.get('attachment_id') or cfg.get('filename') | |
| if key: | |
| config_lookup[key] = cfg | |
| result = [] | |
| for att in attachments: | |
| att_id = str(att.get('id', '')) | |
| filename = att.get('fileName', att.get('name', '')) | |
| # Check for skip config | |
| att_config = config_lookup.get(att_id) or config_lookup.get(filename) | |
| skip = False | |
| skip_reason = None | |
| if att_config: | |
| skip = att_config.get('skip', False) | |
| skip_reason = att_config.get('skip_reason') | |
| att_result = { | |
| "attachment_id": att_id, | |
| "filename": filename, | |
| "file_type": att.get('fileType', att.get('contentType', '')), | |
| "size": att.get('size', att.get('fileSize', 0)), | |
| "description": att.get('description', ''), | |
| "skip": skip, | |
| "skip_reason": skip_reason, | |
| "file_path": None, | |
| "downloaded": False | |
| } | |
| # Download if not skipped | |
| if not skip: | |
| filepath, _ = await PMToolClient.download_attachment( | |
| token=token, | |
| attachment_id=att_id, | |
| download_dir=download_dir | |
| ) | |
| if filepath: | |
| att_result["file_path"] = filepath | |
| att_result["downloaded"] = True | |
| result.append(att_result) | |
| logger.info(f"π Processed {len(result)} attachments ({sum(1 for a in result if a['downloaded'])} downloaded)") | |
| return result | |